542 lines
11 KiB
TypeScript
542 lines
11 KiB
TypeScript
/**
|
|
* Space Lua `tonumber` implementation
|
|
*
|
|
* Matches Lua 5.4 semantics:
|
|
* - parses decimal or hex numerals
|
|
* - if base is given parses signed integers in the given base (2..36)
|
|
* - leading and trailing Lua spaces are trimmed (`\t\r\n\h\v`)
|
|
* - returns null on failure
|
|
*
|
|
* Notes:
|
|
* - hex without exponent is an integer
|
|
* - hex float requires `p` or `P` exponent
|
|
* - decimal float allows '.' and `e` or `E` exponent
|
|
*/
|
|
import type { NumericType } from "./ast.ts";
|
|
|
|
function skipSpace(s: string, i: number): number {
|
|
const n = s.length;
|
|
while (i < n) {
|
|
const c = s.charCodeAt(i);
|
|
if (c === 32 || (c >= 9 && c <= 13)) {
|
|
// SP, HT, LF, VT, FF, CR
|
|
i++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
return i;
|
|
}
|
|
|
|
function charToDigitBase(c: number, base: number): number {
|
|
if (c >= 48 && c <= 57) {
|
|
// '0'..'9'
|
|
const v = c - 48;
|
|
if (v < base) {
|
|
return v;
|
|
}
|
|
return -1;
|
|
}
|
|
if (c >= 65 && c <= 90) {
|
|
// 'A'..'Z'
|
|
const v = 10 + (c - 65);
|
|
if (v < base) {
|
|
return v;
|
|
}
|
|
return -1;
|
|
}
|
|
if (c >= 97 && c <= 122) {
|
|
// 'a'..'z'
|
|
const v = 10 + (c - 97);
|
|
if (v < base) {
|
|
return v;
|
|
}
|
|
return -1;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function parseIntWithBase(
|
|
s: string,
|
|
base: number,
|
|
): { ok: boolean; end: number; value: number } {
|
|
const n = s.length;
|
|
let i = 0;
|
|
i = skipSpace(s, i);
|
|
if (i >= n) {
|
|
return { ok: false, end: i, value: 0 };
|
|
}
|
|
|
|
let sign = 1;
|
|
if (s.charCodeAt(i) === 45) {
|
|
// '-'
|
|
sign = -1;
|
|
i++;
|
|
} else if (s.charCodeAt(i) === 43) {
|
|
// '+'
|
|
i++;
|
|
}
|
|
|
|
if (i >= n) {
|
|
return { ok: false, end: i, value: 0 };
|
|
}
|
|
|
|
let acc = 0;
|
|
let any = false;
|
|
|
|
while (i < n) {
|
|
const c = s.charCodeAt(i);
|
|
const isAlnum =
|
|
(c >= 48 && c <= 57) || // '0'..'9'
|
|
(c >= 65 && c <= 90) || // 'A'..'Z'
|
|
(c >= 97 && c <= 122); // 'a'..'z'
|
|
|
|
if (!isAlnum) {
|
|
break;
|
|
}
|
|
|
|
const d = charToDigitBase(c, base);
|
|
if (d < 0) {
|
|
return { ok: false, end: i, value: 0 };
|
|
}
|
|
|
|
acc = acc * base + d;
|
|
any = true;
|
|
i++;
|
|
}
|
|
|
|
if (!any) {
|
|
return { ok: false, end: i, value: 0 };
|
|
}
|
|
|
|
i = skipSpace(s, i);
|
|
if (i !== n) {
|
|
return { ok: false, end: i, value: 0 };
|
|
}
|
|
|
|
return { ok: true, end: i, value: sign * acc };
|
|
}
|
|
|
|
function parseInt(s: string): { ok: boolean; value: number } {
|
|
const n = s.length;
|
|
let i = 0;
|
|
i = skipSpace(s, i);
|
|
if (i >= n) {
|
|
return { ok: false, value: 0 };
|
|
}
|
|
|
|
let neg = false;
|
|
if (s.charCodeAt(i) === 45) {
|
|
// '-'
|
|
neg = true;
|
|
i++;
|
|
} else if (s.charCodeAt(i) === 43) {
|
|
// '+'
|
|
i++;
|
|
}
|
|
|
|
if (i >= n) {
|
|
return { ok: false, value: 0 };
|
|
}
|
|
|
|
let acc = 0;
|
|
let any = false;
|
|
|
|
// hex?
|
|
if (s.charCodeAt(i) === 48 && i + 1 < n) {
|
|
// '0'
|
|
const x = s.charCodeAt(i + 1);
|
|
if (x === 120 || x === 88) {
|
|
// 'x' or 'X'
|
|
i += 2;
|
|
while (i < n) {
|
|
const c = s.charCodeAt(i);
|
|
let d = -1;
|
|
if (c >= 48 && c <= 57) {
|
|
// '0'..'9'
|
|
d = c - 48; // '0'
|
|
} else if (c >= 65 && c <= 70) {
|
|
// 'A'..'F'
|
|
d = 10 + (c - 65);
|
|
} else if (c >= 97 && c <= 102) {
|
|
// 'a'..'f'
|
|
d = 10 + (c - 97);
|
|
} else {
|
|
d = -1;
|
|
}
|
|
if (d < 0) {
|
|
break;
|
|
}
|
|
acc = acc * 16 + d;
|
|
any = true;
|
|
i++;
|
|
}
|
|
i = skipSpace(s, i);
|
|
if (!any || i !== n) {
|
|
return { ok: false, value: 0 };
|
|
}
|
|
return { ok: true, value: neg ? -acc : acc };
|
|
}
|
|
}
|
|
|
|
// decimal integer
|
|
while (i < n) {
|
|
const c = s.charCodeAt(i);
|
|
if (c < 48 || c > 57) {
|
|
// not '0'..'9'
|
|
break;
|
|
}
|
|
acc = acc * 10 + (c - 48);
|
|
any = true;
|
|
i++;
|
|
}
|
|
|
|
i = skipSpace(s, i);
|
|
if (!any || i !== n) {
|
|
return { ok: false, value: 0 };
|
|
}
|
|
return { ok: true, value: neg ? -acc : acc };
|
|
}
|
|
|
|
function parseDecFloat(s: string): { ok: boolean; value: number } {
|
|
const n = s.length;
|
|
let i = 0;
|
|
i = skipSpace(s, i);
|
|
if (i >= n) {
|
|
return { ok: false, value: 0 };
|
|
}
|
|
|
|
let sign = 1;
|
|
const c0 = s.charCodeAt(i);
|
|
if (c0 === 45) {
|
|
// '-'
|
|
sign = -1;
|
|
i++;
|
|
} else if (c0 === 43) {
|
|
// '+'
|
|
i++;
|
|
}
|
|
|
|
let intAny = false;
|
|
let fracAny = false;
|
|
let val = 0;
|
|
|
|
// integer part
|
|
while (i < n) {
|
|
const c = s.charCodeAt(i);
|
|
if (c < 48 || c > 57) {
|
|
// not '0'..'9'
|
|
break;
|
|
}
|
|
val = val * 10 + (c - 48);
|
|
intAny = true;
|
|
i++;
|
|
}
|
|
|
|
// fractional part
|
|
if (i < n) {
|
|
if (s.charCodeAt(i) === 46) {
|
|
// '.'
|
|
i++;
|
|
let scale = 1;
|
|
while (i < n) {
|
|
const c = s.charCodeAt(i);
|
|
if (c < 48 || c > 57) {
|
|
// not '0'..'9'
|
|
break;
|
|
}
|
|
scale *= 0.1;
|
|
val += (c - 48) * scale;
|
|
fracAny = true;
|
|
i++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// exponent
|
|
let exp = 0;
|
|
let hasExp = false;
|
|
if (i < n) {
|
|
const ec = s.charCodeAt(i);
|
|
if (ec === 101 || ec === 69) {
|
|
// 'e' or 'E'
|
|
hasExp = true;
|
|
i++;
|
|
if (i >= n) {
|
|
return { ok: false, value: 0 };
|
|
}
|
|
let expSign = 1;
|
|
if (i < n) {
|
|
const es = s.charCodeAt(i);
|
|
if (es === 45) {
|
|
// '-'
|
|
expSign = -1;
|
|
i++;
|
|
} else if (es === 43) {
|
|
// '+'
|
|
i++;
|
|
}
|
|
}
|
|
if (i >= n) {
|
|
return { ok: false, value: 0 };
|
|
}
|
|
let anyExp = false;
|
|
while (i < n) {
|
|
const c = s.charCodeAt(i);
|
|
if (c < 48 || c > 57) {
|
|
// not '0'..'9'
|
|
break;
|
|
}
|
|
exp = exp * 10 + (c - 48);
|
|
anyExp = true;
|
|
i++;
|
|
}
|
|
if (!anyExp) {
|
|
return { ok: false, value: 0 };
|
|
}
|
|
exp *= expSign;
|
|
}
|
|
}
|
|
|
|
if (!intAny && !fracAny) {
|
|
return { ok: false, value: 0 };
|
|
}
|
|
|
|
i = skipSpace(s, i);
|
|
if (i !== n) {
|
|
return { ok: false, value: 0 };
|
|
}
|
|
|
|
const result = sign * (hasExp ? val * 10 ** exp : val);
|
|
return { ok: true, value: result };
|
|
}
|
|
|
|
function parseHexFloat(s: string): { ok: boolean; value: number } {
|
|
const n = s.length;
|
|
let i = 0;
|
|
i = skipSpace(s, i);
|
|
if (i >= n) {
|
|
return { ok: false, value: 0 };
|
|
}
|
|
|
|
let sign = 1;
|
|
const c0 = s.charCodeAt(i);
|
|
if (c0 === 45) {
|
|
// '-'
|
|
sign = -1;
|
|
i++;
|
|
} else if (c0 === 43) {
|
|
// '+'
|
|
i++;
|
|
}
|
|
|
|
if (!(i + 1 < n && s.charCodeAt(i) === 48)) {
|
|
// '0'
|
|
return { ok: false, value: 0 };
|
|
}
|
|
const x = s.charCodeAt(i + 1);
|
|
if (!(x === 120 || x === 88)) {
|
|
// 'x' or 'X'
|
|
return { ok: false, value: 0 };
|
|
}
|
|
i += 2;
|
|
|
|
let intVal = 0;
|
|
let fracVal = 0;
|
|
let fracScale = 1;
|
|
let anyHex = false;
|
|
let sawDot = false;
|
|
|
|
// integer hex digits
|
|
while (i < n) {
|
|
const c = s.charCodeAt(i);
|
|
let d = -1;
|
|
if (c >= 48 && c <= 57) {
|
|
// '0'..'9'
|
|
d = c - 48;
|
|
} else if (c >= 65 && c <= 70) {
|
|
// 'A'..'F'
|
|
d = 10 + (c - 65);
|
|
} else if (c >= 97 && c <= 102) {
|
|
// 'a'..'f'
|
|
d = 10 + (c - 97);
|
|
} else {
|
|
d = -1;
|
|
}
|
|
if (d < 0) {
|
|
break;
|
|
}
|
|
intVal = intVal * 16 + d;
|
|
anyHex = true;
|
|
i++;
|
|
}
|
|
|
|
// optional fractional part
|
|
if (i < n) {
|
|
if (s.charCodeAt(i) === 46) {
|
|
// '.'
|
|
sawDot = true;
|
|
i++;
|
|
while (i < n) {
|
|
const c = s.charCodeAt(i);
|
|
let d = -1;
|
|
if (c >= 48 && c <= 57) {
|
|
// '0'..'9'
|
|
d = c - 48;
|
|
} else if (c >= 65 && c <= 70) {
|
|
// 'A'..'F'
|
|
d = 10 + (c - 65);
|
|
} else if (c >= 97 && c <= 102) {
|
|
// 'a'..'f'
|
|
d = 10 + (c - 97);
|
|
} else {
|
|
d = -1;
|
|
}
|
|
if (d < 0) {
|
|
break;
|
|
}
|
|
fracScale *= 16;
|
|
fracVal = fracVal * 16 + d;
|
|
anyHex = true;
|
|
i++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// exponent:
|
|
// - required if there is no dot (so `0x10` stays an integer),
|
|
// - optional if there was a dot (`0x1.2` means `0x1.2p0`).
|
|
let expSign = 1;
|
|
let exp = 0;
|
|
|
|
if (i < n) {
|
|
const ec = s.charCodeAt(i);
|
|
if (ec === 112 || ec === 80) {
|
|
// 'p' or 'P'
|
|
i++;
|
|
if (i < n) {
|
|
const sc = s.charCodeAt(i);
|
|
if (sc === 45) {
|
|
// '-'
|
|
expSign = -1;
|
|
i++;
|
|
} else if (sc === 43) {
|
|
// '+'
|
|
i++;
|
|
}
|
|
}
|
|
if (i >= n) {
|
|
return { ok: false, value: 0 };
|
|
}
|
|
|
|
let anyExp = false;
|
|
while (i < n) {
|
|
const c = s.charCodeAt(i);
|
|
if (c < 48 || c > 57) {
|
|
// not '0'..'9'
|
|
break;
|
|
}
|
|
exp = exp * 10 + (c - 48);
|
|
anyExp = true;
|
|
i++;
|
|
}
|
|
if (!anyHex || !anyExp) {
|
|
return { ok: false, value: 0 };
|
|
}
|
|
} else {
|
|
// no exponent marker
|
|
if (!sawDot) {
|
|
// without dot we must reject so parseInt can claim it as integer
|
|
return { ok: false, value: 0 };
|
|
}
|
|
if (!anyHex) {
|
|
return { ok: false, value: 0 };
|
|
}
|
|
expSign = 1;
|
|
exp = 0; // implicit p0
|
|
}
|
|
} else {
|
|
// end of string
|
|
if (!sawDot) {
|
|
return { ok: false, value: 0 };
|
|
}
|
|
if (!anyHex) {
|
|
return { ok: false, value: 0 };
|
|
}
|
|
expSign = 1;
|
|
exp = 0;
|
|
}
|
|
|
|
i = skipSpace(s, i);
|
|
if (i !== n) {
|
|
return { ok: false, value: 0 };
|
|
}
|
|
|
|
const frac = fracVal === 0 ? 0 : fracVal / fracScale;
|
|
const result = sign * (intVal + frac) * 2 ** (expSign * exp);
|
|
return { ok: true, value: result };
|
|
}
|
|
|
|
// Detailed string conversion that returns numeric type, returns null
|
|
// on failure, integer -0 is canonicalized to +0
|
|
export function luaToNumberDetailed(
|
|
s: string,
|
|
base?: number,
|
|
): { value: number; numericType: NumericType } | null {
|
|
if (base !== undefined) {
|
|
// Base range errors are handled by the stdlib `tonumber` builtin,
|
|
// to match Lua's "bad argument #2 ..." error message.
|
|
if (!(typeof base === "number" && base >= 2 && base <= 36)) {
|
|
return null;
|
|
}
|
|
|
|
const parsed = parseIntWithBase(s, base);
|
|
if (parsed.ok) {
|
|
const v = parsed.value;
|
|
return { value: v === 0 ? 0 : v, numericType: "int" };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
{
|
|
// biome-ignore lint/correctness/useParseIntRadix: local parseInt function, not global
|
|
const parsed = parseInt(s);
|
|
if (parsed.ok) {
|
|
const v = parsed.value;
|
|
return { value: v === 0 ? 0 : v, numericType: "int" };
|
|
}
|
|
}
|
|
|
|
{
|
|
const parsed = parseHexFloat(s);
|
|
if (parsed.ok) {
|
|
return { value: parsed.value, numericType: "float" };
|
|
}
|
|
}
|
|
|
|
{
|
|
const parsed = parseDecFloat(s);
|
|
if (parsed.ok) {
|
|
return { value: parsed.value, numericType: "float" };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Space Lua `tonumber` returning only the numeric value or null
|
|
export function luaToNumber(s: unknown, base?: number): number | null {
|
|
if (typeof s === "number") {
|
|
return s;
|
|
}
|
|
// Tagged float: { value: number, isFloat: true }
|
|
if (s !== null && typeof s === "object" && (s as any).isFloat === true) {
|
|
return (s as any).value;
|
|
}
|
|
if (typeof s !== "string") {
|
|
return null;
|
|
}
|
|
const detailed = luaToNumberDetailed(s, base);
|
|
return detailed ? detailed.value : null;
|
|
}
|