Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add code actions for React #112

Open
wants to merge 12 commits into
base: develop
Choose a base branch
from
6 changes: 6 additions & 0 deletions src/configurationType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,12 @@ export type Configuration = {
typeAlias: string
interface: string
}
/**
* Enable code actions for React
*
* @default true
*/
'codeActions.enableReactCodeActions': boolean
}

// scrapped using search editor. config: caseInsesetive, context lines: 0, regex: const fix\w+ = "[^ ]+"
Expand Down
37 changes: 37 additions & 0 deletions typescript/src/codeActions/custom/React/conditionalRendering.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { CodeAction } from '../../getCodeActions'

/*
Before: <div></div>
After: { && (<div></div>)}
*/
export default {
id: 'conditionalRendering',
name: 'Wrap into Condition',
zardoy marked this conversation as resolved.
Show resolved Hide resolved
kind: 'refactor.rewrite.conditionalRendering',
zardoy marked this conversation as resolved.
Show resolved Hide resolved
tryToApply(sourceFile, position, _range, node, formatOptions) {
if (!node || !position) return

const isSelection = ts.isJsxOpeningElement(node);
const isSelfClosingElement = node.parent && ts.isJsxSelfClosingElement(node.parent);

if (!isSelection && !isSelfClosingElement && (!ts.isIdentifier(node) || (!ts.isJsxOpeningElement(node.parent) && !ts.isJsxClosingElement(node.parent)))) {
return;
}

const wrapNode = isSelection || isSelfClosingElement ? node.parent : node.parent.parent;

const isTopJsxElement = ts.isJsxElement(wrapNode.parent)

if (!isTopJsxElement) {
return [
{ start: wrapNode.getStart(), length: 0, newText: ` && (` },
{ start: wrapNode.getEnd(), length: 0, newText: `)` },
]
}

return [
{ start: wrapNode.getStart(), length: 0, newText: `{ && (` },
{ start: wrapNode.getEnd(), length: 0, newText: `)}` },
]
},
} satisfies CodeAction
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { CodeAction } from '../../getCodeActions'

/*
Before: <div></div>
After: { ? (<div></div>) : null}
*/
export default {
id: 'conditionalRenderingTernary',
name: 'Wrap into Condition (ternary)',
kind: 'refactor.rewrite.conditionalRenderingTernary',
tryToApply(sourceFile, position, _range, node, formatOptions) {
if (!node || !position) return

const isSelection = ts.isJsxOpeningElement(node);
const isSelfClosingElement = node.parent && ts.isJsxSelfClosingElement(node.parent);

if (!isSelection && !isSelfClosingElement && (!ts.isIdentifier(node) || (!ts.isJsxOpeningElement(node.parent) && !ts.isJsxClosingElement(node.parent)))) {
return;
}

const wrapNode = isSelection || isSelfClosingElement ? node.parent : node.parent.parent;

const isTopJsxElement = ts.isJsxElement(wrapNode.parent)

if (!isTopJsxElement) {
return [
{ start: wrapNode.getStart(), length: 0, newText: ` ? (` },
{ start: wrapNode.getEnd(), length: 0, newText: `) : null` },
]
}

return [
{ start: wrapNode.getStart(), length: 0, newText: `{ ? (` },
{ start: wrapNode.getEnd(), length: 0, newText: `) : null}` },
]
},
} satisfies CodeAction
57 changes: 57 additions & 0 deletions typescript/src/codeActions/custom/React/createPropsInterface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { autoImportPackage, getChangesTracker } from '../../../utils';
import { CodeAction } from '../../getCodeActions'

/*
Before:
const Component = () => {...};
After:
interface Props {}
const Component: React.FC<Props> = () => {...}
*/
export default {
id: 'createPropsInterface',
name: 'Create Props Interface',
kind: 'refactor.rewrite.createPropsInterface',
tryToApply(sourceFile, position, _range, node, formatOptions, languageService) {
if (!node || !position) return

const componentType = node.parent;

const typeChecker = languageService.getProgram()!.getTypeChecker()
const type = typeChecker.getTypeAtLocation(node)
const typeName = typeChecker.typeToString(type)


const isReactFCType = componentType?.parent && ts.isQualifiedName(componentType) && componentType.parent.getFullText().trim() === "React.FC";

const isComponentName = !isReactFCType && /(FC<{}>|\) => Element|ReactElement<)/.test(typeName)

if (!isReactFCType && !isComponentName) {
return undefined;
}

const reactComponent = isComponentName ? componentType.parent.parent : componentType.parent.parent.parent.parent;

if (!reactComponent || !ts.isVariableStatement(reactComponent)) {
return undefined;
}

const newInterface = ts.factory.createInterfaceDeclaration(undefined, 'Props', [], [], [])

const changesTracker = autoImportPackage(sourceFile, 'react', 'React', true);

changesTracker.insertNodeBefore(sourceFile, reactComponent, newInterface, true)

if (isComponentName) {
return [
{ start: node!.getEnd(), length: 0, newText: `: React.FC<Props>` },
...changesTracker.getChanges()[0]?.textChanges!
].filter(Boolean)
}

return [
{ start: componentType!.getEnd(), length: 0, newText: `<Props>` },
...changesTracker.getChanges()[0]?.textChanges!
].filter(Boolean)
},
} satisfies CodeAction
60 changes: 60 additions & 0 deletions typescript/src/codeActions/custom/React/generateJSXMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import pluralize from 'pluralize';
import { isIdentifier, SyntaxKind, TypeFlags } from 'typescript-full';
import { autoImportPackage, findChildContainingPosition, findParrentNode, getChangesTracker, getIndentFromPos } from '../../../utils';
import { CodeAction } from '../../getCodeActions'

