import type { API, ASTPath, default as core, FileInfo, ImportDeclaration, ImportDefaultSpecifier, ImportSpecifier, JSXAttribute, Options, Program, VariableDeclaration, } from 'jscodeshift'; import { type Collection } from 'jscodeshift/src/Collection'; export type Nullable = T | null; export function getNamedSpecifier( j: core.JSCodeshift, source: any, specifier: string, importName: string, ): any { const specifiers = source .find(j.ImportDeclaration) .filter((path: ASTPath) => path.node.source.value === specifier) .find(j.ImportSpecifier) .filter((path: ASTPath) => path.node.imported.name === importName); if (!specifiers.length) { return null; } return specifiers.nodes()[0]!.local!.name; } function getDefaultSpecifier(j: core.JSCodeshift, source: ReturnType, specifier: string) { const specifiers = source .find(j.ImportDeclaration) .filter((path: ASTPath) => path.node.source.value === specifier) .find(j.ImportDefaultSpecifier); if (!specifiers.length) { return null; } return specifiers.nodes()[0]!.local!.name; } export function getJSXAttributesByName( j: core.JSCodeshift, element: ASTPath, attributeName: string, ): Collection { return j(element) .find(j.JSXOpeningElement) .find(j.JSXAttribute) .filter((attribute) => { const matches = j(attribute) .find(j.JSXIdentifier) .filter((identifier) => identifier.value.name === attributeName); return Boolean(matches.length); }); } export function hasImportDeclaration( j: core.JSCodeshift, source: any, importPath: string, ): boolean { const imports = source .find(j.ImportDeclaration) .filter( (path: ASTPath) => typeof path.node.source.value === 'string' && path.node.source.value.startsWith(importPath), ); return Boolean(imports.length); } export function findIdentifierAndReplaceAttribute( j: core.JSCodeshift, source: ReturnType, identifierName: string, searchAttr: string, replaceWithAttr: string, ): void { source .find(j.JSXElement) .find(j.JSXOpeningElement) .filter((path) => { return !!j(path.node) .find(j.JSXIdentifier) .filter((identifier) => identifier.value.name === identifierName); }) .forEach((element) => { j(element) .find(j.JSXAttribute) .find(j.JSXIdentifier) .filter((attr) => attr.node.name === searchAttr) .forEach((attribute) => { j(attribute).replaceWith(j.jsxIdentifier(replaceWithAttr)); }); }); } export function hasVariableAssignment( j: core.JSCodeshift, source: ReturnType, identifierName: string, ): Collection | boolean { const occurance = source.find(j.VariableDeclaration).filter((path) => { return !!j(path.node) .find(j.VariableDeclarator) .find(j.Identifier) .filter((identifier) => { return identifier.node.name === identifierName; }).length; }); return !!occurance.length ? occurance : false; } // not replacing newlines (which \s does) const spacesAndTabs: RegExp = /[ \t]{2,}/g; const lineStartWithSpaces: RegExp = /^[ \t]*/gm; function clean(value: string): string { return ( value .replace(spacesAndTabs, ' ') .replace(lineStartWithSpaces, '') // using .trim() to clear the any newlines before the first text and after last text .trim() ); } export function addCommentToStartOfFile({ j, base, message, }: { j: core.JSCodeshift; base: Collection; message: string; }): void { addCommentBefore({ j, target: base.find(j.Program), message, }); } export function addCommentBefore({ j, target, message, }: { j: core.JSCodeshift; target: Collection | Collection; message: string; }): void { const content: string = ` TODO: (from codemod) ${clean(message)} `; target.forEach((path: ASTPath) => { path.value.comments = path.value.comments || []; const exists = path.value.comments.find((comment) => comment.value === content); // avoiding duplicates of the same comment if (exists) { return; } path.value.comments.push(j.commentBlock(content)); }); } export function tryCreateImport({ j, base, relativeToPackage, packageName, }: { j: core.JSCodeshift; base: Collection; relativeToPackage: string; packageName: string; }): void { const exists: boolean = base.find(j.ImportDeclaration).filter((path) => path.value.source.value === packageName) .length > 0; if (exists) { return; } base .find(j.ImportDeclaration) .filter((path) => path.value.source.value === relativeToPackage) .insertBefore(j.importDeclaration([], j.literal(packageName))); } export function addToImport({ j, base, importSpecifier, packageName, }: { j: core.JSCodeshift; base: Collection; importSpecifier: ImportSpecifier | ImportDefaultSpecifier; packageName: string; }): void { base .find(j.ImportDeclaration) .filter((path) => path.value.source.value === packageName) .replaceWith((declaration) => { return j.importDeclaration( [ // we are appending to the existing specifiers // We are doing a filter hear because sometimes specifiers can be removed // but they hand around in the declaration ...(declaration.value.specifiers || []).filter( (item) => item.type === 'ImportSpecifier' && item.imported != null, ), importSpecifier, ], j.literal(packageName), ); }); } export const createRenameFuncFor: ( component: string, importName: string, from: string, to: string, ) => (j: core.JSCodeshift, source: Collection) => void = (component: string, importName: string, from: string, to: string) => (j: core.JSCodeshift, source: Collection) => { const specifier = getNamedSpecifier(j, source, component, importName); if (!specifier) { return; } source.findJSXElements(specifier).forEach((element) => { getJSXAttributesByName(j, element, from).forEach((attribute) => { j(attribute).replaceWith(j.jsxAttribute(j.jsxIdentifier(to), attribute.node.value)); }); }); // Ignoring this because it's causing false positives for the radio rename initiative // let variable = hasVariableAssignment(j, source, specifier); // if (variable) { // (variable as Collection) // .find(j.VariableDeclarator) // .forEach((declarator) => { // j(declarator) // .find(j.Identifier) // .filter((identifier) => identifier.name === 'id') // .forEach((ids) => { // findIdentifierAndReplaceAttribute(j, source, ids.node.name, from, to); // }); // }); // } }; export const createRemoveFuncFor: ( component: string, importName: string, prop: string, comment?: string, ) => (j: core.JSCodeshift, source: Collection) => void = (component: string, importName: string, prop: string, comment?: string) => (j: core.JSCodeshift, source: Collection) => { const specifier = getNamedSpecifier(j, source, component, importName) || getDefaultSpecifier(j, source, component); if (!specifier) { return; } source.findJSXElements(specifier).forEach((element) => { getJSXAttributesByName(j, element, prop).forEach((attribute) => { j(attribute).remove(); if (comment) { addCommentToStartOfFile({ j, base: source, message: comment }); } }); }); }; export const createRenameImportFor: ({ componentName, newComponentName, oldPackagePath, newPackagePath, }: { componentName: string; newComponentName?: string; oldPackagePath: string; newPackagePath: string; }) => (j: core.JSCodeshift, source: Collection) => void = ({ componentName, newComponentName, oldPackagePath, newPackagePath, }: { componentName: string; newComponentName?: string; oldPackagePath: string; newPackagePath: string; }) => (j: core.JSCodeshift, source: Collection) => { const isUsingName: boolean = source .find(j.ImportDeclaration) .filter((path) => path.node.source.value === oldPackagePath) .find(j.ImportSpecifier) .nodes() .filter((specifier) => specifier.imported && specifier.imported.name === componentName) .length > 0; if (!isUsingName) { return; } const existingAlias: Nullable = source .find(j.ImportDeclaration) .filter((path) => path.node.source.value === oldPackagePath) .find(j.ImportSpecifier) .nodes() .map((specifier): Nullable => { if (specifier.imported && specifier.imported.name !== componentName) { return null; } // If aliased: return the alias if (specifier.local && specifier.local.name !== componentName) { return specifier.local.name; } return null; }) .filter(Boolean)[0] || null; // Check to see if need to create new package path // Try create an import declaration just before the old import tryCreateImport({ j, base: source, relativeToPackage: oldPackagePath, packageName: newPackagePath, }); const newSpecifier: ImportSpecifier | ImportDefaultSpecifier = (() => { // If there's a new name use that if (newComponentName) { return j.importSpecifier(j.identifier(newComponentName), j.identifier(newComponentName)); } if (existingAlias) { return j.importSpecifier(j.identifier(componentName), j.identifier(existingAlias)); } // Add specifier return j.importSpecifier(j.identifier(componentName), j.identifier(componentName)); })(); addToImport({ j, base: source, importSpecifier: newSpecifier, packageName: newPackagePath, }); // Remove old path source .find(j.ImportDeclaration) .filter((path) => path.node.source.value === oldPackagePath) .remove(); }; export const createRemoveImportsFor: ({ importsToRemove, packagePath, comment, }: { importsToRemove: string[]; packagePath: string; comment: string; }) => (j: core.JSCodeshift, source: Collection) => void = ({ importsToRemove, packagePath, comment, }: { importsToRemove: string[]; packagePath: string; comment: string; }) => (j: core.JSCodeshift, source: Collection) => { const isUsingName: boolean = source.find(j.ImportDeclaration).filter((path) => path.node.source.value === packagePath) .length > 0; if (!isUsingName) { return; } const existingAlias: Nullable = source .find(j.ImportDeclaration) .filter((path) => path.node.source.value === packagePath) .find(j.ImportSpecifier) .nodes() .map((specifier): Nullable => { if (!importsToRemove.includes(specifier.imported.name)) { return null; } // If aliased: return the alias if (specifier.local && !importsToRemove.includes(specifier.local.name)) { return specifier.local.name; } return null; }) .filter(Boolean)[0] || null; // Remove imports source .find(j.ImportDeclaration) .filter((path) => path.node.source.value === packagePath) .find(j.ImportSpecifier) .find(j.Identifier) .filter((identifier) => { if ( importsToRemove.includes(identifier.value.name) || identifier.value.name === existingAlias ) { addCommentToStartOfFile({ j, base: source, message: comment }); return true; } return false; }) .remove(); // Remove entire import if it is empty const isEmptyImport = source .find(j.ImportDeclaration) .filter((path) => path.node.source.value === packagePath) .find(j.ImportSpecifier) .find(j.Identifier).length === 0; if (isEmptyImport) { source .find(j.ImportDeclaration) .filter((path) => path.node.source.value === packagePath) .remove(); } }; export const createTransformer: ( component: string, migrates: { (j: core.JSCodeshift, source: Collection): void; }[], ) => (fileInfo: FileInfo, api: API, options: Options) => string = (component: string, migrates: { (j: core.JSCodeshift, source: Collection): void }[]) => (fileInfo: FileInfo, { jscodeshift: j }: API, options: Options) => { const source: Collection = j(fileInfo.source); if (!hasImportDeclaration(j, source, component)) { return fileInfo.source; } migrates.forEach((tf) => tf(j, source)); return source.toSource(options.printOptions || { quote: 'single' }); };