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
15 changes: 10 additions & 5 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -835,15 +835,20 @@ function countFile(config, text, result) {
// The code uses a named group of the pattern, like rr_year for (?<year>…)
function usesNamedGroups(config) {
return namedGroups(config.regex).some(name =>
new RegExp('(^|[^\\w$])rr_' + name.replace(/\$/g, '\\$') + '(?![\\w$])').test(
config.replacementOri
)
new RegExp(
// rr_year_ is the group with a space at the end, so the code uses the group then too
'(^|[^\\p{ID_Continue}$])rr_' + name.replace(/\$/g, '\\$') + '_?(?![\\p{ID_Continue}$])',
'u'
).test(config.replacementOri)
);
}

// The names of the named groups of a regex, like year for (?<year>…)
// The names of the named groups of a regex, like year for (?<year>…). A name can have any letter, like año.
// They come from the regex itself, so text that only looks like a group, like \(?<a> or [(?<a>], is not one.
// With | at the end the regex matches an empty text, and the match has all the groups.
function namedGroups(regex) {
return [...regex.source.matchAll(/\(\?<([A-Za-z_$][\w$]*)>/g)].map(([, name]) => name);
const match = new RegExp(regex.source + '|', regex.flags.replace(/[gy]/g, '')).exec('');
return Object.keys(match?.groups ?? {});
}

// The numbers of --js and their cousins: $n0 is $n with zeros in front to 2 digits, $n00 to 3 and so on to 10,
Expand Down
41 changes: 41 additions & 0 deletions test/fix/816.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// https://github.com/mathiasrw/rexreplace/issues/816
// A named group with a letter that is not ASCII was not seen in the code, so the code ran once per file, where the group was not there.
import {describe, test, expect} from 'bun:test';
import {rr} from '../helper.js';

describe('#816 named groups with letters that are not ASCII', () => {
test.each([
['año', '(?<año>\\d)', '"[" + rr_año + "]"', 'a[1]b[2]'],
['日', '(?<日>\\d)', 'rr_日 * 2', 'a2b4'],
['año with a space at the end', '(?<año>\\d)', 'rr_año_ + "|"', 'a1 |b2 |'],
['𝒜, outside the BMP', '(?<𝒜>\\d)', 'rr_𝒜 * 2', 'a2b4'],
['$é, with $', '(?<$é>\\d)', 'rr_$é * 2', 'a2b4'],
['Æble, in uppercase', '(?<Æble>\\d)', 'rr_Æble * 2', 'a2b4'],
['x٣, with a digit that is not ASCII', '(?<x٣>\\d)', 'rr_x٣ * 2', 'a2b4'],
])('can be used, like %s', async (name, pattern, code, out) => {
expect((await rr(pattern, code, {pipedData: 'a1b2', replacementJs: true})).out).toBe(out);
});

test('can not end with _ either', async () => {
const result = await rr('(?<año_>x)', 'rr_año', {pipedData: 'x', replacementJs: true});
expect(result.err).toStartWith(' ❌ A named group can not end with _: año_');
expect(result.exit).toBe(3);
});

test('are read from the regex, so text that only looks like a group is not one', async () => {
const config = {replacementJs: true};
expect((await rr('\\(?<a_>b', '"z"', {...config, pipedData: 'x(?<a_>b'})).out).toBe('x(?z');
expect((await rr('[(?<a_>]', '"z"', {...config, pipedData: 'ab'})).out).toBe('zb');
});

test('are only seen as the whole name, so rr_rr_year is not the group year', async () => {
const result = await rr('(?<year>\\d)', 'rr_rr_year', {pipedData: '1', replacementJs: true});
expect(result.err).toContain('ReferenceError: rr_rr_year is not defined');
expect(result.exit).toBe(3);
});

test('can be used with a space at the end on its own, like rr_year_', async () => {
const config = {pipedData: '2024', replacementJs: true};
expect((await rr('(?<year>\\d+)', 'rr_year_ + "|"', config)).out).toBe('2024 |');
});
});
Loading