Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add selector-max-compound-selectors rule #1167

Merged
merged 4 commits into from
May 20, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
- Added: `selector-attribute-operator-space-before` rule.
- Added: `selector-max-empty-lines` rule.
- Added: `selector-pseudo-element-no-unknown` rule.
- Added: `selector-max-compound-selectors` rule.
- Added: flexible support for end-of-line comments in `at-rule-semicolon-newline-after`, `block-opening-brace-newline-after`, and `declaration-block-semicolon-newline-after`.
- Fixed: string and verbose formatters no longer use an ambiguous colour schemes.
- Fixed: string formatter no longer outputs an empty line if there are no problems.
Expand Down
1 change: 1 addition & 0 deletions docs/user-guide/example-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ You might want to learn a little about [how rules are named and how they work to
"selector-list-comma-space-after": "always"|"never"|"always-single-line"|"never-single-line",
"selector-list-comma-space-before": "always"|"never"|"always-single-line"|"never-single-line",
"selector-max-empty-lines": int,
"selector-max-compound-selectors": int,
"selector-max-specificity": string,
"selector-no-attribute": true,
"selector-no-combinator": true,
Expand Down
1 change: 1 addition & 0 deletions docs/user-guide/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ Here are all the rules within stylelint, grouped by the [*thing*](http://apps.wo
- [`selector-combinator-space-after`](../../src/rules/selector-combinator-space-after/README.md): Require a single space or disallow whitespace after the combinators of selectors.
- [`selector-combinator-space-before`](../../src/rules/selector-combinator-space-before/README.md): Require a single space or disallow whitespace before the combinators of selectors.
- [`selector-id-pattern`](../../src/rules/selector-id-pattern/README.md): Specify a pattern for id selectors.
- [`selector-max-compound-selectors`](../../src/rules/selector-max-compound-selectors/README.md): Limit the number of compound selectors in a selector.
- [`selector-max-specificity`](../../src/rules/selector-max-specificity/README.md): Limit the specificity of selectors.
- [`selector-no-attribute`](../../src/rules/selector-no-attribute/README.md): Disallow attribute selectors.
- [`selector-no-combinator`](../../src/rules/selector-no-combinator/README.md): Disallow combinators in selectors.
Expand Down
2 changes: 2 additions & 0 deletions src/rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import selectorListCommaNewlineBefore from "./selector-list-comma-newline-before
import selectorListCommaSpaceAfter from "./selector-list-comma-space-after"
import selectorListCommaSpaceBefore from "./selector-list-comma-space-before"
import selectorMaxEmptyLines from "./selector-max-empty-lines"
import selectorMaxCompoundSelectors from "./selector-max-compound-selectors"
import selectorMaxSpecificity from "./selector-max-specificity"
import selectorNoAttribute from "./selector-no-attribute"
import selectorNoCombinator from "./selector-no-combinator"
Expand Down Expand Up @@ -260,6 +261,7 @@ export default {
"selector-list-comma-space-after": selectorListCommaSpaceAfter,
"selector-list-comma-space-before": selectorListCommaSpaceBefore,
"selector-max-empty-lines": selectorMaxEmptyLines,
"selector-max-compound-selectors": selectorMaxCompoundSelectors,
"selector-max-specificity": selectorMaxSpecificity,
"selector-no-attribute": selectorNoAttribute,
"selector-no-combinator": selectorNoCombinator,
Expand Down
52 changes: 52 additions & 0 deletions src/rules/selector-max-compound-selectors/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# selector-max-compound-selectors

Limit the number of compound selectors in a selector.

```css
div .bar[data-val] > a.baz + .boom > #lorem {
/* ↑ ↑ ↑ ↑ ↑
| | | | |
Lv1 v2 Lv3 Lv4 Lv5 -- these are compound selectors */
```

A [compound selector](https://www.w3.org/TR/selectors4/#compound) is a chain of one or more simple (tag, class, id, universal, attribute) selectors. The reason why one might want to limit their number is described in [SMACSS book](http://smacss.com/book/applicability).

This rule resolves nested selectors before calculating the depth of a selector.

`:not()` is considered one compound selector irrespective to the complexity of the selector inside it. The rule does process that inner selector, yet separately of the main selector.

## Options

`int`: Maximum compound selectors allowed.

For example, with `3`:

The following patterns are considered warnings:

```css
.foo .bar .baz .lorem {}
```

```css
.foo .baz {
& > .bar .lorem{}
}
```

The following are *not* considered warnings:

```css
div {}
```

```css
.foo div {}
```

```css
#foo #bar > #baz {}
```

```css
.foo + div :not (a b ~ c) {} /* `a b ~ c` is inside :not() and is processed separately */
```
183 changes: 183 additions & 0 deletions src/rules/selector-max-compound-selectors/__tests__/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { testRule } from "../../../testUtils"

import rule, { ruleName, messages } from ".."

// Testing plain selectors, different combinators
testRule(rule, {
ruleName,
config: [2],

accept: [ {
code: "a.class#id[ type = \"value\"]::before { top: 0; }",
}, {
code: "a[ type = \"value\"].class[data-val] { top: 0; }",
}, {
code: "a b { top: 0; }",
}, {
code: " a b { top: 0; }",
}, {
code: " a> b { top: 0; }",
}, {
code: " a > b { top: 0; }",
}, {
code: " a >b { top: 0; }",
}, {
code: "a b, a b.c { top: 0; }",
}, {
code: "a { b { top: 0; }}",
}, {
code: "a { top: 0; d { top: 0; }}",
} ],

reject: [ {
code: "a b c { top: 0; }",
message: messages.expected("a b c", 2),
line: 1,
column: 1,
}, {
code: "#id > .cl + .cl2 { top: 0; }",
message: messages.expected("#id > .cl + .cl2", 2),
line: 1,
column: 1,
}, {
code: "a c, d + e h { top: 0; }",
message: messages.expected("d + e h", 2),
line: 1,
column: 6,
}, {
code: "a ~ h + d { top: 0; }",
message: messages.expected("a ~ h + d", 2),
line: 1,
column: 1,
} ],
})

// Testing :not, attr selectors
testRule(rule, {
ruleName,
config: [2],

accept: [ {
code: ":not( a b ) {}",
description: "Standalone :not(), number of compound selectors <= max inside it",
}, {
code: "a b:not(c d) {}",
}, {
code: "[type=\"text\"] {top: 1px;}",
description: "Single attr selector, complies.",
}, {
code: "a [type=\"text\"] {}",
description: "Type selector and a single attr selector, complies.",
}, {
code: " [type=\"text\"]#id.classname l {}",
description: "attr selector with class and id selectors, complies.",
} ],

reject: [ {
code: ":not(a b c) { top: 0; }",
description: "Standalone :not(), number of compound selectors > max inside it",
message: messages.expected("a b c", 2),
line: 1,
column: 6,
}, {
code: "a b > c:not( d e ) { top: 0; }",
description: "number of compound selectors > max outside of :not()",
message: messages.expected("a b > c:not( d e )", 2),
line: 1,
column: 1,
}, {
code: "a b :not(d) { top: 0; }",
description: "Standalone :not, number of compound selectors > max outside of :not()",
message: messages.expected("a b :not(d)", 2),
line: 1,
column: 1,
}, {
code: "a b :not(d) { top: 0; }",
description: "Standalone attr selector, number of compound selectors > max outside of :not()",
message: messages.expected("a b :not(d)", 2),
line: 1,
column: 1,
} ],
})

// Tesging nested selectors
testRule(rule, {
ruleName,
config: [2],

accept: [ {
code: ".cd .de,\n.ef > b {}",
}, {
code: "a { b {} }",
description: "standard nesting",
}, {
code: "div:hover { .de {} }",
description: "element, pseudo-class, nested class",
}, {
code: ".ab, .cd { & > .de {} }",
description: "initial (unnecessary) parent selector",
}, {
code: ".cd { .de > & {} }",
description: "necessary parent selector",
}, {
code: ".cd { @media print { .de {} } }",
description: "nested rule within nested media query",
}, {
code: "@media print { .cd { .de {} } }",
description: "media query > rule > rule",
} ],

reject: [ {
code: ".thing div,\n.burgers .bacon a {}",
message: messages.expected(".burgers .bacon a", 2),
line: 2,
column: 1,
}, {
code: ".cd { .de { .fg {} } }",
message: messages.expected(".cd .de .fg", 2),
}, {
code: ".cd { .de { & > .fg {} } }",
message: messages.expected(".cd .de > .fg", 2),
}, {
code: ".cd { .de { &:hover > .fg {} } }",
message: messages.expected(".cd .de:hover > .fg", 2),
}, {
code: ".cd { .de { .fg > & {} } }",
message: messages.expected(".fg > .cd .de", 2),
}, {
code: "a { @media print { b > c { d {} } } }",
description: "The rule fails, but nesting even deeper with more compound selectors,",
message: messages.expected("a b > c d", 2),
}, {
code: ".a { @media print { & .b > .c { & + .d {} } } }",
description: "The rule fails, but nesting even deeper with more compound selectors, parent ref.,",
message: messages.expected(".a .b > .c + .d", 2),
}, {
code: "@media print { li { & + .ab { .cd { top: 10px; } } } }",
description: "The rule fails, but nesting even deeper with more compound selectors, has declarations",
message: messages.expected("li + .ab .cd", 2),
} ],
})

// Testing interpolation
testRule(rule, {
ruleName,
config: [1],
syntax: "scss",

accept: [{
code: "#hello #{$test} {}",
description: "ignore rules with variable interpolation",
}],
})

testRule(rule, {
ruleName,
config: [1],
syntax: "less",

accept: [{
code: "#hello @{test} {}",
description: "ignore rules with variable interpolation",
}],
})
71 changes: 71 additions & 0 deletions src/rules/selector-max-compound-selectors/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import resolvedNestedSelector from "postcss-resolve-nested-selector"
import selectorParser from "postcss-selector-parser"

import {
isStandardRule,
isStandardSelector,
report,
ruleMessages,
validateOptions,
} from "../../utils"

export const ruleName = "selector-max-compound-selectors"

export const messages = ruleMessages(ruleName, {
expected: (selector, max) => `Expected "${selector}" to have no more than ${max} compound selectors`,
})

export default function (max) {
return (root, result) => {
const validOptions = validateOptions(result, ruleName, {
actual: max,
possible: [function (max) {
return typeof max === "number" && max > 0
}],
})
if (!validOptions) { return }

// Finds actual selectors in selectorNode object and checks them
function checkSelector(selectorNode, rule) {
let compoundCount = 1

selectorNode.each(childNode => {
// Only traverse inside actual selectors and :not()
if (childNode.type === "selector" || childNode.value === ":not") {
checkSelector(childNode, rule)
}

// Compund selectors are separated by combinators, so increase count when meeting one
if (childNode.type === "combinator") { compoundCount++ }
})

if (selectorNode.type !== "root" && selectorNode.type !== "pseudo" && compoundCount > max) {
report({
ruleName,
result,
node: rule,
message: messages.expected(selectorNode, max),
word: selectorNode,
})
}
}

root.walkRules(rule => {
// Nested selectors are processed in steps, as nesting levels are resolved.
// Here we skip processing the intermediate parts of selectors (to process only fully resolved selectors)
if (rule.nodes.some(node => node.type === "rule" || node.type === "atrule")) { return }
// Skip custom rules, Less selectors, etc.
if (!isStandardRule(rule)) { return }
// Skip selectors with interpolation
if (!isStandardSelector(rule.selector)) { return }

// Using `rule.selectors` gets us each selector if there is a comma separated set
rule.selectors.forEach((selector) => {
resolvedNestedSelector(selector, rule).forEach(resolvedSelector => {
// Process each resolved selector with `checkSelector` via postcss-selector-parser
selectorParser(s => checkSelector(s, rule)).process(resolvedSelector)
})
})
})
}
}