From 27339c98f6a8ef756eba6995d7e90e02fa99adfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Jan=20Fialka?= Date: Thu, 16 Oct 2025 08:41:47 +0200 Subject: [PATCH] Space Lua: Align arithmetic model with standard Lua (#1611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Align Space Lua arithmetic model with standard Lua * Add missing __unm and __bnot metamethods - add new basic tests - add helper for negative tests * Add comments for ASCII numeric constants Signed-off-by: Matouš Jan Fialka --- client/space_lua/arithmetic_test.lua | 602 ++++++++++++++++++++++---- client/space_lua/eval.ts | 618 +++++++++++++++++++++------ client/space_lua/numeric.ts | 136 ++++++ client/space_lua/runtime.ts | 49 ++- client/space_lua/tonumber.ts | 121 +++--- plug-api/syscalls.ts | 2 +- plugs/editor/page.ts | 4 +- 7 files changed, 1253 insertions(+), 279 deletions(-) create mode 100644 client/space_lua/numeric.ts diff --git a/client/space_lua/arithmetic_test.lua b/client/space_lua/arithmetic_test.lua index c4b8591c..33edcae7 100644 --- a/client/space_lua/arithmetic_test.lua +++ b/client/space_lua/arithmetic_test.lua @@ -1,91 +1,549 @@ -local function assert_eq(actual, expected, msg) +local function assert_eq(actual, expected, message) if actual ~= expected then - error('assert_eq failed: ' .. msg) + error('Assertion failed: ' .. message) end end --- Integer-only (0 and -0 as integers) --- Expectation: -0 is integer 0, so 1/-0 == +Inf, -1/-0 == +Inf -assert_eq( 1/ 0 == 1/ 0, true, 'int: 1/ 0 == 1/ 0') -assert_eq( 1/ 0 == -1/ 0, false, 'int: 1/ 0 == -1/ 0') -assert_eq( 1/ 0 == 1/-0, true, 'int: 1/ 0 == 1/-0') -assert_eq( 1/ 0 == -1/-0, false, 'int: 1/ 0 == -1/-0') +local function assert_throws(msg_substr, fn) + local ok, err = pcall(fn) -assert_eq( -1/ 0 == 1/ 0, false, 'int: -1/ 0 == 1/ 0') -assert_eq( -1/ 0 == -1/ 0, true, 'int: -1/ 0 == -1/ 0') -assert_eq( -1/ 0 == 1/-0, false, 'int: -1/ 0 == 1/-0') -assert_eq( -1/ 0 == -1/-0, true, 'int: -1/ 0 == -1/-0') + if ok then + error('Assertion failed: expected error containing "' + .. msg_substr .. '"') + end -assert_eq( 1/-0 == 1/ 0, true, 'int: 1/-0 == 1/ 0') -assert_eq( 1/-0 == -1/ 0, false, 'int: 1/-0 == -1/ 0') -assert_eq( 1/-0 == 1/-0, true, 'int: 1/-0 == 1/-0') -assert_eq( 1/-0 == -1/-0, false, 'int: 1/-0 == -1/-0') + if type(err) ~= 'string' then + err = tostring(err) + end -assert_eq( -1/-0 == 1/ 0, false, 'int: -1/-0 == 1/ 0') -assert_eq( -1/-0 == -1/ 0, true, 'int: -1/-0 == -1/0 ') -assert_eq( -1/-0 == 1/-0, false, 'int: -1/-0 == 1/-0') -assert_eq( -1/-0 == -1/-0, true, 'int: -1/-0 == -1/-0') + if not string.find(err, msg_substr, 1, true) then + error('Assertion failed: expected error message to contain "' + .. msg_substr .. '", got: "' .. err .. '"') + end +end --- Float-only (0.0 and -0.0 as floats) --- Expectation: -0.0 is float negative zero, so 1/-0.0 == -Inf, -1/-0.0 == +Inf -assert_eq( 1/ 0.0 == 1/ 0.0, true, 'float: 1/ 0.0 == 1/ 0.0') -assert_eq( 1/ 0.0 == -1/ 0.0, false, 'float: 1/ 0.0 == -1/ 0.0') -assert_eq( 1/ 0.0 == 1/-0.0, false, 'float: 1/ 0.0 == 1/-0.0') -assert_eq( 1/ 0.0 == -1/-0.0, true, 'float: 1/ 0.0 == -1/-0.0') +-- 1. Integer vs float zero divisors -assert_eq( -1/ 0.0 == 1/ 0.0, false, 'float: -1/ 0.0 == 1/ 0.0') -assert_eq( -1/ 0.0 == -1/ 0.0, true, 'float: -1/ 0.0 == -1/ 0.0') -assert_eq( -1/ 0.0 == 1/-0.0, true, 'float: -1/ 0.0 == 1/-0.0') -assert_eq( -1/ 0.0 == -1/-0.0, false, 'float: -1/ 0.0 == -1/-0.0') +-- 1.1. Integer zeros collapse (no -0 for integers) +assert_eq(1/0 == 1/-0, true, 'int: 1/0 == 1/-0 (+Inf)') +assert_eq(-1/0 == 1/-0, false, 'int: -1/0 != 1/-0') -assert_eq( 1/-0.0 == 1/ 0.0, false, 'float: 1/-0.0 == 1/ 0.0') -assert_eq( 1/-0.0 == -1/ 0.0, true, 'float: 1/-0.0 == -1/ 0.0') -assert_eq( 1/-0.0 == 1/-0.0, true, 'float: 1/-0.0 == 1/-0.0') -assert_eq( 1/-0.0 == -1/-0.0, false, 'float: 1/-0.0 == -1/-0.0') +-- 1.2. Float zeros sign preservation +assert_eq(1/0.0 == 1/-0.0, false, 'float: +Inf != -Inf') +assert_eq(1/0.0 == -1/-0.0, true, 'float: 1/0.0 == -1/-0.0') +assert_eq(-1/0.0 == 1/-0.0, true, 'float: -1/0.0 == 1/-0.0') -assert_eq( -1/-0.0 == 1/ 0.0, true, 'float: -1/-0.0 == 1/ 0.0') -assert_eq( -1/-0.0 == -1/ 0.0, false, 'float: -1/-0.0 == -1/ 0.0') -assert_eq( -1/-0.0 == 1/-0.0, false, 'float: -1/-0.0 == 1/-0.0') -assert_eq( -1/-0.0 == -1/-0.0, true, 'float: -1/-0.0 == -1/-0.0') +-- 1.3. Basic division +assert_eq(5/2, 2.5, 'div: 5/2 == 2.5') +assert_eq(-5/2, -2.5, 'div: -5/2 == -2.5') +assert_eq(5/-2, -2.5, 'div: 5/-2 == -2.5') +assert_eq(-5/-2, 2.5, 'div: -5/-2 == 2.5') --- Integer-Float (left int 0/-0; right float 0.0/-0.0) -assert_eq( 1/ 0 == 1/ 0.0, true, 'int-float: 1/0 == 1/ 0.0') -assert_eq( 1/ 0 == -1/ 0.0, false, 'int-float: 1/0 == -1/ 0.0') -assert_eq( 1/ 0 == 1/-0.0, false, 'int-float: 1/0 == 1/-0.0') -assert_eq( 1/ 0 == -1/-0.0, true, 'int-float: 1/0 == -1/-0.0') +-- 2. Unary minus literals and simple expressions +assert_eq(1/-(0) == 1/0, true, 'unary minus: int literal (+Inf)') +assert_eq(1/-(0.0) == -1/0.0, true, 'unary minus: float literal (-Inf)') +assert_eq(1/-(1-1) == 1/0, true, 'unary minus: int expr (+Inf)') +assert_eq(1/-(1.0-1.0) == -1/0.0, true, 'unary minus: float expr (-Inf)') -assert_eq( -1/ 0 == 1/ 0.0, false, 'int-float: -1/0 == 1/ 0.0') -assert_eq( -1/ 0 == -1/ 0.0, true, 'int-float: -1/0 == -1/ 0.0') -assert_eq( -1/ 0 == 1/-0.0, true, 'int-float: -1/0 == 1/-0.0') -assert_eq( -1/ 0 == -1/-0.0, false, 'int-float: -1/0 == -1/-0.0') +-- 2.1. Unary minus coercion and precedence with power +assert_eq(-2^2, -4, 'precedence: -2^2 == -(2^2)') +assert_eq((-2)^2, 4, 'precedence: (-2)^2 == 4') -assert_eq( 1/-0 == 1/ 0.0, true, 'int-float: 1/-0 == 1/ 0.0') -assert_eq( 1/-0 == -1/ 0.0, false, 'int-float: 1/-0 == -1/ 0.0') -assert_eq( 1/-0 == 1/-0.0, false, 'int-float: 1/-0 == 1/-0.0') -assert_eq( 1/-0 == -1/-0.0, true, 'int-float: 1/-0 == -1/-0.0') +-- 3. Integer operations (must not produce -0) +assert_eq(1/(1-1) == 1/0, true, 'int: sub (+0)') +assert_eq(1/(0*-1) == 1/0, true, 'int: mul (+0)') +assert_eq(1/(0%1) == 1/0, true, 'int: mod (+0)') +assert_eq(1/(0%-1) == 1/0, true, 'int: mod neg divisor (+0)') -assert_eq( -1/-0 == 1/ 0.0, false, 'int-float: -1/-0 == 1/ 0.0') -assert_eq( -1/-0 == -1/ 0.0, true, 'int-float: -1/-0 == -1/ 0.0') -assert_eq( -1/-0 == 1/-0.0, true, 'int-float: -1/-0 == 1/-0.0') -assert_eq( -1/-0 == -1/-0.0, false, 'int-float: -1/-0 == -1/-0.0') +-- 4. Float operations (must preserve -0.0) +assert_eq(1/(0.0*-1.0) == -1/ 0.0, true, 'float: mul (-0.0)') +assert_eq(1/((-0.0)%1.0) == -1/ 0.0, true, 'float: mod (-0.0)') +assert_eq(1/((-0.0)%-1.0) == -1/ 0.0, true, 'float: mod neg divisor (-0.0)') --- Float-Integer (left float 0.0/-0.0; right int 0/-0) -assert_eq( 1/ 0.0 == 1/ 0, true, 'float-int: 1/0.0 == 1/ 0') -assert_eq( 1/ 0.0 == -1/ 0, false, 'float-int: 1/0.0 == -1/ 0') -assert_eq( 1/ 0.0 == 1/-0, true, 'float-int: 1/0.0 == 1/-0') -assert_eq( 1/ 0.0 == -1/-0, false, 'float-int: 1/0.0 == -1/-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') -assert_eq( -1/ 0.0 == 1/ 0, false, 'float-int: -1/0.0 == 1/ 0') -assert_eq( -1/ 0.0 == -1/ 0, true, 'float-int: -1/0.0 == -1/ 0') -assert_eq( -1/ 0.0 == 1/-0, false, 'float-int: -1/0.0 == 1/-0') -assert_eq( -1/ 0.0 == -1/-0, true, 'float-int: -1/0.0 == -1/-0') +-- 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)') +assert_eq(1/(1.0+(-1)) == 1/0.0, true, 'mixed: add (+0.0)') +assert_eq(1/(-(1-1.0)) == -1/0.0, true, 'mixed: sub then unary minus (-0.0)') -assert_eq( 1/-0.0 == 1/ 0, false, 'float-int: 1/-0.0 == 1/ 0') -assert_eq( 1/-0.0 == -1/ 0, true, 'float-int: 1/-0.0 == -1/ 0') -assert_eq( 1/-0.0 == 1/-0, false, 'float-int: 1/-0.0 == 1/-0') -assert_eq( 1/-0.0 == -1/-0, true, 'float-int: 1/-0.0 == -1/-0') +-- 6. Variables +local zi, zf, zfn = 0, 0.0, -0.0 -assert_eq( -1/-0.0 == 1/ 0, true, 'float-int: -1/-0.0 == 1/ 0') -assert_eq( -1/-0.0 == -1/ 0, false, 'float-int: -1/-0.0 == -1/ 0') -assert_eq( -1/-0.0 == 1/-0, true, 'float-int: -1/-0.0 == 1/-0') -assert_eq( -1/-0.0 == -1/-0, false, 'float-int: -1/-0.0 == -1/-0') +assert_eq(1/zi == 1/0, true, 'var: zi (+Inf)') +assert_eq(1/zf == 1/0.0, true, 'var: zf (+Inf)') +assert_eq(1/zfn == -1/0.0, true, 'var: zfn (-Inf)') +assert_eq(1/-(zi) == 1/0, true, 'var: unary minus zi (+Inf)') +assert_eq(1/-(zfn) == 1/0.0, true, 'var: unary minus zfn (+Inf)') + +-- 7. Functions returning zeros and unary minus +local function ret_zi() + return 0 +end + +local function ret_zf() + return 0.0 +end + +local function ret_zfn() + return -0.0 +end + +assert_eq(1/ret_zi() == 1/0, true, 'fn: ret_zi (+Inf)') +assert_eq(1/ret_zf() == 1/0.0, true, 'fn: ret_zf (+Inf)') +assert_eq(1/ret_zfn() == -1/0.0, true, 'fn: ret_zfn (-Inf)') + +assert_eq(1/-(ret_zi()) == 1/0, true, 'fn unary minus: ret_zi (+Inf)') +assert_eq(1/-(ret_zf()) == -1/0.0, true, 'fn unary minus: ret_zf (-Inf)') +assert_eq(1/-(ret_zfn()) == 1/0.0, true, 'fn unary minus: ret_zfn (+Inf)') + +-- 8. Tables and arrays +local t, arr = {zi=zi, zfn=zfn}, {zi, zfn} + +-- 8.1. Tables +assert_eq(1/t.zi == 1/0, true, 'table: t.zi (+Inf)') +assert_eq(1/t.zfn == -1/0.0, true, 'table: t.zfn (-Inf)') + +-- 8.2. Arrays +assert_eq(1/arr[1] == 1/0, true, 'array: arr[1]=zi (+Inf)') +assert_eq(1/arr[2] == -1/0.0, true, 'array: arr[2]=zfn (-Inf)') + +-- 9. Deeply nested parentheses and expressions +local xi, xf = 1-1, 1.0-1.0 +local deepi = -((((0+0)-(1-1))+(zi-xi))) -- int path (+0) +local deepf = -((((0.0+0.0)-(1.0-1.0))+(zf-xf))) -- float path (-0.0) + +assert_eq(1/deepi == 1/0, true, 'nested: int (+Inf)') +assert_eq(1/deepf == -1/0.0, true, 'nested: float (-Inf)') + +-- 10. Floor division near zero +assert_eq(1/(0//1) == 1/0, true, 'floor div: int (+0)') +assert_eq(1/(0.0//1.0) == 1/0.0, true, 'floor div: float (+Inf)') +assert_eq(1/((-0.0)//1.0) == -1/0.0, true, 'floor div: float (-Inf)') +assert_eq(1/(0//1.0) == 1/0.0, true, 'floor div: mixed (+0.0)') + +-- 10.1. Modulo/division identity +local function id_ok(a, b) + return a == b * (a // b) + a % b +end + +assert_eq(id_ok(5, 2), true, 'identity: 5, 2') +assert_eq(id_ok(-5, 2), true, 'identity: -5, 2') +assert_eq(id_ok(5, -2), true, 'identity: 5, -2') +assert_eq(id_ok(-5, -2), true, 'identity: -5, -2') + +-- 10.2. Floor division signs +assert_eq(5//-2, -3, 'idiv: 5//-2 == -3') +assert_eq(-5//2, -3, 'idiv: -5//2 == -3') +assert_eq(-5//-2, 2, 'idiv: -5//-2 == 2') + +-- 11. Ordering and NaN +assert_eq((-0.0) < (0.0), false, 'ordering: -0.0 < 0.0 is false') +assert_eq((0.0) < (-0.0), false, 'ordering: 0.0 < -0.0 is false') +assert_eq((-0.0) <= (0.0), true, 'ordering: -0.0 <= 0.0') +assert_eq((0.0) <= (-0.0), true, 'ordering: 0.0 <= -0.0') +assert_eq((0/0) == (0/0), false, 'NaN: never equals itself') + +-- 12. Bitwise operators +assert_eq((~0) == -1, true, 'bitwise not on int ok') + +val = pcall( + function() + return ~0.0 + end +) +assert_eq(val, true, 'bitwise not on float ok') + +val = pcall( + function() + return 0<<1 + end +) +assert_eq(val, true, 'shl int ok') + +val = pcall( + function() + return 0.0<<1 + end +) +assert_eq(val, true, 'shl float ok') + +-- 12.1 Bitwise ops results +assert_eq((5&3) == 1, true, 'bitwise and result') +assert_eq((5|2) == 7, true, 'bitwise or result') +assert_eq((5~1) == 4, true, 'bitwise xor result') +assert_eq((1<<5) == 32, true, 'bitwise shl result') +assert_eq((32>>5) == 1, true, 'bitwise shr result') + +-- 12.2 Bitwise with float values +assert_eq((~(-0.0)) == -1, true, 'bitwise not on -0.0 == -1') + +-- 13. Evaluation order (left-to-right) for binary ops +local log, val + +-- arithmetic + - * / +local function lhs_num() + log[#log + 1] = 'L' + return 1 +end + +local function rhs_num() + log[#log + 1] = 'R' + return 2 +end + +log = {} +val = lhs_num()+rhs_num() +assert_eq(table.concat(log, ''), 'LR', 'order: +') + +log = {} +val = lhs_num()-rhs_num() +assert_eq(table.concat(log, ''), 'LR', 'order: -') + +log = {} +val = lhs_num()*rhs_num() +assert_eq(table.concat(log, ''), 'LR', 'order: *') + +log = {} +val = lhs_num()/rhs_num() +assert_eq(table.concat(log, ''), 'LR', 'order: /') + +-- floor div and mod +log = {} +val = lhs_num()//rhs_num() +assert_eq(table.concat(log, ''), 'LR', 'order: //') + +log = {} +val = lhs_num()%rhs_num() +assert_eq(table.concat(log, ''), 'LR', 'order: %') + +-- power and concatenation +log = {} +val = lhs_num()^rhs_num() +assert_eq(table.concat(log, ''), 'LR', 'order: ^') + +local function lhs_str() + log[#log + 1] = 'L' + return 'a' +end + +local function rhs_str() + log[#log + 1] = 'R' + return 'b' +end + +log = {} +val = lhs_str()..rhs_str() +assert_eq(table.concat(log, ''), 'LR', 'order: ..') + +-- relational +log = {} +val = (lhs_num() < rhs_num()) +assert_eq(table.concat(log, ''), 'LR', 'order: <') + +log = {} +val = (lhs_num() <= rhs_num()) +assert_eq(table.concat(log, ''), 'LR', 'order: <=') + +log = {} +val = (lhs_num() > rhs_num()) +assert_eq(table.concat(log, ''), 'LR', 'order: >') + +log = {} +val = (lhs_num() >= rhs_num()) +assert_eq(table.concat(log, ''), 'LR', 'order: >=') + +log = {} +val = (lhs_num() == rhs_num()) +assert_eq(table.concat(log, ''), 'LR', 'order: ==') + +log = {} +val = (lhs_num() ~= rhs_num()) +assert_eq(table.concat(log, ''), 'LR', 'order: ~=') + +-- bitwise +log = {} +val = lhs_num()&rhs_num() +assert_eq(table.concat(log, ''), 'LR', 'order: &') + +log = {} +val = lhs_num()|rhs_num() +assert_eq(table.concat(log, ''), 'LR', 'order: |') + +log = {} +val = lhs_num()~rhs_num() +assert_eq(table.concat(log, ''), 'LR', 'order: ~ (xor)') + +log = {} +val = lhs_num()<>rhs_num() +assert_eq(table.concat(log, ''), 'LR', 'order: >>') + +-- 13.2. Nested expressions and associativity +local function exp_a() + log[#log + 1] = 'A' + return 2 +end + +local function exp_b() + log[#log + 1] = 'B' + return 3 +end + +local function exp_c() + log[#log + 1] = 'C' + return 2 +end + +log = {} +val = exp_a() ^ (exp_b() ^ exp_c()) +assert_eq(val, 512, 'nested expression result') +assert_eq(table.concat(log, ''), 'ABC', 'nested expression associativity') + +assert_eq(2^3^2, 512, 'power associativity') + +-- 14. String-to-number coercion around zero +assert_eq(1/('0') == 1/0.0, true, 'str: ("0") (+Inf)') +assert_eq(1/('-0') == 1/0.0, true, 'str: ("-0") (+Inf)') +assert_eq(1/('0.0') == 1/0.0, true, 'str: ("0.0") (+Inf)') +assert_eq(1/('-0.0') == -1/0.0, true, 'str: ("-0.0") (-Inf)') +assert_eq(1/-('0') == 1/0.0, true, 'str: unary minus -("0") (+Inf)') +assert_eq(1/-('-0') == 1/0.0, true, 'str: unary minus -("-0") (+Inf)') + +-- 14.1. General arithmetic with numeric strings +assert_eq('1'+2, 3, 'str-num: "1"+2 == 3') +assert_eq(' -2 ' * '3', -6, 'str-num: " -2 " * "3" == -6') +assert_eq('0x10' + 1, 17, 'str-num: hex int string + 1 == 17') +assert_eq('0x1p4' + 0, 16, 'str-num: hex float string + 0 == 16') +assert_throws('attempt to perform arithmetic on a non-number', + function() + return 'x1'+1 + end +) + +-- 15. Recursive function producing int zero (and unary minus) +local function rec_zero(n) + if n == 0 then + return 0 + end + return -rec_zero(n - 1) +end + +assert_eq(1/rec_zero(5) == 1/0, true, 'recursive: rec_zero (+Inf)') +assert_eq(1/(rec_zero(5)) == 1/0, true, 'recursive: (rec_zero) (+Inf)') +assert_eq(1/-(rec_zero(5)) == 1/0, true, 'recursive: -(rec_zero) (+Inf)') +assert_eq(1/-rec_zero(5) == 1/0, true, 'recursive: -(rec_zero) (+Inf)') + +-- 16. Modulo and integer division by zero + +-- 16.1. Modulo by zero +assert_throws('modulo by zero', + function() + return 1%0 + end +) + +val = pcall( + function() + return 1.0%0.0 + end +) +assert_eq(val, true, 'float mod by zero ok (NaN)') + +val = pcall( + function() + return 1.0%0 + end +) +assert_eq(val, true, 'mixed (float,int) mod by zero ok (NaN)') + +val = pcall( + function() + return 1%0.0 + end +) +assert_eq(val, true, 'mixed (int,float) mod by zero ok (NaN)') + +-- 16.2. Integer division by zero +assert_throws('divide by zero', + function() + return 1//0 + end +) + +val = pcall( + function() + return 1.0//0.0 + end +) +assert_eq(val, true, 'float idiv by zero ok (+Inf/-Inf)') + +val = pcall( + function() + return 1.0//0 + end +) +assert_eq(val, true, 'mixed (float,int) idiv by zero ok (+Inf/-Inf)') + +val = pcall( + function() + return 1//0.0 + end +) +assert_eq(val, true, 'mixed (int,float) idiv by zero ok (+Inf/-Inf)') + +-- 16.3. Modulo sign semantics (explicit) +assert_eq(5 % -2, -1, 'mod: 5 % -2 == -1') +assert_eq(-5 % 2, 1, 'mod: -5 % 2 == 1') +assert_eq(-5 % -2, -1, 'mod: -5 % -2 == -1') + +-- 17. Metamethod precedence: __add should dispatch +local mt = { + __add = function(_, _) + return 'added' + end +} +local tbl = setmetatable({}, mt) + +assert_eq(tbl + tbl, 'added', '__add dispatched') + +-- 17.1 Unary metamethod: __unm (negation) +local mt_unm = { + __unm = function(_) + return 'negated' + end +} +local u = setmetatable({}, mt_unm) + +assert_eq(-u, 'negated', '__unm dispatched') + +-- 17.2 Unary metamethod: __bnot (bitwise NOT) +local bnot_calls = 0 +local mt_bnot = { + __bnot = function(_) + bnot_calls = bnot_calls + 1 + return 123 + end +} +local b = setmetatable({}, mt_bnot) + +assert_eq(~b, 123, '__bnot dispatched') +assert_eq(bnot_calls, 1, '__bnot called exactly once') + +-- 17.3. Unary metamethods multi-return (first return only) +local mt_unm_mr = { + __unm = function(_) + return 7, 8 + end +} +local um = setmetatable({}, mt_unm_mr) + +assert_eq(-um, 7, '__unm uses first return value') + +local mt_bnot_mr = { + __bnot = function(_) + return 9, 10 + end +} +local bm = setmetatable({}, mt_bnot_mr) + +assert_eq(~bm, 9, '__bnot uses first return value') + +-- 18. Multi-return in arithmetic (first return only) +local function multi_ret() + return 0, 1 +end + +assert_eq(1/(multi_ret()) == 1/0, true, 'multi-ret: 1st used (+Inf)') +assert_eq(1/-(multi_ret()) == 1/0, true, 'multi-ret: unary minus 1st used (+Inf)') + +-- 18.1 Exponentiation zero edge cases +assert_eq(0^0 == 1, true, 'pow: 0^0 == 1') +assert_eq((-0.0)^0 == 1, true, 'pow: (-0.0)^0 == 1') + +-- 19. Error tests +assert_throws('has no integer representation', + function() + return ~0.5 + end +) + +assert_throws('attempt to perform arithmetic on a non-number', + function() + return 'a'+1 + end +) + +assert_throws('attempt to perform arithmetic on a non-number', + function() + return -{} + end +) + +-- 19.1. Bitwise on non-integers should error +assert_throws('has no integer representation', + function() + return 1.5&1 + end +) + +assert_throws('attempt to perform arithmetic on a non-number', + function() + return '3'|1 + end +) + +assert_throws('has no integer representation', + function() + return 1~1.2 + end +) + +assert_throws('has no integer representation', + function() + return 1<<0.1 + end +) + +-- 19.2. Relational type error +assert_throws('attempt to compare number with string', + function() + return 1<'1' + end +) + +-- 19.3. Additional negative tests +assert_throws('attempt to perform arithmetic on a non-number', + function() + return 1+{} + end +) + +assert_throws('attempt to perform arithmetic on a non-number', + function() + return -'x' + end +) + +assert_throws('attempt to perform arithmetic on a non-number', + function() + return ~'1' + end +) + +assert_throws('attempt to compare string with number', + function() + return '1'<1 + end +) + +assert_throws('attempt to compare number with object', + function() + return 1<{} + end +) diff --git a/client/space_lua/eval.ts b/client/space_lua/eval.ts index da94849d..b600b6c1 100644 --- a/client/space_lua/eval.ts +++ b/client/space_lua/eval.ts @@ -1,4 +1,10 @@ -import type { ASTCtx, LuaExpression, LuaLValue, LuaStatement } from "./ast.ts"; +import type { + ASTCtx, + LuaExpression, + LuaLValue, + LuaStatement, + NumericType, +} from "./ast.ts"; import { evalPromiseValues } from "./util.ts"; import { type ILuaFunction, @@ -30,18 +36,126 @@ import { ArrayQueryCollection, type LuaCollectionQuery, } from "./query_collection.ts"; -import { luaToNumber } from "./tonumber.ts"; +import { + boxZero, + coerceNumeric, + coerceNumericPair, + type OpHints, +} from "./numeric.ts"; -// Wrapper for arithmetical operators -function luaCoerceToNumber(val: unknown): number { - if (typeof val === "number") return val; - if (typeof val === "string") { - const n = luaToNumber(val); - if (n !== null) { - return n; - } +function luaFloorDiv( + a: unknown, + b: unknown, + ctx: ASTCtx, + sf: LuaStackFrame, + hints?: OpHints, +): number { + const { ax, bx, bothInt } = coerceNumericPair(a, b, hints); + if (bothInt && bx === 0) { + throw new LuaRuntimeError( + `attempt to divide by zero`, + sf.withCtx(ctx), + ); } - throw new Error(`attempt to perform arithmetic on a non-number`); + + const q = Math.floor(ax / bx); + if (q === 0) { + if (bothInt) { + return boxZero("int"); + } + return Object.is(q, -0) ? -0 : boxZero("float"); + } + return q; +} + +function luaMod( + a: unknown, + b: unknown, + ctx: ASTCtx, + sf: LuaStackFrame, + hints?: OpHints, +): number { + const { ax, bx, bothInt } = coerceNumericPair(a, b, hints); + if (bothInt && bx === 0) { + throw new LuaRuntimeError( + `attempt to perform modulo by zero`, + sf.withCtx(ctx), + ); + } + + const q = Math.floor(ax / bx); + const r = ax - q * bx; + if (r === 0) { + if (Object.is(ax, -0)) { + return -0; + } + return boxZero(bothInt ? "int" : "float"); + } + return r; +} + +function luaLess( + a: any, + b: any, + ctx: ASTCtx, + sf: LuaStackFrame, +): boolean { + const { ta, tb, av, bv } = luaRelOperands(a, b); + + if (ta === "number" && tb === "number") { + return av < bv; + } + + if (ta === "string" && tb === "string") { + return av < bv; + } + + throw new LuaRuntimeError( + `attempt to compare ${ta} with ${tb}`, + sf.withCtx(ctx), + ); +} + +function luaLessEqual( + a: any, + b: any, + ctx: ASTCtx, + sf: LuaStackFrame, +): boolean { + const { ta, tb, av, bv } = luaRelOperands(a, b); + + if (ta === "number" && tb === "number") { + return av <= bv; + } + + if (ta === "string" && tb === "string") { + return av <= bv; + } + + throw new LuaRuntimeError( + `attempt to compare ${ta} with ${tb}`, + sf.withCtx(ctx), + ); +} + +function luaUnaryMinus( + v: any, +): number { + const { n, zeroKind } = coerceNumeric(v); + + if (n === 0) { + if (Object.is(n, -0)) { + return boxZero("float"); + } + if (zeroKind === "int") { + return boxZero("int"); + } + if (zeroKind === "float") { + return -0; + } + return -0; + } + return -n; } async function handleTableFieldSync( @@ -77,6 +191,23 @@ async function handleTableFieldSync( } } +// Unwrap parentheses and unary +/- around a numeric literal +function astNumberKind(e: LuaExpression | undefined): NumericType | undefined { + if (!e) { + return undefined; + } + while (e.type === "Parenthesized") { + e = e.expression; + } + if (e.type === "Unary" && (e.operator === "-" || e.operator === "+")) { + return astNumberKind(e.argument); + } + if (e.type === "Number") { + return e.numericType === "int" ? "int" : "float"; + } + return undefined; +} + export function evalExpression( e: LuaExpression, env: LuaEnv, @@ -84,122 +215,119 @@ export function evalExpression( ): Promise | LuaValue { try { switch (e.type) { - case "String": + case "String": { return e.value; - case "Number": + } + case "Number": { + return (e.value === 0 && !Object.is(e.value, -0)) + ? boxZero(e.numericType === "int" ? "int" : "float") + : e.value; + } + case "Boolean": { return e.value; - case "Boolean": - return e.value; - case "Nil": + } + case "Nil": { return null; + } case "Binary": { if (e.operator === "or") { // Special case: eagerly evaluate left before even attempting right - const left = evalExpression(e.left, env, sf); - if (left instanceof Promise) { - return left.then((left) => { - if (luaTruthy(left)) { - return left; - } - return evalExpression(e.right, env, sf); - }); - } else if (luaTruthy(left)) { - return left; - } else { - return evalExpression(e.right, env, sf); - } + return evalLogical("or", e.left, e.right, env, sf); } else if (e.operator === "and") { // Special case: eagerly evaluate left before even attempting right - const left = evalExpression(e.left, env, sf); - if (left instanceof Promise) { - return left.then((left) => { - if (!luaTruthy(left)) { - return left; - } - return evalExpression(e.right, env, sf); - }); - } else if (!luaTruthy(left)) { - return left; - } else { - return evalExpression(e.right, env, sf); - } - } - const values = evalPromiseValues([ - evalExpression(e.left, env, sf), - evalExpression(e.right, env, sf), - ]); - if (values instanceof Promise) { - return values.then(([left, right]) => - luaOp( - e.operator, - singleResult(left), - singleResult(right), - e.ctx, - sf, - ) - ); - } else { - return luaOp( - e.operator, - singleResult(values[0]), - singleResult(values[1]), - e.ctx, - sf, - ); + return evalLogical("and", e.left, e.right, env, sf); } + // Enforce left-to-right evaluation + const hints: OpHints = opHintsFromBinary(e); + return evalBinaryWithLR( + e.operator, + e.left, + e.right, + e.ctx, + env, + sf, + hints, + ); } case "Unary": { const value = evalExpression(e.argument, env, sf); if (value instanceof Promise) { return value.then((value) => { switch (e.operator) { - case "-": - if ( - e.argument.type === "Number" && - e.argument.numericType === "int" - ) { - if (e.argument.value === 0) { - return 0; - } - } - return -luaCoerceToNumber(singleResult(value)); - case "+": - return +singleResult(value); - case "not": - return !singleResult(value); - case "~": - return ~exactInt(singleResult(value), e.ctx, sf); - case "#": - return luaLen(singleResult(value)); - default: - throw new Error( - `Unknown unary operator ${e.operator}`, + case "-": { + const arg = singleResult(value); + return unaryWithMeta( + arg, + "__unm", + e.ctx, + sf, + () => luaUnaryMinus(arg), ); + } + case "+": { + return +singleResult(value); + } + case "not": { + return !singleResult(value); + } + case "~": { + const arg = singleResult(value); + return unaryWithMeta( + arg, + "__bnot", + e.ctx, + sf, + () => ~exactInt(arg, e.ctx, sf), + ); + } + case "#": { + return luaLen(singleResult(value)); + } + default: { + throw new LuaRuntimeError( + `Unknown unary operator ${e.operator}`, + sf.withCtx(e.ctx), + ); + } } }); } else { switch (e.operator) { - case "-": - if ( - e.argument.type === "Number" && e.argument.numericType === "int" - ) { - if (e.argument.value === 0) { - return 0; - } - } - return -luaCoerceToNumber(singleResult(value)); - case "+": - return +singleResult(value); - case "not": - return !singleResult(value); - case "~": - return ~exactInt(singleResult(value), e.ctx, sf); - case "#": - return luaLen(singleResult(value)); - default: - throw new Error( - `Unknown unary operator ${e.operator}`, + case "-": { + const arg = singleResult(value); + return unaryWithMeta( + arg, + "__unm", + e.ctx, + sf, + () => luaUnaryMinus(arg), ); + } + case "+": { + return +singleResult(value); + } + case "not": { + return !singleResult(value); + } + case "~": { + const arg = singleResult(value); + return unaryWithMeta( + arg, + "__bnot", + e.ctx, + sf, + () => ~exactInt(arg, e.ctx, sf), + ); + } + case "#": { + return luaLen(singleResult(value)); + } + default: { + throw new LuaRuntimeError( + `Unknown unary operator ${e.operator}`, + sf.withCtx(e.ctx), + ); + } } } } @@ -207,8 +335,9 @@ export function evalExpression( case "Variable": case "FunctionCall": case "TableAccess": - case "PropertyAccess": + case "PropertyAccess": { return evalPrefixExpression(e, env, sf); + } case "TableConstructor": { return Promise.resolve().then(async () => { const table = new LuaTable(); @@ -227,7 +356,10 @@ export function evalExpression( // console.log("Query", e); const findFromClause = e.clauses.find((c) => c.type === "From"); if (!findFromClause) { - throw new LuaRuntimeError("No from clause found", sf.withCtx(e.ctx)); + throw new LuaRuntimeError( + "No from clause found", + sf.withCtx(e.ctx), + ); } const objectVariable = findFromClause.name; const objectExpression = findFromClause.expression; @@ -297,7 +429,10 @@ export function evalExpression( ); } default: - throw new Error(`Unknown expression type ${e.type}`); + throw new LuaRuntimeError( + `Unknown expression type ${e.type}`, + sf.withCtx(e.ctx), + ); } } catch (err: any) { // Repackage any non Lua-specific exceptions with some position information @@ -323,8 +458,9 @@ function evalPrefixExpression( return value; } } - case "Parenthesized": + case "Parenthesized": { return evalExpression(e.expression, env, sf); + } // <>[<>] case "TableAccess": { const values = evalPromiseValues([ @@ -398,7 +534,10 @@ function evalPrefixExpression( } } default: - throw new Error(`Unknown prefix expression type ${e.type}`); + throw new LuaRuntimeError( + `Unknown prefix expression type ${e.type}`, + sf.withCtx(e.ctx), + ); } } @@ -421,6 +560,138 @@ function evalMetamethod( } } +// Unary metamethod lookup and call +function evalUnaryMetamethod( + value: any, + metaMethod: "__unm" | "__bnot", + ctx: ASTCtx, + sf: LuaStackFrame, +): LuaValue | Promise | undefined { + const mt = getMetatable(value, sf); + if (mt?.has(metaMethod)) { + const fn = mt.get(metaMethod); + return luaCall(fn, [value], ctx, sf); + } + return undefined; +} + +// Unary metamethod handling (with fallback) +function unaryWithMeta( + arg: any, + meta: "__unm" | "__bnot", + ctx: ASTCtx, + sf: LuaStackFrame, + fallback: () => any, +): any { + const mm = evalUnaryMetamethod(arg, meta, ctx, sf); + + if (mm !== undefined) { + return mm instanceof Promise ? mm.then(singleResult) : singleResult(mm); + } + return fallback(); +} + +// Logical short-circuit evaluation +function evalLogical( + op: "and" | "or", + leftExpr: LuaExpression, + rightExpr: LuaExpression, + env: LuaEnv, + sf: LuaStackFrame, +): any { + const left = evalExpression(leftExpr, env, sf); + + const decide = (lv: any) => { + if (op === "or") { + if (luaTruthy(lv)) { + return lv; + } + return evalExpression(rightExpr, env, sf); + } else { + if (!luaTruthy(lv)) { + return lv; + } + return evalExpression(rightExpr, env, sf); + } + }; + + if (left instanceof Promise) { + return left.then(decide); + } else { + return decide(left); + } +} + +function opHintsFromBinary( + e: Extract, +): OpHints { + return { + leftKind: astNumberKind(e.left), + rightKind: astNumberKind(e.right), + }; +} + +function evalBinaryWithLR( + op: string, + leftExpr: LuaExpression, + rightExpr: LuaExpression, + ctx: ASTCtx, + env: LuaEnv, + sf: LuaStackFrame, + hints?: OpHints, +): any { + const leftVal = evalExpression(leftExpr, env, sf); + + const evalRightAndOp = (lv: any) => { + const rightVal = evalExpression(rightExpr, env, sf); + if (rightVal instanceof Promise) { + return rightVal.then((rv) => + luaOp( + op, + singleResult(lv), + singleResult(rv), + ctx, + sf, + hints, + ) + ); + } else { + return luaOp( + op, + singleResult(leftVal instanceof Promise ? lv : leftVal), + singleResult(rightVal), + ctx, + sf, + hints, + ); + } + }; + + if (leftVal instanceof Promise) { + return leftVal.then(evalRightAndOp); + } else { + return evalRightAndOp(leftVal); + } +} + +// Relational comparison "prelude" +function luaRelOperands( + a: any, + b: any, +): { + ta: string; + tb: string; + av: any; + bv: any; +} { + const ta = (a instanceof Number) ? "number" : typeof a; + const tb = (b instanceof Number) ? "number" : typeof b; + const av = (a instanceof Number) ? Number(a) : a; + const bv = (b instanceof Number) ? Number(b) : b; + + return { ta, tb, av, bv }; +} + export function getMetatable( value: LuaValue, sf?: LuaStackFrame, @@ -461,37 +732,77 @@ const operatorsMetaMethods: Record LuaValue; }> = { "+": { metaMethod: "__add", - nativeImplementation: (a, b) => luaCoerceToNumber(a) + luaCoerceToNumber(b), + 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; + }, }, "-": { metaMethod: "__sub", - nativeImplementation: (a, b) => luaCoerceToNumber(a) - luaCoerceToNumber(b), + 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; + }, }, "*": { metaMethod: "__mul", - nativeImplementation: (a, b) => luaCoerceToNumber(a) * luaCoerceToNumber(b), + 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; + }, }, "/": { metaMethod: "__div", - nativeImplementation: (a, b) => luaCoerceToNumber(a) / luaCoerceToNumber(b), + nativeImplementation: (a, b, _ctx, _sf, hints) => { + const { ax, bx } = coerceNumericPair(a, b, hints); + return ax / bx; + }, }, "//": { metaMethod: "__idiv", - nativeImplementation: (a, b) => - Math.floor(luaCoerceToNumber(a) / luaCoerceToNumber(b)), + nativeImplementation: (a, b, ctx, sf, hints) => + luaFloorDiv(a, b, ctx, sf, hints), }, "%": { metaMethod: "__mod", - nativeImplementation: (a, b) => luaCoerceToNumber(a) % luaCoerceToNumber(b), + nativeImplementation: (a, b, ctx, sf, hints) => + luaMod(a, b, ctx, sf, hints), }, "^": { metaMethod: "__pow", - nativeImplementation: (a, b) => - luaCoerceToNumber(a) ** luaCoerceToNumber(b), + nativeImplementation: (a, b, _ctx, _sf, hints) => { + const { ax, bx } = coerceNumericPair(a, b, hints); + return ax ** bx; + }, }, "&": { metaMethod: "__band", @@ -523,6 +834,7 @@ const operatorsMetaMethods: Record { const aString = luaToString(a); const bString = luaToString(b); + if (aString instanceof Promise || bString instanceof Promise) { return Promise.all([aString, bString]).then(([a, b]) => a + b); } else { @@ -542,15 +854,20 @@ const operatorsMetaMethods: Record !luaEquals(a, b), }, - "<": { metaMethod: "__lt", nativeImplementation: (a, b) => a < b }, - "<=": { metaMethod: "__le", nativeImplementation: (a, b) => a <= b }, - ">": { nativeImplementation: (a, b, ctx, sf) => !luaOp("<=", a, b, ctx, sf) }, - ">=": { nativeImplementation: (a, b, ctx, sf) => !luaOp("<", a, b, ctx, sf) }, - "and": { - metaMethod: "__and", - nativeImplementation: (a, b) => a && b, + "<": { + metaMethod: "__lt", + nativeImplementation: (a, b, ctx, sf) => luaLess(a, b, ctx, sf), + }, + "<=": { + metaMethod: "__le", + nativeImplementation: (a, b, ctx, sf) => luaLessEqual(a, b, ctx, sf), + }, + ">": { + nativeImplementation: (a, b, ctx, sf) => !luaOp("<=", a, b, ctx, sf), + }, + ">=": { + nativeImplementation: (a, b, ctx, sf) => !luaOp("<", a, b, ctx, sf), }, - "or": { metaMethod: "__or", nativeImplementation: (a, b) => a || b }, }; function luaOp( @@ -559,6 +876,7 @@ function luaOp( right: any, ctx: ASTCtx, sf: LuaStackFrame, + hints?: OpHints, ): any { const handler = operatorsMetaMethods[op]; if (!handler) { @@ -572,7 +890,7 @@ function luaOp( } } - return handler.nativeImplementation(left, right, ctx, sf); + return handler.nativeImplementation(left, right, ctx, sf, hints); } function evalExpressions( @@ -633,11 +951,16 @@ export async function evalStatement( } break; } - case "Semicolon": + case "Semicolon": { break; + } case "Label": - case "Goto": - throw new Error("Labels and gotos are not supported"); + case "Goto": { + throw new LuaRuntimeError( + "Labels and gotos are not supported", + sf.withCtx(s.ctx), + ); + } case "Block": { const newEnv = new LuaEnv(env); for (const statement of s.statements) { @@ -695,8 +1018,9 @@ export async function evalStatement( } while (!luaTruthy(await evalExpression(s.condition, env, sf))); break; } - case "Break": + case "Break": { throw new LuaBreak(); + } case "FunctionCallStatement": { await evalExpression(s.call, env, sf); return; @@ -828,8 +1152,12 @@ function evalLValue( sf: LuaStackFrame, ): LuaLValueContainer | Promise { switch (lval.type) { - case "Variable": - return { env, key: lval.name }; + case "Variable": { + return { + env, + key: lval.name, + }; + } case "TableAccess": { const objValue = evalExpression( lval.object, @@ -879,16 +1207,28 @@ function evalLValue( } function exactInt( - num: number, + num: any, ctx: ASTCtx, sf: LuaStackFrame, ): number { // See conversion from float to integer https://www.lua.org/manual/5.4/manual.html#3.4.3 - if (!Number.isInteger(num)) { + let n: number; + if (typeof num === "number") { + n = num; + } else if (num instanceof Number) { + n = Number(num); + } else { throw new LuaRuntimeError( - `Number ${num} has no integer representation (consider math.floor or math.ceil)`, + `attempt to perform arithmetic on a non-number`, sf.withCtx(ctx), ); } - return num; + + if (!Number.isInteger(n)) { + throw new LuaRuntimeError( + `Number ${n} has no integer representation (consider math.floor or math.ceil)`, + sf.withCtx(ctx), + ); + } + return n; } diff --git a/client/space_lua/numeric.ts b/client/space_lua/numeric.ts new file mode 100644 index 00000000..d1f368e6 --- /dev/null +++ b/client/space_lua/numeric.ts @@ -0,0 +1,136 @@ +import type { NumericType } from "./ast.ts"; +import { luaToNumberDetailed } from "./tonumber.ts"; + +export const ZeroBoxKind = Symbol("ZeroBox"); + +export function isBoxedZero(x: any): boolean { + return x instanceof Number && Number(x) === 0 && !Object.is(Number(x), -0); +} + +export function getZeroBoxKind(x: any): NumericType | undefined { + return isBoxedZero(x) + ? (x as any)[ZeroBoxKind] as NumericType | undefined + : undefined; +} + +export function boxZero(kind: NumericType): number { + const z = new Number(0); + (z as any)[ZeroBoxKind] = kind; + return z as unknown as number; +} + +export type OpHints = { + leftKind?: NumericType; + rightKind?: NumericType; +}; + +export function coerceNumeric( + val: unknown, +): { + n: number; + isInt: boolean; + zeroKind?: NumericType; +} { + if (isBoxedZero(val)) { + return { n: 0, isInt: true, zeroKind: getZeroBoxKind(val)! }; + } + + if (typeof val === "number") { + const n = val; + if (n === 0) { + if (Object.is(n, -0)) { + return { + n, + isInt: true, + zeroKind: "float", + }; + } + + return { + n, + isInt: true, + }; + } + return { + n, + isInt: Number.isInteger(n), + }; + } + + if (val instanceof Number) { + const n = Number(val); + if (n === 0 && !Object.is(n, -0)) { + return { + n, + isInt: true, + zeroKind: getZeroBoxKind(val), + }; + } + return { + n, + isInt: Number.isInteger(n), + }; + } + + if (typeof val === "string") { + const det = luaToNumberDetailed(val); + if (!det) { + throw new Error(`attempt to perform arithmetic on a non-number`); + } + + const n = det.value; + const isInt = det.numericType === "int"; + if (n === 0) { + if (Object.is(n, -0)) { + return { + n, + isInt, + zeroKind: "float", + }; + } + return { + n, + isInt, + zeroKind: det.numericType, + }; + } + + return { + n, + isInt, + }; + } + + throw new Error(`attempt to perform arithmetic on a non-number`); +} + +export function coerceNumericPair( + a: unknown, + b: unknown, + hints?: OpHints, +): { + ax: number; + bx: number; + bothInt: boolean; + aZeroKind?: NumericType; + bZeroKind?: NumericType; +} { + const A = coerceNumeric(a); + const B = coerceNumeric(b); + + const aIsInt = hints?.leftKind + ? hints.leftKind === "int" + : (A.zeroKind ? A.zeroKind === "int" : A.isInt); + + const bIsInt = hints?.rightKind + ? hints.rightKind === "int" + : (B.zeroKind ? B.zeroKind === "int" : B.isInt); + + return { + ax: A.n, + bx: B.n, + bothInt: aIsInt && bIsInt, + aZeroKind: A.zeroKind, + bZeroKind: B.zeroKind, + }; +} diff --git a/client/space_lua/runtime.ts b/client/space_lua/runtime.ts index 6c321c1b..7ba63226 100644 --- a/client/space_lua/runtime.ts +++ b/client/space_lua/runtime.ts @@ -296,6 +296,14 @@ 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 toIndex(key: any): number { + return (key instanceof Number ? Number(key) : key) - 1; + } + get length(): number { return this.arrayPart.length; } @@ -324,8 +332,8 @@ export class LuaTable implements ILuaSettable, ILuaGettable { has(key: LuaValue) { if (typeof key === "string") { return this.stringKeys[key] !== undefined; - } else if (Number.isInteger(key) && key >= 1) { - return this.arrayPart[key - 1] !== undefined; + } else if (LuaTable.isIntegerKey(key)) { + return this.arrayPart[LuaTable.toIndex(key)] !== undefined; } else if (this.otherKeys) { return this.otherKeys.has(key); } @@ -345,8 +353,8 @@ export class LuaTable implements ILuaSettable, ILuaGettable { } else { this.stringKeys[key] = value; } - } else if (Number.isInteger(key) && key >= 1) { - this.arrayPart[key - 1] = value; + } else if (LuaTable.isIntegerKey(key)) { + this.arrayPart[LuaTable.toIndex(key)] = value; } else { if (!this.otherKeys) { this.otherKeys = new Map(); @@ -385,8 +393,8 @@ export class LuaTable implements ILuaSettable, ILuaGettable { rawGet(key: LuaValue): LuaValue | null { if (typeof key === "string") { return this.stringKeys[key]; - } else if (Number.isInteger(key) && key >= 1) { - return this.arrayPart[key - 1]; + } else if (LuaTable.isIntegerKey(key)) { + return this.arrayPart[LuaTable.toIndex(key)]; } else if (this.otherKeys) { return this.otherKeys.get(key); } @@ -513,7 +521,14 @@ export function luaIndexValue( } } // If not, perhaps let's assume this is a plain JavaScript object and we just index into it - const objValue = value[key]; + const idxKey = key instanceof Number ? Number(key) : key; + if ( + typeof value === "object" && Array.isArray(value) && + typeof idxKey === "number" + ) { + return (value as any)[idxKey - 1]; + } + const objValue = (value as any)[idxKey]; if (objValue === undefined || objValue === null) { return null; } else { @@ -539,7 +554,8 @@ export async function luaSet( if (obj instanceof LuaTable || obj instanceof LuaEnv) { await obj.set(key, value, sf); } else { - obj[key] = value; + const k = key instanceof Number ? Number(key) : key; + obj[k] = value; } } @@ -563,11 +579,13 @@ export function luaGet( if (obj instanceof LuaTable || obj instanceof LuaEnv) { return obj.get(key, sf); - } else if (typeof key === "number") { - return obj[key - 1]; + } else if (typeof key === "number" || key instanceof Number) { + const idx = key instanceof Number ? Number(key) : key; + return obj[idx - 1]; } else { // Native JS object - const val = obj[key]; + const k = key instanceof Number ? Number(key) : key; + const val = obj[k]; if (typeof val === "function") { // Automatically bind the function to the object return val.bind(obj); @@ -649,6 +667,11 @@ 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; + } return a === b; } @@ -670,7 +693,7 @@ export function luaTypeOf(val: any): LuaType | Promise { return val.then((v) => luaTypeOf(v)); } else if (typeof val === "boolean") { return "boolean"; - } else if (typeof val === "number") { + } else if (typeof val === "number" || val instanceof Number) { return "number"; } else if (typeof val === "string") { return "string"; @@ -903,6 +926,8 @@ export function luaValueToJS(value: any, sf: LuaStackFrame): any { return value.call(sf, ...jsArgs); } }; + } else if (value instanceof Number) { + return Number(value); } else { return value; } diff --git a/client/space_lua/tonumber.ts b/client/space_lua/tonumber.ts index 38dbf6c3..80430036 100644 --- a/client/space_lua/tonumber.ts +++ b/client/space_lua/tonumber.ts @@ -12,12 +12,13 @@ * - hex float requires `p` or `P` exponent * - decimal float allows '.' and `e` or `E` exponent */ +import type { NumericType } from "./ast.ts"; function skipSpace(s: string, i: number): number { const n = s.length; while (i < n) { const c = s.charCodeAt(i); - if (c === 32 || (c >= 9 && c <= 13)) { + if (c === 32 || (c >= 9 && c <= 13)) { // SP, HT, LF, VT, FF, CR i++; } else { break; @@ -27,21 +28,21 @@ function skipSpace(s: string, i: number): number { } function charToDigitBase(c: number, base: number): number { - if (c >= 48 && c <= 57) { + if (c >= 48 && c <= 57) { // '0'..'9' const v = c - 48; if (v < base) { return v; } else { return -1; } - } else if (c >= 65 && c <= 90) { + } else if (c >= 65 && c <= 90) { // 'A'..'Z' const v = 10 + (c - 65); if (v < base) { return v; } else { return -1; } - } else if (c >= 97 && c <= 122) { + } else if (c >= 97 && c <= 122) { // 'a'..'z' const v = 10 + (c - 97); if (v < base) { return v; @@ -65,10 +66,10 @@ function parseIntWithBase( } let sign = 1; - if (s.charCodeAt(i) === 45) { + if (s.charCodeAt(i) === 45) { // '-' sign = -1; i++; - } else if (s.charCodeAt(i) === 43) { + } else if (s.charCodeAt(i) === 43) { // '+' i++; } @@ -81,9 +82,9 @@ function parseIntWithBase( while (i < n) { const c = s.charCodeAt(i); - const isAlnum = (c >= 48 && c <= 57) || - (c >= 65 && c <= 90) || - (c >= 97 && c <= 122); + const isAlnum = (c >= 48 && c <= 57) || // '0'..'9' + (c >= 65 && c <= 90) || // 'A'..'Z' + (c >= 97 && c <= 122); // 'a'..'z' if (!isAlnum) { break; @@ -135,18 +136,18 @@ function parseInt(s: string): { ok: boolean; value: number } { let any = false; // hex? - if (s.charCodeAt(i) === 48 && i + 1 < n) { + if (s.charCodeAt(i) === 48 && i + 1 < n) { // '0' const x = s.charCodeAt(i + 1); - if (x === 120 || x === 88) { + if (x === 120 || x === 88) { // 'x' or 'X' i += 2; while (i < n) { const c = s.charCodeAt(i); let d = -1; - if (c >= 48 && c <= 57) { - d = c - 48; - } else if (c >= 65 && c <= 70) { + if (c >= 48 && c <= 57) { // '0'..'9' + d = c - 48; // '0' + } else if (c >= 65 && c <= 70) { // 'A'..'F' d = 10 + (c - 65); - } else if (c >= 97 && c <= 102) { + } else if (c >= 97 && c <= 102) { // 'a'..'f' d = 10 + (c - 97); } else { d = -1; @@ -170,7 +171,7 @@ function parseInt(s: string): { ok: boolean; value: number } { // decimal integer while (i < n) { const c = s.charCodeAt(i); - if (c < 48 || c > 57) { + if (c < 48 || c > 57) { // not '0'..'9' break; } acc = acc * 10 + (c - 48); @@ -196,10 +197,10 @@ function parseDecFloat(s: string): { ok: boolean; value: number } { let sign = 1; const c0 = s.charCodeAt(i); - if (c0 === 45) { + if (c0 === 45) { // '-' sign = -1; i++; - } else if (c0 === 43) { + } else if (c0 === 43) { // '+' i++; } @@ -210,7 +211,7 @@ function parseDecFloat(s: string): { ok: boolean; value: number } { // integer part while (i < n) { const c = s.charCodeAt(i); - if (c < 48 || c > 57) { + if (c < 48 || c > 57) { // not '0'..'9' break; } val = val * 10 + (c - 48); @@ -220,12 +221,12 @@ function parseDecFloat(s: string): { ok: boolean; value: number } { // fractional part if (i < n) { - if (s.charCodeAt(i) === 46) { + if (s.charCodeAt(i) === 46) { // '.' i++; let scale = 1; while (i < n) { const c = s.charCodeAt(i); - if (c < 48 || c > 57) { + if (c < 48 || c > 57) { // not '0'..'9' break; } scale *= 0.1; @@ -241,7 +242,7 @@ function parseDecFloat(s: string): { ok: boolean; value: number } { let hasExp = false; if (i < n) { const ec = s.charCodeAt(i); - if (ec === 101 || ec === 69) { + if (ec === 101 || ec === 69) { // 'e' or 'E' hasExp = true; i++; if (i >= n) { @@ -250,10 +251,10 @@ function parseDecFloat(s: string): { ok: boolean; value: number } { let expSign = 1; if (i < n) { const es = s.charCodeAt(i); - if (es === 45) { + if (es === 45) { // '-' expSign = -1; i++; - } else if (es === 43) { + } else if (es === 43) { // '+' i++; } } @@ -263,7 +264,7 @@ function parseDecFloat(s: string): { ok: boolean; value: number } { let anyExp = false; while (i < n) { const c = s.charCodeAt(i); - if (c < 48 || c > 57) { + if (c < 48 || c > 57) { // not '0'..'9' break; } exp = exp * 10 + (c - 48); @@ -300,18 +301,18 @@ function parseHexFloat(s: string): { ok: boolean; value: number } { let sign = 1; const c0 = s.charCodeAt(i); - if (c0 === 45) { + if (c0 === 45) { // '-' sign = -1; i++; - } else if (c0 === 43) { + } else if (c0 === 43) { // '+' i++; } - if (!(i + 1 < n && s.charCodeAt(i) === 48)) { + if (!(i + 1 < n && s.charCodeAt(i) === 48)) { // '0' return { ok: false, value: 0 }; } const x = s.charCodeAt(i + 1); - if (!(x === 120 || x === 88)) { + if (!(x === 120 || x === 88)) { // 'x' or 'X' return { ok: false, value: 0 }; } i += 2; @@ -325,11 +326,11 @@ function parseHexFloat(s: string): { ok: boolean; value: number } { while (i < n) { const c = s.charCodeAt(i); let d = -1; - if (c >= 48 && c <= 57) { + if (c >= 48 && c <= 57) { // '0'..'9' d = c - 48; - } else if (c >= 65 && c <= 70) { + } else if (c >= 65 && c <= 70) { // 'A'..'F' d = 10 + (c - 65); - } else if (c >= 97 && c <= 102) { + } else if (c >= 97 && c <= 102) { // 'a'..'f' d = 10 + (c - 97); } else { d = -1; @@ -344,16 +345,16 @@ function parseHexFloat(s: string): { ok: boolean; value: number } { // optional fractional part if (i < n) { - if (s.charCodeAt(i) === 46) { + if (s.charCodeAt(i) === 46) { // '.' i++; while (i < n) { const c = s.charCodeAt(i); let d = -1; - if (c >= 48 && c <= 57) { + if (c >= 48 && c <= 57) { // '0'..'9' d = c - 48; - } else if (c >= 65 && c <= 70) { + } else if (c >= 65 && c <= 70) { // 'A'..'F' d = 10 + (c - 65); - } else if (c >= 97 && c <= 102) { + } else if (c >= 97 && c <= 102) { // 'a'..'f' d = 10 + (c - 97); } else { d = -1; @@ -374,7 +375,7 @@ function parseHexFloat(s: string): { ok: boolean; value: number } { return { ok: false, value: 0 }; } const ec = s.charCodeAt(i); - if (!(ec === 112 || ec === 80)) { + if (!(ec === 112 || ec === 80)) { // 'p' or 'P' return { ok: false, value: 0 }; } i++; @@ -382,10 +383,10 @@ function parseHexFloat(s: string): { ok: boolean; value: number } { let expSign = 1; if (i < n) { const sc = s.charCodeAt(i); - if (sc === 45) { + if (sc === 45) { // '-' expSign = -1; i++; - } else if (sc === 43) { + } else if (sc === 43) { // '+' i++; } } @@ -397,7 +398,7 @@ function parseHexFloat(s: string): { ok: boolean; value: number } { let exp = 0; while (i < n) { const c = s.charCodeAt(i); - if (c < 48 || c > 57) { + if (c < 48 || c > 57) { // not '0'..'9' break; } exp = exp * 10 + (c - 48); @@ -418,15 +419,12 @@ function parseHexFloat(s: string): { ok: boolean; value: number } { return { ok: true, value: result }; } -export function luaToNumber(s: string, base?: number): number | null { - if (typeof s === "number") { - // No conversion required - return s; - } - if (typeof s !== "string") { - return null; - } - +// Detailed string conversion that returns numeric type, returns null +// on failure, integer -0 is canonicalized to +0 +export function luaToNumberDetailed( + s: string, + base?: number, +): { value: number; numericType: NumericType } | null { if (base !== undefined) { if (!(typeof base === "number" && base >= 2 && base <= 36)) { return null; @@ -434,7 +432,8 @@ export function luaToNumber(s: string, base?: number): number | null { const parsed = parseIntWithBase(s, base); if (parsed.ok) { - return parsed.value; + const v = parsed.value; + return { value: v === 0 ? 0 : v, numericType: "int" }; } else { return null; } @@ -443,23 +442,39 @@ export function luaToNumber(s: string, base?: number): number | null { { const parsed = parseInt(s); if (parsed.ok) { - return parsed.value; + const v = parsed.value; + return { value: v === 0 ? 0 : v, numericType: "int" }; } } { const parsed = parseHexFloat(s); if (parsed.ok) { - return parsed.value; + return { value: parsed.value, numericType: "float" }; } } { const parsed = parseDecFloat(s); if (parsed.ok) { - return parsed.value; + return { value: parsed.value, numericType: "float" }; } } return null; } + +// Space Lua `tonumber` returning only the numeric value or null +export function luaToNumber(s: unknown, base?: number): number | null { + if (typeof s === "number") { + return s; + } + if (s instanceof Number) { + return Number(s); + } + if (typeof s !== "string") { + return null; + } + const detailed = luaToNumberDetailed(s, base); + return detailed ? detailed.value : null; +} diff --git a/plug-api/syscalls.ts b/plug-api/syscalls.ts index 57ac281b..a9147837 100644 --- a/plug-api/syscalls.ts +++ b/plug-api/syscalls.ts @@ -14,7 +14,7 @@ export * as mq from "./syscalls/mq.ts"; export * as datastore from "./syscalls/datastore.ts"; export * as jsonschema from "./syscalls/jsonschema.ts"; export * as lua from "./syscalls/lua.ts"; -export * as config from "./syscalls/config.ts" +export * as config from "./syscalls/config.ts"; // Not technically syscalls, but we want to export them for convenience export * as index from "./syscalls/index.ts"; diff --git a/plugs/editor/page.ts b/plugs/editor/page.ts index 80180c9d..cd349f2f 100644 --- a/plugs/editor/page.ts +++ b/plugs/editor/page.ts @@ -11,11 +11,11 @@ export async function deletePage() { // Query for last const recentlyOpenedPages = await editor.getRecentlyOpenedPages(); const allPages = await space.listPages(); - const existingPageNames = new Set(allPages.map(p => p.name)); + const existingPageNames = new Set(allPages.map((p) => p.name)); // Find the first recently opened page that still exists and is not the current page const firstRecentlyOpenedPage = recentlyOpenedPages.find( - (page) => page.name !== pageName && existingPageNames.has(page.name) + (page) => page.name !== pageName && existingPageNames.has(page.name), ); await space.deletePage(pageName); console.log("Navigating to previous page");