diff --git a/demo/decorators.ts b/demo/decorators.ts new file mode 100644 index 000000000..0412e5809 --- /dev/null +++ b/demo/decorators.ts @@ -0,0 +1,33 @@ +export const classDecorator = (constructor: Function) => { + Object.seal(constructor); + Object.seal(constructor.prototype); +}; + +export const methodDecorator = (value: boolean) =>{ + return (target: unknown, propertyKey: string, descriptor?: PropertyDescriptor) => { + if (descriptor) { + descriptor.enumerable = value; + } + }; +}; + +export const accessorDecorator = (value: boolean) => { + return (target: unknown, propertyKey: string, descriptor?: PropertyDescriptor) => { + if (descriptor) { + descriptor.configurable = value; + } + }; +}; + +/** + * @Annotation + * @param {boolean} value + * @return {function(?, string, (PropertyDescriptor|undefined)): void} + */ +export const annotatedAccessorDecorator = (value: boolean) => { + return (target: unknown, propertyKey: string, descriptor?: PropertyDescriptor) => { + if (descriptor) { + descriptor.configurable = value; + } + }; +}; diff --git a/demo/demo_exports.ts b/demo/demo_exports.ts index 77bb7f5ad..721e3fdf4 100644 --- a/demo/demo_exports.ts +++ b/demo/demo_exports.ts @@ -14,7 +14,7 @@ export function baz() : SpecialType { export class MyClass { constructor(private objName: string) {} - public getName() { + getName() { return this.objName; } } \ No newline at end of file diff --git a/demo/demo_star_exports.ts b/demo/demo_star_exports.ts index aad097d7f..d9aa521d6 100644 --- a/demo/demo_star_exports.ts +++ b/demo/demo_star_exports.ts @@ -3,6 +3,8 @@ import type { SpecialType } from "./demo_exports"; export * from "./demo_exports"; +export * as decorators from "./decorators"; + const myObj = new MyClass("myObj"); -const bazValue: SpecialType = baz() \ No newline at end of file +const bazValue: SpecialType = baz(); \ No newline at end of file diff --git a/demo/tsconfig.json b/demo/tsconfig.json index 60753679f..15b499228 100644 --- a/demo/tsconfig.json +++ b/demo/tsconfig.json @@ -7,6 +7,7 @@ "strict": true, "moduleResolution": "Bundler", "esModuleInterop": true, + "experimentalDecorators": true, }, "exclude": [ "test.ts" diff --git a/demo/using_decorators.ts b/demo/using_decorators.ts new file mode 100644 index 000000000..4fced0c34 --- /dev/null +++ b/demo/using_decorators.ts @@ -0,0 +1,33 @@ +import { accessorDecorator, annotatedAccessorDecorator, classDecorator, methodDecorator } from "./decorators"; + +@classDecorator +export class Person { + private name_: string; + constructor(name: string) { + this.name_ = name; + } + + @accessorDecorator(true) + get name() { + return this.name_; + } + + @annotatedAccessorDecorator(true) + get age() { + return 42; + } + + @methodDecorator(true) + greet() { + console.log(`Hello, my name is ${this.name}.`); + } + } + + const p = new Person("Ron"); + p.greet(); + + + export class Employee { + constructor(private age_: number) {} + get age() { return this.age_; } + } \ No newline at end of file diff --git a/package.json b/package.json index b2f9930b5..9aa5eea06 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@types/source-map-support": "^0.5.3", "diff-match-patch": "^1.0.5", "glob": "8.0.1", - "google-closure-compiler": "^20230411.0.0", + "google-closure-compiler": "^20230502.0.0", "jasmine": "^4.1.0", "jasmine-node": "^3.0.0", "source-map": "^0.7.3", diff --git a/src/decorators.ts b/src/decorators.ts index fb8523ad1..751bda247 100644 --- a/src/decorators.ts +++ b/src/decorators.ts @@ -94,12 +94,12 @@ function isExportingDecorator(decorator: ts.Decorator, typeChecker: ts.TypeCheck * ], Foo.prototype, * __googReflect.objectProperty("prop", Foo.prototype), void 0); */ -export function transformDecoratorsOutputForClosurePropertyRenaming(diagnostics: ts.Diagnostic[]) { +export function transformDecoratorsOutputForClosurePropertyRenaming(diagnostics: ts.Diagnostic[], useGoogModule = true) { return (context: ts.TransformationContext) => { const result: ts.Transformer = (sourceFile: ts.SourceFile) => { let nodeNeedingGoogReflect: undefined|ts.Node = undefined; const visitor: ts.Visitor = (node) => { - const replacementNode = rewriteDecorator(node); + const replacementNode = rewriteDecorator(node, useGoogModule); if (replacementNode) { nodeNeedingGoogReflect = node; return replacementNode; @@ -113,30 +113,34 @@ export function transformDecoratorsOutputForClosurePropertyRenaming(diagnostics: if (nodeNeedingGoogReflect !== undefined) { const statements = [...updatedSourceFile.statements]; const googModuleIndex = statements.findIndex(isGoogModuleStatement); - if (googModuleIndex === -1) { + if (useGoogModule && googModuleIndex === -1) { reportDiagnostic( diagnostics, nodeNeedingGoogReflect, 'Internal tsickle error: could not find goog.module statement to import __tsickle_googReflect for decorator compilation.'); return sourceFile; } - const googRequireReflectObjectProperty = - ts.factory.createVariableStatement( - undefined, - ts.factory.createVariableDeclarationList( - [ts.factory.createVariableDeclaration( - '__tsickle_googReflect', - /* exclamationToken */ undefined, /* type */ undefined, - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('goog'), 'require'), - undefined, - [ts.factory.createStringLiteral('goog.reflect')]))], - ts.NodeFlags.Const)); - // The boilerplate we produce has a goog.module line, then two related - // lines dealing with the `module` variable. Insert our goog.require - // after that to avoid visually breaking up the module info, and to be - // with the rest of the goog.require statements. - statements.splice(googModuleIndex + 3, 0, googRequireReflectObjectProperty); + if (useGoogModule) { + const googRequireReflectObjectProperty = + ts.factory.createVariableStatement( + undefined, + ts.factory.createVariableDeclarationList( + [ts.factory.createVariableDeclaration( + '__tsickle_googReflect', + /* exclamationToken */ undefined, /* type */ undefined, + ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('goog'), 'require'), + undefined, + [ts.factory.createStringLiteral('goog.reflect')]))], + ts.NodeFlags.Const)); + + + // The boilerplate we produce has a goog.module line, then two related + // lines dealing with the `module` variable. Insert our goog.require + // after that to avoid visually breaking up the module info, and to be + // with the rest of the goog.require statements. + statements.splice(googModuleIndex + 3, 0, googRequireReflectObjectProperty); + } updatedSourceFile = ts.factory.updateSourceFile( updatedSourceFile, ts.setTextRange( @@ -161,7 +165,7 @@ export function transformDecoratorsOutputForClosurePropertyRenaming(diagnostics: * * Returns undefined if no modification is necessary. */ -function rewriteDecorator(node: ts.Node): ts.Node|undefined { +function rewriteDecorator(node: ts.Node, useGoogModule = true): ts.Node|undefined { if (!ts.isCallExpression(node)) { return; } @@ -188,12 +192,23 @@ function rewriteDecorator(node: ts.Node): ts.Node|undefined { return; } const fieldNameLiteral = untypedFieldNameLiteral; - args[2] = ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('__tsickle_googReflect'), - 'objectProperty'), - undefined, - [ts.factory.createStringLiteral(fieldNameLiteral.text), args[1]]); + if (useGoogModule) { + args[2] = ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('__tsickle_googReflect'), + 'objectProperty'), + undefined, + [ts.factory.createStringLiteral(fieldNameLiteral.text), args[1]]); + } else { + args[2] = ts.factory.createCallExpression( + ts.factory.createIdentifier('JSCompiler_renameProperty'), + undefined, + [ + ts.factory.createStringLiteral(fieldNameLiteral.text), + args[1], + ], + ); + } return ts.factory.updateCallExpression( node, node.expression, node.typeArguments, args); } diff --git a/src/export_star_transformer.ts b/src/export_star_transformer.ts new file mode 100644 index 000000000..3329c2a58 --- /dev/null +++ b/src/export_star_transformer.ts @@ -0,0 +1,59 @@ +import * as ts from "typescript"; + +/** + * Closure compiler cannot handle: + * + * ``` + * export * as namespace from "./module"; + * ``` + * + * This transformer changes star namespace exports to the following: + * + * ``` + * import * as namespace from "./module"; + * export {namespace}; + * ``` + */ +export function exportStarTransformer() { + return (context: ts.TransformationContext): ts.Transformer => { + return (sourceFile: ts.SourceFile) => { + function visit(node: ts.Node): ts.Node | ts.Node[] { + // Check if the node is a NamespaceExport (export * as NS from "module") + if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamespaceExport(node.exportClause)) { + const namespaceName = node.exportClause.name; + const moduleSpecifier = node.moduleSpecifier; + + if (!moduleSpecifier) return node; + + // Create import * as namespace from "module" + const importDecl = ts.factory.createImportDeclaration( + undefined, + ts.factory.createImportClause( + false, + undefined, + ts.factory.createNamespaceImport(namespaceName) + ), + moduleSpecifier + ); + + // Create export {namespace} + const exportDecl = ts.factory.createExportDeclaration( + undefined, + false, + ts.factory.createNamedExports([ + ts.factory.createExportSpecifier( + false, + undefined, + namespaceName + ) + ]) + ); + + return [importDecl, exportDecl]; + } + return ts.visitEachChild(node, visit, context); + } + return ts.visitEachChild(sourceFile, visit, context); + }; + }; +} diff --git a/src/fix_downleveled_decorators.ts b/src/fix_downleveled_decorators.ts new file mode 100644 index 000000000..d10a8b2b9 --- /dev/null +++ b/src/fix_downleveled_decorators.ts @@ -0,0 +1,54 @@ +import * as ts from "typescript"; + +/** + * This fixes the downleveled decorators so Closure doesn't have + * trouble with them. The problem is that when `experimentalDecorators` is + * enabled, we TSC ends up converting a class decorator like this: + * + * @classDecorator + * export class Person { ... } + * + * to this: + * + * let Person = class Person { ... }; + * + * as well as some calls to __decorate(Person, ...) + * + * The problem is that this causes Closure Compiler to fail with this error: + * ERROR - [JSC_CANNOT_CONVERT_YET] Transpilation of 'Classes with possible name shadowing' is not yet implemented. + * 21| let Person = class Person { + * ^^^^^^^^^^^^^^ + * + * This transformer fixes the problem by converting the class expression + * to a class declaration. + */ +export function fixDownleveledDecorators() { + return (context: ts.TransformationContext): ts.Transformer => { + return (sourceFile: ts.SourceFile) => { + function visit(node: ts.Node): ts.Node { + // Check if the node is a VariableDeclarationList + if (ts.isVariableDeclarationList(node)) { + for (const declaration of node.declarations) { + if ( + declaration.initializer && + ts.isClassExpression(declaration.initializer) + ) { + const className = declaration.name; + // convert the class expression to a class declaration + const classDeclaration = ts.factory.createClassDeclaration( + undefined, + className.getText(), + [], + [], + declaration.initializer.members + ); + return classDeclaration; + } + } + } + return ts.visitEachChild(node, visit, context); + } + return ts.visitEachChild(sourceFile, visit, context); + }; + }; +} diff --git a/src/jsdoc_transformer.ts b/src/jsdoc_transformer.ts index 5227d045c..d33ac13de 100644 --- a/src/jsdoc_transformer.ts +++ b/src/jsdoc_transformer.ts @@ -1429,7 +1429,7 @@ export function jsdocTransformer( } function visitor(node: ts.Node): ts.Node|ts.Node[] { - const leaveModulesAlone = !host.googmodule && host.transformTypesToClosure + const leaveModulesAlone = !host.googmodule && host.transformTypesToClosure; if (transformerUtil.isAmbient(node)) { if (!transformerUtil.hasModifierFlag(node as ts.Declaration, ts.ModifierFlags.Export)) { return node; diff --git a/src/tsickle.ts b/src/tsickle.ts index 3b10f073a..5514d26aa 100644 --- a/src/tsickle.ts +++ b/src/tsickle.ts @@ -24,6 +24,8 @@ import {FileSummary, SummaryGenerationProcessorHost} from './summary'; import {isDtsFileName} from './transformer_util'; import * as tsmes from './ts_migration_exports_shim'; import {makeTsickleDeclarationMarkerTransformerFactory} from './tsickle_declaration_marker'; +import {fixDownleveledDecorators} from './fix_downleveled_decorators'; +import { exportStarTransformer } from './export_star_transformer'; // Exported for users as a default impl of pathToModuleName. export {pathToModuleName} from './cli_support'; @@ -248,6 +250,13 @@ export function emit( transformDecoratorsOutputForClosurePropertyRenaming( tsickleDiagnostics)); tsTransformers.after!.push(transformDecoratorJsdoc()); + } else if (host.transformTypesToClosure) { + tsTransformers.after!.push(exportStarTransformer()); + tsTransformers.after!.push( + transformDecoratorsOutputForClosurePropertyRenaming( + tsickleDiagnostics, false)); + tsTransformers.after!.push(transformDecoratorJsdoc()); + tsTransformers.after!.push(fixDownleveledDecorators()); } if (host.addDtsClutzAliases) { tsTransformers.afterDeclarations!.push( diff --git a/test/decorators.ts b/test/decorators.ts new file mode 100644 index 000000000..0046be056 --- /dev/null +++ b/test/decorators.ts @@ -0,0 +1,42 @@ +export const classDecorator = (constructor: Function) => { + Object.seal(constructor); + Object.seal(constructor.prototype); +}; + +export const methodDecorator = (value: boolean) =>{ + return (target: unknown, propertyKey: string, descriptor?: PropertyDescriptor) => { + if (descriptor) { + descriptor.enumerable = value; + } + }; +}; + +export const accessorDecorator = (value: boolean) => { + return (target: unknown, propertyKey: string, descriptor?: PropertyDescriptor) => { + if (descriptor) { + descriptor.configurable = value; + } + }; +}; + + +@classDecorator +export class Person { + private name_: string; + constructor(name: string) { + this.name_ = name; + } + + @accessorDecorator(true) + get name() { + return this.name_; + } + + @methodDecorator(true) + greet() { + console.log(`Hello, my name is ${this.name}.`); + } + } + + const p = new Person("Ron"); + p.greet(); \ No newline at end of file diff --git a/test/export_star_transformer_test.ts b/test/export_star_transformer_test.ts new file mode 100644 index 000000000..117f2b921 --- /dev/null +++ b/test/export_star_transformer_test.ts @@ -0,0 +1,45 @@ +import * as ts from 'typescript'; +import {exportStarTransformer} from '../src/export_star_transformer'; +import {transformCode} from './transformer_util'; + +describe('exportStarTransformer', () => { + it('transforms export * as namespace', () => { + const input = `export * as ns from './module';`; + const expected = `import * as ns from "./module";\nexport { ns };`; + expect(transformCode(input, exportStarTransformer()).trim()).toBe(expected); + }); + + it('ignores regular exports', () => { + const input = `export { foo } from './module';`; + expect(transformCode(input, exportStarTransformer()).trim()).toBe(input); + }); + + it('ignores regular star exports', () => { + const input = `export * from './module';`; + expect(transformCode(input, exportStarTransformer()).trim()).toBe(input); + }); + + it('handles multiple exports', () => { + const input = ` +export * as ns1 from './module1'; +export * as ns2 from './module2'; +`; + const expected = `import * as ns1 from "./module1";\nexport { ns1 };\nimport * as ns2 from "./module2";\nexport { ns2 };`; + expect(transformCode(input, exportStarTransformer()).trim()).toBe(expected); + }); + + it('preserves other statements', () => { + const input = ` +import { foo } from './foo'; +export * as ns from './module'; +const x = 1; +`; + const expected = `import { foo } from './foo';\nimport * as ns from "./module";\nexport { ns };\nconst x = 1;`; + expect(transformCode(input, exportStarTransformer()).trim()).toBe(expected); + }); + + it('ignores export declarations without module specifier', () => { + const input = `export * as ns from '';`; + expect(transformCode(input, exportStarTransformer()).trim()).toBe(input); + }); +}); \ No newline at end of file diff --git a/test/fix_downleveled_decorators_test.ts b/test/fix_downleveled_decorators_test.ts new file mode 100644 index 000000000..b6b6afd20 --- /dev/null +++ b/test/fix_downleveled_decorators_test.ts @@ -0,0 +1,97 @@ +import * as ts from 'typescript'; +import {fixDownleveledDecorators} from '../src/fix_downleveled_decorators'; +import {transformCode} from './transformer_util'; + +describe('fixDownleveledDecorators', () => { + it('converts class expression to class declaration in variable declaration', () => { + const input = `let Person = class Person { + constructor() {} + method() {} + };`; + const expected = `class Person { + constructor() { } + method() { } +}`; + expect(transformCode(input, fixDownleveledDecorators()).trim()).toBe(expected); + }); + + it('preserves non-class variable declarations', () => { + const input = `let x = 5; +let Person = class Person {}; +let y = "test";`; + const expected = `let x = 5; +class Person { +} +let y = "test";`; + expect(transformCode(input, fixDownleveledDecorators()).trim()).toBe(expected); + }); + + it('handles multiple class expressions in the same file', () => { + const input = `let Person = class Person {}; +let Employee = class Employee {};`; + const expected = `class Person { +} +class Employee { +}`; + expect(transformCode(input, fixDownleveledDecorators()).trim()).toBe(expected); + }); + + it('preserves class methods and properties', () => { + const input = `let Person = class Person { + name: string; + constructor(n: string) { this.name = n; } + greet() { return "Hello " + this.name; } + };`; + const expected = `class Person { + name: string; + constructor(n: string) { + this.name = n; + } + greet() { + return "Hello " + this.name; + } +}`; + expect(transformCode(input, fixDownleveledDecorators()).trim()).toBe(expected); + }); + + it('ignores regular class declarations', () => { + const input = `class Person { + constructor() {} + }`; + expect(transformCode(input, fixDownleveledDecorators()).trim()).toBe(input); + }); + + it('handles class expressions with decorators', () => { + const input = `let Person = class Person { + @propertyDecorator + name: string; + + @methodDecorator + greet() {} + };`; + const expected = `class Person { + @propertyDecorator + name: string; + @methodDecorator + greet() { } +}`; + expect(transformCode(input, fixDownleveledDecorators()).trim()).toBe(expected); + }); + + it('preserves comments and decorators', () => { + const input = ` + /** Person class documentation */ + let Person = class Person { + /** name property */ + @propertyDecorator + name: string; + };`; + const expected = `/** Person class documentation */ +class Person { + /** name property */ + @propertyDecorator + name: string; +}`; + expect(transformCode(input, fixDownleveledDecorators()).trim()).toBe(expected); + }); +}); \ No newline at end of file diff --git a/test/transformer_util.ts b/test/transformer_util.ts new file mode 100644 index 000000000..dfabd79d7 --- /dev/null +++ b/test/transformer_util.ts @@ -0,0 +1,16 @@ +import * as ts from 'typescript'; + +/** + * Helper function to test TypeScript transformers. + * Takes a source code string and a transformer factory, applies the transformer, + * and returns the transformed code as a string. + */ +export function transformCode( + input: string, + transformerFactory: (context: ts.TransformationContext) => ts.Transformer): string { + const sourceFile = ts.createSourceFile( + 'test.ts', input, ts.ScriptTarget.ES2015, true); + const result = ts.transform(sourceFile, [transformerFactory]); + const printer = ts.createPrinter(); + return printer.printFile(result.transformed[0]); +} diff --git a/tsconfig.json b/tsconfig.json index 2b9334885..ab0b76c24 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,8 @@ "strict": true, "outDir": "out", "paths": {"tsickle": ["./src/tsickle.ts"]}, - "esModuleInterop": true + "esModuleInterop": true, + "experimentalDecorators": true }, "include": [ "src/*.ts", diff --git a/tslint.json b/tslint.json index 9d46e7b88..e62c5dcda 100644 --- a/tslint.json +++ b/tslint.json @@ -63,7 +63,6 @@ "ban-keywords", "allow-leading-underscore", "allow-trailing-underscore" - ], - "file-header": [true, "Copyright Google Inc\\."] + ] } }