From 9419cdcd9be61908330e1dce68a9156dbb911d23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Jan=20Fialka?= Date: Wed, 28 Jan 2026 09:05:23 +0100 Subject: [PATCH] Space Lua: `` attribute, `__close` metamethod and extensive tests (#1791) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Matouš Jan Fialka --- client/space_lua/ast.ts | 3 + client/space_lua/close_attribute.test.ts | 2016 ++++++++++++++++++++++ client/space_lua/const_attribute.test.ts | 4 +- client/space_lua/eval.ts | 907 +++++----- client/space_lua/labels.ts | 39 +- client/space_lua/parse.ts | 89 +- client/space_lua/rp.ts | 19 +- client/space_lua/runtime.ts | 262 ++- client/space_lua/stdlib.ts | 89 +- client/space_lua/util.ts | 8 +- 10 files changed, 2900 insertions(+), 536 deletions(-) create mode 100644 client/space_lua/close_attribute.test.ts diff --git a/client/space_lua/ast.ts b/client/space_lua/ast.ts index bdaf37c1..e607c34c 100644 --- a/client/space_lua/ast.ts +++ b/client/space_lua/ast.ts @@ -20,6 +20,8 @@ export type LuaBlock = { needsEnv?: boolean; // true if this block itself (not nested ones) defines at least one label hasLabelHere?: boolean; + // true if this block itself (not nested ones) may create to-be-closed values + hasCloseHere?: boolean; } & ASTContext; // STATEMENTS @@ -141,6 +143,7 @@ export type LuaLocalStatement = { export enum LuaAttribute { Const = "const", + Close = "close", } export type LuaAttName = { diff --git a/client/space_lua/close_attribute.test.ts b/client/space_lua/close_attribute.test.ts new file mode 100644 index 00000000..fa062040 --- /dev/null +++ b/client/space_lua/close_attribute.test.ts @@ -0,0 +1,2016 @@ +import { + assertEquals, + assertInstanceOf, + assertStringIncludes, +} from "@std/assert"; +import { isPromise } from "./rp.ts"; +import { parse } from "./parse.ts"; +import { evalStatement } from "./eval.ts"; +import { LuaEnv, LuaRuntimeError, LuaStackFrame } from "./runtime.ts"; +import { luaBuildStandardEnv } from "./stdlib.ts"; + +async function evalBlock(code: string, env?: LuaEnv): Promise { + const ast = parse(code); + const G = luaBuildStandardEnv(); + const base = env ?? new LuaEnv(G); + const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx); + const r = evalStatement(ast, base, sf, false); + + if (isPromise(r)) { + await r; + } + return base; +} + +async function runAndCatch(code: string, ref = "close_attribute.lua") { + const ast = parse(code, { ref }); + const G = luaBuildStandardEnv(); + const env = new LuaEnv(G); + const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx); + + try { + const r = evalStatement(ast, env, sf, false); + if (isPromise(r)) { + await r; + } + throw new Error("Expected error but evaluation succeeded"); + } catch (e: unknown) { + return { e, code, ref }; + } +} + +async function runAndCatchEnv(code: string, ref = "close_attribute.lua") { + const ast = parse(code, { ref }); + const G = luaBuildStandardEnv(); + const env = new LuaEnv(G); + const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx); + let err: unknown = null; + + try { + const r = evalStatement(ast, env, sf, false); + if (isPromise(r)) { + await r; + } + } catch (e: unknown) { + err = e; + } + + return { err, env }; +} + +// 1. parsing and static validation + +Deno.test("close: parse ok", () => { + parse( + ` + local x = {} + `, + { ref: "close_parse.lua" }, + ); +}); + +Deno.test("close: parse ok without init", () => { + parse( + ` + do + local x + end + `, + { ref: "close_no_init.lua" }, + ); +}); + +Deno.test("close: local list only one", () => { + try { + parse( + ` + do + local a, b = {}, {} + end + `, + { ref: "multi_close.lua" }, + ); + throw new Error("Expected parse error"); + } catch (e) { + assertStringIncludes(String((e as any)?.message ?? e), ""); + assertStringIncludes(String((e as any)?.message ?? e), "local list"); + } +}); + +Deno.test("close: parse ok", () => { + parse( + ` + do + local x = 1 + end + `, + { ref: "const_parse.lua" }, + ); +}); + +Deno.test("close: and parse ok", () => { + parse( + ` + do + local x = 1 + local y = setmetatable({}, { + __close = function() + end + }) + end + `, + { ref: "const_and_close_parse.lua" }, + ); +}); + +Deno.test("close: invalid goto into scope", () => { + try { + parse( + ` + do + goto L + local x = setmetatable({}, { + __close = function() + end + }) + ::L:: + end + `, + { ref: "goto_into_scope.lua" }, + ); + throw new Error("Expected parse error"); + } catch (e) { + assertStringIncludes(String((e as any)?.message ?? e), "goto"); + } +}); + +// 2. basic scope exit + +Deno.test("close: nil ignored; false is non-closable", async () => { + const { e, ref, code } = await runAndCatch( + ` + do + local x = nil + local y = false + end + `, + "close_nil_false.lua", + ); + + assertStringIncludes(ref, "close_nil_false.lua"); + assertStringIncludes(code, "local y = false"); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "non-closable"); +}); + +Deno.test("close: normal close gets nil error", async () => { + const env = await evalBlock(` + t = {} + + do + local x = setmetatable({"X"}, { + __close = function(self, err) + if err == nil then + table.insert(t, "nil") + else + table.insert(t, "not-nil") + end + end + }) + end + `); + + assertEquals((env.get("t") as any).get(1), "nil"); +}); + +Deno.test("close: close order", async () => { + const env = await evalBlock(` + t = {} + + do + local a = setmetatable({1}, { + __close = function(self) + table.insert(t, self[1]) + end + }) + + local b = setmetatable({2}, { + __close = function(self) + table.insert(t, self[1]) + end + }) + end + `); + + assertEquals((env.get("t") as any).get(1), 2); + assertEquals((env.get("t") as any).get(2), 1); +}); + +Deno.test("close: shadowed variables close independently", async () => { + const env = await evalBlock(` + t = {} + + do + local x = setmetatable({"OUTER"}, { + __close = function(self) table.insert(t, "close-" .. self[1]) end + }) + + do + local x = setmetatable({"INNER"}, { + __close = function(self) table.insert(t, "close-" .. self[1]) end + }) + table.insert(t, "inner-end") + end + + table.insert(t, "middle") + end + + table.insert(t, "outer-end") + `); + + const t = env.get("t") as any; + + assertEquals(t.get(1), "inner-end"); + assertEquals(t.get(2), "close-INNER"); + assertEquals(t.get(3), "middle"); + assertEquals(t.get(4), "close-OUTER"); + assertEquals(t.get(5), "outer-end"); +}); + +Deno.test("close: metatable swap after mark closes with new __close", async () => { + const env = await evalBlock(` + t = {} + + do + local mt1 = { + __close = function() + table.insert(t, "close-1") + end + } + local mt2 = { + __close = function() + table.insert(t, "close-2") + end + } + + local x = setmetatable({}, mt1) + setmetatable(x, mt2) + end + `); + + const t = env.get("t") as any; + + // Lua uses the metamethod at close time; this verifies close-time + // lookup + assertEquals(t.get(1), "close-2"); +}); + +Deno.test("close: replacing __close function affects close-time behavior", async () => { + const env = await evalBlock(` + t = {} + + do + local mt = { + __close = function() + table.insert(t, "close-1") + end + } + + local x = setmetatable({}, mt) + + mt.__close = function() + table.insert(t, "close-2") + end + end + `); + + const t = env.get("t") as any; + assertEquals(t.length, 1); + assertEquals(t.get(1), "close-2"); +}); + +// 3. errors and unwinding + +Deno.test("close: non-closable init", async () => { + const { e } = await runAndCatch(` + do + local x = {} + end + `); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "non-closable"); +}); + +Deno.test("close: reassign after implicit nil errors", async () => { + const { e } = await runAndCatch( + ` + do + local a + a = 1 + end + `, + "close_reassign_after_implicit_nil.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes( + (e as LuaRuntimeError).message, + "attempt to assign to const variable 'a'", + ); +}); + +Deno.test("close: reassign after explicit nil errors", async () => { + const { e } = await runAndCatch( + ` + do + local a = nil + a = 1 + end + `, + "close_reassign_after_explicit_nil.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes( + (e as LuaRuntimeError).message, + "attempt to assign to const variable 'a'", + ); +}); + +Deno.test("close: reassign after false is non-closable", async () => { + const { e } = await runAndCatch( + ` + do + local a = false + a = 1 + end + `, + "close_reassign_after_false.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "non-closable"); +}); + +Deno.test("close: reassign after closable value errors", async () => { + const { e } = await runAndCatch( + ` + do + local a = setmetatable({"A"}, { + __close = function(self) + end + }) + a = 1 + end + `, + "close_reassign_after_closable.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes( + (e as LuaRuntimeError).message, + "attempt to assign to const variable 'a'", + ); +}); + +Deno.test("close: reassignment through closure is non-closable", async () => { + const { e } = await runAndCatch( + ` + do + local a = false + + local function g() + a = nil + end + + g() + end + `, + "close_reassign_through_closure.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "non-closable"); +}); + +Deno.test("close: initializer true is non-closable", async () => { + const { e } = await runAndCatch( + ` + do + local a = true + end + `, + "close_init_true.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "non-closable"); +}); + +Deno.test("close: initializer integer is non-closable", async () => { + const { e } = await runAndCatch( + ` + do + local a = 1 + end + `, + "close_init_int.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "non-closable"); +}); + +Deno.test("close: initializer float is non-closable", async () => { + const { e } = await runAndCatch( + ` + do + local a = 3.14 + end + `, + "close_init_float.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "non-closable"); +}); + +Deno.test("close: initializer string is non-closable", async () => { + const { e } = await runAndCatch( + ` + do + local a = "string" + end + `, + "close_init_string.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "non-closable"); +}); + +Deno.test("close: initializer function is non-closable", async () => { + const { e } = await runAndCatch( + ` + do + local a = function() end + end + `, + "close_init_function.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "non-closable"); +}); + +Deno.test("close: __close called exactly once on normal exit", async () => { + const env = await evalBlock(` + t = {} + + do + local called = 0 + local x = setmetatable({}, { + __close = function() + called = called + 1 + table.insert(t, called) + end + }) + end + `); + + const t = env.get("t") as any; + + assertEquals(t.length, 1); + assertEquals(t.get(1), 1); +}); + +Deno.test("close: async __close awaited on normal exit", async () => { + const G = luaBuildStandardEnv(); + const env = new LuaEnv(G); + + // Promise-returning function: forces async close path deterministically. + (env as any).setLocal( + "delay", + (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), + ); + + await evalBlock( + ` + t = {} + + do + local x = setmetatable({}, { + __close = function() + delay(10) + table.insert(t, "closed") + end + }) + end + `, + env, + ); + + const t = env.get("t") as any; + assertEquals(t.length, 1); + assertEquals(t.get(1), "closed"); +}); + +Deno.test("close: __close called exactly once on error unwind", async () => { + const { err: e, env } = await runAndCatchEnv( + ` + t = {} + called = 0 + + do + local x = setmetatable({}, { + __close = function(self, err) + called = called + 1 + table.insert(t, called) + end + }) + error("boom") + end + `, + "close_once_on_error.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "boom"); + + const t = env.get("t") as any; + assertEquals(t.length, 1); + assertEquals(t.get(1), 1); +}); + +Deno.test("close: async __close awaited on error unwind", async () => { + const code = ` + t = {} + + do + local x = setmetatable({"X"}, { + __close = function(self, err) + delay(10) + table.insert(t, "close-" .. self[1] .. "-" .. tostring(err)) + end + }) + error("boom") + end + `; + + const ast = parse(code, { ref: "close_async_unwind.lua" }); + const G = luaBuildStandardEnv(); + const env = new LuaEnv(G); + const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx); + + env.setLocal( + "delay", + (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), + ); + + let e: unknown = null; + try { + const r = evalStatement(ast, env, sf, false); + if (isPromise(r)) { + await r; + } + } catch (err: unknown) { + e = err; + } + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "boom"); + + const t = env.get("t") as any; + assertEquals(t.length, 1); + assertEquals(t.get(1), "close-X-boom"); +}); + +Deno.test("close: error in __close inside vararg function", async () => { + const { e } = await runAndCatch( + ` + local function mk() + return setmetatable({}, { + __close = function() + error("closefail") + end + }) + end + + local function f(...) + local x = mk() + return ... + end + + f(1, 2, 3) + `, + "close_vararg_close_error.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "closefail"); +}); + +Deno.test("close: __close not callable", async () => { + const { e } = await runAndCatch( + ` + do + local x = setmetatable({}, { + __close = 1 + }) + end + `, + "close_not_callable.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "non-closable"); +}); + +Deno.test("close: __close not found via __index", async () => { + const { e } = await runAndCatch( + ` + do + local mt = { + __index = { + __close = function() + end + } + } + + local x = setmetatable({}, mt) + end + `, + "close_index_metaclose.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "non-closable"); +}); + +Deno.test("close: mutate __close", async () => { + const { e } = await runAndCatch( + ` + do + local mt = { + __close = function() + end + } + local x = setmetatable({}, mt) + mt.__close = 1 + end + `, + "close_mutate.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "not callable"); +}); + +Deno.test("close: remove __close after mark errors at close time", async () => { + const { err: e } = await runAndCatchEnv( + ` + do + local mt = { + __close = function() + end + } + local x = setmetatable({}, mt) + + mt.__close = nil + end + `, + "close_remove_metaclose.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "not callable"); +}); + +Deno.test("close: __close callable via __call", async () => { + const { e } = await runAndCatch( + ` + do + local c = setmetatable({}, { + __call = function(self, obj, err) + table.insert(t, "called") + end + }) + + local x = setmetatable({}, { + __close = c + }) + end + `, + "close_callable_via_call.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "non-closable"); +}); + +Deno.test("close: error after close var init still closes", async () => { + const { e } = await runAndCatch( + ` + t = {} + + local function mk() + return setmetatable({"A"}, { + __close = function(self) + table.insert(t, "close-" .. self[1]) + end + }) + end + + do + local a = mk() + error("boom") + end + `, + "close_error_after_init_closes.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "boom"); +}); + +Deno.test("close: close gets error", async () => { + const { e } = await runAndCatch(` + t = {} + + local function mk() + return setmetatable({}, { + __close = function(self, err) + table.insert(t, tostring(err)) + end + }) + end + + do + local a = mk() + error("boom") + end + `); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "boom"); +}); + +Deno.test("close: close error", async () => { + const { e } = await runAndCatch( + ` + do + local x = setmetatable({}, { + __close = function() + error("closefail") + end + }) + end + `, + "close_error.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "closefail"); +}); + +Deno.test("close: close error on error", async () => { + const { e } = await runAndCatch( + ` + do + local x = setmetatable({}, { + __close = function() + error("closefail") + end + }) + + error("boom") + end + `, + "close_error_on_error.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "closefail"); +}); + +Deno.test("close: close errors stop", async () => { + const { err: e, env } = await runAndCatchEnv( + ` + t = {} + + do + local a = setmetatable({"A"}, { + __close = function(self) + table.insert(t, "close-" .. self[1]) + error("c1") + end + }) + + local b = setmetatable({"B"}, { + __close = function(self) + table.insert(t, "close-" .. self[1]) + error("c2") + end + }) + end + `, + "close_errors_stop.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "c2"); + + // verify that both closes were attempted (Lua 5.4), but the first + // close error (from B, closed first) is the reported error + const t = env.get("t") as any; + assertEquals(t.length, 2); + assertEquals(t.get(1), "close-B"); + assertEquals(t.get(2), "close-A"); +}); + +Deno.test("close: multiple closers unwind on error", async () => { + const { err: e, env } = await runAndCatchEnv( + ` + t = {} + + local function mk(x) + return setmetatable({x}, { + __close = function(self, err) + table.insert(t, "close-" .. self[1] .. "-" .. tostring(err)) + end + }) + end + + do + local a = mk("A") + local b = mk("B") + error("boom") + end + `, + "close_multi_unwind.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "boom"); + + // verify both were closed in reverse order with the error object + const t = env.get("t") as any; + assertEquals(t.length, 2); + assertEquals(t.get(1), "close-B-boom"); + assertEquals(t.get(2), "close-A-boom"); +}); + +Deno.test("close: close errors stop during unwind", async () => { + const { err: e, env } = await runAndCatchEnv( + ` + t = {} + + local function mk(x, fail) + return setmetatable({x}, { + __close = function(self, err) + table.insert(t, "close-" .. self[1] .. "-" .. tostring(err)) + if fail then + error("closefail-" .. self[1]) + end + end + }) + end + + do + local a = mk("A", false) + local b = mk("B", true) + error("boom") + end + `, + "close_unwind_close_error.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "closefail-B"); + + // verify both closes were attempted (Lua 5.4), but the first close + // error (from B, closed first) is the reported error + const t = env.get("t") as any; + assertEquals(t.length, 2); + assertEquals(t.get(1), "close-B-boom"); + assertEquals(t.get(2), "close-A-boom"); +}); + +Deno.test("close: complex assignment error closes prior", async () => { + const { err: e, env } = await runAndCatchEnv( + ` + t = {} + + local function f1() + return setmetatable({}, { + __close = function() + table.insert(t, "a_closed") + end + }) + end + + local function f2() + error("assign_error") + end + + do + local a = f1() + local b = f2() + end + `, + "complex_assign_error.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "assign_error"); + + // verify 'a' was closed despite 'b' failing to assign + const t = env.get("t") as any; + assertEquals(t.get(1), "a_closed"); +}); + +Deno.test("close: multi-init closes prior when later init errors", async () => { + const { err: e, env } = await runAndCatchEnv( + ` + t = {} + + local function mk() + return setmetatable({}, { + __close = function() + table.insert(t, "a_closed") + end + }) + end + + local function fail() + error("boom") + end + + do + local a, b = mk(), fail() + end + `, + "close_multi_init_later_error.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "boom"); + + const t = env.get("t") as any; + assertEquals(t.get(1), "a_closed"); +}); + +Deno.test("close: async close error reported; later closers still run", async () => { + const G = luaBuildStandardEnv(); + const env = new LuaEnv(G); + + env.setLocal( + "delay", + (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), + ); + + const { err: e } = await (async () => { + const code = ` + t = {} + + do + local a = setmetatable({"A"}, { + __close = function(self, err) + delay(5) + table.insert(t, "close-" .. self[1]) + end + }) + + local b = setmetatable({"B"}, { + __close = function(self, err) + delay(5) + table.insert(t, "close-" .. self[1]) + error("b-closefail") + end + }) + end + `; + const ast = parse(code, { ref: "close_async_close_error.lua" }); + const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx); + + let err: unknown = null; + try { + const r = evalStatement(ast, env, sf, false); + if (isPromise(r)) await r; + } catch (x) { + err = x; + } + return { err }; + })(); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "b-closefail"); + + const t = env.get("t") as any; + // B closes first and errors, but A must still be closed in Lua 5.4 intent + assertEquals(t.length, 2); + assertEquals(t.get(1), "close-B"); + assertEquals(t.get(2), "close-A"); +}); + +// 4. control flow exits + +Deno.test("close: return closes", async () => { + const env = await evalBlock(` + t = {} + + local function f() + do + local x = setmetatable({"X"}, { + __close = function(self) + table.insert(t, "close-" .. self[1]) + end + }) + return 1 + end + end + + table.insert(t, f()) + `); + + assertEquals((env.get("t") as any).get(1), "close-X"); + assertEquals((env.get("t") as any).get(2), 1); +}); + +Deno.test("close: return in if closes", async () => { + const env = await evalBlock(` + t = {} + + local function f() + do + local x = setmetatable({"X"}, { + __close = function(self) + table.insert(t, "close-" .. self[1]) + end + }) + if true then + return 2 + end + return 3 + end + end + + table.insert(t, f()) + `); + + assertEquals((env.get("t") as any).get(1), "close-X"); + assertEquals((env.get("t") as any).get(2), 2); +}); + +Deno.test("close: goto closes", async () => { + const env = await evalBlock(` + t = {} + + do + local x = setmetatable({"X"}, { + __close = function(self) + table.insert(t, "close-" .. self[1]) + end + }) + + goto L1 + ::L2:: + error("bad") + ::L1:: + goto L3 + ::L3:: + end + `); + + assertEquals((env.get("t") as any).get(1), "close-X"); +}); + +Deno.test("close: goto nested", async () => { + const env = await evalBlock(` + t = {} + + do + do + local a = setmetatable({"A"}, { + __close = function(self) + table.insert(t, "close-" .. self[1]) + end + }) + do + local b = setmetatable({"B"}, { + __close = function(self) + table.insert(t, "close-" .. self[1]) + end + }) + goto L + end + end + ::L:: + end + `); + + assertEquals((env.get("t") as any).get(1), "close-B"); + assertEquals((env.get("t") as any).get(2), "close-A"); +}); + +Deno.test("close: return to-be-closed variable", async () => { + const env = await evalBlock(` + t = {} + + local function f() + local x = setmetatable({"X"}, { + __close = function(self) + table.insert(t, "closed") + end + }) + return x + end + + local r = f() + table.insert(t, type(r)) + `); + + const t = env.get("t") as any; + assertEquals(t.get(1), "closed"); + assertEquals(t.get(2), "table"); +}); + +// 5. generic-for loop-scoped closing + +Deno.test("close: for-in no close", async () => { + const env = await evalBlock(` + t = {} + + local function iter(state, ctrl) + if ctrl == nil then + return 1, "v" + end + return nil + end + + local function gen() + return iter, nil, nil + end + + for k, v in gen() do + table.insert(t, "body") + end + `); + + assertEquals((env.get("t") as any).get(1), "body"); + assertEquals((env.get("t") as any).length, 1); +}); + +Deno.test("close: for-in false close is non-closable", async () => { + const { e } = await runAndCatch( + ` + local function iter(state, ctrl) + if ctrl == nil then + return 1 + end + return nil + end + + local function gen() + return iter, nil, nil, false + end + + for k in gen() do + end + `, + "for_in_false_close.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "non-closable"); +}); + +Deno.test("close: for-in bad close", async () => { + const { e } = await runAndCatch( + ` + local function iter(state, ctrl) + if ctrl == nil then + return 1 + end + return nil + end + + local function gen() + return iter, nil, nil, {} + end + + for k in gen() do + end + `, + "for_in_bad_close.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "non-closable"); +}); + +Deno.test("close: for-in updates control", async () => { + const env = await evalBlock(` + t = {} + local calls = 0 + + local function iter(state, ctrl) + calls = calls + 1 + if ctrl == nil then + return 1 + end + return nil + end + + local function gen() + local closing = setmetatable({"C"}, { + __close = function(self) + table.insert(t, "close-" .. self[1]) + end + }) + + return iter, nil, nil, closing + end + + for k in gen() do + table.insert(t, "body") + end + + table.insert(t, calls) + `); + + assertEquals((env.get("t") as any).get(1), "body"); + assertEquals((env.get("t") as any).get(2), "close-C"); + assertEquals((env.get("t") as any).get(3), 2); +}); + +Deno.test("close: for-in closes", async () => { + const env = await evalBlock(` + t = {} + + local function iter(state, ctrl) + if ctrl == nil then + return 1, "v" + end + return nil + end + + local function gen() + local closing = setmetatable({"C"}, { + __close = function(self) + table.insert(t, "close-" .. self[1]) + end + }) + + return iter, nil, nil, closing + end + + for k, v in gen() do + table.insert(t, "body") + end + `); + + assertEquals((env.get("t") as any).get(1), "body"); + assertEquals((env.get("t") as any).get(2), "close-C"); +}); + +Deno.test("close: for-in async iterator and async closing are awaited", async () => { + const G = luaBuildStandardEnv(); + const env = new LuaEnv(G); + + (env as any).setLocal( + "delay", + (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), + ); + + await evalBlock( + ` + t = {} + local calls = 0 + + local function iter(state, ctrl) + calls = calls + 1 + delay(5) + if ctrl == nil then + return 1 + end + return nil + end + + local function gen() + local closing = setmetatable({"C"}, { + __close = function(self, err) + delay(5) + table.insert(t, "close-" .. self[1] .. "-" .. tostring(err)) + end + }) + return iter, nil, nil, closing + end + + for k in gen() do + table.insert(t, "body") + end + + table.insert(t, calls) + `, + env, + ); + + const t = env.get("t") as any; + assertEquals(t.get(1), "body"); + assertEquals(t.get(2), "close-C-nil"); + assertEquals(t.get(3), 2); +}); + +Deno.test("close: loop scope", async () => { + const env = await evalBlock(` + t = {} + + local function iter(state, ctrl) + if ctrl == nil then + return 1 + end + return nil + end + + local function gen() + local closing = setmetatable({"C"}, { + __close = function(self) + table.insert(t, "close-" .. self[1]) + end + }) + return iter, nil, nil, closing + end + + for k in gen() do + table.insert(t, "body") + end + + table.insert(t, "after") + `); + + assertEquals((env.get("t") as any).get(1), "body"); + assertEquals((env.get("t") as any).get(2), "close-C"); + assertEquals((env.get("t") as any).get(3), "after"); +}); + +Deno.test("close: for-in closing closes before outer block closers", async () => { + const env = await evalBlock(` + t = {} + + local function iter(state, ctrl) + if ctrl == nil then + return 1 + end + return nil + end + + do + local outer = setmetatable({"O"}, { + __close = function(self) + table.insert(t, "close-" .. self[1]) + end + }) + + local function gen() + local closing = setmetatable({"L"}, { + __close = function(self) + table.insert(t, "close-" .. self[1]) + end + }) + return iter, nil, nil, closing + end + + for k in gen() do + table.insert(t, "body") + end + + table.insert(t, "after-loop") + end + + table.insert(t, "after-block") + `); + + const t = env.get("t") as any; + + // Required ordering: + // body runs + // loop closing value closes at loop end + // after-loop runs + // outer closes when leaving do-block + // after-block runs + assertEquals(t.get(1), "body"); + assertEquals(t.get(2), "close-L"); + assertEquals(t.get(3), "after-loop"); + assertEquals(t.get(4), "close-O"); + assertEquals(t.get(5), "after-block"); +}); + +Deno.test("close: for-in closes on break", async () => { + const env = await evalBlock(` + t = {} + + local function iter(state, ctrl) + if ctrl == nil then + return 1, "v" + end + return 2, "w" + end + + local function gen() + local closing = setmetatable({"C"}, { + __close = function(self) + table.insert(t, "close-" .. self[1]) + end + }) + + return iter, nil, nil, closing + end + + for k, v in gen() do + table.insert(t, "body") + break + end + `); + + assertEquals((env.get("t") as any).get(1), "body"); + assertEquals((env.get("t") as any).get(2), "close-C"); +}); + +Deno.test("close: for-in closes on error", async () => { + const { err: e, env } = await runAndCatchEnv(` + t = {} + + local function iter(state, ctrl) + if ctrl == nil then + return 1, "v" + end + return nil + end + + local function gen() + local closing = setmetatable({"C"}, { + __close = function(self, err) + table.insert(t, "close-" .. self[1] .. "-" .. tostring(err)) + end + }) + + return iter, nil, nil, closing + end + + for k, v in gen() do + error("boom") + end + `); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "boom"); + + // verify closer received error + const t = env.get("t") as any; + assertEquals(t.get(1), "close-C-boom"); +}); + +Deno.test("close: for-in closes if iterator errors", async () => { + const { err: e, env } = await runAndCatchEnv( + ` + t = {} + + local function iter(state, ctrl) + error("iterboom") + end + + local function gen() + local closing = setmetatable({"C"}, { + __close = function(self, err) + table.insert(t, "close-" .. self[1] .. "-" .. tostring(err)) + end + }) + + return iter, nil, nil, closing + end + + for k in gen() do + end + `, + "for_in_iterator_error.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "iterboom"); + + // verify closer received error + const t = env.get("t") as any; + assertEquals(t.get(1), "close-C-iterboom"); +}); + +Deno.test("close: return inside for-in body closes loop closing value", async () => { + const env = await evalBlock(` + t = {} + + local function iter(state, ctrl) + if ctrl == nil then + return 1 + end + return nil + end + + local function gen() + local closing = setmetatable({"C"}, { + __close = function(self, err) + table.insert(t, "close-" .. self[1] .. "-" .. tostring(err)) + end + }) + return iter, nil, nil, closing + end + + local function f() + for k in gen() do + return 1 + end + return 2 + end + + table.insert(t, f()) + `); + + const t = env.get("t") as any; + assertEquals(t.get(1), "close-C-nil"); + assertEquals(t.get(2), 1); +}); + +Deno.test("close: error inside for-in body closes loop closing value with error", async () => { + const { err: e, env } = await runAndCatchEnv( + ` + t = {} + + local function iter(state, ctrl) + if ctrl == nil then + return 1 + end + return nil + end + + local function gen() + local closing = setmetatable({"C"}, { + __close = function(self, err) + table.insert(t, "close-" .. self[1] .. "-" .. tostring(err)) + end + }) + return iter, nil, nil, closing + end + + for k in gen() do + error("boom") + end + `, + "for_in_body_error_close_value.lua", + ); + + assertInstanceOf(e, LuaRuntimeError); + assertStringIncludes((e as LuaRuntimeError).message, "boom"); + + const t = env.get("t") as any; + assertEquals(t.get(1), "close-C-boom"); +}); + +// 6. pairs integration + +Deno.test("close: pairs closes", async () => { + const env = await evalBlock(` + t = {} + local closed = false + + local a = {10, 20} + + local function iter(state, ctrl) + if ctrl == nil then + return 1, state[1] + end + return nil + end + + setmetatable(a, { + __pairs = function(self) + local tbc = setmetatable({"P"}, { + __close = function() + table.insert(t, "close-P") + closed = true + end + }) + return iter, self, nil, tbc + end + }) + + for k, v in pairs(a) do + table.insert(t, "body") + end + + if closed then + table.insert(t, "closed") + end + `); + + assertEquals((env.get("t") as any).get(1), "body"); + assertEquals((env.get("t") as any).get(2), "close-P"); + assertEquals((env.get("t") as any).get(3), "closed"); +}); + +// 7. protected calls + +Deno.test("close: pcall closes on success", async () => { + const env = await evalBlock(` + t = {} + + local function f() + local x = setmetatable({"X"}, { + __close = function(self, err) + table.insert(t, "close-" .. self[1] .. "-" .. tostring(err)) + end + }) + return 1 + end + + local ok, r = pcall(f) + if ok then + table.insert(t, r) + end + `); + + assertEquals((env.get("t") as any).get(1), "close-X-nil"); + assertEquals((env.get("t") as any).get(2), 1); +}); + +Deno.test("close: multi-return expansion binds and closes", async () => { + const env = await evalBlock(` + t = {} + + local function mk() + local obj = setmetatable({"A"}, { + __close = function(self, err) + table.insert(t, "close-" .. self[1] .. "-" .. tostring(err)) + end + }) + return obj, 99 + end + + do + local a, b = mk() + table.insert(t, b) + end + `); + + const t = env.get("t") as any; + assertEquals(t.get(1), 99); + assertEquals(t.get(2), "close-A-nil"); +}); + +Deno.test("close: pcall return closes with nil error", async () => { + const env = await evalBlock(` + t = {} + + local function mk() + return setmetatable({"R"}, { + __close = function(self, err) + table.insert(t, "close-" .. self[1] .. "-" .. tostring(err)) + end + }) + end + + local function f() + do + local x = mk() + return 7 + end + end + + local ok, r = pcall(f) + table.insert(t, ok) + table.insert(t, r) + `); + + const t = env.get("t") as any; + assertEquals(t.get(1), "close-R-nil"); + assertEquals(t.get(2), true); + assertEquals(t.get(3), 7); +}); + +Deno.test("close: pcall break closes with nil error", async () => { + const env = await evalBlock(` + t = {} + + local function mk() + return setmetatable({"B"}, { + __close = function(self, err) + table.insert(t, "close-" .. self[1] .. "-" .. tostring(err)) + end + }) + end + + local function f() + do + local x = mk() + while true do + break + end + end + return 1 + end + + local ok, r = pcall(f) + table.insert(t, ok) + table.insert(t, r) + `); + + const t = env.get("t") as any; + assertEquals(t.get(1), "close-B-nil"); + assertEquals(t.get(2), true); + assertEquals(t.get(3), 1); +}); + +Deno.test("close: pcall goto closes with nil error", async () => { + const env = await evalBlock(` + t = {} + + local function mk() + return setmetatable({"G"}, { + __close = function(self, err) + table.insert(t, "close-" .. self[1] .. "-" .. tostring(err)) + end + }) + end + + local function f() + do + local x = mk() + goto L + error("unreachable") + ::L:: + end + return 2 + end + + local ok, r = pcall(f) + table.insert(t, ok) + table.insert(t, r) + `); + + const t = env.get("t") as any; + assertEquals(t.get(1), "close-G-nil"); + assertEquals(t.get(2), true); + assertEquals(t.get(3), 2); +}); + +Deno.test("close: pcall closes on error", async () => { + const env = await evalBlock(` + t = {} + + local function f() + local x = setmetatable({"X"}, { + __close = function(self, err) + table.insert(t, "close-" .. self[1] .. "-" .. tostring(err)) + end + }) + error("boom") + end + + local ok, msg = pcall(f) + if not ok then + table.insert(t, tostring(msg)) + end + `); + + assertEquals((env.get("t") as any).get(1), "close-X-boom"); + assertEquals((env.get("t") as any).get(2), "boom"); +}); + +Deno.test("close: pcall close error overrides original and skips remaining closers", async () => { + const env = await evalBlock(` + t = {} + + local function mk(label, mode) + return setmetatable({label}, { + __close = function(self, err) + table.insert(t, "close-" .. self[1] .. "-" .. tostring(err)) + if mode == "error" then + error("closefail-" .. self[1]) + end + end + }) + end + + local function f() + do + local a = mk("A", "ok") + local b = mk("B", "error") + local c = mk("C", "ok") + error("boom") + end + end + + local ok, msg = pcall(f) + table.insert(t, ok) + table.insert(t, tostring(msg)) + `); + + const t = env.get("t") as any; + + // C closes first, then B closes and errors; A is still closed in Lua 5.4 + assertEquals(t.get(1), "close-C-boom"); + assertEquals(t.get(2), "close-B-boom"); + assertEquals(t.get(3), "close-A-boom"); + assertEquals(t.get(4), false); + assertEquals(t.get(5), "closefail-B"); + assertEquals(t.length, 5); +}); + +Deno.test("close: xpcall closes on error", async () => { + const env = await evalBlock(` + t = {} + + local function f() + local x = setmetatable({"X"}, { + __close = function(self, err) + table.insert(t, "close-" .. self[1] .. "-" .. tostring(err)) + end + }) + error("boom") + end + + local function h(err) + return "handled-" .. tostring(err) + end + + local ok, msg = xpcall(f, h) + if not ok then + table.insert(t, tostring(msg)) + end + `); + + assertEquals((env.get("t") as any).get(1), "close-X-boom"); + assertEquals((env.get("t") as any).get(2), "handled-boom"); +}); + +Deno.test("close: xpcall boundary contains __close errors", async () => { + const env = await evalBlock(` + t = {} + + local function f() + do + local x = setmetatable({}, { + __close = function(self, err) + table.insert(t, "close-err-" .. tostring(err)) + error("closefail") + end + }) + error("boom") + end + end + + local function h(err) + table.insert(t, "handler-" .. tostring(err)) + return "handled-" .. tostring(err) + end + + local ok, msg = xpcall(f, h) + table.insert(t, ok) + table.insert(t, tostring(msg)) + `); + + const t = env.get("t") as any; + + // close runs during unwind and sees original error + assertEquals(t.get(1), "close-err-boom"); + + // the close error overrides the original for `xpcall`, so the + // handler sees "closefail" + assertEquals(t.get(2), "handler-closefail"); + + assertEquals(t.get(3), false); + assertEquals(t.get(4), "handled-closefail"); + assertEquals(t.length, 4); +}); + +Deno.test("close: nested to-be-closed created inside __close", async () => { + const env = await evalBlock(` + t = {} + + local function mk(label) + return setmetatable({label}, { + __close = function(self, err) + table.insert(t, "close-" .. self[1] .. "-" .. tostring(err)) + end + }) + end + + do + local outer = setmetatable({"OUTER"}, { + __close = function(self, err) + table.insert(t, "outer-close-start-" .. tostring(err)) + do + local inner = mk("INNER") + table.insert(t, "inner-scope-end") + end + table.insert(t, "outer-close-end") + end + }) + table.insert(t, "body-end") + end + `); + + const t = env.get("t") as any; + + // Expected order: + // - body finishes + // - outer close begins + // - inner scope ends, so inner closes (during exec of outer __close) + // - outer close ends + assertEquals(t.get(1), "body-end"); + assertEquals(t.get(2), "outer-close-start-nil"); + assertEquals(t.get(3), "inner-scope-end"); + assertEquals(t.get(4), "close-INNER-nil"); + assertEquals(t.get(5), "outer-close-end"); +}); + +Deno.test("close: pcall boundary does not close closers created in pcall args", async () => { + const env = await evalBlock(` + t = {} + + local function mk(label) + return setmetatable({label}, { + __close = function(self) + table.insert(t, "close-" .. self[1]) + end + }) + end + + local function f() + table.insert(t, "in-f") + return 1 + end + + local function arg() + local x = mk("ARG") + table.insert(t, "arg") + return 123 + end + + local ok, r = pcall(f, arg()) + table.insert(t, ok) + table.insert(t, r) + `); + + const t = env.get("t") as any; + + // if `pcall` boundary is correct, ARG is closed at end of its own + // scope and `pcall` does not close it + assertEquals(t.get(1), "arg"); + assertEquals(t.get(2), "close-ARG"); + assertEquals(t.get(3), "in-f"); + assertEquals(t.get(4), true); + assertEquals(t.get(5), 1); +}); + +Deno.test("close: nested pcall boundaries", async () => { + const env = await evalBlock(` + t = {} + + local function mk(label) + return setmetatable({label}, { + __close = function(self, err) + table.insert(t, "close-" .. self[1] .. "-" .. tostring(err)) + end + }) + end + + local function inner() + local b = mk("B") + error("inner") + end + + local function outer() + local a = mk("A") + local ok, msg = pcall(inner) + table.insert(t, ok) + table.insert(t, tostring(msg)) + return 1 + end + + local ok, r = pcall(outer) + table.insert(t, ok) + table.insert(t, r) + `); + + const t = env.get("t") as any; + + // inner closes with "inner" outer continues, then outer closes with + // nil on success of outer itself + assertEquals(t.get(1), "close-B-inner"); + assertEquals(t.get(2), false); + assertEquals(t.get(3), "inner"); + assertEquals(t.get(4), "close-A-nil"); + assertEquals(t.get(5), true); + assertEquals(t.get(6), 1); +}); diff --git a/client/space_lua/const_attribute.test.ts b/client/space_lua/const_attribute.test.ts index 5f34b600..12c8c3c7 100644 --- a/client/space_lua/const_attribute.test.ts +++ b/client/space_lua/const_attribute.test.ts @@ -41,10 +41,10 @@ async function runAndCatch(code: string, ref = "const_attribute.lua") { Deno.test("const: Unknown attribute (parse-time)", () => { let threw = false; try { - parse(`local x = 1`, { ref: "unknown_attribute.lua" }); + parse(`local x = 1`, { ref: "unknown_attribute.lua" }); } catch (e: any) { threw = true; - assertStringIncludes(String(e?.message ?? e), "unknown attribute 'close'"); + assertStringIncludes(String(e?.message ?? e), "unknown attribute 'nope'"); } if (!threw) { throw new Error("Expected parse error for unknown attribute"); diff --git a/client/space_lua/eval.ts b/client/space_lua/eval.ts index cabd08f3..3e743210 100644 --- a/client/space_lua/eval.ts +++ b/client/space_lua/eval.ts @@ -16,12 +16,15 @@ import { jsToLuaValue, LuaBreak, luaCall, + luaCloseFromMark, + luaEnsureCloseStack, LuaEnv, luaEquals, LuaFunction, luaGet, luaIndexValue, type LuaLValueContainer, + luaMarkToBeClosed, LuaMultiRes, LuaReturn, LuaRuntimeError, @@ -334,47 +337,8 @@ export function evalExpression( case "Unary": { const u = asUnary(e); const value = evalExpression(u.argument, env, sf); - if (isPromise(value)) { - return value.then((value) => { - switch (u.operator) { - case "-": { - const arg = singleResult(value); - return unaryWithMeta( - arg, - "__unm", - u.ctx, - sf, - () => luaUnaryMinus(arg), - ); - } - case "+": { - return +singleResult(value); - } - case "not": { - return !luaTruthy(value); - } - case "~": { - const arg = singleResult(value); - return unaryWithMeta( - arg, - "__bnot", - u.ctx, - sf, - () => ~exactInt(arg, u.ctx, sf), - ); - } - case "#": { - return luaLengthOp(singleResult(value), u.ctx, sf); - } - default: { - throw new LuaRuntimeError( - `Unknown unary operator ${u.operator}`, - sf.withCtx(u.ctx), - ); - } - } - }); - } else { + + const apply = (value: LuaValue) => { switch (u.operator) { case "-": { const arg = singleResult(value); @@ -412,7 +376,9 @@ export function evalExpression( ); } } - } + }; + + return rpThen(value, apply); } case "Variable": @@ -615,54 +581,15 @@ function evalPrefixExpression( } } - // Unsure if part of the spec, but it seems to be common for lua implementations - // to evaluate all args before evaluating the callee - const parts = fc.args.map((arg: LuaExpression) => - evalExpression(arg, env, sf) - ); - const argsVal = rpAll(parts); + const argsVal = evalExpressions(fc.args, env, sf); const thenCall = (args: LuaValue[]) => luaCall(calleeVal, [...selfArgs, ...args], fc.ctx, sf); - if (isPromise(argsVal)) { - return (argsVal as Promise).then((argsResolved: any[]) => { - if (argsResolved.length === 0) return thenCall([]); - const out: LuaValue[] = []; - // All but last expression produce a single value - for (let i = 0; i < argsResolved.length - 1; i++) { - out.push(singleResult(argsResolved[i])); - } - // Last expression preserves multiple results - const last = argsResolved[argsResolved.length - 1]; - if (last instanceof LuaMultiRes) { - out.push(...last.flatten().values); - } else { - out.push(singleResult(last)); - } - return thenCall(out); - }); - } else { - const argsResolved = argsVal as LuaValue[]; - if (argsResolved.length === 0) return thenCall([]); - const out: LuaValue[] = []; - for (let i = 0; i < argsResolved.length - 1; i++) { - out.push(singleResult(argsResolved[i])); - } - const last = argsResolved[argsResolved.length - 1]; - if (last instanceof LuaMultiRes) { - out.push(...last.flatten().values); - } else { - out.push(singleResult(last)); - } - return thenCall(out); - } + return rpThen(argsVal, thenCall); }; - if (isPromise(prefixValue)) { - return (prefixValue as Promise).then(handleFunctionCall); - } else { - return handleFunctionCall(prefixValue); - } + + return rpThen(prefixValue, handleFunctionCall); } default: { throw new LuaRuntimeError( @@ -1109,9 +1036,206 @@ function evalExpressions( return out; }; - return isPromise(argsVal) - ? (argsVal as Promise).then(finalize) - : finalize(argsVal as LuaValue[]); + return rpThen(argsVal, finalize); +} + +type EvalBlockResult = + | void + | LuaValue[] + | GotoSignal + | Promise; + +function runStatementsNoGoto( + stmts: LuaStatement[], + execEnv: LuaEnv, + sf: LuaStackFrame, + returnOnReturn: boolean, + startIdx: number, +): void | LuaValue[] | Promise { + const processFrom = ( + idx: number, + ): void | LuaValue[] | Promise => { + for (let i = idx; i < stmts.length; i++) { + const result = evalStatement( + stmts[i], + execEnv, + sf, + returnOnReturn, + ); + if (isPromise(result)) { + return (result as Promise).then((res) => { + if (res !== undefined && !isGotoSignal(res)) { + return res; + } + if (isGotoSignal(res)) { + throw new LuaRuntimeError( + "unexpected goto signal", + sf.withCtx(stmts[i].ctx), + ); + } + return processFrom(i + 1); + }); + } + if (result !== undefined) { + if (isGotoSignal(result)) { + throw new LuaRuntimeError( + "unexpected goto signal", + sf.withCtx(stmts[i].ctx), + ); + } + return result; + } + } + return; + }; + + return processFrom(startIdx); +} + +function withCloseBoundary( + sf: LuaStackFrame, + mark: number, + out: EvalBlockResult, +): EvalBlockResult { + if (!isPromise(out)) { + const r = luaCloseFromMark(sf, mark, null); + if (isPromise(r)) { + return (r as Promise).then(() => out as any); + } + return out; + } + + const p = out as Promise; + + const onFulfilled = (res: any) => { + const r = luaCloseFromMark(sf, mark, null); + return isPromise(r) ? (r as Promise).then(() => res) : res; + }; + + const onRejected = (e: any) => { + const errObj: LuaValue = e instanceof LuaRuntimeError + ? e.message + : (e?.message ?? String(e)); + const r = luaCloseFromMark(sf, mark, errObj); + if (isPromise(r)) { + return (r as Promise).then(() => { + throw e; + }); + } + throw e; + }; + + return p.then(onFulfilled, onRejected); +} + +function evalBlockNoClose( + b: LuaBlock, + env: LuaEnv, + sf: LuaStackFrame, + returnOnReturn: boolean, +): EvalBlockResult { + const hasGotoFlag = b.hasGoto === true; + const hasLabelFlag = b.hasLabel === true; + const hasLabelHere = b.hasLabelHere === true; + + const curFn = sf.currentFunction; + const fnHasGotos = curFn?.funcHasGotos; + + if (fnHasGotos === false || (!hasGotoFlag && !hasLabelFlag)) { + const dup = b.dupLabelError; + if (dup) { + // Duplicated labels detected by parser. + throw new LuaRuntimeError( + `label '${dup.name}' already defined`, + sf.withCtx(dup.ctx), + ); + } + + const execEnv = b.needsEnv === true ? new LuaEnv(env) : env; + return runStatementsNoGoto(b.statements, execEnv, sf, returnOnReturn, 0); + } + + if (fnHasGotos === true && !hasLabelHere && !hasGotoFlag) { + const execEnv = b.needsEnv === true ? new LuaEnv(env) : env; + const stmts = b.statements; + const runFrom = ( + i: number, + ): EvalBlockResult => { + for (; i < stmts.length; i++) { + const r = evalStatement(stmts[i], execEnv, sf, returnOnReturn); + if (isPromise(r)) { + return (r as Promise).then((res) => { + if (isGotoSignal(res)) return res; + if (res !== undefined) return res; + return runFrom(i + 1); + }); + } else { + if (isGotoSignal(r)) return r; + if (r !== undefined) return r; + } + } + return; + }; + return runFrom(0); + } + + let meta: ReturnType | undefined; + if (fnHasGotos === undefined && (hasGotoFlag || hasLabelFlag)) { + meta = blockMetaOrThrow(b, sf); + if (curFn) { + curFn.funcHasGotos = !!meta?.funcHasGotos; + } + } else if (fnHasGotos === true) { + meta = hasLabelFlag || hasGotoFlag ? blockMetaOrThrow(b, sf) : undefined; + } else { + meta = undefined; + } + + if (!meta || !meta.funcHasGotos) { + const dup = b.dupLabelError; + if (dup) { + throw new LuaRuntimeError( + `label '${dup.name}' already defined`, + sf.withCtx(dup.ctx), + ); + } + const execEnv = b.needsEnv === true ? new LuaEnv(env) : env; + return runStatementsNoGoto(b.statements, execEnv, sf, returnOnReturn, 0); + } + + const execEnv = b.needsEnv === true ? new LuaEnv(env) : env; + const stmts = b.statements; + + const runFrom = ( + i: number, + ): EvalBlockResult => { + for (; i < stmts.length; i++) { + const r = evalStatement(stmts[i], execEnv, sf, returnOnReturn); + if (isPromise(r)) { + return (r as Promise).then((res) => { + const consumed = consumeGotoInBlock(res, meta!.labels); + if (typeof consumed === "number") { + return runFrom(consumed); + } + if (consumed !== undefined) { + return consumed; + } + return runFrom(i + 1); + }); + } + const consumed = consumeGotoInBlock(r, meta.labels); + if (typeof consumed === "number") { + i = consumed - 1; + continue; + } + if (consumed !== undefined) { + return consumed; + } + } + return; + }; + + return runFrom(0); } /** @@ -1196,33 +1320,86 @@ export function evalStatement( } } - if (hasInit) { - const valuesRP = evalExpressions(l.expressions!, env, sf); - const setAll = (values: LuaValue[]) => { - for (let i = 0; i < l.names.length; i++) { - const name = l.names[i]; - const v = values[i]; - const isConst = - name.attributes?.includes(LuaAttribute.Const) === true; - if (isConst) { - env.setLocalConst(name.name, v); - } else { - env.setLocal(name.name, v); - } - } - return; - }; - if (isPromise(valuesRP)) { - return (valuesRP as Promise).then(setAll); + const bindOne = (name: any, v: LuaValue) => { + const isConst = name.attributes?.includes(LuaAttribute.Const) === true; + const isClose = name.attributes?.includes(LuaAttribute.Close) === true; + + if (isConst || isClose) { + env.setLocalConst(name.name, v); } else { - return setAll(valuesRP); + env.setLocal(name.name, v); } - } else { + + if (isClose) { + luaMarkToBeClosed(sf, v, name.ctx); + } + }; + + if (!hasInit) { for (let i = 0; i < l.names.length; i++) { - env.setLocal(l.names[i].name, null); + bindOne(l.names[i], null); } return; } + + // Evaluate initializers left-to-right and bind/mark `` + // locals as soon as they receive a value. This ensures earlier + // `` locals are closed if a later initializer errors. + const exprs = l.expressions!; + const out: LuaValue[] = []; + let boundCount = 0; + + const bindAvailable = () => { + while (boundCount < l.names.length && boundCount < out.length) { + bindOne(l.names[boundCount], out[boundCount] ?? null); + boundCount++; + } + }; + + const finish = () => { + while (out.length < l.names.length) { + out.push(null); + } + bindAvailable(); + }; + + const runFrom = (i: number): void | Promise => { + if (i >= exprs.length) { + finish(); + return; + } + + const isLastExpr = i === exprs.length - 1; + const rp = evalExpression(exprs[i], env, sf); + + const onValue = (v: LuaValue) => { + if (isLastExpr) { + if (v instanceof LuaMultiRes) { + for (const x of v.values) { + out.push(x); + } + } else { + out.push(v); + } + } else { + out.push(singleResult(v)); + } + + bindAvailable(); + + // If we already have enough values for all locals, remaining + // expressions will not affect the binding, so we can stop. + if (out.length >= l.names.length && !isLastExpr) { + return; + } + + return runFrom(i + 1); + }; + + return rpThen(rp, onValue) as any; + }; + + return runFrom(0); } case "Semicolon": { return; @@ -1237,208 +1414,35 @@ export function evalStatement( } case "Block": { const b = asBlock(s); - const hasGotoFlag = b.hasGoto === true; - const hasLabelFlag = b.hasLabel === true; - const hasLabelHere = b.hasLabelHere === true; - const curFn = (sf as any).currentFunction as LuaFunction | undefined; - const fnHasGotos = curFn?.funcHasGotos; + if (!b.hasCloseHere) { + return evalBlockNoClose(b, env, sf, returnOnReturn); + } - // Fast path: function known to have no gotos, run without meta - if (fnHasGotos === false || (!hasGotoFlag && !hasLabelFlag)) { - const dup = b.dupLabelError; - if (dup) { - // Duplicated labels detected by parser. - throw new LuaRuntimeError( - `label '${dup.name}' already defined`, - sf.withCtx(dup.ctx), - ); + // Blocks establish a boundary (mark) and close all entries + // created within the block on exit or error, shrinking the stack + // back to mark. This is _required_ for correct `pcall` and + // `xpcall` boundary semantics. + const closeStack = luaEnsureCloseStack(sf); + const mark = closeStack.length; + + let out: EvalBlockResult; + try { + out = evalBlockNoClose(b, env, sf, returnOnReturn); + } catch (e: any) { + const errObj: LuaValue = e instanceof LuaRuntimeError + ? e.message + : (e?.message ?? String(e)); + const r = luaCloseFromMark(sf, mark, errObj); + if (isPromise(r)) { + return (r as Promise).then(() => { + throw e; + }); } - - // Sync-first execution: iterate statements in a simple loop; if - // a statement returns a Promise, immediately switch to async by - // returning a continuation that resumes execution from the next - // statement (`i + 1`). - const execEnv = b.needsEnv === true ? new LuaEnv(env) : env; - const stmts = b.statements; - - const processFrom = ( - idx: number, - ): void | LuaValue[] | Promise => { - for (let i = idx; i < stmts.length; i++) { - const result = evalStatement( - stmts[i], - execEnv, - sf, - returnOnReturn, - ); - if (isPromise(result)) { - return (result as Promise).then((res) => { - if (res !== undefined && !isGotoSignal(res)) { - return res; - } - if (isGotoSignal(res)) { - // Should not happen in fast path - throw new LuaRuntimeError( - "unexpected goto signal", - sf.withCtx(stmts[i].ctx), - ); - } - return processFrom(i + 1); - }); - } - // Will only happen with `return` statement - if (result !== undefined) { - if (isGotoSignal(result)) { - throw new LuaRuntimeError( - "unexpected goto signal", - sf.withCtx(stmts[i].ctx), - ); - } - return result; - } - } - return; - }; - - return processFrom(0); + throw e; } - // If function has gotos, but this block itself has no labels, - // avoid computing metadata for this block. - if (fnHasGotos === true && !hasLabelHere && !hasGotoFlag) { - const execEnv = b.needsEnv === true ? new LuaEnv(env) : env; - const stmts = b.statements; - const runFrom = ( - i: number, - ): - | void - | LuaValue[] - | GotoSignal - | Promise => { - for (; i < stmts.length; i++) { - const r = evalStatement(stmts[i], execEnv, sf, returnOnReturn); - if (isPromise(r)) { - return (r as Promise).then((res) => { - if (isGotoSignal(res)) return res; - if (res !== undefined) return res; - return runFrom(i + 1); - }); - } else { - if (isGotoSignal(r)) return r; - if (r !== undefined) return r; - } - } - return; - }; - return runFrom(0); - } - - // Need metadata (function or block has label/goto) - let meta: ReturnType | undefined; - if (fnHasGotos === undefined && (hasGotoFlag || hasLabelFlag)) { - meta = blockMetaOrThrow(b, sf); - if (curFn) { - curFn.funcHasGotos = !!meta?.funcHasGotos; - } - } else if (fnHasGotos === true) { - // Only fetch metadata for blocks that actually have label/goto - meta = hasLabelFlag || hasGotoFlag - ? blockMetaOrThrow(b, sf) - : undefined; - } else { - meta = undefined; - } - - if (!meta || !meta.funcHasGotos) { - const dup = b.dupLabelError; - if (dup) { - throw new LuaRuntimeError( - `label '${dup.name}' already defined`, - sf.withCtx(dup.ctx), - ); - } - const execEnv = b.needsEnv === true ? new LuaEnv(env) : env; - const stmts = b.statements; - - const processFrom = ( - idx: number, - ): void | LuaValue[] | Promise => { - for (let i = idx; i < stmts.length; i++) { - const result = evalStatement( - stmts[i], - execEnv, - sf, - returnOnReturn, - ); - if (isPromise(result)) { - return (result as Promise).then((res) => { - if (res !== undefined && !isGotoSignal(res)) { - return res; - } - if (isGotoSignal(res)) { - throw new LuaRuntimeError( - "unexpected goto signal", - sf.withCtx(stmts[i].ctx), - ); - } - return processFrom(i + 1); - }); - } - if (result !== undefined) { - if (isGotoSignal(result)) { - throw new LuaRuntimeError( - "unexpected goto signal", - sf.withCtx(stmts[i].ctx), - ); - } - return result; - } - } - return; - }; - - return processFrom(0); - } else { - const execEnv = b.needsEnv === true ? new LuaEnv(env) : env; - const stmts = b.statements; - - const runFrom = ( - i: number, - ): - | void - | LuaValue[] - | GotoSignal - | Promise => { - for (; i < stmts.length; i++) { - const r = evalStatement(stmts[i], execEnv, sf, returnOnReturn); - if (isPromise(r)) { - return (r as Promise).then((res) => { - const consumed = consumeGotoInBlock(res, meta!.labels); - if (typeof consumed === "number") { - return runFrom(consumed); - } - if (consumed !== undefined) { - return consumed; - } - return runFrom(i + 1); - }); - } - const consumed = consumeGotoInBlock(r, meta.labels); - if (typeof consumed === "number") { - // consumed is the next statement index; adjust for for-loop increment - i = consumed - 1; - continue; - } - if (consumed !== undefined) { - return consumed; - } - } - return; - }; - - return runFrom(0); - } + return withCloseBoundary(sf, mark, out); } case "If": { const iff = asIf(s); @@ -1739,7 +1743,6 @@ export function evalStatement( } case "For": { const fr = asFor(s); - // Evaluate bounds and step once (sync-first) const startV = evalExpression(fr.start, env, sf); const endV = evalExpression(fr.end, env, sf); const stepV = fr.step ? evalExpression(fr.step, env, sf) : 1; @@ -1867,148 +1870,200 @@ export function evalStatement( ); } - const state: LuaValue = iteratorMultiRes.values[1] || null; - const control: LuaValue = iteratorMultiRes.values[2] || null; + const state: LuaValue = iteratorMultiRes.values[1] ?? null; + let control: LuaValue = iteratorMultiRes.values[2] ?? null; + const closing: LuaValue = iteratorMultiRes.values[3] ?? null; + + // The closing value in a generic-for has loop scope. Close it + // when the loop ends, not only when the surrounding block ends. + const closeStack = luaEnsureCloseStack(sf); + const mark = closeStack.length; + + // The closing value is a "to-be-closed" value, and only `nil` + // means "no close"; `false` is NOT special here. + if (closing !== null) { + luaMarkToBeClosed(sf, closing, fi.ctx); + } + + const errObjFrom = (e: any): LuaValue => + e instanceof LuaRuntimeError ? e.message : (e?.message ?? String(e)); + + const finish = (res: any) => { + const r = luaCloseFromMark(sf, mark, null); + return isPromise(r) ? (r as Promise).then(() => res) : res; + }; + + const finishErr = (e: any): Promise | never => { + const errObj = errObjFrom(e); + const r = luaCloseFromMark(sf, mark, errObj); + if (isPromise(r)) { + return (r as Promise).then(() => { + throw e; + }); + } + throw e; + }; + + try { + const runAsync = async () => { + while (true) { + const callRes = luaCall( + iteratorValue, + [state, control], + fi.ctx, + sf, + ); + const iterResult = new LuaMultiRes( + isPromise(callRes) ? await callRes : callRes, + ).flatten(); + const nextControl = iterResult.values[0]; + if (nextControl === null || nextControl === undefined) { + break; + } + control = nextControl; + + const localEnv = new LuaEnv(env); + for (let i = 0; i < fi.names.length; i++) { + localEnv.setLocal(fi.names[i], iterResult.values[i]); + } + + try { + const r = evalStatement(fi.block, localEnv, sf, returnOnReturn); + if (isPromise(r)) { + const res = await r; + if (isGotoSignal(res)) { + return await finish(res); + } + if (res !== undefined) { + return await finish(res); + } + } else if (isGotoSignal(r)) { + return await finish(r); + } else if (r !== undefined) { + return await finish(r); + } + } catch (e: any) { + if (e instanceof LuaBreak) { + break; + } else { + throw e; + } + } + } + return await finish(undefined); + }; - const runAsync = async () => { while (true) { - const callRes = luaCall( + const iterCall = luaCall( iteratorValue, [state, control], fi.ctx, sf, ); - const iterResult = new LuaMultiRes( - isPromise(callRes) ? await callRes : callRes, - ).flatten(); - if ( - iterResult.values[0] === null || - iterResult.values[0] === undefined - ) { - break; + if (isPromise(iterCall)) { + return (iterCall as Promise).then((itv) => { + const iterResult = new LuaMultiRes(itv).flatten(); + const nextControl = iterResult.values[0]; + if (nextControl === null || nextControl === undefined) { + const r = finish(undefined); + if (isPromise(r)) return (r as Promise).then(() => {}); + return; + } + control = nextControl; + + const localEnv = new LuaEnv(env); + for (let i = 0; i < fi.names.length; i++) { + localEnv.setLocal(fi.names[i], iterResult.values[i]); + } + + try { + const r = evalStatement( + fi.block, + localEnv, + sf, + returnOnReturn, + ); + if (isPromise(r)) { + return (r as Promise).then((res) => { + if (isGotoSignal(res)) { + return rpThen(finish(undefined), () => res); + } + if (res !== undefined) { + return rpThen(finish(undefined), () => res); + } + return runAsync(); + }).catch((e: any) => { + if (e instanceof LuaBreak) { + return finish(undefined); + } + throw e; + }); + } else { + if (isGotoSignal(r)) { + return rpThen(finish(undefined), () => r); + } + if (r !== undefined) { + return rpThen(finish(undefined), () => r); + } + return runAsync(); + } + } catch (e: any) { + if (e instanceof LuaBreak) { + return finish(undefined); + } + throw e; + } + }).catch((e: any) => finishErr(e)); } + + const iterResult = new LuaMultiRes(iterCall).flatten(); + const nextControl = iterResult.values[0]; + if (nextControl === null || nextControl === undefined) { + const r = finish(undefined); + if (isPromise(r)) { + return (r as Promise); + } + return; + } + control = nextControl; + const localEnv = new LuaEnv(env); for (let i = 0; i < fi.names.length; i++) { localEnv.setLocal(fi.names[i], iterResult.values[i]); } + try { const r = evalStatement(fi.block, localEnv, sf, returnOnReturn); if (isPromise(r)) { - const res = await r; - if (isGotoSignal(res)) { - return res; - } - if (res !== undefined) { - return res; - } + return (r as Promise).then((res) => { + if (isGotoSignal(res)) { + return rpThen(finish(undefined), () => res); + } + if (res !== undefined) { + return rpThen(finish(undefined), () => res); + } + return runAsync(); + }).catch((e: any) => { + if (e instanceof LuaBreak) { + return finish(undefined); + } + throw e; + }).catch((e: any) => finishErr(e)); } else if (isGotoSignal(r)) { - return r; + return rpThen(finish(undefined), () => r); } else if (r !== undefined) { - return r; + return rpThen(finish(undefined), () => r); } } catch (e: any) { if (e instanceof LuaBreak) { - break; - } else { - throw e; + return finish(undefined); } + return finishErr(e); } } - return; - }; - - while (true) { - const iterCall = luaCall(iteratorValue, [state, control], fi.ctx, sf); - if (isPromise(iterCall)) { - return (iterCall as Promise).then((itv) => { - const iterResult = new LuaMultiRes(itv).flatten(); - if ( - iterResult.values[0] === null || - iterResult.values[0] === undefined - ) { - return; - } - const localEnv = new LuaEnv(env); - for (let i = 0; i < fi.names.length; i++) { - localEnv.setLocal(fi.names[i], iterResult.values[i]); - } - try { - const r = evalStatement(fi.block, localEnv, sf, returnOnReturn); - if (isPromise(r)) { - return (r as Promise).then((res) => { - if (isGotoSignal(res)) { - return res; - } - if (res !== undefined) { - return res; - } - return runAsync(); - }).catch((e: any) => { - if (e instanceof LuaBreak) { - return; - } - throw e; - }); - } else { - if (isGotoSignal(r)) { - return r; - } - if (r !== undefined) { - return r; - } else { - return runAsync(); - } - } - } catch (e: any) { - if (e instanceof LuaBreak) { - return; - } else { - throw e; - } - } - }); - } - const iterResult = new LuaMultiRes(iterCall).flatten(); - if ( - iterResult.values[0] === null || iterResult.values[0] === undefined - ) { - break; - } - const localEnv = new LuaEnv(env); - for (let i = 0; i < fi.names.length; i++) { - localEnv.setLocal(fi.names[i], iterResult.values[i]); - } - try { - const r = evalStatement(fi.block, localEnv, sf, returnOnReturn); - if (isPromise(r)) { - return (r as Promise).then((res) => { - if (isGotoSignal(res)) { - return res; - } - if (res !== undefined) { - return res; - } - return runAsync(); - }).catch((e: any) => { - if (e instanceof LuaBreak) { - return; - } - throw e; - }); - } else if (isGotoSignal(r)) { - return r; - } else if (r !== undefined) { - return r; - } - } catch (e: any) { - if (e instanceof LuaBreak) { - break; - } else { - throw e; - } - } + } catch (e: any) { + return finishErr(e); } - return; }; if (isPromise(exprVals)) { @@ -2108,4 +2163,4 @@ function exactInt( ); } return n; -} +} \ No newline at end of file diff --git a/client/space_lua/labels.ts b/client/space_lua/labels.ts index 6d5ce30a..18671f34 100644 --- a/client/space_lua/labels.ts +++ b/client/space_lua/labels.ts @@ -12,6 +12,7 @@ import type { LuaRepeatStatement, LuaWhileStatement, } from "./ast.ts"; +import { LuaAttribute } from "./ast.ts"; import { asBlock } from "./ast_narrow.ts"; type BlockGotoMeta = { @@ -66,6 +67,7 @@ type ValidationCtx = { gotos: GotoInfo[]; hasGoto: boolean; nextLocalId: number; + closeLocals: Set; }; type BlockRole = @@ -90,6 +92,7 @@ function resolveFunction(root: LuaBlock): FunctionMeta { gotos: [], hasGoto: false, nextLocalId: 1, + closeLocals: new Set(), }; const seenBlocks = new Set(); @@ -101,7 +104,6 @@ function resolveFunction(root: LuaBlock): FunctionMeta { undefined, "Root", new Set(), - new Set(), blockMeta, vctx, seenBlocks, @@ -155,12 +157,25 @@ function resolveFunction(root: LuaBlock): FunctionMeta { // Local scope forward jump check let entersLocalScope = false; + let entersCloseScope = false; + for (const id of lset) { if (!g.active.has(id)) { entersLocalScope = true; - break; + if (vctx.closeLocals.has(id)) { + entersCloseScope = true; + break; + } } } + + if (entersCloseScope) { + throw new LabelResolveError( + `goto '${target}' jumps into the scope of a local variable`, + g.node.ctx, + ); + } + if (entersLocalScope) { const safeEnd = isSafeEndLabel(labelDefBlock, lloc.index, roleByBlock); if (!safeEnd) { @@ -216,7 +231,6 @@ function processBlock( parent: LuaBlock | undefined, role: BlockRole, active: Set, - visibleLabels: Set, blockMeta: WeakMap, vctx: ValidationCtx, seen: Set, @@ -236,7 +250,6 @@ function processBlock( vctx.labelLocByBlock.set(block, labelLocMap); const curActive = cloneSet(active); - const vis = new Set(visibleLabels); const stmts = block.statements; for (let i = 0; i < stmts.length; i++) { @@ -254,7 +267,6 @@ function processBlock( const actSet = cloneSet(curActive); labelActiveMap.set(lab.name, actSet); labelLocMap.set(lab.name, { index: i, ctx: lab.ctx }); - vis.add(lab.name); break; } case "Goto": { @@ -266,7 +278,15 @@ function processBlock( case "Local": { const l = s as LuaLocalStatement; for (let j = 0; j < l.names.length; j++) { - curActive.add(vctx.nextLocalId++); + const id = vctx.nextLocalId++; + curActive.add(id); + + const isClose = + l.names[j].attributes?.includes(LuaAttribute.Close) === + true; + if (isClose) { + vctx.closeLocals.add(id); + } } break; } @@ -286,7 +306,6 @@ function processBlock( block, "For", childActive, - new Set(vis), blockMeta, vctx, seen, @@ -306,7 +325,6 @@ function processBlock( block, "ForIn", childActive, - new Set(vis), blockMeta, vctx, seen, @@ -322,7 +340,6 @@ function processBlock( block, "While", cloneSet(curActive), - new Set(vis), blockMeta, vctx, seen, @@ -338,7 +355,6 @@ function processBlock( block, "Repeat", cloneSet(curActive), - new Set(vis), blockMeta, vctx, seen, @@ -355,7 +371,6 @@ function processBlock( block, "If", cloneSet(curActive), - new Set(vis), blockMeta, vctx, seen, @@ -369,7 +384,6 @@ function processBlock( block, "If", cloneSet(curActive), - new Set(vis), blockMeta, vctx, seen, @@ -386,7 +400,6 @@ function processBlock( block, "Do", cloneSet(curActive), - new Set(vis), blockMeta, vctx, seen, diff --git a/client/space_lua/parse.ts b/client/space_lua/parse.ts index d7ce411b..4a35496f 100644 --- a/client/space_lua/parse.ts +++ b/client/space_lua/parse.ts @@ -24,6 +24,8 @@ import type { LuaTableField, } from "./ast.ts"; import { LuaAttribute } from "./ast.ts"; +import { getBlockGotoMeta } from "./labels.ts"; +import { LuaRuntimeError, LuaStackFrame } from "./runtime.ts"; const luaStyleTags = styleTags({ Name: t.variableName, @@ -69,6 +71,18 @@ function parseChunk(t: ParseTree, ctx: ASTCtx): LuaBlock { return parseBlock(t.children![0], ctx); } +function hasCloseLocal(names: LuaAttName[] | undefined): boolean { + if (!names) { + return false; + } + for (const n of names) { + if (n.attributes?.includes(LuaAttribute.Close) === true) { + return true; + } + } + return false; +} + function parseBlock(t: ParseTree, ctx: ASTCtx): LuaBlock { if (t.type !== "Block") { throw new Error(`Expected Block, got ${t.type}`); @@ -82,6 +96,7 @@ function parseBlock(t: ParseTree, ctx: ASTCtx): LuaBlock { let hasLocalDecl = false; let dup: { name: string; ctx: ASTCtx } | undefined; let hasLabelHere = false; + let hasCloseHere = false; const seen = new Set(); @@ -105,7 +120,13 @@ function parseBlock(t: ParseTree, ctx: ASTCtx): LuaBlock { hasGoto = true; break; } - case "Local": + case "Local": { + hasLocalDecl = true; + if (!hasCloseHere) { + hasCloseHere = hasCloseLocal((s as any).names as LuaAttName[]); + } + break; + } case "LocalFunction": { hasLocalDecl = true; break; @@ -114,6 +135,7 @@ function parseBlock(t: ParseTree, ctx: ASTCtx): LuaBlock { const child = s as LuaBlock; hasLabel = hasLabel || !!child.hasLabel; hasGoto = hasGoto || !!child.hasGoto; + hasCloseHere = hasCloseHere || !!child.hasCloseHere; break; } case "If": { @@ -121,20 +143,29 @@ function parseBlock(t: ParseTree, ctx: ASTCtx): LuaBlock { for (const c of iff.conditions) { hasLabel = hasLabel || !!c.block.hasLabel; hasGoto = hasGoto || !!c.block.hasGoto; + hasCloseHere = hasCloseHere || !!c.block.hasCloseHere; } if (iff.elseBlock) { hasLabel = hasLabel || !!iff.elseBlock.hasLabel; hasGoto = hasGoto || !!iff.elseBlock.hasGoto; + hasCloseHere = hasCloseHere || !!iff.elseBlock.hasCloseHere; } break; } case "While": case "Repeat": - case "For": + case "For": { + const child = (s as any).block as LuaBlock; + hasLabel = hasLabel || !!child.hasLabel; + hasGoto = hasGoto || !!child.hasGoto; + hasCloseHere = hasCloseHere || !!child.hasCloseHere; + break; + } case "ForIn": { const child = (s as any).block as LuaBlock; hasLabel = hasLabel || !!child.hasLabel; hasGoto = hasGoto || !!child.hasGoto; + hasCloseHere = true; break; } default: { @@ -158,6 +189,9 @@ function parseBlock(t: ParseTree, ctx: ASTCtx): LuaBlock { if (hasLabelHere) { block.hasLabelHere = true; } + if (hasCloseHere) { + block.hasCloseHere = true; + } return block; } @@ -295,13 +329,26 @@ function parseStatement(t: ParseTree, ctx: ASTCtx): LuaStatement { expressions: parseExpList(t.children![2], ctx), ctx: context(t, ctx), }; - case "Local": + case "Local": { + const names = parseAttNames(t.children![1], ctx); + + let closeCount = 0; + for (const n of names) { + if (n.attributes?.includes(LuaAttribute.Close) === true) { + closeCount++; + if (closeCount > 1) { + throw new Error("multiple variables in local list"); + } + } + } + return { type: "Local", - names: parseAttNames(t.children![1], ctx), + names, expressions: t.children![3] ? parseExpList(t.children![3], ctx) : [], ctx: context(t, ctx), }; + } case "ReturnStatement": { const expressions = t.children![1] ? parseExpList(t.children![1], ctx) @@ -366,14 +413,19 @@ function parseAttName(t: ParseTree, ctx: ASTCtx): LuaAttName { const attribute = t.children![1].children![1] ? t.children![1].children![1].children![0].text! : undefined; - if (attribute && attribute !== LuaAttribute.Const) { + if ( + attribute && + attribute !== LuaAttribute.Const && + attribute !== LuaAttribute.Close + ) { throw new Error(`unknown attribute '${attribute}'`); } + const attributes = attribute ? [attribute as LuaAttribute] : undefined; return { type: "AttName", name: t.children![0].children![0].text!, attribute, - attributes: attribute ? [LuaAttribute.Const] : undefined, + attributes, ctx: context(t, ctx), }; } @@ -867,14 +919,27 @@ export function stripLuaComments(s: string): string { } export function parse(s: string, ctx: ASTCtx = {}): LuaBlock { - const t = parseToCrudeAST(stripLuaComments(s)); - // console.log("Clean tree", JSON.stringify(t, null, 2)); - const result = parseChunk(t, ctx); - // console.log("Parsed AST", JSON.stringify(result, null, 2)); - return result; + try { + const t = parseToAST(stripLuaComments(s)); + // console.log("Clean tree", JSON.stringify(t, null, 2)); + const result = parseChunk(t, ctx); + // console.log("Parsed AST", JSON.stringify(result, null, 2)); + getBlockGotoMeta(result); + return result; + } catch (e: any) { + if (e && typeof e === "object" && "astCtx" in e) { + throw new LuaRuntimeError( + e.message, + LuaStackFrame.lostFrame.withCtx( + (e as any).astCtx as ASTCtx, + ), + ); + } + throw e; + } } -export function parseToCrudeAST(t: string): ParseTree { +export function parseToAST(t: string): ParseTree { const n = lezerToParseTree(t, parser.parse(t).topNode); return cleanTree(n, true); } diff --git a/client/space_lua/rp.ts b/client/space_lua/rp.ts index a6ed1dc6..78e68494 100644 --- a/client/space_lua/rp.ts +++ b/client/space_lua/rp.ts @@ -4,7 +4,8 @@ export type RP = T | Promise; // Returns true when v is a Promise or a has a then function. export function isPromise(v: RP): v is Promise { - return v !== null && typeof v === "object" && + return v !== null && + (typeof v === "object" || typeof v === "function") && typeof (v as any).then === "function"; } @@ -17,22 +18,10 @@ export function rpThen(v: RP, f: (a: A) => RP): RP { * avoiding Promise allocation when all inputs are synchronous. */ export function rpAll(arr: RP[]): RP { - let hasPromise = false; for (let i = 0; i < arr.length; i++) { if (isPromise(arr[i])) { - hasPromise = true; - break; + return Promise.all(arr as Promise[]); } } - if (!hasPromise) { - // All sync values: return as-is - return arr as T[]; - } - - // At least one Promise: allocate a new array - const out = new Array>(arr.length); - for (let i = 0; i < arr.length; i++) { - out[i] = arr[i] as any; - } - return Promise.all(out as Promise[]); + return arr as T[]; } diff --git a/client/space_lua/runtime.ts b/client/space_lua/runtime.ts index 7c8319b5..f13eb491 100644 --- a/client/space_lua/runtime.ts +++ b/client/space_lua/runtime.ts @@ -24,11 +24,11 @@ export interface ILuaFunction { } export interface ILuaSettable { - set(key: LuaValue, value: LuaValue, sf?: LuaStackFrame): void; + set(key: LuaValue, value: LuaValue, sf?: LuaStackFrame): void | Promise; } export interface ILuaGettable { - get(key: LuaValue, sf?: LuaStackFrame): LuaValue | undefined; + get(key: LuaValue, sf?: LuaStackFrame): LuaValue | Promise | null; } // Small helpers for type safety/readability @@ -57,6 +57,156 @@ export function ctxOrNull(sf?: LuaStackFrame): ASTCtx | null { // Reuse a single empty context to avoid allocating `{}` in hot paths const EMPTY_CTX = {} as ASTCtx; +// Close-stack support +export type LuaCloseEntry = { value: LuaValue; ctx: ASTCtx }; + +type LuaThreadState = { + closeStack?: LuaCloseEntry[]; +}; + +// Check whether a value is callable without invoking it. +export function luaIsCallable( + v: LuaValue, + sf: LuaStackFrame, +): boolean { + if (v === null || v === undefined) { + return false; + } + if (typeof v === "function") { + return true; + } + if (isILuaFunction(v)) { + return true; + } + if (v instanceof LuaTable) { + const mt = getMetatable(v, sf); + if (mt && mt.has("__call")) { + const mm = mt.get("__call", sf); + return !!mm && (typeof mm === "function" || isILuaFunction(mm)); + } + } + return false; +} + +// In Lua, `__close` must be a function (no `__call` fallback). +function luaIsCloseMethod( + v: LuaValue, +): boolean { + return typeof v === "function" || isILuaFunction(v); +} + +export function luaEnsureCloseStack(sf: LuaStackFrame): LuaCloseEntry[] { + if (!sf.threadState.closeStack) { + sf.threadState.closeStack = []; + } + return sf.threadState.closeStack as LuaCloseEntry[]; +} + +export function luaMarkToBeClosed( + sf: LuaStackFrame, + value: LuaValue, + ctx: ASTCtx, +): void { + const closeStack = luaEnsureCloseStack(sf); + + // In Lua, `nil` is not closed. + if (value === null) { + return; + } + + const mt = getMetatable(value, sf); + if (!mt || !mt.has("__close")) { + throw new LuaRuntimeError( + "variable got a non-closable value", + sf.withCtx(ctx), + ); + } + + const mm = mt.get("__close"); + if (!luaIsCloseMethod(mm)) { + throw new LuaRuntimeError( + "variable got a non-closable value", + sf.withCtx(ctx), + ); + } + + closeStack.push({ value, ctx }); +} + +// Close entries from a mark (LIFO) and shrink stack back to mark. This +// is the core semantic for block exits and protected call boundaries. +export function luaCloseFromMark( + sf: LuaStackFrame, + mark: number, + errObj: LuaValue | null, +): Promise | void { + const closeStack = sf.threadState?.closeStack as LuaCloseEntry[] | undefined; + if (!closeStack) { + return; + } + if (closeStack.length <= mark) { + return; + } + + const callClose = (entry: LuaCloseEntry): LuaValue | Promise => { + const mt = getMetatable(entry.value, sf); + const mm = mt ? mt.get("__close", sf) : null; + if (!luaIsCloseMethod(mm)) { + throw new LuaRuntimeError( + "metamethod '__close' is not callable", + sf.withCtx(entry.ctx), + ); + } + if (errObj === null) { + return luaCall(mm, [entry.value], entry.ctx, sf); + } + return luaCall(mm, [entry.value, errObj], entry.ctx, sf); + }; + + // Close all to-be-closed variables (LIFO) even if one close errors. + // The reported error should be the first close error encountered. + const runFrom = (i: number): void | Promise => { + let firstErr: unknown | null = null; + + const recordErr = (e: unknown) => { + if (firstErr === null) { + firstErr = e; + } + }; + + const next = (idx: number): void | Promise => { + for (let j = idx; j >= mark; j--) { + let r: LuaValue | Promise; + try { + r = callClose(closeStack[j]); + } catch (e) { + recordErr(e); + continue; + } + + if (isPromise(r)) { + return (r as Promise).then( + () => next(j - 1), + (e: any) => { + recordErr(e); + return next(j - 1); + }, + ); + } + } + + closeStack.length = mark; + if (firstErr !== null) { + throw firstErr; + } + }; + + return next(i); + }; + + return runFrom(closeStack.length - 1); +} + export class LuaEnv implements ILuaSettable, ILuaGettable { variables = new Map(); private readonly consts = new Set(); @@ -99,13 +249,13 @@ export class LuaEnv implements ILuaSettable, ILuaGettable { get( name: string, - sf?: LuaStackFrame, - ): Promise | LuaValue | undefined { + _sf?: LuaStackFrame, + ): Promise | LuaValue | null { if (this.variables.has(name)) { return this.variables.get(name); } if (this.parent) { - return this.parent.get(name, sf); + return this.parent.get(name, _sf); } return null; } @@ -134,13 +284,20 @@ export class LuaEnv implements ILuaSettable, ILuaGettable { } export class LuaStackFrame { - static lostFrame = new LuaStackFrame(new LuaEnv(), null); + // Must not share mutable per-thread state across calls/tests. This is + // a getter that returns a fresh frame each time. + static get lostFrame(): LuaStackFrame { + return new LuaStackFrame(new LuaEnv(), null, undefined, undefined, { + closeStack: undefined, + }); + } constructor( readonly threadLocal: LuaEnv, readonly astCtx: ASTCtx | null, readonly parent?: LuaStackFrame, readonly currentFunction?: LuaFunction, + readonly threadState: LuaThreadState = { closeStack: undefined }, ) { } @@ -150,15 +307,29 @@ export class LuaStackFrame { ): LuaStackFrame { const env = new LuaEnv(); env.setLocal("_GLOBAL", globalEnv); - return new LuaStackFrame(env, ctx); + return new LuaStackFrame(env, ctx, undefined, undefined, { + closeStack: undefined, + }); } withCtx(ctx: ASTCtx): LuaStackFrame { - return new LuaStackFrame(this.threadLocal, ctx, this, this.currentFunction); + return new LuaStackFrame( + this.threadLocal, + ctx, + this, + this.currentFunction, + this.threadState, + ); } withFunction(fn: LuaFunction): LuaStackFrame { - return new LuaStackFrame(this.threadLocal, this.astCtx, this.parent, fn); + return new LuaStackFrame( + this.threadLocal, + this.astCtx, + this.parent, + fn, + this.threadState, + ); } } @@ -240,28 +411,16 @@ export class LuaFunction implements ILuaFunction { env.setLocal("...", new LuaMultiRes(varargs)); // Evaluate the function body with returnOnReturn set to true - try { - const r = evalStatement(this.body.block, env, sfWithFn, true); - const map = (val: any) => { - if (val !== undefined) { - return mapFunctionReturnValue(val); - } - }; - if (isPromise(r)) { - return r.then(map).catch((e: any) => { - if (e instanceof LuaReturn) { - return mapFunctionReturnValue(e.values); - } - throw e; - }); - } else { - return map(r); + const r = evalStatement(this.body.block, env, sfWithFn, true); + const map = (val: any) => { + if (val !== undefined) { + return mapFunctionReturnValue(val); } - } catch (e: any) { - if (e instanceof LuaReturn) { - return mapFunctionReturnValue(e.values); - } - throw e; + }; + if (isPromise(r)) { + return r.then(map); + } else { + return map(r); } }; @@ -604,12 +763,27 @@ export function luaIndexValue( } // If not, let's see if the value has a metatable and if it has a __index metamethod const metatable = getMetatable(value, sf); - if (metatable && metatable.has("__index")) { - // Invoke the meta table - const metaValue = metatable.get("__index", sf); - if (isPromise(metaValue)) { - // Got a promise, we need to wait for it - return (metaValue as Promise).then((mv: any) => { + if (metatable) { + const mm = metatable.rawGet("__index"); + if (!(mm === undefined || mm === null)) { + // Invoke the meta table + const metaValue = mm; + if (isPromise(metaValue)) { + // Got a promise, we need to wait for it + return (metaValue as Promise).then((mv: any) => { + if (mv?.call) { + return luaCall(mv, [value, key], sf?.astCtx ?? EMPTY_CTX, sf); + } else if (mv instanceof LuaTable) { + return mv.get(key, sf); + } else { + throw new LuaRuntimeError( + "Meta table __index must be a function or table", + sf || LuaStackFrame.lostFrame, + ); + } + }); + } else { + const mv = metaValue; if (mv?.call) { return luaCall(mv, [value, key], sf?.astCtx ?? EMPTY_CTX, sf); } else if (mv instanceof LuaTable) { @@ -620,18 +794,6 @@ export function luaIndexValue( sf || LuaStackFrame.lostFrame, ); } - }); - } else { - const mv = metaValue as any; - if (mv?.call) { - return luaCall(mv, [value, key], sf?.astCtx ?? EMPTY_CTX, sf); - } else if (mv instanceof LuaTable) { - return mv.get(key, sf); - } else { - throw new LuaRuntimeError( - "Meta table __index must be a function or table", - sf || LuaStackFrame.lostFrame, - ); } } } @@ -1007,7 +1169,7 @@ export function luaToString( export function getMetatable( value: LuaValue, sf?: LuaStackFrame, -): LuaValue | null { +): LuaTable | null { if (value === null || value === undefined) { return null; } @@ -1033,7 +1195,7 @@ export function getMetatable( } if ((value as any).metatable) { - return (value as any).metatable; + return (value as any).metatable as LuaTable; } else { return null; } diff --git a/client/space_lua/stdlib.ts b/client/space_lua/stdlib.ts index a7c4d677..b415e93e 100644 --- a/client/space_lua/stdlib.ts +++ b/client/space_lua/stdlib.ts @@ -1,14 +1,18 @@ import { type ILuaFunction, + isILuaFunction, isLuaTable, LuaBuiltinFunction, luaCall, + luaCloseFromMark, + luaEnsureCloseStack, LuaEnv, luaGet, luaKeys, luaLen, LuaMultiRes, LuaRuntimeError, + type LuaStackFrame, type LuaTable, luaToString, luaTypeOf, @@ -60,6 +64,18 @@ const ipairsFunction = new LuaBuiltinFunction((sf, ar: LuaTable | any[]) => { const pairsFunction = new LuaBuiltinFunction( (sf, t: LuaTable | any[] | Record) => { + // Respect `__pairs` metamethod for Lua tables + if (isLuaTable(t)) { + const mt = (t as any).metatable as LuaTable | null | undefined; + if (mt) { + const mm = mt.get("__pairs", sf); + if (mm && (typeof mm === "function" || isILuaFunction(mm))) { + // __pairs must return (iter, state, control, closing) + return luaCall(mm, [t], sf.astCtx ?? {}, sf); + } + } + } + let keys: (string | number)[]; if (Array.isArray(t)) { keys = Array.from({ length: t.length }, (_, i) => i + 1); // For arrays, generate 1-based indices @@ -74,7 +90,7 @@ const pairsFunction = new LuaBuiltinFunction( } let i = 0; - return async () => { + const iter = async () => { if (i >= keys.length) { return; } @@ -83,6 +99,9 @@ const pairsFunction = new LuaBuiltinFunction( const value = await luaGet(t, key, sf.astCtx ?? null, sf); return new LuaMultiRes([key, value]); }; + + // Must return (iter, state, control) for generic for + return new LuaMultiRes([iter, t, null]); }, ); @@ -129,30 +148,68 @@ const errorFunction = new LuaBuiltinFunction((sf, message: string) => { throw new LuaRuntimeError(message, sf); }); +async function pcallBoundary( + sf: LuaStackFrame, + fn: ILuaFunction, + args: LuaValue[], +): Promise< + | { ok: true; values: LuaValue[] } + | { ok: false; message: string } +> { + const closeStack = luaEnsureCloseStack(sf); + const mark = closeStack.length; + + const errMsgOf = (e: any): string => + e instanceof LuaRuntimeError ? e.message : (e?.message ?? String(e)); + + try { + const r = await luaCall(fn, args, sf.astCtx!, sf); + await luaCloseFromMark(sf, mark, null); + const values = r instanceof LuaMultiRes ? r.flatten().values : [r]; + return { ok: true, values }; + } catch (e: any) { + const msg = errMsgOf(e); + try { + await luaCloseFromMark(sf, mark, msg); + return { ok: false, message: msg }; + } catch (closeErr: any) { + return { ok: false, message: errMsgOf(closeErr) }; + } + } +} + const pcallFunction = new LuaBuiltinFunction( async (sf, fn: ILuaFunction, ...args) => { - try { - return new LuaMultiRes([true, await luaCall(fn, args, sf.astCtx!, sf)]); - } catch (e: any) { - if (e instanceof LuaRuntimeError) { - return new LuaMultiRes([false, e.message]); - } - return new LuaMultiRes([false, e.message]); + // To-be-closed variables must be closed when unwinding to the + // protected call boundary. Space Lua uses a per-thread close + // stack, so we snapshot its length and close anything pushed + // after that. + // + // The protected call boundary must be established *before* + // evaluating the function and its arguments. Otherwise, any + // `` locals created while evaluating `pcall`'s arguments + // will be wrongly treated as "inside" the protected call, and + // `pcall` may end up closing them (or affecting close ordering). + // + // `threadState` is read-only on the stack frame; do not reassign! + const res = await pcallBoundary(sf, fn, args); + if (res.ok) { + return new LuaMultiRes([true, ...res.values]); } + return new LuaMultiRes([false, res.message]); }, ); const xpcallFunction = new LuaBuiltinFunction( async (sf, fn: ILuaFunction, errorHandler: ILuaFunction, ...args) => { - try { - return new LuaMultiRes([true, await fn.call(sf, ...args)]); - } catch (e: any) { - const errorMsg = e instanceof LuaRuntimeError ? e.message : e.message; - return new LuaMultiRes([ - false, - await luaCall(errorHandler, [errorMsg], sf.astCtx!, sf), - ]); + // Same semantic as `pcall` (see comments there) + const res = await pcallBoundary(sf, fn, args); + if (res.ok) { + return new LuaMultiRes([true, ...res.values]); } + const hr = await luaCall(errorHandler, [res.message], sf.astCtx!, sf); + const outVals = hr instanceof LuaMultiRes ? hr.flatten().values : [hr]; + return new LuaMultiRes([false, ...outVals]); }, ); diff --git a/client/space_lua/util.ts b/client/space_lua/util.ts index b00c1a50..20ab1b55 100644 --- a/client/space_lua/util.ts +++ b/client/space_lua/util.ts @@ -1,9 +1,13 @@ +import { isPromise } from "./rp.ts"; + export function evalPromiseValues(vals: any[]): Promise | any[] { const promises = []; const promiseResults = new Array(vals.length); for (let i = 0; i < vals.length; i++) { - if (vals[i] instanceof Promise) { - promises.push(vals[i].then((v: any) => promiseResults[i] = v)); + if (isPromise(vals[i])) { + promises.push( + (vals[i] as Promise).then((v: any) => promiseResults[i] = v), + ); } else { promiseResults[i] = vals[i]; }