From 443f5ebc9bed45a5f17c367bda6a69e80951fbcd Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 13 Oct 2026 09:42:05 -0500 Subject: [PATCH] Compare strings against literals inline --- packages/compiler/src/backend/llvm/emitter.ts | 40 +++- .../compiler/src/backend/llvm/expr-dynamic.ts | 28 ++- .../src/backend/llvm/expr-primitives.ts | 17 +- .../compiler/src/backend/llvm/expr-records.ts | 8 +- .../backend/llvm/local-union-storage.test.ts | 2 +- .../src/backend/llvm/string-lifetimes.test.ts | 6 +- .../backend/llvm/string-literal-equality.ts | 92 +++++++++ .../src/backend/llvm/switch-dispatch.test.ts | 83 +++++++- .../src/backend/llvm/switch-dispatch.ts | 70 ++++++- .../corpus/4920-string-literal-switch.ts.json | 6 + tests/corpus/4920-string-literal-switch.ts | 189 ++++++++++++++++++ 11 files changed, 503 insertions(+), 38 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/string-literal-equality.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4920-string-literal-switch.ts.json create mode 100644 tests/corpus/4920-string-literal-switch.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 526cc9f78..04d80985f 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -306,6 +306,7 @@ import { vAdapters, } from "./shapes.js"; import type { ExprOf, LibCallExpr, LlStreamTypedRefAdapter, LlValue } from "./expr-context.js"; +import { INLINE_LITERAL_BYTES, literalEqualityDefinition } from "./string-literal-equality.js"; export { LlvmUnsupportedError } from "./unsupported.js"; @@ -527,6 +528,8 @@ export class LlEmitter { /** Interned string literals: UTF-8 text → { symbol, byte length } — * first-use order, the runtime ABI’s determinism discipline. */ private readonly literals = new Map(); + /** Literal equality helpers: literal text → helper symbol (literalEquals). */ + private readonly literalEqualities = new Map(); /** Symbols of interned constant data, named by content (contentSymbol). */ private readonly contentSymbols = new Set(); /** Interned unit-armed union instances: "unionId:tag" → symbol — one @@ -2821,6 +2824,18 @@ export class LlEmitter { ``, ); } + for (const [text, name] of this.literalEqualities) { + defs.push( + ...literalEqualityDefinition( + name, + Buffer.from(text, "utf8"), + this.internLiteral(text), + this.sizeType, + FN_ATTRS, + ), + ``, + ); + } // String length and charCodeAt read proven-ASCII strings inline: bit 31 // of the capacity word (SCR_STR_ASCII_BIT, the low half's sign bit on // little-endian targets) makes UTF-16 units bytes. Unproven strings and @@ -3637,6 +3652,20 @@ export class LlEmitter { return name; } + /** The inline equality test against one string literal (an + * `i1 (ptr)` helper LLVM inlines at each site), by literal symbol. */ + literalEquals(text: string): string { + const literal = this.internLiteral(text); + const name = `@sc_streq_${literal.slice("@sc_lit_".length)}`; + if (!this.literalEqualities.has(text)) { + this.literalEqualities.set(text, name); + if (Buffer.byteLength(text, "utf8") > INLINE_LITERAL_BYTES) { + this.declare(`declare zeroext i1 @scr_str_eq(ptr, ptr)`); + } + } + return name; + } + /** Interned NUL-terminated C-string constant (the scr_jb_puts / * stringify-indent currency) — `@`-ref, first-use order. */ cstr(text: string): string { @@ -6315,7 +6344,6 @@ export class LlEmitter { break; } const join = B.newLabel("rks.j"); - this.declare(`declare zeroext i1 @scr_str_eq(ptr, ptr)`); if (iv.kind === "dyn" && shape.indexValue) { // A dyn-valued shape (the C recordKeySetHelper's dyn arm, // inline): declared keys VALIDATE the dyn value against the @@ -6325,10 +6353,9 @@ export class LlEmitter { // insert the dyn value into the overflow map as-is. this.declare(`declare void @scr_dyn_release(ptr)`); for (const f of shape.fields) { - const lit = this.internLiteral(f.name); const hit = B.tmp(); B.line( - `${hit} = call zeroext i1 @sc_str_eq(ptr ${key.name}, ptr ${lit}) ; ${llvmCommentText(f.name)}`, + `${hit} = call zeroext i1 ${this.literalEquals(f.name)}(ptr ${key.name}) ; ${llvmCommentText(f.name)}`, ); const lh = B.newLabel("rks.h"); const ln = B.newLabel("rks.n"); @@ -6382,10 +6409,9 @@ export class LlEmitter { } for (const f of shape.fields) { // typeEquals(f.type, iv) — the frontend fences everything else. - const lit = this.internLiteral(f.name); const hit = B.tmp(); B.line( - `${hit} = call zeroext i1 @sc_str_eq(ptr ${key.name}, ptr ${lit}) ; ${llvmCommentText(f.name)}`, + `${hit} = call zeroext i1 ${this.literalEquals(f.name)}(ptr ${key.name}) ; ${llvmCommentText(f.name)}`, ); const lh = B.newLabel("rks.h"); const ln = B.newLabel("rks.n"); @@ -7258,7 +7284,9 @@ export class LlEmitter { this.frames.push([]); const t = this.emitExpr(c.test); const hit = B.tmp(); - if (c.test.type.kind === "string") { + if (c.test.kind === "strLit") { + B.line(`${hit} = call zeroext i1 ${this.literalEquals(c.test.value)}(ptr ${disc.name})`); + } else if (c.test.type.kind === "string") { this.declare(`declare zeroext i1 @scr_str_eq(ptr, ptr)`); B.line(`${hit} = call zeroext i1 @sc_str_eq(ptr ${disc.name}, ptr ${t.name})`); } else if (c.test.type.kind === "bool") { diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index a07de963c..1df984f71 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -792,9 +792,16 @@ export function emitDynamicExpr( B.line(`${go} = and i1 ${notSame}, ${present}`); B.condBr(go, bytes, join); B.startBlock(bytes); - host.declare(`declare zeroext i1 @scr_str_eq(ptr, ptr)`); const eqBytes = B.tmp(); - B.line(`${eqBytes} = call zeroext i1 @sc_str_eq(ptr ${l.name}, ptr ${r.name})`); + const literal = wrappedStringLiteral(e.right) ?? wrappedStringLiteral(e.left); + if (literal !== null) { + // Both hold strings here; the literal's length and bytes compare inline. + const other = wrappedStringLiteral(e.right) !== null ? l : r; + B.line(`${eqBytes} = call zeroext i1 ${host.literalEquals(literal)}(ptr ${other.name})`); + } else { + host.declare(`declare zeroext i1 @scr_str_eq(ptr, ptr)`); + B.line(`${eqBytes} = call zeroext i1 @sc_str_eq(ptr ${l.name}, ptr ${r.name})`); + } B.line(`store i1 ${eqBytes}, ptr ${slot}`); B.br(join); B.startBlock(join); @@ -846,11 +853,19 @@ export function emitDynamicExpr( break; } case "string": { - host.declare(`declare zeroext i1 @scr_str_eq(ptr, ptr)`); const a = host.unionPeek(l.name, def.id); const b = host.unionPeek(r.name, def.id); const t = B.tmp(); - B.line(`${t} = call zeroext i1 @sc_str_eq(ptr ${a}, ptr ${b})`); + const right = wrappedStringLiteral(e.right); + const left = wrappedStringLiteral(e.left); + if (right !== null) { + B.line(`${t} = call zeroext i1 ${host.literalEquals(right)}(ptr ${a})`); + } else if (left !== null) { + B.line(`${t} = call zeroext i1 ${host.literalEquals(left)}(ptr ${b})`); + } else { + host.declare(`declare zeroext i1 @scr_str_eq(ptr, ptr)`); + B.line(`${t} = call zeroext i1 @sc_str_eq(ptr ${a}, ptr ${b})`); + } B.line(`store i1 ${t}, ptr ${slot}`); break; } @@ -1181,3 +1196,8 @@ function emitUnionEqWrappedScalar( B.line(`${negated} = xor i1 ${result}, true`); return { name: negated, type: e.type }; } + +/** The text of a string literal wrapped into a union, or null. */ +function wrappedStringLiteral(e: IrExpr): string | null { + return e.kind === "unionWrap" && e.value.kind === "strLit" ? e.value.value : null; +} diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 64eaf20b7..f0466a0b4 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -547,9 +547,22 @@ export function emitStringExpr( } case "strEq": { const [l, r] = emitStringInputs(host, [e.left, e.right]); - host.declare(`declare zeroext i1 @scr_str_eq(ptr, ptr)`); const eq = B.tmp(); - B.line(`${eq} = call zeroext i1 @sc_str_eq(ptr ${l!.name}, ptr ${r!.name})`); + // A literal side compares its length and bytes inline. + const literal = + e.right.kind === "strLit" + ? { text: e.right.value, other: l! } + : e.left.kind === "strLit" + ? { text: e.left.value, other: r! } + : null; + if (literal !== null) { + B.line( + `${eq} = call zeroext i1 ${host.literalEquals(literal.text)}(ptr ${literal.other.name})`, + ); + } else { + host.declare(`declare zeroext i1 @scr_str_eq(ptr, ptr)`); + B.line(`${eq} = call zeroext i1 @sc_str_eq(ptr ${l!.name}, ptr ${r!.name})`); + } if (!e.negated) return { name: eq, type: e.type }; const t = B.tmp(); B.line(`${t} = xor i1 ${eq}, true`); diff --git a/packages/compiler/src/backend/llvm/expr-records.ts b/packages/compiler/src/backend/llvm/expr-records.ts index 6f36429c0..202cb9af1 100644 --- a/packages/compiler/src/backend/llvm/expr-records.ts +++ b/packages/compiler/src/backend/llvm/expr-records.ts @@ -353,12 +353,10 @@ export function keyedRecordReadInto( // convert through the per-type toDyn walker (borrowed read → fresh // +1 tree), a dyn-valued overflow hit passes through (+1 from the // map get), and a miss is JS's undefined — the dyn singleton. - host.declare(`declare zeroext i1 @scr_str_eq(ptr, ptr)`); for (const f of overflowOnly ? [] : shape.fields) { - const lit = host.internLiteral(f.name); const hit = B.tmp(); B.line( - `${hit} = call zeroext i1 @sc_str_eq(ptr ${keyName}, ptr ${lit}) ; ${llvmCommentText(f.name)}`, + `${hit} = call zeroext i1 ${host.literalEquals(f.name)}(ptr ${keyName}) ; ${llvmCommentText(f.name)}`, ); const lh = B.newLabel("rkg.h"); const ln = B.newLabel("rkg.n"); @@ -474,12 +472,10 @@ export function keyedRecordReadInto( const payload = owned ? expr : host.retainValue(expr, vt); return host.unionNewOwned(def.id, tag, { name: payload, type: vt }); }; - host.declare(`declare zeroext i1 @scr_str_eq(ptr, ptr)`); for (const f of overflowOnly ? [] : shape.fields) { - const lit = host.internLiteral(f.name); const hit = B.tmp(); B.line( - `${hit} = call zeroext i1 @sc_str_eq(ptr ${keyName}, ptr ${lit}) ; ${llvmCommentText(f.name)}`, + `${hit} = call zeroext i1 ${host.literalEquals(f.name)}(ptr ${keyName}) ; ${llvmCommentText(f.name)}`, ); const lh = B.newLabel("rkg.h"); const ln = B.newLabel("rkg.n"); diff --git a/packages/compiler/src/backend/llvm/local-union-storage.test.ts b/packages/compiler/src/backend/llvm/local-union-storage.test.ts index 3d1fb57da..e91f56654 100644 --- a/packages/compiler/src/backend/llvm/local-union-storage.test.ts +++ b/packages/compiler/src/backend/llvm/local-union-storage.test.ts @@ -291,7 +291,7 @@ test("strict equality against a wrapped literal borrows the local and stack-boxe expect(facts(mod).has("item")).toBe(true); for (const bits of [32, 64] as const) { const ir = body(mod, bits); - expect(ir).toContain("@sc_str_eq"); + expect(ir).toMatch(/call zeroext i1 @sc_streq_\d+\(ptr /); expect(ir).not.toMatch(/@scr_union_(?:new|retain|release)/); } }); diff --git a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts index c93f1dcb9..d40d998d5 100644 --- a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts @@ -452,10 +452,10 @@ test("string comparisons decide identity, lengths and leading bytes before the r type: BOOL, loc, }); - const same = fn("same", [], { + const same = fn("same", ["a", "b"], { kind: "strEq", - left: str("a"), - right: str("b"), + left: { kind: "varRef", localId: "a", type: STRING, loc }, + right: { kind: "varRef", localId: "b", type: STRING, loc }, negated: false, type: BOOL, loc, diff --git a/packages/compiler/src/backend/llvm/string-literal-equality.ts b/packages/compiler/src/backend/llvm/string-literal-equality.ts new file mode 100644 index 000000000..1427d7e8b --- /dev/null +++ b/packages/compiler/src/backend/llvm/string-literal-equality.ts @@ -0,0 +1,92 @@ +/** Equality against a string literal, decided inline. Strings compare as + * their UTF-8 bytes (scr_str_eq), so a literal's equality test is its byte + * length plus a few little-endian word compares against constants: no + * runtime call and no read of the literal's own storage. */ + +/** Literals up to this many bytes compare inline; longer ones keep the + * length test and call the runtime for the bytes. */ +export const INLINE_LITERAL_BYTES = 32; + +export interface LiteralWord { + readonly offset: number; + readonly bits: number; + /** The word's little-endian value, signed as LLVM prints an iN constant. */ + readonly value: string; +} + +/** Little-endian value of bytes[offset, offset + size) as a signed iN. */ +export function littleEndianWord(bytes: Uint8Array, offset: number, size: number): string { + let value = 0n; + for (let index = size - 1; index >= 0; index--) { + value = (value << 8n) | BigInt(bytes[offset + index]!); + } + return BigInt.asIntN(size * 8, value).toString(); +} + +/** The literal's bytes as loads of at most eight bytes each. */ +export function literalWords(bytes: Uint8Array): LiteralWord[] { + const words: LiteralWord[] = []; + for (let offset = 0; offset < bytes.length; offset += 8) { + const size = Math.min(8, bytes.length - offset); + words.push({ + offset, + bits: size * 8, + value: littleEndianWord(bytes, offset, size), + }); + } + return words; +} + +/** An internal function `i1 (ptr)` testing a string for equality with one + * literal: the byte length first, then the bytes, which are read only once + * the length proves they exist. `literal` is the interned ScrStr, used by + * the runtime fallback for literals longer than INLINE_LITERAL_BYTES. */ +export function literalEqualityDefinition( + name: string, + bytes: Uint8Array, + literal: string, + sizeType: "i32" | "i64", + attrs: string, +): string[] { + const lines = [ + `define internal zeroext i1 ${name}(ptr %s) ${attrs} {`, + `entry:`, + ` %len.p = getelementptr inbounds %ScrStr, ptr %s, i32 0, i32 1`, + ` %len = load ${sizeType}, ptr %len.p`, + ` %samelen = icmp eq ${sizeType} %len, ${bytes.length}`, + ]; + if (bytes.length === 0) { + lines.push(` ret i1 %samelen`, `}`); + return lines; + } + lines.push(` br i1 %samelen, label %bytes, label %no`, `bytes:`); + if (bytes.length > INLINE_LITERAL_BYTES) { + lines.push(` %eq = call zeroext i1 @scr_str_eq(ptr %s, ptr ${literal})`, ` ret i1 %eq`); + } else { + lines.push(` %data = getelementptr inbounds %ScrStr, ptr %s, i32 1`); + let any = ""; + literalWords(bytes).forEach((word, index) => { + const pointer = index === 0 ? "%data" : `%p${index}`; + if (index > 0) { + lines.push(` ${pointer} = getelementptr inbounds i8, ptr %data, i32 ${word.offset}`); + } + const type = `i${word.bits}`; + lines.push(` %w${index} = load ${type}, ptr ${pointer}, align 1`); + lines.push(` %x${index} = xor ${type} %w${index}, ${word.value}`); + let wide = `%x${index}`; + if (word.bits < 64) { + wide = `%z${index}`; + lines.push(` ${wide} = zext ${type} %x${index} to i64`); + } + if (any === "") { + any = wide; + } else { + lines.push(` %o${index} = or i64 ${any}, ${wide}`); + any = `%o${index}`; + } + }); + lines.push(` %eq = icmp eq i64 ${any}, 0`, ` ret i1 %eq`); + } + lines.push(`no:`, ` ret i1 false`, `}`); + return lines; +} diff --git a/packages/compiler/src/backend/llvm/switch-dispatch.test.ts b/packages/compiler/src/backend/llvm/switch-dispatch.test.ts index 04ff0e185..22e68486e 100644 --- a/packages/compiler/src/backend/llvm/switch-dispatch.test.ts +++ b/packages/compiler/src/backend/llvm/switch-dispatch.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "vitest"; -import { F64, STRING, VOID, type IrExpr, type IrModule, type IrType } from "../../ir/ir.js"; +import { BOOL, F64, STRING, VOID, type IrExpr, type IrModule, type IrType } from "../../ir/ir.js"; import { emitLlvmModule } from "./emitter.js"; const loc = { file: "switch.ts", start: 0, end: 0 }; @@ -96,15 +96,88 @@ test("noninteger literals and computed tests retain source-order comparisons", ( } }); -test("string dispatch uses target-width byte lengths and checks equality inside each group", () => { +test("string dispatch selects by byte length, then by the bytes as one word", () => { for (const bits of [32, 64] as const) { const body = emit(STRING, ["", "a", "é", "😀", "tail", "é"].map(string), bits); expect(body).toContain(`load i${bits}, ptr`); - expect(body).toContain(`switch i${bits}`); expect(body).not.toContain("_str_utf16_len"); - expect(body.match(/call zeroext i1 @sc_str_eq/g)).toHaveLength(5); - const dispatch = body.split("\n").find((line) => line.includes(`switch i${bits}`))!; + // Every literal fits a word: no comparison calls at all. + expect(body).not.toContain("@sc_str_eq"); + expect(body).not.toContain("@sc_streq_"); + const lines = body.split("\n"); + const dispatch = lines.find((line) => line.includes(`switch i${bits}`))!; expect(dispatch).toContain(`i${bits} 2, label`); expect(dispatch.match(new RegExp(`i${bits} 4, label`, "g"))).toHaveLength(1); + // "a" = 0x61; "é" = c3 a9; "😀" = f0 9f 98 80 and "tail" share one word switch. + expect(lines.find((line) => line.includes("switch i8 "))).toContain("i8 97, label"); + expect(lines.find((line) => line.includes("switch i16 "))).toContain("i16 -22077, label"); + const words = lines.find( + (line) => line.includes("switch i32 ") && line.includes("i32 1818845556"), + ); + expect(words).toContain("i32 -2137481232, label"); } }); + +test("duplicate string labels keep the first destination", () => { + const body = emit(STRING, ["on", "off", "on"].map(string)); + const words = body.split("\n").find((line) => line.includes("switch i16 "))!; + expect(words.match(/i16 28271, label/g)).toHaveLength(1); + expect(words).toContain("label %sw.c1 "); + expect(words).not.toContain("label %sw.c3 "); +}); + +test("long string labels select by their first word and confirm the rest inline", () => { + const long = "a-literal-longer-than-thirty-two-bytes-total"; + const body = emit(STRING, ["functions", "functionz", "instanceof", long].map(string)); + const lines = body.split("\n"); + // "function" is the shared first word of both nine-byte labels. + const prefix = lines.find((line) => line.includes("switch i64 ") && line.includes("sw.prefix"))!; + expect(prefix.match(/i64 7957695015192261990, label/g)).toHaveLength(1); + expect(body.match(/call zeroext i1 @sc_streq_/g)).toHaveLength(4); + expect(body).not.toContain("@sc_str_eq"); + const mod = emitLlvmModuleFor(["functions", long]); + const helper = (name: string) => + new RegExp(`^define internal [^\\n]*@sc_streq_${name}\\([^]*?^}`, "m"); + const short = helper("\\d+").exec(mod)![0]; + expect(short).toContain("icmp eq i64 %len, 9"); + expect(short).toContain("load i64, ptr %data, align 1"); + expect(short).toContain("load i8, ptr %p1, align 1"); + expect(short).not.toContain("@scr_str_eq"); + expect(mod).toMatch( + /icmp eq i64 %len, 44\n br i1 %samelen, label %bytes, label %no\nbytes:\n %eq = call zeroext i1 @scr_str_eq/, + ); +}); + +function emitLlvmModuleFor(labels: string[]): string { + const value: IrExpr = { kind: "varRef", localId: "value", type: STRING, loc }; + const mod: IrModule = { + irVersion: 16, + sourceFile: loc.file, + entry: "main", + functions: [ + { name: "main", params: [], returnType: VOID, locals: [], body: [], loc }, + ...labels.map((label, index) => ({ + name: `equals${index}`, + params: [{ name: "value", localId: "value", type: STRING }], + returnType: BOOL, + locals: [{ id: "value", name: "value", type: STRING, mutable: false }], + loc, + body: [ + { + kind: "return" as const, + value: { + kind: "strEq" as const, + left: value, + right: string(label), + negated: false, + type: BOOL, + loc, + }, + loc, + }, + ], + })), + ], + }; + return emitLlvmModule(mod); +} diff --git a/packages/compiler/src/backend/llvm/switch-dispatch.ts b/packages/compiler/src/backend/llvm/switch-dispatch.ts index b02ab2d9e..93e4a7aff 100644 --- a/packages/compiler/src/backend/llvm/switch-dispatch.ts +++ b/packages/compiler/src/backend/llvm/switch-dispatch.ts @@ -2,6 +2,7 @@ import type { IrExpr, IrStmt } from "../../ir/ir.js"; import { withinInt32 } from "../../ir/integer-ranges.js"; import { exactInteger, widenInteger } from "./integer-values.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; +import { littleEndianWord } from "./string-literal-equality.js"; type SwitchCase = Extract["cases"][number]; @@ -107,32 +108,79 @@ function emitStringSwitch( if (group === undefined) groups.set(length, (group = [])); group.push({ value: test.value, label: labels[index]! }); } - if (seen.size < 4 || groups.size < 2) return false; + if (seen.size < 2) return false; + // Strings are equal exactly when their UTF-8 bytes are (scr_str_eq): the + // byte length selects a group, and a group of literals up to eight bytes + // long selects by the bytes as one little-endian word. const B = host.B; const pointer = B.tmp(); const length = B.tmp(); B.line(`${pointer} = getelementptr inbounds ${host.sizeType}, ptr ${disc.name}, i32 1`); B.line(`${length} = load ${host.sizeType}, ptr ${pointer}`); const groupLabels = new Map(); - for (const size of groups.keys()) groupLabels.set(size, B.newLabel("sw.length")); + for (const [size, group] of groups) { + // The empty string is the only literal of length zero. + groupLabels.set(size, size === 0 ? group[0]!.label : B.newLabel("sw.length")); + } B.terminate( `switch ${host.sizeType} ${length}, label %${fallback} [ ${[...groupLabels] .map(([size, label]) => `${host.sizeType} ${size}, label %${label}`) .join(" ")} ]`, ); - host.declare("declare zeroext i1 @scr_str_eq(ptr, ptr)"); for (const [size, group] of groups) { + if (size === 0) continue; B.startBlock(groupLabels.get(size)!); - for (let index = 0; index < group.length; index++) { - const item = group[index]!; - const literal = host.internLiteral(item.value); - const hit = B.tmp(); - const next = index + 1 < group.length ? B.newLabel("sw.equal") : fallback; - B.line(`${hit} = call zeroext i1 @sc_str_eq(ptr ${disc.name}, ptr ${literal})`); - B.condBr(hit, item.label, next); - if (next !== fallback) B.startBlock(next); + if (size <= 8) { + const type = `i${size * 8}`; + const data = B.tmp(); + const word = B.tmp(); + B.line(`${data} = getelementptr inbounds %ScrStr, ptr ${disc.name}, i32 1`); + B.line(`${word} = load ${type}, ptr ${data}, align 1`); + // Distinct texts can share bytes (lone surrogates encode as U+FFFD); + // the first case keeps the word, as the comparison chain would. + const targets = new Map(); + for (const item of group) { + const value = littleEndianWord(Buffer.from(item.value, "utf8"), 0, size); + if (!targets.has(value)) targets.set(value, item.label); + } + B.terminate( + `switch ${type} ${word}, label %${fallback} [ ${[...targets] + .map(([value, label]) => `${type} ${value}, label %${label}`) + .join(" ")} ]`, + ); + continue; } + // Longer literals select by their first eight bytes, then confirm the + // whole literal (the helper's repeated loads fold into this block's). + const data = B.tmp(); + const word = B.tmp(); + B.line(`${data} = getelementptr inbounds %ScrStr, ptr ${disc.name}, i32 1`); + B.line(`${word} = load i64, ptr ${data}, align 1`); + const prefixes = new Map(); + for (const item of group) { + const prefix = littleEndianWord(Buffer.from(item.value, "utf8"), 0, 8); + let same = prefixes.get(prefix); + if (same === undefined) prefixes.set(prefix, (same = [])); + same.push(item); + } + const prefixLabels = [...prefixes.keys()].map(() => B.newLabel("sw.prefix")); + B.terminate( + `switch i64 ${word}, label %${fallback} [ ${[...prefixes.keys()] + .map((prefix, index) => `i64 ${prefix}, label %${prefixLabels[index]!}`) + .join(" ")} ]`, + ); + [...prefixes.values()].forEach((same, prefixIndex) => { + B.startBlock(prefixLabels[prefixIndex]!); + for (let index = 0; index < same.length; index++) { + const item = same[index]!; + const hit = B.tmp(); + const next = index + 1 < same.length ? B.newLabel("sw.equal") : fallback; + B.line(`${hit} = call zeroext i1 ${host.literalEquals(item.value)}(ptr ${disc.name})`); + B.condBr(hit, item.label, next); + if (next !== fallback) B.startBlock(next); + } + }); } return true; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4920-string-literal-switch.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4920-string-literal-switch.ts.json new file mode 100644 index 000000000..f48b575b2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4920-string-literal-switch.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4920-string-literal-switch.ts" + ], + "diags": [] +} diff --git a/tests/corpus/4920-string-literal-switch.ts b/tests/corpus/4920-string-literal-switch.ts new file mode 100644 index 000000000..3232b07b1 --- /dev/null +++ b/tests/corpus/4920-string-literal-switch.ts @@ -0,0 +1,189 @@ +// String switches and literal equality: length and byte dispatch must keep +// strict equality, first-match duplicates, fallthrough, default placement, +// and every length (empty, short, word-sized, prefix-sharing, long). + +function classify(word: string): string { + switch (word) { + case "": + return "empty"; + case "a": + return "a"; + case "b": + case "c": + return "b-or-c"; + case "if": + return "kw-if"; + case "in": + return "kw-in"; + case "let": + return "kw-let"; + case "a": + return "duplicate a (unreachable)"; + case "else": + return "kw-else"; + case "while": + return "kw-while"; + case "return": + return "kw-return"; + case "default": + return "kw-default"; + case "function": + return "kw-function"; + case "functions": + return "plural"; + case "functionz": + return "z-suffix"; + case "continue": + return "kw-continue"; + case "instanceof": + return "kw-instanceof"; + case "café": + return "accent"; + case "日本": + return "japanese"; + case "🙂": + return "emoji"; + case "a-very-long-literal-that-is-longer-than-thirty-two-bytes": + return "long"; + case "exactly-thirty-two-bytes-long!!!": + return "thirty-two"; + default: + return "other:" + word.length; + } +} + +function fallthrough(op: string): string[] { + const out: string[] = []; + switch (op) { + case "+": + out.push("plus"); + case "-": + out.push("minus"); + break; + default: + out.push("default"); + case "*": + out.push("times"); + case "/": + out.push("divide"); + break; + case "%": + out.push("mod"); + } + return out; +} + +function twoCases(s: string): number { + switch (s) { + case "on": + return 1; + case "off": + return 0; + } + return -1; +} + +let evaluations = 0; +function next(value: string): string { + evaluations++; + return value; +} + +const words = [ + "", + "a", + "b", + "c", + "d", + "if", + "in", + "it", + "let", + "lex", + "else", + "elsE", + "while", + "return", + "returns", + "default", + "function", + "functions", + "functionz", + "functionx", + "continue", + "instanceof", + "instanceOf", + "café", + "cafe", + "日本", + "日", + "🙂", + "🙃", + "a-very-long-literal-that-is-longer-than-thirty-two-bytes", + "a-very-long-literal-that-is-longer-than-thirty-two-bytez", + "exactly-thirty-two-bytes-long!!!", + "exactly-thirty-two-bytes-long!!?", +]; + +for (const word of words) { + // Built at run time, so no case shares the literal's identity. + const built = ("<" + word + ">").slice(1, word.length + 1); + console.log(JSON.stringify(word), classify(word), classify(built)); +} + +for (const op of ["+", "-", "*", "/", "%", "^", "", "++"]) { + console.log(JSON.stringify(op), fallthrough(op).join(","), twoCases(op + "n")); +} +console.log(twoCases("on"), twoCases("off"), twoCases("of"), twoCases("of" + "f")); + +switch (next("let")) { + case "if": + console.log("if"); + break; + case "let": + console.log("let", evaluations); + break; + case "else": + console.log("else"); + break; +} + +// Literal equality on either side, negated, in loops and on record keys. +let hits = 0; +const kinds = ["num", "ident", "op", "lparen", "rparen", "comma", "end", "identifier"]; +for (let round = 0; round < 3; round++) { + for (const kind of kinds) { + const k = kind.slice(0); + if (k === "ident") hits += 1; + if ("identifier" === k) hits += 10; + if (k !== "op") hits += 100; + if (k === "") hits += 1000; + } +} +console.log("hits", hits); + +interface Totals { + department: number; + salary: number; + "x-long-field-name-beyond-a-word": number; +} +const totals: Totals = { department: 1, salary: 2, "x-long-field-name-beyond-a-word": 3 }; +const keys: (keyof Totals)[] = ["salary", "department", "x-long-field-name-beyond-a-word"]; +let sum = 0; +for (const key of keys) { + sum = sum * 10 + totals[key]; + totals[key] = totals[key] + 1; +} +console.log("record keys", sum, JSON.stringify(totals)); + +// Optional and mixed unions compare their string arm against the literal. +function isAbc(x: string | undefined): boolean { + return x === "abc"; +} +function notLong(x: string | number): boolean { + return x !== "a-literal-longer-than-thirty-two-bytes-in-total"; +} +const maybe: (string | undefined)[] = [undefined, "abc", "ab" + "c", "abd", "", "abcd"]; +console.log(maybe.map(isAbc).join(","), maybe.map((x) => "abc" === x).join(",")); +const mixed: (string | number)[] = [1, "a-literal-longer-than-thirty-two-bytes-in-total", "a-literal-longer-than-thirty-two-bytes-in-totaL", 0]; +console.log(mixed.map(notLong).join(","));