diff --git a/CHANGELOG.md b/CHANGELOG.md index 678e103483..27b1f966ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2576,3 +2576,4 @@ If you're still not using React 15 you can keep the old behavior by setting the [`state-in-constructor`]: docs/rules/state-in-constructor.md [`jsx-props-no-spreading`]: docs/rules/jsx-props-no-spreading.md [`static-property-placement`]: docs/rules/static-property-placement.md +[`jsx-curly-newline`]: docs/rules/jsx-curly-newline.md diff --git a/docs/rules/jsx-curly-newline.md b/docs/rules/jsx-curly-newline.md new file mode 100644 index 0000000000..ddac9caad7 --- /dev/null +++ b/docs/rules/jsx-curly-newline.md @@ -0,0 +1,149 @@ +# Enforce linebreaks in curly braces in JSX attributes and expressions. (react/jsx-curly-newline) + +Many style guides require or disallow newlines inside of jsx curly expressions. + +**Fixable:** This rule is automatically fixable using the `--fix` flag on the command line. + +## Rule Details + +This rule enforces consistent linebreaks inside of curlies of jsx curly expressions. + +## Rule Options + +This rule accepts either an object option: + +```ts +{ + multiline: "consistent" | "forbid" | "require", // default to 'consistent' + singleline: "consistent" | "forbid" | "require", // default to 'consistent' +} +``` +Option `multiline` takes effect when the jsx expression inside the curlies occupies multiple lines. + +Option `singleline` takes effect when the jsx expression inside the curlies occupies a single line. + +* `consistent` enforces either both curly braces have a line break directly inside them, or no line breaks are present. +* `forbid` disallows linebreaks directly inside curly braces. +* `require` enforces the presence of linebreaks directly inside curlies. + +or a string option: + +* `consistent` (default) is an alias for `{ multiline: "consistent", singleline: "consistent" }`. +* `never` is an alias for `{ multiline: "forbid", singleline: "forbid" }` + +or an + +### consistent (default) + +When `consistent` or `{ multiline: "consistent", singleline: "consistent" }` is set, the following patterns are considered warnings: + +```jsx +
+ { foo + } +
+ +
+ { + foo } +
+ +
+ { foo && + foo.bar + } +
+``` + +The following patterns are **not** warnings: + +```jsx +
+ { foo } +
+ +
+ { + foo + } +
+``` + +### never + +When `never` or `{ multiline: "forbid", singleline: "forbid" }` is set, the following patterns are considered warnings: + +```jsx +
+ { + foo && + foo.bar + } +
+ +
+ { + foo + } +
+ +
+ { foo + } +
+``` + +The following patterns are **not** warnings: + +```jsx +
+ { foo && + foo.bar } +
+ +
+ { foo } +
+``` + +## require + +When `{ multiline: "require", singleline: "require" }` is set, the following patterns are considered warnings: + +```jsx +
+ { foo && + foo.bar } +
+ +
+ { foo } +
+ +
+ { foo + } +
+``` + +The following patterns are **not** warnings: + +```jsx +
+ { + foo && + foo.bar + } +
+ +
+ { + foo + } +
+``` + + +## When Not To Use It + +You can turn this rule off if you are not concerned with the consistency of padding linebreaks inside of JSX attributes or expressions. diff --git a/index.js b/index.js index 82a89f97fd..f628c55ff6 100644 --- a/index.js +++ b/index.js @@ -19,6 +19,7 @@ const allRules = { 'jsx-closing-bracket-location': require('./lib/rules/jsx-closing-bracket-location'), 'jsx-closing-tag-location': require('./lib/rules/jsx-closing-tag-location'), 'jsx-curly-spacing': require('./lib/rules/jsx-curly-spacing'), + 'jsx-curly-newline': require('./lib/rules/jsx-curly-newline'), 'jsx-equals-spacing': require('./lib/rules/jsx-equals-spacing'), 'jsx-filename-extension': require('./lib/rules/jsx-filename-extension'), 'jsx-first-prop-new-line': require('./lib/rules/jsx-first-prop-new-line'), diff --git a/lib/rules/jsx-curly-newline.js b/lib/rules/jsx-curly-newline.js new file mode 100644 index 0000000000..2c67f58e9d --- /dev/null +++ b/lib/rules/jsx-curly-newline.js @@ -0,0 +1,187 @@ +/** + * @fileoverview enforce consistent line breaks inside jsx curly + */ + +'use strict'; + +const docsUrl = require('../util/docsUrl'); + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +function getNormalizedOption(context) { + const rawOption = context.options[0] || 'consistent'; + + if (rawOption === 'consistent') { + return { + multiline: 'consistent', + singleline: 'consistent' + }; + } + + if (rawOption === 'never') { + return { + multiline: 'forbid', + singleline: 'forbid' + }; + } + + return { + multiline: rawOption.multiline || 'consistent', + singleline: rawOption.singleline || 'consistent' + }; +} + +module.exports = { + meta: { + type: 'layout', + + docs: { + description: 'enforce consistent line breaks inside jsx curly', + category: 'Stylistic Issues', + recommended: false, + url: docsUrl('jsx-curly-newline') + }, + + fixable: 'whitespace', + + schema: [ + { + oneOf: [ + { + enum: ['consistent', 'never'] + }, + { + type: 'object', + properties: { + singleline: {enum: ['consistent', 'require', 'forbid']}, + multiline: {enum: ['consistent', 'require', 'forbid']} + }, + additionalProperties: false + } + ] + } + ], + + + messages: { + expectedBefore: 'Expected newline before \'}\'.', + expectedAfter: 'Expected newline after \'{\'.', + unexpectedBefore: 'Unexpected newline before \'{\'.', + unexpectedAfter: 'Unexpected newline after \'}\'.' + } + }, + + create(context) { + const sourceCode = context.getSourceCode(); + const option = getNormalizedOption(context); + + // ---------------------------------------------------------------------- + // Helpers + // ---------------------------------------------------------------------- + + /** + * Determines whether two adjacent tokens are on the same line. + * @param {Object} left - The left token object. + * @param {Object} right - The right token object. + * @returns {boolean} Whether or not the tokens are on the same line. + */ + function isTokenOnSameLine(left, right) { + return left.loc.end.line === right.loc.start.line; + } + + /** + * Determines whether there should be newlines inside curlys + * @param {ASTNode} expression The expression contained in the curlys + * @param {boolean} hasLeftNewline `true` if the left curly has a newline in the current code. + * @returns {boolean} `true` if there should be newlines inside the function curlys + */ + function shouldHaveNewlines(expression, hasLeftNewline) { + const isMultiline = expression.loc.start.line !== expression.loc.end.line; + + switch (isMultiline ? option.multiline : option.singleline) { + case 'forbid': return false; + case 'require': return true; + case 'consistent': + default: return hasLeftNewline; + } + } + + /** + * Validates curlys + * @param {Object} curlys An object with keys `leftParen` for the left paren token, and `rightParen` for the right paren token + * @param {ASTNode} expression The expression inside the curly + * @returns {void} + */ + function validateCurlys(curlys, expression) { + const leftCurly = curlys.leftCurly; + const rightCurly = curlys.rightCurly; + const tokenAfterLeftCurly = sourceCode.getTokenAfter(leftCurly); + const tokenBeforeRightCurly = sourceCode.getTokenBefore(rightCurly); + const hasLeftNewline = !isTokenOnSameLine(leftCurly, tokenAfterLeftCurly); + const hasRightNewline = !isTokenOnSameLine(tokenBeforeRightCurly, rightCurly); + const needsNewlines = shouldHaveNewlines(expression, hasLeftNewline); + + if (hasLeftNewline && !needsNewlines) { + context.report({ + node: leftCurly, + messageId: 'unexpectedAfter', + fix(fixer) { + return sourceCode + .getText() + .slice(leftCurly.range[1], tokenAfterLeftCurly.range[0]) + .trim() ? + null : // If there is a comment between the { and the first element, don't do a fix. + fixer.removeRange([leftCurly.range[1], tokenAfterLeftCurly.range[0]]); + } + }); + } else if (!hasLeftNewline && needsNewlines) { + context.report({ + node: leftCurly, + messageId: 'expectedAfter', + fix: fixer => fixer.insertTextAfter(leftCurly, '\n') + }); + } + + if (hasRightNewline && !needsNewlines) { + context.report({ + node: rightCurly, + messageId: 'unexpectedBefore', + fix(fixer) { + return sourceCode + .getText() + .slice(tokenBeforeRightCurly.range[1], rightCurly.range[0]) + .trim() ? + null : // If there is a comment between the last element and the }, don't do a fix. + fixer.removeRange([ + tokenBeforeRightCurly.range[1], + rightCurly.range[0] + ]); + } + }); + } else if (!hasRightNewline && needsNewlines) { + context.report({ + node: rightCurly, + messageId: 'expectedBefore', + fix: fixer => fixer.insertTextBefore(rightCurly, '\n') + }); + } + } + + + // ---------------------------------------------------------------------- + // Public + // ---------------------------------------------------------------------- + + return { + JSXExpressionContainer(node) { + const curlyTokens = { + leftCurly: sourceCode.getFirstToken(node), + rightCurly: sourceCode.getLastToken(node) + }; + validateCurlys(curlyTokens, node.expression); + } + }; + } +}; diff --git a/tests/lib/rules/jsx-curly-newline.js b/tests/lib/rules/jsx-curly-newline.js new file mode 100644 index 0000000000..8b4779cc18 --- /dev/null +++ b/tests/lib/rules/jsx-curly-newline.js @@ -0,0 +1,298 @@ +/** + * @fileoverview enforce consistent line breaks inside jsx curly + */ + +'use strict'; + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const RuleTester = require('eslint').RuleTester; +const rule = require('../../../lib/rules/jsx-curly-newline'); + +const parserOptions = { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { + jsx: true + } +}; + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const LEFT_MISSING_ERROR = {messageId: 'expectedAfter', type: 'Punctuator'}; +const LEFT_UNEXPECTED_ERROR = {messageId: 'unexpectedAfter', type: 'Punctuator'}; +const RIGHT_MISSING_ERROR = {messageId: 'expectedBefore', type: 'Punctuator'}; +const RIGHT_UNEXPECTED_ERROR = {messageId: 'unexpectedBefore', type: 'Punctuator'}; +// const EXPECTED_BETWEEN = {messageId: 'expectedBetween', type: 'Identifier'}; + +const CONSISTENT = ['consistent']; +const NEVER = ['never']; +const MULTILINE_REQUIRE = [{singleline: 'consistent', multiline: 'require'}]; + +const ruleTester = new RuleTester({parserOptions}); + +ruleTester.run('jsx-curly-newline', rule, { + + valid: [ + + // consistent option (default) + + { + code: '
{foo}
', + options: ['consistent'] + }, + + { + code: ` +
+ { + foo + } +
`, + options: CONSISTENT + }, + + { + code: ` +
+ { foo && + foo.bar } +
`, + options: CONSISTENT + }, + + { + code: ` +
+ { + foo && + foo.bar + } +
`, + options: CONSISTENT + }, + + { + code: ` +
`, + options: CONSISTENT + }, + + + // {singleline: 'consistent', multiline: 'require'} option + + { + code: '
{foo}
', + options: MULTILINE_REQUIRE + }, + { + code: '
', + options: MULTILINE_REQUIRE + }, + { + code: ` +
+ { + foo && + foo.bar + } +
`, + options: MULTILINE_REQUIRE + }, + { + code: ` +
+ { + foo + } +
`, + options: MULTILINE_REQUIRE + }, + + // never option + + { + code: '
{foo}
', + options: NEVER + }, + + { + code: '
', + options: NEVER + }, + + { + code: ` +
+ { foo && + foo.bar } +
`, + + options: NEVER + } + ], + + invalid: [ + + // conistent option (default) + + { + code: ` +
+ { foo \n} +
`, + output: ` +
+ { foo} +
`, + options: CONSISTENT, + errors: [RIGHT_UNEXPECTED_ERROR] + }, + + { + code: ` +
+ { foo && + foo.bar \n} +
`, + output: ` +
+ { foo && + foo.bar} +
`, + options: CONSISTENT, + errors: [RIGHT_UNEXPECTED_ERROR] + }, + { + code: ` +
+ { foo && + bar + } +
`, + output: ` +
+ { foo && + bar} +
`, + options: CONSISTENT, + errors: [RIGHT_UNEXPECTED_ERROR] + }, + + // {singleline: 'consistent', multiline: 'require'} option + { + code: '
{foo\n}
', + output: '
{foo}
', + errors: [RIGHT_UNEXPECTED_ERROR], + options: MULTILINE_REQUIRE + }, + { + code: '
{\nfoo}
', + output: '
{\nfoo\n}
', + errors: [RIGHT_MISSING_ERROR], + options: MULTILINE_REQUIRE + }, + { + code: ` +
+ { foo && + bar } +
`, + output: ` +
+ {\n foo && + bar \n} +
`, + errors: [LEFT_MISSING_ERROR, RIGHT_MISSING_ERROR], + options: MULTILINE_REQUIRE + }, + { + code: ` +
`, + output: ` +
`, + errors: [LEFT_MISSING_ERROR], + options: MULTILINE_REQUIRE + }, + + // never options + + { + code: ` +
+ {\nfoo\n} +
`, + output: ` +
+ {foo} +
`, + options: NEVER, + errors: [LEFT_UNEXPECTED_ERROR, RIGHT_UNEXPECTED_ERROR] + }, + + { + code: ` +
+ { + foo && + foo.bar + } +
`, + output: ` +
+ {foo && + foo.bar} +
`, + options: NEVER, + errors: [LEFT_UNEXPECTED_ERROR, RIGHT_UNEXPECTED_ERROR] + }, + + { + code: ` +
+ { foo && + foo.bar + } +
`, + output: ` +
+ { foo && + foo.bar} +
`, + options: NEVER, + errors: [RIGHT_UNEXPECTED_ERROR] + }, + + { + code: ` +
+ { /* not fixed due to comment */ + foo } +
`, + output: null, + options: NEVER, + errors: [LEFT_UNEXPECTED_ERROR] + }, + + { + code: ` +
+ { foo + /* not fixed due to comment */} +
`, + output: null, + options: NEVER, + errors: [RIGHT_UNEXPECTED_ERROR] + } + ] +});