Files
plainleaf/client/space_lua/stdlib/format.ts
T
75cc79800f Space Lua: Align numeric/table semantics with Lua, align number formating, optimize loops allocations (#1823)
* Don't know what's going on with the Deno docker image, disabling the deno.lock file for now

* Another try

* Space Lua: Align numeric and table semantics with Lua

This change improves Space Lua compatibility with standard Lua 5.4,
focusing on numeric subtypes and table behavior. The test suite is
extended to lock in the expected semantics and should pass under both
Space Lua and a Lua interpreter.

SUMMARY OF CHANGES
------------------

Tighten Lua compatibility across evaluator and runtime:

- correct metamethod dispatch (`__index`, `__newindex`, `__call`,
  comparison metamethods),

- loop limits,

- raw metamethod lookups.

Rework numeric semantics to preserve Lua **integer** vs **float**
behavior:

- $0$ vs $0.0$ and $-0.0$,

- explicit zero kind representation, and

- updates arithmetic/bitwise coercions accordingly.

Improve parser correctness by rejecting **unary plus** with aligned Lua
errors and better parsing errors reporting.

Fix `stdlib` behavior to match that of Lua:

- `table` function `concat`, `insert`, `remove`, `sort` and `unpack`
  gain metamethod awareness and enforce Lua errors,

- `ipairs` iteration updated to stop on first nil and honor `__index`,

- `tonumber` updated to Luae conversion using `luaToNumberDetailed`,

- `math.modf` return corrected,

- `math.type` accuracy improvements for float/integer and $-0.0$,

- `math.pi` added.

Fix numeric subtypes for `/` and `^` operators so `math.type` matches
Lua results using tagging as well as unary `-`.

Expand test coverage:

- new `metamethods_test.lua` for Lua metamethod/operator semantics,

- extend arithmetic and length tests for zero-kind propagation and
  `rawlen` vs `__len` metamethod,

- Extend `math` test suite to test proper Lua alignment (`math.type` and
  more), and

- update context error expectations for Lua error messages.

RATIONALE
---------

Lua differs from JavaScript by having two numeric subtypes: **integer**
and **float**. Operators depend on the subtype: `+`, `-`, `*`, `//` and
`%` use integer mode when both operands are integers and float mode
otherwise. Bitwise operators require integers and `math.type(x)` reports
"integer" or "float". JavaScript has one numeric primitive type
(`number`) so a plain number value cannot record whether Lua considers
a value to be a float when the value has no fractional part (for example
$2.0$).

Lua also differs from "everything is IEEE 754 double" because the rules
are defined in terms of integer and float subtypes. Float operations
preserve IEEE 754 behavior including `NaN`, infinities and signed zero
($-0.0$) which affects results like $1/0.0$ versus $1/-0.0$. Integer
arithmetic does not preserve $-0$ and collapses it to $0$. Lua integer
arithmetic is exact within its integer range while JavaScript `number`
cannot exactly represent all integers in that range.

Lua numbers are integers or floats. Numeric strings coerce to integer or
float based on _lexical_ form. Each arithmetic operator selects the
result subtype from the operator rules and operand subtypes.  Integer
only operators (bitwise and `//` as integer division) require integer
representability. Mixed arithmetic promotes to float as needed.  Two
operators are **always float** typed: division (`/`) and exponentiation
(`^`) produce floats even if both operands are integers and even if the
numeric value has no fractional part. `math.type` reports that internal
subtype.

Tables are associative arrays and assigning `nil` removes a key. The
length operator `#` uses `__len` metamethod if present otherwise it uses
the raw length rule. `rawlen(table)` ignores `__len`. Without `__len`
Lua defines `#` as some boundary `N` such that `table[N]` is not `nil`
and `table[N+1]` is `nil`. If the table has holes (missing or `nil`
entries in the positive integer key sequence) the boundary may be non
unique so `#` is stable only for proper sequences without holes.

PERFORMANCE NOTES
-----------------

Numeric changes add small checks to preserve Lua integer and float
subtype semantics and avoid allocations except when the subtype would
otherwise be lost.

Some table operations may be slower due to stricter Lua 5.4 behavior
especially around length and sequence boundary handling which currently
requires extra metadata tracking and scans and cannot be avoided without
a completely different internal table representation.

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

* Another try

* Replace `LuaFloatTag` plain objects with boxed Number for float tagging

* Restore pre-merge eval/numeric architecture and fix regressions

This commit restores the original branch architecture.

On top of the restored foundation, float-typed integer results (e.g.
`1.0 + 1.0` = `2.0`, `0.0 // 1.0` = `0.0`) are now correctly tagged via
`makeLuaFloat` so that `tostring` and `math.type` report them as floats.
The *unary minus* fast path for float literals and the `tonumber`
function also preserve float tagging.

Performance regressions from the merge are addressed by avoiding
`Number` boxing for non-integer floats (`3.14` needs no tag — it is
unambiguously float), adding *string key* fast paths in `LuaTable`
`has`/`rawGet`/`rawSet` to skip numeric normalization for the dominant
case, and inlining a `typeof` check in math standard library functions
to avoid function call overhead on plain numbers.

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

* Replace boxed `Number` float tagging with plain tagged objects

* Replace `new Number()` boxing with plain tagged float objects for Lua
  float type tracking.

* Integer-valued floats that need type disambiguation are now
  represented as `{ value: number, isFloat: true }` instead of boxed
  `Number` objects with a symbol property.

* Pre-allocated singletons are used for positive and negative float
  zeros to avoid allocation entirely in common cases.

* Updated all detection, unwrapping, and coercion paths across
  `numeric.ts`, `runtime.ts`, `eval.ts`, `stdlib.ts`, and `stdlib/`
  modules to use the new `isTaggedFloat` type guard.

* Removed all `instanceof Number` checks.

* Deleted the `FloatKind` symbol and eliminated redundant helpers
  `isLuaFloat`, `isFloatTag`, `getZeroBoxKind` and `toPlainNumber` that
  became dead code.

* Simplified `math.type`, `luaToString`, `luaEquals`, `luaTypeName` and
  various other key normalization paths.

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

* Add fast paths in `coerceNumericPair` for tagged float operands

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

* Fix copy/paste typo

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

* Avoid extra `LuaEnv` allocations in "For" and "ForIn" loops

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

* [Space Lua] Reuse loop variable environment in `for` and `for-in` loops

Numeric `for` and generic `for-in` loops allocated a fresh `LuaEnv` on
every iteration to hold loop variables. But this is only necessary when
a closure inside the loop body captures the loop variable.

The optimization uses a two-level check computed at parse time. If no
function definition exists in the loop's subtree, environment reuse is
safe. When a function definition is present a deeper analysis walks the
block to determine whether any function body references the loop
variable names without them being shadowed by its own parameters. When
a closure captures a loop variable the loop fall back to per-iteration
allocation.

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

* [Space Lua] Format numbers using standard Lua rules

Standard Lua formats floats via C `sprintf("%.14g")` (14 significant
digits, scientific notation when shorter, exponent padded to 2 digits,
and a guaranteed `.0` suffix for integer-valued floats).

* Replace the old `luaFormatNumber` with JS `toPrecision(14)`-based
  implementation that reproduces this behavior.

* Integrates it so that `${}` expressions in the UI also display
  numbers correctly.

* Fixes tagged floats (`{ value, isFloat }`) were being stripped by
  `luaValueToJS` or matched as plain objects before reaching the number
  formatter. This caused `${}` expressions to render raw JS numbers.

Examples:

```
- ${tostring(2^63)}
- ${2^63}
- ${(2^63)}
```

All of the the above examples show correct `9.2233720368548e+18` now.

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

* [Space Lua] Fix `string.format` for floats and tagged numbers

Unwrap tagged floats before `printf`, handle `inf`/`-inf`/`-nan` in
`formatDouble`, and fix `%g` producing `0e+00` for zero. Hopefuly it's
enough to gain Lua formatting.

Tests:

```
- ${string.format("%.14g", 0.0)} - `0`
- ${string.format("%.14g", 1.0)} - `1`
- ${string.format("%.14g", 1/3)} - `0.33333333333333`
- ${string.format("%.14g", math.pi)} - `3.1415926535898`
- ${string.format("%.14g", 1e-10)} - `1e-10`
- ${string.format("%.14g", 1e18)} - `1e+18`
- ${string.format("%.14g", 2^63)} - `9.2233720368548e+18`
- ${string.format("%.14g", 2^53)} - `9.007199254741e+15`
- ${string.format("%.14g", 1.7976931348623e+308)} - `1.7976931348623e+308`
- ${string.format("%.14g", 5e-324)} - `4.9406564584125e-324`
- ${string.format("%.14g", 0/0)} - `-nan`
- ${string.format("%.14g", 1/0)} - `inf`
- ${string.format("%.14g", -1/0)} - `-inf`
```

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

* [Space Lua] Add and integrate new `luaFormat` utility and test suite

* Add `luaFormat` string formatting function compatible with Lua, and
  integrate it across the codebase as a replacement for prior formatting
  approaches.

* Add extensive test suite.

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

* Fix check

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

* [Space Lua] Add `%a`/`%A` and `%q` format specifiers to `string.format`

Implement hexadecimal floating-point (`%a`/`%A`) and quoted literal
(`%q`) specifiers.

* Add `%a`/`%A` as IEEE 754 double decomposition with full flag, width
  and precision support.

* Add `%q` as producind valid Lua literals for strings, numbers,
  booleans and nil.

* Use `Math.PI` for `math.pi` to preserve full double precision.

* Remove Deno based test suite and replace it with native Lua test
  suite.

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

* [Space Lua] Add `%p` format specifier to `string.format`

In standard Lua, `%p` formats the internal C heap address of a value,
producing output like `0x55a3bc4e2f10`. It works on tables, functions,
threads, strings, and userdata (GC-ed objects). For `nil`, booleans, and
numbers it returns `(null)`.

In Space Lua, there are no *raw memory addresses* since the runtime is
JavaScript. Instead, `%p` assigns a *stable sequential integer* to each
object via a `WeakMap`, formatted as a 14-digit zero-padded hex value.
The key difference is that identifiers are deterministic and sequential
rather than random-looking heap addresses:

```lua
local t = {}

print(string.format("identifier: %p", t)) -- 0x00000000000001
print(string.format("the same:   %p", t)) -- 0x00000000000001
print(string.format("another:    %p", {}) -- 0x00000000000002
```

For strings, a regular `Map` is used so identical string content always
produces the same identifier.

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

* Revert merge changes to the deno.json

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

* Replace `interface` with `type`

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

* Remove `has_math()` relict function test

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

* Refactor loop to map

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

* Remove `Deno.remove("deno.lock")` weirdness

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

* Refactor: Early return undefined in `astNumberKind` instead of assigning

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

---------

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
Co-authored-by: Zef Hemel <zef@zef.me>
2026-02-12 15:22:12 +01:00

771 lines
18 KiB
TypeScript

// Supported specifiers set: [diuoxXaAfeEgGcspq%]
// Supported flags set: [-+0# ]
// Width and precision via digits or `*`
type FormatSpec = {
flags: number; // FLAG_*
width: number;
hasPrec: boolean;
prec: number;
spec: number;
};
const FLAG_MINUS = 1;
const FLAG_PLUS = 2;
const FLAG_ZERO = 4;
const FLAG_HASH = 8;
const FLAG_SPACE = 16;
function isDigit(c: number): boolean {
return c >= 48 && c <= 57; // '0'..'9'
}
// Parse a format spec starting after '%' and return index of specifier.
function parseSpec(
fmt: string,
start: number,
): { spec: FormatSpec; end: number } {
let i = start;
const len = fmt.length;
let flags = 0;
outer: while (i < len) {
switch (fmt.charCodeAt(i)) {
case 45:
flags |= FLAG_MINUS;
i++;
break; // '-'
case 43:
flags |= FLAG_PLUS;
i++;
break; // '+'
case 48:
flags |= FLAG_ZERO;
i++;
break; // '0'
case 35:
flags |= FLAG_HASH;
i++;
break; // '#'
case 32:
flags |= FLAG_SPACE;
i++;
break; // ' '
default:
break outer;
}
}
// Parse width
let width = 0;
if (i < len && fmt.charCodeAt(i) === 42) { // '*'
width = -1;
i++;
} else {
while (i < len && isDigit(fmt.charCodeAt(i))) {
width = width * 10 + (fmt.charCodeAt(i) - 48);
i++;
}
}
// Parse precision
let hasPrec = false;
let prec = 0;
if (i < len && fmt.charCodeAt(i) === 46) { // '.'
hasPrec = true;
i++;
if (i < len && fmt.charCodeAt(i) === 42) { // '*'
prec = -1;
i++;
} else {
while (i < len && isDigit(fmt.charCodeAt(i))) {
prec = prec * 10 + (fmt.charCodeAt(i) - 48);
i++;
}
}
}
// Skip length modifiers [hlL] ignored in Lua
while (
i < len &&
(fmt.charCodeAt(i) === 104 || // 'h'
fmt.charCodeAt(i) === 108 || // 'l'
fmt.charCodeAt(i) === 76) // 'L'
) {
i++;
}
if (i >= len) {
throw new Error("invalid format (missing specifier)");
}
return {
spec: { flags, width, hasPrec, prec, spec: fmt.charCodeAt(i) },
end: i,
};
}
// pad a string to `width` respecting `FLAG_MINUS` and `FLAG_ZERO`
function pad(s: string, width: number, flags: number, numPad: boolean): string {
if (width <= 0 || s.length >= width) return s;
const n = width - s.length;
if (numPad && (flags & FLAG_ZERO) && !(flags & FLAG_MINUS)) {
let signLen = 0;
if (s.charCodeAt(0) === 45 || s.charCodeAt(0) === 43) { // '-' or '+'
signLen = 1;
} else if (
s.charCodeAt(0) === 48 &&
(s.charCodeAt(1) === 120 || s.charCodeAt(1) === 88)
) {
signLen = 2; // '0x' or '0X'
}
return s.slice(0, signLen) + "0".repeat(n) + s.slice(signLen);
}
if (flags & FLAG_MINUS) {
return s + " ".repeat(n);
}
return " ".repeat(n) + s;
}
function addSign(s: string, flags: number): string {
if (flags & FLAG_PLUS) return "+" + s;
if (flags & FLAG_SPACE) return " " + s;
return s;
}
function formatInt(n: number, spec: FormatSpec): string {
const code = spec.spec;
const v = Math.trunc(n);
let base = 10;
let unsigned = false;
let upper = false;
switch (code) {
case 100:
case 105: // 'd', 'i'
break;
case 117: // 'u'
unsigned = true;
break;
case 111: // 'o'
base = 8;
unsigned = true;
break;
case 120: // 'x'
base = 16;
unsigned = true;
break;
case 88: // 'X'
base = 16;
unsigned = true;
upper = true;
break;
}
let neg = false;
let digits: string;
if (unsigned && v < 0) {
// Reinterpret as 64-bit unsigned
const bv = BigInt(v) + (1n << 64n);
digits = bv.toString(base);
} else if (unsigned) {
digits = v.toString(base);
} else {
neg = v < 0;
digits = (neg ? -v : v).toString(base);
}
if (upper) digits = digits.toUpperCase();
// Precision
if (spec.hasPrec) {
if (spec.prec === 0 && v === 0) {
digits = "";
} else if (digits.length < spec.prec) {
digits = "0".repeat(spec.prec - digits.length) + digits;
}
}
// Alt flag
let prefix = "";
if (spec.flags & FLAG_HASH) {
if (base === 8 && (digits.length === 0 || digits.charCodeAt(0) !== 48)) {
prefix = "0";
} else if (base === 16 && v !== 0) {
prefix = upper ? "0X" : "0x";
}
}
let result: string;
if (neg) {
result = "-" + prefix + digits;
} else {
result = addSign(prefix + digits, spec.flags);
}
const numPad = !spec.hasPrec;
return pad(result, spec.width, spec.flags, numPad);
}
function formatFloat(n: number, spec: FormatSpec): string {
const code = spec.spec;
const upper = code === 69 || code === 71 || code === 70;
// 'E'=69 'G'=71 'F'=70 'e'=101 'g'=103 'f'=102
const lower = code | 32; // to lowercase
// Lua convention
if (!isFinite(n)) {
let s: string;
if (n !== n) {
s = upper ? "-NAN" : "-nan";
} else if (n > 0) {
s = upper ? "INF" : "inf";
s = addSign(s, spec.flags);
} else {
s = upper ? "-INF" : "-inf";
}
return pad(s, spec.width, spec.flags, false);
}
const neg = n < 0 || (n === 0 && 1 / n === -Infinity);
const abs = neg ? -n : n;
const prec = spec.hasPrec ? spec.prec : 6;
let body: string;
if (lower === 102) { // 'f'
body = abs.toFixed(prec);
} else if (lower === 101) { // 'e'
body = abs.toExponential(prec);
// Ensure exponent has at least 2 digits
body = ensureExpTwoDigits(body);
} else { // 'g'
const gPrec = (prec === 0) ? 1 : prec;
if (abs === 0) {
body = "0";
} else {
// C rule: use 'e' if exponent < -4 or exponent >= precision
const exp = Math.floor(Math.log10(abs));
if (exp < -4 || exp >= gPrec) {
body = abs.toExponential(gPrec - 1);
body = ensureExpTwoDigits(body);
} else {
// Number of decimals = precision - (exponent + 1)
const decimals = gPrec - (exp + 1);
body = abs.toFixed(decimals);
}
}
// Strip trailing zeros unless '#' flag
if (!(spec.flags & FLAG_HASH)) {
body = stripTrailingZerosG(body);
}
}
if (upper) {
body = body.toUpperCase();
}
// Alt flag for 'f'/'e': ensure decimal point exists
if ((spec.flags & FLAG_HASH) && lower !== 103) {
if (body.indexOf(".") === -1) {
// Insert dot before 'e' if present, else append
const eIdx = body.indexOf("e");
const EIdx = body.indexOf("E");
const expIdx = eIdx !== -1 ? eIdx : EIdx;
if (expIdx !== -1) {
body = body.slice(0, expIdx) + "." + body.slice(expIdx);
} else {
body = body + ".";
}
}
}
// Alt flag for 'g': keep trailing zeros but ensure decimal point
if ((spec.flags & FLAG_HASH) && lower === 103) {
if (body.indexOf(".") === -1) {
const expIdx = findExpIndex(body);
if (expIdx !== -1) {
body = body.slice(0, expIdx) + "." + body.slice(expIdx);
} else {
body = body + ".";
}
}
}
let result: string;
if (neg) {
result = "-" + body;
} else {
result = addSign(body, spec.flags);
}
return pad(result, spec.width, spec.flags, true);
}
function findExpIndex(s: string): number {
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c === 101 || c === 69) return i; // 'e' or 'E'
}
return -1;
}
// Ensure exponent part has at least 2 digits
function ensureExpTwoDigits(s: string): string {
const idx = findExpIndex(s);
if (idx === -1) return s;
// idx+1 is sign, idx+2... are digits
const signIdx = idx + 1;
if (signIdx >= s.length) return s;
const digitStart = signIdx + 1;
const expLen = s.length - digitStart;
if (expLen < 2) {
return s.slice(0, digitStart) + "0" + s.slice(digitStart);
}
return s;
}
// Strip trailing zeros from '%g' output
function stripTrailingZerosG(s: string): string {
const expIdx = findExpIndex(s);
const mantissa = expIdx !== -1 ? s.slice(0, expIdx) : s;
const exp = expIdx !== -1 ? s.slice(expIdx) : "";
const dotIdx = mantissa.indexOf(".");
if (dotIdx === -1) return s; // nothing to strip
let end = mantissa.length;
while (end > dotIdx + 1 && mantissa.charCodeAt(end - 1) === 48) { // '0'
end--;
}
// Remove dot if nothing after it
if (end === dotIdx + 1) {
end = dotIdx;
}
return mantissa.slice(0, end) + exp;
}
// Format a number as hexadecimal floating-point (%a/%A)
function formatHexFloat(n: number, spec: FormatSpec): string {
const code = spec.spec;
const upper = code === 65; // 'A'
if (!isFinite(n)) {
let s: string;
if (n !== n) {
s = upper ? "-NAN" : "-nan";
} else if (n > 0) {
s = upper ? "INF" : "inf";
s = addSign(s, spec.flags);
} else {
s = upper ? "-INF" : "-inf";
}
return pad(s, spec.width, spec.flags, false);
}
const neg = n < 0 || (n === 0 && 1 / n === -Infinity);
const abs = neg ? -n : n;
let body: string;
if (abs === 0) {
const prec = spec.hasPrec ? spec.prec : 0;
if (prec > 0) {
body = "0x0." + "0".repeat(prec) + "p+0";
} else {
body = "0x0p+0";
}
} else {
body = hexFloatBody(abs, spec);
}
if (upper) body = body.toUpperCase();
// Alt flag: ensure decimal point
if (spec.flags & FLAG_HASH) {
const pIdx = findPIndex(body);
if (pIdx !== -1) {
let hasDot = false;
for (let k = 0; k < pIdx; k++) {
if (body.charCodeAt(k) === 46) { // '.'
hasDot = true;
break;
}
}
if (!hasDot) {
body = body.slice(0, pIdx) + "." + body.slice(pIdx);
}
}
}
let result: string;
if (neg) {
result = "-" + body;
} else {
result = addSign(body, spec.flags);
}
return pad(result, spec.width, spec.flags, true);
}
// Find index of 'p' or 'P' in hex float string
function findPIndex(s: string): number {
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c === 112 || c === 80) return i; // 'p' or 'P'
}
return -1;
}
// Number of bits needed to represent a positive bigint
function bitLength(n: bigint): number {
let bits = 0;
let v = n;
while (v > 0n) {
bits++;
v >>= 1n;
}
return bits;
}
// Decompose a positive non-zero finite float into `0xH.HHHpN` form
function hexFloatBody(abs: number, spec: FormatSpec): string {
const buf = new Float64Array(1);
const view = new DataView(buf.buffer);
view.setFloat64(0, abs);
const bits = view.getBigUint64(0);
const biasedExp = Number((bits >> 52n) & 0x7FFn);
const frac = bits & 0xFFFFFFFFFFFFFn;
let exponent: number;
let mantBits: bigint;
if (biasedExp === 0) {
// Subnormal
if (frac === 0n) return "0x0p+0";
const shift = 52 - bitLength(frac) + 1;
mantBits = frac << BigInt(shift);
exponent = -1022 - shift;
} else {
// Normal
exponent = biasedExp - 1023;
mantBits = frac | (1n << 52n);
}
let firstDigit = Number(mantBits >> 52n);
const restBits = mantBits & ((1n << 52n) - 1n);
// 13 hex digits from 52 bits
let fracHex = hexDigits52(restBits);
if (spec.hasPrec) {
if (spec.prec < 13) {
const carry = roundHexInPlace(fracHex, spec.prec);
if (carry) firstDigit++;
fracHex = truncHexDigits(fracHex, spec.prec);
} else {
fracHex = padHexRight(fracHex, spec.prec);
}
} else {
fracHex = stripHexTrailingZeros(fracHex);
}
const expSign = exponent >= 0 ? "+" : "";
if (fracHex.length > 0) {
return "0x" + firstDigit + "." + fracHex + "p" + expSign + exponent;
}
return "0x" + firstDigit + "p" + expSign + exponent;
}
// Convert 52-bit value to 13 hex digits, zero-padded
function hexDigits52(bits: bigint): string {
const s = bits.toString(16);
if (s.length >= 13) return s;
return "0".repeat(13 - s.length) + s;
}
// Parse one hex char to its numeric value
function hexVal(c: number): number {
if (c >= 48 && c <= 57) return c - 48; // '0'..'9'
if (c >= 97 && c <= 102) return c - 87; // 'a'..'f'
if (c >= 65 && c <= 70) return c - 55; // 'A'..'F'
return 0;
}
function roundHexInPlace(digits: string, prec: number): boolean {
if (prec >= digits.length) return false;
const nextVal = hexVal(digits.charCodeAt(prec));
if (nextVal < 8) return false;
if (prec === 0) return true;
const arr = new Array<number>(prec);
for (let i = 0; i < prec; i++) {
arr[i] = hexVal(digits.charCodeAt(i));
}
let carry = 1;
for (let i = prec - 1; i >= 0 && carry; i--) {
arr[i] += carry;
if (arr[i] >= 16) {
arr[i] = 0;
carry = 1;
} else {
carry = 0;
}
}
return carry === 1;
}
function truncHexDigits(digits: string, prec: number): string {
if (prec === 0) return "";
const nextVal = hexVal(digits.charCodeAt(prec));
if (nextVal < 8) return digits.slice(0, prec);
const arr = new Array<number>(prec);
for (let i = 0; i < prec; i++) {
arr[i] = hexVal(digits.charCodeAt(i));
}
let carry = 1;
for (let i = prec - 1; i >= 0 && carry; i--) {
arr[i] += carry;
if (arr[i] >= 16) {
arr[i] = 0;
} else {
carry = 0;
}
}
let out = "";
for (let i = 0; i < prec; i++) {
out += arr[i].toString(16);
}
return out;
}
function padHexRight(s: string, len: number): string {
if (s.length >= len) return s;
return s + "0".repeat(len - s.length);
}
function stripHexTrailingZeros(s: string): string {
let end = s.length;
while (end > 0 && s.charCodeAt(end - 1) === 48) { // '0'
end--;
}
if (end === s.length) return s;
return s.slice(0, end);
}
function quoteString(s: string): string {
let out = '"';
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c === 34 || c === 92 || c === 10) {
// '"', '\\', '\n': backslash + literal char
out += "\\";
out += String.fromCharCode(c);
} else if (c < 32) {
const next = i + 1 < s.length ? s.charCodeAt(i + 1) : -1;
const isNextDigit = next >= 48 && next <= 57;
if (isNextDigit) {
const ds = c.toString();
out += "\\";
if (ds.length < 3) out += "0".repeat(3 - ds.length);
out += ds;
} else {
out += "\\" + c.toString();
}
} else {
out += String.fromCharCode(c);
}
}
out += '"';
return out;
}
// Format a float for %q: hex representation preserving full precision
function quoteFloat(n: number): string {
if (n !== n) return "(0/0)";
if (n === Infinity) return "1e9999";
if (n === -Infinity) return "-1e9999";
const spec: FormatSpec = {
flags: 0,
width: 0,
hasPrec: false,
prec: 0,
spec: 97, // 'a'
};
return formatHexFloat(n, spec);
}
function formatQ(v: unknown): string {
if (v === null || v === undefined) return "nil";
if (v === true) return "true";
if (v === false) return "false";
if (typeof v === "number") {
if (v === 0 && 1 / v === -Infinity) return quoteFloat(v);
if (Number.isInteger(v) && Number.isFinite(v)) {
return v.toString();
}
return quoteFloat(v);
}
return quoteString(String(v));
}
function formatChar(n: number): string {
return String.fromCharCode(n & 0x7f);
}
const objectIds = new WeakMap<WeakKey, number>();
const stringIds = new Map<string, number>();
let nextId = 1;
function toPointer(v: unknown): string {
if (v === null || v === undefined) return "(null)";
if (typeof v === "boolean" || typeof v === "number") return "(null)";
// Primitives (strings, symbols, etc.) cannot be `WeakMap` keys
if (typeof v !== "object" && typeof v !== "function") {
const key = String(v);
let id = stringIds.get(key);
if (id === undefined) {
id = nextId++;
stringIds.set(key, id);
}
return "0x" + id.toString(16).padStart(14, "0");
}
const obj = v as object;
let id = objectIds.get(obj);
if (id === undefined) {
id = nextId++;
objectIds.set(obj, id);
}
return "0x" + id.toString(16).padStart(14, "0");
}
function formatPointer(v: unknown, spec: FormatSpec): string {
const s = toPointer(v);
// `%p` only supports width and '-' flag, no precision
return pad(s, spec.width, spec.flags, false);
}
export function luaFormat(fmt: string, ...args: any[]): string {
let out = "";
let ai = 0; // arg index
const len = fmt.length;
let i = 0;
while (i < len) {
const c = fmt.charCodeAt(i);
if (c !== 37) { // not '%'
// Fast path: scan for next '%' or end
let j = i + 1;
while (j < len && fmt.charCodeAt(j) !== 37) j++;
out += fmt.slice(i, j);
i = j;
continue;
}
// '%' found
i++;
if (i >= len) {
throw new Error("invalid format (ends with '%')");
}
// '%%' into literal '%'
if (fmt.charCodeAt(i) === 37) {
out += "%";
i++;
continue;
}
const { spec, end } = parseSpec(fmt, i);
i = end + 1;
// Resolve `*` width and precision from args
let width = spec.width;
if (width === -1) {
width = Number(args[ai++]) || 0;
if (width < 0) {
spec.flags |= FLAG_MINUS;
width = -width;
}
spec.width = width;
}
if (spec.prec === -1) {
let p = Number(args[ai++]) || 0;
if (p < 0) {
spec.hasPrec = false;
p = 0;
}
spec.prec = p;
}
const code = spec.spec;
switch (code) {
case 97:
case 65: // 'a', 'A'
out += formatHexFloat(Number(args[ai++]), spec);
break;
case 100:
case 105:
case 117: // 'd', 'i', 'u'
case 111:
case 120:
case 88: // 'o', 'x', 'X'
out += formatInt(Number(args[ai++]), spec);
break;
case 102:
case 101:
case 69: // 'f', 'e', 'E'
case 103:
case 71:
case 70: // 'g', 'G', 'F'
out += formatFloat(Number(args[ai++]), spec);
break;
case 99: // 'c'
out += pad(
formatChar(Number(args[ai++])),
spec.width,
spec.flags,
false,
);
break;
case 112: { // 'p'
out += formatPointer(args[ai++], spec);
break;
}
case 113: { // 'q'
out += formatQ(args[ai++]);
break;
}
case 115: { // 's'
let s = String(args[ai++]);
if (spec.hasPrec && s.length > spec.prec) {
s = s.slice(0, spec.prec);
}
out += pad(s, spec.width, spec.flags, false);
break;
}
default:
throw new Error(
`invalid format specifier '${String.fromCharCode(code)}'`,
);
}
}
return out;
}