diff --git a/packages/compiler/src/backend/c/c-emitter.ts b/packages/compiler/src/backend/c/c-emitter.ts index 8e383bc30..93647ea03 100644 --- a/packages/compiler/src/backend/c/c-emitter.ts +++ b/packages/compiler/src/backend/c/c-emitter.ts @@ -43,6 +43,7 @@ import type { import { ffiCallbackType, funcOf, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isRefCounted, isUnitType, mapOf, moduleEmbedsCompressedNpm, moduleUsesChildProcess, moduleUsesDgram, moduleUsesDynInvoke, moduleEmbedsBuiltin, moduleUsesFetch, moduleUsesFsWatch, moduleUsesHttp2, moduleUsesHttpServer, moduleUsesNet, moduleUsesNodeTest, moduleUsesProcessEvents, moduleUsesStream, moduleUsesTls, moduleUsesTlsCa, POINTER_KINDS, type PointerKind, RUNTIME_EMITTER_CLASS, STRING, VOID } from "../../ir/ir.js"; import { undefinedArmTag } from "../../ir/analysis.js"; import { scalarizeNumericRecords } from "../../ir/scalar-records.js"; +import type { IntegerRanges } from "../../ir/integer-ranges.js"; import { findConstantNumericTables, type ConstantNumericTable } from "../../ir/constant-tables.js"; import { allocateFfiCallbackAdapters, hasForeignFfiCallback, hasRetainedFfiCallback, type FfiCallbackAdapter } from "../ffi-callbacks.js"; import { @@ -202,6 +203,7 @@ export class CEmitter { * unsigned integer shadow. Ordinary number reads widen the shadow back to * f64; direct byte indices consume it without a conversion round trip. */ integerLoopBindings = new Map(); + integerRanges: IntegerRanges = new Map(); /** Declared functions referenced as values: each needs an env-signature * wrapper + an interned immortal closure (so `f === f` holds). */ readonly fnValues = new Set(); diff --git a/packages/compiler/src/backend/c/exprs.ts b/packages/compiler/src/backend/c/exprs.ts index b64314719..51bb77759 100644 --- a/packages/compiler/src/backend/c/exprs.ts +++ b/packages/compiler/src/backend/c/exprs.ts @@ -838,6 +838,25 @@ function emitLiteralExpr( } } +/** Unsigned operations avoid signed overflow and implementation-defined + * right shifts/conversions. Reinterpret the final bits numerically. */ +function emitIntegerBits(emitter: CEmitter, e: ExprOf<"bin" | "unary">, left: string, right?: string): Temp { + const a = `sc_bits_${emitter.tempCounter++}`; + const out = `sc_bits_${emitter.tempCounter++}`; + emitter.line(`uint32_t ${a} = (uint32_t)(int64_t)${left};`); + let operation: string; + if (e.kind === "unary") operation = `~${a}`; + else { + const b = `sc_bits_${emitter.tempCounter++}`; + const shift = e.op === "<<" || e.op === ">>" || e.op === ">>>"; + emitter.line(`uint32_t ${b} = (uint32_t)(int64_t)${right}${shift ? " & 31u" : ""};`); + operation = e.op === ">>" ? `(${a} >> ${b}) | ((${a} & 0x80000000u) ? ~(UINT32_MAX >> ${b}) : 0u)` + : `${a} ${e.op === ">>>" ? ">>" : e.op} ${b}`; + } + emitter.line(`uint32_t ${out} = ${operation};`); + return emitter.newTemp(e.type, e.op === ">>>" ? `(double)${out}` : `(double)${out} - ((${out} & 0x80000000u) ? 4294967296.0 : 0.0)`); +} + function emitOperatorExpr( emitter: CEmitter, e: ExprOf<"bin" | "unary" | "incDec" | "fieldIncDec" | "assignExpr" | "seqExpr">, @@ -846,6 +865,12 @@ function emitOperatorExpr( case "bin": { const l = emitter.emitExpr(e.left); const r = emitter.emitExpr(e.right); + if ((e.op === "+" || e.op === "-") && emitter.integerRanges.get(e)) { + return emitter.newTemp(e.type, `(double)((int64_t)${l.name} ${e.op} (int64_t)${r.name})`); + } + if (["&", "|", "^", "<<", ">>", ">>>"].includes(e.op) && emitter.integerRanges.get(e.left) && emitter.integerRanges.get(e.right)) { + return emitIntegerBits(emitter, e, l.name, r.name); + } switch (e.op) { case "%": return emitter.newTemp(e.type, `fmod(${l.name}, ${r.name})`); @@ -875,7 +900,8 @@ function emitOperatorExpr( } case "unary": { const v = emitter.emitExpr(e.operand); - if (e.op === "~") return emitter.newTemp(e.type, `scr_bit_not(${v.name})`); + if (e.op === "~") return emitter.integerRanges.get(e.operand) + ? emitIntegerBits(emitter, e, v.name) : emitter.newTemp(e.type, `scr_bit_not(${v.name})`); return emitter.newTemp(e.type, `${e.op}${v.name}`); } case "incDec": { diff --git a/packages/compiler/src/backend/c/stmts.ts b/packages/compiler/src/backend/c/stmts.ts index 4e0b6adca..4eff5b0f7 100644 --- a/packages/compiler/src/backend/c/stmts.ts +++ b/packages/compiler/src/backend/c/stmts.ts @@ -11,6 +11,7 @@ import { boxAccess, cDecl, cStringLiteral, elemAccess, vAdapters } from "./types import { OVERFLOW_MEMBER } from "./shapes.js"; import { emitStableReceiver } from "./exprs.js"; import { matchIntegerBytesForLoop } from "../../ir/integer-loops.js"; +import { analyzeIntegerRanges } from "../../ir/integer-ranges.js"; import { endsWithJump, matchStringSelfConcat } from "../../ir/analysis.js"; @@ -32,6 +33,7 @@ export function emitFunction(emitter: CEmitter, fn: IrFunction): void { emitter.currentLocals = new Map(fn.locals.map((l) => [l.id, l])); emitter.captureIds = new Set((fn.captures ?? []).map((c) => c.localId)); emitter.integerLoopBindings.clear(); + emitter.integerRanges = analyzeIntegerRanges(fn); emitter.line(`${emitter.signature(fn)} {${emitter.srcComment(fn.loc)}`); emitter.indent++; diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index dc1f91400..2f2a36436 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -82,6 +82,7 @@ import type { import { CAUGHT, ffiCallbackType, isDynTypedRefType, isFfiContextParam, isRefCounted, isUnitType, moduleEmbedsBuiltin, moduleEmbedsCompressedNpm, moduleUsesChildProcess, moduleUsesDynInvoke, moduleUsesFetch, moduleUsesFsWatch, moduleUsesHttpServer, moduleUsesNet, moduleUsesNodeTest, moduleUsesProcessEvents, moduleUsesStream, moduleUsesTls, moduleUsesTlsCa, NPM_COMPRESS_MIN, POINTER_KINDS, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, typeKey, VOID } from "../../ir/ir.js"; import { matchIntegerBytesForLoop } from "../../ir/integer-loops.js"; import { scalarizeNumericRecords } from "../../ir/scalar-records.js"; +import { analyzeIntegerRanges, type IntegerRanges } from "../../ir/integer-ranges.js"; import { findConstantNumericTables, type ConstantNumericTable } from "../../ir/constant-tables.js"; import { allocateFfiCallbackAdapters, hasForeignFfiCallback, hasRetainedFfiCallback, type FfiCallbackAdapter } from "../ffi-callbacks.js"; import { RUNTIME_ABI_MARKER } from "../runtime-abi.js"; @@ -336,6 +337,7 @@ class LlEmitter { private captureIds = new Set(); /** Active canonical byte-loop induction bindings: local id → size_t slot. */ private integerLoopBindings = new Map(); + private integerRanges: IntegerRanges = new Map(); /** Enclosing try-with-FINALLY regions, innermost last: a `return` * inside one runs every crossed finally (innermost first) before the * actual ret — the C emitter's pending-return path, with the finally @@ -2972,6 +2974,7 @@ class LlEmitter { this.currentLocals = new Map(fn.locals.map((l) => [l.id, l])); this.captureIds = new Set((fn.captures ?? []).map((c) => c.localId)); this.integerLoopBindings.clear(); + this.integerRanges = analyzeIntegerRanges(fn); this.chainSlots.clear(); this.finallyStack = []; this.tryStack = []; @@ -4356,8 +4359,8 @@ class LlEmitter { return emitBytesGet(this.expressionContext(), elem, receiver, index, integerIndex); } - private emitToUint32(value: string): string { - return emitToUint32(this.expressionContext(), value); + private emitToUint32(value: string, expr?: IrExpr): string { + return emitToUint32(this.expressionContext(), value, expr); } private emitBytesSet(elem: IrBytesElem, receiver: string, index: string, value: string, integerIndex = false): void { diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index 4f81223e1..37e5a0c0d 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -172,8 +172,15 @@ export function emitBytesGet(host: LlvmEmitterContext, elem: IrBytesElem, receiv return { name: out, type: F64 }; } -export function emitToUint32(host: LlvmEmitterContext, value: string): string { +export function emitToUint32(host: LlvmEmitterContext, value: string, expr?: IrExpr): string { const B = host.B; + if (expr && host.integerRanges.get(expr)) { + const integer = B.tmp(); + const out = B.tmp(); + B.line(`${integer} = fptosi double ${value} to i64`); + B.line(`${out} = trunc i64 ${integer} to i32`); + return out; + } const aboveMin = B.tmp(); const belowMax = B.tmp(); const fast = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/expr-context.ts b/packages/compiler/src/backend/llvm/expr-context.ts index acd895acf..2a0b4459c 100644 --- a/packages/compiler/src/backend/llvm/expr-context.ts +++ b/packages/compiler/src/backend/llvm/expr-context.ts @@ -4,6 +4,7 @@ import type { IrBytesElem, IrExpr, IrFfiImport, IrFunction, IrLibFn, IrLocal, IrModule, IrRecordShape, IrStmt, IrType, IrUnionDef, SrcLoc } from "../../ir/ir.js"; import type { FfiCallbackAdapter } from "../ffi-callbacks.js"; import type { ConstantNumericTable } from "../../ir/constant-tables.js"; +import type { IntegerRanges } from "../../ir/integer-ranges.js"; import type { BlockBuilder } from "./blocks.js"; import type { LlClassMeta } from "./classes.js"; import type { LlDyn } from "./dyn.js"; @@ -56,6 +57,7 @@ export interface LlvmEmitterContext extends ShapeHost { cstr(text: string): string; currentGenerator: { yieldT: IrType; nextT: IrType; } | null; constantNumericTables: ReadonlyMap; + integerRanges: IntegerRanges; currentWasiCoro: { kind: "async" | "generator"; id: string; handle: string; self: string; finalLabel: string; cleanupLabel: string; suspendLabel: string; } | null; declare(decl: string): void; dyn: LlDyn; @@ -74,7 +76,7 @@ export interface LlvmEmitterContext extends ShapeHost { emitBytesIntrinsic(e: IrExpr & { kind: "bytesIntrinsic" }): LlValue; emitBytesLength(elem: IrBytesElem, receiver: string, bytes: boolean): LlValue; emitStableReceiver(receiver: IrExpr, following: IrExpr[]): LlValue; - emitToUint32(value: string): string; + emitToUint32(value: string, expr?: IrExpr): string; emitCallExpr(e: ExprOf<"call" | "ffiCall" | "closure" | "callValue" | "selfRef" | "new" | "classRef" | "newValue" | "instanceOfValue" | "promiseVoidWiden" | "upcast" | "downcast" | "instanceOf" | "virtualCall">): LlValue; emitChildProcessLibCall(e: LibCallExpr): LlValue; emitContainerExpr(e: ExprOf<"arrayLit" | "arrayNewLen" | "arrayGet" | "arrayHas" | "arrayState" | "arrIntrinsic" | "bytesNew" | "bytesIntrinsic" | "mapNew" | "mapIntrinsic" | "setIntrinsic" | "setNew">): LlValue; diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 8d189dc82..ddf9955bc 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -98,14 +98,25 @@ export function emitOperatorExpr(host: LlvmEmitterContext, e: ExprOf<"bin" | "un B.line(`${t} = icmp ${e.op === "===" ? "eq" : "ne"} ptr ${l.name}, ${r.name}`); } else if (arith[e.op] !== undefined || cmp[e.op] !== undefined) { if (e.left.type.kind !== "f64") throw new LlvmUnsupportedError(`bin:${e.op}:${e.left.type.kind}`, e.loc); - if (arith[e.op] !== undefined) B.line(`${t} = ${arith[e.op]} double ${l.name}, ${r.name}`); + if ((e.op === "+" || e.op === "-") && host.integerRanges.get(e)) { + const left = B.tmp(); + const right = B.tmp(); + const result = B.tmp(); + // Every signed i54 value is exactly representable as a double. + // Exposing that width lets LLVM cancel later number/integer + // round trips. The proven safe result cannot overflow i54. + B.line(`${left} = fptosi double ${l.name} to i54`); + B.line(`${right} = fptosi double ${r.name} to i54`); + B.line(`${result} = ${e.op === "+" ? "add" : "sub"} nsw i54 ${left}, ${right}`); + B.line(`${t} = sitofp i54 ${result} to double`); + } else if (arith[e.op] !== undefined) B.line(`${t} = ${arith[e.op]} double ${l.name}, ${r.name}`); else B.line(`${t} = fcmp ${cmp[e.op]} double ${l.name}, ${r.name}`); } else if (bit[e.op] !== undefined) { if (e.left.type.kind !== "f64" || e.right.type.kind !== "f64") { throw new LlvmUnsupportedError(`bin:${e.op}:${e.left.type.kind}:${e.right.type.kind}`, e.loc); } - const left = host.emitToUint32(l.name); - let right = host.emitToUint32(r.name); + const left = host.emitToUint32(l.name, e.left); + let right = host.emitToUint32(r.name, e.right); if (e.op === "<<" || e.op === ">>" || e.op === ">>>") { const shift = B.tmp(); B.line(`${shift} = and i32 ${right}, 31`); @@ -128,7 +139,7 @@ export function emitOperatorExpr(host: LlvmEmitterContext, e: ExprOf<"bin" | "un if (e.op === "-") B.line(`${t} = fneg double ${v.name}`); else if (e.op === "!") B.line(`${t} = xor i1 ${v.name}, true`); else { - const value = host.emitToUint32(v.name); + const value = host.emitToUint32(v.name, e.operand); const result = B.tmp(); B.line(`${result} = xor i32 ${value}, -1`); B.line(`${t} = sitofp i32 ${result} to double`); diff --git a/packages/compiler/src/ir/integer-ranges.test.ts b/packages/compiler/src/ir/integer-ranges.test.ts new file mode 100644 index 000000000..c08c800d8 --- /dev/null +++ b/packages/compiler/src/ir/integer-ranges.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "vitest"; +import { analyzeIntegerRanges } from "./integer-ranges.js"; +import { F64, type IrExpr, type IrFunction, type IrStmt } from "./ir.js"; + +const loc = { file: "test.ts", start: 0, end: 0 }; +const num = (value: number): IrExpr => ({ loc, kind: "numLit", value, type: F64 }); +const ref = (localId = "x"): IrExpr => ({ loc, kind: "varRef", localId, type: F64 }); +const bin = (op: Extract["op"], left: IrExpr, right: IrExpr): IrExpr => ({ loc, kind: "bin", op, left, right, type: F64 }); +const assign = (value: IrExpr): IrStmt => ({ loc, kind: "assign", localId: "x", value }); +const fn = (body: IrStmt[], boxed = false): IrFunction => ({ loc, name: "f", params: [], returnType: F64, locals: [{ id: "x", name: "x", type: F64, mutable: true, ...(boxed ? { boxed: true as const } : {}) }], body }); + +test("tracks exact additions and bitwise conversions through assignments", () => { + const sum = bin("+", ref(), bin("<<", ref(), num(3))); + const unsigned = bin(">>>", ref(), num(0)); + const ranges = analyzeIntegerRanges(fn([assign(bin("|", num(0), num(0))), assign(sum), { loc, kind: "return", value: unsigned }])); + expect(ranges.get(sum)).toEqual({ min: -4294967296, max: 4294967294 }); + expect(ranges.get(unsigned)).toEqual({ min: 0, max: 4294967295 }); +}); + +test("rejects negative zero, fractions, nonfinite values and imprecise sums", () => { + for (const value of [-0, 0.5, NaN, Infinity, -Infinity, 9007199254740992]) { + const sum = bin("+", num(value), num(0)); + expect(analyzeIntegerRanges(fn([{ loc, kind: "return", value: sum }])).get(sum)).toBeNull(); + } + const exact = bin("-", num(Number.MAX_SAFE_INTEGER), num(1)); + const rounded = bin("+", num(Number.MAX_SAFE_INTEGER), num(2)); + const ranges = analyzeIntegerRanges(fn([assign(exact), { loc, kind: "return", value: rounded }])); + expect(ranges.get(exact)).toEqual({ min: 9007199254740990, max: 9007199254740990 }); + expect(ranges.get(rounded)).toBeNull(); +}); + +test("forgets facts across opaque expressions, control flow and boxed locals", () => { + const sum = (): IrExpr => bin("+", ref(), num(1)); + const call: IrExpr = { loc, kind: "call", callee: "unknown", args: [], type: F64 }; + const afterCall = sum(); + const afterBlock = sum(); + const boxed = sum(); + const ranges = analyzeIntegerRanges(fn([ + assign(num(1)), { loc, kind: "exprStmt", expr: call }, assign(afterCall), + assign(num(1)), { loc, kind: "block", body: [assign(num(Infinity))] }, { loc, kind: "return", value: afterBlock }, + ])); + expect(ranges.get(afterCall)).toBeNull(); + expect(ranges.get(afterBlock)).toBeNull(); + expect(analyzeIntegerRanges(fn([assign(num(1)), { loc, kind: "return", value: boxed }], true)).get(boxed)).toBeNull(); +}); + +test("analyzes nested bodies independently and snapshots operands before writes", () => { + const before = ref(); + const write: IrExpr = { loc, kind: "assignExpr", localId: "x", value: num(Infinity), type: F64 }; + const after = ref(); + const nested = bin("+", ref(), num(1)); + const ranges = analyzeIntegerRanges(fn([ + assign(num(1)), { loc, kind: "exprStmt", expr: bin("|", before, write) }, { loc, kind: "exprStmt", expr: after }, + { loc, kind: "block", body: [assign(num(2)), { loc, kind: "return", value: nested }] }, + ])); + expect(ranges.get(before)).toEqual({ min: 1, max: 1 }); + expect(ranges.get(after)).toBeNull(); + expect(ranges.get(nested)).toEqual({ min: 3, max: 3 }); +}); + +test("shared expression objects never inherit a proof from another occurrence", () => { + const shared = ref(); + const ranges = analyzeIntegerRanges(fn([ + assign(num(1)), { loc, kind: "exprStmt", expr: shared }, assign(num(Infinity)), + { loc, kind: "return", value: { loc, kind: "call", callee: "unknown", args: [shared], type: F64 } }, + ])); + expect(ranges.get(shared)).toBeNull(); +}); diff --git a/packages/compiler/src/ir/integer-ranges.ts b/packages/compiler/src/ir/integer-ranges.ts new file mode 100644 index 000000000..264f71b8a --- /dev/null +++ b/packages/compiler/src/ir/integer-ranges.ts @@ -0,0 +1,102 @@ +import type { IrExpr, IrFunction, IrStmt } from "./ir.js"; + +/** Exactly representable integers, excluding negative zero. These facts + * justify signed i64 arithmetic as well as unchecked ToUint32 conversion. */ +export interface IntegerRange { min: number; max: number } +export type IntegerRanges = ReadonlyMap; +const SIGNED: IntegerRange = { min: -2147483648, max: 2147483647 }; +const UNSIGNED: IntegerRange = { min: 0, max: 4294967295 }; + +/** Deliberately local range analysis. Facts flow only through straight-line + * statements and numeric expression evaluation. Unknown expressions and + * control-flow boundaries discard them; nested bodies start independently. + * No assumptions about parameters, captures, globals or loop iterations. */ +export function analyzeIntegerRanges(fn: IrFunction): IntegerRanges { + const ranges = new Map(); + if (fn.async || fn.generator) return ranges; + const captures = new Set((fn.captures ?? []).map((c) => c.localId)); + const eligible = new Set(fn.locals.filter((l) => l.type.kind === "f64" && !l.boxed && !l.tdz && !captures.has(l.id)).map((l) => l.id)); + type Facts = Map; + + function remember(e: IrExpr, range: IntegerRange | null): IntegerRange | null { + // IR normally is a tree, but shared expression objects must be safe at + // every occurrence. An unknown occurrence invalidates any earlier fact. + const previous = ranges.get(e); + ranges.set(e, previous === undefined ? range : previous && range ? { + min: Math.min(previous.min, range.min), max: Math.max(previous.max, range.max), + } : null); + return range; + } + function opaque(value: unknown): void { + if (Array.isArray(value)) { value.forEach(opaque); return; } + if (value === null || typeof value !== "object") return; + const node = value as Record; + if (typeof node["kind"] === "string") ranges.set(value as IrExpr, null); + for (const [key, child] of Object.entries(node)) if (key !== "type" && key !== "loc") opaque(child); + } + function expr(e: IrExpr, facts: Facts): IntegerRange | null { + let range: IntegerRange | null = null; + switch (e.kind) { + case "numLit": + if (Number.isSafeInteger(e.value) && !Object.is(e.value, -0)) range = { min: e.value, max: e.value }; + break; + case "varRef": range = facts.get(e.localId) ?? null; break; + case "bin": { + // Evaluate in source order: an opaque right operand may invalidate + // locals, but cannot change the already-snapshotted left value. + const left = expr(e.left, facts); + const right = expr(e.right, facts); + if (e.type.kind !== "f64") break; + if (e.op === ">>>") range = UNSIGNED; + else if (["&", "|", "^", "<<", ">>"].includes(e.op)) range = SIGNED; + else if (left && right && (e.op === "+" || e.op === "-")) { + const min = e.op === "+" ? left.min + right.min : left.min - right.max; + const max = e.op === "+" ? left.max + right.max : left.max - right.min; + if (Number.isSafeInteger(min) && Number.isSafeInteger(max)) range = { min, max }; + } + break; + } + case "unary": + expr(e.operand, facts); + if (e.op === "~") range = SIGNED; + break; + default: + facts.clear(); + opaque(e); + return null; + } + return remember(e, range); + } + function body(stmts: IrStmt[]): void { + const facts: Facts = new Map(); + for (const s of stmts) { + switch (s.kind) { + case "varDecl": case "assign": { + const value = s.kind === "varDecl" ? s.init : s.value; + const range = value ? expr(value, facts) : null; + facts.delete(s.localId); + if (range && eligible.has(s.localId)) facts.set(s.localId, range); + break; + } + case "return": + if (s.value) expr(s.value, facts); + facts.clear(); + break; + case "exprStmt": expr(s.expr, facts); break; + default: + facts.clear(); + // Only statement-list children are analyzed. Headers, conditions, + // case selectors and every other expression remain conservative. + for (const [key, value] of Object.entries(s)) { + if (key === "loc") continue; + if (["body", "then", "else_", "tryBody", "catchBody", "finallyBody"].includes(key) && Array.isArray(value)) body(value as IrStmt[]); + else if (s.kind === "switch" && key === "cases") { + for (const c of s.cases) { opaque(c.test); body(c.body); } + } else opaque(value); + } + } + } + } + body(fn.body); + return ranges; +} diff --git a/packages/compiler/test/integer-bitwise-arithmetic.test.ts b/packages/compiler/test/integer-bitwise-arithmetic.test.ts new file mode 100644 index 000000000..3390e0182 --- /dev/null +++ b/packages/compiler/test/integer-bitwise-arithmetic.test.ts @@ -0,0 +1,42 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import { emitCModule } from "../src/backend/c/c-emitter.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; + +test("bounded integer arithmetic emits integer operations and leaves uncertain numbers alone", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-integer-bits-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, ` +function safe(input: number): number { + let h = input >>> 0; + h = h ^ (h << 13); + h = h + (h << 3); + return h >>> 0; +} +function unknown(input: number): number { return (input + 1) >>> 0; } +function negativeZero(): number { const x = -0; return x + x; } +console.log(safe(17), unknown(1.5), 1 / negativeZero()); +`); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + const c = emitCModule(mod); + const ll = emitLlvmModule(mod); + const cBody = (name: string): string => c.match(new RegExp(`static [^\\n]+ sc_f_${name}\\([^\\n]*\\) \\{([\\s\\S]*?)\\n\\}`))![1]!; + const llBody = (name: string): string => ll.match(new RegExp(`define internal [^\\n]+ @sc_f_${name}\\([^\\n]*\\) #0 \\{([\\s\\S]*?)\\n\\}`))![1]!; + expect(cBody("safe").match(/scr_bit_/g)).toHaveLength(1); + expect(cBody("safe")).toContain("(double)((int64_t)"); + expect(llBody("safe")).toContain(" = add nsw i54 "); + // Only the arbitrary input's slow ToUint32 path still adds doubles. + expect(llBody("safe").match(/ = fadd double /g)).toHaveLength(1); + expect(cBody("unknown")).toContain("scr_bit_ushr("); + expect(llBody("unknown")).toContain("uint32.coerce.slow"); + expect(llBody("unknown")).toContain(" = fadd double "); + expect(llBody("negativeZero")).toContain(" = fadd double "); + } finally { await rm(dir, { recursive: true, force: true }); } +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 7cc2ad549..6e0fc2f43 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -6691,6 +6691,12 @@ ], "diags": [] }, + "/tests/corpus/2973-integer-bitwise-arithmetic.ts": { + "order": [ + "/tests/corpus/2973-integer-bitwise-arithmetic.ts" + ], + "diags": [] + }, "/tests/corpus/300-if-else.ts": { "order": [ "/tests/corpus/300-if-else.ts" diff --git a/tests/corpus/2973-integer-bitwise-arithmetic.ts b/tests/corpus/2973-integer-bitwise-arithmetic.ts new file mode 100644 index 000000000..fe4e04c58 --- /dev/null +++ b/tests/corpus/2973-integer-bitwise-arithmetic.ts @@ -0,0 +1,89 @@ +function mix(input: number): number { + let h = input >>> 0; + h = h ^ (h << 13); + h = h ^ (h >>> 17); + h = h ^ (h << 5); + h = h + (h << 3); + h = h ^ (h >>> 11); + h = h + (h << 15); + return h >>> 0; +} + +function bits(input: number, shift: number): void { + const a = input | 0; + const b = shift >>> 0; + const sum = a + (a << 3); + const difference = sum - (a >>> 1); + const and = a & b; + const or = a | b; + const xor = a ^ b; + const left = a << b; + const signed = a >> b; + const unsigned = a >>> b; + const inverted = ~a; + console.log(sum, difference, and, or, xor, left, signed, unsigned, inverted); +} + +const inputs = [0, -0, 1, -1, 0.5, -1.5, 2147483647, -2147483648, 4294967295, 4294967296, 9007199254740991, 9007199254740992, 1e30, NaN, Infinity, -Infinity]; +for (const input of inputs) { + console.log("mix", input, mix(input)); + for (const shift of [0, 1, 31, 32, 33, -1, 63, 1.5, NaN, Infinity]) bits(input, shift); +} + +function boundaries(): void { + const max = 9007199254740991; + const exact = max - 1; + const rounded = max + 2; + const n = -0; + const neg = n + n; + const sub = n - 0; + const zero = (0 | 0) - (0 | 0); + console.log("bounds", exact, rounded, rounded | 0, 1 / neg, 1 / sub, 1 / zero); +} +boundaries(); + +function stale(flag: boolean): void { + let x = 1 | 0; + if (flag) x = Infinity; + const afterBranch = x + 2; + x = 3 | 0; + const snapshot = x | (x = 4294967297.75); + const afterWrite = x + 2; + let count = 0; + while (count < 3) { + const previous = x + 1; + console.log("loop", previous, previous | 0); + x = count === 0 ? NaN : 2.5; + count++; + } + console.log("stale", afterBranch, snapshot, afterWrite); +} +stale(false); +stale(true); + +function captured(): void { + let x = 1 | 0; + function replace(): number { x = Infinity; return 1; } + const snapshot = x | replace(); + const next = x + 1; + console.log("captured", snapshot, next); +} +captured(); + +function exceptional(): void { + let x = 1 | 0; + try { + x = Infinity; + throw new Error("stop"); + } catch { + console.log("catch", x + 1, x | 0); + } finally { + x = -0; + } + const sum = x + x; + console.log("finally", 1 / sum); +} +exceptional(); + +function ordered(value: number): number { console.log("operand", value); return value; } +console.log("order", (ordered(4294967295) >>> 0) + (ordered(-2147483648) | 0));