Last active
January 29, 2024 11:47
-
-
Save daniel-sc/f65d75fe3fca70d819e77868e31a1687 to your computer and use it in GitHub Desktop.
Automatically migrate NgRx action classes to creator functions
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {readdirSync, readFileSync, writeFileSync} from 'fs'; | |
import {resolve} from 'path'; | |
import * as ts from 'typescript'; | |
/// original action class name to type enum | |
const actionMapping = new Map<string, {enumType: string; constructorParams: Param[]}>(); | |
/// this matches only a limited nesting of brackets - it uses lookaheads as performance fix for catastrophic backtracking: | |
const bracketMatcherPart = '\\(((?:[^()]*(?=[()])|\\((?:[^()]*(?=[()])|\\((?:[^()]*(?=[()])|\\((?:[^()]*(?=[()])|\\([^()]*(?=[()])\\))*\\))*\\))*\\))*)\\)'; | |
interface Param { | |
name: string; | |
type: string; | |
} | |
function replaceActionClassUsagesInFile(filePath: string) { | |
const fileContents = readFileSync(filePath, 'utf8'); | |
const updated = replaceActionClassUsages(fileContents); | |
writeFileSync(filePath, updated, 'utf8'); | |
} | |
function splitParamValues(paramValuesAsString: string): string[] { | |
const result: string[] = []; | |
let currentParam = ''; | |
let bracketCount = 0; | |
paramValuesAsString.split(',').forEach(paramValue => { | |
currentParam += paramValue; | |
bracketCount += (paramValue.match(/[({]/g) || []).length - (paramValue.match(/[)}]/g) || []).length; | |
if (bracketCount === 0) { | |
result.push(currentParam); | |
currentParam = ''; | |
} else { | |
currentParam += ','; | |
} | |
}); | |
return result; | |
} | |
function replaceActionClassUsages(fileContents: string): string { | |
let result = fileContents.replace(/action\.payload(\.?)(?!routerState)/g, 'action$1'); | |
actionMapping.forEach((value, actionClass) => { | |
const creatorName = actionClass.charAt(0).toLowerCase() + actionClass.slice(1); | |
const newActionReplacer = (m: string, group1: string) => { | |
const paramValues = splitParamValues(group1); | |
return constructorParamsAlreadyWrapped(value.constructorParams) || value.constructorParams.length === 0 | |
? `${creatorName}(${group1})` | |
: `${creatorName}({${value.constructorParams.map((p, i) => `${p.name}: ${paramValues[i]}`).join(', ')}})`; | |
}; | |
const enumName = value.enumType.replace(/\..*/, ''); | |
result = result | |
.replace(new RegExp(`(import \\{[^}]*)\\b${enumName}\\b,?\s*`, 'g'), '$1') | |
.replace(new RegExp(`(import \\{[^}]*),\s*,`, 'g'), '$1,') | |
.replace(new RegExp(`(import \\{)\s*,`, 'g'), '$1') | |
.replace(new RegExp(`(import \\{[^}]*)\\b${actionClass}\\b`, 'g'), `$1${creatorName}`) | |
// know issue: function calls inside constructor args..: | |
.replace(new RegExp(`new ${actionClass}${bracketMatcherPart}`, 'g'), newActionReplacer) | |
.replace(new RegExp(`ofType<${actionClass}>\\([\\w.]+\\)`, 'g'), `ofType(${creatorName})`) | |
.replace(new RegExp(`\\b${value.enumType}\\b`, 'g'), `${creatorName}.type`) | |
.replace(new RegExp(`\\b${actionClass}\\b`, 'g'), `ReturnType<typeof ${creatorName}>`) | |
.replace(new RegExp(`((?:it|describe)\\(.*)ReturnType<typeof ([^>])*>`, 'g'), `$1${creatorName}`); | |
}); | |
return result; | |
} | |
function constructorParamsAlreadyWrapped(params: Param[]) { | |
// only consider inline type definitions, as we cannot know if a type is a class or interface: | |
return params.length === 1 && params[0].type.startsWith('{') && params[0].type.endsWith('}'); | |
} | |
function refactorActionClassesInFile(filePath: string) { | |
console.log('refactorActionClassesInFile', filePath); | |
const fileContents = readFileSync(filePath, 'utf8'); | |
const sourceFile = ts.createSourceFile(filePath, fileContents, ts.ScriptTarget.ES2020, true); | |
let actionTypesEnum: ts.EnumDeclaration | null = null; | |
function isActionClass(node: ts.Node): node is ts.ClassDeclaration { | |
return ts.isClassDeclaration(node) && node.heritageClauses && node.heritageClauses.some(clause => clause.types.some(type => type.expression.getText(sourceFile) === 'Action')); | |
} | |
function createActionCreator(actionClass: ts.ClassDeclaration): ts.VariableStatement { | |
const className = actionClass.name.getText(sourceFile); | |
const actionType = actionClass.members.find(member => ts.isPropertyDeclaration(member) && member.name.getText(sourceFile) === 'type'); | |
if (!actionType || !ts.isPropertyDeclaration(actionType)) { | |
throw new Error(`Action type not found in class ${className}`); | |
} | |
const actionTypeText = actionType.initializer.getText(sourceFile); | |
const actionTypeValue = | |
actionTypesEnum && actionTypeText.startsWith(actionTypesEnum.name.getText(sourceFile) + '.') | |
? actionTypesEnum.members.find(m => m.name.getText(sourceFile) === actionTypeText.replace(/^.*\./, ''))?.initializer?.getText(sourceFile) ?? actionTypeText | |
: actionTypeText; | |
const constructor = actionClass.members.find(member => ts.isConstructorDeclaration(member)) as ts.ConstructorDeclaration | undefined; | |
let payloadType = ''; | |
const params = constructor?.parameters?.map(p => ({name: p.name.getText(sourceFile), type: p.type?.getText(sourceFile) ?? p.initializer?.getText(sourceFile) ?? ''})) ?? []; | |
if (params.length > 0) { | |
if (constructor.parameters.some(p => p.initializer)) { | |
console.warn(`action class constructor of ${className} has initializer which cannot be migrated`, constructor.getText(sourceFile)); | |
} | |
if (constructorParamsAlreadyWrapped(params)) { | |
const parameter = constructor.parameters[0]; | |
payloadType = parameter.type?.getText(sourceFile) ?? parameter.initializer?.getText(sourceFile) ?? ''; | |
} else { | |
payloadType = `{${constructor.parameters | |
.map(p => `${p.name.getText(sourceFile)}${p.questionToken ? '?' : ''}: ${p.type?.getText(sourceFile) ?? p.initializer?.getText(sourceFile) ?? ''}`) | |
.join(', ')}}`; | |
} | |
} | |
actionMapping.set(className, {enumType: actionTypeText, constructorParams: params}); | |
const createActionText = `export const ${className.charAt(0).toLowerCase() + className.slice(1)} = createAction(${actionTypeValue}${payloadType ? `, props<${payloadType}>()` : ''});`; | |
const createActionNode = ts.createSourceFile('temp.ts', createActionText, ts.ScriptTarget.ES2020); | |
//console.log('createActionNode', createActionNode.statements[0].getText(createActionNode)); | |
return createActionNode.statements[0] as ts.VariableStatement; | |
} | |
const result = ts.transform( | |
sourceFile, | |
[ | |
context => node => | |
ts.visitNode(node, function visitor(n): ts.VisitResult<ts.Node> { | |
if (ts.isEnumDeclaration(n) && /Actions?Types?$/.test(n.name.text)) { | |
actionTypesEnum = n; | |
return null; | |
} | |
if (isActionClass(n)) { | |
return createActionCreator(n); | |
} | |
return ts.visitEachChild(n, visitor, context); | |
}) | |
], | |
{} | |
); | |
const transformedSourceFile = result.transformed[0]; | |
const printer = ts.createPrinter(); | |
const updatedContents = printer.printFile(transformedSourceFile as ts.SourceFile); | |
// createAction, props from '@ngrx/store' | |
const manualReplacedText = updatedContents | |
.replace(/\/\* eslint-disable \*\/\n?/g, '') | |
.replace(new RegExp(`(import \\{[^}]*)\\bAction\\b,?\s*`, 'g'), '$1') | |
.replace(new RegExp(`(import \\{)([^}]*)(\} from ['"]@ngrx/store["'])`, 'g'), (m, g1, g2, g3) => `${g1}${g2.includes('props') ? g2 : 'props, ' + g2}${g3}`) | |
.replace(new RegExp(`(import \\{)([^}]*)(\} from ['"]@ngrx/store["'])`, 'g'), (m, g1, g2, g3) => `${g1}${g2.includes('createAction') ? g2 : 'createAction, ' + g2}${g3}`); | |
writeFileSync(filePath, manualReplacedText); | |
} | |
function allTsFiles(path: string): string[] { | |
return readdirSync(path, {withFileTypes: true}).reduce( | |
(acc, dirent) => [...acc, ...(dirent.isDirectory() ? allTsFiles(resolve(path, dirent.name)) : dirent.name.endsWith('.ts') ? [resolve(path, dirent.name)] : [])], | |
[] as string[] | |
); | |
} | |
// store script argument in variable: | |
const filePath = process.argv[2]; | |
console.log('migrating files in ', resolve(__dirname, filePath)); | |
const tsFiles = allTsFiles(resolve(__dirname, filePath)); | |
// refactorActionClassesInFile(resolve(__dirname, filePath)); | |
//console.log('tsFiles', tsFiles); | |
tsFiles.filter(file => file.endsWith('.actions.ts')).forEach(refactorActionClassesInFile); | |
tsFiles.forEach(replaceActionClassUsagesInFile); | |
console.log('done'); | |
//console.log('action mappings', actionMapping); | |
// post actions: | |
// organize imports | |
// add missing imports | |
// format file | |
// lint fix |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment