UNPKG

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