blob: 945b61dbbb2971cc016e0a251499fc3f0fdd56e6 [file] [log] [blame]
"use strict";
const _ = require("lodash");
const isStandardSyntaxRule = require("../../utils/isStandardSyntaxRule");
const isStandardSyntaxSelector = require("../../utils/isStandardSyntaxSelector");
const keywordSets = require("../../reference/keywordSets");
const optionsMatches = require("../../utils/optionsMatches");
const parseSelector = require("../../utils/parseSelector");
const report = require("../../utils/report");
const resolvedNestedSelector = require("postcss-resolve-nested-selector");
const ruleMessages = require("../../utils/ruleMessages");
const specificity = require("specificity");
const validateOptions = require("../../utils/validateOptions");
const ruleName = "selector-max-specificity";
const messages = ruleMessages(ruleName, {
expected: (selector, specificity) =>
`Expected "${selector}" to have a specificity no more than "${specificity}"`
});
// Return an array representation of zero specificity. We need a new array each time so that it can mutated
const zeroSpecificity = () => [0, 0, 0, 0];
// Calculate the sum of given array of specificity arrays
const specificitySum = specificities => {
const sum = zeroSpecificity();
specificities.forEach(specificityArray => {
specificityArray.forEach((value, i) => {
sum[i] += value;
});
});
return sum;
};
const rule = function(max, options) {
return (root, result) => {
const validOptions = validateOptions(
result,
ruleName,
{
actual: max,
possible: [
function(max) {
// Check that the max specificity is in the form "a,b,c"
const pattern = new RegExp("^\\d+,\\d+,\\d+$");
return pattern.test(max);
}
]
},
{
actual: options,
possible: {
ignoreSelectors: [_.isString, _.isRegExp]
},
optional: true
}
);
if (!validOptions) {
return;
}
// Calculate the specificity of a simple selector (type, attribute, class, ID, or pseudos's own value)
const simpleSpecificity = selector => {
if (optionsMatches(options, "ignoreSelectors", selector)) {
return zeroSpecificity();
}
return specificity.calculate(selector)[0].specificityArray;
};
// Calculate the the specificity of the most specific direct child
const maxChildSpecificity = node =>
node.reduce((max, child) => {
const childSpecificity = nodeSpecificity(child); // eslint-disable-line no-use-before-define
return specificity.compare(childSpecificity, max) === 1
? childSpecificity
: max;
}, zeroSpecificity());
// Calculate the specificity of a pseudo selector including own value and children
const pseudoSpecificity = node => {
// `node.toString()` includes children which should be processed separately,
// so use `node.value` instead
const ownValue = node.value;
const ownSpecificity =
ownValue === ":not" || ownValue === ":matches"
? // :not and :matches don't add specificity themselves, but their children do
zeroSpecificity()
: simpleSpecificity(ownValue);
return specificitySum([ownSpecificity, maxChildSpecificity(node)]);
};
const shouldSkipPseudoClassArgument = node => {
// postcss-selector-parser includes the arguments to nth-child() functions
// as "tags", so we need to ignore them ourselves.
// The fake-tag's "parent" is actually a selector node, whose parent
// should be the :nth-child pseudo node.
const parentNode = node.parent.parent;
if (parentNode && parentNode.value) {
const parentNodeValue = parentNode.value;
const normalisedParentNode = parentNodeValue
.toLowerCase()
.replace(/:+/, "");
return (
parentNode.type === "pseudo" &&
(keywordSets.aNPlusBNotationPseudoClasses.has(normalisedParentNode) ||
keywordSets.linguisticPseudoClasses.has(normalisedParentNode))
);
}
return false;
};
// Calculate the specificity of a node parsed by `postcss-selector-parser`
const nodeSpecificity = node => {
if (shouldSkipPseudoClassArgument(node)) {
return zeroSpecificity();
}
switch (node.type) {
case "attribute":
case "class":
case "id":
case "tag":
return simpleSpecificity(node.toString());
case "pseudo":
return pseudoSpecificity(node);
case "selector":
// Calculate the sum of all the direct children
return specificitySum(node.map(nodeSpecificity));
default:
return zeroSpecificity();
}
};
const maxSpecificityArray = ("0," + max).split(",").map(parseFloat);
root.walkRules(rule => {
if (!isStandardSyntaxRule(rule)) {
return;
}
if (!isStandardSyntaxSelector(rule.selector)) {
return;
}
// Using rule.selectors gets us each selector in the eventuality we have a comma separated set
rule.selectors.forEach(selector => {
resolvedNestedSelector(selector, rule).forEach(resolvedSelector => {
try {
// Skip non-standard syntax selectors
if (!isStandardSyntaxSelector(resolvedSelector)) {
return;
}
parseSelector(resolvedSelector, result, rule, selectorTree => {
// Check if the selector specificity exceeds the allowed maximum
if (
specificity.compare(
maxChildSpecificity(selectorTree),
maxSpecificityArray
) === 1
) {
report({
ruleName,
result,
node: rule,
message: messages.expected(resolvedSelector, max),
word: selector
});
}
});
} catch (e) {
result.warn("Cannot parse selector", {
node: rule,
stylelintType: "parseError"
});
}
});
});
});
};
};
rule.ruleName = ruleName;
rule.messages = messages;
module.exports = rule;