UNPKG

16.8 kBJavaScriptView Raw
1// @ts-nocheck
2
3'use strict';
4
5const _ = require('lodash');
6const beforeBlockString = require('../../utils/beforeBlockString');
7const hasBlock = require('../../utils/hasBlock');
8const optionsMatches = require('../../utils/optionsMatches');
9const report = require('../../utils/report');
10const ruleMessages = require('../../utils/ruleMessages');
11const styleSearch = require('style-search');
12const validateOptions = require('../../utils/validateOptions');
13
14const ruleName = 'indentation';
15const messages = ruleMessages(ruleName, {
16 expected: (x) => `Expected indentation of ${x}`,
17});
18
19/**
20 * @param {number|"tab"} space - Number of whitespaces to expect, or else
21 * keyword "tab" for single `\t`
22 * @param {object} [options]
23 */
24function rule(space, options = {}, context) {
25 const isTab = space === 'tab';
26 const indentChar = isTab ? '\t' : ' '.repeat(space);
27 const warningWord = isTab ? 'tab' : 'space';
28
29 return (root, result) => {
30 const validOptions = validateOptions(
31 result,
32 ruleName,
33 {
34 actual: space,
35 possible: [_.isNumber, 'tab'],
36 },
37 {
38 actual: options,
39 possible: {
40 baseIndentLevel: [_.isNumber, 'auto'],
41 except: ['block', 'value', 'param'],
42 ignore: ['value', 'param', 'inside-parens'],
43 indentInsideParens: ['twice', 'once-at-root-twice-in-block'],
44 indentClosingBrace: [_.isBoolean],
45 },
46 optional: true,
47 },
48 );
49
50 if (!validOptions) {
51 return;
52 }
53
54 // Cycle through all nodes using walk.
55 root.walk((node) => {
56 if (node.type === 'root') {
57 // Ignore nested template literals root in css-in-js lang
58 return;
59 }
60
61 const nodeLevel = indentationLevel(node);
62
63 // Cut out any * and _ hacks from `before`
64 const before = (node.raws.before || '').replace(/[*_]$/, '');
65 const after = node.raws.after || '';
66 const parent = node.parent;
67
68 const expectedOpeningBraceIndentation = indentChar.repeat(nodeLevel);
69
70 // Only inspect the spaces before the node
71 // if this is the first node in root
72 // or there is a newline in the `before` string.
73 // (If there is no newline before a node,
74 // there is no "indentation" to check.)
75 const isFirstChild = parent.type === 'root' && parent.first === node;
76 const lastIndexOfNewline = before.lastIndexOf('\n');
77
78 // Inspect whitespace in the `before` string that is
79 // *after* the *last* newline character,
80 // because anything besides that is not indentation for this node:
81 // it is some other kind of separation, checked by some separate rule
82 if (
83 (lastIndexOfNewline !== -1 ||
84 (isFirstChild && (!getDocument(parent) || parent.raws.beforeStart.endsWith('\n')))) &&
85 before.slice(lastIndexOfNewline + 1) !== expectedOpeningBraceIndentation
86 ) {
87 if (context.fix) {
88 if (isFirstChild && _.isString(node.raws.before)) {
89 node.raws.before = node.raws.before.replace(
90 /^[ \t]*(?=\S|$)/,
91 expectedOpeningBraceIndentation,
92 );
93 }
94
95 node.raws.before = fixIndentation(node.raws.before, expectedOpeningBraceIndentation);
96 } else {
97 report({
98 message: messages.expected(legibleExpectation(nodeLevel)),
99 node,
100 result,
101 ruleName,
102 });
103 }
104 }
105
106 // Only blocks have the `after` string to check.
107 // Only inspect `after` strings that start with a newline;
108 // otherwise there's no indentation involved.
109 // And check `indentClosingBrace` to see if it should be indented an extra level.
110 const closingBraceLevel = options.indentClosingBrace ? nodeLevel + 1 : nodeLevel;
111 const expectedClosingBraceIndentation = indentChar.repeat(closingBraceLevel);
112
113 if (
114 hasBlock(node) &&
115 after &&
116 after.includes('\n') &&
117 after.slice(after.lastIndexOf('\n') + 1) !== expectedClosingBraceIndentation
118 ) {
119 if (context.fix) {
120 node.raws.after = fixIndentation(node.raws.after, expectedClosingBraceIndentation);
121 } else {
122 report({
123 message: messages.expected(legibleExpectation(closingBraceLevel)),
124 node,
125 index: node.toString().length - 1,
126 result,
127 ruleName,
128 });
129 }
130 }
131
132 // If this is a declaration, check the value
133 if (node.value) {
134 checkValue(node, nodeLevel);
135 }
136
137 // If this is a rule, check the selector
138 if (node.selector) {
139 checkSelector(node, nodeLevel);
140 }
141
142 // If this is an at rule, check the params
143 if (node.type === 'atrule') {
144 checkAtRuleParams(node, nodeLevel);
145 }
146 });
147
148 function indentationLevel(node, level = 0) {
149 if (node.parent.type === 'root') {
150 return level + getRootBaseIndentLevel(node.parent, options.baseIndentLevel, space);
151 }
152
153 let calculatedLevel;
154
155 // Indentation level equals the ancestor nodes
156 // separating this node from root; so recursively
157 // run this operation
158 calculatedLevel = indentationLevel(node.parent, level + 1);
159
160 // If options.except includes "block",
161 // blocks are taken down one from their calculated level
162 // (all blocks are the same level as their parents)
163 if (
164 optionsMatches(options, 'except', 'block') &&
165 (node.type === 'rule' || node.type === 'atrule') &&
166 hasBlock(node)
167 ) {
168 calculatedLevel--;
169 }
170
171 return calculatedLevel;
172 }
173
174 function checkValue(decl, declLevel) {
175 if (!decl.value.includes('\n')) {
176 return;
177 }
178
179 if (optionsMatches(options, 'ignore', 'value')) {
180 return;
181 }
182
183 const declString = decl.toString();
184 const valueLevel = optionsMatches(options, 'except', 'value') ? declLevel : declLevel + 1;
185
186 checkMultilineBit(declString, valueLevel, decl);
187 }
188
189 function checkSelector(rule, ruleLevel) {
190 const selector = rule.selector;
191
192 // Less mixins have params, and they should be indented extra
193 if (rule.params) {
194 ruleLevel += 1;
195 }
196
197 checkMultilineBit(selector, ruleLevel, rule);
198 }
199
200 function checkAtRuleParams(atRule, ruleLevel) {
201 if (optionsMatches(options, 'ignore', 'param')) {
202 return;
203 }
204
205 // @nest and SCSS's @at-root rules should be treated like regular rules, not expected
206 // to have their params (selectors) indented
207 const paramLevel =
208 optionsMatches(options, 'except', 'param') ||
209 atRule.name === 'nest' ||
210 atRule.name === 'at-root'
211 ? ruleLevel
212 : ruleLevel + 1;
213
214 checkMultilineBit(beforeBlockString(atRule).trim(), paramLevel, atRule);
215 }
216
217 function checkMultilineBit(source, newlineIndentLevel, node) {
218 if (!source.includes('\n')) {
219 return;
220 }
221
222 // Data for current node fixing
223 const fixPositions = [];
224
225 // `outsideParens` because function arguments and also non-standard parenthesized stuff like
226 // Sass maps are ignored to allow for arbitrary indentation
227 let parentheticalDepth = 0;
228
229 styleSearch(
230 {
231 source,
232 target: '\n',
233 outsideParens: optionsMatches(options, 'ignore', 'inside-parens'),
234 },
235 (match, matchCount) => {
236 const precedesClosingParenthesis = /^[ \t]*\)/.test(source.slice(match.startIndex + 1));
237
238 if (
239 optionsMatches(options, 'ignore', 'inside-parens') &&
240 (precedesClosingParenthesis || match.insideParens)
241 ) {
242 return;
243 }
244
245 let expectedIndentLevel = newlineIndentLevel;
246
247 // Modififications for parenthetical content
248 if (!optionsMatches(options, 'ignore', 'inside-parens') && match.insideParens) {
249 // If the first match in is within parentheses, reduce the parenthesis penalty
250 if (matchCount === 1) parentheticalDepth -= 1;
251
252 // Account for windows line endings
253 let newlineIndex = match.startIndex;
254
255 if (source[match.startIndex - 1] === '\r') {
256 newlineIndex--;
257 }
258
259 const followsOpeningParenthesis = /\([ \t]*$/.test(source.slice(0, newlineIndex));
260
261 if (followsOpeningParenthesis) {
262 parentheticalDepth += 1;
263 }
264
265 const followsOpeningBrace = /{[ \t]*$/.test(source.slice(0, newlineIndex));
266
267 if (followsOpeningBrace) {
268 parentheticalDepth += 1;
269 }
270
271 const startingClosingBrace = /^[ \t]*}/.test(source.slice(match.startIndex + 1));
272
273 if (startingClosingBrace) {
274 parentheticalDepth -= 1;
275 }
276
277 expectedIndentLevel += parentheticalDepth;
278
279 // Past this point, adjustments to parentheticalDepth affect next line
280
281 if (precedesClosingParenthesis) {
282 parentheticalDepth -= 1;
283 }
284
285 switch (options.indentInsideParens) {
286 case 'twice':
287 if (!precedesClosingParenthesis || options.indentClosingBrace) {
288 expectedIndentLevel += 1;
289 }
290
291 break;
292 case 'once-at-root-twice-in-block':
293 if (node.parent === node.root()) {
294 if (precedesClosingParenthesis && !options.indentClosingBrace) {
295 expectedIndentLevel -= 1;
296 }
297
298 break;
299 }
300
301 if (!precedesClosingParenthesis || options.indentClosingBrace) {
302 expectedIndentLevel += 1;
303 }
304
305 break;
306 default:
307 if (precedesClosingParenthesis && !options.indentClosingBrace) {
308 expectedIndentLevel -= 1;
309 }
310 }
311 }
312
313 // Starting at the index after the newline, we want to
314 // check that the whitespace characters (excluding newlines) before the first
315 // non-whitespace character equal the expected indentation
316 const afterNewlineSpaceMatches = /^([ \t]*)\S/.exec(source.slice(match.startIndex + 1));
317
318 if (!afterNewlineSpaceMatches) {
319 return;
320 }
321
322 const afterNewlineSpace = afterNewlineSpaceMatches[1];
323 const expectedIndentation = indentChar.repeat(
324 expectedIndentLevel > 0 ? expectedIndentLevel : 0,
325 );
326
327 if (afterNewlineSpace !== expectedIndentation) {
328 if (context.fix) {
329 // Adding fixes position in reverse order, because if we change indent in the beginning of the string it will break all following fixes for that string
330 fixPositions.unshift({
331 expectedIndentation,
332 currentIndentation: afterNewlineSpace,
333 startIndex: match.startIndex,
334 });
335 } else {
336 report({
337 message: messages.expected(legibleExpectation(expectedIndentLevel)),
338 node,
339 index: match.startIndex + afterNewlineSpace.length + 1,
340 result,
341 ruleName,
342 });
343 }
344 }
345 },
346 );
347
348 if (fixPositions.length) {
349 if (node.type === 'rule') {
350 fixPositions.forEach((fixPosition) => {
351 node.selector = replaceIndentation(
352 node.selector,
353 fixPosition.currentIndentation,
354 fixPosition.expectedIndentation,
355 fixPosition.startIndex,
356 );
357 });
358 }
359
360 if (node.type === 'decl') {
361 const declProp = node.prop;
362 const declBetween = node.raws.between;
363
364 fixPositions.forEach((fixPosition) => {
365 if (fixPosition.startIndex < declProp.length + declBetween.length) {
366 node.raws.between = replaceIndentation(
367 declBetween,
368 fixPosition.currentIndentation,
369 fixPosition.expectedIndentation,
370 fixPosition.startIndex - declProp.length,
371 );
372 } else {
373 node.value = replaceIndentation(
374 node.value,
375 fixPosition.currentIndentation,
376 fixPosition.expectedIndentation,
377 fixPosition.startIndex - declProp.length - declBetween.length,
378 );
379 }
380 });
381 }
382
383 if (node.type === 'atrule') {
384 const atRuleName = node.name;
385 const atRuleAfterName = node.raws.afterName;
386 const atRuleParams = node.params;
387
388 fixPositions.forEach((fixPosition) => {
389 // 1 — it's a @ length
390 if (fixPosition.startIndex < 1 + atRuleName.length + atRuleAfterName.length) {
391 node.raws.afterName = replaceIndentation(
392 atRuleAfterName,
393 fixPosition.currentIndentation,
394 fixPosition.expectedIndentation,
395 fixPosition.startIndex - atRuleName.length - 1,
396 );
397 } else {
398 node.params = replaceIndentation(
399 atRuleParams,
400 fixPosition.currentIndentation,
401 fixPosition.expectedIndentation,
402 fixPosition.startIndex - atRuleName.length - atRuleAfterName.length - 1,
403 );
404 }
405 });
406 }
407 }
408 }
409 };
410
411 function legibleExpectation(level) {
412 const count = isTab ? level : level * space;
413 const quantifiedWarningWord = count === 1 ? warningWord : `${warningWord}s`;
414
415 return `${count} ${quantifiedWarningWord}`;
416 }
417}
418
419function getRootBaseIndentLevel(root, baseIndentLevel, space) {
420 const document = getDocument(root);
421
422 if (!document) {
423 return 0;
424 }
425
426 let indentLevel = root.source.baseIndentLevel;
427
428 if (!Number.isSafeInteger(indentLevel)) {
429 indentLevel = inferRootIndentLevel(root, baseIndentLevel, () =>
430 inferDocIndentSize(document, space),
431 );
432 root.source.baseIndentLevel = indentLevel;
433 }
434
435 return indentLevel;
436}
437
438function getDocument(node) {
439 const document = node.document;
440
441 if (document) {
442 return document;
443 }
444
445 const root = node.root();
446
447 return root && root.document;
448}
449
450function inferDocIndentSize(document, space) {
451 let indentSize = document.source.indentSize;
452
453 if (Number.isSafeInteger(indentSize)) {
454 return indentSize;
455 }
456
457 const source = document.source.input.css;
458 const indents = source.match(/^ *(?=\S)/gm);
459
460 if (indents) {
461 const scores = {};
462 let lastIndentSize = 0;
463 let lastLeadingSpacesLength = 0;
464 const vote = (leadingSpacesLength) => {
465 if (leadingSpacesLength) {
466 lastIndentSize = Math.abs(leadingSpacesLength - lastLeadingSpacesLength) || lastIndentSize;
467
468 if (lastIndentSize > 1) {
469 if (scores[lastIndentSize]) {
470 scores[lastIndentSize]++;
471 } else {
472 scores[lastIndentSize] = 1;
473 }
474 }
475 } else {
476 lastIndentSize = 0;
477 }
478
479 lastLeadingSpacesLength = leadingSpacesLength;
480 };
481
482 indents.forEach((leadingSpaces) => {
483 vote(leadingSpaces.length);
484 });
485
486 let bestScore = 0;
487
488 for (const indentSizeDate in scores) {
489 if (Object.prototype.hasOwnProperty.call(scores, indentSizeDate)) {
490 const score = scores[indentSizeDate];
491
492 if (score > bestScore) {
493 bestScore = score;
494 indentSize = indentSizeDate;
495 }
496 }
497 }
498 }
499
500 indentSize = Number(indentSize) || (indents && indents[0].length) || Number(space) || 2;
501 document.source.indentSize = indentSize;
502
503 return indentSize;
504}
505
506function inferRootIndentLevel(root, baseIndentLevel, indentSize) {
507 function getIndentLevel(indent) {
508 let tabCount = indent.match(/\t/g);
509
510 tabCount = tabCount ? tabCount.length : 0;
511 let spaceCount = indent.match(/ /g);
512
513 spaceCount = spaceCount ? Math.round(spaceCount.length / indentSize()) : 0;
514
515 return tabCount + spaceCount;
516 }
517
518 if (!Number.isSafeInteger(baseIndentLevel)) {
519 let source = root.source.input.css;
520
521 source = source.replace(/^[^\r\n]+/, (firstLine) => {
522 if (/(?:^|\n)([ \t]*)$/.test(root.raws.beforeStart)) {
523 return RegExp.$1 + firstLine;
524 }
525
526 return '';
527 });
528
529 const indents = source.match(/^[ \t]*(?=\S)/gm);
530
531 if (indents) {
532 return Math.min(...indents.map(getIndentLevel));
533 }
534
535 baseIndentLevel = 1;
536 }
537
538 const indents = [];
539 const foundIndents = /(?:^|\n)([ \t]*)\S[^\r\n]*(?:\r?\n\s*)*$/m.exec(root.raws.beforeStart);
540
541 // The indent level of the CSS code block in non-CSS-like files is determined by the shortest indent of non-empty line.
542 if (foundIndents) {
543 let shortest = Number.MAX_SAFE_INTEGER;
544 let i = 0;
545
546 while (++i < foundIndents.length) {
547 const current = getIndentLevel(foundIndents[i]);
548
549 if (current < shortest) {
550 shortest = current;
551
552 if (shortest === 0) {
553 break;
554 }
555 }
556 }
557
558 if (shortest !== Number.MAX_SAFE_INTEGER) {
559 indents.push(new Array(shortest).fill(' ').join(''));
560 }
561 }
562
563 const after = root.raws.after;
564
565 if (after) {
566 let afterEnd;
567
568 if (after.endsWith('\n')) {
569 const document = root.document;
570
571 if (document) {
572 const nextRoot = document.nodes[document.nodes.indexOf(root) + 1];
573
574 if (nextRoot) {
575 afterEnd = nextRoot.raws.beforeStart;
576 } else {
577 afterEnd = document.raws.afterEnd;
578 }
579 } else {
580 // Nested root node in css-in-js lang
581 const parent = root.parent;
582
583 const nextRoot = parent.nodes[parent.nodes.indexOf(root) + 1];
584
585 if (nextRoot) {
586 afterEnd = nextRoot.raws.beforeStart;
587 } else {
588 afterEnd = root.raws.afterEnd;
589 }
590 }
591 } else {
592 afterEnd = after;
593 }
594
595 indents.push(afterEnd.match(/^[ \t]*/)[0]);
596 }
597
598 if (indents.length) {
599 return Math.max(...indents.map(getIndentLevel)) + baseIndentLevel;
600 }
601
602 return baseIndentLevel;
603}
604
605function fixIndentation(str, whitespace) {
606 if (!_.isString(str)) {
607 return str;
608 }
609
610 return str.replace(/\n[ \t]*(?=\S|$)/g, `\n${whitespace}`);
611}
612
613function replaceIndentation(input, searchString, replaceString, startIndex) {
614 const offset = startIndex + 1;
615 const stringStart = input.slice(0, offset);
616 const stringEnd = input.slice(offset + searchString.length);
617
618 return stringStart + replaceString + stringEnd;
619}
620
621rule.ruleName = ruleName;
622rule.messages = messages;
623module.exports = rule;