From b180358b3a40efc095da1da60bc0a910bc3120a8 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Fri, 23 Oct 2026 23:28:37 +1100 Subject: [PATCH 1/3] Find named groups with letters that are not ASCII in --js --- src/engine.ts | 13 ++++++++----- test/fix/816.test.js | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 test/fix/816.test.js diff --git a/src/engine.ts b/src/engine.ts index 182dca19..9b434add 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -835,15 +835,18 @@ function countFile(config, text, result) { // The code uses a named group of the pattern, like rr_year for (?…) function usesNamedGroups(config) { return namedGroups(config.regex).some(name => - new RegExp('(^|[^\\w$])rr_' + name.replace(/\$/g, '\\$') + '(?![\\w$])').test( - config.replacementOri - ) + new RegExp( + '(^|[^\\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 (?…) +// The names of the named groups of a regex, like year for (?…). A name can have any letter, like año. function namedGroups(regex) { - return [...regex.source.matchAll(/\(\?<([A-Za-z_$][\w$]*)>/g)].map(([, name]) => name); + return [ + ...regex.source.matchAll(/\(\?<([\p{ID_Start}$_][\p{ID_Continue}$\u200C\u200D]*)>/gu), + ].map(([, name]) => name); } // 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, diff --git a/test/fix/816.test.js b/test/fix/816.test.js new file mode 100644 index 00000000..286b6142 --- /dev/null +++ b/test/fix/816.test.js @@ -0,0 +1,19 @@ +// 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', '(?\\d)', '"[" + rr_año + "]"', 'a[1]b[2]'], + ['日', '(?<日>\\d)', 'rr_日 * 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('(?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); + }); +}); From 5f39a8ef840568e1c42ec62e9a250d6163335a01 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 00:19:17 +1100 Subject: [PATCH 2/3] Read the named groups from the regex, and see rr_year_ on its own --- src/engine.ts | 10 ++++++---- test/fix/816.test.js | 11 +++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index 9b434add..a034e554 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -836,17 +836,19 @@ function countFile(config, text, result) { function usesNamedGroups(config) { return namedGroups(config.regex).some(name => new RegExp( - '(^|[^\\p{ID_Continue}$])rr_' + name.replace(/\$/g, '\\$') + '(?![\\p{ID_Continue}$])', + // 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 (?…). A name can have any letter, like año. +// They come from the regex itself, so text that only looks like a group, like \(? or [(?], 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(/\(\?<([\p{ID_Start}$_][\p{ID_Continue}$\u200C\u200D]*)>/gu), - ].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, diff --git a/test/fix/816.test.js b/test/fix/816.test.js index 286b6142..11cde209 100644 --- a/test/fix/816.test.js +++ b/test/fix/816.test.js @@ -16,4 +16,15 @@ describe('#816 named groups with letters that are not ASCII', () => { 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('\\(?b', '"z"', {...config, pipedData: 'x(?b'})).out).toBe('x(?z'); + expect((await rr('[(?]', '"z"', {...config, pipedData: 'ab'})).out).toBe('zb'); + }); + + 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('(?\\d+)', 'rr_year_ + "|"', config)).out).toBe('2024 |'); + }); }); From 8bf85921528a61f090217f201b55537ae1793b18 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Sat, 24 Oct 2026 02:38:38 +1100 Subject: [PATCH 3/3] Test named groups with the space cousin, outside the BMP, with $, in uppercase, with a non-ASCII digit, and rr_rr_year --- test/fix/816.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/fix/816.test.js b/test/fix/816.test.js index 11cde209..60fe0ff9 100644 --- a/test/fix/816.test.js +++ b/test/fix/816.test.js @@ -7,6 +7,11 @@ describe('#816 named groups with letters that are not ASCII', () => { test.each([ ['año', '(?\\d)', '"[" + rr_año + "]"', 'a[1]b[2]'], ['日', '(?<日>\\d)', 'rr_日 * 2', 'a2b4'], + ['año with a space at the end', '(?\\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', '(?\\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); }); @@ -23,6 +28,12 @@ describe('#816 named groups with letters that are not ASCII', () => { expect((await rr('[(?]', '"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('(?\\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('(?\\d+)', 'rr_year_ + "|"', config)).out).toBe('2024 |');