Skip to content

Instantly share code, notes, and snippets.

@daniel-sc
Last active January 29, 2024 11:47
Show Gist options
  • Save daniel-sc/f65d75fe3fca70d819e77868e31a1687 to your computer and use it in GitHub Desktop.
Save daniel-sc/f65d75fe3fca70d819e77868e31a1687 to your computer and use it in GitHub Desktop.
Automatically migrate NgRx action classes to creator functions
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