diff --git a/docs/user-guide/rules/list.md b/docs/user-guide/rules/list.md index be7dc668d7..26971f3cd7 100644 --- a/docs/user-guide/rules/list.md +++ b/docs/user-guide/rules/list.md @@ -198,6 +198,7 @@ Within each cateogory, the rules are grouped by the [_thing_](http://apps.workfl - [`selector-nested-pattern`](../../../lib/rules/selector-nested-pattern/README.md): Specify a pattern for the selectors of rules nested within rules. - [`selector-no-qualifying-type`](../../../lib/rules/selector-no-qualifying-type/README.md): Disallow qualifying a selector by type. - [`selector-no-vendor-prefix`](../../../lib/rules/selector-no-vendor-prefix/README.md): Disallow vendor prefixes for selectors (Autofixable). +- [`selector-not-notation`](../../../lib/rules/selector-not-notation/README.md): Specify simple or complex notation for `:not()` pseudo-classes (Autofixable). - [`selector-pseudo-class-allowed-list`](../../../lib/rules/selector-pseudo-class-allowed-list/README.md): Specify a list of allowed pseudo-class selectors. - [`selector-pseudo-class-disallowed-list`](../../../lib/rules/selector-pseudo-class-disallowed-list/README.md): Specify a list of disallowed pseudo-class selectors. - [`selector-pseudo-element-allowed-list`](../../../lib/rules/selector-pseudo-element-allowed-list/README.md): Specify a list of allowed pseudo-element selectors. diff --git a/lib/rules/index.js b/lib/rules/index.js index a300e747b4..238b24a36c 100644 --- a/lib/rules/index.js +++ b/lib/rules/index.js @@ -214,6 +214,7 @@ const rules = { 'selector-nested-pattern': importLazy('./selector-nested-pattern'), 'selector-no-qualifying-type': importLazy('./selector-no-qualifying-type'), 'selector-no-vendor-prefix': importLazy('./selector-no-vendor-prefix'), + 'selector-not-notation': importLazy('./selector-not-notation'), 'selector-pseudo-class-allowed-list': importLazy('./selector-pseudo-class-allowed-list'), 'selector-pseudo-class-case': importLazy('./selector-pseudo-class-case'), 'selector-pseudo-class-disallowed-list': importLazy('./selector-pseudo-class-disallowed-list'), diff --git a/lib/rules/selector-not-notation/README.md b/lib/rules/selector-not-notation/README.md new file mode 100644 index 0000000000..02d2a5ce30 --- /dev/null +++ b/lib/rules/selector-not-notation/README.md @@ -0,0 +1,75 @@ +# selector-not-notation + +Specify simple or complex notation for `:not()` pseudo-classes. + + +```css + a:not(.foo, .bar) {} +/** ↑ + * This notation */ +``` + +In Selectors Level 3, only a single _simple selector_ was allowed as the argument to `:not()`, whereas Selectors Level 4 allows a _selector list_. + +Use: + +- `"complex"` to author modern Selectors Level 4 CSS +- `"simple"` for backwards compatibility with older browsers + +The [`fix` option](../../../docs/user-guide/usage/options.md#fix) option can automatically fix most of the problems reported by this rule. + +## Options + +`string`: `"simple"|"complex"` + +### `"simple"` + +The following patterns are considered problems: + + +```css +:not(a, div) {} +``` + + +```css +:not(a.foo) {} +``` + +The following patterns are _not_ considered problems: + + +```css +:not(a):not(div) {} +``` + + +```css +:not(a) {} +``` + +### `"complex"` + +The following pattern is considered a problem: + + +```css +:not(a):not(div) {} +``` + +The following patterns are _not_ considered problems: + + +```css +:not(a, div) {} +``` + + +```css +:not(a.foo) {} +``` + + +```css +:not(a).foo:not(:empty) {} +``` diff --git a/lib/rules/selector-not-notation/__tests__/index.js b/lib/rules/selector-not-notation/__tests__/index.js new file mode 100644 index 0000000000..b2a2e97757 --- /dev/null +++ b/lib/rules/selector-not-notation/__tests__/index.js @@ -0,0 +1,117 @@ +'use strict'; + +const { messages, ruleName } = require('..'); + +testRule({ + ruleName, + config: ['simple'], + fix: true, + + accept: [ + { + code: ':not() {}', + }, + { + code: ':not( a ) {}', + }, + { + code: ':nOt(a) {}', + }, + { + code: ':not(*) {}', + }, + { + code: ':not(:link) {}', + }, + { + code: ':not(.foo) {}', + }, + { + code: ':not([title]) {}', + }, + ], + + reject: [ + { + code: ':not(:not()) {}', + unfixable: true, + message: messages.expected('simple'), + line: 1, + column: 1, + }, + { + code: ':not(::before) {}', + unfixable: true, + message: messages.expected('simple'), + line: 1, + column: 1, + }, + { + code: ':not(:first-line) {}', + unfixable: true, + message: messages.expected('simple'), + line: 1, + column: 1, + }, + { + code: ':not(a, div) {}', + fixed: ':not(a):not(div) {}', + message: messages.expected('simple'), + line: 1, + column: 1, + }, + { + code: ':not(a ,) {}', + fixed: ':not(a) {}', + message: messages.expected('simple'), + line: 1, + column: 1, + }, + { + code: ':not(a.foo) {}', + unfixable: true, + message: messages.expected('simple'), + line: 1, + column: 1, + }, + ], +}); + +testRule({ + ruleName, + config: ['complex'], + fix: true, + + accept: [ + { + code: ':not()::after {}', + }, + { + code: ':not(a, div) {}', + }, + { + code: ':not(a.foo) {}', + }, + { + code: ':not(a).foo:not(:empty) {}', + }, + ], + + reject: [ + { + code: ':not( .foo ,:hover ):not(a,div) {}', + fixed: ':not(.foo, :hover, a, div) {}', + message: messages.expected('complex'), + line: 1, + column: 21, + }, + { + code: ':not():not(a) {}', + fixed: ':not(a) {}', + message: messages.expected('complex'), + line: 1, + column: 7, + skip: true, + }, + ], +}); diff --git a/lib/rules/selector-not-notation/index.js b/lib/rules/selector-not-notation/index.js new file mode 100644 index 0000000000..98d22dc366 --- /dev/null +++ b/lib/rules/selector-not-notation/index.js @@ -0,0 +1,193 @@ +'use strict'; + +const isStandardSyntaxRule = require('../../utils/isStandardSyntaxRule'); +const isStandardSyntaxSelector = require('../../utils/isStandardSyntaxSelector'); +const parseSelector = require('../../utils/parseSelector'); +const report = require('../../utils/report'); +const ruleMessages = require('../../utils/ruleMessages'); +const validateOptions = require('../../utils/validateOptions'); +const { + isPseudoClass: buggyIsPseudoClass, // see https://github.com/postcss/postcss-selector-parser/pull/256 + isAttribute, + isClassName, + isUniversal, + isIdentifier, + isTag, +} = require('postcss-selector-parser'); +const { assert } = require('../../utils/validateTypes'); + +const ruleName = 'selector-not-notation'; +const messages = ruleMessages(ruleName, { + expected: (type) => `Expected ${type} :not() pseudo-class notation`, +}); +const meta = { url: 'https://stylelint.io/user-guide/rules/list/selector-not-notation' }; + +/** @typedef {import('postcss-selector-parser').Node} Node */ +/** @typedef {import('postcss-selector-parser').Selector} Selector */ +/** @typedef {import('postcss-selector-parser').Pseudo} Pseudo */ + +/** + * @param {Node} node + * @returns {boolean} + */ +const isPseudoClass = (node) => + buggyIsPseudoClass(node) && + node.value.toLowerCase() !== ':first-line' && + node.value.toLowerCase() !== ':first-letter'; + +/** + * @param {Node} node + * @returns {boolean} + */ +const isSimpleSelector = (node) => + isPseudoClass(node) || + isAttribute(node) || + isClassName(node) || + isUniversal(node) || + isIdentifier(node) || + isTag(node); + +/** + * @param {Node} node + * @returns {boolean} + */ +const isNot = (node) => + isPseudoClass(node) && node.value !== undefined && node.value.toLowerCase() === ':not'; + +/** + * @param {Selector[]} list + * @returns {boolean} + */ +const isSimple = (list) => { + if (list.length > 1) return false; + + const [first, second] = list[0].nodes; // list is never empty + + if (!first) return true; + + if (second) return false; + + return isSimpleSelector(first) && !isNot(first); +}; + +/** @type {import('stylelint').Rule} */ +const rule = (primary, _, context) => { + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { + actual: primary, + possible: ['simple', 'complex'], + }); + + if (!validOptions) return; + + root.walkRules((ruleNode) => { + if (!isStandardSyntaxRule(ruleNode)) return; + + const selector = ruleNode.selector; + + if (!selector.includes(':not(')) return; + + if (!isStandardSyntaxSelector(selector)) return; + + const fixedSelector = parseSelector(selector, result, ruleNode, (container) => { + container.walkPseudos((pseudo) => { + if (!isNot(pseudo)) return; + + if (primary === 'complex') { + const prev = pseudo.prev(); + const hasConsecutiveNot = prev && isNot(prev); + + if (!hasConsecutiveNot) return; + + if (context.fix) return fixComplex(/** @type {Pseudo} */ (prev)); + } else { + const selectors = pseudo.nodes; + + if (isSimple(selectors)) return; + + const mustFix = + context.fix && + selectors.length > 1 && + (!selectors[1].nodes.length || selectors.every(({ nodes }) => nodes.length === 1)); + + if (mustFix) return fixSimple(pseudo); + } + + report({ + message: messages.expected(primary), + node: ruleNode, + index: pseudo.sourceIndex, + result, + ruleName, + }); + }); + }); + + if (context.fix && fixedSelector) { + ruleNode.selector = fixedSelector; + } + }); + }; +}; + +/** + * @param {Pseudo} not + */ +function fixSimple(not) { + const simpleSelectors = not.nodes + .filter(({ nodes }) => isSimpleSelector(nodes[0])) + .map((s) => { + s.nodes[0].rawSpaceBefore = ''; + s.nodes[0].rawSpaceAfter = ''; + + return s; + }); + const firstSelector = simpleSelectors.shift(); + + assert(firstSelector); + assert(not.parent); + + not.empty(); + not.nodes.push(firstSelector); + + for (const s of simpleSelectors) { + const last = not.parent.last; + + not.parent.insertAfter(last, last.clone({ nodes: [s] })); + } +} + +/** + * @param {Pseudo} previousNot + */ +function fixComplex(previousNot) { + const indentAndTrimRight = (/** @type {Selector[]} */ selectors) => { + for (const s of selectors) { + s.nodes[0].rawSpaceBefore = ' '; + s.nodes[0].rawSpaceAfter = ''; + } + }; + const [head, ...tail] = previousNot.nodes; + let node = previousNot.next(); + + if (!head.nodes.length) return; + + head.nodes[0].rawSpaceBefore = ''; + head.nodes[0].rawSpaceAfter = ''; + indentAndTrimRight(tail); + + while (isNot(node)) { + const selectors = /** @type {Pseudo} */ (node).nodes; + const prev = node; + + indentAndTrimRight(selectors); + previousNot.nodes = previousNot.nodes.concat(selectors); + node = node.next(); + prev.remove(); + } +} + +rule.ruleName = ruleName; +rule.messages = messages; +rule.meta = meta; +module.exports = rule;