diff --git a/src/transformation/visitors/call.ts b/src/transformation/visitors/call.ts index 7656ed01d..549e609f1 100644 --- a/src/transformation/visitors/call.ts +++ b/src/transformation/visitors/call.ts @@ -16,13 +16,78 @@ import { transformLuaTableCallExpression } from "./lua-table"; export type PropertyCallExpression = ts.CallExpression & { expression: ts.PropertyAccessExpression }; +function getExpressionsBeforeAndAfterFirstSpread( + expressions: readonly ts.Expression[] +): [readonly ts.Expression[], readonly ts.Expression[]] { + // [a, b, ...c, d, ...e] --> [a, b] and [...c, d, ...e] + const index = expressions.findIndex(ts.isSpreadElement); + const hasSpreadElement = index !== -1; + const before = hasSpreadElement ? expressions.slice(0, index) : expressions; + const after = hasSpreadElement ? expressions.slice(index) : []; + return [before, after]; +} + +function transformSpreadableExpressionsIntoArrayConcatArguments( + context: TransformationContext, + expressions: readonly ts.Expression[] | ts.NodeArray +): lua.Expression[] { + // [...array, a, b, ...tuple()] --> [ [...array], [a, b], [...tuple()] ] + // chunk non-spread arguments together so they don't concat + const chunks: ts.Expression[][] = []; + for (const [index, expression] of expressions.entries()) { + if (ts.isSpreadElement(expression)) { + chunks.push([expression]); + const next = expressions[index + 1]; + if (next && !ts.isSpreadElement(next)) { + chunks.push([]); + } + } else { + let lastChunk = chunks[chunks.length - 1]; + if (!lastChunk) { + lastChunk = []; + chunks.push(lastChunk); + } + lastChunk.push(expression); + } + } + + return chunks.map(chunk => wrapInTable(...chunk.map(expression => context.transformExpression(expression)))); +} + +export function flattenSpreadExpressions( + context: TransformationContext, + expressions: readonly ts.Expression[] +): lua.Expression[] { + const [preSpreadExpressions, postSpreadExpressions] = getExpressionsBeforeAndAfterFirstSpread(expressions); + const transformedPreSpreadExpressions = preSpreadExpressions.map(a => context.transformExpression(a)); + + // Nothing special required + if (postSpreadExpressions.length === 0) { + return transformedPreSpreadExpressions; + } + + // Only one spread element at the end? Will work as expected + if (postSpreadExpressions.length === 1) { + return [...transformedPreSpreadExpressions, context.transformExpression(postSpreadExpressions[0])]; + } + + // Use Array.concat and unpack the result of that as the last Expression + const concatArguments = transformSpreadableExpressionsIntoArrayConcatArguments(context, postSpreadExpressions); + const lastExpression = createUnpackCall( + context, + transformLuaLibFunction(context, LuaLibFeature.ArrayConcat, undefined, ...concatArguments) + ); + + return [...transformedPreSpreadExpressions, lastExpression]; +} + export function transformArguments( context: TransformationContext, params: readonly ts.Expression[], signature?: ts.Signature, callContext?: ts.Expression ): lua.Expression[] { - const parameters = params.map(param => context.transformExpression(param)); + const parameters = flattenSpreadExpressions(context, params); // Add context as first param if present if (callContext) { diff --git a/src/transformation/visitors/literal.ts b/src/transformation/visitors/literal.ts index 6149e476f..f4384c48f 100644 --- a/src/transformation/visitors/literal.ts +++ b/src/transformation/visitors/literal.ts @@ -14,6 +14,7 @@ import { import { getSymbolIdOfSymbol, trackSymbolReference } from "../utils/symbols"; import { isArrayType } from "../utils/typescript"; import { transformFunctionLikeDeclaration } from "./function"; +import { flattenSpreadExpressions } from "./call"; // TODO: Move to object-literal.ts? export function transformPropertyName(context: TransformationContext, node: ts.PropertyName): lua.Expression { @@ -137,13 +138,10 @@ const transformObjectLiteralExpression: FunctionVisitor = (expression, context) => { - const values = expression.elements.map(element => - lua.createTableFieldExpression( - ts.isOmittedExpression(element) ? lua.createNilLiteral(element) : context.transformExpression(element), - undefined, - element - ) + const filteredElements = expression.elements.map(e => + ts.isOmittedExpression(e) ? ts.createIdentifier("undefined") : e ); + const values = flattenSpreadExpressions(context, filteredElements).map(e => lua.createTableFieldExpression(e)); return lua.createTableExpression(values, expression); }; diff --git a/test/unit/spread.spec.ts b/test/unit/spread.spec.ts index 9f6f19ae4..0a54e3a41 100644 --- a/test/unit/spread.spec.ts +++ b/test/unit/spread.spec.ts @@ -1,10 +1,71 @@ import * as tstl from "../../src"; import * as util from "../util"; +import { formatCode } from "../util"; // TODO: Make some utils for testing other targets const expectUnpack: util.TapCallback = builder => expect(builder.getMainLuaCodeChunk()).toMatch(/[^.]unpack\(/); const expectTableUnpack: util.TapCallback = builder => expect(builder.getMainLuaCodeChunk()).toContain("table.unpack"); +const arrayLiteralCases = [ + "1, 2, ...[3, 4, 5]", + "...[1, 2], 3, 4, 5", + "1, ...[[2]], 3", + "...[1, 2, 3], 4, ...[5, 6]", + "1, 2, ...[3, 4], ...[5, 6]", +]; + +const tupleReturnDefinition = ` +/** @tupleReturn */ +function tuple(...args: any[]) { + return args; +}`; + +describe.each(["function call", "array literal"] as const)("in %s", kind => { + // prettier-ignore + const factory = (code: string) => kind === "function call" + ? `((...args: any[]) => args)(${code})` + : `[${code}]`; + + test.each(arrayLiteralCases)("of array literal (%p)", expression => { + util.testExpression(factory(expression)).expectToMatchJsResult(); + }); + + test.each(arrayLiteralCases)("of tuple return call (%p)", expression => { + util.testFunction` + ${tupleReturnDefinition} + return ${factory(`...tuple(${expression})`)}; + `.expectToMatchJsResult(); + }); + + test("of multiple string literals", () => { + util.testExpression(factory('..."spread", ..."string"')).expectToMatchJsResult(); + }); + + test.each(["", "string", "string with spaces", "string 1 2 3"])("of string literal (%p)", str => { + util.testExpression(factory(`...${formatCode(str)}`)).expectToMatchJsResult(); + }); + + test("of iterable", () => { + util.testFunction` + const it = { + i: -1, + [Symbol.iterator]() { + return this; + }, + next() { + ++this.i; + return { + value: 2 ** this.i, + done: this.i == 9, + } + } + }; + + return ${factory("...it")}; + `.expectToMatchJsResult(); + }); +}); + describe("in function call", () => { util.testEachVersion( undefined, @@ -12,7 +73,6 @@ describe("in function call", () => { function foo(a: number, b: number, ...rest: number[]) { return { a, b, rest } } - const array = [0, 1, 2, 3] as const; return foo(...array); `, @@ -26,34 +86,17 @@ describe("in function call", () => { }); describe("in array literal", () => { - util.testEachVersion("of array literal", () => util.testExpression`[...[0, 1, 2]]`, { + util.testEachVersion(undefined, () => util.testExpression`[...[0, 1, 2]]`, { [tstl.LuaTarget.LuaJIT]: builder => builder.tap(expectUnpack), [tstl.LuaTarget.Lua51]: builder => builder.tap(expectUnpack), [tstl.LuaTarget.Lua52]: builder => builder.tap(expectTableUnpack), [tstl.LuaTarget.Lua53]: builder => builder.tap(expectTableUnpack).expectToMatchJsResult(), }); - test.each(["", "string", "string with spaces", "string 1 2 3"])("of string literal (%p)", str => { - util.testExpressionTemplate`[...${str}]`.expectToMatchJsResult(); - }); - - test("of iterable", () => { + test("of array literal /w OmittedExpression", () => { util.testFunction` - const it = { - i: -1, - [Symbol.iterator]() { - return this; - }, - next() { - ++this.i; - return { - value: 2 ** this.i, - done: this.i == 9, - } - } - }; - - return [...it] + const array = [1, 2, ...[3], , 5]; + return { a: array[0], b: array[1], c: array[2], d: array[3] }; `.expectToMatchJsResult(); }); });