[d565449] | 1 | /**
|
---|
| 2 | * @fileoverview Validate closing bracket location in JSX
|
---|
| 3 | * @author Yannick Croissant
|
---|
| 4 | */
|
---|
| 5 |
|
---|
| 6 | 'use strict';
|
---|
| 7 |
|
---|
| 8 | const has = require('hasown');
|
---|
| 9 | const repeat = require('string.prototype.repeat');
|
---|
| 10 |
|
---|
| 11 | const docsUrl = require('../util/docsUrl');
|
---|
| 12 | const getSourceCode = require('../util/eslint').getSourceCode;
|
---|
| 13 | const report = require('../util/report');
|
---|
| 14 |
|
---|
| 15 | // ------------------------------------------------------------------------------
|
---|
| 16 | // Rule Definition
|
---|
| 17 | // ------------------------------------------------------------------------------
|
---|
| 18 |
|
---|
| 19 | const messages = {
|
---|
| 20 | bracketLocation: 'The closing bracket must be {{location}}{{details}}',
|
---|
| 21 | };
|
---|
| 22 |
|
---|
[0c6b92a] | 23 | /** @type {import('eslint').Rule.RuleModule} */
|
---|
[d565449] | 24 | module.exports = {
|
---|
| 25 | meta: {
|
---|
| 26 | docs: {
|
---|
| 27 | description: 'Enforce closing bracket location in JSX',
|
---|
| 28 | category: 'Stylistic Issues',
|
---|
| 29 | recommended: false,
|
---|
| 30 | url: docsUrl('jsx-closing-bracket-location'),
|
---|
| 31 | },
|
---|
| 32 | fixable: 'code',
|
---|
| 33 |
|
---|
| 34 | messages,
|
---|
| 35 |
|
---|
| 36 | schema: [{
|
---|
| 37 | anyOf: [
|
---|
| 38 | {
|
---|
| 39 | enum: ['after-props', 'props-aligned', 'tag-aligned', 'line-aligned'],
|
---|
| 40 | },
|
---|
| 41 | {
|
---|
| 42 | type: 'object',
|
---|
| 43 | properties: {
|
---|
| 44 | location: {
|
---|
| 45 | enum: ['after-props', 'props-aligned', 'tag-aligned', 'line-aligned'],
|
---|
| 46 | },
|
---|
| 47 | },
|
---|
| 48 | additionalProperties: false,
|
---|
| 49 | }, {
|
---|
| 50 | type: 'object',
|
---|
| 51 | properties: {
|
---|
| 52 | nonEmpty: {
|
---|
| 53 | enum: ['after-props', 'props-aligned', 'tag-aligned', 'line-aligned', false],
|
---|
| 54 | },
|
---|
| 55 | selfClosing: {
|
---|
| 56 | enum: ['after-props', 'props-aligned', 'tag-aligned', 'line-aligned', false],
|
---|
| 57 | },
|
---|
| 58 | },
|
---|
| 59 | additionalProperties: false,
|
---|
| 60 | },
|
---|
| 61 | ],
|
---|
| 62 | }],
|
---|
| 63 | },
|
---|
| 64 |
|
---|
| 65 | create(context) {
|
---|
| 66 | const MESSAGE_LOCATION = {
|
---|
| 67 | 'after-props': 'placed after the last prop',
|
---|
| 68 | 'after-tag': 'placed after the opening tag',
|
---|
| 69 | 'props-aligned': 'aligned with the last prop',
|
---|
| 70 | 'tag-aligned': 'aligned with the opening tag',
|
---|
| 71 | 'line-aligned': 'aligned with the line containing the opening tag',
|
---|
| 72 | };
|
---|
| 73 | const DEFAULT_LOCATION = 'tag-aligned';
|
---|
| 74 |
|
---|
| 75 | const config = context.options[0];
|
---|
| 76 | const options = {
|
---|
| 77 | nonEmpty: DEFAULT_LOCATION,
|
---|
| 78 | selfClosing: DEFAULT_LOCATION,
|
---|
| 79 | };
|
---|
| 80 |
|
---|
| 81 | if (typeof config === 'string') {
|
---|
| 82 | // simple shorthand [1, 'something']
|
---|
| 83 | options.nonEmpty = config;
|
---|
| 84 | options.selfClosing = config;
|
---|
| 85 | } else if (typeof config === 'object') {
|
---|
| 86 | // [1, {location: 'something'}] (back-compat)
|
---|
| 87 | if (has(config, 'location')) {
|
---|
| 88 | options.nonEmpty = config.location;
|
---|
| 89 | options.selfClosing = config.location;
|
---|
| 90 | }
|
---|
| 91 | // [1, {nonEmpty: 'something'}]
|
---|
| 92 | if (has(config, 'nonEmpty')) {
|
---|
| 93 | options.nonEmpty = config.nonEmpty;
|
---|
| 94 | }
|
---|
| 95 | // [1, {selfClosing: 'something'}]
|
---|
| 96 | if (has(config, 'selfClosing')) {
|
---|
| 97 | options.selfClosing = config.selfClosing;
|
---|
| 98 | }
|
---|
| 99 | }
|
---|
| 100 |
|
---|
| 101 | /**
|
---|
| 102 | * Get expected location for the closing bracket
|
---|
| 103 | * @param {Object} tokens Locations of the opening bracket, closing bracket and last prop
|
---|
[0c6b92a] | 104 | * @return {string} Expected location for the closing bracket
|
---|
[d565449] | 105 | */
|
---|
| 106 | function getExpectedLocation(tokens) {
|
---|
| 107 | let location;
|
---|
| 108 | // Is always after the opening tag if there is no props
|
---|
| 109 | if (typeof tokens.lastProp === 'undefined') {
|
---|
| 110 | location = 'after-tag';
|
---|
| 111 | // Is always after the last prop if this one is on the same line as the opening bracket
|
---|
| 112 | } else if (tokens.opening.line === tokens.lastProp.lastLine) {
|
---|
| 113 | location = 'after-props';
|
---|
| 114 | // Else use configuration dependent on selfClosing property
|
---|
| 115 | } else {
|
---|
| 116 | location = tokens.selfClosing ? options.selfClosing : options.nonEmpty;
|
---|
| 117 | }
|
---|
| 118 | return location;
|
---|
| 119 | }
|
---|
| 120 |
|
---|
| 121 | /**
|
---|
| 122 | * Get the correct 0-indexed column for the closing bracket, given the
|
---|
| 123 | * expected location.
|
---|
| 124 | * @param {Object} tokens Locations of the opening bracket, closing bracket and last prop
|
---|
[0c6b92a] | 125 | * @param {string} expectedLocation Expected location for the closing bracket
|
---|
[d565449] | 126 | * @return {?Number} The correct column for the closing bracket, or null
|
---|
| 127 | */
|
---|
| 128 | function getCorrectColumn(tokens, expectedLocation) {
|
---|
| 129 | switch (expectedLocation) {
|
---|
| 130 | case 'props-aligned':
|
---|
| 131 | return tokens.lastProp.column;
|
---|
| 132 | case 'tag-aligned':
|
---|
| 133 | return tokens.opening.column;
|
---|
| 134 | case 'line-aligned':
|
---|
| 135 | return tokens.openingStartOfLine.column;
|
---|
| 136 | default:
|
---|
| 137 | return null;
|
---|
| 138 | }
|
---|
| 139 | }
|
---|
| 140 |
|
---|
| 141 | /**
|
---|
| 142 | * Check if the closing bracket is correctly located
|
---|
| 143 | * @param {Object} tokens Locations of the opening bracket, closing bracket and last prop
|
---|
[0c6b92a] | 144 | * @param {string} expectedLocation Expected location for the closing bracket
|
---|
| 145 | * @return {boolean} True if the closing bracket is correctly located, false if not
|
---|
[d565449] | 146 | */
|
---|
| 147 | function hasCorrectLocation(tokens, expectedLocation) {
|
---|
| 148 | switch (expectedLocation) {
|
---|
| 149 | case 'after-tag':
|
---|
| 150 | return tokens.tag.line === tokens.closing.line;
|
---|
| 151 | case 'after-props':
|
---|
| 152 | return tokens.lastProp.lastLine === tokens.closing.line;
|
---|
| 153 | case 'props-aligned':
|
---|
| 154 | case 'tag-aligned':
|
---|
| 155 | case 'line-aligned': {
|
---|
| 156 | const correctColumn = getCorrectColumn(tokens, expectedLocation);
|
---|
| 157 | return correctColumn === tokens.closing.column;
|
---|
| 158 | }
|
---|
| 159 | default:
|
---|
| 160 | return true;
|
---|
| 161 | }
|
---|
| 162 | }
|
---|
| 163 |
|
---|
| 164 | /**
|
---|
| 165 | * Get the characters used for indentation on the line to be matched
|
---|
| 166 | * @param {Object} tokens Locations of the opening bracket, closing bracket and last prop
|
---|
[0c6b92a] | 167 | * @param {string} expectedLocation Expected location for the closing bracket
|
---|
| 168 | * @param {number} [correctColumn] Expected column for the closing bracket. Default to 0
|
---|
| 169 | * @return {string} The characters used for indentation
|
---|
[d565449] | 170 | */
|
---|
| 171 | function getIndentation(tokens, expectedLocation, correctColumn) {
|
---|
| 172 | const newColumn = correctColumn || 0;
|
---|
| 173 | let indentation;
|
---|
| 174 | let spaces = '';
|
---|
| 175 | switch (expectedLocation) {
|
---|
| 176 | case 'props-aligned':
|
---|
| 177 | indentation = /^\s*/.exec(getSourceCode(context).lines[tokens.lastProp.firstLine - 1])[0];
|
---|
| 178 | break;
|
---|
| 179 | case 'tag-aligned':
|
---|
| 180 | case 'line-aligned':
|
---|
| 181 | indentation = /^\s*/.exec(getSourceCode(context).lines[tokens.opening.line - 1])[0];
|
---|
| 182 | break;
|
---|
| 183 | default:
|
---|
| 184 | indentation = '';
|
---|
| 185 | }
|
---|
| 186 | if (indentation.length + 1 < newColumn) {
|
---|
| 187 | // Non-whitespace characters were included in the column offset
|
---|
| 188 | spaces = repeat(' ', +correctColumn - indentation.length);
|
---|
| 189 | }
|
---|
| 190 | return indentation + spaces;
|
---|
| 191 | }
|
---|
| 192 |
|
---|
| 193 | /**
|
---|
| 194 | * Get the locations of the opening bracket, closing bracket, last prop, and
|
---|
| 195 | * start of opening line.
|
---|
| 196 | * @param {ASTNode} node The node to check
|
---|
| 197 | * @return {Object} Locations of the opening bracket, closing bracket, last
|
---|
| 198 | * prop and start of opening line.
|
---|
| 199 | */
|
---|
| 200 | function getTokensLocations(node) {
|
---|
| 201 | const sourceCode = getSourceCode(context);
|
---|
| 202 | const opening = sourceCode.getFirstToken(node).loc.start;
|
---|
| 203 | const closing = sourceCode.getLastTokens(node, node.selfClosing ? 2 : 1)[0].loc.start;
|
---|
| 204 | const tag = sourceCode.getFirstToken(node.name).loc.start;
|
---|
| 205 | let lastProp;
|
---|
| 206 | if (node.attributes.length) {
|
---|
| 207 | lastProp = node.attributes[node.attributes.length - 1];
|
---|
| 208 | lastProp = {
|
---|
| 209 | column: sourceCode.getFirstToken(lastProp).loc.start.column,
|
---|
| 210 | firstLine: sourceCode.getFirstToken(lastProp).loc.start.line,
|
---|
| 211 | lastLine: sourceCode.getLastToken(lastProp).loc.end.line,
|
---|
| 212 | };
|
---|
| 213 | }
|
---|
| 214 | const openingLine = sourceCode.lines[opening.line - 1];
|
---|
| 215 | const closingLine = sourceCode.lines[closing.line - 1];
|
---|
| 216 | const isTab = {
|
---|
| 217 | openTab: /^\t/.test(openingLine),
|
---|
| 218 | closeTab: /^\t/.test(closingLine),
|
---|
| 219 | };
|
---|
| 220 | const openingStartOfLine = {
|
---|
| 221 | column: /^\s*/.exec(openingLine)[0].length,
|
---|
| 222 | line: opening.line,
|
---|
| 223 | };
|
---|
| 224 | return {
|
---|
| 225 | isTab,
|
---|
| 226 | tag,
|
---|
| 227 | opening,
|
---|
| 228 | closing,
|
---|
| 229 | lastProp,
|
---|
| 230 | selfClosing: node.selfClosing,
|
---|
| 231 | openingStartOfLine,
|
---|
| 232 | };
|
---|
| 233 | }
|
---|
| 234 |
|
---|
| 235 | /**
|
---|
| 236 | * Get an unique ID for a given JSXOpeningElement
|
---|
| 237 | *
|
---|
| 238 | * @param {ASTNode} node The AST node being checked.
|
---|
[0c6b92a] | 239 | * @returns {string} Unique ID (based on its range)
|
---|
[d565449] | 240 | */
|
---|
| 241 | function getOpeningElementId(node) {
|
---|
| 242 | return node.range.join(':');
|
---|
| 243 | }
|
---|
| 244 |
|
---|
| 245 | const lastAttributeNode = {};
|
---|
| 246 |
|
---|
| 247 | return {
|
---|
| 248 | JSXAttribute(node) {
|
---|
| 249 | lastAttributeNode[getOpeningElementId(node.parent)] = node;
|
---|
| 250 | },
|
---|
| 251 |
|
---|
| 252 | JSXSpreadAttribute(node) {
|
---|
| 253 | lastAttributeNode[getOpeningElementId(node.parent)] = node;
|
---|
| 254 | },
|
---|
| 255 |
|
---|
| 256 | 'JSXOpeningElement:exit'(node) {
|
---|
| 257 | const attributeNode = lastAttributeNode[getOpeningElementId(node)];
|
---|
| 258 | const cachedLastAttributeEndPos = attributeNode ? attributeNode.range[1] : null;
|
---|
| 259 |
|
---|
| 260 | let expectedNextLine;
|
---|
| 261 | const tokens = getTokensLocations(node);
|
---|
| 262 | const expectedLocation = getExpectedLocation(tokens);
|
---|
| 263 | let usingSameIndentation = true;
|
---|
| 264 |
|
---|
| 265 | if (expectedLocation === 'tag-aligned') {
|
---|
| 266 | usingSameIndentation = tokens.isTab.openTab === tokens.isTab.closeTab;
|
---|
| 267 | }
|
---|
| 268 |
|
---|
| 269 | if (hasCorrectLocation(tokens, expectedLocation) && usingSameIndentation) {
|
---|
| 270 | return;
|
---|
| 271 | }
|
---|
| 272 |
|
---|
| 273 | const data = {
|
---|
| 274 | location: MESSAGE_LOCATION[expectedLocation],
|
---|
| 275 | details: '',
|
---|
| 276 | };
|
---|
| 277 | const correctColumn = getCorrectColumn(tokens, expectedLocation);
|
---|
| 278 |
|
---|
| 279 | if (correctColumn !== null) {
|
---|
| 280 | expectedNextLine = tokens.lastProp
|
---|
| 281 | && (tokens.lastProp.lastLine === tokens.closing.line);
|
---|
| 282 | data.details = ` (expected column ${correctColumn + 1}${expectedNextLine ? ' on the next line)' : ')'}`;
|
---|
| 283 | }
|
---|
| 284 |
|
---|
| 285 | report(context, messages.bracketLocation, 'bracketLocation', {
|
---|
| 286 | node,
|
---|
| 287 | loc: tokens.closing,
|
---|
| 288 | data,
|
---|
| 289 | fix(fixer) {
|
---|
| 290 | const closingTag = tokens.selfClosing ? '/>' : '>';
|
---|
| 291 | switch (expectedLocation) {
|
---|
| 292 | case 'after-tag':
|
---|
| 293 | if (cachedLastAttributeEndPos) {
|
---|
| 294 | return fixer.replaceTextRange([cachedLastAttributeEndPos, node.range[1]],
|
---|
| 295 | (expectedNextLine ? '\n' : '') + closingTag);
|
---|
| 296 | }
|
---|
| 297 | return fixer.replaceTextRange([node.name.range[1], node.range[1]],
|
---|
| 298 | (expectedNextLine ? '\n' : ' ') + closingTag);
|
---|
| 299 | case 'after-props':
|
---|
| 300 | return fixer.replaceTextRange([cachedLastAttributeEndPos, node.range[1]],
|
---|
| 301 | (expectedNextLine ? '\n' : '') + closingTag);
|
---|
| 302 | case 'props-aligned':
|
---|
| 303 | case 'tag-aligned':
|
---|
| 304 | case 'line-aligned':
|
---|
| 305 | return fixer.replaceTextRange([cachedLastAttributeEndPos, node.range[1]],
|
---|
| 306 | `\n${getIndentation(tokens, expectedLocation, correctColumn)}${closingTag}`);
|
---|
| 307 | default:
|
---|
| 308 | return true;
|
---|
| 309 | }
|
---|
| 310 | },
|
---|
| 311 | });
|
---|
| 312 | },
|
---|
| 313 | };
|
---|
| 314 | },
|
---|
| 315 | };
|
---|