diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f67fd5098..761db6d352 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel ### Added * add [`no-namespace`] rule ([#2640] @yacinehmito @ljharb) +* [`prefer-stateless-function`]: add fixer ([#1229][], [#1096][], @RiddleMan @golopot) ### Fixed * [`display-name`]: Get rid of false position on component detection ([#2759] @iiison) @@ -17,6 +18,8 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel [#2640]: https://github.com/yannickcr/eslint-plugin-react/pull/2640 [#2759]: https://github.com/yannickcr/eslint-plugin-react/pull/2759 [#1873]: https://github.com/yannickcr/eslint-plugin-react/pull/1873 +[#1229]: https://github.com/yannickcr/eslint-plugin-react/pull/1229 +[#1096]: https://github.com/yannickcr/eslint-plugin-react/issues/1096 ## [7.25.3] - 2021.09.19 diff --git a/lib/rules/prefer-stateless-function.js b/lib/rules/prefer-stateless-function.js index 3a65ce514c..e6e94b4a5e 100644 --- a/lib/rules/prefer-stateless-function.js +++ b/lib/rules/prefer-stateless-function.js @@ -7,6 +7,8 @@ 'use strict'; +const detectIndent = require('detect-indent'); +const values = require('object.values'); const Components = require('../util/Components'); const versionUtil = require('../util/version'); const astUtil = require('../util/ast'); @@ -21,6 +23,183 @@ const messages = { componentShouldBePure: 'Component should be written as a pure function' }; +function statefulComponentHandler(sourceCode, componentNode) { + /** + * Returns name of the stateful component + * @returns {string} + */ + function getComponentName() { + return (componentNode.id && componentNode.id.name) || ''; + } + + /** + * Returns node of render definition + * @returns {*} + */ + function getRenderNode() { + return componentNode.body.body + .find((member) => member.type === 'MethodDefinition' && member.key.name === 'render'); + } + + function getProperties() { + return componentNode.body.body.filter((property) => property.type === 'ClassProperty'); + } + + /** + * Returns every static property defined in the component + * @returns {Array} array of the properties + */ + function getStaticProps() { + return getProperties().map((property) => { + const staticKeywordRegex = /static /g; + const componentName = getComponentName(); + + return sourceCode.getText(property) + .replace(staticKeywordRegex, `${componentName}.`); + }); + } + + /** + * Return body of the render function with curly braces + * @returns {undefined|string} + */ + function getRenderBody() { + const renderNode = getRenderNode(); + + if (!renderNode) { + return ''; + } + + return sourceCode.getText(renderNode.value.body); + } + + return { + name: getComponentName(), + body: getRenderBody(), + staticProps: getStaticProps() + }; +} + +/** + * Gets indentation style of the file + * @param {object} sourceCode + * @returns {string} one indentation example + */ +function getFileIndentation(sourceCode) { + return detectIndent(sourceCode.getText()).indent || ' '; +} + +/** + * Removes every usage of this in properties + * Example: this.props -> props + * @param {string} str + * @returns {string} + */ +function removeThisFromPropsUsages(str) { + const thisRegex = /this\.props/g; + + return str.replace(thisRegex, 'props'); +} + +function ruleFixer(sourceCode, componentNode, utils) { + /** + * Returns string which is indented one level down + * @param {string} str + * @returns {string} + */ + function indentOneLevelDown(str) { + const indentation = getFileIndentation(sourceCode); + + return str + .split('\n') + .map((line) => line.replace(indentation, '')) + .join('\n'); + } + + /** + * Return how deep whole block is indented + * @param {string} body code block body + * @returns {string} base indentation + */ + function getBlockBaseIndentation(body) { + const lines = body.split('\n'); + const lastLine = lines[lines.length - 1]; + const matchIndentation = /^(\s*)[^\s]/g; + + return matchIndentation.exec(lastLine)[1]; + } + + /** + * Returns correctly indented static props of the component + * @param {string[]} staticProps + * @param {string} transformedBody + * @returns {string} + */ + function getStaticPropsText(staticProps, transformedBody) { + const indentation = getBlockBaseIndentation(transformedBody); + + return staticProps + .map((props) => indentation + indentOneLevelDown(props)) + .join('\n'); + } + + /** + * Returns prepared body of the render function + * @param {string} body body + * @returns {string} + */ + function transformBody(body) { + if (!body) { + return '{}'; + } + + return removeThisFromPropsUsages(indentOneLevelDown(body)); + } + + /** + * Returns render of the stateless function + * @param {string} name name of the component + * @param {boolean} hasContext if the component accepts context + * @param {string} transformedBody body of the component with a curly braces + * @returns {string} stateless component body + */ + function getComponentText(name, hasContext, transformedBody) { + return `function ${name}(props${hasContext ? ', context' : ''}) ${transformedBody}`; + } + + /** + * Returns concatenated values of the parts of component + * @param {object} options + * @returns {string} + */ + function getComponent(options) { + const transformedBody = transformBody(options.body); + const componentText = getComponentText(options.name, options.contextType, transformedBody); + const staticProps = getStaticPropsText(options.staticProps, transformedBody); + + if (staticProps) { + return `${componentText}\n${staticProps}`; + } + + return componentText; + } + + return function (fixer) { + if (utils.isES5Component(componentNode)) { + return; + } + + const componentDetails = statefulComponentHandler(sourceCode, componentNode); + + if (!componentDetails.name) { + return; + } + + // eslint-disable-next-line + return fixer.replaceText(componentNode, getComponent(componentDetails)); + }; +} + module.exports = { meta: { docs: { @@ -32,6 +211,8 @@ module.exports = { messages, + fixable: 'code', + schema: [{ type: 'object', properties: { @@ -197,7 +378,14 @@ module.exports = { && !!property.value.body && isRedundantSuperCall(property.value.body.body, property.value.params); const isRender = name === 'render'; - return !isDisplayName && !isPropTypes && !contextTypes && !defaultProps && !isUselessConstructor && !isRender; + const isStatic = property.static; + return !isDisplayName + && !isPropTypes + && !contextTypes + && !defaultProps + && !isUselessConstructor + && !isRender + && !isStatic; }); } @@ -362,24 +550,26 @@ module.exports = { 'Program:exit'() { const list = components.list(); - Object.keys(list).forEach((component) => { + values(list).forEach((component) => { + const {node, ...rest} = component; if ( - hasOtherProperties(list[component].node) - || list[component].useThis - || list[component].useRef - || list[component].invalidReturn - || list[component].hasChildContextTypes - || list[component].useDecorators - || (!utils.isES5Component(list[component].node) && !utils.isES6Component(list[component].node)) + hasOtherProperties(node) + || component.useThis + || component.useRef + || component.invalidReturn + || component.hasChildContextTypes + || component.useDecorators + || (!utils.isES5Component(node) && !utils.isES6Component(node)) ) { return; } - if (list[component].hasSCU) { + if (component.hasSCU) { return; } report(context, messages.componentShouldBePure, 'componentShouldBePure', { - node: list[component].node + node, + fix: ruleFixer(context.getSourceCode(), node, utils) }); }); } diff --git a/package.json b/package.json index 0a28bfb4a8..31dea1c0e0 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "array.prototype.flatmap": "^1.2.4", "doctrine": "^2.1.0", "estraverse": "^5.2.0", + "detect-indent": "^5.0.0", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.0.4", "object.entries": "^1.1.4", diff --git a/tests/lib/rules/prefer-stateless-function.js b/tests/lib/rules/prefer-stateless-function.js index f45559490f..e622832519 100644 --- a/tests/lib/rules/prefer-stateless-function.js +++ b/tests/lib/rules/prefer-stateless-function.js @@ -344,6 +344,11 @@ ruleTester.run('prefer-stateless-function', rule, { } } `, + output: ` + function Foo(props) { + return
{props.foo}
; + } + `, errors: [{ messageId: 'componentShouldBePure' }] @@ -355,6 +360,11 @@ ruleTester.run('prefer-stateless-function', rule, { } } `, + output: ` + function Foo(props) { + return
{props.foo}
; + } + `, errors: [{ messageId: 'componentShouldBePure' }] @@ -366,6 +376,11 @@ ruleTester.run('prefer-stateless-function', rule, { } } `, + output: ` + function Foo() { + return
foo
; + } + `, errors: [{ messageId: 'componentShouldBePure' }] @@ -377,6 +392,11 @@ ruleTester.run('prefer-stateless-function', rule, { } } `, + output: ` + function Foo(props) { + return
{props.foo}
; + } + `, errors: [{ messageId: 'componentShouldBePure' }] @@ -391,6 +411,11 @@ ruleTester.run('prefer-stateless-function', rule, { } } `, + output: ` + function Foo(props) { + return
{props.foo}
; + } + `, parser: parsers.BABEL_ESLINT, errors: [{ messageId: 'componentShouldBePure' @@ -404,6 +429,11 @@ ruleTester.run('prefer-stateless-function', rule, { } } `, + output: ` + function Foo(props) { + return
{props.foo}
; + } + `, parser: parsers.BABEL_ESLINT, errors: [{ messageId: 'componentShouldBePure' @@ -421,6 +451,14 @@ ruleTester.run('prefer-stateless-function', rule, { } } `, + output: ` + function Foo(props) { + return
{props.foo}
; + } + Foo.propTypes = { + name: PropTypes.string + }; + `, parser: parsers.BABEL_ESLINT, errors: [{ messageId: 'componentShouldBePure' @@ -436,6 +474,14 @@ ruleTester.run('prefer-stateless-function', rule, { } } `, + output: ` + function Foo(props) { + return
{props.foo}
; + } + Foo.propTypes = { + name: PropTypes.string + }; + `, parser: parsers.BABEL_ESLINT, errors: [{ messageId: 'componentShouldBePure' @@ -451,6 +497,11 @@ ruleTester.run('prefer-stateless-function', rule, { } } `, + output: ` + function Foo(props) { + return
{props.foo}
; + } + `, parser: parsers.BABEL_ESLINT, errors: [{ messageId: 'componentShouldBePure' @@ -466,6 +517,11 @@ ruleTester.run('prefer-stateless-function', rule, { } } `, + output: ` + function Foo(props) { + return
{props.foo}
; + } + `, parser: parsers.BABEL_ESLINT, errors: [{ messageId: 'componentShouldBePure' @@ -475,10 +531,15 @@ ruleTester.run('prefer-stateless-function', rule, { class Foo extends React.Component { render() { let {props:{foo}, context:{bar}} = this; - return
{this.props.foo}
; + return
{foo}{bar}
; } } `, + output: ` + function Foo(props, context) { + return
{props.foo}{context.bar}
; + } + `, errors: [{ messageId: 'componentShouldBePure' }] @@ -493,6 +554,14 @@ ruleTester.run('prefer-stateless-function', rule, { } } `, + output: ` + function Foo(props) { + if (!props.foo) { + return null; + } + return
{props.foo}
; + } + `, parser: parsers.BABEL_ESLINT, errors: [{ messageId: 'componentShouldBePure' @@ -508,6 +577,14 @@ ruleTester.run('prefer-stateless-function', rule, { } }); `, + output: ` + function Foo(props) { + if (!props.foo) { + return null; + } + return
{props.foo}
; + } + `, errors: [{ messageId: 'componentShouldBePure' }] @@ -519,6 +596,11 @@ ruleTester.run('prefer-stateless-function', rule, { } } `, + output: ` + function Foo() { + return true ?
: null; + } + `, errors: [{ messageId: 'componentShouldBePure' }] @@ -534,6 +616,14 @@ ruleTester.run('prefer-stateless-function', rule, { } } `, + output: ` + function Foo(props) { + return props.foo ?
: null; + } + Foo.defaultProps = { + foo: true + }; + `, parser: parsers.BABEL_ESLINT, errors: [{ messageId: 'componentShouldBePure' @@ -552,6 +642,14 @@ ruleTester.run('prefer-stateless-function', rule, { } } `, + output: ` + function Foo(props) { + return props.foo ?
: null; + } + Foo.defaultProps = { + foo: true + }; + `, errors: [{ messageId: 'componentShouldBePure' }] @@ -567,6 +665,14 @@ ruleTester.run('prefer-stateless-function', rule, { foo: true }; `, + output: ` + function Foo(props) { + return props.foo ?
: null; + } + Foo.defaultProps = { + foo: true + }; + `, errors: [{ messageId: 'componentShouldBePure' }] @@ -582,6 +688,14 @@ ruleTester.run('prefer-stateless-function', rule, { } } `, + output: ` + function Foo(props, context) { + return context.foo ?
: null; + } + Foo.contextTypes = { + foo: PropTypes.boolean + }; + `, parser: parsers.BABEL_ESLINT, errors: [{ messageId: 'componentShouldBePure' @@ -600,6 +714,14 @@ ruleTester.run('prefer-stateless-function', rule, { } } `, + output: ` + function Foo(props, context) { + return context.foo ?
: null; + } + Foo.contextTypes = { + foo: PropTypes.boolean + }; + `, errors: [{ messageId: 'componentShouldBePure' }] @@ -615,6 +737,294 @@ ruleTester.run('prefer-stateless-function', rule, { foo: PropTypes.boolean }; `, + output: ` + function Foo(props, context) { + return context.foo ?
: null; + } + `, + errors: [{ + message: 'Component should be written as a pure function' + }] + }, { + // should just change stateful to stateless + code: [ + 'class Foo extends Component {', + ' render() {', + ' return false;', + ' }', + '}' + ].join('\n'), + output: [ + 'function Foo(props) {', + ' return false;', + '}' + ].join('\n'), + parser: parsers.BABEL_ESLINT, + errors: [{ + message: 'Component should be written as a pure function' + }] + }, { + // should rename this.props into just props + code: [ + 'class Foo extends Component {', + ' render() {', + ' const { foo, bar } = this.props;', + '', + ' return
{this.props.test}
;', + ' }', + '}' + ].join('\n'), + output: [ + 'function Foo(props) {', + ' const { foo, bar } = props;', + '', + ' return
{props.test}
;', + '}' + ].join('\n'), + parser: parsers.BABEL_ESLINT, + errors: [{ + message: 'Component should be written as a pure function' + }] + }, { + // should move every static prop to function props + code: [ + 'class Foo extends Component {', + ' static propTypes = {', + ' foo: PropTypes.func', + ' };', + ' static displayName = \'Bar\';', + ' static foo = \'Baz\';', + ' render() {', + ' const { foo, bar } = this.props;', + ' ', + ' return
{this.props.test}
;', + ' }', + '}' + ].join('\n'), + output: [ + 'function Foo(props) {', + ' const { foo, bar } = props;', + ' ', + ' return
{props.test}
;', + '}', + 'Foo.propTypes = {', + ' foo: PropTypes.func', + '};', + 'Foo.displayName = \'Bar\';', + 'Foo.foo = \'Baz\';' + ].join('\n'), + parser: parsers.BABEL_ESLINT, + errors: [{ + message: 'Component should be written as a pure function' + }] + }, { + // should keep formatting of source code + code: [ + 'class Foo extends Component {', + ' static propTypes={foo: PropTypes.func};', + ' static displayName=\'Bar\';', + ' static foo=\'Baz\';', + ' render() {', + ' const {foo, bar} = this.props;', + ' ', + ' return
{this.props.test}
;', + ' }', + '}' + ].join('\n'), + output: [ + 'function Foo(props) {', + ' const {foo, bar} = props;', + ' ', + ' return
{props.test}
;', + '}', + 'Foo.propTypes={foo: PropTypes.func};', + 'Foo.displayName=\'Bar\';', + 'Foo.foo=\'Baz\';' + ].join('\n'), + parser: parsers.BABEL_ESLINT, + errors: [{ + message: 'Component should be written as a pure function' + }] + }, { + // should keep formatting of source code 2 + code: [ + 'class Foo extends Component {', + ' static propTypes={', + ' foo: PropTypes.func};', + ' static displayName=\'Bar\';', + ' static foo=\'Baz\';', + ' render() {', + ' const {foo, bar} = this.props;', + ' ', + ' return
{this.props.test}
;', + ' }', + '}' + ].join('\n'), + output: [ + 'function Foo(props) {', + ' const {foo, bar} = props;', + ' ', + ' return
{props.test}
;', + '}', + 'Foo.propTypes={', + ' foo: PropTypes.func};', + 'Foo.displayName=\'Bar\';', + 'Foo.foo=\'Baz\';' + ].join('\n'), + parser: parsers.BABEL_ESLINT, + errors: [{ + message: 'Component should be written as a pure function' + }] + }, { + // should keep formatting of source code while its in another block + code: [ + '{', + ' class Foo extends Component {', + ' static propTypes={', + ' foo: PropTypes.func};', + ' static displayName=\'Bar\';', + ' static foo=\'Baz\';', + ' render() {', + ' const {foo, bar} = this.props;', + ' ', + ' return
{this.props.test}
;', + ' }', + ' }', + '}' + ].join('\n'), + output: [ + '{', + ' function Foo(props) {', + ' const {foo, bar} = props;', + ' ', + ' return
{props.test}
;', + ' }', + ' Foo.propTypes={', + ' foo: PropTypes.func};', + ' Foo.displayName=\'Bar\';', + ' Foo.foo=\'Baz\';', + '}' + ].join('\n'), + parser: parsers.BABEL_ESLINT, + errors: [{ + message: 'Component should be written as a pure function' + }] + }, { + // should work without render function + code: [ + 'class Foo extends Component {', + '}' + ].join('\n'), + output: [ + 'function Foo(props) {}' + ].join('\n'), + parser: parsers.BABEL_ESLINT, + errors: [{ + message: 'Component should be written as a pure function' + }] + }, { + // should leave as it is when class is not named + code: [ + 'let x = class extends Component {', + '}' + ].join('\n'), + output: [ + 'let x = class extends Component {', + '}' + ].join('\n'), + parser: parsers.BABEL_ESLINT, + errors: [{ + message: 'Component should be written as a pure function' + }] + }, { + // should just change stateful to stateless for default parser + code: [ + 'class Foo extends Component {', + ' render() {', + ' return false;', + ' }', + '}' + ].join('\n'), + output: [ + 'function Foo(props) {', + ' return false;', + '}' + ].join('\n'), + errors: [{ + message: 'Component should be written as a pure function' + }] + }, { + // should rename this.props into just props for default parser + code: [ + 'class Foo extends Component {', + ' render() {', + ' const { foo, bar } = this.props;', + '', + ' return
{this.props.test}
;', + ' }', + '}' + ].join('\n'), + output: [ + 'function Foo(props) {', + ' const { foo, bar } = props;', + '', + ' return
{props.test}
;', + '}' + ].join('\n'), + errors: [{ + message: 'Component should be written as a pure function' + }] + }, { + // should keep formatting of source code for default parser + code: [ + 'class Foo extends Component {', + ' render() {', + ' const {foo, bar} = this.props;', + ' ', + ' return
', + ' {this.props.test}
;', + ' }', + '}', + 'Foo.propTypes={foo: PropTypes.func};', + 'Foo.displayName=\'Bar\';', + 'Foo.foo=\'Baz\';' + ].join('\n'), + output: [ + 'function Foo(props) {', + ' const {foo, bar} = props;', + ' ', + ' return
', + ' {props.test}
;', + '}', + 'Foo.propTypes={foo: PropTypes.func};', + 'Foo.displayName=\'Bar\';', + 'Foo.foo=\'Baz\';' + ].join('\n'), + errors: [{ + message: 'Component should be written as a pure function' + }] + }, { + // should work without render function for default parser + code: [ + 'class Foo extends Component {', + '}' + ].join('\n'), + output: [ + 'function Foo(props) {}' + ].join('\n'), + errors: [{ + message: 'Component should be written as a pure function' + }] + }, { + // should leave as it is when class is not named for default parser + code: [ + 'let x = class extends Component {', + '}' + ].join('\n'), + output: [ + 'let x = class extends Component {', + '}' + ].join('\n'), errors: [{ messageId: 'componentShouldBePure' }]