From 2e60d0e7d705a73109f3c832a68f7810d2fc496d Mon Sep 17 00:00:00 2001 From: Alex Zherdev Date: Sun, 23 Sep 2018 12:44:22 -0700 Subject: [PATCH] [New] add rule to enforce fragment syntax --- README.md | 1 + docs/rules/jsx-fragments.md | 57 ++++++++++ index.js | 1 + lib/rules/jsx-fragments.js | 179 +++++++++++++++++++++++++++++ lib/util/pragma.js | 13 +++ tests/lib/rules/jsx-fragments.js | 187 +++++++++++++++++++++++++++++++ 6 files changed, 438 insertions(+) create mode 100644 docs/rules/jsx-fragments.md create mode 100644 lib/rules/jsx-fragments.js create mode 100644 tests/lib/rules/jsx-fragments.js diff --git a/README.md b/README.md index 5b787b1afc..161c75663b 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ Enable the rules that you would like to use. * [react/jsx-no-undef](docs/rules/jsx-no-undef.md): Disallow undeclared variables in JSX * [react/jsx-one-expression-per-line](docs/rules/jsx-one-expression-per-line.md): Limit to one expression per line in JSX * [react/jsx-curly-brace-presence](docs/rules/jsx-curly-brace-presence.md): Enforce curly braces or disallow unnecessary curly braces in JSX +* [react/jsx-fragments](docs/rules/jsx-fragments.md): Enforce shorthand or standard form for React fragments * [react/jsx-pascal-case](docs/rules/jsx-pascal-case.md): Enforce PascalCase for user-defined JSX components * [react/jsx-props-no-multi-spaces](docs/rules/jsx-props-no-multi-spaces.md): Disallow multiple spaces between inline JSX props (fixable) * [react/jsx-sort-default-props](docs/rules/jsx-sort-default-props.md): Enforce default props alphabetical sorting diff --git a/docs/rules/jsx-fragments.md b/docs/rules/jsx-fragments.md new file mode 100644 index 0000000000..1620652b11 --- /dev/null +++ b/docs/rules/jsx-fragments.md @@ -0,0 +1,57 @@ +# Enforce shorthand or standard form for React fragments (react/jsx-fragments) + +In JSX, a React fragment is created either with `...`, or, using the shorthand syntax, `<>...`. This rule allows you to enforce one way or the other. + +Support for fragments was added in React v16.2, so the rule will warn on either of these forms if an older React version is specified in [shared settings][shared_settings]. + +## Rule Options + +```js +... +"react/jsx-fragments": [, ] +... +``` + +### `syntax` mode + +This is the default mode. It will enforce the shorthand syntax for React fragments, with one exception. [Keys or attributes are not supported by the shorthand syntax][short_syntax], so the rule will not warn on standard-form fragments that use those. + +The following pattern is considered a warning: + +```jsx + +``` + +The following patterns are **not** considered warnings: + +```jsx +<> +``` + +```jsx + +``` + +### `element` mode + +This mode enforces the standard form for React fragments. + +The following pattern is considered a warning: + +```jsx +<> +``` + +The following patterns are **not** considered warnings: + +```jsx + +``` + +```jsx + +``` + +[fragments]: https://reactjs.org/docs/fragments.html +[shared_settings]: /README.md#configuration +[short_syntax]: https://reactjs.org/docs/fragments.html#short-syntax diff --git a/index.js b/index.js index 45a96d7da3..25faa607a6 100644 --- a/index.js +++ b/index.js @@ -36,6 +36,7 @@ const allRules = { 'jsx-no-undef': require('./lib/rules/jsx-no-undef'), 'jsx-curly-brace-presence': require('./lib/rules/jsx-curly-brace-presence'), 'jsx-pascal-case': require('./lib/rules/jsx-pascal-case'), + 'jsx-fragments': require('./lib/rules/jsx-fragments'), 'jsx-props-no-multi-spaces': require('./lib/rules/jsx-props-no-multi-spaces'), 'jsx-sort-default-props': require('./lib/rules/jsx-sort-default-props'), 'jsx-sort-props': require('./lib/rules/jsx-sort-props'), diff --git a/lib/rules/jsx-fragments.js b/lib/rules/jsx-fragments.js new file mode 100644 index 0000000000..da9d97322e --- /dev/null +++ b/lib/rules/jsx-fragments.js @@ -0,0 +1,179 @@ +/** + * @fileoverview Enforce shorthand or standard form for React fragments. + * @author Alex Zherdev + */ +'use strict'; + +const elementType = require('jsx-ast-utils/elementType'); +const pragmaUtil = require('../util/pragma'); +const variableUtil = require('../util/variable'); +const versionUtil = require('../util/version'); +const docsUrl = require('../util/docsUrl'); + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +function replaceNode(source, node, text) { + return `${source.slice(0, node.range[0])}${text}${source.slice(node.range[1])}`; +} + +module.exports = { + meta: { + docs: { + description: 'Enforce shorthand or standard form for React fragments', + category: 'Stylistic Issues', + recommended: false, + url: docsUrl('jsx-fragments') + }, + fixable: 'code', + + schema: [{ + enum: ['syntax', 'element'] + }] + }, + + create: function(context) { + const configuration = context.options[0] || 'syntax'; + const sourceCode = context.getSourceCode(); + const reactPragma = pragmaUtil.getFromContext(context); + const fragmentPragma = pragmaUtil.getFragmentFromContext(context); + const openFragShort = '<>'; + const closeFragShort = ''; + const openFragLong = `<${reactPragma}.${fragmentPragma}>`; + const closeFragLong = ``; + + function reportOnReactVersion(node) { + if (!versionUtil.testReactVersion(context, '16.2.0')) { + context.report({ + node, + message: 'Fragments are only supported starting from React v16.2' + }); + return true; + } + + return false; + } + + function getFixerToLong(jsxFragment) { + return function(fixer) { + let source = sourceCode.getText(); + source = replaceNode(source, jsxFragment.closingFragment, closeFragLong); + source = replaceNode(source, jsxFragment.openingFragment, openFragLong); + const lengthDiff = openFragLong.length - sourceCode.getText(jsxFragment.openingFragment).length + + closeFragLong.length - sourceCode.getText(jsxFragment.closingFragment).length; + const range = jsxFragment.range; + return fixer.replaceTextRange(range, source.slice(range[0], range[1] + lengthDiff)); + }; + } + + function getFixerToShort(jsxElement) { + return function(fixer) { + let source = sourceCode.getText(); + source = replaceNode(source, jsxElement.closingElement, closeFragShort); + source = replaceNode(source, jsxElement.openingElement, openFragShort); + const lengthDiff = sourceCode.getText(jsxElement.openingElement).length - openFragShort.length + + sourceCode.getText(jsxElement.closingElement).length - closeFragShort.length; + const range = jsxElement.range; + return fixer.replaceTextRange(range, source.slice(range[0], range[1] - lengthDiff)); + }; + } + + function refersToReactFragment(name) { + const variableInit = variableUtil.findVariableByName(context, name); + if (!variableInit) { + return false; + } + + // const { Fragment } = React; + if (variableInit.type === 'Identifier' && variableInit.name === reactPragma) { + return true; + } + + // const Fragment = React.Fragment; + if ( + variableInit.type === 'MemberExpression' + && variableInit.object.type === 'Identifier' + && variableInit.object.name === reactPragma + && variableInit.property.type === 'Identifier' + && variableInit.property.name === fragmentPragma + ) { + return true; + } + + // const { Fragment } = require('react'); + if ( + variableInit.callee + && variableInit.callee.name === 'require' + && variableInit.arguments + && variableInit.arguments[0] + && variableInit.arguments[0].value === 'react' + ) { + return true; + } + + return false; + } + + const jsxElements = []; + const fragmentNames = new Set([`${reactPragma}.${fragmentPragma}`]); + + // -------------------------------------------------------------------------- + // Public + // -------------------------------------------------------------------------- + + return { + JSXElement(node) { + jsxElements.push(node); + }, + + JSXFragment(node) { + if (reportOnReactVersion(node)) { + return; + } + + if (configuration === 'element') { + context.report({ + node, + message: `Prefer ${reactPragma}.${fragmentPragma} over fragment shorthand`, + fix: getFixerToLong(node) + }); + } + }, + + ImportDeclaration(node) { + if (node.source && node.source.value === 'react') { + node.specifiers.forEach(spec => { + if (spec.imported && spec.imported.name === fragmentPragma) { + if (spec.local) { + fragmentNames.add(spec.local.name); + } + } + }); + } + }, + + 'Program:exit'() { + jsxElements.forEach(node => { + const openingEl = node.openingElement; + const elName = elementType(openingEl); + + if (fragmentNames.has(elName) || refersToReactFragment(elName)) { + if (reportOnReactVersion(node)) { + return; + } + + const attrs = openingEl.attributes; + if (configuration === 'syntax' && !(attrs && attrs.length > 0)) { + context.report({ + node, + message: `Prefer fragment shorthand over ${reactPragma}.${fragmentPragma}`, + fix: getFixerToShort(node) + }); + } + } + }); + } + }; + } +}; diff --git a/lib/util/pragma.js b/lib/util/pragma.js index 5162005938..27e3ed7fa7 100644 --- a/lib/util/pragma.js +++ b/lib/util/pragma.js @@ -21,6 +21,18 @@ function getCreateClassFromContext(context) { return pragma; } +function getFragmentFromContext(context) { + let pragma = 'Fragment'; + // .eslintrc shared settings (http://eslint.org/docs/user-guide/configuring#adding-shared-settings) + if (context.settings.react && context.settings.react.fragment) { + pragma = context.settings.react.fragment; + } + if (!JS_IDENTIFIER_REGEX.test(pragma)) { + throw new Error(`Fragment pragma ${pragma} is not a valid identifier`); + } + return pragma; +} + function getFromContext(context) { let pragma = 'React'; @@ -43,5 +55,6 @@ function getFromContext(context) { module.exports = { getCreateClassFromContext: getCreateClassFromContext, + getFragmentFromContext: getFragmentFromContext, getFromContext: getFromContext }; diff --git a/tests/lib/rules/jsx-fragments.js b/tests/lib/rules/jsx-fragments.js new file mode 100644 index 0000000000..c230085040 --- /dev/null +++ b/tests/lib/rules/jsx-fragments.js @@ -0,0 +1,187 @@ +/** + * @fileoverview Tests for jsx-fragments + * @author Alex Zherdev + */ +'use strict'; + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/jsx-fragments'); +const RuleTester = require('eslint').RuleTester; + +const parserOptions = { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { + jsx: true + } +}; + +const settings = { + react: { + version: '16.2', + pragma: 'Act', + fragment: 'Frag' + } +}; + +const settingsOld = { + react: { + version: '16.1', + pragma: 'Act', + fragment: 'Frag' + } +}; + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({parserOptions}); +ruleTester.run('jsx-fragments', rule, { + valid: [{ + code: '<>', + parser: 'babel-eslint', + settings + }, { + code: '', + options: ['element'], + settings + }, { + code: ` + import Act, { Frag as F } from 'react'; + ; + `, + options: ['element'], + settings + }, { + code: ` + const F = Act.Frag; + ; + `, + options: ['element'], + settings + }, { + code: ` + const { Frag } = Act; + ; + `, + options: ['element'], + settings + }, { + code: ` + const { Frag } = require('react'); + ; + `, + options: ['element'], + settings + }, { + code: '', + options: ['syntax'], + settings + }], + + invalid: [{ + code: '<>', + parser: 'babel-eslint', + settings: settingsOld, + errors: [{ + message: 'Fragments are only supported starting from React v16.2' + }] + }, { + code: '', + settings: settingsOld, + errors: [{ + message: 'Fragments are only supported starting from React v16.2' + }] + }, { + code: '<>', + parser: 'babel-eslint', + options: ['element'], + settings, + errors: [{ + message: 'Prefer Act.Frag over fragment shorthand' + }], + output: '' + }, { + code: '', + options: ['syntax'], + settings, + errors: [{ + message: 'Prefer fragment shorthand over Act.Frag' + }], + output: '<>' + }, { + code: ` + import Act, { Frag as F } from 'react'; + ; + `, + options: ['syntax'], + settings, + errors: [{ + message: 'Prefer fragment shorthand over Act.Frag' + }], + output: ` + import Act, { Frag as F } from 'react'; + <>; + ` + }, { + code: ` + import Act, { Frag } from 'react'; + ; + `, + options: ['syntax'], + settings, + errors: [{ + message: 'Prefer fragment shorthand over Act.Frag' + }], + output: ` + import Act, { Frag } from 'react'; + <>; + ` + }, { + code: ` + const F = Act.Frag; + ; + `, + options: ['syntax'], + settings, + errors: [{ + message: 'Prefer fragment shorthand over Act.Frag' + }], + output: ` + const F = Act.Frag; + <>; + ` + }, { + code: ` + const { Frag } = Act; + ; + `, + options: ['syntax'], + settings, + errors: [{ + message: 'Prefer fragment shorthand over Act.Frag' + }], + output: ` + const { Frag } = Act; + <>; + ` + }, { + code: ` + const { Frag } = require('react'); + ; + `, + options: ['syntax'], + settings, + errors: [{ + message: 'Prefer fragment shorthand over Act.Frag' + }], + output: ` + const { Frag } = require('react'); + <>; + ` + }] +});