diff --git a/src/com/google/javascript/jscomp/Es6RewriteClassExtendsExpressions.java b/src/com/google/javascript/jscomp/Es6RewriteClassExtendsExpressions.java new file mode 100644 index 00000000000..1ee735b2012 --- /dev/null +++ b/src/com/google/javascript/jscomp/Es6RewriteClassExtendsExpressions.java @@ -0,0 +1,173 @@ +/* + * Copyright 2018 The Closure Compiler Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.javascript.jscomp; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.javascript.jscomp.deps.ModuleNames; +import com.google.javascript.jscomp.parsing.parser.FeatureSet; +import com.google.javascript.jscomp.parsing.parser.FeatureSet.Feature; +import com.google.javascript.rhino.IR; +import com.google.javascript.rhino.Node; + +/** + * Extracts ES6 class extends expressions and creates an alias. + * + *

Example: Before: + * + *

class Foo extends Bar() {} + * + *

After: + * + *

+ * const $jscomp$classextends$var0 = Bar(); + * class Foo extends $jscomp$classextends$var0 {} + * + * + *

This must be done before {@link Es6ConvertSuper}, because that pass only handles extends + * clauses which are simple NAME or GETPROP nodes. + * + * TODO(bradfordcsmith): This pass may no longer be necessary once the typechecker passes have all + * been updated to understand ES6 classes. + */ +public final class Es6RewriteClassExtendsExpressions extends NodeTraversal.AbstractPostOrderCallback + implements HotSwapCompilerPass { + + static final String CLASS_EXTENDS_VAR = "$classextends$var"; + + private final AbstractCompiler compiler; + private int classExtendsVarCounter = 0; + private static final FeatureSet features = FeatureSet.BARE_MINIMUM.with(Feature.CLASSES); + + Es6RewriteClassExtendsExpressions(AbstractCompiler compiler) { + this.compiler = compiler; + } + + @Override + public void process(Node externs, Node root) { + // TODO(bradfordcsmith): Do we really need to run this on externs? + TranspilationPasses.processTranspile(compiler, externs, features, this); + TranspilationPasses.processTranspile(compiler, root, features, this); + } + + @Override + public void hotSwapScript(Node scriptRoot, Node originalRoot) { + TranspilationPasses.hotSwapTranspile(compiler, scriptRoot, features, this); + } + + @Override + public void visit(NodeTraversal t, Node n, Node parent) { + if (n.isClass() && needsExtendsDecomposing(n)) { + if (canDecomposeSimply(n)) { + extractExtends(t, n); + } else { + decomposeInIIFE(t, n); + } + } + } + + private boolean needsExtendsDecomposing(Node classNode) { + checkArgument(classNode.isClass()); + Node superClassNode = classNode.getSecondChild(); + return !superClassNode.isEmpty() & !superClassNode.isQualifiedName(); + } + + /** + * Find common cases where we can safely decompose class extends expressions which are not + * qualified names. Enables transpilation of complex extends expressions. + * + *

We can only decompose the expression in a limited set of cases to avoid changing evaluation + * order of side-effect causing statements. + */ + private boolean canDecomposeSimply(Node classNode) { + Node enclosingStatement = checkNotNull(NodeUtil.getEnclosingStatement(classNode), classNode); + if (enclosingStatement == classNode) { + // `class Foo extends some_expression {}` + // can always be converted to + // ``` + // const tmpvar = some_expression; + // class Foo extends tmpvar {} + // ``` + return true; + } else { + Node classNodeParent = classNode.getParent(); + if (NodeUtil.isNameDeclaration(enclosingStatement) + && classNodeParent.isName() + && classNodeParent.isFirstChildOf(enclosingStatement)) { + // `const Foo = class extends some_expression {}, maybe_other_var;` + // can always be converted to + // ``` + // const tmpvar = some_expression; + // const Foo = class extends tmpvar {}, maybe_other_var; + // ``` + return true; + } else if (enclosingStatement.isExprResult() + && classNodeParent.isOnlyChildOf(enclosingStatement) + && classNodeParent.isAssign() + && classNode.isSecondChildOf(classNodeParent)) { + // `lhs = class extends some_expression {};` + Node lhsNode = classNodeParent.getFirstChild(); + // We can extract a temporary variable for some_expression as long as lhs expression + // has no side effects. + return !NodeUtil.mayHaveSideEffects(lhsNode); + } else { + return false; + } + } + } + + private void extractExtends(NodeTraversal t, Node classNode) { + String name = + ModuleNames.fileToJsIdentifier(classNode.getStaticSourceFile().getName()) + + CLASS_EXTENDS_VAR + + classExtendsVarCounter++; + + Node statement = NodeUtil.getEnclosingStatement(classNode); + Node originalExtends = classNode.getSecondChild(); + originalExtends.replaceWith(IR.name(name).useSourceInfoFrom(originalExtends)); + Node extendsAlias = + IR.constNode(IR.name(name), originalExtends) + .useSourceInfoIfMissingFromForTree(originalExtends); + statement.getParent().addChildBefore(extendsAlias, statement); + NodeUtil.addFeatureToScript(NodeUtil.getEnclosingScript(classNode), Feature.CONST_DECLARATIONS); + t.reportCodeChange(classNode); + } + + /** + * When a class is used in an expressions where adding an alias as the previous statement might + * change execution order of a side-effect causing statement, wrap the class in an IIFE so that + * decomposition can happen safely. + */ + private void decomposeInIIFE(NodeTraversal t, Node classNode) { + // converts + // `class X extends something {}` + // to + // `(function() { return class X extends something {}; })()` + Node functionBody = IR.block(); + Node function = IR.function(IR.name(""), IR.paramList(), functionBody); + Node call = NodeUtil.newCallNode(function); + classNode.replaceWith(call); + functionBody.addChildToBack(IR.returnNode(classNode)); + call.useSourceInfoIfMissingFromForTree(classNode); + // NOTE: extractExtends() will end up reporting the change for the new function, so we only + // need to report the change to the enclosing scope + t.reportCodeChange(call); + // Now do the extends expression extraction within the IIFE + extractExtends(t, classNode); + } +} diff --git a/src/com/google/javascript/jscomp/PassNames.java b/src/com/google/javascript/jscomp/PassNames.java index 8c57e3ed3b0..df8d21228df 100644 --- a/src/com/google/javascript/jscomp/PassNames.java +++ b/src/com/google/javascript/jscomp/PassNames.java @@ -51,6 +51,7 @@ public final class PassNames { public static final String DISAMBIGUATE_PRIVATE_PROPERTIES = "disambiguatePrivateProperties"; public static final String DISAMBIGUATE_PROPERTIES = "disambiguateProperties"; public static final String ES6_EXTRACT_CLASSES = "Es6ExtractClasses"; + public static final String ES6_REWRITE_CLASS_EXTENDS = "Es6ExtractClassExtends"; public static final String EXPLOIT_ASSIGN = "exploitAssign"; public static final String EXPORT_TEST_FUNCTIONS = "exportTestFunctions"; public static final String EXTERN_EXPORTS = "externExports"; diff --git a/src/com/google/javascript/jscomp/TranspilationPasses.java b/src/com/google/javascript/jscomp/TranspilationPasses.java index c019d1b8489..6e374a883f6 100644 --- a/src/com/google/javascript/jscomp/TranspilationPasses.java +++ b/src/com/google/javascript/jscomp/TranspilationPasses.java @@ -100,6 +100,7 @@ static void addPreTypecheckTranspilationPasses( } passes.add(es6NormalizeShorthandProperties); + passes.add(es6RewriteClassExtends); passes.add(es6ConvertSuper); passes.add(es6RenameVariablesInParamLists); passes.add(es6SplitVariableDeclarations); @@ -276,6 +277,19 @@ protected FeatureSet featureSet() { } }; + static final HotSwapPassFactory es6RewriteClassExtends = + new HotSwapPassFactory(PassNames.ES6_REWRITE_CLASS_EXTENDS) { + @Override + protected HotSwapCompilerPass create(AbstractCompiler compiler) { + return new Es6RewriteClassExtendsExpressions(compiler); + } + + @Override + protected FeatureSet featureSet() { + return ES2018; + } + }; + static final HotSwapPassFactory es6ExtractClasses = new HotSwapPassFactory(PassNames.ES6_EXTRACT_CLASSES) { @Override diff --git a/src/com/google/javascript/rhino/Node.java b/src/com/google/javascript/rhino/Node.java index 6f3dcd9ccc1..c67bf79d91d 100644 --- a/src/com/google/javascript/rhino/Node.java +++ b/src/com/google/javascript/rhino/Node.java @@ -1622,12 +1622,18 @@ public final boolean isDescendantOf(Node node) { return false; } + public final boolean isOnlyChildOf(Node possibleParent) { + return possibleParent == getParent() && getPrevious() == null && getNext() == null; + } + public final boolean isFirstChildOf(Node possibleParent) { return possibleParent == getParent() && getPrevious() == null; } public final boolean isSecondChildOf(Node possibleParent) { - return getPrevious().isFirstChildOf(possibleParent); + Node previousNode = getPrevious(); + + return previousNode != null && previousNode.isFirstChildOf(possibleParent); } /** diff --git a/test/com/google/javascript/jscomp/Es6RewriteClassExtendsExpressionsTest.java b/test/com/google/javascript/jscomp/Es6RewriteClassExtendsExpressionsTest.java new file mode 100644 index 00000000000..4764cc3bd4e --- /dev/null +++ b/test/com/google/javascript/jscomp/Es6RewriteClassExtendsExpressionsTest.java @@ -0,0 +1,201 @@ +/* + * Copyright 2018 The Closure Compiler Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.javascript.jscomp; + +import com.google.javascript.jscomp.CompilerOptions.LanguageMode; + +public final class Es6RewriteClassExtendsExpressionsTest extends CompilerTestCase { + + @Override + protected CompilerPass getProcessor(Compiler compiler) { + return new Es6RewriteClassExtendsExpressions(compiler); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + setAcceptedLanguage(LanguageMode.ECMASCRIPT_NEXT); + setLanguageOut(LanguageMode.ECMASCRIPT3); + disableTypeCheck(); + enableRunTypeCheckAfterProcessing(); + } + + public void testBasic() { + test( + "const foo = {'bar': Object}; class Foo extends foo['bar'] {}", + lines( + "const foo = {'bar': Object};", + "const testcode$classextends$var0 = foo['bar'];", + "class Foo extends testcode$classextends$var0 {}")); + } + + public void testName() { + testSame("class Foo extends Object {}"); + } + + public void testGetProp() { + testSame("const foo = { bar: Object}; class Foo extends foo.bar {}"); + } + + public void testMixinFunction() { + test( + lines( + "/** @return {function(new:Object)} */", + "function mixObject(Superclass) {", + " return class extends Superclass {", + " bar() { return 'bar'; }", + " };", + "}", + "class Foo {}", + "class Bar extends mixObject(Foo) {}"), + lines( + "/** @return {function(new:Object)} */", + "function mixObject(Superclass) {", + " return class extends Superclass {", + " bar() { return 'bar'; }", + " };", + "}", + "class Foo {}", + "const testcode$classextends$var0 = mixObject(Foo);", + "class Bar extends testcode$classextends$var0 {}")); + } + + public void testClassExpressions() { + testSame( + lines( + "const foo = { bar: Object};", + "function baz(arg) {}", + "baz(class extends foo.bar {});")); + } + + public void testVarDeclaration() { + test( + "const foo = {'bar': Object}; var Foo = class extends foo['bar'] {};", + lines( + "const foo = {'bar': Object};", + "const testcode$classextends$var0 = foo['bar'];", + "var Foo = class extends testcode$classextends$var0 {};")); + + test( + "const foo = {'bar': Object}; let Foo = class extends foo['bar'] {};", + lines( + "const foo = {'bar': Object};", + "const testcode$classextends$var0 = foo['bar'];", + "let Foo = class extends testcode$classextends$var0 {};")); + + test( + "const foo = {'bar': Object}; const Foo = class extends foo['bar'] {};", + lines( + "const foo = {'bar': Object};", + "const testcode$classextends$var0 = foo['bar'];", + "const Foo = class extends testcode$classextends$var0 {};")); + } + + public void testDeclarationInForLoop() { + test( + lines( + "const foo = {'bar': Object};", + "for (let Foo = class extends foo['bar'] {}, i = 0; i < 1; i++) {}"), + lines( + "const foo = {'bar': Object};", + // use an iife since parent of the let isn't a block where we can add statements + // TODO(bradfordcsmith): Would it be better (smaller output code) to just declare + // the temporary variable in the block containing the for-loop? + "for (let Foo = (function() {", + " const testcode$classextends$var0 = foo['bar'];", + " return class extends testcode$classextends$var0 {};", + " })(), i = 0; i < 1; i++) {}")); + } + + public void testAssign() { + test( + "const foo = {'bar': Object}; var Foo; Foo = class extends foo['bar'] {};", + lines( + "const foo = {'bar': Object};", + "var Foo;", + "const testcode$classextends$var0 = foo['bar'];", + "Foo = class extends testcode$classextends$var0 {};")); + + test( + "const foo = {'bar': Object}; foo.baz = class extends foo['bar'] {};", + lines( + "const foo = {'bar': Object};", + "const testcode$classextends$var0 = foo['bar'];", + "foo.baz = class extends testcode$classextends$var0 {};")); + + test( + "const foo = {'bar': Object}; foo.foo = foo.baz = class extends foo['bar'] {};", + lines( + "const foo = {'bar': Object};", + "foo.foo = foo.baz = (function() {", + " const testcode$classextends$var0 = foo['bar'];", + " return class extends testcode$classextends$var0 {};", + "})();")); + + test( + "const foo = {'bar': Object}; foo['baz'] = class extends foo['bar'] {};", + lines( + "const foo = {'bar': Object};", + "const testcode$classextends$var0 = foo['bar'];", + "foo['baz'] = class extends testcode$classextends$var0 {};")); + + test( + "const foo = {'bar': Object, baz: {}}; foo.baz['foo'] = class extends foo['bar'] {};", + lines( + "const foo = {'bar': Object, baz: {}};", + "const testcode$classextends$var0 = foo['bar'];", + "foo.baz['foo'] = class extends testcode$classextends$var0 {};")); + } + + public void testAssignWithSideEffects() { + test( + lines( + "let baz = 1;", + "const foo = {'bar': Object};", + "foo[baz++] = class extends foo['bar'] {};"), + lines( + "let baz = 1;", + "const foo = {'bar': Object};", + // Use an IIFE to preserve execution order when expressions have side effects. + "foo[baz++] = (function() {", + " const testcode$classextends$var0 = foo['bar'];", + " return class extends testcode$classextends$var0 {};", + "})();")); + } + + public void testMultipleVarDeclaration() { + test( + lines( + "const foo = { 'bar': Object};", + "function mayHaveSideEffects() {}", + "var baz = mayHaveSideEffects(), Foo = class extends foo['bar'] {};"), + lines( + "const foo = { 'bar': Object};", + "function mayHaveSideEffects() {}", + "var baz = mayHaveSideEffects(), Foo = (function() {", + " const testcode$classextends$var0 = foo['bar'];", + " return class extends testcode$classextends$var0 {};", + "})();")); + + test( + "const foo = {'bar': Object}; const Foo = class extends foo['bar'] {}, baz = false;", + lines( + "const foo = {'bar': Object};", + "const testcode$classextends$var0 = foo['bar'];", + "const Foo = class extends testcode$classextends$var0 {}, baz = false;")); + } +}