3 const _ = require("lodash");
4 const beforeBlockString = require("../../utils/beforeBlockString");
5 const hasBlock = require("../../utils/hasBlock");
6 const optionsMatches = require("../../utils/optionsMatches");
7 const report = require("../../utils/report");
8 const ruleMessages = require("../../utils/ruleMessages");
9 const styleSearch = require("style-search");
10 const validateOptions = require("../../utils/validateOptions");
12 const ruleName = "indentation";
13 const messages = ruleMessages(ruleName, {
14 expected: x => `Expected indentation of ${x}`
18 * @param {number|"tab"} space - Number of whitespaces to expect, or else
19 * keyword "tab" for single `\t`
20 * @param {object} [options]
22 const rule = function(space, options, context) {
23 options = options || {};
25 const isTab = space === "tab";
26 const indentChar = isTab ? "\t" : _.repeat(" ", space);
27 const warningWord = isTab ? "tab" : "space";
29 return (root, result) => {
30 const validOptions = validateOptions(
35 possible: [_.isNumber, "tab"]
40 except: ["block", "value", "param"],
41 ignore: ["value", "param", "inside-parens"],
42 indentInsideParens: ["twice", "once-at-root-twice-in-block"],
43 indentClosingBrace: [_.isBoolean]
52 // Cycle through all nodes using walk.
54 const nodeLevel = indentationLevel(node);
55 const expectedWhitespace = _.repeat(indentChar, nodeLevel);
57 let before = node.raws.before || "";
58 const after = node.raws.after || "";
60 // Only inspect the spaces before the node
61 // if this is the first node in root
62 // or there is a newline in the `before` string.
63 // (If there is no newline before a node,
64 // there is no "indentation" to check.)
66 node.root().first === node || before.indexOf("\n") !== -1;
68 // Cut out any * and _ hacks from `before`
70 before[before.length - 1] === "*" ||
71 before[before.length - 1] === "_"
73 before = before.slice(0, before.length - 1);
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
82 before.slice(before.lastIndexOf("\n") + 1) !== expectedWhitespace
85 node.raws.before = fixIndentation(
91 message: messages.expected(legibleExpectation(nodeLevel)),
99 // Only blocks have the `after` string to check.
100 // Only inspect `after` strings that start with a newline;
101 // otherwise there's no indentation involved.
102 // And check `indentClosingBrace` to see if it should be indented an extra level.
103 const closingBraceLevel = options.indentClosingBrace
106 const expectedClosingBraceIndentation = _.repeat(
113 after.indexOf("\n") !== -1 &&
114 after.slice(after.lastIndexOf("\n") + 1) !==
115 expectedClosingBraceIndentation
118 node.raws.after = fixIndentation(
120 expectedClosingBraceIndentation
124 message: messages.expected(legibleExpectation(closingBraceLevel)),
126 index: node.toString().length - 1,
133 // If this is a declaration, check the value
135 checkValue(node, nodeLevel);
138 // If this is a rule, check the selector
140 checkSelector(node, nodeLevel);
143 // If this is an at rule, check the params
144 if (node.type === "atrule") {
145 checkAtRuleParams(node, nodeLevel);
149 function indentationLevel(node, level) {
152 if (node.parent.type === "root") {
153 return level + getRootBaseIndentLevel(node.parent);
158 // Indentation level equals the ancestor nodes
159 // separating this node from root; so recursively
160 // run this operation
161 calculatedLevel = indentationLevel(node.parent, level + 1);
163 // If options.except includes "block",
164 // blocks are taken down one from their calculated level
165 // (all blocks are the same level as their parents)
167 optionsMatches(options, "except", "block") &&
168 (node.type === "rule" || node.type === "atrule") &&
174 return calculatedLevel;
177 function checkValue(decl, declLevel) {
178 if (decl.value.indexOf("\n") === -1) {
181 if (optionsMatches(options, "ignore", "value")) {
185 const declString = decl.toString();
186 const valueLevel = optionsMatches(options, "except", "value")
190 checkMultilineBit(declString, valueLevel, decl);
193 function checkSelector(rule, ruleLevel) {
194 const selector = rule.selector;
196 // Less mixins have params, and they should be indented extra
201 checkMultilineBit(selector, ruleLevel, rule);
204 function checkAtRuleParams(atRule, ruleLevel) {
205 if (optionsMatches(options, "ignore", "param")) {
209 // @nest rules should be treated like regular rules, not expected
210 // to have their params (selectors) indented
212 optionsMatches(options, "except", "param") || atRule.name === "nest"
216 checkMultilineBit(beforeBlockString(atRule).trim(), paramLevel, atRule);
219 function checkMultilineBit(source, newlineIndentLevel, node) {
220 if (source.indexOf("\n") === -1) {
224 // Data for current node fixing
225 const fixPositions = [];
227 // `outsideParens` because function arguments and also non-standard parenthesized stuff like
228 // Sass maps are ignored to allow for arbitrary indentation
229 let parentheticalDepth = 0;
235 outsideParens: optionsMatches(options, "ignore", "inside-parens")
237 (match, matchCount) => {
238 const precedesClosingParenthesis = /^[ \t]*\)/.test(
239 source.slice(match.startIndex + 1)
243 optionsMatches(options, "ignore", "inside-parens") &&
244 (precedesClosingParenthesis || match.insideParens)
249 let expectedIndentLevel = newlineIndentLevel;
251 // Modififications for parenthetical content
253 !optionsMatches(options, "ignore", "inside-parens") &&
256 // If the first match in is within parentheses, reduce the parenthesis penalty
257 if (matchCount === 1) parentheticalDepth -= 1;
259 // Account for windows line endings
260 let newlineIndex = match.startIndex;
261 if (source[match.startIndex - 1] === "\r") {
265 const followsOpeningParenthesis = /\([ \t]*$/.test(
266 source.slice(0, newlineIndex)
268 if (followsOpeningParenthesis) {
269 parentheticalDepth += 1;
272 const followsOpeningBrace = /\{[ \t]*$/.test(
273 source.slice(0, newlineIndex)
275 if (followsOpeningBrace) {
276 parentheticalDepth += 1;
279 const startingClosingBrace = /^[ \t]*}/.test(
280 source.slice(match.startIndex + 1)
282 if (startingClosingBrace) {
283 parentheticalDepth -= 1;
286 expectedIndentLevel += parentheticalDepth;
288 // Past this point, adjustments to parentheticalDepth affect next line
290 if (precedesClosingParenthesis) {
291 parentheticalDepth -= 1;
294 switch (options.indentInsideParens) {
296 if (!precedesClosingParenthesis || options.indentClosingBrace) {
297 expectedIndentLevel += 1;
300 case "once-at-root-twice-in-block":
301 if (node.parent === node.root()) {
303 precedesClosingParenthesis &&
304 !options.indentClosingBrace
306 expectedIndentLevel -= 1;
310 if (!precedesClosingParenthesis || options.indentClosingBrace) {
311 expectedIndentLevel += 1;
315 if (precedesClosingParenthesis && !options.indentClosingBrace) {
316 expectedIndentLevel -= 1;
321 // Starting at the index after the newline, we want to
322 // check that the whitespace characters (excluding newlines) before the first
323 // non-whitespace character equal the expected indentation
324 const afterNewlineSpaceMatches = /^([ \t]*)\S/.exec(
325 source.slice(match.startIndex + 1)
328 if (!afterNewlineSpaceMatches) {
332 const afterNewlineSpace = afterNewlineSpaceMatches[1];
333 const expectedIndentation = _.repeat(indentChar, expectedIndentLevel);
335 if (afterNewlineSpace !== expectedIndentation) {
337 // 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
338 fixPositions.unshift({
340 currentIndentation: afterNewlineSpace,
341 startIndex: match.startIndex
345 message: messages.expected(
346 legibleExpectation(expectedIndentLevel)
349 index: match.startIndex + afterNewlineSpace.length + 1,
358 if (fixPositions.length) {
359 if (node.type === "rule") {
360 fixPositions.forEach(function(fixPosition) {
361 node.selector = replaceIndentation(
363 fixPosition.currentIndentation,
364 fixPosition.expectedIndentation,
365 fixPosition.startIndex
370 if (node.type === "decl") {
371 const declProp = node.prop;
372 const declBetween = node.raws.between;
374 fixPositions.forEach(function(fixPosition) {
375 if (fixPosition.startIndex < declProp.length + declBetween.length) {
376 node.raws.between = replaceIndentation(
378 fixPosition.currentIndentation,
379 fixPosition.expectedIndentation,
380 fixPosition.startIndex - declProp.length
383 node.value = replaceIndentation(
385 fixPosition.currentIndentation,
386 fixPosition.expectedIndentation,
387 fixPosition.startIndex - declProp.length - declBetween.length
393 if (node.type === "atrule") {
394 const atRuleName = node.name;
395 const atRuleAfterName = node.raws.afterName;
396 const atRuleParams = node.params;
398 fixPositions.forEach(function(fixPosition) {
399 // 1 — it's a @ length
401 fixPosition.startIndex <
402 1 + atRuleName.length + atRuleAfterName.length
404 node.raws.afterName = replaceIndentation(
406 fixPosition.currentIndentation,
407 fixPosition.expectedIndentation,
408 fixPosition.startIndex - atRuleName.length - 1
411 node.params = replaceIndentation(
413 fixPosition.currentIndentation,
414 fixPosition.expectedIndentation,
415 fixPosition.startIndex -
417 atRuleAfterName.length -
426 function getRootBaseIndentLevel(node) {
430 if (isNaN(node.source.baseIndentLevel)) {
431 node.source.baseIndentLevel = getBaseIndentLevel(node, root, space);
433 return node.source.baseIndentLevel;
437 function legibleExpectation(level) {
438 const count = isTab ? level : level * space;
439 const quantifiedWarningWord = count === 1 ? warningWord : warningWord + "s";
441 return `${count} ${quantifiedWarningWord}`;
445 function getBaseIndentLength(indents) {
446 return Math.min.apply(Math, indents.map(indent => indent.length));
449 function getSoftIndentLevel(softIndentCount, space) {
450 return Math.round(softIndentCount / (parseInt(space) || 2));
453 function getBaseIndentLevel(node, root, space) {
454 const code = node.source.input.css;
455 if (/^\S+/m.test(code)) {
459 const softIndents = code.match(/^ +(?=\S+)/gm);
460 const hardIndents = code.match(/^\t+(?=\S+)/gm);
461 let softIndentCount = softIndents ? softIndents.length : 0;
462 let hardIndentCount = hardIndents ? hardIndents.length : 0;
464 if (softIndentCount > hardIndentCount) {
465 return getSoftIndentLevel(getBaseIndentLength(softIndents), space);
466 } else if (hardIndentCount) {
467 return getBaseIndentLength(hardIndents);
470 let afterEnd = root.nodes[root.nodes.indexOf(node) + 1];
472 afterEnd = afterEnd.raws.beforeStart;
474 afterEnd = root.raws.afterEnd;
477 softIndentCount = afterEnd.match(/^\s*/)[0].replace(/\t/g, () => {
482 return hardIndentCount + getSoftIndentLevel(softIndentCount, space);
485 function fixIndentation(str, whitespace) {
486 if (!_.isString(str)) {
490 const strLength = str.length;
496 let stringEnd = str[strLength - 1];
498 if (stringEnd !== "*" && stringEnd !== "_") {
502 const stringStart = str.slice(0, str.lastIndexOf("\n") + 1);
504 return stringStart + whitespace + stringEnd;
507 function replaceIndentation(input, searchString, replaceString, startIndex) {
508 const offset = startIndex + 1;
509 const stringStart = input.slice(0, offset);
510 const stringEnd = input.slice(offset + searchString.length);
512 return stringStart + replaceString + stringEnd;
515 rule.ruleName = ruleName;
516 rule.messages = messages;
517 module.exports = rule;