Skip to content

Commit

Permalink
Added: selector-max-attribute rule.
Browse files Browse the repository at this point in the history
  • Loading branch information
alexander-akait committed Jun 12, 2017
1 parent d229a67 commit 02ad62b
Show file tree
Hide file tree
Showing 7 changed files with 361 additions and 25 deletions.
1 change: 1 addition & 0 deletions docs/user-guide/example-config.md
Expand Up @@ -132,6 +132,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-attribute": int,
"selector-max-class": int,
"selector-max-compound-selectors": int,
"selector-max-specificity": string,
Expand Down
1 change: 1 addition & 0 deletions docs/user-guide/rules.md
Expand Up @@ -162,6 +162,7 @@ Here are all the rules within stylelint, grouped by the [*thing*](http://apps.wo
- [`selector-combinator-space-before`](../../lib/rules/selector-combinator-space-before/README.md): Require a single space or disallow whitespace before the combinators of selectors.
- [`selector-descendant-combinator-no-non-space`](../../lib/rules/selector-descendant-combinator-no-non-space/README.md): Disallow non-space characters for descendant combinators of selectors.
- [`selector-id-pattern`](../../lib/rules/selector-id-pattern/README.md): Specify a pattern for id selectors.
- [`selector-max-attribute`](../../lib/rules/selector-max-attribute/README.md): Limit the number of attribute selectors in a selector.
- [`selector-max-class`](../../lib/rules/selector-max-class/README.md): Limit the number of classes in a selector.
- [`selector-max-compound-selectors`](../../lib/rules/selector-max-compound-selectors/README.md): Limit the number of compound selectors in a selector.
- [`selector-max-specificity`](../../lib/rules/selector-max-specificity/README.md): Limit the specificity of selectors.
Expand Down
2 changes: 2 additions & 0 deletions lib/rules/index.js
Expand Up @@ -137,6 +137,7 @@ const selectorListCommaSpaceAfter = require("./selector-list-comma-space-after")
const selectorListCommaSpaceBefore = require("./selector-list-comma-space-before")
const selectorMaxCompoundSelectors = require("./selector-max-compound-selectors")
const selectorMaxEmptyLines = require("./selector-max-empty-lines")
const selectorMaxAttribute = require("./selector-max-attribute")
const selectorMaxClass = require("./selector-max-class")
const selectorMaxSpecificity = require("./selector-max-specificity")
const selectorNestedPattern = require("./selector-nested-pattern")
Expand Down Expand Up @@ -312,6 +313,7 @@ module.exports = {
"selector-list-comma-newline-before": selectorListCommaNewlineBefore,
"selector-list-comma-space-after": selectorListCommaSpaceAfter,
"selector-list-comma-space-before": selectorListCommaSpaceBefore,
"selector-max-attribute": selectorMaxAttribute,
"selector-max-class": selectorMaxClass,
"selector-max-compound-selectors": selectorMaxCompoundSelectors,
"selector-max-empty-lines": selectorMaxEmptyLines,
Expand Down
77 changes: 77 additions & 0 deletions lib/rules/selector-max-attribute/README.md
@@ -0,0 +1,77 @@
# selector-max-attribute

Limit the number of attribute selectors in a selector.

```css
[rel="external"] {}
/** ↑
* This type of selector */
```

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

The `:not()` pseudo-class is also evaluated separately. The rule processes the argument as if it were an independent selector, and the result does not count toward the total for the entire selector.

## Options

`int`: Maximum attribute selectors allowed.

For example, with `2`:

The following patterns are considered warnings:

```css
[type="number"][name="quality"][data-attribute="value"] {}
```

```css
[type="number"][name="quality"][disabled] {}
```

```css
[type="number"][name="quality"] {
& [data-attribute="value"] {}
}
```

```css
[type="number"][name="quality"] {
& [disabled] {}
}
```

```css
[type="number"][name="quality"] {
& > [data-attribute="value"] {}
}
```

```css
/* `[data-attribute="value"][disabled]` is inside `:not()`, so it is evaluated separately */
input:not([type="text"][data-attribute="value"][disabled]) {}
```

The following patterns are *not* considered warnings:

```css
[type="text"] {}
```

```css
[type="text"][name="message"] {}
```

```css
[type="text"][disabled]
```

```css
/* each selector in a selector list is evaluated separately */
[type="text"][name="message"],
[type="number"][name="quality"] {}
```

```css
/* `[data-attribute="value"][disabled]` is inside `:not()`, so it is evaluated separately */
[type="text"][name="message"]:not([data-attribute="value"][disabled]) {}
```
180 changes: 180 additions & 0 deletions lib/rules/selector-max-attribute/__tests__/index.js
@@ -0,0 +1,180 @@
"use strict"

const messages = require("..").messages
const ruleName = require("..").ruleName
const rules = require("../../../rules")

const rule = rules[ruleName]

// Sanity checks
testRule(rule, {
ruleName,
config: [0],

accept: [ {
code: "foo {}",
}, {
code: ".bar {}",
}, {
code: "foo .bar {}",
}, {
code: ":root { --custom-property-set: {} }",
} ],

reject: [ {
code: "[foo] {}",
message: messages.expected("[foo]", 0),
line: 1,
column: 1,
}, {
code: "a[rel=\"external\"] {}",
message: messages.expected("a[rel=\"external\"]", 0),
line: 1,
column: 1,
}, {
code: "a, .foo[type=\"text\"] {}",
message: messages.expected(".foo[type=\"text\"]", 0),
line: 1,
column: 4,
}, {
code: "a > [foo] {}",
message: messages.expected("a > [foo]", 0),
line: 1,
column: 1,
}, {
code: "a[rel='external'] {}",
message: messages.expected("a[rel='external']", 0),
line: 1,
column: 1,
} ],
})

// Standard tests
testRule(rule, {
ruleName,
config: [2],

accept: [ {
code: "[type=\"text\"] {}",
description: "fewer than max classes",
}, {
code: "[type=\"text\"][disabled]:hover {}",
description: "pseudo selectors",
}, {
code: "[type=\"text\"][name=\"message\"] {}",
description: "exactly max classes",
}, {
code: "[type=\"text\"][disabled] {}",
description: "exactly max classes",
}, {
code: "[type=\"text\"] [type=\"number\"] {}",
description: "compound selector",
}, {
code: "[type=\"text\"], \n[type=\"number\"] {}",
description: "multiple selectors: fewer than max classes",
}, {
code: "[type=\"text\"][name=\"message\"], \n[type=\"password\"][name=\"password\"] {}",
description: "multiple selectors: exactly max classes",
}, {
code: "[type=\"text\"][disabled], \n[type=\"password\"][disabled] {}",
description: "multiple selectors: exactly max classes",
}, {
code: "[type=\"text\"][name=\"message\"]:not([type=\"number\"][name=\"quality\"]) {}",
description: ":not(): inside and outside",
}, {
code: "[type=\"text\"] { [name=\"message\"] {} }",
description: "nested selectors",
}, {
code: "[type=\"text\"] { [name=\"message\"] > & {} }",
description: "nested selectors: parent selector",
}, {
code: "[type=\"text\"], [name=\"message\"] { & > [data-attribute=\"value\"] {} }",
description: "nested selectors: superfluous parent selector",
}, {
code: "@media print { [type=\"text\"][name=\"message\"] {} }",
description: "media query: parent",
}, {
code: "[type=\"text\"] { @media print { [name=\"message\"] {} } }",
description: "media query: nested",
} ],

reject: [ {
code: "[type=\"text\"][name=\"message\"][data-attribute=\"value\"] {}",
description: "greater than max classes",
message: messages.expected("[type=\"text\"][name=\"message\"][data-attribute=\"value\"]", 2),
line: 1,
column: 1,
}, {
code: "[type=\"text\"][name=\"message\"][disabled] {}",
description: "greater than max classes with attribute selector without value",
message: messages.expected("[type=\"text\"][name=\"message\"][disabled]", 2),
line: 1,
column: 1,
}, {
code: "[type=\"text\"] [name=\"message\"] [data-attribute=\"value\"] {}",
description: "compound selector: greater than max classes",
message: messages.expected("[type=\"text\"] [name=\"message\"] [data-attribute=\"value\"]", 2),
line: 1,
column: 1,
}, {
code: "[type=\"text\"], \n[type=\"number\"][name=\"quality\"][data-attribute=\"value\"] {}",
description: "multiple selectors: greater than max classes",
message: messages.expected("[type=\"number\"][name=\"quality\"][data-attribute=\"value\"]", 2),
line: 2,
column: 1,
}, {
code: "[type=\"text\"], \n[type=\"number\"][name=\"quality\"][disabled] {}",
description: "multiple selectors: greater than max classes",
message: messages.expected("[type=\"number\"][name=\"quality\"][disabled]", 2),
line: 2,
column: 1,
}, {
code: ":not([type=\"text\"][name=\"message\"][data-attribute=\"value\"]) {}",
description: ":not(): greater than max classes, inside",
message: messages.expected("[type=\"text\"][name=\"message\"][data-attribute=\"value\"]", 2),
line: 1,
column: 6,
}, {
code: ":not([type=\"text\"][name=\"message\"][disabled]) {}",
description: ":not(): greater than max classes, inside",
message: messages.expected("[type=\"text\"][name=\"message\"][disabled]", 2),
line: 1,
column: 6,
}, {
code: "[type=\"text\"][name=\"message\"][data-attribute=\"value\"] :not([data-attribute-2=\"value\"]) {}",
description: ":not(): greater than max classes, outside",
message: messages.expected("[type=\"text\"][name=\"message\"][data-attribute=\"value\"] :not([data-attribute-2=\"value\"])", 2),
line: 1,
column: 1,
}, {
code: "[type=\"text\"][name=\"message\"][data-attribute=\"value\"]:not([disabled]) {}",
description: ":not(): greater than max classes, outside",
message: messages.expected("[type=\"text\"][name=\"message\"][data-attribute=\"value\"]:not([disabled])", 2),
line: 1,
column: 1,
}, {
code: "[type=\"text\"] { &:hover > [data-attribute=\"value\"][data-attribute-2=\"value\"] {} }",
description: "nested selectors: greater than max classes",
message: messages.expected("[type=\"text\"]:hover > [data-attribute=\"value\"][data-attribute-2=\"value\"]", 2),
line: 1,
column: 17,
} ],
})

// SCSS tests
testRule(rule, {
ruleName,
config: [0],
syntax: "scss",

accept: [ {
code: ".[#{$interpolation}] {}",
description: "scss: ignore variable interpolation",
}, {
code: "#{$interpolation}[type=\"text\"] {}",
description: "scss: ignore variable interpolation",
}, {
code: "[type=\"text\"]#{$interpolation} { margin: { left: 0; top: 0; }; }",
description: "scss: nested properties",
} ],
})
75 changes: 75 additions & 0 deletions lib/rules/selector-max-attribute/index.js
@@ -0,0 +1,75 @@
"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 resolvedNestedSelector = require("postcss-resolve-nested-selector")

const ruleName = "selector-max-attribute"

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

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

function checkSelector(selectorNode, ruleNode) {
const count = selectorNode.reduce((total, childNode) => {
// Only traverse inside actual selectors and :not()
if (childNode.type === "selector" || childNode.value === ":not") {
checkSelector(childNode, ruleNode)
}

return total += (childNode.type === "attribute" ? 1 : 0)
}, 0)

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

root.walkRules(ruleNode => {
if (!isStandardSyntaxRule(ruleNode)) {
return
}
if (!isStandardSyntaxSelector(ruleNode.selector)) {
return
}
if (ruleNode.nodes.some(node => [ "rule", "atrule" ].indexOf(node.type) !== -1)) {
// Skip unresolved nested selectors
return
}

ruleNode.selectors.forEach(selector => {
resolvedNestedSelector(selector, ruleNode).forEach(resolvedSelector => {
parseSelector(resolvedSelector, result, ruleNode, container => checkSelector(container, ruleNode))
})
})
})
}
}

rule.ruleName = ruleName
rule.messages = messages
module.exports = rule

0 comments on commit 02ad62b

Please sign in to comment.