/** * @fileoverview Enforce or disallow spaces inside of curly braces in JSX attributes. * @author Jamund Ferguson * @author Brandyn Bennett * @author Michael Ficarra * @author Vignesh Anand * @author Jamund Ferguson * @author Yannick Croissant * @author Erik Wendel */ 'use strict'; const has = require('has'); // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ const SPACING = { always: 'always', never: 'never' }; const SPACING_VALUES = [SPACING.always, SPACING.never]; module.exports = { meta: { docs: { description: 'Enforce or disallow spaces inside of curly braces in JSX attributes', category: 'Stylistic Issues', recommended: false }, fixable: 'code', schema: { definitions: { basicConfig: { type: 'object', properties: { when: { enum: SPACING_VALUES }, allowMultiline: { type: 'boolean' }, spacing: { type: 'object', properties: { objectLiterals: { enum: SPACING_VALUES } } } } }, basicConfigOrBoolean: { oneOf: [{ $ref: '#/definitions/basicConfig' }, { type: 'boolean' }] } }, type: 'array', items: [{ oneOf: [{ allOf: [{ $ref: '#/definitions/basicConfig' }, { type: 'object', properties: { attributes: { $ref: '#/definitions/basicConfigOrBoolean' }, children: { $ref: '#/definitions/basicConfigOrBoolean' } } }] }, { enum: SPACING_VALUES }] }, { type: 'object', properties: { allowMultiline: { type: 'boolean' }, spacing: { type: 'object', properties: { objectLiterals: { enum: SPACING_VALUES } } } }, additionalProperties: false }] } }, create: function(context) { function normalizeConfig(configOrTrue, defaults, lastPass) { const config = configOrTrue === true ? {} : configOrTrue; const when = config.when || defaults.when; const allowMultiline = has(config, 'allowMultiline') ? config.allowMultiline : defaults.allowMultiline; const spacing = config.spacing || {}; let objectLiteralSpaces = spacing.objectLiterals || defaults.objectLiteralSpaces; if (lastPass) { // On the final pass assign the values that should be derived from others if they are still undefined objectLiteralSpaces = objectLiteralSpaces || when; } return { when, allowMultiline, objectLiteralSpaces }; } const DEFAULT_WHEN = SPACING.never; const DEFAULT_ALLOW_MULTILINE = true; const DEFAULT_ATTRIBUTES = true; const DEFAULT_CHILDREN = false; const sourceCode = context.getSourceCode(); let originalConfig = context.options[0] || {}; if (SPACING_VALUES.indexOf(originalConfig) !== -1) { originalConfig = Object.assign({when: context.options[0]}, context.options[1]); } const defaultConfig = normalizeConfig(originalConfig, { when: DEFAULT_WHEN, allowMultiline: DEFAULT_ALLOW_MULTILINE }); const attributes = has(originalConfig, 'attributes') ? originalConfig.attributes : DEFAULT_ATTRIBUTES; const attributesConfig = attributes ? normalizeConfig(attributes, defaultConfig, true) : null; const children = has(originalConfig, 'children') ? originalConfig.children : DEFAULT_CHILDREN; const childrenConfig = children ? normalizeConfig(children, defaultConfig, true) : null; // -------------------------------------------------------------------------- // Helpers // -------------------------------------------------------------------------- /** * Determines whether two adjacent tokens have a newline between them. * @param {Object} left - The left token object. * @param {Object} right - The right token object. * @returns {boolean} Whether or not there is a newline between the tokens. */ function isMultiline(left, right) { return left.loc.start.line !== right.loc.start.line; } /** * Reports that there shouldn't be a newline after the first token * @param {ASTNode} node - The node to report in the event of an error. * @param {Token} token - The token to use for the report. * @returns {void} */ function reportNoBeginningNewline(node, token, spacing) { context.report({ node: node, loc: token.loc.start, message: `There should be no newline after '${token.value}'`, fix: function(fixer) { const nextToken = sourceCode.getTokenAfter(token); return fixer.replaceTextRange([token.range[1], nextToken.range[0]], spacing === SPACING.always ? ' ' : ''); } }); } /** * Reports that there shouldn't be a newline before the last token * @param {ASTNode} node - The node to report in the event of an error. * @param {Token} token - The token to use for the report. * @returns {void} */ function reportNoEndingNewline(node, token, spacing) { context.report({ node: node, loc: token.loc.start, message: `There should be no newline before '${token.value}'`, fix: function(fixer) { const previousToken = sourceCode.getTokenBefore(token); return fixer.replaceTextRange([previousToken.range[1], token.range[0]], spacing === SPACING.always ? ' ' : ''); } }); } /** * Reports that there shouldn't be a space after the first token * @param {ASTNode} node - The node to report in the event of an error. * @param {Token} token - The token to use for the report. * @returns {void} */ function reportNoBeginningSpace(node, token) { context.report({ node: node, loc: token.loc.start, message: `There should be no space after '${token.value}'`, fix: function(fixer) { const nextToken = sourceCode.getTokenAfter(token); const nextNode = sourceCode.getNodeByRangeIndex(nextToken.range[0]); const leadingComments = sourceCode.getComments(nextNode).leading; const rangeEndRef = leadingComments.length ? leadingComments[0] : nextToken; return fixer.removeRange([token.range[1], rangeEndRef.range[0]]); } }); } /** * Reports that there shouldn't be a space before the last token * @param {ASTNode} node - The node to report in the event of an error. * @param {Token} token - The token to use for the report. * @returns {void} */ function reportNoEndingSpace(node, token) { context.report({ node: node, loc: token.loc.start, message: `There should be no space before '${token.value}'`, fix: function(fixer) { const previousToken = sourceCode.getTokenBefore(token); const previousNode = sourceCode.getNodeByRangeIndex(previousToken.range[0]); const trailingComments = sourceCode.getComments(previousNode).trailing; const rangeStartRef = trailingComments.length ? trailingComments[trailingComments.length - 1] : previousToken; return fixer.removeRange([rangeStartRef.range[1], token.range[0]]); } }); } /** * Reports that there should be a space after the first token * @param {ASTNode} node - The node to report in the event of an error. * @param {Token} token - The token to use for the report. * @returns {void} */ function reportRequiredBeginningSpace(node, token) { context.report({ node: node, loc: token.loc.start, message: `A space is required after '${token.value}'`, fix: function(fixer) { return fixer.insertTextAfter(token, ' '); } }); } /** * Reports that there should be a space before the last token * @param {ASTNode} node - The node to report in the event of an error. * @param {Token} token - The token to use for the report. * @returns {void} */ function reportRequiredEndingSpace(node, token) { context.report({ node: node, loc: token.loc.start, message: `A space is required before '${token.value}'`, fix: function(fixer) { return fixer.insertTextBefore(token, ' '); } }); } /** * Determines if spacing in curly braces is valid. * @param {ASTNode} node The AST node to check. * @returns {void} */ function validateBraceSpacing(node) { let config; switch (node.parent.type) { case 'JSXAttribute': case 'JSXOpeningElement': config = attributesConfig; break; case 'JSXElement': config = childrenConfig; break; default: return; } if (config === null) { return; } const first = context.getFirstToken(node); const last = sourceCode.getLastToken(node); let second = context.getTokenAfter(first, {includeComments: true}); let penultimate = sourceCode.getTokenBefore(last, {includeComments: true}); if (!second) { second = context.getTokenAfter(first); const leadingComments = sourceCode.getNodeByRangeIndex(second.range[0]).leadingComments; second = leadingComments ? leadingComments[0] : second; } if (!penultimate) { penultimate = sourceCode.getTokenBefore(last); const trailingComments = sourceCode.getNodeByRangeIndex(penultimate.range[0]).trailingComments; penultimate = trailingComments ? trailingComments[trailingComments.length - 1] : penultimate; } const isObjectLiteral = first.value === second.value; const spacing = isObjectLiteral ? config.objectLiteralSpaces : config.when; if (spacing === SPACING.always) { if (!sourceCode.isSpaceBetweenTokens(first, second)) { reportRequiredBeginningSpace(node, first); } else if (!config.allowMultiline && isMultiline(first, second)) { reportNoBeginningNewline(node, first, spacing); } if (!sourceCode.isSpaceBetweenTokens(penultimate, last)) { reportRequiredEndingSpace(node, last); } else if (!config.allowMultiline && isMultiline(penultimate, last)) { reportNoEndingNewline(node, last, spacing); } } else if (spacing === SPACING.never) { if (isMultiline(first, second)) { if (!config.allowMultiline) { reportNoBeginningNewline(node, first, spacing); } } else if (sourceCode.isSpaceBetweenTokens(first, second)) { reportNoBeginningSpace(node, first); } if (isMultiline(penultimate, last)) { if (!config.allowMultiline) { reportNoEndingNewline(node, last, spacing); } } else if (sourceCode.isSpaceBetweenTokens(penultimate, last)) { reportNoEndingSpace(node, last); } } } // -------------------------------------------------------------------------- // Public // -------------------------------------------------------------------------- return { JSXExpressionContainer: validateBraceSpacing, JSXSpreadAttribute: validateBraceSpacing }; } };