Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 63 additions & 4 deletions README.md

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions devguide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Dev guide

How we like the code in RexReplace to be.

## The code

- **Slim and simple over features.** If a feature makes the code complicated, we drop it instead of building it.
- **Beautiful code: strict, lean, focused.** Each function does one thing. No extra options or "just in case" code.
- **Read like the code around it.** Use the same names, idioms and amount of comments as the code next to it.
- **Comments say why, not what.** Write them in plain, simple English. Leave the comment out if the code already says it.
- **No sloppy shortcuts.** If two cases need different calls, write a plain `if`/`else` instead of one call that only half fits both.
- **Everything is printed with `write()` in `src/output.ts`,** so `--nl` knows how each stream ends.
- **stdout is the result.** That is the names of the affected files, or the content from `--output`, `--output-match` or piped data. Everything else goes to stderr, so the result can always be piped.

## Telling what is going on

All output goes through `src/output.ts`. Pick the function by who the message is for:

| Function | Shown when | For | Example |
| --- | --- | --- | --- |
| `print(item, delimiter, tail, stream)` | Always | The result. Puts the delimiter after each item, or only between them without a tail. Without a delimiter it is a line break, unless the item already ends with one. | A name, the content of a file, a match |
| `printDone()` | At the end of a run, also when it is stopped | Ends stdout and stderr with a line break when `--nl` is set | |
| `info(name)` | Unless `--quiet` | The name of an affected file. stdout, or stderr when stdout has content. | `a.md` |
| `warn(msg)` | Unless `--quiet` | Something is likely a mistake, but the run goes on | `Data is piped in, but no flag tells what to do with it` |
| `error(msg, data)` | Unless `--quiet-total` | Something went wrong. The run goes on, unless `--halt` is set. | `File not found: a.md` |
| `die(msg, data, help)` | Unless `--quiet-total` | Something went wrong and the run can not go on. Exits with 1. | `No data piped into replacement` |
| `chat(msg)` | `--verbose` (or `--debug`) | The user: what happens to each file | `Open sync: a.md`, `Nothing changed in: a.md` |
| `debug(data)` | `--debug` | The developer: what happens inside, at points that matter | `Work on content from: a.md` |
| `step(data)` | `--verbose` and `--debug` | The developer: a trace of every step, with the data at that step | `Get final regex`, the full config |

- The three levels of telling go from little to a lot: `chat`, then `debug`, then `step`.
- `chat` falls back to `debug` without `--verbose`, so `--debug` alone also shows what happens to each file.
- `debug` and `step` print their data as JSON. That keeps line breaks and spaces visible.
- `chat`, `debug` and `step` only ever write to stderr.

## Tests

