From 15aa52ac2d3df46712719a26e54018f0e4ff2d33 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 12:30:17 +1100 Subject: [PATCH 01/12] Add --glob-pipe to pipe in the files to work on to close #711 --- README.md | 17 +++++++-- src/cli.ts | 31 ++++++++++----- src/engine.ts | 17 ++++++++- test/cli/run.sh | 30 +++++++++++++++ test/js/fix/703.test.js | 26 +++++++------ test/js/fix/711.test.js | 85 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 181 insertions(+), 25 deletions(-) create mode 100644 test/js/fix/711.test.js diff --git a/README.md b/README.md index d88508b2..ba4268a5 100644 --- a/README.md +++ b/README.md @@ -168,10 +168,16 @@ The path of each file that changed is printed to stdout, one name on each line. > 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. +Use `--null-out` (or `--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 +> rexreplace 'foo' 'bar' '**/*.md' --null-out | xargs -0 git add +``` + +Pipe the names into the next run with `--glob-pipe`, so it only works on the files the first one changed: + +```bash +> rexreplace 'foo' 'bar' '**/*.md' --null-out | rexreplace 'baz' 'qux' --glob-pipe --null-in ``` @@ -233,7 +239,7 @@ Use --js-pipe to access the pipe data in your javascript code. | | **`--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] | +| | **`--null-out`**, **`--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-out. [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] | @@ -254,6 +260,8 @@ Use --js-pipe to access the pipe data in your javascript code. | `-T` | **`--trim-pipe`** Trim piped data before processing. If piped data only consists of chars that can be trimmed (new line, space, tabs...) it will become an empty string. [boolean] | | `-p` | **`--pipe`** Read the piped data and make it available to --replacement-js as `pipe`. [boolean] | | | **`--js-pipe`** Shorthand for --replacement-js --pipe (-jp). [boolean] | +| `-g` | **`--glob-pipe`** Each line of the piped data is a path or a glob of the files to work on. They are used together with the ones given as arguments. [boolean] | +| | **`--null-in`** The piped paths and globs of --glob-pipe are separated by NUL bytes, like from `find -print0` or --null-out. [boolean] | | `-R` | **`--replacement-pipe`** Replacement will be piped in. You still need to provide a dummy value (like `_`) as replacement parameter. [boolean] | | `-j` | **`--replacement-js`**, **`--js`** Treat replacement as javascript source code. The statement from the last expression will become the replacement string. Purposefully implemented the most insecure way possible to remove _any_ incentive to consider running code from an untrusted part. The full match will be available as a javascript variable named $0 while each captured group will be available as $1, $2, $3, ... and so on. At some point, the $ char _will_ give you a headache when used from the command line, so use €0, €1, €2, €3... instead. If the javascript source code references to the full match or a captured group the code will run once per match. Otherwise, it will run once per file. The code has access to the following variables: `r` as an alias for `require` with both expanded to understand a relative path even if it is not starting with `./`, `fs` from node, `path` from node, `globs` from npm, `pipe`: the data piped into the command with --pipe (null if no piped data, and `pipe_` is `❌ `), `find`: pattern searched for (the needle), `text`: full text being searched i.e. file content or piped data (the haystack), `bytes`: total size of the haystack in bytes, `size`: human-friendly representation of the total size of the haystack, `time`: String representing the local time when the command was invoked, `time_obj`: date object representing `time`, `now`: alias for `time`, `cwd`: current process working dir, `nl`: a new-line char, `_`: a single space char (for easy string concatenation). The following values defaults to `❌` if haystack does not originate from a file: `file`: contains the full path of the active file being searched (including full filename), `file_rel`: contains `file` relative to current process working dir, `dirpath`: contains the full path without filename of the active file being searched, `dirpath_rel`: contains `dirpath` relative to current process working dir, `filename`: is the full filename of the active file being searched without path, `name`: filename of the active file being searched with no extension, `ext`: extension of the filename including leading dot, `mtime`: ISO inspired representation of the last local modification time of the current file, `ctime`: ISO representation of the local creation time of the current file. `mtime_obj`: date object representing `mtime`, `ctime_obj`: date object representing `ctime`. All variables, except from module, date objects, `nl` and `_`, has a corresponding variable name followed by `_` where the content has an extra space at the end (for easy concatenation). [boolean] | | `-x` | **`--exclude-re`** Files with a path that matches this regular expression will be excluded from processing. Can be provided multiple times. [string] | @@ -270,6 +278,7 @@ Files identified for potential processing are identified by: - Subtract group C from group A and B (the files are compared by their full path) - Remove any file paths that matches any `-x` provided regex. Use `--full-path` to look for the full path. - All globs are case-insensitive, like the search, unless `--void-ignore-case` is set. +- The lines piped in with `--glob-pipe` are input values too, just like the arguments. ### Features @@ -279,7 +288,7 @@ Files identified for potential processing are identified by: - 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 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` +- Pick the delimiter between names and between the content of files, like `--null-out` 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 diff --git a/src/cli.ts b/src/cli.ts index b6c4564c..292cbb95 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -155,10 +155,11 @@ const yargs = require('yargs') 'Sets --name-delimiter and --content-delimiter to the same value. Supports escape sequences like `\\t`, `\\n` and the `€` and `§` aliases.' ) - .boolean('null') + .boolean('null-out') + .alias('null-out', '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." + 'null-out', + "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-out." ) .boolean('name-delimiter-tail') @@ -275,6 +276,22 @@ const yargs = require('yargs') .boolean('js-pipe') .describe('js-pipe', `Shorthand for --replacement-js --pipe (-jp).`) + .boolean('g') + .alias('g', 'glob-pipe') + .describe( + 'g', + 'Each line of the piped data is a path or a glob of the files to work on. They are used together with the ones given as arguments.' + ) + // The piped data can only be one thing + .conflicts('g', ['R', 'p', 'js-pipe']) + + .boolean('null-in') + .describe( + 'null-in', + 'The piped paths and globs of --glob-pipe are separated by NUL bytes, like from `find -print0` or --null-out.' + ) + .implies('null-in', 'glob-pipe') + .boolean('j') .alias('j', 'js') .alias('j', 'replacement-js') @@ -366,11 +383,6 @@ All variables, except from module, date objects, \`nl\` and \`_\`, has a corresp .describe('G', "filename/globas are filename(s) for files containing one filename/globs on each line to be search/replaced") .alias('G', 'globs-file') - .boolean('g') - .describe('g', "filename/globs will be piped in. If any filename/globs are given in command the piped data will be prepened") - .alias('g', 'glob-pipe') - - .boolean('j') .describe('j', "Pattern is javascript source that will return a string giving the pattern to use") .alias('j', 'pattern-js') @@ -563,7 +575,8 @@ const run = () => ); // Like grep, stdin is only read when piped data is expected. An open pipe that nobody writes to would make the run wait forever. -const pipeDataExpected = !config.includeGlob.length || config.replacementPipe || config.pipe; +const pipeDataExpected = + !config.includeGlob.length || config.replacementPipe || config.pipe || config.globPipe; if (!pipeDataExpected || Boolean(process.stdin.isTTY)) { run(); diff --git a/src/engine.ts b/src/engine.ts index d5b3e34f..0bd0fb82 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -77,6 +77,18 @@ function newStatus(config) { async function run(config) { validateEncoding(config.encoding); + // With --glob-pipe each line of the piped data is a path or a glob, used together with the ones given as arguments + if (config.globPipe) { + if (config.replacementPipe || config.pipe) { + die( + '--glob-pipe can not be used with --replacement-pipe or --pipe, as the piped data can only be one thing' + ); + } + config.pipedDataUsed = true; + const lines = (config.pipedData ?? '').split(config.nullIn ? '\0' : /\r?\n/); + config.includeGlob = config.includeGlob.concat(lines.filter(line => '' !== line)); + } + config.pattern = getFinalPattern(config.pattern, config) || ''; config.replacement = getFinalReplacement(config.replacement, config) || ''; @@ -84,7 +96,7 @@ async function run(config) { 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); + const delimiter = config.delimiter ?? (config.nullOut ? '\0' : null); config.nameDelimiter = config.nameDelimiter ?? delimiter; config.contentDelimiter = config.contentDelimiter ?? delimiter; @@ -262,6 +274,9 @@ function updated(file, config) { // How the piped data was used function pipeType(config, piped) { + if (config.globPipe) { + return 'GLOBS'; + } if (piped) { return 'CONTENT'; } diff --git a/test/cli/run.sh b/test/cli/run.sh index 200860da..244a421e 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -451,6 +451,36 @@ assert "rexreplace 'b' 'x' my.file --content-delimiter '\x4' 2>&1 | grep -c assert "rexreplace 'b' 'x' my.file --delimiter '\x4' 2>&1 | grep -c 'Invalid escape sequence in --delimiter'" '1' assert "cat my.file" 'foobar' +# --glob-pipe: each line of the piped data is a path or a glob to work on (#711) +reset +assert "printf 'my.file\nyour.file\n' | rexreplace 'b' 'x' --glob-pipe -A" 'my.file\nyour.file' +reset +assert "printf '*.file' | rexreplace 'b' 'x' -g -A" 'my.file\nyour.file' +# The names of the changed files go on to the next command, also when a name has a line break in it +reset +assert "rexreplace 'oo' 'x' '*.file' --null-out | rexreplace 'x' 'y' --glob-pipe --null-in" 'my.file' +assert "cat my.file" 'fybar' +# The piped data can only be one thing +reset +assert_raises "printf 'my.file' | rexreplace 'b' 'x' -g -R" 1 +assert_raises "printf 'my.file' | rexreplace 'b' 'x' -g --js-pipe" 1 +assert_raises "printf 'my.file' | rexreplace 'b' 'x' --null-in" 1 +assert "cat my.file" 'foobar' + + +# Data with NUL bytes is binary safe: the NUL bytes come out where they went in +reset +assert "printf 'foo\0boo\0' | rexreplace 'o' 'x' | tr '\0' '|'" 'fxx|bxx|' +assert "printf 'foo\0boo\0' | rexreplace 'o' 'x' --output | tr '\0' '|'" 'fxx|bxx|' +assert "printf 'foo\0boo\0' | rexreplace 'o' '_' --output-match --null-out | tr '\0' '|'" 'o|o|o|o|' +printf 'foo\0boo\0' > nul.file +assert "rexreplace 'o' 'x' nul.file" 'nul.file' +assert "tr '\0' '|' < nul.file" 'fxx|bxx|' +printf 'foo\0boo\0' > nul.file +assert "rexreplace 'o' 'x' nul.file --output 2>/dev/null | tr '\0' '|'" 'fxx|bxx|' +assert "rexreplace 'o' '_' nul.file --output-match --null-out 2>/dev/null | tr '\0' '|'" 'o|o|o|o|' + + # Globs are case-insensitive like the search, unless --void-ignore-case (#716) reset assert "rexreplace 'oo' 'x' '*.FILE' -X 'YOUR*'" 'my.file' diff --git a/test/js/fix/703.test.js b/test/js/fix/703.test.js index 21ab0a07..847f7101 100644 --- a/test/js/fix/703.test.js +++ b/test/js/fix/703.test.js @@ -61,14 +61,16 @@ describe('#703 the names of affected files', () => { expect((await rr('x', 'o', {includeGlob: [file]})).out).toBe(file + '\n'); }); - describe('--null', () => { + describe('--null-out', () => { test('ends each name with a NUL byte', async () => { - expect(await run({null: true})).toEqual({out: 'a.file\0c.file\0', err: '', exit: null}); + expect(await run({nullOut: 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'); + expect((await rr('o', 'x', {includeGlob: ['*.file'], nullOut: true})).out).toBe( + 'my\n.file\0' + ); }); }); @@ -301,7 +303,7 @@ describe('#703 the names of affected files', () => { }); }); - describe('--delimiter and --null', () => { + describe('--delimiter and --null-out', () => { test('--delimiter sets both delimiters', async () => { expect(await run({output: true, delimiter: ', '})).toEqual({ out: 'fxx, bar, fxx, ', @@ -310,8 +312,8 @@ describe('#703 the names of affected files', () => { }); }); - test('--null is the same as --delimiter with a NUL byte', async () => { - expect(await run({output: true, null: true})).toEqual({ + test('--null-out is the same as --delimiter with a NUL byte', async () => { + expect(await run({output: true, nullOut: true})).toEqual({ out: 'fxx\0bar\0fxx\0', err: 'a.file\0c.file\0', exit: null, @@ -319,7 +321,9 @@ describe('#703 the names of affected files', () => { }); 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: ', ', nullOut: true})).out).toBe( + 'fxx, bar, fxx, ' + ); expect( await run({ output: true, @@ -328,7 +332,7 @@ describe('#703 the names of affected files', () => { nameDelimiter: ';', }) ).toEqual({out: 'fxx|bar|fxx|', err: 'a.file;c.file;', exit: null}); - expect(await run({output: true, null: true, nameDelimiter: ', '})).toEqual({ + expect(await run({output: true, nullOut: true, nameDelimiter: ', '})).toEqual({ out: 'fxx\0bar\0fxx\0', err: 'a.file, c.file, ', exit: null, @@ -354,8 +358,8 @@ describe('#703 the names of affected files', () => { expect(await run({[flag]: true, [quiet]: true})).toEqual({out, err: '', exit: null}); }); - test('follow --null, --name-delimiter and --full-path', async () => { - expect((await run({[flag]: true, null: true})).err).toBe('a.file\0c.file\0'); + test('follow --null-out, --name-delimiter and --full-path', async () => { + expect((await run({[flag]: true, nullOut: 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, fullPath: true})).err).toBe( path.resolve('a.file') + '\n' + path.resolve('c.file') + '\n' @@ -382,7 +386,7 @@ describe('#703 the names of affected files', () => { }); test.each([ - ['null', {null: true}, '\0', '\0'], + ['null', {nullOut: true}, '\0', '\0'], ['name-delimiter', {nameDelimiter: ', ', nameDelimiterTail: false}, ', ', ''], ])('works with --%s', async (name, flags, between, last) => { expect((await run({fullPath: true, ...flags})).out).toBe( diff --git a/test/js/fix/711.test.js b/test/js/fix/711.test.js new file mode 100644 index 00000000..6585e47d --- /dev/null +++ b/test/js/fix/711.test.js @@ -0,0 +1,85 @@ +// https://github.com/mathiasrw/rexreplace/issues/711 +// A list of files from another command could only be used with `xargs` or `$(…)`. +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {rr, rrStatus} from '../helper.js'; + +const content = name => fs.readFileSync(name, 'utf8'); + +// Make each file with the content `foo` +function makeFiles(...names) { + names.forEach(name => fs.writeFileSync(name, 'foo')); +} + +describe('#711 --glob-pipe', () => { + test('each line of the piped data is a file to work on', async () => { + makeFiles('a.file', 'b.file', 'c.file'); + const config = {pipedData: 'a.file\nb.file\n', globPipe: true}; + expect(await rr('o', 'x', config)).toEqual({out: 'a.file\nb.file\n', err: '', exit: null}); + expect(content('c.file')).toBe('foo'); + }); + + test('a line can be a glob', async () => { + makeFiles('a.file', 'b.file', 'c.txt'); + const result = await rr('o', 'x', {pipedData: '*.file', globPipe: true}); + expect(result.out).toBe('a.file\nb.file\n'); + }); + + test('a line is used together with the paths and globs given as arguments', async () => { + makeFiles('a.file', 'b.file', 'c.txt'); + const config = {pipedData: 'a.file\n', includeGlob: ['*.txt'], globPipe: true}; + expect((await rr('o', 'x', config)).out).toBe('a.file\nc.txt\n'); + }); + + test('a line that is a file is used as it is, like an argument (#718)', async () => { + makeFiles('a[1].file', 'a1.file'); + const result = await rr('o', 'x', {pipedData: 'a[1].file\n', globPipe: true}); + expect(result.out).toBe('a[1].file\n'); + expect(content('a1.file')).toBe('foo'); + }); + + test('a line that starts with ! takes files away from the other globs', async () => { + makeFiles('a.file', 'b.file'); + const result = await rr('o', 'x', {pipedData: '*.file\n!b.file\n', globPipe: true}); + expect(result.out).toBe('a.file\n'); + }); + + test('takes Windows line breaks, leaves out empty lines and keeps spaces in names', async () => { + makeFiles('a.file', 'my file.txt'); + const config = {pipedData: '\r\na.file\r\n\r\nmy file.txt\r\n', globPipe: true}; + expect((await rr('o', 'x', config)).out).toBe('a.file\nmy file.txt\n'); + }); + + test('with --null-in the lines are separated by NUL bytes', async () => { + makeFiles('a.file', 'line\nbreak.file'); + const config = {pipedData: 'a.file\0line\nbreak.file\0', globPipe: true, nullIn: true}; + expect((await rr('o', 'x', config)).out).toBe('a.file\nline\nbreak.file\n'); + expect(content('line\nbreak.file')).toBe('fxx'); + }); + + test('with nothing piped in and no arguments there are no files', async () => { + expect(await rr('o', 'x', {pipedData: '', globPipe: true})).toEqual({ + out: '', + err: '0 files found\n', + exit: 1, + }); + }); + + test('is told about in the status report', async () => { + makeFiles('a.file'); + const status = await rrStatus('o', 'x', {pipedData: 'a.file\n', globPipe: true}); + expect(status.pipe).toEqual({bytes: 7, type: 'GLOBS', updated: false}); + expect(status.files).toEqual({searched: ['a.file'], updated: ['a.file']}); + }); + + test.each([ + ['--replacement-pipe', {replacementPipe: true}], + ['--pipe', {pipe: true}], + ])('can not be used with %s, as the piped data can only be one thing', async (name, flags) => { + makeFiles('a.file'); + const result = await rr('o', 'x', {pipedData: 'a.file', globPipe: true, ...flags}); + expect(result.exit).toBe(1); + expect(result.err).toContain('--glob-pipe can not be used with'); + expect(content('a.file')).toBe('foo'); + }); +}); From 7cfca468d0be637f9e66156305c3d0752fc5096d Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 12:45:45 +1100 Subject: [PATCH 02/12] Write null byte in place of NUL --- README.md | 2 +- src/cli.ts | 2 +- test/cli/run.sh | 14 +++++++------- test/js/fix/703.test.js | 4 ++-- test/js/fix/711.test.js | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ba4268a5..7a2a4e91 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ Use --js-pipe to access the pipe data in your javascript code. | `-p` | **`--pipe`** Read the piped data and make it available to --replacement-js as `pipe`. [boolean] | | | **`--js-pipe`** Shorthand for --replacement-js --pipe (-jp). [boolean] | | `-g` | **`--glob-pipe`** Each line of the piped data is a path or a glob of the files to work on. They are used together with the ones given as arguments. [boolean] | -| | **`--null-in`** The piped paths and globs of --glob-pipe are separated by NUL bytes, like from `find -print0` or --null-out. [boolean] | +| | **`--null-in`** The piped paths and globs of --glob-pipe are separated by null bytes, like from `find -print0` or --null-out. [boolean] | | `-R` | **`--replacement-pipe`** Replacement will be piped in. You still need to provide a dummy value (like `_`) as replacement parameter. [boolean] | | `-j` | **`--replacement-js`**, **`--js`** Treat replacement as javascript source code. The statement from the last expression will become the replacement string. Purposefully implemented the most insecure way possible to remove _any_ incentive to consider running code from an untrusted part. The full match will be available as a javascript variable named $0 while each captured group will be available as $1, $2, $3, ... and so on. At some point, the $ char _will_ give you a headache when used from the command line, so use €0, €1, €2, €3... instead. If the javascript source code references to the full match or a captured group the code will run once per match. Otherwise, it will run once per file. The code has access to the following variables: `r` as an alias for `require` with both expanded to understand a relative path even if it is not starting with `./`, `fs` from node, `path` from node, `globs` from npm, `pipe`: the data piped into the command with --pipe (null if no piped data, and `pipe_` is `❌ `), `find`: pattern searched for (the needle), `text`: full text being searched i.e. file content or piped data (the haystack), `bytes`: total size of the haystack in bytes, `size`: human-friendly representation of the total size of the haystack, `time`: String representing the local time when the command was invoked, `time_obj`: date object representing `time`, `now`: alias for `time`, `cwd`: current process working dir, `nl`: a new-line char, `_`: a single space char (for easy string concatenation). The following values defaults to `❌` if haystack does not originate from a file: `file`: contains the full path of the active file being searched (including full filename), `file_rel`: contains `file` relative to current process working dir, `dirpath`: contains the full path without filename of the active file being searched, `dirpath_rel`: contains `dirpath` relative to current process working dir, `filename`: is the full filename of the active file being searched without path, `name`: filename of the active file being searched with no extension, `ext`: extension of the filename including leading dot, `mtime`: ISO inspired representation of the last local modification time of the current file, `ctime`: ISO representation of the local creation time of the current file. `mtime_obj`: date object representing `mtime`, `ctime_obj`: date object representing `ctime`. All variables, except from module, date objects, `nl` and `_`, has a corresponding variable name followed by `_` where the content has an extra space at the end (for easy concatenation). [boolean] | | `-x` | **`--exclude-re`** Files with a path that matches this regular expression will be excluded from processing. Can be provided multiple times. [string] | diff --git a/src/cli.ts b/src/cli.ts index 292cbb95..0238f445 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -288,7 +288,7 @@ const yargs = require('yargs') .boolean('null-in') .describe( 'null-in', - 'The piped paths and globs of --glob-pipe are separated by NUL bytes, like from `find -print0` or --null-out.' + 'The piped paths and globs of --glob-pipe are separated by null bytes, like from `find -print0` or --null-out.' ) .implies('null-in', 'glob-pipe') diff --git a/test/cli/run.sh b/test/cli/run.sh index 244a421e..1bda7d45 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -468,17 +468,17 @@ assert_raises "printf 'my.file' | rexreplace 'b' 'x' --null-in" 1 assert "cat my.file" 'foobar' -# Data with NUL bytes is binary safe: the NUL bytes come out where they went in +# Data with null bytes is binary safe: the null bytes come out where they went in reset assert "printf 'foo\0boo\0' | rexreplace 'o' 'x' | tr '\0' '|'" 'fxx|bxx|' assert "printf 'foo\0boo\0' | rexreplace 'o' 'x' --output | tr '\0' '|'" 'fxx|bxx|' assert "printf 'foo\0boo\0' | rexreplace 'o' '_' --output-match --null-out | tr '\0' '|'" 'o|o|o|o|' -printf 'foo\0boo\0' > nul.file -assert "rexreplace 'o' 'x' nul.file" 'nul.file' -assert "tr '\0' '|' < nul.file" 'fxx|bxx|' -printf 'foo\0boo\0' > nul.file -assert "rexreplace 'o' 'x' nul.file --output 2>/dev/null | tr '\0' '|'" 'fxx|bxx|' -assert "rexreplace 'o' '_' nul.file --output-match --null-out 2>/dev/null | tr '\0' '|'" 'o|o|o|o|' +printf 'foo\0boo\0' > null.file +assert "rexreplace 'o' 'x' null.file" 'null.file' +assert "tr '\0' '|' < null.file" 'fxx|bxx|' +printf 'foo\0boo\0' > null.file +assert "rexreplace 'o' 'x' null.file --output 2>/dev/null | tr '\0' '|'" 'fxx|bxx|' +assert "rexreplace 'o' '_' null.file --output-match --null-out 2>/dev/null | tr '\0' '|'" 'o|o|o|o|' # Globs are case-insensitive like the search, unless --void-ignore-case (#716) diff --git a/test/js/fix/703.test.js b/test/js/fix/703.test.js index 847f7101..ebbdfc5a 100644 --- a/test/js/fix/703.test.js +++ b/test/js/fix/703.test.js @@ -62,7 +62,7 @@ describe('#703 the names of affected files', () => { }); describe('--null-out', () => { - test('ends each name with a NUL byte', async () => { + test('ends each name with a null byte', async () => { expect(await run({nullOut: true})).toEqual({out: 'a.file\0c.file\0', err: '', exit: null}); }); @@ -312,7 +312,7 @@ describe('#703 the names of affected files', () => { }); }); - test('--null-out is the same as --delimiter with a NUL byte', async () => { + test('--null-out is the same as --delimiter with a null byte', async () => { expect(await run({output: true, nullOut: true})).toEqual({ out: 'fxx\0bar\0fxx\0', err: 'a.file\0c.file\0', diff --git a/test/js/fix/711.test.js b/test/js/fix/711.test.js index 6585e47d..75e2edd5 100644 --- a/test/js/fix/711.test.js +++ b/test/js/fix/711.test.js @@ -50,7 +50,7 @@ describe('#711 --glob-pipe', () => { expect((await rr('o', 'x', config)).out).toBe('a.file\nmy file.txt\n'); }); - test('with --null-in the lines are separated by NUL bytes', async () => { + test('with --null-in the lines are separated by null bytes', async () => { makeFiles('a.file', 'line\nbreak.file'); const config = {pipedData: 'a.file\0line\nbreak.file\0', globPipe: true, nullIn: true}; expect((await rr('o', 'x', config)).out).toBe('a.file\nline\nbreak.file\n'); From 6f82fdd403386c8b9075f33a7b9ad22f6af53376 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 12:46:21 +1100 Subject: [PATCH 03/12] Say null bytes are kept, not that data is binary safe --- test/cli/run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cli/run.sh b/test/cli/run.sh index 1bda7d45..c72984fb 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -468,7 +468,7 @@ assert_raises "printf 'my.file' | rexreplace 'b' 'x' --null-in" 1 assert "cat my.file" 'foobar' -# Data with null bytes is binary safe: the null bytes come out where they went in +# Null bytes are kept: they come out where they went in. (Bytes that are not valid UTF-8 are only kept with -e latin1.) reset assert "printf 'foo\0boo\0' | rexreplace 'o' 'x' | tr '\0' '|'" 'fxx|bxx|' assert "printf 'foo\0boo\0' | rexreplace 'o' 'x' --output | tr '\0' '|'" 'fxx|bxx|' From dda891b6a77b57ad4f43b1d07364dd383f6ac640 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 12:49:12 +1100 Subject: [PATCH 04/12] Show how to read null-separated data, and how to keep binary data --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 7a2a4e91..86e7538e 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,12 @@ Pipe the names into the next run with `--glob-pipe`, so it only works on the fil > rexreplace 'foo' 'bar' '**/*.md' --null-out | rexreplace 'baz' 'qux' --glob-pipe --null-in ``` +Null bytes are just chars in the search, so null-separated data can be made readable by turning them into line breaks: + +```bash +> find . -name '*.md' -print0 | rexreplace '\x00' '\n' +``` + **Matching pairs** @@ -310,6 +316,7 @@ Files identified for potential processing are identified by: ### Limitations - RexReplace reads each file fully into memory, so working on your 8Gb log files will probably not be ideal. +- Null bytes are kept as they are. Files are read as utf8 by default, so in binary data the bytes that are not valid UTF-8 become `�` when the file is changed. Use `--encoding latin1` to keep every byte. - For versions of Node prior to 6, please use version 2.2.x. For versions of Node prior to 0.12, please use [the legacy version of RexReplace called rreplace](https://www.npmjs.com/package/rreplace) ### Priorities From d665cb8ebf4e866e5b2b28e7a9da8911fc3729c1 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 12:34:48 +1100 Subject: [PATCH 05/12] Test UTF-8 in all inputs and outputs to close #735 --- test/cli/run.sh | 11 ++++ test/js/utf8.test.js | 131 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 test/js/utf8.test.js diff --git a/test/cli/run.sh b/test/cli/run.sh index c72984fb..bba288cf 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -451,6 +451,17 @@ assert "rexreplace 'b' 'x' my.file --content-delimiter '\x4' 2>&1 | grep -c assert "rexreplace 'b' 'x' my.file --delimiter '\x4' 2>&1 | grep -c 'Invalid escape sequence in --delimiter'" '1' assert "cat my.file" 'foobar' +# UTF-8 goes in and out as it is (#735) +reset +assert "printf 'a🦖b' | rexreplace '🦖' '👩‍💻'" 'a👩‍💻b' +# Without --unicode . is half an emoji, so 'x.y' does not match an emoji between x and y +assert "printf 'x🦖y' | rexreplace 'x.y' '_'" 'x🦖y' +assert "printf 'x🦖y' | rexreplace 'x.y' '_' --unicode" '_' +echo foo > '🦖 中.file' +assert "rexreplace 'o' 'x' '🦖 中.file'" '🦖 中.file' +assert "cat '🦖 中.file'" 'fxx' + + # --glob-pipe: each line of the piped data is a path or a glob to work on (#711) reset assert "printf 'my.file\nyour.file\n' | rexreplace 'b' 'x' --glob-pipe -A" 'my.file\nyour.file' diff --git a/test/js/utf8.test.js b/test/js/utf8.test.js new file mode 100644 index 00000000..1675052e --- /dev/null +++ b/test/js/utf8.test.js @@ -0,0 +1,131 @@ +// https://github.com/mathiasrw/rexreplace/issues/735 +// Emoji and other UTF-8 in every way data comes in and goes out. +import {describe, test, expect} from 'bun:test'; +import path from 'path'; +import {rr} from './helper.js'; + +// rr() gives stdout as a JavaScript string. This is what real stdout (or a file) gets, so half an emoji shows up as `�`. +const asUtf8 = text => Buffer.from(text).toString(); + +const text = 'æ 中 🦖 👩‍💻\r\n'; + +describe('#735 UTF-8 in the pattern', () => { + test('is found as it is', async () => { + expect(asUtf8((await rr('🦖', 'x', {pipedData: text})).out)).toBe('æ 中 x 👩‍💻\r\n'); + }); + + test('. takes half an emoji without --unicode, which then shows as �', async () => { + const out = (await rr('.', 'x', {pipedData: '🦖a', voidGlobal: true})).out; + expect(asUtf8(out)).toBe('x�a'); + }); + + test('. takes the whole emoji with --unicode', async () => { + const config = {pipedData: '🦖a', voidGlobal: true, unicode: true}; + expect(asUtf8((await rr('.', 'x', config)).out)).toBe('xa'); + }); + + test('is plain text with --literal', async () => { + const config = {pipedData: 'a中.🦖b 中x🦖', literal: true}; + expect(asUtf8((await rr('中.🦖', 'x', config)).out)).toBe('axb 中x🦖'); + }); +}); + +describe('#735 UTF-8 in the replacement', () => { + test('is inserted as it is', async () => { + expect(asUtf8((await rr('a', '🦖', {pipedData: 'aba'})).out)).toBe('🦖b🦖'); + }); + + test('works with the € and § aliases', async () => { + expect(asUtf8((await rr('(🦖)', '€1€1', {pipedData: 'a🦖b'})).out)).toBe('a🦖🦖b'); + expect(asUtf8((await rr('§d', '🦖', {pipedData: 'a1b'})).out)).toBe('a🦖b'); + }); + + test('can come from --replacement-js, where an emoji has the length 2', async () => { + const config = {pipedData: 'a🦖', replacementJs: true}; + expect(asUtf8((await rr('🦖', '$0 + $0.length', config)).out)).toBe('a🦖2'); + }); +}); + +describe('#735 UTF-8 in the content of a file', () => { + test('is written as UTF-8 when changed in place', async () => { + await Bun.write('my.file', text); + await rr('🦖', '🦕', {includeGlob: ['my.file']}); + expect(Buffer.from(await Bun.file('my.file').arrayBuffer())).toEqual( + Buffer.from('æ 中 🦕 👩‍💻\r\n') + ); + }); + + test('is printed with --output', async () => { + await Bun.write('my.file', text); + const out = (await rr('🦖', '🦕', {includeGlob: ['my.file'], output: true})).out; + expect(asUtf8(out)).toBe('æ 中 🦕 👩‍💻\r\n'); + }); + + test('is printed with --output-match', async () => { + await Bun.write('my.file', text); + const config = {includeGlob: ['my.file'], outputMatch: true}; + expect(asUtf8((await rr('🦖|👩‍💻', '_', config)).out)).toBe('🦖\n👩‍💻\n'); + }); +}); + +describe('#735 UTF-8 in piped data', () => { + test('comes out as it went in, with \\r\\n', async () => { + expect(asUtf8((await rr('中', 'x', {pipedData: text})).out)).toBe('æ x 🦖 👩‍💻\r\n'); + }); + + test('can be the replacement with --replacement-pipe', async () => { + await Bun.write('my.file', 'aXb'); + await rr('X', '_', {includeGlob: ['my.file'], replacementPipe: true, pipedData: '🦖'}); + expect(await Bun.file('my.file').text()).toBe('a🦖b'); + }); +}); + +describe('#735 UTF-8 in file names', () => { + const name = '🦖 中.file'; + + test('a name is printed as it was given', async () => { + await Bun.write(name, 'foo'); + expect(asUtf8((await rr('o', 'x', {includeGlob: [name]})).out)).toBe(name + '\n'); + expect(await Bun.file(name).text()).toBe('fxx'); + }); + + test('a glob finds a name with emoji', async () => { + await Bun.write(name, 'foo'); + await Bun.write('other.file', 'foo'); + expect(asUtf8((await rr('o', 'x', {includeGlob: ['*🦖*']})).out)).toBe(name + '\n'); + }); + + test('a name is printed with its full path with --full-path', async () => { + await Bun.write(name, 'foo'); + const out = (await rr('o', 'x', {includeGlob: [name], fullPath: true})).out; + expect(asUtf8(out)).toBe(path.resolve(name) + '\n'); + }); +}); + +describe('#735 UTF-8 in the delimiters', () => { + async function makeFiles() { + await Bun.write('a.file', 'foo'); + await Bun.write('b.file', 'foo'); + return {includeGlob: ['*.file']}; + } + + test('--name-delimiter, also with no tail', async () => { + const config = {...(await makeFiles()), nameDelimiter: '🦖'}; + expect(asUtf8((await rr('o', 'x', config)).out)).toBe('a.file🦖b.file🦖'); + await makeFiles(); + const noTail = {...config, nameDelimiterTail: false}; + expect(asUtf8((await rr('o', 'x', noTail)).out)).toBe('a.file🦖b.file'); + }); + + test('--content-delimiter with a combined emoji', async () => { + const config = {...(await makeFiles()), output: true, contentDelimiter: '👩‍💻'}; + expect(asUtf8((await rr('o', 'x', config)).out)).toBe('fxx👩‍💻fxx👩‍💻'); + }); + + test('--delimiter sets both', async () => { + const config = {...(await makeFiles()), output: true, delimiter: '🦕'}; + const result = await rr('o', 'x', config); + expect(asUtf8(result.out)).toBe('fxx🦕fxx🦕'); + expect(asUtf8(result.err)).toBe('a.file🦕b.file🦕'); + }); +}); From 41346f4edd7603fe077501f559fbac50bccac95c Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 13:19:56 +1100 Subject: [PATCH 06/12] Split the tests into test/engine and test/cli, and run the CLI with Bun's shell --- bunfig.toml | 2 +- devguide.md | 6 +- package.json | 2 +- test/{js => cli}/debug.test.js | 28 +++---- test/cli/fix/691.test.js | 75 ++++++++++++++++++ test/{js => cli}/fix/692.test.js | 28 ++----- test/cli/fix/700.test.js | 57 ++++++++++++++ test/cli/fix/733.test.js | 12 +++ test/{js => cli}/fix/736.test.js | 1 + test/cli/fix/742.test.js | 47 +++++++++++ test/cli/halt.test.js | 25 ++++++ test/{js => engine}/basics.test.js | 21 +---- test/{js => engine}/crossover.test.js | 2 +- test/{js => engine}/dot-all.test.js | 2 +- test/{js => engine}/encoding.test.js | 2 +- test/{js => engine}/engine.test.js | 2 +- test/{js => engine}/exclude-glob.test.js | 2 +- test/{js => engine}/exclude-re.test.js | 2 +- test/{js => engine}/fix/681.test.js | 2 +- test/{js => engine}/fix/682.test.js | 2 +- test/{js => engine}/fix/683.test.js | 2 +- test/{js => engine}/fix/699.test.js | 2 +- test/{js => engine}/fix/703.test.js | 2 +- test/{js => engine}/fix/705.test.js | 2 +- test/{js => engine}/fix/708.test.js | 2 +- test/{js => engine}/fix/710.test.js | 2 +- test/{js => engine}/fix/711.test.js | 2 +- test/{js => engine}/fix/716.test.js | 2 +- test/{js => engine}/fix/718.test.js | 2 +- test/{js => engine}/fix/719.test.js | 2 +- test/{js => engine}/fix/726.test.js | 2 +- test/{js => engine}/fix/728.test.js | 2 +- test/{js => engine}/fix/742.test.js | 54 +------------ test/{js => engine}/halt.test.js | 37 +-------- test/{js => engine}/keep-backup.test.js | 2 +- test/{js => engine}/literal.test.js | 2 +- test/{js => engine}/output-match.test.js | 2 +- test/{js => engine}/output.test.js | 2 +- test/{js => engine}/pattern-file.test.js | 0 test/{js => engine}/quiet-total.test.js | 2 +- test/{js => engine}/quiet.test.js | 2 +- test/{js => engine}/replacement-file.test.js | 0 test/{js => engine}/replacement-js.test.js | 2 +- test/{js => engine}/replacement-pipe.test.js | 2 +- test/{js => engine}/unicode.test.js | 2 +- test/{js => engine}/utf8.test.js | 2 +- test/{js => engine}/verbose.test.js | 2 +- test/{js => engine}/version.test.js | 2 - test/{js => engine}/void-async.test.js | 2 +- test/{js => engine}/void-backup.test.js | 2 +- test/{js => engine}/void-euro.test.js | 2 +- test/{js => engine}/void-global.test.js | 2 +- test/{js => engine}/void-ignore-case.test.js | 2 +- test/{js => engine}/void-multiline.test.js | 2 +- test/{js => engine}/void-section.test.js | 2 +- test/fuzzy/basics.test.js | 2 +- test/fuzzy/crossover.test.js | 2 +- test/fuzzy/dot-all.test.js | 2 +- test/fuzzy/encoding.test.js | 2 +- test/fuzzy/exclude-glob.test.js | 2 +- test/fuzzy/exclude-re.test.js | 2 +- test/fuzzy/keep-backup.test.js | 2 +- test/fuzzy/literal.test.js | 2 +- test/fuzzy/output-match.test.js | 2 +- test/fuzzy/output.test.js | 2 +- test/fuzzy/replacement-js.test.js | 2 +- test/fuzzy/replacement-pipe.test.js | 2 +- test/fuzzy/unicode.test.js | 2 +- test/fuzzy/void-async.test.js | 2 +- test/fuzzy/void-backup.test.js | 2 +- test/fuzzy/void-euro.test.js | 2 +- test/fuzzy/void-global.test.js | 2 +- test/fuzzy/void-ignore-case.test.js | 2 +- test/fuzzy/void-multiline.test.js | 2 +- test/fuzzy/void-section.test.js | 2 +- test/{js => }/helper.js | 16 +++- test/js/fix/691.test.js | 83 -------------------- test/js/fix/700.test.js | 66 ---------------- test/js/help.test.js | 5 -- test/js/trim-pipe.test.js | 5 -- 80 files changed, 315 insertions(+), 373 deletions(-) rename test/{js => cli}/debug.test.js (71%) create mode 100644 test/cli/fix/691.test.js rename test/{js => cli}/fix/692.test.js (64%) create mode 100644 test/cli/fix/700.test.js create mode 100644 test/cli/fix/733.test.js rename test/{js => cli}/fix/736.test.js (90%) create mode 100644 test/cli/fix/742.test.js create mode 100644 test/cli/halt.test.js rename test/{js => engine}/basics.test.js (92%) rename test/{js => engine}/crossover.test.js (99%) rename test/{js => engine}/dot-all.test.js (98%) rename test/{js => engine}/encoding.test.js (99%) rename test/{js => engine}/engine.test.js (98%) rename test/{js => engine}/exclude-glob.test.js (99%) rename test/{js => engine}/exclude-re.test.js (99%) rename test/{js => engine}/fix/681.test.js (96%) rename test/{js => engine}/fix/682.test.js (97%) rename test/{js => engine}/fix/683.test.js (98%) rename test/{js => engine}/fix/699.test.js (99%) rename test/{js => engine}/fix/703.test.js (99%) rename test/{js => engine}/fix/705.test.js (99%) rename test/{js => engine}/fix/708.test.js (98%) rename test/{js => engine}/fix/710.test.js (97%) rename test/{js => engine}/fix/711.test.js (98%) rename test/{js => engine}/fix/716.test.js (97%) rename test/{js => engine}/fix/718.test.js (99%) rename test/{js => engine}/fix/719.test.js (98%) rename test/{js => engine}/fix/726.test.js (99%) rename test/{js => engine}/fix/728.test.js (98%) rename test/{js => engine}/fix/742.test.js (78%) rename test/{js => engine}/halt.test.js (67%) rename test/{js => engine}/keep-backup.test.js (99%) rename test/{js => engine}/literal.test.js (99%) rename test/{js => engine}/output-match.test.js (99%) rename test/{js => engine}/output.test.js (99%) rename test/{js => engine}/pattern-file.test.js (100%) rename test/{js => engine}/quiet-total.test.js (98%) rename test/{js => engine}/quiet.test.js (98%) rename test/{js => engine}/replacement-file.test.js (100%) rename test/{js => engine}/replacement-js.test.js (99%) rename test/{js => engine}/replacement-pipe.test.js (99%) rename test/{js => engine}/unicode.test.js (98%) rename test/{js => engine}/utf8.test.js (99%) rename test/{js => engine}/verbose.test.js (98%) rename test/{js => engine}/version.test.js (85%) rename test/{js => engine}/void-async.test.js (99%) rename test/{js => engine}/void-backup.test.js (98%) rename test/{js => engine}/void-euro.test.js (99%) rename test/{js => engine}/void-global.test.js (98%) rename test/{js => engine}/void-ignore-case.test.js (98%) rename test/{js => engine}/void-multiline.test.js (98%) rename test/{js => engine}/void-section.test.js (99%) rename test/{js => }/helper.js (85%) delete mode 100644 test/js/fix/691.test.js delete mode 100644 test/js/fix/700.test.js delete mode 100644 test/js/help.test.js delete mode 100644 test/js/trim-pipe.test.js diff --git a/bunfig.toml b/bunfig.toml index dab8e7f1..5c75643e 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,2 +1,2 @@ [test] -preload = ["./test/js/helper.js"] +preload = ["./test/helper.js"] diff --git a/devguide.md b/devguide.md index 33fae48c..996e5df2 100644 --- a/devguide.md +++ b/devguide.md @@ -36,8 +36,10 @@ All output goes through `src/output.ts`. Pick the function by who the message is - **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}` from `rr()`), so nothing extra slips through. Use `rrStatus()` for the status of the same run. - 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). +- The folder tells what is tested, so a file is named after the feature: `test/engine/.test.js` tests the engine with `rr()`, and `test/cli/.test.js` tests the CLI with `cli()`, which runs it with Bun's `$` shell. +- A test for an issue goes in `test/engine/fix/.test.js` or `test/cli/fix/.test.js`. +- `test/cli/run.sh` is a demo of how RexReplace is used: a few clear examples for each feature. Checks of many combinations go in `test/cli/.test.js`. +- Use Bun native file IO (`Bun.write`, `Bun.file`) in new tests (#734), and `cli()` to run the CLI. - 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. diff --git a/package.json b/package.json index d9af7bc0..d9383848 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "bundle-bun": "bun build src/cli.bun.ts --target=bun --format=cjs --packages=external", "prebuild": "rm -fr bin && yarn format", "sync-version": "bun npm/sync-version.js", - "test-js": "AGENT=1 bun test ./test/js", + "test-js": "AGENT=1 bun test ./test/engine ./test/cli", "test-fuzzy": "AGENT=1 bun test ./test/fuzzy", "test-minify": "AGENT=1 yarn build-minify && yarn test-cli && yarn test-js", "test-cli": "yarn test-cli-global && yarn test-cli-node && yarn test-cli-bun", diff --git a/test/js/debug.test.js b/test/cli/debug.test.js similarity index 71% rename from test/js/debug.test.js rename to test/cli/debug.test.js index cb3f2b0c..5cc5bbaf 100644 --- a/test/js/debug.test.js +++ b/test/cli/debug.test.js @@ -1,23 +1,13 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import path from 'path'; - -// --debug is set by the CLI, so these cases run the CLI itself -const cli = path.join(import.meta.dir, '../../src/cli.ts'); - -function runCli(...args) { - const {stdout, stderr, exitCode} = Bun.spawnSync([process.execPath, cli, ...args], { - stdin: 'ignore', - }); - return {out: stdout.toString(), err: stderr.toString(), exit: exitCode}; -} +import {cli} from '../helper.js'; describe('--debug', () => { test.each(['--debug', '-d'])( '%s prints only the status report, with what went into the engine and what it did', - flag => { + async flag => { fs.writeFileSync('my.file', 'foobar'); - const result = runCli('o', 'x', 'my.file', flag); + const result = await cli(['o', 'x', 'my.file', flag]); expect(result.err).toBe(''); expect(result.exit).toBe(0); expect(result.out.startsWith('\n{')).toBe(true); @@ -40,23 +30,23 @@ describe('--debug', () => { } ); - test('still prints the result of --output before the status report', () => { + test('still prints the result of --output before the status report', async () => { fs.writeFileSync('my.file', 'foobar'); - const result = runCli('o', 'x', 'my.file', '--debug', '--output'); + const result = await cli(['o', 'x', 'my.file', '--debug', '--output']); expect(result.out.startsWith('fxxbar\n\n{')).toBe(true); expect(JSON.parse(result.out.slice('fxxbar\n'.length)).files.updated).toEqual(['my.file']); }); - test('gives the errors in the status report, and the exit code 1', () => { - const result = runCli('o', 'x', 'nope.file', '--debug'); + test('gives the errors in the status report, and the exit code 1', async () => { + const result = await cli(['o', 'x', 'nope.file', '--debug']); expect(result.err).toBe(''); expect(result.exit).toBe(1); expect(JSON.parse(result.out).errors).toEqual(['0 files found']); }); - test('gives the error of an invalid pattern in the status report', () => { + test('gives the error of an invalid pattern in the status report', async () => { fs.writeFileSync('my.file', 'foobar'); - const result = runCli('(', 'x', 'my.file', '--debug'); + const result = await cli(['(', 'x', 'my.file', '--debug']); expect(result.err).toBe(''); expect(result.exit).toBe(1); expect(JSON.parse(result.out).errors[0]).toContain('Invalid regular expression'); diff --git a/test/cli/fix/691.test.js b/test/cli/fix/691.test.js new file mode 100644 index 00000000..860b83f8 --- /dev/null +++ b/test/cli/fix/691.test.js @@ -0,0 +1,75 @@ +// https://github.com/mathiasrw/rexreplace/issues/691 +// A replacement containing a real line break, a `\'` or a lone backslash at the end crashed the CLI with a SyntaxError and a stack trace. +// The replacement is unescaped by the CLI (not the engine), so these tests run the CLI itself. +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {cli} from '../../helper.js'; + +// 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'); + return cli(['o', replacement, 'my.file', '--output', '--no-content-delimiter-tail', ...flags]); +} + +describe('#691 replacement that used to crash the CLI', () => { + test('a real line break is inserted as a line break', async () => { + expect(await replaceO('a\nb')).toEqual({out: 'fa\nba\nbbar', err: expectedOutput, exit: 0}); + expect((await replaceO('a\r\nb')).out).toBe('fa\r\nba\r\nbbar'); + expect((await replaceO('\u2028\u2029')).out).toBe('f\u2028\u2029\u2028\u2029bar'); + }); + + test("`\\'` gives a single quote", async () => { + expect(await replaceO("\\'")).toEqual({out: "f''bar", err: expectedOutput, exit: 0}); + }); + + test('a lone backslash at the end gives a backslash', async () => { + expect(await replaceO('\\')).toEqual({out: 'f\\\\bar', err: expectedOutput, exit: 0}); + expect((await replaceO('x\\')).out).toBe('fx\\x\\bar'); + }); + + test('a backslash in front of a real line break still joins the lines (as before)', async () => { + expect(await replaceO('a\\\nb')).toEqual({out: 'fababbar', err: expectedOutput, exit: 0}); + expect((await replaceO('a\\\r\nb')).out).toBe('fababbar'); + expect((await replaceO('a \\\n b \\\n c')).out).toBe('fa b ca b cbar'); + }); + + test('the replacement can not run as code', async () => { + const result = await replaceO("\\'+process.exit(3)//"); + expect(result).toEqual({ + out: "f'+process.exit(3)//'+process.exit(3)//bar", + err: expectedOutput, + exit: 0, + }); + }); + + test('an invalid escape sequence gives an error message and no stack trace', async () => { + const result = await replaceO('\\x4'); + expect(result.out).toBe(''); + expect(result.err).toBe(' ❌ Invalid escape sequence in replacement: \\x4\n\n'); + expect(result.exit).toBe(1); + expect(await replaceO('\\x4', '--quiet-total')).toEqual({out: '', err: '', exit: 1}); + }); + + test.each([ + ['\\n', '\n'], + ['\\t', '\t'], + ['\\x41', 'A'], + ['\\u00e9', 'é'], + ['\\\\', '\\'], + ['\\\\n', '\\n'], + ["'", "'"], + ["\\\\'", "\\'"], + ['"', '"'], + ['`${1}`', '`${1}`'], + ])('%j still gives %j', async (replacement, expected) => { + expect(await replaceO(replacement)).toEqual({ + out: `f${expected}${expected}bar`, + err: expectedOutput, + exit: 0, + }); + }); +}); diff --git a/test/js/fix/692.test.js b/test/cli/fix/692.test.js similarity index 64% rename from test/js/fix/692.test.js rename to test/cli/fix/692.test.js index 5471e10c..6437f67f 100644 --- a/test/js/fix/692.test.js +++ b/test/cli/fix/692.test.js @@ -1,30 +1,18 @@ // https://github.com/mathiasrw/rexreplace/issues/692 // A single argument containing -v or -h printed the version or help. The checks live in the CLI layer, so the CLI is started as a separate process. import {describe, test, expect} from 'bun:test'; -import {spawnSync} from 'child_process'; -import path from 'path'; - -const cli = path.resolve(import.meta.dir, '../../../src/cli.ts'); - -function run(...args) { - const {stdout, stderr, status} = spawnSync(process.execPath, [cli, ...args], { - encoding: 'utf8', - }); - return {out: stdout, err: stderr, exit: status}; -} +import {cli} from '../../helper.js'; describe('#692 single argument', () => { - const version = run('--version').out; - - test.each(['-v', '-version', '--version'])('%s prints only the version', arg => { - const result = run(arg); - expect(result.out).toBe(version); + test.each(['-v', '-version', '--version'])('%s prints only the version', async arg => { + const result = await cli([arg]); + expect(result.out).toBe((await cli(['--version'])).out); expect(result.out.trim()).toMatch(/^\S+$/); expect(result.exit).toBe(0); }); - test.each(['-h', '-help', '--help'])('%s prints the help with exit code 0', arg => { - const result = run(arg); + test.each(['-h', '-help', '--help'])('%s prints the help with exit code 0', async arg => { + const result = await cli([arg]); expect(result.out).toContain('Options:'); expect(result.err).toBe(''); expect(result.exit).toBe(0); @@ -33,8 +21,8 @@ describe('#692 single argument', () => { // -V is verbose and -H is halt: other flags, not a request for version or help test.each(['my-vile', '-V', 'o-ho', '-H', 'a-v', 'a-h', 'xversion', 'xhelp'])( '%s is a usage error: help on stderr, nothing on stdout, exit code 1', - arg => { - const result = run(arg); + async arg => { + const result = await cli([arg]); expect(result.out).toBe(''); expect(result.err).toContain('Options:'); expect(result.exit).toBe(1); diff --git a/test/cli/fix/700.test.js b/test/cli/fix/700.test.js new file mode 100644 index 00000000..efaf63d0 --- /dev/null +++ b/test/cli/fix/700.test.js @@ -0,0 +1,57 @@ +// https://github.com/mathiasrw/rexreplace/issues/700 (also reported as #348) +// `§` is an alias for `\` but in the replacement it was converted after the escape sequences were interpreted, so `§n` gave a backslash and an `n` instead of a line break. +// The replacement is unescaped by the CLI (not the engine), so these tests run the CLI itself. +import {describe, test, expect} from 'bun:test'; +import {cli} from '../../helper.js'; + +// Give `input` to the CLI as piped data and return what it gives back +const run = (input, ...args) => cli(args, input); + +// Replace the `b` in `foobar` with the replacement +const replaceB = (replacement, ...flags) => run('foobar', 'b', replacement, ...flags); + +describe('#700 § is a full substitute for \\ in the replacement', () => { + test('the example from #348', async () => { + expect(await run('abc\n', '§n', 'X§n')).toEqual({out: 'abcX\n', err: '', exit: 0}); + expect(await run('abc\n', '§n', 'X\\n')).toEqual({out: 'abcX\n', err: '', exit: 0}); + }); + + test.each([ + ['§n', '\n'], + ['§t', '\t'], + ['§r§n', '\r\n'], + ['§x41', 'A'], + ['§u00e9', 'é'], + ['§u{1F600}', '😀'], + ['§§', '\\'], + ['§§n', '\\n'], + ['§', '\\'], + ["§'", "'"], + ['a§\nb', 'ab'], + ['[€&§n]', '[b\n]'], + ])('%j gives %j like it does with a backslash', async (replacement, expected) => { + const result = await replaceB(replacement); + expect(result).toEqual({out: `foo${expected}ar`, err: '', exit: 0}); + expect(await replaceB(replacement.replaceAll('§', '\\'))).toEqual(result); + }); + + test('an invalid escape sequence gives the same error as with a backslash', async () => { + expect(await replaceB('§x4')).toEqual({ + out: '', + err: ' ❌ Invalid escape sequence in replacement: §x4\n\n', + exit: 1, + }); + }); + + test('--void-section keeps § as a plain char', async () => { + expect((await replaceB('§n', '--void-section')).out).toBe('foo§nar'); + expect((await replaceB('§', '--void-section')).out).toBe('foo§ar'); + expect((await replaceB('§x4', '--void-section')).out).toBe('foo§x4ar'); + expect((await replaceB('§\\n', '-§')).out).toBe('foo§\nar'); + }); + + test('--replacement-js still gets § as a backslash in the source', async () => { + expect((await replaceB('"a§nb"', '--replacement-js')).out).toBe('fooa\nbar'); + expect((await replaceB('"a§nb"', '--replacement-js', '--void-section')).out).toBe('fooa§nbar'); + }); +}); diff --git a/test/cli/fix/733.test.js b/test/cli/fix/733.test.js new file mode 100644 index 00000000..eb8ef430 --- /dev/null +++ b/test/cli/fix/733.test.js @@ -0,0 +1,12 @@ +// https://github.com/mathiasrw/rexreplace/issues/733 +// yargs turned a file glob like `2024` into a number, and fast-glob only takes strings, so RexReplace crashed. +import {describe, test, expect} from 'bun:test'; +import {cli} from '../../helper.js'; + +describe('#733 a file glob that reads as a number', () => { + test.each(['2024', '1e3', '0x10'])('is kept as a string, like %s', async name => { + await Bun.write(name, 'foobar'); + expect(await cli(['o', 'x', name])).toEqual({out: name + '\n', err: '', exit: 0}); + expect(await Bun.file(name).text()).toBe('fxxbar'); + }); +}); diff --git a/test/js/fix/736.test.js b/test/cli/fix/736.test.js similarity index 90% rename from test/js/fix/736.test.js rename to test/cli/fix/736.test.js index c8d003bc..0d6d42ea 100644 --- a/test/js/fix/736.test.js +++ b/test/cli/fix/736.test.js @@ -4,6 +4,7 @@ import {describe, test, expect} from 'bun:test'; import path from 'path'; +// Not cli() from the helper: the test needs a pipe that stays open, and cli() pipes in data that ends const cli = path.resolve(import.meta.dir, '../../../src/cli.ts'); describe('#736 stdin is only read when the data is asked for', () => { diff --git a/test/cli/fix/742.test.js b/test/cli/fix/742.test.js new file mode 100644 index 00000000..76b33482 --- /dev/null +++ b/test/cli/fix/742.test.js @@ -0,0 +1,47 @@ +// https://github.com/mathiasrw/rexreplace/issues/742 +// The CLI is the only part that acts on the process. It prints the status report with --status and --status-compact. +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {cli} from '../../helper.js'; + +describe('#742 the CLI', () => { + test('--status prints a line break and the status report with an indent of two spaces after the result', async () => { + fs.writeFileSync('a.file', 'foo'); + const result = await cli(['o', 'x', 'a.file', '--status']); + const status = { + files: {searched: ['a.file'], updated: ['a.file']}, + pipe: null, + warnings: [], + errors: [], + }; + expect(result).toEqual({ + out: 'a.file\n\n' + JSON.stringify(status, null, 2) + '\n', + err: '', + exit: 0, + }); + }); + + test.each(['--status-compact', '-c'])( + '%s prints the status report on one line, with no line break in front', + async flag => { + fs.writeFileSync('a.file', 'foo'); + const result = await cli(['o', 'x', 'a.file', flag, '--quiet-total']); + expect(result.out).toBe( + '{"files":{"searched":["a.file"],"updated":["a.file"]},"pipe":null,"warnings":[],"errors":[]}\n' + ); + } + ); + + test('sets the exit code to 1 when a file gives an error, and prints the status report', async () => { + const result = await cli(['o', 'x', 'nope.file', '--status-compact', '--quiet-total']); + expect(JSON.parse(result.out).errors).toEqual(['0 files found']); + expect(result.exit).toBe(1); + }); + + test('ends the process on a panic, and still prints the status report', async () => { + fs.writeFileSync('a.file', 'foo'); + const result = await cli(['(', 'x', 'a.file', '--status-compact', '--quiet-total']); + expect(JSON.parse(result.out).errors).toHaveLength(1); + expect(result.exit).toBe(1); + }); +}); diff --git a/test/cli/halt.test.js b/test/cli/halt.test.js new file mode 100644 index 00000000..3b68b975 --- /dev/null +++ b/test/cli/halt.test.js @@ -0,0 +1,25 @@ +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {cli} from '../helper.js'; + +describe('--halt', () => { + describe('--bail alias', () => { + test('is listed in --help with --halt', async () => { + const result = await cli(['--help']); + expect(result.exit).toBe(0); + expect(result.out).toMatch(/-H,\s*--halt,\s*--bail/); + }); + + test.each(['--bail', '--halt', '-H'])('%s stops on the first error like --halt', async flag => { + const result = await cli(['o', 'x', 'nope.file', flag]); + expect(result.err).toContain('0 files found'); + expect(result.exit).toBe(1); + }); + + test('--bail is accepted and changes nothing when all goes well', async () => { + fs.writeFileSync('my.file', 'foobar'); + expect((await cli(['o', 'x', 'my.file', '--bail'])).exit).toBe(0); + expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); + }); + }); +}); diff --git a/test/js/basics.test.js b/test/engine/basics.test.js similarity index 92% rename from test/js/basics.test.js rename to test/engine/basics.test.js index e650ea6d..06956eda 100644 --- a/test/js/basics.test.js +++ b/test/engine/basics.test.js @@ -1,8 +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'; +import {rr} from '../helper.js'; describe('pattern', () => { test('is a regular expression', async () => { @@ -181,24 +180,6 @@ describe('files', () => { expect(fs.readFileSync('.hidden.file', 'utf8')).toBe('foobar'); }); - // The file names are read by src/cli.ts, so this runs the CLI itself - test.each(['2024', '1e3', '0x10'])( - 'can have a name that reads as a number, like %s (#733)', - async name => { - await Bun.write(name, 'foobar'); - const cli = path.join(import.meta.dir, '../../src/cli.ts'); - const {stdout, stderr, exitCode} = Bun.spawnSync([process.execPath, cli, 'o', 'x', name], { - stdin: 'ignore', - }); - expect({out: stdout.toString(), err: stderr.toString(), exit: exitCode}).toEqual({ - out: name + '\n', - err: '', - exit: 0, - }); - expect(await Bun.file(name).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'); diff --git a/test/js/crossover.test.js b/test/engine/crossover.test.js similarity index 99% rename from test/js/crossover.test.js rename to test/engine/crossover.test.js index 86ee983d..ffd705e8 100644 --- a/test/js/crossover.test.js +++ b/test/engine/crossover.test.js @@ -1,7 +1,7 @@ // Flags combined with each other import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; describe('regex flags', () => { const flags = ['voidIgnoreCase', 'voidGlobal', 'voidMultiline', 'dotAll', 'unicode']; diff --git a/test/js/dot-all.test.js b/test/engine/dot-all.test.js similarity index 98% rename from test/js/dot-all.test.js rename to test/engine/dot-all.test.js index 33d1045a..e6b72d39 100644 --- a/test/js/dot-all.test.js +++ b/test/engine/dot-all.test.js @@ -1,5 +1,5 @@ import {describe, test, expect} from 'bun:test'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; describe('--dot-all', () => { test('lets . match a newline', async () => { diff --git a/test/js/encoding.test.js b/test/engine/encoding.test.js similarity index 99% rename from test/js/encoding.test.js rename to test/engine/encoding.test.js index 288f2a47..9ef47893 100644 --- a/test/js/encoding.test.js +++ b/test/engine/encoding.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; // "fæo" as latin1 (not valid utf8) const latin1 = () => Buffer.from([0x66, 0xe6, 0x6f]); diff --git a/test/js/engine.test.js b/test/engine/engine.test.js similarity index 98% rename from test/js/engine.test.js rename to test/engine/engine.test.js index 8c63f709..84d2fd5e 100644 --- a/test/js/engine.test.js +++ b/test/engine/engine.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; describe('--engine', () => { test('default is the engine of the runtime', async () => { diff --git a/test/js/exclude-glob.test.js b/test/engine/exclude-glob.test.js similarity index 99% rename from test/js/exclude-glob.test.js rename to test/engine/exclude-glob.test.js index 699ba215..7a63d4e3 100644 --- a/test/js/exclude-glob.test.js +++ b/test/engine/exclude-glob.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; // Make `names` as files containing "foo" and return the ones changed by replacing "foo" const changed = async (names, config) => { diff --git a/test/js/exclude-re.test.js b/test/engine/exclude-re.test.js similarity index 99% rename from test/js/exclude-re.test.js rename to test/engine/exclude-re.test.js index f6f4f148..d62477c1 100644 --- a/test/js/exclude-re.test.js +++ b/test/engine/exclude-re.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; // Make `names` as files containing "foo" and return the ones changed by replacing "foo" const changed = async (names, config) => { diff --git a/test/js/fix/681.test.js b/test/engine/fix/681.test.js similarity index 96% rename from test/js/fix/681.test.js rename to test/engine/fix/681.test.js index 72c19bdd..357c981a 100644 --- a/test/js/fix/681.test.js +++ b/test/engine/fix/681.test.js @@ -1,7 +1,7 @@ // https://github.com/mathiasrw/rexreplace/issues/681 // `--halt` used the error message as exit code: Node 18 exited with 0, newer Node versions crashed with ERR_INVALID_ARG_TYPE. import {describe, test, expect} from 'bun:test'; -import {rr} from '../helper.js'; +import {rr} from '../../helper.js'; describe('#681 --halt exits with a numeric non-zero code', () => { test('no files found', async () => { diff --git a/test/js/fix/682.test.js b/test/engine/fix/682.test.js similarity index 97% rename from test/js/fix/682.test.js rename to test/engine/fix/682.test.js index 5176e780..ac6fc7db 100644 --- a/test/js/fix/682.test.js +++ b/test/engine/fix/682.test.js @@ -2,7 +2,7 @@ // `--quiet` promises "Only display errors (no other info)" but used to hide errors too (making it behave like `--quiet-total`). import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from '../helper.js'; +import {rr} from '../../helper.js'; describe('#682 --quiet still displays errors', () => { test('invalid regular expression', async () => { diff --git a/test/js/fix/683.test.js b/test/engine/fix/683.test.js similarity index 98% rename from test/js/fix/683.test.js rename to test/engine/fix/683.test.js index 66cc0cd1..aa9b6d63 100644 --- a/test/js/fix/683.test.js +++ b/test/engine/fix/683.test.js @@ -2,7 +2,7 @@ // `--exclude-re` promises to "follow same regex flags and setup as the main search" but was used as a regex with no flags (so it was always case sensitive). import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from '../helper.js'; +import {rr} from '../../helper.js'; // Make `names` as files containing "foo" and return the ones changed by replacing "foo" const changed = async (names, config) => { diff --git a/test/js/fix/699.test.js b/test/engine/fix/699.test.js similarity index 99% rename from test/js/fix/699.test.js rename to test/engine/fix/699.test.js index e22d8946..0295034d 100644 --- a/test/js/fix/699.test.js +++ b/test/engine/fix/699.test.js @@ -3,7 +3,7 @@ // These tests pin that file globs, `--exclude-glob` and piped data are used exactly as they are. import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from '../helper.js'; +import {rr} from '../../helper.js'; // Make `names` as files containing "foo" and return the ones changed by replacing "foo" const changed = async (names, config) => { diff --git a/test/js/fix/703.test.js b/test/engine/fix/703.test.js similarity index 99% rename from test/js/fix/703.test.js rename to test/engine/fix/703.test.js index ebbdfc5a..7fe6cd2d 100644 --- a/test/js/fix/703.test.js +++ b/test/engine/fix/703.test.js @@ -3,7 +3,7 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; import path from 'path'; -import {rr} from '../helper.js'; +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 diff --git a/test/js/fix/705.test.js b/test/engine/fix/705.test.js similarity index 99% rename from test/js/fix/705.test.js rename to test/engine/fix/705.test.js index 5526b0ab..b40376c5 100644 --- a/test/js/fix/705.test.js +++ b/test/engine/fix/705.test.js @@ -3,7 +3,7 @@ import {describe, test, expect, spyOn} from 'bun:test'; import fs from 'fs'; import {engine} from '../../../src/engine.ts'; -import {rr} from '../helper.js'; +import {rr} from '../../helper.js'; const content = names => names.map(name => fs.readFileSync(name, 'utf8')); diff --git a/test/js/fix/708.test.js b/test/engine/fix/708.test.js similarity index 98% rename from test/js/fix/708.test.js rename to test/engine/fix/708.test.js index 3fc6e88f..f713836d 100644 --- a/test/js/fix/708.test.js +++ b/test/engine/fix/708.test.js @@ -3,7 +3,7 @@ import {describe, test, expect, spyOn} from 'bun:test'; import fs from 'fs'; import os from 'os'; -import {rr} from '../helper.js'; +import {rr} from '../../helper.js'; const workers = Math.max(16, os.availableParallelism() * 2); diff --git a/test/js/fix/710.test.js b/test/engine/fix/710.test.js similarity index 97% rename from test/js/fix/710.test.js rename to test/engine/fix/710.test.js index 1e6a4fa0..e4aef0a6 100644 --- a/test/js/fix/710.test.js +++ b/test/engine/fix/710.test.js @@ -2,7 +2,7 @@ // There was no way to see which files a command would change without changing them. import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr, rrStatus} from '../helper.js'; +import {rr, rrStatus} from '../../helper.js'; // Note that rr() from the helper sets voidAsync per default, so the async flow is `voidAsync: false` const flows = [ diff --git a/test/js/fix/711.test.js b/test/engine/fix/711.test.js similarity index 98% rename from test/js/fix/711.test.js rename to test/engine/fix/711.test.js index 75e2edd5..864853a4 100644 --- a/test/js/fix/711.test.js +++ b/test/engine/fix/711.test.js @@ -2,7 +2,7 @@ // A list of files from another command could only be used with `xargs` or `$(…)`. import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr, rrStatus} from '../helper.js'; +import {rr, rrStatus} from '../../helper.js'; const content = name => fs.readFileSync(name, 'utf8'); diff --git a/test/js/fix/716.test.js b/test/engine/fix/716.test.js similarity index 97% rename from test/js/fix/716.test.js rename to test/engine/fix/716.test.js index ba77a265..d2adec5b 100644 --- a/test/js/fix/716.test.js +++ b/test/engine/fix/716.test.js @@ -2,7 +2,7 @@ // The search was case-insensitive by default, but the globs were case-sensitive. import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from '../helper.js'; +import {rr} from '../../helper.js'; // The names differ in more than case, so they can both be there on a file system that is case-insensitive function makeFiles() { diff --git a/test/js/fix/718.test.js b/test/engine/fix/718.test.js similarity index 99% rename from test/js/fix/718.test.js rename to test/engine/fix/718.test.js index 01b2fd72..cac7165d 100644 --- a/test/js/fix/718.test.js +++ b/test/engine/fix/718.test.js @@ -3,7 +3,7 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; import path from 'path'; -import {rr} from '../helper.js'; +import {rr} from '../../helper.js'; const content = name => fs.readFileSync(name, 'utf8'); diff --git a/test/js/fix/719.test.js b/test/engine/fix/719.test.js similarity index 98% rename from test/js/fix/719.test.js rename to test/engine/fix/719.test.js index 17fc6f7e..58220a93 100644 --- a/test/js/fix/719.test.js +++ b/test/engine/fix/719.test.js @@ -1,7 +1,7 @@ // https://github.com/mathiasrw/rexreplace/issues/719 // Data piped in together with files was not used, and nothing told about it. import {describe, test, expect} from 'bun:test'; -import {rr} from '../helper.js'; +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 expectedOutput = 'my.file\n'; diff --git a/test/js/fix/726.test.js b/test/engine/fix/726.test.js similarity index 99% rename from test/js/fix/726.test.js rename to test/engine/fix/726.test.js index 843e7147..4c1db156 100644 --- a/test/js/fix/726.test.js +++ b/test/engine/fix/726.test.js @@ -3,7 +3,7 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; import path from 'path'; -import {rr} from '../helper.js'; +import {rr} from '../../helper.js'; // Note that rr() from the helper sets voidAsync per default, so the async flow is `voidAsync: false` const flows = [ diff --git a/test/js/fix/728.test.js b/test/engine/fix/728.test.js similarity index 98% rename from test/js/fix/728.test.js rename to test/engine/fix/728.test.js index ac4359e2..8c1422bc 100644 --- a/test/js/fix/728.test.js +++ b/test/engine/fix/728.test.js @@ -2,7 +2,7 @@ // An error from a file was printed, but the exit code was 0, so a script could not see that something went wrong. import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from '../helper.js'; +import {rr} from '../../helper.js'; // Note that rr() from the helper sets voidAsync per default, so the async flow is `voidAsync: false` const flows = [ diff --git a/test/js/fix/742.test.js b/test/engine/fix/742.test.js similarity index 78% rename from test/js/fix/742.test.js rename to test/engine/fix/742.test.js index 82bff536..854e9230 100644 --- a/test/js/fix/742.test.js +++ b/test/engine/fix/742.test.js @@ -3,10 +3,7 @@ // when the run can not go on, and only the CLI acts on the process. import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import path from 'path'; -import {rr, rrStatus} from '../helper.js'; - -const cli = path.join(import.meta.dir, '../../../src/cli.ts'); +import {rr, rrStatus} from '../../helper.js'; // Make a.file and c.file with `foo`, and b.file with `bar` (nothing to replace) function makeFiles() { @@ -16,13 +13,6 @@ function makeFiles() { return {includeGlob: ['*.file']}; } -function runCli(...args) { - const {stdout, stderr, exitCode} = Bun.spawnSync([process.execPath, cli, ...args], { - stdin: 'ignore', - }); - return {out: stdout.toString(), err: stderr.toString(), exit: exitCode}; -} - describe('#742 the engine resolves with a status', () => { test('with the files searched and updated', async () => { expect(await rrStatus('o', 'x', makeFiles())).toEqual({ @@ -192,45 +182,3 @@ describe('#742 the engine part of the status', () => { expect(status.engine.execution.replacement).toBe('y'); }); }); - -describe('#742 the CLI', () => { - test('--status prints a line break and the status report with an indent of two spaces after the result', () => { - fs.writeFileSync('a.file', 'foo'); - const result = runCli('o', 'x', 'a.file', '--status'); - const status = { - files: {searched: ['a.file'], updated: ['a.file']}, - pipe: null, - warnings: [], - errors: [], - }; - expect(result).toEqual({ - out: 'a.file\n\n' + JSON.stringify(status, null, 2) + '\n', - err: '', - exit: 0, - }); - }); - - test.each(['--status-compact', '-c'])( - '%s prints the status report on one line, with no line break in front', - flag => { - fs.writeFileSync('a.file', 'foo'); - const result = runCli('o', 'x', 'a.file', flag, '--quiet-total'); - expect(result.out).toBe( - '{"files":{"searched":["a.file"],"updated":["a.file"]},"pipe":null,"warnings":[],"errors":[]}\n' - ); - } - ); - - test('sets the exit code to 1 when a file gives an error, and prints the status report', () => { - const result = runCli('o', 'x', 'nope.file', '--status-compact', '--quiet-total'); - expect(JSON.parse(result.out).errors).toEqual(['0 files found']); - expect(result.exit).toBe(1); - }); - - test('ends the process on a panic, and still prints the status report', () => { - fs.writeFileSync('a.file', 'foo'); - const result = runCli('(', 'x', 'a.file', '--status-compact', '--quiet-total'); - expect(JSON.parse(result.out).errors).toHaveLength(1); - expect(result.exit).toBe(1); - }); -}); diff --git a/test/js/halt.test.js b/test/engine/halt.test.js similarity index 67% rename from test/js/halt.test.js rename to test/engine/halt.test.js index 42bd66c8..80d65d17 100644 --- a/test/js/halt.test.js +++ b/test/engine/halt.test.js @@ -1,19 +1,6 @@ import {describe, test, expect} from 'bun:test'; -import {spawnSync} from 'child_process'; import fs from 'fs'; -import path from 'path'; -import {rr} from './helper.js'; - -// --bail is a CLI alias for --halt, so these cases run the CLI itself (like test/js/fix/691.test.js). -const cli = path.join(import.meta.dir, '../../src/cli.ts'); - -function runCli(...args) { - const {stdout, stderr, status} = spawnSync(process.execPath, [cli, ...args], { - input: '', - encoding: 'utf8', - }); - return {out: stdout, err: stderr, exit: status}; -} +import {rr} from '../helper.js'; describe('--halt', () => { // a.file can not be read, and the files are handled in the order of the list @@ -50,28 +37,6 @@ describe('--halt', () => { expect((await rr('(', 'x', {pipedData: 'foobar', halt: true})).exit).toBe(1); }); - describe('--bail alias', () => { - test('is listed in --help with --halt', () => { - const result = runCli('--help'); - expect(result.exit).toBe(0); - expect(result.out).toContain('--bail'); - expect(result.out).toMatch(/-H,\s*--halt,\s*--bail/); - }); - - test.each(['--bail', '--halt', '-H'])('%s stops on the first error like --halt', flag => { - const result = runCli('o', 'x', 'nope.file', flag); - expect(result.err).toContain('0 files found'); - expect(result.exit).toBe(1); - }); - - test('--bail is accepted and changes nothing when all goes well', () => { - fs.writeFileSync('my.file', 'foobar'); - const result = runCli('o', 'x', 'my.file', '--bail'); - expect(result.exit).toBe(0); - expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); - }); - }); - describe('combined', () => { test.each(['quiet', 'quietTotal'])('with --%s it still stops', async flag => { const result = await rr('o', 'x', { diff --git a/test/js/keep-backup.test.js b/test/engine/keep-backup.test.js similarity index 99% rename from test/js/keep-backup.test.js rename to test/engine/keep-backup.test.js index ca071e0f..3f7974c4 100644 --- a/test/js/keep-backup.test.js +++ b/test/engine/keep-backup.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; const backups = (dir = '.') => fs.readdirSync(dir).filter(name => name.endsWith('.backup')); diff --git a/test/js/literal.test.js b/test/engine/literal.test.js similarity index 99% rename from test/js/literal.test.js rename to test/engine/literal.test.js index 99d0fb4c..50b96328 100644 --- a/test/js/literal.test.js +++ b/test/engine/literal.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; describe('--literal', () => { test('searches for the pattern as plain text', async () => { diff --git a/test/js/output-match.test.js b/test/engine/output-match.test.js similarity index 99% rename from test/js/output-match.test.js rename to test/engine/output-match.test.js index f605a8cf..868907d1 100644 --- a/test/js/output-match.test.js +++ b/test/engine/output-match.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; describe('--output-match', () => { test('prints each match on its own line', async () => { diff --git a/test/js/output.test.js b/test/engine/output.test.js similarity index 99% rename from test/js/output.test.js rename to test/engine/output.test.js index d0cda2f1..07a8f315 100644 --- a/test/js/output.test.js +++ b/test/engine/output.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; describe('--output', () => { test('sends the result to stdout and leaves the file alone', async () => { diff --git a/test/js/pattern-file.test.js b/test/engine/pattern-file.test.js similarity index 100% rename from test/js/pattern-file.test.js rename to test/engine/pattern-file.test.js diff --git a/test/js/quiet-total.test.js b/test/engine/quiet-total.test.js similarity index 98% rename from test/js/quiet-total.test.js rename to test/engine/quiet-total.test.js index 6a507356..2edf9f14 100644 --- a/test/js/quiet-total.test.js +++ b/test/engine/quiet-total.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; describe('--quiet-total', () => { test('does not name the files that were changed', async () => { diff --git a/test/js/quiet.test.js b/test/engine/quiet.test.js similarity index 98% rename from test/js/quiet.test.js rename to test/engine/quiet.test.js index 8b54ab53..becfb8ea 100644 --- a/test/js/quiet.test.js +++ b/test/engine/quiet.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; describe('--quiet', () => { test('does not name the files that were changed', async () => { diff --git a/test/js/replacement-file.test.js b/test/engine/replacement-file.test.js similarity index 100% rename from test/js/replacement-file.test.js rename to test/engine/replacement-file.test.js diff --git a/test/js/replacement-js.test.js b/test/engine/replacement-js.test.js similarity index 99% rename from test/js/replacement-js.test.js rename to test/engine/replacement-js.test.js index ee819f0a..56d70a22 100644 --- a/test/js/replacement-js.test.js +++ b/test/engine/replacement-js.test.js @@ -1,7 +1,7 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; import path from 'path'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; const js = async (pattern, code, pipedData, config) => (await rr(pattern, code, {pipedData, replacementJs: true, ...config})).out; diff --git a/test/js/replacement-pipe.test.js b/test/engine/replacement-pipe.test.js similarity index 99% rename from test/js/replacement-pipe.test.js rename to test/engine/replacement-pipe.test.js index b0b8ad7d..0aa21aac 100644 --- a/test/js/replacement-pipe.test.js +++ b/test/engine/replacement-pipe.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; describe('--replacement-pipe', () => { test('uses the piped data as replacement', async () => { diff --git a/test/js/unicode.test.js b/test/engine/unicode.test.js similarity index 98% rename from test/js/unicode.test.js rename to test/engine/unicode.test.js index df24f638..5510a18c 100644 --- a/test/js/unicode.test.js +++ b/test/engine/unicode.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; describe('--unicode', () => { test('lets . match a full emoji', async () => { diff --git a/test/js/utf8.test.js b/test/engine/utf8.test.js similarity index 99% rename from test/js/utf8.test.js rename to test/engine/utf8.test.js index 1675052e..a704ee61 100644 --- a/test/js/utf8.test.js +++ b/test/engine/utf8.test.js @@ -2,7 +2,7 @@ // Emoji and other UTF-8 in every way data comes in and goes out. import {describe, test, expect} from 'bun:test'; import path from 'path'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; // rr() gives stdout as a JavaScript string. This is what real stdout (or a file) gets, so half an emoji shows up as `�`. const asUtf8 = text => Buffer.from(text).toString(); diff --git a/test/js/verbose.test.js b/test/engine/verbose.test.js similarity index 98% rename from test/js/verbose.test.js rename to test/engine/verbose.test.js index dcaf7ac3..bcb53ce0 100644 --- a/test/js/verbose.test.js +++ b/test/engine/verbose.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; describe('--verbose', () => { test('tells what is going on', async () => { diff --git a/test/js/version.test.js b/test/engine/version.test.js similarity index 85% rename from test/js/version.test.js rename to test/engine/version.test.js index 63c37ac3..0bcba3d4 100644 --- a/test/js/version.test.js +++ b/test/engine/version.test.js @@ -12,6 +12,4 @@ describe('--version', () => { expect(pkgBun.version).toBe(pkg.version + '+bun'); expect(pkgBun.dependencies.rexreplace).toBe(pkg.version); }); - - test.todo('printing the version is done by src/cli.ts and is tested in test/cli/run.sh'); }); diff --git a/test/js/void-async.test.js b/test/engine/void-async.test.js similarity index 99% rename from test/js/void-async.test.js rename to test/engine/void-async.test.js index 0542b42d..592c3eb8 100644 --- a/test/js/void-async.test.js +++ b/test/engine/void-async.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; // Note that rr() from the helper sets voidAsync per default, so the async flow is `voidAsync: false` diff --git a/test/js/void-backup.test.js b/test/engine/void-backup.test.js similarity index 98% rename from test/js/void-backup.test.js rename to test/engine/void-backup.test.js index 288677c2..3ee1ed1e 100644 --- a/test/js/void-backup.test.js +++ b/test/engine/void-backup.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; describe('--void-backup', () => { test('writes straight to the file', async () => { diff --git a/test/js/void-euro.test.js b/test/engine/void-euro.test.js similarity index 99% rename from test/js/void-euro.test.js rename to test/engine/void-euro.test.js index 7a74ecd3..c0c64234 100644 --- a/test/js/void-euro.test.js +++ b/test/engine/void-euro.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; describe('--void-euro', () => { test('per default € is an alias for $ in the pattern', async () => { diff --git a/test/js/void-global.test.js b/test/engine/void-global.test.js similarity index 98% rename from test/js/void-global.test.js rename to test/engine/void-global.test.js index 96c59ca8..5893b81b 100644 --- a/test/js/void-global.test.js +++ b/test/engine/void-global.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; describe('--void-global', () => { test('stops after the first match', async () => { diff --git a/test/js/void-ignore-case.test.js b/test/engine/void-ignore-case.test.js similarity index 98% rename from test/js/void-ignore-case.test.js rename to test/engine/void-ignore-case.test.js index d64a10ea..b8f1444f 100644 --- a/test/js/void-ignore-case.test.js +++ b/test/engine/void-ignore-case.test.js @@ -1,5 +1,5 @@ import {describe, test, expect} from 'bun:test'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; describe('--void-ignore-case', () => { test('makes the search case sensitive', async () => { diff --git a/test/js/void-multiline.test.js b/test/engine/void-multiline.test.js similarity index 98% rename from test/js/void-multiline.test.js rename to test/engine/void-multiline.test.js index 2778d7ba..7df40933 100644 --- a/test/js/void-multiline.test.js +++ b/test/engine/void-multiline.test.js @@ -1,5 +1,5 @@ import {describe, test, expect} from 'bun:test'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; describe('--void-multiline', () => { test('makes ^ match the start of the content only', async () => { diff --git a/test/js/void-section.test.js b/test/engine/void-section.test.js similarity index 99% rename from test/js/void-section.test.js rename to test/engine/void-section.test.js index 02c10999..3ba95863 100644 --- a/test/js/void-section.test.js +++ b/test/engine/void-section.test.js @@ -1,6 +1,6 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import {rr} from '../helper.js'; describe('--void-section', () => { test('per default § is an alias for \\ in the pattern', async () => { diff --git a/test/fuzzy/basics.test.js b/test/fuzzy/basics.test.js index 1187c927..008bdfcc 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, ended} from '../js/helper.js'; +import {rr, random, ended} from '../helper.js'; describe('basics', () => { test('gives the same as String.replace with the flags gim', async () => { diff --git a/test/fuzzy/crossover.test.js b/test/fuzzy/crossover.test.js index e87eca20..88b48258 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, ended} from '../js/helper.js'; +import {rr, random, ended} from '../helper.js'; const flags = [ 'verbose', diff --git a/test/fuzzy/dot-all.test.js b/test/fuzzy/dot-all.test.js index d8c15bb0..fc86ba50 100644 --- a/test/fuzzy/dot-all.test.js +++ b/test/fuzzy/dot-all.test.js @@ -1,5 +1,5 @@ import {describe, test, expect} from 'bun:test'; -import {rr, random} from '../js/helper.js'; +import {rr, random} from '../helper.js'; describe('--dot-all', () => { test('gives the same as String.replace with and without the s flag', async () => { diff --git a/test/fuzzy/encoding.test.js b/test/fuzzy/encoding.test.js index ad67345e..ed7262a8 100644 --- a/test/fuzzy/encoding.test.js +++ b/test/fuzzy/encoding.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} from '../helper.js'; describe('--encoding', () => { test('latin1 leaves all bytes around the match alone', async () => { diff --git a/test/fuzzy/exclude-glob.test.js b/test/fuzzy/exclude-glob.test.js index 1d637729..892605a2 100644 --- a/test/fuzzy/exclude-glob.test.js +++ b/test/fuzzy/exclude-glob.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} from '../helper.js'; // Make `names` as files containing "foo" and return the ones changed by replacing "foo" const changed = async (names, config) => { diff --git a/test/fuzzy/exclude-re.test.js b/test/fuzzy/exclude-re.test.js index 9b672320..b3ec0bea 100644 --- a/test/fuzzy/exclude-re.test.js +++ b/test/fuzzy/exclude-re.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} from '../helper.js'; // Make `names` as files containing "foo" and return the ones changed by replacing "foo" const changed = async (names, config) => { diff --git a/test/fuzzy/keep-backup.test.js b/test/fuzzy/keep-backup.test.js index 193190b2..2bd4936e 100644 --- a/test/fuzzy/keep-backup.test.js +++ b/test/fuzzy/keep-backup.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} from '../helper.js'; const backups = (dir = '.') => fs.readdirSync(dir).filter(name => name.endsWith('.backup')); diff --git a/test/fuzzy/literal.test.js b/test/fuzzy/literal.test.js index 7741d527..4113e6f6 100644 --- a/test/fuzzy/literal.test.js +++ b/test/fuzzy/literal.test.js @@ -1,5 +1,5 @@ import {describe, test, expect} from 'bun:test'; -import {rr, random} from '../js/helper.js'; +import {rr, random} from '../helper.js'; describe('--literal', () => { const alphabet = 'abAB01 \t\n-[]{}()*+?.,/\\^$|#\'"`%&;<>!~=:@€§æ中'; diff --git a/test/fuzzy/output-match.test.js b/test/fuzzy/output-match.test.js index 100cc12a..e9831bc7 100644 --- a/test/fuzzy/output-match.test.js +++ b/test/fuzzy/output-match.test.js @@ -1,5 +1,5 @@ import {describe, test, expect} from 'bun:test'; -import {rr, random} from '../js/helper.js'; +import {rr, random} from '../helper.js'; describe('--output-match', () => { test('prints what String.match finds', async () => { diff --git a/test/fuzzy/output.test.js b/test/fuzzy/output.test.js index 0bc81e8d..d0a76666 100644 --- a/test/fuzzy/output.test.js +++ b/test/fuzzy/output.test.js @@ -1,5 +1,5 @@ import {describe, test, expect} from 'bun:test'; -import {rr, random, ended} from '../js/helper.js'; +import {rr, random, ended} from '../helper.js'; const read = names => Promise.all(names.map(name => Bun.file(name).text())); diff --git a/test/fuzzy/replacement-js.test.js b/test/fuzzy/replacement-js.test.js index 3336891b..d3b212aa 100644 --- a/test/fuzzy/replacement-js.test.js +++ b/test/fuzzy/replacement-js.test.js @@ -1,5 +1,5 @@ import {describe, test, expect} from 'bun:test'; -import {rr, random} from '../js/helper.js'; +import {rr, random} from '../helper.js'; const js = async (pattern, code, pipedData, config) => (await rr(pattern, code, {pipedData, replacementJs: true, ...config})).out; diff --git a/test/fuzzy/replacement-pipe.test.js b/test/fuzzy/replacement-pipe.test.js index ac2df810..aa2ca815 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, ended} from '../js/helper.js'; +import {rr, random, ended} from '../helper.js'; describe('--replacement-pipe', () => { test('inserts any piped text without a $ exactly as it is', async () => { diff --git a/test/fuzzy/unicode.test.js b/test/fuzzy/unicode.test.js index 321e757e..2e6c5c88 100644 --- a/test/fuzzy/unicode.test.js +++ b/test/fuzzy/unicode.test.js @@ -1,5 +1,5 @@ import {describe, test, expect} from 'bun:test'; -import {rr, random} from '../js/helper.js'; +import {rr, random} from '../helper.js'; describe('--unicode', () => { const alphabet = ['a', 'b', ' ', '\n', 'æ', '中', '🦖', '🦕', '👍🏽', 'é']; diff --git a/test/fuzzy/void-async.test.js b/test/fuzzy/void-async.test.js index dc3061dc..7737f2e6 100644 --- a/test/fuzzy/void-async.test.js +++ b/test/fuzzy/void-async.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} from '../helper.js'; describe('--void-async', () => { test('the sync and the async flow end up with the same files', async () => { diff --git a/test/fuzzy/void-backup.test.js b/test/fuzzy/void-backup.test.js index 64a84c6c..1dae589c 100644 --- a/test/fuzzy/void-backup.test.js +++ b/test/fuzzy/void-backup.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} from '../helper.js'; describe('--void-backup', () => { test('ends up with the same file as with a backup', async () => { diff --git a/test/fuzzy/void-euro.test.js b/test/fuzzy/void-euro.test.js index 4b468f20..693afb53 100644 --- a/test/fuzzy/void-euro.test.js +++ b/test/fuzzy/void-euro.test.js @@ -1,5 +1,5 @@ import {describe, test, expect} from 'bun:test'; -import {rr, random} from '../js/helper.js'; +import {rr, random} from '../helper.js'; describe('--void-euro', () => { test('€ and $ can be swapped freely unless voided', async () => { diff --git a/test/fuzzy/void-global.test.js b/test/fuzzy/void-global.test.js index 62a159b0..acdd9ad5 100644 --- a/test/fuzzy/void-global.test.js +++ b/test/fuzzy/void-global.test.js @@ -1,5 +1,5 @@ import {describe, test, expect} from 'bun:test'; -import {rr, random} from '../js/helper.js'; +import {rr, random} from '../helper.js'; describe('--void-global', () => { test('gives the same as String.replace without the g flag', async () => { diff --git a/test/fuzzy/void-ignore-case.test.js b/test/fuzzy/void-ignore-case.test.js index 02429715..89284000 100644 --- a/test/fuzzy/void-ignore-case.test.js +++ b/test/fuzzy/void-ignore-case.test.js @@ -1,5 +1,5 @@ import {describe, test, expect} from 'bun:test'; -import {rr, random} from '../js/helper.js'; +import {rr, random} from '../helper.js'; describe('--void-ignore-case', () => { test('gives the same as String.replace with and without the i flag', async () => { diff --git a/test/fuzzy/void-multiline.test.js b/test/fuzzy/void-multiline.test.js index e5c7dace..1d0559f9 100644 --- a/test/fuzzy/void-multiline.test.js +++ b/test/fuzzy/void-multiline.test.js @@ -1,5 +1,5 @@ import {describe, test, expect} from 'bun:test'; -import {rr, random} from '../js/helper.js'; +import {rr, random} from '../helper.js'; describe('--void-multiline', () => { test('gives the same as String.replace with and without the m flag', async () => { diff --git a/test/fuzzy/void-section.test.js b/test/fuzzy/void-section.test.js index 89c8d853..9e6a0dd5 100644 --- a/test/fuzzy/void-section.test.js +++ b/test/fuzzy/void-section.test.js @@ -1,5 +1,5 @@ import {describe, test, expect} from 'bun:test'; -import {rr, random} from '../js/helper.js'; +import {rr, random} from '../helper.js'; describe('--void-section', () => { test('§ and \\ can be swapped freely unless voided', async () => { diff --git a/test/js/helper.js b/test/helper.js similarity index 85% rename from test/js/helper.js rename to test/helper.js index 4dc9779a..633a4e5b 100644 --- a/test/js/helper.js +++ b/test/helper.js @@ -1,10 +1,11 @@ // Preloaded by bunfig.toml before every test file. import {spyOn, beforeEach, afterEach} from 'bun:test'; +import {$} from 'bun'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import {engine} from '../../src/engine.ts'; -import {alias} from '../../src/alias.ts'; +import {engine} from '../src/engine.ts'; +import {alias} from '../src/alias.ts'; // Every test runs in its own empty directory const root = process.cwd(); @@ -87,6 +88,17 @@ async function run(pattern, replacement, config) { return {result, status}; } +const cliPath = path.join(import.meta.dir, '../src/cli.ts'); + +// Run the CLI with Bun in a shell, like a user would. `input` is piped in, so stdin is never a terminal. +// Gives what was sent to stdout (`out`) and stderr (`err`), and the exit code (`exit`). +export async function cli(args, input = '') { + const result = await $`${process.execPath} ${cliPath} ${args} < ${new Response(input)}` + .nothrow() + .quiet(); + return {out: result.stdout.toString(), err: result.stderr.toString(), exit: result.exitCode}; +} + // 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(''); diff --git a/test/js/fix/691.test.js b/test/js/fix/691.test.js deleted file mode 100644 index fb3cc7c0..00000000 --- a/test/js/fix/691.test.js +++ /dev/null @@ -1,83 +0,0 @@ -// https://github.com/mathiasrw/rexreplace/issues/691 -// A replacement containing a real line break, a `\'` or a lone backslash at the end crashed the CLI with a SyntaxError and a stack trace. -// The replacement is unescaped by the CLI (not the engine), so these tests run the CLI itself. -import {describe, test, expect} from 'bun:test'; -import {spawnSync} from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -const cli = path.join(import.meta.dir, '../../../src/cli.ts'); - -// 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', '--no-content-delimiter-tail', ...flags], - {input: '', encoding: 'utf8'} - ); - return {out: stdout, err: stderr, exit: status}; -} - -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: 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: expectedOutput, exit: 0}); - }); - - test('a lone backslash at the end gives a backslash', () => { - 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: 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'); - }); - - test('the replacement can not run as code', () => { - const result = replaceO("\\'+process.exit(3)//"); - expect(result).toEqual({ - out: "f'+process.exit(3)//'+process.exit(3)//bar", - err: expectedOutput, - exit: 0, - }); - }); - - test('an invalid escape sequence gives an error message and no stack trace', () => { - const result = replaceO('\\x4'); - expect(result.out).toBe(''); - expect(result.err).toBe(' ❌ Invalid escape sequence in replacement: \\x4\n\n'); - expect(result.exit).toBe(1); - expect(replaceO('\\x4', '--quiet-total')).toEqual({out: '', err: '', exit: 1}); - }); - - test.each([ - ['\\n', '\n'], - ['\\t', '\t'], - ['\\x41', 'A'], - ['\\u00e9', 'é'], - ['\\\\', '\\'], - ['\\\\n', '\\n'], - ["'", "'"], - ["\\\\'", "\\'"], - ['"', '"'], - ['`${1}`', '`${1}`'], - ])('%j still gives %j', (replacement, expected) => { - expect(replaceO(replacement)).toEqual({ - out: `f${expected}${expected}bar`, - err: expectedOutput, - exit: 0, - }); - }); -}); diff --git a/test/js/fix/700.test.js b/test/js/fix/700.test.js deleted file mode 100644 index 57568dc7..00000000 --- a/test/js/fix/700.test.js +++ /dev/null @@ -1,66 +0,0 @@ -// https://github.com/mathiasrw/rexreplace/issues/700 (also reported as #348) -// `§` is an alias for `\` but in the replacement it was converted after the escape sequences were interpreted, so `§n` gave a backslash and an `n` instead of a line break. -// The replacement is unescaped by the CLI (not the engine), so these tests run the CLI itself. -import {describe, test, expect} from 'bun:test'; -import {spawnSync} from 'child_process'; -import path from 'path'; - -const cli = path.join(import.meta.dir, '../../../src/cli.ts'); - -// Give `input` to the CLI as piped data and return what it gives back -function run(input, ...args) { - const {stdout, stderr, status} = spawnSync(process.execPath, [cli, ...args], { - input, - encoding: 'utf8', - }); - return {out: stdout, err: stderr, exit: status}; -} - -// Replace the `b` in `foobar` with the replacement -const replaceB = (replacement, ...flags) => run('foobar', 'b', replacement, ...flags); - -describe('#700 § is a full substitute for \\ in the replacement', () => { - test('the example from #348', () => { - expect(run('abc\n', '§n', 'X§n')).toEqual({out: 'abcX\n', err: '', exit: 0}); - expect(run('abc\n', '§n', 'X\\n')).toEqual({out: 'abcX\n', err: '', exit: 0}); - }); - - test.each([ - ['§n', '\n'], - ['§t', '\t'], - ['§r§n', '\r\n'], - ['§x41', 'A'], - ['§u00e9', 'é'], - ['§u{1F600}', '😀'], - ['§§', '\\'], - ['§§n', '\\n'], - ['§', '\\'], - ["§'", "'"], - ['a§\nb', 'ab'], - ['[€&§n]', '[b\n]'], - ])('%j gives %j like it does with a backslash', (replacement, expected) => { - const result = replaceB(replacement); - expect(result).toEqual({out: `foo${expected}ar`, err: '', exit: 0}); - expect(replaceB(replacement.replaceAll('§', '\\'))).toEqual(result); - }); - - test('an invalid escape sequence gives the same error as with a backslash', () => { - expect(replaceB('§x4')).toEqual({ - out: '', - err: ' ❌ Invalid escape sequence in replacement: §x4\n\n', - exit: 1, - }); - }); - - test('--void-section keeps § as a plain char', () => { - expect(replaceB('§n', '--void-section').out).toBe('foo§nar'); - expect(replaceB('§', '--void-section').out).toBe('foo§ar'); - expect(replaceB('§x4', '--void-section').out).toBe('foo§x4ar'); - expect(replaceB('§\\n', '-§').out).toBe('foo§\nar'); - }); - - test('--replacement-js still gets § as a backslash in the source', () => { - expect(replaceB('"a§nb"', '--replacement-js').out).toBe('fooa\nbar'); - expect(replaceB('"a§nb"', '--replacement-js', '--void-section').out).toBe('fooa§nbar'); - }); -}); diff --git a/test/js/help.test.js b/test/js/help.test.js deleted file mode 100644 index e8c4554b..00000000 --- a/test/js/help.test.js +++ /dev/null @@ -1,5 +0,0 @@ -import {describe, test} from 'bun:test'; - -describe('--help', () => { - test.todo('is done by src/cli.ts (not the engine) and is tested in test/cli/run.sh'); -}); diff --git a/test/js/trim-pipe.test.js b/test/js/trim-pipe.test.js deleted file mode 100644 index 67b541a0..00000000 --- a/test/js/trim-pipe.test.js +++ /dev/null @@ -1,5 +0,0 @@ -import {describe, test} from 'bun:test'; - -describe('--trim-pipe', () => { - test.todo('is done by src/cli.ts (not the engine) and is tested in test/cli/run.sh'); -}); From 5a39f64cf6894079a542b6d0ca18111721d8c5e6 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 13:29:10 +1100 Subject: [PATCH 07/12] Drop the xargs example from the delimiter feature, and say how to run binary safe --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 86e7538e..477754bb 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ Files identified for potential processing are identified by: - 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 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-out` for `xargs -0` +- Pick the delimiter between names and between the content of files - See the [release note](https://github.com/mathiasrw/rexreplace/releases) for a log of changes. Descriptions are given in latest patch version. ### Quirks @@ -316,7 +316,7 @@ Files identified for potential processing are identified by: ### Limitations - RexReplace reads each file fully into memory, so working on your 8Gb log files will probably not be ideal. -- Null bytes are kept as they are. Files are read as utf8 by default, so in binary data the bytes that are not valid UTF-8 become `�` when the file is changed. Use `--encoding latin1` to keep every byte. +- Files are read as utf8 by default. Null bytes are kept as they are but for binary data in general not all bytes are valid and becomes `�` if changed. Use `--encoding latin1` to run binary safe: every byte is kept, but note that because that means one byte per char search and replace only works with ASCII chars (no utf8 or emojis). - For versions of Node prior to 6, please use version 2.2.x. For versions of Node prior to 0.12, please use [the legacy version of RexReplace called rreplace](https://www.npmjs.com/package/rreplace) ### Priorities From 2eda50784878f6e7263fdf6ecc3adcfdc641ae26 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 13:31:00 +1100 Subject: [PATCH 08/12] Keep run.sh as a short demo, move the checks to CLI tests run with Bun's shell, and test two runtimes --- README.md | 15 +- devguide.md | 4 +- package.json | 3 +- test/cli/debug.test.js | 20 +- test/cli/delimiter.test.js | 168 +++++++++++ test/cli/encoding.test.js | 30 ++ test/cli/fix/691.test.js | 10 +- test/cli/fix/692.test.js | 31 -- test/cli/fix/700.test.js | 5 +- test/cli/fix/733.test.js | 5 +- test/cli/fix/736.test.js | 7 +- test/cli/fix/742.test.js | 18 +- test/cli/glob-pipe.test.js | 38 +++ test/cli/global.run.bun.sh | 2 +- test/cli/global.run.node.sh | 36 --- test/cli/halt.test.js | 25 -- test/cli/names.test.js | 78 +++++ test/cli/nl.test.js | 54 ++++ test/cli/null-bytes.test.js | 41 +++ test/cli/pipe.test.js | 71 +++++ test/cli/run.sh | 547 ++++++------------------------------ test/helper.js | 14 +- test/runtime/bun.sh | 35 +-- test/runtime/node.sh | 14 +- 24 files changed, 635 insertions(+), 636 deletions(-) create mode 100644 test/cli/delimiter.test.js create mode 100644 test/cli/encoding.test.js delete mode 100644 test/cli/fix/692.test.js create mode 100644 test/cli/glob-pipe.test.js delete mode 100644 test/cli/global.run.node.sh delete mode 100644 test/cli/halt.test.js create mode 100644 test/cli/names.test.js create mode 100644 test/cli/nl.test.js create mode 100644 test/cli/null-bytes.test.js create mode 100644 test/cli/pipe.test.js diff --git a/README.md b/README.md index 86e7538e..e90f41e6 100644 --- a/README.md +++ b/README.md @@ -343,19 +343,20 @@ rr x y myfile -o > /dev/null 0,21s user 0,04s system 86% cpu 0,294 total ### Regression -The tests come in three groups: +The tests come in four groups: -- **CLI tests** in [test/cli/run.sh](https://github.com/mathiasrw/rexreplace/blob/main/test/cli/run.sh) run the built command end to end. [test/cli/global.run.node.sh](https://github.com/mathiasrw/rexreplace/blob/main/test/cli/global.run.node.sh) installs RexReplace globally and makes a small replacement with each command. The scripts in [test/runtime](https://github.com/mathiasrw/rexreplace/tree/main/test/runtime) run them with Node and with Bun. You must have both. -- **JS tests** in [`test/js/*.test.js`](https://github.com/mathiasrw/rexreplace/tree/main/test/js) call the engine directly (no build and no CLI involved), so they are fast enough to run while developing. There is one file per flag, named after the long version of the flag. +- **The demo** in [test/cli/run.sh](https://github.com/mathiasrw/rexreplace/blob/main/test/cli/run.sh) shows how RexReplace is used, with a few examples of each feature. It is run with the installed commands: `rexreplace` with Node and `rexreplace-bun` with Bun, by the scripts in [test/runtime](https://github.com/mathiasrw/rexreplace/tree/main/test/runtime). You must have both. +- **CLI tests** in [`test/cli/*.test.js`](https://github.com/mathiasrw/rexreplace/tree/main/test/cli) run the CLI with Bun's shell, and check stdout, stderr and the exit code. This is where combinations of flags are checked. +- **Engine tests** in [`test/engine/*.test.js`](https://github.com/mathiasrw/rexreplace/tree/main/test/engine) call the engine directly (no build and no CLI involved), so they are fast enough to run while developing. There is one file per flag, named after the long version of the flag. - **Fuzzy tests** in [`test/fuzzy/*.test.js`](https://github.com/mathiasrw/rexreplace/tree/main/test/fuzzy) feed the engine random input and compare the result with what plain javascript gives. -The build and the JS and fuzzy tests are run by [bun](https://bun.sh). After `git clone`'ing the repo and `yarn install`'ing you can invoke the tests with: +The build and the CLI, engine and fuzzy tests are run by [bun](https://bun.sh). After `git clone`'ing the repo and `yarn install`'ing you can invoke the tests with: ```bash -> yarn test # build + CLI tests + JS tests + fuzzy tests -> yarn test-js # only the JS tests +> yarn test # build + demo + CLI and engine tests + fuzzy tests +> yarn test-js # only the CLI and engine tests > yarn test-fuzzy # only the fuzzy tests -> yarn test-cli # only the CLI tests (build first) +> yarn test-cli # only the demo (build first) ``` The fuzzy tests get new input on every run, so they are not part of what the CI pipeline runs (`yarn test-ci`). The seed is printed, so a failing run can be repeated: diff --git a/devguide.md b/devguide.md index 996e5df2..ce4dd7c7 100644 --- a/devguide.md +++ b/devguide.md @@ -36,10 +36,10 @@ All output goes through `src/output.ts`. Pick the function by who the message is - **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}` from `rr()`), so nothing extra slips through. Use `rrStatus()` for the status of the same run. - A known bug is a `test.todo('BUG #: ...')` until it is fixed. Then it becomes a real test. -- The folder tells what is tested, so a file is named after the feature: `test/engine/.test.js` tests the engine with `rr()`, and `test/cli/.test.js` tests the CLI with `cli()`, which runs it with Bun's `$` shell. +- The folder tells what is tested, so a file is named after the feature: `test/engine/.test.js` tests the engine with `rr()`, and `test/cli/.test.js` tests the CLI by running it with Bun's `$` shell, like ``sh($`bun ${cli} o x my.file`)``. - A test for an issue goes in `test/engine/fix/.test.js` or `test/cli/fix/.test.js`. - `test/cli/run.sh` is a demo of how RexReplace is used: a few clear examples for each feature. Checks of many combinations go in `test/cli/.test.js`. -- Use Bun native file IO (`Bun.write`, `Bun.file`) in new tests (#734), and `cli()` to run the CLI. +- Use Bun native file IO (`Bun.write`, `Bun.file`) 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. diff --git a/package.json b/package.json index d9383848..98d5548f 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,7 @@ "test-js": "AGENT=1 bun test ./test/engine ./test/cli", "test-fuzzy": "AGENT=1 bun test ./test/fuzzy", "test-minify": "AGENT=1 yarn build-minify && yarn test-cli && yarn test-js", - "test-cli": "yarn test-cli-global && yarn test-cli-node && yarn test-cli-bun", - "test-cli-global": "AGENT=1 bash test/cli/global.run.node.sh", + "test-cli": "yarn test-cli-node && yarn test-cli-bun", "test-cli-global-bun": "AGENT=1 bash test/cli/global.run.bun.sh", "test-cli-node": "AGENT=1 bash test/runtime/node.sh", "test-cli-bun": "AGENT=1 bash test/runtime/bun.sh", diff --git a/test/cli/debug.test.js b/test/cli/debug.test.js index 5cc5bbaf..36950668 100644 --- a/test/cli/debug.test.js +++ b/test/cli/debug.test.js @@ -1,13 +1,13 @@ import {describe, test, expect} from 'bun:test'; -import fs from 'fs'; -import {cli} from '../helper.js'; +import {$} from 'bun'; +import {cli, sh} from '../helper.js'; describe('--debug', () => { test.each(['--debug', '-d'])( '%s prints only the status report, with what went into the engine and what it did', async flag => { - fs.writeFileSync('my.file', 'foobar'); - const result = await cli(['o', 'x', 'my.file', flag]); + await Bun.write('my.file', 'foobar'); + const result = await sh($`bun ${cli} o x my.file ${flag}`); expect(result.err).toBe(''); expect(result.exit).toBe(0); expect(result.out.startsWith('\n{')).toBe(true); @@ -26,27 +26,27 @@ describe('--debug', () => { 'Work on content from: my.file', 'Write new content to: my.file', ]); - expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); + expect(await Bun.file('my.file').text()).toBe('fxxbar'); } ); test('still prints the result of --output before the status report', async () => { - fs.writeFileSync('my.file', 'foobar'); - const result = await cli(['o', 'x', 'my.file', '--debug', '--output']); + await Bun.write('my.file', 'foobar'); + const result = await sh($`bun ${cli} o x my.file --debug --output`); expect(result.out.startsWith('fxxbar\n\n{')).toBe(true); expect(JSON.parse(result.out.slice('fxxbar\n'.length)).files.updated).toEqual(['my.file']); }); test('gives the errors in the status report, and the exit code 1', async () => { - const result = await cli(['o', 'x', 'nope.file', '--debug']); + const result = await sh($`bun ${cli} o x nope.file --debug`); expect(result.err).toBe(''); expect(result.exit).toBe(1); expect(JSON.parse(result.out).errors).toEqual(['0 files found']); }); test('gives the error of an invalid pattern in the status report', async () => { - fs.writeFileSync('my.file', 'foobar'); - const result = await cli(['(', 'x', 'my.file', '--debug']); + await Bun.write('my.file', 'foobar'); + const result = await sh($`bun ${cli} '(' x my.file --debug`); expect(result.err).toBe(''); expect(result.exit).toBe(1); expect(JSON.parse(result.out).errors[0]).toContain('Invalid regular expression'); diff --git a/test/cli/delimiter.test.js b/test/cli/delimiter.test.js new file mode 100644 index 00000000..fa272a03 --- /dev/null +++ b/test/cli/delimiter.test.js @@ -0,0 +1,168 @@ +// The delimiters after the names and after the content, as given on the command line +import {describe, test, expect} from 'bun:test'; +import {$} from 'bun'; +import {cli, sh} from '../helper.js'; + +async function makeFiles() { + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); +} + +describe('--name-delimiter', () => { + test('goes after each name', async () => { + await makeFiles(); + const result = await sh($`bun ${cli} b x my.file your.file -A --name-delimiter ', '`); + expect(result.out).toBe('my.file, your.file, '); + }); + + test('understands escape sequences and the € and § aliases', async () => { + await makeFiles(); + const delimiter = '\\t§t€'; + const result = await sh($`bun ${cli} b x my.file your.file -A --name-delimiter ${delimiter}`); + expect(result.out).toBe('my.file\t\t$your.file\t\t$'); + }); + + test('keeps € and § with --void-euro and --void-section', async () => { + await makeFiles(); + const delimiter = '§t€'; + const result = await sh( + $`bun ${cli} b x my.file your.file -A --name-delimiter ${delimiter} --no-name-delimiter-tail --void-section --void-euro` + ); + expect(result.out).toBe('my.file§t€your.file'); + }); + + test('is only put between the names with --no-name-delimiter-tail or --name-delimiter-tail=false', async () => { + await makeFiles(); + const noTail = await sh( + $`bun ${cli} b x my.file your.file -A --name-delimiter ', ' --no-name-delimiter-tail` + ); + expect(noTail.out).toBe('my.file, your.file'); + await makeFiles(); + const tailFalse = await sh( + $`bun ${cli} b x my.file your.file -A --name-delimiter ', ' --name-delimiter-tail=false` + ); + expect(tailFalse.out).toBe('my.file, your.file'); + }); + + test('with no tail and one name gives just the name', async () => { + await makeFiles(); + const result = await sh( + $`bun ${cli} b x my.file --name-delimiter ', ' --no-name-delimiter-tail` + ); + expect(result.out).toBe('my.file'); + }); +}); + +describe('the content delimiter', () => { + test('is a line break by default, unless the content already ends with one', async () => { + await makeFiles(); + expect((await sh($`bun ${cli} b x my.file your.file -o`)).out).toBe('fooxar\naxc123\n'); + await Bun.write('a.file', 'foo'); + await Bun.write('b.file', 'bar'); + expect((await sh($`bun ${cli} o x a.file b.file -o`)).out).toBe('fxx\nbar\n'); + expect((await sh($`bun ${cli} '[ab]' _ a.file b.file -m`)).out).toBe('b\na\n'); + }); + + test('is not put after piped data, unless asked for', async () => { + expect((await sh($`echo -n foo | bun ${cli} o x`)).out).toBe('fxx'); + expect((await sh($`echo -n foo | bun ${cli} o x --content-delimiter '|'`)).out).toBe('fxx'); + const tail = await sh( + $`echo -n foo | bun ${cli} o x --content-delimiter '|' --content-delimiter-tail` + ); + expect(tail.out).toBe('fxx|'); + }); + + test('is used as it is when given', async () => { + await makeFiles(); + expect((await sh($`bun ${cli} b x my.file your.file -o --delimiter ''`)).out).toBe( + 'fooxar\naxc123\n' + ); + const delimiter = '§t-\\n'; + expect( + (await sh($`bun ${cli} b x my.file your.file -o --content-delimiter ${delimiter}`)).out + ).toBe('fooxar\n\t-\naxc123\n\t-\n'); + const noTail = await sh( + $`bun ${cli} b x my.file your.file -o --content-delimiter '|' --no-content-delimiter-tail` + ); + expect(noTail.out).toBe('fooxar\n|axc123\n'); + }); + + test('goes after each match with --output-match, also with no tail', async () => { + await makeFiles(); + expect((await sh($`bun ${cli} '[bc]' _ my.file your.file -m --delimiter ','`)).out).toBe( + 'b,b,c,' + ); + expect( + (await sh($`bun ${cli} '[bc]' _ my.file your.file -m --delimiter ',' --no-tail`)).out + ).toBe('b,b,c'); + }); +}); + +describe('--delimiter, --null-out and --tail', () => { + test('--delimiter sets both delimiters', async () => { + await makeFiles(); + expect(await sh($`bun ${cli} b x my.file your.file -o --delimiter '|'`)).toEqual({ + out: 'fooxar\n|axc123\n|', + err: 'my.file|your.file|', + exit: 0, + }); + }); + + test('--null-out is a null byte after each name and each content', async () => { + await makeFiles(); + expect((await sh($`bun ${cli} b x my.file your.file -A --null-out`)).out).toBe( + 'my.file\0your.file\0' + ); + await makeFiles(); + expect((await sh($`bun ${cli} b x my.file your.file -o --null-out`)).out).toBe( + 'fooxar\n\0axc123\n\0' + ); + }); + + test('the most specific flag wins', async () => { + await makeFiles(); + expect((await sh($`bun ${cli} b x my.file your.file -o --null-out --delimiter ', '`)).err).toBe( + 'my.file, your.file, ' + ); + expect( + (await sh($`bun ${cli} b x my.file your.file -o --delimiter ', ' --content-delimiter '|'`)) + .out + ).toBe('fooxar\n|axc123\n|'); + expect( + (await sh($`bun ${cli} b x my.file your.file -A --name-delimiter ',' --null-out`)).out + ).toBe('my.file,your.file,'); + }); + + test('--tail sets both tails, and the specific tail flag wins', async () => { + await makeFiles(); + expect((await sh($`bun ${cli} '[bc]' _ my.file your.file -m --delimiter ',' --tail`)).out).toBe( + 'b,b,c,' + ); + expect((await sh($`bun ${cli} b x my.file your.file -o --delimiter '|' --no-tail`)).err).toBe( + 'my.file|your.file' + ); + expect((await sh($`bun ${cli} b x my.file your.file -o --delimiter '|' --tail`)).out).toBe( + 'fooxar\n|axc123\n|' + ); + expect( + ( + await sh( + $`bun ${cli} b x my.file your.file -o --delimiter '|' --no-tail --name-delimiter-tail` + ) + ).err + ).toBe('my.file|your.file|'); + }); +}); + +describe('an invalid escape sequence in a delimiter', () => { + test.each([['--name-delimiter'], ['--content-delimiter'], ['--delimiter']])( + 'in %s stops with an error and changes nothing', + async flag => { + await makeFiles(); + const result = await sh($`bun ${cli} b x my.file ${flag} '\x4'`); + expect(result.err).toContain(`Invalid escape sequence in ${flag}`); + expect(result.exit).toBe(1); + expect(await Bun.file('my.file').text()).toBe('foobar\n'); + } + ); +}); diff --git a/test/cli/encoding.test.js b/test/cli/encoding.test.js new file mode 100644 index 00000000..6455915b --- /dev/null +++ b/test/cli/encoding.test.js @@ -0,0 +1,30 @@ +// The CLI checks the encoding before stdin or any file is read (#690) +import {describe, test, expect} from 'bun:test'; +import {$} from 'bun'; +import {cli, sh} from '../helper.js'; + +const lastLine = text => text.trim().split('\n').at(-1); + +describe('--encoding', () => { + test('an unknown encoding stops the run with an error, and the file is not changed', async () => { + await Bun.write('my.file', 'foobar'); + const result = await sh($`bun ${cli} o x my.file --encoding nope`); + expect(result.exit).toBe(1); + expect(lastLine(result.err)).toBe('Unknown encoding: nope'); + expect(await Bun.file('my.file').text()).toBe('foobar'); + }); + + test('an unknown encoding also stops the run for piped data', async () => { + const result = await sh($`echo -n foo | bun ${cli} o x --encoding nope`); + expect(result.exit).toBe(1); + expect(lastLine(result.err)).toBe('Unknown encoding: nope'); + }); + + test('the name of an encoding is known in any case', async () => { + expect(await sh($`echo -n foo | bun ${cli} o x --encoding LATIN1`)).toEqual({ + out: 'fxx', + err: '', + exit: 0, + }); + }); +}); diff --git a/test/cli/fix/691.test.js b/test/cli/fix/691.test.js index 860b83f8..d554e5db 100644 --- a/test/cli/fix/691.test.js +++ b/test/cli/fix/691.test.js @@ -2,17 +2,17 @@ // A replacement containing a real line break, a `\'` or a lone backslash at the end crashed the CLI with a SyntaxError and a stack trace. // The replacement is unescaped by the CLI (not the engine), so these tests run the CLI itself. import {describe, test, expect} from 'bun:test'; -import fs from 'fs'; -import {cli} from '../../helper.js'; +import {$} from 'bun'; +import {cli, sh} from '../../helper.js'; // 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'); - return cli(['o', replacement, 'my.file', '--output', '--no-content-delimiter-tail', ...flags]); +async function replaceO(replacement, ...flags) { + await Bun.write('my.file', 'foobar'); + return sh($`bun ${cli} o ${replacement} my.file --output --no-content-delimiter-tail ${flags}`); } describe('#691 replacement that used to crash the CLI', () => { diff --git a/test/cli/fix/692.test.js b/test/cli/fix/692.test.js deleted file mode 100644 index 6437f67f..00000000 --- a/test/cli/fix/692.test.js +++ /dev/null @@ -1,31 +0,0 @@ -// https://github.com/mathiasrw/rexreplace/issues/692 -// A single argument containing -v or -h printed the version or help. The checks live in the CLI layer, so the CLI is started as a separate process. -import {describe, test, expect} from 'bun:test'; -import {cli} from '../../helper.js'; - -describe('#692 single argument', () => { - test.each(['-v', '-version', '--version'])('%s prints only the version', async arg => { - const result = await cli([arg]); - expect(result.out).toBe((await cli(['--version'])).out); - expect(result.out.trim()).toMatch(/^\S+$/); - expect(result.exit).toBe(0); - }); - - test.each(['-h', '-help', '--help'])('%s prints the help with exit code 0', async arg => { - const result = await cli([arg]); - expect(result.out).toContain('Options:'); - expect(result.err).toBe(''); - expect(result.exit).toBe(0); - }); - - // -V is verbose and -H is halt: other flags, not a request for version or help - test.each(['my-vile', '-V', 'o-ho', '-H', 'a-v', 'a-h', 'xversion', 'xhelp'])( - '%s is a usage error: help on stderr, nothing on stdout, exit code 1', - async arg => { - const result = await cli([arg]); - expect(result.out).toBe(''); - expect(result.err).toContain('Options:'); - expect(result.exit).toBe(1); - } - ); -}); diff --git a/test/cli/fix/700.test.js b/test/cli/fix/700.test.js index efaf63d0..ac06c152 100644 --- a/test/cli/fix/700.test.js +++ b/test/cli/fix/700.test.js @@ -2,10 +2,11 @@ // `§` is an alias for `\` but in the replacement it was converted after the escape sequences were interpreted, so `§n` gave a backslash and an `n` instead of a line break. // The replacement is unescaped by the CLI (not the engine), so these tests run the CLI itself. import {describe, test, expect} from 'bun:test'; -import {cli} from '../../helper.js'; +import {$} from 'bun'; +import {cli, sh} from '../../helper.js'; // Give `input` to the CLI as piped data and return what it gives back -const run = (input, ...args) => cli(args, input); +const run = (input, ...args) => sh($`bun ${cli} ${args} < ${new Response(input)}`); // Replace the `b` in `foobar` with the replacement const replaceB = (replacement, ...flags) => run('foobar', 'b', replacement, ...flags); diff --git a/test/cli/fix/733.test.js b/test/cli/fix/733.test.js index eb8ef430..0115666e 100644 --- a/test/cli/fix/733.test.js +++ b/test/cli/fix/733.test.js @@ -1,12 +1,13 @@ // https://github.com/mathiasrw/rexreplace/issues/733 // yargs turned a file glob like `2024` into a number, and fast-glob only takes strings, so RexReplace crashed. import {describe, test, expect} from 'bun:test'; -import {cli} from '../../helper.js'; +import {$} from 'bun'; +import {cli, sh} from '../../helper.js'; describe('#733 a file glob that reads as a number', () => { test.each(['2024', '1e3', '0x10'])('is kept as a string, like %s', async name => { await Bun.write(name, 'foobar'); - expect(await cli(['o', 'x', name])).toEqual({out: name + '\n', err: '', exit: 0}); + expect(await sh($`bun ${cli} o x ${name}`)).toEqual({out: name + '\n', err: '', exit: 0}); expect(await Bun.file(name).text()).toBe('fxxbar'); }); }); diff --git a/test/cli/fix/736.test.js b/test/cli/fix/736.test.js index 0d6d42ea..6c0c1bd7 100644 --- a/test/cli/fix/736.test.js +++ b/test/cli/fix/736.test.js @@ -1,11 +1,10 @@ // https://github.com/mathiasrw/rexreplace/issues/736 // stdin was read every time it was not a terminal, so a pipe that was open but never written to made the run wait forever. -// The flags that ask for the piped data are tested in test/cli/run.sh. +// The flags that ask for the piped data are tested in test/cli/pipe.test.js. import {describe, test, expect} from 'bun:test'; -import path from 'path'; +import {cli} from '../../helper.js'; -// Not cli() from the helper: the test needs a pipe that stays open, and cli() pipes in data that ends -const cli = path.resolve(import.meta.dir, '../../../src/cli.ts'); +// Bun.spawn and not sh() from the helper: the test needs a pipe that stays open, and a pipe in a shell command ends describe('#736 stdin is only read when the data is asked for', () => { test('files are changed without waiting for a pipe that is never closed', async () => { diff --git a/test/cli/fix/742.test.js b/test/cli/fix/742.test.js index 76b33482..3e3732ae 100644 --- a/test/cli/fix/742.test.js +++ b/test/cli/fix/742.test.js @@ -1,13 +1,13 @@ // https://github.com/mathiasrw/rexreplace/issues/742 // The CLI is the only part that acts on the process. It prints the status report with --status and --status-compact. import {describe, test, expect} from 'bun:test'; -import fs from 'fs'; -import {cli} from '../../helper.js'; +import {$} from 'bun'; +import {cli, sh} from '../../helper.js'; describe('#742 the CLI', () => { test('--status prints a line break and the status report with an indent of two spaces after the result', async () => { - fs.writeFileSync('a.file', 'foo'); - const result = await cli(['o', 'x', 'a.file', '--status']); + await Bun.write('a.file', 'foo'); + const result = await sh($`bun ${cli} o x a.file --status`); const status = { files: {searched: ['a.file'], updated: ['a.file']}, pipe: null, @@ -24,8 +24,8 @@ describe('#742 the CLI', () => { test.each(['--status-compact', '-c'])( '%s prints the status report on one line, with no line break in front', async flag => { - fs.writeFileSync('a.file', 'foo'); - const result = await cli(['o', 'x', 'a.file', flag, '--quiet-total']); + await Bun.write('a.file', 'foo'); + const result = await sh($`bun ${cli} o x a.file ${flag} --quiet-total`); expect(result.out).toBe( '{"files":{"searched":["a.file"],"updated":["a.file"]},"pipe":null,"warnings":[],"errors":[]}\n' ); @@ -33,14 +33,14 @@ describe('#742 the CLI', () => { ); test('sets the exit code to 1 when a file gives an error, and prints the status report', async () => { - const result = await cli(['o', 'x', 'nope.file', '--status-compact', '--quiet-total']); + const result = await sh($`bun ${cli} o x nope.file --status-compact --quiet-total`); expect(JSON.parse(result.out).errors).toEqual(['0 files found']); expect(result.exit).toBe(1); }); test('ends the process on a panic, and still prints the status report', async () => { - fs.writeFileSync('a.file', 'foo'); - const result = await cli(['(', 'x', 'a.file', '--status-compact', '--quiet-total']); + await Bun.write('a.file', 'foo'); + const result = await sh($`bun ${cli} '(' x a.file --status-compact --quiet-total`); expect(JSON.parse(result.out).errors).toHaveLength(1); expect(result.exit).toBe(1); }); diff --git a/test/cli/glob-pipe.test.js b/test/cli/glob-pipe.test.js new file mode 100644 index 00000000..419c5592 --- /dev/null +++ b/test/cli/glob-pipe.test.js @@ -0,0 +1,38 @@ +// --glob-pipe (-g): each line of the piped data is a path or a glob of the files to work on (#711) +import {describe, test, expect} from 'bun:test'; +import {$} from 'bun'; +import {cli, sh} from '../helper.js'; + +async function makeFiles() { + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); +} + +describe('--glob-pipe', () => { + test('a piped line can be a glob', async () => { + await makeFiles(); + expect(await sh($`echo -n '*.file' | bun ${cli} b x -g -A`)).toEqual({ + out: 'my.file\nyour.file\n', + err: '', + exit: 0, + }); + }); + + test.each([['--replacement-pipe'], ['--pipe'], ['--js-pipe']])( + 'can not be used with %s, as the piped data can only be one thing', + async flag => { + await makeFiles(); + const result = await sh($`echo -n my.file | bun ${cli} b x --glob-pipe ${flag}`); + expect(result.err).toContain('are mutually exclusive'); + expect(result.exit).toBe(1); + expect(await Bun.file('my.file').text()).toBe('foobar\n'); + } + ); + + test('--null-in can only be used with --glob-pipe', async () => { + await makeFiles(); + const result = await sh($`echo -n my.file | bun ${cli} b x --null-in`); + expect(result.exit).toBe(1); + expect(await Bun.file('my.file').text()).toBe('foobar\n'); + }); +}); diff --git a/test/cli/global.run.bun.sh b/test/cli/global.run.bun.sh index 990683a5..e6782536 100644 --- a/test/cli/global.run.bun.sh +++ b/test/cli/global.run.bun.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Run this after a release. It installs the newly published rexreplace-bun globally with Bun, and that installs the published rexreplace it depends on. -# It removes the global rexreplace. Run global.run.node.sh to get that back. +# It removes the global rexreplace. Run `npm install -g rexreplace` to get that back. DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" diff --git a/test/cli/global.run.node.sh b/test/cli/global.run.node.sh deleted file mode 100644 index bc37a5ae..00000000 --- a/test/cli/global.run.node.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" - -source $DIR/aserta.sh - - -cd "$(dirname "${BASH_SOURCE[0]}")/../.." - -bun uninstall -g rexreplace rexreplace-bun -npm uninstall --location=global rexreplace rexreplace-bun - -npm install --location=global . - -assert_matches "rr --version" "(dev|[0-9])$" -echo 'foobar' > my.file -rr o x my.file -assert "cat my.file" "fxxbar" - - -assert_matches "rexreplace --version" "(dev|[0-9])$" -echo 'foobar' > my.file -rexreplace o x my.file -assert "cat my.file" "fxxbar" - - -assert_matches "rexreplace-bun --version" "(dev|[0-9])\+bun$" -echo 'foobar' > my.file -rexreplace-bun o x my.file -assert "cat my.file" "fxxbar" - - -rm my.file - - -assert_end "rexreplace global cli" diff --git a/test/cli/halt.test.js b/test/cli/halt.test.js deleted file mode 100644 index 3b68b975..00000000 --- a/test/cli/halt.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import {describe, test, expect} from 'bun:test'; -import fs from 'fs'; -import {cli} from '../helper.js'; - -describe('--halt', () => { - describe('--bail alias', () => { - test('is listed in --help with --halt', async () => { - const result = await cli(['--help']); - expect(result.exit).toBe(0); - expect(result.out).toMatch(/-H,\s*--halt,\s*--bail/); - }); - - test.each(['--bail', '--halt', '-H'])('%s stops on the first error like --halt', async flag => { - const result = await cli(['o', 'x', 'nope.file', flag]); - expect(result.err).toContain('0 files found'); - expect(result.exit).toBe(1); - }); - - test('--bail is accepted and changes nothing when all goes well', async () => { - fs.writeFileSync('my.file', 'foobar'); - expect((await cli(['o', 'x', 'my.file', '--bail'])).exit).toBe(0); - expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); - }); - }); -}); diff --git a/test/cli/names.test.js b/test/cli/names.test.js new file mode 100644 index 00000000..ad297ba2 --- /dev/null +++ b/test/cli/names.test.js @@ -0,0 +1,78 @@ +// The names of the changed files are the result on stdout. With --output and --output-match stdout has the content, so the names go to stderr. +import {describe, test, expect} from 'bun:test'; +import {$} from 'bun'; +import {cli, sh} from '../helper.js'; + +async function makeFiles() { + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); +} + +describe('the names of the changed files', () => { + test('go to stdout, and nothing goes to stderr', async () => { + await makeFiles(); + expect(await sh($`bun ${cli} b x my.file your.file -A`)).toEqual({ + out: 'my.file\nyour.file\n', + err: '', + exit: 0, + }); + }); + + test('stay alone on stdout with --verbose', async () => { + await makeFiles(); + const result = await sh($`bun ${cli} b x my.file --verbose`); + expect(result.out).toBe('my.file\n'); + expect(result.err.split('\n')).not.toContain('my.file'); + }); + + test('are not printed with --quiet, but the file is changed', async () => { + await makeFiles(); + expect(await sh($`bun ${cli} b x my.file -q`)).toEqual({out: '', err: '', exit: 0}); + expect(await Bun.file('my.file').text()).toBe('fooxar\n'); + }); + + describe('go to stderr when stdout has the content', () => { + test('with --output', async () => { + await makeFiles(); + expect(await sh($`bun ${cli} b x my.file -o`)).toEqual({ + out: 'fooxar\n', + err: 'my.file\n', + exit: 0, + }); + expect(await Bun.file('my.file').text()).toBe('foobar\n'); + }); + + test('with --output-match', async () => { + await makeFiles(); + expect(await sh($`bun ${cli} b _ my.file -m`)).toEqual({ + out: 'b\n', + err: 'my.file\n', + exit: 0, + }); + }); + + test('and are not printed with --quiet', async () => { + await makeFiles(); + expect((await sh($`bun ${cli} b x my.file -o -q`)).err).toBe(''); + expect((await sh($`bun ${cli} b _ my.file -m -q`)).err).toBe(''); + }); + + test('only for a file where something would change', async () => { + await makeFiles(); + expect((await sh($`bun ${cli} z x my.file -o`)).err).toBe(''); + }); + + test('with --name-delimiter', async () => { + await makeFiles(); + const result = await sh($`bun ${cli} b x my.file your.file -o --name-delimiter ', '`); + expect(result.err).toBe('my.file, your.file, '); + }); + + test('with --name-all also for the files where nothing would change', async () => { + await makeFiles(); + expect((await sh($`bun ${cli} b x my.file your.file -o --name-all`)).err).toBe( + 'my.file\nyour.file\n' + ); + }); + }); +}); diff --git a/test/cli/nl.test.js b/test/cli/nl.test.js new file mode 100644 index 00000000..0abce2bd --- /dev/null +++ b/test/cli/nl.test.js @@ -0,0 +1,54 @@ +// --nl (-l) ends stdout and stderr with a line break, unless they already end with one +import {describe, test, expect} from 'bun:test'; +import {$} from 'bun'; +import {cli, sh} from '../helper.js'; + +async function makeFiles() { + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); +} + +describe('--nl', () => { + test('adds a line break after the last match, also as -l', async () => { + await makeFiles(); + expect( + (await sh($`bun ${cli} '[bc]' _ my.file your.file -m --delimiter ',' --no-tail --nl`)).out + ).toBe('b,b,c\n'); + expect( + (await sh($`bun ${cli} '[bc]' _ my.file your.file -m --delimiter ',' --no-tail -l`)).out + ).toBe('b,b,c\n'); + }); + + test('comes after the tail', async () => { + await makeFiles(); + expect( + (await sh($`bun ${cli} '[bc]' _ my.file your.file -m --delimiter ',' --tail --nl`)).out + ).toBe('b,b,c,\n'); + }); + + test('adds nothing when the output already ends with a line break', async () => { + await makeFiles(); + expect((await sh($`bun ${cli} b x my.file -o --nl`)).out).toBe('fooxar\n'); + }); + + test('adds a line break after piped data', async () => { + expect((await sh($`echo -n foo | bun ${cli} o x --nl`)).out).toBe('fxx\n'); + }); + + test('adds nothing when nothing is printed', async () => { + expect((await sh($`echo -n foo | bun ${cli} nope x -m --nl`)).out).toBe(''); + }); + + test('ends the names with a line break, on stdout and on stderr', async () => { + await makeFiles(); + const stdout = await sh( + $`bun ${cli} b x my.file your.file -A --name-delimiter ',' --no-name-delimiter-tail --nl` + ); + expect(stdout.out).toBe('my.file,your.file\n'); + await makeFiles(); + const stderr = await sh( + $`bun ${cli} b x my.file your.file -o --name-delimiter ',' --no-name-delimiter-tail --nl` + ); + expect(stderr.err).toBe('my.file,your.file\n'); + }); +}); diff --git a/test/cli/null-bytes.test.js b/test/cli/null-bytes.test.js new file mode 100644 index 00000000..4baf6d9c --- /dev/null +++ b/test/cli/null-bytes.test.js @@ -0,0 +1,41 @@ +// Null bytes come out where they went in, piped in and in a file +import {describe, test, expect} from 'bun:test'; +import {$} from 'bun'; +import {cli, sh} from '../helper.js'; + +const data = 'foo\0boo\0'; + +describe('null bytes are kept', () => { + describe('in piped data', () => { + test('when replacing, also with --output', async () => { + expect((await sh($`bun ${cli} o x < ${new Response(data)}`)).out).toBe('fxx\0bxx\0'); + expect((await sh($`bun ${cli} o x --output < ${new Response(data)}`)).out).toBe('fxx\0bxx\0'); + }); + + test('with --output-match and --null-out', async () => { + expect( + (await sh($`bun ${cli} o _ --output-match --null-out < ${new Response(data)}`)).out + ).toBe('o\0o\0o\0o\0'); + }); + }); + + describe('in a file', () => { + test('when it is changed in place', async () => { + await Bun.write('null.file', data); + expect(await sh($`bun ${cli} o x null.file`)).toEqual({out: 'null.file\n', err: '', exit: 0}); + expect(await Bun.file('null.file').text()).toBe('fxx\0bxx\0'); + }); + + test('with --output, followed by the line break after the content', async () => { + await Bun.write('null.file', data); + expect((await sh($`bun ${cli} o x null.file --output`)).out).toBe('fxx\0bxx\0\n'); + }); + + test('with --output-match and --null-out', async () => { + await Bun.write('null.file', data); + expect((await sh($`bun ${cli} o _ null.file --output-match --null-out`)).out).toBe( + 'o\0o\0o\0o\0' + ); + }); + }); +}); diff --git a/test/cli/pipe.test.js b/test/cli/pipe.test.js new file mode 100644 index 00000000..a6760c52 --- /dev/null +++ b/test/cli/pipe.test.js @@ -0,0 +1,71 @@ +import {describe, test, expect} from 'bun:test'; +import {$} from 'bun'; +import {cli, sh} from '../helper.js'; + +const warning = ' 🟡 Data is piped in, but no flag tells what to do with it. It is not used.\n\n'; + +describe('--pipe and --js-pipe', () => { + test('without --pipe the code does not get the piped data', async () => { + await Bun.write('my.file', 'foobar\n'); + expect(await sh($`echo -n x | bun ${cli} foo pipe my.file -o --replacement-js`)).toEqual({ + out: 'nullbar\n', + err: 'my.file\n', + exit: 0, + }); + }); + + test('the code gets the piped data with --replacement-js --pipe', async () => { + await Bun.write('my.file', 'foobar\n'); + expect( + (await sh($`echo -n x | bun ${cli} foo pipe my.file -o --replacement-js --pipe`)).out + ).toBe('xbar\n'); + }); + + test('the code gets the piped data with -jp and --js-pipe', async () => { + await Bun.write('my.file', 'foobar\n'); + expect((await sh($`echo -n x | bun ${cli} foo pipe my.file -o -jp`)).out).toBe('xbar\n'); + expect((await sh($`echo -n x | bun ${cli} foo pipe my.file -o --js-pipe`)).out).toBe('xbar\n'); + }); + + test('--trim-pipe trims the piped data first', async () => { + await Bun.write('my.file', 'foobar\n'); + expect((await sh($`echo -n ' x ' | bun ${cli} foo pipe my.file -o --js-pipe -T`)).out).toBe( + 'xbar\n' + ); + }); +}); + +describe('the warning that piped data is not used', () => { + test('is given when data is read with --pipe, but not used', async () => { + await Bun.write('my.file', 'foobar\n'); + expect(await sh($`echo -n x | bun ${cli} o x my.file --pipe`)).toEqual({ + out: 'my.file\n', + err: warning, + exit: 0, + }); + expect(await Bun.file('my.file').text()).toBe('fxxbar\n'); + }); + + test('is not given without --pipe, as the data is not read', async () => { + await Bun.write('my.file', 'foobar\n'); + expect((await sh($`echo -n x | bun ${cli} o x my.file`)).err).toBe(''); + }); + + test('is not given with --quiet', async () => { + await Bun.write('my.file', 'foobar\n'); + expect((await sh($`echo -n x | bun ${cli} o x my.file --pipe -q`)).err).toBe(''); + }); + + test('is not given when the data is used', async () => { + await Bun.write('my.file', 'foobar\n'); + expect((await sh($`echo -n x | bun ${cli} b _ my.file -R`)).err).toBe(''); + expect((await sh($`echo -n x | bun ${cli} b pipe my.file --js-pipe`)).err).toBe(''); + }); + + test('is not given when nothing, or only a line break, is piped in', async () => { + await Bun.write('my.file', 'foobar\n'); + expect((await sh($`echo -n '' | bun ${cli} o x my.file --pipe`)).err).toBe(''); + expect((await sh($`echo '' | bun ${cli} o x my.file --pipe`)).err).toBe(''); + expect((await sh($`bun ${cli} o x my.file --pipe < ${new Response('\r\n')}`)).err).toBe(''); + }); +}); diff --git a/test/cli/run.sh b/test/cli/run.sh index bba288cf..341a7172 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -1,29 +1,17 @@ #!/usr/bin/env bash +# A demo of how RexReplace is used, with a few examples of each feature. +# The checks of many combinations are in test/cli/*.test.js. + DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" source $DIR/aserta.sh -# The tests call `rexreplace`. Set RR to test another command (like `rr` or `bun run rexreplace-bun`). +# The tests call `rexreplace`. Set RR to test another command (like `rexreplace-bun`). if [ -n "$RR" ]; then rexreplace() { command $RR "$@"; } fi -# # Command exit codes -# assert_success "true" -# assert_failure "false" -# assert_raises "unknown_cmd" 127 -# -# Expected output -# assert "echo test" "test" -# assert "seq 3" "1\n2\n3" -# assert_contains "echo foobar" "oba" -# assert_startswith "echo foobar" "foo" -# assert_endswith "echo foobar" "bar" -# assert_matches "echo foobar" "^f.*r$" -# -# assert_end "example" - reset() { echo echo "$(date +'%Y-%m-%d %H:%M:%S') - Reset test data " @@ -34,601 +22,238 @@ reset() { -# Plain usage +# Plain usage: the file is changed, and its name is printed reset -rexreplace x x my.file -assert "cat my.file" "foobar" - -reset -rexreplace o x my.file +assert "rexreplace o x my.file" "my.file" assert "cat my.file" "fxxbar" reset rexreplace "b" "\n" my.file assert "cat my.file" "foo\nar" - -# Replacement with a real line break, an escaped single quote or a lone backslash at the end (#691) -reset -rexreplace "b" $'\n' my.file -assert "cat my.file" "foo\nar" - -reset -rexreplace "b" "\\'" my.file -assert "cat my.file" "foo'ar" - -reset -rexreplace "r" '\' my.file -assert "cat my.file" 'fooba\\' - - -# rr can handle a pattern and replcaement starting with '-' +# The pattern and the replacement can start with '-' reset rexreplace '^(.+)$' '- $1' my.file rexreplace '- f' '_' my.file assert "cat my.file" "_oobar" -# Piped data +# Piped data is the text to search, and the result is printed reset assert "cat my.file | rexreplace foo xxx" "xxxbar" - -# -v +# --version and --help, also as -v and -h reset -assert_success "rexreplace -version" +assert_matches "rexreplace --version" "(dev|[0-9])" assert "rexreplace -v" "$(rexreplace --version)" -assert "rexreplace --version" "$(rexreplace -version)" - -# a single argument only containing -v is not a request for the version (-V is verbose) -assert_failure "rexreplace my-vile" -assert_failure "rexreplace -V" - - -# -h -reset -assert_success "rexreplace -help" -assert_success "rexreplace -h" assert_success "rexreplace --help" - -# a single argument only containing -h is not a request for help (-H is halt) +assert_success "rexreplace -h" +# A single argument that only has -v or -h in it is not a request for the version or help +assert_failure "rexreplace my-vile" assert_failure "rexreplace o-ho" -assert_failure "rexreplace -H" - -# -H, --halt, --bail -reset -assert_success "rexreplace --help | grep -F -- '--bail' >/dev/null" -assert_raises "rexreplace o x nope.file --bail" 1 -assert_raises "rexreplace o x nope.file --halt" 1 -assert_raises "rexreplace o x nope.file -H" 1 -# --bail is accepted (not an unknown argument) and leaves files untouched when nothing fails -assert_success "rexreplace o x my.file --bail" -assert "cat my.file" "fxxbar" -# -o +# --halt (or --bail) stops at the first error reset -assert "rexreplace x x my.file --output" "foobar" +assert_raises "rexreplace o x nope.file --halt" 1 +assert_raises "rexreplace o x nope.file --bail" 1 + +# --output prints the result and leaves the file as it is reset assert "rexreplace o x my.file --output" "fxxbar" +assert "cat my.file" "foobar" -# -E -# `default` is the engine of the runtime: V8 with Node and JSC with Bun. The engine of the other runtime can not be picked. +# --engine: `default` is the engine of the runtime: V8 with Node and JSC with Bun reset -# assert "rexreplace o x my.file --output --engine RE2" "fxxbar" # RE2 depricated assert "rexreplace o x my.file --output --engine default" "fxxbar" - if [[ "$(rexreplace --version)" == *+bun ]]; then - assert "rexreplace o x my.file --output --engine JSC" "fxxbar" assert_failure "rexreplace o x my.file --output --engine V8" else - assert "rexreplace o x my.file --output --engine V8" "fxxbar" assert_failure "rexreplace o x my.file --output --engine JSC" fi -# -e -# An unknown encoding stops the run with an error, both for files and for piped data (#690) +# --encoding reset -assert_failure "rexreplace o x my.file -e nope" -assert "cat my.file" 'foobar' -assert_failure "printf foo | rexreplace o x -e nope" -assert "printf foo | rexreplace o x -e nope 2>&1 | grep -v 'exited with code' | tail -1" 'Unknown encoding: nope' -assert "printf foo | rexreplace o x -e LATIN1" 'fxx' +assert "printf foo | rexreplace o x --encoding latin1" 'fxx' +assert_failure "rexreplace o x my.file --encoding nope" -# -b +# --keep-backup reset rexreplace o x my.file --keep-backup assert "cat my.file" "fxxbar" assert "cat my.file.*" "foobar" rm my.file.* -# -I -reset -assert "rexreplace Foo xxx my.file -o" "xxxbar" +# The search is case-insensitive, unless --void-ignore-case reset +assert "rexreplace Foo xxx my.file -o" "xxxbar" assert "rexreplace Foo xxx my.file -o --void-ignore-case" "foobar" -# -G +# --void-global replaces only the first match reset assert "rexreplace o x my.file -o --void-global" "fxobar" -# -O -reset -assert "rexreplace [fb]. _ my.file --output-match" "fo\\nba" - -reset -assert "rexreplace '([fb](.))' _ my.file --output-match" "foo\\nbaa" - -# -GO +# --output-match prints the matches, or the groups if the pattern has any reset -assert "rexreplace [fb]. _ my.file --output-match --voidGlobal" "fo" - +assert "rexreplace '[fb].' _ my.file --output-match" "fo\nba" +assert "rexreplace '([fb](.))' _ my.file --output-match" "foo\nbaa" -# -s +# --dot-all lets . match a line break reset echo foobar >> my.file -assert "rexreplace ar.foo _ my.file -o --dot-all " "foob_bar" +assert "rexreplace ar.foo _ my.file -o --dot-all" "foob_bar" - - -# -M +# ^ is the start of each line, unless --void-multiline reset echo foobar >> my.file assert "rexreplace '^.' 'x' my.file -o" "xoobar\nxoobar" - -reset -echo foobar >> my.file assert "rexreplace '^.' 'x' my.file -o --void-multiline" "xoobar\nfoobar" -# back reference +# Groups in the replacement, with $ or the alias € reset assert "rexreplace '(f?(o))o(.*)' '\$3\$1\$2' my.file -o" "barfoo" - -reset assert "rexreplace '(f?(o))o(.*)' '€3€1€2' my.file -o" "barfoo" - - -# globs -reset -echo barfoo > my_file -assert "rexreplace o x my*le -o" "fxxbar\nbarfxx" -rm my_file - - -# A path that is a file is used as it is, also with glob characters in its name (#718) -reset -echo foo > 'a[1].file' -echo foo > a1.file -assert "rexreplace o x 'a[1].file'" 'a[1].file' -assert "cat a1.file" 'foo' -assert "rexreplace o y '*.file' -X 'a\\[1\\].file' -X 'my.file' -X 'your.file'" 'a1.file' -assert "cat 'a[1].file'" 'fxx' - - -# -€ -reset -assert "rexreplace '.$' '$' my.file -o" 'fooba$' - -reset -assert "rexreplace '.€' '€' my.file -o" 'fooba$' - -reset assert "rexreplace '.€' '€' my.file -o --void-euro" 'foobar' -# -§ -reset -echo foo[bar] > my.file -assert "rexreplace '[\]]' '[' my.file -o" 'foo[bar[' - - +# § is an alias for \, also in the replacement, unless --void-section reset echo foo[bar] > my.file assert "rexreplace '[§]]' '[' my.file -o" 'foo[bar[' - -reset -echo foo[bar] > my.file assert "rexreplace '[§]]' '[' my.file -o --void-section" 'foo[bar]' - -# § works like \ in the replacement too (#700) reset rexreplace 'b' '§n' my.file assert "cat my.file" "foo\nar" -reset -rexreplace 'r' '§§' my.file -assert "cat my.file" 'fooba\\' - -reset -rexreplace 'b' '§n' my.file --void-section -assert "cat my.file" 'foo§nar' - - -# -j -reset -assert "rexreplace 'foo' '2+2' my.file -o --replacement-js" '4bar' - -reset -assert "rexreplace 'foo' '2+2' my.file -o -j" '4bar' +# Globs reset -assert "rexreplace 'foo' '2+2' my.file -o --js" '4bar' - -reset -assert "rexreplace 'foo' 'var i = 2; i + 2' my.file -o --replacement-js" '4bar' +echo barfoo > my_file +assert "rexreplace o x 'my*le' -o" "fxxbar\nbarfxx" +rm my_file +# A path to a file is used as it is, also with glob characters in its name reset -#assert "rexreplace '[fb](.)' '€1.toUpperCase();' my.file -o --replacement-js" 'OoAr' - +echo foo > 'a[1].file' +echo foo > a1.file +assert "rexreplace o x 'a[1].file'" 'a[1].file' +assert "cat a1.file" 'foo' -# Access to js variables +# Globs are case-insensitive like the search reset -assert "rexreplace 'fo(o)bar' '[!!fs,!!globs,find,text.trim()].join(\":\")' my.file -o --replacement-js" 'true:true:fo(o)bar:foobar' +assert "rexreplace 'oo' 'x' '*.FILE' -X 'YOUR*'" 'my.file' +# --exclude-glob and --exclude-re reset -assert "printf foobar | rexreplace 'foobar' \"['file:'+file,'dirpath:'+dirpath,'filename:'+filename,'name:'+name,'ext:'+ext,'text:'+text].join(':')\" -o --replacement-js" 'file:❌:dirpath:❌:filename:❌:name:❌:ext:❌:text:foobar' - - +rexreplace 'b' 'x' '*.file' --exclude-glob 'y*' +assert "cat your.file" 'abc123' reset -assert "rexreplace 'foobar' \"['filename:'+filename,'name:'+name,'ext:'+ext,'text:'+text].join(':')\" my.file -o --replacement-js" 'filename:my.file:name:my:ext:.file:text:foobar' +rexreplace 'b' 'x' '*.file' --exclude-re '^y' +assert "cat my.file" 'fooxar' +assert "cat your.file" 'abc123' -reset -assert "rexreplace 'foo((b)ar)' '€1+€2' my.file -o --replacement-js" 'barb' -## Test replacement-js on multiple files +# --literal searches for the pattern as plain text reset -assert "rexreplace 'foo((b)ar)' '€1+€2' *.file -o --replacement-js" 'barb\nabc123' +assert "rexreplace '*+*' 'b' my.file -o --literal" 'foobar' -reset -assert "rexreplace 'a(.+)' '\"_replace_\"+€1' *.file -o --replacement-js" 'foob_replace_r\n_replace_bc123' -# Content manually testes -# todo: automate test of content +# --replacement-js: the code gives the replacement reset -assert "rexreplace 'foobar' '[require, fs, globs, path, pipe, pipe_, find, find_, text, text_, file, file_, file_rel, file_rel_, dirpath, dirpath_, dirpath_rel, dirpath_rel_, dirname, dirname_, filename, filename_, name, name_, ext, ext_, cwd, cwd_, now, now_, time_obj, time, time_, mtime_obj, mtime, mtime_, ctime_obj, ctime, ctime_, bytes, bytes_, size, size_, _].length' my.file -o --replacement-js" '44' +assert "rexreplace 'foo' '2+2' my.file -o --replacement-js" '4bar' +assert "rexreplace 'foo((b)ar)' '€1+€2' my.file -o --js" 'barb' +assert "rexreplace 'foobar' \"[filename, name, ext].join(':')\" my.file -o -j" 'my.file:my:.file' -# -R +# --replacement-pipe: the replacement is piped in reset assert "printf x | rexreplace 'b' _ my.file -o --replacement-pipe" 'fooxar' - -# --pipe +# --js-pipe: the code gets the piped data as `pipe` reset -assert "printf x | rexreplace 'foo' 'pipe' my.file -o --replacement-js" 'nullbar' -assert "printf x | rexreplace 'foo' 'pipe' my.file -o --replacement-js --pipe" 'xbar' -assert "printf x | rexreplace 'foo' 'pipe' my.file -o -jp" 'xbar' assert "printf x | rexreplace 'foo' 'pipe' my.file -o --js-pipe" 'xbar' -assert "printf ' x ' | rexreplace 'foo' 'pipe' my.file -o --js-pipe -T" 'xbar' - -# Warning when piped data is not used +# Data that is piped in but not used gives a warning 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 "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'" -assert_failure "printf x | rexreplace 'b' _ my.file -R 2>&1 | grep 'Data is piped in'" -assert_failure "printf x | rexreplace 'b' 'pipe' my.file --js-pipe 2>&1 | grep 'Data is piped in'" -assert_failure "printf '' | rexreplace 'o' 'x' my.file --pipe 2>&1 | grep 'Data is piped in'" -assert_failure "printf '\n' | rexreplace 'o' 'x' my.file --pipe 2>&1 | grep 'Data is piped in'" -assert_failure "printf '\r\n' | rexreplace 'o' 'x' my.file --pipe 2>&1 | grep 'Data is piped in'" +assert "printf x | rexreplace 'o' 'x' my.file --pipe 2>&1 >/dev/null | grep -c 'Data is piped in'" '1' -# Names of affected files go to stdout (#703) +# The names of the changed files go to stdout, so they can be piped on 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 -cx 'my.file'" '0' - -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' +assert "rexreplace 'b' 'x' my.file your.file -A --null-out | xargs -0 cat" 'fooxar\naxc123' -# --name-delimiter +# With --output stdout has the result, so the names go to stderr 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$' +assert "rexreplace 'b' 'x' my.file -o 2>&1 >/dev/null" 'my.file' +# --name-all also names the files where nothing was changed reset -assert "rexreplace 'b' 'x' my.file your.file -A --name-delimiter ',' --null" 'my.file,your.file,' +assert "rexreplace 'z' 'x' my.file your.file -A --name-all" 'my.file\nyour.file' -# --no-name-delimiter-tail +# --name-delimiter, with no tail after the last name reset assert "rexreplace 'b' 'x' my.file your.file -A --name-delimiter ', ' --no-name-delimiter-tail" 'my.file, your.file' +# --content-delimiter goes after the content of each file. A value that starts with - is given with = reset -assert "rexreplace 'b' 'x' my.file your.file -A --name-delimiter ', ' --name-delimiter-tail=false" 'my.file, your.file' +assert "rexreplace 'b' 'x' my.file your.file -o --content-delimiter='---\n'" 'fooxar\n---\naxc123\n---' +# --nl ends what is printed with a line break (tr shows it) reset -assert "rexreplace 'b' 'x' my.file --name-delimiter ', ' --no-name-delimiter-tail" 'my.file' +assert "printf foo | rexreplace 'o' 'x' --nl | tr '\n' '+'" 'fxx+' -reset -assert "rexreplace 'b' 'x' my.file your.file -A --name-delimiter '§t€' --no-name-delimiter-tail -§ -€" 'my.file§t€your.file' -# --name-all +# --simulate names the files that would be changed, but writes nothing reset -assert "rexreplace 'z' 'x' my.file your.file -A --name-all" 'my.file\nyour.file' +assert "rexreplace 'oo' 'x' my.file your.file --simulate" 'my.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) +# --full-path prints the full path of each name 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' +assert "rexreplace 'b' 'x' my.file --full-path" "$(pwd -P)/my.file" -# 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' - -# UTF-8 goes in and out as it is (#735) -reset -assert "printf 'a🦖b' | rexreplace '🦖' '👩‍💻'" 'a👩‍💻b' -# Without --unicode . is half an emoji, so 'x.y' does not match an emoji between x and y -assert "printf 'x🦖y' | rexreplace 'x.y' '_'" 'x🦖y' -assert "printf 'x🦖y' | rexreplace 'x.y' '_' --unicode" '_' -echo foo > '🦖 中.file' -assert "rexreplace 'o' 'x' '🦖 中.file'" '🦖 中.file' -assert "cat '🦖 中.file'" 'fxx' - - -# --glob-pipe: each line of the piped data is a path or a glob to work on (#711) +# --glob-pipe: the files to work on are piped in reset assert "printf 'my.file\nyour.file\n' | rexreplace 'b' 'x' --glob-pipe -A" 'my.file\nyour.file' -reset -assert "printf '*.file' | rexreplace 'b' 'x' -g -A" 'my.file\nyour.file' -# The names of the changed files go on to the next command, also when a name has a line break in it +# The changed files of one run go on to the next run reset assert "rexreplace 'oo' 'x' '*.file' --null-out | rexreplace 'x' 'y' --glob-pipe --null-in" 'my.file' assert "cat my.file" 'fybar' -# The piped data can only be one thing -reset -assert_raises "printf 'my.file' | rexreplace 'b' 'x' -g -R" 1 -assert_raises "printf 'my.file' | rexreplace 'b' 'x' -g --js-pipe" 1 -assert_raises "printf 'my.file' | rexreplace 'b' 'x' --null-in" 1 -assert "cat my.file" 'foobar' -# Null bytes are kept: they come out where they went in. (Bytes that are not valid UTF-8 are only kept with -e latin1.) +# Null bytes are kept reset assert "printf 'foo\0boo\0' | rexreplace 'o' 'x' | tr '\0' '|'" 'fxx|bxx|' -assert "printf 'foo\0boo\0' | rexreplace 'o' 'x' --output | tr '\0' '|'" 'fxx|bxx|' assert "printf 'foo\0boo\0' | rexreplace 'o' '_' --output-match --null-out | tr '\0' '|'" 'o|o|o|o|' -printf 'foo\0boo\0' > null.file -assert "rexreplace 'o' 'x' null.file" 'null.file' -assert "tr '\0' '|' < null.file" 'fxx|bxx|' -printf 'foo\0boo\0' > null.file -assert "rexreplace 'o' 'x' null.file --output 2>/dev/null | tr '\0' '|'" 'fxx|bxx|' -assert "rexreplace 'o' '_' null.file --output-match --null-out 2>/dev/null | tr '\0' '|'" 'o|o|o|o|' - - -# Globs are case-insensitive like the search, unless --void-ignore-case (#716) -reset -assert "rexreplace 'oo' 'x' '*.FILE' -X 'YOUR*'" 'my.file' -assert "cat my.file" 'fxbar' -reset -assert_raises "rexreplace 'oo' 'x' '*.FILE' --void-ignore-case" 1 - -# --simulate: the files that would be changed are named, but nothing is written (#710) -reset -assert "rexreplace 'oo' 'x' my.file your.file --simulate" 'my.file' -assert "cat my.file" 'foobar' -assert "rexreplace 'b' 'x' my.file -S" 'my.file' -assert "cat my.file" 'foobar' - -# --full-path -reset -assert "rexreplace 'b' 'x' my.file -f" "$(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 --full-path" "$(pwd -P)/my.file" -assert "cat my.file" 'fooxar' - - -# -L -reset -assert "rexreplace 'b' '*+*' my.file -o" 'foo*+*ar' -assert "rexreplace '*+*' 'b' my.file -o --literal" 'foobar' - - - -# -x -reset - rexreplace 'b' '*+*' my.file -assert "cat my.file" 'foo*+*ar' -assert "cat your.file" 'abc123' -reset - rexreplace 'b' '*+*' '*.file' -assert "cat my.file" 'foo*+*ar' -assert "cat your.file" 'a*+*c123' +# UTF-8 goes in and out as it is reset - rexreplace 'b' '*+*' '*.file' -x y -assert "cat my.file" 'foobar' -assert "cat your.file" 'abc123' -reset - rexreplace 'b' '*+*' '*.file' -x ^y -assert "cat my.file" 'foo*+*ar' -assert "cat your.file" 'abc123' -reset - rexreplace 'b' '*+*' '*.file' -x ^Y -assert "cat my.file" 'foo*+*ar' -assert "cat your.file" 'abc123' -reset - rexreplace 'b' '*+*' '*.file' -x ^Y --void-ignore-case -assert "cat my.file" 'foo*+*ar' -assert "cat your.file" 'a*+*c123' - - -# -X -reset - rexreplace 'b' '*+*' '*.file' -X '*.file' -assert "cat my.file" 'foobar' -assert "cat your.file" 'abc123' -reset - rexreplace 'b' '*+*' '*.file' -X 'y*' -assert "cat my.file" 'foo*+*ar' -assert "cat your.file" 'abc123' - - - - - - - - - - -# # -P -# reset -# echo '.€' > pattern.txt -# echo '€' > replacement.txt -# assert "rexreplace 'pattern.txt' 'replacement.txt' my.file -o --pattern-file --replacement-file" 'fooba$' -# rm pattern.txt -# rm replacement.txt - -# # Multiply lines in files! -# reset -# echo " . \n € " > pattern.txt -# echo " €\n " > replacement.txt -# assert "rexreplace 'pattern.txt' 'replacement.txt' my.file -o --pattern-file --replacement-file" 'fooba$' -# rm pattern.txt -# rm replacement.txt - - -# # Singe line file (with space) -# reset -# echo 'fooba r' > my.file -# echo ' .€' > pattern.txt -# echo ' €' > replacement.txt -# assert "rexreplace 'pattern.txt' 'replacement.txt' my.file -o --pattern-file --replacement-file" 'fooba $' -# rm pattern.txt -# rm replacement.txt - - - - -# Todo: test -e -# assert "rexreplace ??? ??? my.file -e" "foobar" -# reset - -# Todo: test -q -# assert "rexreplace ??? ??? my.file -q" "foobar" -# reset +assert "printf 'a🦖b' | rexreplace '🦖' '👩‍💻'" 'a👩‍💻b' +assert "printf 'x🦖y' | rexreplace 'x.y' '_' --unicode" '_' +echo foo > '🦖 中.file' +assert "rexreplace 'o' 'x' '🦖 中.file'" '🦖 中.file' -# Todo: test -Q -# assert "rexreplace ??? ??? my.file -Q" "foobar" -# reset -# Todo: test -d -# assert "rexreplace ??? ??? my.file -d" "foobar" -# reset -rm my.file -rm your.file +rm -f *.file assert_end "rexreplace cli" - - - - - - - - - - diff --git a/test/helper.js b/test/helper.js index 633a4e5b..ed2ccfd9 100644 --- a/test/helper.js +++ b/test/helper.js @@ -1,6 +1,5 @@ // Preloaded by bunfig.toml before every test file. import {spyOn, beforeEach, afterEach} from 'bun:test'; -import {$} from 'bun'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -88,14 +87,13 @@ async function run(pattern, replacement, config) { return {result, status}; } -const cliPath = path.join(import.meta.dir, '../src/cli.ts'); +// The CLI, to run with Bun in a shell command: $`bun ${cli} o x my.file` +export const cli = path.join(import.meta.dir, '../src/cli.ts'); -// Run the CLI with Bun in a shell, like a user would. `input` is piped in, so stdin is never a terminal. -// Gives what was sent to stdout (`out`) and stderr (`err`), and the exit code (`exit`). -export async function cli(args, input = '') { - const result = await $`${process.execPath} ${cliPath} ${args} < ${new Response(input)}` - .nothrow() - .quiet(); +// Run a shell command made with Bun's $, and give what was sent to stdout (`out`) and stderr (`err`), and the exit code (`exit`). +// Bun's shell turns non-ASCII text in the command into \u escapes, so give a value with `§`, `€` or emoji as ${'…'}. +export async function sh(command) { + const result = await command.nothrow().quiet(); return {out: result.stdout.toString(), err: result.stderr.toString(), exit: result.exitCode}; } diff --git a/test/runtime/bun.sh b/test/runtime/bun.sh index 7699958d..a51d53d1 100644 --- a/test/runtime/bun.sh +++ b/test/runtime/bun.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Runs the CLI tests with `bun run` on rexreplace and on rexreplace-bun +# Runs the CLI tests with the rexreplace-bun package, where all commands use Bun set -e @@ -19,34 +19,15 @@ mkdir -p ./npm/rexreplace-bun/node_modules ln -sfn ../../.. ./npm/rexreplace-bun/node_modules/rexreplace # Install in ./tmp/runtime, so the global install on the machine is not changed -npm install --location=global --prefix ./tmp/runtime/rexreplace . npm install --location=global --prefix ./tmp/runtime/rexreplace-bun ./npm/rexreplace-bun -# The commands of rexreplace -export PATH="$PWD/tmp/runtime/rexreplace/bin:$PATH" - -echo "CLI tests of rr from rexreplace (it asks for Node, so Bun starts it with Node)" -bun run rr --version -RR="bun run rr" bash test/cli/run.sh -RR="bun run rr" bash test/cli/run.ai.sh - -echo "CLI tests of rexreplace-bun from rexreplace" -bun run rexreplace-bun --version -RR="bun run rexreplace-bun" bash test/cli/run.sh -RR="bun run rexreplace-bun" bash test/cli/run.ai.sh - -# The commands of rexreplace-bun export PATH="$PWD/tmp/runtime/rexreplace-bun/bin:$PATH" -echo "CLI tests of rr from rexreplace-bun" -bun run rr --version -RR="bun run rr" bash test/cli/run.sh -RR="bun run rr" bash test/cli/run.ai.sh - -echo "CLI tests of rexreplace-bun from rexreplace-bun" -bun run rexreplace-bun --version -RR="bun run rexreplace-bun" bash test/cli/run.sh -RR="bun run rexreplace-bun" bash test/cli/run.ai.sh +# All three commands of rexreplace-bun use Bun, so the version ends with +bun +rr --version | grep -q '+bun$' +rexreplace --version | grep -q '+bun$' +rexreplace-bun --version | grep -q '+bun$' -# rexreplace-bun must use Bun. The version then ends with +bun. -bun run rr --version | grep -q '+bun$' +echo "CLI tests of rexreplace-bun (Bun)" +RR="rexreplace-bun" bash test/cli/run.sh +RR="rexreplace-bun" bash test/cli/run.ai.sh diff --git a/test/runtime/node.sh b/test/runtime/node.sh index 3b849b30..f2c8ec13 100644 --- a/test/runtime/node.sh +++ b/test/runtime/node.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash +# Runs the CLI tests with the `rexreplace` command, which uses Node + set -e cd "$(dirname "${BASH_SOURCE[0]}")/../.." @@ -16,7 +18,11 @@ npm install --location=global --prefix ./tmp/runtime/rexreplace . export PATH="$PWD/tmp/runtime/rexreplace/bin:$PATH" -echo "CLI tests of rr from rexreplace" -rr --version -RR="rr" bash test/cli/run.sh -RR="rr" bash test/cli/run.ai.sh +# The package has three commands: rr and rexreplace use Node, and rexreplace-bun uses Bun +rr --version | grep -qv '+bun$' +rexreplace --version | grep -qv '+bun$' +rexreplace-bun --version | grep -q '+bun$' + +echo "CLI tests of rexreplace (Node)" +RR="rexreplace" bash test/cli/run.sh +RR="rexreplace" bash test/cli/run.ai.sh From 8ec64ecdbd84c1350705e89a44d3d72c06dcb578 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 13:32:31 +1100 Subject: [PATCH 09/12] Say set the delimiter, not pick --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 477754bb..5d0d3b2f 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ Files identified for potential processing are identified by: - 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 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 +- Set the delimiter between names and between the content of files - See the [release note](https://github.com/mathiasrw/rexreplace/releases) for a log of changes. Descriptions are given in latest patch version. ### Quirks From 453ad1eb1213f5811be6e962682c9e22dc6fb208 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 13:40:46 +1100 Subject: [PATCH 10/12] Write the files each CLI test uses in the test itself --- test/cli/delimiter.test.js | 48 ++++++++++++++++++++++---------------- test/cli/glob-pipe.test.js | 12 ++++------ test/cli/names.test.js | 26 ++++++++++----------- test/cli/nl.test.js | 19 +++++++-------- 4 files changed, 53 insertions(+), 52 deletions(-) diff --git a/test/cli/delimiter.test.js b/test/cli/delimiter.test.js index fa272a03..4eddc724 100644 --- a/test/cli/delimiter.test.js +++ b/test/cli/delimiter.test.js @@ -3,27 +3,25 @@ import {describe, test, expect} from 'bun:test'; import {$} from 'bun'; import {cli, sh} from '../helper.js'; -async function makeFiles() { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); -} - describe('--name-delimiter', () => { test('goes after each name', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); const result = await sh($`bun ${cli} b x my.file your.file -A --name-delimiter ', '`); expect(result.out).toBe('my.file, your.file, '); }); test('understands escape sequences and the € and § aliases', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); const delimiter = '\\t§t€'; const result = await sh($`bun ${cli} b x my.file your.file -A --name-delimiter ${delimiter}`); expect(result.out).toBe('my.file\t\t$your.file\t\t$'); }); test('keeps € and § with --void-euro and --void-section', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); const delimiter = '§t€'; const result = await sh( $`bun ${cli} b x my.file your.file -A --name-delimiter ${delimiter} --no-name-delimiter-tail --void-section --void-euro` @@ -32,12 +30,14 @@ describe('--name-delimiter', () => { }); test('is only put between the names with --no-name-delimiter-tail or --name-delimiter-tail=false', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); const noTail = await sh( $`bun ${cli} b x my.file your.file -A --name-delimiter ', ' --no-name-delimiter-tail` ); expect(noTail.out).toBe('my.file, your.file'); - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); const tailFalse = await sh( $`bun ${cli} b x my.file your.file -A --name-delimiter ', ' --name-delimiter-tail=false` ); @@ -45,7 +45,7 @@ describe('--name-delimiter', () => { }); test('with no tail and one name gives just the name', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); const result = await sh( $`bun ${cli} b x my.file --name-delimiter ', ' --no-name-delimiter-tail` ); @@ -55,7 +55,8 @@ describe('--name-delimiter', () => { describe('the content delimiter', () => { test('is a line break by default, unless the content already ends with one', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); expect((await sh($`bun ${cli} b x my.file your.file -o`)).out).toBe('fooxar\naxc123\n'); await Bun.write('a.file', 'foo'); await Bun.write('b.file', 'bar'); @@ -73,7 +74,8 @@ describe('the content delimiter', () => { }); test('is used as it is when given', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); expect((await sh($`bun ${cli} b x my.file your.file -o --delimiter ''`)).out).toBe( 'fooxar\naxc123\n' ); @@ -88,7 +90,8 @@ describe('the content delimiter', () => { }); test('goes after each match with --output-match, also with no tail', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); expect((await sh($`bun ${cli} '[bc]' _ my.file your.file -m --delimiter ','`)).out).toBe( 'b,b,c,' ); @@ -100,7 +103,8 @@ describe('the content delimiter', () => { describe('--delimiter, --null-out and --tail', () => { test('--delimiter sets both delimiters', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); expect(await sh($`bun ${cli} b x my.file your.file -o --delimiter '|'`)).toEqual({ out: 'fooxar\n|axc123\n|', err: 'my.file|your.file|', @@ -109,18 +113,21 @@ describe('--delimiter, --null-out and --tail', () => { }); test('--null-out is a null byte after each name and each content', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); expect((await sh($`bun ${cli} b x my.file your.file -A --null-out`)).out).toBe( 'my.file\0your.file\0' ); - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); expect((await sh($`bun ${cli} b x my.file your.file -o --null-out`)).out).toBe( 'fooxar\n\0axc123\n\0' ); }); test('the most specific flag wins', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); expect((await sh($`bun ${cli} b x my.file your.file -o --null-out --delimiter ', '`)).err).toBe( 'my.file, your.file, ' ); @@ -134,7 +141,8 @@ describe('--delimiter, --null-out and --tail', () => { }); test('--tail sets both tails, and the specific tail flag wins', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); expect((await sh($`bun ${cli} '[bc]' _ my.file your.file -m --delimiter ',' --tail`)).out).toBe( 'b,b,c,' ); @@ -158,7 +166,7 @@ describe('an invalid escape sequence in a delimiter', () => { test.each([['--name-delimiter'], ['--content-delimiter'], ['--delimiter']])( 'in %s stops with an error and changes nothing', async flag => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); const result = await sh($`bun ${cli} b x my.file ${flag} '\x4'`); expect(result.err).toContain(`Invalid escape sequence in ${flag}`); expect(result.exit).toBe(1); diff --git a/test/cli/glob-pipe.test.js b/test/cli/glob-pipe.test.js index 419c5592..c5ea1724 100644 --- a/test/cli/glob-pipe.test.js +++ b/test/cli/glob-pipe.test.js @@ -3,14 +3,10 @@ import {describe, test, expect} from 'bun:test'; import {$} from 'bun'; import {cli, sh} from '../helper.js'; -async function makeFiles() { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); -} - describe('--glob-pipe', () => { test('a piped line can be a glob', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); expect(await sh($`echo -n '*.file' | bun ${cli} b x -g -A`)).toEqual({ out: 'my.file\nyour.file\n', err: '', @@ -21,7 +17,7 @@ describe('--glob-pipe', () => { test.each([['--replacement-pipe'], ['--pipe'], ['--js-pipe']])( 'can not be used with %s, as the piped data can only be one thing', async flag => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); const result = await sh($`echo -n my.file | bun ${cli} b x --glob-pipe ${flag}`); expect(result.err).toContain('are mutually exclusive'); expect(result.exit).toBe(1); @@ -30,7 +26,7 @@ describe('--glob-pipe', () => { ); test('--null-in can only be used with --glob-pipe', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); const result = await sh($`echo -n my.file | bun ${cli} b x --null-in`); expect(result.exit).toBe(1); expect(await Bun.file('my.file').text()).toBe('foobar\n'); diff --git a/test/cli/names.test.js b/test/cli/names.test.js index ad297ba2..3cd07d56 100644 --- a/test/cli/names.test.js +++ b/test/cli/names.test.js @@ -3,14 +3,10 @@ import {describe, test, expect} from 'bun:test'; import {$} from 'bun'; import {cli, sh} from '../helper.js'; -async function makeFiles() { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); -} - describe('the names of the changed files', () => { test('go to stdout, and nothing goes to stderr', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); expect(await sh($`bun ${cli} b x my.file your.file -A`)).toEqual({ out: 'my.file\nyour.file\n', err: '', @@ -19,21 +15,21 @@ describe('the names of the changed files', () => { }); test('stay alone on stdout with --verbose', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); const result = await sh($`bun ${cli} b x my.file --verbose`); expect(result.out).toBe('my.file\n'); expect(result.err.split('\n')).not.toContain('my.file'); }); test('are not printed with --quiet, but the file is changed', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); expect(await sh($`bun ${cli} b x my.file -q`)).toEqual({out: '', err: '', exit: 0}); expect(await Bun.file('my.file').text()).toBe('fooxar\n'); }); describe('go to stderr when stdout has the content', () => { test('with --output', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); expect(await sh($`bun ${cli} b x my.file -o`)).toEqual({ out: 'fooxar\n', err: 'my.file\n', @@ -43,7 +39,7 @@ describe('the names of the changed files', () => { }); test('with --output-match', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); expect(await sh($`bun ${cli} b _ my.file -m`)).toEqual({ out: 'b\n', err: 'my.file\n', @@ -52,24 +48,26 @@ describe('the names of the changed files', () => { }); test('and are not printed with --quiet', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); expect((await sh($`bun ${cli} b x my.file -o -q`)).err).toBe(''); expect((await sh($`bun ${cli} b _ my.file -m -q`)).err).toBe(''); }); test('only for a file where something would change', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); expect((await sh($`bun ${cli} z x my.file -o`)).err).toBe(''); }); test('with --name-delimiter', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); const result = await sh($`bun ${cli} b x my.file your.file -o --name-delimiter ', '`); expect(result.err).toBe('my.file, your.file, '); }); test('with --name-all also for the files where nothing would change', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); expect((await sh($`bun ${cli} b x my.file your.file -o --name-all`)).err).toBe( 'my.file\nyour.file\n' ); diff --git a/test/cli/nl.test.js b/test/cli/nl.test.js index 0abce2bd..0435b86f 100644 --- a/test/cli/nl.test.js +++ b/test/cli/nl.test.js @@ -3,14 +3,10 @@ import {describe, test, expect} from 'bun:test'; import {$} from 'bun'; import {cli, sh} from '../helper.js'; -async function makeFiles() { - await Bun.write('my.file', 'foobar\n'); - await Bun.write('your.file', 'abc123\n'); -} - describe('--nl', () => { test('adds a line break after the last match, also as -l', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); expect( (await sh($`bun ${cli} '[bc]' _ my.file your.file -m --delimiter ',' --no-tail --nl`)).out ).toBe('b,b,c\n'); @@ -20,14 +16,15 @@ describe('--nl', () => { }); test('comes after the tail', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); expect( (await sh($`bun ${cli} '[bc]' _ my.file your.file -m --delimiter ',' --tail --nl`)).out ).toBe('b,b,c,\n'); }); test('adds nothing when the output already ends with a line break', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); expect((await sh($`bun ${cli} b x my.file -o --nl`)).out).toBe('fooxar\n'); }); @@ -40,12 +37,14 @@ describe('--nl', () => { }); test('ends the names with a line break, on stdout and on stderr', async () => { - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); const stdout = await sh( $`bun ${cli} b x my.file your.file -A --name-delimiter ',' --no-name-delimiter-tail --nl` ); expect(stdout.out).toBe('my.file,your.file\n'); - await makeFiles(); + await Bun.write('my.file', 'foobar\n'); + await Bun.write('your.file', 'abc123\n'); const stderr = await sh( $`bun ${cli} b x my.file your.file -o --name-delimiter ',' --no-name-delimiter-tail --nl` ); From 15a5daa53cb4dc82978105412899ec514dfd3877 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 13:42:49 +1100 Subject: [PATCH 11/12] Write the files each engine test uses in the test itself --- test/engine/fix/710.test.js | 28 ++++++++++++++++------------ test/engine/fix/716.test.js | 14 ++++++-------- test/engine/fix/742.test.js | 23 ++++++++++++----------- test/engine/utf8.test.js | 19 ++++++++++--------- 4 files changed, 44 insertions(+), 40 deletions(-) diff --git a/test/engine/fix/710.test.js b/test/engine/fix/710.test.js index e4aef0a6..7e807d3f 100644 --- a/test/engine/fix/710.test.js +++ b/test/engine/fix/710.test.js @@ -10,21 +10,16 @@ const flows = [ ['--void-async', {voidAsync: true}], ]; -// Make a.file and c.file with `foo`, and b.file with `bar` (nothing to replace) -function makeFiles() { - fs.writeFileSync('a.file', 'foo'); - fs.writeFileSync('b.file', 'bar'); - fs.writeFileSync('c.file', 'foo'); - return {includeGlob: ['*.file'], simulate: true}; -} - const contents = () => ['a.file', 'b.file', 'c.file'].map(name => fs.readFileSync(name, 'utf8')); describe('#710 --simulate', () => { test.each(flows)( 'names the files that would be changed, and changes nothing, with %s', async (name, flags) => { - const result = await rr('o', 'x', {...makeFiles(), ...flags}); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + const result = await rr('o', 'x', {includeGlob: ['*.file'], simulate: true, ...flags}); expect(result.out.split('\n').sort()).toEqual(['', 'a.file', 'c.file']); expect(result.err).toBe(''); expect(result.exit).toBe(null); @@ -34,12 +29,18 @@ describe('#710 --simulate', () => { ); test('makes no backup, also with --keep-backup', async () => { - await rr('o', 'x', {...makeFiles(), keepBackup: true}); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + await rr('o', 'x', {includeGlob: ['*.file'], simulate: true, keepBackup: true}); expect(fs.readdirSync('.').sort()).toEqual(['a.file', 'b.file', 'c.file']); }); test('has the files that would be changed in the status report', async () => { - const status = await rrStatus('o', 'x', makeFiles()); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + const status = await rrStatus('o', 'x', {includeGlob: ['*.file'], simulate: true}); expect(status.files).toEqual({ searched: ['a.file', 'b.file', 'c.file'], updated: ['a.file', 'c.file'], @@ -47,7 +48,10 @@ describe('#710 --simulate', () => { }); test('with --name-all names all files, but only the changed ones are updated', async () => { - const config = {...makeFiles(), nameAll: true}; + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + const config = {includeGlob: ['*.file'], simulate: true, nameAll: true}; expect((await rr('o', 'x', config)).out).toBe('a.file\nb.file\nc.file\n'); expect((await rrStatus('o', 'x', config)).files.updated).toEqual(['a.file', 'c.file']); expect(contents()).toEqual(['foo', 'bar', 'foo']); diff --git a/test/engine/fix/716.test.js b/test/engine/fix/716.test.js index d2adec5b..f57dfe77 100644 --- a/test/engine/fix/716.test.js +++ b/test/engine/fix/716.test.js @@ -5,14 +5,10 @@ import fs from 'fs'; import {rr} from '../../helper.js'; // The names differ in more than case, so they can both be there on a file system that is case-insensitive -function makeFiles() { - fs.writeFileSync('a.file', 'foo'); - fs.writeFileSync('B.FILE', 'foo'); -} - describe('#716 the globs are case-insensitive like the search', () => { test('a glob finds a file whatever the case of its name', async () => { - makeFiles(); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('B.FILE', 'foo'); expect(await rr('o', 'x', {includeGlob: ['*.file']})).toEqual({ out: 'B.FILE\na.file\n', err: '', @@ -21,7 +17,8 @@ describe('#716 the globs are case-insensitive like the search', () => { }); test('--exclude-glob leaves out a file whatever the case of its name', async () => { - makeFiles(); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('B.FILE', 'foo'); // `*.*` finds both files also when globs are case-sensitive, so only --exclude-glob is tested const config = {includeGlob: ['*.*'], excludeGlob: ['b.*']}; expect((await rr('o', 'x', config)).out).toBe('a.file\n'); @@ -29,7 +26,8 @@ describe('#716 the globs are case-insensitive like the search', () => { }); test('--void-ignore-case makes all globs case-sensitive', async () => { - makeFiles(); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('B.FILE', 'foo'); const config = {includeGlob: ['*.file'], voidIgnoreCase: true}; expect((await rr('o', 'x', config)).out).toBe('a.file\n'); fs.writeFileSync('a.file', 'foo'); diff --git a/test/engine/fix/742.test.js b/test/engine/fix/742.test.js index 854e9230..454db424 100644 --- a/test/engine/fix/742.test.js +++ b/test/engine/fix/742.test.js @@ -5,17 +5,12 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; import {rr, rrStatus} from '../../helper.js'; -// Make a.file and c.file with `foo`, and b.file with `bar` (nothing to replace) -function makeFiles() { - fs.writeFileSync('a.file', 'foo'); - fs.writeFileSync('b.file', 'bar'); - fs.writeFileSync('c.file', 'foo'); - return {includeGlob: ['*.file']}; -} - describe('#742 the engine resolves with a status', () => { test('with the files searched and updated', async () => { - expect(await rrStatus('o', 'x', makeFiles())).toEqual({ + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + expect(await rrStatus('o', 'x', {includeGlob: ['*.file']})).toEqual({ files: {searched: ['a.file', 'b.file', 'c.file'], updated: ['a.file', 'c.file']}, pipe: null, warnings: [], @@ -24,13 +19,19 @@ describe('#742 the engine resolves with a status', () => { }); test('with the files that would be updated with --output', async () => { - const status = await rrStatus('o', 'x', {...makeFiles(), output: true}); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + const status = await rrStatus('o', 'x', {includeGlob: ['*.file'], output: true}); expect(status.files.updated).toEqual(['a.file', 'c.file']); expect(fs.readFileSync('a.file', 'utf8')).toBe('foo'); }); test('with --name-all only updates the files that are changed', async () => { - const status = await rrStatus('o', 'x', {...makeFiles(), nameAll: true}); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + fs.writeFileSync('c.file', 'foo'); + const status = await rrStatus('o', 'x', {includeGlob: ['*.file'], nameAll: true}); expect(status.files.updated).toEqual(['a.file', 'c.file']); }); diff --git a/test/engine/utf8.test.js b/test/engine/utf8.test.js index a704ee61..ec9aed00 100644 --- a/test/engine/utf8.test.js +++ b/test/engine/utf8.test.js @@ -103,27 +103,28 @@ describe('#735 UTF-8 in file names', () => { }); describe('#735 UTF-8 in the delimiters', () => { - async function makeFiles() { + test('--name-delimiter, also with no tail', async () => { await Bun.write('a.file', 'foo'); await Bun.write('b.file', 'foo'); - return {includeGlob: ['*.file']}; - } - - test('--name-delimiter, also with no tail', async () => { - const config = {...(await makeFiles()), nameDelimiter: '🦖'}; + const config = {includeGlob: ['*.file'], nameDelimiter: '🦖'}; expect(asUtf8((await rr('o', 'x', config)).out)).toBe('a.file🦖b.file🦖'); - await makeFiles(); + await Bun.write('a.file', 'foo'); + await Bun.write('b.file', 'foo'); const noTail = {...config, nameDelimiterTail: false}; expect(asUtf8((await rr('o', 'x', noTail)).out)).toBe('a.file🦖b.file'); }); test('--content-delimiter with a combined emoji', async () => { - const config = {...(await makeFiles()), output: true, contentDelimiter: '👩‍💻'}; + await Bun.write('a.file', 'foo'); + await Bun.write('b.file', 'foo'); + const config = {includeGlob: ['*.file'], output: true, contentDelimiter: '👩‍💻'}; expect(asUtf8((await rr('o', 'x', config)).out)).toBe('fxx👩‍💻fxx👩‍💻'); }); test('--delimiter sets both', async () => { - const config = {...(await makeFiles()), output: true, delimiter: '🦕'}; + await Bun.write('a.file', 'foo'); + await Bun.write('b.file', 'foo'); + const config = {includeGlob: ['*.file'], output: true, delimiter: '🦕'}; const result = await rr('o', 'x', config); expect(asUtf8(result.out)).toBe('fxx🦕fxx🦕'); expect(asUtf8(result.err)).toBe('a.file🦕b.file🦕'); From 5041ed2d1f0bff087c6e09738396b11c4e9c5d4f Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 13:44:14 +1100 Subject: [PATCH 12/12] Write the files in the test itself in the rest of the engine tests --- test/engine/fix/708.test.js | 16 ++++------ test/engine/fix/711.test.js | 33 ++++++++++++--------- test/engine/fix/718.test.js | 59 +++++++++++++++++++++---------------- 3 files changed, 59 insertions(+), 49 deletions(-) diff --git a/test/engine/fix/708.test.js b/test/engine/fix/708.test.js index f713836d..723a1db0 100644 --- a/test/engine/fix/708.test.js +++ b/test/engine/fix/708.test.js @@ -7,16 +7,10 @@ import {rr} from '../../helper.js'; const workers = Math.max(16, os.availableParallelism() * 2); -// Make `count` files with the content `foo` and give their names -async function makeFiles(count) { - const names = Array.from({length: count}, (_, i) => `file-${i}.txt`); - await Promise.all(names.map(name => Bun.write(name, 'foo'))); - return names; -} - describe('#708 the number of files in work at the same time is limited', () => { test('all files are changed when there are more files than workers', async () => { - const names = await makeFiles(workers * 3); + const names = Array.from({length: workers * 3}, (_, i) => `file-${i}.txt`); + await Promise.all(names.map(name => Bun.write(name, 'foo'))); const result = await rr('o', 'x', {includeGlob: ['*.txt'], voidAsync: false}); expect(result.err).toBe(''); expect(result.out.split('\n').sort()).toEqual(['', ...names].sort()); @@ -26,7 +20,8 @@ describe('#708 the number of files in work at the same time is limited', () => { }); test(`at most ${workers} files are read at the same time`, async () => { - await makeFiles(workers * 3); + const names = Array.from({length: workers * 3}, (_, i) => `file-${i}.txt`); + await Promise.all(names.map(name => Bun.write(name, 'foo'))); const readFile = fs.promises.readFile; let reading = 0; let mostReading = 0; @@ -50,7 +45,8 @@ describe('#708 the number of files in work at the same time is limited', () => { }); test('the number of CPU cores is not asked for with 16 files or fewer', async () => { - await makeFiles(16); + const names = Array.from({length: 16}, (_, i) => `file-${i}.txt`); + await Promise.all(names.map(name => Bun.write(name, 'foo'))); const spy = spyOn(os, 'availableParallelism'); try { await rr('o', 'x', {includeGlob: ['*.txt'], voidAsync: false}); diff --git a/test/engine/fix/711.test.js b/test/engine/fix/711.test.js index 864853a4..477206b2 100644 --- a/test/engine/fix/711.test.js +++ b/test/engine/fix/711.test.js @@ -6,52 +6,57 @@ import {rr, rrStatus} from '../../helper.js'; const content = name => fs.readFileSync(name, 'utf8'); -// Make each file with the content `foo` -function makeFiles(...names) { - names.forEach(name => fs.writeFileSync(name, 'foo')); -} - describe('#711 --glob-pipe', () => { test('each line of the piped data is a file to work on', async () => { - makeFiles('a.file', 'b.file', 'c.file'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.writeFileSync('c.file', 'foo'); const config = {pipedData: 'a.file\nb.file\n', globPipe: true}; expect(await rr('o', 'x', config)).toEqual({out: 'a.file\nb.file\n', err: '', exit: null}); expect(content('c.file')).toBe('foo'); }); test('a line can be a glob', async () => { - makeFiles('a.file', 'b.file', 'c.txt'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.writeFileSync('c.txt', 'foo'); const result = await rr('o', 'x', {pipedData: '*.file', globPipe: true}); expect(result.out).toBe('a.file\nb.file\n'); }); test('a line is used together with the paths and globs given as arguments', async () => { - makeFiles('a.file', 'b.file', 'c.txt'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.writeFileSync('c.txt', 'foo'); const config = {pipedData: 'a.file\n', includeGlob: ['*.txt'], globPipe: true}; expect((await rr('o', 'x', config)).out).toBe('a.file\nc.txt\n'); }); test('a line that is a file is used as it is, like an argument (#718)', async () => { - makeFiles('a[1].file', 'a1.file'); + fs.writeFileSync('a[1].file', 'foo'); + fs.writeFileSync('a1.file', 'foo'); const result = await rr('o', 'x', {pipedData: 'a[1].file\n', globPipe: true}); expect(result.out).toBe('a[1].file\n'); expect(content('a1.file')).toBe('foo'); }); test('a line that starts with ! takes files away from the other globs', async () => { - makeFiles('a.file', 'b.file'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); const result = await rr('o', 'x', {pipedData: '*.file\n!b.file\n', globPipe: true}); expect(result.out).toBe('a.file\n'); }); test('takes Windows line breaks, leaves out empty lines and keeps spaces in names', async () => { - makeFiles('a.file', 'my file.txt'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('my file.txt', 'foo'); const config = {pipedData: '\r\na.file\r\n\r\nmy file.txt\r\n', globPipe: true}; expect((await rr('o', 'x', config)).out).toBe('a.file\nmy file.txt\n'); }); test('with --null-in the lines are separated by null bytes', async () => { - makeFiles('a.file', 'line\nbreak.file'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('line\nbreak.file', 'foo'); const config = {pipedData: 'a.file\0line\nbreak.file\0', globPipe: true, nullIn: true}; expect((await rr('o', 'x', config)).out).toBe('a.file\nline\nbreak.file\n'); expect(content('line\nbreak.file')).toBe('fxx'); @@ -66,7 +71,7 @@ describe('#711 --glob-pipe', () => { }); test('is told about in the status report', async () => { - makeFiles('a.file'); + fs.writeFileSync('a.file', 'foo'); const status = await rrStatus('o', 'x', {pipedData: 'a.file\n', globPipe: true}); expect(status.pipe).toEqual({bytes: 7, type: 'GLOBS', updated: false}); expect(status.files).toEqual({searched: ['a.file'], updated: ['a.file']}); @@ -76,7 +81,7 @@ describe('#711 --glob-pipe', () => { ['--replacement-pipe', {replacementPipe: true}], ['--pipe', {pipe: true}], ])('can not be used with %s, as the piped data can only be one thing', async (name, flags) => { - makeFiles('a.file'); + fs.writeFileSync('a.file', 'foo'); const result = await rr('o', 'x', {pipedData: 'a.file', globPipe: true, ...flags}); expect(result.exit).toBe(1); expect(result.err).toContain('--glob-pipe can not be used with'); diff --git a/test/engine/fix/718.test.js b/test/engine/fix/718.test.js index cac7165d..4ec33705 100644 --- a/test/engine/fix/718.test.js +++ b/test/engine/fix/718.test.js @@ -7,19 +7,11 @@ import {rr} from '../../helper.js'; const content = name => fs.readFileSync(name, 'utf8'); -// Make each file with the content `foo` -function makeFiles(...names) { - names.forEach(name => { - fs.mkdirSync(path.dirname(name), {recursive: true}); - fs.writeFileSync(name, 'foo'); - }); -} - describe('#718 a path that is a file is used as it is', () => { test.each(['my (1).file', '(draft).file', '{a,b}.file', '!important.file', 'a+(b).file'])( '%s is found', async name => { - makeFiles(name); + fs.writeFileSync(name, 'foo'); expect(await rr('o', 'x', {includeGlob: [name]})).toEqual({ out: name + '\n', err: '', @@ -35,7 +27,8 @@ describe('#718 a path that is a file is used as it is', () => { ['what?.file', 'whatX.file'], ['star*.file', 'star123.file'], ])('%s does not also change %s', async (name, other) => { - makeFiles(name, other); + fs.writeFileSync(name, 'foo'); + fs.writeFileSync(other, 'foo'); expect(await rr('o', 'x', {includeGlob: [name]})).toEqual({ out: name + '\n', err: '', @@ -46,7 +39,9 @@ describe('#718 a path that is a file is used as it is', () => { }); test('in a folder', async () => { - makeFiles('pages/[id].file', 'pages/i.file'); + fs.mkdirSync('pages', {recursive: true}); + fs.writeFileSync('pages/[id].file', 'foo'); + fs.writeFileSync('pages/i.file', 'foo'); expect((await rr('o', 'x', {includeGlob: ['pages/[id].file']})).out).toBe('pages/[id].file\n'); expect(content('pages/i.file')).toBe('foo'); }); @@ -54,44 +49,51 @@ describe('#718 a path that is a file is used as it is', () => { describe('#718 together with globs', () => { test('a path that is not a file is still a glob', async () => { - makeFiles('a[1].file', 'a1.file'); + fs.writeFileSync('a[1].file', 'foo'); + fs.writeFileSync('a1.file', 'foo'); expect((await rr('o', 'x', {includeGlob: ['*.file']})).out).toBe('a1.file\na[1].file\n'); }); test('a path to a folder is not a file, so it is still a glob', async () => { - makeFiles('docs/a.file'); + fs.mkdirSync('docs', {recursive: true}); + fs.writeFileSync('docs/a.file', 'foo'); expect((await rr('o', 'x', {includeGlob: ['docs']})).exit).toBe(1); expect((await rr('o', 'x', {includeGlob: ['docs/*.file']})).out).toBe('docs/a.file\n'); }); test('the files are sorted, both those given by their name and those found by a glob', async () => { - makeFiles('a.file', 'b[1].file', 'c.file'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b[1].file', 'foo'); + fs.writeFileSync('c.file', 'foo'); const result = await rr('o', 'x', {includeGlob: ['b[1].file', 'c.file', 'a*.file']}); expect(result.out).toBe('a.file\nb[1].file\nc.file\n'); }); test('a file given by its name and also found by a glob is only handled once', async () => { - makeFiles('a.file', 'b.file'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); const result = await rr('o', 'x', {includeGlob: ['./a.file', '*.file']}); expect(result).toEqual({out: './a.file\nb.file\n', err: '', exit: null}); expect(fs.readdirSync('.').sort()).toEqual(['a.file', 'b.file']); }); test('a file given with a relative and an absolute path is only handled once', async () => { - makeFiles('a.file'); + fs.writeFileSync('a.file', 'foo'); const result = await rr('o', 'x', {includeGlob: ['a.file', path.resolve('a.file')]}); expect(result.out).toBe('a.file\n'); expect(content('a.file')).toBe('fxx'); }); test('a glob that starts with ! takes files away from the other globs', async () => { - makeFiles('a1.file', 'a2.file'); + fs.writeFileSync('a1.file', 'foo'); + fs.writeFileSync('a2.file', 'foo'); expect((await rr('o', 'x', {includeGlob: ['*.file', '!a1.file']})).out).toBe('a2.file\n'); expect(content('a1.file')).toBe('foo'); }); test('a glob that starts with ! does not take away a file given by its name', async () => { - makeFiles('a1.file', 'a2.file'); + fs.writeFileSync('a1.file', 'foo'); + fs.writeFileSync('a2.file', 'foo'); const config = {includeGlob: ['a1.file', '*.file', '!a1.file']}; expect((await rr('o', 'x', config)).out).toBe('a1.file\na2.file\n'); expect(content('a1.file')).toBe('fxx'); @@ -101,7 +103,9 @@ describe('#718 together with globs', () => { describe('#718 with --exclude-glob', () => { test('it is always a glob, also when it is the name of a file', async () => { // As a glob, `a[1].file` finds both `a[1].file` and `a1.file` - makeFiles('a[1].file', 'a1.file', 'b.file'); + fs.writeFileSync('a[1].file', 'foo'); + fs.writeFileSync('a1.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); const config = { includeGlob: ['a[1].file', 'a1.file', 'b.file'], excludeGlob: ['a[1].file'], @@ -112,28 +116,32 @@ describe('#718 with --exclude-glob', () => { }); test('a name with glob characters is left out when they are escaped', async () => { - makeFiles('a[1].file', 'a1.file'); + fs.writeFileSync('a[1].file', 'foo'); + fs.writeFileSync('a1.file', 'foo'); const config = {includeGlob: ['*.file'], excludeGlob: ['a\\[1\\].file']}; expect((await rr('o', 'x', config)).out).toBe('a1.file\n'); expect(content('a[1].file')).toBe('foo'); }); test('all of them are globbed together, so a glob that starts with ! keeps a file', async () => { - makeFiles('a.file', 'b.file'); + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); const config = {includeGlob: ['*.file'], excludeGlob: ['*.file', '!a.file']}; expect((await rr('o', 'x', config)).out).toBe('a.file\n'); expect(content('b.file')).toBe('foo'); }); test('a glob leaves out a file given by its name', async () => { - makeFiles('a[1].file'); + fs.writeFileSync('a[1].file', 'foo'); const config = {includeGlob: ['a[1].file'], excludeGlob: ['*.file']}; expect(await rr('o', 'x', config)).toEqual({out: '', err: '0 files found\n', exit: 1}); expect(content('a[1].file')).toBe('foo'); }); test('a glob leaves out a file given with another path to it', async () => { - makeFiles('docs/a.file', 'docs/b.file'); + fs.mkdirSync('docs', {recursive: true}); + fs.writeFileSync('docs/a.file', 'foo'); + fs.writeFileSync('docs/b.file', 'foo'); const config = {includeGlob: ['./docs/a.file', 'docs/b.file'], excludeGlob: ['docs/a*']}; expect((await rr('o', 'x', config)).out).toBe('docs/b.file\n'); expect(content('docs/a.file')).toBe('foo'); @@ -142,14 +150,15 @@ describe('#718 with --exclude-glob', () => { describe('#718 with --exclude-re', () => { test('it also leaves out a file given by its name', async () => { - makeFiles('a[1].file', 'a1.file'); + fs.writeFileSync('a[1].file', 'foo'); + fs.writeFileSync('a1.file', 'foo'); const config = {includeGlob: ['a[1].file', 'a1.file'], excludeRe: ['\\[']}; expect((await rr('o', 'x', config)).out).toBe('a1.file\n'); expect(content('a[1].file')).toBe('foo'); }); test('it sees the full path with --full-path', async () => { - makeFiles('a.file'); + fs.writeFileSync('a.file', 'foo'); expect((await rr('o', 'x', {includeGlob: ['a.file'], excludeRe: ['^/']})).out).toBe('a.file\n'); fs.writeFileSync('a.file', 'foo'); const config = {includeGlob: ['a.file'], excludeRe: ['^/'], fullPath: true};