Skip to content

Commit

Permalink
Add ignoreSelectors: [] to selector-max-compound-selectors (#7544)
Browse files Browse the repository at this point in the history
Co-authored-by: Masafumi Koba <473530+ybiquitous@users.noreply.github.com>
  • Loading branch information
FloEdelmann and ybiquitous committed Mar 13, 2024
1 parent 1085e38 commit 5078666
Show file tree
Hide file tree
Showing 7 changed files with 276 additions and 18 deletions.
5 changes: 5 additions & 0 deletions .changeset/big-trains-learn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"stylelint": minor
---

Added: `ignoreSelectors: []` to `selector-max-compound-selectors`
64 changes: 63 additions & 1 deletion lib/rules/selector-max-compound-selectors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ A [compound selector](https://www.w3.org/TR/selectors4/#compound) is a chain of

This rule resolves nested selectors before counting the depth of a selector. Each selector in a [selector list](https://www.w3.org/TR/selectors4/#selector-list) is evaluated separately.

`:not()` is considered one compound selector irrespective to the complexity of the selector inside it. The rule _does_ process that inner selector, but does so separately, independent of the main selector.
<!-- prettier-ignore -->
> [!WARNING]
> `:not()` is considered one compound selector irrespective to the complexity of the selector inside it. The rule _does_ process that inner selector, but does so separately, independent of the main selector.
The [`message` secondary option](../../../docs/user-guide/configure.md#message) can accept the arguments of this rule.

Expand Down Expand Up @@ -59,3 +61,63 @@ div {}
```css
.foo + div :not (a b ~ c) {} /* `a b ~ c` is inside `:not()`, so it is evaluated separately */
```

## Optional secondary options

### `ignoreSelectors: ["/regex/", /regex/, "non-regex"]`

Ignore some compound selectors. This may be useful for deep selectors like Vue's `::v-deep` or Angular's `::ng-deep` that behave more like combinators than compound selectors.

For example, with `2`.

Given:

```json
["::v-deep", "/ignored/", ":not"]
```

The following patterns are considered problems:

<!-- prettier-ignore -->
```css
.foo .bar ::v-deep .baz {}
```

<!-- prettier-ignore -->
```css
p a :not(.foo .bar .baz) {}
```

The following patterns are _not_ considered problems:

<!-- prettier-ignore -->
```css
.foo::v-deep .bar {}
```

<!-- prettier-ignore -->
```css
.foo ::v-deep .baz {}
```

<!-- prettier-ignore -->
```css
p a :not(.foo .bar) {}
```

<!-- prettier-ignore -->
```css
.foo {
&.some-ignored-class ::v-deep > .bar {}
}
```

<!-- prettier-ignore -->
```css
.foo .bar > .ignored.ignored {}
```

<!-- prettier-ignore -->
```css
.foo .bar > .ignored .ignored {}
```
125 changes: 125 additions & 0 deletions lib/rules/selector-max-compound-selectors/__tests__/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -384,3 +384,128 @@ testRule({
},
],
});

// Testing ignoreSelectors
testRule({
ruleName,
config: [2, { ignoreSelectors: ['::v-deep', '/-moz-.*/', /-screen$/, '.ignored'] }],

accept: [
{
code: '.ignored .foo .bar {}',
description: 'ignored selector at the beginning',
},
{
code: '.foo ::v-deep .bar {}',
description: 'ignored selector in the middle',
},
{
code: '.foo ::v-deep > .bar {}',
description: 'ignored selector in the middle followed by a combinator',
},
{
code: '.foo::v-deep .bar {}',
description: 'ignored selector as pseudo-element',
},
{
code: '.foo::v-deep > .bar {}',
description: 'ignored selector as pseudo-element followed by a combinator',
},
{
code: '.foo > ::v-deep .bar {}',
description: 'ignored selector preceded by a combinator',
},
{
code: '.foo input::-moz-placeholder {}',
description: 'selector ignored by string regex',
},
{
code: '.foo :-webkit-full-screen a {}',
description: 'selector ignored by regex',
},
{
code: '.foo::v-deep :-webkit-full-screen a ::-moz-placeholder {}',
description: 'multiple ignored selectors',
},
{
code: '.foo { & ::v-deep > .bar {} }',
description: 'nested ignored selector',
},
{
code: '.foo .bar > .ignored.ignored {}',
description: 'concatenated class selectors ignored one by one',
},
{
code: '.foo .bar > .ignored .ignored {}',
description: 'doubled class selectors ignored',
},
],

reject: [
{
code: '.foo .bar ::v-deep .baz {}',
description: 'ignored selector in the middle',
message: messages.expected('.foo .bar ::v-deep .baz', 2),
line: 1,
column: 1,
endLine: 1,
endColumn: 24,
},
{
code: '.foo .bar::v-deep .baz {}',
description: 'ignored selector as pseudo-element',
message: messages.expected('.foo .bar::v-deep .baz', 2),
line: 1,
column: 1,
endLine: 1,
endColumn: 23,
},
{
code: '.foo { & ::v-deep > .bar .baz {} }',
description: 'nested ignored selector',
message: messages.expected('.foo ::v-deep > .bar .baz', 2),
line: 1,
column: 8,
endLine: 1,
endColumn: 33,
},
],
});

testRule({
ruleName,
config: [2, { ignoreSelectors: [':not'] }],

accept: [
{
code: '.foo .bar:not(.baz) {}',
},
{
code: 'p a :not(.foo .bar) {}',
},
{
code: 'p a :not(.foo .bar, .foo .baz) {}',
},
],

reject: [
{
code: 'p a :not(.foo .bar .baz) {}',
description: 'still evaluates compound selectors inside :not',
message: messages.expected('.foo .bar .baz', 2),
line: 1,
column: 10,
endLine: 1,
endColumn: 24,
},
{
code: 'p a :not(.foo .bar, .foo .bar .baz) {}',
description: 'still evaluates compound selectors inside :not with comma',
message: messages.expected(' .foo .bar .baz', 2),
line: 1,
column: 20,
endLine: 1,
endColumn: 35,
},
],
});
45 changes: 39 additions & 6 deletions lib/rules/selector-max-compound-selectors/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
'use strict';

const resolvedNestedSelector = require('postcss-resolve-nested-selector');
const validateTypes = require('../../utils/validateTypes.cjs');
const isContextFunctionalPseudoClass = require('../../utils/isContextFunctionalPseudoClass.cjs');
const isNonNegativeInteger = require('../../utils/isNonNegativeInteger.cjs');
const isStandardSyntaxRule = require('../../utils/isStandardSyntaxRule.cjs');
const optionsMatches = require('../../utils/optionsMatches.cjs');
const parseSelector = require('../../utils/parseSelector.cjs');
const report = require('../../utils/report.cjs');
const ruleMessages = require('../../utils/ruleMessages.cjs');
Expand All @@ -25,17 +27,39 @@ const meta = {
};

/** @type {import('stylelint').Rule} */
const rule = (primary) => {
const rule = (primary, secondaryOptions) => {
return (root, result) => {
const validOptions = validateOptions(result, ruleName, {
actual: primary,
possible: isNonNegativeInteger,
});
const validOptions = validateOptions(
result,
ruleName,
{
actual: primary,
possible: isNonNegativeInteger,
},
{
actual: secondaryOptions,
possible: {
ignoreSelectors: [validateTypes.isString, validateTypes.isRegExp],
},
optional: true,
},
);

if (!validOptions) {
return;
}

/**
* @param {import('postcss-selector-parser').Node} selectorNode
* @returns {boolean}
*/
function isSelectorIgnored(selectorNode) {
const selector =
selectorNode.type === 'pseudo' ? selectorNode.value : selectorNode.toString();

return optionsMatches(secondaryOptions, 'ignoreSelectors', selector);
}

/**
* Finds actual selectors in selectorNode object and checks them.
*
Expand All @@ -45,7 +69,7 @@ const rule = (primary) => {
function checkSelector(selectorNode, ruleNode) {
let compoundCount = 1;

selectorNode.each((childNode) => {
selectorNode.each((childNode, index) => {
// Only traverse inside actual selectors and context functional pseudo-classes
if (childNode.type === 'selector' || isContextFunctionalPseudoClass(childNode)) {
checkSelector(childNode, ruleNode);
Expand All @@ -54,6 +78,15 @@ const rule = (primary) => {
// Compound selectors are separated by combinators, so increase count when meeting one
if (childNode.type === 'combinator') {
compoundCount++;

return;
}

// Try ignoring the selector if the current node is the first, or the previous node is a combinator
const previousNode = selectorNode.at(index - 1);

if ((!previousNode || previousNode.type === 'combinator') && isSelectorIgnored(childNode)) {
compoundCount--;
}
});

Expand Down
45 changes: 39 additions & 6 deletions lib/rules/selector-max-compound-selectors/index.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import resolvedNestedSelector from 'postcss-resolve-nested-selector';

import { isRegExp, isString } from '../../utils/validateTypes.mjs';
import isContextFunctionalPseudoClass from '../../utils/isContextFunctionalPseudoClass.mjs';
import isNonNegativeInteger from '../../utils/isNonNegativeInteger.mjs';
import isStandardSyntaxRule from '../../utils/isStandardSyntaxRule.mjs';
import optionsMatches from '../../utils/optionsMatches.mjs';
import parseSelector from '../../utils/parseSelector.mjs';
import report from '../../utils/report.mjs';
import ruleMessages from '../../utils/ruleMessages.mjs';
Expand All @@ -22,17 +24,39 @@ const meta = {
};

/** @type {import('stylelint').Rule} */
const rule = (primary) => {
const rule = (primary, secondaryOptions) => {
return (root, result) => {
const validOptions = validateOptions(result, ruleName, {
actual: primary,
possible: isNonNegativeInteger,
});
const validOptions = validateOptions(
result,
ruleName,
{
actual: primary,
possible: isNonNegativeInteger,
},
{
actual: secondaryOptions,
possible: {
ignoreSelectors: [isString, isRegExp],
},
optional: true,
},
);

if (!validOptions) {
return;
}

/**
* @param {import('postcss-selector-parser').Node} selectorNode
* @returns {boolean}
*/
function isSelectorIgnored(selectorNode) {
const selector =
selectorNode.type === 'pseudo' ? selectorNode.value : selectorNode.toString();

return optionsMatches(secondaryOptions, 'ignoreSelectors', selector);
}

/**
* Finds actual selectors in selectorNode object and checks them.
*
Expand All @@ -42,7 +66,7 @@ const rule = (primary) => {
function checkSelector(selectorNode, ruleNode) {
let compoundCount = 1;

selectorNode.each((childNode) => {
selectorNode.each((childNode, index) => {
// Only traverse inside actual selectors and context functional pseudo-classes
if (childNode.type === 'selector' || isContextFunctionalPseudoClass(childNode)) {
checkSelector(childNode, ruleNode);
Expand All @@ -51,6 +75,15 @@ const rule = (primary) => {
// Compound selectors are separated by combinators, so increase count when meeting one
if (childNode.type === 'combinator') {
compoundCount++;

return;
}

// Try ignoring the selector if the current node is the first, or the previous node is a combinator
const previousNode = selectorNode.at(index - 1);

if ((!previousNode || previousNode.type === 'combinator') && isSelectorIgnored(childNode)) {
compoundCount--;
}
});

Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 5078666

Please sign in to comment.