fix(ngcc): recognize enum declarations emitted in JavaScript (#36550)

An enum declaration in TypeScript code will be emitted into JavaScript
as a regular variable declaration, with the enum members being declared
inside an IIFE. For ngcc to support interpreting such variable
declarations as enum declarations with its members, ngcc needs to
recognize the enum declaration emit structure and extract all member
from the statements in the IIFE.

This commit extends the `ConcreteDeclaration` structure in the
`ReflectionHost` abstraction to be able to capture the enum members
on a variable declaration, as a substitute for the original
`ts.EnumDeclaration` as it existed in TypeScript code. The static
interpreter has been extended to handle the extracted enum members
as it would have done for `ts.EnumDeclaration`.

Fixes #35584
Resolves FW-2069

PR Close #36550
This commit is contained in:
JoostK 2020-04-10 00:35:40 +02:00 committed by Andrew Kushnir
parent a6a7e1bb99
commit 89c589085d
17 changed files with 865 additions and 26 deletions

View File

@ -115,7 +115,7 @@ export class ModuleWithProvidersAnalyzer {
dtsNgModule.getText()}`);
}
return {node: dtsNgModule, known: null, viaModule: null};
return {node: dtsNgModule, known: null, viaModule: null, identity: null};
}
}

View File

@ -156,7 +156,10 @@ export class CommonJsReflectionHost extends Esm5ReflectionHost {
const reexports: ExportDeclaration[] = [];
importedExports.forEach((decl, name) => {
if (decl.node !== null) {
reexports.push({name, declaration: {node: decl.node, known: null, viaModule}});
reexports.push({
name,
declaration: {node: decl.node, known: null, viaModule, identity: decl.identity}
});
} else {
reexports.push(
{name, declaration: {node: null, known: null, expression: decl.expression, viaModule}});
@ -184,7 +187,7 @@ export class CommonJsReflectionHost extends Esm5ReflectionHost {
}
const viaModule = !importInfo.from.startsWith('.') ? importInfo.from : null;
return {node: importedFile, known: getTsHelperFnFromIdentifier(id), viaModule};
return {node: importedFile, known: getTsHelperFnFromIdentifier(id), viaModule, identity: null};
}
private resolveModuleName(moduleName: string, containingFile: ts.SourceFile): ts.SourceFile

View File

@ -8,13 +8,14 @@
import * as ts from 'typescript';
import {ClassDeclaration, ClassMember, ClassMemberKind, ConcreteDeclaration, CtorParameter, Declaration, Decorator, isDecoratorIdentifier, KnownDeclaration, reflectObjectLiteral, TypeScriptReflectionHost, TypeValueReference,} from '../../../src/ngtsc/reflection';
import {ClassDeclaration, ClassMember, ClassMemberKind, ConcreteDeclaration, CtorParameter, Declaration, Decorator, EnumMember, isDecoratorIdentifier, KnownDeclaration, reflectObjectLiteral, SpecialDeclarationKind, TypeScriptReflectionHost, TypeValueReference} from '../../../src/ngtsc/reflection';
import {isWithinPackage} from '../analysis/util';
import {Logger} from '../logging/logger';
import {BundleProgram} from '../packages/bundle_program';
import {findAll, getNameText, hasNameIdentifier, isDefined, stripDollarSuffix} from '../utils';
import {ClassSymbol, isSwitchableVariableDeclaration, ModuleWithProvidersFunction, NgccClassSymbol, NgccReflectionHost, PRE_R3_MARKER, SwitchableVariableDeclaration} from './ngcc_host';
import {stripParentheses} from './utils';
export const DECORATORS = 'decorators' as ts.__String;
export const PROP_DECORATORS = 'propDecorators' as ts.__String;
@ -344,14 +345,29 @@ export class Esm2015ReflectionHost extends TypeScriptReflectionHost implements N
getDeclarationOfIdentifier(id: ts.Identifier): Declaration|null {
const superDeclaration = super.getDeclarationOfIdentifier(id);
// If no declaration was found or it's an inline declaration, return as is.
if (superDeclaration === null || superDeclaration.node === null) {
return superDeclaration;
}
// If the declaration already has traits assigned to it, return as is.
if (superDeclaration.known !== null || superDeclaration.identity !== null) {
return superDeclaration;
}
// The identifier may have been of an additional class assignment such as `MyClass_1` that was
// present as alias for `MyClass`. If so, resolve such aliases to their original declaration.
if (superDeclaration !== null && superDeclaration.node !== null &&
superDeclaration.known === null) {
const aliasedIdentifier = this.resolveAliasedClassIdentifier(superDeclaration.node);
if (aliasedIdentifier !== null) {
return this.getDeclarationOfIdentifier(aliasedIdentifier);
}
// Variable declarations may represent an enum declaration, so attempt to resolve its members.
if (ts.isVariableDeclaration(superDeclaration.node)) {
const enumMembers = this.resolveEnumMembers(superDeclaration.node);
if (enumMembers !== null) {
superDeclaration.identity = {kind: SpecialDeclarationKind.DownleveledEnum, enumMembers};
}
}
return superDeclaration;
@ -1763,10 +1779,189 @@ export class Esm2015ReflectionHost extends TypeScriptReflectionHost implements N
// definition file. This requires default types to be enabled for the host program.
return this.src.program.isSourceFileDefaultLibrary(node.getSourceFile());
}
/**
* In JavaScript, enum declarations are emitted as a regular variable declaration followed by an
* IIFE in which the enum members are assigned.
*
* export var Enum;
* (function (Enum) {
* Enum["a"] = "A";
* Enum["b"] = "B";
* })(Enum || (Enum = {}));
*
* @param declaration A variable declaration that may represent an enum
* @returns An array of enum members if the variable declaration is followed by an IIFE that
* declares the enum members, or null otherwise.
*/
protected resolveEnumMembers(declaration: ts.VariableDeclaration): EnumMember[]|null {
// Initialized variables don't represent enum declarations.
if (declaration.initializer !== undefined) return null;
const variableStmt = declaration.parent.parent;
if (!ts.isVariableStatement(variableStmt)) return null;
const block = variableStmt.parent;
if (!ts.isBlock(block) && !ts.isSourceFile(block)) return null;
const declarationIndex = block.statements.findIndex(statement => statement === variableStmt);
if (declarationIndex === -1 || declarationIndex === block.statements.length - 1) return null;
const subsequentStmt = block.statements[declarationIndex + 1];
if (!ts.isExpressionStatement(subsequentStmt)) return null;
const iife = stripParentheses(subsequentStmt.expression);
if (!ts.isCallExpression(iife) || !isEnumDeclarationIife(iife)) return null;
const fn = stripParentheses(iife.expression);
if (!ts.isFunctionExpression(fn)) return null;
return this.reflectEnumMembers(fn);
}
/**
* Attempts to extract all `EnumMember`s from a function that is according to the JavaScript emit
* format for enums:
*
* function (Enum) {
* Enum["MemberA"] = "a";
* Enum["MemberB"] = "b";
* }
*
* @param fn The function expression that is assumed to contain enum members.
* @returns All enum members if the function is according to the correct syntax, null otherwise.
*/
private reflectEnumMembers(fn: ts.FunctionExpression): EnumMember[]|null {
if (fn.parameters.length !== 1) return null;
const enumName = fn.parameters[0].name;
if (!ts.isIdentifier(enumName)) return null;
const enumMembers: EnumMember[] = [];
for (const statement of fn.body.statements) {
const enumMember = this.reflectEnumMember(enumName, statement);
if (enumMember === null) {
return null;
}
enumMembers.push(enumMember);
}
return enumMembers;
}
/**
* Attempts to extract a single `EnumMember` from a statement in the following syntax:
*
* Enum["MemberA"] = "a";
*
* or, for enum member with numeric values:
*
* Enum[Enum["MemberA"] = 0] = "MemberA";
*
* @param enumName The identifier of the enum that the members should be set on.
* @param statement The statement to inspect.
* @returns An `EnumMember` if the statement is according to the expected syntax, null otherwise.
*/
protected reflectEnumMember(enumName: ts.Identifier, statement: ts.Statement): EnumMember|null {
if (!ts.isExpressionStatement(statement)) return null;
const expression = statement.expression;
// Check for the `Enum[X] = Y;` case.
if (!isEnumAssignment(enumName, expression)) {
return null;
}
const assignment = reflectEnumAssignment(expression);
if (assignment != null) {
return assignment;
}
// Check for the `Enum[Enum[X] = Y] = ...;` case.
const innerExpression = expression.left.argumentExpression;
if (!isEnumAssignment(enumName, innerExpression)) {
return null;
}
return reflectEnumAssignment(innerExpression);
}
}
///////////// Exported Helpers /////////////
/**
* Checks whether the iife has the following call signature:
*
* (Enum || (Enum = {})
*
* Note that the `Enum` identifier is not checked, as it could also be something
* like `exports.Enum`. Instead, only the structure of binary operators is checked.
*
* @param iife The call expression to check.
* @returns true if the iife has a call signature that corresponds with a potential
* enum declaration.
*/
function isEnumDeclarationIife(iife: ts.CallExpression): boolean {
if (iife.arguments.length !== 1) return false;
const arg = iife.arguments[0];
if (!ts.isBinaryExpression(arg) || arg.operatorToken.kind !== ts.SyntaxKind.BarBarToken ||
!ts.isParenthesizedExpression(arg.right)) {
return false;
}
const right = arg.right.expression;
if (!ts.isBinaryExpression(right) || right.operatorToken.kind !== ts.SyntaxKind.EqualsToken) {
return false;
}
if (!ts.isObjectLiteralExpression(right.right) || right.right.properties.length !== 0) {
return false;
}
return true;
}
/**
* An enum member assignment that looks like `Enum[X] = Y;`.
*/
export type EnumMemberAssignment = ts.BinaryExpression&{left: ts.ElementAccessExpression};
/**
* Checks whether the expression looks like an enum member assignment targeting `Enum`:
*
* Enum[X] = Y;
*
* Here, X and Y can be any expression.
*
* @param enumName The identifier of the enum that the members should be set on.
* @param expression The expression that should be checked to conform to the above form.
* @returns true if the expression is of the correct form, false otherwise.
*/
function isEnumAssignment(
enumName: ts.Identifier, expression: ts.Expression): expression is EnumMemberAssignment {
if (!ts.isBinaryExpression(expression) ||
expression.operatorToken.kind !== ts.SyntaxKind.EqualsToken ||
!ts.isElementAccessExpression(expression.left)) {
return false;
}
// Verify that the outer assignment corresponds with the enum declaration.
const enumIdentifier = expression.left.expression;
return ts.isIdentifier(enumIdentifier) && enumIdentifier.text === enumName.text;
}
/**
* Attempts to create an `EnumMember` from an expression that is believed to represent an enum
* assignment.
*
* @param expression The expression that is believed to be an enum assignment.
* @returns An `EnumMember` or null if the expression did not represent an enum member after all.
*/
function reflectEnumAssignment(expression: EnumMemberAssignment): EnumMember|null {
const memberName = expression.left.argumentExpression;
if (!ts.isPropertyName(memberName)) return null;
return {name: memberName, initializer: expression.right};
}
export type ParamInfo = {
decorators: Decorator[]|null,
typeExpression: ts.Expression|null

View File

@ -13,6 +13,7 @@ import {getNameText, getTsHelperFnFromDeclaration, getTsHelperFnFromIdentifier,
import {Esm2015ReflectionHost, getPropertyValueFromSymbol, isAssignment, isAssignmentStatement, ParamInfo} from './esm2015_host';
import {NgccClassSymbol} from './ngcc_host';
import {stripParentheses} from './utils';
/**
@ -849,7 +850,3 @@ function isUndefinedComparison(expression: ts.Expression): expression is ts.Expr
expression.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken &&
ts.isVoidExpression(expression.right) && ts.isIdentifier(expression.left);
}
export function stripParentheses(node: ts.Node): ts.Node {
return ts.isParenthesizedExpression(node) ? node.expression : node;
}

View File

@ -15,7 +15,8 @@ import {BundleProgram} from '../packages/bundle_program';
import {FactoryMap, getTsHelperFnFromIdentifier, stripExtension} from '../utils';
import {ExportDeclaration, ExportStatement, findNamespaceOfIdentifier, findRequireCallReference, isExportStatement, isReexportStatement, isRequireCall, ReexportStatement} from './commonjs_umd_utils';
import {Esm5ReflectionHost, stripParentheses} from './esm5_host';
import {Esm5ReflectionHost} from './esm5_host';
import {stripParentheses} from './utils';
export class UmdReflectionHost extends Esm5ReflectionHost {
protected umdModules =
@ -179,7 +180,10 @@ export class UmdReflectionHost extends Esm5ReflectionHost {
const reexports: ExportDeclaration[] = [];
importedExports.forEach((decl, name) => {
if (decl.node !== null) {
reexports.push({name, declaration: {node: decl.node, known: null, viaModule}});
reexports.push({
name,
declaration: {node: decl.node, known: null, viaModule, identity: decl.identity}
});
} else {
reexports.push(
{name, declaration: {node: null, known: null, expression: decl.expression, viaModule}});
@ -211,7 +215,12 @@ export class UmdReflectionHost extends Esm5ReflectionHost {
// We need to add the `viaModule` because the `getExportsOfModule()` call
// did not know that we were importing the declaration.
return {node: importedFile, known: getTsHelperFnFromIdentifier(id), viaModule: importInfo.from};
return {
node: importedFile,
known: getTsHelperFnFromIdentifier(id),
viaModule: importInfo.from,
identity: null
};
}
private resolveModuleName(moduleName: string, containingFile: ts.SourceFile): ts.SourceFile

View File

@ -0,0 +1,12 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import * as ts from 'typescript';
export function stripParentheses(node: ts.Node): ts.Node {
return ts.isParenthesizedExpression(node) ? node.expression : node;
}

View File

@ -9,7 +9,7 @@ import * as ts from 'typescript';
import {absoluteFrom, getFileSystem, getSourceFileOrError} from '../../../src/ngtsc/file_system';
import {runInEachFileSystem, TestFile} from '../../../src/ngtsc/file_system/testing';
import {ClassMemberKind, CtorParameter, InlineDeclaration, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration, KnownDeclaration, TypeScriptReflectionHost} from '../../../src/ngtsc/reflection';
import {ClassMemberKind, ConcreteDeclaration, CtorParameter, DownleveledEnum, InlineDeclaration, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration, KnownDeclaration, TypeScriptReflectionHost} from '../../../src/ngtsc/reflection';
import {getDeclaration} from '../../../src/ngtsc/testing';
import {loadFakeCore, loadTestFiles} from '../../../test/helpers';
import {CommonJsReflectionHost} from '../../src/host/commonjs_host';
@ -1706,6 +1706,7 @@ exports.ExternalModule = ExternalModule;
known: knownAs,
node: getHelperDeclaration(helperName),
viaModule,
identity: null,
});
};
@ -1740,6 +1741,7 @@ exports.ExternalModule = ExternalModule;
expect(actualDeclaration).not.toBe(null);
expect(actualDeclaration!.node).toBe(expectedDeclarationNode);
expect(actualDeclaration!.viaModule).toBe(null);
expect((actualDeclaration as ConcreteDeclaration).identity).toBe(null);
});
it('should return the correct declaration for an outer alias identifier', () => {
@ -2197,6 +2199,121 @@ exports.ExternalModule = ExternalModule;
['__unknownHelper', null],
]);
});
it('should recognize enum declarations with string values', () => {
const testFile: TestFile = {
name: _('/node_modules/test-package/some/file.js'),
contents: `
var Enum;
(function (Enum) {
Enum["ValueA"] = "1";
Enum["ValueB"] = "2";
})(exports.Enum || (exports.Enum = {}));
var value = Enum;`
};
loadTestFiles([testFile]);
const bundle = makeTestBundleProgram(testFile.name);
const host =
createHost(bundle, new CommonJsReflectionHost(new MockLogger(), false, bundle));
const valueDecl = getDeclaration(
bundle.program, _('/node_modules/test-package/some/file.js'), 'value',
ts.isVariableDeclaration);
const declaration = host.getDeclarationOfIdentifier(
valueDecl.initializer as ts.Identifier) as ConcreteDeclaration;
const enumMembers = (declaration.identity as DownleveledEnum).enumMembers;
expect(declaration.node.parent.parent.getText()).toBe('var Enum;');
expect(enumMembers!.length).toBe(2);
expect(enumMembers![0].name.getText()).toBe('"ValueA"');
expect(enumMembers![0].initializer!.getText()).toBe('"1"');
expect(enumMembers![1].name.getText()).toBe('"ValueB"');
expect(enumMembers![1].initializer!.getText()).toBe('"2"');
});
it('should recognize enum declarations with numeric values', () => {
const testFile: TestFile = {
name: _('/node_modules/test-package/some/file.js'),
contents: `
var Enum;
(function (Enum) {
Enum[Enum["ValueA"] = "1"] = "ValueA";
Enum[Enum["ValueB"] = "2"] = "ValueB";
})(exports.Enum || (exports.Enum = {}));
var value = Enum;`
};
loadTestFiles([testFile]);
const bundle = makeTestBundleProgram(testFile.name);
const host =
createHost(bundle, new CommonJsReflectionHost(new MockLogger(), false, bundle));
const valueDecl = getDeclaration(
bundle.program, _('/node_modules/test-package/some/file.js'), 'value',
ts.isVariableDeclaration);
const declaration = host.getDeclarationOfIdentifier(
valueDecl.initializer as ts.Identifier) as ConcreteDeclaration;
const enumMembers = (declaration.identity as DownleveledEnum).enumMembers;
expect(declaration.node.parent.parent.getText()).toBe('var Enum;');
expect(enumMembers!.length).toBe(2);
expect(enumMembers![0].name.getText()).toBe('"ValueA"');
expect(enumMembers![0].initializer!.getText()).toBe('"1"');
expect(enumMembers![1].name.getText()).toBe('"ValueB"');
expect(enumMembers![1].initializer!.getText()).toBe('"2"');
});
it('should not consider IIFEs that do no assign members to the parameter as an enum declaration',
() => {
const testFile: TestFile = {
name: _('/node_modules/test-package/some/file.js'),
contents: `
var Enum;
(function (E) {
Enum["ValueA"] = "1";
Enum["ValueB"] = "2";
})(exports.Enum || (exports.Enum = {}));
var value = Enum;`
};
loadTestFiles([testFile]);
const bundle = makeTestBundleProgram(testFile.name);
const host =
createHost(bundle, new CommonJsReflectionHost(new MockLogger(), false, bundle));
const valueDecl = getDeclaration(
bundle.program, _('/node_modules/test-package/some/file.js'), 'value',
ts.isVariableDeclaration);
const declaration = host.getDeclarationOfIdentifier(
valueDecl.initializer as ts.Identifier) as ConcreteDeclaration;
expect(declaration.node.parent.parent.getText()).toBe('var Enum;');
expect(declaration.identity).toBe(null);
});
it('should not consider IIFEs without call argument as an enum declaration', () => {
const testFile: TestFile = {
name: _('/node_modules/test-package/some/file.js'),
contents: `
var Enum;
(function (Enum) {
Enum["ValueA"] = "1";
Enum["ValueB"] = "2";
})();
var value = Enum;`
};
loadTestFiles([testFile]);
const bundle = makeTestBundleProgram(testFile.name);
const host =
createHost(bundle, new CommonJsReflectionHost(new MockLogger(), false, bundle));
const valueDecl = getDeclaration(
bundle.program, _('/node_modules/test-package/some/file.js'), 'value',
ts.isVariableDeclaration);
const declaration = host.getDeclarationOfIdentifier(
valueDecl.initializer as ts.Identifier) as ConcreteDeclaration;
expect(declaration.node.parent.parent.getText()).toBe('var Enum;');
expect(declaration.identity).toBe(null);
});
});
describe('getClassSymbol()', () => {

View File

@ -10,7 +10,7 @@ import * as ts from 'typescript';
import {absoluteFrom, getFileSystem, getSourceFileOrError} from '../../../src/ngtsc/file_system';
import {runInEachFileSystem, TestFile} from '../../../src/ngtsc/file_system/testing';
import {ClassMemberKind, CtorParameter, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration, TypeScriptReflectionHost} from '../../../src/ngtsc/reflection';
import {ClassMemberKind, ConcreteDeclaration, CtorParameter, DownleveledEnum, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration, TypeScriptReflectionHost} from '../../../src/ngtsc/reflection';
import {getDeclaration} from '../../../src/ngtsc/testing';
import {loadFakeCore, loadTestFiles} from '../../../test/helpers';
import {DelegatingReflectionHost} from '../../src/host/delegating_host';
@ -1558,6 +1558,7 @@ runInEachFileSystem(() => {
expect(actualDeclaration).not.toBe(null);
expect(actualDeclaration!.node).toBe(classNode);
expect(actualDeclaration!.viaModule).toBe(null);
expect((actualDeclaration as ConcreteDeclaration).identity).toBe(null);
});
it('should return the declaration of an externally defined identifier', () => {
@ -1618,6 +1619,119 @@ runInEachFileSystem(() => {
expect(host.getDeclarationOfIdentifier(aliasedClassIdentifier)!.node)
.toBe(classDeclaration);
});
it('should recognize enum declarations with string values', () => {
const testFile: TestFile = {
name: _('/node_modules/test-package/some/file.js'),
contents: `
export var Enum;
(function (Enum) {
Enum["ValueA"] = "1";
Enum["ValueB"] = "2";
})(Enum || (Enum = {}));
var value = Enum;`
};
loadTestFiles([testFile]);
const bundle = makeTestBundleProgram(testFile.name);
const host = createHost(bundle, new Esm2015ReflectionHost(new MockLogger(), false, bundle));
const valueDecl = getDeclaration(
bundle.program, _('/node_modules/test-package/some/file.js'), 'value',
ts.isVariableDeclaration);
const declaration = host.getDeclarationOfIdentifier(
valueDecl.initializer as ts.Identifier) as ConcreteDeclaration;
expect(declaration.node.parent.parent.getText()).toBe('export var Enum;');
const enumMembers = (declaration.identity as DownleveledEnum).enumMembers;
expect(enumMembers!.length).toBe(2);
expect(enumMembers![0].name.getText()).toBe('"ValueA"');
expect(enumMembers![0].initializer!.getText()).toBe('"1"');
expect(enumMembers![1].name.getText()).toBe('"ValueB"');
expect(enumMembers![1].initializer!.getText()).toBe('"2"');
});
it('should recognize enum declarations with numeric values', () => {
const testFile: TestFile = {
name: _('/node_modules/test-package/some/file.js'),
contents: `
export var Enum;
(function (Enum) {
Enum[Enum["ValueA"] = "1"] = "ValueA";
Enum[Enum["ValueB"] = "2"] = "ValueB";
})(Enum || (Enum = {}));
var value = Enum;`
};
loadTestFiles([testFile]);
const bundle = makeTestBundleProgram(testFile.name);
const host = createHost(bundle, new Esm2015ReflectionHost(new MockLogger(), false, bundle));
const valueDecl = getDeclaration(
bundle.program, _('/node_modules/test-package/some/file.js'), 'value',
ts.isVariableDeclaration);
const declaration = host.getDeclarationOfIdentifier(
valueDecl.initializer as ts.Identifier) as ConcreteDeclaration;
const enumMembers = (declaration.identity as DownleveledEnum).enumMembers;
expect(declaration.node.parent.parent.getText()).toBe('export var Enum;');
expect(enumMembers!.length).toBe(2);
expect(enumMembers![0].name.getText()).toBe('"ValueA"');
expect(enumMembers![0].initializer!.getText()).toBe('"1"');
expect(enumMembers![1].name.getText()).toBe('"ValueB"');
expect(enumMembers![1].initializer!.getText()).toBe('"2"');
});
it('should not consider IIFEs that do no assign members to the parameter as an enum declaration',
() => {
const testFile: TestFile = {
name: _('/node_modules/test-package/some/file.js'),
contents: `
export var Enum;
(function (E) {
Enum["ValueA"] = "1";
Enum["ValueB"] = "2";
})(Enum || (Enum = {}));
var value = Enum;`
};
loadTestFiles([testFile]);
const bundle = makeTestBundleProgram(testFile.name);
const host =
createHost(bundle, new Esm2015ReflectionHost(new MockLogger(), false, bundle));
const valueDecl = getDeclaration(
bundle.program, _('/node_modules/test-package/some/file.js'), 'value',
ts.isVariableDeclaration);
const declaration = host.getDeclarationOfIdentifier(
valueDecl.initializer as ts.Identifier) as ConcreteDeclaration;
expect(declaration.node.parent.parent.getText()).toBe('export var Enum;');
expect(declaration.identity).toBe(null);
});
it('should not consider IIFEs without call argument as an enum declaration', () => {
const testFile: TestFile = {
name: _('/node_modules/test-package/some/file.js'),
contents: `
export var Enum;
(function (Enum) {
Enum["ValueA"] = "1";
Enum["ValueB"] = "2";
})();
var value = Enum;`
};
loadTestFiles([testFile]);
const bundle = makeTestBundleProgram(testFile.name);
const host = createHost(bundle, new Esm2015ReflectionHost(new MockLogger(), false, bundle));
const valueDecl = getDeclaration(
bundle.program, _('/node_modules/test-package/some/file.js'), 'value',
ts.isVariableDeclaration);
const declaration = host.getDeclarationOfIdentifier(
valueDecl.initializer as ts.Identifier) as ConcreteDeclaration;
expect(declaration.node.parent.parent.getText()).toBe('export var Enum;');
expect(declaration.identity).toBe(null);
});
});
describe('getExportsOfModule()', () => {

View File

@ -10,7 +10,7 @@ import * as ts from 'typescript';
import {absoluteFrom, getFileSystem, getSourceFileOrError} from '../../../src/ngtsc/file_system';
import {runInEachFileSystem, TestFile} from '../../../src/ngtsc/file_system/testing';
import {ClassMemberKind, CtorParameter, Decorator, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration, KnownDeclaration, TypeScriptReflectionHost} from '../../../src/ngtsc/reflection';
import {ClassMemberKind, ConcreteDeclaration, CtorParameter, Decorator, DownleveledEnum, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration, KnownDeclaration, TypeScriptReflectionHost} from '../../../src/ngtsc/reflection';
import {getDeclaration} from '../../../src/ngtsc/testing';
import {loadFakeCore, loadTestFiles} from '../../../test/helpers';
import {DelegatingReflectionHost} from '../../src/host/delegating_host';
@ -1754,6 +1754,7 @@ runInEachFileSystem(() => {
known: knownAs,
node: getHelperDeclaration(helperName),
viaModule,
identity: null,
});
};
@ -1786,6 +1787,7 @@ runInEachFileSystem(() => {
expect(actualDeclaration).not.toBe(null);
expect(actualDeclaration!.node).toBe(expectedDeclarationNode);
expect(actualDeclaration!.viaModule).toBe(null);
expect((actualDeclaration as ConcreteDeclaration).identity).toBe(null);
});
it('should return the declaration of an externally defined identifier', () => {
@ -2186,6 +2188,117 @@ runInEachFileSystem(() => {
testForHelper('b', '__spread$2', KnownDeclaration.TsHelperSpread);
testForHelper('c', '__spreadArrays$3', KnownDeclaration.TsHelperSpreadArrays);
});
it('should recognize enum declarations with string values', () => {
const testFile: TestFile = {
name: _('/node_modules/test-package/some/file.js'),
contents: `
export var Enum;
(function (Enum) {
Enum["ValueA"] = "1";
Enum["ValueB"] = "2";
})(Enum || (Enum = {}));
var value = Enum;`
};
loadTestFiles([testFile]);
const bundle = makeTestBundleProgram(testFile.name);
const host = createHost(bundle, new Esm5ReflectionHost(new MockLogger(), false, bundle));
const valueDecl = getDeclaration(
bundle.program, _('/node_modules/test-package/some/file.js'), 'value',
ts.isVariableDeclaration);
const declaration = host.getDeclarationOfIdentifier(
valueDecl.initializer as ts.Identifier) as ConcreteDeclaration;
const enumMembers = (declaration.identity as DownleveledEnum).enumMembers;
expect(declaration.node.parent.parent.getText()).toBe('export var Enum;');
expect(enumMembers!.length).toBe(2);
expect(enumMembers![0].name.getText()).toBe('"ValueA"');
expect(enumMembers![0].initializer!.getText()).toBe('"1"');
expect(enumMembers![1].name.getText()).toBe('"ValueB"');
expect(enumMembers![1].initializer!.getText()).toBe('"2"');
});
it('should recognize enum declarations with numeric values', () => {
const testFile: TestFile = {
name: _('/node_modules/test-package/some/file.js'),
contents: `
export var Enum;
(function (Enum) {
Enum[Enum["ValueA"] = "1"] = "ValueA";
Enum[Enum["ValueB"] = "2"] = "ValueB";
})(Enum || (Enum = {}));
var value = Enum;`
};
loadTestFiles([testFile]);
const bundle = makeTestBundleProgram(testFile.name);
const host = createHost(bundle, new Esm5ReflectionHost(new MockLogger(), false, bundle));
const valueDecl = getDeclaration(
bundle.program, _('/node_modules/test-package/some/file.js'), 'value',
ts.isVariableDeclaration);
const declaration = host.getDeclarationOfIdentifier(
valueDecl.initializer as ts.Identifier) as ConcreteDeclaration;
const enumMembers = (declaration.identity as DownleveledEnum).enumMembers;
expect(declaration.node.parent.parent.getText()).toBe('export var Enum;');
expect(enumMembers!.length).toBe(2);
expect(enumMembers![0].name.getText()).toBe('"ValueA"');
expect(enumMembers![0].initializer!.getText()).toBe('"1"');
expect(enumMembers![1].name.getText()).toBe('"ValueB"');
expect(enumMembers![1].initializer!.getText()).toBe('"2"');
});
it('should not consider IIFEs that do no assign members to the parameter as an enum declaration',
() => {
const testFile: TestFile = {
name: _('/node_modules/test-package/some/file.js'),
contents: `
export var Enum;
(function (E) {
Enum["ValueA"] = "1";
Enum["ValueB"] = "2";
})(Enum || (Enum = {}));
var value = Enum;`
};
loadTestFiles([testFile]);
const bundle = makeTestBundleProgram(testFile.name);
const host = createHost(bundle, new Esm5ReflectionHost(new MockLogger(), false, bundle));
const valueDecl = getDeclaration(
bundle.program, _('/node_modules/test-package/some/file.js'), 'value',
ts.isVariableDeclaration);
const declaration = host.getDeclarationOfIdentifier(
valueDecl.initializer as ts.Identifier) as ConcreteDeclaration;
expect(declaration.node.parent.parent.getText()).toBe('export var Enum;');
expect(declaration.identity).toBe(null);
});
it('should not consider IIFEs without call argument as an enum declaration', () => {
const testFile: TestFile = {
name: _('/node_modules/test-package/some/file.js'),
contents: `
export var Enum;
(function (Enum) {
Enum["ValueA"] = "1";
Enum["ValueB"] = "2";
})();
var value = Enum;`
};
loadTestFiles([testFile]);
const bundle = makeTestBundleProgram(testFile.name);
const host = createHost(bundle, new Esm5ReflectionHost(new MockLogger(), false, bundle));
const valueDecl = getDeclaration(
bundle.program, _('/node_modules/test-package/some/file.js'), 'value',
ts.isVariableDeclaration);
const declaration = host.getDeclarationOfIdentifier(
valueDecl.initializer as ts.Identifier) as ConcreteDeclaration;
expect(declaration.node.parent.parent.getText()).toBe('export var Enum;');
expect(declaration.identity).toBe(null);
});
});
describe('getExportsOfModule()', () => {

View File

@ -10,7 +10,7 @@ import * as ts from 'typescript';
import {absoluteFrom, getFileSystem, getSourceFileOrError} from '../../../src/ngtsc/file_system';
import {runInEachFileSystem, TestFile} from '../../../src/ngtsc/file_system/testing';
import {ClassMemberKind, CtorParameter, Import, InlineDeclaration, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration, KnownDeclaration, TypeScriptReflectionHost} from '../../../src/ngtsc/reflection';
import {ClassMemberKind, ConcreteDeclaration, CtorParameter, DownleveledEnum, Import, InlineDeclaration, isNamedClassDeclaration, isNamedFunctionDeclaration, isNamedVariableDeclaration, KnownDeclaration, TypeScriptReflectionHost} from '../../../src/ngtsc/reflection';
import {getDeclaration} from '../../../src/ngtsc/testing';
import {loadFakeCore, loadTestFiles} from '../../../test/helpers';
import {DelegatingReflectionHost} from '../../src/host/delegating_host';
@ -1833,6 +1833,7 @@ runInEachFileSystem(() => {
known: knownAs,
node: getHelperDeclaration(factoryFn, helperName),
viaModule,
identity: null,
});
};
@ -1874,6 +1875,7 @@ runInEachFileSystem(() => {
expect(actualDeclaration).not.toBe(null);
expect(actualDeclaration!.node).toBe(expectedDeclarationNode);
expect(actualDeclaration!.viaModule).toBe(null);
expect((actualDeclaration as ConcreteDeclaration).identity).toBe(null);
});
it('should return the correct declaration for an outer alias identifier', () => {
@ -2179,6 +2181,147 @@ runInEachFileSystem(() => {
testForHelper('b', '__spread$2', KnownDeclaration.TsHelperSpread);
testForHelper('c', '__spreadArrays$3', KnownDeclaration.TsHelperSpreadArrays);
});
it('should recognize enum declarations with string values', () => {
const testFile: TestFile = {
name: _('/node_modules/test-package/some/file.js'),
contents: `
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('@angular/core')) :
typeof define === 'function' && define.amd ? define('some_directive', ['exports', '@angular/core'], factory) :
(factory(global.some_directive,global.ng.core));
}(this, (function (exports,core) { 'use strict';
var Enum;
(function (Enum) {
Enum["ValueA"] = "1";
Enum["ValueB"] = "2";
})(exports.Enum || (exports.Enum = {}));
var value = Enum;
})));
`
};
loadTestFiles([testFile]);
const bundle = makeTestBundleProgram(testFile.name);
const host = createHost(bundle, new UmdReflectionHost(new MockLogger(), false, bundle));
const {factoryFn} = parseStatementForUmdModule(
getSourceFileOrError(bundle.program, _('/node_modules/test-package/some/file.js'))
.statements[0])!;
const valueDecl = getVariableDeclaration(factoryFn, 'value');
const declaration = host.getDeclarationOfIdentifier(
valueDecl.initializer as ts.Identifier) as ConcreteDeclaration;
const enumMembers = (declaration.identity as DownleveledEnum).enumMembers;
expect(declaration.node.parent.parent.getText()).toBe('var Enum;');
expect(enumMembers!.length).toBe(2);
expect(enumMembers![0].name.getText()).toBe('"ValueA"');
expect(enumMembers![0].initializer!.getText()).toBe('"1"');
expect(enumMembers![1].name.getText()).toBe('"ValueB"');
expect(enumMembers![1].initializer!.getText()).toBe('"2"');
});
it('should recognize enum declarations with numeric values', () => {
const testFile: TestFile = {
name: _('/node_modules/test-package/some/file.js'),
contents: `
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('@angular/core')) :
typeof define === 'function' && define.amd ? define('some_directive', ['exports', '@angular/core'], factory) :
(factory(global.some_directive,global.ng.core));
}(this, (function (exports,core) { 'use strict';
var Enum;
(function (Enum) {
Enum[Enum["ValueA"] = "1"] = "ValueA";
Enum[Enum["ValueB"] = "2"] = "ValueB";
})(exports.Enum || (exports.Enum = {}));
var value = Enum;
})));
`
};
loadTestFiles([testFile]);
const bundle = makeTestBundleProgram(testFile.name);
const host = createHost(bundle, new UmdReflectionHost(new MockLogger(), false, bundle));
const {factoryFn} = parseStatementForUmdModule(
getSourceFileOrError(bundle.program, _('/node_modules/test-package/some/file.js'))
.statements[0])!;
const valueDecl = getVariableDeclaration(factoryFn, 'value');
const declaration = host.getDeclarationOfIdentifier(
valueDecl.initializer as ts.Identifier) as ConcreteDeclaration;
const enumMembers = (declaration.identity as DownleveledEnum).enumMembers;
expect(declaration.node.parent.parent.getText()).toBe('var Enum;');
expect(enumMembers!.length).toBe(2);
expect(enumMembers![0].name.getText()).toBe('"ValueA"');
expect(enumMembers![0].initializer!.getText()).toBe('"1"');
expect(enumMembers![1].name.getText()).toBe('"ValueB"');
expect(enumMembers![1].initializer!.getText()).toBe('"2"');
});
it('should not consider IIFEs that do no assign members to the parameter as an enum declaration',
() => {
const testFile: TestFile = {
name: _('/node_modules/test-package/some/file.js'),
contents: `
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('@angular/core')) :
typeof define === 'function' && define.amd ? define('some_directive', ['exports', '@angular/core'], factory) :
(factory(global.some_directive,global.ng.core));
}(this, (function (exports,core) { 'use strict';
var Enum;
(function (E) {
Enum["ValueA"] = "1"];
Enum["ValueB"] = "2"];
})(exports.Enum || (exports.Enum = {}));
var value = Enum;
})));
`
};
loadTestFiles([testFile]);
const bundle = makeTestBundleProgram(testFile.name);
const host = createHost(bundle, new UmdReflectionHost(new MockLogger(), false, bundle));
const valueDecl = getDeclaration(
bundle.program, _('/node_modules/test-package/some/file.js'), 'value',
ts.isVariableDeclaration);
const declaration = host.getDeclarationOfIdentifier(
valueDecl.initializer as ts.Identifier) as ConcreteDeclaration;
expect(declaration.node.parent.parent.getText()).toBe('var Enum;');
expect(declaration.identity).toBe(null);
});
it('should not consider IIFEs without call argument as an enum declaration', () => {
const testFile: TestFile = {
name: _('/node_modules/test-package/some/file.js'),
contents: `
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('@angular/core')) :
typeof define === 'function' && define.amd ? define('some_directive', ['exports', '@angular/core'], factory) :
(factory(global.some_directive,global.ng.core));
}(this, (function (exports,core) { 'use strict';
var Enum;
(function (Enum) {
Enum["ValueA"] = "1"];
Enum["ValueB"] = "2"];
})();
var value = Enum;
})));
`
};
loadTestFiles([testFile]);
const bundle = makeTestBundleProgram(testFile.name);
const host = createHost(bundle, new UmdReflectionHost(new MockLogger(), false, bundle));
const valueDecl = getDeclaration(
bundle.program, _('/node_modules/test-package/some/file.js'), 'value',
ts.isVariableDeclaration);
const declaration = host.getDeclarationOfIdentifier(
valueDecl.initializer as ts.Identifier) as ConcreteDeclaration;
expect(declaration.node.parent.parent.getText()).toBe('var Enum;');
expect(declaration.identity).toBe(null);
});
});
describe('getExportsOfModule()', () => {

View File

@ -317,6 +317,46 @@ runInEachFileSystem(() => {
expect(jsContents).toContain('imports: [ɵngcc1.MyOtherModule]');
});
it('should be able to resolve enum values', () => {
compileIntoApf('test-package', {
'/index.ts': `
import {Component, NgModule} from '@angular/core';
export enum StringEnum {
ValueA = "a",
ValueB = "b",
}
export enum NumericEnum {
Value3 = 3,
Value4,
}
@Component({
template: \`\${StringEnum.ValueA} - \${StringEnum.ValueB} - \${NumericEnum.Value3} - \${NumericEnum.Value4}\`,
})
export class FooCmp {}
@NgModule({
declarations: [FooCmp],
})
export class FooModule {}
`,
});
mainNgcc({
basePath: '/node_modules',
targetEntryPointPath: 'test-package',
propertiesToConsider: ['esm2015', 'esm5'],
});
const es2015Contents = fs.readFile(_(`/node_modules/test-package/esm2015/src/index.js`));
expect(es2015Contents).toContain('ɵngcc0.ɵɵtext(0, "a - b - 3 - 4")');
const es5Contents = fs.readFile(_(`/node_modules/test-package/esm5/src/index.js`));
expect(es5Contents).toContain('ɵngcc0.ɵɵtext(0, "a - b - 3 - 4")');
});
it('should add ɵfac but not duplicate ɵprov properties on injectables', () => {
compileIntoFlatEs5Package('test-package', {
'/index.ts': `

View File

@ -11,7 +11,7 @@ import * as ts from 'typescript';
import {Reference} from '../../imports';
import {OwningModule} from '../../imports/src/references';
import {DependencyTracker} from '../../incremental/api';
import {Declaration, InlineDeclaration, ReflectionHost} from '../../reflection';
import {ConcreteDeclaration, Declaration, EnumMember, InlineDeclaration, ReflectionHost, SpecialDeclarationKind} from '../../reflection';
import {isDeclaration} from '../../util/src/typescript';
import {ArrayConcatBuiltinFn, ArraySliceBuiltinFn} from './builtin';
@ -225,6 +225,10 @@ export class StaticInterpreter {
}
if (decl.known !== null) {
return resolveKnownDeclaration(decl.known);
} else if (
isConcreteDeclaration(decl) && decl.identity !== null &&
decl.identity.kind === SpecialDeclarationKind.DownleveledEnum) {
return this.getResolvedEnum(decl.node, decl.identity.enumMembers, context);
}
const declContext = {...context, ...joinModuleContext(context, node, decl)};
// The identifier's declaration is either concrete (a ts.Declaration exists for it) or inline
@ -279,7 +283,7 @@ export class StaticInterpreter {
}
private visitEnumDeclaration(node: ts.EnumDeclaration, context: Context): ResolvedValue {
const enumRef = this.getReference(node, context) as Reference<ts.EnumDeclaration>;
const enumRef = this.getReference(node, context);
const map = new Map<string, EnumValue>();
node.members.forEach(member => {
const name = this.stringNameFromPropertyName(member.name, context);
@ -572,7 +576,21 @@ export class StaticInterpreter {
}
}
private getReference(node: ts.Declaration, context: Context): Reference {
private getResolvedEnum(node: ts.Declaration, enumMembers: EnumMember[], context: Context):
ResolvedValue {
const enumRef = this.getReference(node, context);
const map = new Map<string, EnumValue>();
enumMembers.forEach(member => {
const name = this.stringNameFromPropertyName(member.name, context);
if (name !== undefined) {
const resolved = this.visit(member.initializer, context);
map.set(name, new EnumValue(enumRef, name, resolved));
}
});
return map;
}
private getReference<T extends ts.Declaration>(node: T, context: Context): Reference<T> {
return new Reference(node, owningModule(context));
}
}
@ -638,3 +656,11 @@ function owningModule(context: Context, override: OwningModule|null = null): Own
return null;
}
}
/**
* Helper type guard to workaround a narrowing limitation in g3, where testing for
* `decl.node !== null` would not narrow `decl` to be of type `ConcreteDeclaration`.
*/
function isConcreteDeclaration(decl: Declaration): decl is ConcreteDeclaration {
return decl.node !== null;
}

View File

@ -73,7 +73,7 @@ export class ResolvedModule {
*/
export class EnumValue {
constructor(
readonly enumRef: Reference<ts.EnumDeclaration>, readonly name: string,
readonly enumRef: Reference<ts.Declaration>, readonly name: string,
readonly resolved: ResolvedValue) {}
}

View File

@ -11,7 +11,7 @@ import {absoluteFrom, getSourceFileOrError} from '../../file_system';
import {runInEachFileSystem} from '../../file_system/testing';
import {Reference} from '../../imports';
import {DependencyTracker} from '../../incremental/api';
import {Declaration, KnownDeclaration, TypeScriptReflectionHost} from '../../reflection';
import {Declaration, KnownDeclaration, SpecialDeclarationKind, TypeScriptReflectionHost} from '../../reflection';
import {getDeclaration, makeProgram} from '../../testing';
import {DynamicValue} from '../src/dynamic';
import {PartialEvaluator} from '../src/interface';
@ -488,10 +488,23 @@ runInEachFileSystem(() => {
if (!(result instanceof EnumValue)) {
return fail(`result is not an EnumValue`);
}
expect(result.enumRef.node.name.text).toBe('Foo');
expect((result.enumRef.node as ts.EnumDeclaration).name.text).toBe('Foo');
expect(result.name).toBe('B');
});
it('enum resolution works when recognized in reflection host', () => {
const {checker, expression} = makeExpression('var Foo;', 'Foo.ValueB');
const reflectionHost = new DownleveledEnumReflectionHost(checker);
const evaluator = new PartialEvaluator(reflectionHost, checker, null);
const result = evaluator.evaluate(expression);
if (!(result instanceof EnumValue)) {
return fail(`result is not an EnumValue`);
}
expect(result.enumRef.node.parent.parent.getText()).toBe('var Foo;');
expect(result.name).toBe('ValueB');
expect(result.resolved).toBe('b');
});
it('variable declaration resolution works', () => {
const value = evaluate(`import {value} from './decl';`, 'value', [
{name: _('/decl.d.ts'), contents: 'export declare let value: number;'},
@ -843,6 +856,20 @@ runInEachFileSystem(() => {
});
});
class DownleveledEnumReflectionHost extends TypeScriptReflectionHost {
getDeclarationOfIdentifier(id: ts.Identifier): Declaration|null {
const declaration = super.getDeclarationOfIdentifier(id);
if (declaration !== null && declaration.node !== null) {
const enumMembers = [
{name: ts.createStringLiteral('ValueA'), initializer: ts.createStringLiteral('a')},
{name: ts.createStringLiteral('ValueB'), initializer: ts.createStringLiteral('b')},
];
declaration.identity = {kind: SpecialDeclarationKind.DownleveledEnum, enumMembers};
}
return declaration;
}
}
/**
* Customizes the resolution of module exports and identifier declarations to recognize known
* helper functions from `tslib`. Such functions are not handled specially in the default
@ -872,6 +899,7 @@ runInEachFileSystem(() => {
known: tsHelperFn,
node: id,
viaModule: null,
identity: null,
};
}

View File

@ -404,6 +404,22 @@ export interface Import {
from: string;
}
/**
* A single enum member extracted from JavaScript when no `ts.EnumDeclaration` is available.
*/
export interface EnumMember {
/**
* The name of the enum member.
*/
name: ts.PropertyName;
/**
* The initializer expression of the enum member. Unlike in TypeScript, this is always available
* in emitted JavaScript.
*/
initializer: ts.Expression;
}
/**
* Base type for all `Declaration`s.
*/
@ -434,6 +450,28 @@ export interface BaseDeclaration<T extends ts.Declaration = ts.Declaration> {
export interface ConcreteDeclaration<T extends ts.Declaration = ts.Declaration> extends
BaseDeclaration<T> {
node: T;
/**
* Optionally represents a special identity of the declaration, or `null` if the declaration
* does not have a special identity.
*/
identity: SpecialDeclarationIdentity|null;
}
export type SpecialDeclarationIdentity = DownleveledEnum;
export const enum SpecialDeclarationKind {
DownleveledEnum,
}
/**
* A special declaration identity that represents an enum. This is used in downleveled forms where
* a `ts.EnumDeclaration` is emitted in an alternative form, e.g. an IIFE call that declares all
* members.
*/
export interface DownleveledEnum {
kind: SpecialDeclarationKind.DownleveledEnum;
enumMembers: EnumMember[];
}
/**

View File

@ -315,12 +315,14 @@ export class TypeScriptReflectionHost implements ReflectionHost {
node: symbol.valueDeclaration,
known: null,
viaModule,
identity: null,
};
} else if (symbol.declarations !== undefined && symbol.declarations.length > 0) {
return {
node: symbol.declarations[0],
known: null,
viaModule,
identity: null,
};
} else {
return null;

View File

@ -363,6 +363,7 @@ runInEachFileSystem(() => {
node: targetDecl,
known: null,
viaModule: 'absolute',
identity: null,
});
});
@ -393,6 +394,7 @@ runInEachFileSystem(() => {
node: targetDecl,
known: null,
viaModule: 'absolute',
identity: null,
});
});
});