blob: d3838eeb941560a305fc1bcd58bce9d0ccd1473f [file]
import { parse, walk } from 'css-tree';
import {
aNPlusBOfSNotationPseudoClasses,
dirIdentifiers,
elementBackedPseudoElements,
subPseudoElements,
} from '../../reference/selectors.mjs';
import { assert } from '../../utils/validateTypes.mjs';
import getRuleSelector from '../../utils/getRuleSelector.mjs';
import isKeyframeRule from '../../utils/isKeyframeRule.mjs';
import isStandardSyntaxRule from '../../utils/isStandardSyntaxRule.mjs';
import { mayIncludeRegexes } from '../../utils/regexes.mjs';
import report from '../../utils/report.mjs';
import ruleMessages from '../../utils/ruleMessages.mjs';
import uniteSets from '../../utils/uniteSets.mjs';
import validateOptions from '../../utils/validateOptions.mjs';
const ruleName = 'selector-no-invalid';
const messages = ruleMessages(ruleName, {
rejected: (selector, reason) => `Invalid selector "${selector}"${reason ? `, ${reason}` : ''}`,
});
const meta = {
url: 'https://stylelint.io/user-guide/rules/selector-no-invalid',
};
/** @import { CssNode, PseudoClassSelector, PseudoElementSelector, Selector } from 'css-tree' */
/**
* @typedef {(reason: string) => void} Complain
*/
/**
* Pseudo-classes and pseudo-elements whose selector arguments are unforgiving,
* excluding the deprecated `:host-context()`;
* see https://drafts.csswg.org/selectors/#typedef-forgiving-selector-list
*/
const unforgivingArgumentPseudoSelectors = uniteSets(aNPlusBOfSNotationPseudoClasses, [
'has',
'host',
'not',
'slotted',
]);
/**
* Pseudo-selectors whose argument must be a compound selector;
* see https://drafts.csswg.org/css-shadow-1/
*/
const compoundArgumentPseudoSelectors = new Set(['host', 'slotted']);
const expectedDirIdentifiers = [...dirIdentifiers].map((id) => `"${id}"`).join(' or ');
/** @type {import('stylelint').CoreRules[typeof ruleName]} */
const rule = (primary) => {
return (root, result) => {
const validOptions = validateOptions(result, ruleName, { actual: primary });
if (!validOptions) return;
root.walkRules((ruleNode) => {
if (!isStandardSyntaxRule(ruleNode)) return;
if (isKeyframeRule(ruleNode)) return;
const selector = getRuleSelector(ruleNode);
/**
* @param {number} index
* @param {number} endIndex
* @param {string} subject
* @param {string} reason
*/
const complain = (index, endIndex, subject, reason) => {
report({
message: messages.rejected,
messageArgs: [subject, reason],
node: ruleNode,
index,
endIndex,
ruleName,
result,
});
};
let ast;
try {
ast = parse(selector, { context: 'selectorList', positions: true });
} catch (error) {
if (!(error instanceof SyntaxError)) throw error;
const offset = 'offset' in error && typeof error.offset === 'number' ? error.offset : 0;
const message = error.message;
complain(
offset,
Math.min(offset + 1, selector.length),
selector,
message.charAt(0).toLowerCase() + message.slice(1),
);
return;
}
if (!mayIncludeRegexes.pseudo.test(selector)) return;
assert(ast.type === 'SelectorList');
for (const selectorNode of ast.children) {
assert(selectorNode.type === 'Selector');
assert(selectorNode.loc);
const index = selectorNode.loc.start.offset;
const endIndex = selectorNode.loc.end.offset;
const selectorSource = selector.slice(index, endIndex);
/** @type {Complain} */
const complainAboutSelector = (reason) => {
complain(index, endIndex, selectorSource, reason);
};
checkUnforgivingArguments(selectorNode, selectorSource, complainAboutSelector);
checkTopLevelSelector(selectorNode, selectorSource, complainAboutSelector);
checkDirPseudoClasses(selectorNode, selectorSource, complainAboutSelector);
}
});
};
};
/**
* @param {Selector} selectorNode
* @param {string} selectorSource
* @param {Complain} complain
* @returns {void}
*/
function checkUnforgivingArguments(selectorNode, selectorSource, complain) {
if (!mayHaveUnforgivingArguments(selectorSource)) return;
walk(selectorNode, {
/** @param {CssNode} node */
enter(node) {
if (node.type !== 'PseudoClassSelector' && node.type !== 'PseudoElementSelector') return;
const name = node.name.toLowerCase();
if (!unforgivingArgumentPseudoSelectors.has(name)) return;
const reason = checkContainerArguments(node, name);
if (reason) complain(reason);
},
});
}
/**
* @param {string} selectorSource
* @returns {boolean}
*/
function mayHaveUnforgivingArguments(selectorSource) {
return (
mayIncludeRegexes.hasPseudoClass.test(selectorSource) ||
mayIncludeRegexes.hostPseudoClass.test(selectorSource) ||
mayIncludeRegexes.slottedPseudoElement.test(selectorSource) ||
(mayIncludeRegexes.pseudoElement.test(selectorSource) &&
(mayIncludeRegexes.notPseudoClass.test(selectorSource) ||
mayIncludeRegexes.aNPlusBOfSNotationPseudoClass.test(selectorSource)))
);
}
/**
* @param {PseudoClassSelector | PseudoElementSelector} container
* @param {string} name The container's lowercased name
* @returns {string | undefined} The reason the arguments are invalid
*/
function checkContainerArguments(container, name) {
if (hasArgumentPseudoElement(container)) {
return `pseudo-elements are invalid within "${formatPseudoName(container)}"`;
}
if (name === 'has' && hasNestedHas(container)) {
return '":has()" is invalid within ":has()"';
}
if (compoundArgumentPseudoSelectors.has(name) && hasArgumentCombinator(container)) {
return `combinators are invalid within "${formatPseudoName(container)}"`;
}
return undefined;
}
/**
* @param {PseudoClassSelector | PseudoElementSelector} container
* @returns {boolean}
*/
function hasArgumentPseudoElement(container) {
let found = false;
walk(container, {
/** @param {CssNode} node */
enter(node) {
if (node === container) return;
if (node.type === 'PseudoElementSelector') {
found = true;
return walk.break;
}
if (node.type === 'PseudoClassSelector' && node.children !== null) return walk.skip;
},
});
return found;
}
/**
* Whether a `:has()` contains another `:has()`
* see https://drafts.csswg.org/selectors/#relational
*
* @param {PseudoClassSelector | PseudoElementSelector} hasNode
* @returns {boolean}
*/
function hasNestedHas(hasNode) {
let found = false;
walk(hasNode, {
/** @param {CssNode} node */
enter(node) {
if (node === hasNode) return;
if (node.type !== 'PseudoClassSelector') return;
if (node.name.toLowerCase() === 'has') {
found = true;
return walk.break;
}
},
});
return found;
}
/**
* @param {PseudoClassSelector | PseudoElementSelector} container
* @returns {boolean}
*/
function hasArgumentCombinator(container) {
const argument = container.children?.first;
if (!argument || argument.type !== 'Selector') return false;
return argument.children.some((node) => node.type === 'Combinator');
}
/**
* Combinators and further pseudo-elements after a pseudo-element are invalid
* see https://drafts.csswg.org/selectors/#pseudo-element-structure and
* https://drafts.csswg.org/selectors/#sub-pseudo-elements
*
* @param {Selector} selectorNode
* @param {string} selectorSource
* @param {Complain} complain
* @returns {void}
*/
function checkTopLevelSelector(selectorNode, selectorSource, complain) {
if (!mayIncludeRegexes.pseudoElement.test(selectorSource)) return;
if (selectorNode.children.some((node) => node.type === 'NestingSelector')) return;
/** @type {PseudoElementSelector | undefined} */
let lastPseudoElement;
/** @type {CssNode | undefined} */
let previousNode;
for (const node of selectorNode.children) {
if (node.type === 'PseudoElementSelector') {
if (
previousNode?.type === 'PseudoElementSelector' &&
isInvalidSubPseudoElement(previousNode, node)
) {
complain(
`"${formatPseudoName(node)}" is invalid after "${formatPseudoName(previousNode)}"`,
);
return;
}
lastPseudoElement = node;
} else if (node.type === 'Combinator' && lastPseudoElement) {
complain(`combinators are invalid after "${formatPseudoName(lastPseudoElement)}"`);
return;
}
previousNode = node;
}
}
/**
* Whether `second`, compounded to `first`, is not a defined sub-pseudo-element
* see https://drafts.csswg.org/selectors/#sub-pseudo-elements
*
* @param {PseudoElementSelector} first
* @param {PseudoElementSelector} second
* @returns {boolean}
*/
function isInvalidSubPseudoElement(first, second) {
if (elementBackedPseudoElements.has(first.name.toLowerCase())) return false;
if (subPseudoElements.has(second.name.toLowerCase())) return false;
return true;
}
/**
* @param {Selector} selectorNode
* @param {string} selectorSource
* @param {Complain} complain
* @returns {void}
*/
function checkDirPseudoClasses(selectorNode, selectorSource, complain) {
if (!mayIncludeRegexes.dirPseudoClass.test(selectorSource)) return;
walk(selectorNode, {
/** @param {CssNode} node */
enter(node) {
if (node.type !== 'PseudoClassSelector') return;
if (node.name.toLowerCase() !== 'dir') return;
if (isValidDirPseudoClass(node)) return;
complain(`expected ${expectedDirIdentifiers} within "${formatPseudoName(node)}"`);
},
});
}
/**
* @param {PseudoClassSelector} node
* @returns {boolean}
*/
function isValidDirPseudoClass(node) {
const { children } = node;
if (children?.size !== 1) return false;
const first = children.first;
if (first?.type !== 'Identifier') return false;
return dirIdentifiers.has(first.name.toLowerCase());
}
/**
* @param {PseudoClassSelector | PseudoElementSelector} node
* @returns {string}
*/
function formatPseudoName(node) {
const colons = node.type === 'PseudoElementSelector' ? '::' : ':';
return `${colons}${node.name.toLowerCase()}${node.children === null ? '' : '()'}`;
}
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = meta;
export default rule;