- **Tests are strict, lean and focused too.** One thing per test, and the name of the test says what it is.
- Expect the full result where it matters (`{out, err, exit}`), so nothing extra slips through.
- A known bug is a `test.todo('BUG #<issue>: ...')` until it is fixed. Then it becomes a real test.
- A test for an issue goes in `test/js/fix/<issue>.test.js`.
- Use Bun native file IO (`Bun.write`, `Bun.file`) and `Bun.spawnSync` in new tests (#734).
- Names in tests say what they hold, like `expectedOutput`, not `named`.
- Async work can finish in any order. Do not expect an order the code does not promise.

## Issues and pull requests

Write in plain, simple English, with sections like `## Today`, `## Change`, `## To decide`, `## Good to know` and `## Reference`.
23 changes: 23 additions & 0 deletions src/alias.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// The `€` and `§` aliases are for the shell, where `$` and `\` are hard to type, so the CLI uses them and not the engine.
// Globs, file names and piped data never use them, as `€` and `§` can be part of the text.
export function alias(config) {
const swap = (str) => {
if (!str) {
return str;
}
if (!config.voidEuro) {
str = str.replace(/€/g, '$');
}
if (!config.voidSection) {
str = str.replace(/§/g, '\\');
}
return str;
};

config.pattern = swap(config.pattern);
config.replacement = swap(config.replacement);
config.excludeRe = config.excludeRe.map(swap);
config.delimiter = swap(config.delimiter);
config.nameDelimiter = swap(config.nameDelimiter);
config.contentDelimiter = swap(config.contentDelimiter);
}
95 changes: 85 additions & 10 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import * as rexreplace from './engine.ts';
import {outputConfig, die} from './output.ts';
import {alias} from './alias.ts';

// Make it visible when Bun is the runtime (like with rexreplace-bun) as the regex engine is then not the one from Node.
// Added as semver build metadata (`+bun`) as a `-bun` would make it a prerelease.
Expand Down Expand Up @@ -110,6 +111,64 @@ const yargs = require('yargs')
.describe('Q', 'Never display errors or info')
.alias('Q', 'quiet-total')

.boolean('a')
.describe('a', 'Print the full path of the named files instead of the relative path.')
.alias('a', 'absolute-path')

.boolean('name-all')
.describe(
'name-all',
'Print the names of all files found by the globs, also the ones where nothing was changed.'
)

.string('name-delimiter')
.describe(
'name-delimiter',
'String printed between the names of the files. Default is a line break, unless the name already ends with one.'
)
.string('content-delimiter')
.describe(
'content-delimiter',
'String printed between the content of each file for --output, or between the matches for --output-match. Default is a line break, unless the content already ends with one.'
)

.string('delimiter')
.describe(
'delimiter',
'Sets --name-delimiter and --content-delimiter to the same value. Supports escape sequences like `\\t`, `\\n` and the `€` and `§` aliases.'
)

.boolean('null')
.describe(
'null',
"Shorthand for --delimiter '\\0'. Useful for piping content with spaces or line breaks (like for `xargs -0`). The specific delimiter values win over --delimiter, and --delimiter wins over --null."
)

.boolean('name-delimiter-tail')
.describe(
'name-delimiter-tail',
'Print the name delimiter after the last name. Defaults to true. Use --no-name-delimiter-tail or --name-delimiter-tail=false to not print the delimiter after the last name.'
)

.boolean('content-delimiter-tail')
.describe(
'content-delimiter-tail',
'Print the content delimiter after the last content. Defaults to true, but to false when piped data is printed, so it is left as it came in. Matches from --output-match always get it, from a file or a pipe.'
)

.boolean('tail')
.describe(
'tail',
'Shorthand for setting both --name-delimiter-tail and --content-delimiter-tail. Use --no-tail to not print the delimiter after the last name and the last content. The specific tail flags win over --tail.'
)

.boolean('l')
.describe(
'l',
'Let what is printed to stdout and stderr (the names, or the content from -o, -m or piped data) always end with a new line. No extra new line if it already ends with a new line, or if nothing was printed.'
)
.alias('l', 'nl')

.boolean('H')
.describe('H', 'Halt on first error')
.alias('H', 'halt')
Expand All @@ -123,14 +182,14 @@ const yargs = require('yargs')
.boolean('€')
.describe(
'€',
"Void having '€' as alias for '$' in the pattern, replacement and --exclude-re parameters (the alias is never used for globs or piped data)"
"Void having '€' as alias for '$'. The alias is used in all inputs, but never in globs, file names or piped data."
)
.alias('€', 'void-euro')

.boolean('§')
.describe(
'§',
"Void having '§' as alias for '\\' in the pattern, replacement and --exclude-re parameters (the alias is never used for globs or piped data)"
"Void having '§' as alias for '\\'. The alias is used in all inputs, but never in globs, file names or piped data."
)
.alias('§', 'void-section')

Expand Down Expand Up @@ -412,17 +471,33 @@ function unescapeString(str = '') {

config.excludeRe = [].concat(yargs.argv.excludeRe).filter(Boolean);

if (config.replacementJs) {
config.replacement = replacement;
} else {
config.replacement = replacement;

// The aliases come before the escape sequences are interpreted, so `§n` gives a line break just like `\n`
const typed = {...config};
alias(config);

// The error shows the value as it was typed
const unescape = (key, name) => {
try {
// `§` must be a `\` before the escape sequences are interpreted, so `§n` gives a line break just like `\n`
config.replacement = unescapeString(
config.voidSection ? replacement : replacement.replace(/§/g, '\\')
);
config[key] = unescapeString(config[key]);
} catch (e) {
return die('Invalid escape sequence in replacement:', replacement);
die('Invalid escape sequence in ' + name + ':', typed[key]);
}
};

if (!config.replacementJs) {
unescape('replacement', 'replacement');
}

if (config.delimiter) {
unescape('delimiter', '--delimiter');
}
if (config.nameDelimiter) {
unescape('nameDelimiter', '--name-delimiter');
}
if (config.contentDelimiter) {
unescape('contentDelimiter', '--content-delimiter');
}

/*if(Boolean(process.stdout.isTTY)){
Expand Down
99 changes: 59 additions & 40 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,20 @@ const globs = require('fast-glob');

const now = new Date();

import {outputConfig, step, debug, chat, info, warn, error, die} from './output.ts';
import {
outputConfig,
step,
debug,
chat,
info,
print,
printDone,
warn,
error,
die,
} from './output.ts';

const re = {
euro: /€/g,
section: /§/g,
mctime: /[mc]time/,
colon: /:/g,
capturedGroupRef: /\$\d/,
Expand Down Expand Up @@ -39,12 +48,28 @@ export async function engine(config: any = {engine: 'default'}) {

config.replacementOri = config.replacement;

// The most specific delimiter wins. Without one there is a line break after each item, unless it already ends with one.
const delimiter = config.delimiter ?? (config.null ? '\0' : null);
config.nameDelimiter = config.nameDelimiter ?? delimiter;
config.contentDelimiter = config.contentDelimiter ?? delimiter;

// --output-match wins over --output
config.output = config.output && !config.outputMatch;

config.regex = getFinalRegex(config.pattern, config) || '';

step(config);

if (handlePipedData(config)) {
return doReplacement('Piped data', config, config.pipedData);
const piped = handlePipedData(config);

// The more specific tail wins. Piped data that is printed is left as it came in, but matches are matches, from a file or a pipe.
config.nameDelimiterTail = config.nameDelimiterTail ?? config.tail ?? true;
config.contentDelimiterTail =
config.contentDelimiterTail ?? config.tail ?? !(piped && config.output);

if (piped) {
doReplacement('Piped data', config, config.pipedData);
return printDone();
}

config.files = getFilePaths(config);
Expand Down Expand Up @@ -72,6 +97,7 @@ export async function engine(config: any = {engine: 'default'}) {
.map((filepath) => openFile(filepath, config));

await Promise.all(work);
return printDone();
}

function openFile(file, config) {
Expand Down Expand Up @@ -99,19 +125,25 @@ function doReplacement(_file_rr: string, _config_rr: any, _data_rr: string) {
: _config_rr.replacement
);

// The output of matched strings is done from the replacement, so no need to continue
if (_config_rr.outputMatch) {
if (_config_rr.output || _config_rr.outputMatch) {
// With --output-match the matches are printed by the replacement
if (_config_rr.output) {
debug('Output result from: ' + _file_rr);
print(result, _config_rr.contentDelimiter, _config_rr.contentDelimiterTail);
}
// The file is not changed, but it is still named if its content would change
if ((_config_rr.nameAll || result !== _data_rr) && !_config_rr.dataIsPiped) {
info(_file_rr);
}
return;
}

if (_config_rr.output) {
debug('Output result from: ' + _file_rr);
return process.stdout.write(result);
}

// Nothing replaced = no need for writing file again
if (result === _data_rr) {
chat('Nothing changed in: ' + _file_rr);
if (_config_rr.nameAll) {
info(_file_rr);
}
return;
}

Expand Down Expand Up @@ -172,7 +204,8 @@ function handlePipedData(config) {

if (null !== config.pipedData && !config.pipedDataUsed) {
config.dataIsPiped = true;
config.output = true;
// Piped data is printed, unless only the matches are
config.output = !config.outputMatch;
return true;
}

Expand All @@ -181,7 +214,6 @@ function handlePipedData(config) {

function getFinalPattern(pattern, conf: any) {
step('Get final pattern');
pattern = replacePlaceholders(pattern, conf);

if (conf.literal) {
pattern = pattern.replace(re.regexSpecialChars, '\\$&');
Expand All @@ -202,8 +234,6 @@ function getFinalReplacement(replacement, conf: any) {
return oneLinerFromFile(fs.readFileSync(replacement,'utf8'));
}*/

replacement = replacePlaceholders(replacement, conf);

if (conf.replacementPipe) {
step('Piping replacement');
conf.pipedDataUsed = true;
Expand All @@ -220,19 +250,17 @@ function getFinalReplacement(replacement, conf: any) {
return die('outputMatch is only supported in node 6+');
}

return function () {
return function (match, ...rest) {
step(arguments);

if (arguments.length === 3) {
step('Printing full match');
process.stdout.write(arguments[0] + '\n');
return '';
}

for (var i = 1; i < arguments.length - 2; i++) {
process.stdout.write(arguments[i]);
}
process.stdout.write('\n');
// The groups are followed by the offset and the full text, and by the named groups if the pattern has any.
// Without groups the full match is printed, otherwise the groups are printed together.
const groups = rest.slice(0, 'object' === typeof rest[rest.length - 1] ? -3 : -2);
print(
groups.length ? groups.join('') : match,
conf.contentDelimiter,
conf.contentDelimiterTail
);
return '';
};
}
Expand Down Expand Up @@ -629,18 +657,6 @@ function localTimeString(dateObj = new Date()) {
).slice(-2)}.${('00' + dateObj.getMilliseconds()).slice(-3)}`;
}

function replacePlaceholders(str = '', conf: any) {
if (!conf.voidEuro) {
str = str.replace(re.euro, '$');
}

if (!conf.voidSection) {
str = str.replace(re.section, '\\');
}

return str;
}

function getFilePaths(conf) {
let {includeGlob, excludeGlob, excludeRe} = conf;

Expand All @@ -659,5 +675,8 @@ function getFilePaths(conf) {
filesToInclude = filesToInclude.filter((el) => !filesToExclude.includes(el));
}

return filesToInclude.sort();
filesToInclude.sort();

// With --absolute-path each file is known by its full path from here on, so that is the name that is printed
return conf.absolutePath ? filesToInclude.map((file) => path.resolve(file)) : filesToInclude;
}
Loading
Loading