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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions internal/luatest/try_break_continue_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package luatest

import (
"testing"

"github.com/realcoldfry/tslua/internal/transpiler"
)

// TestTry_BreakContinueAcrossPcall covers break/continue statements inside a
// try/catch/finally body. The transpiler wraps the try body in a pcall(function()
// ... end), so a direct break or goto would cross the Lua function boundary.
// The fix is a sentinel-flag assignment plus return, with dispatch after the
// pcall returns and finally has run.
//
// Regression:
// - https://github.com/RealColdFry/tslua/issues/87
// - https://github.com/RealColdFry/tslua/issues/92
func TestTry_BreakContinueAcrossPcall(t *testing.T) {
t.Parallel()

cases := []struct {
name string
body string
want string
needGoto bool
}{
{
"unlabeled continue inside try/finally",
`const log: number[] = [];
for (let i = 0; i < 10; i++) {
try {
if (i === 2) { i = 7; continue; }
} finally {}
log.push(i);
}
return log;`,
`{0, 1, 8, 9}`,
false,
},
{
"unlabeled break inside try/catch",
`const log: number[] = [];
for (let i = 0; i < 10; i++) {
try {
if (i === 3) break;
} catch {}
log.push(i);
}
return log;`,
`{0, 1, 2}`,
false,
},
{
"continue inside try/finally runs finally",
`const log: number[] = [];
for (let i = 0; i < 3; i++) {
try {
if (i === 1) continue;
log.push(i);
} finally {
log.push(100 + i);
}
}
return log;`,
`{0, 100, 101, 2, 102}`,
false,
},
{
"labeled break inside try targets outer loop",
`const log: number[] = [];
outer: for (let i = 0; i < 5; i++) {
for (let j = 0; j < 5; j++) {
try {
if (j === 1 && i === 2) break outer;
} finally {}
log.push(i * 10 + j);
}
}
return log;`,
`{0, 1, 2, 3, 4, 10, 11, 12, 13, 14, 20}`,
true,
},
{
"labeled continue inside try targets outer loop",
`const log: number[] = [];
outer: for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
try {
if (j === 1) continue outer;
} finally {}
log.push(i * 10 + j);
}
}
return log;`,
`{0, 10, 20}`,
true,
},
{
"break inside try inside inner loop only exits inner",
`const log: number[] = [];
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 5; j++) {
try {
if (j === 2) break;
} finally {}
log.push(i * 10 + j);
}
}
return log;`,
`{0, 1, 10, 11, 20, 21}`,
false,
},
}

targets := []struct {
name string
target transpiler.LuaTarget
}{
{"LuaJIT", transpiler.LuaTargetLuaJIT},
{"5.1", transpiler.LuaTargetLua51},
{"5.2", transpiler.LuaTargetLua52},
{"5.3", transpiler.LuaTargetLua53},
{"5.4", transpiler.LuaTargetLua54},
{"5.5", transpiler.LuaTargetLua55},
}

for _, c := range cases {
for _, tgt := range targets {
if c.needGoto && !tgt.target.SupportsGoto() {
continue
}
t.Run(c.name+" ["+tgt.name+"]", func(t *testing.T) {
t.Parallel()
ExpectFunction(t, c.body, c.want, Opts{LuaTarget: tgt.target})
})
}
}
}

