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/index.js b/src/rules/index.js index 5e853d4d..09115b26 100644 --- a/src/rules/index.js +++ b/src/rules/index.js @@ -56,6 +56,7 @@ const rules = { "function-unquote-no-unquoted-strings-inside": require("./function-unquote-no-unquoted-strings-inside"), "map-keys-quotes": require("./map-keys-quotes"), "media-feature-value-dollar-variable": require("./media-feature-value-dollar-variable"), + "mixin-no-risky-parent-selectors": require("./mixin-no-risky-parent-selectors"), "no-dollar-variables": require("./no-dollar-variables"), "no-duplicate-dollar-variables": require("./no-duplicate-dollar-variables"), "no-duplicate-mixins": require("./no-duplicate-mixins"), diff --git a/src/rules/mixin-no-risky-parent-selectors/README.md b/src/rules/mixin-no-risky-parent-selectors/README.md new file mode 100644 index 00000000..dd34c1c8 --- /dev/null +++ b/src/rules/mixin-no-risky-parent-selectors/README.md @@ -0,0 +1,139 @@ +# 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. + +This example: +```scss +@mixin foo { + .a { + color: blue; + .b & { + color: red; + } + } +} + +.c { + @include foo; +} +``` + +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; +} +``` + +Outputs: +```scss +.c .a { + color: blue; +} +.c .b .a { + color: red; +} +``` + +This 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/mixin-no-risky-parent-selectors/__tests__/index.js b/src/rules/mixin-no-risky-parent-selectors/__tests__/index.js new file mode 100644 index 00000000..734a412e --- /dev/null +++ b/src/rules/mixin-no-risky-parent-selectors/__tests__/index.js @@ -0,0 +1,159 @@ +"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", + message: + "Unexpected nested parent selector in @mixin rule. (scss/mixin-no-risky-parent-selectors)" + } + ], + + 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)" + } + ] +}); diff --git a/src/rules/mixin-no-risky-parent-selectors/index.js b/src/rules/mixin-no-risky-parent-selectors/index.js new file mode 100644 index 00000000..a3fd98c7 --- /dev/null +++ b/src/rules/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("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("&") && !selector.replace(" ", "").startsWith("&") + ); +} + +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;