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

Issue:
Rule suggestion: `function-color-channel`
*(split out of #1053)*

Reports use of the [deprecated](https://sass-lang.com/documentation/modules/color/#deprecated-functions) functions:

* `color.alpha` / `alpha` / `opacity`
* `color.blackness` / `blackness`
* `color.blue` / `blue`
* `color.green` / `green`
* `color.hue` / `hue`
* `color.lightness` / `lightness`
* `color.red` / `red`
* `color.saturation` / `saturation`
* `color.whiteness`

… and suggests using `color.channel` instead.

Pull request:
Add new `function-color-channel` rule
Closes #1057.

Patch:
diff --git a/README.md b/README.md
index dba79fb..8eabd47 100644
--- a/README.md
+++ b/README.md
@@ -146,6 +146,7 @@ Please also see the [example configs](./docs/examples/) for special cases.
 ### Function
 
 - [`function-calculation-no-interpolation`](./src/rules/function-calculation-no-interpolation/README.md): Disallow interpolation in `calc()`, `clamp()`, `min()`, and `max()` functions.
+- [`function-color-channel`](./src/rules/function-color-channel/README.md): Encourage the use of the [color.channel](https://sass-lang.com/documentation/modules/color#channel) function over related deprecated color functions.
 - [`function-color-relative`](./src/rules/function-color-relative/README.md): Encourage the use of the [scale-color](https://sass-lang.com/documentation/modules/color#scale-color) function over regular color functions.
 - [`function-disallowed-list`](./src/rules/function-disallowed-list/README.md): Specify a list of disallowed functions. Should be used **instead of** Stylelint's [function-disallowed-list](https://stylelint.io/user-guide/rules/function-disallowed-list).
 - [`function-no-unknown`](./src/rules/function-no-unknown/README.md): Disallow unknown functions. Should be used **instead of** Stylelint's [function-no-unknown](https://stylelint.io/user-guide/rules/function-no-unknown).
diff --git a/src/rules/function-color-channel/README.md b/src/rules/function-color-channel/README.md
new file mode 100644
index 0000000..df0e644
--- /dev/null
+++ b/src/rules/function-color-channel/README.md
@@ -0,0 +1,45 @@
+# function-color-channel
+
+Encourage the use of the [color.channel](https://sass-lang.com/documentation/modules/color#channel) function over related deprecated color functions:
+
+- [color.alpha](https://sass-lang.com/documentation/modules/color#alpha) / [alpha](https://sass-lang.com/documentation/modules/color#alpha) / [opacity](https://sass-lang.com/documentation/modules/color#opacity)
+- [color.blackness](https://sass-lang.com/documentation/modules/color#blackness) / [blackness](https://sass-lang.com/documentation/modules/color#blackness)
+- [color.blue](https://sass-lang.com/documentation/modules/color#blue) / [blue](https://sass-lang.com/documentation/modules/color#blue)
+- [color.green](https://sass-lang.com/documentation/modules/color#green) / [green](https://sass-lang.com/documentation/modules/color#green)
+- [color.hue](https://sass-lang.com/documentation/modules/color#hue) / [hue](https://sass-lang.com/documentation/modules/color#hue)
+- [color.lightness](https://sass-lang.com/documentation/modules/color#lightness) / [lightness](https://sass-lang.com/documentation/modules/color#lightness)
+- [color.red](https://sass-lang.com/documentation/modules/color#red) / [red](https://sass-lang.com/documentation/modules/color#red)
+- [color.saturation](https://sass-lang.com/documentation/modules/color#saturation) / [saturation](https://sass-lang.com/documentation/modules/color#saturation)
+- [color.whiteness](https://sass-lang.com/documentation/modules/color#whiteness)
+
+```scss
+p {
+  opacity: opacity(rgb(210, 225, 221, 0.4));
+  /**      ↑     ↑
+   * This function should be color.channel
+   */
+}
+```
+
+## Options
+
+### `true`
+
+The following patterns are considered violations:
+
+```scss
+@use "sass:color";
+p {
+  opacity: color.alpha(rgb(210, 225, 221, 0.4));
+  opacity: alpha(#abcdef80);
+  opacity: opacity($my-color);
+}
+```
+
+The following patterns are _not_ considered violations:
+
+```scss
+p {
+  opacity: color.channel(rgb(210, 225, 221, 0.4), "alpha");
+}
+```
diff --git a/src/rules/function-color-channel/index.js b/src/rules/function-color-channel/index.js
new file mode 100644
index 0000000..8a03d71
--- /dev/null
+++ b/src/rules/function-color-channel/index.js
@@ -0,0 +1,85 @@
+"use strict";
+
+const valueParser = require("postcss-value-parser");
+const { utils } = require("stylelint");
+const declarationValueIndex = require("../../utils/declarationValueIndex");
+const namespace = require("../../utils/namespace");
+const ruleUrl = require("../../utils/ruleUrl");
+
+const ruleName = namespace("function-color-channel");
+
+const messages = utils.ruleMessages(ruleName, {
+  expected: "Expected the color.channel function to be used"
+});
+
+const meta = {
+  url: ruleUrl(ruleName)
+};
+
+const functionNames = new Set([
+  "color.alpha",
+  "color.blackness",
+  "color.blue",
+  "color.green",
+  "color.hue",
+  "color.lightness",
+  "color.red",
+  "color.saturation",
+  "color.whiteness",
+  "alpha",
+  "blackness",
+  "blue",
+  "green",
+  "hue",
+  "lightness",
+  "opacity",
+  "red",
+  "saturation"
+]);
+
+function isColorFunction(node) {
+  return node.type === "function" && functionNames.has(node.value);
+}
+
+function rule(primary) {
+  return (root, result) => {
+    const validOptions = utils.validateOptions(result, ruleName, {
+      actual: primary
+    });
+
+    if (!validOptions) {
+      return;
+    }
+
+    root.walkDecls(decl => {
+      const declValueIndex = declarationValueIndex(decl);
+
+      valueParser(decl.value).walk(node => {
+        if (node.type !== "function" || node.value === "") {
+          return;
+        }
+
+        const isFilter = decl.prop === "filter";
+        const isSassColorFunction = !isFilter && isColorFunction(node);
+
+        if (isSassColorFunction) {
+          const index = declValueIndex + node.sourceIndex;
+          utils.report({
+            message: messages.expected,
+            node: decl,
+            index,
+            endIndex: index + node.value.length,
+            result,
+            ruleName
+          });
+        }
+      });
+    });
+  };
+}
+
+rule.ruleName = ruleName;
+rule.messages = messages;
+rule.meta = meta;
+
+module.exports = rule;
diff --git a/src/rules/function-color-relative/index.js b/src/rules/function-color-relative/index.js
index 884cc40..363307a 100644
--- a/src/rules/function-color-relative/index.js
+++ b/src/rules/function-color-relative/index.js
@@ -9,14 +9,14 @@ const ruleUrl = require("../../utils/ruleUrl");
 const ruleName = namespace("function-color-relative");
 
 const messages = utils.ruleMessages(ruleName, {
-  rejected: "Expected the scale-color function to be used"
+  expected: "Expected the scale-color function to be used"
 });
 
 const meta = {
   url: ruleUrl(ruleName)
 };
 
-const function_names = [
+const functionNames = new Set([
   "saturate",
   "desaturate",
   "darken",
@@ -25,10 +25,10 @@ const function_names = [
   "fade-in",
   "transparentize",
   "fade-out"
-];
+]);
 
 function isColorFunction(node) {
-  return node.type === "function" && function_names.includes(node.value);
+  return node.type === "function" && functionNames.has(node.value);
 }
 
 function rule(primary) {
@@ -65,7 +65,7 @@ function rule(primary) {
           funcNodes.forEach(funcNode => {
             const index = declValueIndex + funcNode.sourceIndex;
             utils.report({
-              message: messages.rejected,
+              message: messages.expected,
               node: decl,
               index,
               endIndex: index + funcNode.value.length,
diff --git a/src/rules/index.js b/src/rules/index.js
index b8afe6b..b3cc39b 100644
--- a/src/rules/index.js
+++ b/src/rules/index.js
@@ -53,6 +53,7 @@ const rules = {
   "double-slash-comment-whitespace-inside": require("./double-slash-comment-whitespace-inside"),
   "function-disallowed-list": require("./function-disallowed-list"),
   "function-calculation-no-interpolation": require("./function-calculation-no-interpolation"),
+  "function-color-channel": require("./function-color-channel"),
   "function-color-relative": require("./function-color-relative"),
   "function-no-unknown": require("./function-no-unknown"),
   "function-quote-no-quoted-strings-inside": require("./function-quote-no-quoted-strings-inside"),


Test patch:
diff --git a/src/rules/function-color-channel/__tests__/index.js b/src/rules/function-color-channel/__tests__/index.js
new file mode 100644
index 0000000..a139fc4
--- /dev/null
+++ b/src/rules/function-color-channel/__tests__/index.js
@@ -0,0 +1,291 @@
+"use strict";
+
+const { messages, ruleName } = require("..");
+testRule({
+  ruleName,
+  config: [true],
+  customSyntax: "postcss-scss",
+
+  accept: [
+    {
+      code: `
+        @use 'sass:color';
+        p {
+          opacity: color.channel(hsl(80deg 30% 50%), "opacity");
+        }
+      `,
+      description: "accepts the color.channel function"
+    },
+    {
+      code: `
+        p {
+          color: scale-color(blue, $alpha: -40%);
+        }
+      `,
+      description: "accepts the scale-color function"
+    },
+    {
+      code: `
+        p {
+          filter: opacity(50%);
+        }
+      `,
+      description: "ignores a color function inside the filter property"
+    },
+    {
+      code: `
+        p {
+          filter: drop-shadow(0 0 5px black) opacity(50%);
+        }
+      `,
+      description:
+        "ignores a color function inside the filter property when multiple values are used"
+    }
+  ],
+
+  reject: [
+    {
+      code: `
+        @use 'sass:color';
+        p {
+          opacity: color.alpha(#e1d7d2);
+        }
+      `,
+      description: "does not accept the color.alpha function",
+      message: messages.expected,
+      line: 4,
+      column: 20,
+      endLine: 4,
+      endColumn: 31
+    },
+    {
+      code: `
+        p {
+          opacity: alpha(#e1d7d2);
+        }
+      `,
+      description: "does not accept the alpha function",
+      message: messages.expected,
+      line: 3,
+      column: 20,
+      endLine: 3,
+      endColumn: 25
+    },
+    {
+      code: `
+        p {
+          opacity: opacity(#e1d7d2);
+        }
+      `,
+      description: "does not accept the opacity function",
+      message: messages.expected,
+      line: 3,
+      column: 20,
+      endLine: 3,
+      endColumn: 27
+    },
+    {
+      code: `
+        @use 'sass:color';
+        p {
+          opacity: color.blackness(#e1d7d2);
+        }
+      `,
+      description: "does not accept the color.blackness function",
+      message: messages.expected,
+      line: 4,
+      column: 20,
+      endLine: 4,
+      endColumn: 35
+    },
+    {
+      code: `
+        p {
+          opacity: blackness(#e1d7d2);
+        }
+      `,
+      description: "does not accept the blackness function",
+      message: messages.expected,
+      line: 3,
+      column: 20,
+      endLine: 3,
+      endColumn: 29
+    },
+    {
+      code: `
+        @use 'sass:color';
+        p {
+          opacity: color.blue(#e1d7d2);
+        }
+      `,
+      description: "does not accept the color.blue function",
+      message: messages.expected,
+      line: 4,
+      column: 20,
+      endLine: 4,
+      endColumn: 30
+    },
+    {
+      code: `
+        p {
+          opacity: blue(#e1d7d2);
+        }
+      `,
+      description: "does not accept the blue function",
+      message: messages.expected,
+      line: 3,
+      column: 20,
+      endLine: 3,
+      endColumn: 24
+    },
+    {
+      code: `
+        @use 'sass:color';
+        p {
+          opacity: color.green(#e1d7d2);
+        }
+      `,
+      description: "does not accept the color.green function",
+      message: messages.expected,
+      line: 4,
+      column: 20,
+      endLine: 4,
+      endColumn: 31
+    },
+    {
+      code: `
+        p {
+          opacity: green(#e1d7d2);
+        }
+      `,
+      description: "does not accept the green function",
+      message: messages.expected,
+      line: 3,
+      column: 20,
+      endLine: 3,
+      endColumn: 25
+    },
+    {
+      code: `
+        @use 'sass:color';
+        p {
+          opacity: color.hue(#e1d7d2);
+        }
+      `,
+      description: "does not accept the color.hue function",
+      message: messages.expected,
+      line: 4,
+      column: 20,
+      endLine: 4,
+      endColumn: 29
+    },
+    {
+      code: `
+        p {
+          opacity: hue(#e1d7d2);
+        }
+      `,
+      description: "does not accept the hue function",
+      message: messages.expected,
+      line: 3,
+      column: 20,
+      endLine: 3,
+      endColumn: 23
+    },
+    {
+      code: `
+        @use 'sass:color';
+        p {
+          opacity: color.lightness(#e1d7d2);
+        }
+      `,
+      description: "does not accept the color.lightness function",
+      message: messages.expected,
+      line: 4,
+      column: 20,
+      endLine: 4,
+      endColumn: 35
+    },
+    {
+      code: `
+        p {
+          opacity: lightness(#e1d7d2);
+        }
+      `,
+      description: "does not accept the lightness function",
+      message: messages.expected,
+      line: 3,
+      column: 20,
+      endLine: 3,
+      endColumn: 29
+    },
+    {
+      code: `
+        @use 'sass:color';
+        p {
+          opacity: color.red(#e1d7d2);
+        }
+      `,
+      description: "does not accept the color.red function",
+      message: messages.expected,
+      line: 4,
+      column: 20,
+      endLine: 4,
+      endColumn: 29
+    },
+    {
+      code: `
+        p {
+          opacity: red(#e1d7d2);
+        }
+      `,
+      description: "does not accept the red function",
+      message: messages.expected,
+      line: 3,
+      column: 20,
+      endLine: 3,
+      endColumn: 23
+    },
+    {
+      code: `
+        @use 'sass:color';
+        p {
+          opacity: color.saturation(#e1d7d2);
+        }
+      `,
+      description: "does not accept the color.saturation function",
+      message: messages.expected,
+      line: 4,
+      column: 20,
+      endLine: 4,
+      endColumn: 36
+    },
+    {
+      code: `
+        p {
+          opacity: saturation(#e1d7d2);
+        }
+      `,
+      description: "does not accept the saturation function",
+      message: messages.expected,
+      line: 3,
+      column: 20,
+      endLine: 3,
+      endColumn: 30
+    },
+    {
+      code: `
+        @use 'sass:color';
+        p {
+          opacity: color.whiteness(#e1d7d2);
+        }
+      `,
+      description: "does not accept the color.whiteness function",
+      message: messages.expected,
+      line: 4,
+      column: 20,
+      endLine: 4,
+      endColumn: 35
+    }
+  ]
+});
diff --git a/src/rules/function-color-relative/__tests__/index.js b/src/rules/function-color-relative/__tests__/index.js
index 24e9da6..951233f 100644
--- a/src/rules/function-color-relative/__tests__/index.js
+++ b/src/rules/function-color-relative/__tests__/index.js
@@ -13,7 +13,7 @@ testRule({
           color: scale-color(blue, $alpha: -40%);
         }
       `,
-      description: "accepts the scalar-color function"
+      description: "accepts the scale-color function"
     },
     {
       code: `
@@ -42,7 +42,7 @@ testRule({
         }
       `,
       description: "does not accept the saturate function",
-      message: messages.rejected,
+      message: messages.expected,
       line: 3,
       column: 18,
       endLine: 3,
@@ -55,7 +55,7 @@ testRule({
         }
       `,
       description: "does not accept the desaturate function",
-      message: messages.rejected,
+      message: messages.expected,
       line: 3,
       column: 18,
       endLine: 3,
@@ -68,7 +68,7 @@ testRule({
         }
       `,
       description: "does not accept the darken function",
-      message: messages.rejected,
+      message: messages.expected,
       line: 3,
       column: 18,
       endLine: 3,
@@ -81,7 +81,7 @@ testRule({
         }
       `,
       description: "does not accept the lighten function",
-      message: messages.rejected,
+      message: messages.expected,
       line: 3,
       column: 18,
       endLine: 3,
@@ -94,7 +94,7 @@ testRule({
         }
       `,
       description: "does not accept the opacify function",
-      message: messages.rejected,
+      message: messages.expected,
       line: 3,
       column: 18,
       endLine: 3,
@@ -107,7 +107,7 @@ testRule({
         }
       `,
       description: "does not accept the fade-in function",
-      message: messages.rejected,
+      message: messages.expected,
       line: 3,
       column: 18,
       endLine: 3,
@@ -120,7 +120,7 @@ testRule({
         }
       `,
       description: "does not accept the transparentize function",
-      message: messages.rejected,
+      message: messages.expected,
       line: 3,
       column: 18,
       endLine: 3,
@@ -133,7 +133,7 @@ testRule({
         }
       `,
       description: "does not accept the fade-out function",
-      message: messages.rejected,
+      message: messages.expected,
       line: 3,
       column: 18,
       endLine: 3,
@@ -147,7 +147,7 @@ testRule({
       `,
       description:
         "does not accept color functions inside a drop-shadow filter",
-      message: messages.rejected,
+      message: messages.expected,
       line: 3,
       column: 39,
       endLine: 3,
@@ -161,7 +161,7 @@ testRule({
       `,
       description:
         "does not accept color functions inside a drop-shadow filter when multiple filters are used",
-      message: messages.rejected,
+      message: messages.expected,
       line: 3,
       column: 54,
       endLine: 3,
@@ -175,7 +175,7 @@ testRule({
       `,
       description:
         "does not accept color functions inside a drop-shadow filter when multiple filters are used",
-      message: messages.rejected,
+      message: messages.expected,
       line: 3,
       column: 53,
       endLine: 3,
@@ -189,7 +189,7 @@ testRule({
       `,
       description:
         "does not accept color functions inside a drop-shadow filter when multiple filters are used",
-      message: messages.rejected,
+      message: messages.expected,
       line: 3,
       column: 39,
       endLine: 3,