diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5b0d8d80..090f3048 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,8 @@ jobs: - uses: actboy168/setup-luamake@master - run: luamake -arch x86_64 -notest -sanitize - run: luamake test -v + - run: luamake -arch x86_64 -notest -sanitize -optchain + - run: luamake test -v windows-clang: name: windows (clang) runs-on: windows-latest @@ -59,6 +61,8 @@ jobs: - uses: actboy168/setup-luamake@master - run: luamake -notest -sanitize - run: luamake test -v + - run: luamake -notest -sanitize -optchain + - run: luamake test -v linux: strategy: fail-fast: false @@ -71,6 +75,8 @@ jobs: - run: luamake -notest -sanitize - run: luamake lua test/glibc-version.lua - run: luamake test -v + - run: luamake -notest -sanitize -optchain + - run: luamake test -v linux-qemu: strategy: fail-fast: false diff --git a/.luarc.json b/.luarc.json index 5556ee08..b5f87ac9 100644 --- a/.luarc.json +++ b/.luarc.json @@ -20,7 +20,12 @@ "code-after-break": "Warning", "empty-block": "Warning", "trailing-space": "Warning" - } + }, + "ignoredFiles": "Disable" }, - "workspace.checkThirdParty": false + "workspace.checkThirdParty": false, + "workspace.ignoreDir": [ + ".vscode", + "test/test_optional_chain.lua" + ] } diff --git a/3rd/lua-patch/optchain/README.md b/3rd/lua-patch/optchain/README.md new file mode 100644 index 00000000..be2b63e7 --- /dev/null +++ b/3rd/lua-patch/optchain/README.md @@ -0,0 +1,52 @@ +# 可选链(Optional Chaining)补丁 + +## 概述 + +为 Lua 添加可选链操作符支持,语法与语义对齐 ES2020 的 `?.`,包含四种形式: + +- `x?.y` — 字段访问 +- `x?:f()` — 方法调用 +- `f?()` — 函数调用 +- `t?[1]` — 索引访问 + +支持任意链式组合,如 `a?.b.c`、`a?.b?.c`、`obj?:get()?.x`。 + +## 实现方式 + +以 git diff 补丁形式存放在本目录(`lua54.patch` / `lua55.patch`),通过通用补丁基础设施(见 `AGENT.md` 的「自定义 Lua 补丁」)在构建期应用:`luamake -optchain` 时把官方源码整树复制到 `$builddir/patched/lua/` 并 `git apply` 本补丁。 + +补丁直接修改 `lparser.c` / `lvm.c` / `lopcodes.*` / `lopnames.h` / `ljumptab.h` / `ldebug.c` / `luac.c` / `lcode.c`,**无 `#ifdef` 门控**——门控完全在构建层:是否打补丁由注册表里的 `optchain` 开关决定,默认构建不打补丁、直接编译官方源码,行为零影响。 + +- **解析器(`lparser.c`)**:`?.` 在编译期展开为标准指令组合(LOADNIL / EQ / JMP + 字段/索引/调用); +- **新增指令 `OP_SETTOP`**:链末调用(`f?()`、`obj?:m()`)的短路路径使用 `CALL(k) / JMP / OP_SETTOP` 固定布局,`OP_SETTOP` 把结果寄存器填 nil 并精确设置栈顶 `L->top`,使开放指令(`OP_RETURN` / `OP_CALL` / `OP_SETLIST`)读到恰好数量的 nil; +- `OP_SETTOP` 追加在 `OP_EXTRAARG` 之后,普通代码的指令编号完全不变,补丁版编译的普通代码与标准版字节码一致; +- 新指令的配套同步:`ljumptab.h`(GCC computed-goto 跳转表,缺项会在运行时跳转 NULL)、`ldebug.c`(`findsetreg` 寄存器归属分析)、`luac.c`(`-l` 反汇编打印)、`lcode.c` / `lvm.c`(debug 构建的 top 断言对「短路生产者运行期才确定」的固定布局适配)。 + +## 为什么需要新增指令(及备选方案) + +Lua 的 `LOADNIL` 只把寄存器填为 nil,不会调整栈顶 `L->top`,因此链末调用在多值上下文(`return f?()`、`g(f?())`、`{f?()}`)短路时,开放指令会读到栈上残留的旧值,产生「nil 数量偏多」。此前考虑过不加指令的绕过方案:短路路径改为调用一个预先准备的、返回 1 个 nil 的函数——`OP_CALL` 会按实际结果数设置 `L->top`,且在固定结果数时自动补足 nil(如 `a, b, c = nilfn()` 得 3 个 nil),可精确覆盖所有短路场景,语义上可行。但该方案: + +- 依赖运行时辅助函数:需要一个全局/内部注册的「nil 函数」,既污染命名空间,又可能被用户重定义而破坏短路语义; +- 真实调用开销:每次短路都走一次完整函数调用(栈帧/参数调整),远慢于一条简单指令,短路路径也需「加载函数 + 调用」两条指令; +- 语义隐晦:依赖「辅助函数返回 1 个 nil」与「CALL 自动补 nil」的间接行为。 + +因此最终新增 `OP_SETTOP`:一条指令同时完成「寄存器填 nil」与「精确设置栈顶」,语义直接、零运行时依赖与调用开销,且位于 `OP_EXTRAARG` 之后不影响普通代码的指令编号。 + +## 语义(对齐 ES2020) + +- **仅 nil 短路**:`false?.a` 仍会报错(与 JS 的 `undefined` / `null` 语义对齐); +- **短路即整链**:一旦中间某节为 nil,整条链立即得到 nil,后续 key / 实参不再求值(无副作用); +- **receiver 只求值一次**:`recv()?.a?.b` 中 `recv()` 只调用一次; +- **结果不可赋值**:`obj?.a = 1` 为语法错误; +- **支持多返回值**:链末调用保留 open call,可产生多个值;短路路径精确产生对应数量的 nil: + - `return f?()` 短路 → 恰好 1 个 nil + - `g(f?())` 短路 → 恰好 1 个参数 + - `{f?()}` 短路 → 恰好 1 个 nil 元素 + - `a, b, c = f?()` 短路 → 3 个 nil + - 非短路时正常返回被调用函数的全部值 + +## 构建与测试 + +- 通过 `luamake -optchain` 启用; +- 新增 `test/test_optional_chain.lua`,覆盖字段 / 索引 / 方法 / 调用(含带参)/ 链式组合 / 短路副作用 / 单次求值 / 不可赋值 / 非法语法 / 多返回值(含短路精确性)等用例; +- 测试文件在默认构建下自动跳过(`test/test.lua` 用运行时 `load` 探测 `?.` 是否可用),不影响现有测试。 diff --git a/3rd/lua-patch/optchain/lua54.patch b/3rd/lua-patch/optchain/lua54.patch new file mode 100644 index 00000000..093ee360 --- /dev/null +++ b/3rd/lua-patch/optchain/lua54.patch @@ -0,0 +1,239 @@ +diff --git a/ldebug.c b/ldebug.c +index 0ac7fed..b30f2a2 100644 +--- a/ldebug.c ++++ b/ldebug.c +@@ -445,7 +445,8 @@ static int findsetreg (const Proto *p, int lastpc, int reg) { + int a = GETARG_A(i); + int change; /* true if current instruction changed 'reg' */ + switch (op) { +- case OP_LOADNIL: { /* set registers from 'a' to 'a+b' */ ++ case OP_LOADNIL: ++ case OP_SETTOP: { /* set registers from 'a' to 'a+b' */ + int b = GETARG_B(i); + change = (a <= reg && reg <= a + b); + break; +diff --git a/ljumptab.h b/ljumptab.h +index 8306f25..3f59c76 100644 +--- a/ljumptab.h ++++ b/ljumptab.h +@@ -107,6 +107,7 @@ static const void *const disptab[NUM_OPCODES] = { + &&L_OP_CLOSURE, + &&L_OP_VARARG, + &&L_OP_VARARGPREP, +-&&L_OP_EXTRAARG ++&&L_OP_EXTRAARG, ++&&L_OP_SETTOP + + }; +diff --git a/lopcodes.c b/lopcodes.c +index c67aa22..97f715b 100644 +--- a/lopcodes.c ++++ b/lopcodes.c +@@ -100,5 +100,6 @@ LUAI_DDEF const lu_byte luaP_opmodes[NUM_OPCODES] = { + ,opmode(0, 1, 0, 0, 1, iABC) /* OP_VARARG */ + ,opmode(0, 0, 1, 0, 1, iABC) /* OP_VARARGPREP */ + ,opmode(0, 0, 0, 0, 0, iAx) /* OP_EXTRAARG */ ++ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_SETTOP */ + }; + +diff --git a/lopcodes.h b/lopcodes.h +index 46911ca..d4822d0 100644 +--- a/lopcodes.h ++++ b/lopcodes.h +@@ -307,10 +307,11 @@ OP_VARARG,/* A C R[A], R[A+1], ..., R[A+C-2] = vararg */ + OP_VARARGPREP,/*A (adjust vararg parameters) */ + + OP_EXTRAARG/* Ax extra (larger) argument for previous opcode */ ++,OP_SETTOP/* A B R[A], ..., R[A+B] := nil; top := A+B+1 */ + } OpCode; + + +-#define NUM_OPCODES ((int)(OP_EXTRAARG) + 1) ++#define NUM_OPCODES ((int)(OP_SETTOP) + 1) + + + +diff --git a/lopnames.h b/lopnames.h +index 965cec9..63b372a 100644 +--- a/lopnames.h ++++ b/lopnames.h +@@ -96,6 +96,7 @@ static const char *const opnames[] = { + "VARARG", + "VARARGPREP", + "EXTRAARG", ++ "SETTOP", + NULL + }; + +diff --git a/lparser.c b/lparser.c +index eed008c..bbe35fa 100644 +--- a/lparser.c ++++ b/lparser.c +@@ -37,6 +37,24 @@ + + #define hasmultret(k) ((k) == VCALL || (k) == VVARARG) + ++/* ++** Patch the short-circuit path of an optional-chain call so that it ++** produces 'nresults' nil values instead of a single one. This allows ++** chains ending in a call to yield multiple results (e.g. ++** 'local a, b = f?()'). ++*/ ++static void luaK_setreturns_optchain (FuncState *fs, expdesc *e, int nresults) { ++ if (e->k == VCALL) { /* open function call? */ ++ int pc = e->u.info; /* position of the call */ ++ if (TESTARG_k(fs->f->code[pc])) { /* optional-chain call (fixed layout)? */ ++ /* A positive 'nresults' needs exactly that many nils; otherwise ++ (0, value discarded) B stays 0 (a single nil). */ ++ if (nresults > 0) /* fixed positive number of results? */ ++ SETARG_B(fs->f->code[pc + 2], nresults - 1); /* widen OP_SETTOP */ ++ } ++ } ++} ++ + + /* because all strings are unified by the scanner, the parser + can use pointer equality for string equality */ +@@ -486,6 +504,7 @@ static void adjust_assign (LexState *ls, int nvars, int nexps, expdesc *e) { + int extra = needed + 1; /* discount last expression itself */ + if (extra < 0) + extra = 0; ++ luaK_setreturns_optchain(fs, e, extra); + luaK_setreturns(fs, e, extra); /* last exp. provides the difference */ + } + else { +@@ -1105,9 +1124,29 @@ static void suffixedexp (LexState *ls, expdesc *v) { + /* suffixedexp -> + primaryexp { '.' NAME | '[' exp ']' | ':' NAME funcargs | funcargs } */ + FuncState *fs = ls->fs; ++ int niljumps = NO_JUMP; /* patch list of optional-chain exits (nil) */ ++ int chain_base = fs->freereg; /* result register of the chain */ + primaryexp(ls, v); + for (;;) { + switch (ls->t.token) { ++ case '?': { /* optional chain: '?.' '?:' '?[' '?(' */ ++ int reg, nilreg; ++ luaX_next(ls); /* consume '?' */ ++ if (ls->t.token != '.' && ls->t.token != ':' && ++ ls->t.token != '[' && ls->t.token != '(') ++ luaX_syntaxerror(ls, "unexpected symbol near '?'"); ++ reg = luaK_exp2anyreg(fs, v); /* evaluate receiver only once */ ++ nilreg = fs->freereg; ++ luaK_nil(fs, nilreg, 1); /* ensure it is nil at runtime */ ++ /* Reserve the temp through luaK_reserveregs (which also grows ++ 'maxstacksize') instead of a raw freereg++ that would bypass ++ luaK_checkstack and leave 'maxstacksize' stale. */ ++ luaK_reserveregs(fs, 1); ++ luaK_codeABCk(fs, OP_EQ, reg, nilreg, 0, 1); /* jump when nil */ ++ luaK_concat(fs, &niljumps, luaK_jump(fs)); ++ fs->freereg--; /* release the nil slot; only used by OP_EQ */ ++ break; /* next iteration handles '.' ':' '[' '(' */ ++ } + case '.': { /* fieldsel */ + fieldsel(ls, v); + break; +@@ -1132,7 +1171,48 @@ static void suffixedexp (LexState *ls, expdesc *v) { + funcargs(ls, v); + break; + } +- default: return; ++ default: ++ if (niljumps != NO_JUMP) { ++ int skip, nilpc; ++ if (v->k == VCALL) { ++ /* A chain ending in a call can yield multiple results: keep ++ the call open instead of collapsing it to a single value. ++ We emit a fixed layout so that later consumers can patch ++ the short-circuit path without storing extra info in 'e': ++ CALL base ... (with the 'k' flag set, marking the chain) ++ JMP skip ++ OP_SETTOP base 0 (fills 1 nil and fixes the stack top) ++ skip: ++ The OP_SETTOP is always at call+2; luaK_setreturns_optchain ++ widens its B field when more nils are needed. 'e' keeps the ++ plain VCALL semantics (t/f stay NO_JUMP), so single-value ++ consumers work unchanged. */ ++ int base = GETARG_A(fs->f->code[v->u.info]); /* call base */ ++ SETARG_k(fs->f->code[v->u.info], 1); /* mark optional chain */ ++ skip = luaK_jump(fs); /* non-nil path skips the nil fill */ ++ nilpc = luaK_codeABC(fs, OP_SETTOP, base, 0, 0); ++ luaK_patchtohere(fs, skip); ++ fs->freereg = base + 1; ++ luaK_patchlist(fs, niljumps, nilpc); /* nil exits jump here */ ++ } ++ else { ++ int r; ++ if (vkisindexed(v->k)) ++ luaK_exp2anyreg(fs, v); /* make it a value, not a var */ ++ r = v->u.info; /* now a VNONRELOC register */ ++ if (r != chain_base) { /* move result to the chain base register */ ++ luaK_codeABC(fs, OP_MOVE, chain_base, r, 0); ++ v->u.info = chain_base; ++ v->k = VNONRELOC; ++ } ++ skip = luaK_jump(fs); /* non-nil path skips the nil fill */ ++ nilpc = luaK_codeABC(fs, OP_LOADNIL, chain_base, 0, 0); ++ luaK_patchtohere(fs, skip); ++ fs->freereg = chain_base + 1; /* free chain temporaries */ ++ luaK_patchlist(fs, niljumps, nilpc); /* nil exits jump to LOADNIL */ ++ } ++ } ++ return; + } + } + } +@@ -1824,7 +1904,10 @@ static void retstat (LexState *ls) { + if (hasmultret(e.k)) { + luaK_setmultret(fs, &e); + #if defined(NDEBUG) +- if (e.k == VCALL && nret == 1 && !fs->bl->insidetbc) { /* tail call? */ ++ /* no tail call for optional-chain calls: the short-circuit path ++ needs the fixed CALL/JMP/OP_SETTOP layout */ ++ if (e.k == VCALL && nret == 1 && !fs->bl->insidetbc ++ && !TESTARG_k(fs->f->code[e.u.info])) { /* tail call? */ + SET_OPCODE(getinstruction(fs,&e), OP_TAILCALL); + lua_assert(GETARG_A(getinstruction(fs,&e)) == luaY_nvarstack(fs)); + } +@@ -1967,4 +2050,3 @@ LClosure *luaY_parser (lua_State *L, ZIO *z, Mbuffer *buff, + L->top.p--; /* remove scanner's table */ + return cl; /* closure is on the stack, too */ + } +- +diff --git a/luac.c b/luac.c +index 5f4a141..944ebaa 100644 +--- a/luac.c ++++ b/luac.c +@@ -650,6 +650,9 @@ static void PrintCode(const Proto* f) + case OP_EXTRAARG: + printf("%d",ax); + break; ++ case OP_SETTOP: ++ printf("%d %d",a,b); ++ break; + #if 0 + default: + printf("%d %d %d",a,b,c); +diff --git a/lvm.c b/lvm.c +index 7023a04..65ffabf 100644 +--- a/lvm.c ++++ b/lvm.c +@@ -1236,6 +1236,19 @@ void luaV_execute (lua_State *L, CallInfo *ci) { + } while (b--); + vmbreak; + } ++ vmcase(OP_SETTOP) { ++ /* Optional-chain short circuit: fill R[A..A+B] with nils and ++ also fix the stack top so that open instructions (OP_RETURN, ++ OP_CALL, OP_SETLIST) read exactly the nils produced here. ++ (Only the optional-chain compiler emits this instruction.) */ ++ StkId ra = RA(i); ++ int b = GETARG_B(i); ++ do { ++ setnilvalue(s2v(ra++)); ++ } while (b--); ++ L->top.p = RA(i) + GETARG_B(i) + 1; ++ vmbreak; ++ } + vmcase(OP_GETUPVAL) { + StkId ra = RA(i); + int b = GETARG_B(i); diff --git a/3rd/lua-patch/optchain/lua55.patch b/3rd/lua-patch/optchain/lua55.patch new file mode 100644 index 00000000..1426b7ea --- /dev/null +++ b/3rd/lua-patch/optchain/lua55.patch @@ -0,0 +1,278 @@ +diff --git a/lcode.c b/lcode.c +index 4caa804..8fff883 100644 +--- a/lcode.c ++++ b/lcode.c +@@ -1935,7 +1935,15 @@ void luaK_finish (FuncState *fs) { + Instruction *pc = &p->code[i]; + /* avoid "not used" warnings when assert is off (for 'onelua.c') */ + (void)luaP_isOT; (void)luaP_isIT; +- lua_assert(i == 0 || luaP_isOT(*(pc - 1)) == luaP_isIT(*pc)); ++ /* An optional-chain call keeps an open CALL followed by a JMP that ++ skips the short-circuit fill (OP_SETTOP), and OP_SETTOP itself ++ sets the top only when reached: the actual producer of the top is ++ known only at runtime, so these two fixed-layout pairs are exempt. */ ++ lua_assert(i == 0 ++ || GET_OPCODE(*(pc - 1)) == OP_SETTOP ++ || (GET_OPCODE(*(pc - 1)) == OP_CALL && TESTARG_k(*(pc - 1)) ++ && GET_OPCODE(*pc) == OP_JMP) ++ || luaP_isOT(*(pc - 1)) == luaP_isIT(*pc)); + switch (GET_OPCODE(*pc)) { + case OP_RETURN0: case OP_RETURN1: { + if (!(fs->needclose || (p->flag & PF_VAHID))) +diff --git a/ldebug.c b/ldebug.c +index f54c9c5..3e30097 100644 +--- a/ldebug.c ++++ b/ldebug.c +@@ -452,7 +452,8 @@ static int findsetreg (const Proto *p, int lastpc, int reg) { + int a = GETARG_A(i); + int change; /* true if current instruction changed 'reg' */ + switch (op) { +- case OP_LOADNIL: { /* set registers from 'a' to 'a+b' */ ++ case OP_LOADNIL: ++ case OP_SETTOP: { /* set registers from 'a' to 'a+b' */ + int b = GETARG_B(i); + change = (a <= reg && reg <= a + b); + break; +diff --git a/ljumptab.h b/ljumptab.h +index 52fa6d7..3122b74 100644 +--- a/ljumptab.h ++++ b/ljumptab.h +@@ -109,6 +109,7 @@ static const void *const disptab[NUM_OPCODES] = { + &&L_OP_GETVARG, + &&L_OP_ERRNNIL, + &&L_OP_VARARGPREP, +-&&L_OP_EXTRAARG ++&&L_OP_EXTRAARG, ++&&L_OP_SETTOP + + }; +diff --git a/lopcodes.c b/lopcodes.c +index 7e18231..5346592 100644 +--- a/lopcodes.c ++++ b/lopcodes.c +@@ -106,6 +106,7 @@ LUAI_DDEF const lu_byte luaP_opmodes[NUM_OPCODES] = { + ,opmode(0, 0, 0, 0, 0, iABx) /* OP_ERRNNIL */ + ,opmode(0, 0, 1, 0, 1, iABC) /* OP_VARARGPREP */ + ,opmode(0, 0, 0, 0, 0, iAx) /* OP_EXTRAARG */ ++ ,opmode(0, 0, 0, 0, 1, iABC) /* OP_SETTOP */ + }; + + +diff --git a/lopcodes.h b/lopcodes.h +index b6bd182..820b30d 100644 +--- a/lopcodes.h ++++ b/lopcodes.h +@@ -345,10 +345,11 @@ OP_ERRNNIL,/* A Bx raise error if R[A] ~= nil (K[Bx - 1] is global name)*/ + OP_VARARGPREP,/* (adjust varargs) */ + + OP_EXTRAARG/* Ax extra (larger) argument for previous opcode */ ++,OP_SETTOP/* A B R[A], ..., R[A+B] := nil; top := A+B+1 */ + } OpCode; + + +-#define NUM_OPCODES ((int)(OP_EXTRAARG) + 1) ++#define NUM_OPCODES ((int)(OP_SETTOP) + 1) + + + +diff --git a/lopnames.h b/lopnames.h +index 0554a2e..e90d52f 100644 +--- a/lopnames.h ++++ b/lopnames.h +@@ -98,6 +98,7 @@ static const char *const opnames[] = { + "ERRNNIL", + "VARARGPREP", + "EXTRAARG", ++ "SETTOP", + NULL + }; + +diff --git a/lparser.c b/lparser.c +index 090e150..9c7a49c 100644 +--- a/lparser.c ++++ b/lparser.c +@@ -37,6 +37,27 @@ + + #define hasmultret(k) ((k) == VCALL || (k) == VVARARG) + ++/* ++** Patch the short-circuit path of an optional-chain call so that it ++** produces 'nresults' nil values instead of a single one. This allows ++** chains ending in a call to yield multiple results (e.g. ++** 'local a, b = f?()'). ++*/ ++static void luaK_setreturns_optchain (FuncState *fs, expdesc *e, int nresults) { ++ if (e->k == VCALL) { /* open function call? */ ++ int pc = e->u.info; /* position of the call */ ++ if (TESTARG_k(fs->f->code[pc])) { /* optional-chain call (fixed layout)? */ ++ /* The short-circuit path is a fixed layout right after the call: ++ CALL (with 'k' flag set) / JMP / OP_SETTOP. The OP_SETTOP (which ++ also fixes the stack top; see lvm.c) holds the number of nil ++ slots minus one in its B field: a positive 'nresults' needs ++ exactly that many nils; otherwise B stays 0 (a single nil). */ ++ if (nresults > 0) /* fixed positive number of results? */ ++ SETARG_B(fs->f->code[pc + 2], nresults - 1); /* widen OP_SETTOP */ ++ } ++ } ++} ++ + + /* because all strings are unified by the scanner, the parser + can use pointer equality for string equality */ +@@ -552,6 +573,7 @@ static void adjust_assign (LexState *ls, int nvars, int nexps, expdesc *e) { + int extra = needed + 1; /* discount last expression itself */ + if (extra < 0) + extra = 0; ++ luaK_setreturns_optchain(fs, e, extra); + luaK_setreturns(fs, e, extra); /* last exp. provides the difference */ + } + else { +@@ -1218,9 +1240,29 @@ static void suffixedexp (LexState *ls, expdesc *v) { + /* suffixedexp -> + primaryexp { '.' NAME | '[' exp ']' | ':' NAME funcargs | funcargs } */ + FuncState *fs = ls->fs; ++ int niljumps = NO_JUMP; /* patch list of optional-chain exits (nil) */ ++ int chain_base = fs->freereg; /* result register of the chain */ + primaryexp(ls, v); + for (;;) { + switch (ls->t.token) { ++ case '?': { /* optional chain: '?.' '?:' '?[' '?(' */ ++ int reg, nilreg; ++ luaX_next(ls); /* consume '?' */ ++ if (ls->t.token != '.' && ls->t.token != ':' && ++ ls->t.token != '[' && ls->t.token != '(') ++ luaX_syntaxerror(ls, "unexpected symbol near '?'"); ++ reg = luaK_exp2anyreg(fs, v); /* evaluate receiver only once */ ++ nilreg = fs->freereg; ++ luaK_nil(fs, nilreg, 1); /* ensure it is nil at runtime */ ++ /* Reserve the temp through luaK_reserveregs (which also grows ++ 'maxstacksize') instead of a raw freereg++ that would bypass ++ luaK_checkstack and leave 'maxstacksize' stale. */ ++ luaK_reserveregs(fs, 1); ++ luaK_codeABCk(fs, OP_EQ, reg, nilreg, 0, 1); /* jump when nil */ ++ luaK_concat(fs, &niljumps, luaK_jump(fs)); ++ fs->freereg--; /* release the nil slot; only used by OP_EQ */ ++ break; /* next iteration handles '.' ':' '[' '(' */ ++ } + case '.': { /* fieldsel */ + fieldsel(ls, v); + break; +@@ -1245,7 +1287,48 @@ static void suffixedexp (LexState *ls, expdesc *v) { + funcargs(ls, v); + break; + } +- default: return; ++ default: ++ if (niljumps != NO_JUMP) { ++ int skip, nilpc; ++ if (v->k == VCALL) { ++ /* A chain ending in a call can yield multiple results: keep ++ the call open instead of collapsing it to a single value. ++ We emit a fixed layout so that later consumers can patch ++ the short-circuit path without storing extra info in 'e': ++ CALL base ... (with the 'k' flag set, marking the chain) ++ JMP skip ++ OP_SETTOP base 0 (fills 1 nil and fixes the stack top) ++ skip: ++ The OP_SETTOP is always at call+2; luaK_setreturns_optchain ++ widens its B field when more nils are needed. 'e' keeps the ++ plain VCALL semantics (t/f stay NO_JUMP), so single-value ++ consumers work unchanged. */ ++ int base = GETARG_A(fs->f->code[v->u.info]); /* call base */ ++ SETARG_k(fs->f->code[v->u.info], 1); /* mark optional chain */ ++ skip = luaK_jump(fs); /* non-nil path skips the nil fill */ ++ nilpc = luaK_codeABC(fs, OP_SETTOP, base, 0, 0); ++ luaK_patchtohere(fs, skip); ++ fs->freereg = base + 1; ++ luaK_patchlist(fs, niljumps, nilpc); /* nil exits jump here */ ++ } ++ else { ++ int r; ++ if (vkisindexed(v->k)) ++ luaK_exp2anyreg(fs, v); /* make it a value, not a var */ ++ r = v->u.info; /* now a VNONRELOC register */ ++ if (r != chain_base) { /* move result to the chain base register */ ++ luaK_codeABC(fs, OP_MOVE, chain_base, r, 0); ++ v->u.info = chain_base; ++ v->k = VNONRELOC; ++ } ++ skip = luaK_jump(fs); /* non-nil path skips the nil fill */ ++ nilpc = luaK_codeABC(fs, OP_LOADNIL, chain_base, 0, 0); ++ luaK_patchtohere(fs, skip); ++ fs->freereg = chain_base + 1; /* free chain temporaries */ ++ luaK_patchlist(fs, niljumps, nilpc); /* nil exits jump to LOADNIL */ ++ } ++ } ++ return; + } + } + } +@@ -2036,7 +2119,10 @@ static void retstat (LexState *ls) { + if (hasmultret(e.k)) { + luaK_setmultret(fs, &e); + #if defined(NDEBUG) +- if (e.k == VCALL && nret == 1 && !fs->bl->insidetbc) { /* tail call? */ ++ /* no tail call for optional-chain calls: the short-circuit path ++ needs the fixed CALL/JMP/OP_SETTOP layout */ ++ if (e.k == VCALL && nret == 1 && !fs->bl->insidetbc ++ && !TESTARG_k(fs->f->code[e.u.info])) { /* tail call? */ + SET_OPCODE(getinstruction(fs,&e), OP_TAILCALL); + lua_assert(GETARG_A(getinstruction(fs,&e)) == luaY_nvarstack(fs)); + } +@@ -2201,4 +2287,3 @@ LClosure *luaY_parser (lua_State *L, ZIO *z, Mbuffer *buff, + L->top.p--; /* remove scanner's table */ + return cl; /* closure is on the stack, too */ + } +- +diff --git a/luac.c b/luac.c +index 4a2016a..1e73bd4 100644 +--- a/luac.c ++++ b/luac.c +@@ -661,6 +661,9 @@ static void PrintCode(const Proto* f) + case OP_EXTRAARG: + printf("%d",ax); + break; ++ case OP_SETTOP: ++ printf("%d %d",a,b); ++ break; + #if 0 + default: + printf("%d %d %d",a,b,c); +diff --git a/lvm.c b/lvm.c +index c70e2b8..fce695c 100644 +--- a/lvm.c ++++ b/lvm.c +@@ -1227,8 +1227,11 @@ void luaV_execute (lua_State *L, CallInfo *ci) { + #endif + lua_assert(base == ci->func.p + 1); + lua_assert(base <= L->top.p && L->top.p <= L->stack_last.p); +- /* for tests, invalidate top for instructions not expecting it */ +- lua_assert(luaP_isIT(i) || (cast_void(L->top.p = base), 1)); ++ /* for tests, invalidate top for instructions not expecting it; ++ a jump is top-transparent (an optional-chain open call may keep ++ the top it set across the jump that skips the short-circuit path) */ ++ lua_assert(luaP_isIT(i) || GET_OPCODE(i) == OP_JMP ++ || (cast_void(L->top.p = base), 1)); + vmdispatch (GET_OPCODE(i)) { + vmcase(OP_MOVE) { + StkId ra = RA(i); +@@ -1284,6 +1287,19 @@ void luaV_execute (lua_State *L, CallInfo *ci) { + } while (b--); + vmbreak; + } ++ vmcase(OP_SETTOP) { ++ /* Optional-chain short circuit: fill R[A..A+B] with nils and ++ also fix the stack top so that open instructions (OP_RETURN, ++ OP_CALL, OP_SETLIST) read exactly the nils produced here. ++ (Only the optional-chain compiler emits this instruction.) */ ++ StkId ra = RA(i); ++ int b = GETARG_B(i); ++ do { ++ setnilvalue(s2v(ra++)); ++ } while (b--); ++ L->top.p = RA(i) + GETARG_B(i) + 1; ++ vmbreak; ++ } + vmcase(OP_GETUPVAL) { + StkId ra = RA(i); + int b = GETARG_B(i); diff --git a/3rd/lua54/lprefix.h b/3rd/lua54/lprefix.h index 15880072..f37e046f 100644 --- a/3rd/lua54/lprefix.h +++ b/3rd/lua54/lprefix.h @@ -39,16 +39,16 @@ #define _CRT_SECURE_NO_WARNINGS /* avoid warnings about ISO C functions */ #endif -#include "../lua-patch/bee_utf8_prefix.h" +#include "bee_utf8_prefix.h" #endif /* } */ -#include "../lua-patch/luai_devent.h" +#include "luai_devent.h" #include #if !defined(NDEBUG) -#include "../lua-patch/bee_assert.h" +#include "bee_assert.h" # if defined(lua_assert) # undef lua_assert @@ -65,7 +65,7 @@ #if defined(_MSC_VER) && defined(BEE_FAST_SETJMP) && !defined(__SANITIZE_ADDRESS__) -#include "../lua-patch/fast_setjmp.h" +#include "fast_setjmp.h" #define LUAI_THROW(L,c) fast_longjmp((c)->b, 1) #define LUAI_TRY(L,c,a) if (fast_setjmp((c)->b) == 0) { a } diff --git a/3rd/lua54/onelua.c b/3rd/lua54/onelua.c index ed0dcd61..67eb9d6f 100644 --- a/3rd/lua54/onelua.c +++ b/3rd/lua54/onelua.c @@ -108,7 +108,7 @@ #include "ltablib.c" #include "lutf8lib.c" //#include "linit.c" -#include "../lua-patch/bee_assert.c" +#include "bee_assert.c" #endif /* lua */ diff --git a/3rd/lua55/lprefix.h b/3rd/lua55/lprefix.h index 25dbf28b..c01351a7 100644 --- a/3rd/lua55/lprefix.h +++ b/3rd/lua55/lprefix.h @@ -39,16 +39,16 @@ #define _CRT_SECURE_NO_WARNINGS /* avoid warnings about ISO C functions */ #endif -#include "../lua-patch/bee_utf8_prefix.h" +#include "bee_utf8_prefix.h" #endif /* } */ -#include "../lua-patch/luai_devent.h" +#include "luai_devent.h" #include #if !defined(NDEBUG) -#include "../lua-patch/bee_assert.h" +#include "bee_assert.h" # if defined(lua_assert) # undef lua_assert @@ -65,7 +65,7 @@ #if defined(_MSC_VER) && defined(BEE_FAST_SETJMP) && !defined(__SANITIZE_ADDRESS__) -#include "../lua-patch/fast_setjmp.h" +#include "fast_setjmp.h" #define LUAI_THROW(L,c) fast_longjmp((c)->b, 1) #define LUAI_TRY(L,c,f,ud) if (fast_setjmp((c)->b) == 0) ((f)(L, ud)) diff --git a/3rd/lua55/onelua.c b/3rd/lua55/onelua.c index eede4796..5f366973 100644 --- a/3rd/lua55/onelua.c +++ b/3rd/lua55/onelua.c @@ -120,7 +120,7 @@ // #include "linit.c" #endif -#include "../lua-patch/bee_assert.c" +#include "bee_assert.c" /* test library -- used only for internal development */ #if defined(LUA_DEBUG) diff --git a/AGENT.md b/AGENT.md index f8a8aa89..fe604938 100644 --- a/AGENT.md +++ b/AGENT.md @@ -94,7 +94,9 @@ Lua 用户代码 ### 自定义 Lua 补丁 -vendored 的 Lua 源码已打补丁(见 `3rd/lua-patch/`),Lua 5.4 和 5.5 均适用的补丁包括: +vendored 的 Lua 源码有两类补丁,均位于 `3rd/lua-patch/`: + +**1. 始终编译的内联补丁**(Lua 5.4 和 5.5 均适用,无开关): - Windows 上支持 ANSI 转义码 - Windows 上使用 UTF-8 字符串编码 - Windows 上使用快速 setjmp @@ -102,6 +104,17 @@ vendored 的 Lua 源码已打补丁(见 `3rd/lua-patch/`),Lua 5.4 和 5.5 - Debug 模式下禁用尾调用 - Debug 构建中启用 `lua_assert` +**2. 构建期 `git apply` 补丁**(通用补丁基础设施): + +构建时始终将官方 Lua 源码**整树**复制到 `$builddir/patched/lua/`,再依次应用启用的补丁(`apply_lua_patch` 目标,无条件构建;无启用补丁时退化为纯复制,不调用 git)。补丁按约定放在 `3rd/lua-patch//lua.patch`(例如 `3rd/lua-patch/optchain/lua55.patch`)。 + +新增一个此类补丁的步骤: + +1. 在 `3rd/lua-patch//` 下为每个目标 Lua 版本放置 `lua.patch`(`git diff` 格式,即 `git apply` 可直接应用的补丁文件); +2. 在 `compile/common.lua` 的 `lua_patches` 清单中注册一行 `{ flag = "", dir = "" }`。`flag` 是可选的命令行开关(默认关闭):`luamake -` 即启用该补丁;若补丁无需开关(始终启用),可省略 `flag` 字段。 + +补丁按清单顺序应用,后一个补丁可覆盖前一个补丁的修改。整树复制使 `#include` 始终解析到补丁目录内的文件(补丁版或原样版),新增补丁无需关心它触及哪些文件,构建代码零改动;开关切换时构建脚本会自动重写产物并清理补丁遗留文件。注意:有补丁启用时构建需要 PATH 上有 `git`。 + ## CI 矩阵 测试平台覆盖:Windows(x86、x86_64、Clang、MinGW)、macOS(多版本,Intel+ARM)、Linux(Ubuntu 22.04/24.04、ARM)、FreeBSD、OpenBSD、NetBSD,以及通过 QEMU 运行的 ARMv7/RISC-V。详见 `.github/workflows/test.yml`。 diff --git a/compile/apply_patch.lua b/compile/apply_patch.lua new file mode 100644 index 00000000..178f49b6 --- /dev/null +++ b/compile/apply_patch.lua @@ -0,0 +1,90 @@ +-- 将源码树整树复制到构建目录,并应用 git 补丁。 +-- 用法: apply_patch.lua [patch...] +-- +-- 在暂存目录里组装最终内容(整树复制 + 按序打补丁),再与 dst 逐文件 +-- 比对:只有内容变化的文件才重写(条件写,配合规则的 restat 使下游按 +-- 内容精确重编,且 mtime 永不倒退);不在暂存内容中的 dst 文件删除。 +-- 不传补丁时退化为纯复制,不调用 git。 +local fs = require "bee.filesystem" + +local src, dst = ... +assert(src and dst, "usage: apply_patch.lua [patch...]") + +-- 暂存目录放在 dst 同级,避免被下面的过期文件清理误删 +local stage = dst .. ".stage" +fs.remove_all(stage) +fs.create_directories(stage) +fs.create_directories(dst) + +local function read_file(path) + local f = io.open(path, "rb") + if not f then + return nil + end + return f:read "a" +end + +local function write_file(path, content) + fs.create_directories(fs.path(path):parent_path()) + local f = assert(io.open(path, "wb")) + f:write(content) +end + +local function copy_tree(from, to) + local frompath = fs.path(from) + for file in fs.pairs_r(frompath) do + if fs.is_regular_file(file) then + local target = fs.path(to) / fs.relative(file, frompath) + fs.create_directories(target:parent_path()) + fs.copy_file(file, target, fs.copy_options.overwrite_existing) + end + end +end + +-- 1. 整树复制到暂存目录 +copy_tree(src, stage) + +-- 2. 按序应用补丁 +for i = 3, select("#", ...) do + local patch = select(i, ...) + local ok = os.execute(('git apply --directory="%s" "%s"'):format(stage, patch)) + assert(ok, "git apply failed: " .. patch) +end + +-- 3. 条件写同步到 dst;keep 集即暂存内容(含补丁新建的文件) +local keep = {} +local stagepath = fs.path(stage) +for file in fs.pairs_r(stagepath) do + local rel = fs.relative(file, stagepath) + if fs.is_regular_file(file) then + keep[rel:string()] = true + local new = assert(read_file(file:string()), "read failed: " .. file:string()) + local target = (fs.path(dst) / rel):string() + if read_file(target) ~= new then + write_file(target, new) + end + end +end + +-- 4. 删除不在暂存内容中的过期文件 +local dstpath = fs.path(dst) +local dirs = {} +for file in fs.pairs_r(dstpath) do + if fs.is_regular_file(file) then + if not keep[fs.relative(file, dstpath):string()] then + fs.remove(file) + end + else + dirs[#dirs+1] = file + end +end + +-- 5. 按深度倒序删除空目录 +table.sort(dirs, function (a, b) return #a:string() > #b:string() end) +for _, dir in ipairs(dirs) do + if fs.pairs(dir)() == nil then + fs.remove(dir) + end +end + +fs.remove_all(stage) diff --git a/compile/bootstrap.lua b/compile/bootstrap.lua index b75fddc7..852f4c23 100644 --- a/compile/bootstrap.lua +++ b/compile/bootstrap.lua @@ -5,6 +5,7 @@ lm:src "source_bootstrap" { ".", lm.luadir, }, + objdeps = "apply_lua_patch", sources = { "bootstrap/main.cpp", "bootstrap/bootstrap_init.cpp", diff --git a/compile/common.lua b/compile/common.lua index 4e04c89f..9b9cc38c 100644 --- a/compile/common.lua +++ b/compile/common.lua @@ -5,7 +5,51 @@ lm:required_version "1.6" lm.compile_commands = "$builddir" lm.lua = lm.lua or "55" -lm.luadir = lm:path("3rd/lua"..lm.lua) +lm.luadir = lm:path("$builddir/patched/lua"..lm.lua) + +-- 通用 Lua 补丁基础设施:构建期把官方源码整树复制到 +-- $builddir/patched/lua/,再按注册表顺序应用启用的补丁(git apply)。 +-- 无启用补丁时退化为纯复制;整树复制使 include 始终解析到补丁目录内的 +-- 文件,新增补丁无需改动构建代码。 +local fs = require "bee.filesystem" + +-- 补丁注册表:补丁文件约定为 3rd/lua-patch//lua.patch; +-- flag 是可选的命令行开关(luamake -),省略则始终启用。 +local lua_patches = { + { flag = "optchain", dir = "optchain" }, +} + +local srcdir = "3rd/lua"..lm.lua +local dstdir = tostring(lm.luadir) + +local args = { srcdir, dstdir } +local inputs = {} +for _, p in ipairs(lua_patches) do + if not p.flag or lm[p.flag] then + local patchfile = ("3rd/lua-patch/%s/lua%s.patch"):format(p.dir, lm.lua) + assert(fs.exists(fs.path(lm.workdir) / patchfile), "patch not found: " .. patchfile) + args[#args+1] = patchfile + inputs[#inputs+1] = patchfile + end +end + +local outputs = {} +local srcpath = fs.path(lm.workdir) / srcdir +for file in fs.pairs_r(srcpath) do + if fs.is_regular_file(file) then + local rel = fs.relative(file, srcpath):string() + inputs[#inputs+1] = srcdir .. "/" .. rel + outputs[#outputs+1] = dstdir .. "/" .. rel + end +end + +lm:runlua "apply_lua_patch" { + script = "compile/apply_patch.lua", + args = args, + inputs = inputs, + outputs = outputs, + restat = true, +} local function macos_version() local cxx = lm.cxx or "c++17" @@ -80,7 +124,11 @@ if lm.sanitize then end lm:source_set "source_lua" { - includes = lm.luadir, + objdeps = "apply_lua_patch", + includes = { + lm.luadir, + "3rd/lua-patch", + }, sources = { lm.luadir / "onelua.c", }, @@ -114,6 +162,7 @@ lm:source_set "source_lua" { } lm:source_set "source_bee" { + objdeps = "apply_lua_patch", includes = lm.luadir, sources = "3rd/lua-seri/lua-seri.cpp", msvc = { @@ -153,6 +202,7 @@ local function need(lst) end lm:source_set "source_bee" { + objdeps = "apply_lua_patch", includes = { ".", lm.luadir, @@ -235,6 +285,7 @@ lm:source_set "source_bee" { } lm:source_set "source_bee" { + objdeps = "apply_lua_patch", includes = { ".", lm.luadir, diff --git a/compile/lua.lua b/compile/lua.lua index 94822f33..e0f9a153 100644 --- a/compile/lua.lua +++ b/compile/lua.lua @@ -3,6 +3,11 @@ local lm = require "luamake" if lm.os == "windows" then lm:shared_library("lua"..lm.lua) { deps = "bee_utf8_crt", + objdeps = "apply_lua_patch", + includes = { + lm.luadir, + "3rd/lua-patch", + }, sources = { lm.luadir / "onelua.c", lm.luadir / "linit.c", @@ -21,9 +26,11 @@ if lm.os == "windows" then "bee_utf8_crt", "lua"..lm.lua, }, + objdeps = "apply_lua_patch", includes = { ".", lm.luadir, + "3rd/lua-patch", }, sources = { "3rd/lua-patch/bee_lua.c", @@ -33,7 +40,12 @@ if lm.os == "windows" then } lm:executable "luac" { deps = "bee_utf8_crt", - includes = ".", + objdeps = "apply_lua_patch", + includes = { + ".", + lm.luadir, + "3rd/lua-patch", + }, sources = { lm.luadir / "onelua.c", "3rd/lua-patch/bee_utf8_main.c", @@ -61,7 +73,11 @@ end lm:executable "lua" { deps = "source_lua", - includes = lm.luadir, + objdeps = "apply_lua_patch", + includes = { + lm.luadir, + "3rd/lua-patch", + }, sources = { lm.luadir / "lua.c", lm.luadir / "linit.c", diff --git a/test/test.lua b/test/test.lua index 24456811..8c822067 100644 --- a/test/test.lua +++ b/test/test.lua @@ -33,6 +33,14 @@ local _ = crash.create_handler "-" require "test_skip" require "test_lua" + +do -- optional chain is enabled at build time via the git patch + local loadable = load "local x; return x?.y" + if loadable then + require "test_optional_chain" + end +end + require "test_serialization" require "test_filesystem" require "test_thread" diff --git a/test/test_optional_chain.lua b/test/test_optional_chain.lua new file mode 100644 index 00000000..ce74c1c7 --- /dev/null +++ b/test/test_optional_chain.lua @@ -0,0 +1,224 @@ +-- Optional chaining (?.) is a custom syntax enabled at build time by applying +-- the git patch in 3rd/lua-patch/optchain/ (luamake -optchain). The '?.' syntax +-- errors shown by LuaLS below are expected: the language server does not know +-- this extension. These tests only run when the interpreter was built with +-- the patch (see the loader in test/test.lua). +local lt = require "ltest" + +local test_optchain = lt.test "optional_chain" + +function test_optchain:test_field() + local obj = { a = { b = 42 } } + lt.assertEquals(obj?.a?.b, 42) + local nothing + lt.assertNil(nothing?.a?.b) + lt.assertNil(obj?.x?.y) + lt.assertEquals((nil)?.a, nil) +end + +function test_optchain:test_field_chain() + local obj = { a = { b = { c = 1 } } } + lt.assertEquals(obj?.a.b.c, 1) + local nothing + lt.assertNil(nothing?.a.b.c) + lt.assertEquals(obj?.a?.b.c, 1) + lt.assertNil(obj?.x?.y?.z) + lt.assertEquals(obj?.a?.b?.c, 1) +end + +function test_optchain:test_index() + local t = { [1] = { [2] = "hi" } } + lt.assertEquals(t?[1]?[2], "hi") + local nothing + lt.assertNil(nothing?[1]) + lt.assertNil(t?[2]?[1]) + lt.assertEquals(t?[1][2], "hi") +end + +function test_optchain:test_method() + local obj = { x = 1, get = function(self) return self.x end } + lt.assertEquals(obj?:get(), 1) + local nothing + lt.assertNil(nothing?:get()) +end + +function test_optchain:test_call() + local f = function() return "ok" end + lt.assertEquals(f?(), "ok") + local nothing + lt.assertNil(nothing?()) +end + +function test_optchain:test_call_args() + local f = function(a, b, c) return a + b + c end + lt.assertEquals(f?(1, 2, 3), 6) + local nothing + lt.assertNil(nothing?(1, 2, 3)) +end + +function test_optchain:test_call_args_short_circuit() + -- short-circuit must not evaluate the arguments (no side effects) + local calls = 0 + local function arg(v) calls = calls + 1; return v end + local f = function(a, b, c) return a + b + c end + lt.assertEquals(f?(arg(1), arg(2), arg(3)), 6) + lt.assertEquals(calls, 3) + local nothing + lt.assertNil(nothing?(arg(1), arg(2), arg(3))) + lt.assertEquals(calls, 3) -- args not evaluated on short-circuit +end + +function test_optchain:test_call_args_multi() + local g = function(a, b) return a, b end + local x, y = g?(10, 20) + lt.assertEquals(x, 10) + lt.assertEquals(y, 20) + local nothing + local n1, n2 = nothing?(10, 20) + lt.assertNil(n1) + lt.assertNil(n2) +end + +function test_optchain:test_short_circuit_key() + local calls = 0 + local function key() calls = calls + 1; return 1 end + local nothing + local t = { [1] = "v" } + lt.assertEquals(nothing?[key()], nil) + lt.assertEquals(calls, 0) + lt.assertEquals(t?[key()], "v") + lt.assertEquals(calls, 1) +end + +function test_optchain:test_short_circuit_args() + local calls = 0 + local function arg() calls = calls + 1; return 1 end + local obj = { f = function(self, x) return x end } + local nothing + lt.assertEquals(nothing?:f(arg()), nil) + lt.assertEquals(calls, 0) + lt.assertEquals(obj?:f(arg()), 1) + lt.assertEquals(calls, 1) +end + +function test_optchain:test_eval_once() + local calls = 0 + local function recv() calls = calls + 1; return { a = { b = 1 } } end + lt.assertEquals(recv()?.a?.b, 1) + lt.assertEquals(calls, 1) + lt.assertNil(recv()?.x?.y) + lt.assertEquals(calls, 2) +end + +function test_optchain:test_false_not_short_circuit() + local f = false + lt.assertError(function () return f?.a end) + lt.assertError(function () return f?[1] end) +end + +function test_optchain:test_not_assignable() + local ok + ok, _ = load("obj?.a = 1") + lt.assertTrue(not ok) + ok, _ = load("obj?[1] = 2") + lt.assertTrue(not ok) + ok, _ = load("obj?:f = 3") + lt.assertTrue(not ok) +end + +function test_optchain:test_bad_syntax() + local ok + ok, _ = load("local a; return a?") + lt.assertTrue(not ok) + ok, _ = load("local a; return a ?? 1") + lt.assertTrue(not ok) + ok, _ = load("local a; return a?b") + lt.assertTrue(not ok) +end + +-- Multiple results: a chain ending in a call can yield several values +-- (the short-circuit path fills the whole result range with nils). + +function test_optchain:test_multi_value_assign() + local obj = { getSize = function() return 100, 200 end } + local w, h = obj?:getSize() + lt.assertEquals(w, 100) + lt.assertEquals(h, 200) + local nothing + local a, b, c = nothing?:getSize() + lt.assertNil(a) + lt.assertNil(b) + lt.assertNil(c) +end + +function test_optchain:test_multi_value_method() + local o = { pair = function(self) return 1, 2, 3 end } + local x, y, z = o?:pair() + lt.assertEquals(x, 1) + lt.assertEquals(y, 2) + lt.assertEquals(z, 3) +end + +function test_optchain:test_multi_value_return() + local obj = { getSize = function() return 7, 8 end } + local function f() + return obj?:getSize() + end + local r1, r2 = f() + lt.assertEquals(r1, 7) + lt.assertEquals(r2, 8) + local function g() + local n + return n?:getSize() + end + local s1, s2 = g() + lt.assertNil(s1) + lt.assertNil(s2) +end + +function test_optchain:test_multi_value_table() + local obj = { getSize = function() return 5, 6 end } + local t = { obj?:getSize() } + lt.assertEquals(t[1], 5) + lt.assertEquals(t[2], 6) + local nothing + local tn = { nothing?:getSize() } + lt.assertEquals(#tn, 0) -- one nil element; trailing nils don't count for # +end + +function test_optchain:test_multi_value_single() + -- Single-value contexts still collapse to one value. + local obj = { getSize = function() return 100, 200 end } + local s = obj?:getSize() + lt.assertEquals(s, 100) +end + +function test_optchain:test_multi_value_args() + -- A chain ending in a call, used as call arguments, yields exactly + -- the produced values (short-circuit: exactly one nil argument). + local function count(...) return select("#", ...) end + local obj = { getSize = function() return 100, 200 end } + lt.assertEquals(count(obj?:getSize()), 2) + local nothing + lt.assertEquals(count(nothing?:getSize()), 1) +end + +function test_optchain:test_extra_values_discarded() + -- More expressions than variables: the last multi-return optional-chain + -- call is discarded. This path passes 'nresults == 0' to + -- luaK_setreturns_optchain, which must leave OP_SETTOP's B field at 0 + -- (a single nil). Regression: it used to store nresults-1 == -1 (255), + -- overflowing the stack on short-circuit. + local f + local a = 1, f?() -- f is nil → short-circuit, result discarded + lt.assertEquals(a, 1) + + local g = function() return 10, 20 end + local b = 1, g?() -- non-short-circuit path (control) + lt.assertEquals(b, 1) + + local h + local c = 1 + c = 1, h?() -- assignment-statement form (same adjust_assign) + lt.assertEquals(c, 1) +end