Skip to content
Closed
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
43 changes: 43 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,27 @@ const yargs = require('yargs')
.describe('Q', 'Never display errors or info')
.alias('Q', 'quiet-total')

.boolean('null')
.alias('null', '0')
.describe(
'null',
'End each changed file name with a NUL byte instead of a line break (safe for paths with spaces or newlines). Conflicts with --name-delimiter.'
)

.string('name-delimiter')
.describe(
'name-delimiter',
'Put this string between changed file names instead of a line break. No delimiter after the last name. Escape sequences and €/§ aliases work like in the replacement. Conflicts with --null.'
)
.conflicts('null', 'name-delimiter')

.boolean('a')
.alias('a', 'absolute-path')
.describe(
'a',
'Print each changed file name as a full path from `/`. Default is to print the path the way it was given.'
)

.boolean('H')
.describe('H', 'Halt on first error')
.alias('H', 'halt')
Expand Down Expand Up @@ -407,6 +428,12 @@ function unescapeString(str = '') {

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

// Ending after each changed name: NUL with --null, custom --name-delimiter, else newline
const customNameDelimiter =
!config.null && undefined !== config.nameDelimiter && null !== config.nameDelimiter;
config.nameDelimiterBetween = !!customNameDelimiter;
const rawNameDelimiter = customNameDelimiter ? config.nameDelimiter : null;

if (config.replacementJs) {
config.replacement = replacement;
} else {
Expand All @@ -421,6 +448,22 @@ function unescapeString(str = '') {
}
}

if (config.null) {
config.nameDelimiter = '\0';
} else if (customNameDelimiter) {
// Same treatment as the replacement (§ before unescape; €/§ again in the engine)
try {
config.nameDelimiter = unescapeString(
config.voidSection ? rawNameDelimiter : String(rawNameDelimiter).replace(/§/g, '\\')
);
} catch (e) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we putting this in a try catch?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept only around unescapeString, same as the replacement — invalid sequences like \x4 need a clean error instead of a stack trace. Fixed in 8150175.

outputConfig(config);
return die('Invalid escape sequence in --name-delimiter:', rawNameDelimiter);
}
} else {
config.nameDelimiter = '\n';
}

/*if(Boolean(process.stdout.isTTY)){
config.output = true;
}*/
Expand Down
32 changes: 27 additions & 5 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const globs = require('fast-glob');

const now = new Date();

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

