diff --git a/packages/cli/templates/react/ReactTypeScriptFileUpdate.ts b/packages/cli/templates/react/ReactTypeScriptFileUpdate.ts index adfa1c69d..8d3267345 100644 --- a/packages/cli/templates/react/ReactTypeScriptFileUpdate.ts +++ b/packages/cli/templates/react/ReactTypeScriptFileUpdate.ts @@ -54,8 +54,8 @@ export class ReactTypeScriptFileUpdate extends TypeScriptFileUpdate { this.astTransformer.requestNewMembersInArrayLiteral( variableAsParentCondition(this.astTransformer, ROUTES_VARIABLE_NAME), [newRoute], - prepend, - anchorElement + anchorElement, + { prepend } ); } diff --git a/packages/cli/templates/webcomponents/WebComponentsTypeScriptFileUpdate.ts b/packages/cli/templates/webcomponents/WebComponentsTypeScriptFileUpdate.ts index 41fed580d..dff2b0862 100644 --- a/packages/cli/templates/webcomponents/WebComponentsTypeScriptFileUpdate.ts +++ b/packages/cli/templates/webcomponents/WebComponentsTypeScriptFileUpdate.ts @@ -43,8 +43,8 @@ export class WebComponentsTypeScriptFileUpdate extends TypeScriptFileUpdate { this.astTransformer.requestNewMembersInArrayLiteral( variableAsParentCondition(this.astTransformer, ROUTES_VARIABLE_NAME), [newRoute], - prepend, - anchorElement + anchorElement, + { prepend } ); } diff --git a/packages/core/package.json b/packages/core/package.json index d43c1258d..52834cc51 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -12,10 +12,10 @@ "author": "Infragistics", "license": "MIT", "dependencies": { - "@inquirer/prompts": "^5.4.0", + "@inquirer/prompts": "~5.4.0", "chalk": "^2.3.2", "glob": "^7.1.2", - "through2": "^4.0.2", + "through2": "^2.0.3", "typescript": "~5.5.4" }, "devDependencies": { diff --git a/packages/core/types/types-typescript.ts b/packages/core/types/types-typescript.ts index 661c1da99..ab3730c61 100644 --- a/packages/core/types/types-typescript.ts +++ b/packages/core/types/types-typescript.ts @@ -84,3 +84,38 @@ export interface ChangeRequest { */ node: T | ts.NodeArray; } + +/** + * Options that can be applied when modifying a literal expression. + */ +export interface LiteralExpressionOptionsBase { + /** + * Whether the literal should be on multiple lines. + * @remarks This option is only applicable to {@link ts.ObjectLiteralExpression} and {@link ts.ArrayLiteralExpression}. + */ + multiline?: boolean; +} + +/** + * Options that can be applied when modifying an {@link ts.ObjectLiteralExpression}. + */ +export interface ObjectLiteralExpressionEditOptions + extends LiteralExpressionOptionsBase { + /** + * Whether to override all elements of the property's initializer. + * @remarks This option is only applicable to {@link ts.PropertyAssignment} with an initializer that is {@link ts.ArrayLiteralExpression}. + * All other initializers will be overridden by default. + */ + override?: boolean; +} + +/** + * Options that can be applied when modifying an {@link ts.ArrayLiteralExpression}. + */ +export interface ArrayLiteralExpressionEditOptions + extends LiteralExpressionOptionsBase { + /** + * If any elements should be added at the beginning of an {@link ts.ArrayLiteralExpression}. + */ + prepend?: boolean; +} diff --git a/packages/core/typescript/TransformerFactories.ts b/packages/core/typescript/TransformerFactories.ts index 5e8a5c386..f93104bd4 100644 --- a/packages/core/typescript/TransformerFactories.ts +++ b/packages/core/typescript/TransformerFactories.ts @@ -1,7 +1,9 @@ import * as ts from 'typescript'; import { + ArrayLiteralExpressionEditOptions, Identifier, ImportDeclarationMeta, + ObjectLiteralExpressionEditOptions, PropertyAssignment, } from '../types'; import { SIDE_EFFECTS_IMPORT_TEMPLATE_NAME } from '../util'; @@ -26,8 +28,8 @@ import { TypeScriptExpressionCollector } from './TypeScriptExpressionCollector'; export const newMemberInObjectLiteralTransformerFactory = ( newProperty: ts.PropertyAssignment, visitCondition: (node: ts.Node) => boolean, - multiline: boolean, - expressionCollector: TypeScriptExpressionCollector + expressionCollector: TypeScriptExpressionCollector, + options: ObjectLiteralExpressionEditOptions ): ts.TransformerFactory => { return (context: ts.TransformationContext) => { return (rootNode: T) => { @@ -73,7 +75,8 @@ export const newMemberInObjectLiteralTransformerFactory = ( context, node, expressionCollector, - multiline + options?.multiline, + options?.override ); } @@ -91,53 +94,14 @@ export const newMemberInObjectLiteralTransformerFactory = ( }; }; -/** - * Creates a {@link ts.TransformerFactory} that updates a member in a {@link ts.ObjectLiteralExpression}. - */ -export const updateForObjectLiteralMemberTransformerFactory = ( - visitCondition: (node: ts.ObjectLiteralExpression) => boolean, - targetMember: PropertyAssignment -): ts.TransformerFactory => { - return (context: ts.TransformationContext) => { - return (rootNode: T) => { - const visitor = (node: ts.Node): ts.VisitResult => { - if (ts.isObjectLiteralExpression(node) && visitCondition(node)) { - const newProperties = node.properties.map((property) => { - const isPropertyAssignment = ts.isPropertyAssignment(property); - if ( - isPropertyAssignment && - ts.isIdentifier(property.name) && - property.name.text === targetMember.name - ) { - return context.factory.updatePropertyAssignment( - property, - property.name, - targetMember.value - ); - } - return property; - }); - - return context.factory.updateObjectLiteralExpression( - node, - newProperties - ); - } - return ts.visitEachChild(node, visitor, context); - }; - return ts.visitNode(rootNode, visitor, ts.isSourceFile); - }; - }; -}; - /** * Creates a {@link ts.TransformerFactory} that adds a new element to a {@link ts.ArrayLiteralExpression}. */ export const newMemberInArrayLiteralTransformerFactory = ( visitCondition: (node: ts.ArrayLiteralExpression) => boolean, elements: ts.Expression[], - prepend: boolean = false, - anchorElement?: ts.StringLiteral | ts.NumericLiteral | PropertyAssignment + anchorElement?: ts.StringLiteral | ts.NumericLiteral | PropertyAssignment, + options?: ArrayLiteralExpressionEditOptions ): ts.TransformerFactory => { return (context: ts.TransformationContext) => { return (rootNode: T) => { @@ -175,9 +139,14 @@ export const newMemberInArrayLiteralTransformerFactory = ( }); } + /** + * TODO: + * Consider extracting some of the logic to the factory that handles array literals as property initializers and reusing that here. + * The anchor element should be preserved while it should also allow for overriding of the elements, if needed. + */ if (anchor) { let structure!: ts.Expression[]; - if (prepend) { + if (options?.prepend) { structure = node.elements .slice(0, node.elements.indexOf(anchor)) .concat(elements) @@ -195,7 +164,7 @@ export const newMemberInArrayLiteralTransformerFactory = ( ); } - if (prepend) { + if (options?.prepend) { return context.factory.updateArrayLiteralExpression(node, [ ...elements, ...node.elements, @@ -213,6 +182,27 @@ export const newMemberInArrayLiteralTransformerFactory = ( }; }; +/** + * Creates a {@link ts.TransformerFactory} that sorts the elements in a {@link ts.ArrayLiteralExpression}. + */ +export const sortInArrayLiteralTransformerFactory = ( + visitCondition: (node: ts.ArrayLiteralExpression) => boolean, + sortCondition: (a: ts.Expression, b: ts.Expression) => number +) => { + return (context: ts.TransformationContext) => { + return (rootNode: T) => { + const visitor = (node: ts.Node): ts.VisitResult => { + if (ts.isArrayLiteralExpression(node) && visitCondition(node)) { + const elements = [...node.elements].sort(sortCondition); + return context.factory.updateArrayLiteralExpression(node, elements); + } + return ts.visitEachChild(node, visitor, context); + }; + return ts.visitNode(rootNode, visitor, ts.isSourceFile); + }; + }; +}; + /** * Creates a {@link ts.TransformerFactory} that adds a new argument to a {@link ts.CallExpression}. */ @@ -488,29 +478,7 @@ function updatePropertyAssignmentWithIdentifier( ? newProperty.initializer : newProperty.objectAssignmentInitializer; - const updatedProperty = ts.isPropertyAssignment(existingProperty) - ? context.factory.updatePropertyAssignment( - existingProperty, - existingProperty.name, - newPropInitializer - ) - : context.factory.updateShorthandPropertyAssignment( - existingProperty, - existingProperty.name, - newPropInitializer - ); - const structure = Array.from(node.properties); - const targetIndex = structure.indexOf(existingProperty); - if (targetIndex > -1) { - // attempt to modify the property assignment and preserve the order - structure[targetIndex] = updatedProperty; - return context.factory.updateObjectLiteralExpression(node, structure); - } - // append the property assignment at the end - return context.factory.updateObjectLiteralExpression(node, [ - ...node.properties.filter((p) => p !== existingProperty), - updatedProperty, - ]); + return updateProperty(node, existingProperty, newPropInitializer, context); } /** @@ -520,6 +488,7 @@ function updatePropertyAssignmentWithIdentifier( * @param context The transformation context. * @param node The object literal expression node. * @param multiline Whether the array literal should be multiline. + * @param override Whether to override all elements if the property's initializer is an array. */ function updatePropertyAssignmentWithArrayLiteral( newProperty: ts.PropertyAssignment | ts.ShorthandPropertyAssignment, @@ -527,7 +496,8 @@ function updatePropertyAssignmentWithArrayLiteral( context: ts.TransformationContext, node: ts.ObjectLiteralExpression, expressionCollector: TypeScriptExpressionCollector, - multiline: boolean + multiline: boolean, + override: boolean ): ts.ObjectLiteralExpression { const existingPropInitializer = ts.isPropertyAssignment(existingProperty) ? existingProperty.initializer @@ -543,25 +513,44 @@ function updatePropertyAssignmentWithArrayLiteral( const newElements = ts.isArrayLiteralExpression(newPropInitializer) ? [...newPropInitializer.elements] : [newPropInitializer]; - const uniqueElements = expressionCollector.collectUniqueExpressions([ - ...elements, - ...newElements, - ]); + const uniqueElements = override + ? expressionCollector.collectUniqueExpressions(newElements) + : expressionCollector.collectUniqueExpressions([ + ...elements, + ...newElements, + ]); const valueExpression = context.factory.createArrayLiteralExpression( uniqueElements, multiline ); + + return updateProperty(node, existingProperty, valueExpression, context); +} + +/** + * Updates a {@link ts.PropertyAssignment} with a new {@link ts.Initializer}. + * @param node The object literal expression node. + * @param existingProperty The property to update. + * @param newInitializer The new initializer to set. + * @param context The transformation context. + */ +function updateProperty( + node: ts.ObjectLiteralExpression, + existingProperty: ts.PropertyAssignment | ts.ShorthandPropertyAssignment, + newInitializer: ts.Expression, + context: ts.TransformationContext +): ts.ObjectLiteralExpression { const updatedProperty = ts.isPropertyAssignment(existingProperty) ? context.factory.updatePropertyAssignment( existingProperty, existingProperty.name, - valueExpression + newInitializer ) : context.factory.updateShorthandPropertyAssignment( existingProperty, existingProperty.name, - valueExpression + newInitializer ); const structure = Array.from(node.properties); diff --git a/packages/core/typescript/TypeScriptAstTransformer.ts b/packages/core/typescript/TypeScriptAstTransformer.ts index 005ce89ce..99e81db26 100644 --- a/packages/core/typescript/TypeScriptAstTransformer.ts +++ b/packages/core/typescript/TypeScriptAstTransformer.ts @@ -9,6 +9,8 @@ import { ChangeRequest, ChangeType, SyntaxKind, + ObjectLiteralExpressionEditOptions, + ArrayLiteralExpressionEditOptions, } from '../types'; import { TypeScriptFormattingService } from './TypeScriptFormattingService'; import { @@ -17,13 +19,13 @@ import { newImportDeclarationTransformerFactory, newMemberInArrayLiteralTransformerFactory, newMemberInObjectLiteralTransformerFactory, - updateForObjectLiteralMemberTransformerFactory, + sortInArrayLiteralTransformerFactory, } from './TransformerFactories'; import { TypeScriptExpressionCollector } from './TypeScriptExpressionCollector'; import { TypeScriptNodeFactory } from './TypeScriptNodeFactory'; /** - * Applies transformations to a source file using TypeScript compiler API. + * Applies changes to a `TypeScript` source file through `AST` mutations. */ export class TypeScriptAstTransformer { private _defaultCompilerOptions: ts.CompilerOptions; @@ -33,7 +35,7 @@ export class TypeScriptAstTransformer { private _printer: ts.Printer | undefined; /** - * Create a new TypeScriptAstTransformer instance for the given source file. + * Create a new `TypeScriptAstTransformer` instance for the given source file. * @param sourceFile The source file to update. * @param printerOptions Options to use when printing the source file. * @param customCompilerOptions Custom compiler options to use when transforming the source file. @@ -92,8 +94,8 @@ export class TypeScriptAstTransformer { } /** - * Looks up a property assignment in the AST. - * @param visitCondition The condition by which the property assignment is found. + * Looks up a {@link ts.PropertyAssignment} in the `AST`. + * @param visitCondition The condition by which the {@link ts.PropertyAssignment} is found. * @param lastMatch Whether to return the last match found. If not set, the first match will be returned. */ public findPropertyAssignment( @@ -117,7 +119,7 @@ export class TypeScriptAstTransformer { } /** - * Searches the AST for a variable declaration with the given name and type. + * Searches the `AST` for a variable declaration with the given name and type. * @param name The name of the variable to look for. * @param type The type of the variable to look for. * @returns The variable declaration if found, otherwise `undefined`. @@ -172,8 +174,8 @@ export class TypeScriptAstTransformer { } /** - * Creates a request that will resolve during {@link finalize} for a new property assignment in an object literal expression. - * @param visitCondition The condition by which the object literal expression is found. + * Creates a request that will resolve during {@link finalize} for a new {@link ts.PropertyAssignment} in an {@link ts.ObjectLiteralExpression}. + * @param visitCondition The condition by which the {@link ts.ObjectLiteralExpression} is found. * @param propertyAssignment The property that will be added. */ public requestNewMemberInObjectLiteral( @@ -181,23 +183,24 @@ export class TypeScriptAstTransformer { propertyAssignment: PropertyAssignment ): void; /** - * Creates a request that will resolve during {@link finalize} for a new property assignment in an object literal expression. - * @param visitCondition The condition by which the object literal expression is found. + * Creates a request that will resolve during {@link finalize} for a new {@link ts.PropertyAssignment} in an {@link ts.ObjectLiteralExpression}. + * @param visitCondition The condition by which the {@link ts.ObjectLiteralExpression} is found. * @param propertyName The name of the property that will be added. * @param propertyValue The value of the property that will be added. - * @param multiline Whether the object literal should be multiline. + * @param options Options to apply when modifying the object literal. + * @remarks Will update the property's initializer if it already exists. */ public requestNewMemberInObjectLiteral( visitCondition: (node: ts.ObjectLiteralExpression) => boolean, propertyName: string, propertyValue: ts.Expression, - multiline?: boolean + options?: ObjectLiteralExpressionEditOptions ): void; public requestNewMemberInObjectLiteral( visitCondition: (node: ts.ObjectLiteralExpression) => boolean, propertyNameOrAssignment: string | PropertyAssignment, propertyValue?: ts.Expression, - multiline?: boolean + options?: ObjectLiteralExpressionEditOptions ): void { let newProperty: ts.PropertyAssignment; if (propertyNameOrAssignment instanceof Object) { @@ -217,8 +220,8 @@ export class TypeScriptAstTransformer { const transformerFactory = newMemberInObjectLiteralTransformerFactory( newProperty, visitCondition, - multiline, - this._expressionCollector + this._expressionCollector, + options ); this.requestChange( ChangeType.NewNode, @@ -229,12 +232,12 @@ export class TypeScriptAstTransformer { } /** - * Creates a request that will resolve during {@link finalize} for a new property assignment that has a JSX value. - * The member is added in an object literal expression. - * @param visitCondition The condition by which the object literal expression is found. + * Creates a request that will resolve during {@link finalize} for a new property assignment that has a `JSX` value. + * The member is added in an {@link ts.ObjectLiteralExpression}. + * @param visitCondition The condition by which the {@link ts.ObjectLiteralExpression} is found. * @param propertyName The name of the property that will be added. * @param propertyValue The value of the property that will be added. - * @param jsxAttributes The JSX attributes to add to the JSX element. + * @param jsxAttributes The `JSX` attributes to add to the `JSX` element. * * @remarks Creates a property assignment of the form `{ propertyName: }` in the object literal. */ @@ -258,65 +261,38 @@ export class TypeScriptAstTransformer { } /** - * Creates a request that will resolve during {@link finalize} for an update to the value of a member in an object literal expression. - * @param visitCondition The condition by which the object literal expression is found. - * @param targetMember The member that will be updated. The value should be the new value to set. - */ - public requestUpdateForObjectLiteralMember( - visitCondition: (node: ts.ObjectLiteralExpression) => boolean, - targetMember: PropertyAssignment - ): void { - const transformerFactory = updateForObjectLiteralMemberTransformerFactory( - visitCondition, - targetMember - ); - - this.requestChange( - ChangeType.NodeUpdate, - transformerFactory, - SyntaxKind.PropertyAssignment, - ts.factory.createPropertyAssignment(targetMember.name, targetMember.value) - ); - } - - /** - * Creates a request that will resolve during {@link finalize} which adds a new element to an array literal expression. - * @param visitCondition The condition by which the array literal expression is found. - * @param elements The elements that will be added to the array literal. - * @param prepend If the elements should be added at the beginning of the array. + * Creates a request that will resolve during {@link finalize} which adds a new element to an {@link ts.ArrayLiteralExpression}. + * @param visitCondition The condition by which the {@link ts.ArrayLiteralExpression} is found. + * @param elements The elements that will be added to the {@link ts.ArrayLiteralExpression}. * @param anchorElement The element to anchor the new elements to. - * @param multiline Whether the array literal should be multiline. - * @remarks The `anchorElement` must match the type of the elements in the collection. + * @param options Options to apply when modifying the {@link ts.ArrayLiteralExpression}. + * @remarks The {@link anchorElement} must match the type of the elements in the collection. */ public requestNewMembersInArrayLiteral( visitCondition: (node: ts.ArrayLiteralExpression) => boolean, elements: ts.Expression[], - prepend?: boolean, anchorElement?: ts.Expression | PropertyAssignment, - multiline?: boolean + options?: ArrayLiteralExpressionEditOptions ): void; /** - * Creates a request that will resolve during {@link finalize} which adds a new element to an array literal expression. - * @param visitCondition The condition by which the array literal expression is found. - * @param elements The elements that will be added to the array literal - * @param prepend If the elements should be added at the beginning of the array. + * Creates a request that will resolve during {@link finalize} which adds a new element to an {@link ts.ArrayLiteralExpression}. + * @param visitCondition The condition by which the {@link ts.ArrayLiteralExpression} is found. + * @param elements The elements that will be added to the {@link ts.ArrayLiteralExpression}. * @param anchorElement The element to anchor the new elements to. - * @param multiline Whether the array literal should be multiline. - * @remarks The `anchorElement` must match the type of the elements in the collection. + * @param options Options to apply when modifying the {@link ts.ArrayLiteralExpression}. + * @remarks The {@link anchorElement} must match the type of the elements in the collection. */ public requestNewMembersInArrayLiteral( visitCondition: (node: ts.ArrayLiteralExpression) => boolean, elements: PropertyAssignment[], - prepend?: boolean, anchorElement?: ts.Expression | PropertyAssignment, - multiline?: boolean + options?: ArrayLiteralExpressionEditOptions ): void; public requestNewMembersInArrayLiteral( visitCondition: (node: ts.ArrayLiteralExpression) => boolean, expressionOrPropertyAssignment: ts.Expression[] | PropertyAssignment[], - prepend: boolean = false, anchorElement?: ts.StringLiteral | ts.NumericLiteral | PropertyAssignment, - multiline: boolean = false + options?: ArrayLiteralExpressionEditOptions ): void { let elements: ts.Expression[] | PropertyAssignment[]; const isExpression = expressionOrPropertyAssignment.every((e) => @@ -327,15 +303,18 @@ export class TypeScriptAstTransformer { } else { elements = (expressionOrPropertyAssignment as PropertyAssignment[]).map( (property) => - this._factory.createObjectLiteralExpression([property], multiline) + this._factory.createObjectLiteralExpression( + [property], + options?.multiline + ) ); } const transformerFactory = newMemberInArrayLiteralTransformerFactory( visitCondition, elements, - prepend, - anchorElement + anchorElement, + options ); this.requestChange( ChangeType.NewNode, @@ -345,13 +324,39 @@ export class TypeScriptAstTransformer { ); } + /** + * Creates a request that will resolve during {@link finalize} which sorts the elements in an {@link ts.ArrayLiteralExpression}}. + * @param visitCondition The condition by which the {@link ts.ArrayLiteralExpression} is found. + * @param sortCondition The sorting function to apply to the array's elements. + * + * @remarks The {@link sortCondition} function should return a negative number if `a` should come before `b`, + * a positive number if `a` should come after `b`, or zero if they are equal. + * + * Uses {@link Array.prototype.sort} internally. + */ + public requestSortInArrayLiteral( + visitCondition: (node: ts.ArrayLiteralExpression) => boolean, + sortCondition: (a: ts.Expression, b: ts.Expression) => number + ): void { + const transformerFactory = sortInArrayLiteralTransformerFactory( + visitCondition, + sortCondition + ); + this.requestChange( + ChangeType.NodeUpdate, + transformerFactory, + SyntaxKind.ArrayLiteralExpression, + null // assume the nodes of the matched array literal + ); + } + /** * Creates a request that will resolve during {@link finalize} which adds a new argument to a method call expression. * @param visitCondition The condition by which the method call expression is found. * @param argument The argument to add to the method call. * @param position The position in the argument list to add the new argument. * @param override Whether to override the argument at the given position. - * @remarks If `position` is not provided or is less than zero, the argument will be added at the end of the argument list. + * @remarks If {@link position} is not provided or is less than zero, the argument will be added at the end of the argument list. */ public requestNewArgumentInMethodCallExpression( visitCondition: (node: ts.CallExpression) => boolean, @@ -376,7 +381,7 @@ export class TypeScriptAstTransformer { } /** - * Checks if an import declaration's identifier or alias would collide with an existing one. + * Checks if an {@link ts.ImportDeclaration}'s identifier or alias would collide with an existing one. * @param identifier The identifier to check for collisions. * @param moduleName The module that the import is for, used for side effects imports. * @param isSideEffects If the import is strictly a side effects import. @@ -397,12 +402,13 @@ export class TypeScriptAstTransformer { } /** - * Creates a request that will resolve during {@link finalize} which adds an import declaration to the source file. - * @param importDeclarationMeta Metadata for the new import declaration. + * Creates a request that will resolve during {@link finalize} which adds an {@link ts.ImportDeclaration} to the source file. + * @param importDeclarationMeta Metadata for the new {@link ts.ImportDeclaration}. * @param isDefault Whether the import is a default import. - * @remarks If `isDefault` is `true`, the first identifier will be used and + * @param isSideEffects Whether the import is a side effects import. + * @remarks If {@link isDefault} is `true`, the first identifier will be used and * the import will be a default import of the form `import MyClass from "my-module"`. - * @remarks If `isSideEffects` is `true`, all other options are ignored + * @remarks If {@link isSideEffects} is `true`, all other options are ignored * and the import will be a side effects import of the form `import "my-module"`. * @reference {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#description|MDN} */ @@ -438,8 +444,8 @@ export class TypeScriptAstTransformer { } /** - * Applies the requested changes to the source file. - * @remarks Does not mutate the original `ts.SourceFile`. Instead, it creates a new one with the changes applied. + * Applies the requested changes to the {@link ts.SourceFile}. + * @remarks Does not mutate the original {@link ts.SourceFile}. Instead, it creates a new one with the changes applied. */ public applyChanges(): ts.SourceFile { let clone = this.sourceFile.getSourceFile(); @@ -458,8 +464,8 @@ export class TypeScriptAstTransformer { } /** - * Applies all transformations, parses the AST and returns the resulting source code. - * @remarks This method should be called after all modifications are ready to be applied to the AST. + * Applies all transformations, parses the `AST` and returns the resulting source code. + * @remarks This method should be called after all modifications are ready to be applied to the `AST`. * If a {@link formatter} is present, it will be used to format the source code and update the file on the FS. */ public finalize(): string { diff --git a/packages/core/typescript/TypeScriptFileUpdate.ts b/packages/core/typescript/TypeScriptFileUpdate.ts index 0be4d0f3a..7e5f0b80d 100644 --- a/packages/core/typescript/TypeScriptFileUpdate.ts +++ b/packages/core/typescript/TypeScriptFileUpdate.ts @@ -108,7 +108,7 @@ export abstract class TypeScriptFileUpdate { visitCondition, RouteTarget.Children, initializer, - multiline + { multiline } ); } diff --git a/packages/igx-templates/AngularTypeScriptFileUpdate.ts b/packages/igx-templates/AngularTypeScriptFileUpdate.ts index 95af5f6dc..b240b4d81 100644 --- a/packages/igx-templates/AngularTypeScriptFileUpdate.ts +++ b/packages/igx-templates/AngularTypeScriptFileUpdate.ts @@ -27,6 +27,7 @@ import { AngularDecoratorMetaTarget, AngularRouteEntry, AngularRouteTarget, + AngularDecoratorOptions, } from './types'; export class AngularTypeScriptFileUpdate extends TypeScriptFileUpdate { @@ -72,8 +73,8 @@ export class AngularTypeScriptFileUpdate extends TypeScriptFileUpdate { this.astTransformer.requestNewMembersInArrayLiteral( variableAsParentCondition(this.astTransformer, ROUTES_VARIABLE_NAME), [newRoute], - prepend, - anchorElement + anchorElement, + { prepend } ); } @@ -139,6 +140,59 @@ export class AngularTypeScriptFileUpdate extends TypeScriptFileUpdate { this.applyDecoratorMutations(NG_MODULE_DECORATOR_NAME, copy, multiline); } + /** + * Sorts the elements of an Angular decorator property that is an {@link ts.ArrayLiteralExpression}. + * @param decoratorName The name of the decorator to look for. + * @param target The target metadata property to sort. + * + * @remarks The {@link target} must be a {@link ts.PropertyAssignment} with an initializer that is an {@link ts.ArrayLiteralExpression}. + */ + public sortDecoratorPropertyInitializer( + decoratorName: string, + target: AngularDecoratorOptions + ) { + const visitCondition = (node: ts.ArrayLiteralExpression) => { + const propertyAssignment = this.astTransformer.findNodeAncestor( + node, + (node) => + ts.isPropertyAssignment(node) && + ts.isIdentifier(node.name) && + node.name.text.toLowerCase() === target.toLowerCase() && + ts.isArrayLiteralExpression(node.initializer) + ); + + if (!propertyAssignment) { + return false; + } + + const expectedDecorator = this.checkNgDecorator(decoratorName, node); + return expectedDecorator; + }; + + const igxMembersPrefix = 'igx'; + this.astTransformer.requestSortInArrayLiteral(visitCondition, (a, b) => { + // this check is just to type guard to a ts.Identifier + // in reality, the elements should always be identifiers + if (!ts.isIdentifier(a) || !ts.isIdentifier(b)) return -1; + + const aText = a.text.toLowerCase(); + const bText = b.text.toLowerCase(); + if ( + aText.startsWith(igxMembersPrefix) && + !bText.startsWith(igxMembersPrefix) + ) { + return 1; + } else if ( + !aText.startsWith(igxMembersPrefix) && + bText.startsWith(igxMembersPrefix) + ) { + return -1; + } else { + return aText.localeCompare(bText); + } + }); + } + /** * Adds metadata to the arguments provided in `TestBed.configureTestingModule`. * @param dep The dependency to add to the testing module's metadata. @@ -402,7 +456,7 @@ export class AngularTypeScriptFileUpdate extends TypeScriptFileUpdate { ): void { for (const key of Object.keys(meta)) { if (meta[key].length > 0) { - this.mutateNgDecoratorMeta(decoratorName, meta[key], key, multiline); + this.addMetaToNgDecorator(decoratorName, meta[key], key, multiline); } } } @@ -414,7 +468,7 @@ export class AngularTypeScriptFileUpdate extends TypeScriptFileUpdate { * @param target The target metadata property to update. * @param multiline Whether to format the new entry as multiline. */ - private mutateNgDecoratorMeta( + private addMetaToNgDecorator( name: string, meta: string[], target: string, @@ -454,7 +508,7 @@ export class AngularTypeScriptFileUpdate extends TypeScriptFileUpdate { visitorCondition, target, this.factory.createArrayLiteralExpression(value, multiline), - multiline + { multiline } ); } diff --git a/packages/igx-templates/types/enumerations/AngularDecoratorMetaTarget.ts b/packages/igx-templates/types/AngularDecoratorMetaTarget.ts similarity index 100% rename from packages/igx-templates/types/enumerations/AngularDecoratorMetaTarget.ts rename to packages/igx-templates/types/AngularDecoratorMetaTarget.ts diff --git a/packages/igx-templates/types/enumerations/AngularDecoratorMetaTargetType.ts b/packages/igx-templates/types/enumerations/AngularDecoratorMetaTargetType.ts new file mode 100644 index 000000000..242eec92e --- /dev/null +++ b/packages/igx-templates/types/enumerations/AngularDecoratorMetaTargetType.ts @@ -0,0 +1,8 @@ +/** Represents the names of the different members an Angular decorator can have. */ +export enum AngularDecoratorOptions { + Imports = 'imports', + Declarations = 'declarations', + Providers = 'providers', + Exports = 'exports', + Schemas = 'schemas', +} diff --git a/packages/igx-templates/types/enumerations/AngularDecoratorName.ts b/packages/igx-templates/types/enumerations/AngularDecoratorName.ts new file mode 100644 index 000000000..50293c2a3 --- /dev/null +++ b/packages/igx-templates/types/enumerations/AngularDecoratorName.ts @@ -0,0 +1,5 @@ +/* Represents the names of the different decorators in Angular. */ +export enum AngularDecoratorName { + Component = 'Component', + NgModule = 'NgModule', +} diff --git a/packages/igx-templates/types/index.ts b/packages/igx-templates/types/index.ts index 1f93cbcc3..a0a972bb7 100644 --- a/packages/igx-templates/types/index.ts +++ b/packages/igx-templates/types/index.ts @@ -1,4 +1,6 @@ -export * from "./enumerations/AngularDecoratorMetaTarget"; +export * from "./AngularDecoratorMetaTarget"; export * from "./enumerations/AngularRouteTarget"; +export * from "./enumerations/AngularDecoratorName"; +export * from "./enumerations/AngularDecoratorMetaTargetType"; export * from "./AngularRouteEntry"; export * from "./AngularRouteLike"; diff --git a/spec/unit/TypeScript-AST-Transformer-spec.ts b/spec/unit/TypeScript-AST-Transformer-spec.ts index c360c5cf4..c2de3d701 100644 --- a/spec/unit/TypeScript-AST-Transformer-spec.ts +++ b/spec/unit/TypeScript-AST-Transformer-spec.ts @@ -204,31 +204,49 @@ describe('TypeScript AST Transformer', () => { ); }); - it('should update an existing member of an object literal', () => { - astTransformer.requestUpdateForObjectLiteralMember( + it('should update an existing member of an object literal if it is an array literal and override the initializer', () => { + FILE_CONTENT = `const myObj = { key1: ["hello", "world"] };`; + sourceFile = ts.createSourceFile( + FILE_NAME, + FILE_CONTENT, + ts.ScriptTarget.Latest, + true + ); + astTransformer = new TypeScriptAstTransformer(sourceFile); + + astTransformer.requestNewMemberInObjectLiteral( ts.isObjectLiteralExpression, - { - name: 'key2', - value: ts.factory.createStringLiteral('new-value'), - } + 'key1', + nodeFactory.createArrayLiteralExpression([ + ts.factory.createStringLiteral('new-value'), + ]), + { multiline: false, override: true } ); const result = astTransformer.finalize(); - expect(result).toEqual( - `const myObj = { key1: "hello", key2: "new-value" };\n` - ); + expect(result).toEqual(`const myObj = { key1: ["new-value"] };\n`); }); - it('should not update a non-existing member of an object literal', () => { - astTransformer.requestUpdateForObjectLiteralMember( + it('should update an existing member of an object literal if it is an array literal without overriding the initializer', () => { + FILE_CONTENT = `const myObj = { key1: ["hello", "world"] };`; + sourceFile = ts.createSourceFile( + FILE_NAME, + FILE_CONTENT, + ts.ScriptTarget.Latest, + true + ); + astTransformer = new TypeScriptAstTransformer(sourceFile); + + astTransformer.requestNewMemberInObjectLiteral( ts.isObjectLiteralExpression, - { - name: 'key3', - value: ts.factory.createStringLiteral('new-value'), - } + 'key1', + nodeFactory.createArrayLiteralExpression([ + ts.factory.createStringLiteral('new-value'), + ]), + { multiline: false, override: false } ); const result = astTransformer.finalize(); expect(result).toEqual( - `const myObj = { key1: "hello", key2: "world" };\n` + `const myObj = { key1: ["hello", "world", "new-value"] };\n` ); }); @@ -239,7 +257,7 @@ describe('TypeScript AST Transformer', () => { ts.factory.createStringLiteral('new-value') ); - astTransformer.requestUpdateForObjectLiteralMember( + astTransformer.requestNewMemberInObjectLiteral( ts.isObjectLiteralExpression, { name: 'key3', @@ -280,7 +298,8 @@ describe('TypeScript AST Transformer', () => { astTransformer.requestNewMembersInArrayLiteral( ts.isArrayLiteralExpression, [ts.factory.createIdentifier('4')], - true + null, // anchor element + { prepend: true } ); const result = astTransformer.finalize(); @@ -291,8 +310,8 @@ describe('TypeScript AST Transformer', () => { astTransformer.requestNewMembersInArrayLiteral( ts.isArrayLiteralExpression, [ts.factory.createIdentifier('4')], - true, - ts.factory.createIdentifier('3') + ts.factory.createIdentifier('3'), + { prepend: true } ); const result = astTransformer.finalize(); @@ -323,8 +342,8 @@ describe('TypeScript AST Transformer', () => { }, ]), ], - true, - anchor + anchor, + { prepend: true } ); const anotherAnchor = { @@ -341,8 +360,8 @@ describe('TypeScript AST Transformer', () => { }, ]), ], - true, - anotherAnchor + anotherAnchor, + { prepend: true } ); const result = astTransformer.finalize(); @@ -406,6 +425,63 @@ describe('TypeScript AST Transformer', () => { ); expect(result).toEqual(`[\n "new-value",\n 5\n]`); }); + + it('should properly sort the members of an array literal numbers', () => { + FILE_CONTENT = `const myArr = [1, 10, -3, 0, 65, 12, 6.3, 6.2, 11];`; + sourceFile = ts.createSourceFile( + FILE_NAME, + FILE_CONTENT, + ts.ScriptTarget.Latest, + true + ); + astTransformer = new TypeScriptAstTransformer(sourceFile); + astTransformer.requestSortInArrayLiteral( + ts.isArrayLiteralExpression, + (a, b) => { + let leftSide = 0; + let rightSide = 0; + if (ts.isNumericLiteral(a)) { + leftSide = parseFloat(a.text); + } + if (ts.isNumericLiteral(b)) { + rightSide = parseFloat(b.text); + } + + const resolveUnaryExprPrefix = ( + kind: ts.SyntaxKind, + parsedNum: number + ) => { + if (kind === ts.SyntaxKind.MinusToken) { + return -parsedNum; + } + return parsedNum; + }; + + if (ts.isPrefixUnaryExpression(a) && ts.isNumericLiteral(a.operand)) { + leftSide = resolveUnaryExprPrefix( + a.operator, + parseFloat(a.operand.text) + ); + } + if (ts.isPrefixUnaryExpression(b) && ts.isNumericLiteral(b.operand)) { + rightSide = resolveUnaryExprPrefix( + b.operator, + parseFloat(b.operand.text) + ); + } + return leftSide - rightSide; + } + ); + + const result = astTransformer.finalize(); + expect(result).toEqual( + `const myArr = [-3, 0, 1, 6.2, 6.3, 10, 11, 12, 65];\n` + ); + }); + + it('should override all elements in the array literal', () => { + pending('Consider implementing logic that allows the overriding of all the elements in a floating array literal.'); + }); }); describe('Imports', () => { diff --git a/spec/unit/ts-transform/AngularTypeScriptFileUpdate-spec.ts b/spec/unit/ts-transform/AngularTypeScriptFileUpdate-spec.ts index aac58baab..a92b3fca2 100644 --- a/spec/unit/ts-transform/AngularTypeScriptFileUpdate-spec.ts +++ b/spec/unit/ts-transform/AngularTypeScriptFileUpdate-spec.ts @@ -1,5 +1,9 @@ import { App, FS_TOKEN } from '@igniteui/cli-core'; -import { AngularTypeScriptFileUpdate } from '@igniteui/angular-templates'; +import { + AngularDecoratorOptions, + AngularDecoratorName, + AngularTypeScriptFileUpdate, +} from '@igniteui/angular-templates'; import { EOL } from 'os'; import { MockFS } from './Mock-FS'; @@ -89,6 +93,24 @@ const standaloneComponentFilePath = 'path/to/component'; const pathToAppConfig = 'path/to/app.config'; const specFilePath = 'path/to/my-component.spec'; +let fileUpdate!: AngularTypeScriptFileUpdate; + +function setupFileUpdate( + standalone = false, + filePath: string, + fileContent: string +) { + spyOn(App, 'initialize').and.callThrough(); + + spyOn(App.container, 'get').and.returnValue( + new MockFS(new Map([[filePath, fileContent]])) + ); + + fileUpdate = new AngularTypeScriptFileUpdate(filePath, standalone, { + singleQuotes: true, + }); +} + describe('Unit - AngularTypeScriptFileUpdate', () => { describe('Initialization', () => { it('should be created with a path/to/file', () => { @@ -151,7 +173,6 @@ describe('Unit - AngularTypeScriptFileUpdate', () => { }); }); - let fileUpdate!: AngularTypeScriptFileUpdate; describe('App Config', () => { beforeEach(() => { spyOn(App.container, 'get').and.returnValue( @@ -779,21 +800,9 @@ describe('Unit - AngularTypeScriptFileUpdate', () => { describe('Metadata', () => { describe('NgModule', () => { - beforeEach(() => { - spyOn(App, 'initialize').and.callThrough(); - spyOn(App.container, 'get').and.returnValue( - new MockFS(new Map([[moduleFilePath, moduleFileContent]])) - ); - fileUpdate = new AngularTypeScriptFileUpdate( - moduleFilePath, - false, // standalone - { - singleQuotes: true, - } - ); - }); - it('should add to imports array and update forRoot()', () => { + setupFileUpdate(false, moduleFilePath, moduleFileContent); + fileUpdate.addNgModuleMeta({ import: ['RouterModule'], from: '@angular/router', @@ -823,6 +832,8 @@ describe('Unit - AngularTypeScriptFileUpdate', () => { }); it("should add to declarations/exports/providers, creating them if they don't exist", () => { + setupFileUpdate(false, moduleFilePath, moduleFileContent); + fileUpdate.addNgModuleMeta( { declare: ['MyComponent'], @@ -875,6 +886,8 @@ describe('Unit - AngularTypeScriptFileUpdate', () => { }); it('should replace variable placeholders / apply variable config transformations', () => { + setupFileUpdate(false, moduleFilePath, moduleFileContent); + const configVariables = { '$(key)': 'Replace', '$(key2)': 'Replace2', @@ -931,6 +944,8 @@ describe('Unit - AngularTypeScriptFileUpdate', () => { }); it('should not add members to the same array if they are already present', () => { + setupFileUpdate(false, moduleFilePath, moduleFileContent); + fileUpdate.addNgModuleMeta( { declare: ['MyComponent'], @@ -992,26 +1007,79 @@ describe('Unit - AngularTypeScriptFileUpdate', () => { EOL ); }); - }); - describe('Standalone Component', () => { - beforeEach(() => { - spyOn(App, 'initialize').and.callThrough(); - spyOn(App.container, 'get').and.returnValue( - new MockFS( - new Map([ - [standaloneComponentFilePath, standaloneComponentFileContent], - ]) - ) + it('should properly sort the elements of the `imports` member array', () => { + const moduleWithImports = `import { NgModule } from '@angular/core'; + import { CommonModule } from '@angular/common'; + + @NgModule({ + imports: [ + BrowserModule, + HammerModule, + AppRoutingModule, + BrowserAnimationsModule, + IgxInputGroupModule, + ReactiveFormsModule, + FormsModule, + HttpClientModule + ] + }) + export class MyModule { + }`; + setupFileUpdate(false, moduleFilePath, moduleWithImports); + + fileUpdate.sortDecoratorPropertyInitializer( + AngularDecoratorName.NgModule, + AngularDecoratorOptions.Imports ); - fileUpdate = new AngularTypeScriptFileUpdate( - standaloneComponentFilePath, - true, // standalone - { singleQuotes: true } + + const result = fileUpdate.finalize(); + expect(result).toEqual( + `import { NgModule } from '@angular/core';` + + EOL + + `import { CommonModule } from '@angular/common';` + + EOL + + EOL + + `@NgModule({` + + EOL + + ` imports: [` + + EOL + + ` AppRoutingModule,` + + EOL + + ` BrowserAnimationsModule,` + + EOL + + ` BrowserModule,` + + EOL + + ` FormsModule,` + + EOL + + ` HammerModule,` + + EOL + + ` HttpClientModule,` + + EOL + + ` ReactiveFormsModule,` + + EOL + + ` IgxInputGroupModule` + + EOL + + ` ]` + + EOL + + `})` + + EOL + + `export class MyModule {` + + EOL + + `}` + + EOL ); }); + }); + describe('Standalone Component', () => { it("should update a standalone component's import meta", () => { + setupFileUpdate( + true, + standaloneComponentFilePath, + standaloneComponentFileContent + ); + fileUpdate.addStandaloneComponentMeta({ import: ['MyComponent'], from: 'my-module', @@ -1053,6 +1121,12 @@ describe('Unit - AngularTypeScriptFileUpdate', () => { }); it("should update a standalone component's provide meta", () => { + setupFileUpdate( + true, + standaloneComponentFilePath, + standaloneComponentFileContent + ); + fileUpdate.addStandaloneComponentMeta({ provide: ['MyService'], from: 'my-service', @@ -1096,6 +1170,12 @@ describe('Unit - AngularTypeScriptFileUpdate', () => { }); it('should replace variable placeholders / apply variable config transformations', () => { + setupFileUpdate( + true, + standaloneComponentFilePath, + standaloneComponentFileContent + ); + const configVariables = { '$(key)': 'Replace', __key4__: 'replace4', @@ -1159,6 +1239,12 @@ describe('Unit - AngularTypeScriptFileUpdate', () => { }); it('should not add members to the same array if they are already present', () => { + setupFileUpdate( + true, + standaloneComponentFilePath, + standaloneComponentFileContent + ); + fileUpdate.addStandaloneComponentMeta({ provide: ['MyService'], from: 'my-service', @@ -1206,6 +1292,104 @@ describe('Unit - AngularTypeScriptFileUpdate', () => { EOL ); }); + + it('should properly sort the elements of the `imports` member array', () => { + const componentWithImports = `import { Component } from '@angular/core'; + import { CommonModule } from '@angular/common'; + import { BrowserModule } from '@angular/platform-browser'; + import { HammerModule } from '@angular/platform-browser'; + import { RouterOutlet } from '@angular/router'; + import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + import { IGX_INPUT_GROUP_DIRECTIVES } from 'igniteui-angular'; + + @Component({ + selector: 'app-root', + standalone: true, + imports: [ + BrowserModule, + HammerModule, + AppRoutingModule, + BrowserAnimationsModule, + IGX_INPUT_GROUP_DIRECTIVES, + ReactiveFormsModule, + FormsModule, + HttpClientModule + ], + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'] + }) + export class AppComponent { + title = 'Home - IgniteUI for Angular'; + }`; + + setupFileUpdate( + true, + standaloneComponentFilePath, + componentWithImports + ); + + fileUpdate.sortDecoratorPropertyInitializer( + AngularDecoratorName.Component, + AngularDecoratorOptions.Imports + ); + + const result = fileUpdate.finalize(); + expect(result).toEqual( + `import { Component } from '@angular/core';` + + EOL + + `import { CommonModule } from '@angular/common';` + + EOL + + `import { BrowserModule } from '@angular/platform-browser';` + + EOL + + `import { HammerModule } from '@angular/platform-browser';` + + EOL + + `import { RouterOutlet } from '@angular/router';` + + EOL + + `import { BrowserAnimationsModule } from '@angular/platform-browser/animations';` + + EOL + + `import { IGX_INPUT_GROUP_DIRECTIVES } from 'igniteui-angular';` + + EOL + + EOL + + `@Component({` + + EOL + + ` selector: 'app-root',` + + EOL + + ` standalone: true,` + + EOL + + ` imports: [` + + EOL + + ` AppRoutingModule,` + + EOL + + ` BrowserAnimationsModule,` + + EOL + + ` BrowserModule,` + + EOL + + ` FormsModule,` + + EOL + + ` HammerModule,` + + EOL + + ` HttpClientModule,` + + EOL + + ` ReactiveFormsModule,` + + EOL + + ` IGX_INPUT_GROUP_DIRECTIVES` + + EOL + + ` ],` + + EOL + + ` templateUrl: './app.component.html',` + + EOL + + ` styleUrls: ['./app.component.scss']` + + EOL + + `})` + + EOL + + `export class AppComponent {` + + EOL + + ` title = 'Home - IgniteUI for Angular';` + + EOL + + `}` + + EOL + ); + }); }); describe('Spec File', () => { diff --git a/yarn.lock b/yarn.lock index 812cdc112..35f2da469 100644 --- a/yarn.lock +++ b/yarn.lock @@ -383,7 +383,7 @@ "@inquirer/type" "^1.5.3" ansi-escapes "^4.3.2" -"@inquirer/prompts@^5.4.0": +"@inquirer/prompts@^5.4.0", "@inquirer/prompts@~5.4.0": version "5.4.0" resolved "https://registry.yarnpkg.com/@inquirer/prompts/-/prompts-5.4.0.tgz#8bf2e47c39039b5f82e5d0fd69cf58b047a264d5" integrity sha512-HIQGd7JOX6WXf7zg7WGs+1m+e3eRFyL4mDtWRlV01AXqZido9W3BSoku2BR4E1lK/NCXok6jg6tTcLw4I0thfg==