/*
Before: {items}
After: {items.map((item) => <div key={item.id}> </div>)}
*/
export default {
id: 'createJSXMap',
name: 'Map this array to JSX',
kind: 'refactor.rewrite.createJSXMap',
tryToApply(sourceFile, position, _range, node, formatOptions, languageService) {
if (!node || !node.parent || !position) return

const prevNode = findChildContainingPosition(ts, sourceFile, position - 2);

if (prevNode && ts.isIdentifier(prevNode)) {
node = prevNode;
}

if (!ts.isIdentifier(node) || !findParrentNode(node, ts.SyntaxKind.JsxExpression)) {
return undefined;
}

const typeChecker = languageService.getProgram()!.getTypeChecker()
const type = typeChecker.getTypeAtLocation(node)


const typeName = typeChecker.typeToString(type)
const isArrayType = typeName.trim().endsWith('[]');

if (!isArrayType) {
return undefined;
}

const arrayType = type.getNumberIndexType();

if (!arrayType) {
return;
}

const isHasId = arrayType?.getProperties().some(key => key.name === 'id')

const varName = node.getFullText().trim().replace(/^(?:all)?(.+?)(?:List)?$/, '$1')
let inferredName = varName && pluralize.singular(varName)

if (inferredName === varName) {
inferredName = "item"
}

const indent = getIndentFromPos(ts, sourceFile, position)

return [
{ start: node.getEnd(), length: 0, newText: `.map((${inferredName}) => (\n${indent} <div${isHasId ? ` key={${inferredName}.id}` : ''}>\n${indent} \n${indent} </div>\n${indent}))` },
]

},
} satisfies CodeAction
66 changes: 66 additions & 0 deletions typescript/src/codeActions/custom/React/wrapIntoMemo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { CodeAction } from '../../getCodeActions'
import { findChildContainingKind, autoImportPackage, deepFindNode } from '../../../utils'

/*
Before: const Component = () => {...};
After: const Component = memo(() => {...})
*/
export default {
id: 'wrapIntoMemo',
name: 'Wrap into React Memo',
kind: 'refactor.rewrite.wrapIntoMemo',
tryToApply(sourceFile, position, _range, node, formatOptions, languageService) {
if (!node || !node.kind || !position) return

const typeChecker = languageService.getProgram()!.getTypeChecker()
const type = typeChecker.getTypeAtLocation(node)
const typeName = typeChecker.typeToString(type)

if (!/(FC<{}>|\) => Element|ReactElement<)/.test(typeName)) {
return undefined;
}

const reactComponent = findChildContainingKind(node!.parent, ts.SyntaxKind.Identifier);

const fileExport = findChildContainingKind(sourceFile, ts.SyntaxKind.ExportAssignment);
const isDefaultExport = fileExport?.getChildren().some((children) => children.kind === ts.SyntaxKind.DefaultKeyword);
const exportIdentifier = deepFindNode(fileExport!, (node) => node?.getFullText()?.trim() === reactComponent?.getFullText().trim());

const isAlreadyMemo = deepFindNode(fileExport!, (node) => node?.getFullText()?.trim() === "memo")

if (isAlreadyMemo) {
return undefined;
}

const changesTracker = autoImportPackage(sourceFile, 'react', 'memo');

if (isDefaultExport && exportIdentifier) {

return [
{ start: exportIdentifier!.getStart(), length: 0, newText: `memo(` },
{ start: exportIdentifier!.getEnd(), length: 0, newText: `)` },
changesTracker.getChanges()[0]?.textChanges[0]!
].filter(Boolean)
}

const func = (c) => {
if (c.getFullText().trim() === "memo") {
return c
}

return ts.forEachChild(c, func)
}

const componentFunction = node?.parent.getChildren().find(ts.isArrowFunction)

if (!componentFunction) {
return undefined;
}

return [
{ start: componentFunction!.getStart(), length: 0, newText: `memo(` },
{ start: componentFunction!.getEnd(), length: 0, newText: `)` },
changesTracker.getChanges()[0]?.textChanges[0]!
].filter(Boolean)
},
} satisfies CodeAction
49 changes: 49 additions & 0 deletions typescript/src/codeActions/custom/React/wrapIntoUseCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { CodeAction } from '../../getCodeActions'
import { autoImportPackage } from '../../../utils'

