diff --git a/src/rules/at-import-partial-extension-allowed-list/__tests__/index.js b/src/rules/at-import-partial-extension-allowed-list/__tests__/index.js index f534ba95..e51585c1 100644 --- a/src/rules/at-import-partial-extension-allowed-list/__tests__/index.js +++ b/src/rules/at-import-partial-extension-allowed-list/__tests__/index.js @@ -83,8 +83,7 @@ testRule({ line: 2, column: 20, message: messages.rejected("scssy"), - description: - "One file, ext not from an allowed list, space at the end." + description: "One file, ext not from an allowed list, space at the end." }, { code: ` diff --git a/src/rules/at-mixin-no-risky-parent-selectors/README.md b/src/rules/at-mixin-no-risky-parent-selectors/README.md new file mode 100644 index 00000000..b4db75f6 --- /dev/null +++ b/src/rules/at-mixin-no-risky-parent-selectors/README.md @@ -0,0 +1,142 @@ +# mixin-no-risky-parent-selectors + +If a mixin contains a parent selector within another style rule, and is used in a nested context, +the output selector may include the outermost parent selector in an unexpected way. + +In this example: +```scss +@mixin foo { + .a { + color: blue; + .b & { + color: red; + } + } +} + +.c { + @include foo; +} +``` + +The user may expect `.c` to go outside all selectors in `foo`: +`.c .b .a {...}` + +But this outputs: +```scss +.c .a { + color: blue; +} +.b .c .a { + color: red; +} +``` + +However, if we pull the parent selector into the child and make the child style rule a sibling: +```scss +@mixin foo { + .a { + color: blue; + } + .b .a { + color: red; + } +} + +.c { + @include foo; +} +``` + +It outputs: +```scss +.c .a { + color: blue; +} +.c .b .a { + color: red; +} +``` + +This only occurs when a parent selector meets all of the following conditions: +- Is within a `@mixin` rule. +- Is nested within another style rule. +- Is not positioned at the beginning of a complex selector. + +## Options + +### `true` + +The following patterns are considered warnings: + +```scss +@mixin foo { + .bar { + color: blue; + .baz & { + color: red; + } + } +} +``` + +```scss +@mixin foo { + .bar { + color: blue; + .qux, .baz & .quux{ + color: red; + } + } +} +``` + +The following patterns are _not_ considered warnings: + +```scss +.foo { + .bar { + color: blue; + .baz & { + color: red; + } + } +} +``` + +```scss +@mixin foo { + .bar { + color: blue; + & .baz { + color: red; + } + } +} +``` + +```scss +.bar { + color: blue; + .baz & { + color: red; + } +} +``` + +```scss +.foo { + color: blue; + & .bar, .baz & .qux { + color: red; + } +} +``` + +```scss +@mixin foo { + .baz & { + color: red; + } +} +``` \ No newline at end of file diff --git a/src/rules/at-mixin-no-risky-parent-selectors/__tests__/index.js b/src/rules/at-mixin-no-risky-parent-selectors/__tests__/index.js new file mode 100644 index 00000000..92988938 --- /dev/null +++ b/src/rules/at-mixin-no-risky-parent-selectors/__tests__/index.js @@ -0,0 +1,194 @@ +"use strict"; + +const { ruleName } = require(".."); + +testRule({ + ruleName, + config: [true], + customSyntax: "postcss-scss", + + accept: [ + { + code: ` + .parent { + color: blue; + + & .b { + color: red; + } + } + `, + description: "Nested parent selector" + }, + { + code: ` + .parent { + color: blue; + + & .b, &.context { + color: red; + } + } + `, + description: "Nested parent selector in complex selector" + }, + { + code: ` + .bar { + & .parent { + color: blue; + + .context { + color: red; + } + } + } + `, + description: "Parent selector nested in another style rule" + }, + { + code: ` + @mixin foo { + &.context { + color: red; + } + } + `, + description: "Parent selector in mixin" + }, + { + code: ` + .bar { + .parent { + color: blue; + + & .context { + color: red; + } + } + } + `, + description: "Parent selector nested in more than one style rule" + }, + { + code: ` + .parent { + color: blue; + + .context & { + color: red; + } + } + `, + description: "Selector ending in parent selector" + }, + { + code: ` + .parent { + color: blue; + + .context & .b { + color: red; + } + } + `, + description: "Parent selector in the middle of complex selector" + }, + { + code: ` + .parent { + color: blue; + + & .b, .context & { + color: red; + } + } + `, + description: "Complex selector, one ending in parent selector" + }, + { + code: ` + @mixin foo { + .parent { + color: blue; + + & .context { + color: red; + } + } + } + `, + description: "Parent selector in mixin, nested, at the beginning" + }, + { + code: ` + @mixin foo { + .bar & { + color: red; + } + } + `, + description: "Parent selector in mixin, at the end" + }, + { + code: ` + @mixin foo { + .parent { + .bar, & { + color: red; + } + } + } + `, + description: "Complex selector in mixin, nested, no following class." + } + ], + + reject: [ + { + code: ` + @mixin foo { + .bar { + color: blue; + .baz & { + color: red; + } + } + } + `, + description: "Parent selector nested in selector within a mixin", + message: + "Unexpected nested parent selector in @mixin rule. (scss/mixin-no-risky-parent-selectors)" + }, + { + code: ` + @mixin foo { + .bar { + color: blue; + & .qux, .baz & .quux{ + color: red; + } + } + } + `, + description: "Parent selector nested in complex selector within mixin", + message: + "Unexpected nested parent selector in @mixin rule. (scss/mixin-no-risky-parent-selectors)" + }, + { + code: ` + @mixin foo { + .bar { + color: blue; + & .qux & { + color: red; + } + } + } + `, + description: "Parent selector nested in complex selector within mixin", + message: + "Unexpected nested parent selector in @mixin rule. (scss/mixin-no-risky-parent-selectors)" + } + ] +}); diff --git a/src/rules/at-mixin-no-risky-parent-selectors/index.js b/src/rules/at-mixin-no-risky-parent-selectors/index.js new file mode 100644 index 00000000..2f2d89d5 --- /dev/null +++ b/src/rules/at-mixin-no-risky-parent-selectors/index.js @@ -0,0 +1,67 @@ +"use strict"; + +const { utils } = require("stylelint"); +const namespace = require("../../utils/namespace"); +const ruleUrl = require("../../utils/ruleUrl"); + +const ruleName = namespace("at-mixin-no-risky-parent-selectors"); + +const messages = utils.ruleMessages(ruleName, { + rejected: `Unexpected nested parent selector in @mixin rule.` +}); + +const meta = { + url: ruleUrl(ruleName) +}; + +function isWithinMixin(node) { + let parent = node.parent; + while (parent) { + if (parent.type === "atrule" && parent.name === "mixin") { + return true; + } + parent = parent.parent; + } + return false; +} + +function hasNestedParentSelector(selectors) { + return selectors + .split(",") + .some( + selector => + selector.includes("&") && /.+&/.test(selector.replace(" ", "")) + ); +} + +function rule(actual) { + return (root, result) => { + const validOptions = utils.validateOptions(result, ruleName, { actual }); + + if (!validOptions) { + return; + } + + root.walkRules(node => { + if ( + node.selector?.includes("&") && + isWithinMixin(node) && + hasNestedParentSelector(node.selector) && + node.parent.selector + ) { + utils.report({ + message: messages.rejected, + node, + result, + ruleName + }); + } + }); + }; +} + +rule.ruleName = ruleName; +rule.messages = messages; +rule.meta = meta; + +module.exports = rule; diff --git a/src/rules/index.js b/src/rules/index.js index 5e853d4d..bf4256fb 100644 --- a/src/rules/index.js +++ b/src/rules/index.js @@ -24,6 +24,7 @@ const rules = { "at-mixin-named-arguments": require("./at-mixin-named-arguments"), "at-mixin-parentheses-space-before": require("./at-mixin-parentheses-space-before"), "at-mixin-pattern": require("./at-mixin-pattern"), + "at-mixin-no-risky-parent-selectors": require("./at-mixin-no-risky-parent-selectors"), "at-rule-conditional-no-parentheses": require("./at-rule-conditional-no-parentheses"), "at-root-no-redundant": require("./at-root-no-redundant"), "at-rule-no-unknown": require("./at-rule-no-unknown"),