Files
plainleaf/client/space_lua/numeric.ts
T
Matouš Jan FialkaandGitHub c38876b46f Space Lua: Improve numeric types semantics (#1803)
* Space Lua: Improve numeric types semantics

Add numeric result normalization for arithmetic operations:

* Integer operations preserve int type, collapse -0 to +0
* Float operations preserve float type, maintain -0.0
* Division always produces float results

Fix for-loop variable type tracking:

* Loop variable type determined by start/step parameters
* Integer loops produce integer variables
* Float/mixed loops produce float-tagged variables

Implement table key normalization:

* Integer-valued floats (1.0, 2.0) normalize to integer keys
* All zero variants (0, 0.0, -0.0) map to same key
* Non-integer floats (1.5) remain distinct keys

Add tests for:

* Arithmetic type preservation across operators
* For-loop type modes (int/float/mixed)
* Table key equivalence and normalization
* Edge cases with zero, -0.0, and special values
* Standard library `type` and `math.type`

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Code refactor and deduplication

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

---------

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
2026-02-02 15:52:03 +01:00

213 lines
4.4 KiB
TypeScript

import type { NumericType } from "./ast.ts";
import { luaToNumberDetailed } from "./tonumber.ts";
import { luaTypeName } from "./runtime.ts";
export const ZeroBoxKind = Symbol("ZeroBox");
export type LuaFloatTag = {
readonly type: "float";
readonly value: number;
};
export function isBoxedZero(x: any): boolean {
return x instanceof Number && Number(x) === 0 && !isNegativeZero(Number(x));
}
export function getZeroBoxKind(x: any): NumericType | undefined {
return isBoxedZero(x)
? (x as any)[ZeroBoxKind] as NumericType | undefined
: undefined;
}
export function boxZero(kind: NumericType): number {
const z = new Number(0);
(z as any)[ZeroBoxKind] = kind;
return z as unknown as number;
}
export function isFloatTag(v: any): v is LuaFloatTag {
return v && typeof v === "object" && v.type === "float";
}
// Tag an integer-valued number as a float:
// * zero: returns boxed float zero,
// * integer: returns tagged float,
// * non-integer: returns plain number.
export function floatLiteral(n: number): number | LuaFloatTag {
if (n === 0 && !isNegativeZero(n)) {
return boxZero("float");
}
if (Number.isInteger(n)) {
return { type: "float" as const, value: n };
}
return n;
}
export function untagNumber(n: any): number {
if (isFloatTag(n)) {
return n.value;
}
if (n instanceof Number) {
return Number(n);
}
return n;
}
export function getNumericKind(
n: number | LuaFloatTag | any,
): NumericType | undefined {
if (isFloatTag(n)) {
return "float";
}
if (typeof n === "number") {
return Number.isInteger(n) ? "int" : "float";
}
if (n instanceof Number) {
const kind = getZeroBoxKind(n);
if (kind) {
return kind;
}
return Number.isInteger(Number(n)) ? "int" : "float";
}
return undefined;
}
export type OpHints = {
leftKind?: NumericType;
rightKind?: NumericType;
};
export const luaStringCoercionError: Error = new Error(
"LuaStringCoercionError",
);
export function isNegativeZero(x: number): boolean {
// faster than `Object.is(<object>, -0)`
return x === 0 && 1 / x === -Infinity;
}
function produceNegativeZero() {
return { n: -0, isInt: false, zeroKind: "float" as const };
}
export function coerceNumeric(
val: unknown,
): {
n: number;
isInt: boolean;
zeroKind?: NumericType;
} {
if (isFloatTag(val)) {
const n = val.value;
if (n === 0) {
if (isNegativeZero(n)) {
return produceNegativeZero();
}
return { n: 0, isInt: false, zeroKind: "float" };
}
// Non-zero float tag: always mark as float
return { n, isInt: false, zeroKind: "float" };
}
if (isBoxedZero(val)) {
return { n: 0, isInt: true, zeroKind: getZeroBoxKind(val)! };
}
if (typeof val === "number") {
const n = val;
if (n === 0) {
if (isNegativeZero(n)) {
return produceNegativeZero();
}
return {
n,
isInt: true,
};
}
return {
n,
isInt: Number.isInteger(n),
};
}
if (val instanceof Number) {
const n = Number(val);
if (n === 0 && !isNegativeZero(n)) {
return {
n,
isInt: true,
zeroKind: getZeroBoxKind(val),
};
}
return {
n,
isInt: Number.isInteger(n),
};
}
if (typeof val === "string") {
const det = luaToNumberDetailed(val);
if (!det) {
throw luaStringCoercionError;
}
const n = det.value;
const isInt = det.numericType === "int";
if (n === 0) {
if (isNegativeZero(n)) {
return produceNegativeZero();
}
return {
n,
isInt,
zeroKind: det.numericType,
};
}
return {
n,
isInt,
};
}
throw new Error(
`attempt to perform arithmetic on a ${luaTypeName(val)} value`,
);
}
export function coerceNumericPair(
a: unknown,
b: unknown,
hints?: OpHints,
): {
ax: number;
bx: number;
bothInt: boolean;
aZeroKind?: NumericType;
bZeroKind?: NumericType;
} {
const A = coerceNumeric(a);
const B = coerceNumeric(b);
const aIsInt = hints?.leftKind !== undefined
? hints.leftKind === "int"
: (A.zeroKind === "float"
? false
: (A.zeroKind === "int" ? true : A.isInt));
const bIsInt = hints?.rightKind !== undefined
? hints.rightKind === "int"
: (B.zeroKind === "float"
? false
: (B.zeroKind === "int" ? true : B.isInt));
return {
ax: A.n,
bx: B.n,
bothInt: aIsInt && bIsInt,
aZeroKind: A.zeroKind,
bZeroKind: B.zeroKind,
};
}