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