diff --git a/src/engine.ts b/src/engine.ts index 182dca19..a034e554 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -835,15 +835,20 @@ 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( + // 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 (?…) +// 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(/\(\?<([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, diff --git a/test/fix/816.test.js b/test/fix/816.test.js new file mode 100644 index 00000000..60fe0ff9 --- /dev/null +++ b/test/fix/816.test.js @@ -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', '(?\\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); + }); + + 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); + }); + + 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('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 |'); + }); +});