fix(r): reject only a real (?< group in exportPattern, not one inside a character class

The linear-regex parser refused any pattern whose text contained "(?<", so
[(?<] and \(?<x were dropped as look-behind. Reject at the group the parser
reaches, which is never inside a class or after a backslash. A real look-behind
or named group is still refused, after any class, with the same linear parse.
This commit is contained in:
Mick Cooney 2026-10-08 17:57:48 +01:00
parent 3f786ef94a
commit 7980dba6ce
2 changed files with 48 additions and 7 deletions

View file

@ -325,15 +325,10 @@ const BRACES = /\{(\d+)(?:(,)(\d*))?\}/y;
class Parser {
private pos = 0;
private depth = 0;
/** `\k` is an identity escape only when the pattern has no named group. */
private readonly hasNamedGroup: boolean;
constructor(private readonly src: string) {
this.hasNamedGroup = src.includes('(?<');
}
constructor(private readonly src: string) {}
parse(): Node {
if (this.hasNamedGroup) reject('named groups and look-behind are unsupported');
const node = this.parseDisjunction();
if (this.pos < this.src.length) reject('unmatched )');
return node;
@ -414,6 +409,9 @@ class Parser {
this.pos++; // (
if (this.peek() === '?') {
// `(?:` is a plain group; `(?=`, `(?!`, `(?<` and everything else is unsupported or invalid.
// Only a `(` the parser reaches here opens a group: one inside a character class
// (`[(?<]`) or after a backslash (`\(?<`) is a literal and never gets this far.
if (this.peek(1) === '<') reject('named groups and look-behind are unsupported');
if (this.peek(1) !== ':') reject('look-around and group modifiers are unsupported');
this.pos += 2;
}
@ -543,7 +541,7 @@ class Parser {
return { code: 0x75 };
}
case 'k':
// Identity escape (no named groups exist; `parse` already rejected those).
// Identity escape (no named groups exist; `parseGroup` rejects those).
return { code: 0x6b };
default:
if (c >= '1' && c <= '9') reject('back-reference or legacy octal escape');

View file

@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import { compileRExportPattern } from '../../src/core/ingestion/languages/r/export-pattern.js';
import {
compileLinearRegex,
compileLinearRegexDetailed,
MAX_STATES,
} from '../../src/core/ingestion/languages/r/linear-regex.js';
@ -733,6 +734,48 @@ describe('compileRExportPattern is linear-time on catastrophic patterns', () =>
}
});
describe('"(?<" is only a group opener outside a character class and an escape', () => {
it.each([
['[(?<]', ['(', '?', '<'], ['a', '']],
['[^(?<]x', ['ax'], ['(x', '<x']],
['^[(?<]+$', ['(<?', '<'], ['a<', '']],
['[(?<]\\k', ['(k'], ['k']],
['\\(?<x', ['<x', '(<x'], ['x']],
['\\[[(?<]', ['[<', '[('], ['<']],
['[\\]](?:a)', [']a'], ['a']],
['[\\](?<]x', ['(x', ']x', '<x'], ['ax']],
['[[(?<]', ['[', '('], ['a']],
])('compiles %s and matches it as written', (pattern, matching, notMatching) => {
const regex = compileLinearRegex(pattern);
expect(regex, pattern).not.toBeNull();
for (const name of matching) expect(regex?.test(name), `${pattern} ~ ${name}`).toBe(true);
for (const name of notMatching) {
expect(regex?.test(name), `${pattern} !~ ${name}`).toBe(false);
}
});
it.each([
['a real look-behind', '(?<=x)y'],
['a negative look-behind', '(?<!x)y'],
['a named group', '(?<n>a)'],
['after a closed class', '[ab](?<=x)'],
['after a class holding an escaped ]', '[\\]](?<=x)'],
['after an escaped [', '\\[(?<=x)'],
['after a class that holds (?<', '[(?<](?<=x)'],
['inside a group', '(a|(?<=x)b)'],
])('still rejects %s', (_label, pattern) => {
expect(compileLinearRegex(pattern), pattern).toBeNull();
});
it('reports a named group or look-behind as such', () => {
for (const pattern of ['(?<=x)y', '(?<n>a)']) {
const result = compileLinearRegexDetailed(pattern);
expect(result.regex).toBeNull();
expect(result.reason).toContain('look-behind');
}
});
});
it('bounds the matcher by pattern size, not by the name it is run against', () => {
// The state count is fixed at compile time and capped, so the work per tested name
// is at most name length x MAX_STATES whatever the pattern says.