const re = {
euro: /€/g,
Expand All @@ -28,6 +28,18 @@ const runtimeEngine = process.versions.bun ? 'JSC' : 'V8';

// Returns a promise that resolves when all files are done
export async function engine(config: any = {engine: 'default'}) {
// Ending after each changed name (CLI may already have set these)
if (config.null) {
config.nameDelimiter = '\0';
config.nameDelimiterBetween = false;
} else if (null == config.nameDelimiter || undefined === config.nameDelimiter) {
config.nameDelimiter = '\n';
config.nameDelimiterBetween = false;
} else if (undefined === config.nameDelimiterBetween) {
// Engine called directly with a custom delimiter
config.nameDelimiterBetween = true;
}

outputConfig(config);

step('Displaying steps for:');
Expand All @@ -41,6 +53,9 @@ export async function engine(config: any = {engine: 'default'}) {

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

// nameDelimiter is treated like the replacement (€ / § aliases)
config.nameDelimiter = replacePlaceholders(config.nameDelimiter, config);

step(config);

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

await Promise.all(work);
flushChangedNames();
}

function openFile(file, config) {
Expand Down Expand Up @@ -120,11 +136,16 @@ function doReplacement(_file_rr: string, _config_rr: any, _data_rr: string) {

debug('Write new content to: ' + _file_rr);

// Name as given, or full path from `/` with --absolute-path (same as `file` in --replacement-js)
const nameToPrint = _config_rr.absolutePath
? path.normalize(path.join(process.cwd(), _file_rr))
: _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(() => info(nameToPrint), error);
}

//Make sure data is always on disk
Expand All @@ -142,14 +163,14 @@ function doReplacement(_file_rr: string, _config_rr: any, _data_rr: string) {
} catch (e) {
return error(e);
}
return info(_file_rr);
return info(nameToPrint);
}

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(() => info(nameToPrint), error);
}

function handlePipedData(config) {
Expand Down Expand Up @@ -651,5 +672,6 @@ function getFilePaths(conf) {
filesToInclude = filesToInclude.filter((el) => !filesToExclude.includes(el));
}

return filesToInclude.sort();
// Do not process the same file multiple times (overlapping globs)
return [...new Set(filesToInclude)].sort();
}
40 changes: 38 additions & 2 deletions src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,56 @@ font.red = font.green = font.gray = (str) => str;

let config: any = null;

// Same path must not be reported twice (e.g. overlapping globs)
const reported = new Set();

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No no no. We make sure we dont process the same file multiple times.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8150175. Dropped namePrinted. Overlapping globs are deduped in getFilePaths, and info skips a path already reported.

export const outputConfig = function (_config) {
config = _config;
reported.clear();
if (config) {
config.changedNames = [];
}
};

// Names of files changed in place go to stdout (like grep -l / gofmt -l / prettier --write).
// config.nameDelimiter is '\n' by default, '\0' with --null, or the custom --name-delimiter.
// A custom delimiter is joined between names only (no trailing delimiter); see flushChangedNames.
export const info = function (msg, data = '') {
if (config.quiet || config.quietTotal) {
return;
}
console.error(font.gray(msg), data);

const name =
'' === data || undefined === data || null === data ? String(msg) : String(msg) + data;
if (reported.has(name)) {
return;
}
reported.add(name);

if (config.nameDelimiterBetween) {
config.changedNames.push(name);
return;
}

process.stdout.write(name + config.nameDelimiter);
};

// Write custom --name-delimiter names in one go (no delimiter after the last name)
export const flushChangedNames = function () {
if (config.quiet || config.quietTotal || !config.nameDelimiterBetween) {
return;
}
if (config.changedNames && config.changedNames.length) {
process.stdout.write(config.changedNames.join(config.nameDelimiter));
}
};

// Verbose chatter stays on stderr (errors, --verbose, --debug)
export const chat = function (msg, data = '') {
if (config.verbose) {
info(msg, data);
if (!(config.quiet || config.quietTotal)) {
console.error(font.gray(msg), data);
}
} else {
debug(msg + ' ' + data);
}
Expand Down
58 changes: 58 additions & 0 deletions test/cli/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,64 @@ rexreplace "b" "\n" my.file
assert "cat my.file" "foo\nar"


# Changed file names go to stdout; stderr stays clean (unless verbose/debug/errors)
reset
rexreplace o x my.file > stdout.log 2> stderr.log
assert "cat my.file" "fxxbar"
assert "cat stdout.log" "my.file"
assert "cat stderr.log" ""

reset
rexreplace o x my.file --output > stdout.log 2> stderr.log
assert "cat my.file" "foobar"
assert "cat stdout.log" "fxxbar"
assert "cat stderr.log" ""

reset
rexreplace o x my.file --verbose > stdout.log 2> stderr.log
assert "cat my.file" "fxxbar"
assert "cat stdout.log" "my.file"
assert_contains "cat stderr.log" "files found"

reset
assert "rexreplace a x my.file your.file --name-delimiter ', ' --void-async" "my.file, your.file"
assert "cat my.file" "foobxr"
assert "cat your.file" "xbc123"

reset
rexreplace o x my.file -0 > stdout.log 2> stderr.log
assert "cat my.file" "fxxbar"
assert "cat stderr.log" ""
# assert() cannot keep NUL bytes; check size: "my.file" (7) + NUL (1)
assert "wc -c < stdout.log | tr -d ' '" "8"

reset
assert_failure "rexreplace o x my.file --null --name-delimiter ','"

# --absolute-path / -a prints the full path; default keeps the path as given
reset
mkdir -p docs
echo foo > docs/a.md
rexreplace o x 'docs/*.md' > stdout.log 2> stderr.log
assert "cat stdout.log" "docs/a.md"
assert "cat stderr.log" ""
assert "cat docs/a.md" "fxx"

reset
mkdir -p docs
echo foo > docs/a.md
rexreplace o x 'docs/*.md' --absolute-path > stdout.log 2> stderr.log
assert_startswith "cat stdout.log" "/"
assert_endswith "cat stdout.log" "/docs/a.md"
assert "cat stderr.log" ""

reset
echo foobar > my.file
rexreplace o x my.file -a > stdout.log 2> stderr.log
assert_startswith "cat stdout.log" "/"
assert_endswith "cat stdout.log" "/my.file"


# Replacement with a real line break, an escaped single quote or a lone backslash at the end (#691)
reset
rexreplace "b" $'\n' my.file
Expand Down
8 changes: 7 additions & 1 deletion test/fuzzy/crossover.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,13 @@ describe('crossover', () => {

expect(result.exit, note).toBe(null);
expect(result.out, note).toBe(
config.outputMatch ? matches : config.output ? replaced : ''
config.outputMatch
? matches
: config.output
? replaced
: written && replaced !== text && !config.quiet && !config.quietTotal
? file + '\n'
: ''
);

const expected = written ? replaced : text;
Expand Down
56 changes: 56 additions & 0 deletions test/js/absolute-path.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {describe, test, expect} from 'bun:test';
import fs from 'fs';
import path from 'path';
import {rr} from './helper.js';

describe('--absolute-path', () => {
test('prints the full path of each changed file', async () => {
fs.mkdirSync('docs');
fs.writeFileSync('docs/a.md', 'foo');
const result = await rr('o', 'x', {
includeGlob: ['docs/*.md'],
absolutePath: true,
});
expect(result.out).toBe(path.normalize(path.join(process.cwd(), 'docs/a.md')) + '\n');
expect(result.err).toBe('');
expect(fs.readFileSync('docs/a.md', 'utf8')).toBe('fxx');
});

test('is not the default: the path is printed the way it was given', async () => {
fs.mkdirSync('docs');
fs.writeFileSync('docs/a.md', 'foo');
const result = await rr('o', 'x', {includeGlob: ['docs/*.md']});
expect(result.out).toBe('docs/a.md\n');
expect(result.err).toBe('');
});

test('still respects --quiet', async () => {
fs.writeFileSync('my.file', 'foobar');
expect(
await rr('o', 'x', {includeGlob: ['my.file'], absolutePath: true, quiet: true})
).toEqual({out: '', err: '', exit: null});
});

test('works with --null', async () => {
fs.writeFileSync('my.file', 'foobar');
const full = path.normalize(path.join(process.cwd(), 'my.file'));
const result = await rr('o', 'x', {
includeGlob: ['my.file'],
absolutePath: true,
null: true,
});
expect(result.out).toBe(full + '\0');
});

test('works with --name-delimiter on several files', async () => {
['a', 'b'].forEach((name) => fs.writeFileSync(name + '.file', 'foo'));
const a = path.normalize(path.join(process.cwd(), 'a.file'));
const b = path.normalize(path.join(process.cwd(), 'b.file'));
const result = await rr('o', 'x', {
includeGlob: ['*.file'],
absolutePath: true,
nameDelimiter: '|',
});
expect(result.out).toBe(a + '|' + b);
});
});
9 changes: 5 additions & 4 deletions test/js/basics.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,11 @@ describe('replacement', () => {
});

describe('files', () => {
test('are changed in place and named on stderr', async () => {
test('are changed in place and named on stdout', async () => {
fs.writeFileSync('my.file', 'foobar');
const result = await rr('o', 'x', {includeGlob: ['my.file']});
expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar');
expect(result).toEqual({out: '', err: 'my.file \n', exit: null});
expect(result).toEqual({out: 'my.file\n', err: '', exit: null});
expect(fs.readdirSync('.')).toEqual(['my.file']);
});

Expand Down Expand Up @@ -129,7 +129,8 @@ describe('files', () => {
includeGlob: ['my.file', '*.file', './my.file', 'my.*'],
});
expect(fs.readFileSync('my.file', 'utf8')).toBe('foooobar');
expect(result.err).toBe('my.file \n');
expect(result.out).toBe('my.file\n');
expect(result.err).toBe('');
});

test('are handled in alphabetical order', async () => {
Expand Down Expand Up @@ -207,7 +208,7 @@ describe('piped data', () => {
test('is ignored if files are given', async () => {
fs.writeFileSync('my.file', 'foobar');
const result = await rr('o', 'x', {pipedData: 'boo', includeGlob: ['my.file']});
expect(result.out).toBe('');
expect(result.out).toBe('my.file\n');
expect(fs.readFileSync('my.file', 'utf8')).toBe('fxxbar');
});

Expand Down
Loading