Read named groups through optional chains and keep their null prototype (#1299)

This commit is contained in:
Chris Tate
2026-10-18 10:34:42 -05:00
committed by GitHub
8 changed files with 102 additions and 48 deletions
@@ -1 +1 @@
- **Regular expressions:** Native checked storage preserves identity, `source`, `flags`, flag properties, and stateful `test`, and the flag getters (`global`, `unicodeSets`, and the rest) compile on statically typed regexes. Static `exec`, global `match`, and numeric `lastIndex` access use the native matcher. Runtime constructors accept checked strings and native regex copies; `replace` and `replaceAll` accept native regex values with string templates or callbacks, including captures, named groups, and UTF-16 offsets. Non-numeric `lastIndex`, detached methods, and `exec` through an untyped receiver are unsupported. The `v` (unicode sets) flag supports class set operations (`--`, `&&`), nested classes, `\q{...}` string literals, and properties of strings such as `\p{RGI_Emoji}`. `new RegExp` with an invalid pattern throws a `SyntaxError` whose message comes from the native engine and can differ from Node's. The `d` (match indices) flag is unsupported: regex literals and `new RegExp` calls with a literal flag string containing it fail to compile with SC1120, and `new RegExp` with a computed flag string containing it throws an error naming the flag.
- **Regular expressions:** Native checked storage preserves identity, `source`, `flags`, flag properties, and stateful `test`, and the flag getters (`global`, `unicodeSets`, and the rest) compile on statically typed regexes. Static `exec`, global `match`, and numeric `lastIndex` access use the native matcher. Runtime constructors accept checked strings and native regex copies; `replace` and `replaceAll` accept native regex values with string templates or callbacks, including captures, named groups, and UTF-16 offsets. Named groups (`groups`) read on matches of a statically known regex: from `exec` and `match`, through optional chains (`m?.groups?.name`), in a `for...of` over `matchAll`, and on the element parameter of an array-method callback over `matchAll` rows. The groups object has a null prototype, as in Node. Non-numeric `lastIndex`, detached methods, and `exec` through an untyped receiver are unsupported. The `v` (unicode sets) flag supports class set operations (`--`, `&&`), nested classes, `\q{...}` string literals, and properties of strings such as `\p{RGI_Emoji}`. `new RegExp` with an invalid pattern throws a `SyntaxError` whose message comes from the native engine and can differ from Node's. The `d` (match indices) flag is unsupported: regex literals and `new RegExp` calls with a literal flag string containing it fail to compile with SC1120, and `new RegExp` with a computed flag string containing it throws an error naming the flag.
@@ -1,3 +1,4 @@
import { nullProtoRecordShape, recordNullProtoTest } from "../null-prototype.js";
import { countedFor, numLit, varRef } from "../../../ir/build.js";
import { objectEnumerationReceiver } from "../object-enumeration-receiver.js";
import { InternalCompilerError } from "../../../errors.js";
@@ -852,50 +853,57 @@ export function lowerRecordOvfCaptureHelper(
lift === "dyn"
? { kind: "dynFrom", value: v, type: DYN, loc }
: lowerer.applyWidthLift(lift, v, tIv, loc);
const body: IrStmt[] = [
{
kind: "varDecl",
localId: "out.0",
init: {
kind: "recordLit",
fields: inits.map((d) => ({
name: d.name,
value:
d.kind === "direct"
? lowerer.presenceKeepingCopy(
sRef,
fromId,
d.name,
lowerer.applyWidthLift(
d.lift,
{
kind: "recordGet",
obj: sRef,
shapeId: fromId,
field: d.name,
type: d.src,
loc,
},
to.fields.find((f) => f.name === d.name)!.type,
loc,
),
const fresh: IrExpr & { kind: "recordLit" } = {
kind: "recordLit",
fields: inits.map((d) => ({
name: d.name,
value:
d.kind === "direct"
? lowerer.presenceKeepingCopy(
sRef,
fromId,
d.name,
lowerer.applyWidthLift(
d.lift,
{
kind: "recordGet",
obj: sRef,
shapeId: fromId,
field: d.name,
type: d.src,
loc,
)
: ({
// No source property of this name: the field starts
// absent (an overflow write below can supply it).
kind: "fieldAbsent",
unionId: d.unionId,
type: to.fields.find((f) => f.name === d.name)!.type,
loc,
} satisfies IrExpr),
})),
type: toT,
loc,
},
loc,
},
];
},
to.fields.find((f) => f.name === d.name)!.type,
loc,
),
loc,
)
: ({
// No source property of this name: the field starts
// absent (an overflow write below can supply it).
kind: "fieldAbsent",
unionId: d.unionId,
type: to.fields.find((f) => f.name === d.name)!.type,
loc,
} satisfies IrExpr),
})),
type: toT,
loc,
};
// The source and its reshape are one object in JavaScript, so a
// null-prototype source (a match's groups, Object.groupBy) keeps it.
const init: IrExpr =
nullProtoRecordShape(lowerer, fromT) && nullProtoRecordShape(lowerer, toT)
? {
kind: "ternary",
cond: recordNullProtoTest(sRef, fromId, loc),
then: { ...fresh, nullProto: true },
else_: fresh,
type: toT,
loc,
}
: fresh;
const body: IrStmt[] = [{ kind: "varDecl", localId: "out.0", init, loc }];
const fieldStmts = new Map<string, IrStmt>();
// Source declared fields in declaration order, skipping unset
// optionals (stance 37) and the direct-initialized (consumed) names.
@@ -13739,6 +13739,8 @@ const MATCH_ROW_CALLBACK_METHODS = new Set([
"filter",
"find",
"findLast",
"findIndex",
"findLastIndex",
"some",
"every",
"flatMap",
@@ -13852,7 +13854,10 @@ function matchAllRegexOf(lowerer: Lowerer, e: ts.Expression): ts.Expression | nu
* receiver isn't a match slice or the regex isn't traceable — the
* caller's member fence (with the groups hint) names the gap. */
function lowerMatchGroupsRead(lowerer: Lowerer, expr: ts.PropertyAccessExpression): IrExpr | null {
if (expr.name.text !== "groups" || expr.questionDotToken !== undefined) return null;
if (expr.name.text !== "groups") return null;
// `m?.groups` re-dispatches through the optional-chain machinery, which
// narrows the receiver to its match arm first.
if (expr.questionDotToken !== undefined && !lowerer.chainHandled.has(expr)) return null;
const loc = locOf(expr);
const strArr = arrayOf(STRING);
const recvT = lowerer.mapTypeOf(lowerer.typeOf(expr.expression));
@@ -3046,8 +3046,8 @@ export function stdlibMemberFence(lowerer: Lowerer, access: ts.PropertyAccessExp
hint =
"'.groups' lowers when the match's regex is STATICALLY known — a regex literal, or a " +
"const initialized with one (the group-name table is built at compile time) — and the " +
"read is not an optional-chain step: narrow the match instead (if (m) { m.groups } or " +
"m!.groups)";
"match comes from exec, match, a for-of over matchAll, or the element parameter of an " +
"array-method callback over matchAll rows";
} else if (recvIr?.kind === "array" && member === "flat") {
hint =
"flat has no lowering (flatMap does) — flatten into an accumulator instead: " +
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5907-regex-groups-match-rows.ts"
],
"diags": []
}
@@ -0,0 +1,33 @@
// Named groups on matchAll rows (array-method callbacks over spread rows,
// duplicate names), through optional chains on exec and match results
// (including null matches), and the groups object's null prototype through
// inspection, declared types and unions. Node.js prints the same.
const text = "a1 b22 c333";
const re = /(?<word>[a-z])(?<num>\d+)/g;
const rows = [...text.matchAll(re)];
console.log(rows.map((m) => `${m.groups!.word}=${m.groups!.num}`).join(" "));
rows.forEach((m) => console.log("each", m.groups?.word, m[0]));
console.log(rows.filter((m) => m.groups!.num.length > 1).length, rows.find((m) => m.groups!.word === "c")?.[0]);
console.log([..."x9y8".matchAll(/(?<l>[a-z])|(?<d>\d)/g)].map((m) => `${m.groups!.l ?? "-"}${m.groups!.d ?? "-"}`).join(","));
console.log(rows.some((m) => m.groups!.word === "b"), rows.every(function (m) { return m.groups!.num !== ""; }));
for (const s of ["2024-05", "nope", "07/1999"]) {
const m = /(?<year>\d{4})-(?<month>\d{2})|(?<month>\d{2})\/(?<year>\d{4})/.exec(s);
console.log(s, m?.groups?.year, m?.groups?.month ?? "none", m?.groups);
}
const mm = "id=42".match(/id=(?<id>\d+)/);
console.log(mm?.groups?.id, "nothing".match(/(?<z>z)/)?.groups?.z);
const none = /(?<q>q)/.exec("abc");
console.log(none?.groups, none?.groups?.q === undefined);
const parts = /(?<y>\d)-(?<z>\w)/.exec("1-a")!;
const g = parts.groups!;
console.log(parts.groups, JSON.stringify(g), Object.keys(g), Object.getPrototypeOf(g) === null);
try { console.log(String(g)); } catch (e) { console.log((e as Error).name, (e as Error).message); }
const { y, z } = parts.groups!;
console.log(y, z);
const one = /(?<y>\d)/.exec("1");
console.log(one?.groups);
const maybeGroups = one?.groups;
console.log(maybeGroups);
console.log(one!.groups);
const declared: { [k: string]: string } | undefined = one!.groups;
console.log(declared);
+1 -1
View File
@@ -21,4 +21,4 @@ regex.ts:9:22 - error SC2020: 'RegExpExecArray.groups' is part of the standard l
| ^~~~~~~~
10 | }
hint: '.groups' lowers when the match's regex is STATICALLY known — a regex literal, or a const initialized with one (the group-name table is built at compile time) — and the read is not an optional-chain step: narrow the match instead (if (m) { m.groups } or m!.groups)
hint: '.groups' lowers when the match's regex is STATICALLY known — a regex literal, or a const initialized with one (the group-name table is built at compile time) — and the match comes from exec, match, a for-of over matchAll, or the element parameter of an array-method callback over matchAll rows
+2
View File
@@ -1894,6 +1894,7 @@
"5904-async-generator-completion.ts": 2950,
"5905-array-from-async-generator.ts": 2750,
"5906-array-from-async-generator-js.js": 2950,
"5907-regex-groups-match-rows.ts": 3150,
"5910-set-prototype-of-primitive-object-kinds.ts": 2719,
"5911-dyn-class-to-primitive.ts": 2850,
"5912-dyn-class-conversion-methods.ts": 3251,
@@ -4602,6 +4603,7 @@
"5904-async-generator-completion.ts": 3800,
"5905-array-from-async-generator.ts": 3500,
"5906-array-from-async-generator-js.js": 3750,
"5907-regex-groups-match-rows.ts": 3750,
"5910-set-prototype-of-primitive-object-kinds.ts": 5033,
"5911-dyn-class-to-primitive.ts": 4732,
"5912-dyn-class-conversion-methods.ts": 4424,