From 64bd5a0960f984c2e48d419695bcdcd98d5c9de7 Mon Sep 17 00:00:00 2001 From: "Diogo Franco (Kovensky)" Date: Mon, 25 Jul 2016 09:54:59 +0900 Subject: [PATCH] Add jsx-tag-spacing rule (Fixes #693) --- index.js | 3 +- lib/rules/jsx-tag-spacing.js | 250 +++++++++++++++++++++++ tests/lib/rules/jsx-tag-spacing.js | 308 +++++++++++++++++++++++++++++ 3 files changed, 560 insertions(+), 1 deletion(-) create mode 100644 lib/rules/jsx-tag-spacing.js create mode 100644 tests/lib/rules/jsx-tag-spacing.js diff --git a/index.js b/index.js index a0ac8bf0b1..023c7bfd3b 100644 --- a/index.js +++ b/index.js @@ -56,7 +56,8 @@ var rules = { 'no-find-dom-node': require('./lib/rules/no-find-dom-node'), 'no-danger-with-children': require('./lib/rules/no-danger-with-children'), 'style-prop-object': require('./lib/rules/style-prop-object'), - 'no-unused-prop-types': require('./lib/rules/no-unused-prop-types') + 'no-unused-prop-types': require('./lib/rules/no-unused-prop-types'), + 'jsx-tag-spacing': require('./lib/rules/jsx-tag-spacing') }; var ruleNames = Object.keys(rules); diff --git a/lib/rules/jsx-tag-spacing.js b/lib/rules/jsx-tag-spacing.js new file mode 100644 index 0000000000..6f69d299ba --- /dev/null +++ b/lib/rules/jsx-tag-spacing.js @@ -0,0 +1,250 @@ +/** + * @fileoverview Validates whitespace in and around the JSX opening and closing brackets + * @author Diogo Franco (Kovensky) + */ +'use strict'; + +// ------------------------------------------------------------------------------ +// Helpers +// ------------------------------------------------------------------------------ + +/** + * Find the token before the closing bracket. + * @param {ASTNode} node - The JSX element node. + * @returns {Token} The token before the closing bracket. + */ +function getTokenBeforeClosingBracket(node) { + var attributes = node.attributes; + if (attributes.length === 0) { + return node.name; + } + return attributes[ attributes.length - 1 ]; +} + +// ------------------------------------------------------------------------------ +// Validators +// ------------------------------------------------------------------------------ + +function validateClosingSlash(context, node, option) { + var sourceCode = context.getSourceCode(); + + var SELF_CLOSING_NEVER_MESSAGE = 'Whitespace is forbidden between `/` and `>`'; + var SELF_CLOSING_ALWAYS_MESSAGE = 'Whitespace is required between `/` and `>`'; + var NEVER_MESSAGE = 'Whitespace is forbidden between `<` and `/`'; + var ALWAYS_MESSAGE = 'Whitespace is required between `<` and `/`'; + + var adjacent; + + if (node.selfClosing) { + var lastTokens = sourceCode.getLastTokens(node, 2); + + adjacent = !sourceCode.isSpaceBetweenTokens(lastTokens[0], lastTokens[1]); + + if (option === 'never') { + if (!adjacent) { + context.report({ + node: node, + loc: { + start: lastTokens[0].loc.start, + end: lastTokens[1].loc.end + }, + message: SELF_CLOSING_NEVER_MESSAGE, + fix: function(fixer) { + return fixer.removeRange([lastTokens[0].range[1], lastTokens[1].range[0]]); + } + }); + } + } else if (option === 'always' && adjacent) { + context.report({ + node: node, + loc: { + start: lastTokens[0].loc.start, + end: lastTokens[1].loc.end + }, + message: SELF_CLOSING_ALWAYS_MESSAGE, + fix: function(fixer) { + return fixer.insertTextBefore(lastTokens[1], ' '); + } + }); + } + } else { + var firstTokens = sourceCode.getFirstTokens(node, 2); + + adjacent = !sourceCode.isSpaceBetweenTokens(firstTokens[0], firstTokens[1]); + + if (option === 'never') { + if (!adjacent) { + context.report({ + node: node, + loc: { + start: firstTokens[0].loc.start, + end: firstTokens[1].loc.end + }, + message: NEVER_MESSAGE, + fix: function(fixer) { + return fixer.removeRange([firstTokens[0].range[1], firstTokens[1].range[0]]); + } + }); + } + } else if (option === 'always' && adjacent) { + context.report({ + node: node, + loc: { + start: firstTokens[0].loc.start, + end: firstTokens[1].loc.end + }, + message: ALWAYS_MESSAGE, + fix: function(fixer) { + return fixer.insertTextBefore(firstTokens[1], ' '); + } + }); + } + } +} + +function validateBeforeSelfClosing(context, node, option) { + var sourceCode = context.getSourceCode(); + + var NEVER_MESSAGE = 'A space is forbidden before closing bracket'; + var ALWAYS_MESSAGE = 'A space is required before closing bracket'; + + var leftToken = getTokenBeforeClosingBracket(node); + var closingSlash = sourceCode.getTokenAfter(leftToken); + + if (leftToken.loc.end.line !== closingSlash.loc.start.line) { + return; + } + + if (option === 'always' && !sourceCode.isSpaceBetweenTokens(leftToken, closingSlash)) { + context.report({ + node: node, + loc: closingSlash.loc.start, + message: ALWAYS_MESSAGE, + fix: function(fixer) { + return fixer.insertTextBefore(closingSlash, ' '); + } + }); + } else if (option === 'never' && sourceCode.isSpaceBetweenTokens(leftToken, closingSlash)) { + context.report({ + node: node, + loc: closingSlash.loc.start, + message: NEVER_MESSAGE, + fix: function(fixer) { + var previousToken = sourceCode.getTokenBefore(closingSlash); + return fixer.removeRange([previousToken.range[1], closingSlash.range[0]]); + } + }); + } +} + +function validateAfterOpening(context, node, option) { + var sourceCode = context.getSourceCode(); + + var NEVER_MESSAGE = 'A space is forbidden after opening bracket'; + var ALWAYS_MESSAGE = 'A space is required after opening bracket'; + + var openingToken = sourceCode.getTokenBefore(node.name); + + if (option === 'allow-multiline') { + if (openingToken.loc.start.line !== node.name.loc.start.line) { + return; + } + } + + var adjacent = !sourceCode.isSpaceBetweenTokens(openingToken, node.name); + + if (option === 'never' || option === 'allow-multiline') { + if (!adjacent) { + context.report({ + node: node, + loc: { + start: openingToken.loc.start, + end: node.name.loc.start + }, + message: NEVER_MESSAGE, + fix: function(fixer) { + return fixer.removeRange([openingToken.range[1], node.name.range[0]]); + } + }); + } + } else if (option === 'always' && adjacent) { + context.report({ + node: node, + loc: { + start: openingToken.loc.start, + end: node.name.loc.start + }, + message: ALWAYS_MESSAGE, + fix: function(fixer) { + return fixer.insertTextBefore(node.name, ' '); + } + }); + } +} + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +module.exports = { + meta: { + docs: {}, + fixable: 'whitespace', + schema: [ + { + type: 'object', + properties: { + closingSlash: { + enum: ['always', 'never', 'allow'] + }, + beforeSelfClosing: { + enum: ['always', 'never', 'allow'] + }, + afterOpening: { + enum: ['always', 'allow-multiline', 'never', 'allow'] + } + }, + default: { + closingSlash: 'never', + beforeSelfClosing: 'always', + afterOpening: 'never' + }, + additionalProperties: false + } + ] + }, + create: function (context) { + var options = { + closingSlash: 'never', + beforeSelfClosing: 'always', + afterOpening: 'never' + }; + for (var key in options) { + if (options.hasOwnProperty(key) && context.options[0].hasOwnProperty(key)) { + options[key] = context.options[0][key]; + } + } + + return { + JSXOpeningElement: function (node) { + if (options.closingSlash !== 'allow' && node.selfClosing) { + validateClosingSlash(context, node, options.closingSlash); + } + if (options.afterOpening !== 'allow') { + validateAfterOpening(context, node, options.afterOpening); + } + if (options.beforeSelfClosing !== 'allow' && node.selfClosing) { + validateBeforeSelfClosing(context, node, options.beforeSelfClosing); + } + }, + JSXClosingElement: function (node) { + if (options.afterOpening !== 'allow') { + validateAfterOpening(context, node, options.afterOpening); + } + if (options.closingSlash !== 'allow') { + validateClosingSlash(context, node, options.closingSlash); + } + } + }; + } +}; diff --git a/tests/lib/rules/jsx-tag-spacing.js b/tests/lib/rules/jsx-tag-spacing.js new file mode 100644 index 0000000000..c50ddadd92 --- /dev/null +++ b/tests/lib/rules/jsx-tag-spacing.js @@ -0,0 +1,308 @@ +/** + * @fileoverview Tests for jsx-tag-spacing + * @author Diogo Franco (Kovensky) + */ + +'use strict'; + +// ----------------------------------------------------------------------------- +// Requirements +// ----------------------------------------------------------------------------- + +var rule = require('../../../lib/rules/jsx-tag-spacing'); +var RuleTester = require('eslint').RuleTester; + +var parserOptions = { + ecmaVersion: 6, + ecmaFeatures: { + jsx: true + } +}; + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +// generate options object that disables checks other than the tested one + +function closingSlashOptions(option) { + return [{ + closingSlash: option, + beforeSelfClosing: 'allow', + afterOpening: 'allow' + }]; +} + +function beforeSelfClosingOptions(option) { + return [{ + closingSlash: 'allow', + beforeSelfClosing: option, + afterOpening: 'allow' + }]; +} + +function afterOpeningOptions(option) { + return [{ + closingSlash: 'allow', + beforeSelfClosing: 'allow', + afterOpening: option + }]; +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +var ruleTester = new RuleTester({ + parserOptions: parserOptions +}); +ruleTester.run('jsx-tag-spacing', rule, { + valid: [{ + code: '', + options: beforeSelfClosingOptions('always') + }, { + code: '', + options: beforeSelfClosingOptions('always') + }, { + code: '', + options: beforeSelfClosingOptions('always') + }, { + code: '', + options: beforeSelfClosingOptions('always') + }, { + code: '', + options: beforeSelfClosingOptions('always') + }, { + code: [ + '' + ].join('\n'), + options: beforeSelfClosingOptions('always') + }, { + code: '', + options: beforeSelfClosingOptions('never') + }, { + code: '', + options: beforeSelfClosingOptions('never') + }, { + code: '', + options: beforeSelfClosingOptions('never') + }, { + code: '', + options: beforeSelfClosingOptions('never') + }, { + code: '', + options: beforeSelfClosingOptions('never') + }, { + code: [ + '' + ].join('\n'), + options: beforeSelfClosingOptions('never') + }, { + code: ';', + options: closingSlashOptions('never') + }, { + code: ';', + options: closingSlashOptions('never') + }, { + code: '
;', + options: closingSlashOptions('never') + }, { + code: '
;', + options: closingSlashOptions('never') + }, { + code: '< /App>', + options: closingSlashOptions('always') + }, { + code: '

', + options: closingSlashOptions('always') + }, { + code: '', + options: afterOpeningOptions('never') + }, { + code: '', + options: afterOpeningOptions('never') + }, { + code: '< App>', + options: afterOpeningOptions('always') + }, { + code: '< App/>', + options: afterOpeningOptions('always') + }, { + code: [ + '<', + 'App/>' + ].join('\n'), + options: afterOpeningOptions('allow-multiline') + }, { + code: '', + options: [{ + closingSlash: 'never', + beforeSelfClosing: 'never', + afterOpening: 'never' + }] + }, { + code: '< App / >', + options: [{ + closingSlash: 'always', + beforeSelfClosing: 'always', + afterOpening: 'always' + }] + }], + + invalid: [{ + code: '', + output: '', + options: beforeSelfClosingOptions('always'), + errors: [ + {message: 'A space is required before closing bracket'} + ] + }, { + code: '', + output: '', + options: beforeSelfClosingOptions('always'), + errors: [ + {message: 'A space is required before closing bracket'} + ] + }, { + code: '', + output: '', + options: beforeSelfClosingOptions('always'), + errors: [ + {message: 'A space is required before closing bracket'} + ] + }, { + code: '', + output: '', + options: beforeSelfClosingOptions('always'), + errors: [ + {message: 'A space is required before closing bracket'} + ] + }, { + code: '', + output: '', + options: beforeSelfClosingOptions('never'), + errors: [ + {message: 'A space is forbidden before closing bracket'} + ] + }, { + code: '', + output: '', + options: beforeSelfClosingOptions('never'), + errors: [ + {message: 'A space is forbidden before closing bracket'} + ] + }, { + code: '', + output: '', + options: beforeSelfClosingOptions('never'), + errors: [ + {message: 'A space is forbidden before closing bracket'} + ] + }, { + code: '', + output: '', + options: beforeSelfClosingOptions('never'), + errors: [ + {message: 'A space is forbidden before closing bracket'} + ] + }, { + code: ';', + output: ';', + errors: [{message: 'Whitespace is forbidden between `/` and `>`'}], + options: closingSlashOptions('never') + }, { + code: [ + '' + ].join('\n'), + output: '', + errors: [{message: 'Whitespace is forbidden between `/` and `>`'}], + options: closingSlashOptions('never') + }, { + code: '

< /div>;', + output: '
;', + errors: [{message: 'Whitespace is forbidden between `<` and `/`'}], + options: closingSlashOptions('never') + }, { + code: [ + '
<', + '/div>;' + ].join('\n'), + output: '
;', + errors: [{message: 'Whitespace is forbidden between `<` and `/`'}], + options: closingSlashOptions('never') + }, { + code: '', + output: '< /App>', + errors: [{message: 'Whitespace is required between `<` and `/`'}], + options: closingSlashOptions('always') + }, { + code: '

', + output: '

', + errors: [{message: 'Whitespace is required between `/` and `>`'}], + options: closingSlashOptions('always') + }, { + code: '< App/>', + output: '', + errors: [{message: 'A space is forbidden after opening bracket'}], + options: afterOpeningOptions('never') + }, { + code: '< App>', + output: '', + errors: [{message: 'A space is forbidden after opening bracket'}], + options: afterOpeningOptions('never') + }, { + code: '', + output: '', + errors: [{message: 'A space is forbidden after opening bracket'}], + options: afterOpeningOptions('never') + }, { + code: '< App>', + output: '', + errors: [ + {message: 'A space is forbidden after opening bracket'}, + {message: 'A space is forbidden after opening bracket'} + ], + options: afterOpeningOptions('never') + }, { + code: [ + '<', + 'App/>' + ].join('\n'), + output: '', + errors: [{message: 'A space is forbidden after opening bracket'}], + options: afterOpeningOptions('never') + }, { + code: '', + output: '< App>', + errors: [{message: 'A space is required after opening bracket'}], + options: afterOpeningOptions('always') + }, { + code: '< App>', + output: '< App>', + errors: [{message: 'A space is required after opening bracket'}], + options: afterOpeningOptions('always') + }, { + code: '', + output: '< App>', + errors: [ + {message: 'A space is required after opening bracket'}, + {message: 'A space is required after opening bracket'} + ], + options: afterOpeningOptions('always') + }, { + code: '', + output: '< App/>', + errors: [{message: 'A space is required after opening bracket'}], + options: afterOpeningOptions('always') + }, { + code: '< App/>', + output: '', + errors: [{message: 'A space is forbidden after opening bracket'}], + options: afterOpeningOptions('allow-multiline') + }] +});