Repository: stylelint-scss/stylelint-scss
Pull request #849 (2023-08-29 21:59:22)

Add function-calculation-no-interpolation rule
Fix #539.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e07e624..56f87ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+# 5.2.0
+
+- Added: `function-calculation-no-interpolation` rule to forbid interpolation in calc functions (#539).
+
+**Full Changelog**: https://github.com/stylelint-scss/stylelint-scss/compare/v5.1.0...v5.2.0
+
 # 5.1.0
 
 - Added: `function-disallowed-list` rule support to ban specific built-in functions (#422, #844).
diff --git a/README.md b/README.md
index dee7946..078723f 100644
--- a/README.md
+++ b/README.md
@@ -30,9 +30,7 @@ Create the `.stylelintrc.json` config file (or open the existing one), add `styl
 
 ```jsonc
 {
-  "plugins": [
-    "stylelint-scss"
-  ],
+  "plugins": ["stylelint-scss"],
   "rules": {
     // recommended rules
     "at-rule-no-unknown": null,
@@ -145,6 +143,7 @@ Please also see the [example configs](./docs/examples/) for special cases.
 - [`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).
+- [`function-calculation-no-interpolation`](./src/rules/function-calculation-no-interpolation/README.md): Forbids interpolation in `calc()`, `clamp()`, `min()`, and `max()` functions.
 - [`function-quote-no-quoted-strings-inside`](./src/rules/function-quote-no-quoted-strings-inside/README.md): Disallow quoted strings inside the [quote function](https://sass-lang.com/documentation/modules/string#quote) (Autofixable).
 - [`function-unquote-no-unquoted-strings-inside`](./src/rules/function-unquote-no-unquoted-strings-inside/README.md): Disallow unquoted strings inside the [unquote function](https://sass-lang.com/documentation/modules/string#unquote) (Autofixable).
 
diff --git a/src/rules/function-calculation-no-interpolation/README.md b/src/rules/function-calculation-no-interpolation/README.md
new file mode 100644
index 0000000..729bd55
--- /dev/null
+++ b/src/rules/function-calculation-no-interpolation/README.md
@@ -0,0 +1,64 @@
+# function-calculation-no-interpolation
+
+Disallow interpolation in `calc()`, `clamp()`, `min()`, and `max()`.
+
+<!-- prettier-ignore -->
+```scss
+.a { .b: calc(#{$c} + 1); }
+/**            ↑
+      * This argument */
+```
+
+Since the release of [first-class `calc()`](https://sass-lang.com/documentation/values/calculations/),
+calculation functions `calc()`, `clamp()`, `min()`, and `max()` accept variables
+and function calls as arguments. This rule disallows interpolation to avoid
+extra verbose or even invalid CSS.
+
+## Options
+
+### `true`
+
+The following patterns are considered warnings:
+
+<!-- prettier-ignore -->
+```scss
+$c: 1;
+.a { .b: calc(#{$c + 1}); }
+```
+
+<!-- prettier-ignore -->
+```scss
+$c: 1;
+.a { .b: calc(max(#{$c})); }
+```
+
+<!-- prettier-ignore -->
+```scss
+$c: 1;
+.a { .b: min(#{$c}); }
+```
+
+<!-- prettier-ignore -->
+```scss
+$c: 1;
+.a { .b: clamp(#{$c} + 2px); }
+```
+
+The following patterns are _not_ considered warnings:
+
+<!-- prettier-ignore -->
+```scss
+.a { .b: calc(1 + 1); }
+```
+
+<!-- prettier-ignore -->
+```scss
+$c: 1;
+.a { .b: abc(#{$c} + 1px); }
+```
+
+<!-- prettier-ignore -->
+```scss
+$c: 1;
+.a { .b: calc(abc(#{$c})); }
+```
diff --git a/src/rules/function-calculation-no-interpolation/index.js b/src/rules/function-calculation-no-interpolation/index.js
new file mode 100644
index 0000000..0d3902e
--- /dev/null
+++ b/src/rules/function-calculation-no-interpolation/index.js
@@ -0,0 +1,56 @@
+"use strict";
+
+const { utils } = require("stylelint");
+const namespace = require("../../utils/namespace");
+const ruleUrl = require("../../utils/ruleUrl");
+const valueParser = require("postcss-value-parser");
+
+const ruleName = namespace("function-calculation-no-interpolation");
+
+const messages = utils.ruleMessages(ruleName, {
+  rejected: func => `Unexpected interpolation in "${func}".`
+});
+
+const meta = {
+  url: ruleUrl(ruleName)
+};
+
+function rule(actual) {
+  return (root, result) => {
+    const validOptions = utils.validateOptions(result, ruleName, { actual });
+
+    if (!validOptions) {
+      return;
+    }
+
+    const calculationFunctions = ["calc", "max", "min", "clamp"];
+
+    root.walkDecls(decl => {
+      valueParser(decl.value).walk(node => {
+        if (node.type !== "function" || node.value === "") {
+          return;
+        }
+        if (
+          calculationFunctions.includes(node.value) &&
+          node.nodes.some(
+            args => args.type === "word" && /^#{.*|\s*}/.test(args.value)
+          )
+        ) {
+          utils.report({
+            message: messages.rejected(node.value),
+            node: decl,
+            word: decl.name,
+            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 fcdbe99..b1ac11b 100644
--- a/src/rules/index.js
+++ b/src/rules/index.js
@@ -43,6 +43,7 @@ const rules = {
   "double-slash-comment-inline": require("./double-slash-comment-inline"),
   "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-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-calculation-no-interpolation/__tests__/index.js b/src/rules/function-calculation-no-interpolation/__tests__/index.js
new file mode 100644
index 0000000..fa91900
--- /dev/null
+++ b/src/rules/function-calculation-no-interpolation/__tests__/index.js
@@ -0,0 +1,82 @@
+"use strict";
+
+const { messages, ruleName } = require("..");
+
+testRule({
+  ruleName,
+  config: [true],
+  customSyntax: "postcss-scss",
+
+  accept: [
+    {
+      code: `.a { .b: calc(1 + 1); }`,
+      description: "`calc` function, no interpolation"
+    },
+    {
+      code: `
+      $c: 1;
+      .a { .b: abc(#{$c} + 1px); }
+      `,
+      description: "Allowed function with interpolation"
+    },
+    {
+      code: `
+      $c: 1;
+      .a { .b: calc(abc(#{$c})); }
+      `,
+      description: "Allowed function with interpolation nested in `calc`"
+    }
+  ],
+  reject: [
+    {
+      code: `
+      $c: 1;
+      .a { .b: calc(#{$c} + 1); }
+      `,
+      line: 3,
+      column: 12,
+      message: messages.rejected("calc"),
+      description: "`calc` function one argument interpolated"
+    },
+    {
+      code: `
+      $c: 1;
+      .a { .b: calc(#{$c + 1}); }
+      `,
+      line: 3,
+      column: 12,
+      message: messages.rejected("calc"),
+      description: "`calc` function all arguments interpolated"
+    },
+    {
+      code: `
+      $c: 1;
+      .a { .b: calc(max(#{$c})); }
+      `,
+      line: 3,
+      column: 12,
+      message: messages.rejected("max"),
+      description: "`max` function with interpolation"
+    },
+    {
+      code: `
+      $c: 1;
+      .a { .b: min(#{$c} + 1px); }
+      `,
+      line: 3,
+      column: 12,
+      message: messages.rejected("min"),
+      description: "`min` function with interpolation"
+    },
+    {
+      code: `
+      $c: 1;
+      .a { .b: clamp(#{$c} + #{$d}); }
+      `,
+      line: 3,
+      column: 12,
+      message: messages.rejected("clamp"),
+      description: "`clamp` function with interpolation"
+    }
+  ]
+});