Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions client/space_lua/arithmetic_test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1741,3 +1741,34 @@ do
assertEquals(math.type(sum_f), 'float', 'float sum stays float')
end
end

-- 24. Concatenation number-to-string coercion

-- 24.1. Integer formatting must not get decimal point
assertEquals(1 .. "", "1", ".. int 1")
assertEquals(0 .. "", "0", ".. int 0")
assertEquals(-5 .. "", "-5", ".. int -5")

-- 24.2. Integer-valued floats must show .0 suffix
assertEquals(1.0 .. "", "1.0", ".. float 1.0")
assertEquals(2.0 .. "", "2.0", ".. float 2.0")
assertEquals(-3.0 .. "", "-3.0", ".. float -3.0")

-- 24.3. 10.8*22 must produce Lua's "237.6" and not "237.60000000000002"
local r = 10.8 * 22
assertEquals(r .. "", "237.6", ".. 10.8*22 formats correctly")

-- 24.4. Special float values
assertEquals(1/0.0 .. "", "inf", ".. +inf")
assertEquals(-1/0.0 .. "", "-inf", ".. -inf")
assertEquals(0.0/0.0 .. "", "-nan", ".. nan")

-- 24.5. Non-integer floats
assertEquals(0.5 .. "", "0.5", ".. float 0.5")
assertEquals(3.14 .. "", "3.14", ".. float 3.14")

-- 24.6. LHS and RHS number coercion
assertEquals("x=" .. 42, "x=42", ".. string..int")
assertEquals("x=" .. 1.5, "x=1.5", ".. string..float")
assertEquals(99 .. "!", "99!", ".. int..string")
assertEquals(0.0 .. "!", "0.0!", ".. float..string")
12 changes: 0 additions & 12 deletions client/space_lua/context_errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,6 @@ Deno.test("Context error: indexing nil value includes message and ref", async ()
assertCtxErrorContains(e, code, ref, "attempt to index a nil value");
});

Deno.test("Context error: indexing with nil key includes message and ref", async () => {
const { e, code, ref } = await runAndCatch(
`
local t = {}
local k = nil
local x = t[k]
`,
"nil_key.lua",
);
assertCtxErrorContains(e, code, ref, "attempt to index with a nil key");
});

