Files
sx/issues/0090-int-formatter-extremes.md
agra 64f77e9779 fix(std): render integer formatter extremes — i64::MIN and unsigned all-ones [F0.8]
Resolves issue 0090. The `{}` integer formatter mis-rendered both ends of
the 64-bit range:

- `int_to_string` computed the magnitude as `0 - n`, which overflows for
  `s64::MIN` (its magnitude is unrepresentable as a positive s64) — the
  value stayed negative, the digit loop ran zero times, so only `-`
  printed. It now extracts digits straight from `n` (per-digit
  `|n % 10|`, `n` truncating toward zero), never negating MIN.

- `any_to_string`'s `case int:` formatted every integer as s64, so a u64
  all-ones value printed as `-1`. There was no `uint` type-category to
  distinguish signedness. Added an additive `type_is_unsigned(T)`
  reflection builtin (static fold + dynamic interp/LLVM paths, mirroring
  `type_name`), backed by the new `TypeTable.isUnsignedInt` predicate, and
  a `uint_to_string` formatter (unsigned decimal via long-division over
  four 16-bit limbs). `case int:` routes through `type_is_unsigned(type)`.

The 16-bit-limb split is factored into a shared `decompose_u16x4`, now
reused by `int_to_hex_string` (no second unsigned-math routine).

Regression: examples/0046-basic-int-formatter-extremes pins both extremes
plus a width spread; unit tests cover `isUnsignedInt`. Docs (specs.md
representation note, readme std API) updated for unsigned/extreme `{}`
behavior. IR snapshots refreshed for the two new std functions.
2026-06-05 09:05:37 +03:00

4.0 KiB

0090 — integer formatter can't render i64::MIN or unsigned all-ones

STATUS: RESOLVED (F0.8). Both extremes now render correctly: s64.min-9223372036854775808, u64.max18446744073709551615.

Root cause.

  • Symptom 1 (i64::MIN): std.int_to_string computed the magnitude as 0 - n, which overflows for s64::MIN (its magnitude is unrepresentable as a positive s64) — the value stayed negative, the while v > 0 loop ran zero times, and only the - was emitted.
  • Symptom 2 (unsigned all-ones): any_to_string's case int: arm formatted every integer as s64 (int_to_string(xx val)); there was no way to tell a u64 from an s64, so an all-ones u64 printed as -1.

Fix per file.

  • library/modules/std.sxint_to_string now extracts digits straight from n (taking |n % 10| per digit, n truncates toward zero) so it never negates s64::MIN. Added uint_to_string (unsigned decimal via long-division-by-10 over four 16-bit limbs) and decompose_u16x4 (the shared 16-bit-limb split, now reused by int_to_hex_string too). any_to_string's case int: routes through the new type_is_unsigned(type) query to pick the unsigned vs signed formatter. Declared type_is_unsigned :: ($T: Type) -> bool #builtin;.
  • src/ir/types.zigTypeTable.isUnsignedInt (canonical signedness predicate; single source of truth).
  • src/ir/inst.zigtype_is_unsigned BuiltinId.
  • src/ir/calls.zig — register type_is_unsigned as a .bool reflection builtin.
  • src/ir/lower.zigtryLowerReflectionCall arm: static fold + dynamic callBuiltin.
  • src/ir/interp.zig — interp arm (reads the boxed TypeId / type_of aggregate shape).
  • src/ir/emit_llvm.zig + src/backend/llvm/reflection.zig + src/backend/llvm/ops.zig — lazy [N x i1] __sx_type_is_unsigned table built from isUnsignedInt; runtime arm GEPs in at the TypeId.

Regression test. examples/0046-basic-int-formatter-extremes.sx pins both extremes plus a width spread (s8/s16/s32 + u8/u16/u32/u64, mins/maxes, 0, ordinary values). Unit tests: isUnsignedInt in src/ir/types.test.zig.

STATUS (original): OPEN. Pre-existing + orthogonal; surfaced (not introduced) by NL.1. Manager-verified independent of the numeric-limit accessors. Scheduled separately.

Symptom

print("{}", x) mis-renders the integer extremes the s64-based formatter can't represent:

  • i64::MIN (-9223372036854775808) prints a bare - (the minus sign with NO digits).
  • An unsigned all-ones value (e.g. u64.max = 18446744073709551615) prints -1 (the i64 bit-reinterpretation), not the unsigned decimal.

Reproduction (no numeric-limit accessor needed — pre-existing)

#import "modules/std.sx";
main :: () {
    x := -9223372036854775807 - 1;   // i64::MIN
    print("min={}\n", x);            // prints "min=-"  (should be -9223372036854775808)
}

u64.max (via the NL.1 accessor, or any all-ones u64) prints -1 for the same root reason.

Root cause (suspected)

The integer-to-string path is s64-based (std.int_to_string / the {} formatter takes s64): it negates the value to print the sign, but -i64::MIN overflows, and it has no unsigned-aware path so an all-ones u64 is read as -1. Needs a width/ signedness-aware integer formatter (format by the value's actual integer TYPE: unsigned types print the unsigned decimal; signed MIN is handled without negating).

Investigation prompt

Make the {} integer formatter type-aware: render an unsigned integer as its unsigned decimal (all 64 bits for u64), and handle signed MIN without the -MIN overflow (e.g. format the magnitude via unsigned arithmetic, or special-case MIN). Verify: i64::MIN prints -9223372036854775808; u64.max prints 18446744073709551615; existing numeric output (incl. the NL.1 examples, which assert via bit-reinterpret) stays green. Likely area: the formatter / int_to_string in the std print path and/or the comptime {} lowering.