diff --git a/prims.lua b/prims.lua index 8d6dcb9..b637701 100644 --- a/prims.lua +++ b/prims.lua @@ -256,23 +256,12 @@ defprim("intern", 1, function(s) return intern(s) end) --- On 5.3+ string.format("%d", x) ERRORS for an integral float outside int64 --- range; go through math.tointeger and fall back to %.17g (where LuaJIT's --- own %d output is junk anyway). math.tointeger is nil under LuaJIT/5.1, so --- that path is byte-identical to before. -local mtoint = math.tointeger -local function numToStr(n) - if type(n)=="number" and n==math.floor(n) and n~=math.huge and n~=-math.huge then - if mtoint then - local i = mtoint(n) - if i then return string.format("%d", i) end - return string.format("%.17g", n) - end - return string.format("%d", n) - end - -- non-integer: shortest round-trippable form, not LuaJIT's lossy %.14g (#24) - return R.shortest_float(n) -end +-- `str` on a number and the printer are two surfaces on ONE rendering rule, so +-- there is one implementation of it, in runtime.lua. Keeping a second copy here +-- is how they diverged before; do not reintroduce one. See R.num_to_str for the +-- rule (exact %d inside int64, shortest-round-trip expanded positionally +-- outside it, shortest_float for non-integral and non-finite values). +local numToStr = R.num_to_str defprim("str", 1, function(x) local t = type(x) diff --git a/runtime.lua b/runtime.lua index 3aecc39..4621aaf 100644 --- a/runtime.lua +++ b/runtime.lua @@ -210,10 +210,49 @@ local function shortest_float(n) end return string.format("%.17g", n) end -local function to_str(x, seen) - local t = type(x) - if t == "number" then - if x == math.floor(x) and x == x and x ~= math.huge and x ~= -math.huge then + +-- TWO63: 2^63, exact as a double. The %d path is only sound for integral +-- values inside int64; on LuaJIT/5.1 it SATURATES outside that range instead +-- of erroring, so everything from 2^63 up printed as 9223372036854775807 and +-- everything from -2^63 down as -9223372036854775808 (issue: rendering half of +-- the int64-saturation class that c83c40e fixed for numeric literals). +local TWO63 = 9223372036854775808.0 + +-- Positional decimal for a finite integral double at or beyond int64 range. +-- %.17g / tostring would give exponent form, which is not what any reference +-- port prints; %d cannot represent the value at all. So: take the SHORTEST +-- round-trippable scientific form (same rule shortest_float applies to +-- non-integral values, issue #24) and expand its digits positionally. +-- +-- 1e19 -> "1e+19" -> 1 + 19 zeros +-- 2^63 -> "9.223372036854776e+18" -> 9223372036854776 + 3 zeros +-- +-- Shortest-round-trip rather than the double's exact value (%.0f) on purpose: +-- it round-trips, it is what shen-rust prints for the same double (verified +-- byte-for-byte), and it is the rule this printer already follows everywhere +-- else. Sign-symmetric, so -2^63 renders as the negation of 2^63 rather than +-- exposing the two's-complement asymmetry of %d. +local function positional_integral(n) + local s + for p = 0, 17 do + s = string.format("%." .. p .. "e", n) + if tonumber(s) == n then break end + end + local sign, lead, frac, e = s:match("^(%-?)(%d)%.?(%d*)[eE]([-+]?%d+)$") + if not sign then return shortest_float(n) end -- unrecognised: degrade, don't lie + local zeros = tonumber(e) - #frac + if zeros < 0 then return shortest_float(n) end -- not actually integral + return sign .. lead .. frac .. string.rep("0", zeros) +end + +-- The single number-rendering rule for the whole port. runtime.to_str (the +-- printer) and the `str` primitive in prims.lua BOTH go through this: they are +-- two surfaces on one rule and have diverged before, so there is exactly one +-- implementation. Non-finite values (+-inf, NaN) fall through to +-- shortest_float unchanged -- overflow policy is deliberately not decided here. +local function num_to_str(x) + if x == math.floor(x) and x == x and x ~= math.huge and x ~= -math.huge then + if x > -TWO63 and x < TWO63 then if mtoint then local i = mtoint(x) if i then return string.format("%d", i) end @@ -221,7 +260,15 @@ local function to_str(x, seen) end return string.format("%d", x) end - return shortest_float(x) + return positional_integral(x) + end + return shortest_float(x) +end + +local function to_str(x, seen) + local t = type(x) + if t == "number" then + return num_to_str(x) elseif t == "boolean" then return x and "true" or "false" elseif t == "string" then return '"' .. x .. '"' elseif x == NIL then return "()" @@ -243,5 +290,6 @@ local function to_str(x, seen) end M.to_str = to_str M.shortest_float = shortest_float +M.num_to_str = num_to_str return M diff --git a/scripts/run-tests.lua b/scripts/run-tests.lua index d908803..8c58758 100644 --- a/scripts/run-tests.lua +++ b/scripts/run-tests.lua @@ -38,6 +38,7 @@ local specs = { "test/jit_spec.lua", "test/library_spec.lua", "test/numeric_literal_spec.lua", + "test/numeric_render_spec.lua", "test/primitives_spec.lua", "test/prolog_semantics_spec.lua", "test/reader_spec.lua", diff --git a/test/numeric_render_spec.lua b/test/numeric_render_spec.lua new file mode 100644 index 0000000..81bed8d --- /dev/null +++ b/test/numeric_render_spec.lua @@ -0,0 +1,179 @@ +-- test/numeric_render_spec.lua — PORT-AUTHORED coverage of how a NUMBER is +-- RENDERED, on both of the port's two rendering paths: +-- +-- * runtime.lua `to_str` — the printer (REPL echo, `shen.tostring`, specs) +-- * prims.lua `numToStr` — the Shen `str` primitive +-- +-- Companion to numeric_literal_spec.lua, which covers the COMPILE-time path +-- (compiler.lua `cnum`) and deliberately left rendering out of scope. +-- +-- The bug this guards: both rendering paths rendered every finite integral +-- double with string.format("%d", n). Under LuaJIT/5.1 (no math.tointeger to +-- guard with) that SATURATES at 2^63-1 rather than erroring, so every value +-- outside int64 range printed as the same sentinel: +-- +-- (* 1000000000.0 10000000000.0) => 9223372036854775807 +-- (str 1e19) => 9223372036854775807 +-- 1e300 => 9223372036854775807 +-- +-- The value was computed correctly (that is numeric_literal_spec's job); only +-- the rendering was destroyed. shen-cl and shen-rust both print the full +-- positional integer. +-- +-- CONVENTION ASSERTED HERE +-- A finite integral double renders as positional decimal digits, never an +-- exponent, at any magnitude. Below 2^63 that is the exact value (the %d +-- path, unchanged). At or above 2^63 a double is no longer exactly +-- int64-representable, so we render the SHORTEST round-trippable decimal +-- expanded positionally -- the same rule shortest_float already applies to +-- non-integral values (issue #24), and byte-identical to shen-rust. +-- Non-integral finite values and the non-finite values (inf/-inf/nan) are +-- untouched; overflow POLICY is deliberately not decided here. +-- +-- luajit test/numeric_render_spec.lua +local shen = require("shen") +shen.boot{ quiet = true } +local R = require("runtime") + +local npass, nfail = 0, 0 +local function check(cond, name) + if cond then npass = npass + 1 + else + nfail = nfail + 1 + io.write("FAIL: ", name, "\n") + end +end +local function fail3(what, want, got) + nfail = nfail + 1 + io.write("FAIL: ", what, "\n want: ", want, "\n got: ", got, "\n") +end + +-- Assert BOTH rendering paths on the same expression, and assert they agree +-- with each other. They have diverged in this repo before, so "printer says X" +-- is not evidence that "(str ...) says X". +local function checkboth(src, want) + local ok, printed = pcall(function() return R.to_str(shen.eval(src)) end) + if not ok then fail3(src .. " [to_str]", want, "raised: " .. tostring(printed)) + elseif printed ~= want then fail3(src .. " [to_str]", want, printed) + else npass = npass + 1 end + + local ok2, viastr = pcall(function() return shen.eval("(str " .. src .. ")") end) + if not ok2 then fail3(src .. " [str]", want, "raised: " .. tostring(viastr)) + elseif viastr ~= want then fail3(src .. " [str]", want, viastr) + else npass = npass + 1 end + + if ok and ok2 then + check(printed == viastr, + "to_str and (str ...) agree on " .. src .. + " (to_str=" .. tostring(printed) .. ", str=" .. tostring(viastr) .. ")") + end +end + +local rep = string.rep + +-- --------------------------------------------------------------------------- +-- THE DEFECT: finite integral values at or above 2^63 render positionally. +-- Every expectation below was taken from shen-rust (fresh build) on the same +-- expression and is byte-for-byte identical to it. +-- --------------------------------------------------------------------------- +local BIG = { + -- computed, not read: reader-independent anchors + { "(* 1000000000.0 10000000000.0)", "10000000000000000000" }, + { "(* -1000000000.0 10000000000.0)", "-10000000000000000000" }, + { "(* 4611686018427387904.0 2.0)", "9223372036854776000" }, -- 2^63 + { "(* 4611686018427387904.0 4.0)", "18446744073709552000" }, -- 2^64 + { "(* 3.0 4611686018427387904.0)", "13835058055282164000" }, -- 3*2^62 + { "(* 9007199254740992.0 1048576.0)","9444732965739290000000" }, -- 2^73 + -- read from source + { "1e19", "10000000000000000000" }, + { "-1e19", "-10000000000000000000" }, + { "1e20", "100000000000000000000" }, + { "1e21", "1000000000000000000000" }, + { "9223372036854775808", "9223372036854776000" }, + { "18446744073709551616", "18446744073709552000" }, + { "12345678901234567890", "12345678901234563000" }, + { "1e100", "10000000000000006" .. rep("0", 84) }, + { "1e300", "10000000000000002" .. rep("0", 284) }, + { "-1e300", "-10000000000000002" .. rep("0", 284) }, + { "1e308", "9999999999999998" .. rep("0", 292) }, +} +for _, p in ipairs(BIG) do checkboth(p[1], p[2]) end + +-- Structural properties of the big-value form, asserted independently of the +-- exact digits: positional only, and it must round-trip to the same double. +for _, p in ipairs(BIG) do + local s = p[2] + check(not s:find("[eE]"), "no exponent in rendering of " .. p[1]) + check(not s:find("%."), "no decimal point in rendering of " .. p[1]) + check(s ~= "9223372036854775807" and s ~= "-9223372036854775808", + "rendering of " .. p[1] .. " is not the int64 saturation sentinel") + check(tonumber(s) == shen.eval(p[1]), + "rendering of " .. p[1] .. " round-trips to the same double") +end + +-- 1e300 is 301 digits, 1e100 is 101 -- a magnitude sanity check that a future +-- exponent-form regression would trip even if the digits changed. +check(#(shen.eval("(str 1e300)")) == 301, "(str 1e300) is 301 digits") +check(#(shen.eval("(str 1e100)")) == 101, "(str 1e100) is 101 digits") + +-- --------------------------------------------------------------------------- +-- COLLATERAL: everything the %d path already got right must be untouched. +-- --------------------------------------------------------------------------- +local SMALL = { + { "0", "0" }, + { "(- 0 0)", "0" }, + { "42", "42" }, + { "-42", "-42" }, + { "1000000", "1000000" }, + { "(* 6 7)", "42" }, + { "9007199254740992", "9007199254740992" }, -- 2^53 + { "(* 4503599627370496 2)", "9007199254740992" }, + { "9223372036854774784", "9223372036854774784" }, -- max double < 2^63 + { "(/ 1e300 1e290)", "10000000000" }, + { "(/ 1e19 1e10)", "1000000000" }, + { "(- 1e300 1e300)", "0" }, + { "(* 2.5 2)", "5" }, + -- non-integral: shortest round-trippable form (issue #24), unchanged + { "0.1", "0.1" }, + { "(/ 1 3)", "0.3333333333333333" }, + { "(+ 0.1 0.2)", "0.30000000000000004" }, + { "1e-300", "1.0000000000000022e-300" }, +} +for _, p in ipairs(SMALL) do checkboth(p[1], p[2]) end + +-- --------------------------------------------------------------------------- +-- NON-FINITE: unchanged, and deliberately so -- overflow policy is an open +-- cross-port question (shen-cl raises, shen-go/shen-lua/shen-rust propagate). +-- Build a genuine +inf by overflowing, never by reading a literal. +-- --------------------------------------------------------------------------- +local POSINF = "(* (/ 1.0 1e-300) (/ 1.0 1e-300))" +local NEGINF = "(- 0 " .. POSINF .. ")" +local NAN = "(- " .. POSINF .. " " .. POSINF .. ")" +checkboth(POSINF, "inf") +checkboth(NEGINF, "-inf") +checkboth(NAN, "nan") + +-- --------------------------------------------------------------------------- +-- Direct unit coverage of the printer over raw Lua doubles, so the rule is +-- pinned independently of what the Shen reader happens to produce. +-- --------------------------------------------------------------------------- +local DIRECT = { + { 0, "0" }, { 42, "42" }, { -42, "-42" }, + { 2^53, "9007199254740992" }, { -2^53, "-9007199254740992" }, + { 9223372036854774784, "9223372036854774784" }, + { 2^63, "9223372036854776000" }, + { -2^63, "-9223372036854776000" }, + { 2^64, "18446744073709552000" }, + { 1e19, "10000000000000000000" }, + { -1e19, "-10000000000000000000" }, + { 1e300, "1" .. rep("0", 300) }, -- Lua-parsed 1e300 IS the exact power + { math.huge, "inf" }, { -math.huge, "-inf" }, +} +for _, p in ipairs(DIRECT) do + local got = R.to_str(p[1]) + if got == p[2] then npass = npass + 1 + else fail3("to_str(" .. string.format("%.17g", p[1]) .. ")", p[2], got) end +end + +io.write(string.format("numeric_render_spec: %d pass, %d fail\n", npass, nfail)) +os.exit(nfail == 0 and 0 or 1)