| "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; |