diff --git a/client/space_lua/arithmetic_test.lua b/client/space_lua/arithmetic_test.lua index 5173173c..11aa772f 100644 --- a/client/space_lua/arithmetic_test.lua +++ b/client/space_lua/arithmetic_test.lua @@ -63,6 +63,40 @@ assert_eq(1/((-0.0)%-1.0) == -1/ 0.0, true, 'float: mod neg divisor (-0.0)') -- 4.1. Zero result from float addition prefers +0.0 assert_eq(1/((-0.0)+0.0) == 1/0.0, true, 'float: (-0.0)+0.0 yields +0.0') +-- 4.2. Plain -0.0 handling in arithmetic +do + local nz = -0.0 + -- Addition: -0.0 + 0 yields +0.0 (IEEE 754 rule) + assert_eq(1/(nz + 0), 1/0.0, 'plain -0.0: nz + 0 yields +0.0') + assert_eq(1/(0 + nz), 1/0.0, 'plain -0.0: 0 + nz yields +0.0') + + -- Multiplication preserves -0.0 + assert_eq(1/(nz * 1), -1/0.0, 'plain -0.0: nz * 1 yields -0.0') + assert_eq(1/(1 * nz), -1/0.0, 'plain -0.0: 1 * nz yields -0.0') + + -- Subtraction: -0.0 - 0 = -0.0 + assert_eq(1/(nz - 0), 1/0.0, 'plain -0.0: nz - 0 yields +0.0') + + -- Subtraction: -0.0 - 0.0 = -0.0 (float) + assert_eq(1/(nz - 0.0), -1/0.0, 'plain -0.0: nz - 0.0 yields -0.0') + + -- Subtraction: 0 - (-0.0) = +0.0 + assert_eq(1/(0 - nz), 1/0.0, 'plain -0.0: 0 - nz yields +0.0') +end + +-- 4.3. Expression-generated -0.0 +do + local r = 0.0 * -1.0 + assert_eq(1/r, -1/0.0, 'expr: 0.0 * -1.0 yields -0.0') + + local s = -1.0 * 0.0 + assert_eq(1/s, -1/0.0, 'expr: -1.0 * 0.0 yields -0.0') + + -- Division producing -0.0 + local d = -0.0 / 1.0 + assert_eq(1/d, -1/0.0, 'expr: -0.0 / 1.0 yields -0.0') +end + -- 5. Mixed arithmetic producing zero assert_eq(1/(0*-1.0) == -1/0.0, true, 'mixed: mul int*float (-0.0)') assert_eq(1/(0.0*-1 ) == -1/0.0, true, 'mixed: mul float*int (-0.0)') @@ -701,3 +735,904 @@ assertThrows("attempt to divide by zero", return 1 // (1-1) end ) + +-- 20. `tostring()` numeric formatting + +-- 20.1. Integers +assert_eq(tostring(0), '0', 'tostring: 0') +assert_eq(tostring(1), '1', 'tostring: 1') +assert_eq(tostring(-5), '-5', 'tostring: -5') +assert_eq(tostring(42), '42', 'tostring: 42') +assert_eq(tostring(-123), '-123', 'tostring: -123') + +-- 20.2. Float zeros (positive and negative) +assert_eq(tostring(0.0), '0.0', 'tostring: 0.0') +assert_eq(tostring(-0.0), '-0.0', 'tostring: -0.0') + +-- 20.3. Integer-valued floats +assert_eq(tostring(1.0), '1.0', 'tostring: 1.0') +assert_eq(tostring(2.0), '2.0', 'tostring: 2.0') +assert_eq(tostring(-3.0), '-3.0', 'tostring: -3.0') +assert_eq(tostring(100.0), '100.0', 'tostring: 100.0') + +-- 20.4. Non-integer floats +assert_eq(tostring(0.5), '0.5', 'tostring: 0.5') +assert_eq(tostring(3.14), '3.14', 'tostring: 3.14') +assert_eq(tostring(-2.5), '-2.5', 'tostring: -2.5') + +-- 20.5. Special float values +assert_eq(tostring(1/0.0), 'inf', 'tostring: +inf') +assert_eq(tostring(-1/0.0), '-inf', 'tostring: -inf') +assert_eq(tostring(0.0/0.0), '-nan', 'tostring: NaN') +assert_eq(tostring(1.0%0.0), '-nan', 'tostring: NaN from modulo') +assert_eq(tostring((-1.0)%0.0), '-nan', 'tostring: NaN from neg modulo') + +-- 20.6. Results from tonumber() preserve type +assert_eq(tostring(tonumber('5')), '5', 'tonumber int: "5"') +assert_eq(tostring(tonumber('5.')), '5.0', 'tonumber float: "5."') +assert_eq(tostring(tonumber('.5')), '0.5', 'tonumber float: ".5"') +assert_eq(tostring(tonumber('5.0')), '5.0', 'tonumber float: "5.0"') +assert_eq(tostring(tonumber('0x10')), '16', 'tonumber hex int: "0x10"') +assert_eq(tostring(tonumber('0x10.0')), '16.0', 'tonumber hex float: "0x10.0"') +assert_eq(tostring(tonumber('0x1p4')), '16.0', 'tonumber hex float: "0x1p4"') + +-- 20.7. Arithmetic results formatting +assert_eq(tostring(1+1), '2', 'int+int yields int') +assert_eq(tostring(1.0+1.0), '2.0', 'float+float yields float') +assert_eq(tostring(1+1.0), '2.0', 'int+float yields float') +assert_eq(tostring(5/2), '2.5', 'division yields float') +assert_eq(tostring(4/2), '2.0', 'division exact yields float') + +-- 21. Numeric for loops: comprehensive type/mode coverage + +-- 21.1. Integer mode (all params integer-valued and untagged) + +do + local types, vals = {}, {} + for i = 1, 3 do + table.insert(types, math.type(i)) + table.insert(vals, tostring(i)) + end + assert_eq(table.concat(types, ','), 'integer,integer,integer', 'for 1,3: types') + assert_eq(table.concat(vals, ','), '1,2,3', 'for 1,3: values') +end + +do + local types = {} + for i = 1, 3, 1 do + table.insert(types, math.type(i)) + end + assert_eq(types[1], 'integer', 'for 1,3,1: type') +end + +do + local vals = {} + for i = 3, 1, -1 do + table.insert(vals, tostring(i)) + end + assert_eq(table.concat(vals, ','), '3,2,1', 'for 3,1,-1: descending') +end + +do + local found_zero = false + local zero_str + for i = -1, 1 do + if i == 0 then + found_zero = true + zero_str = tostring(i) + end + end + assert_eq(found_zero, true, 'for -1,1: crosses zero') + assert_eq(zero_str, '0', 'for -1,1: zero is int') +end + +do + for i = 1, 3 do + local zero = i - i + assert_eq(math.type(zero), 'integer', 'for 1,3: i-i is int') + assert_eq(tostring(zero), '0', 'for 1,3: i-i formats as int') + end +end + +do + for i = 2, 4 do + local expr = i * 2 - i - i + assert_eq(math.type(expr), 'integer', 'for 2,4: int expr is int') + assert_eq(expr, 0, 'for 2,4: int expr value') + end +end + +-- 21.2. Float mode (all params float-typed) + +do + local types, vals = {}, {} + for i = 1.0, 3.0, 1.0 do + table.insert(types, math.type(i)) + table.insert(vals, tostring(i)) + end + assert_eq(table.concat(types, ','), 'float,float,float', 'for 1.0,3.0,1.0: types') + assert_eq(table.concat(vals, ','), '1.0,2.0,3.0', 'for 1.0,3.0,1.0: values') +end + +do + local vals = {} + for i = 0.5, 2.5, 0.5 do + table.insert(vals, tostring(i)) + end + assert_eq(table.concat(vals, ','), '0.5,1.0,1.5,2.0,2.5', 'for 0.5,2.5,0.5: values') +end + +do + local vals = {} + for i = 3.0, 1.0, -1.0 do + table.insert(vals, tostring(i)) + end + assert_eq(table.concat(vals, ','), '3.0,2.0,1.0', 'for 3.0,1.0,-1.0: descending') +end + +do + local vals = {} + for i = 2.5, 0.5, -0.5 do + table.insert(vals, tostring(i)) + end + assert_eq(table.concat(vals, ','), '2.5,2.0,1.5,1.0,0.5', 'for 2.5,0.5,-0.5: values') +end + +do + local zero_str + for i = -1.0, 1.0 do + if i == 0 then + zero_str = tostring(i) + end + end + assert_eq(zero_str, '0.0', 'for -1.0,1.0: zero is float') +end + +do + for i = 1.0, 3.0 do + local zero = i - i + assert_eq(math.type(zero), 'float', 'for 1.0,3.0: i-i is float') + assert_eq(tostring(zero), '0.0', 'for 1.0,3.0: i-i formats as float') + end +end + +do + for i = 2.0, 4.0 do + local expr = i * 2.0 - i - i + assert_eq(math.type(expr), 'float', 'for 2.0,4.0: float expr is float') + assert_eq(tostring(expr), '0.0', 'for 2.0,4.0: float expr formats') + end +end + +-- 21.3. Mixed mode (start type determines loop var type) + +do + local types, vals = {}, {} + for i = 1, 3.5 do + table.insert(types, math.type(i)) + table.insert(vals, tostring(i)) + end + assert_eq(types[1], 'integer', 'for 1,3.5: start int yields var int') + assert_eq(types[2], 'integer', 'for 1,3.5: var stays int') + assert_eq(table.concat(vals, ','), '1,2,3', 'for 1,3.5: int formatting') +end + +do + local types, vals = {}, {} + for i = 1.0, 3 do + table.insert(types, math.type(i)) + table.insert(vals, tostring(i)) + end + assert_eq(types[1], 'float', 'for 1.0,3: start float yields var float') + assert_eq(types[2], 'float', 'for 1.0,3: var stays float') + assert_eq(table.concat(vals, ','), '1.0,2.0,3.0', 'for 1.0,3: float formatting') +end + +do + local types = {} + for i = 1, 3.0 do + table.insert(types, math.type(i)) + end + assert_eq(types[1], 'integer', 'for 1,3.0: end=3.0 but start int yields var int') +end + +do + local types, vals = {}, {} + for i = 1, 3, 1.0 do + table.insert(types, math.type(i)) + table.insert(vals, tostring(i)) + end + assert_eq(types[1], 'float', 'for 1,3,1.0: step float yields var float') + assert_eq(table.concat(vals, ','), '1.0,2.0,3.0', 'for 1,3,1.0: float formatting') +end + +do + local types, vals = {}, {} + for i = 1.0, 3.0, 1 do + table.insert(types, math.type(i)) + table.insert(vals, tostring(i)) + end + assert_eq(types[1], 'float', 'for 1.0,3.0,1: start float yields var float') + assert_eq(table.concat(vals, ','), '1.0,2.0,3.0', 'for 1.0,3.0,1: float formatting') +end + +do + local types = {} + for i = 3.0, 1, -1 do + table.insert(types, math.type(i)) + end + assert_eq(types[1], 'float', 'for 3.0,1,-1: start float yields var float') +end + +-- Mixed mode: float step, integer start +do + local types, vals = {}, {} + for i = 1, 3, 1.0 do + table.insert(types, math.type(i)) + table.insert(vals, tostring(i)) + end + assert_eq(types[1], 'float', 'for 1,3,1.0: step float yields var float') + assert_eq(table.concat(vals, ','), '1.0,2.0,3.0', 'for 1,3,1.0: float formatting') +end + +-- Mixed mode: integer start, negative float step +do + local types, vals = {}, {} + for i = 3, 1, -1.0 do + table.insert(types, math.type(i)) + table.insert(vals, tostring(i)) + end + assert_eq(types[1], 'float', 'for 3,1,-1.0: step float yields var float') + assert_eq(table.concat(vals, ','), '3.0,2.0,1.0', 'for 3,1,-1.0: float formatting') +end + +do + for i = 1, 3.5 do + local zero = i - i + assert_eq(math.type(zero), 'integer', 'for 1,3.5: start int yields i-i is int') + end +end + +do + for i = 1.0, 3 do + local zero = i - i + assert_eq(math.type(zero), 'float', 'for 1.0,3: start float yields i-i is float') + end +end + +-- 21.4. Boundary conditions + +do + local count = 0 + for i = 5, 5 do + count = count + 1 + assert_eq(i, 5, 'for 5,5: i=5') + end + assert_eq(count, 1, 'for 5,5: one iteration') +end + +do + local count = 0 + for i = 5, 3 do + count = count + 1 + end + assert_eq(count, 0, 'for 5,3: no iterations') +end + +do + local count = 0 + for i = 3, 5, -1 do + count = count + 1 + end + assert_eq(count, 0, 'for 3,5,-1: no iterations') +end + +do + local last + for i = 1.0, 3.0 do + last = i + end + assert_eq(tostring(last), '3.0', 'for 1.0,3.0: exactly reaches end') +end + +do + local last + for i = 1, 10, 3 do + last = i + end + assert_eq(last, 10, 'for 1,10,3: reaches end exactly') +end + +do + local last + for i = 1, 9, 3 do + last = i + end + assert_eq(last, 7, 'for 1,9,3: stops before end') +end + +do + local vals = {} + for i = 0.1, 0.3, 0.1 do + table.insert(vals, i) + end + assert_eq(#vals, 2, 'for 0.1,0.3,0.1: small float step iterations') +end + +do + local vals = {} + -- 0.5 has exact binary representation + for i = 0.5, 1.5, 0.5 do + table.insert(vals, i) + end + assert_eq(#vals, 3, 'for 0.5,1.5,0.5: small float step') +end + +-- 21.5. Edge cases and errors + +assertThrows('step is zero', function() + for i = 1, 10, 0 do + end +end) + +assertThrows('step is zero', function() + for i = 1.0, 10.0, 0.0 do + end +end) + +do + local count = 0 + for i = 1, 1000000 do + count = count + 1 + if count > 5 then break end + end + assert_eq(count, 6, 'for 1,1000000: large range with break') +end + +do + local sum = 0 + for i = 1, 5, 2 do + sum = sum + i + end + assert_eq(sum, 9, 'for 1,5,2: step>1 sum (1+3+5)') +end + +do + local sum = 0 + for i = 10, 1, -3 do + sum = sum + i + end + assert_eq(sum, 22, 'for 10,1,-3: negative step>1 sum (10+7+4+1)') +end + +-- 22. Table numeric key equivalence + +-- 22.1. Integer-valued floats normalize to integers + +do + local t = {} + t[1] = 'one' + assert_eq(t[1.0], 'one', 'table: t[1] accessed via t[1.0]') + assert_eq(t[1], 'one', 'table: t[1] accessed via t[1]') +end + +do + local t = {} + t[2.0] = 'two' + assert_eq(t[2], 'two', 'table: t[2.0] accessed via t[2]') + assert_eq(t[2.0], 'two', 'table: t[2.0] accessed via t[2.0]') +end + +do + local t = {} + t[1] = 'a' + t[2.0] = 'b' + t[3] = 'c' + assert_eq(t[1.0], 'a', 'table: multi-key t[1.0]') + assert_eq(t[2], 'b', 'table: multi-key t[2]') + assert_eq(t[3.0], 'c', 'table: multi-key t[3.0]') +end + +do + local t = {} + t[5] = 'first' + t[5.0] = 'second' + assert_eq(t[5], 'second', 'table: overwrite int with float') + assert_eq(t[5.0], 'second', 'table: overwrite int with float (access)') +end + +do + local t = {} + t[100] = 'hundred' + assert_eq(t[100.0], 'hundred', 'table: large int t[100.0]') +end + +-- 22.2. Zero normalization (both -0 and +0 map to same key) + +do + local t = {} + t[0] = 'zero' + assert_eq(t[-0.0], 'zero', 'table: t[0] accessed via t[-0.0]') + assert_eq(t[0.0], 'zero', 'table: t[0] accessed via t[0.0]') + assert_eq(t[0], 'zero', 'table: t[0] accessed via t[0]') +end + +do + local t = {} + t[-0.0] = 'negzero' + assert_eq(t[0], 'negzero', 'table: t[-0.0] accessed via t[0]') + assert_eq(t[0.0], 'negzero', 'table: t[-0.0] accessed via t[0.0]') + assert_eq(t[-0.0], 'negzero', 'table: t[-0.0] accessed via t[-0.0]') +end + +do + local t = {} + t[0.0] = 'float_zero' + assert_eq(t[0], 'float_zero', 'table: t[0.0] accessed via t[0]') + assert_eq(t[-0.0], 'float_zero', 'table: t[0.0] accessed via t[-0.0]') +end + +do + local t = {} + t[0] = 'first' + t[-0.0] = 'second' + t[0.0] = 'third' + assert_eq(t[0], 'third', 'table: zero overwrite final') + assert_eq(t[-0.0], 'third', 'table: zero overwrite via -0.0') +end + +-- 22.3. Non-integer floats are distinct keys + +do + local t = {} + t[1] = 'int_one' + t[1.5] = 'one_point_five' + assert_eq(t[1], 'int_one', 'table: non-int float t[1]') + assert_eq(t[1.5], 'one_point_five', 'table: non-int float t[1.5]') + assert_eq(t[1.0], 'int_one', 'table: non-int float t[1.0] maps to int') +end + +do + local t = {} + t[0.5] = 'half' + t[1.5] = 'one_half' + t[2.5] = 'two_half' + assert_eq(t[0.5], 'half', 'table: multi non-int t[0.5]') + assert_eq(t[1.5], 'one_half', 'table: multi non-int t[1.5]') + assert_eq(t[2.5], 'two_half', 'table: multi non-int t[2.5]') +end + +-- 22.4. Mixed integer and float keys + +do + local t = {} + t[0] = 'zero' + t[1] = 'one' + t[1.5] = 'one_point_five' + t[2.0] = 'two' + t[-0.0] = 'neg_zero' + + assert_eq(t[0], 'neg_zero', 'table: mixed t[0] (last zero)') + assert_eq(t[0.0], 'neg_zero', 'table: mixed t[0.0]') + assert_eq(t[-0.0], 'neg_zero', 'table: mixed t[-0.0]') + assert_eq(t[1], 'one', 'table: mixed t[1]') + assert_eq(t[1.0], 'one', 'table: mixed t[1.0]') + assert_eq(t[1.5], 'one_point_five', 'table: mixed t[1.5]') + assert_eq(t[2], 'two', 'table: mixed t[2]') + assert_eq(t[2.0], 'two', 'table: mixed t[2.0]') +end + +-- 22.5. Key equivalence with expressions + +do + local t = {} + t[1+1] = 'two' + assert_eq(t[2.0], 'two', 'table: expr key t[1+1] via t[2.0]') + assert_eq(t[4/2], 'two', 'table: expr key t[4/2]') +end + +do + local t = {} + t[1-1] = 'int_zero' + assert_eq(t[0], 'int_zero', 'table: expr t[1-1] via t[0]') + assert_eq(t[0.0], 'int_zero', 'table: expr t[1-1] via t[0.0]') + assert_eq(t[-0.0], 'int_zero', 'table: expr t[1-1] via t[-0.0]') +end + +do + local t = {} + t[1.0-1.0] = 'float_zero' + assert_eq(t[0], 'float_zero', 'table: expr t[1.0-1.0] via t[0]') + assert_eq(t[-0.0], 'float_zero', 'table: expr t[1.0-1.0] via t[-0.0]') +end + +-- 22.6. Key equivalence with variables + +do + local t = {} + local i = 1 + local f = 1.0 + t[i] = 'from_int' + assert_eq(t[f], 'from_int', 'table: var int key via float var') +end + +do + local t = {} + local i = 1 + local f = 1.0 + t[f] = 'from_float' + assert_eq(t[i], 'from_float', 'table: var float key via int var') +end + +do + local t = {} + local zi = 0 + local zf = 0.0 + local zfn = -0.0 + t[zi] = 'int_zero' + assert_eq(t[zf], 'int_zero', 'table: var zi via zf') + assert_eq(t[zfn], 'int_zero', 'table: var zi via zfn') +end + +-- 22.7. Key equivalence in array part + +do + local t = {10, 20, 30} + assert_eq(t[1.0], 10, 'table: array t[1.0]') + assert_eq(t[2.0], 20, 'table: array t[2.0]') + assert_eq(t[3.0], 30, 'table: array t[3.0]') +end + +do + local t = {} + t[1.0] = 'first' + t[2.0] = 'second' + assert_eq(t[1], 'first', 'table: array assign t[1.0] via t[1]') + assert_eq(t[2], 'second', 'table: array assign t[2.0] via t[2]') +end + +-- 22.8. Key counting and iteration + +do + local t = {} + t[1] = 'a' + t[1.0] = 'b' + t[2] = 'c' + t[2.0] = 'd' + + local count = 0 + for k, v in pairs(t) do + count = count + 1 + end + assert_eq(count, 2, 'table: normalized keys count as one') +end + +do + local t = {} + t[0] = 'a' + t[0.0] = 'b' + t[-0.0] = 'c' + + local count = 0 + for k, v in pairs(t) do + count = count + 1 + end + assert_eq(count, 1, 'table: all zeros count as one key') +end + +-- 23. Type introspection: type() and math.type() + +-- 23.1. Basic type() function (returns general Lua types) + +assert_eq(type(nil), 'nil', 'type(nil)') +assert_eq(type(true), 'boolean', 'type(true)') +assert_eq(type(false), 'boolean', 'type(false)') +assert_eq(type(0), 'number', 'type(0) is number') +assert_eq(type(1), 'number', 'type(1) is number') +assert_eq(type(0.0), 'number', 'type(0.0) is number') +assert_eq(type(1.0), 'number', 'type(1.0) is number') +assert_eq(type(-0.0), 'number', 'type(-0.0) is number') +assert_eq(type(1.5), 'number', 'type(1.5) is number') +assert_eq(type(1/0.0), 'number', 'type(inf) is number') +assert_eq(type(0.0/0.0), 'number', 'type(nan) is number') +assert_eq(type('hello'), 'string', 'type(string)') +assert_eq(type({}), 'table', 'type(table)') +assert_eq(type(function() end), 'function', 'type(function)') + +-- 23.2. math.type() function (distinguishes integer vs float) + +-- Integers +assert_eq(math.type(0), 'integer', 'math.type(0)') +assert_eq(math.type(1), 'integer', 'math.type(1)') +assert_eq(math.type(-1), 'integer', 'math.type(-1)') +assert_eq(math.type(42), 'integer', 'math.type(42)') +assert_eq(math.type(-123), 'integer', 'math.type(-123)') +assert_eq(math.type(1000000), 'integer', 'math.type(1000000)') + +-- Floats (literals with decimal point) +assert_eq(math.type(0.0), 'float', 'math.type(0.0)') +assert_eq(math.type(-0.0), 'float', 'math.type(-0.0)') +assert_eq(math.type(1.0), 'float', 'math.type(1.0)') +assert_eq(math.type(2.0), 'float', 'math.type(2.0)') +assert_eq(math.type(-3.0), 'float', 'math.type(-3.0)') +assert_eq(math.type(100.0), 'float', 'math.type(100.0)') + +-- Floats (non-integer values) +assert_eq(math.type(0.5), 'float', 'math.type(0.5)') +assert_eq(math.type(1.5), 'float', 'math.type(1.5)') +assert_eq(math.type(3.14), 'float', 'math.type(3.14)') +assert_eq(math.type(-2.5), 'float', 'math.type(-2.5)') + +-- Special float values +assert_eq(math.type(1/0.0), 'float', 'math.type(inf)') +assert_eq(math.type(-1/0.0), 'float', 'math.type(-inf)') +assert_eq(math.type(0.0/0.0), 'float', 'math.type(nan)') + +-- Non-numbers return nil +assert_eq(math.type(nil), nil, 'math.type(nil) yields nil') +assert_eq(math.type(true), nil, 'math.type(boolean) yields nil') +assert_eq(math.type('123'), nil, 'math.type(string) yields nil') +assert_eq(math.type({}), nil, 'math.type(table) yields nil') +assert_eq(math.type(function() end), nil, 'math.type(function) yields nil') + +-- 23.3. Integer arithmetic preserves integer type + +do + local a = 1 + local b = 2 + assert_eq(math.type(a + b), 'integer', 'int + int yields integer') + assert_eq(math.type(a - b), 'integer', 'int - int yields integer') + assert_eq(math.type(a * b), 'integer', 'int * int yields integer') + assert_eq(math.type(a // b), 'integer', 'int // int yields integer') + assert_eq(math.type(a % b), 'integer', 'int % int yields integer') +end + +-- Integer operations producing zero +do + assert_eq(math.type(1 - 1), 'integer', '1 - 1 yields integer zero') + assert_eq(math.type(0 * 5), 'integer', '0 * 5 yields integer zero') + assert_eq(math.type(0 % 1), 'integer', '0 % 1 yields integer zero') + assert_eq(math.type(0 // 1), 'integer', '0 // 1 yields integer zero') +end + +-- 23.4. Float arithmetic preserves float type + +do + local a = 1.0 + local b = 2.0 + assert_eq(math.type(a + b), 'float', 'float + float yields float') + assert_eq(math.type(a - b), 'float', 'float - float yields float') + assert_eq(math.type(a * b), 'float', 'float * float yields float') + assert_eq(math.type(a / b), 'float', 'float / float yields float') + assert_eq(math.type(a // b), 'float', 'float // float yields float') + assert_eq(math.type(a % b), 'float', 'float % float yields float') +end + +-- Float operations producing zero +do + assert_eq(math.type(1.0 - 1.0), 'float', '1.0 - 1.0 yields float zero') + assert_eq(math.type(0.0 * 5.0), 'float', '0.0 * 5.0 yields float zero') + assert_eq(math.type(0.0 % 1.0), 'float', '0.0 % 1.0 yields float zero') + assert_eq(math.type(0.0 // 1.0), 'float', '0.0 // 1.0 yields float zero') +end + +-- 23.5. Mixed arithmetic promotes to float + +do + assert_eq(math.type(1 + 1.0), 'float', 'int + float yields float') + assert_eq(math.type(1.0 + 1), 'float', 'float + int yields float') + assert_eq(math.type(2 * 1.5), 'float', 'int * float yields float') + assert_eq(math.type(3.0 - 1), 'float', 'float - int yields float') + assert_eq(math.type(5 // 2.0), 'float', 'int // float yields float') + assert_eq(math.type(5.0 % 2), 'float', 'float % int yields float') +end + +-- Mixed operations producing zero +do + assert_eq(math.type(1 - 1.0), 'float', '1 - 1.0 yields float zero') + assert_eq(math.type(0 * 1.0), 'float', '0 * 1.0 yields float zero') + assert_eq(math.type(0.0 * 1), 'float', '0.0 * 1 yields float zero') +end + +-- 23.6. Division and power always produce floats + +do + assert_eq(math.type(4 / 2), 'float', '4 / 2 yields float (2.0)') + assert_eq(math.type(5 / 2), 'float', '5 / 2 yields float (2.5)') + assert_eq(math.type(1 / 1), 'float', '1 / 1 yields float (1.0)') + + assert_eq(math.type(2 ^ 3), 'float', '2 ^ 3 yields float (8.0)') + assert_eq(math.type(2 ^ 0), 'float', '2 ^ 0 yields float (1.0)') + assert_eq(math.type(10 ^ 2), 'float', '10 ^ 2 yields float (100.0)') +end + +-- Division producing zero +do + assert_eq(math.type(0 / 1), 'float', '0 / 1 yields float zero') + assert_eq(math.type(0.0 / 1.0), 'float', '0.0 / 1.0 yields float zero') +end + +-- 23.7. Unary minus preserves type + +do + assert_eq(math.type(-5), 'integer', '-5 is integer') + assert_eq(math.type(-0), 'integer', '-0 is integer') + + assert_eq(math.type(-5.0), 'float', '-5.0 is float') + assert_eq(math.type(-0.0), 'float', '-0.0 is float') +end + +-- Unary minus on variables +do + local i = 5 + local f = 5.0 + assert_eq(math.type(-i), 'integer', 'unary minus int var') + assert_eq(math.type(-f), 'float', 'unary minus float var') +end + +-- Unary minus on expressions +do + assert_eq(math.type(-(1 + 1)), 'integer', 'unary minus int expr') + assert_eq(math.type(-(1.0 + 1.0)), 'float', 'unary minus float expr') +end + +-- 23.8. String coercion preserves type (tonumber) + +do + assert_eq(math.type(tonumber('5')), 'integer', 'tonumber("5") yields integer') + assert_eq(math.type(tonumber('5.')), 'float', 'tonumber("5.") yields float') + assert_eq(math.type(tonumber('.5')), 'float', 'tonumber(".5") yields float') + assert_eq(math.type(tonumber('5.0')), 'float', 'tonumber("5.0") yields float') + assert_eq(math.type(tonumber('-0')), 'integer', 'tonumber("-0") yields integer') + assert_eq(math.type(tonumber('-0.0')), 'float', 'tonumber("-0.0") yields float') +end + +-- Hexadecimal literals +do + assert_eq(math.type(tonumber('0x10')), 'integer', 'tonumber("0x10") yields integer') + assert_eq(math.type(tonumber('0x10.0')), 'float', 'tonumber("0x10.0") yields float') + assert_eq(math.type(tonumber('0x1p4')), 'float', 'tonumber("0x1p4") yields float') +end + +-- 23.9. Variables preserve type through assignment + +do + local i = 5 + local f = 5.0 + + assert_eq(math.type(i), 'integer', 'int variable') + assert_eq(math.type(f), 'float', 'float variable') + + local i2 = i + local f2 = f + + assert_eq(math.type(i2), 'integer', 'int variable copy') + assert_eq(math.type(f2), 'float', 'float variable copy') +end + +-- 23.10. Function returns preserve type + +do + local function ret_int() + return 42 + end + + local function ret_float() + return 42.0 + end + + assert_eq(math.type(ret_int()), 'integer', 'function returns integer') + assert_eq(math.type(ret_float()), 'float', 'function returns float') +end + +-- 23.11. Table values preserve type + +do + local t = { + i = 10, + f = 10.0, + [1] = 20, + [2] = 20.0 + } + + assert_eq(math.type(t.i), 'integer', 'table int value (string key)') + assert_eq(math.type(t.f), 'float', 'table float value (string key)') + assert_eq(math.type(t[1]), 'integer', 'table int value (int key)') + assert_eq(math.type(t[2]), 'float', 'table float value (int key)') +end + +-- 23.12. For-loop variable type tracking + +do + -- Integer loop + for i = 1, 3 do + assert_eq(math.type(i), 'integer', 'for 1,3: var is integer') + break -- Just test first iteration + end + + -- Float loop (start is float) + for i = 1.0, 3 do + assert_eq(math.type(i), 'float', 'for 1.0,3: var is float') + break + end + + -- Float loop (step is float) + for i = 1, 3, 1.0 do + assert_eq(math.type(i), 'float', 'for 1,3,1.0: var is float') + break + end +end + +-- 23.13. Bitwise operations require integers (input & output) + +do + assert_eq(math.type(5 & 3), 'integer', 'bitwise and yields integer') + assert_eq(math.type(5 | 2), 'integer', 'bitwise or yields integer') + assert_eq(math.type(5 ~ 1), 'integer', 'bitwise xor yields integer') + assert_eq(math.type(1 << 5), 'integer', 'left shift yields integer') + assert_eq(math.type(32 >> 2), 'integer', 'right shift yields integer') + assert_eq(math.type(~0), 'integer', 'bitwise not yields integer') +end + +-- Bitwise operations convert float operands to integer +do + assert_eq(math.type(5.0 & 3), 'integer', 'float & int yields integer') + assert_eq(math.type(5 | 2.0), 'integer', 'int | float yields integer') + assert_eq(math.type(5.0 ~ 1.0), 'integer', 'float ~ float yields integer') +end + +-- 23.14. Edge cases: zero types + +do + -- Positive integer zero + assert_eq(math.type(0), 'integer', '0 is integer') + assert_eq(math.type(1 - 1), 'integer', '1 - 1 is integer') + assert_eq(math.type(0 * 1), 'integer', '0 * 1 is integer') + + -- Positive float zero + assert_eq(math.type(0.0), 'float', '0.0 is float') + assert_eq(math.type(1.0 - 1.0), 'float', '1.0 - 1.0 is float') + + -- Negative float zero + assert_eq(math.type(-0.0), 'float', '-0.0 is float') + assert_eq(math.type(0.0 * -1.0), 'float', '0.0 * -1.0 is float') + + -- Integer operations never produce -0 + assert_eq(math.type(0 * -1), 'integer', '0 * -1 is integer (not -0)') +end + +-- 23.15. Special float values + +do + local inf = 1.0 / 0.0 + local neginf = -1.0 / 0.0 + local nan = 0.0 / 0.0 + + assert_eq(type(inf), 'number', 'inf has type number') + assert_eq(type(neginf), 'number', '-inf has type number') + assert_eq(type(nan), 'number', 'nan has type number') + + assert_eq(math.type(inf), 'float', 'inf has math.type float') + assert_eq(math.type(neginf), 'float', '-inf has math.type float') + assert_eq(math.type(nan), 'float', 'nan has math.type float') +end + +-- 23.16. Type consistency across operations + +do + local i = 10 + local sum_i = 0 + + for n = 1, 5 do + sum_i = sum_i + n + assert_eq(math.type(sum_i), 'integer', 'integer sum stays integer') + end + + local f = 10.0 + local sum_f = 0.0 + + for n = 1.0, 5.0 do + sum_f = sum_f + n + assert_eq(math.type(sum_f), 'float', 'float sum stays float') + end +end diff --git a/client/space_lua/break_async.test.ts b/client/space_lua/break_async.test.ts index 95f4ccc3..7d42936c 100644 --- a/client/space_lua/break_async.test.ts +++ b/client/space_lua/break_async.test.ts @@ -43,7 +43,7 @@ Deno.test("Nested for loops: inner break (sync body)", async () => { for j = i + 1, #t do found = found + 1 break - error("BUG! Execution continued after break!") + error("BUG! Execution continued after break!") end end `, @@ -67,7 +67,7 @@ Deno.test("Nested for loops: inner break with async call in body (Promise path)" asyncOne() -- forces Promise in body found = found + 1 -- still must execute before break break -- breaks only inner loop - error("BUG! Execution continued after break!") + error("BUG! Execution continued after break!") end end `, diff --git a/client/space_lua/eval.ts b/client/space_lua/eval.ts index 63e0f2bf..997c36a3 100644 --- a/client/space_lua/eval.ts +++ b/client/space_lua/eval.ts @@ -45,8 +45,15 @@ import { boxZero, coerceNumeric, coerceNumericPair, + floatLiteral, + getNumericKind, + getZeroBoxKind, + isFloatTag, + isNegativeZero, + type LuaFloatTag, luaStringCoercionError, type OpHints, + untagNumber, } from "./numeric.ts"; import { isPromise, rpAll, rpThen } from "./rp.ts"; import { @@ -181,13 +188,34 @@ function maybeLuaArithStringError( return null; } +// Normalize arithmetic result based on operation mode: +// * integer operations: -0 becomes +0, integer results stay plain, +// * float operations: -0 stays -0, integer results tagged as float. +function normalizeArithmeticResult( + r: number, + bothInt: boolean, +): number | LuaFloatTag { + if (r === 0) { + if (isNegativeZero(r)) { + return bothInt ? 0 : -0; + } + return bothInt ? 0 : boxZero("float"); + } + + if (!bothInt && Number.isInteger(r)) { + return floatLiteral(r); + } + + return r; +} + function luaFloorDiv( a: unknown, b: unknown, ctx: ASTCtx, sf: LuaStackFrame, hints?: OpHints, -): number { +): number | LuaFloatTag { const { ax, bx, bothInt } = coerceNumericPair(a, b, hints); if (bothInt && bx === 0) { throw new LuaRuntimeError( @@ -199,10 +227,16 @@ function luaFloorDiv( const q = Math.floor(ax / bx); if (q === 0) { if (bothInt) { - return boxZero("int"); + return 0; } - return Object.is(q, -0) ? -0 : boxZero("float"); + return isNegativeZero(q) ? -0 : boxZero("float"); } + + // Tag integer-valued float results + if (!bothInt && Number.isInteger(q)) { + return floatLiteral(q); + } + return q; } @@ -212,7 +246,7 @@ function luaMod( ctx: ASTCtx, sf: LuaStackFrame, hints?: OpHints, -): number { +): number | LuaFloatTag { const { ax, bx, bothInt } = coerceNumericPair(a, b, hints); if (bothInt && bx === 0) { throw new LuaRuntimeError( @@ -224,11 +258,17 @@ function luaMod( const q = Math.floor(ax / bx); const r = ax - q * bx; if (r === 0) { - if (Object.is(ax, -0)) { + if (isNegativeZero(ax)) { return -0; } - return boxZero(bothInt ? "int" : "float"); + return bothInt ? 0 : boxZero("float"); } + + // Tag integer-valued float results + if (!bothInt && Number.isInteger(r)) { + return floatLiteral(r); + } + return r; } @@ -276,23 +316,31 @@ function luaLessEqual( ); } -function luaUnaryMinus( - v: any, -): number { +function luaUnaryMinus(v: any): number | LuaFloatTag { const { n, zeroKind } = coerceNumeric(v); if (n === 0) { - if (Object.is(n, -0)) { - return boxZero("float"); + const origKind = getZeroBoxKind(v); + const isFloat = origKind === "float" || zeroKind === "float" || + isFloatTag(v); + + // If it's a float zero for sure, flip the sign + if (isFloat) { + return isNegativeZero(v) || isNegativeZero(n) ? 0 : -0; } - if (zeroKind === "int") { - return boxZero("int"); - } - if (zeroKind === "float") { - return -0; - } - return -0; + return 0; } + + // Preserve float type for non-zero values. Without this code -5.0 + // would become plain integer -5 (it would loose its float type). + if (isFloatTag(v) || zeroKind === "float" || getNumericKind(v) === "float") { + const result = -n; + if (Number.isInteger(result)) { + return floatLiteral(result); + } + return result; + } + return -n; } @@ -357,9 +405,13 @@ export function evalExpression( return e.value; } case "Number": { - return (e.value === 0 && !Object.is(e.value, -0)) - ? boxZero(e.numericType === "int" ? "int" : "float") - : e.value; + if (e.value === 0 && !isNegativeZero(e.value)) { + return e.numericType === "int" ? 0 : boxZero("float"); + } + if (e.numericType === "float" && Number.isInteger(e.value)) { + return floatLiteral(e.value); + } + return e.value; } case "Boolean": { return e.value; @@ -390,6 +442,19 @@ export function evalExpression( } case "Unary": { const u = asUnary(e); + if (u.operator === "-" && u.argument.type === "Number") { + const num = u.argument; + if (num.value === 0 && !isNegativeZero(num.value)) { + if (num.numericType === "float") { + return -0; + } else { + return 0; + } + } + return num.numericType === "float" && Number.isInteger(-num.value) + ? floatLiteral(-num.value) + : -num.value; + } const value = evalExpression(u.argument, env, sf); const apply = (value: LuaValue) => { @@ -855,52 +920,51 @@ const operatorsMetaMethods: Record { const { ax, bx, bothInt } = coerceNumericPair(a, b, hints); - const r = ax + bx; - - if (r === 0) { - if (Object.is(r, -0)) { - return bothInt ? boxZero("int") : -0; - } - return boxZero(bothInt ? "int" : "float"); - } - return r; + return normalizeArithmeticResult(ax + bx, bothInt); }, }, "-": { metaMethod: "__sub", nativeImplementation: (a, b, _ctx, _sf, hints) => { - const { ax, bx, bothInt } = coerceNumericPair(a, b, hints); - const r = ax - bx; + const { ax, bx, bothInt, bZeroKind } = coerceNumericPair( + a, + b, + hints, + ); + const result = ax - bx; - if (r === 0) { - if (Object.is(r, -0)) { - return bothInt ? boxZero("int") : -0; + if (result === 0 && isNegativeZero(result) && !bothInt) { + const rightIsIntZero = bx === 0 && + (bZeroKind === undefined || bZeroKind === "int"); + if (rightIsIntZero) { + return boxZero("float"); // Normalize to +0.0 } - return boxZero(bothInt ? "int" : "float"); } - return r; + + return normalizeArithmeticResult(result, bothInt); }, }, "*": { metaMethod: "__mul", nativeImplementation: (a, b, _ctx, _sf, hints) => { const { ax, bx, bothInt } = coerceNumericPair(a, b, hints); - const r = ax * bx; - - if (r === 0) { - if (Object.is(r, -0)) { - return bothInt ? boxZero("int") : -0; - } - return boxZero(bothInt ? "int" : "float"); - } - return r; + return normalizeArithmeticResult(ax * bx, bothInt); }, }, "/": { metaMethod: "__div", nativeImplementation: (a, b, _ctx, _sf, hints) => { - const { ax, bx } = coerceNumericPair(a, b, hints); - return ax / bx; + const { ax, bx, bothInt, bZeroKind } = coerceNumericPair(a, b, hints); + + // In integer division mode, or if divisor is an integer zero, + // normalize -0 to +0. + let divisor = bx; + if ((bothInt || bZeroKind === "int") && isNegativeZero(bx)) { + divisor = 0; + } + + const result = ax / divisor; + return normalizeArithmeticResult(result, false); }, }, "//": { @@ -917,7 +981,10 @@ const operatorsMetaMethods: Record { const { ax, bx } = coerceNumericPair(a, b, hints); - return ax ** bx; + const result = ax ** bx; + + // Always treat as potentially float + return normalizeArithmeticResult(result, false); }, }, "&": { @@ -1775,15 +1842,66 @@ export function evalStatement( const endV = evalExpression(fr.end, env, sf); const stepV = fr.step ? evalExpression(fr.step, env, sf) : 1; - const runAsync = async (start: any, end: any, step: any) => { - for ( - let i = start; - step > 0 ? i <= end : i >= end; - i += step - ) { - const localEnv = new LuaEnv(env); - localEnv.setLocal(fr.name, i); - const r = evalStatement(fr.block, localEnv, sf, returnOnReturn); + const classifyMode = ( + start: any, + _end: any, + step: any, + ): "int" | "float" => { + const s0 = untagNumber(start); + const st0 = untagNumber(step); + + if (!Number.isInteger(s0) || !Number.isInteger(st0)) { + return "float"; + } + + const startKind = getNumericKind(start); + const stepKind = getNumericKind(step); + + if (startKind === "float" || stepKind === "float") { + return "float"; + } + + return "int"; + }; + + const wrapLoopVar = (i: number, mode: "int" | "float") => { + if (mode === "float") { + return floatLiteral(i); + } + return i; + }; + + // Extract the common iteration logic + const executeIteration = ( + i: number, + mode: "int" | "float", + ): void | ControlSignal | Promise => { + const localEnv = new LuaEnv(env); + localEnv.setLocal(fr.name, wrapLoopVar(i, mode)); + return evalStatement(fr.block, localEnv, sf, returnOnReturn); + }; + + const runAsync = async ( + _start: any, + end: any, + step: any, + mode: "int" | "float", + startIndex: number, + ) => { + const e0 = untagNumber(end); + const st0 = untagNumber(step); + + if (st0 === 0) { + throw new LuaRuntimeError("'for' step is zero", sf.withCtx(fr.ctx)); + } + + // Condition changes based on direction + const shouldContinue = st0 > 0 + ? (i: number) => i <= e0 + : (i: number) => i >= e0; + + for (let i = startIndex; shouldContinue(i); i += st0) { + const r = executeIteration(i, mode); const res = isPromise(r) ? await r : r; if (res !== undefined) { if (isBreakSignal(res)) { @@ -1799,18 +1917,23 @@ export function evalStatement( start: any, end: any, step: any, - ): - | void - | ControlSignal - | Promise => { - for ( - let i = start; - step > 0 ? i <= end : i >= end; - i += step - ) { - const localEnv = new LuaEnv(env); - localEnv.setLocal(fr.name, i); - const r = evalStatement(fr.block, localEnv, sf, returnOnReturn); + mode: "int" | "float", + ): void | ControlSignal | Promise => { + const s0 = untagNumber(start); + const e0 = untagNumber(end); + const st0 = untagNumber(step); + + if (st0 === 0) { + throw new LuaRuntimeError("'for' step is zero", sf.withCtx(fr.ctx)); + } + + // Condition changes based on direction + const shouldContinue = st0 > 0 + ? (i: number) => i <= e0 + : (i: number) => i >= e0; + + for (let i = s0; shouldContinue(i); i += st0) { + const r = executeIteration(i, mode); if (isPromise(r)) { return (r as Promise).then((res) => { if (res !== undefined) { @@ -1819,7 +1942,7 @@ export function evalStatement( } return res; } - return runAsync(i + step, end, step); + return runAsync(start, end, step, mode, i + st0); }); } else if (r !== undefined) { if (isBreakSignal(r)) { @@ -1830,19 +1953,22 @@ export function evalStatement( } return; }; - if ( !isPromise(startV) && !isPromise(endV) && !isPromise(stepV) ) { - return runSyncFirst(startV, endV, (stepV as number) ?? 1); + const mode = classifyMode(startV, endV, stepV); + return runSyncFirst(startV, endV, stepV ?? 1, mode); } else { return Promise.all([ isPromise(startV) ? startV : Promise.resolve(startV), isPromise(endV) ? endV : Promise.resolve(endV), isPromise(stepV) ? stepV : Promise.resolve(stepV), - ]).then(([start, end, step]) => runSyncFirst(start, end, step ?? 1)); + ]).then(([start, end, step]) => { + const mode = classifyMode(start, end, step ?? 1); + return runSyncFirst(start, end, step ?? 1, mode); + }); } } case "ForIn": { @@ -2109,7 +2235,10 @@ function exactInt( ): number { // See conversion from float to integer https://www.lua.org/manual/5.4/manual.html#3.4.3 let n: number; - if (typeof num === "number") { + + if (isFloatTag(num)) { + n = num.value; + } else if (typeof num === "number") { n = num; } else if (num instanceof Number) { n = Number(num); diff --git a/client/space_lua/numeric.ts b/client/space_lua/numeric.ts index 2f54a2e4..8f3e787a 100644 --- a/client/space_lua/numeric.ts +++ b/client/space_lua/numeric.ts @@ -4,8 +4,13 @@ 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 && !Object.is(Number(x), -0); + return x instanceof Number && Number(x) === 0 && !isNegativeZero(Number(x)); } export function getZeroBoxKind(x: any): NumericType | undefined { @@ -20,16 +25,71 @@ export function boxZero(kind: NumericType): number { 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; }; -// Marker error used to let the evaluator decide the final message. export const luaStringCoercionError: Error = new Error( "LuaStringCoercionError", ); +export function isNegativeZero(x: number): boolean { + // faster than `Object.is(, -0)` + return x === 0 && 1 / x === -Infinity; +} + +function produceNegativeZero() { + return { n: -0, isInt: false, zeroKind: "float" as const }; +} + export function coerceNumeric( val: unknown, ): { @@ -37,6 +97,18 @@ export function coerceNumeric( 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)! }; } @@ -44,14 +116,9 @@ export function coerceNumeric( if (typeof val === "number") { const n = val; if (n === 0) { - if (Object.is(n, -0)) { - return { - n, - isInt: true, - zeroKind: "float", - }; + if (isNegativeZero(n)) { + return produceNegativeZero(); } - return { n, isInt: true, @@ -65,7 +132,7 @@ export function coerceNumeric( if (val instanceof Number) { const n = Number(val); - if (n === 0 && !Object.is(n, -0)) { + if (n === 0 && !isNegativeZero(n)) { return { n, isInt: true, @@ -81,19 +148,15 @@ export function coerceNumeric( if (typeof val === "string") { const det = luaToNumberDetailed(val); if (!det) { - // Let evaluator produce the final error message throw luaStringCoercionError; } const n = det.value; const isInt = det.numericType === "int"; + if (n === 0) { - if (Object.is(n, -0)) { - return { - n, - isInt, - zeroKind: "float", - }; + if (isNegativeZero(n)) { + return produceNegativeZero(); } return { n, @@ -127,13 +190,17 @@ export function coerceNumericPair( const A = coerceNumeric(a); const B = coerceNumeric(b); - const aIsInt = hints?.leftKind + const aIsInt = hints?.leftKind !== undefined ? hints.leftKind === "int" - : (A.zeroKind ? A.zeroKind === "int" : A.isInt); + : (A.zeroKind === "float" + ? false + : (A.zeroKind === "int" ? true : A.isInt)); - const bIsInt = hints?.rightKind + const bIsInt = hints?.rightKind !== undefined ? hints.rightKind === "int" - : (B.zeroKind ? B.zeroKind === "int" : B.isInt); + : (B.zeroKind === "float" + ? false + : (B.zeroKind === "int" ? true : B.isInt)); return { ax: A.n, diff --git a/client/space_lua/runtime.ts b/client/space_lua/runtime.ts index 48f37129..862bd037 100644 --- a/client/space_lua/runtime.ts +++ b/client/space_lua/runtime.ts @@ -2,6 +2,7 @@ import type { ASTCtx, LuaFunctionBody } from "./ast.ts"; import { evalStatement } from "./eval.ts"; import { asyncQuickSort } from "./util.ts"; import { isPromise, rpAll } from "./rp.ts"; +import { isFloatTag, isNegativeZero } from "./numeric.ts"; export type LuaType = | "nil" @@ -65,6 +66,10 @@ type LuaThreadState = { }; export function luaTypeName(val: any): LuaType { + if (isFloatTag(val)) { + return "number"; + } + const t = luaTypeOf(val); if (typeof t === "string") { @@ -576,12 +581,49 @@ export class LuaTable implements ILuaSettable, ILuaGettable { this.metatable = null; } - private static isIntegerKey(key: any): boolean { - const k = key instanceof Number ? Number(key) : key; - return typeof k === "number" && Number.isInteger(k) && k >= 1; + private static numKeyValue(key: any): number | null { + if (isFloatTag(key)) { + return key.value; + } + if (key instanceof Number) { + return Number(key); + } + if (typeof key === "number") { + return key; + } + return null; } + + // Normalize numeric keys for table indexing: + // * negative zero becomes positive zero, + // * integer-valued floats become plain integers, + // * non-integer floats stay as-is. + private static normalizeNumericKey(key: any): any { + const numVal = LuaTable.numKeyValue(key); + if (numVal !== null) { + // Normalize -0 to +0 + if (isNegativeZero(numVal)) { + return 0; + } + // Integer-valued numbers normalize to integers + if (Number.isInteger(numVal)) { + return numVal; + } + // Non-integer floats + return numVal; + } + return key; + } + + private static isIntegerKey(key: any): boolean { + const norm = LuaTable.normalizeNumericKey(key); + return typeof norm === "number" && Number.isInteger(norm) && norm >= 1; + } + private static toIndex(key: any): number { - return (key instanceof Number ? Number(key) : key) - 1; + const norm = LuaTable.normalizeNumericKey(key); + const k = typeof norm === "number" ? norm : (norm as number); + return k - 1; } get length(): number { @@ -622,12 +664,14 @@ export class LuaTable implements ILuaSettable, ILuaGettable { } has(key: LuaValue) { - if (typeof key === "string") { - return this.stringKeys[key] !== undefined; - } else if (LuaTable.isIntegerKey(key)) { - return this.arrayPart[LuaTable.toIndex(key)] !== undefined; + const normalizedKey = LuaTable.normalizeNumericKey(key); + + if (typeof normalizedKey === "string") { + return this.stringKeys[normalizedKey] !== undefined; + } else if (LuaTable.isIntegerKey(normalizedKey)) { + return this.arrayPart[LuaTable.toIndex(normalizedKey)] !== undefined; } else if (this.otherKeys) { - return this.otherKeys.has(key); + return this.otherKeys.has(normalizedKey); } return false; } @@ -639,25 +683,28 @@ export class LuaTable implements ILuaSettable, ILuaGettable { if (isPromise(value)) { return value.then((v) => this.rawSet(key, v)); } - if (typeof key === "string") { + + const normalizedKey = LuaTable.normalizeNumericKey(key); + + if (typeof normalizedKey === "string") { if (value === null || value === undefined) { - delete this.stringKeys[key]; + delete this.stringKeys[normalizedKey]; } else { - this.stringKeys[key] = value; + this.stringKeys[normalizedKey] = value; } return; } - if (LuaTable.isIntegerKey(key)) { - this.arrayPart[LuaTable.toIndex(key)] = value; + if (LuaTable.isIntegerKey(normalizedKey)) { + this.arrayPart[LuaTable.toIndex(normalizedKey)] = value; return; } if (!this.otherKeys) { this.otherKeys = new Map(); } if (value === null || value === undefined) { - this.otherKeys.delete(key); + this.otherKeys.delete(normalizedKey); } else { - this.otherKeys.set(key, value); + this.otherKeys.set(normalizedKey, value); } } @@ -704,12 +751,14 @@ export class LuaTable implements ILuaSettable, ILuaGettable { } rawGet(key: LuaValue): LuaValue | null { - if (typeof key === "string") { - return this.stringKeys[key]; - } else if (LuaTable.isIntegerKey(key)) { - return this.arrayPart[LuaTable.toIndex(key)]; + const normalizedKey = LuaTable.normalizeNumericKey(key); + + if (typeof normalizedKey === "string") { + return this.stringKeys[normalizedKey]; + } else if (LuaTable.isIntegerKey(normalizedKey)) { + return this.arrayPart[LuaTable.toIndex(normalizedKey)]; } else if (this.otherKeys) { - return this.otherKeys.get(key); + return this.otherKeys.get(normalizedKey); } } @@ -1016,12 +1065,18 @@ export function luaCall( } export function luaEquals(a: any, b: any): boolean { - const an = a instanceof Number ? Number(a) : a; - const bn = b instanceof Number ? Number(b) : b; - if ((typeof an === "number") && (typeof bn === "number")) { - return an === bn; + // Unwrap Number objects + let an = a instanceof Number ? Number(a) : a; + let bn = b instanceof Number ? Number(b) : b; + + // Unwrap LuaFloatTag objects + if (an && typeof an === "object" && "type" in an) { + an = an.value; } - return a === b; + if (bn && typeof bn === "object" && "type" in bn) { + bn = bn.value; + } + return an === bn; } export function luaKeys(val: any): any[] { @@ -1044,6 +1099,8 @@ export function luaTypeOf(val: any): LuaType | Promise { return "boolean"; } else if (typeof val === "number" || val instanceof Number) { return "number"; + } else if (isFloatTag(val)) { + return "number"; } else if (typeof val === "string") { return "string"; } else if (val instanceof LuaTable) { @@ -1143,6 +1200,27 @@ export function luaToString( if (isPromise(value)) { return (value as Promise).then((v) => luaToString(v, visited)); } + + if (value && typeof value === "object" && value.type === "float") { + return luaFormatNumber(value.value, "float"); + } + + if (value instanceof Number) { + const numVal = Number(value); + const symbols = Object.getOwnPropertySymbols(value); + for (const sym of symbols) { + const kind = (value as any)[sym]; + if (kind === "int" || kind === "float") { + return luaFormatNumber(numVal, kind); + } + } + return luaFormatNumber(numVal); + } + + if (typeof value === "number") { + return luaFormatNumber(value); + } + // Check for circular references if (typeof value === "object" && visited.has(value)) { return ""; @@ -1206,6 +1284,39 @@ export function luaToString( return String(value); } +// Format a number according to Lua semantics: +// * integers: plain format (e.g., `42`); +// * floats: with decimal point (e.g., `1.0`); +// * special values: `inf`, `-inf`, `nan`, `-nan`; +// * negative zero: `-0.0`. +function luaFormatNumber(n: number, kind?: "int" | "float"): string { + if (!isFinite(n)) { + if (isNaN(n)) { + // NaN in Lua always formats as `-nan` + return "-nan"; + } + return n < 0 ? "-inf" : "inf"; + } + + if (n === 0 && isNegativeZero(n)) { + return "-0.0"; + } + + if (n === 0 && kind === "float") { + return "0.0"; + } + + if (kind === "float" && Number.isInteger(n)) { + return n.toString() + ".0"; + } + + if (Number.isInteger(n)) { + return String(n); + } + + return String(n); +} + export function getMetatable( value: LuaValue, sf?: LuaStackFrame, diff --git a/client/space_lua/stdlib.ts b/client/space_lua/stdlib.ts index 6d5454a4..27b9d511 100644 --- a/client/space_lua/stdlib.ts +++ b/client/space_lua/stdlib.ts @@ -27,10 +27,11 @@ import { mathApi } from "./stdlib/math.ts"; import { parse } from "./parse.ts"; import { evalStatement } from "./eval.ts"; import { encodingApi } from "./stdlib/encoding.ts"; -import { luaToNumber } from "./tonumber.ts"; +import { luaToNumberDetailed } from "./tonumber.ts"; import { luaLoad } from "./stdlib/load.ts"; import { cryptoApi } from "./stdlib/crypto.ts"; import { netApi } from "./stdlib/net.ts"; +import { floatLiteral } from "./numeric.ts"; const printFunction = new LuaBuiltinFunction(async (_sf, ...args) => { console.log( @@ -148,7 +149,25 @@ const tonumberFunction = new LuaBuiltinFunction( ); } } - return luaToNumber(value, base); + + if (typeof value === "number" || value instanceof Number) { + return value; + } + + if (typeof value !== "string") { + return null; + } + + const result = luaToNumberDetailed(value, base); + if (result === null) { + return null; + } + + if (result.numericType === "float") { + return floatLiteral(result.value); + } + + return result.value; }, ); diff --git a/client/space_lua/stdlib/math.ts b/client/space_lua/stdlib/math.ts index 3d1f35e3..533ffa27 100644 --- a/client/space_lua/stdlib/math.ts +++ b/client/space_lua/stdlib/math.ts @@ -1,4 +1,5 @@ import { LuaBuiltinFunction, LuaRuntimeError, LuaTable } from "../runtime.ts"; +import { getZeroBoxKind, isFloatTag, isNegativeZero } from "../numeric.ts"; export const mathApi = new LuaTable({ // math constants @@ -14,8 +15,34 @@ export const mathApi = new LuaTable({ ); } - if (typeof x === "number" || x instanceof Number) { + if (isFloatTag(x)) { + return "float"; + } + + if (x instanceof Number) { + const kind = getZeroBoxKind(x); + if (kind === "float") { + return "float"; + } + if (kind === "int") { + return "integer"; + } + const n = Number(x); + if (!Number.isFinite(n)) { + return "float"; + } + + return Number.isInteger(n) ? "integer" : "float"; + } + + if (typeof x === "number") { + const n = Number(x); + + // Negative zero is a float + if (n === 0 && isNegativeZero(n)) { + return "float"; + } // NaN and +Inf/-Inf are floats if (!Number.isFinite(n)) {