Skip to content

Commit

Permalink
feat(compiler): Stencil decorator import aliasing (#5161)
Browse files Browse the repository at this point in the history
* create map of original imports to their aliased import

this commit creates a map of original import names to aliased import names. This will be used to get the aliased name of a decorator when parsing Stencil decorators to static component metadata

* change `importAliasMap` to class

This commit creates an `ImportAliasMap` class that extends the `Map` class. This makes it easy to instantiate, reference the underlying type, and clearly override the default getter

* pass aliased name to decorator parsers

The commit passes the aliased import name from the `ImportAliasMap` to each respective decorator transformer

Fixes: #3137

STENCIL-263

* add e2e tests for some aliased imports

* check namedBindings exist

* add rest of decorators to test
  • Loading branch information
tanner-reits committed Dec 13, 2023
1 parent 3feee7c commit 97dcb45
Show file tree
Hide file tree
Showing 15 changed files with 302 additions and 44 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,17 @@ import { isDecoratorNamed } from './decorator-utils';
* @param newMembers an out param for new class members
* @param typeChecker a TypeScript typechecker, needed for resolving the prop
* declaration name
* @param decoratorName the name of the decorator to look for
*/
export const attachInternalsDecoratorsToStatic = (
diagnostics: d.Diagnostic[],
decoratedMembers: ts.ClassElement[],
newMembers: ts.ClassElement[],
typeChecker: ts.TypeChecker,
decoratorName: string,
) => {
const attachInternalsMembers = decoratedMembers.filter(ts.isPropertyDeclaration).filter((prop) => {
return !!retrieveTsDecorators(prop)?.find(isDecoratorNamed('AttachInternals'));
return !!retrieveTsDecorators(prop)?.find(isDecoratorNamed(decoratorName));
});

// no decorated fields, return!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from './decorators-constants';
import { elementDecoratorsToStatic } from './element-decorator';
import { eventDecoratorsToStatic } from './event-decorator';
import { ImportAliasMap } from './import-alias-map';
import { listenDecoratorsToStatic } from './listen-decorator';
import { methodDecoratorsToStatic, validateMethods } from './method-decorator';
import { propDecoratorsToStatic } from './prop-decorator';
Expand Down Expand Up @@ -48,14 +49,16 @@ export const convertDecoratorsToStatic = (
program: ts.Program,
): ts.TransformerFactory<ts.SourceFile> => {
return (transformCtx) => {
let sourceFile: ts.SourceFile;
const visit = (node: ts.Node): ts.VisitResult<ts.Node> => {
if (ts.isClassDeclaration(node)) {
return visitClassDeclaration(config, diagnostics, typeChecker, program, node);
return visitClassDeclaration(config, diagnostics, typeChecker, program, node, sourceFile);
}
return ts.visitEachChild(node, visit, transformCtx);
};

return (tsSourceFile) => {
sourceFile = tsSourceFile;
return ts.visitEachChild(tsSourceFile, visit, transformCtx);
};
};
Expand Down Expand Up @@ -83,6 +86,7 @@ export const convertDecoratorsToStatic = (
* @param typeChecker a TypeScript typechecker instance
* @param program a {@link ts.Program} object
* @param classNode the node currently being visited
* @param sourceFile the source file containing the class node
* @returns a class node, possibly updated with new static values
*/
const visitClassDeclaration = (
Expand All @@ -91,8 +95,11 @@ const visitClassDeclaration = (
typeChecker: ts.TypeChecker,
program: ts.Program,
classNode: ts.ClassDeclaration,
sourceFile: ts.SourceFile,
): ts.ClassDeclaration => {
const componentDecorator = retrieveTsDecorators(classNode)?.find(isDecoratorNamed('Component'));
const importAliasMap = new ImportAliasMap(sourceFile);

const componentDecorator = retrieveTsDecorators(classNode)?.find(isDecoratorNamed(importAliasMap.get('Component')));
if (!componentDecorator) {
return classNode;
}
Expand All @@ -103,17 +110,31 @@ const visitClassDeclaration = (
// create an array of all class members which are _not_ methods decorated
// with a Stencil decorator. We do this here because we'll implement the
// behavior specified for those decorated methods later on.
const filteredMethodsAndFields = removeStencilMethodDecorators(Array.from(classMembers), diagnostics);
const filteredMethodsAndFields = removeStencilMethodDecorators(Array.from(classMembers), diagnostics, importAliasMap);

// parser component decorator (Component)
// parse component decorator
componentDecoratorToStatic(config, typeChecker, diagnostics, classNode, filteredMethodsAndFields, componentDecorator);

// stores a reference to fields that should be watched for changes
// parse member decorators (Prop, State, Listen, Event, Method, Element and Watch)
if (decoratedMembers.length > 0) {
propDecoratorsToStatic(diagnostics, decoratedMembers, typeChecker, program, filteredMethodsAndFields);
stateDecoratorsToStatic(decoratedMembers, filteredMethodsAndFields, typeChecker);
eventDecoratorsToStatic(diagnostics, decoratedMembers, typeChecker, program, filteredMethodsAndFields);
propDecoratorsToStatic(
diagnostics,
decoratedMembers,
typeChecker,
program,
filteredMethodsAndFields,
importAliasMap.get('Prop'),
);
stateDecoratorsToStatic(decoratedMembers, filteredMethodsAndFields, typeChecker, importAliasMap.get('State'));
eventDecoratorsToStatic(
diagnostics,
decoratedMembers,
typeChecker,
program,
filteredMethodsAndFields,
importAliasMap.get('Event'),
);
methodDecoratorsToStatic(
config,
diagnostics,
Expand All @@ -122,25 +143,41 @@ const visitClassDeclaration = (
typeChecker,
program,
filteredMethodsAndFields,
importAliasMap.get('Method'),
);
elementDecoratorsToStatic(diagnostics, decoratedMembers, filteredMethodsAndFields, importAliasMap.get('Element'));
watchDecoratorsToStatic(typeChecker, decoratedMembers, filteredMethodsAndFields, importAliasMap.get('Watch'));
listenDecoratorsToStatic(
diagnostics,
typeChecker,
decoratedMembers,
filteredMethodsAndFields,
importAliasMap.get('Listen'),
);
attachInternalsDecoratorsToStatic(
diagnostics,
decoratedMembers,
filteredMethodsAndFields,
typeChecker,
importAliasMap.get('AttachInternals'),
);
elementDecoratorsToStatic(diagnostics, decoratedMembers, typeChecker, filteredMethodsAndFields);
watchDecoratorsToStatic(typeChecker, decoratedMembers, filteredMethodsAndFields);
listenDecoratorsToStatic(diagnostics, typeChecker, decoratedMembers, filteredMethodsAndFields);
attachInternalsDecoratorsToStatic(diagnostics, decoratedMembers, filteredMethodsAndFields, typeChecker);
}

// We call the `handleClassFields` method which handles transforming any
// class fields, removing them from the class and adding statements to the
// class' constructor which instantiate them there instead.
const updatedClassFields = handleClassFields(classNode, filteredMethodsAndFields, typeChecker);
const updatedClassFields = handleClassFields(classNode, filteredMethodsAndFields, typeChecker, importAliasMap);

validateMethods(diagnostics, classMembers);

const currentDecorators = retrieveTsDecorators(classNode);
return ts.factory.updateClassDeclaration(
classNode,
[
...(filterDecorators(currentDecorators, CLASS_DECORATORS_TO_REMOVE) ?? []),
...(filterDecorators(
currentDecorators,
CLASS_DECORATORS_TO_REMOVE.map((decorator) => importAliasMap.get(decorator)),
) ?? []),
...(retrieveTsModifiers(classNode) ?? []),
],
classNode.name,
Expand All @@ -163,16 +200,21 @@ const visitClassDeclaration = (
* @param classMembers a list of ClassElement AST nodes
* @param diagnostics a collection of compiler diagnostics, to which an error
* may be added
* @param importAliasMap a map of Stencil decorator names to their import names
* @returns a new list of the same ClassElement nodes, with any nodes which have
* Stencil-specific decorators modified to remove them
*/
const removeStencilMethodDecorators = (
classMembers: ts.ClassElement[],
diagnostics: d.Diagnostic[],
importAliasMap: ImportAliasMap,
): ts.ClassElement[] => {
return classMembers.map((member) => {
const currentDecorators = retrieveTsDecorators(member);
const newDecorators = filterDecorators(currentDecorators, MEMBER_DECORATORS_TO_REMOVE);
const newDecorators = filterDecorators(
currentDecorators,
MEMBER_DECORATORS_TO_REMOVE.map((decorator) => importAliasMap.get(decorator)),
);

if (currentDecorators !== newDecorators) {
if (ts.isMethodDeclaration(member)) {
Expand All @@ -188,7 +230,7 @@ const removeStencilMethodDecorators = (
member.body,
);
} else if (ts.isPropertyDeclaration(member)) {
if (shouldInitializeInConstructor(member)) {
if (shouldInitializeInConstructor(member, importAliasMap)) {
// if the current class member is decorated with either 'State' or
// 'Prop' we need to modify the property declaration to transform it
// from a class field but we handle this in the `handleClassFields`
Expand Down Expand Up @@ -348,18 +390,20 @@ export const filterDecorators = (
* @param classNode a TypeScript AST node for a Stencil component class
* @param classMembers the class members that we need to update
* @param typeChecker a reference to the {@link ts.TypeChecker}
* @param importAliasMap a map of Stencil decorator names to their import names
* @returns a list of updated class elements which can be inserted into the class
*/
function handleClassFields(
classNode: ts.ClassDeclaration,
classMembers: ts.ClassElement[],
typeChecker: ts.TypeChecker,
importAliasMap: ImportAliasMap,
): ts.ClassElement[] {
const statements: ts.ExpressionStatement[] = [];
const updatedClassMembers: ts.ClassElement[] = [];

for (const member of classMembers) {
if (shouldInitializeInConstructor(member) && ts.isPropertyDeclaration(member)) {
if (shouldInitializeInConstructor(member, importAliasMap) && ts.isPropertyDeclaration(member)) {
const memberName = tsPropDeclNameAsString(member, typeChecker);

// this is a class field that we'll need to handle, so lets push a statement for
Expand Down Expand Up @@ -401,15 +445,19 @@ function handleClassFields(
* details.
*
* @param member the member to check
* @param importAliasMap a map of Stencil decorator names to their import names
* @returns whether this should be rewritten or not
*/
const shouldInitializeInConstructor = (member: ts.ClassElement): boolean => {
const shouldInitializeInConstructor = (member: ts.ClassElement, importAliasMap: ImportAliasMap): boolean => {
const currentDecorators = retrieveTsDecorators(member);
if (currentDecorators === undefined) {
// decorators have already been removed from this element, indicating that
// we don't need to do anything
return false;
}
const filteredDecorators = filterDecorators(currentDecorators, CONSTRUCTOR_DEFINED_MEMBER_DECORATORS);
const filteredDecorators = filterDecorators(
currentDecorators,
CONSTRUCTOR_DEFINED_MEMBER_DECORATORS.map((decorator) => importAliasMap.get(decorator)),
);
return currentDecorators !== filteredDecorators;
};
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import ts from 'typescript';

import { objectLiteralToObjectMap } from '../transform-utils';
import type { StencilDecorator } from './decorators-constants';

export const getDecoratorParameters: GetDecoratorParameters = (
decorator: ts.Decorator,
Expand Down Expand Up @@ -43,7 +42,7 @@ const getDecoratorParameter = (arg: ts.Expression, typeChecker: ts.TypeChecker):
* @param propName the name of the decorator to match against
* @returns true if the conditions above are both true, false otherwise
*/
export const isDecoratorNamed = (propName: StencilDecorator) => {
export const isDecoratorNamed = (propName: string) => {
return (dec: ts.Decorator): boolean => {
return ts.isCallExpression(dec.expression) && dec.expression.expression.getText() === propName;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import { isDecoratorNamed } from './decorator-utils';
export const elementDecoratorsToStatic = (
diagnostics: d.Diagnostic[],
decoratedMembers: ts.ClassElement[],
typeChecker: ts.TypeChecker,
newMembers: ts.ClassElement[],
decoratorName: string,
) => {
const elementRefs = decoratedMembers
.filter(ts.isPropertyDeclaration)
.map((prop) => parseElementDecorator(diagnostics, typeChecker, prop))
.map((prop) => parseElementDecorator(prop, decoratorName))
.filter((element) => !!element);

if (elementRefs.length > 0) {
Expand All @@ -25,12 +25,8 @@ export const elementDecoratorsToStatic = (
}
};

const parseElementDecorator = (
_diagnostics: d.Diagnostic[],
_typeChecker: ts.TypeChecker,
prop: ts.PropertyDeclaration,
): string | null => {
const elementDecorator = retrieveTsDecorators(prop)?.find(isDecoratorNamed('Element'));
const parseElementDecorator = (prop: ts.PropertyDeclaration, decoratorName: string): string | null => {
const elementDecorator = retrieveTsDecorators(prop)?.find(isDecoratorNamed(decoratorName));

if (elementDecorator == null) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ export const eventDecoratorsToStatic = (
typeChecker: ts.TypeChecker,
program: ts.Program,
newMembers: ts.ClassElement[],
decoratorName: string,
) => {
const events = decoratedProps
.filter(ts.isPropertyDeclaration)
.map((prop) => parseEventDecorator(diagnostics, typeChecker, program, prop))
.map((prop) => parseEventDecorator(diagnostics, typeChecker, program, prop, decoratorName))
.filter((ev) => !!ev);

if (events.length > 0) {
Expand All @@ -39,15 +40,17 @@ export const eventDecoratorsToStatic = (
* @param program a {@link ts.Program} object
* its surrounding context in the AST
* @param prop the property on the Stencil component class that is decorated with `@Event()`
* @param decoratorName the name of the decorator to look for
* @returns generated metadata for the class member decorated by `@Event()`, or `null` if none could be derived
*/
const parseEventDecorator = (
diagnostics: d.Diagnostic[],
typeChecker: ts.TypeChecker,
program: ts.Program,
prop: ts.PropertyDeclaration,
decoratorName: string,
): d.ComponentCompilerStaticEvent | null => {
const eventDecorator = retrieveTsDecorators(prop)?.find(isDecoratorNamed('Event'));
const eventDecorator = retrieveTsDecorators(prop)?.find(isDecoratorNamed(decoratorName));

if (eventDecorator == null) {
return null;
Expand Down
42 changes: 42 additions & 0 deletions src/compiler/transformers/decorators-to-static/import-alias-map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import ts from 'typescript';

import { STENCIL_DECORATORS, StencilDecorator } from './decorators-constants';

export class ImportAliasMap extends Map<StencilDecorator, string> {
constructor(sourceFile: ts.SourceFile) {
super();
this.generateImportAliasMap(sourceFile);
}

/**
* Parses a {@link ts.SourceFile} and generates a map of all imported Stencil decorators
* to their aliases import name (if one exists).
*
* @param sourceFile The source file to parse
*/
private generateImportAliasMap(sourceFile: ts.SourceFile) {
const importDeclarations = sourceFile.statements.filter(ts.isImportDeclaration);

for (const importDeclaration of importDeclarations) {
if (importDeclaration.moduleSpecifier.getText().includes('@stencil/core')) {
const namedBindings = importDeclaration.importClause?.namedBindings;

if (namedBindings && ts.isNamedImports(namedBindings)) {
for (const element of namedBindings.elements) {
const importName = element.name.getText();
const originalImportName = element.propertyName?.getText() ?? importName;

// We only care to generate a map for the Stencil decorators
if (STENCIL_DECORATORS.includes(originalImportName as StencilDecorator)) {
this.set(originalImportName as StencilDecorator, importName);
}
}
}
}
}
}

override get(key: StencilDecorator): string {
return super.get(key) ?? key;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ export const listenDecoratorsToStatic = (
typeChecker: ts.TypeChecker,
decoratedMembers: ts.ClassElement[],
newMembers: ts.ClassElement[],
decoratorName: string,
) => {
const listeners = decoratedMembers
.filter(ts.isMethodDeclaration)
.map((method) => parseListenDecorators(diagnostics, typeChecker, method));
.map((method) => parseListenDecorators(diagnostics, typeChecker, method, decoratorName));

const flatListeners = flatOne(listeners);
if (flatListeners.length > 0) {
Expand All @@ -25,8 +26,9 @@ const parseListenDecorators = (
diagnostics: d.Diagnostic[],
typeChecker: ts.TypeChecker,
method: ts.MethodDeclaration,
decoratorName: string,
): d.ComponentCompilerListener[] => {
const listenDecorators = (retrieveTsDecorators(method) ?? []).filter(isDecoratorNamed('Listen'));
const listenDecorators = (retrieveTsDecorators(method) ?? []).filter(isDecoratorNamed(decoratorName));
if (listenDecorators.length === 0) {
return [];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,14 @@ export const methodDecoratorsToStatic = (
typeChecker: ts.TypeChecker,
program: ts.Program,
newMembers: ts.ClassElement[],
decoratorName: string,
) => {
const tsSourceFile = cmpNode.getSourceFile();
const methods = decoratedProps
.filter(ts.isMethodDeclaration)
.map((method) => parseMethodDecorator(config, diagnostics, tsSourceFile, typeChecker, program, method))
.map((method) =>
parseMethodDecorator(config, diagnostics, tsSourceFile, typeChecker, program, method, decoratorName),
)
.filter((method) => !!method);

if (methods.length > 0) {
Expand All @@ -44,8 +47,9 @@ const parseMethodDecorator = (
typeChecker: ts.TypeChecker,
program: ts.Program,
method: ts.MethodDeclaration,
decoratorName: string,
): ts.PropertyAssignment | null => {
const methodDecorator = retrieveTsDecorators(method)?.find(isDecoratorNamed('Method'));
const methodDecorator = retrieveTsDecorators(method)?.find(isDecoratorNamed(decoratorName));
if (methodDecorator == null) {
return null;
}
Expand Down

0 comments on commit 97dcb45

Please sign in to comment.