diff --git a/docs/rules/jsx-curly-newline.md b/docs/rules/jsx-curly-newline.md new file mode 100644 index 0000000000..3c174b7815 --- /dev/null +++ b/docs/rules/jsx-curly-newline.md @@ -0,0 +1,125 @@ +# 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 has a string option: + +* `consistent` enforces either both of linebreaks around curlies are present, or none are present. +* `multiline` enforces that when the contained expression spans multiple line, require both linebreaks. When the contained expression is single line, disallow both line breaks. +* `multiline-lax` (default) enforces that when the contained expression spans multiple line, require both linebreaks. When the contained expression is single line, disallow inconsistent line break. + +### consistent + +When `consistent` is set, the following patterns are considered warnings: + +```jsx +
+ { foo + } +
+ +
+ { + foo } +
+``` + +The following patterns are **not** warnings: + +```jsx +
+ { foo } +
+ +
+ { + foo + } +
+``` + +### multiline + +When `multiline` 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 } +
+``` + +### multiline-lax + +When `multiline-lax` (default) is set, the following patterns are considered warnings: + +```jsx +
+ { foo && + foo.bar } +
+ +
+ { foo + } +
+``` +The following patterns are **not** warnings: + +```jsx +
+ { + foo && + foo.bar + } +
+ +
+ { + foo + } +
+ +
+ { foo } +
+ +``` + +## When Not To Use It + +You can turn this rule off if you are not concerned with the consistency around the linebreaks inside of JSX attributes or expressions. diff --git a/index.js b/index.js index b2d4ccdb90..de964f8251 100644 --- a/index.js +++ b/index.js @@ -18,6 +18,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..f0d8ae1cea --- /dev/null +++ b/lib/rules/jsx-curly-newline.js @@ -0,0 +1,182 @@ +/** + * @fileoverview enforce consistent line breaks inside jsx curly + */ +'use strict'; +const docsUrl = require('../util/docsUrl'); + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +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: [ + { + enum: ['consistent', 'multiline', 'multiline-lax'] + } + ], + + messages: { + expectedBefore: 'Expected newline before \'}\'.', + expectedAfter: 'Expected newline after \'{\'.', + unexpectedBefore: 'Unexpected newline before \'{\'.', + unexpectedAfter: 'Unexpected newline after \'}\'.' + } + }, + + create(context) { + const sourceCode = context.getSourceCode(); + const rawOption = context.options[0] || 'multiline-lax'; + const multilineOption = rawOption === 'multiline'; + const multilineLaxOption = rawOption === 'multiline-lax'; + + // ---------------------------------------------------------------------- + // 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 arguments or parameters in the list + * @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 expressionIsMultiline = expression.loc.start.line !== expression.loc.end.line; + + if (multilineLaxOption && !expressionIsMultiline) { + return hasLeftNewline; + } + if (multilineOption || multilineLaxOption) { + return expressionIsMultiline; + } + + 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() + ? // If there is a comment between the { and the first element, don't do a fix. + null + : 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() + ? // If there is a comment between the last element and the }, don't do a fix. + null + : fixer.removeRange([ + tokenBeforeRightCurly.range[1], + rightCurly.range[0] + ]); + } + }); + } else if (!hasRightNewline && needsNewlines) { + context.report({ + node: rightCurly, + messageId: 'expectedBefore', + fix: fixer => fixer.insertTextBefore(rightCurly, '\n') + }); + } + } + + /** + * Gets the left curly and right curly tokens of a node. + * @param {ASTNode} node The JSXExpressionContainer node. + * @returns {{leftCurly: Object, rightCurly: Object}} An object contaning left and right curly tokens. + */ + function getCurlyTokens(node) { + return { + leftCurly: sourceCode.getFirstToken(node), + rightCurly: sourceCode.getLastToken(node) + }; + } + + /** + * Validates the curlys for a JSXExpressionContainer node. + * @param {ASTNode} node The JSXExpressionContainer node. + * @returns {void} + */ + function validateNode(node) { + validateCurlys( + getCurlyTokens(node), + node.expression + ); + } + + // ---------------------------------------------------------------------- + // Public + // ---------------------------------------------------------------------- + + return { + JSXExpressionContainer: validateNode + }; + } +}; diff --git a/tests/lib/rules/jsx-curly-newline.js b/tests/lib/rules/jsx-curly-newline.js new file mode 100644 index 0000000000..546ce98df6 --- /dev/null +++ b/tests/lib/rules/jsx-curly-newline.js @@ -0,0 +1,265 @@ +/** + * @fileoverview enforce consistent line breaks inside jsx curly + */ +'use strict'; + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/jsx-curly-newline'); +const RuleTester = require('eslint').RuleTester; + +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 ruleTester = new RuleTester({parserOptions}); + +ruleTester.run('jsx-curly-newline', rule, { + + valid: [ + + // multiline-lax option (default) + + '
{foo}
', + '
', + ` +
+ { + foo && + foo.bar + } +
`, + ` +
+ { + foo + } +
`, + ` +
`, + + // multiline option + + { + code: '
{foo}
', + options: ['multiline'] + }, + + { + code: '
', + options: ['multiline'] + }, + + { + code: ` +
+ { + foo && + foo.bar + } +
`, + + options: ['multiline'] + }, + + // consistent option + + { + code: '
{foo}
', + options: ['consistent'], + parser: 'babel-eslint' + }, + + { + code: ` +
+ { + foo + } +
`, + options: ['consistent'], + parser: 'babel-eslint' + }, + + { + code: ` +
+ { foo && + foo.bar } +
`, + options: ['consistent'], + parser: 'babel-eslint' + }, + + { + code: ` +
+ { + foo && + foo.bar + } +
`, + options: ['consistent'], + parser: 'babel-eslint' + } + ], + + invalid: [ + + // multiline-lax option (default) + { + code: '
{foo\n}
', + output: '
{foo}
', + errors: [RIGHT_UNEXPECTED_ERROR] + }, + { + code: '
{\nfoo}
', + output: '
{\nfoo\n}
', + errors: [RIGHT_MISSING_ERROR] + }, + { + code: ` +
+ { foo && + bar } +
`, + output: ` +
+ {\n foo && + bar \n} +
`, + errors: [LEFT_MISSING_ERROR, RIGHT_MISSING_ERROR] + }, + + { + code: ` +
`, + output: ` +
`, + errors: [LEFT_MISSING_ERROR] + }, + + // multiline options + + { + code: ` +
+ {\nfoo\n} +
`, + output: ` +
+ {foo} +
`, + options: ['multiline'], + errors: [LEFT_UNEXPECTED_ERROR, RIGHT_UNEXPECTED_ERROR] + }, + + { + code: ` +
+ { foo && + foo.bar } +
`, + output: ` +
+ {\n foo && + foo.bar \n} +
`, + options: ['multiline'], + errors: [LEFT_MISSING_ERROR, RIGHT_MISSING_ERROR] + }, + + { + code: ` +
+ { foo && + foo.bar + } +
`, + output: ` +
+ {\n foo && + foo.bar + } +
`, + options: ['multiline'], + errors: [LEFT_MISSING_ERROR] + }, + + { + code: ` +
+ { /* not fixed due to comment */ + foo } +
`, + output: null, + options: ['multiline'], + errors: [LEFT_UNEXPECTED_ERROR] + }, + + { + code: ` +
+ { foo + /* not fixed due to comment */} +
`, + output: null, + options: ['multiline'], + errors: [RIGHT_UNEXPECTED_ERROR] + }, + + // conistent option + + { + 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] + } + ] +});