From 675388805ce59a3174a322091a61686c16cb3129 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 10:18:12 -0500 Subject: [PATCH] Read named groups through optional chains and keep their null prototype `m?.groups?.year` compiles on exec and match results, including a null match, where the read was refused before. The matchAll row callbacks also cover findIndex and findLastIndex. Reshaping an index-signature record into another index-signature type keeps the source's null prototype, since the two are one object in JavaScript. A match's groups read through `m?.groups`, or assigned to a declared type such as `{ [k: string]: string } | undefined`, now prints "[Object: null prototype]" as in Node, where it printed a plain object. --- .../builtin-apis/regular-expressions.mdx | 2 +- .../lowering/containers/indexed-objects.ts | 94 ++++++++++--------- .../src/frontend/lowering/lower-exprs.ts | 7 +- .../src/frontend/lowering/surfaces.ts | 4 +- .../5907-regex-groups-match-rows.ts.json | 6 ++ tests/corpus/5907-regex-groups-match-rows.ts | 33 +++++++ tests/harness/__snapshots__/regex.ts.txt | 2 +- tests/harness/corpus-costs.json | 2 + 8 files changed, 102 insertions(+), 48 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5907-regex-groups-match-rows.ts.json create mode 100644 tests/corpus/5907-regex-groups-match-rows.ts diff --git a/docs/content/limitations/builtin-apis/regular-expressions.mdx b/docs/content/limitations/builtin-apis/regular-expressions.mdx index a765bbe3c..d4287396d 100644 --- a/docs/content/limitations/builtin-apis/regular-expressions.mdx +++ b/docs/content/limitations/builtin-apis/regular-expressions.mdx @@ -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. diff --git a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts index e917ee75f..cca4bd51b 100644 --- a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts +++ b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts @@ -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(); // Source declared fields in declaration order, skipping unset // optionals (stance 37) and the direct-initialized (consumed) names. diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 142b00c7e..9f12be3e0 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -13726,6 +13726,8 @@ const MATCH_ROW_CALLBACK_METHODS = new Set([ "filter", "find", "findLast", + "findIndex", + "findLastIndex", "some", "every", "flatMap", @@ -13839,7 +13841,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)); diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 1cd12f9c6..d394c1960 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -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: " + diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5907-regex-groups-match-rows.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5907-regex-groups-match-rows.ts.json new file mode 100644 index 000000000..ec7d62945 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5907-regex-groups-match-rows.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5907-regex-groups-match-rows.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5907-regex-groups-match-rows.ts b/tests/corpus/5907-regex-groups-match-rows.ts new file mode 100644 index 000000000..22334bad6 --- /dev/null +++ b/tests/corpus/5907-regex-groups-match-rows.ts @@ -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 = /(?[a-z])(?\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(/(?[a-z])|(?\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 = /(?\d{4})-(?\d{2})|(?\d{2})\/(?\d{4})/.exec(s); + console.log(s, m?.groups?.year, m?.groups?.month ?? "none", m?.groups); +} +const mm = "id=42".match(/id=(?\d+)/); +console.log(mm?.groups?.id, "nothing".match(/(?z)/)?.groups?.z); +const none = /(?q)/.exec("abc"); +console.log(none?.groups, none?.groups?.q === undefined); +const parts = /(?\d)-(?\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 = /(?\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); diff --git a/tests/harness/__snapshots__/regex.ts.txt b/tests/harness/__snapshots__/regex.ts.txt index 22bdbef90..899633aed 100644 --- a/tests/harness/__snapshots__/regex.ts.txt +++ b/tests/harness/__snapshots__/regex.ts.txt @@ -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) \ No newline at end of file + 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 \ No newline at end of file diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 954f0857d..4702d3676 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -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,