1 | 'use strict';
|
2 |
|
3 | const valueParser = require('postcss-value-parser');
|
4 |
|
5 | const declarationValueIndex = require('../../utils/declarationValueIndex');
|
6 | const getDeclarationValue = require('../../utils/getDeclarationValue');
|
7 | const report = require('../../utils/report');
|
8 | const ruleMessages = require('../../utils/ruleMessages');
|
9 | const setDeclarationValue = require('../../utils/setDeclarationValue');
|
10 | const validateOptions = require('../../utils/validateOptions');
|
11 |
|
12 | const ruleName = 'function-calc-no-unspaced-operator';
|
13 |
|
14 | const messages = ruleMessages(ruleName, {
|
15 | expectedBefore: (operator) => `Expected single space before "${operator}" operator`,
|
16 | expectedAfter: (operator) => `Expected single space after "${operator}" operator`,
|
17 | expectedOperatorBeforeSign: (operator) => `Expected an operator before sign "${operator}"`,
|
18 | });
|
19 |
|
20 | const meta = {
|
21 | url: 'https://stylelint.io/user-guide/rules/list/function-calc-no-unspaced-operator',
|
22 | };
|
23 |
|
24 | const OPERATORS = new Set(['*', '/', '+', '-']);
|
25 | const OPERATOR_REGEX = /[*/+-]/;
|
26 |
|
27 |
|
28 | const rule = (primary, _secondaryOptions, context) => {
|
29 | return (root, result) => {
|
30 | const validOptions = validateOptions(result, ruleName, { actual: primary });
|
31 |
|
32 | if (!validOptions) return;
|
33 |
|
34 | |
35 |
|
36 |
|
37 |
|
38 |
|
39 | function complain(message, node, index) {
|
40 | report({ message, node, index, result, ruleName });
|
41 | }
|
42 |
|
43 | root.walkDecls((decl) => {
|
44 | let needsFix = false;
|
45 | const valueIndex = declarationValueIndex(decl);
|
46 | const parsedValue = valueParser(getDeclarationValue(decl));
|
47 |
|
48 | |
49 |
|
50 |
|
51 |
|
52 |
|
53 | function checkAroundOperator(nodes, operatorIndex, direction) {
|
54 | const isBeforeOp = direction === -1;
|
55 | const currentNode = nodes[operatorIndex + direction];
|
56 | const operator = nodes[operatorIndex].value;
|
57 | const operatorSourceIndex = nodes[operatorIndex].sourceIndex;
|
58 |
|
59 | if (currentNode && !isSingleSpace(currentNode)) {
|
60 | if (currentNode.type === 'word') {
|
61 | if (isBeforeOp) {
|
62 | const lastChar = currentNode.value.slice(-1);
|
63 |
|
64 | if (OPERATORS.has(lastChar)) {
|
65 | if (context.fix) {
|
66 | currentNode.value = `${currentNode.value.slice(0, -1)} ${lastChar}`;
|
67 |
|
68 | return true;
|
69 | }
|
70 |
|
71 | complain(messages.expectedOperatorBeforeSign(operator), decl, operatorSourceIndex);
|
72 |
|
73 | return true;
|
74 | }
|
75 | } else {
|
76 | const firstChar = currentNode.value.slice(0, 1);
|
77 |
|
78 | if (OPERATORS.has(firstChar)) {
|
79 | if (context.fix) {
|
80 | currentNode.value = `${firstChar} ${currentNode.value.slice(1)}`;
|
81 |
|
82 | return true;
|
83 | }
|
84 |
|
85 | complain(messages.expectedAfter(operator), decl, operatorSourceIndex);
|
86 |
|
87 | return true;
|
88 | }
|
89 | }
|
90 |
|
91 | if (context.fix) {
|
92 | needsFix = true;
|
93 | currentNode.value = isBeforeOp ? `${currentNode.value} ` : ` ${currentNode.value}`;
|
94 |
|
95 | return true;
|
96 | }
|
97 |
|
98 | complain(
|
99 | isBeforeOp ? messages.expectedBefore(operator) : messages.expectedAfter(operator),
|
100 | decl,
|
101 | valueIndex + operatorSourceIndex,
|
102 | );
|
103 |
|
104 | return true;
|
105 | }
|
106 |
|
107 | if (currentNode.type === 'space') {
|
108 | const indexOfFirstNewLine = currentNode.value.search(/(\n|\r\n)/);
|
109 |
|
110 | if (indexOfFirstNewLine === 0) return;
|
111 |
|
112 | if (context.fix) {
|
113 | needsFix = true;
|
114 |
|
115 | currentNode.value =
|
116 | indexOfFirstNewLine === -1 ? ' ' : currentNode.value.slice(indexOfFirstNewLine);
|
117 |
|
118 | return true;
|
119 | }
|
120 |
|
121 | const message = isBeforeOp
|
122 | ? messages.expectedBefore(operator)
|
123 | : messages.expectedAfter(operator);
|
124 |
|
125 | complain(message, decl, valueIndex + operatorSourceIndex);
|
126 |
|
127 | return true;
|
128 | }
|
129 |
|
130 | if (currentNode.type === 'function') {
|
131 | if (context.fix) {
|
132 | needsFix = true;
|
133 | nodes.splice(operatorIndex, 0, {
|
134 | type: 'space',
|
135 | value: ' ',
|
136 | sourceIndex: 0,
|
137 | sourceEndIndex: 1,
|
138 | });
|
139 |
|
140 | return true;
|
141 | }
|
142 |
|
143 | const message = isBeforeOp
|
144 | ? messages.expectedBefore(operator)
|
145 | : messages.expectedAfter(operator);
|
146 |
|
147 | complain(message, decl, valueIndex + operatorSourceIndex);
|
148 |
|
149 | return true;
|
150 | }
|
151 | }
|
152 |
|
153 | return false;
|
154 | }
|
155 |
|
156 | |
157 |
|
158 |
|
159 | function checkForOperatorInFirstNode(nodes) {
|
160 | const firstNode = nodes[0];
|
161 |
|
162 | const operatorIndex =
|
163 | (firstNode.type === 'word' || -1) && firstNode.value.search(OPERATOR_REGEX);
|
164 | const operator = firstNode.value.slice(operatorIndex, operatorIndex + 1);
|
165 |
|
166 | if (operatorIndex <= 0) return false;
|
167 |
|
168 | const charBefore = firstNode.value.charAt(operatorIndex - 1);
|
169 | const charAfter = firstNode.value.charAt(operatorIndex + 1);
|
170 |
|
171 | if (charBefore && charBefore !== ' ' && charAfter && charAfter !== ' ') {
|
172 | if (context.fix) {
|
173 | needsFix = true;
|
174 | firstNode.value = insertCharAtIndex(firstNode.value, operatorIndex + 1, ' ');
|
175 | firstNode.value = insertCharAtIndex(firstNode.value, operatorIndex, ' ');
|
176 | } else {
|
177 | complain(
|
178 | messages.expectedBefore(operator),
|
179 | decl,
|
180 | valueIndex + firstNode.sourceIndex + operatorIndex,
|
181 | );
|
182 | complain(
|
183 | messages.expectedAfter(operator),
|
184 | decl,
|
185 | valueIndex + firstNode.sourceIndex + operatorIndex + 1,
|
186 | );
|
187 | }
|
188 | } else if (charBefore && charBefore !== ' ') {
|
189 | if (context.fix) {
|
190 | needsFix = true;
|
191 | firstNode.value = insertCharAtIndex(firstNode.value, operatorIndex, ' ');
|
192 | } else {
|
193 | complain(
|
194 | messages.expectedBefore(operator),
|
195 | decl,
|
196 | valueIndex + firstNode.sourceIndex + operatorIndex,
|
197 | );
|
198 | }
|
199 | } else if (charAfter && charAfter !== ' ') {
|
200 | if (context.fix) {
|
201 | needsFix = true;
|
202 | firstNode.value = insertCharAtIndex(firstNode.value, operatorIndex, ' ');
|
203 | } else {
|
204 | complain(
|
205 | messages.expectedAfter(operator),
|
206 | decl,
|
207 | valueIndex + firstNode.sourceIndex + operatorIndex + 1,
|
208 | );
|
209 | }
|
210 | }
|
211 |
|
212 | return true;
|
213 | }
|
214 |
|
215 | |
216 |
|
217 |
|
218 | function checkForOperatorInLastNode(nodes) {
|
219 | if (nodes.length === 1) return false;
|
220 |
|
221 | const lastNode = nodes[nodes.length - 1];
|
222 |
|
223 | const operatorIndex =
|
224 | (lastNode.type === 'word' || -1) && lastNode.value.search(OPERATOR_REGEX);
|
225 |
|
226 | if (lastNode.value[operatorIndex - 1] === ' ') return false;
|
227 |
|
228 | if (context.fix) {
|
229 | needsFix = true;
|
230 | lastNode.value = insertCharAtIndex(lastNode.value, operatorIndex + 1, ' ').trim();
|
231 | lastNode.value = insertCharAtIndex(lastNode.value, operatorIndex, ' ').trim();
|
232 |
|
233 | return true;
|
234 | }
|
235 |
|
236 | complain(
|
237 | messages.expectedOperatorBeforeSign(lastNode.value[operatorIndex]),
|
238 | decl,
|
239 | valueIndex + lastNode.sourceIndex + operatorIndex,
|
240 | );
|
241 |
|
242 | return true;
|
243 | }
|
244 |
|
245 | |
246 |
|
247 |
|
248 | function checkWords(nodes) {
|
249 | if (checkForOperatorInFirstNode(nodes) || checkForOperatorInLastNode(nodes)) return;
|
250 |
|
251 | for (const [index, node] of nodes.entries()) {
|
252 | const lastChar = node.value.slice(-1);
|
253 | const firstChar = node.value.slice(0, 1);
|
254 |
|
255 | if (node.type === 'word') {
|
256 | if (index === 0 && OPERATORS.has(lastChar)) {
|
257 | if (context.fix) {
|
258 | node.value = `${node.value.slice(0, -1)} ${lastChar}`;
|
259 |
|
260 | continue;
|
261 | }
|
262 |
|
263 | complain(messages.expectedBefore(lastChar), decl, node.sourceIndex);
|
264 | } else if (index === nodes.length && OPERATORS.has(firstChar)) {
|
265 | if (context.fix) {
|
266 | node.value = `${firstChar} ${node.value.slice(1)}`;
|
267 |
|
268 | continue;
|
269 | }
|
270 |
|
271 | complain(messages.expectedOperatorBeforeSign(firstChar), decl, node.sourceIndex);
|
272 | }
|
273 | }
|
274 | }
|
275 | }
|
276 |
|
277 | parsedValue.walk((node) => {
|
278 | if (node.type !== 'function' || node.value.toLowerCase() !== 'calc') return;
|
279 |
|
280 | let foundOperatorNode = false;
|
281 |
|
282 | for (const [nodeIndex, currNode] of node.nodes.entries()) {
|
283 | if (currNode.type !== 'word' || !OPERATORS.has(currNode.value)) continue;
|
284 |
|
285 | foundOperatorNode = true;
|
286 |
|
287 | const nodeBefore = node.nodes[nodeIndex - 1];
|
288 | const nodeAfter = node.nodes[nodeIndex + 1];
|
289 |
|
290 | if (isSingleSpace(nodeBefore) && isSingleSpace(nodeAfter)) continue;
|
291 |
|
292 | if (checkAroundOperator(node.nodes, nodeIndex, 1)) continue;
|
293 |
|
294 | checkAroundOperator(node.nodes, nodeIndex, -1);
|
295 | }
|
296 |
|
297 | if (!foundOperatorNode) {
|
298 | checkWords(node.nodes);
|
299 | }
|
300 | });
|
301 |
|
302 | if (needsFix) {
|
303 | setDeclarationValue(decl, parsedValue.toString());
|
304 | }
|
305 | });
|
306 | };
|
307 | };
|
308 |
|
309 |
|
310 |
|
311 |
|
312 |
|
313 |
|
314 | function insertCharAtIndex(str, index, char) {
|
315 | return str.slice(0, index) + char + str.slice(index, str.length);
|
316 | }
|
317 |
|
318 |
|
319 |
|
320 |
|
321 |
|
322 | function isSingleSpace(node) {
|
323 | return node && node.type === 'space' && node.value === ' ';
|
324 | }
|
325 |
|
326 | rule.ruleName = ruleName;
|
327 | rule.messages = messages;
|
328 | rule.meta = meta;
|
329 | module.exports = rule;
|