// TestAsyncTry_LabeledBreakContinue covers labeled break/continue inside an
// async try body. Previously the labeled branch short-circuited before the
// try-scope check, emitting a goto that crossed the __TS__AsyncAwaiter function
// boundary. See https://github.com/RealColdFry/tslua/issues/92.
func TestAsyncTry_LabeledBreakContinue(t *testing.T) {
t.Parallel()

cases := []struct {
name string
body string
want string
}{
{
"labeled break outer inside async try",
`const log: number[] = [];
async function run() {
outer: for (let i = 0; i < 3; i++) {
try {
await Promise.resolve();
if (i === 1) break outer;
log.push(i);
} catch {}
}
}
run();
return log;`,
`{0}`,
},
{
"labeled continue outer inside async try",
`const log: number[] = [];
async function run() {
outer: for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
try {
await Promise.resolve();
if (j === 1) continue outer;
log.push(i * 10 + j);
} catch {}
}
}
}
run();
return log;`,
`{0, 10, 20}`,
},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
ExpectFunction(t, c.body, c.want, Opts{LuaTarget: transpiler.LuaTargetLuaJIT})
})
}
}
9 changes: 9 additions & 0 deletions internal/transpiler/loops.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ func (t *Transpiler) transformLabeledStatement(node *ast.Node) []lua.Statement {
if t.continueLabelMap == nil {
t.continueLabelMap = make(map[string]string)
}
if t.labelScopeDepths == nil {
t.labelScopeDepths = make(map[string]int)
}

if hasBreak {
t.breakLabels[tsLabel] = breakLabel
Expand All @@ -137,13 +140,19 @@ func (t *Transpiler) transformLabeledStatement(node *ast.Node) []lua.Statement {
t.continueLabelMap[tsLabel] = continueLabel
t.activeLabeledContinue = continueLabel
}
// Record the scope-stack length at registration. The inner iteration/block
// pushes its own scope at this index, so a break/continue inside that scope
// targeting this label whose scope-stack index is below this length has
// crossed any Try/Catch scope in between.
t.labelScopeDepths[tsLabel] = len(t.scopeStack)

// Transform the inner statement (it will use the registered labels)
stmts := t.transformStatementWithComments(ls.Statement)

// Clean up labels
delete(t.breakLabels, tsLabel)
delete(t.continueLabelMap, tsLabel)
delete(t.labelScopeDepths, tsLabel)
t.activeLabeledContinue = ""

if hasBreak {
Expand Down
107 changes: 47 additions & 60 deletions internal/transpiler/statements.go
Original file line number Diff line number Diff line change
Expand Up @@ -733,8 +733,8 @@ func (t *Transpiler) transformReturnStatement(node *ast.Node) []lua.Statement {
if t.asyncDepth > 0 {
// If we're inside an async try/catch, defer the return to post-check
// logic after the awaiter chain resolves. Ported from TSTL PR #1706.
if tryScope := t.findAsyncTryScope(); tryScope != nil {
tryScope.AsyncTryHasReturn = true
if tryScope := t.findTryScopeInStack(); tryScope != nil {
tryScope.TryHasReturn = true
result := []lua.Statement{
lua.Assign([]lua.Expression{lua.Ident("____hasReturned")}, []lua.Expression{lua.Bool(true)}),
}
Expand Down Expand Up @@ -1332,9 +1332,11 @@ func (t *Transpiler) transformTryStatement(node *ast.Node) []lua.Statement {
return t.transformBlock(ts.TryBlock)
}

// Track that we're inside a try block (return → return true, value)
// Track that we're inside a try block (return → return true, value) and
// push a ScopeTry so nested break/continue can detect they'd cross the
// pcall function boundary.
t.tryDepth++
tryBlockStmts := t.transformBlock(ts.TryBlock)
tryBlockStmts, tryScope := t.transformBlockInNewScope(ts.TryBlock, ScopeTry)
tryHasReturn := containsReturnInBlock(ts.TryBlock)
t.tryDepth--

Expand Down Expand Up @@ -1366,9 +1368,16 @@ func (t *Transpiler) transformTryStatement(node *ast.Node) []lua.Statement {
}

t.tryDepth++
catchBlockStmts := t.transformBlock(cc.Block)
var catchBlockStmts []lua.Statement
var catchScope *Scope
catchBlockStmts, catchScope = t.transformBlockInNewScope(cc.Block, ScopeCatch)
catchHasReturn := containsReturnInBlock(cc.Block)
t.tryDepth--
// Merge catch's deferred transfers into the try scope so the single
// post-check after finally handles both try and catch body transfers.
for _, xfer := range catchScope.TryDeferredTransfers {
tryScope.addDeferredTransfer(xfer)
}

var catchParams []*lua.Identifier
if hasCatchVar {
Expand Down Expand Up @@ -1479,6 +1488,25 @@ func (t *Transpiler) transformTryStatement(node *ast.Node) []lua.Statement {
))
}

// Break/continue propagation: if the try/catch body had to defer a jump
// because it would have crossed the pcall function boundary, declare the
// sentinel flags before the pcall and dispatch them after finally.
if len(tryScope.TryDeferredTransfers) > 0 {
flagDecls := make([]*lua.Identifier, 0, len(tryScope.TryDeferredTransfers))
for _, xfer := range tryScope.TryDeferredTransfers {
flagDecls = append(flagDecls, lua.Ident(xfer.FlagName))
}
// Prepend the flag declaration so the pcall/catch closures can assign it.
result = append([]lua.Statement{lua.LocalDecl(flagDecls, nil)}, result...)
for _, xfer := range tryScope.TryDeferredTransfers {
result = append(result, lua.If(
lua.Ident(xfer.FlagName),
&lua.Block{Statements: xfer.Dispatch},
nil,
))
}
}

return []lua.Statement{lua.Do(result...)}
}

Expand Down Expand Up @@ -1570,28 +1598,26 @@ func (t *Transpiler) transformAsyncTry(ts *ast.TryStatement) []lua.Statement {
}

// Collect deferred-control-flow flags from try and catch scopes.
hasReturn := tryScope.AsyncTryHasReturn
hasBreak := tryScope.AsyncTryHasBreak
hasContinue := tryScope.AsyncTryHasContinue
hasReturn := tryScope.TryHasReturn
if catchScope != nil {
hasReturn = hasReturn || catchScope.AsyncTryHasReturn
hasBreak = hasBreak || catchScope.AsyncTryHasBreak
hasContinue = hasContinue || catchScope.AsyncTryHasContinue
hasReturn = hasReturn || catchScope.TryHasReturn
// Merge catch's deferred transfers into the try scope.
for _, xfer := range catchScope.TryDeferredTransfers {
tryScope.addDeferredTransfer(xfer)
}
}
transfers := tryScope.TryDeferredTransfers

var result []lua.Statement

// Declare flag locals before the awaiter so the inner closures can assign them.
if hasReturn || hasBreak || hasContinue {
if hasReturn || len(transfers) > 0 {
var flagDecls []*lua.Identifier
if hasReturn {
flagDecls = append(flagDecls, lua.Ident("____hasReturned"), lua.Ident("____returnValue"))
}
if hasBreak {
flagDecls = append(flagDecls, lua.Ident("____hasBroken"))
}
if hasContinue {
flagDecls = append(flagDecls, lua.Ident("____hasContinued"))
for _, xfer := range transfers {
flagDecls = append(flagDecls, lua.Ident(xfer.FlagName))
}
result = append(result, lua.LocalDecl(flagDecls, nil))
}
Expand Down Expand Up @@ -1628,56 +1654,17 @@ func (t *Transpiler) transformAsyncTry(ts *ast.TryStatement) []lua.Statement {
}},
})
}
if hasBreak {
// if ____hasBroken then break end
// Dispatch each deferred break/continue transfer captured during body transforms.
for _, xfer := range transfers {
result = append(result, &lua.IfStatement{
Condition: lua.Ident("____hasBroken"),
IfBlock: &lua.Block{Statements: []lua.Statement{lua.Break()}},
})
}
if hasContinue {
// if ____hasContinued then <continue stmts> end
continueStmts := t.buildContinueStatements()
result = append(result, &lua.IfStatement{
Condition: lua.Ident("____hasContinued"),
IfBlock: &lua.Block{Statements: continueStmts},
Condition: lua.Ident(xfer.FlagName),
IfBlock: &lua.Block{Statements: xfer.Dispatch},
})
}

return result
}

// buildContinueStatements emits the target-appropriate continue sequence for
// the innermost enclosing loop (native continue, goto label, or repeat-break
// fallback). Used by the async try post-check when a continue inside the try
// needs to propagate to the outer loop.
func (t *Transpiler) buildContinueStatements() []lua.Statement {
if t.luaTarget.HasNativeContinue() {
var stmts []lua.Statement
if n := len(t.forLoopPreContinue); n > 0 {
stmts = append(stmts, t.forLoopPreContinue[n-1]...)
}
if n := len(t.forLoopIncrementors); n > 0 {
if inc := t.forLoopIncrementors[n-1]; inc != nil {
stmts = append(stmts, inc...)
}
}
return append(stmts, lua.Continue())
}
label := "__continue"
if len(t.continueLabels) > 0 {
label = t.continueLabels[len(t.continueLabels)-1]
}
if t.luaTarget.SupportsGoto() {
return []lua.Statement{lua.Goto(label)}
}
// Lua 5.0/5.1: set continue flag and break out of repeat loop
return []lua.Statement{
lua.Assign([]lua.Expression{lua.Ident(label)}, []lua.Expression{lua.Bool(true)}),
lua.Break(),
}
}

// throw expr → error(expr, 0)
func (t *Transpiler) transformThrowStatement(node *ast.Node) []lua.Statement {
ts := node.AsThrowStatement()
Expand Down
Loading