diff --git a/package.json b/package.json index ef5a18fe..6e174642 100644 --- a/package.json +++ b/package.json @@ -166,6 +166,7 @@ "semver": "^7.3.8", "string-dedent": "^3.0.1", "ts-expose-internals": "^4.9.3", + "ts-react-hooks-tools": "0.1.17", "ts-simple-type": "^1.0.7", "unleashed-typescript": "^1.3.0", "vscode-framework": "^0.0.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a92fc52..48d8e915 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,7 @@ importers: semver: ^7.3.8 string-dedent: ^3.0.1 ts-expose-internals: ^4.9.3 + ts-react-hooks-tools: 0.1.17 ts-simple-type: ^1.0.7 tsm: ^2.3.0 type-fest: ^2.13.1 @@ -98,6 +99,7 @@ importers: semver: 7.3.8 string-dedent: 3.0.1 ts-expose-internals: 4.9.3 + ts-react-hooks-tools: 0.1.17 ts-simple-type: 1.0.7 unleashed-typescript: 1.3.0_typescript@4.9.3 vscode-framework: 0.0.18_37hwwvsstfvtglssqblfqfuf2i @@ -647,6 +649,13 @@ packages: '@types/node': 16.11.21 dev: true + /@types/glob/7.2.0: + resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} + dependencies: + '@types/minimatch': 5.1.2 + '@types/node': 16.18.3 + dev: false + /@types/glob/8.0.0: resolution: {integrity: sha512-l6NQsDDyQUVeoTynNpC9uRvCUint/gSUXQA2euwmTuWGvPY5LSDUu6tkCtJB2SvGQlJQzLaKqcGZP4//7EDveA==} dependencies: @@ -686,6 +695,10 @@ packages: resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} dev: false + /@types/mocha/8.2.3: + resolution: {integrity: sha512-ekGvFhFgrc2zYQoX4JeZPmVzZxw6Dtllga7iGHzfbYIYkAMUx/sAFP2GdFpLff+vdHXu5fl7WX9AT+TtqYcsyw==} + dev: false + /@types/mocha/9.1.1: resolution: {integrity: sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==} dev: false @@ -1192,6 +1205,11 @@ packages: engines: {node: '>=6'} dev: false + /ansi-regex/3.0.1: + resolution: {integrity: sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==} + engines: {node: '>=4'} + dev: false + /ansi-regex/4.1.1: resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} engines: {node: '>=6'} @@ -1577,6 +1595,21 @@ packages: yargs: 13.3.2 dev: false + /chokidar/3.5.1: + resolution: {integrity: sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.2 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.5.0 + optionalDependencies: + fsevents: 2.3.2 + dev: false + /chokidar/3.5.3: resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} engines: {node: '>= 8.10.0'} @@ -1808,6 +1841,19 @@ packages: ms: 2.1.3 dev: false + /debug/4.3.1_supports-color@8.1.1: + resolution: {integrity: sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + supports-color: 8.1.1 + dev: false + /debug/4.3.3: resolution: {integrity: sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==} engines: {node: '>=6.0'} @@ -3115,6 +3161,17 @@ packages: is-glob: 4.0.3 dev: false + /glob/7.1.6: + resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: false + /glob/7.2.0: resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} dependencies: @@ -3213,6 +3270,11 @@ packages: /graceful-fs/4.2.9: resolution: {integrity: sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==} + /growl/1.10.5: + resolution: {integrity: sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==} + engines: {node: '>=4.x'} + dev: false + /has-bigints/1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} dev: false @@ -3646,6 +3708,13 @@ packages: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} dev: false + /js-yaml/4.0.0: + resolution: {integrity: sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q==} + hasBin: true + dependencies: + argparse: 2.0.1 + dev: false + /js-yaml/4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -3913,6 +3982,13 @@ packages: /lodash/4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + /log-symbols/4.0.0: + resolution: {integrity: sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA==} + engines: {node: '>=10'} + dependencies: + chalk: 4.1.2 + dev: false + /log-symbols/4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} @@ -4149,6 +4225,38 @@ packages: yargs-unparser: 2.0.0 dev: false + /mocha/8.4.0: + resolution: {integrity: sha512-hJaO0mwDXmZS4ghXsvPVriOhsxQ7ofcpQdm8dE+jISUOKopitvnXFQmpRR7jd2K6VBG6E26gU3IAbXXGIbu4sQ==} + engines: {node: '>= 10.12.0'} + hasBin: true + dependencies: + '@ungap/promise-all-settled': 1.1.2 + ansi-colors: 4.1.1 + browser-stdout: 1.3.1 + chokidar: 3.5.1 + debug: 4.3.1_supports-color@8.1.1 + diff: 5.0.0 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 7.1.6 + growl: 1.10.5 + he: 1.2.0 + js-yaml: 4.0.0 + log-symbols: 4.0.0 + minimatch: 3.0.4 + ms: 2.1.3 + nanoid: 3.1.20 + serialize-javascript: 5.0.1 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + which: 2.0.2 + wide-align: 1.1.3 + workerpool: 6.1.0 + yargs: 16.2.0 + yargs-parser: 20.2.4 + yargs-unparser: 2.0.0 + dev: false + /modify-json-file/1.2.2: resolution: {integrity: sha512-4CwL/8zzn8oTxm2qM+gGNFGqGS201aDecHATlS6v7o74nHcGP78ciJ5JGqauVLUbsO1DjKgUJfSGL41XWkuzJA==} engines: {node: '>=12'} @@ -4194,6 +4302,12 @@ packages: resolution: {integrity: sha512-0ZIR9PasPxGXmRsEF8jsDzndzHDj7tIav+JUmvIFB/WHswliFnquxECT/De7GR4yg99ky/NlRKJT82G1y271bw==} dev: false + /nanoid/3.1.20: + resolution: {integrity: sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: false + /nanoid/3.2.0: resolution: {integrity: sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4827,6 +4941,13 @@ packages: util-deprecate: 1.0.2 dev: false + /readdirp/3.5.0: + resolution: {integrity: sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: false + /readdirp/3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -5004,6 +5125,12 @@ packages: upper-case-first: 2.0.2 dev: false + /serialize-javascript/5.0.1: + resolution: {integrity: sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==} + dependencies: + randombytes: 2.1.0 + dev: false + /serialize-javascript/6.0.0: resolution: {integrity: sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==} dependencies: @@ -5179,6 +5306,14 @@ packages: engines: {node: '>=0.12.0'} dev: false + /string-width/2.1.1: + resolution: {integrity: sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==} + engines: {node: '>=4'} + dependencies: + is-fullwidth-code-point: 2.0.0 + strip-ansi: 4.0.0 + dev: false + /string-width/3.1.0: resolution: {integrity: sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==} engines: {node: '>=6'} @@ -5234,6 +5369,13 @@ packages: safe-buffer: 5.1.2 dev: false + /strip-ansi/4.0.0: + resolution: {integrity: sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==} + engines: {node: '>=4'} + dependencies: + ansi-regex: 3.0.1 + dev: false + /strip-ansi/5.2.0: resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} engines: {node: '>=6'} @@ -5434,6 +5576,18 @@ packages: yn: 3.1.1 dev: false + /ts-react-hooks-tools/0.1.17: + resolution: {integrity: sha512-t2yL10rRwkQ41mBf4b374NPg6NgRUld7EevxENF19W3RuSGC6Yc2umR4pYWHMxhCaNdoMPWhHGjabUwknb0KEA==} + dependencies: + '@types/glob': 7.2.0 + '@types/mocha': 8.2.3 + glob: 7.2.0 + mocha: 8.4.0 + vscode-test: 1.6.1 + transitivePeerDependencies: + - supports-color + dev: false + /ts-simple-type/1.0.7: resolution: {integrity: sha512-zKmsCQs4dZaeSKjEA7pLFDv7FHHqAFLPd0Mr//OIJvu8M+4p4bgSFJwZSEBEg3ec9W7RzRz1vi8giiX0+mheBQ==} dev: false @@ -5991,6 +6145,19 @@ packages: resolution: {integrity: sha512-hHQV6iig+M21lTdItKPkJAaWrxALQb/nqpVffakO4knJOh3DrU2SXOMzUzNgo1eADPzu3qSsJY1weCzvR52q9A==} dev: false + /vscode-test/1.6.1: + resolution: {integrity: sha512-086q88T2ca1k95mUzffvbzb7esqQNvJgiwY4h29ukPhFo8u+vXOOmelUoU5EQUHs3Of8+JuQ3oGdbVCqaxuTXA==} + engines: {node: '>=8.9.3'} + deprecated: This package has been renamed to @vscode/test-electron, please update to the new name + dependencies: + http-proxy-agent: 4.0.1 + https-proxy-agent: 5.0.0 + rimraf: 3.0.2 + unzipper: 0.10.11 + transitivePeerDependencies: + - supports-color + dev: false + /vscode-uri/2.1.2: resolution: {integrity: sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==} dev: false @@ -6072,6 +6239,12 @@ packages: dependencies: isexe: 2.0.0 + /wide-align/1.1.3: + resolution: {integrity: sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==} + dependencies: + string-width: 2.1.1 + dev: false + /word-wrap/1.2.3: resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==} engines: {node: '>=0.10.0'} @@ -6081,6 +6254,10 @@ packages: resolution: {integrity: sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=} dev: false + /workerpool/6.1.0: + resolution: {integrity: sha512-toV7q9rWNYha963Pl/qyeZ6wG+3nnsyvolaNUS8+R5Wtw6qJPTxIlOP1ZSvcGhEJw+l3HMMmtiNo9Gl61G4GVg==} + dev: false + /workerpool/6.2.1: resolution: {integrity: sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==} dev: false diff --git a/src/configurationType.ts b/src/configurationType.ts index 9bdd3650..ce0f7529 100644 --- a/src/configurationType.ts +++ b/src/configurationType.ts @@ -581,6 +581,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+ = "[^ ]+" diff --git a/typescript/src/codeActions/custom/React/wrapIntoMemo.ts b/typescript/src/codeActions/custom/React/wrapIntoMemo.ts new file mode 100644 index 00000000..738877cd --- /dev/null +++ b/typescript/src/codeActions/custom/React/wrapIntoMemo.ts @@ -0,0 +1,115 @@ +import { CodeAction } from '../../getCodeActions' +import { findChildContainingKind, autoImportPackage, deepFindNode } from '../../../utils' +import {CustomizedLanguageService} from 'ts-react-hooks-tools/dist/service' +import {RefactorKind} from 'ts-react-hooks-tools/dist/types' + +let service: CustomizedLanguageService + +const getReactRefactoring = (languageService: ts.LanguageService, languageServiceHost: ts.LanguageServiceHost, fileName: string, range: ts.TextRange, full: boolean) => { + service ??= new CustomizedLanguageService({ + config: {}, + languageService, + languageServiceHost, + } as any, ts, { + log() { } + } as any, { config: {} } as any) + + const program = languageService.getProgram()! + return service.getInfo(range.pos, range.end, program.getSourceFile(fileName)!, program, full) +} + +/* + 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, languageServiceHost) { + if (!node) return + + function getPatchedRange(position: number): ts.TextRange | undefined { + // allow this activation range: [|const a|] = b + c + if (!node) return + if (ts.isIdentifier(node) && ts.isVariableDeclaration(node.parent)) node = node.parent.parent + if (!ts.isVariableDeclarationList(node)) return + const declarationName = node.declarations[0]?.name + if (!declarationName) return + if (position > declarationName.end) return + return node + } + + const patchedRange = position ? getPatchedRange(position) : range + if (!patchedRange) return + const info = getReactRefactoring(languageService, languageServiceHost, sourceFile.fileName, patchedRange, !!formatOptions) + // good position or range + if (info?.kind === RefactorKind.useMemo) { + if (!formatOptions) return true + const formatContext = tsFull.formatting.getFormatContext( + formatOptions, + languageServiceHost + ); + const textChangesContext = { + formatContext, + host: languageServiceHost, + preferences: {} // pass only if string factory is used + }; + const {edits: [edit]} = service.getEditsForConvertUseMemo(info, sourceFile, textChangesContext) + const hasReact = (sourceFile as FullSourceFile).identifiers.has('React') + // TODO patch output + return {edits: [edit!]} + } + + const checker = languageService.getProgram()!.getTypeChecker() + const type = checker.getTypeAtLocation(node) + const typeName = checker.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 diff --git a/typescript/src/codeActions/custom/React/wrapIntoUseCallback.ts b/typescript/src/codeActions/custom/React/wrapIntoUseCallback.ts new file mode 100644 index 00000000..08287bc1 --- /dev/null +++ b/typescript/src/codeActions/custom/React/wrapIntoUseCallback.ts @@ -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 diff --git a/typescript/src/codeActions/decorateProxy.ts b/typescript/src/codeActions/decorateProxy.ts index 0441e91b..57be5ae9 100644 --- a/typescript/src/codeActions/decorateProxy.ts +++ b/typescript/src/codeActions/decorateProxy.ts @@ -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 @@ -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')) { diff --git a/typescript/src/codeActions/extended/conditionalRendering.ts b/typescript/src/codeActions/extended/conditionalRendering.ts new file mode 100644 index 00000000..84f6e8d0 --- /dev/null +++ b/typescript/src/codeActions/extended/conditionalRendering.ts @@ -0,0 +1,46 @@ +import { ExtendedCodeAction } from '../getCodeActions' + +/* + Before:
+ After: { && (
)} +*/ +export default { + title: 'Render Conditionally (&&)', + kind: 'refactor.surround.conditionalRendering', + tryToApply({ position, node }) { + 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 { + snippetEdits: [ + { span: { start: wrapNode.getStart(), length: 0 }, newText: `$0 && (` }, + { span: { start: wrapNode.getEnd(), length: 0 }, newText: `)` }, + ], + edits: [], + } + } + + return { + snippetEdits: [ + { span: { start: wrapNode.getStart(), length: 0 }, newText: `{$0 && (` }, + { span: { start: wrapNode.getEnd(), length: 0 }, newText: `)}` }, + ], + edits: [], + } + }, +} satisfies ExtendedCodeAction diff --git a/typescript/src/codeActions/extended/conditionalRenderingTernary.ts b/typescript/src/codeActions/extended/conditionalRenderingTernary.ts new file mode 100644 index 00000000..5a3b190c --- /dev/null +++ b/typescript/src/codeActions/extended/conditionalRenderingTernary.ts @@ -0,0 +1,46 @@ +import { ExtendedCodeAction } from '../getCodeActions' + +/* + Before:
+ After: { ? (
) : null} +*/ +export default { + title: 'Render Conditionally (ternary)', + kind: 'refactor.surround.conditionalRenderingTernary', + tryToApply({ position, node }) { + 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 { + snippetEdits: [ + { span: { start: wrapNode.getStart(), length: 0 }, newText: `$0 ? (` }, + { span: { start: wrapNode.getEnd(), length: 0 }, newText: `) : null` }, + ], + edits: [], + } + } + + return { + snippetEdits: [ + { span: { start: wrapNode.getStart(), length: 0 }, newText: `{$0 ? (` }, + { span: { start: wrapNode.getEnd(), length: 0 }, newText: `) : null}` }, + ], + edits: [], + } + }, +} satisfies ExtendedCodeAction diff --git a/typescript/src/codeActions/extended/createPropsInterface.ts b/typescript/src/codeActions/extended/createPropsInterface.ts new file mode 100644 index 00000000..650332ce --- /dev/null +++ b/typescript/src/codeActions/extended/createPropsInterface.ts @@ -0,0 +1,82 @@ +import { text } from "stream/consumers"; +import { autoImportPackage, deepFindParrentNode, findChildContainingKind, getChangesTracker } from "../../utils"; +import { ExtendedCodeAction } from "../getCodeActions"; + +/* + Before: + const Component = () => {...}; + After: + interface Props {} + const Component: React.FC = () => {...} +*/ +export default { + title: 'Create Props Interface', + kind: 'refactor.rewrite.createPropsInterface', + tryToApply({ sourceFile, position, node, formatOptions, languageService }) { + if (!node || !position) return + + const typeChecker = languageService.getProgram()!.getTypeChecker() + const type = typeChecker.getTypeAtLocation(node) + const typeName = typeChecker.typeToString(type) + + const isReactTypeNode = ts.isIdentifier(node) && node.parent.getText().trim() === 'React.FC'; + + const isComponentNameNode = !isReactTypeNode && ts.isIdentifier(node) && /(FC<{}>|\) => Element|ReactElement<)/.test(typeName) + + if (!isReactTypeNode && !isComponentNameNode) { + return undefined; + } + + const reactComponent = deepFindParrentNode(node, (parrentNode) => ts.isVariableStatement(parrentNode)); + + if (!reactComponent) { + return undefined; + } + + const componentDeclarationNode = findChildContainingKind(reactComponent, ts.SyntaxKind.VariableDeclaration); + + if (!componentDeclarationNode) { + return undefined; + } + + const componentTypeNode = componentDeclarationNode.forEachChild((child) => ts.isTypeReferenceNode(child) && child); + + const newInterface = ts.factory.createInterfaceDeclaration(undefined, 'Props', undefined, undefined, []) + const interfaceChangesTracker = getChangesTracker(formatOptions); + interfaceChangesTracker.insertNodeBefore(sourceFile, reactComponent, newInterface, true) + const interfaceChanges = interfaceChangesTracker.getChanges()[0]?.textChanges[0]!; + + if (!componentTypeNode) { + const componentNameNode = componentDeclarationNode.forEachChild((child) => ts.isIdentifier(child) && child); + + if (!componentNameNode) { + return + } + + return { + edits: [ + { span: { start: componentNameNode.getEnd(), length: 0 }, newText: `: React.FC` }, + + ], + snippetEdits: [ + { + ...interfaceChanges, + newText: interfaceChanges.newText.replace('}', '\n $0 \n}') + } + ] + } + } + + return { + edits: [ + { span: { start: componentTypeNode.getEnd(), length: 0 }, newText: `` }, + ], + snippetEdits: [ + { + ...interfaceChanges, + newText: interfaceChanges.newText.replace('}', '\n $0 \n}') + } + ] + } + }, +} satisfies ExtendedCodeAction diff --git a/typescript/src/codeActions/getCodeActions.ts b/typescript/src/codeActions/getCodeActions.ts index fab96a21..5bcd5dba 100644 --- a/typescript/src/codeActions/getCodeActions.ts +++ b/typescript/src/codeActions/getCodeActions.ts @@ -7,8 +7,16 @@ import addMissingProperties from './extended/addMissingProperties' import { ApplyExtendedCodeActionResult, IpcExtendedCodeAction } from '../ipcTypes' import { Except } from 'type-fest' -const codeActions: CodeAction[] = [objectSwapKeysAndValues, changeStringReplaceToRegex, splitDeclarationAndInitialization] -const extendedCodeActions: ExtendedCodeAction[] = [addMissingProperties] +import conditionalRenderingTernary from './extended/conditionalRenderingTernary' +import wrapIntoMemo from './custom/react/wrapIntoMemo' +import wrapIntoUseCallback from './custom/react/wrapIntoUseCallback' +import createPropsInterface from './extended/createPropsInterface' +import conditionalRendering from './extended/conditionalRendering' +import { GetConfig } from '../types' + +const codeActions: CodeAction[] = [objectSwapKeysAndValues, changeStringReplaceToRegex, splitDeclarationAndInitialization, wrapIntoMemo] +const reactExtendedCodeActions: CodeAction[] = [wrapIntoMemo, wrapIntoUseCallback] +const extendedCodeActions: ExtendedCodeAction[] = [addMissingProperties, createPropsInterface, conditionalRendering, conditionalRenderingTernary] type SimplifiedRefactorInfo = | { @@ -102,12 +110,14 @@ 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) + const node = findChildContainingExactPosition(sourceFile, pos) + const appliableCodeActions = compact( codeActions.map(action => { const edits = action.tryToApply(sourceFile, pos, range, node, formatOptions, languageService, languageServiceHost) diff --git a/typescript/src/utils.ts b/typescript/src/utils.ts index 830d0e04..7f1ebb29 100644 --- a/typescript/src/utils.ts +++ b/typescript/src/utils.ts @@ -47,6 +47,146 @@ export function findChildContainingPositionMaxDepth(sourceFile: ts.SourceFile, p return find(sourceFile) } +export function findChildContainingKind(sourceNode: ts.Node, kind: ts.SyntaxKind) { + function find(node: ts.Node): ts.Node | undefined { + if (!node) { + return; + } + + if (node.kind === kind) { + return node; + } + + return ts.forEachChild(node, find); + } + + return find(sourceNode); +} + +export function deepFindNode(sourceNode: ts.Node, func: ((node: ts.Node) => boolean)) { + function find(node: ts.Node): ts.Node | undefined { + if (func(node)) { + return node; + } + + return ts.forEachChild(node, find); + } + + return find(sourceNode); +} + +export function findParrentNode(sourceNode: ts.Node, kind: ts.SyntaxKind) { + function find(node: ts.Node): ts.Node | undefined { + if (!node) { + return undefined; + } + + if (node.kind === kind) { + return node; + } + + return find(node.parent) + } + + return find(sourceNode); +} + +export function deepFindParrentNode(sourceNode: ts.Node, func: ((node: ts.Node) => boolean)) { + function find(node: ts.Node): ts.Node | undefined { + if (!node) { + return undefined; + } + + if (func(node)) { + return node; + } + + return find(node.parent) + } + + return find(sourceNode); +} + +export function autoImportPackage(sourceFile: ts.SourceFile, packageName: string, identifierName: string, isDefault?: boolean): ChangesTracker { + function find(node: ts.Node): ts.Node | ChangesTracker | undefined { + if (ts.isImportDeclaration(node)) { + const childrens = node.getChildren(); + const packageNameDeclaration = childrens.find(ts.isStringLiteral)?.getFullText().trim().slice(1, -1); + + if (packageNameDeclaration === packageName) { + const importClause = childrens.find(ts.isImportClause) + const namedImport = importClause?.getChildren().find(ts.isNamedImports); + + const importIdentifier = ts.factory.createIdentifier(identifierName); + const newImport = ts.factory.createImportSpecifier(false, undefined, importIdentifier); + const changesTracker = getChangesTracker({}) + + const isNamespaceImport = importClause?.getChildren().find(ts.isNamespaceImport); + + if (isNamespaceImport) { + // IDK what to do with namespace import + return changesTracker; + } + + if (!namedImport) { + const newNamedImport = ts.factory.createNamedImports([newImport]); + changesTracker.insertNodeAfterComma(sourceFile, importClause?.getChildren().at(-1)!, newNamedImport) + + return changesTracker; + } + + if (isDefault) { + const isDefaultImportExists = importClause?.getChildren().find(ts.isIdentifier); + + if (isDefaultImportExists) { + return changesTracker; + } + + changesTracker.insertNodeBefore(sourceFile, importClause?.getChildren().at(0)!, importIdentifier); + + return changesTracker; + } + + const existingImports = namedImport.getChildren()[1]!.getChildren() + + if (existingImports.map(existingImport => existingImport.getFullText().trim()).includes(identifierName)) { + return changesTracker; + } + + const lastImport = existingImports.at(-1) + + changesTracker.insertNodeInListAfter(sourceFile, lastImport!, newImport) + + return changesTracker; + } + + } + + return ts.forEachChild(node, find); + } + + const result = find(sourceFile); + + // No package import + if (!result) { + const changesTracker = getChangesTracker({}) + + const importIdentifier = ts.factory.createIdentifier(identifierName); + const newImport = ts.factory.createImportSpecifier(false, undefined, importIdentifier); + const namedImport = ts.factory.createNamedImports([newImport]) + const importClause = isDefault ? ts.factory.createImportClause(false, importIdentifier, undefined) : ts.factory.createImportClause(false, undefined, namedImport); + const packageLiteral = ts.factory.createStringLiteral(packageName); + + const importDeclaration = ts.factory.createImportDeclaration(undefined, importClause, packageLiteral); + + changesTracker.insertNodeAtTopOfFile(sourceFile, importDeclaration, false); + + return changesTracker; + } + + return result as ChangesTracker; +} + export function getNodePath(sourceFile: ts.SourceFile, position: number): ts.Node[] { const nodes: ts.Node[] = [] function find(node: ts.Node): ts.Node | undefined {