/* eslint-disable no-use-before-define, @typescript-eslint/no-use-before-define */ import ts from 'typescript'; import { Plugin } from 'ts-migrate-server'; import updateSourceText, { SourceTextUpdate } from '../utils/updateSourceText'; import { findKnownImports, findKnownVariables, collectIdentifierNodes, KnownDefinitionMap, } from './utils/identifiers'; type Options = { anyAlias?: string; }; const hoistClassStaticsPlugin: Plugin = { name: 'hoist-class-statics', run({ fileName, text, options }) { return hoistStaticClassProperties(fileName, text, options); }, }; export default hoistClassStaticsPlugin; /** * Determines whether or not we can hoist this identifier * @param identifier * @param hoistToPos -- the position we would hoist this identifier to * @param knownDefinitions -- a map describing any known imports or variable declarations */ function canHoistIdentifier( identifier: ts.Identifier, hoistToPos: number, knownDefinitions: KnownDefinitionMap, ): boolean { const globalWhitelist = ['Number', 'String', 'Object', 'Date', 'window', 'global']; const id = identifier.text; const isDefined = knownDefinitions[id] && knownDefinitions[id].end <= hoistToPos; const isGlobal = globalWhitelist.includes(id); return ( isDefined || isGlobal || // e.g. in 'PropTypes.string.isRequired' allow the accessing identifiers 'string' and 'isRequired' (ts.isPropertyAccessExpression(identifier.parent) && identifier.parent.name === identifier) || // e.g. in { foo: 'bar' } allow the assigned identifier key 'foo' (ts.isPropertyAssignment(identifier.parent) && identifier.parent.name === identifier) || // e.g. in { foo() {} } allow foo (ts.isMethodDeclaration(identifier.parent) && identifier.parent.name === identifier) ); } /** * Determines whether or not we can hoist this expression * @param expression * @param hoistToPos -- the position we would hoist this expression to * @param knownDefinitions -- a map describing any known imports or variable declarations */ function canHoistExpression( expression: ts.Expression, hoistToPos: number, knownDefinitions: KnownDefinitionMap, ): boolean { const allIdentifiers = collectIdentifierNodes(expression); return allIdentifiers.every((identifier: ts.Identifier) => canHoistIdentifier(identifier, hoistToPos, knownDefinitions), ); } /** * Determines whether or not this assignment was already hoisted to this class * @param statment -- a static binary expresison statement * @param classDeclaration -- the class declaration to hoist to */ function isAlreadyHoisted( statement: ts.ExpressionStatement, classDeclaration: ts.ClassDeclaration, ): boolean { if ( !ts.isBinaryExpression(statement.expression) || !ts.isPropertyAccessExpression(statement.expression.left) ) { return false; } const propertyToHoist = statement.expression.left.name.text; return classDeclaration.members.some( (member) => member.name && ts.isIdentifier(member.name) && member.name.text === propertyToHoist, ); } function hoistStaticClassProperties( fileName: string, sourceText: string, options: Options, ): string { const sourceFile = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true); const printer = ts.createPrinter(); const updates: SourceTextUpdate[] = []; const classDeclarations = sourceFile.statements.filter(ts.isClassDeclaration); const knownDefinitions = { ...findKnownImports(sourceFile), ...findKnownVariables(sourceFile), }; classDeclarations.forEach((classDeclaration) => { const className = classDeclaration.name; if (!className) return; const properties: ts.PropertyDeclaration[] = []; sourceFile.statements.forEach((statement) => { if ( ts.isExpressionStatement(statement) && ts.isBinaryExpression(statement.expression) && ts.isPropertyAccessExpression(statement.expression.left) && ts.isIdentifier(statement.expression.left.expression) && statement.expression.left.expression.text === className.text && statement.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken ) { if (isAlreadyHoisted(statement, classDeclaration)) { return; } if ( canHoistExpression(statement.expression.right, classDeclaration.pos, knownDefinitions) ) { properties.push( ts.createProperty( undefined, [ts.createModifier(ts.SyntaxKind.StaticKeyword)], statement.expression.left.name.text, undefined, undefined, statement.expression.right, ), ); updates.push({ kind: 'delete', index: statement.pos, length: statement.end - statement.pos, }); } else { // otherwise add a static type annotation for this expression properties.push( ts.createProperty( undefined, [ts.createModifier(ts.SyntaxKind.StaticKeyword)], statement.expression.left.name.text, undefined, options.anyAlias != null ? ts.createTypeReferenceNode(options.anyAlias, undefined) : ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), undefined, ), ); } } }); if (properties.length > 0) { if (classDeclaration.members.length === 0) { const updatedClassDeclaration = ts.updateClassDeclaration( classDeclaration, classDeclaration.decorators, classDeclaration.modifiers, classDeclaration.name, classDeclaration.typeParameters, classDeclaration.heritageClauses, ts.createNodeArray(properties), ); let index = classDeclaration.pos; while (index < sourceText.length && /\s/.test(sourceText[index])) index += 1; const length = classDeclaration.end - index; const text = printer.printNode( ts.EmitHint.Unspecified, updatedClassDeclaration, sourceFile, ); updates.push({ kind: 'replace', index, length, text }); } else { const text = ts.sys.newLine + properties .map((property) => printer.printNode(ts.EmitHint.Unspecified, property, sourceFile)) .join(ts.sys.newLine + ts.sys.newLine) + ts.sys.newLine; updates.push({ kind: 'insert', index: classDeclaration.members[0].pos, text }); } } }); return updateSourceText(sourceText, updates); }