diff --git a/common/space_lua/eval.test.ts b/common/space_lua/eval.test.ts index e17a1da0..e9c17ca6 100644 --- a/common/space_lua/eval.test.ts +++ b/common/space_lua/eval.test.ts @@ -304,6 +304,8 @@ Deno.test("Thread local _CTX - advanced cases", async () => { const env = new LuaEnv(luaBuildStandardEnv()); const threadLocal = new LuaEnv(); + env.setLocal("globalEnv", "GLOBAL"); + // Set up some thread local values threadLocal.setLocal("user", "alice"); threadLocal.setLocal("permissions", new LuaTable()); @@ -423,4 +425,25 @@ Deno.test("Thread local _CTX - advanced cases", async () => { assertEquals(await evalExpr("errorTest()", env, sf), "caught"); assertEquals(threadLocal.get("error"), "caught"); + + // Test string interpolation + sf.threadLocal.setLocal("_GLOBAL", env); + assertEquals( + await evalExpr( + "interpolate('Hello, ${globalEnv} and ${loc}!', {loc='local'})", + env, + sf, + ), + "Hello, GLOBAL and local!", + ); + + // Some more complex string interpolation with more complex lua expressions, with nested {} + assertEquals( + await evalExpr( + `interpolate('Some JSON \${js.stringify(js.tojs({name="Pete"}))}!')`, + env, + sf, + ), + `Some JSON {"name":"Pete"}!`, + ); }); diff --git a/common/space_lua/language_test.lua b/common/space_lua/language_test.lua index 7a02292b..9278a95d 100644 --- a/common/space_lua/language_test.lua +++ b/common/space_lua/language_test.lua @@ -75,6 +75,17 @@ do end assert(a == 1) +-- Async function calling +function multiplier(a) + -- Anything will be async in practice + return function(b) + return a * b + end +end + +local multiplier = multiplier(2) +assert(multiplier(3) == 6) + -- Function definitions in tables ns = { name = "Pete" } function ns.returnOne() @@ -308,12 +319,10 @@ assert(result == "Hi world", "Function replacement with single capture failed") -- Function replacement with multiple captures result = string.gsub("hello world", "(h)(e)(l)(l)o", function(h, e, l1, l2) - print("Captures:", h, e, l1, l2) -- Debug what captures we're getting assert(h == "h" and e == "e" and l1 == "l" and l2 == "l", "Function received incorrect captures: " .. h .. ", " .. e .. ", " .. l1 .. ", " .. l2) return string.upper(h) .. string.upper(e) .. l1 .. l2 .. "o" end) -print("Result:", result) -- Debug the actual result assert(result == "HEllo world", "Function replacement with multiple captures failed") -- Function returning nil (should keep original match) diff --git a/common/space_lua/stdlib.ts b/common/space_lua/stdlib.ts index cebfffb8..dac59a4d 100644 --- a/common/space_lua/stdlib.ts +++ b/common/space_lua/stdlib.ts @@ -10,11 +10,18 @@ import { luaToString, luaTypeOf, type LuaValue, + luaValueToJS, } from "$common/space_lua/runtime.ts"; import { stringApi } from "$common/space_lua/stdlib/string.ts"; import { tableApi } from "$common/space_lua/stdlib/table.ts"; import { osApi } from "$common/space_lua/stdlib/os.ts"; import { jsApi } from "$common/space_lua/stdlib/js.ts"; +import { parse } from "$common/space_lua/parse.ts"; +import type { + LuaBlock, + LuaFunctionCallStatement, +} from "$common/space_lua/ast.ts"; +import { evalExpression } from "$common/space_lua/eval.ts"; const printFunction = new LuaBuiltinFunction(async (_sf, ...args) => { console.log("[Lua]", ...(await Promise.all(args.map(luaToString)))); @@ -122,6 +129,82 @@ const getmetatableFunction = new LuaBuiltinFunction((_sf, table: LuaTable) => { return table.metatable; }); +/** + * This is not standard Lua, but it's a useful feature for us + */ +const interpolateFunction = new LuaBuiltinFunction( + async (sf, template: string, expandedEnv?: LuaTable) => { + let result = ""; + let currentIndex = 0; + + while (true) { + const startIndex = template.indexOf("${", currentIndex); + if (startIndex === -1) { + result += template.slice(currentIndex); + break; + } + + result += template.slice(currentIndex, startIndex); + + // Find matching closing brace by counting nesting + let nestLevel = 1; + let endIndex = startIndex + 2; + while (nestLevel > 0 && endIndex < template.length) { + if (template[endIndex] === "{") { + nestLevel++; + } else if (template[endIndex] === "}") { + nestLevel--; + } + if (nestLevel > 0) { + endIndex++; + } + } + + if (nestLevel > 0) { + throw new LuaRuntimeError("Unclosed interpolation expression", sf); + } + + const expr = template.slice(startIndex + 2, endIndex); + try { + const parsedLua = parse(`_(${expr})`) as LuaBlock; + const parsedExpr = + (parsedLua.statements[0] as LuaFunctionCallStatement).call + .args[0]; + + const globalEnv = sf.threadLocal.get("_GLOBAL"); + if (!globalEnv) { + throw new Error("_GLOBAL not defined"); + } + // Create a new env with the global env as the parent, augmented with the expandedEnv + const env = new LuaEnv(globalEnv); + if (expandedEnv) { + // Iterate over the keys in the expandedEnv and set them in the new env + for (const key of expandedEnv.keys()) { + env.setLocal(key, expandedEnv.rawGet(key)); + } + } + const luaResult = luaValueToJS( + await evalExpression( + parsedExpr, + env, + sf, + ), + ); + result += luaToString(luaResult); + } catch (e: any) { + throw new LuaRuntimeError( + `Error evaluating "${expr}": ${e.message}`, + sf, + ); + } + + currentIndex = endIndex + 1; + } + + return result; + }, +); + export function luaBuildStandardEnv() { const env = new LuaEnv(); // Top-level builtins @@ -142,6 +225,8 @@ export function luaBuildStandardEnv() { env.set("error", errorFunction); env.set("pcall", pcallFunction); env.set("xpcall", xpcallFunction); + // String interpolation + env.set("interpolate", interpolateFunction); // APIs env.set("string", stringApi); diff --git a/common/space_lua_api.ts b/common/space_lua_api.ts index ae949cf0..b46a50da 100644 --- a/common/space_lua_api.ts +++ b/common/space_lua_api.ts @@ -72,7 +72,7 @@ function exposeDefinitions( hide: def.get("hide"), } as CommandDef, async (...args: any[]) => { - const tl = await buildThreadLocalEnv(system); + const tl = await buildThreadLocalEnv(system, env); const sf = new LuaStackFrame(tl, null); try { return await fn.call(sf, ...args.map(jsToLuaValue)); @@ -102,7 +102,7 @@ function exposeDefinitions( scriptEnv.registerEventListener( { name: def.get("event") }, async (...args: any[]) => { - const tl = await buildThreadLocalEnv(system); + const tl = await buildThreadLocalEnv(system, env); const sf = new LuaStackFrame(tl, null); try { return await fn.call(sf, ...args.map(jsToLuaValue)); @@ -115,13 +115,14 @@ function exposeDefinitions( ); } -async function buildThreadLocalEnv(system: System) { +async function buildThreadLocalEnv(system: System, globalEnv: LuaEnv) { const tl = new LuaEnv(); const currentPageMeta = await system.localSyscall( "editor.getCurrentPageMeta", [], ); tl.setLocal("pageMeta", currentPageMeta); + tl.setLocal("_GLOBAL", globalEnv); return tl; } diff --git a/web/cm_plugins/lua_directive.ts b/web/cm_plugins/lua_directive.ts index bd52c3ee..da1a7b4a 100644 --- a/web/cm_plugins/lua_directive.ts +++ b/web/cm_plugins/lua_directive.ts @@ -60,6 +60,7 @@ export function luaDirectivePlugin(client: Client) { const tl = new LuaEnv(); tl.setLocal("pageMeta", currentPageMeta); + tl.setLocal("_GLOBAL", client.clientSystem.spaceLuaEnv.env); const sf = new LuaStackFrame(tl, expr.ctx); const threadLocalizedEnv = new LuaEnv( client.clientSystem.spaceLuaEnv.env,