stylelint
Version:
A mighty, modern CSS linter.
518 lines (444 loc) • 15.9 kB
JavaScript
;
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: {
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);
const expectedWhitespace = _.repeat(indentChar, nodeLevel);
let before = node.raws.before || "";
const after = node.raws.after || "";
// 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 inspectBefore =
node.root().first === node || before.indexOf("\n") !== -1;
// Cut out any * and _ hacks from `before`
if (
before[before.length - 1] === "*" ||
before[before.length - 1] === "_"
) {
before = before.slice(0, before.length - 1);
}
// 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 (
inspectBefore &&
before.slice(before.lastIndexOf("\n") + 1) !== expectedWhitespace
) {
if (context.fix) {
node.raws.before = fixIndentation(
node.raws.before,
expectedWhitespace
);
} 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);
}
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 rules should be treated like regular rules, not expected
// to have their params (selectors) indented
const paramLevel =
optionsMatches(options, "except", "param") || atRule.name === "nest"
? 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 getRootBaseIndentLevel(node) {
if (node === root) {
return 0;
}
if (isNaN(node.source.baseIndentLevel)) {
node.source.baseIndentLevel = getBaseIndentLevel(node, root, space);
}
return node.source.baseIndentLevel;
}
};
function legibleExpectation(level) {
const count = isTab ? level : level * space;
const quantifiedWarningWord = count === 1 ? warningWord : warningWord + "s";
return `${count} ${quantifiedWarningWord}`;
}
};
function getBaseIndentLength(indents) {
return Math.min.apply(Math, indents.map(indent => indent.length));
}
function getSoftIndentLevel(softIndentCount, space) {
return Math.round(softIndentCount / (parseInt(space) || 2));
}
function getBaseIndentLevel(node, root, space) {
const code = node.source.input.css;
if (/^\S+/m.test(code)) {
return 0;
}
const softIndents = code.match(/^ +(?=\S+)/gm);
const hardIndents = code.match(/^\t+(?=\S+)/gm);
let softIndentCount = softIndents ? softIndents.length : 0;
let hardIndentCount = hardIndents ? hardIndents.length : 0;
if (softIndentCount > hardIndentCount) {
return getSoftIndentLevel(getBaseIndentLength(softIndents), space);
} else if (hardIndentCount) {
return getBaseIndentLength(hardIndents);
}
let afterEnd = root.nodes[root.nodes.indexOf(node) + 1];
if (afterEnd) {
afterEnd = afterEnd.raws.beforeStart;
} else {
afterEnd = root.raws.afterEnd;
}
hardIndentCount = 0;
softIndentCount = afterEnd.match(/^\s*/)[0].replace(/\t/g, () => {
hardIndentCount++;
return "";
}).length;
return hardIndentCount + getSoftIndentLevel(softIndentCount, space);
}
function fixIndentation(str, whitespace) {
if (!_.isString(str)) {
return str;
}
const strLength = str.length;
if (!strLength) {
return str;
}
let stringEnd = str[strLength - 1];
if (stringEnd !== "*" && stringEnd !== "_") {
stringEnd = "";
}
const stringStart = str.slice(0, str.lastIndexOf("\n") + 1);
return stringStart + whitespace + stringEnd;
}
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;