From e0f68eb90358928ddd3343cc32d0c659dee13e66 Mon Sep 17 00:00:00 2001 From: Pixel Date: Sun, 27 Sep 2026 15:25:49 +0300 Subject: [PATCH] fix: recognize legacy pseudo-elements in `selector-complexity` --- src/rules/selector-complexity.js | 55 ++++++- tests/rules/selector-complexity.test.js | 191 ++++++++++++++++++++++++ 2 files changed, 240 insertions(+), 6 deletions(-) diff --git a/src/rules/selector-complexity.js b/src/rules/selector-complexity.js index 8b7773b7..af1c07c7 100644 --- a/src/rules/selector-complexity.js +++ b/src/rules/selector-complexity.js @@ -9,6 +9,7 @@ /** * @import { CSSRuleDefinition } from "../types.js" + * @import { CssNodePlain, PseudoClassSelectorPlain, PseudoElementSelectorPlain } from "@eslint/css-tree" * @typedef {"maxSelectors" | "disallowedSelectors"} SelectorComplexityMessageIds * @typedef {[{ * maxIds?: number, @@ -32,6 +33,50 @@ // Helpers //----------------------------------------------------------------------------- +/** + * Pseudo-elements that can also be written with the legacy single-colon syntax. + * @see https://drafts.csswg.org/selectors/#pseudo-element-syntax + */ +const legacyPseudoElements = new Set([ + "before", + "after", + "first-line", + "first-letter", +]); + +/** + * Determines whether a pseudo-class selector node is a pseudo-element written + * with the legacy single-colon syntax. + * @param {PseudoClassSelectorPlain} node The pseudo-class selector node. + * @returns {boolean} `true` if the node is a legacy pseudo-element. + */ +function isLegacyPseudoElement(node) { + return legacyPseudoElements.has(node.name.toLowerCase()); +} + +/** + * Determines whether a node is a pseudo-class selector, excluding legacy + * single-colon pseudo-elements. + * @param {CssNodePlain} node The node to check. + * @returns {node is PseudoClassSelectorPlain} `true` if the node is a pseudo-class selector. + */ +function isPseudoClassSelector(node) { + return node.type === "PseudoClassSelector" && !isLegacyPseudoElement(node); +} + +/** + * Determines whether a node is a pseudo-element selector, including legacy + * single-colon pseudo-elements. + * @param {CssNodePlain} node The node to check. + * @returns {node is PseudoClassSelectorPlain | PseudoElementSelectorPlain} `true` if the node is a pseudo-element selector. + */ +function isPseudoElementSelector(node) { + return ( + node.type === "PseudoElementSelector" || + (node.type === "PseudoClassSelector" && isLegacyPseudoElement(node)) + ); +} + /** * An error for exceeding the maximum allowed selectors of a specific type. * @param {Object} context The ESLint rule context object. @@ -295,9 +340,8 @@ export default /** @satisfies {SelectorComplexityRuleDefinition} */ ({ selectors, "AttributeSelector", ); - const pseudoClassSelectors = getSelectors( - selectors, - "PseudoClassSelector", + const pseudoClassSelectors = selectors.filter( + isPseudoClassSelector, ); const universalSelectors = selectors.filter( child => @@ -306,9 +350,8 @@ export default /** @satisfies {SelectorComplexityRuleDefinition} */ ({ const combinatorNodes = getSelectors(selectors, "Combinator"); const combinators = getSelectorNames(combinatorNodes); - const pseudoElementSelectors = getSelectors( - selectors, - "PseudoElementSelector", + const pseudoElementSelectors = selectors.filter( + isPseudoElementSelector, ); const attributeNames = attributeSelectors.map(s => s.name.name); const attributeMatchers = attributeSelectors diff --git a/tests/rules/selector-complexity.test.js b/tests/rules/selector-complexity.test.js index e0bb33a2..c88090d4 100644 --- a/tests/rules/selector-complexity.test.js +++ b/tests/rules/selector-complexity.test.js @@ -109,6 +109,30 @@ ruleTester.run("selector-complexity", rule, { code: "a:hover, a:focus {}", options: [{ maxPseudoClasses: 1 }], }, + { + code: "a:before {}", + options: [{ maxPseudoClasses: 0 }], + }, + { + code: "a:after {}", + options: [{ maxPseudoClasses: 0 }], + }, + { + code: "p:first-line {}", + options: [{ maxPseudoClasses: 0 }], + }, + { + code: "p:first-letter {}", + options: [{ maxPseudoClasses: 0 }], + }, + { + code: "a:hover:before {}", + options: [{ maxPseudoClasses: 1 }], + }, + { + code: "a:AFTER {}", + options: [{ maxPseudoClasses: 0 }], + }, { code: "* {}", options: [{ maxUniversals: 1 }], @@ -161,6 +185,22 @@ ruleTester.run("selector-complexity", rule, { code: "a:hover {}", options: [{ disallowPseudoClasses: ["active"] }], }, + { + code: "a:before {}", + options: [{ disallowPseudoClasses: ["before"] }], + }, + { + code: "a:after {}", + options: [{ disallowPseudoClasses: ["after"] }], + }, + { + code: "p:first-line {}", + options: [{ disallowPseudoClasses: ["first-line"] }], + }, + { + code: "p:first-letter {}", + options: [{ disallowPseudoClasses: ["first-letter"] }], + }, { code: ".foo::before {}", options: [{ disallowPseudoElements: ["placeholder"] }], @@ -169,6 +209,10 @@ ruleTester.run("selector-complexity", rule, { code: ".foo::before, .bar::after {}", options: [{ disallowPseudoElements: ["marker"] }], }, + { + code: "a:before {}", + options: [{ disallowPseudoElements: ["after"] }], + }, { code: "[name='foo'] {}", options: [{ disallowAttributes: ["class"] }], @@ -412,6 +456,23 @@ ruleTester.run("selector-complexity", rule, { }, ], }, + { + code: "a:hover:focus:before {}", + options: [{ maxPseudoClasses: 1 }], + errors: [ + { + messageId: "maxSelectors", + data: { + selector: "pseudo-class", + limit: 1, + }, + line: 1, + column: 1, + endLine: 1, + endColumn: 21, + }, + ], + }, { code: "* * {}", options: [{ maxUniversals: 1 }], @@ -762,6 +823,136 @@ ruleTester.run("selector-complexity", rule, { }, ], }, + { + code: "a:before {}", + options: [{ disallowPseudoElements: ["before"] }], + errors: [ + { + messageId: "disallowedSelectors", + data: { + selectorName: "before", + selector: "pseudo-element", + }, + line: 1, + column: 2, + endLine: 1, + endColumn: 9, + }, + ], + }, + { + code: "a:after {}", + options: [{ disallowPseudoElements: ["after"] }], + errors: [ + { + messageId: "disallowedSelectors", + data: { + selectorName: "after", + selector: "pseudo-element", + }, + line: 1, + column: 2, + endLine: 1, + endColumn: 8, + }, + ], + }, + { + code: "p:first-line {}", + options: [{ disallowPseudoElements: ["first-line"] }], + errors: [ + { + messageId: "disallowedSelectors", + data: { + selectorName: "first-line", + selector: "pseudo-element", + }, + line: 1, + column: 2, + endLine: 1, + endColumn: 13, + }, + ], + }, + { + code: "p:first-letter {}", + options: [{ disallowPseudoElements: ["first-letter"] }], + errors: [ + { + messageId: "disallowedSelectors", + data: { + selectorName: "first-letter", + selector: "pseudo-element", + }, + line: 1, + column: 2, + endLine: 1, + endColumn: 15, + }, + ], + }, + { + code: "a:AFTER {}", + options: [{ disallowPseudoElements: ["after"] }], + errors: [ + { + messageId: "disallowedSelectors", + data: { + selectorName: "AFTER", + selector: "pseudo-element", + }, + line: 1, + column: 2, + endLine: 1, + endColumn: 8, + }, + ], + }, + { + code: "li:first-letter {}", + options: [{ disallowPseudoElements: ["FIRST-LETTER"] }], + errors: [ + { + messageId: "disallowedSelectors", + data: { + selectorName: "first-letter", + selector: "pseudo-element", + }, + line: 1, + column: 3, + endLine: 1, + endColumn: 16, + }, + ], + }, + { + code: "p:first-line, p::first-line {}", + options: [{ disallowPseudoElements: ["first-line"] }], + errors: [ + { + messageId: "disallowedSelectors", + data: { + selectorName: "first-line", + selector: "pseudo-element", + }, + line: 1, + column: 2, + endLine: 1, + endColumn: 13, + }, + { + messageId: "disallowedSelectors", + data: { + selectorName: "first-line", + selector: "pseudo-element", + }, + line: 1, + column: 16, + endLine: 1, + endColumn: 28, + }, + ], + }, { code: "[class*='foo'] {}", options: [{ disallowAttributes: ["class"] }],