From d06186d6221312b058054d4d321c607f6cfff6f1 Mon Sep 17 00:00:00 2001 From: Pamela Lozano <30474787+pamelalozano16@users.noreply.github.com> Date: Fri, 3 May 2024 10:59:51 -0700 Subject: [PATCH] Add mixin-no-risky-parent-selectors rule. (#985) --- .../README.md | 144 ++++++++++++ .../__tests__/index.js | 206 ++++++++++++++++++ .../index.js | 67 ++++++ src/rules/index.js | 1 + 4 files changed, 418 insertions(+) create mode 100644 src/rules/at-mixin-no-risky-nesting-selector/README.md create mode 100644 src/rules/at-mixin-no-risky-nesting-selector/__tests__/index.js create mode 100644 src/rules/at-mixin-no-risky-nesting-selector/index.js diff --git a/src/rules/at-mixin-no-risky-nesting-selector/README.md b/src/rules/at-mixin-no-risky-nesting-selector/README.md new file mode 100644 index 00000000..e07813e1 --- /dev/null +++ b/src/rules/at-mixin-no-risky-nesting-selector/README.md @@ -0,0 +1,144 @@ +# at-mixin-no-risky-nesting-selector + +Disallow risky nesting selectors within a mixin. + +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-nesting-selector/__tests__/index.js b/src/rules/at-mixin-no-risky-nesting-selector/__tests__/index.js new file mode 100644 index 00000000..7e79c8cc --- /dev/null +++ b/src/rules/at-mixin-no-risky-nesting-selector/__tests__/index.js @@ -0,0 +1,206 @@ +"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/at-mixin-no-risky-nesting-selector)", + column: 11, + endColumn: 12, + endLine: 7, + line: 5 + }, + { + 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/at-mixin-no-risky-nesting-selector)", + column: 11, + endColumn: 12, + endLine: 7, + line: 5 + }, + { + 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/at-mixin-no-risky-nesting-selector)", + column: 11, + endColumn: 12, + endLine: 7, + line: 5 + } + ] +}); diff --git a/src/rules/at-mixin-no-risky-nesting-selector/index.js b/src/rules/at-mixin-no-risky-nesting-selector/index.js new file mode 100644 index 00000000..19dc777b --- /dev/null +++ b/src/rules/at-mixin-no-risky-nesting-selector/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-nesting-selector"); + +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..e9a2b56c 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-nesting-selector": require("./at-mixin-no-risky-nesting-selector"), "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"),