import { Logger } from '@angular-devkit/core/src/logger';
import path from 'path';
import ts, { NodeWithTypeArguments, TypeFlags, TypeNode, UnionTypeNode } from 'typescript';
import { IntrospectionContext } from '../../../shared/introspection/types';
export type FormProperty = {
name: string;
type: string;
};
export interface FormPropertyPreprocessing {
property: ts.Symbol;
pathFromRoot: string[];
visitedCustomTypeNamesFromRoot: Set<string>;
}
export function extractFormProperties(
introspectionContext: IntrospectionContext,
sourceFile: ts.SourceFile,
node: ts.Node | undefined
) {
const { checker, logger } = introspectionContext;
const buildingBlockFilePath = sourceFile?.fileName;
const buildingBlockName = path.basename(buildingBlockFilePath, '.ts');
if (!node) return [];
let result: FormProperty[] = [];
node.forEachChild((visitingNode: ts.Node) => {
if (isGetFormMethod(visitingNode)) {
const declaration = visitingNode as ts.MethodDeclaration;
const type = getReturnTypeArgument(checker, declaration);
if (type?.flags === TypeFlags.Any) {
/**
* @deprecated This will turn into an error in the future
*/
logger.warn(
`Can't extract the return type of 'getForm' method in '${buildingBlockName}'. Make sure the form is not explicitly typed with 'UntypedFormGroup' or uses a 'FormGroup' without a generic type. This will turn into an error in the future.`
);
} else if (type) {
result = extractPropertyTree(checker, type, visitingNode, buildingBlockFilePath, logger);
} else {
logger.error(
`The return type of 'getForm' method in '${buildingBlockName}' is not proper. Make sure the form is properly typed with the 'FormGroupTyped<T>' and the return type of 'getForm' method matches this signature!`
);
}
}
});
return result;
}
function isGetFormMethod(node: ts.Node) {
const isMethod = ts.isMethodDeclaration(node);
if (!isMethod) return false;
const declaration = node as ts.MethodDeclaration;
const name = declaration.name as ts.Identifier;
return name?.text === 'getForm';
}
function getReturnTypeArgument(checker: ts.TypeChecker, declaration: ts.MethodDeclaration) {
const methodSignature = checker.getSignatureFromDeclaration(declaration);
if (!methodSignature) return;
const returnType = checker.getReturnTypeOfSignature(methodSignature) as ts.TypeReference;
// FormGroupTyped<T,U> , we need to get the T which is the first item in the 'typeArguments'
const typeArgument = returnType['typeArguments']?.[0];
return typeArgument;
}
function extractTypeName(propertyType: ts.Type): string {
return (
propertyType?.aliasSymbol?.escapedName ?? // If it is a Type , use it
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(propertyType as any)['intrinsicName'] ?? // If it is an Interface or a custom type, use other
propertyType.getSymbol()?.escapedName.toString()
);
}
function extractFormControlType(
propertyType: NodeWithTypeArguments,
pathFromRoot: string[],
logger: Logger
): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const typeArguments: any | undefined = propertyType.typeArguments?.[0];
if (typeArguments?.intrinsicName) {
return typeArguments.intrinsicName;
}
// In some cases the type information is not available. This is most likely related to Typescript's strict mode being off
if (!(typeArguments as UnionTypeNode).types) {
/**
* @deprecated This will turn into an error in the future
*/
logger.warn(
`Could not extract type information for form element: ${pathFromRoot.join(
'.'
)}. This might be related to strict mode being turned off.`
);
return '';
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (typeArguments as UnionTypeNode).types.map((type: any) => type.intrinsicName).join(' | ');
}
function extractFormControlProperty(
propertyType: NodeWithTypeArguments,
pathFromRoot: string[],
logger: Logger
): FormProperty[] {
return [
{
name: pathFromRoot.join('.'),
type: extractFormControlType(propertyType, pathFromRoot, logger)
}
];
}
function extractFormGroupProperties(
typeArguments: TypeNode,
pathFromRoot: string[],
logger: Logger
): FormProperty[] {
const formProperties: FormProperty[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((typeArguments as any)?.properties?.length) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(typeArguments as any).properties.forEach((property: ts.Symbol) => {
const currentPath = [...pathFromRoot, property.escapedName as string];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const type = (property as any).links.type;
if (type) {
formProperties.push(
...extractAngularFormProperties(type, currentPath, logger, type.symbol.escapedName)
);
} else {
logger.error(`Could not extract type information for ${currentPath}`);
}
});
}
return formProperties;
}
function extractAngularFormProperties(
propertyType: NodeWithTypeArguments,
pathFromRoot: string[],
logger: Logger,
typeName?: string
): FormProperty[] {
const typeArguments: TypeNode | undefined = propertyType.typeArguments?.[0];
if (!typeArguments) {
return [];
}
if (typeName === 'FormArray') {
return extractAngularFormProperties(
typeArguments as NodeWithTypeArguments,
pathFromRoot,
logger,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(typeArguments as any).symbol.escapedName
);
}
if (typeName === 'FormGroup') {
return extractFormGroupProperties(typeArguments, pathFromRoot, logger);
}
if (typeName === 'FormRecord') {
return [
{
name: pathFromRoot.join('.'),
type: 'FormRecord'
}
];
}
if (typeName === 'FormControl') {
return extractFormControlProperty(
propertyType as unknown as NodeWithTypeArguments,
pathFromRoot,
logger
);
}
return [];
}
function isAngularForms(typeName: string) {
return typeName === 'FormArray' || typeName === 'FormGroup' || typeName === 'FormControl';
}
function extractPropertyTree(
checker: ts.TypeChecker,
root: ts.Type,
node: ts.Node,
buildingBlockFilePath: string,
logger: Logger
) {
const formInterfaceName = root.getSymbol()?.escapedName;
const formProperties: FormProperty[] = [];
const propertyStack = mapToStackItems(new Set<string>(), root, []);
while (propertyStack.length > 0) {
const { property, pathFromRoot, visitedCustomTypeNamesFromRoot } =
propertyStack.shift() as FormPropertyPreprocessing;
const { propertyType, isArrayType } = getPropertyTypeInformation(
checker,
property,
node,
logger
);
// Add array signature if it is an array
if (isArrayType) pathFromRoot.push('[]');
const typeName = extractTypeName(propertyType);
if (typeName === 'UntypedFormGroup' || typeName === 'UntypedFormControl') {
/**
* @deprecated This will turn into an error in the future
*/
logger.warn(
`Can't extract the type '${typeName}' of the path '${pathFromRoot}' in the form in '${buildingBlockFilePath}'. Make sure the form is not explicitly typed with 'UntypedFormGroup' or 'UntypedFormControl'. This will turn into an error in the future.`
);
continue;
}
if (isAngularForms(typeName)) {
formProperties.push(
...extractAngularFormProperties(
propertyType as unknown as NodeWithTypeArguments,
pathFromRoot,
logger,
typeName
)
);
continue;
}
const isCustomType = isCustomDefinedType(propertyType);
// If it is not a custom type, we just add it to the result
if (!isCustomType) {
formProperties.push({
name: pathFromRoot.join('.'),
type: isArrayType ? `${typeName}[]` : typeName
});
// Go to the next property
continue;
}
// If it is a custom type, we need to check if it has recursion
const isRecursionDetected =
typeName && // If it has a name
property.escapedName !== 'prototype' && // If it is not class prototype
visitedCustomTypeNamesFromRoot.has(typeName); // If it is already visited in the current branch
if (isRecursionDetected) {
const symbolFilePath = propertyType
.getSymbol()
?.getDeclarations()?.[0]
.getSourceFile().fileName;
logger.error(`
Recursion detected in the Form type '${formInterfaceName}'.
Building Block file: '${buildingBlockFilePath}'.
Recursion detected in '${typeName}' type defined in '${symbolFilePath}'
Recursive route: '${pathFromRoot.join('.')}'
`);
continue;
}
const isCustomTypeWithName =
propertyType?.aliasSymbol?.escapedName || // Do we have a name for the type?
propertyType.getSymbol()?.flags !== ts.SymbolFlags.TypeLiteral; // Ignore TypeLiteral (object declaration) with name
if (isCustomTypeWithName) {
visitedCustomTypeNamesFromRoot.add(typeName); // Add the type to the visited list
}
propertyStack.push(
...mapToStackItems(visitedCustomTypeNamesFromRoot, propertyType, pathFromRoot) // Add the properties of the type to the stack
);
}
return formProperties;
}
function mapToStackItems(visitedCustomTypeNames: Set<string>, type: ts.Type, ancestors: string[]) {
return type.getProperties().map((property) => ({
property,
pathFromRoot: [...ancestors, property?.escapedName.toString()],
visitedCustomTypeNamesFromRoot: new Set(visitedCustomTypeNames)
}));
}
function getPropertyTypeInformation(
checker: ts.TypeChecker,
property: ts.Symbol,
node: ts.Node,
logger: Logger
) {
const primitiveOrComplexType = checker.getTypeOfSymbolAtLocation(property, node);
const isArrayType = primitiveOrComplexType.getSymbol()?.escapedName === 'Array';
let propertyType: ts.Type;
if (primitiveOrComplexType?.symbol?.escapedName === 'FormGroupTyped') {
/**
* @deprecated This will turn into an error in the future
*/
logger.warn(
`Detected a FormGroupTyped. Please notice that this type is deprecated and will be removed in the future. This will turn into an error in the future.`
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
propertyType = (primitiveOrComplexType as any).typeArguments[0];
} else if (isArrayType) {
// If type is 'Person[]', extract 'Person' from type arguments
// eslint-disable-next-line @typescript-eslint/no-explicit-any
propertyType = (primitiveOrComplexType as any).typeArguments[0];
} else {
propertyType = primitiveOrComplexType;
}
return {
isArrayType,
propertyType
};
}
function isCustomDefinedType(type: ts.Type) {
return type
.getSymbol()
?.getDeclarations()
?.some((d) => {
return !d.getSourceFile().fileName.startsWith('node_modules/typescript/lib');
});
}