diff --git a/client/space_lua/eval.ts b/client/space_lua/eval.ts index 48b92b98..5d2358bc 100644 --- a/client/space_lua/eval.ts +++ b/client/space_lua/eval.ts @@ -18,7 +18,6 @@ import { LuaFunction, luaGet, luaIndexValue, - luaLen, type LuaLValueContainer, LuaMultiRes, LuaReturn, @@ -28,6 +27,8 @@ import { LuaTable, luaToString, luaTruthy, + type LuaType, + luaTypeOf, type LuaValue, luaValueToJS, singleResult, @@ -281,7 +282,7 @@ export function evalExpression( ); } case "#": { - return luaLen(singleResult(value)); + return luaLengthOp(singleResult(value), e.ctx, sf); } default: { throw new LuaRuntimeError( @@ -320,7 +321,7 @@ export function evalExpression( ); } case "#": { - return luaLen(singleResult(value)); + return luaLengthOp(singleResult(value), e.ctx, sf); } default: { throw new LuaRuntimeError( @@ -895,6 +896,57 @@ function luaOp( return handler.nativeImplementation(left, right, ctx, sf, hints); } +/** + * Length operator: + * - for strings return byte length, ignore `__len`, + * - for Lua tables if metatable has `__len` metamethod then call it; + * use table length otherwise, + * - for other values (userdata): honor `__len` if present, + * - for JavaScript arrays return length, + * - throw error otherwise. + */ +function luaLengthOp( + val: any, + ctx: ASTCtx, + sf: LuaStackFrame, +): LuaValue { + // Strings: ignore `__len` + if (typeof val === "string") { + return val.length; + } + + // Tables: prefer metatable `__len` to raw length + if (val instanceof LuaTable) { + const mt = getMetatable(val, sf); + if (mt && mt.has("__len")) { + const fn = mt.get("__len"); + return luaCall(fn, [val], ctx, sf); + } + return val.length; + } + + // Other values: allow metatable `__len` first + { + const mt = getMetatable(val, sf); + if (mt && mt.has("__len")) { + const fn = mt.get("__len"); + return luaCall(fn, [val], ctx, sf); + } + } + + // JS arrays (interop): length if no `__len` override + if (Array.isArray(val)) { + return val.length; + } + + // Otherwise error with type + const t = luaTypeOf(val) as LuaType; + throw new LuaRuntimeError( + `attempt to get length of a ${t} value`, + sf.withCtx(ctx), + ); +} + function evalExpressions( es: LuaExpression[], env: LuaEnv, diff --git a/client/space_lua/language_core_test.lua b/client/space_lua/language_core_test.lua index e024a014..5588d848 100644 --- a/client/space_lua/language_core_test.lua +++ b/client/space_lua/language_core_test.lua @@ -592,3 +592,60 @@ function varArgTest(a0, ...) end varArgTest(1, 2, 3, 4) + +-- Some `rawlen` tests + +-- Strings +assertEqual(rawlen(""), 0, "rawlen on empty string") +assertEqual(rawlen("abc"), 3, "rawlen on non-empty string") + +-- Tables +assertEqual(rawlen({}), 0, "rawlen on empty table") +assertEqual(rawlen({1, 2, 3}), 3, "rawlen on array table") + +-- `rawlen` ignores `__len` metamethod +do + local t = {} + setmetatable(t, { __len = function(_) return 42 end }) + assertEqual(rawlen(t), 0, "rawlen ignores __len (no array part)") + t[1] = "x"; t[2] = "y" + assertEqual(rawlen(t), 2, "rawlen returns raw array-part length") +end + +-- JS arrays also have raw length +do + local a0 = js.window.JSON.parse("[]") + assertEqual(rawlen(a0), 0, "rawlen on empty JS array") + + local a4 = js.window.JSON.parse("[1,2,3,4]") + assertEqual(rawlen(a4), 4, "rawlen on JS array length 4") +end + +-- Non-string/non-table must error with proper message +local function expect_rawlen_error(v, typeName) + local ok, err = pcall( + function() + return rawlen(v) + end + ) + + assertEqual(ok, false, "rawlen must error on " + .. tostring(typeName)) + + local s = tostring(err) + + assert( + string.find(s, "bad argument #1 to 'rawlen'", 1, true) ~= nil, + "error must mention rawlen" + ) + + assert( + string.find(s, typeName, 1, true) ~= nil, + "error must mention type name" + ) +end + +expect_rawlen_error(nil, "nil") +expect_rawlen_error(1, "number") +expect_rawlen_error(false, "boolean") +expect_rawlen_error(function() end, "function") diff --git a/client/space_lua/len_test.lua b/client/space_lua/len_test.lua new file mode 100644 index 00000000..b1a71130 --- /dev/null +++ b/client/space_lua/len_test.lua @@ -0,0 +1,106 @@ +local function assert_eq(actual, expected, msg) + if actual ~= expected then + error('assert_eq failed: ' .. msg) + end +end + +local function assert_throws(substr, fn) + local ok, err = pcall(fn) + if ok then + error("expected error containing: " .. substr) + end + + err = tostring(err) + if not string.find(err, substr, 1, true) then + error("error message mismatch: got: " .. err) + end +end + +-- 1. Strings: raw length, ignore metamethods +assert_eq(#"", 0, "empty string length") +assert_eq(#"abc", 3, "string length") + +-- 2. Tables (raw length for sequence or `__len`) +assert_eq(#{}, 0, "empty table length") +assert_eq(#{1,2,3}, 3, "array length") + +do + local t = {} + + setmetatable(t, + { + __len = function(_) + return 42 + end, + }) + + assert_eq(#t, 42, "__len should override raw table length") +end + +-- 2.1. Metamethod result handling +do + local t = {} + + setmetatable(t, + { + __len = function(_) + return 42, 1, 2, 3 + end, + }) + + assert_eq(#t, 42, "__len must use only first return value") +end + +-- 2.2. Raw length when no array part +do + local t = { + a = 1, + b = 2, + } + + assert_eq(#t, 0, "table with only non-integer keys has length 0") +end + +-- 2.3. Length when both array and key-value parts +do + local t = { + a = 1, + b = 2, + 1, + 2, + c = 3, + 3, + } + + assert_eq(#t, 3, "table with mixed non-integer and integer keys") +end + +-- 3. Non-strings and non-tables +assert_throws( + "attempt to get length of a nil value", + function() + return #nil + end +) + +assert_throws( + "attempt to get length of a number value", + function() + return #1 + end +) + +assert_throws( + "attempt to get length of a boolean value", + function() + return #false + end +) + +assert_throws( + "attempt to get length of a function value", + function() + local f = function() end + return #f + end +) diff --git a/client/space_lua/lua.test.ts b/client/space_lua/lua.test.ts index ae8d8c82..9f45fdab 100644 --- a/client/space_lua/lua.test.ts +++ b/client/space_lua/lua.test.ts @@ -9,6 +9,10 @@ Deno.test("[Lua] Core language", async () => { await runLuaTest("./language_core_test.lua"); }); +Deno.test("[Lua] Core language (length)", async () => { + await runLuaTest("./len_test.lua"); +}); + Deno.test("[Lua] Core language (truthiness)", async () => { await runLuaTest("./truthiness_test.lua"); }); diff --git a/client/space_lua/runtime.ts b/client/space_lua/runtime.ts index 7ba63226..03ed0d2a 100644 --- a/client/space_lua/runtime.ts +++ b/client/space_lua/runtime.ts @@ -597,16 +597,22 @@ export function luaGet( } } -export function luaLen(obj: any): number { - if (obj instanceof LuaTable) { +export function luaLen( + obj: any, + sf?: LuaStackFrame, +): number { + // Only strings, Lua tables and JS arrays + if ( + typeof obj === "string" || obj instanceof LuaTable || Array.isArray(obj) + ) { return obj.length; - } else if (Array.isArray(obj)) { - return obj.length; - } else if (typeof obj === "string") { - return obj.length; - } else { - return 0; } + + const t = luaTypeOf(obj) as LuaType; + throw new LuaRuntimeError( + `bad argument #1 to 'rawlen' (table or string expected, got ${t})`, + sf || LuaStackFrame.lostFrame, + ); } export function luaCall( diff --git a/client/space_lua/stdlib.ts b/client/space_lua/stdlib.ts index 31c04a8a..fd4ab8e0 100644 --- a/client/space_lua/stdlib.ts +++ b/client/space_lua/stdlib.ts @@ -5,6 +5,7 @@ import { LuaEnv, luaGet, luaKeys, + luaLen, LuaMultiRes, LuaRuntimeError, type LuaTable, @@ -159,6 +160,12 @@ const setmetatableFunction = new LuaBuiltinFunction( }, ); +const rawlenFunction = new LuaBuiltinFunction( + (_sf, value: LuaValue) => { + return luaLen(value, _sf); + }, +); + const rawsetFunction = new LuaBuiltinFunction( (_sf, table: LuaTable, key: LuaValue, value: LuaValue) => { return table.rawSet(key, value); @@ -285,6 +292,7 @@ export function luaBuildStandardEnv() { // meta table stuff env.set("setmetatable", setmetatableFunction); env.set("getmetatable", getmetatableFunction); + env.set("rawlen", rawlenFunction); env.set("rawset", rawsetFunction); env.set("dofile", dofileFunction); // Error handling