X Tutup
Skip to content

feat: switch statements for lua 5.1+ (universal)#1098

Merged
Perryvw merged 24 commits intoTypeScriptToLua:masterfrom
ts-defold:feat/switch-5.1
Sep 4, 2021
Merged

feat: switch statements for lua 5.1+ (universal)#1098
Perryvw merged 24 commits intoTypeScriptToLua:masterfrom
ts-defold:feat/switch-5.1

Conversation

@thejustinwalsh
Copy link
Contributor

@thejustinwalsh thejustinwalsh commented Aug 21, 2021

Resolves #194 (or rather brings support for Lua 5.1 and universal).

Implemented using repeat...until true, if, and some conditional or chaining. Relies on switch fallthrough amalgamation / unrolling for default case fallthrough.

switch (0 as number) {
    case 0:
    case 1:
    case 2:
        out.push("0,1,2");
    default:
        out.push("default");
    case 3:
        out.push("3");
}
repeat
    local ____switch3 = 0
    local ____cond3 = ((____switch3 == 0) or (____switch3 == 1)) or (____switch3 == 2)
    if ____cond3 then
        __TS__ArrayPush(out, \\"0,1,2\\")
    end
    if ____cond3 then
        __TS__ArrayPush(out, \\"default\\")
    end
    ____cond3 = ____cond3 or (____switch3 == 3)
    if ____cond3 then
        __TS__ArrayPush(out, \\"3\\")
    end
    if not ____cond3 then
        __TS__ArrayPush(out, \\"default\\")
        __TS__ArrayPush(out, \\"3\\")
    end
until true

Update:

The final approach was to or together 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 or clause, 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. switchWithBracketsBreakInInternalLoop was 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!

create scope for switch and remove additional scope for each clause.
Copy link
Member

@Perryvw Perryvw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@thejustinwalsh
Copy link
Contributor Author

thejustinwalsh commented Aug 23, 2021

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 if and or. for example...

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();
end

With 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.

@thejustinwalsh
Copy link
Contributor Author

thejustinwalsh commented Aug 23, 2021

Test added in 9172e88 with the contents of #1099 (comment) to ensure proper scoping. implementation on master fails this test.

@thejustinwalsh
Copy link
Contributor Author

@tomblind repeat...until in 504fd1a

@thejustinwalsh thejustinwalsh changed the title feat: switch statements for lua 5.1 feat: switch statements for lua 5.1+ (universal) Aug 23, 2021
@thejustinwalsh
Copy link
Contributor Author

Ready for review.
Any other tests we should run through for good measure?

Copy link
Collaborator

@tomblind tomblind left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the refactor - this is much easier to follow now. There's still a few tricky situations to deal with, though.

@tomblind
Copy link
Collaborator

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("switch uses elseif", () => {
test("switch executes only one", () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

executes only one what?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you think it does not add any value, I'm fine with removing it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@tomblind
Copy link
Collaborator

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

@thejustinwalsh
Copy link
Contributor Author

@tomblind I believe this is the solution to the extra clause emitting for default. Let me know.

Copy link
Collaborator

@tomblind tomblind left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")
end

Would 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)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this the exact same condition as line 75? Would this ever actually be true here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I don't need that. Must of refactored it a few times and didn't catch that.

@thejustinwalsh
Copy link
Contributor Author

thejustinwalsh commented Aug 28, 2021

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")
end

Would 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?

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.

@tomblind
Copy link
Collaborator

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

@tomblind
Copy link
Collaborator

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

@thejustinwalsh
Copy link
Contributor Author

@tomblind can't believe that slipped through the tests. I swear that WAS working in one of these revisions.
Also, no way, too much time was put into making perfect Lua output. We must push forth.

@thejustinwalsh
Copy link
Contributor Author

@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 tomblind mentioned this pull request Sep 3, 2021
Copy link
Collaborator

@tomblind tomblind left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, I've run out of ways to break it 😄

@Perryvw Perryvw merged commit ec76f4b into TypeScriptToLua:master Sep 4, 2021
sanikoyes pushed a commit to sanikoyes/TypeScriptToLua that referenced this pull request Sep 24, 2021
* 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Switch statements not allowed in Lua 5.1

3 participants

X Tutup