From dfcf3035ebe5d27edc8174e3cd6e385cd69fc2b4 Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Sun, 21 Jun 2026 19:13:11 +0200 Subject: [PATCH] Lua: fix parenthes evaluation --- client/space_lua/eval.ts | 5 +- client/space_lua/lua.test.ts | 129 ++++++++++++++++++++++-- client/space_lua/parenthesized_test.lua | 35 +++++++ client/space_lua/parse.ts | 6 +- client/space_lua/pretty_print.test.ts | 4 +- 5 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 client/space_lua/parenthesized_test.lua diff --git a/client/space_lua/eval.ts b/client/space_lua/eval.ts index 762f95a7..0cdd5758 100644 --- a/client/space_lua/eval.ts +++ b/client/space_lua/eval.ts @@ -1015,7 +1015,8 @@ export function evalExpression( case "Variable": case "FunctionCall": case "TableAccess": - case "PropertyAccess": { + case "PropertyAccess": + case "Parenthesized": { return evalPrefixExpression(e, env, sf); } case "TableConstructor": { @@ -1318,7 +1319,7 @@ function evalPrefixExpression( case "Parenthesized": { const p = asParenthesized(e); - return evalExpression(p.expression, env, sf); + return rpThen(evalExpression(p.expression, env, sf), singleResult); } // <>[<>] diff --git a/client/space_lua/lua.test.ts b/client/space_lua/lua.test.ts index 6b982b80..2c3a4b46 100644 --- a/client/space_lua/lua.test.ts +++ b/client/space_lua/lua.test.ts @@ -1,15 +1,18 @@ import { expect, test } from "vitest"; import { parseBlock } from "./parse.ts"; import { luaBuildStandardEnv } from "./stdlib.ts"; -import { LuaEnv, LuaRuntimeError, LuaStackFrame } from "./runtime.ts"; +import { LuaEnv, LuaRuntimeError, LuaStackFrame, LuaTable } from "./runtime.ts"; import { evalStatement } from "./eval.ts"; import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; -import { dirname } from "node:path"; -import { readdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { readdirSync, readFileSync } from "node:fs"; +import { validateObject } from "../plugos/syscalls/jsonschema.ts"; +import { extractSpaceLuaFromPageText } from "../boot_config.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); +// Core Lua tests (bare stdlib env) const testFiles = readdirSync(__dirname, { recursive: true }) .filter((f): f is string => typeof f === "string" && f.endsWith("_test.lua")) .sort(); @@ -20,7 +23,54 @@ for (const file of testFiles) { }); } -async function runLuaTest(luaPath: string) { +// Library API Lua tests (stdlib + jsonschema + library code pre-loaded) +const libraryApisDir = resolve( + __dirname, + "../../libraries/Library/Std/APIs", +); +const libraryTestFiles = readdirSync(libraryApisDir, { recursive: true }) + .filter((f): f is string => typeof f === "string" && f.endsWith("_test.lua")) + .sort(); + +for (const file of libraryTestFiles) { + // A test file may declare its companion library .md explicitly via a header + // comment on (any of) its first lines, e.g. "-- companion: Code Widget.md". + // This is required for multi-word library pages that the derivation below + // cannot reach. + const testSource = readFileSync(resolve(libraryApisDir, file), "utf-8"); + const companionMatch = testSource.match( + /^--\s*companion:\s*(.+?)\s*$/m, + ); + let libraryMdPath: string; + if (companionMatch) { + libraryMdPath = resolve(libraryApisDir, companionMatch[1]); + } else { + // Derive the companion library .md name from the test file name, e.g. + // "widget_sandbox_test.lua" → "Widget.md". + // NOTE: only the first snake_case segment is used, so this scheme cannot + // reach multi-word library pages (e.g. "Task State.md", "DOM.md"). Such a + // test should declare its companion path explicitly via the header comment + // above rather than relying on this derivation. + const libraryName = file + .replace(/_test\.lua$/, "") + // Convert snake_case segment to PascalCase for the .md filename, keeping + // only the first segment so "widget_sandbox" → "Widget" + .split("_")[0] + .replace(/^./, (c) => c.toUpperCase()); + libraryMdPath = resolve(libraryApisDir, `${libraryName}.md`); + } + test(`[Lua] ${file}`, async () => { + await runLuaTest( + resolve(libraryApisDir, file), + { isAbsolutePath: true, preloadMdPath: libraryMdPath }, + ); + }); +} + +async function runLuaTest( + luaPath: string, + opts: { isAbsolutePath?: boolean; preloadMdPath?: string } = {}, +) { if ( typeof globalThis.client !== "undefined" && globalThis.client && @@ -54,12 +104,75 @@ async function runLuaTest(luaPath: string) { }; } - const luaFile = await readFile( - fileURLToPath(new URL(luaPath, import.meta.url)), - "utf-8", - ); + const resolvedPath = opts.isAbsolutePath + ? luaPath + : fileURLToPath(new URL(luaPath, import.meta.url)); + const luaFile = await readFile(resolvedPath, "utf-8"); const chunk = parseBlock(luaFile, {}); const env = new LuaEnv(luaBuildStandardEnv()); + + // If a companion .md library file is provided, extract its space-lua block(s) + // and run them in the env first, after injecting any required syscall stubs. + if (opts.preloadMdPath) { + // Provide a minimal jsonschema table so widget.new validation works + const jsonschemaTbl = new LuaTable(); + await jsonschemaTbl.set( + "validateObject", + (schema: any, obj: any) => validateObject(schema, obj), + ); + env.set("jsonschema", jsonschemaTbl); + + // Provide a minimal in-memory `config` table (normally syscall-backed) so + // library APIs that wrap config.get/config.set (e.g. command.define, + // codeWidget.define) work in the bare test env. Values are stored as their + // raw Lua values (no JS conversion) so e.g. function fields survive a + // set/get round trip. + const configStore = new Map(); + const pathToKey = (path: unknown): string => { + if (path instanceof LuaTable) { + const parts: string[] = []; + for (let i = 1; i <= path.length; i++) { + parts.push(String(path.get(i))); + } + return parts.join("\0"); + } + // string path: support dot notation like the real config API + return String(path).split(".").join("\0"); + }; + const configTbl = new LuaTable(); + await configTbl.set( + "set", + (path: unknown, value: unknown) => { + configStore.set(pathToKey(path), value); + }, + ); + await configTbl.set( + "get", + (path: unknown, defaultValue: unknown) => { + const key = pathToKey(path); + return configStore.has(key) + ? configStore.get(key) + : (defaultValue ?? null); + }, + ); + env.set("config", configTbl); + + let mdText: string; + try { + mdText = await readFile(opts.preloadMdPath, "utf-8"); + } catch { + throw new Error( + `Library preload file not found: ${opts.preloadMdPath}`, + ); + } + const libLua = extractSpaceLuaFromPageText(mdText); + if (libLua) { + const libChunk = parseBlock(libLua, {}); + const libSf = LuaStackFrame.createWithGlobalEnv(env, libChunk.ctx); + await evalStatement(libChunk, env, libSf); + } + } + const sf = LuaStackFrame.createWithGlobalEnv(env, chunk.ctx); try { diff --git a/client/space_lua/parenthesized_test.lua b/client/space_lua/parenthesized_test.lua new file mode 100644 index 00000000..02dd4442 --- /dev/null +++ b/client/space_lua/parenthesized_test.lua @@ -0,0 +1,35 @@ +-- ( exp ) must truncate a multi-return to exactly one value (Lua 5.4 semantics). +-- string.gsub returns 2 values (string, count); parens must drop the count. + +-- 1: parenthesized call yields arity 1 +assert(select("#", (string.gsub("a:b", ":", " "))) == 1, "paren call arity should be 1") + +-- 2: a function returning (multi) yields arity 1 +local function mk() return (string.gsub("a:b", ":", " ")) end +assert(select("#", mk()) == 1, "return (multi) should yield arity 1") + +-- 3: table.insert with a parenthesized-returning call inserts exactly one element +local t1 = {} +table.insert(t1, mk()) +assert(#t1 == 1, "table.insert should add one element, got " .. #t1) +assert(t1[1] == "a b", "inserted value should be the gsub string, got " .. tostring(t1[1])) + +-- 4: table.insert with an inline parenthesized gsub +local t2 = {} +table.insert(t2, (string.gsub("a:b", ":", " "))) +assert(#t2 == 1, "inline paren insert should add one element, got " .. #t2) + +-- 5: contrast — binding to a local already worked; must still work +local t3 = {} +local v = mk() +table.insert(t3, v) +assert(#t3 == 1, "local-bound insert should add one element") + +-- 6: multiple assignment from a parenthesized multi-return drops extra values +local a, b = (string.gsub("a:b", ":", " ")) +assert(a == "a b", "first value preserved") +assert(b == nil, "parenthesized truncation should leave b nil, got " .. tostring(b)) + +-- 7: an UNparenthesized trailing call must still expand (no regression) +local function multi() return string.gsub("a:b", ":", " ") end +assert(select("#", multi()) == 2, "unparenthesized return must still expand to 2") diff --git a/client/space_lua/parse.ts b/client/space_lua/parse.ts index e22b0506..48547ead 100644 --- a/client/space_lua/parse.ts +++ b/client/space_lua/parse.ts @@ -1269,7 +1269,11 @@ function parseExpression(t: ParseTree, ctx: ASTCtx): LuaExpression { }; case "Parens": - return parseExpression(t.children![1], ctx); + return { + type: "Parenthesized", + expression: parseExpression(t.children![1], ctx), + ctx: context(t, ctx), + }; case "FunctionCall": { return parseFunctionCall(t, ctx); } diff --git a/client/space_lua/pretty_print.test.ts b/client/space_lua/pretty_print.test.ts index 41f8eadb..b443723a 100644 --- a/client/space_lua/pretty_print.test.ts +++ b/client/space_lua/pretty_print.test.ts @@ -31,8 +31,8 @@ test("variable and access expressions", () => { expect(fmtExpr("a.b.c")).toBe("a.b.c"); expect(fmtExpr("t[1]")).toBe("t[1]"); expect(fmtExpr('t["k"]')).toBe('t["k"]'); - // the parser drops redundant parens around a bare variable - expect(fmtExpr("(a)")).toBe("a"); + // the parser preserves parens (dropping them could change multi-return semantics) + expect(fmtExpr("(a)")).toBe("(a)"); }); test("binary operators with spacing", () => {