1 | /**
|
---|
2 | * @fileoverview Limit to one expression per line in JSX
|
---|
3 | * @author Mark Ivan Allen <Vydia.com>
|
---|
4 | */
|
---|
5 |
|
---|
6 | 'use strict';
|
---|
7 |
|
---|
8 | const docsUrl = require('../util/docsUrl');
|
---|
9 | const eslintUtil = require('../util/eslint');
|
---|
10 | const jsxUtil = require('../util/jsx');
|
---|
11 | const report = require('../util/report');
|
---|
12 |
|
---|
13 | const getSourceCode = eslintUtil.getSourceCode;
|
---|
14 | const getText = eslintUtil.getText;
|
---|
15 |
|
---|
16 | // ------------------------------------------------------------------------------
|
---|
17 | // Rule Definition
|
---|
18 | // ------------------------------------------------------------------------------
|
---|
19 |
|
---|
20 | const optionDefaults = {
|
---|
21 | allow: 'none',
|
---|
22 | };
|
---|
23 |
|
---|
24 | const messages = {
|
---|
25 | moveToNewLine: '`{{descriptor}}` must be placed on a new line',
|
---|
26 | };
|
---|
27 |
|
---|
28 | /** @type {import('eslint').Rule.RuleModule} */
|
---|
29 | module.exports = {
|
---|
30 | meta: {
|
---|
31 | docs: {
|
---|
32 | description: 'Require one JSX element per line',
|
---|
33 | category: 'Stylistic Issues',
|
---|
34 | recommended: false,
|
---|
35 | url: docsUrl('jsx-one-expression-per-line'),
|
---|
36 | },
|
---|
37 | fixable: 'whitespace',
|
---|
38 |
|
---|
39 | messages,
|
---|
40 |
|
---|
41 | schema: [
|
---|
42 | {
|
---|
43 | type: 'object',
|
---|
44 | properties: {
|
---|
45 | allow: {
|
---|
46 | enum: ['none', 'literal', 'single-child', 'non-jsx'],
|
---|
47 | },
|
---|
48 | },
|
---|
49 | default: optionDefaults,
|
---|
50 | additionalProperties: false,
|
---|
51 | },
|
---|
52 | ],
|
---|
53 | },
|
---|
54 |
|
---|
55 | create(context) {
|
---|
56 | const options = Object.assign({}, optionDefaults, context.options[0]);
|
---|
57 |
|
---|
58 | function nodeKey(node) {
|
---|
59 | return `${node.loc.start.line},${node.loc.start.column}`;
|
---|
60 | }
|
---|
61 |
|
---|
62 | /**
|
---|
63 | * @param {ASTNode} n
|
---|
64 | * @returns {string}
|
---|
65 | */
|
---|
66 | function nodeDescriptor(n) {
|
---|
67 | return n.openingElement ? n.openingElement.name.name : getText(context, n).replace(/\n/g, '');
|
---|
68 | }
|
---|
69 |
|
---|
70 | function handleJSX(node) {
|
---|
71 | const children = node.children;
|
---|
72 |
|
---|
73 | if (!children || !children.length) {
|
---|
74 | return;
|
---|
75 | }
|
---|
76 |
|
---|
77 | if (
|
---|
78 | options.allow === 'non-jsx'
|
---|
79 | && !children.find((child) => (child.type === 'JSXFragment' || child.type === 'JSXElement'))
|
---|
80 | ) {
|
---|
81 | return;
|
---|
82 | }
|
---|
83 |
|
---|
84 | const openingElement = node.openingElement || node.openingFragment;
|
---|
85 | const closingElement = node.closingElement || node.closingFragment;
|
---|
86 | const openingElementStartLine = openingElement.loc.start.line;
|
---|
87 | const openingElementEndLine = openingElement.loc.end.line;
|
---|
88 | const closingElementStartLine = closingElement.loc.start.line;
|
---|
89 | const closingElementEndLine = closingElement.loc.end.line;
|
---|
90 |
|
---|
91 | if (children.length === 1) {
|
---|
92 | const child = children[0];
|
---|
93 | if (
|
---|
94 | openingElementStartLine === openingElementEndLine
|
---|
95 | && openingElementEndLine === closingElementStartLine
|
---|
96 | && closingElementStartLine === closingElementEndLine
|
---|
97 | && closingElementEndLine === child.loc.start.line
|
---|
98 | && child.loc.start.line === child.loc.end.line
|
---|
99 | ) {
|
---|
100 | if (
|
---|
101 | options.allow === 'single-child'
|
---|
102 | || (options.allow === 'literal' && (child.type === 'Literal' || child.type === 'JSXText'))
|
---|
103 | ) {
|
---|
104 | return;
|
---|
105 | }
|
---|
106 | }
|
---|
107 | }
|
---|
108 |
|
---|
109 | const childrenGroupedByLine = {};
|
---|
110 | const fixDetailsByNode = {};
|
---|
111 |
|
---|
112 | children.forEach((child) => {
|
---|
113 | let countNewLinesBeforeContent = 0;
|
---|
114 | let countNewLinesAfterContent = 0;
|
---|
115 |
|
---|
116 | if (child.type === 'Literal' || child.type === 'JSXText') {
|
---|
117 | if (jsxUtil.isWhiteSpaces(child.raw)) {
|
---|
118 | return;
|
---|
119 | }
|
---|
120 |
|
---|
121 | countNewLinesBeforeContent = (child.raw.match(/^\s*\n/g) || []).length;
|
---|
122 | countNewLinesAfterContent = (child.raw.match(/\n\s*$/g) || []).length;
|
---|
123 | }
|
---|
124 |
|
---|
125 | const startLine = child.loc.start.line + countNewLinesBeforeContent;
|
---|
126 | const endLine = child.loc.end.line - countNewLinesAfterContent;
|
---|
127 |
|
---|
128 | if (startLine === endLine) {
|
---|
129 | if (!childrenGroupedByLine[startLine]) {
|
---|
130 | childrenGroupedByLine[startLine] = [];
|
---|
131 | }
|
---|
132 | childrenGroupedByLine[startLine].push(child);
|
---|
133 | } else {
|
---|
134 | if (!childrenGroupedByLine[startLine]) {
|
---|
135 | childrenGroupedByLine[startLine] = [];
|
---|
136 | }
|
---|
137 | childrenGroupedByLine[startLine].push(child);
|
---|
138 | if (!childrenGroupedByLine[endLine]) {
|
---|
139 | childrenGroupedByLine[endLine] = [];
|
---|
140 | }
|
---|
141 | childrenGroupedByLine[endLine].push(child);
|
---|
142 | }
|
---|
143 | });
|
---|
144 |
|
---|
145 | Object.keys(childrenGroupedByLine).forEach((_line) => {
|
---|
146 | const line = parseInt(_line, 10);
|
---|
147 | const firstIndex = 0;
|
---|
148 | const lastIndex = childrenGroupedByLine[line].length - 1;
|
---|
149 |
|
---|
150 | childrenGroupedByLine[line].forEach((child, i) => {
|
---|
151 | let prevChild;
|
---|
152 | let nextChild;
|
---|
153 |
|
---|
154 | if (i === firstIndex) {
|
---|
155 | if (line === openingElementEndLine) {
|
---|
156 | prevChild = openingElement;
|
---|
157 | }
|
---|
158 | } else {
|
---|
159 | prevChild = childrenGroupedByLine[line][i - 1];
|
---|
160 | }
|
---|
161 |
|
---|
162 | if (i === lastIndex) {
|
---|
163 | if (line === closingElementStartLine) {
|
---|
164 | nextChild = closingElement;
|
---|
165 | }
|
---|
166 | } else {
|
---|
167 | // We don't need to append a trailing because the next child will prepend a leading.
|
---|
168 | // nextChild = childrenGroupedByLine[line][i + 1];
|
---|
169 | }
|
---|
170 |
|
---|
171 | function spaceBetweenPrev() {
|
---|
172 | return ((prevChild.type === 'Literal' || prevChild.type === 'JSXText') && / $/.test(prevChild.raw))
|
---|
173 | || ((child.type === 'Literal' || child.type === 'JSXText') && /^ /.test(child.raw))
|
---|
174 | || getSourceCode(context).isSpaceBetweenTokens(prevChild, child);
|
---|
175 | }
|
---|
176 |
|
---|
177 | function spaceBetweenNext() {
|
---|
178 | return ((nextChild.type === 'Literal' || nextChild.type === 'JSXText') && /^ /.test(nextChild.raw))
|
---|
179 | || ((child.type === 'Literal' || child.type === 'JSXText') && / $/.test(child.raw))
|
---|
180 | || getSourceCode(context).isSpaceBetweenTokens(child, nextChild);
|
---|
181 | }
|
---|
182 |
|
---|
183 | if (!prevChild && !nextChild) {
|
---|
184 | return;
|
---|
185 | }
|
---|
186 |
|
---|
187 | const source = getText(context, child);
|
---|
188 | const leadingSpace = !!(prevChild && spaceBetweenPrev());
|
---|
189 | const trailingSpace = !!(nextChild && spaceBetweenNext());
|
---|
190 | const leadingNewLine = !!prevChild;
|
---|
191 | const trailingNewLine = !!nextChild;
|
---|
192 |
|
---|
193 | const key = nodeKey(child);
|
---|
194 |
|
---|
195 | if (!fixDetailsByNode[key]) {
|
---|
196 | fixDetailsByNode[key] = {
|
---|
197 | node: child,
|
---|
198 | source,
|
---|
199 | descriptor: nodeDescriptor(child),
|
---|
200 | };
|
---|
201 | }
|
---|
202 |
|
---|
203 | if (leadingSpace) {
|
---|
204 | fixDetailsByNode[key].leadingSpace = true;
|
---|
205 | }
|
---|
206 | if (leadingNewLine) {
|
---|
207 | fixDetailsByNode[key].leadingNewLine = true;
|
---|
208 | }
|
---|
209 | if (trailingNewLine) {
|
---|
210 | fixDetailsByNode[key].trailingNewLine = true;
|
---|
211 | }
|
---|
212 | if (trailingSpace) {
|
---|
213 | fixDetailsByNode[key].trailingSpace = true;
|
---|
214 | }
|
---|
215 | });
|
---|
216 | });
|
---|
217 |
|
---|
218 | Object.keys(fixDetailsByNode).forEach((key) => {
|
---|
219 | const details = fixDetailsByNode[key];
|
---|
220 |
|
---|
221 | const nodeToReport = details.node;
|
---|
222 | const descriptor = details.descriptor;
|
---|
223 | const source = details.source.replace(/(^ +| +(?=\n)*$)/g, '');
|
---|
224 |
|
---|
225 | const leadingSpaceString = details.leadingSpace ? '\n{\' \'}' : '';
|
---|
226 | const trailingSpaceString = details.trailingSpace ? '{\' \'}\n' : '';
|
---|
227 | const leadingNewLineString = details.leadingNewLine ? '\n' : '';
|
---|
228 | const trailingNewLineString = details.trailingNewLine ? '\n' : '';
|
---|
229 |
|
---|
230 | const replaceText = `${leadingSpaceString}${leadingNewLineString}${source}${trailingNewLineString}${trailingSpaceString}`;
|
---|
231 |
|
---|
232 | report(context, messages.moveToNewLine, 'moveToNewLine', {
|
---|
233 | node: nodeToReport,
|
---|
234 | data: {
|
---|
235 | descriptor,
|
---|
236 | },
|
---|
237 | fix(fixer) {
|
---|
238 | return fixer.replaceText(nodeToReport, replaceText);
|
---|
239 | },
|
---|
240 | });
|
---|
241 | });
|
---|
242 | }
|
---|
243 |
|
---|
244 | return {
|
---|
245 | JSXElement: handleJSX,
|
---|
246 | JSXFragment: handleJSX,
|
---|
247 | };
|
---|
248 | },
|
---|
249 | };
|
---|