diff --git a/index.js b/index.js index 13b72fdd4d..8a8df3b788 100644 --- a/index.js +++ b/index.js @@ -22,6 +22,7 @@ const allRules = { 'jsx-indent-props': require('./lib/rules/jsx-indent-props'), 'jsx-key': require('./lib/rules/jsx-key'), 'jsx-max-props-per-line': require('./lib/rules/jsx-max-props-per-line'), + 'jsx-one-expression-per-line': require('./lib/rules/jsx-one-expression-per-line'), 'jsx-no-bind': require('./lib/rules/jsx-no-bind'), 'jsx-no-comment-textnodes': require('./lib/rules/jsx-no-comment-textnodes'), 'jsx-no-duplicate-props': require('./lib/rules/jsx-no-duplicate-props'), diff --git a/lib/rules/jsx-one-expression-per-line.js b/lib/rules/jsx-one-expression-per-line.js new file mode 100644 index 0000000000..0ee1746232 --- /dev/null +++ b/lib/rules/jsx-one-expression-per-line.js @@ -0,0 +1,181 @@ +/** + * @fileoverview Limit to one element tag per line in JSX + * @author Mark Ivan Allen + */ + +'use strict'; + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +module.exports = { + meta: { + docs: { + description: 'Limit to one element tag per line in JSX', + category: 'Stylistic Issues', + recommended: false + }, + fixable: 'whitespace', + schema: [] + }, + + create: function (context) { + const sourceCode = context.getSourceCode(); + + function nodeKey (node) { + return `${node.loc.start.line},${node.loc.start.column}`; + } + + function nodeDescriptor (n) { + return n.openingElement ? n.openingElement.name.name : sourceCode.getText(n).replace(/\n/g, ''); + } + + return { + JSXElement: function (node) { + const children = node.children; + + if (!children || !children.length) { + return; + } + + const openingElement = node.openingElement; + const closingElement = node.closingElement; + const openingElementEndLine = openingElement.loc.end.line; + const closingElementStartLine = closingElement.loc.start.line; + + const childrenGroupedByLine = {}; + const fixDetailsByNode = {}; + + children.forEach(child => { + let countNewLinesBeforeContent = 0; + let countNewLinesAfterContent = 0; + + if (child.type === 'Literal') { + if (/^\s*$/.test(child.raw)) { + return; + } + + countNewLinesBeforeContent = (child.raw.match(/^ *\n/g) || []).length; + countNewLinesAfterContent = (child.raw.match(/\n *$/g) || []).length; + } + + const startLine = child.loc.start.line + countNewLinesBeforeContent; + const endLine = child.loc.end.line - countNewLinesAfterContent; + + if (startLine === endLine) { + if (!childrenGroupedByLine[startLine]) { + childrenGroupedByLine[startLine] = []; + } + childrenGroupedByLine[startLine].push(child); + } else { + if (!childrenGroupedByLine[startLine]) { + childrenGroupedByLine[startLine] = []; + } + childrenGroupedByLine[startLine].push(child); + if (!childrenGroupedByLine[endLine]) { + childrenGroupedByLine[endLine] = []; + } + childrenGroupedByLine[endLine].push(child); + } + }); + + Object.keys(childrenGroupedByLine).forEach(_line => { + const line = parseInt(_line, 10); + const firstIndex = 0; + const lastIndex = childrenGroupedByLine[line].length - 1; + + childrenGroupedByLine[line].forEach((child, i) => { + let prevChild; + let nextChild; + + if (i === firstIndex) { + if (line === openingElementEndLine) { + prevChild = openingElement; + } + } else { + prevChild = childrenGroupedByLine[line][i - 1]; + } + + if (i === lastIndex) { + if (line === closingElementStartLine) { + nextChild = closingElement; + } + } else { + // We don't need to append a trailing because the next child will prepend a leading. + // nextChild = childrenGroupedByLine[line][i + 1]; + } + + function spaceBetweenPrev () { + return (prevChild.type === 'Literal' && / $/.test(prevChild.raw)) || + (child.type === 'Literal' && /^ /.test(child.raw)) || + sourceCode.isSpaceBetweenTokens(prevChild, child); + } + + function spaceBetweenNext () { + return (nextChild.type === 'Literal' && /^ /.test(nextChild.raw)) || + (child.type === 'Literal' && / $/.test(child.raw)) || + sourceCode.isSpaceBetweenTokens(child, nextChild); + } + + if (!prevChild && !nextChild) { + return; + } + + const source = sourceCode.getText(child); + const leadingSpace = !!(prevChild && spaceBetweenPrev()); + const trailingSpace = !!(nextChild && spaceBetweenNext()); + const leadingNewLine = !!prevChild; + const trailingNewLine = !!nextChild; + + const key = nodeKey(child); + + if (!fixDetailsByNode[key]) { + fixDetailsByNode[key] = { + node: child, + source: source, + descriptor: nodeDescriptor(child) + }; + } + + if (leadingSpace) { + fixDetailsByNode[key].leadingSpace = true; + } + if (leadingNewLine) { + fixDetailsByNode[key].leadingNewLine = true; + } + if (trailingNewLine) { + fixDetailsByNode[key].trailingNewLine = true; + } + if (trailingSpace) { + fixDetailsByNode[key].trailingSpace = true; + } + }); + }); + + Object.keys(fixDetailsByNode).forEach(key => { + const details = fixDetailsByNode[key]; + + const nodeToReport = details.node; + const descriptor = details.descriptor; + const source = details.source.replace(/(^ +| +(?=\n)*$)/g, ''); + + const leadingSpaceString = details.leadingSpace ? '\n{\' \'}' : ''; + const trailingSpaceString = details.trailingSpace ? '{\' \'}\n' : ''; + const leadingNewLineString = details.leadingNewLine ? '\n' : ''; + const trailingNewLineString = details.trailingNewLine ? '\n' : ''; + + const replaceText = `${leadingSpaceString}${leadingNewLineString}${source}${trailingNewLineString}${trailingSpaceString}`; + + context.report({ + node: nodeToReport, + message: `\`${descriptor}\` must be placed on a new line`, + fix: function (fixer) { + return fixer.replaceText(nodeToReport, replaceText); + } + }); + }); + } + }; + } +}; diff --git a/tests/lib/rules/jsx-one-expression-per-line.js b/tests/lib/rules/jsx-one-expression-per-line.js new file mode 100644 index 0000000000..ad9b3baf3f --- /dev/null +++ b/tests/lib/rules/jsx-one-expression-per-line.js @@ -0,0 +1,857 @@ +/** + * @fileoverview Limit to one element tag per line in JSX + * @author Mark Ivan Allen + */ + +'use strict'; + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/jsx-one-expression-per-line'); +const RuleTester = require('eslint').RuleTester; + +const parserOptions = { + ecmaVersion: 8, + sourceType: 'module', + ecmaFeatures: { + experimentalObjectRestSpread: true, + jsx: true + } +}; + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({parserOptions}); +ruleTester.run('jsx-one-expression-per-line', rule, { + valid: [{ + code: '' + }, { + code: '' + }, { + code: '' + }, { + code: [ + '', + ' ', + '' + ].join('\n') + }, { + code: [ + '', + ' ', + ' ', + '' + ].join('\n') + }, { + code: [ + '', + ' ', + '' + ].join('\n') + }, { + code: [ + '', + ' foo bar baz whatever ', + '' + ].join('\n') + }, { + code: [ + '', + ' ', + ' ', + '' + ].join('\n') + }, { + code: [ + '', + '', + '' + ].join('\n') + }, { + code: [ + '<', + 'App', + '>', + ' <', + ' Foo', + ' />', + '' + ].join('\n') + }], + + invalid: [{ + code: '{"foo"}', + output: [ + '', + '{"foo"}', + '' + ].join('\n'), + errors: [{message: '`{"foo"}` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: 'foo', + output: [ + '', + 'foo', + '' + ].join('\n'), + errors: [{message: '`foo` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '
', + ' foo {"bar"}', + '
' + ].join('\n'), + output: [ + '
', + ' foo ', + '{\' \'}', + '{"bar"}', + '
' + ].join('\n'), + errors: [ + {message: '`{"bar"}` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + code: [ + '
', + ' {"foo"} bar', + '
' + ].join('\n'), + output: [ + '
', + ' {"foo"}', + '{\' \'}', + 'bar', + '
' + ].join('\n'), + errors: [ + {message: '` bar` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + code: [ + '', + ' ', + '' + ].join('\n'), + output: [ + '', + ' ', + '', + '' + ].join('\n'), + errors: [{message: '`Bar` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '
', + ' foo', + '
' + ].join('\n'), + output: [ + '
', + ' ', + 'foo', + '
' + ].join('\n'), + errors: [{message: '`foo` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '
', + ' {"foo"}', + '
' + ].join('\n'), + output: [ + '
', + ' ', + '{"foo"}', + '
' + ].join('\n'), + errors: [{message: '`{"foo"}` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '
', + ' {"foo"} { I18n.t(\'baz\') }', + '
' + ].join('\n'), + output: [ + '
', + ' {"foo"} ', + '{\' \'}', + '{ I18n.t(\'baz\') }', + '
' + ].join('\n'), + errors: [ + {message: '`{ I18n.t(\'baz\') }` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + code: [ + '{ bar } { I18n.t(\'baz\') }' + ].join('\n'), + output: [ + '', + '{ bar } ', + '{\' \'}', + ' ', + '{\' \'}', + '{ I18n.t(\'baz\') }', + '' + ].join('\n'), + errors: [ + {message: '`{ bar }` must be placed on a new line'}, + {message: '`Text` must be placed on a new line'}, + {message: '`{ I18n.t(\'baz\') }` must be placed on a new line'} + ], + parserOptions: parserOptions + + }, { + code: [ + ' ' + ].join('\n'), + output: [ + ' ', + '{\' \'}', + ' ', + '{\' \'}', + '', + '' + ].join('\n'), + errors: [ + {message: '`Bar` must be placed on a new line'}, + {message: '`Baz` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + code: [ + ' ' + ].join('\n'), + output: [ + ' ', + '{\' \'}', + ' ', + '{\' \'}', + ' ', + '{\' \'}', + ' ', + '{\' \'}', + '', + '{\' \'}', + ' ' + ].join('\n'), + errors: [ + {message: '`Bar` must be placed on a new line'}, + {message: '`Baz` must be placed on a new line'}, + {message: '`Bunk` must be placed on a new line'}, + {message: '`Bruno` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + code: [ + ' ' + ].join('\n'), + output: [ + ' ', + '{\' \'}', + '', + '' + ].join('\n'), + errors: [ + {message: '`Bar` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + code: [ + ' ', + '' + ].join('\n'), + output: [ + ' ', + '{\' \'}', + '', + '' + ].join('\n'), + errors: [ + {message: '`Bar` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + code: [ + '', + ' ', + '' + ].join('\n'), + output: [ + '', + ' ', + '{\' \'}', + '', + '' + ].join('\n'), + errors: [ + {message: '`Baz` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + code: [ + '', + ' { bar } { I18n.t(\'baz\') }', + '' + ].join('\n'), + output: [ + '', + ' { bar } ', + '{\' \'}', + '{ I18n.t(\'baz\') }', + '' + ].join('\n'), + errors: [ + {message: '`{ I18n.t(\'baz\') }` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + code: [ + '
', + ' foo', + '
' + ].join('\n'), + output: [ + '
', + ' foo', + '', + '
' + ].join('\n'), + errors: [ + {message: '`input` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + code: [ + '
', + ' {"foo"}', + '
' + ].join('\n'), + output: [ + '
', + ' {"foo"}', + '', + '
' + ].join('\n'), + errors: [{message: '`span` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '
', + ' foo ', + '
' + ].join('\n'), + output: [ + '
', + ' foo ', + '{\' \'}', + '', + '
' + ].join('\n'), + errors: [{message: '`input` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '
', + ' foo', + '
' + ].join('\n'), + output: [ + '
', + ' ', + '{\' \'}', + 'foo', + '
' + ].join('\n'), + errors: [{message: '` foo` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '
', + ' ', + '
' + ].join('\n'), + output: [ + '
', + ' ', + '{\' \'}', + '', + '
' + ].join('\n'), + errors: [ + {message: '`input` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + code: [ + '
', + ' ', + '{\' \'}', + '
' + ].join('\n'), + output: [ + '
', + ' ', + '{\' \'}', + '', + '
' + ].join('\n'), + errors: [ + {message: '`input` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + code: [ + '
', + ' {"foo"} ', + '
' + ].join('\n'), + output: [ + '
', + ' {"foo"} ', + '{\' \'}', + '', + '
' + ].join('\n'), + errors: [ + {message: '`input` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + code: [ + '
', + ' {"foo"} bar', + '
' + ].join('\n'), + output: [ + '
', + ' {"foo"}', + '{\' \'}', + 'bar', + '
' + ].join('\n'), + errors: [{message: '` bar` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '
', + ' foo {"bar"}', + '
' + ].join('\n'), + output: [ + '
', + ' foo ', + '{\' \'}', + '{"bar"}', + '
' + ].join('\n'), + errors: [ + {message: '`{"bar"}` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + code: [ + '
', + ' {"foo"}', + '
' + ].join('\n'), + output: [ + '
', + ' ', + '{\' \'}', + '{"foo"}', + '
' + ].join('\n'), + errors: [ + {message: '`{"foo"}` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + code: [ + '', + ' ', + '' + ].join('\n'), + output: [ + '', + ' ', + '', + '' + ].join('\n'), + errors: [{message: '`Bar` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '', + '' + ].join('\n'), + output: [ + '', + '', + '' + ].join('\n'), + errors: [{message: '`Foo` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '', + '' + ].join('\n'), + output: [ + '', + '', + '' + ].join('\n'), + errors: [{message: '`Foo` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '', + '' + ].join('\n'), + output: [ + '', + '', + '' + ].join('\n'), + errors: [{message: '`Foo` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '', + '' + ].join('\n'), + output: [ + '', + '', + '' + ].join('\n'), + errors: [{message: '`Foo` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '', + '
' + ].join('\n'), + output: [ + '', + '', + '' + ].join('\n'), + errors: [{message: '`Foo` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '', + '' + ].join('\n'), + output: [ + '', + '', + '' + ].join('\n'), + errors: [{message: '`Foo` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '', + '' + ].join('\n'), + output: [ + '', + '', + '' + ].join('\n'), + errors: [{message: '`Foo` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '', + ' ' + ].join('\n'), + output: [ + '', + ' ', + '' + ].join('\n'), + errors: [{message: '`Foo` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '', + ' ' + ].join('\n'), + output: [ + '', + ' ', + '' + ].join('\n'), + errors: [{message: '`Foo` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '', + ' ' + ].join('\n'), + output: [ + '', + ' ', + '' + ].join('\n'), + errors: [{message: '`Foo` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '', + ' ', + '' + ].join('\n'), + output: [ + '', + ' ', + '', + '' + ].join('\n'), + errors: [{message: '`Bar` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '', + ' ', + ' ', + '' + ].join('\n'), + output: [ + '', + ' ', + ' ', + '', + '' + ].join('\n'), + errors: [{message: '`Bar` must be placed on a new line'}], + parserOptions: parserOptions + }, { + code: [ + '', + ' ', + ' baz ', + ' ', + '' + ].join('\n'), + output: [ + '', + ' ', + ' ', + '{\' \'}', + 'baz', + '{\' \'}', + '', + ' ', + '' + ].join('\n'), + errors: [ + {message: '` baz ` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + // Would be nice to handle in one pass, but multipass works fine. + code: [ + '', + ' foo {"bar"} baz', + '' + ].join('\n'), + output: [ + '', + ' foo ', + '{\' \'}', + '{"bar"} baz', + '' + ].join('\n'), + errors: [ + {message: '`{"bar"}` must be placed on a new line'}, + {message: '` baz` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + // Would be nice to handle in one pass, but multipass works fine. + code: [ + '', + ' foo {"bar"}', + '' + ].join('\n'), + output: [ + '', + ' foo ', + '{\' \'}', + '{"bar"}', + '' + ].join('\n'), + errors: [ + {message: '`{"bar"}` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + // Would be nice to handle in one pass, but multipass works fine. + code: [ + '', + ' foo ', + '{\' \'}', + '{"bar"} baz', + '' + ].join('\n'), + output: [ + '', + ' foo ', + '{\' \'}', + '{"bar"}', + '{\' \'}', + 'baz', + '' + ].join('\n'), + errors: [ + {message: '` baz` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + // Would be nice to handle in one pass, but multipass works fine. + code: [ + '', + '', + ' foo {"bar"} baz', + '', + '' + ].join('\n'), + output: [ + '', + '', + ' foo ', + '{\' \'}', + '{"bar"} baz', + '', + '' + ].join('\n'), + errors: [ + {message: '`{"bar"}` must be placed on a new line'}, + {message: '` baz` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + // Would be nice to handle in one pass, but multipass works fine. + code: [ + '', + '', + ' foo ', + '{\' \'}', + '{"bar"} baz', + '', + '' + ].join('\n'), + output: [ + '', + '', + ' foo ', + '{\' \'}', + '{"bar"}', + '{\' \'}', + 'baz', + '', + '' + ].join('\n'), + errors: [ + {message: '` baz` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + code: [ + '{', + ' foo', + '}' + ].join('\n'), + output: [ + '', + '{', + ' foo', + '}', + '' + ].join('\n'), + errors: [ + {message: '`{ foo}` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + code: [ + ' {', + ' foo', + '} ' + ].join('\n'), + output: [ + ' ', + '{\' \'}', + '{', + ' foo', + '}', + '{\' \'}', + ' ' + ].join('\n'), + errors: [ + {message: '`{ foo}` must be placed on a new line'} + ], + parserOptions: parserOptions + }, { + code: [ + ' ', + '{\' \'}', + '{', + ' foo', + '} ' + ].join('\n'), + output: [ + ' ', + '{\' \'}', + '{', + ' foo', + '}', + '{\' \'}', + ' ' + ].join('\n'), + errors: [ + {message: '`{ foo}` must be placed on a new line'} + ], + parserOptions: parserOptions + }] +});