From 7e04e4cf0b3d0df01a1c4940daf21703cb7e9df4 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 05:00:19 +1100 Subject: [PATCH 1/2] Let the engine return a status report and the CLI own the process to close #742 --- README.md | 5 +- devguide.md | 22 ++-- src/cli.ts | 221 ++++++++++++++++++-------------- src/engine.ts | 150 +++++++++++++--------- src/output.ts | 57 ++++----- test/cli/run.sh | 2 +- test/fuzzy/crossover.test.js | 1 - test/js/crossover.test.js | 1 - test/js/debug.test.js | 125 ++++++++---------- test/js/fix/703.test.js | 4 +- test/js/fix/705.test.js | 6 - test/js/fix/742.test.js | 238 +++++++++++++++++++++++++++++++++++ test/js/helper.js | 29 +++-- test/js/output.test.js | 4 +- test/js/quiet-total.test.js | 11 +- test/js/quiet.test.js | 17 ++- test/js/verbose.test.js | 32 ++--- test/js/void-async.test.js | 26 ++-- 18 files changed, 602 insertions(+), 349 deletions(-) create mode 100644 test/js/fix/742.test.js diff --git a/README.md b/README.md index b18f9400..5233aa0c 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,10 @@ Use --js-pipe to access the pipe data in your javascript code. | | **`--tail`** Shorthand for setting both --name-delimiter-tail and --content-delimiter-tail. Use --no-tail to not print the delimiter after the last name and the last content. The specific tail flags win over --tail. [boolean] | | `-l` | **`--nl`** Let what is printed to stdout and stderr (the names, or the content from -o, -m or piped data) always end with a new line. No extra new line if it already ends with a new line, or if nothing was printed. [boolean] | | `-H` | **`--halt`**, **`--bail`** Halt on first error [boolean] [default: false] | -| `-d` | **`--debug`** Print debug info [boolean] | +| `-S` | **`--status-compact`** As the last thing before ending any execution a status report will be printed as one line of JSON. Will print even in combination with `--quiet-total`. Will incude extra info about the inner workings of the engine in combination with the `--verbose` flag. [boolean] | +| | **`--status`** Same as `--status-compact`, but always starting with a `\n` followed by pretty printed JSON. [boolean] | +| `-d` | **`--debug`** Only print a status report, with what went into the engine and its steps. The result of --output and --output-match will still printed. [boolean] | + | `-€` | **`--void-euro`** Void having `€` as alias for `$`. The alias is used in all inputs, but never in globs, file names or piped data. [boolean] | | `-§` | **`--void-section`** Void having `§` as alias for `\`. The alias is used in all inputs, but never in globs, file names or piped data. [boolean] | | `-o` | **`--output`** Output the final result instead of saving to file. Will also output content even if no replacement has taken place. [boolean] | diff --git a/devguide.md b/devguide.md index f2ee143b..33fae48c 100644 --- a/devguide.md +++ b/devguide.md @@ -10,6 +10,7 @@ How we like the code in RexReplace to be. - **Comments say why, not what.** Write them in plain, simple English. Leave the comment out if the code already says it. - **No sloppy shortcuts.** If two cases need different calls, write a plain `if`/`else` instead of one call that only half fits both. - **Everything is printed with `write()` in `src/output.ts`,** so `--nl` knows how each stream ends. +- **The engine never touches the process.** It resolves with the status report of the run, or throws a panic when the run can not go on. Only `src/cli.ts` reads `argv` and stdin, sets the exit code and ends the process. - **stdout is the result.** That is the names of the affected files, or the content from `--output`, `--output-match` or piped data. Everything else goes to stderr, so the result can always be piped. ## Telling what is going on @@ -21,22 +22,19 @@ All output goes through `src/output.ts`. Pick the function by who the message is | `print(item, delimiter, tail, stream)` | Always | The result. Puts the delimiter after each item, or only between them without a tail. Without a delimiter it is a line break, unless the item already ends with one. | A name, the content of a file, a match | | `printDone()` | At the end of a run, also when it is stopped | Ends stdout and stderr with a line break when `--nl` is set | | | `info(name)` | Unless `--quiet` | The name of an affected file. stdout, or stderr when stdout has content. | `a.md` | -| `warn(msg)` | Unless `--quiet` | Something is likely a mistake, but the run goes on | `Data is piped in, but no flag tells what to do with it` | -| `error(msg, data)` | Unless `--quiet-total` | Something went wrong. The run goes on, unless `--halt` is set. | `File not found: a.md` | -| `die(msg, data, help)` | Unless `--quiet-total` | Something went wrong and the run can not go on. Exits with 1. | `No data piped into replacement` | -| `chat(msg)` | `--verbose` (or `--debug`) | The user: what happens to each file | `Open sync: a.md`, `Nothing changed in: a.md` | -| `debug(data)` | `--debug` | The developer: what happens inside, at points that matter | `Work on content from: a.md` | -| `step(data)` | `--verbose` and `--debug` | The developer: a trace of every step, with the data at that step | `Get final regex`, the full config | - -- The three levels of telling go from little to a lot: `chat`, then `debug`, then `step`. -- `chat` falls back to `debug` without `--verbose`, so `--debug` alone also shows what happens to each file. -- `debug` and `step` print their data as JSON. That keeps line breaks and spaces visible. -- `chat`, `debug` and `step` only ever write to stderr. +| `warn(msg)` | Unless `--quiet` | Something is likely a mistake, but the run goes on. Also added to `status.warnings`. | `Data is piped in, but no flag tells what to do with it` | +| `error(msg, data)` | Unless `--quiet-total` | Something went wrong. The run goes on, unless `--halt` is set: then a panic is thrown. Also added to `status.errors`, so the CLI gives the exit code 1. | `File not found: a.md` | +| `die(msg, data, help)` | Unless `--quiet-total` | Something went wrong and the run can not go on. Throws a panic, and the CLI exits with 1. Also added to `status.errors`. | `No data piped into replacement` | +| `chat(msg)` | `--verbose`, also with `--quiet`, but not with `--quiet-total` | The user: what happens to each file. Only ever to stderr. Also kept in `engine.steps` of the status report. | `Open sync: a.md`, `Nothing changed in: a.md` | + +- What the run did is in the status report: the engine resolves with it, and `--status` prints it. So there is no extra debug output to print along the way. +- `--debug` prints only the status report, with what went into the engine and what it did. +- `warn()`, `error()` and `die()` add to the status report as they go, also when the quiet flags hide what they print. ## Tests - **Tests are strict, lean and focused too.** One thing per test, and the name of the test says what it is. -- Expect the full result where it matters (`{out, err, exit}`), so nothing extra slips through. +- 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). diff --git a/src/cli.ts b/src/cli.ts index 53a82936..bd68b44b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,8 +15,7 @@ let needHelp = 0; if (process.argv.length < 4) { if (/^(-v|--?version)$/.test(process.argv[process.argv.length - 1])) { console.log(version); - process.exitCode = 0; - process.exit(); + process.exit(0); } else if (/^(-h|--?help)$/.test(process.argv[process.argv.length - 1])) { needHelp = 1; } else { @@ -97,6 +96,17 @@ const yargs = require('yargs') // Not .choices() as node takes the name of an encoding in any case, like `UTF8` .check((argv) => Buffer.isEncoding(argv.encoding) || `Unknown encoding: ${argv.encoding}`) + .string('x') + .describe( + 'x', + 'Exclude files with a path that matches this regular expression. Will follow same regex flags and setup as the main search. Can be used multiple times.' + ) + .alias('x', 'exclude-re') + + .string('X') + .describe('X', 'Exclude files found with this glob. Can be used multiple times.') + .alias('X', 'exclude-glob') + .alias('E', 'engine') .describe( 'E', @@ -177,10 +187,6 @@ const yargs = require('yargs') .alias('H', 'bail') .default('H', false) - .boolean('d') - .describe('d', 'Print debug info') - .alias('d', 'debug') - .boolean('€') .describe( '€', @@ -302,16 +308,6 @@ The following values defaults to \`❌\` if haystack does not originate from a f 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). ` ) - .string('x') - .describe( - 'x', - 'Exclude files with a path that matches this regular expression. Will follow same regex flags and setup as the main search. Can be used multiple times.' - ) - .alias('x', 'exclude-re') - - .string('X') - .describe('X', 'Exclude files found with this glob. Can be used multiple times.') - .alias('X', 'exclude-glob') /* .boolean('N') @@ -395,7 +391,6 @@ N n O P r -S t U W w @@ -404,6 +399,25 @@ Z z */ + .boolean('S') + .alias('S', 'status-compact') + .describe( + 'S', + 'As the last thing before ending any execution a status report will be printed as one line of JSON. Will print even in combination with `--quiet-total`. Will incude extra info about the inner workings of the engine in combination with the `--verbose` flag.' + ) + + .boolean('status') + .describe( + 'status', + 'Same as `--status-compact`, but always starting with a `\n` followed by pretty printed JSON' + ) + + .boolean('d') + .describe( + 'd', + 'Print only the status report, with what went into the engine and what it did. Does not block the output of --output and --output-match.' + ) + .alias('d', 'debug') .help('h') .describe('h', 'Display help.') @@ -411,12 +425,30 @@ Z z .epilog(`Inspiration: .oO(What should 'sed' have been by now?)`); +// The engine resolves with the status of the run, or throws a panic when the run can not go on. +// The CLI is the only part that acts on the process. +function printStatus(config, status) { + if (config.status) { + process.stdout.write('\n' + JSON.stringify(status, null, 2) + '\n'); + } else if (config.statusCompact) { + process.stdout.write(JSON.stringify(status) + '\n'); + } +} + +function exitOnPanic(config, e) { + if (!e?.panic) { + throw e; + } + // A panic from the CLI itself comes before the engine has a status + if (e.status) { + printStatus(config, e.status); + } + process.exit(1); +} + function backOut(exitcode = 1) { - const help = yargs.showHelp(); - const io = exitcode ? console.error : console.log; - //io(help); - process.exitCode = exitcode; - process.exit(); + yargs.showHelp(); + process.exit(exitcode); } const lineBreakEscapes = {'\n': '\\n', '\r': '\\r', '\u2028': '\\u2028', '\u2029': '\\u2029'}; @@ -437,111 +469,108 @@ function unescapeString(str = '') { return new Function(`return '${source}'`)(); } -(function () { - if (0 < needHelp) { - return backOut(needHelp - 1); - } - - // All options into one big config object for the rexreplace core - let config: any = {}; +if (0 < needHelp) { + backOut(needHelp - 1); +} - // Use only camelCase full length version of settings so we make sure the core can be documented properly - Object.keys(yargs.argv).forEach((key) => { - if (1 < key.length && key.indexOf('-') < 0) { - config[key] = yargs.argv[key]; - } - }); +// All options into one big config object for the rexreplace core +const config: any = {}; - if (config.jsPipe) { - config.replacementJs = config.pipe = true; +// Use only camelCase full length version of settings so we make sure the core can be documented properly +Object.keys(yargs.argv).forEach((key) => { + if (1 < key.length && key.indexOf('-') < 0) { + config[key] = yargs.argv[key]; } +}); + +if (config.jsPipe) { + config.replacementJs = config.pipe = true; +} - outputConfig(config); +if (config.debug) { + config.status = config.verbose = config.quietTotal = true; +} - let pipeInUse = false; - let pipeData = ''; +outputConfig(config); - config.pipedData = null; +config.pipedData = null; - config.showHelp = yargs.showHelp; +config.showHelp = yargs.showHelp; - config.pattern = pattern; +config.pattern = pattern; - config.includeGlob = yargs.argv._; +config.includeGlob = yargs.argv._; - config.excludeGlob = [].concat(yargs.argv.excludeGlob).filter(Boolean); +config.excludeGlob = [].concat(yargs.argv.excludeGlob).filter(Boolean); - config.excludeRe = [].concat(yargs.argv.excludeRe).filter(Boolean); +config.excludeRe = [].concat(yargs.argv.excludeRe).filter(Boolean); - config.replacement = replacement; +config.replacement = replacement; - // The aliases come before the escape sequences are interpreted, so `§n` gives a line break just like `\n` - const typed = {...config}; - alias(config); +// The aliases come before the escape sequences are interpreted, so `§n` gives a line break just like `\n` +const typed = {...config}; +alias(config); - // The error shows the value as it was typed - const unescape = (key, name) => { +// The error shows the value as it was typed +const unescape = (key, name) => { + try { + config[key] = unescapeString(config[key]); + } catch (e) { + // The engine is not running yet, so the panic is caught here try { - config[key] = unescapeString(config[key]); - } catch (e) { die('Invalid escape sequence in ' + name + ':', typed[key]); + } catch (panic) { + exitOnPanic(config, panic); } - }; - - if (!config.replacementJs) { - unescape('replacement', 'replacement'); } +}; - if (config.delimiter) { - unescape('delimiter', '--delimiter'); - } - if (config.nameDelimiter) { - unescape('nameDelimiter', '--name-delimiter'); - } - if (config.contentDelimiter) { - unescape('contentDelimiter', '--content-delimiter'); - } +if (!config.replacementJs) { + unescape('replacement', 'replacement'); +} - /*if(Boolean(process.stdout.isTTY)){ - config.output = true; - }*/ +if (config.delimiter) { + unescape('delimiter', '--delimiter'); +} +if (config.nameDelimiter) { + unescape('nameDelimiter', '--name-delimiter'); +} +if (config.contentDelimiter) { + unescape('contentDelimiter', '--content-delimiter'); +} - // The engine tells how many errors there were, and the CLI turns that into the exit code - const run = () => - rexreplace.engine(config).then((errors) => { - if (errors) { +// The exit code is only set, so the process ends by itself when all output is written +const run = () => + rexreplace.engine(config).then( + (status) => { + printStatus(config, status); + if (status.errors.length) { process.exitCode = 1; } - }); + }, + (e) => exitOnPanic(config, e) + ); - // 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; +// 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; - if (!pipeDataExpected || Boolean(process.stdin.isTTY)) { - return run(); - } +if (!pipeDataExpected || Boolean(process.stdin.isTTY)) { + run(); +} else { + // Stays null when nothing comes through the pipe + let pipeData = null; process.stdin.setEncoding(config.encoding); - process.stdin.on('readable', () => { - let chunk = process.stdin.read(); - - if (null !== chunk) { - pipeInUse = true; - pipeData += chunk; - while ((chunk = process.stdin.read())) { - pipeData += chunk; - } - } + process.stdin.on('data', (chunk) => { + pipeData = (pipeData ?? '') + chunk; }); process.stdin.on('end', () => { - if (pipeInUse) { - if (yargs.argv.trimPipe) { - pipeData = pipeData.trim(); - } - config.pipedData = pipeData; + if (null !== pipeData && config.trimPipe) { + pipeData = pipeData.trim(); } + config.pipedData = pipeData; run(); }); -})(); +} diff --git a/src/engine.ts b/src/engine.ts index 04233151..7708c470 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -8,19 +8,7 @@ const globs = require('fast-glob'); const now = new Date(); -import { - outputConfig, - errorCount, - step, - debug, - chat, - info, - print, - printDone, - warn, - error, - die, -} from './output.ts'; +import {outputConfig, chat, info, print, printDone, warn, error, die} from './output.ts'; const re = { mctime: /[mc]time/, @@ -32,7 +20,8 @@ const re = { }; // Only the version ends up in the bundle -export {version} from '../package.json'; +import {version} from '../package.json'; +export {version}; // The regex engine comes with the runtime: Node has V8 and Bun has JSC (JavaScriptCore) const runtime = process.versions.bun ? 'Bun' : 'Node'; @@ -44,19 +33,49 @@ function validateEncoding(encoding = 'utf8') { } } -// Returns a promise that resolves with the number of errors when all files are done +// Resolves with the status of the run when all files are done. +// When the run can not go on, a panic is thrown with the status so far. export async function engine(config: any = {engine: 'default'}) { - await run(config); - return errorCount(); + config.statusInfo = newStatus(config); + outputConfig(config); + try { + await run(config); + } catch (e) { + if (e?.panic) { + e.status = config.statusInfo; + } + throw e; + } finally { + printDone(); + } + return config.statusInfo; +} + +function newStatus(config) { + const status: any = {files: {searched: [], updated: []}, pipe: null, warnings: [], errors: []}; + + // The status report with --verbose also tells what went into the engine and what it did. + // The config is copied before it gets the status, and the piped data is told about in `pipe`. + if (config.verbose && (config.status || config.statusCompact)) { + status.engine = { + version, + runtime: runtime + ' ' + runtimeEngine, + config: JSON.parse( + JSON.stringify(config, (key, value) => + 'statusInfo' === key || 'pipedData' === key ? undefined : value + ) + ), + execution: null, + steps: [], + }; + } + + return status; } async function run(config) { - outputConfig(config); validateEncoding(config.encoding); - step('Displaying steps for:'); - step(config); - config.pattern = getFinalPattern(config.pattern, config) || ''; config.replacement = getFinalReplacement(config.replacement, config) || ''; @@ -73,18 +92,34 @@ async function run(config) { config.regex = getFinalRegex(config.pattern, config) || ''; - step(config); + // What the regex engine is asked to do. With --replacement-js and --output-match it gets a function. + if (config.statusInfo.engine) { + config.statusInfo.engine.execution = { + search: config.regex.source, + flags: config.regex.flags, + replacement: config.replacementJs || config.outputMatch ? null : config.replacement, + replacementSrc: + config.replacementJs && !config.outputMatch ? config.replacementOri : null, + }; + } const piped = handlePipedData(config); + if (null !== config.pipedData) { + config.statusInfo.pipe = { + bytes: Buffer.byteLength(config.pipedData, config.encoding), + type: pipeType(config, piped), + updated: false, + }; + } + // The more specific tail wins. Piped data that is printed is left as it came in, but matches are matches, from a file or a pipe. config.nameDelimiterTail = config.nameDelimiterTail ?? config.tail ?? true; config.contentDelimiterTail = config.contentDelimiterTail ?? config.tail ?? !(piped && config.output); if (piped) { - doReplacement('Piped data', config, config.pipedData); - return printDone(); + return doReplacement('Piped data', config, config.pipedData); } config.files = getFilePaths(config); @@ -99,8 +134,6 @@ async function run(config) { config.voidAsync = config.voidAsync || 1 === config.files.length || config.output || config.outputMatch; - step(config); - const files = config.files.filter((filepath) => fs.existsSync(filepath) ? true : error('File not found:', filepath) ); @@ -120,10 +153,10 @@ async function run(config) { }; await Promise.all(Array.from({length: workers}, worker)); - return printDone(); } function openFile(file, config) { + config.statusInfo.files.searched.push(file); if (config.voidAsync) { chat('Open sync: ' + file); let data; @@ -143,7 +176,7 @@ function openFile(file, config) { // postfix argument names to limit the probabillity of user inputted javascript accidently using same values function doReplacement(_file_rr: string, _config_rr: any, _data_rr: string) { - debug('Work on content from: ' + _file_rr); + chat('Work on content from: ' + _file_rr); // Main regexp of the whole thing const result = _data_rr.replace( @@ -156,11 +189,15 @@ function doReplacement(_file_rr: string, _config_rr: any, _data_rr: string) { if (_config_rr.output || _config_rr.outputMatch) { // With --output-match the matches are printed by the replacement if (_config_rr.output) { - debug('Output result from: ' + _file_rr); + chat('Output result from: ' + _file_rr); print(result, _config_rr.contentDelimiter, _config_rr.contentDelimiterTail); } // The file is not changed, but it is still named if its content would change - if ((_config_rr.nameAll || result !== _data_rr) && !_config_rr.dataIsPiped) { + if (_config_rr.dataIsPiped) { + _config_rr.statusInfo.pipe.updated = result !== _data_rr; + } else if (result !== _data_rr) { + updated(_file_rr, _config_rr); + } else if (_config_rr.nameAll) { info(_file_rr); } return; @@ -178,13 +215,13 @@ function doReplacement(_file_rr: string, _config_rr: any, _data_rr: string) { // Release the memory while storing files _data_rr = ''; - debug('Write new content to: ' + _file_rr); + chat('Write new content to: ' + _file_rr); // Write directly to the same file (if the process is killed all new and old data is lost) if (_config_rr.voidBackup) { return fs.promises .writeFile(_file_rr, result, _config_rr.encoding) - .then(() => info(_file_rr), error); + .then(() => updated(_file_rr, _config_rr), error); } //Make sure data is always on disk @@ -202,19 +239,37 @@ function doReplacement(_file_rr: string, _config_rr: any, _data_rr: string) { } catch (e) { return error(e); } - return info(_file_rr); + return updated(_file_rr, _config_rr); } return fs.promises .rename(oriFile, backupFile) .then(() => fs.promises.writeFile(oriFile, result, _config_rr.encoding)) .then(() => _config_rr.keepBackup || fs.promises.unlink(backupFile)) - .then(() => info(_file_rr), error); + .then(() => updated(_file_rr, _config_rr), error); } -function handlePipedData(config) { - step('Check Piped Data'); +// The content of the file was changed, or would be with --output and --output-match, so it is named +function updated(file, config) { + config.statusInfo.files.updated.push(file); + info(file); +} +// How the piped data was used +function pipeType(config, piped) { + if (piped) { + return 'CONTENT'; + } + if (config.replacementJs) { + return 'JS'; + } + if (config.replacementPipe) { + return 'REPLACEMENT'; + } + return 'UNUSED'; +} + +function handlePipedData(config) { if (config.includeGlob.length) { // Empty piped data (or only a line break) is not worth a warning if ( @@ -241,8 +296,6 @@ function handlePipedData(config) { } function getFinalPattern(pattern, conf: any) { - step('Get final pattern'); - if (conf.literal) { pattern = pattern.replace(re.regexSpecialChars, '\\$&'); } @@ -252,18 +305,15 @@ function getFinalPattern(pattern, conf: any) { pattern = new Function('return '+pattern)(); }*/ - step(pattern); return pattern; } function getFinalReplacement(replacement, conf: any) { - step('Get final replacement'); /*if(config.replacementFile){ return oneLinerFromFile(fs.readFileSync(replacement,'utf8')); }*/ if (conf.replacementPipe) { - step('Piping replacement'); conf.pipedDataUsed = true; if (null === conf.pipedData) { return die('No data piped into replacement'); @@ -272,15 +322,11 @@ function getFinalReplacement(replacement, conf: any) { } if (conf.outputMatch) { - step('Output match'); - if (parseInt(process.versions.node) < 6) { return die('outputMatch is only supported in node 6+'); } return function (match, ...rest) { - step(arguments); - // The groups are followed by the offset and the full text, and by the named groups if the pattern has any. // Without groups the full match is printed, otherwise the groups are printed together. const groups = rest.slice(0, 'object' === typeof rest[rest.length - 1] ? -3 : -2); @@ -303,8 +349,6 @@ function getFinalReplacement(replacement, conf: any) { return die('Captured groups for javascript replacement is only supported in node 6+'); } - step(replacement); - return replacement; } @@ -321,8 +365,6 @@ function getFinalReplacement(replacement, conf: any) { function getFinalRegex(pattern, config) { const engine = 'default' === config.engine ? runtimeEngine : config.engine; - step('Get final regex with engine: ' + engine); - let regex; let flags = getFlags(config); @@ -332,7 +374,6 @@ function getFinalRegex(pattern, config) { try { regex = new RegExp(pattern, flags); } catch (e) { - if (config.debug) throw new Error(e); die(e.message); } break; @@ -348,7 +389,6 @@ function getFinalRegex(pattern, config) { //regex = new RE2(pattern, flags); regex = new RegExp(pattern, flags); } catch (e) { - if (config.debug) throw new Error(e); die(e.message); } break; @@ -356,14 +396,10 @@ function getFinalRegex(pattern, config) { die(`Engine ${config.engine} not supported`); } - step(regex); - return regex; } function getFlags(config) { - step('Get flags'); - let flags = ''; if (!config.voidGlobal) { @@ -386,8 +422,6 @@ function getFlags(config) { flags += 'u'; } - step(flags); - return flags; } @@ -585,8 +619,6 @@ function dynamicReplacement(_file_rr, _config_rr, _data_rr) { } // Capture groups used, so need to run once per match return function () { - step(arguments); - const __pipe = _pipe, __text = _text, __find = _find, diff --git a/src/output.ts b/src/output.ts index 34ec16aa..4e3bb648 100644 --- a/src/output.ts +++ b/src/output.ts @@ -11,17 +11,17 @@ const lastItemChar = new Map(); // The char code of the last character printed to each stream const lastPrintedChar = new Map(); -// The number of errors in this run, so the CLI can give an exit code -let errors = 0; - export const outputConfig = function (_config) { config = _config; lastItemChar.clear(); lastPrintedChar.clear(); - errors = 0; }; -export const errorCount = () => errors; +// Thrown when the run can not go on. The engine adds the status so far, and the CLI ends the process. +export class Panic extends Error { + panic = true; + status = null; +} // Everything is printed from here, so the last character of each stream is known const write = function (text, stream) { @@ -79,15 +79,17 @@ const note = function (msg) { }; export const warn = function (msg) { + config.statusInfo?.warnings.push(msg); // The line break gives an empty line under the warning note(' 🟡 ' + msg + '\n'); }; +// --verbose was asked for, so --quiet does not hide it. --quiet-total wins and hides it too. +// The status report keeps the same lines in `engine.steps`, so they are there even when they are not printed. export const chat = function (msg) { - if (config.verbose) { - note(msg); - } else { - debug(msg); + config.statusInfo?.engine?.steps.push(msg); + if (config.verbose && !config.quietTotal) { + write(font.gray(msg) + '\n', process.stderr); } }; @@ -95,40 +97,23 @@ export const die = function (msg = '', data = '', displayHelp = false) { if (displayHelp && !config.quietTotal) { config.showHelp(); } - // The line break gives an empty line under the error - if (msg && data) { - error(' ❌ ' + msg, data + '\n'); - } else if (msg) { - error(' ❌ ' + msg + '\n'); + const text = msg + (data ? ' ' + data : ''); + config.statusInfo?.errors.push(text); + if (!config.quietTotal) { + // The line break gives an empty line under the error + write(font.red(' ❌ ' + text) + '\n\n', process.stderr); } - kill(); + throw new Panic(text); }; export const error = function (msg, data = '') { + const text = msg + (data ? ' ' + data : ''); + config.statusInfo?.errors.push(text); if (!config.quietTotal) { - write(font.red(msg) + (data ? ' ' + data : '') + '\n', process.stderr); + write(font.red(text) + '\n', process.stderr); } if (config.halt) { - kill(); + throw new Panic(text); } - errors++; return false; }; - -export function debug(data) { - if (config.debug) { - write(font.gray(JSON.stringify(data, null, 4)) + '\n', process.stderr); - } -} - -export function step(data) { - if (config.verbose) { - debug(data); - } -} - -function kill(exitCode = 1) { - // What was printed before the stop still ends with a line break - printDone(); - process.exit(exitCode); -} diff --git a/test/cli/run.sh b/test/cli/run.sh index 3a4c28a4..60825c8e 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -329,7 +329,7 @@ reset assert "rexreplace 'b' 'x' my.file -V 2>/dev/null" 'my.file' reset -assert "rexreplace 'b' 'x' my.file -V 2>&1 >/dev/null | grep -c 'my.file'" '1' +assert "rexreplace 'b' 'x' my.file -V 2>&1 >/dev/null | grep -cx 'my.file'" '0' reset assert "rexreplace 'b' 'x' my.file -q" '' diff --git a/test/fuzzy/crossover.test.js b/test/fuzzy/crossover.test.js index f3d4b245..6ce16f1e 100644 --- a/test/fuzzy/crossover.test.js +++ b/test/fuzzy/crossover.test.js @@ -14,7 +14,6 @@ const flags = [ 'quiet', 'quietTotal', 'halt', - 'debug', 'voidEuro', 'voidSection', 'output', diff --git a/test/js/crossover.test.js b/test/js/crossover.test.js index f6324156..46fda5af 100644 --- a/test/js/crossover.test.js +++ b/test/js/crossover.test.js @@ -45,7 +45,6 @@ describe('every pair of flags', () => { 'quiet', 'quietTotal', 'halt', - 'debug', 'voidEuro', 'voidSection', 'output', diff --git a/test/js/debug.test.js b/test/js/debug.test.js index 2e10b9e2..396d2296 100644 --- a/test/js/debug.test.js +++ b/test/js/debug.test.js @@ -1,87 +1,64 @@ import {describe, test, expect} from 'bun:test'; import fs from 'fs'; -import {rr} from './helper.js'; +import path from 'path'; -describe('--debug', () => { - test('prints what happens to a file', async () => { - fs.writeFileSync('my.file', 'foobar'); - const result = await rr('o', 'x', {includeGlob: ['my.file'], debug: true}); - expect(result.err).toBe( - [ - '"1 files found"', - '"Open sync: my.file"', - '"Work on content from: my.file"', - '"Write new content to: my.file"', - '', - ].join('\n') - ); - expect(result.out).toBe('my.file\n'); - }); +// --debug is set by the CLI, so these cases run the CLI itself +const cli = path.join(import.meta.dir, '../../src/cli.ts'); - test('prints what happens to piped data', async () => { - const result = await rr('o', 'x', {pipedData: 'foobar', debug: true}); - expect(result.err).toBe( - '"Work on content from: Piped data"\n"Output result from: Piped data"\n' - ); +function runCli(...args) { + const {stdout, stderr, exitCode} = Bun.spawnSync([process.execPath, cli, ...args], { + stdin: 'ignore', }); + return {out: stdout.toString(), err: stderr.toString(), exit: exitCode}; +} - test('is not the default', async () => { - expect((await rr('o', 'x', {pipedData: 'foobar'})).err).toBe(''); - }); +describe('--debug', () => { + test.each(['--debug', '-d'])( + '%s prints only the status report, with what went into the engine and what it did', + (flag) => { + fs.writeFileSync('my.file', 'foobar'); + const result = runCli('o', 'x', 'my.file', flag); + expect(result.err).toBe(''); + expect(result.exit).toBe(0); + expect(result.out.startsWith('\n{')).toBe(true); + const status = JSON.parse(result.out); + expect(status.files).toEqual({searched: ['my.file'], updated: ['my.file']}); + expect(status.engine.config).toMatchObject({pattern: 'o', replacement: 'x'}); + expect(status.engine.execution).toEqual({ + search: 'o', + flags: 'gim', + replacement: 'x', + replacementSrc: null, + }); + expect(status.engine.steps).toEqual([ + '1 files found', + 'Open sync: my.file', + 'Work on content from: my.file', + 'Write new content to: my.file', + ]); + expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar'); + } + ); - test('never writes to stdout', async () => { - expect((await rr('o', 'x', {pipedData: 'foobar', debug: true})).out).toBe('fxxbar'); + test('still prints the result of --output before the status report', () => { + fs.writeFileSync('my.file', 'foobar'); + const result = runCli('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('throws the full error for an invalid pattern', async () => { - await expect(rr('(', 'x', {pipedData: 'foobar', debug: true})).rejects.toThrow( - 'Invalid regular expression' - ); + test('gives the errors in the status report, and the exit code 1', () => { + const result = runCli('o', 'x', 'nope.file', '--debug'); + expect(result.err).toBe(''); + expect(result.exit).toBe(1); + expect(JSON.parse(result.out).errors).toEqual(['0 files found']); }); - describe('combined', () => { - test('with --verbose every step and the config is printed', async () => { - const config = {pipedData: 'foobar', debug: true, verbose: true, voidGlobal: true}; - const result = await rr('o', 'x', config); - expect(result.err).toContain('"Get flags"\n"im"'); - expect(result.err).toContain('"voidGlobal": true'); - expect(result.out).toBe('fxobar'); - }); - - test('with --quiet and --quiet-total the debug info is still printed', async () => { - const config = {pipedData: 'foobar', debug: true}; - expect((await rr('o', 'x', {...config, quiet: true})).err).toContain( - 'Work on content from' - ); - expect((await rr('o', 'x', {...config, quietTotal: true})).err).toContain( - 'Work on content from' - ); - }); - - test('with --output', async () => { - fs.writeFileSync('my.file', 'foobar'); - const result = await rr('o', 'x', { - includeGlob: ['my.file'], - debug: true, - output: true, - }); - expect(result.out).toBe('fxxbar\n'); - expect(result.err).toContain('"Output result from: my.file"'); - expect(result.err).not.toContain('Write new content'); - }); - - test('with a file where nothing is replaced', async () => { - fs.writeFileSync('my.file', 'foobar'); - const result = await rr('nope', 'x', {includeGlob: ['my.file'], debug: true}); - expect(result.err).toContain('"Nothing changed in: my.file"'); - }); - - test('with --replacement-js and --output-match', async () => { - const js = await rr('o', '1+1', {pipedData: 'fo', debug: true, replacementJs: true}); - expect(js.out).toBe('f2'); - const match = await rr('o', '_', {pipedData: 'fo', debug: true, outputMatch: true}); - expect(match.out).toBe('o\n'); - expect(match.err).toBe('"Work on content from: Piped data"\n'); - }); + test('gives the error of an invalid pattern in the status report', () => { + fs.writeFileSync('my.file', 'foobar'); + const result = runCli('(', '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/js/fix/703.test.js b/test/js/fix/703.test.js index ea457072..169ea0b1 100644 --- a/test/js/fix/703.test.js +++ b/test/js/fix/703.test.js @@ -32,8 +32,8 @@ describe('#703 the names of affected files', () => { expect(result.err).toBe(''); }); - test('are alone on stdout with --verbose and --debug', async () => { - const result = await run({verbose: true, debug: true}); + test('are alone on stdout with --verbose', async () => { + const result = await run({verbose: true}); expect(result.out).toBe('a.file\nc.file\n'); expect(result.err).toContain('Open sync: a.file'); expect(result.err).toContain('Nothing changed in: b.file'); diff --git a/test/js/fix/705.test.js b/test/js/fix/705.test.js index 5dd24e52..210057b7 100644 --- a/test/js/fix/705.test.js +++ b/test/js/fix/705.test.js @@ -95,12 +95,6 @@ describe('#705 the engine returns a promise that resolves when all files are don }); describe('the promise is rejected when the work can not be done', () => { - test('like with an invalid pattern in debug mode', async () => { - fs.writeFileSync('my.file', 'foo'); - const flags = {includeGlob: ['my.file'], debug: true}; - await expect(rr('(', 'x', flags)).rejects.toThrow('Invalid regular expression'); - }); - test.each(flows)('like with code that fails with %s', async (name, flags) => { fs.writeFileSync('my.file', 'foo'); const config = {includeGlob: ['my.file'], replacementJs: true, ...flags}; diff --git a/test/js/fix/742.test.js b/test/js/fix/742.test.js new file mode 100644 index 00000000..855433ff --- /dev/null +++ b/test/js/fix/742.test.js @@ -0,0 +1,238 @@ +// https://github.com/mathiasrw/rexreplace/issues/742 +// The engine ended the process itself and resolved with nothing useful. Now it resolves with a status, a panic is thrown +// 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'); + +// 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']}; +} + +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({ + files: {searched: ['a.file', 'b.file', 'c.file'], updated: ['a.file', 'c.file']}, + pipe: null, + warnings: [], + errors: [], + }); + }); + + test('with the files that would be updated with --output', async () => { + const status = await rrStatus('o', 'x', {...makeFiles(), 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}); + expect(status.files.updated).toEqual(['a.file', 'c.file']); + }); + + test('with the errors, also when --quiet-total hides them', async () => { + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.chmodSync('b.file', 0); + const status = await rrStatus('o', 'x', {includeGlob: ['*.file'], quietTotal: true}); + expect(status.files).toEqual({searched: ['a.file', 'b.file'], updated: ['a.file']}); + expect(status.errors).toHaveLength(1); + expect(status.errors[0]).toContain('EACCES'); + }); + + test('with the warnings, also when --quiet hides them', async () => { + fs.writeFileSync('a.file', 'foo'); + const status = await rrStatus('o', 'x', { + includeGlob: ['a.file'], + pipedData: 'x', + quiet: true, + }); + expect(status.warnings).toEqual([ + 'Data is piped in, but no flag tells what to do with it. It is not used.', + ]); + }); +}); + +describe('#742 the status tells about the piped data', () => { + test('as the content that is updated', async () => { + expect((await rrStatus('o', 'x', {pipedData: 'foo'})).pipe).toEqual({ + bytes: 3, + type: 'CONTENT', + updated: true, + }); + expect((await rrStatus('z', 'x', {pipedData: 'foo'})).pipe.updated).toBe(false); + }); + + test('with the size in bytes', async () => { + expect((await rrStatus('o', 'x', {pipedData: 'æø🦖'})).pipe.bytes).toBe(8); + }); + + test.each([ + ['REPLACEMENT', {replacementPipe: true}], + ['JS', {replacementJs: true}], + ['JS', {replacementPipe: true, replacementJs: true}], + ['UNUSED', {}], + ])('as %s', async (type, flags) => { + fs.writeFileSync('a.file', 'foo'); + const status = await rrStatus('o', 'pipe', { + includeGlob: ['a.file'], + pipedData: '"y"', + ...flags, + }); + expect(status.pipe).toEqual({bytes: 3, type, updated: false}); + }); +}); + +describe('#742 a panic', () => { + test('stops the run with --halt, with the status so far', async () => { + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.writeFileSync('c.file', 'foo'); + fs.chmodSync('b.file', 0); + const config = {includeGlob: ['*.file'], halt: true}; + expect((await rr('o', 'x', config)).exit).toBe(1); + fs.writeFileSync('a.file', 'foo'); + const status = await rrStatus('o', 'x', config); + expect(status.files).toEqual({searched: ['a.file', 'b.file'], updated: ['a.file']}); + expect(status.errors[0]).toContain('EACCES'); + }); + + test('is thrown when the run can not go on, with the error in the status', async () => { + expect(await rr('(', 'x', {pipedData: 'foo'})).toMatchObject({out: '', exit: 1}); + expect((await rrStatus('(', 'x', {pipedData: 'foo'})).errors).toHaveLength(1); + }); +}); + +describe('#742 the engine part of the status', () => { + const debug = {status: true, verbose: true}; + + test('is only there with --verbose on --status or --status-compact', async () => { + expect(await rrStatus('o', 'x', {pipedData: 'foo', verbose: true})).not.toHaveProperty( + 'engine' + ); + expect(await rrStatus('o', 'x', {pipedData: 'foo', status: true})).not.toHaveProperty( + 'engine' + ); + const status = await rrStatus('o', 'x', { + pipedData: 'foo', + statusCompact: true, + verbose: true, + }); + expect(status.engine.version).toMatch(/^\d+\.\d+\.\d+/); + expect(status.engine.runtime).toBe('Bun JSC'); + }); + + test('has the config as it came in, but not the piped data', async () => { + const {config} = (await rrStatus('o', 'x', {pipedData: 'foo', ...debug})).engine; + expect(config).toMatchObject({pattern: 'o', replacement: 'x', status: true}); + expect(config).not.toHaveProperty('pipedData'); + expect(config).not.toHaveProperty('statusInfo'); + }); + + test.each([ + ['a replacement', {}, 'x', null], + ['--replacement-js', {replacementJs: true}, null, '"x"'], + ['--output-match', {outputMatch: true}, null, null], + ])( + 'tells what the regex engine was asked to do with %s', + async (name, flags, replacement, src) => { + const code = flags.replacementJs ? '"x"' : 'x'; + const status = await rrStatus('o+', code, { + pipedData: 'foo', + voidMultiline: true, + ...debug, + ...flags, + }); + expect(status.engine.execution).toEqual({ + search: 'o+', + flags: 'gi', + replacement, + replacementSrc: src, + }); + } + ); + + test('has the steps of the run in order, also when they are not printed', async () => { + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'bar'); + const config = {includeGlob: ['*.file'], ...debug, quietTotal: true}; + expect((await rr('o', 'x', config)).err).toBe(''); + fs.writeFileSync('a.file', 'foo'); + expect((await rrStatus('o', 'x', config)).engine.steps).toEqual([ + '2 files found', + 'Open sync: a.file', + 'Work on content from: a.file', + 'Write new content to: a.file', + 'Open sync: b.file', + 'Work on content from: b.file', + 'Nothing changed in: b.file', + ]); + }); + + test('tells the replacement that was piped in', async () => { + fs.writeFileSync('a.file', 'foo'); + const status = await rrStatus('o', '_', { + includeGlob: ['a.file'], + pipedData: 'y', + replacementPipe: true, + ...debug, + }); + 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', '-S'])( + '%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/helper.js b/test/js/helper.js index 0528a5c7..cec856e2 100644 --- a/test/js/helper.js +++ b/test/js/helper.js @@ -21,11 +21,20 @@ afterEach(() => { }); // Run the engine in this process (no CLI involved) with the defaults and the `€` and `§` aliases the CLI would give it. -// Gives what was sent to stdout (`out`) and stderr (`err`) plus the exit code if the engine wanted to stop the process (`exit`). +// Gives what was sent to stdout (`out`) and stderr (`err`) plus the exit code the CLI would give (`exit`). // Files are handled one at a time unless `voidAsync: false` is given, so the output comes in the same order on every run. export async function rr(pattern, replacement, config = {}) { + return (await run(pattern, replacement, config)).result; +} + +// The same run as rr(), but gives the status the engine resolves with (or the status of a panic) +export async function rrStatus(pattern, replacement, config = {}) { + return (await run(pattern, replacement, config)).status; +} + +async function run(pattern, replacement, config) { const result = {out: '', err: '', exit: null}; - const halt = {}; + let status; const spies = [ spyOn(process.stdout, 'write').mockImplementation((data) => { @@ -39,9 +48,9 @@ export async function rr(pattern, replacement, config = {}) { result.err += data; return true; }), - spyOn(process, 'exit').mockImplementation((code) => { - result.exit = code; - throw halt; + // Only the CLI acts on the process + spyOn(process, 'exit').mockImplementation(() => { + throw new Error('The engine must not end the process'); }), ]; @@ -59,19 +68,23 @@ export async function rr(pattern, replacement, config = {}) { ...config, }; alias(options); + status = await engine(options); // Like the CLI, an error gives the exit code 1 when the run is done - if (await engine(options)) { + if (status.errors.length) { result.exit = 1; } } catch (e) { - if (e !== halt) { + // Like the CLI, a panic stops the run with the exit code 1 + if (!e?.panic) { throw e; } + status = e.status; + result.exit = 1; } finally { spies.forEach((spy) => spy.mockRestore()); } - return result; + return {result, status}; } // What the content of the files gives by default: a line break after each item, unless it already ends with one diff --git a/test/js/output.test.js b/test/js/output.test.js index f948c923..4aef5514 100644 --- a/test/js/output.test.js +++ b/test/js/output.test.js @@ -61,9 +61,9 @@ describe('--output', () => { expect((await rr('o', 'x', {...config, quietTotal: true})).out).toBe('fxxbar\n'); }); - test('with --verbose and --debug the chatter goes to stderr only', async () => { + test('with --verbose the chatter goes to stderr only', async () => { fs.writeFileSync('my.file', 'foobar'); - const config = {includeGlob: ['my.file'], output: true, verbose: true, debug: true}; + const config = {includeGlob: ['my.file'], output: true, verbose: true}; const result = await rr('o', 'x', config); expect(result.out).toBe('fxxbar\n'); expect(result.err).toContain('Output result from: my.file'); diff --git a/test/js/quiet-total.test.js b/test/js/quiet-total.test.js index fdd01d50..379be139 100644 --- a/test/js/quiet-total.test.js +++ b/test/js/quiet-total.test.js @@ -30,15 +30,10 @@ describe('--quiet-total', () => { }); describe('combined', () => { - test('with --quiet and --verbose it stays silent', async () => { + test('with --verbose it stays silent, as --quiet-total wins', async () => { fs.writeFileSync('my.file', 'foobar'); - const config = {includeGlob: ['my.file'], quietTotal: true, quiet: true, verbose: true}; - expect((await rr('o', 'x', config)).err).toBe(''); - }); - - test('with --debug the debug info is still shown', async () => { - const result = await rr('o', 'x', {pipedData: 'foobar', quietTotal: true, debug: true}); - expect(result.err).toContain('Work on content from: Piped data'); + const config = {includeGlob: ['my.file'], quietTotal: true, verbose: true}; + expect(await rr('o', 'x', config)).toEqual({out: '', err: '', exit: null}); }); test.each(['output', 'outputMatch'])('with --%s stdout is not affected', async (flag) => { diff --git a/test/js/quiet.test.js b/test/js/quiet.test.js index 93878e3d..95539ae5 100644 --- a/test/js/quiet.test.js +++ b/test/js/quiet.test.js @@ -31,18 +31,15 @@ describe('--quiet', () => { }); describe('combined', () => { - test('with --verbose it stays quiet', async () => { + test('with --verbose the chatter is still shown, as it was asked for', async () => { fs.writeFileSync('my.file', 'foobar'); - expect( - (await rr('o', 'x', {includeGlob: ['my.file'], quiet: true, verbose: true})).err - ).toBe(''); - }); - - test('with --debug the debug info is still shown', async () => { - fs.writeFileSync('my.file', 'foobar'); - const result = await rr('o', 'x', {includeGlob: ['my.file'], quiet: true, debug: true}); - expect(result.err).toContain('Work on content from: my.file'); + const result = await rr('o', 'x', { + includeGlob: ['my.file'], + quiet: true, + verbose: true, + }); expect(result.out).toBe(''); + expect(result.err).toContain('Open sync: my.file'); }); test.each(['output', 'outputMatch'])('with --%s stdout is not affected', async (flag) => { diff --git a/test/js/verbose.test.js b/test/js/verbose.test.js index 02c438e3..8bce918d 100644 --- a/test/js/verbose.test.js +++ b/test/js/verbose.test.js @@ -6,7 +6,9 @@ describe('--verbose', () => { test('tells what is going on', async () => { fs.writeFileSync('my.file', 'foobar'); const result = await rr('o', 'x', {includeGlob: ['my.file'], verbose: true}); - expect(result.err).toBe('1 files found\nOpen sync: my.file\n'); + expect(result.err).toBe( + '1 files found\nOpen sync: my.file\nWork on content from: my.file\nWrite new content to: my.file\n' + ); expect(result.out).toBe('my.file\n'); }); @@ -45,26 +47,16 @@ describe('--verbose', () => { }); describe('combined', () => { - test('with --quiet or --quiet-total nothing is told', async () => { + test('is not hidden by --quiet, as it was asked for', async () => { fs.writeFileSync('my.file', 'foobar'); - const config = {includeGlob: ['my.file'], verbose: true}; - expect((await rr('o', 'x', {...config, quiet: true})).err).toBe(''); - expect((await rr('x', 'o', {...config, quietTotal: true})).err).toBe(''); - }); - - test('with --debug every step and the full config is shown', async () => { - const result = await rr('o', 'x', {pipedData: 'foobar', verbose: true, debug: true}); - expect(result.err).toContain('"Displaying steps for:"'); - expect(result.err).toContain('"pattern": "o"'); - // The tests are run by Bun, and Bun has JSC - expect(result.err).toContain('"Get final regex with engine: JSC"'); - expect(result.err).toContain('"gim"'); - expect(result.out).toBe('fxxbar'); + const config = {includeGlob: ['my.file'], verbose: true, quiet: true}; + expect((await rr('o', 'x', config)).err).toContain('Open sync: my.file'); }); - test('with --debug the steps are not shown without --verbose', async () => { - const result = await rr('o', 'x', {pipedData: 'foobar', debug: true}); - expect(result.err).not.toContain('Displaying steps for'); + test('is hidden by --quiet-total, as it wins', async () => { + fs.writeFileSync('my.file', 'foobar'); + const config = {includeGlob: ['my.file'], verbose: true, quietTotal: true}; + expect((await rr('o', 'x', config)).err).toBe(''); }); test('with --output the result is still clean', async () => { @@ -75,7 +67,9 @@ describe('--verbose', () => { output: true, }); expect(result.out).toBe('fxxbar\n'); - expect(result.err).toBe('1 files found\nOpen sync: my.file\nmy.file\n'); + expect(result.err).toBe( + '1 files found\nOpen sync: my.file\nWork on content from: my.file\nOutput result from: my.file\nmy.file\n' + ); }); test('with --replacement-js piped data counts as used', async () => { diff --git a/test/js/void-async.test.js b/test/js/void-async.test.js index 26dd7690..df3163f3 100644 --- a/test/js/void-async.test.js +++ b/test/js/void-async.test.js @@ -7,32 +7,32 @@ import {rr} from './helper.js'; describe('--void-async', () => { test('is done with a file before the next one is opened', async () => { ['a', 'b'].forEach((name) => fs.writeFileSync(name + '.file', 'foo')); - const config = {includeGlob: ['*.file'], voidAsync: true, debug: true}; + const config = {includeGlob: ['*.file'], voidAsync: true, verbose: true}; const result = await rr('o', 'x', config); expect(result.err.trim().split('\n')).toEqual([ - '"2 files found"', - '"Open sync: a.file"', - '"Work on content from: a.file"', - '"Write new content to: a.file"', - '"Open sync: b.file"', - '"Work on content from: b.file"', - '"Write new content to: b.file"', + '2 files found', + 'Open sync: a.file', + 'Work on content from: a.file', + 'Write new content to: a.file', + 'Open sync: b.file', + 'Work on content from: b.file', + 'Write new content to: b.file', ]); expect(result.out).toBe('a.file\nb.file\n'); }); test('is not the default: all files are opened before the first one is done', async () => { ['a', 'b'].forEach((name) => fs.writeFileSync(name + '.file', 'foo')); - const config = {includeGlob: ['*.file'], voidAsync: false, debug: true}; + const config = {includeGlob: ['*.file'], voidAsync: false, verbose: true}; const result = await rr('o', 'x', config); const lines = result.err.split('\n'); expect(lines.slice(0, 3)).toEqual([ - '"2 files found"', - '"Open async: a.file"', - '"Open async: b.file"', + '2 files found', + 'Open async: a.file', + 'Open async: b.file', ]); // Either file can be read first - expect(lines[3]).toMatch(/^"Work on content from: [ab]\.file"$/); + expect(lines[3]).toMatch(/^Work on content from: [ab]\.file$/); expect(result.out.split('\n').sort()).toEqual(['', 'a.file', 'b.file']); expect(['a', 'b'].map((name) => fs.readFileSync(name + '.file', 'utf8'))).toEqual([ 'fxx', From 9329424d80e910f9994eea73368e684b5e53905e Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 05:10:31 +1100 Subject: [PATCH 2/2] Keep file globs that read as numbers as strings to close #733 --- src/cli.ts | 2 ++ test/js/basics.test.js | 28 +++++++++++++++++++--------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index bd68b44b..a9573c9f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,6 +26,8 @@ if (process.argv.length < 4) { } const yargs = require('yargs') + // A file can be named with only digits, like `2024`, so each glob is kept as the string that was given + .parserConfiguration({'parse-positional-numbers': false}) .strict() .usage( diff --git a/test/js/basics.test.js b/test/js/basics.test.js index 496895fe..bcde49e4 100644 --- a/test/js/basics.test.js +++ b/test/js/basics.test.js @@ -182,15 +182,25 @@ describe('files', () => { }); // The file names are read by src/cli.ts, so this runs the CLI itself - test.todo('BUG #733: can have a name that is only a number', async () => { - await Bun.write('2024', 'foobar'); - const cli = path.join(import.meta.dir, '../../src/cli.ts'); - const {stdout, exitCode} = Bun.spawnSync([process.execPath, cli, 'o', 'x', '2024'], { - stdin: 'ignore', - }); - expect({out: stdout.toString(), exit: exitCode}).toEqual({out: '2024\n', exit: 0}); - expect(await Bun.file('2024').text()).toBe('fxxbar'); - }); + test.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']});