Repository: stylelint-scss/stylelint-scss (js)

Issue:
Rule suggestion: `block-no-unnecessary-child-block`
I suggest a new Stylelint rule that reports a single block inside another block:

```scss
// BAD
.foo {
    .bar { … }
}

// GOOD
.foo .bar { … }
```

```scss
// BAD
.foo {
    &-bar { … }
}

// GOOD
.foo-bar { … }
```

```scss
// GOOD, there are multiple child blocks
.foo {
    .bar { … }
    &-baz { … }
}
```

```scss
// GOOD, the outer selector contains a comma
.foo,
.bar {
    &-baz { … }
}
```

Pull request description:
Add `block-no-redundant-nesting` rule
Fixes #679.

Patch:
diff --git a/src/rules/at-root-no-redundant/index.js b/src/rules/at-root-no-redundant/index.js
index 695f659..6d346c6 100644
--- a/src/rules/at-root-no-redundant/index.js
+++ b/src/rules/at-root-no-redundant/index.js
@@ -38,7 +38,10 @@ function rule(actual, _, context) {
     root.walkAtRules("at-root", node => {
       if (
         node.parent.type === "root" ||
-        node.params.replace(/#{.*}/g, "").includes("&") ||
+        node.params
+          .replace(/#{.*}/g, "")
+          .split(",")
+          .every(param => param.includes("&")) ||
         isWithinKeyframes(node)
       ) {
         if (context.fix) {
diff --git a/src/rules/block-no-redundant-nesting/README.md b/src/rules/block-no-redundant-nesting/README.md
new file mode 100644
index 0000000..6744617
--- /dev/null
+++ b/src/rules/block-no-redundant-nesting/README.md
@@ -0,0 +1,64 @@
+# block-no-redundant-nesting
+
+Disallow nesting a single block if it could be merged with its parent block.
+
+```scss
+.foo {
+  .bar {}
+}
+```
+
+[Sass official docs on nested properties](https://sass-lang.com/documentation/style-rules/declarations#nesting).
+
+## Options
+
+### `true`
+
+The following patterns are considered warnings:
+
+```scss
+.foo {
+  .bar {}
+}
+```
+
+```scss
+.foo {
+  &-bar {
+    &-baz {
+      color: red;
+    }
+  }
+}
+```
+
+The following patterns are *not* considered warnings:
+
+```scss
+.foo .bar {}
+```
+
+```scss
+.foo-bar-baz {
+  color: red;
+}
+```
+
+```scss
+// multiple child blocks
+.foo {
+  .bar {}
+  &-baz {}
+}
+```
+
+```scss
+// parent or child with selector list
+.foo {
+  .bar, .baz {}
+}
+
+.foo, .bar {
+  &-baz {}
+}
+```
diff --git a/src/rules/block-no-redundant-nesting/index.js b/src/rules/block-no-redundant-nesting/index.js
new file mode 100644
index 0000000..bc9bfb0
--- /dev/null
+++ b/src/rules/block-no-redundant-nesting/index.js
@@ -0,0 +1,75 @@
+"use strict";
+
+const resolveNestedSelector = require("postcss-resolve-nested-selector");
+const { utils } = require("stylelint");
+const namespace = require("../../utils/namespace");
+const ruleUrl = require("../../utils/ruleUrl");
+
+const ruleName = namespace("block-no-redundant-nesting");
+
+const messages = utils.ruleMessages(ruleName, {
+  rejected: "Unexpected nesting for single child block"
+});
+
+const meta = {
+  url: ruleUrl(ruleName)
+};
+
+function processRuleNode(ruleNode, result, context) {
+  if (ruleNode.nodes.length !== 1) {
+    return;
+  }
+
+  const nestedRuleNode = ruleNode.nodes[0];
+  if (
+    nestedRuleNode.type !== "rule" ||
+    nestedRuleNode.selector.endsWith(":") ||
+    ruleNode.selectors.length !== 1 ||
+    nestedRuleNode.selectors.length !== 1
+  ) {
+    return;
+  }
+
+  if (context.fix) {
+    ruleNode.selector = resolveNestedSelector(
+      nestedRuleNode.selector,
+      nestedRuleNode
+    )[0];
+    ruleNode.nodes = nestedRuleNode.nodes;
+
+    if (ruleNode.nodes.length === 1) {
+      for (const rule of ruleNode.nodes) {
+        rule.parent = ruleNode;
+      }
+      processRuleNode(ruleNode, result, context);
+    }
+    return;
+  }
+
+  utils.report({
+    message: messages.rejected,
+    node: nestedRuleNode,
+    result,
+    ruleName
+  });
+}
+
+function rule(actual, _, context) {
+  return (root, result) => {
+    const validOptions = utils.validateOptions(result, ruleName, {
+      actual
+    });
+
+    if (!validOptions) {
+      return;
+    }
+
+    root.walkRules(ruleNode => processRuleNode(ruleNode, result, context));
+  };
+}
+
+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 8ade850..6286ea9 100644
--- a/src/rules/index.js
+++ b/src/rules/index.js
@@ -26,6 +26,7 @@ const rules = {
   "at-rule-no-unknown": require("./at-rule-no-unknown"),
   "at-use-no-redundant-alias": require("./at-use-no-redundant-alias"),
   "at-use-no-unnamespaced": require("./at-use-no-unnamespaced"),
+  "block-no-redundant-nesting": require("./block-no-redundant-nesting"),
   "comment-no-empty": require("./comment-no-empty"),
   "comment-no-loud": require("./comment-no-loud"),
   "declaration-nested-properties-no-divided-groups": require("./declaration-nested-properties-no-divided-groups"),
diff --git a/src/utils/parseSelector.js b/src/utils/parseSelector.js
index 84d3600..287ce84 100644
--- a/src/utils/parseSelector.js
+++ b/src/utils/parseSelector.js
@@ -6,6 +6,6 @@ module.exports = function (selector, result, node, cb) {
   try {
     return postCssSelectorParser(cb).processSync(selector);
   } catch (e) {
-    result.warn("Cannot parse selector", { node });
+    result.warn("Cannot parse selector", { node, stylelintType: "parseError" });
   }
 };


Test patch:
diff --git a/src/rules/at-root-no-redundant/__tests__/index.js b/src/rules/at-root-no-redundant/__tests__/index.js
index 880bf74..c63d57e 100644
--- a/src/rules/at-root-no-redundant/__tests__/index.js
+++ b/src/rules/at-root-no-redundant/__tests__/index.js
@@ -62,6 +62,18 @@ testRule({
         }
       }`,
       description: "Other at-rules nested inside @keyframes block."
+    },
+    {
+      code: `
+      .foo {
+        @at-root .bar,
+        &baz {
+          a: b;
+        }
+      }
+      `,
+      description:
+        "@at-root is followed by a nested selector containing`&` outside interpolation."
     }
   ],
   reject: [
@@ -131,6 +143,21 @@ testRule({
       column: 14,
       message: messages.rejected,
       description: "@at-root inside a @keyframes block, nested."
+    },
+    {
+      code: `
+      .foo {
+        @at-root &.bar,
+        &baz {
+          a: b;
+        }
+      }
+      `,
+      line: 3,
+      column: 9,
+      message: messages.rejected,
+      description:
+        "@at-root is followed by selectors all containing `&` outside interpolation."
     }
   ]
 });
@@ -198,6 +225,29 @@ testRule({
       column: 9,
       message: messages.rejected,
       description: "@at-root inside a @keyframes block, nested."
+    },
+    {
+      code: `
+      .foo {
+        @at-root &.bar,
+        &baz {
+          a: b;
+        }
+      }
+      `,
+      fixed: `
+      .foo {
+        &.bar,
+        &baz {
+          a: b;
+        }
+      }
+      `,
+      line: 3,
+      column: 9,
+      message: messages.rejected,
+      description:
+        "@at-root is followed by selectors all containing `&` outside interpolation."
     }
   ]
 });
diff --git a/src/rules/block-no-redundant-nesting/__tests__/index.js b/src/rules/block-no-redundant-nesting/__tests__/index.js
new file mode 100644
index 0000000..31b14b4
--- /dev/null
+++ b/src/rules/block-no-redundant-nesting/__tests__/index.js
@@ -0,0 +1,193 @@
+"use strict";
+
+const { messages, ruleName } = require("..");
+
+testRule({
+  ruleName,
+  config: true,
+  customSyntax: "postcss-scss",
+  fix: true,
+
+  accept: [
+    {
+      code: `
+        .foo .bar {}
+        .foo-bar {}
+        .foo {
+          color: red;
+        }
+      `,
+      description: "no nesting"
+    },
+    {
+      code: `
+        .foo {
+          .bar {}
+          &-baz {}
+        }
+      `,
+      description: "multiple child blocks"
+    },
+    {
+      code: `
+        .foo,
+        .bar {
+            &-baz {}
+        }
+      `,
+      description: "multiple outer selectors"
+    },
+    {
+      code: `
+        .foo {
+          .bar, .baz {}
+        }
+      `,
+      description: "multiple inner selectors"
+    },
+    {
+      code: `
+        .foo {
+            color: red;
+
+            &-baz {}
+        }
+      `,
+      description: "property and single child block"
+    },
+    {
+      code: `
+        .foo {
+            &-baz {}
+
+            @media (min-width: 10px) {}
+        }
+      `,
+      description: "at-rule and single child block"
+    },
+    {
+      code: `
+        .foo {
+          font: {
+            size: 16px;
+          }
+        }
+      `,
+      description: "Sass' nested properties"
+    }
+  ],
+
+  reject: [
+    {
+      code: `
+        .foo {
+          .bar {}
+        }
+      `,
+      fixed: `
+        .foo .bar {
+        }
+      `,
+      description: "single child block without &-prefix",
+      warnings: [
+        {
+          line: 3,
+          column: 11,
+          endLine: 3,
+          endColumn: 18,
+          message: messages.rejected
+        }
+      ]
+    },
+    {
+      code: `
+        .foo {
+          &-bar {
+            color: red;
+          }
+        }
+      `,
+      fixed: `
+        .foo-bar {
+            color: red
+        }
+      `,
+      description: "single child block with &-prefix and property",
+      warnings: [
+        {
+          line: 3,
+          column: 11,
+          endLine: 5,
+          endColumn: 12,
+          message: messages.rejected
+        }
+      ]
+    },
+    {
+      code: `
+        .foo:not(.bar, .baz) {
+          .bar {}
+        }
+      `,
+      fixed: `
+        .foo:not(.bar, .baz) .bar {
+        }
+      `,
+      description: "outer selector contains comma but is not a list",
+      warnings: [
+        {
+          line: 3,
+          column: 11,
+          endLine: 3,
+          endColumn: 18,
+          message: messages.rejected
+        }
+      ]
+    },
+    {
+      code: `
+        .foo {
+          .bar {}
+        }
+        .foo {
+          &-bar {
+            &-baz {
+              color: red;
+            }
+          }
+        }
+      `,
+      fixed: `
+        .foo .bar {
+        }
+        .foo-bar-baz {
+              color: red
+        }
+      `,
+      description: "multiple single child blocks",
+      warnings: [
+        {
+          line: 3,
+          column: 11,
+          endLine: 3,
+          endColumn: 18,
+          message: messages.rejected
+        },
+        {
+          line: 6,
+          column: 11,
+          endLine: 10,
+          endColumn: 12,
+          message: messages.rejected
+        },
+        {
+          line: 7,
+          column: 13,
+          endLine: 9,
+          endColumn: 14,
+          message: messages.rejected
+        }
+      ]
+    }
+  ]
+});