You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
290 lines
10 KiB
290 lines
10 KiB
/** |
|
* @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', |
|
|
|
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 = 'The closing bracket must be {{location}}{{details}}'; |
|
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 openingStartOfLine = { |
|
column: /^\s*/.exec(openingLine)[0].length, |
|
line: opening.line |
|
}; |
|
return { |
|
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': function (node) { |
|
const attributeNode = lastAttributeNode[getOpeningElementId(node)]; |
|
const cachedLastAttributeEndPos = attributeNode ? attributeNode.range[1] : null; |
|
let expectedNextLine; |
|
const tokens = getTokensLocations(node); |
|
const expectedLocation = getExpectedLocation(tokens); |
|
|
|
if (hasCorrectLocation(tokens, expectedLocation)) { |
|
return; |
|
} |
|
|
|
const data = {location: MESSAGE_LOCATION[expectedLocation], details: ''}; |
|
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, |
|
message: MESSAGE, |
|
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; |
|
} |
|
} |
|
}); |
|
} |
|
}; |
|
} |
|
};
|
|
|