Skip to content

Preceding Statements#1116

Merged
Perryvw merged 52 commits intoTypeScriptToLua:masterfrom
tomblind:preceding-statements
Nov 20, 2021
Merged

Preceding Statements#1116
Perryvw merged 52 commits intoTypeScriptToLua:masterfrom
tomblind:preceding-statements

Conversation

@tomblind
Copy link
Copy Markdown
Collaborator

@tomblind tomblind commented Sep 3, 2021

Closes #654

This PR fully replaces our IIFEs with preceding statements. To do this, it uses a stack based system akin to what is detailed out here: roblox-ts/roblox-ts#537. Statements can be added before the current expression being transformed using context.addPrecedingStatements(). If nodes higher up want to catch and manipulate these statements, they can do so by pushing and popping a statement collection with context.pushPrecedingStatements() and context.popPrecedingStatements().

Significant refactoring was required in a few places to pull this off. Call expression handling has been modified a lot, but should be understandable. Dealing with assignments was particularly tricky, though, as there's a complex web of cases (especially destructuring assignments). At some point I imagine a full rewrite of assignment handling might be needed to keep it understandable and maintainable, but I'm not even sure what that would look like right now 😅

Evaluation Order

The biggest challenge with this system is maintaining evaluation order in statements with multiple expressions. Consider this example:

function foo(a: number, b: number) {
    let i = 0;
    let o = {
        method(...args: unknown[]) { /* ... */ }
    };

    function bar(): number {
        // Could modify a, b, i, o, or o.method!
    }

    o.method(a, b, i += bar());
}

Once i += bar() is lifted into a preceding statement, any expressions before it in the statement could be affected, and thus need to be cached in temps ahead of time to maintain evaluation order.

Unfortunately, without deeper inspection of the preceding statements being generated, we have to aggressively do this caching, even in cases which don't seem to need it upon visual inspection. For example:

declare function foo(x: number): void;
let i = 0;
foo(i++);

Transpiles to:

local i = 0
local ____foo_2 = foo
local ____G_1 = _G
local ____i_0 = i
i = ____i_0 + 1
____foo_2(____G_1, ____i_0)

Both foo and even _G have to be cached in temps because we can't guarantee that the preceding statements generated by ++i won't modify them. It's possible we could add some specific optimizations for cases like this to reduce the temps, but it would be tricky and a bit messy. Also, thanks to operator overloading, it is possible ++i could have actually modified those values.

const variables won't be cached like this however. Those looking to reduce temps should prefer const over let whenever possible.

Sparse Array Lib

To help with situations which could generate extreme numbers of temps, this PR also adds some new lib functions for manipulating sparse arrays. Consider this:

foo(a, b, c, d, e++, f, g);

Instead of caching all of those variables in temps, we can push them into an array while preserving evaluation order and unpack them into the call's arguments. But, since any of these could have nil values, we use a custom array that tracks its true size.

local ____foo_2 = foo
local ____array_1 = __TS__SparseArrayNew(_G, a, b, c, d)
local ____e_0 = e
e = ____e_0 + 1
__TS__SparseArrayPush(____array_1, ____e_0, f, g)
____foo_2(
    __TS__SparseArraySpread(____array_1)
)

This technique can also be used to handle spread expressions in the middle of arguments, so it also fixes #1142 as a bonus.

Right now, argument lists and array expressions which would generate 3 or more temps when transformed will revert to this behavior. That number can be adjusted, if needed.

Other Notes

  • Some helpers for generating useful temp names has been added to make the output lua a bit more readable
  • Some things like optional chaining could be reworked to take advantage of this, but I'll save those for separate PRs
  • This also fixes Incorrect destructuring assignment order #1127

@GlassBricks
Copy link
Copy Markdown
Contributor

GlassBricks commented Sep 5, 2021

When calling a function with preceding statement arguments, the function itself and the self parameter also need to be stored to temp variables.

I've made a PR to the source branch with (currently failing) tests for this:
tomblind#1

Copy link
Copy Markdown
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.

image
I have skipped the more complicated uses cases (such as call/assignment etc) for now. I hope discussing/reworking the comments I have placed now will give me enough understanding to tackle the remaining files.

@lolleko
Copy link
Copy Markdown
Member

lolleko commented Nov 6, 2021

Wow, great work! First of all, sorry that I probably won't have the time to review this.
I just have one quick question regarding the "caching" of tables like self and _G (as described in the PR description).
Since assigning a table only copies the reference, not the table, does that even work/make sense?

@tomblind
Copy link
Copy Markdown
Collaborator Author

tomblind commented Nov 7, 2021

@lolleko
Thanks! Regarding tables, what is assigned to the variable name is what might be changed by preceding statements, so we need to cache what was originally assigned to prevent incorrect behavior.

Consider this:

function changeG(this: void) { _G = {}; return 1; };
foo(i += changeG());
function changeG()
    _G = {}
    return 1
end
local ____foo_1 = foo
local ____G_0 = _G
i = i + changeG()
____foo_1(____G_0, i)

changeG() should be executed after _G was already evaluated as the first parameter here, but it's bumped back because of preceding statements, so foo would now be called with the new value of _G instead of the old one. So we have to cache the old one and pass that instead.

Copy link
Copy Markdown
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.

image