Deno.test("Context error: calling nil includes message and ref", async () => {
const { e, code, ref } = await runAndCatch(
`
Expand Down
5 changes: 3 additions & 2 deletions client/space_lua/eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
luaEnsureCloseStack,
LuaEnv,
luaEquals,
luaFormatNumber,
LuaFunction,
luaGet,
luaIndexValue,
Expand Down Expand Up @@ -333,10 +334,10 @@ export function luaOp(
return v as string;
}
if (typeof v === "number") {
return String(v);
return luaFormatNumber(v);
}
if (isTaggedFloat(v)) {
return String(v.value);
return luaFormatNumber(v.value, "float");
}
const t = luaTypeName(v);
throw new LuaRuntimeError(
Expand Down
4 changes: 0 additions & 4 deletions client/space_lua/language_core_test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,6 @@ local t = { a = 1, b = 2 }
assert(t.a == 1 and t.b == 2)
assert(t["a"] == 1 and t["b"] == 2)

-- Unpacking tables
local a, b = unpack({ 1, 2 })
assert(a == 1 and b == 2)

-- Scope tests
local a = 1
do
Expand Down
4 changes: 4 additions & 0 deletions client/space_lua/lua.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ Deno.test("[Lua] Load tests", async () => {
await runLuaTest("./stdlib/load_test.lua");
});

Deno.test("[Lua] Core language (truthiness)", async () => {
await runLuaTest("./stdlib/table_test.lua");
});

Deno.test("[Lua] Core language (length)", async () => {
await runLuaTest("./len_test.lua");
});
Expand Down
36 changes: 31 additions & 5 deletions client/space_lua/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,20 @@ export class LuaTable implements ILuaSettable, ILuaGettable {
const errSf = sf || LuaStackFrame.lostFrame;
const ctx = sf?.astCtx ?? EMPTY_CTX;

if (key === null || key === undefined) {
throw new LuaRuntimeError(
"table index is nil",
errSf,
);
}

if (typeof key === "number" && isNaN(key)) {
throw new LuaRuntimeError(
"table index is NaN",
errSf,
);
}

if (this.has(key)) {
return this.rawSet(key, value, numType);
}
Expand Down Expand Up @@ -1243,11 +1257,10 @@ export function luaGet(
errSf,
);
}

// In Lua reading with a nil key returns nil silently
if (key === null || key === undefined) {
throw new LuaRuntimeError(
`attempt to index with a nil key`,
errSf,
);
return null;
}

if (obj instanceof LuaTable || obj instanceof LuaEnv) {
Expand Down Expand Up @@ -1275,14 +1288,27 @@ export function luaGet(
export function luaLen(
obj: any,
sf?: LuaStackFrame,
): number {
raw = false,
): number | Promise<number> {
if (typeof obj === "string") {
return obj.length;
}
if (Array.isArray(obj)) {
return obj.length;
}
if (obj instanceof LuaTable) {
// Check __len metamethod unless raw access is requested
if (!raw) {
const mt = getMetatable(obj, sf || LuaStackFrame.lostFrame);
const mm = mt ? mt.rawGet("__len") : null;
if (mm !== undefined && mm !== null) {
const r = luaCall(mm, [obj], (sf?.astCtx ?? {}) as ASTCtx, sf);
if (isPromise(r)) {
return (r as Promise<any>).then((v: any) => Number(singleResult(v)));
}
return Number(singleResult(r));
}
}
return obj.rawLength;
}

Expand Down
50 changes: 37 additions & 13 deletions client/space_lua/stdlib.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
getMetatable,
type ILuaFunction,
isILuaFunction,
isLuaTable,
Expand All @@ -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";
Expand All @@ -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(
Expand Down Expand Up @@ -121,23 +124,41 @@ export const eachFunction = new LuaBuiltinFunction(
},
);

const unpackFunction = new LuaBuiltinFunction(async (sf, t: LuaTable) => {
const values: LuaValue[] = [];
for (let i = 1; i <= (t as any).length; i++) {
values.push(await luaGet(t, i, sf.astCtx ?? null, sf));
}
return new LuaMultiRes(values);
});

const typeFunction = new LuaBuiltinFunction(
(_sf, value: LuaValue): string | Promise<string> => {
return luaTypeOf(value);
},
);

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<string> => {
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<any>).then(unwrap);
}
return unwrap(r);
}
}
return luaToString(value);
},
);

const tonumberFunction = new LuaBuiltinFunction(
(sf, value: LuaValue, base?: number) => {
Expand Down Expand Up @@ -255,7 +276,7 @@ const setmetatableFunction = new LuaBuiltinFunction(

const rawlenFunction = new LuaBuiltinFunction(
(_sf, value: LuaValue) => {
return luaLen(value, _sf);
return luaLen(value, _sf, true);
},
);

Expand Down Expand Up @@ -446,13 +467,16 @@ export function luaBuildStandardEnv() {
const env = new LuaEnv();
// _G global
env.set("_G", env);
// Lua version string - for now it signals Lua 5.4 compatibility with
// selective 5.5 features; kept non-standard so callers can distinguish
// Space Lua from a plain Lua runtime.
env.set("_VERSION", "Lua 5.4+");
// Top-level builtins
env.set("print", printFunction);
env.set("assert", assertFunction);
env.set("type", typeFunction);
env.set("tostring", tostringFunction);
env.set("tonumber", tonumberFunction);
env.set("unpack", unpackFunction);
env.set("select", selectFunction);
env.set("next", nextFunction);
// Iterators
Expand Down
27 changes: 27 additions & 0 deletions client/space_lua/stdlib/global_test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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")
27 changes: 25 additions & 2 deletions client/space_lua/stdlib/math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
LuaRuntimeError,
LuaTable,
} from "../runtime.ts";
import { isNegativeZero, isTaggedFloat } from "../numeric.ts";
import { isNegativeZero, isTaggedFloat, makeLuaFloat } from "../numeric.ts";
import { LuaPRNG } from "./prng.ts";

// One PRNG per module load, auto-seeded at startup
Expand Down Expand Up @@ -44,6 +44,28 @@ export const mathApi = new LuaTable({
}
return null;
}),

/**
* If the value x is representable as a Lua integer, returns an integer
* with that value. Otherwise returns nil.
* Strings are NOT accepted — only Lua number values.
*/
tointeger: new LuaBuiltinFunction((_sf, x?: any) => {
if (typeof x === "number") {
return Number.isInteger(x) && isFinite(x) ? x : null;
}
if (isTaggedFloat(x)) {
const n = x.value;
return Number.isInteger(n) && isFinite(n) ? n : null;
}
if (typeof x === "string") {
const n = untagNumber(x); // Number(x) coerces the string
if (isNaN(n) || !isFinite(n) || !Number.isInteger(n)) return null;
return n;
}
return null;
}),

/**
* When called without arguments, returns a pseudo-random float with
* uniform distribution in the range [0,1). When called with two
Expand Down Expand Up @@ -91,7 +113,8 @@ export const mathApi = new LuaTable({
modf: new LuaBuiltinFunction((_sf, x: number) => {
const xn = untagNumber(x);
const int = Math.trunc(xn);
const frac = xn - int;
// Guarantee that the `frac` part is always Lua float
const frac = makeLuaFloat(xn - int);
return new LuaMultiRes([int, frac]);
}),

Expand Down
Loading