diff --git a/package.json b/package.json index 12053b0..8971103 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "jscodeshift": "jscodeshift" }, "dependencies": { + "@codemod.com/codemod-utils": "^1.0.0", + "@types/jscodeshift": "^0.12.0", "chalk": "^2.4.2", "eslint": "^6.6.0", "execa": "^3.2.0", @@ -22,7 +24,10 @@ }, "jest": { "globals": { - "baseDir": "../" + "baseDir": "../", + "ts-jest": { + "tsconfig": "./tsconfig.codemod.json" + } }, "testEnvironment": "node", "roots": [ @@ -32,13 +37,20 @@ "transform": { "^.+\\.jsx?$": "babel-jest", "^.+\\.tsx?$": "ts-jest" - } + }, + "transformIgnorePatterns": [ + "/node_modules/(?!@codemod\\.com/codemod-utils)/.+\\.js$" + ], + "moduleFileExtensions": ["js", "ts", "tsx", "jsx"], + "extensionsToTreatAsEsm": [".ts", ".tsx", ".js", ".jsx"] }, "devDependencies": { "@babel/core": "^7.6.4", "@babel/plugin-proposal-object-rest-spread": "^7.6.2", "@babel/preset-env": "^7.6.3", + "@jest/globals": "^29.7.0", "@types/jest": "^24.9.0", + "@types/node": "^22.12.0", "@typescript-eslint/parser": "^7.8.0", "babel-eslint": "^10.0.3", "babel-jest": "^24.9.0", diff --git a/transforms/__tests__/prop-types-typescript.codemod.test.ts b/transforms/__tests__/prop-types-typescript.codemod.test.ts new file mode 100644 index 0000000..ee5331b --- /dev/null +++ b/transforms/__tests__/prop-types-typescript.codemod.test.ts @@ -0,0 +1,540 @@ +/* @license +ISC License +Copyright (c) 2023, Mark Skelton +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ + +/* +Changes to the original file: changed tests structure +*/ + +import * as assert from "assert"; +import transform from "../prop-types-typescript.codemod"; +import * as jscodeshift from "jscodeshift"; +import type { API } from "jscodeshift"; + +const buildApi = (parser: string | undefined): API => ({ + j: parser ? jscodeshift.withParser(parser) : jscodeshift, + jscodeshift: parser ? jscodeshift.withParser(parser) : jscodeshift, + stats: () => { + console.error( + "The stats function was called, which is not supported on purpose" + ); + }, + report: () => { + console.error( + "The report function was called, which is not supported on purpose" + ); + }, +}); + +describe("ratchet", () => { + it("arrow-function", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport const MyComponent = (props) => {\n return \n}\n\nMyComponent.propTypes = {\n bar: PropTypes.string.isRequired,\n foo: PropTypes.number,\n}\n'; + + const OUTPUT = + 'import React from "react"\n\ninterface MyComponentProps {\n bar: string\n foo?: number\n}\n\nexport const MyComponent = (props: MyComponentProps) => {\n return \n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("class-component-static", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport class MyComponent extends React.Component {\n static propTypes = {\n bar: PropTypes.string.isRequired,\n foo: PropTypes.number,\n }\n\n render() {\n return \n }\n}\n'; + + const OUTPUT = + 'import React from "react"\n\ninterface MyComponentProps {\n bar: string\n foo?: number\n}\n\nexport class MyComponent extends React.Component {\n render() {\n return \n }\n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("class-component", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport class MyComponent extends React.Component {\n render() {\n return \n }\n}\n\nMyComponent.propTypes = {\n bar: PropTypes.string.isRequired,\n foo: PropTypes.number,\n}\n'; + + const OUTPUT = + 'import React from "react"\n\ninterface MyComponentProps {\n bar: string\n foo?: number\n}\n\nexport class MyComponent extends React.Component {\n render() {\n return \n }\n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("comments", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport function MyComponent(props) {\n return \n}\n\nMyComponent.propTypes = {\n /**\n * A string with a\n * wrapping comment.\n * @example "foo"\n */\n bar: PropTypes.string.isRequired,\n /**\n * Some function\n */\n foo: PropTypes.func,\n}\n'; + + const OUTPUT = + 'import React from "react"\n\ninterface MyComponentProps {\n /**\n * A string with a\n * wrapping comment.\n * @example "foo"\n */\n bar: string\n /**\n * Some function\n */\n foo?(...args: unknown[]): unknown\n}\n\nexport function MyComponent(props: MyComponentProps) {\n return \n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("complex-props", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport function MyComponent(props) {\n return \n}\n\nMyComponent.propTypes = {\n optionalArray: PropTypes.array,\n optionalBool: PropTypes.bool,\n optionalFunc: PropTypes.func,\n optionalNumber: PropTypes.number,\n optionalObject: PropTypes.object,\n optionalString: PropTypes.string,\n optionalSymbol: PropTypes.symbol,\n optionalNode: PropTypes.node,\n optionalElement: PropTypes.element,\n optionalElementType: PropTypes.elementType,\n optionalEnum: PropTypes.oneOf(["News", "Photos"]),\n optionalNumericEnum: PropTypes.oneOf([1, 2, 3]),\n optionalMixedEnum: PropTypes.oneOf([1, "Unknown", false, () => {}]),\n optionalUnknownEnum: PropTypes.oneOf(Object.keys(arr)),\n optionalUnion: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),\n optionalArrayOf: PropTypes.arrayOf(PropTypes.number),\n optionalObjectOf: PropTypes.objectOf(PropTypes.number),\n optionalInstanceOf: PropTypes.instanceOf(Message),\n optionalObjectWithShape: PropTypes.shape({\n optionalProperty: PropTypes.string,\n requiredProperty: PropTypes.number.isRequired,\n functionProperty: PropTypes.func,\n }),\n optionalObjectWithStrictShape: PropTypes.exact({\n optionalProperty: PropTypes.string,\n requiredProperty: PropTypes.number.isRequired,\n }),\n requiredArray: PropTypes.array.isRequired,\n requiredBool: PropTypes.bool.isRequired,\n requiredFunc: PropTypes.func.isRequired,\n requiredNumber: PropTypes.number.isRequired,\n requiredObject: PropTypes.object.isRequired,\n requiredString: PropTypes.string.isRequired,\n requiredSymbol: PropTypes.symbol.isRequired,\n requiredNode: PropTypes.node.isRequired,\n requiredElement: PropTypes.element.isRequired,\n requiredElementType: PropTypes.elementType.isRequired,\n requiredEnum: PropTypes.oneOf(["News", "Photos"]).isRequired,\n requiredUnion: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,\n requiredArrayOf: PropTypes.arrayOf(PropTypes.number).isRequired,\n requiredObjectOf: PropTypes.objectOf(PropTypes.number).isRequired,\n requiredInstanceOf: PropTypes.instanceOf(Message).isRequired,\n requiredObjectWithShape: PropTypes.shape({\n optionalProperty: PropTypes.string,\n requiredProperty: PropTypes.number.isRequired,\n }).isRequired,\n requiredObjectWithStrictShape: PropTypes.exact({\n optionalProperty: PropTypes.string,\n requiredProperty: PropTypes.number.isRequired,\n }).isRequired,\n}\n'; + + const OUTPUT = + 'import React from "react"\n\ninterface MyComponentProps {\n optionalArray?: unknown[]\n optionalBool?: boolean\n optionalFunc?(...args: unknown[]): unknown\n optionalNumber?: number\n optionalObject?: object\n optionalString?: string\n optionalSymbol?: symbol\n optionalNode?: React.ReactNode\n optionalElement?: React.ReactElement\n optionalElementType?: React.ElementType\n optionalEnum?: "News" | "Photos"\n optionalNumericEnum?: 1 | 2 | 3\n optionalMixedEnum?: 1 | "Unknown" | false | unknown\n optionalUnknownEnum?: unknown[]\n optionalUnion?: string | number\n optionalArrayOf?: number[]\n optionalObjectOf?: Record\n optionalInstanceOf?: Message\n optionalObjectWithShape?: {\n optionalProperty?: string\n requiredProperty: number\n functionProperty?(...args: unknown[]): unknown\n }\n optionalObjectWithStrictShape?: {\n optionalProperty?: string\n requiredProperty: number\n }\n requiredArray: unknown[]\n requiredBool: boolean\n requiredFunc(...args: unknown[]): unknown\n requiredNumber: number\n requiredObject: object\n requiredString: string\n requiredSymbol: symbol\n requiredNode: React.ReactNode\n requiredElement: React.ReactElement\n requiredElementType: React.ElementType\n requiredEnum: "News" | "Photos"\n requiredUnion: string | number\n requiredArrayOf: number[]\n requiredObjectOf: Record\n requiredInstanceOf: Message\n requiredObjectWithShape: {\n optionalProperty?: string\n requiredProperty: number\n }\n requiredObjectWithStrictShape: {\n optionalProperty?: string\n requiredProperty: number\n }\n}\n\nexport function MyComponent(props: MyComponentProps) {\n return \n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), { + "preserve-prop-types": "unconverted", + }); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("custom-validator", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport function MyComponent(props) {\n return \n}\n\nMyComponent.propTypes = {\n a: PropTypes.string,\n b: function () {},\n c: () => {},\n d: PropTypes.arrayOf(function() {}),\n e: PropTypes.arrayOf(() => {}),\n f: PropTypes.objectOf(function() {}),\n g: PropTypes.objectOf(() => {}),\n h: PropTypes.arrayOf(function() {}).isRequired,\n i: PropTypes.arrayOf(() => {}).isRequired,\n j: PropTypes.objectOf(function() {}).isRequired,\n k: PropTypes.objectOf(() => {}).isRequired\n}\n'; + + const OUTPUT = + 'import React from "react"\n\ninterface MyComponentProps {\n a?: string\n b?: unknown\n c?: unknown\n d?: unknown\n e?: unknown\n f?: unknown\n g?: unknown\n h: unknown\n i: unknown\n j: unknown\n k: unknown\n}\n\nexport function MyComponent(props: MyComponentProps) {\n return \n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("extended-props", () => { + const INPUT = + 'import BaseComponent from "./base"\nimport React from "react"\n\nexport function MyComponent(props) {\n return \n}\n\nMyComponent.propTypes = BaseComponent.propTypes\n'; + + const OUTPUT = + 'import BaseComponent from "./base"\nimport React from "react"\n\nexport function MyComponent(props) {\n return \n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("forward-ref-and-func", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React, { forwardRef } from "react"\n\nexport const MyComponent = forwardRef((props, ref) => {\n return \n})\n\nMyComponent.propTypes = {\n bar: PropTypes.string.isRequired,\n foo: PropTypes.number,\n}\n\nexport function ComponentA(props) {\n return \n}\n\nComponentA.propTypes = {\n a: PropTypes.string.isRequired,\n b: PropTypes.number,\n}\n'; + + const OUTPUT = + 'import React, { forwardRef } from "react"\n\ninterface MyComponentProps {\n bar: string\n foo?: number\n}\n\nexport const MyComponent = forwardRef((props, ref) => {\n return \n})\n\ninterface ComponentAProps {\n a: string\n b?: number\n}\n\nexport function ComponentA(props: ComponentAProps) {\n return \n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("forward-ref", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nconst MyComponent = React.forwardRef((props, ref) => {\n return \n})\n\nMyComponent.propTypes = {\n bar: PropTypes.string.isRequired,\n foo: PropTypes.number,\n}\n\nexport default MyComponent\n'; + + const OUTPUT = + 'import React from "react"\n\ninterface MyComponentProps {\n bar: string\n foo?: number\n}\n\nconst MyComponent = React.forwardRef((props, ref) => {\n return \n})\n\nexport default MyComponent\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("function-and-class", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport function ComponentA(props) {\n return \n}\n\nComponentA.propTypes = {\n a: PropTypes.string.isRequired,\n b: PropTypes.number,\n}\n\nexport class ComponentB extends React.Component {\n render() {\n return \n }\n}\n\nComponentB.propTypes = {\n c: PropTypes.array,\n d: PropTypes.object.isRequired,\n}\n'; + + const OUTPUT = + 'import React from "react"\n\ninterface ComponentAProps {\n a: string\n b?: number\n}\n\nexport function ComponentA(props: ComponentAProps) {\n return \n}\n\ninterface ComponentBProps {\n c?: unknown[]\n d: object\n}\n\nexport class ComponentB extends React.Component {\n render() {\n return \n }\n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("function-component", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport function MyComponent(props) {\n return \n}\n\nMyComponent.propTypes = {\n bar: PropTypes.string.isRequired,\n foo: PropTypes.number,\n}\n'; + + const OUTPUT = + 'import React from "react"\n\ninterface MyComponentProps {\n bar: string\n foo?: number\n}\n\nexport function MyComponent(props: MyComponentProps) {\n return \n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("literal-prop", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport function MyComponent(props) {\n return \n}\n\nMyComponent.propTypes = {\n \'data-testid\': PropTypes.string,\n}\n'; + + const OUTPUT = + "import React from \"react\"\n\ninterface MyComponentProps {\n 'data-testid'?: string\n}\n\nexport function MyComponent(props: MyComponentProps) {\n return \n}\n"; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("memo-export", () => { + const INPUT = + "import PropTypes from 'prop-types'\nimport React from 'react'\n\nexport const MyComponent = React.memo(function MyComponent(props) {\n return null\n})\n\nMyComponent.propTypes = {\n a: PropTypes.number\n}\n"; + + const OUTPUT = + "import React from 'react'\n\ninterface MyComponentProps {\n a?: number\n}\n\nexport const MyComponent = React.memo(function MyComponent(props: MyComponentProps) {\n return null\n})\n"; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("memo", () => { + const INPUT = + "import PropTypes from 'prop-types'\nimport React from 'react'\n\nconst MyComponent = React.memo(function MyComponent(props) {\n return null\n})\n\nMyComponent.propTypes = {\n a: PropTypes.number\n}\n"; + + const OUTPUT = + "import React from 'react'\n\ninterface MyComponentProps {\n a?: number\n}\n\nconst MyComponent = React.memo(function MyComponent(props: MyComponentProps) {\n return null\n})\n"; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("multiple-class-components-static", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport class ComponentA extends React.Component {\n static propTypes = {\n a: PropTypes.string.isRequired,\n b: PropTypes.number,\n }\n\n render() {\n return \n }\n}\n\nexport class ComponentB extends React.Component {\n static propTypes = {\n c: PropTypes.array,\n d: PropTypes.object.isRequired,\n }\n\n render() {\n return \n }\n}\n'; + + const OUTPUT = + 'import React from "react"\n\ninterface ComponentAProps {\n a: string\n b?: number\n}\n\nexport class ComponentA extends React.Component {\n render() {\n return \n }\n}\n\ninterface ComponentBProps {\n c?: unknown[]\n d: object\n}\n\nexport class ComponentB extends React.Component {\n render() {\n return \n }\n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("multiple-components", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport function ComponentA(props) {\n return \n}\n\nComponentA.propTypes = {\n a: PropTypes.string.isRequired,\n b: PropTypes.number,\n}\n\nexport function ComponentB(props) {\n return \n}\n\nComponentB.propTypes = {\n c: PropTypes.array,\n d: PropTypes.object.isRequired,\n}\n'; + + const OUTPUT = + 'import React from "react"\n\ninterface ComponentAProps {\n a: string\n b?: number\n}\n\nexport function ComponentA(props: ComponentAProps) {\n return \n}\n\ninterface ComponentBProps {\n c?: unknown[]\n d: object\n}\n\nexport function ComponentB(props: ComponentBProps) {\n return \n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("no-export", () => { + const INPUT = + "import PropTypes from 'prop-types'\nimport React from 'react'\n\nfunction MyComponent(props) {\n return null\n}\n\nMyComponent.propTypes = {\n a: PropTypes.number\n}\n"; + + const OUTPUT = + "import React from 'react'\n\ninterface MyComponentProps {\n a?: number\n}\n\nfunction MyComponent(props: MyComponentProps) {\n return null\n}\n"; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("no-prop-types", () => { + const INPUT = + 'import React from "react"\n\nexport function MyComponent(props) {\n return \n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual(actualOutput, undefined); + }); + + it("odd-required", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport const MyComponent = (props) => {\n return \n}\n\nMyComponent.propTypes = {\n a: PropTypes.arrayOf(PropTypes.shape({\n name: PropTypes.number.isRequired\n }).isRequired),\n b: PropTypes.objectOf(PropTypes.number.isRequired)\n}\n'; + + const OUTPUT = + 'import React from "react"\n\ninterface MyComponentProps {\n a?: {\n name: number\n }[]\n b?: Record\n}\n\nexport const MyComponent = (props: MyComponentProps) => {\n return \n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("preserve-none", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport function ComponentA(props) {\n return \n}\n\nComponentA.propTypes = {\n ...OtherComponent,\n a: PropTypes.string.isRequired,\n b() {}\n}\n\nexport function ComponentB(props) {\n return \n}\n\nComponentB.propTypes = {\n ...ThisComponent,\n c: PropTypes.number,\n d() {}\n}\n'; + + const OUTPUT = + 'import React from "react"\n\ninterface ComponentAProps {\n a: string\n b?: unknown\n}\n\nexport function ComponentA(props: ComponentAProps) {\n return \n}\n\ninterface ComponentBProps {\n c?: number\n d?: unknown\n}\n\nexport function ComponentB(props: ComponentBProps) {\n return \n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("preserve-prop-types", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport function MyComponent(props) {\n return \n}\n\nMyComponent.propTypes = {\n bar: PropTypes.string.isRequired,\n foo: PropTypes.number,\n}\n'; + + const OUTPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\ninterface MyComponentProps {\n bar: string\n foo?: number\n}\n\nexport function MyComponent(props: MyComponentProps) {\n return \n}\n\nMyComponent.propTypes = {\n bar: PropTypes.string.isRequired,\n foo: PropTypes.number,\n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), { + "preserve-prop-types": "all", + }); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("preserve-unconverted-shape", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport function MyComponent(props) {\n return \n}\n\nMyComponent.propTypes = {\n a: PropTypes.string,\n b: function () {},\n c: PropTypes.shape({\n d: PropTypes.bool\n })\n}\n'; + + const OUTPUT = + 'import React from "react"\n\ninterface MyComponentProps {\n a?: string\n b?: unknown\n c?: {\n d?: boolean\n }\n}\n\nexport function MyComponent(props: MyComponentProps) {\n return \n}\n\nMyComponent.propTypes = {\n b: function () {}\n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), { + "preserve-prop-types": "unconverted", + }); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("preserve-unconverted-static", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport class MyComponent extends React.Component {\n static propTypes = {\n bar: PropTypes.string.isRequired,\n foo() {}\n }\n\n render() {\n return \n }\n}\n'; + + const OUTPUT = + 'import React from "react"\n\ninterface MyComponentProps {\n bar: string\n foo?: unknown\n}\n\nexport class MyComponent extends React.Component {\n static propTypes = {\n foo() {}\n }\n\n render() {\n return \n }\n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), { + "preserve-prop-types": "unconverted", + }); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("preserve-unconverted", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport function MyComponent(props) {\n return \n}\n\nMyComponent.propTypes = {\n ...OtherComponent.propTypes,\n a: PropTypes.string,\n b: function () {},\n c: () => {},\n d: PropTypes.arrayOf(function() {}),\n e: PropTypes.arrayOf(() => {}),\n f: PropTypes.objectOf(function() {}),\n g: PropTypes.objectOf(() => {}),\n}\n'; + + const OUTPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\ninterface MyComponentProps {\n a?: string\n b?: unknown\n c?: unknown\n d?: unknown\n e?: unknown\n f?: unknown\n g?: unknown\n}\n\nexport function MyComponent(props: MyComponentProps) {\n return \n}\n\nMyComponent.propTypes = {\n ...OtherComponent.propTypes,\n b: function () {},\n c: () => {},\n d: PropTypes.arrayOf(function() {}),\n e: PropTypes.arrayOf(() => {}),\n f: PropTypes.objectOf(function() {}),\n g: PropTypes.objectOf(() => {})\n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), { + "preserve-prop-types": "unconverted", + }); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("spread-element", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport function MyComponent(props) {\n return \n}\n\nMyComponent.propTypes = {\n ...OtherComponent.propTypes,\n a: PropTypes.string,\n}\n'; + + const OUTPUT = + 'import React from "react"\n\ninterface MyComponentProps {\n a?: string\n}\n\nexport function MyComponent(props: MyComponentProps) {\n return \n}\n\nMyComponent.propTypes = {\n ...OtherComponent.propTypes\n}\n'; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), { + "preserve-prop-types": "unconverted", + }); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); + + it("typescript", () => { + const INPUT = + 'import PropTypes from "prop-types"\nimport React from "react"\n\nexport function MyComponent(props) {\n const foo: string = \'bar\'\n return \n}\n\nMyComponent.propTypes = {\n bar: PropTypes.string.isRequired,\n foo: PropTypes.number,\n}\n'; + + const OUTPUT = + "import React from \"react\"\n\ninterface MyComponentProps {\n bar: string\n foo?: number\n}\n\nexport function MyComponent(props: MyComponentProps) {\n const foo: string = 'bar'\n return \n}\n"; + + const fileInfo = { + path: "index.js", + source: INPUT, + }; + + const actualOutput = transform(fileInfo, buildApi("tsx"), {}); + assert.deepEqual( + actualOutput?.replace(/\W/gm, ""), + OUTPUT.replace(/\W/gm, "") + ); + }); +}); diff --git a/transforms/prop-types-typescript.codemod.ts b/transforms/prop-types-typescript.codemod.ts new file mode 100644 index 0000000..f1e9e9c --- /dev/null +++ b/transforms/prop-types-typescript.codemod.ts @@ -0,0 +1,464 @@ +/*! @license + +ISC License + +Copyright (c) 2023, Mark Skelton + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ + +/* +Changes to the original file: fixed ts errors +*/ + +import type { NodePath } from "ast-types/lib/node-path.js"; +import type { + API, + Collection, + CommentBlock, + CommentLine, + FileInfo, + Identifier, + JSCodeshift, + Literal, + Options, + TSAnyKeyword, + TSFunctionType, +} from "jscodeshift"; + +let j: JSCodeshift; +let options: { + preservePropTypes: "none" | "unconverted" | "all"; +}; + +function reactType(type: string) { + return j.tsQualifiedName(j.identifier("React"), j.identifier(type)); +} + +type TSType = { + comments: (CommentLine | CommentBlock)[]; + key: Identifier | Literal; + required: boolean; + type: TSAnyKeyword | TSFunctionType; +}; + +function createPropertySignature({ comments, key, required, type }: TSType) { + if (type.type === "TSFunctionType") { + return j.tsMethodSignature.from({ + comments, + key, + optional: !required, + parameters: type.parameters, + typeAnnotation: type.typeAnnotation, + }); + } + + return j.tsPropertySignature.from({ + comments, + key, + optional: !required, + typeAnnotation: j.tsTypeAnnotation(type), + }); +} + +function isCustomValidator(path: NodePath) { + return ( + path.get("type").value === "FunctionExpression" || + path.get("type").value === "ArrowFunctionExpression" + ); +} + +const resolveRequired = (path: NodePath) => + isRequired(path) ? path.get("object") : path; + +function getTSType(path: NodePath): any { + const { value: name } = + path.get("type").value === "MemberExpression" + ? path.get("property", "name") + : path.get("callee", "property", "name"); + + switch (name) { + case "func": { + const restElement = j.restElement.from({ + argument: j.identifier("args"), + typeAnnotation: j.tsTypeAnnotation(j.tsArrayType(j.tsUnknownKeyword())), + }); + + return j.tsFunctionType.from({ + parameters: [restElement], + typeAnnotation: j.tsTypeAnnotation(j.tsUnknownKeyword()), + }); + } + + case "arrayOf": { + const type = path.get("arguments", 0); + return isCustomValidator(type) + ? j.tsUnknownKeyword() + : j.tsArrayType(getTSType(resolveRequired(type))); + } + + case "objectOf": { + const type = path.get("arguments", 0); + return isCustomValidator(type) + ? j.tsUnknownKeyword() + : j.tsTypeReference( + j.identifier("Record"), + j.tsTypeParameterInstantiation([ + j.tsStringKeyword(), + getTSType(resolveRequired(type)), + ]) + ); + } + + case "oneOf": { + const arg = path.get("arguments", 0); + + return arg.get("type").value !== "ArrayExpression" + ? j.tsArrayType(j.tsUnknownKeyword()) + : j.tsUnionType( + arg + .get("elements") + .value.map(({ type, value }: { type: any; value: any }) => { + switch (type) { + case "StringLiteral": + return j.tsLiteralType(j.stringLiteral(value)); + + case "NumericLiteral": + return j.tsLiteralType(j.numericLiteral(value)); + + case "BooleanLiteral": + return j.tsLiteralType(j.booleanLiteral(value)); + + default: + return j.tsUnknownKeyword(); + } + }) + ); + } + + case "oneOfType": + return j.tsUnionType(path.get("arguments", 0, "elements").map(getTSType)); + + case "instanceOf": + return j.tsTypeReference( + j.identifier(path.get("arguments", 0, "name").value) + ); + + case "shape": + case "exact": + return j.tsTypeLiteral( + path + .get("arguments", 0, "properties") + .map(mapType) + .map(createPropertySignature) + ); + } + + const map = { + any: j.tsAnyKeyword(), + array: j.tsArrayType(j.tsUnknownKeyword()), + bool: j.tsBooleanKeyword(), + element: j.tsTypeReference(reactType("ReactElement")), + elementType: j.tsTypeReference(reactType("ElementType")), + node: j.tsTypeReference(reactType("ReactNode")), + number: j.tsNumberKeyword(), + object: j.tsObjectKeyword(), + string: j.tsStringKeyword(), + symbol: j.tsSymbolKeyword(), + } as const; + + return name in map ? map[name as keyof typeof map] : j.tsUnknownKeyword(); +} + +const isRequired = (path: NodePath) => + path.get("type").value === "MemberExpression" && + path.get("property", "name").value === "isRequired"; + +function mapType(path: NodePath): TSType { + const required = isRequired(path.get("value")); + const key = path.get("key").value; + const comments = path.get("leadingComments").value; + const type = getTSType( + required ? path.get("value", "object") : path.get("value") + ); + + // If all types should be removed or the type was able to be converted, + // we remove the type. + if (options.preservePropTypes !== "all" && type.type !== "TSUnknownKeyword") { + path.replace(); + } + + return { + comments: comments ?? [], + key, + required, + type, + }; +} + +type CollectedTypes = { + component: string; + types: TSType[]; +}[]; + +function getTSTypes( + source: Collection, + getComponentName: (path: NodePath) => string +) { + const collected = [] as CollectedTypes; + const propertyTypes = ["Property", "ObjectProperty", "ObjectMethod"]; + + source + .filter((path) => path.value) + .forEach((path) => { + collected.push({ + component: getComponentName(path), + types: path + .filter( + ({ value }: { value: any }) => propertyTypes.includes(value.type), + null + ) + .map(mapType, null), + }); + }); + + return collected; +} + +function getFunctionParent(path: NodePath): NodePath { + return path.parent.get("type").value === "Program" + ? path + : getFunctionParent(path.parent); +} + +function getComponentName(path: NodePath) { + const root = + path.get("type").value === "ArrowFunctionExpression" ? path.parent : path; + + return root.get("id", "name").value ?? root.parent.get("id", "name").value; +} +function createInterface(path: NodePath, componentTypes: CollectedTypes) { + const componentName = getComponentName(path); + const types = componentTypes.find((t) => t.component === componentName); + const typeName = `${componentName}Props`; + + // If the component doesn't have propTypes, ignore it + if (!types) return; + + // Add the TS types before the function/class + getFunctionParent(path).insertBefore( + j.tsInterfaceDeclaration( + j.identifier(typeName), + j.tsInterfaceBody(types.types.map(createPropertySignature)) + ) + ); + + return typeName; +} +/** + * If forwardRef is being used, declare the props. + * Otherwise, return false + */ +function addForwardRefTypes(path: NodePath, typeName: string): boolean { + // for `React.forwardRef()` + if (path.node.callee?.property?.name === "forwardRef") { + path.node.callee.property.name = `forwardRef`; + return true; + } + // if calling `forwardRef()` directly + if (path.node.callee?.name === "forwardRef") { + path.node.callee.name = `forwardRef`; + return true; + } + return false; +} + +function addFunctionTSTypes( + source: Collection, + componentTypes: CollectedTypes +) { + source.forEach((path) => { + const typeName = createInterface(path, componentTypes); + if (!typeName) return; + + // add forwardRef types if present + if (addForwardRefTypes(path.parentPath, typeName)) return; + // Function components & Class Components + // Add the TS types to the props param + path.get("params", 0).value.typeAnnotation = j.tsTypeReference( + // For some reason, jscodeshift isn't adding the colon so we have to do + // that ourselves. + j.identifier(`: ${typeName}`) + ); + }); +} + +function addClassTSTypes(source: Collection, componentTypes: CollectedTypes) { + source.find(j.ClassDeclaration).forEach((path) => { + const typeName = createInterface(path, componentTypes); + if (!typeName) return; + + // Add the TS types to the React.Component super class + path.value.superTypeParameters = j.tsTypeParameterInstantiation([ + j.tsTypeReference(j.identifier(typeName)), + ]); + }); +} + +function collectPropTypes(source: Collection) { + return source + .find(j.AssignmentExpression) + .filter( + (path) => path.get("left", "property", "name").value === "propTypes" + ) + .map((path) => path.get("right", "properties")); +} + +function collectStaticPropTypes(source: Collection) { + return source + .find(j.ClassProperty) + .filter((path) => !!path.value.static) + .filter((path) => path.get("key", "name").value === "propTypes") + .map((path) => path.get("value", "properties")); +} + +function cleanup( + source: Collection, + propTypes: Collection, + staticPropTypes: Collection +) { + propTypes.forEach((path) => { + if (!path.parent.get("right", "properties", "length").value) { + path.parent.prune(); + } + }); + + staticPropTypes.forEach((path) => { + if (!path.parent.get("value", "properties", "length").value) { + path.parent.prune(); + } + }); + + const propTypesUsages = source + .find(j.MemberExpression) + .filter((path) => path.get("object", "name").value === "PropTypes"); + + // We can remove the import without caring about the preserve-prop-types + // option since the criteria for removal is that no PropTypes.* member + // expressions exist. + if (propTypesUsages.length === 0) { + source + .find(j.ImportDeclaration) + .filter((path) => path.value.source.value === "prop-types") + .remove(); + } +} + +const isOnlyWhitespace = (str: string) => !/\S/.test(str); + +/** + * Guess the tab width of the file. This file is a modified version of recast's + * built-in tab width guessing with a modification to better handle files with + * block comments. + * @see https://github.com/benjamn/recast/blob/8cc1f42408c41b5616d82574f5552c2da3e11cf7/lib/lines.ts#L280-L314 + */ +function guessTabWidth(source: string) { + const lines = source.split("\n"); + const counts: number[] = []; + let lastIndent = 0; + + for (const line of lines) { + // Whitespace-only lines don't tell us much about the likely tab width + if (isOnlyWhitespace(line)) { + continue; + } + + // Calculate the indentation of the line excluding lines starting with an + // asterisk. This is because these lines are often part of block comments + // which are indented an extra space which throws off our tab width guessing. + const indent = line.match(/^(\s*)/) + ? line.trim().startsWith("*") + ? lastIndent + : RegExp.$1.length + : 0; + + const diff = Math.abs(indent - lastIndent); + counts[diff] = ~~(counts[diff] ?? 0) + 1; + lastIndent = indent; + } + + let maxCount = -1; + let result = 2; + + // Loop through the counts array to find the most common tab width in the file + for (let tabWidth = 1; tabWidth < counts.length; tabWidth++) { + const count = counts[tabWidth]; + if (count !== undefined && count > maxCount) { + maxCount = count; + result = tabWidth; + } + } + + return result; +} + +// Use the TSX to allow parsing of TypeScript code that still contains prop +// types. Though not typical, this exists in the wild. +export const parser = "tsx"; + +export default function transform(file: FileInfo, api: API, opts: Options) { + j = api.jscodeshift; + const source = j(file.source); + + // Parse the CLI options + options = { + preservePropTypes: + opts["preserve-prop-types"] === true + ? "all" + : opts["preserve-prop-types"] || "none", + }; + + const propTypes = collectPropTypes(source); + + const tsTypes = getTSTypes( + propTypes, + (path) => path.parent.get("left", "object", "name").value + ); + + const staticPropTypes = collectStaticPropTypes(source); + + if (propTypes.length === 0 && staticPropTypes.length === 0) { + return undefined; + } + + const staticTSTypes = getTSTypes( + staticPropTypes, + (path) => path.parent.parent.parent.value.id.name + ); + + addFunctionTSTypes(source.find(j.FunctionDeclaration), tsTypes); + addFunctionTSTypes(source.find(j.FunctionExpression), tsTypes); + addFunctionTSTypes(source.find(j.ArrowFunctionExpression), tsTypes); + addClassTSTypes(source, tsTypes); + addClassTSTypes(source, staticTSTypes); + + if (options.preservePropTypes === "none") { + propTypes.remove(); + staticPropTypes.remove(); + } + + // Remove empty propTypes expressions and imports + cleanup(source, propTypes, staticPropTypes); + + return source.toSource({ tabWidth: guessTabWidth(file.source) }); +} diff --git a/tsconfig.codemod.json b/tsconfig.codemod.json new file mode 100644 index 0000000..fcee9a7 --- /dev/null +++ b/tsconfig.codemod.json @@ -0,0 +1,40 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "moduleResolution": "node", + "module": "NodeNext", + "target": "ES2015", + "lib": ["ESNext", "DOM"], + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "isolatedModules": true, + "jsx": "react-jsx", + "useDefineForClassFields": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "preserveWatchOutput": true, + "strict": true, + "strictNullChecks": true, + "incremental": true, + "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": false, + "allowJs": true, + "outDir": "./dist", + "rootDir": "./transforms", + "moduleDetection": "auto" + }, + "include": [ + "./transforms/**/*.codemod.ts", + "./transforms/**/*.codemod.js", + "./transforms/**/*.codemod.tsx", + "./transforms/**/*.codemod.jsx" + ], + "exclude": ["node_modules", "./dist/**/*"], + "ts-node": { + "transpileOnly": true + } +} diff --git a/tsconfig.json b/tsconfig.json index 0d6dcc7..e305bf1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,9 @@ { - "compilerOptions": { - "moduleDetection": "force", - "target": "ES2015" - } - } \ No newline at end of file + "compilerOptions": { + "moduleDetection": "force", + "target": "ES2015" + }, + "references": [ + { "path": "./tsconfig.codemod.json" } + ] +}