/*
Before: const func = () => value;
After: const func = useCallback(() => value, [])
*/
export default {
id: 'wrapIntoUseCallback',
name: 'Wrap into useCallback',
kind: 'refactor.rewrite.wrapIntoUseCallback',
tryToApply(sourceFile, position, _range, node, formatOptions, languageService) {
if (!node || !position) return

const [functionIdentifier, _, arrowFunction] = node.parent.getChildren()

if (!functionIdentifier || !arrowFunction ) {
return undefined;
}

if (!ts.isIdentifier(functionIdentifier) || !ts.isArrowFunction(arrowFunction)) {
return undefined
}

// Check is react component
const reactComponent = node?.parent?.parent?.parent?.parent?.parent?.parent

if (!reactComponent) {
return undefined;
}

const typeChecker = languageService.getProgram()!.getTypeChecker()
const type = typeChecker.getTypeAtLocation(reactComponent)
const typeName = typeChecker.typeToString(type)

if (!['FC<', '() => Element', 'ReactElement<'].some((el) => typeName.startsWith(el))) {
return undefined;
}

const changesTracker = autoImportPackage(sourceFile, 'react', 'useCallback');

return [
{ start: arrowFunction!.getStart(), length: 0, newText: `useCallback(` },
{ start: arrowFunction!.getEnd(), length: 0, newText: `, [])` },
changesTracker.getChanges()[0]?.textChanges[0]!
].filter(Boolean);

},
} satisfies CodeAction
4 changes: 2 additions & 2 deletions typescript/src/codeActions/decorateProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export default (proxy: ts.LanguageService, languageService: ts.LanguageService,

if (c('markTsCodeActions.enable')) prior = prior.map(item => ({ ...item, description: `🔵 ${item.description}` }))

const { info: refactorInfo } = getCustomCodeActions(sourceFile, positionOrRange, languageService, languageServiceHost)
const { info: refactorInfo } = getCustomCodeActions(sourceFile, positionOrRange, languageService, languageServiceHost, c)
if (refactorInfo) prior = [...prior, refactorInfo]

return prior
Expand All @@ -39,7 +39,7 @@ export default (proxy: ts.LanguageService, languageService: ts.LanguageService,
if (category === REFACTORS_CATEGORY) {
const program = languageService.getProgram()
const sourceFile = program!.getSourceFile(fileName)!
const { edit } = getCustomCodeActions(sourceFile, positionOrRange, languageService, languageServiceHost, formatOptions, actionName)
const { edit } = getCustomCodeActions(sourceFile, positionOrRange, languageService, languageServiceHost, c, formatOptions, actionName)
return edit
}
if (refactorName === 'Extract Symbol' && actionName.startsWith('function_scope')) {
Expand Down
18 changes: 17 additions & 1 deletion typescript/src/codeActions/getCodeActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import { findChildContainingPosition } from '../utils'
import objectSwapKeysAndValues from './custom/objectSwapKeysAndValues'
import changeStringReplaceToRegex from './custom/changeStringReplaceToRegex'
import splitDeclarationAndInitialization from './custom/splitDeclarationAndInitialization'
import conditionalRendering from "./custom/React/conditionalRendering";
import conditionalRenderingTernary from "./custom/React/conditionalRenderingTernary";
import wrapIntoMemo from "./custom/React/wrapIntoMemo";
import wrapIntoUseCallback from "./custom/React/wrapIntoUseCallback";
import createPropsInterface from "./custom/React/createPropsInterface";
import generateJSXMap from "./custom/React/generateJSXMap";
import { GetConfig } from '../types'

type SimplifiedRefactorInfo =
| {
Expand Down Expand Up @@ -31,7 +38,8 @@ export type CodeAction = {
tryToApply: ApplyCodeAction
}

const codeActions: CodeAction[] = [/* toggleBraces */ objectSwapKeysAndValues, changeStringReplaceToRegex, splitDeclarationAndInitialization]
const reactCodeActions: CodeAction[] = [conditionalRendering, conditionalRenderingTernary, wrapIntoMemo, wrapIntoUseCallback, createPropsInterface, generateJSXMap]
const JSCodeActions: CodeAction[] = [/* toggleBraces */ objectSwapKeysAndValues, changeStringReplaceToRegex, splitDeclarationAndInitialization]

export const REFACTORS_CATEGORY = 'essential-refactors'

Expand All @@ -40,12 +48,20 @@ export default (
positionOrRange: ts.TextRange | number,
languageService: ts.LanguageService,
languageServiceHost: ts.LanguageServiceHost,
config: GetConfig,
formatOptions?: ts.FormatCodeSettings,
requestingEditsId?: string,
): { info?: ts.ApplicableRefactorInfo; edit: ts.RefactorEditInfo } => {
const range = typeof positionOrRange !== 'number' && positionOrRange.pos !== positionOrRange.end ? positionOrRange : undefined
const pos = typeof positionOrRange === 'number' ? positionOrRange : positionOrRange.pos
const node = findChildContainingPosition(ts, sourceFile, pos)

let codeActions = [...JSCodeActions];

if (config('codeActions.enableReactCodeActions')) {
codeActions = [...codeActions, ...reactCodeActions];
}

const appliableCodeActions = compact(
codeActions.map(action => {
const edits = action.tryToApply(sourceFile, pos, range, node, formatOptions, languageService, languageServiceHost)
Expand Down
Loading