d2-ui
Version:
255 lines (236 loc) • 8.87 kB
JavaScript
/**
* @fileoverview Validate closing bracket location in JSX
* @author Yannick Croissant
*/
'use strict';
// ------------------------------------------------------------------------------
// Rule Definition
// ------------------------------------------------------------------------------
module.exports = function(context) {
var MESSAGE = 'The closing bracket must be {{location}}{{details}}';
var 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'
};
var DEFAULT_LOCATION = 'tag-aligned';
var sourceCode = context.getSourceCode();
var config = context.options[0];
var 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 (config.hasOwnProperty('location')) {
options.nonEmpty = config.location;
options.selfClosing = config.location;
}
// [1, {nonEmpty: 'something'}]
if (config.hasOwnProperty('nonEmpty')) {
options.nonEmpty = config.nonEmpty;
}
// [1, {selfClosing: 'something'}]
if (config.hasOwnProperty('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) {
var 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.line) {
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.line === tokens.closing.line;
case 'props-aligned':
case 'tag-aligned':
case 'line-aligned':
var correctColumn = getCorrectColumn(tokens, expectedLocation);
return correctColumn === tokens.closing.column;
default:
return true;
}
}
/**
* 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) {
var opening = sourceCode.getFirstToken(node).loc.start;
var closing = sourceCode.getLastTokens(node, node.selfClosing ? 2 : 1)[0].loc.start;
var tag = sourceCode.getFirstToken(node.name).loc.start;
var lastProp;
if (node.attributes.length) {
lastProp = node.attributes[node.attributes.length - 1];
lastProp = {
column: sourceCode.getFirstToken(lastProp).loc.start.column,
line: sourceCode.getLastToken(lastProp).loc.end.line
};
}
var openingLine = sourceCode.lines[opening.line - 1];
var openingStartOfLine = {
column: /^\s*/.exec(openingLine)[0].length,
line: opening.line
};
return {
tag: tag,
opening: opening,
closing: closing,
lastProp: lastProp,
selfClosing: node.selfClosing,
openingStartOfLine: openingStartOfLine
};
}
var lastAttributeEndPos;
var lastAttributeStartPos;
return {
JSXAttribute: function(node) {
lastAttributeEndPos = node.end;
lastAttributeStartPos = node.start;
},
'JSXOpeningElement:exit': function(node) {
var cachedLastAttributeEndPos = lastAttributeEndPos;
var cachedLastAttributeStartPos = lastAttributeStartPos;
var expectedNextLine;
var tokens = getTokensLocations(node);
var expectedLocation = getExpectedLocation(tokens);
lastAttributeStartPos = null;
lastAttributeEndPos = null;
if (hasCorrectLocation(tokens, expectedLocation)) {
return;
}
var data = {location: MESSAGE_LOCATION[expectedLocation], details: ''};
var correctColumn = getCorrectColumn(tokens, expectedLocation);
if (correctColumn !== null) {
expectedNextLine = tokens.lastProp &&
(tokens.lastProp.line === tokens.closing.line);
data.details = ' (expected column ' + (correctColumn + 1) +
(expectedNextLine ? ' on the next line)' : ')');
}
context.report({
node: node,
loc: tokens.closing,
message: MESSAGE,
data: data,
fix: function(fixer) {
var closingTag = tokens.selfClosing ? '/>' : '>';
switch (expectedLocation) {
case 'after-tag':
if (cachedLastAttributeEndPos) {
return fixer.replaceTextRange([cachedLastAttributeEndPos, node.end],
(expectedNextLine ? '\n' : '') + closingTag);
}
return fixer.replaceTextRange([node.name.loc.end.column + 1, node.end],
(expectedNextLine ? '\n' : '') + closingTag);
case 'after-props':
return fixer.replaceTextRange([cachedLastAttributeEndPos, node.end],
(expectedNextLine ? '\n' : '') + closingTag);
case 'props-aligned':
var spaces = new Array(cachedLastAttributeEndPos - cachedLastAttributeStartPos);
return fixer.replaceTextRange([cachedLastAttributeEndPos, node.end],
'\n' + spaces.join(' ') + closingTag);
case 'tag-aligned':
var tagSpaces = new Array(node.start);
return fixer.replaceTextRange([cachedLastAttributeEndPos, node.end],
'\n' + tagSpaces.join(' ') + closingTag);
case 'line-aligned':
var walkNode = node;
var lineSpaces = 0;
while ((walkNode = walkNode.parent)) {
if (walkNode.type === 'VariableDeclaration' ||
walkNode.type === 'ReturnStatement' ||
walkNode.type === 'ExpressionStatement') {
lineSpaces = walkNode.loc.start.column + 1;
break;
}
}
lineSpaces = new Array(lineSpaces);
return fixer.replaceTextRange([cachedLastAttributeEndPos, node.end],
'\n' + lineSpaces.join(' ') + closingTag);
default:
return true;
}
}
});
}
};
};
module.exports.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
}
]
}];