123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305 |
- /**
- * @fileoverview Validate closing bracket location in JSX
- * @author Yannick Croissant
- */
- 'use strict';
- const has = require('has');
- const docsUrl = require('../util/docsUrl');
- // ------------------------------------------------------------------------------
- // Rule Definition
- // ------------------------------------------------------------------------------
- module.exports = {
- meta: {
- docs: {
- description: 'Validate closing bracket location in JSX',
- category: 'Stylistic Issues',
- recommended: false,
- url: docsUrl('jsx-closing-bracket-location')
- },
- fixable: 'code',
- messages: {
- bracketLocation: 'The closing bracket must be {{location}}{{details}}'
- },
- schema: [{
- oneOf: [
- {
- enum: ['after-props', 'props-aligned', 'tag-aligned', 'line-aligned']
- },
- {
- type: 'object',
- properties: {
- location: {
- enum: ['after-props', 'props-aligned', 'tag-aligned', 'line-aligned']
- }
- },
- additionalProperties: false
- }, {
- type: 'object',
- properties: {
- nonEmpty: {
- enum: ['after-props', 'props-aligned', 'tag-aligned', 'line-aligned', false]
- },
- selfClosing: {
- enum: ['after-props', 'props-aligned', 'tag-aligned', 'line-aligned', false]
- }
- },
- additionalProperties: false
- }
- ]
- }]
- },
- create(context) {
- const MESSAGE_LOCATION = {
- 'after-props': 'placed after the last prop',
- 'after-tag': 'placed after the opening tag',
- 'props-aligned': 'aligned with the last prop',
- 'tag-aligned': 'aligned with the opening tag',
- 'line-aligned': 'aligned with the line containing the opening tag'
- };
- const DEFAULT_LOCATION = 'tag-aligned';
- const config = context.options[0];
- const options = {
- nonEmpty: DEFAULT_LOCATION,
- selfClosing: DEFAULT_LOCATION
- };
- if (typeof config === 'string') {
- // simple shorthand [1, 'something']
- options.nonEmpty = config;
- options.selfClosing = config;
- } else if (typeof config === 'object') {
- // [1, {location: 'something'}] (back-compat)
- if (has(config, 'location')) {
- options.nonEmpty = config.location;
- options.selfClosing = config.location;
- }
- // [1, {nonEmpty: 'something'}]
- if (has(config, 'nonEmpty')) {
- options.nonEmpty = config.nonEmpty;
- }
- // [1, {selfClosing: 'something'}]
- if (has(config, 'selfClosing')) {
- options.selfClosing = config.selfClosing;
- }
- }
- /**
- * Get expected location for the closing bracket
- * @param {Object} tokens Locations of the opening bracket, closing bracket and last prop
- * @return {String} Expected location for the closing bracket
- */
- function getExpectedLocation(tokens) {
- let location;
- // Is always after the opening tag if there is no props
- if (typeof tokens.lastProp === 'undefined') {
- location = 'after-tag';
- // Is always after the last prop if this one is on the same line as the opening bracket
- } else if (tokens.opening.line === tokens.lastProp.lastLine) {
- location = 'after-props';
- // Else use configuration dependent on selfClosing property
- } else {
- location = tokens.selfClosing ? options.selfClosing : options.nonEmpty;
- }
- return location;
- }
- /**
- * Get the correct 0-indexed column for the closing bracket, given the
- * expected location.
- * @param {Object} tokens Locations of the opening bracket, closing bracket and last prop
- * @param {String} expectedLocation Expected location for the closing bracket
- * @return {?Number} The correct column for the closing bracket, or null
- */
- function getCorrectColumn(tokens, expectedLocation) {
- switch (expectedLocation) {
- case 'props-aligned':
- return tokens.lastProp.column;
- case 'tag-aligned':
- return tokens.opening.column;
- case 'line-aligned':
- return tokens.openingStartOfLine.column;
- default:
- return null;
- }
- }
- /**
- * Check if the closing bracket is correctly located
- * @param {Object} tokens Locations of the opening bracket, closing bracket and last prop
- * @param {String} expectedLocation Expected location for the closing bracket
- * @return {Boolean} True if the closing bracket is correctly located, false if not
- */
- function hasCorrectLocation(tokens, expectedLocation) {
- switch (expectedLocation) {
- case 'after-tag':
- return tokens.tag.line === tokens.closing.line;
- case 'after-props':
- return tokens.lastProp.lastLine === tokens.closing.line;
- case 'props-aligned':
- case 'tag-aligned':
- case 'line-aligned': {
- const correctColumn = getCorrectColumn(tokens, expectedLocation);
- return correctColumn === tokens.closing.column;
- }
- default:
- return true;
- }
- }
- /**
- * Get the characters used for indentation on the line to be matched
- * @param {Object} tokens Locations of the opening bracket, closing bracket and last prop
- * @param {String} expectedLocation Expected location for the closing bracket
- * @param {Number} [correctColumn] Expected column for the closing bracket. Default to 0
- * @return {String} The characters used for indentation
- */
- function getIndentation(tokens, expectedLocation, correctColumn) {
- correctColumn = correctColumn || 0;
- let indentation;
- let spaces = [];
- switch (expectedLocation) {
- case 'props-aligned':
- indentation = /^\s*/.exec(context.getSourceCode().lines[tokens.lastProp.firstLine - 1])[0];
- break;
- case 'tag-aligned':
- case 'line-aligned':
- indentation = /^\s*/.exec(context.getSourceCode().lines[tokens.opening.line - 1])[0];
- break;
- default:
- indentation = '';
- }
- if (indentation.length + 1 < correctColumn) {
- // Non-whitespace characters were included in the column offset
- spaces = new Array(+correctColumn + 1 - indentation.length);
- }
- return indentation + spaces.join(' ');
- }
- /**
- * Get the locations of the opening bracket, closing bracket, last prop, and
- * start of opening line.
- * @param {ASTNode} node The node to check
- * @return {Object} Locations of the opening bracket, closing bracket, last
- * prop and start of opening line.
- */
- function getTokensLocations(node) {
- const sourceCode = context.getSourceCode();
- const opening = sourceCode.getFirstToken(node).loc.start;
- const closing = sourceCode.getLastTokens(node, node.selfClosing ? 2 : 1)[0].loc.start;
- const tag = sourceCode.getFirstToken(node.name).loc.start;
- let lastProp;
- if (node.attributes.length) {
- lastProp = node.attributes[node.attributes.length - 1];
- lastProp = {
- column: sourceCode.getFirstToken(lastProp).loc.start.column,
- firstLine: sourceCode.getFirstToken(lastProp).loc.start.line,
- lastLine: sourceCode.getLastToken(lastProp).loc.end.line
- };
- }
- const openingLine = sourceCode.lines[opening.line - 1];
- const closingLine = sourceCode.lines[closing.line - 1];
- const isTab = {
- openTab: /^\t/.test(openingLine),
- closeTab: /^\t/.test(closingLine)
- };
- const openingStartOfLine = {
- column: /^\s*/.exec(openingLine)[0].length,
- line: opening.line
- };
- return {
- isTab,
- tag,
- opening,
- closing,
- lastProp,
- selfClosing: node.selfClosing,
- openingStartOfLine
- };
- }
- /**
- * Get an unique ID for a given JSXOpeningElement
- *
- * @param {ASTNode} node The AST node being checked.
- * @returns {String} Unique ID (based on its range)
- */
- function getOpeningElementId(node) {
- return node.range.join(':');
- }
- const lastAttributeNode = {};
- return {
- JSXAttribute(node) {
- lastAttributeNode[getOpeningElementId(node.parent)] = node;
- },
- JSXSpreadAttribute(node) {
- lastAttributeNode[getOpeningElementId(node.parent)] = node;
- },
- 'JSXOpeningElement:exit'(node) {
- const attributeNode = lastAttributeNode[getOpeningElementId(node)];
- const cachedLastAttributeEndPos = attributeNode ? attributeNode.range[1] : null;
- let expectedNextLine;
- const tokens = getTokensLocations(node);
- const expectedLocation = getExpectedLocation(tokens);
- let usingSameIndentation = true;
- if (expectedLocation === 'tag-aligned') {
- usingSameIndentation = tokens.isTab.openTab === tokens.isTab.closeTab;
- }
- if (hasCorrectLocation(tokens, expectedLocation) && usingSameIndentation) {
- return;
- }
- const data = {location: MESSAGE_LOCATION[expectedLocation]};
- const correctColumn = getCorrectColumn(tokens, expectedLocation);
- if (correctColumn !== null) {
- expectedNextLine = tokens.lastProp
- && (tokens.lastProp.lastLine === tokens.closing.line);
- data.details = ` (expected column ${correctColumn + 1}${expectedNextLine ? ' on the next line)' : ')'}`;
- }
- context.report({
- node,
- loc: tokens.closing,
- messageId: 'bracketLocation',
- data,
- fix(fixer) {
- const closingTag = tokens.selfClosing ? '/>' : '>';
- switch (expectedLocation) {
- case 'after-tag':
- if (cachedLastAttributeEndPos) {
- return fixer.replaceTextRange([cachedLastAttributeEndPos, node.range[1]],
- (expectedNextLine ? '\n' : '') + closingTag);
- }
- return fixer.replaceTextRange([node.name.range[1], node.range[1]],
- (expectedNextLine ? '\n' : ' ') + closingTag);
- case 'after-props':
- return fixer.replaceTextRange([cachedLastAttributeEndPos, node.range[1]],
- (expectedNextLine ? '\n' : '') + closingTag);
- case 'props-aligned':
- case 'tag-aligned':
- case 'line-aligned':
- return fixer.replaceTextRange([cachedLastAttributeEndPos, node.range[1]],
- `\n${getIndentation(tokens, expectedLocation, correctColumn)}${closingTag}`);
- default:
- return true;
- }
- }
- });
- }
- };
- }
- };
|