diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index d2d990e16..49ffda803 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -130,7 +130,7 @@ import { lowerSafeBytesRead, } from "./array-values.js"; import { strCharsCall } from "./containers/array-construction.js"; -import { lowerOptionalStringIndex } from "./string-index.js"; +import { lowerOptionalStringIndex, lowerUnionStringIndex } from "./string-index.js"; import { tryLowerIndexedComparison } from "./indexed-comparison.js"; import { npmStaticPackageOfPath } from "../npm-static.js"; import { unsupportedModuleFeatureOf } from "../builtin-modules.js"; @@ -6879,6 +6879,19 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre locOf(expr), ); } + // The checker narrowed the receiver to string, but its stored value + // is still the union (an array-derived binding, a typeof guard). + if (index.type.kind === "f64") { + const read = lowerUnionStringIndex( + lowerer, + recv, + index, + expr.argumentExpression, + lowerer.withUndefinedArm(STRING), + locOf(expr), + ); + if (read) return read; + } lowerer.unsupported( "SC1090", expr, diff --git a/packages/compiler/src/frontend/lowering/string-index.ts b/packages/compiler/src/frontend/lowering/string-index.ts index e9755742a..dd086cd73 100644 --- a/packages/compiler/src/frontend/lowering/string-index.ts +++ b/packages/compiler/src/frontend/lowering/string-index.ts @@ -1,4 +1,15 @@ -import { BOOL, F64, STRING, type IrExpr, type IrType, type SrcLoc } from "../../ir/ir.js"; +import type * as ts from "../ts7/adapter.js"; +import { + BOOL, + DYN, + F64, + STRING, + isUnitType, + type IrExpr, + type IrStmt, + type IrType, + type SrcLoc, +} from "../../ir/ir.js"; import { numLit, varRef } from "../../ir/build.js"; import type { Lowerer } from "./lowerer.js"; @@ -66,3 +77,77 @@ export function lowerOptionalStringIndex( loc, }; } + +/** A number-keyed read of a receiver the checker narrowed to string whose + * stored value is still a union: an array-derived binding keeps its + * undefined arm (`const t = tokens[i]`), and a `typeof t === "string"` + * guard narrows the checker's type without retagging the storage. Both + * operands are captured first, as for a plain string receiver. A unit arm + * performs the checked-dynamic keyed read on that unit, which throws Node's + * "Cannot read properties of undefined (reading '')"; another value + * arm, which the checker has excluded, fails the checked extraction; the + * string arm reads like any string receiver. Null when the union has no + * string arm. */ +export function lowerUnionStringIndex( + lowerer: Lowerer, + receiver: IrExpr, + index: IrExpr, + keyNode: ts.Node, + type: IrType, + loc: SrcLoc, +): IrExpr | null { + if (receiver.type.kind !== "union") return null; + const unionType = receiver.type; + const unionId = unionType.unionId; + const def = lowerer.unions.get(unionId); + const stringTag = lowerer.armTag(unionId, STRING); + if (!def || stringTag < 0) return null; + const receiverLocal = lowerer.declareHiddenLocal("%indexedUnion", unionType); + const indexLocal = lowerer.declareHiddenLocal("%unionStringIndex", F64); + const stored = (): IrExpr => varRef(receiverLocal.id, unionType, loc); + const key = (): IrExpr => varRef(indexLocal.id, F64, loc); + const stmts: IrStmt[] = [ + { kind: "varDecl", localId: receiverLocal.id, init: receiver, loc }, + { kind: "varDecl", localId: indexLocal.id, init: index, loc }, + ]; + let text: IrExpr; + if (def.arms.every((arm, tag) => tag === stringTag || isUnitType(arm))) { + text = { kind: "unionNarrow", unionId, tag: stringTag, value: stored(), type: STRING, loc }; + } else { + const helper = lowerer.narrowedArmHelper(unionId, STRING, loc); + if (helper === null) return null; + text = lowerer.checkedNarrowCall(helper, stored(), STRING, loc); + } + let result = lowerOptionalStringIndex(lowerer, text, key(), type, loc); + for (let tag = def.arms.length - 1; tag >= 0; tag--) { + const arm = def.arms[tag]!; + if (!isUnitType(arm)) continue; + const unit = arm.kind === "nullT" ? "null" : "undefined"; + const thrown: IrExpr = { + kind: "dynCheck", + value: { + kind: "dynKeyGet", + value: { + kind: "dynFrom", + value: { kind: "unitLit", unit, type: arm, loc }, + type: DYN, + loc, + }, + key: lowerer.ensureString(key(), keyNode), + type: DYN, + loc, + }, + type: STRING, + loc, + }; + result = { + kind: "ternary", + cond: { kind: "unionIsTag", unionId, tag, negated: false, value: stored(), type: BOOL, loc }, + then: lowerer.coerceToExpected(thrown, type), + else_: result, + type, + loc, + }; + } + return { kind: "seqExpr", stmts, result, type, loc }; +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6043-string-index-narrowed-union.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6043-string-index-narrowed-union.ts.json new file mode 100644 index 000000000..3ff98a72a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6043-string-index-narrowed-union.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6043-string-index-narrowed-union.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6043-string-index-narrowed-union.ts b/tests/corpus/6043-string-index-narrowed-union.ts new file mode 100644 index 000000000..6fee639eb --- /dev/null +++ b/tests/corpus/6043-string-index-narrowed-union.ts @@ -0,0 +1,59 @@ +// Indexing a string whose binding was read from an array or narrowed from a +// union with typeof. The receiver's stored value keeps its other arms, so an +// out-of-bounds array read throws Node's TypeError at the index, naming the +// key. +interface Key { + name: string; +} +type Token = string | Key; + +// path-to-regexp's tokensToRegexp shape. +function endDelimited(tokens: Token[], delimiters: string): boolean { + const endToken = tokens[tokens.length - 1]; + return typeof endToken === "string" + ? delimiters.indexOf(endToken[endToken.length - 1]) > -1 + : endToken === undefined; +} +console.log( + endDelimited(["a/", { name: "x" }], "/"), + endDelimited(["a/"], "/"), + endDelimited([], "/"), + endDelimited(["ab"], "/"), +); + +function lastChar(tokens: Token[]): string { + const t = tokens[0]; + if (typeof t === "string") return t[t.length - 1]; + return "-"; +} +console.log(lastChar(["xyz"]), lastChar([{ name: "k" }])); + +function lastLetter(words: string[], i: number): string { + const w = words[i]; + return w[w.length - 1]; +} +console.log(lastLetter(["ab", "cd"], 1)); + +function at(words: string[], i: number, j: number): string | undefined { + const w = words[i]; + return w[j]; +} +console.log(at(["abc"], 0, 1), at(["abc"], 0, 7), at(["abc"], 0, 1.5), at(["abc"], 0, -1)); +for (const j of [2, -0.5, 1e21]) { + try { + at(["abc"], 3, j); + } catch (e) { + console.log(e instanceof TypeError, (e as Error).message); + } +} + +let calls = 0; +function key(): number { + calls++; + return 0; +} +function viaCall(words: string[]): string { + const w = words[0]; + return w[key()]; +} +console.log(viaCall(["q"]), calls);