diff --git a/client/space_lua/stdlib.ts b/client/space_lua/stdlib.ts index a112103e..b4fe8b30 100644 --- a/client/space_lua/stdlib.ts +++ b/client/space_lua/stdlib.ts @@ -1,4 +1,5 @@ import { + getMetatable, type ILuaFunction, isILuaFunction, isLuaTable, @@ -17,6 +18,7 @@ import { luaToString, luaTypeOf, type LuaValue, + singleResult, } from "./runtime.ts"; import { stringApi } from "./stdlib/string.ts"; import { tableApi } from "./stdlib/table.ts"; @@ -32,6 +34,7 @@ import { luaLoad } from "./stdlib/load.ts"; import { cryptoApi } from "./stdlib/crypto.ts"; import { netApi } from "./stdlib/net.ts"; import { isTaggedFloat, makeLuaFloat } from "./numeric.ts"; +import { isPromise } from "./rp.ts"; const printFunction = new LuaBuiltinFunction(async (_sf, ...args) => { console.log( @@ -135,9 +138,35 @@ const typeFunction = new LuaBuiltinFunction( }, ); -const tostringFunction = new LuaBuiltinFunction((_sf, value: any) => { - return luaToString(value); -}); +// tostring() checks `__tostring` metamethod first (with live SF), then +// falls back to the default `luaToString` representation. +const tostringFunction = new LuaBuiltinFunction( + (sf, value: any): string | Promise => { + const mt = getMetatable(value, sf); + if (mt) { + const mm = mt.rawGet("__tostring"); + if (mm !== undefined && mm !== null) { + const ctx = sf.astCtx ?? {}; + const r = luaCall(mm, [value], ctx as any, sf); + const unwrap = (v: any): string => { + const s = singleResult(v); + if (typeof s !== "string") { + throw new LuaRuntimeError( + "'__tostring' must return a string", + sf, + ); + } + return s; + }; + if (isPromise(r)) { + return (r as Promise).then(unwrap); + } + return unwrap(r); + } + } + return luaToString(value); + }, +); const tonumberFunction = new LuaBuiltinFunction( (sf, value: LuaValue, base?: number) => { diff --git a/client/space_lua/stdlib/global_test.lua b/client/space_lua/stdlib/global_test.lua index e2ef5318..9d1cdc1f 100644 --- a/client/space_lua/stdlib/global_test.lua +++ b/client/space_lua/stdlib/global_test.lua @@ -25,3 +25,30 @@ assertEqual(some(" \n"), nil) assertEqual(some({}), nil) assertEqual(some({"baz"})[1], "baz") -- compare an element to ensure passthrough assertEqual(some({foo="bar"})["foo"], "bar") + +-- tostring: primitives +assertEqual(tostring(nil), "nil") +assertEqual(tostring(true), "true") +assertEqual(tostring(false), "false") +assertEqual(tostring(42), "42") +assertEqual(tostring(3.14), "3.14") + +-- tostring: `__tostring` metamethod is called with the live SF +local mt = { __tostring = function(t) return "custom:" .. t.name end } +local obj = setmetatable({ name = "lua" }, mt) +assertEqual(tostring(obj), "custom:lua") + +-- tostring: `__tostring` returning non-string must error +local bad_mt = { __tostring = function(_) return 99 end } +local bad_obj = setmetatable({}, bad_mt) +local ok, err = pcall(tostring, bad_obj) +assertEqual(ok, false) + +-- tostring: `__tostring` on a nested call (SF propagation via `luaCall`) +local inner_mt = { + __tostring = function(t) + return "inner:" .. tostring(t.val) + end +} +local inner = setmetatable({ val = 7 }, inner_mt) +assertEqual(tostring(inner), "inner:7")