UNPKG

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