"use strict"; const _ = require("lodash"); const beforeBlockString = require("../../utils/beforeBlockString"); const hasBlock = require("../../utils/hasBlock"); const optionsMatches = require("../../utils/optionsMatches"); const report = require("../../utils/report"); const ruleMessages = require("../../utils/ruleMessages"); const styleSearch = require("style-search"); const validateOptions = require("../../utils/validateOptions"); const ruleName = "indentation"; const messages = ruleMessages(ruleName, { expected: x => `Expected indentation of ${x}` }); /** * @param {number|"tab"} space - Number of whitespaces to expect, or else * keyword "tab" for single `\t` * @param {object} [options] */ const rule = function(space, options, context) { options = options || {}; const isTab = space === "tab"; const indentChar = isTab ? "\t" : _.repeat(" ", space); const warningWord = isTab ? "tab" : "space"; return (root, result) => { const validOptions = validateOptions( result, ruleName, { actual: space, possible: [_.isNumber, "tab"] }, { actual: options, possible: { baseIndentLevel: [_.isNumber, "auto"], except: ["block", "value", "param"], ignore: ["value", "param", "inside-parens"], indentInsideParens: ["twice", "once-at-root-twice-in-block"], indentClosingBrace: [_.isBoolean] }, optional: true } ); if (!validOptions) { return; } // Cycle through all nodes using walk. root.walk(node => { const nodeLevel = indentationLevel(node); // Cut out any * and _ hacks from `before` const before = (node.raws.before || "").replace(/[*_]$/, ""); const after = node.raws.after || ""; const parent = node.parent; const expectedOpeningBraceIndentation = _.repeat(indentChar, nodeLevel); // Only inspect the spaces before the node // if this is the first node in root // or there is a newline in the `before` string. // (If there is no newline before a node, // there is no "indentation" to check.) const isFirstChild = parent.type === "root" && parent.first === node; const lastIndexOfNewline = before.lastIndexOf("\n"); // Inspect whitespace in the `before` string that is // *after* the *last* newline character, // because anything besides that is not indentation for this node: // it is some other kind of separation, checked by some separate rule if ( (lastIndexOfNewline !== -1 || (isFirstChild && (!parent.document || parent.raws.beforeStart.slice(-1) === "\n"))) && before.slice(lastIndexOfNewline + 1) !== expectedOpeningBraceIndentation ) { if (context.fix) { if (isFirstChild && _.isString(node.raws.before)) { node.raws.before = node.raws.before.replace( /^[ \t]*(?=\S|$)/, expectedOpeningBraceIndentation ); } node.raws.before = fixIndentation( node.raws.before, expectedOpeningBraceIndentation ); } else { report({ message: messages.expected(legibleExpectation(nodeLevel)), node, result, ruleName }); } } // Only blocks have the `after` string to check. // Only inspect `after` strings that start with a newline; // otherwise there's no indentation involved. // And check `indentClosingBrace` to see if it should be indented an extra level. const closingBraceLevel = options.indentClosingBrace ? nodeLevel + 1 : nodeLevel; const expectedClosingBraceIndentation = _.repeat( indentChar, closingBraceLevel ); if ( hasBlock(node) && after && after.indexOf("\n") !== -1 && after.slice(after.lastIndexOf("\n") + 1) !== expectedClosingBraceIndentation ) { if (context.fix) { node.raws.after = fixIndentation( node.raws.after, expectedClosingBraceIndentation ); } else { report({ message: messages.expected(legibleExpectation(closingBraceLevel)), node, index: node.toString().length - 1, result, ruleName }); } } // If this is a declaration, check the value if (node.value) { checkValue(node, nodeLevel); } // If this is a rule, check the selector if (node.selector) { checkSelector(node, nodeLevel); } // If this is an at rule, check the params if (node.type === "atrule") { checkAtRuleParams(node, nodeLevel); } }); function indentationLevel(node, level) { level = level || 0; if (node.parent.type === "root") { return ( level + getRootBaseIndentLevel(node.parent, options.baseIndentLevel, space) ); } let calculatedLevel; // Indentation level equals the ancestor nodes // separating this node from root; so recursively // run this operation calculatedLevel = indentationLevel(node.parent, level + 1); // If options.except includes "block", // blocks are taken down one from their calculated level // (all blocks are the same level as their parents) if ( optionsMatches(options, "except", "block") && (node.type === "rule" || node.type === "atrule") && hasBlock(node) ) { calculatedLevel--; } return calculatedLevel; } function checkValue(decl, declLevel) { if (decl.value.indexOf("\n") === -1) { return; } if (optionsMatches(options, "ignore", "value")) { return; } const declString = decl.toString(); const valueLevel = optionsMatches(options, "except", "value") ? declLevel : declLevel + 1; checkMultilineBit(declString, valueLevel, decl); } function checkSelector(rule, ruleLevel) { const selector = rule.selector; // Less mixins have params, and they should be indented extra if (rule.params) { ruleLevel += 1; } checkMultilineBit(selector, ruleLevel, rule); } function checkAtRuleParams(atRule, ruleLevel) { if (optionsMatches(options, "ignore", "param")) { return; } // @nest and SCSS's @at-root rules should be treated like regular rules, not expected // to have their params (selectors) indented const paramLevel = optionsMatches(options, "except", "param") || atRule.name === "nest" || atRule.name === "at-root" ? ruleLevel : ruleLevel + 1; checkMultilineBit(beforeBlockString(atRule).trim(), paramLevel, atRule); } function checkMultilineBit(source, newlineIndentLevel, node) { if (source.indexOf("\n") === -1) { return; } // Data for current node fixing const fixPositions = []; // `outsideParens` because function arguments and also non-standard parenthesized stuff like // Sass maps are ignored to allow for arbitrary indentation let parentheticalDepth = 0; styleSearch( { source, target: "\n", outsideParens: optionsMatches(options, "ignore", "inside-parens") }, (match, matchCount) => { const precedesClosingParenthesis = /^[ \t]*\)/.test( source.slice(match.startIndex + 1) ); if ( optionsMatches(options, "ignore", "inside-parens") && (precedesClosingParenthesis || match.insideParens) ) { return; } let expectedIndentLevel = newlineIndentLevel; // Modififications for parenthetical content if ( !optionsMatches(options, "ignore", "inside-parens") && match.insideParens ) { // If the first match in is within parentheses, reduce the parenthesis penalty if (matchCount === 1) parentheticalDepth -= 1; // Account for windows line endings let newlineIndex = match.startIndex; if (source[match.startIndex - 1] === "\r") { newlineIndex--; } const followsOpeningParenthesis = /\([ \t]*$/.test( source.slice(0, newlineIndex) ); if (followsOpeningParenthesis) { parentheticalDepth += 1; } const followsOpeningBrace = /\{[ \t]*$/.test( source.slice(0, newlineIndex) ); if (followsOpeningBrace) { parentheticalDepth += 1; } const startingClosingBrace = /^[ \t]*}/.test( source.slice(match.startIndex + 1) ); if (startingClosingBrace) { parentheticalDepth -= 1; } expectedIndentLevel += parentheticalDepth; // Past this point, adjustments to parentheticalDepth affect next line if (precedesClosingParenthesis) { parentheticalDepth -= 1; } switch (options.indentInsideParens) { case "twice": if (!precedesClosingParenthesis || options.indentClosingBrace) { expectedIndentLevel += 1; } break; case "once-at-root-twice-in-block": if (node.parent === node.root()) { if ( precedesClosingParenthesis && !options.indentClosingBrace ) { expectedIndentLevel -= 1; } break; } if (!precedesClosingParenthesis || options.indentClosingBrace) { expectedIndentLevel += 1; } break; default: if (precedesClosingParenthesis && !options.indentClosingBrace) { expectedIndentLevel -= 1; } } } // Starting at the index after the newline, we want to // check that the whitespace characters (excluding newlines) before the first // non-whitespace character equal the expected indentation const afterNewlineSpaceMatches = /^([ \t]*)\S/.exec( source.slice(match.startIndex + 1) ); if (!afterNewlineSpaceMatches) { return; } const afterNewlineSpace = afterNewlineSpaceMatches[1]; const expectedIndentation = _.repeat(indentChar, expectedIndentLevel); if (afterNewlineSpace !== expectedIndentation) { if (context.fix) { // 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 fixPositions.unshift({ expectedIndentation, currentIndentation: afterNewlineSpace, startIndex: match.startIndex }); } else { report({ message: messages.expected( legibleExpectation(expectedIndentLevel) ), node, index: match.startIndex + afterNewlineSpace.length + 1, result, ruleName }); } } } ); if (fixPositions.length) { if (node.type === "rule") { fixPositions.forEach(function(fixPosition) { node.selector = replaceIndentation( node.selector, fixPosition.currentIndentation, fixPosition.expectedIndentation, fixPosition.startIndex ); }); } if (node.type === "decl") { const declProp = node.prop; const declBetween = node.raws.between; fixPositions.forEach(function(fixPosition) { if (fixPosition.startIndex < declProp.length + declBetween.length) { node.raws.between = replaceIndentation( declBetween, fixPosition.currentIndentation, fixPosition.expectedIndentation, fixPosition.startIndex - declProp.length ); } else { node.value = replaceIndentation( node.value, fixPosition.currentIndentation, fixPosition.expectedIndentation, fixPosition.startIndex - declProp.length - declBetween.length ); } }); } if (node.type === "atrule") { const atRuleName = node.name; const atRuleAfterName = node.raws.afterName; const atRuleParams = node.params; fixPositions.forEach(function(fixPosition) { // 1 — it's a @ length if ( fixPosition.startIndex < 1 + atRuleName.length + atRuleAfterName.length ) { node.raws.afterName = replaceIndentation( atRuleAfterName, fixPosition.currentIndentation, fixPosition.expectedIndentation, fixPosition.startIndex - atRuleName.length - 1 ); } else { node.params = replaceIndentation( atRuleParams, fixPosition.currentIndentation, fixPosition.expectedIndentation, fixPosition.startIndex - atRuleName.length - atRuleAfterName.length - 1 ); } }); } } } }; function legibleExpectation(level) { const count = isTab ? level : level * space; const quantifiedWarningWord = count === 1 ? warningWord : warningWord + "s"; return `${count} ${quantifiedWarningWord}`; } }; function getRootBaseIndentLevel(root, baseIndentLevel, space) { const document = root.document; if (!document) { return 0; } let indentLevel = root.source.baseIndentLevel; if (!Number.isSafeInteger(indentLevel)) { indentLevel = inferRootIndentLevel(root, baseIndentLevel, () => inferDocIndentSize(document, space) ); root.source.baseIndentLevel = indentLevel; } return indentLevel; } function inferDocIndentSize(document, space) { let indentSize = document.source.indentSize; if (Number.isSafeInteger(indentSize)) { return indentSize; } const source = document.source.input.css; const indents = source.match(/^ *(?=\S)/gm); if (indents) { const scores = {}; let lastIndentSize = 0; let lastLeadingSpacesLength = 0; const vote = leadingSpacesLength => { if (leadingSpacesLength) { lastIndentSize = Math.abs(leadingSpacesLength - lastLeadingSpacesLength) || lastIndentSize; if (lastIndentSize > 1) { if (scores[lastIndentSize]) { scores[lastIndentSize]++; } else { scores[lastIndentSize] = 1; } } } else { lastIndentSize = 0; } lastLeadingSpacesLength = leadingSpacesLength; }; indents.forEach(leadingSpaces => { vote(leadingSpaces.length); }); let bestScore = 0; for (const indentSizeDate in scores) { const score = scores[indentSizeDate]; if (score > bestScore) { bestScore = score; indentSize = indentSizeDate; } } } indentSize = +indentSize || (indents && indents[0].length) || +space || 2; document.source.indentSize = indentSize; return indentSize; } function inferRootIndentLevel(root, baseIndentLevel, indentSize) { function getIndentLevel(indent) { let tabCount = indent.match(/\t/g); tabCount = tabCount ? tabCount.length : 0; let spaceCount = indent.match(/ /g); spaceCount = spaceCount ? Math.round(spaceCount.length / indentSize()) : 0; return tabCount + spaceCount; } if (!Number.isSafeInteger(baseIndentLevel)) { let source = root.source.input.css; source = source.replace(/^[^\r\n]+/, firstLine => /(?:^|\n)([ \t]*)$/.test(root.raws.beforeStart) ? RegExp.$1 + firstLine : "" ); const indents = source.match(/^[ \t]*(?=\S)/gm); if (indents) { return Math.min.apply(Math, indents.map(getIndentLevel)); } else { baseIndentLevel = 1; } } const indents = []; if (/(?:^|\n)([ \t]*)\S[^\r\n]*(?:\r?\n\s*)*$/.test(root.raws.beforeStart)) { indents.push(RegExp.$1); } const after = root.raws.after; if (after) { let afterEnd; if (after.slice(-1) === "\n") { const document = root.document; afterEnd = document.nodes[root.nodes.indexOf(root) + 1]; if (afterEnd) { afterEnd = afterEnd.raws.beforeStart; } else { afterEnd = document.raws.afterEnd; } } else { afterEnd = after; } indents.push(afterEnd.match(/^[ \t]*/)[0]); } if (indents.length) { return Math.max.apply(Math, indents.map(getIndentLevel)) + baseIndentLevel; } return baseIndentLevel; } function fixIndentation(str, whitespace) { if (!_.isString(str)) { return str; } return str.replace(/\n[ \t]*(?=\S|$)/g, "\n" + whitespace); } function replaceIndentation(input, searchString, replaceString, startIndex) { const offset = startIndex + 1; const stringStart = input.slice(0, offset); const stringEnd = input.slice(offset + searchString.length); return stringStart + replaceString + stringEnd; } rule.ruleName = ruleName; rule.messages = messages; module.exports = rule;