From 56f9fca3fd451f0652ff10c44258da1271d0eebe Mon Sep 17 00:00:00 2001 From: Evilebot Tnawi Date: Mon, 26 Jun 2017 13:56:49 +0300 Subject: [PATCH] Added: `selector-max-combinators` rule. (#2658) --- docs/user-guide/example-config.md | 1 + docs/user-guide/rules.md | 1 + lib/rules/index.js | 2 + lib/rules/selector-max-combinators/README.md | 63 ++++++ .../__tests__/index.js | 206 ++++++++++++++++++ lib/rules/selector-max-combinators/index.js | 75 +++++++ 6 files changed, 348 insertions(+) create mode 100644 lib/rules/selector-max-combinators/README.md create mode 100644 lib/rules/selector-max-combinators/__tests__/index.js create mode 100644 lib/rules/selector-max-combinators/index.js diff --git a/docs/user-guide/example-config.md b/docs/user-guide/example-config.md index 90c0f9b137..38203bcc99 100644 --- a/docs/user-guide/example-config.md +++ b/docs/user-guide/example-config.md @@ -134,6 +134,7 @@ You might want to learn a little about [how rules are named and how they work to "selector-list-comma-space-before": "always"|"never"|"always-single-line"|"never-single-line", "selector-max-attribute": int, "selector-max-class": int, + "selector-max-combinators": int, "selector-max-compound-selectors": int, "selector-max-empty-lines": int, "selector-max-id": int, diff --git a/docs/user-guide/rules.md b/docs/user-guide/rules.md index 590b94b104..935eb2733d 100644 --- a/docs/user-guide/rules.md +++ b/docs/user-guide/rules.md @@ -165,6 +165,7 @@ Here are all the rules within stylelint, grouped by the [*thing*](http://apps.wo - [`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-combinators`](../../lib/rules/selector-max-combinators/README.md): Limit the number of combinators 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-empty-lines`](../../lib/rules/selector-max-empty-lines/README.md): Limit the number of adjacent empty lines within selectors. - [`selector-max-id`](../../lib/rules/selector-max-id/README.md): Limit the number of id selectors in a selector. diff --git a/lib/rules/index.js b/lib/rules/index.js index 1dda4e3aee..cffc893e2f 100644 --- a/lib/rules/index.js +++ b/lib/rules/index.js @@ -138,6 +138,7 @@ const selectorListCommaSpaceAfter = require("./selector-list-comma-space-after") const selectorListCommaSpaceBefore = require("./selector-list-comma-space-before") const selectorMaxAttribute = require("./selector-max-attribute") const selectorMaxClass = require("./selector-max-class") +const selectorMaxCombinators = require("./selector-max-combinators") const selectorMaxCompoundSelectors = require("./selector-max-compound-selectors") const selectorMaxEmptyLines = require("./selector-max-empty-lines") const selectorMaxId = require("./selector-max-id") @@ -319,6 +320,7 @@ module.exports = { "selector-list-comma-space-before": selectorListCommaSpaceBefore, "selector-max-attribute": selectorMaxAttribute, "selector-max-class": selectorMaxClass, + "selector-max-combinators": selectorMaxCombinators, "selector-max-compound-selectors": selectorMaxCompoundSelectors, "selector-max-empty-lines": selectorMaxEmptyLines, "selector-max-id": selectorMaxId, diff --git a/lib/rules/selector-max-combinators/README.md b/lib/rules/selector-max-combinators/README.md new file mode 100644 index 0000000000..b0ddfb92b8 --- /dev/null +++ b/lib/rules/selector-max-combinators/README.md @@ -0,0 +1,63 @@ +# selector-max-combinators + +Limit the number of combinators in a selector. + +```css + a > b + c ~ d e { color: pink; } +/** ↑ ↑ ↑ ↑ + * These are combinators */ +``` + +This rule resolves nested selectors before counting the number of combinators selectors. Each selector in a [selector list](https://www.w3.org/TR/selectors4/#selector-list) is evaluated separately. + +## Options + +`int`: Maximum combinators selectors allowed. + +For example, with `2`: + +The following patterns are considered violations: + +```css +a b ~ c + d {} +``` + +```css +a b ~ c { + & > d {} +} +``` + +```css +a b { + & ~ c { + & + d {} + } +} +``` + +The following patterns are *not* considered violations: + +```css +a {} +``` + +```css +a b {} +``` + +```css +a b ~ c {} +``` + +```css +a b { + & ~ c {} +} +``` + +```css +/* each selector in a selector list is evaluated separately */ +a b, +c > d {} +``` diff --git a/lib/rules/selector-max-combinators/__tests__/index.js b/lib/rules/selector-max-combinators/__tests__/index.js new file mode 100644 index 0000000000..a46c0162c3 --- /dev/null +++ b/lib/rules/selector-max-combinators/__tests__/index.js @@ -0,0 +1,206 @@ +"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 {}", + }, { + code: "[foo] {}", + }, { + code: ":root { --foo: 1px; }", + description: "custom property in root", + }, { + code: "html { --foo: 1px; }", + description: "custom property in selector", + }, { + code: ":root { --custom-property-set: {} }", + description: "custom property set in root", + }, { + code: "html { --custom-property-set: {} }", + description: "custom property set in selector", + } ], + + reject: [ { + code: "foo bar {}", + message: messages.expected("foo bar", 0), + line: 1, + column: 1, + }, { + code: "foo + bar {}", + message: messages.expected("foo + bar", 0), + line: 1, + column: 1, + }, { + code: "foo > bar {}", + message: messages.expected("foo > bar", 0), + line: 1, + column: 1, + }, { + code: "foo ~ bar {}", + message: messages.expected("foo ~ bar", 0), + line: 1, + column: 1, + }, { + code: "foo bar, .baz {}", + message: messages.expected("foo bar", 0), + line: 1, + column: 1, + }, { + code: ".foo, bar baz {}", + message: messages.expected("bar baz", 0), + line: 1, + column: 7, + }, { + code: "\t.foo,\n\tbar baz {}", + message: messages.expected("bar baz", 0), + line: 2, + column: 2, + }, { + code: "foo#bar ~ baz {}", + message: messages.expected("foo#bar ~ baz", 0), + line: 1, + column: 1, + } ], +}) + +// Standard tests +testRule(rule, { + ruleName, + config: [2], + + accept: [ { + code: "foo bar {}", + description: "fewer than max combinators", + }, { + code: "foo bar:hover {}", + description: "pseudo selectors", + }, { + code: "foo bar, \nbaz quux {}", + description: "multiple selectors: fewer than max combinators", + }, { + code: "foo bar:not(baz) {}", + description: ":not(): outside", + }, { + code: "foo { bar {} }", + description: "nested selectors", + }, { + code: "foo { bar { baz {} } }", + description: "nested selectors", + }, { + code: "foo { bar > & {} }", + description: "nested selectors: parent selector", + }, { + code: "foo, bar { & > quux {} }", + description: "nested selectors: superfluous parent selector", + }, { + code: "@media print { foo bar {} }", + description: "media query: parent", + }, { + code: "foo { @media print { bar {} } }", + description: "media query: nested", + } ], + + reject: [ { + code: "foo bar baz quux {}", + description: "compound selector: greater than max combinators", + message: messages.expected("foo bar baz quux", 2), + line: 1, + column: 1, + }, { + code: "foo, \nbar baz quux bat {}", + description: "multiple selectors: greater than max classes", + message: messages.expected("bar baz quux bat", 2), + line: 2, + column: 1, + }, { + code: "foo bar baz quux:not(bat) {}", + description: ":not(): greater than max combinators, outside", + message: messages.expected("foo bar baz quux:not(bat)", 2), + line: 1, + column: 1, + }, { + code: "foo { bar { baz { quux {} } } }", + description: "nested selectors: greater than max combinators", + message: messages.expected("foo bar baz quux", 2), + line: 1, + column: 19, + }, { + code: "foo { bar > & baz ~ quux {} }", + description: "nested selectors: parent selector", + message: messages.expected("bar > foo baz ~ quux", 2), + line: 1, + column: 7, + }, { + code: "foo, bar { & > baz ~ quux + bat {} }", + description: "nested selectors: superfluous parent selector", + message: messages.expected("foo > baz ~ quux + bat", 2), + line: 1, + column: 12, + } ], +}) + +testRule(rule, { + ruleName, + config: [true], + skipBasicChecks: true, + syntax: "scss", + + accept: [ { + code: "@keyframes spin { #{50% - $n} {} }", + }, { + code: "@for $n from 1 through 10 { .n-#{$n} { content: \"n: #{1 + 1}\"; } }", + description: "ignore sass interpolation inside @for", + }, { + code: "@for $n from 1 through 10 { .n#{$n}-#{$n} { content: \"n: #{1 + 1}\"; } }", + description: "ignore multiple sass interpolations in a selector inside @for", + }, { + code: "@for $n from 1 through 10 { .n#{$n}n#{$n} { content: \"n: #{1 + 1}\"; } }", + description: "ignore multiple sass interpolations in a selector inside @for", + }, { + code: "@each $n in $vals { .n-#{$n} { content: \"n: #{1 + 1}\"; } }", + description: "ignore sass interpolation inside @each", + }, { + code: "@while $n < 10 { .n-#{$n} { content: \"n: #{1 + 1}\"; } }", + description: "ignore sass interpolation inside @while", + }, { + code: "div:nth-child(#{map-get($foo, bar)}) {}", + description: "ignore sass map-get interpolation", + } ], +}) + +testRule(rule, { + ruleName, + config: [true], + skipBasicChecks: true, + syntax: "less", + + accept: [ { + code: ".for(@n: 1) when (@n <= 10) { .n-@{n} { content: %(\"n: %d\", 1 + 1); } .for(@n + 1); }", + description: "ignore Less interpolation inside .for", + }, { + code: ".for(@n: 1) when (@n <= 10) { .n-@{n}-@{n} { content: %(\"n: %d\", 1 + 1); } .for(@n + 1); }", + description: "ignore multiple Less interpolations in a selector inside .for", + }, { + code: ".for(@n: 1) when (@n <= 10) { .n-@{n}n@{n} { content: %(\"n: %d\", 1 + 1); } .for(@n + 1); }", + description: "ignore multiple Less interpolations in a selector inside .for", + }, { + code: ".each(@vals, @n: 1) when (@n <= length(@vals)) { @val: extract(@vals, @n); .n-@{val} { content: %(\"n: %d\", 1 + 1); } .each(@vals, @n + 1); }", + description: "ignore Less interpolation inside .each", + }, { + code: ".while(@n: 0) when (@n < 10) { .n-@{n} { content: %(\"n: %d\", 1 + 1); } .while(@n + 1) }", + description: "ignore Less interpolation inside .while", + } ], +}) diff --git a/lib/rules/selector-max-combinators/index.js b/lib/rules/selector-max-combinators/index.js new file mode 100644 index 0000000000..84bd7cfbd3 --- /dev/null +++ b/lib/rules/selector-max-combinators/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-combinators" + +const messages = ruleMessages(ruleName, { + expected: (selector, max) => `Expected "${selector}" to have no more than ${max} ${max === 1 ? "combinator" : "combinators"}`, +}) + +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 + if (childNode.type === "selector") { + checkSelector(childNode, ruleNode) + } + + return total += (childNode.type === "combinator" ? 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