for (let i = 0; i < expressions.length; ++i) {
context.pushPrecedingStatements();
transformExpressions.push(context.transformExpression(expressions[i]));
const expressionPrecedingStatements = context.popPrecedingStatements();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Guess you could still use the transformInPrecedingStatementScope helper here

@GlassBricks
Copy link
Copy Markdown
Contributor

GlassBricks commented Nov 9, 2021

Nullish coalescing is not implemented with preceding statements.

This led me to see a larger problem: transformBinaryOperation (in visitors/binary-expression/index.ts) takes left and right lua.expressions rather than ts expressions. This means there is no way to obtain the preceding statements, and every usage of transformBinaryExpression that uses a short-circuit binary operation is potentially wrong.

E.g. see this failing test:

test("nullish-coalescing assignment expression with side effect rhs", () => {
    util.testFunction`
        let i = 0;
        let t = { foo: 0 }
        const bar = (t.foo ??= i++)
        return [i, t.foo, bar]
    `
        .expectToMatchJsResult();
});

Assignment expressions with a table index lhs uses transformBinaryOperation.

@GlassBricks
Copy link
Copy Markdown
Contributor

GlassBricks commented Nov 9, 2021

If I can comment further, in hindsight I think an issue with this preceding statements implementation is that it makes the code much more stateful; every transform now has the possibility of adding preceding statements, and so can easily lead bugs like above.

An idea for a less stateful implementation would be to introduce some interface ExpressionAndPrecedingStatements (with a shorter name) which contains a lua.Expression and preceding statements needed when evaluated. (Most) parts of the code base that currently return lua.Expression would return that interface instead. This way preceding statements would need to be handled explicitly, and can be treated together with an expression, e.g. the logic oftransformBinaryOperation doesn't need to be modified too much to work with preceding statements using this.

Utilities such as transformExpressionAndPutStatements(ts.Expression, precedingStatements: lua.Statement[] could help with simple cases.

This would touch more of the existing code, but might be worth it. Preceding statements already has an impact on almost all parts of transformation.

I'm open to discuss this/alternatives

@lolleko
Copy link
Copy Markdown
Member

lolleko commented Nov 9, 2021

@tomblind

changeG() should be executed after _G was already evaluated as the first parameter here, but it's bumped back because of preceding statements, so foo would now be called with the new value of _G instead of the old one. So we have to cache the old one and pass that instead.

Thanks for clarifying, I understand now.

@tomblind
Copy link
Copy Markdown
Collaborator Author

@GlassBricks

every usage of transformBinaryExpression that uses a short-circuit binary operation is potentially wrong.

Good catch on this one. It's going to take some refactoring to get right. 😞

An idea for a less stateful implementation would be to introduce some interface
ExpressionAndPrecedingStatements (with a shorter name) which contains a lua.Expression and preceding statements needed when evaluated.

This was my original plan for this, but it's a fundamental change that would require refactoring pretty much all transformations, so I avoided it in the hopes of keeping the scope of this PR down.

@GlassBricks
Copy link
Copy Markdown
Contributor

This was my original plan for this, but it's a fundamental change that would require refactoring pretty much all transformations, so I avoided it in the hopes of keeping the scope of this PR down.

The scope of this PR is already pretty big... Even without refactoring everything, using this interface still may be beneficial.

Double-checking every function that takes a lua.Expression but not preceding statements, transformNullishCoalescingOperator in binaryExpression/index.ts is also potentially wrong (and still uses an IIFE).

@Perryvw
Copy link
Copy Markdown
Member

Perryvw commented Nov 14, 2021

This was my original plan for this, but it's a fundamental change that would require refactoring pretty much all transformations, so I avoided it in the hopes of keeping the scope of this PR down.

The scope of this PR is already pretty big... Even without refactoring everything, using this interface still may be beneficial.

Double-checking every function that takes a lua.Expression but not preceding statements, transformNullishCoalescingOperator in binaryExpression/index.ts is also potentially wrong (and still uses an IIFE).

Right now the changes to expressions are very limited, all logic for combining and forwarding preceding statements is now spread over the statement transformers, which is already more than I would ideally like. Doing this change would have a very large impact because all expression transformers would need to be touched, and all of them would require to have some logic about how to forward preceding statements from its child expressions.

If we can make this PR change without doing this refactor, I would be in favor of that. I am interested in this alternative, but would prefer to judge it as an independent change.

@tomblind
Copy link
Copy Markdown
Collaborator Author

The issue with transformBinaryOperator is fixed and tests have been added for it.

transformNullishCoalescingOperator in binaryExpression/index.ts is also potentially wrong (and still uses an IIFE).

Thanks for reminding me on this. I had meant to put in a bug for it but forgot. Since it's not directly related to preceding statements, I think it would be best to fix this in a follow up PR.

switchVariable: ts.Identifier,
expression: ts.Expression
): ts.Expression => {
const comparison = ts.factory.createBinaryExpression(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This looks nice and simple, but don't all these ts.factory.create* methods interfere with source maps and potentially type checking that needs tree traversal?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good point. Wasn't fully considering that. I've reverted to using lua expressions here and manually handled preceding statements.

…ured ts expressions and handled preceding statements manually
@GlassBricks
Copy link
Copy Markdown
Contributor

GlassBricks commented Nov 16, 2021

To comment on the above, I believe you can use ts.setOriginalNode on a generated TS node to retain type information and source map position. This might be useful to reuse functionality of other complex transforms.

Copy link
Copy Markdown
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.

image

@Perryvw Perryvw merged commit d8e6546 into TypeScriptToLua:master Nov 20, 2021
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.

using __TS_ArrayConcat for spreads breaks when nil values are present Incorrect destructuring assignment order Multi-statement expressions

4 participants