Keep Dates as time values where no use observes their identity

This commit is contained in:
Chris Tate
2026-10-22 20:36:52 -05:00
parent 8d5b3377aa
commit fa843548a5
11 changed files with 429 additions and 8 deletions
@@ -559,21 +559,25 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
? "scr_stream_dyn_async_iterator_open"
: LIB_FN_SYMS[e.fn]);
if (sym === undefined) throw new LlvmUnsupportedError(`libCall:${e.fn}`, e.loc);
// The runtime's Date functions take and answer time values: a Date
// operand passes its time value, read at once from the borrowed object,
// and a Date result is a new Date object.
const args = READ_ONLY_STRING_LIB_FNS.has(e.fn)
? emitBorrowedInputs(host, e.args)
: e.args.map((a) =>
borrowsJsonInputs(e.fn) || borrowsIteratorInputs(e.fn)
? emitBorrowedInput(host, a)
: host.emitExpr(a),
a.type.kind === "date"
? { name: emitDateTimeArg(host, a), type: F64 }
: borrowsJsonInputs(e.fn) || borrowsIteratorInputs(e.fn)
? emitBorrowedInput(host, a)
: host.emitExpr(a),
);
// getTime and valueOf answer the time value the argument already is.
if ((e.fn === "date.getTime" || e.fn === "date.valueOf") && e.args[0]?.type.kind === "date")
return { name: args[0]!.name, type: e.type };
// The deep comparisons' number test: computed here, with no runtime call.
if (e.fn === "assert.sameValue")
return { name: emitSameValueF64(host, args[0]!.name, args[1]!.name), type: e.type };
// The runtime's Date functions take and answer time values: a Date
// operand passes its time value, and a Date result is a new Date object.
const callArgs = args.map((a) =>
a.type.kind === "date" ? { name: emitDateTime(host, a.name), type: F64 } : a,
);
const callArgs = args;
if (e.type.kind === "date") {
const ms = emitSymbolLibCall(host, { ...e, type: F64 }, sym, callArgs);
host.declare(`declare ptr @scr_date_new(double)`);
@@ -584,6 +588,19 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
return emitSymbolLibCall(host, e, sym, callArgs);
}
/** The time value of a Date argument. A Date that a runtime Date function
* produces for this argument alone (`new Date(ms).getTime()`, a
* time-value local's read) passes the function's time value and creates
* no Date object. */
function emitDateTimeArg(host: LlvmEmitterContext, date: IrExpr): string {
// A time-value local's read is its time value itself.
if (date.kind === "libCall" && date.fn === "date.fromTime")
return host.emitExpr(date.args[0]!).name;
if (date.kind === "libCall" && date.fn.startsWith("date."))
return emitGenericLibCall(host, { ...date, type: F64 }).name;
return emitDateTime(host, emitBorrowedInput(host, date).name);
}
/** A Date object's time value (ScrDate.ms). */
function emitDateTime(host: LlvmEmitterContext, date: string): string {
const B = host.B;
@@ -330,6 +330,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"date.parse": "scr_date_parse",
"date.getTime": "scr_date_get_time",
"date.valueOf": "scr_date_get_time",
"date.fromTime": "scr_date_get_time",
"date.parseGetTime": "scr_date_parse",
"date.utc": "scr_date_utc",
"date.newLocal": "scr_date_new_local",
@@ -0,0 +1,189 @@
import * as ts from "../ts7/adapter.js";
import type { Lowerer } from "./lowerer.js";
/** Date methods whose receiver is only read for its time value: a Date
* local used as their receiver can store the time value instead of a Date
* object (builtins/dates.ts lowers them). */
const TIME_VALUE_METHODS = new Set([
"getTime",
"valueOf",
"toISOString",
"toJSON",
"toString",
"toDateString",
"toTimeString",
"toUTCString",
"getFullYear",
"getUTCFullYear",
"getMonth",
"getUTCMonth",
"getDate",
"getUTCDate",
"getDay",
"getUTCDay",
"getHours",
"getUTCHours",
"getMinutes",
"getUTCMinutes",
"getSeconds",
"getUTCSeconds",
"getMilliseconds",
"getUTCMilliseconds",
"getTimezoneOffset",
]);
const RELATIONAL = new Set([
ts.SyntaxKind.LessThanToken,
ts.SyntaxKind.LessThanEqualsToken,
ts.SyntaxKind.GreaterThanToken,
ts.SyntaxKind.GreaterThanEqualsToken,
]);
/** True when a local `const` (or a `let` that is never assigned again)
* holding a Date can keep the Date's time value instead of a Date object:
* no use observes the Date's identity. Every use reads the time value as
* a Date method receiver, a String() or template operand, a relational or
* string `+` operand, or `new Date(d)`'s argument, inside the declaring
* function, or inside the block or loop that scopes a module-level
* binding (no closure captures it). Top-level module bindings keep the
* object. Other uses box the time value into a new Date at the use. */
export function isTimeValueDateDeclaration(
lowerer: Lowerer,
decl: ts.VariableDeclaration,
): boolean {
const known = lowerer.timeValueDateDecisions.get(decl);
if (known !== undefined) return known;
const answer = decideTimeValueDate(lowerer, decl);
lowerer.timeValueDateDecisions.set(decl, answer);
return answer;
}
function decideTimeValueDate(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean {
if (!ts.isIdentifier(decl.name) || decl.initializer === undefined) return false;
const sf = decl.getSourceFile();
if (sf.isDeclarationFile || /\.[cm]?jsx?$/.test(sf.fileName)) return false;
const list = decl.parent;
if (!ts.isVariableDeclarationList(list)) return false;
const isConst = (list.flags & ts.NodeFlags.Const) !== 0;
const isLet = (list.flags & ts.NodeFlags.Let) !== 0;
if (!isConst && !isLet) return false;
if (!ts.isVariableStatement(list.parent) && !ts.isForStatement(list.parent)) return false;
const fn = enclosingFunction(decl);
// The uses to check: the function body, or for a binding inside a block
// or loop at module level (a local of the module's initializer, not a
// module binding) the block or loop that scopes it.
let scope: ts.Node | undefined;
if (fn !== null) scope = fn.body;
else if (!inClassStaticBlock(decl)) scope = moduleBlockScope(decl);
if (scope === undefined) return false;
// The lib Date interface by its symbol first: mapping every candidate
// binding's type would intern its unions before the lowering does.
const typeSymbol = lowerer.typeOf(decl.name).getSymbol();
if (
typeSymbol === undefined ||
ts.symbolName(typeSymbol) !== "Date" ||
!typeSymbol.declarations.some(
(d) => ts.isInterfaceDeclaration(d) && lowerer.isStdlibFile(d.getSourceFile()),
)
)
return false;
if (lowerer.mapTypeOf(lowerer.typeOf(decl.name))?.kind !== "date") return false;
const symbol = lowerer.checker.getSymbolAtLocation(decl.name);
if (!symbol) return false;
let ok = true;
const visit = (node: ts.Node): void => {
if (!ok) return;
if (ts.isIdentifier(node) && node !== decl.name && node.text === decl.name.text) {
if (lowerer.checker.getSymbolAtLocation(node) === symbol) {
if (enclosingFunction(node) !== fn || !readsTimeValue(lowerer, node)) ok = false;
}
return;
}
ts.forEachChild(node, visit);
};
visit(scope);
return ok;
}
/** The block or for statement that scopes a module-level binding which is
* not a module binding: a declaration in a block or loop body, or in a
* for statement's initializer. Undefined for a top-level declaration. */
function moduleBlockScope(decl: ts.VariableDeclaration): ts.Node | undefined {
const list = decl.parent;
if (!list || !ts.isVariableDeclarationList(list)) return undefined;
if (ts.isForStatement(list.parent)) return list.parent;
const statement = list.parent;
if (!ts.isVariableStatement(statement) || ts.isSourceFile(statement.parent)) return undefined;
for (let cur: ts.Node | undefined = statement.parent; cur; cur = cur.parent) {
if (ts.isBlock(cur) || ts.isCaseBlock(cur)) return cur;
if (ts.isSourceFile(cur)) return undefined;
}
return undefined;
}
function inClassStaticBlock(node: ts.Node): boolean {
for (let cur = node.parent; cur; cur = cur.parent) {
if (ts.isClassStaticBlockDeclaration(cur)) return true;
if (ts.isSourceFile(cur)) return false;
}
return false;
}
function enclosingFunction(node: ts.Node): ts.FunctionLikeDeclaration | null {
for (let cur = node.parent; cur; cur = cur.parent) {
if (
ts.isFunctionDeclaration(cur) ||
ts.isFunctionExpression(cur) ||
ts.isArrowFunction(cur) ||
ts.isMethodDeclaration(cur) ||
ts.isConstructorDeclaration(cur) ||
ts.isGetAccessorDeclaration(cur) ||
ts.isSetAccessorDeclaration(cur)
)
return cur;
if (ts.isClassStaticBlockDeclaration(cur) || ts.isSourceFile(cur)) return null;
}
return null;
}
/** A use of the binding that reads only its time value. */
function readsTimeValue(lowerer: Lowerer, id: ts.Identifier): boolean {
let node: ts.Node = id;
while (ts.isParenthesizedExpression(node.parent)) node = node.parent;
const parent = node.parent;
if (ts.isPropertyAccessExpression(parent) && parent.expression === node) {
const call = parent.parent;
return (
!parent.questionDotToken &&
ts.isCallExpression(call) &&
call.expression === parent &&
!call.questionDotToken &&
TIME_VALUE_METHODS.has(parent.name.text) &&
lowerer.isStdlibMember(parent)
);
}
if (ts.isTemplateSpan(parent)) return parent.expression === node;
if (ts.isCallExpression(parent)) {
return (
parent.arguments.nodes.length === 1 &&
parent.arguments.nodes[0] === node &&
ts.isIdentifier(parent.expression) &&
lowerer.isStdlibGlobal(parent.expression, "String")
);
}
if (ts.isNewExpression(parent)) {
return (
parent.arguments?.nodes.length === 1 &&
parent.arguments.nodes[0] === node &&
ts.isIdentifier(parent.expression) &&
lowerer.isStdlibGlobal(parent.expression, "Date")
);
}
if (ts.isBinaryExpression(parent)) {
const other = parent.left === node ? parent.right : parent.left;
const otherKind = lowerer.mapTypeOf(lowerer.typeOf(other))?.kind;
if (RELATIONAL.has(parent.operatorToken.kind)) return otherKind === "date";
if (parent.operatorToken.kind === ts.SyntaxKind.PlusToken) return otherKind === "string";
}
return false;
}
@@ -8,6 +8,7 @@ import { type Lowerer, PoisonError } from "../lowerer.js";
import {
BOOL,
BYTES_ELEMENT_NAME,
DATE_T,
DYN,
F64,
type IrExpr,
@@ -170,6 +171,12 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
// when the checker types this USE as a single arm, maybeNarrow
// bridges the tagged representation with a unionNarrow.
const local = lowerer.resolveLocal(expr);
// A Date local holding its time value reads as that Date: only uses
// that read the time value reach here (date-values.ts).
if (local && lowerer.timeValueDateLocals.has(local)) {
const time: IrExpr = { kind: "varRef", localId: local.id, type: F64, loc };
return { kind: "libCall", fn: "date.fromTime", args: [time], type: DATE_T, loc };
}
if (local) {
// A switch can dispatch past a lexical declaration. Keep this
// previously refused codec form out until switch-scope TDZ boxes
@@ -28,6 +28,7 @@ import * as ts from "../ts7/adapter.js";
import { processDataMemberValue, programReadsExitCode } from "./builtins/process.js";
import { requireInitFlagStmt } from "./require-initialization.js";
import { isJsonRequireDecl } from "./json-require.js";
import { isTimeValueDateDeclaration } from "./date-values.js";
import { bindingSource } from "../binding-source.js";
import { lowerConstructorReturn } from "./constructor-returns.js";
import type { Lowerer } from "./lowerer.js";
@@ -63,6 +64,7 @@ import {
BOOL,
BYTES_U8,
CAUGHT,
DATE_T,
DYN,
F64,
type IrExpr,
@@ -5234,6 +5236,16 @@ export function lowerVarDecl(
if (alias) return alias;
}
// A Date local whose identity no use observes stores its time value.
if (isTimeValueDateDeclaration(lowerer, decl)) {
const loc = locOf(decl);
const date = lowerer.lowerExprExpecting(decl.initializer!, DATE_T);
const local = lowerer.declareLocal(decl.name, decl.name.text, F64, isLet);
lowerer.timeValueDateLocals.add(local);
const init: IrExpr = { kind: "libCall", fn: "date.getTime", args: [date], type: F64, loc };
return { kind: "varDecl", localId: local.id, init, loc };
}
// A NULLISH generic binding (`const i: I<A & B> = null as any` — the
// declared type has no mapping, the value is provably null/undefined
// forever): no storage exists; reads know the value (member reads and
@@ -2331,6 +2331,10 @@ export class Lowerer {
readonly runtimeOptionalFields = new Set<string>();
/** Bindings initialized by a Date's `toJSON()` (analyzeDateJsonResults). */
readonly dateJsonBindings = new Set<ts.Symbol>();
/** isTimeValueDateDeclaration's answers, and the locals that hold a
* Date's time value instead of a Date object (date-values.ts). */
readonly timeValueDateDecisions = new Map<ts.Node, boolean>();
readonly timeValueDateLocals = new Set<IrLocal>();
/** Record bindings whose flow class reaches storage outside the class:
* they keep the declared layout, so an initializer from unchecked reads
* converts once instead of the binding adopting the widened variant. */
@@ -4738,6 +4738,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
"date.getMilliseconds": { argTypes: [DATE_T], result: F64 },
"date.getUTCMilliseconds": { argTypes: [DATE_T], result: F64 },
"date.getTimezoneOffset": { argTypes: [DATE_T], result: F64 },
/** A Date object for a time value a local holds (date-values.ts): a
* lib-call argument passes the time value itself, and any other use
* creates the Date. */
"date.fromTime": { argTypes: [F64], result: DATE_T },
/** Date.prototype.toString / toDateString / toTimeString and String(date):
* V8's local-time formats with the host zone's long name, "Invalid Date"
* for an invalid date. Owned (+1) string; never throws. */
@@ -0,0 +1,100 @@
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 { emitLlvmModule } from "../src/backend/llvm/emitter.js";
import type { IrModule } from "../src/ir/ir.js";
async function lowerSource(source: string): Promise<IrModule> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-date-time-values-"));
try {
const entry = join(dir, "main.ts");
await writeFile(entry, source);
const outPath = join(dir, "main.ir.json");
const result = await compile(entry, { outPath, outDir: dir, outputKind: "ir" });
if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n"));
return deserializeModule(await readFile(outPath, "utf8"));
} finally {
await rm(dir, { recursive: true, force: true });
}
}
/** The IR type of each local named `name` in function `fn`. */
function localKinds(mod: IrModule, fn: string, name: string): string[] {
const f = mod.functions.find((candidate) => candidate.name === fn);
if (!f) throw new Error(`no function ${fn}`);
return f.locals.filter((local) => local.name === name).map((local) => local.type.kind);
}
test("a Date local read only for its time value stores the time value", async () => {
const mod = await lowerSource(`
function format(ms: number): string {
const d = new Date(ms);
const later = new Date(ms + 1);
return d.toISOString() + String(d) + \`\${later}\` + (d < later) + d.getUTCHours() + new Date(d).getTime();
}
console.log(format(0));
`);
expect(localKinds(mod, "format", "d")).toEqual(["f64"]);
expect(localKinds(mod, "format", "later")).toEqual(["f64"]);
// Reading the time value creates no Date object.
const llvm = emitLlvmModule(mod, { pointerBits: 64 });
const body = /^define internal [^\n]*@sc_f_format\([^]*?^}/m.exec(llvm)?.[0] ?? "";
expect(body).not.toContain("@scr_date_new(");
});
test("a Date local whose identity a use can observe keeps the Date object", async () => {
const mod = await lowerSource(`
const keep: Date[] = [];
function uses(ms: number): boolean {
const compared = new Date(ms);
const stored = new Date(ms);
const passed = new Date(ms);
const captured = new Date(ms);
const returned = new Date(ms);
let reassigned = new Date(ms);
reassigned = new Date(ms + 1);
keep.push(stored);
const read = (): number => captured.getTime();
console.log(String(passed), read(), returned.getTime(), reassigned.getTime());
return compared === compared && pass(passed) === passed && back(returned) === returned;
}
function pass(d: Date): Date {
return d;
}
function back(d: Date): Date {
return d;
}
const top = new Date(0);
console.log(uses(0), top.getTime());
`);
for (const name of ["compared", "stored", "passed", "captured", "returned", "reassigned"]) {
expect(localKinds(mod, "uses", name), name).toEqual(["date"]);
}
});
test("a Date in a block or loop at module level stores the time value unless a use observes it", async () => {
const mod = await lowerSource(`
const seen: Date[] = [];
let total = 0;
for (let i = 0; i < 3; i++) {
const read = new Date(i * 1000);
const kept = new Date(i * 1000);
seen.push(kept);
total += read.getUTCSeconds() + read.toISOString().length;
}
{
const block = new Date(5);
const captured = new Date(6);
const later = (): number => captured.getTime();
total += block.getTime() + later();
}
const top = new Date(7);
console.log(total, seen.length, top.getTime());
`);
expect(localKinds(mod, "%init.0", "read")).toEqual(["f64"]);
expect(localKinds(mod, "%init.0", "block")).toEqual(["f64"]);
expect(localKinds(mod, "%init.0", "kept")).toEqual(["date"]);
expect(localKinds(mod, "%init.0", "captured")).toEqual(["date"]);
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5966-date-time-values.ts"
],
"diags": []
}
+80
View File
@@ -0,0 +1,80 @@
// Date locals that are only read for their time value (method receivers,
// String() and template operands, relational and string `+` operands,
// `new Date(d)`) print and compare as Dates; locals whose identity a use
// can observe keep it.
export {};
process.env.TZ = "America/New_York";
function describe(ms: number): string {
const d = new Date(ms);
const later = new Date(ms + 3_600_000);
const copy = new Date(d);
return [
d.toISOString(),
d.toUTCString(),
String(d),
`${later}`,
"at " + d,
d < later,
later >= d,
d.getHours(),
d.getUTCDay(),
d.getTimezoneOffset(),
copy.getTime() === d.getTime(),
d.toJSON(),
].join(" | ");
}
console.log(describe(Date.UTC(2026, 2, 8, 6, 30)));
console.log(describe(Date.UTC(2026, 10, 1, 5, 30)));
function invalid(): string {
const d = new Date(NaN);
return [String(d), d.getTime(), String(d.toJSON()), d < new Date(0), `${d}`].join(" | ");
}
console.log(invalid());
function sum(days: number): number {
let total = 0;
for (let i = 0; i < days; i++) {
const day = new Date(Date.UTC(2026, 0, 1) + i * 86_400_000);
total += day.getUTCDate() + day.getUTCMonth() + day.toISOString().length;
}
return total;
}
console.log(sum(400));
// Identity-observing uses keep the Date object.
const kept: Date[] = [];
function identities(ms: number): string {
const a = new Date(ms);
const b = a;
const stored = new Date(ms);
kept.push(stored);
const captured = new Date(ms);
const read = (): number => captured.getTime();
const passed = new Date(ms);
return [a === b, kept.includes(stored), kept[0] === stored, read(), same(passed, passed)].join(",");
}
function same(x: Date, y: Date): boolean {
return x === y;
}
console.log(identities(5));
const held: unknown = kept[0];
console.log(held instanceof Date, (held as Date) === kept[0]);
// Dates in blocks and loops at module level, read for their time value or
// kept, stored and captured.
const moduleKept: Date[] = [];
let moduleTotal = "";
for (let i = 0; i < 3; i++) {
const read = new Date(Date.UTC(2026, 2, 8 + i, 6, 30));
const kept = new Date(read.getTime());
moduleKept.push(kept);
moduleTotal += `${read.toISOString()} ${read.getHours()} ${String(read)}\n`;
}
{
const block = new Date(Date.UTC(2026, 10, 1, 5, 59));
const captured = new Date(block);
const later = (): string => captured.toISOString();
moduleTotal += `${block.getTimezoneOffset()} ${later()} ${captured === moduleKept[0]}`;
}
console.log(moduleTotal, moduleKept.map((d) => d.getUTCDate()).join(","));
@@ -0,0 +1 @@
{ "plain": 3096, "san": 3319 }