react-hookify
Version:
A CLI tool to convert React class components into functional components with hooks
574 lines (514 loc) • 17.7 kB
JavaScript
/* eslint-disable max-statements */
/* eslint-disable complexity */
/* eslint-disable no-lonely-if */
function capitalize(str) {
//input 'dog'
const firstLeter = str[0].toUpperCase()
const newStr = firstLeter + str.slice(1, str.length)
return newStr
//output 'Dog'
}
function hookifyPath(pathStr) {
/* '/public/client/app.js' */
let finalPath = pathStr
const slicingIdx = pathStr.lastIndexOf('.')
finalPath = pathStr.slice(0, slicingIdx)
const fileType = pathStr.slice(slicingIdx)
return `${finalPath}_Hookified${fileType}`
/* '/public/client/app_Hookified.js' */
}
function oldifyPath(pathStr) {
/* '/public/client/app.js' */
let finalPath = pathStr
const slicingIdx = pathStr.lastIndexOf('.')
finalPath = pathStr.slice(0, slicingIdx)
const fileType = pathStr.slice(slicingIdx)
return `${finalPath}_oldCopy${fileType}`
/* '/public/client/app_oldCopy.js' */
}
function addAsyncTag(arrOfGenericMethods) {
for (let idx = 0; idx < arrOfGenericMethods.length; idx++) {
if (arrOfGenericMethods[idx].includes('await')) {
arrOfGenericMethods[idx] = 'async ' + arrOfGenericMethods[idx]
}
}
}
function parseReactImport(classDetails, arrOfUseEffects, handledConstructor) {
const strBeforeClassDeclaration = classDetails.beforeClass
const startIdx = strBeforeClassDeclaration.search(
/(import)(\WReact)(.*?)(react)/
)
const endIdx = strBeforeClassDeclaration.indexOf('react', startIdx) + 6
const importToReplace = strBeforeClassDeclaration.slice(startIdx, endIdx)
let imports = []
if (/\=\WuseState([ ]*)\((.*?)\)/.test(handledConstructor)) {
imports.push('useState')
}
if (arrOfUseEffects.length) {
imports.push('useEffect')
}
if (importToReplace.includes('Fragment')) {
imports.push('Fragment')
}
imports = imports.length ? `, {${imports.join(', ')}}` : ''
const importTemplate = `import React${imports} from 'react'`
return strBeforeClassDeclaration.replace(importToReplace, importTemplate)
}
function extractControlledFormMethodNames(methodsArr, handledRender) {
const arrOfControlMethods = []
for (let methodIdx = 0; methodIdx < methodsArr.length; methodIdx++) {
if (/(this.setState)\s*\(\s*\{\s*\[.*?\]/.test(methodsArr[methodIdx])) {
const funcName = methodsArr[methodIdx]
.match(/(function)(.*?)(\()/)[2]
.trim()
if (handledRender.includes('<form') && handledRender.includes(funcName)) {
arrOfControlMethods.push(funcName)
methodsArr[methodIdx] = ''
} else {
const toCommentOut = methodsArr[methodIdx].match(
/(this.setState\([^\)]+\))/
)[1]
methodsArr[methodIdx] = methodsArr[methodIdx].replace(
toCommentOut,
`/*\n\tThis is tricky to translate to hooks and would require some manual refactoring\n\t*/\n/*${toCommentOut}*/`
)
}
}
}
return replaceControlledMethods(handledRender, arrOfControlMethods)
}
function replaceControlledMethods(handledRender, arrOfControlMethods) {
if (arrOfControlMethods.length === 0) return handledRender
const arrOfElements = handledRender.match(/<[^\>]+(>)/g)
let elementsToModify = []
for (
let controlMethodIdx = 0;
controlMethodIdx < arrOfControlMethods.length;
controlMethodIdx++
) {
const currentFuncName = arrOfControlMethods[controlMethodIdx]
for (let idx = 0; idx < arrOfElements.length; idx++) {
if (
arrOfElements[idx].includes('name') &&
arrOfElements[idx].includes('onChange') &&
arrOfElements[idx].includes(currentFuncName)
) {
const currentElementStr = arrOfElements[idx]
const nameOfInput = currentElementStr.match(
/name\s*=\s*[\"\']([0-9a-z_]+)[\"\']/i
)[1]
const typeOfInput = currentElementStr.match(
/type\s*=\s*[\"\']([0-9a-z_]+)[\"\']/i
)[1]
let moddedElement
if (typeOfInput === 'checkbox') {
moddedElement = currentElementStr.replace(
`this.${currentFuncName}`,
`(event) => set${capitalize(nameOfInput)}(event.target.checked)`
)
} else {
moddedElement = currentElementStr.replace(
`this.${currentFuncName}`,
`(event) => set${capitalize(nameOfInput)}(event.target.value)`
)
}
elementsToModify.push({
current: currentElementStr,
modded: moddedElement,
})
}
}
}
for (let idx = 0; idx < elementsToModify.length; idx++) {
const elementPair = elementsToModify[idx]
handledRender = handledRender.replace(
elementPair.current,
elementPair.modded
)
}
return handledRender
}
//replaces all instances of .setState
function replaceSetState(classAsString) {
function escapeRegExp(string) {
return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&')
}
let replacedStr = classAsString
while (replacedStr.search(/(>)([ \t\n]*)(this.setState)/g) !== -1) {
//this whole while loop is to account for implicit returns
let startIdx = replacedStr.search(/(>)([ \t\n]*)(this.setState)/g) + 1
let openIdx = replacedStr.indexOf('(', startIdx)
let endIdx = openIdx + 1
let funcSlice = replacedStr.slice(openIdx, endIdx)
while (!validBraces(funcSlice)) {
endIdx++
funcSlice = replacedStr.slice(openIdx, endIdx)
}
let strToReplace = replacedStr.slice(startIdx, endIdx)
strToReplace = escapeRegExp(strToReplace)
let regex = new RegExp(`(>[ \\s]*)(${strToReplace.trim()})`, 'g')
let replacer = function replacer(match, p1, p2) {
p2 = `{${p2}}`
return [p1, p2].join('')
}
replacedStr = replacedStr.replace(regex, replacer)
}
while (replacedStr.search(/((?<!\/\*)(this.setState))/) > -1) {
const asyncSetState = replacedStr.match(/(\S*)\s?(this\.setState)/)
const isAsync = asyncSetState[1] === 'await'
let startIdx = replacedStr.search(/((?<!\/\*)(this.setState))/)
const lookBehind = replacedStr.slice(startIdx - 4, startIdx)
if (lookBehind.includes('/*')) {
continue
}
let endIdx = getEndIdx(/((?<!\/\*)(this.setState))/, replacedStr) + startIdx
if (isAsync) {
startIdx = asyncSetState.index
}
// let endIdx = getEndIdxOfFunc(replacedStr, 'this.setState') + 1
let strToReplace = replacedStr.slice(startIdx, endIdx)
if (strToReplace.trim() === '') {
strToReplace = replacedStr.match(/(this.setState\([^\)]+\))/)[1]
replacedStr = replacedStr.replace(
strToReplace,
`/*\n\tThis is tricky to translate to hooks and would require some manual refactoring\n\t*/\n/*${strToReplace}*/`
)
continue
}
let awaitStr = isAsync ? 'await' : ''
let setStateInsides = getInsideOfFuncRegex(
replacedStr,
/((?<!\/\*)(this.setState))/
)
let arrOfProps = parseSetStateIntoArr(setStateInsides)
let replacementStr = arrOfProps
.map((prop) => `${awaitStr} set${capitalize(prop[0])}(${prop[1]})`)
.join(';\n')
replacedStr = replacedStr.replace(strToReplace, replacementStr)
}
return replacedStr
}
function getInsideOfFuncRegex(string, regex) {
let startIdx = string.search(regex)
if (startIdx === -1) return
let argsToSkip = 0
if (regex !== 'this.setState' && regex !== 'this.state') {
// let funcArgs = string.slice(startIdx).match(/(\((.*?)\))/)
let funcArgs = string
.slice(startIdx)
.match(/(\((.*?)\))([ ]*)(=>)?([ ]*)({)/)
argsToSkip = funcArgs ? funcArgs[0].length : 0
}
startIdx = string.indexOf('{', startIdx + argsToSkip)
let endIdx = startIdx + 1
let funcSlice = string.slice(startIdx, endIdx)
while (!validBraces(funcSlice)) {
endIdx++
funcSlice = string.slice(startIdx, endIdx)
}
return funcSlice.slice(1, funcSlice.length - 2)
}
function parseObjIntoArr(objInsides) {
/*
INPUT:
"firstName: 'bob',
lastName: 'snob',
friends: ['joe', 'shmoe'],"
OUTPUT:
[[firstName, 'bob'], [lastName, 'snob'], [friends, ['joe','shmoe']]]
*/
function storeNestedObjects(stateInsides, storage) {
if (!stateInsides.includes('{')) {
//might need to be more specific here
return stateInsides
} else {
const startIdx = stateInsides.indexOf('{')
let endIdx = startIdx + 1
let funcSlice = stateInsides.slice(startIdx, endIdx)
while (!validBraces(funcSlice)) {
endIdx++
funcSlice = stateInsides.slice(startIdx, endIdx)
}
let objToStore = funcSlice.slice(0, funcSlice.length)
storage.push(objToStore)
let newState = stateInsides.replace(objToStore, `|?$|props`)
return storeNestedObjects(newState, storage)
}
}
if (!objInsides) {
return undefined
}
let storage = []
if (/{\s*\S*:/g.test(objInsides)) {
objInsides = storeNestedObjects(objInsides, storage)
}
//splits our state into an array
const arrOfProps = objInsides
.split(/,(?=\s+\S+:)/)
.map((singleState) => singleState.trim().split(':'))
//if obj had some nested objects, give them back here
.map((singleState) => {
if (singleState[1].includes('|?$|props')) {
singleState[1] = storage.shift()
}
return singleState
})
return arrOfProps
}
function parseSetStateIntoArr(objInsides) {
/*
INPUT:
"firstName: 'bob',
lastName: 'snob',
friends: ['joe', 'shmoe'],"
OUTPUT:
[[firstName, 'bob'], [lastName, 'snob'], [friends, ['joe','shmoe']]]
*/
function storeNestedObjects(stateInsides, storage) {
if (!stateInsides.includes('{')) {
//might need to be more specific here
return stateInsides
} else {
const startIdx = stateInsides.indexOf('{')
let endIdx = startIdx + 1
let funcSlice = stateInsides.slice(startIdx, endIdx)
while (!validBraces(funcSlice)) {
endIdx++
funcSlice = stateInsides.slice(startIdx, endIdx)
}
let objToStore = funcSlice.slice(0, funcSlice.length)
storage.push(objToStore)
let newState = stateInsides.replace(objToStore, `|?$|props`)
return storeNestedObjects(newState, storage)
}
}
let storage = []
if (/{\s*\S*:/g.test(objInsides)) {
objInsides = storeNestedObjects(objInsides, storage)
}
//splits our state into an array
const arrOfProps = objInsides
.split(/,(?=\s+\S+:)/)
.map((singleState) => {
if (!singleState.includes(':')) {
return `${singleState}:${singleState}`
}
return singleState
})
.map((singleState) => singleState.trim().split(':'))
//if obj had some nested objects, give them back here
.map((singleState) => {
if (singleState[1].includes('|?$|props')) {
singleState[1] = storage.shift()
}
return singleState
})
return arrOfProps
}
// Used to determine when a function has ended (aka the braces are valid)
// This is used inside the next two functions
function validBraces(braces) {
let matches = { '(': ')', '{': '}', '[': ']' }
let stack = []
let currentChar
for (let i = 0; i < braces.length; i++) {
currentChar = braces[i]
if ('(){}[]'.includes(currentChar)) {
if (matches[currentChar]) {
// opening braces
stack.push(currentChar)
} else {
// closing braces
if (currentChar !== matches[stack.pop()]) {
return false
}
}
}
}
return stack.length === 0 // any unclosed braces left?
}
// Returns everything between the starting and ending brackets of a function, object, or class
// Example input: 'componentDidMount() { const x = 5 }'
// Example output: 'const x = 5'
function getInsideOfFunc(string, methodStr) {
let startIdx = string.indexOf(methodStr)
if (startIdx === -1) return
let argsToSkip = 0
if (methodStr !== 'this.setState' && methodStr !== 'this.state') {
// let funcArgs = string.slice(startIdx).match(/(\((.*?)\))/)
let funcArgs = string
.slice(startIdx)
.match(/(\((.*?)\))([ ]*)(=>)?([ ]*)({)/)
argsToSkip = funcArgs ? funcArgs[0].length : 0
}
startIdx = string.indexOf('{', startIdx + argsToSkip)
let endIdx = startIdx + 1
let funcSlice = string.slice(startIdx, endIdx)
while (!validBraces(funcSlice)) {
endIdx++
funcSlice = string.slice(startIdx, endIdx)
}
return funcSlice.slice(1, funcSlice.length - 2)
}
function getInsideOfLifeCycle(string, methodStr) {
let startIdx = string.indexOf(methodStr)
startIdx = string.indexOf('{', startIdx)
let endIdx = startIdx + 1
let funcSlice = string.slice(startIdx, endIdx)
while (!validBraces(funcSlice)) {
endIdx++
funcSlice = string.slice(startIdx, endIdx)
}
funcSlice = funcSlice
.slice(funcSlice.indexOf('return') + 6, funcSlice.length - 2)
.trim()
return funcSlice
}
// Very similar to the last function. Returns the index of when the function ends
// Example input: 'componentDidMount() { const x = 5 } '
// Example output: 35
function getEndIdxOfFunc(string, methodStr) {
let startIdx = string.indexOf(methodStr)
if (startIdx === -1) return
let argsToSkip = 0
if (methodStr !== 'this.setState' && methodStr !== 'this.state') {
// let funcArgs = string.slice(startIdx).match(/(\((.*?)\))/)
let funcArgs = string
.slice(startIdx)
.match(/(\((.*?)\))([ ]*)(=>)?([ ]*)({)/)
argsToSkip = funcArgs ? funcArgs[0].length : 0
}
startIdx = string.indexOf('{', startIdx + argsToSkip)
let endIdx = startIdx + 1
let funcSlice = string.slice(startIdx, endIdx)
while (!validBraces(funcSlice)) {
endIdx++
funcSlice = string.slice(startIdx, endIdx)
}
return endIdx
}
// more modular version of previosu functions (supports '{([')
function getEndIdxOfBraces(string) {
let startIdx = string.search(/[{[(]/)
let endIdx = startIdx + 1
let funcSlice = string.slice(startIdx, endIdx)
while (!validBraces(funcSlice)) {
endIdx++
funcSlice = string.slice(startIdx, endIdx)
}
return endIdx
}
function getEndIdx(regexTarget, fullStr) {
const startIdx = fullStr.search(regexTarget)
const targetStr = fullStr.substring(startIdx)
return getEndIdxOfBraces(targetStr)
}
// returns the body of the class component (aka everything after the constructor,
// or everything after Component { if there is no constructor
function getBody(classDetails) {
let genericMethods = []
const { classCompInString, startOfBodyIdx } = classDetails
let endIdx = getEndIdxOfFunc(classCompInString, 'Component') - 1 // add in React.Component Logic
const body = classCompInString.slice(startOfBodyIdx, endIdx).trim()
getBodyMethods(genericMethods, body)
genericMethods = removeLifecycles(genericMethods)
addAsyncTag(genericMethods)
return { body, genericMethods }
}
// Iterates through the body and adds all of the methods to an array called funcs.
// The methods are put into array as strings
// Example input: 'componentDidMount() { const x = 5 } \n method2() { const y = 6 }'
// Example output: ['componentDidMount() { const x = 5 }', 'method2() { const y = 6 }']
function getBodyMethods(funcs, bodyStr) {
// find first non-white space (aka function starting index)
let nextFuncIdx = bodyStr.search(/\S/)
if (nextFuncIdx === -1) {
return
}
let newBody = bodyStr.substring(nextFuncIdx)
// if we find a comment, need to skip over it!
// comment type: //
if (newBody.slice(0, 2) === '//') {
let newStartIdx = newBody.indexOf('\n') + 1
newBody = newBody.substring(newStartIdx)
getBodyMethods(funcs, newBody)
}
// comment type: /* */
else if (newBody.slice(0, 2) === '/*') {
let newStartIdx = newBody.indexOf('*/') + 2
newBody = newBody.substring(newStartIdx)
getBodyMethods(funcs, newBody)
}
// if we find an actual method:
else {
let funcNameModified
let funcName = newBody.slice(0, newBody.indexOf('('))
if (funcName.slice(0, 5) === 'async') {
funcName = funcName.substring(6)
}
if (funcName.includes('=')) {
funcNameModified = funcName.replace('=', '')
funcNameModified = funcNameModified.replace('async', '')
} else {
funcNameModified = funcName
}
let funcArgs = newBody.slice(newBody.indexOf('(') + 1, newBody.indexOf(')'))
let inside = getInsideOfFunc(bodyStr, `${funcName}`)
let endOfFuncIdx = getEndIdxOfFunc(bodyStr, `${funcName}`)
newBody = bodyStr.substring(endOfFuncIdx)
let funcStringified = `function ${funcNameModified}(${funcArgs}) {
${inside}
}`
if (funcNameModified.toLowerCase() !== 'render') {
funcs.push(funcStringified)
}
/*
Will need this to handle get/set/static
if (
funcName.toLowerCase() !== 'render' &&
funcName.toLowerCase() === 'componentdidmount' &&
funcName.toLowerCase() === 'componentdidupdate' &&
funcName.toLowerCase() === 'componentDidMount'
) {
funcs.push(funcStringified)
}
if (
funcName.includes('componentDidMount') ||
funcName.includes('componentDidUpdate') ||
funcName.includes('componentWillMount')
) {
lifecyclesArr.push(funcStringified.slice(8))
}
*/
getBodyMethods(funcs, newBody)
}
}
function removeLifecycles(funcs) {
return funcs.filter((lifecycle) => {
let name = lifecycle.slice(9, lifecycle.indexOf('('))
let testArr = [
'componentDidMount',
'componentDidUpdate',
'componentWillUnmount',
]
return !testArr.includes(name)
})
}
module.exports = {
getInsideOfLifeCycle,
addAsyncTag,
capitalize,
hookifyPath,
oldifyPath,
getBodyMethods,
getBody,
getEndIdxOfFunc,
getInsideOfFunc,
validBraces,
parseObjIntoArr,
replaceSetState,
getEndIdxOfBraces,
parseReactImport,
removeLifecycles,
extractControlledFormMethodNames,
}