feat: switch statements for lua 5.1+ (universal)#1098
feat: switch statements for lua 5.1+ (universal)#1098Perryvw merged 24 commits intoTypeScriptToLua:masterfrom
Conversation
create scope for switch and remove additional scope for each clause.
There was a problem hiding this comment.
Seems like a smart solution! Could we maybe optimize this a little bit by aggregating empty fallthrough cases into a single if clause? Would be a nice snapshot test too. I'm thinking of something like:
switch (a) {
case b:
case c:
case d:
foo();
break;
default:
bar();
break;
}translating into something like:
if (a == b or a == c or a == d) then
foo()
else
bar();
end
Great minds think alike! ;) I didn't think of the empty case grouping, but I was onto this idea that if I actually didn't use an else statement at all, I could handle all cases with switch (a) {
case b:
case c:
foo();
case d:
bar();
break;
default:
baz();
break;
}if (a == b or a == c) then
foo()
end
if (a == b or a == c or a == d) then
bar()
end
if (not (a == b or a == c or a == d))
baz();
endWith this approach, you still collect the fallthroughs, but then instead of duplicating the code blocks, you let the if's cascade, similar to how it was done with goto statements. I am fairly certain that the hoisting would allow for this to result in the same execution as the code duplication technique. The default case would then always have to be the not of all the cases present in the switch. This allows for default to appear anywhere in the execution order, by prepending with or's for the fallthrough of default as well... This could be further optimized by storing the right-hand side of the compare in a table as the key where the absence of the key means it is the default case. Special care would need to be taken around nil, as that can't be a key in the table, but could be a case clause. |
|
Test added in 9172e88 with the contents of #1099 (comment) to ensure proper scoping. implementation on master fails this test. |
|
Ready for review. |
tomblind
left a comment
There was a problem hiding this comment.
Thanks for the refactor - this is much easier to follow now. There's still a few tricky situations to deal with, though.
|
This is looking good overall. My only remaining concern is that default clauses fallen into at the end produces an extra block that will never get hit. This doesn't technically break anything, it's just weird. switch (x) {
case 1:
console.log(1);
default:
console.log("default");
}local ____switch2 = x
local ____cond2 = ____switch2 == 1
if ____cond2 then
print(1)
end
if ____cond2 then
print("default")
end
if not ____cond2 then
print("default")
end |
test/unit/switch.spec.ts
Outdated
| }); | ||
|
|
||
| test("switch uses elseif", () => { | ||
| test("switch executes only one", () => { |
There was a problem hiding this comment.
Good question, not sure what this test was trying to prove in the first place. We don't use else if now.
Should I delete it, or rename it to executes only one clause?
There was a problem hiding this comment.
If you think it does not add any value, I'm fine with removing it.
There was a problem hiding this comment.
Decided on executes only one clause to make sure we still have an explicit test for that case, rather than implicit coverage in another test.
|
Sorry, but I came up with another way to break this 😅 Trailing empty cases at the end of the switch are not currently evaluated: let y = 0;
function foo() {
return y++;
}
let x = 1;
switch (x) {
case foo():
console.log("foo");
case foo():
}
console.log(y); // should be 2, but is currently 1 |
|
@tomblind I believe this is the solution to the extra clause emitting for default. Let me know. |
There was a problem hiding this comment.
There's still a redundant default clause, although in a different way now:
local ____switch2 = x
local ____cond2 = ____switch2 == 1
if ____cond2 then
print(1)
end
if ____cond2 then
print("default")
break
end
do
print("default")
endWould just skipping the condition default clause if it's the last clause in the switch solve this? Or are there other cases that would break?
| isInitialCondition = false; | ||
| } else { | ||
| // If the default is not fallen into skip it and handle in the final default output instead | ||
| if (previousClause && containsBreakOrReturn(previousClause.statements)) { |
There was a problem hiding this comment.
Isn't this the exact same condition as line 75? Would this ever actually be true here?
There was a problem hiding this comment.
Yeah, I don't need that. Must of refactored it a few times and didn't catch that.
You know more tests might know the answer... ;) Let us find out. Initially, I thought I would need to keep the default there as it falls through into the default, but now that we are adding break to the final clause, then we can naturally fall through to the final default clause, like suggested. No problem with side effects either as default will never evaluate a new condition, and so long as we hoast the evaluation to the initial statement, I think we are golden. Tests are passing too. |
|
Sorry, found another way to break it: let y = 0;
function foo() {
return y++;
}
let x = 0;
switch (x) {
case 1:
console.log(1);
case foo():
default:
console.log("default");
}
console.log(y); // should print 1, but doesn't because foo() is never evaluated |
|
maybe instead of trying to coalesce the conditions for empty statements, we can just eval them separately to keep the logic simpler: switch (x) {
case 1:
case 2:
case 3:
// clause for case 3
}____cond = ____cond or ____switch == 1
____cond = ____cond or ____switch == 2
____cond = ____cond or ____switch == 3
if ____cond then
--clause for case 3
end |
|
@tomblind can't believe that slipped through the tests. I swear that WAS working in one of these revisions. |
|
@tomblind I mislead myself with my own comment, and after reading through the code again immediately realized the missing condition. I don't think I have it in me to hoist the second check to the initial condition in this special case though, as it would require another refactor or complicate it further. LMK. |
tomblind
left a comment
There was a problem hiding this comment.
ok, I've run out of ways to break it 😄
* feat: switch statements for lua 5.1 * refactor: cleanup & switch with only default * fix: lexical scoping create scope for switch and remove additional scope for each clause. * fix: test do not pollute parent scope * fix: scope switch in repeat-until, emit break * fix: non duplicating case body switch * chore: comments * refactor: handle side-effects plus test * fix: cleanup & feedback * chore: remove dead code * refactor: cleanup redundent break statement * refactor: cleanup for maintainability and review * chore: remove dead comment * fix: handle no initial statment fallthrough * test: async case * fix: containsBreakOrReturn & final default fallthrough * refactor: coalesceCondition function * fix: do not copy statements to check for break * refactor: cleanup clauses access * chore(nit): comment * fix: ensure final clause is evaluated * refactor: simplify and cleanup output * fix: remove redundant final default clause * fix: ensure we evaluate empty fallthrough clause
Resolves #194 (or rather brings support for Lua 5.1 and universal).
Implemented using
repeat...until true,if, and some conditionalorchaining. Relies on switch fallthrough amalgamation / unrolling for default case fallthrough.Update:
The final approach was to
ortogether the conditions into a variable and check that condition in the if statement to ensure that we do not add any additional comparisons that would trigger undesired side effects.In order to handle the default case, we must still amalgamate the fallthrough cases that a default would fall into, and provide that as the final
orclause, by checking that no previous condition had passed.I am making my best attempt to ensure that any redundant or useless cases are removed, and empty cases coalesced.
Unforeseen bonus, the generated code is a bit easier to reason about!
Orignal:
The approach I took is to gather the fall-through bodies based on the defined order in the switch statement and amalgamate them until I hit a break statement that would break the control flow of the switch case. I then move the resulting default block to a new else statement. It is crucial to handle the code generation for the default case before moving it into the else block, as the rules of fall through would dictate that the case directly after the default would execute.
Took lots of testing until it clicked in my head that this method is indeed conformant with how a switch statement executes.
switchWithBracketsBreakInInternalLoopwas the real kicker, and made me realize handling break statements in their proper scope was missing. By skipping the child tree of any loop or switch the code will not pick up unnecessary breaks of switch control flow.Performance would be the only open question, although I would not expect this method to be any slower than jumping with goto, as it is really just an unrolled switch statement.
Also send me your code clarity, clean-up, cognitive load lightening, and readability suggestions, please!