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

Issue:
`operator-no-unspaced` registring `*` as operator although it is used as a selector
Hi, this looks to me as a false positive:

SCSS code: 
```scss
.element {
    @supports selector(:has(*)) {
        opacity: 0;    
    }
}
```
stylelintrc config: 
```json
{
  "plugins": [
    "stylelint-scss"
  ],
  "rules": {
    "scss/operator-no-unspaced": true
  }
}
```
Error:
```
2:26  ✖  Expected single space before "*"  scss/operator-no-unspaced
2:26  ✖  Expected single space after "*"   scss/operator-no-unspaced
```

Wrapping the `*` with space, to me seems unnecessary here and prettier would try to remove them.


reproduce issue via https://github.com/mbehzad/stylelint-scss-operator-no-unspaced-issue

Pull request:
operator-no-unspaced: add support for :has
Fixes #709

Patch:
diff --git a/src/utils/sassValueParser/index.js b/src/utils/sassValueParser/index.js
index 5508fd0..c517409 100644
--- a/src/utils/sassValueParser/index.js
+++ b/src/utils/sassValueParser/index.js
@@ -158,7 +158,7 @@ export function mathOperatorCharType(string, index, isAfterColon) {
 
     // ---- Processing * character
     if (character === "*") {
-      return "op";
+      return checkMultiplication(string, index);
     }
 
     // ---- Processing % character
@@ -180,6 +180,34 @@ export function mathOperatorCharType(string, index, isAfterColon) {
 // Functions for checking particular characters (+, -, /)
 // --------------------------------------------------------------------------
 
+/**
+ * Checks the specified `*` char type: operator, sign (*), part of string
+ *
+ * @param {String} string - the source string
+ * @param {Number} index - the index of the character in string to check
+ * @return {String|false}
+ *    • "op", if the character is a operator in a math/string operation
+ *    • "sign" if it is a sign before a positive number,
+ *    • "char" if it is a part of a string or identifier,
+ *    • false - if it is none from above (most likely an error)
+ */
+function checkMultiplication(string, index) {
+  const insideFn = isInsideFunctionCall(string, index);
+
+  if (insideFn.is && insideFn.fn) {
+    const fnArgsReg = new RegExp(insideFn.fn + "\\(([^)]+)\\)");
+    const fnArgs = string.match(fnArgsReg);
+    const isSingleMultiplicationChar =
+      Array.isArray(fnArgs) && fnArgs[1] === "*";
+    // e.g. selector(:has(*))
+    if (isSingleMultiplicationChar) {
+      return "char";
+    }
+  }
+
+  return "op";
+}
+
 /**
  * Checks the specified `+` char type: operator, sign (+ or -), part of string
  *
@@ -350,6 +378,11 @@ function checkMinus(string, index) {
 
     // e.g. `#{10px -1}`, `#{math.acos(-0.5)}`
     if (isInsideInterpolation(string, index)) {
+      // e.g. `url(https://my-url.com/image-#{$i -2}-dark.svg)`
+      if (isInsideFunctionCall_.fn === "url") {
+        return "op";
+      }
+
       if (
         isInsideFunctionCall_.is &&
         ((isValueWithUnitAfter_.is && !isValueWithUnitAfter_.opsBetween) ||
@@ -518,7 +551,11 @@ function checkSlash(string, index, isAfterColon) {
   // e.g. `(1px/1)`, `fn(7 / 15)`, but not `url(8/11)`
   const isInsideFn = isInsideFunctionCall(string, index);
 
-  if (isInsideFn.is && isInsideFn.fn === "url" && isProtocolBefore(before)) {
+  if (isInsideFn.is && isInsideFn.fn === "url") {
+    // e.g. `url(https://my-url.com/image-#{$i /2}-dark.svg)`
+    if (isInsideInterpolation(string, index)) {
+      return "op";
+    }
     return "char";
   }
 
@@ -693,11 +730,13 @@ function isInsideInterpolation(string, index) {
  *    {Boolean} return.is - if inside a function arguments
  *    {String} return.fn - function name
  */
-function isInsideFunctionCall(string, index) {
+export function isInsideFunctionCall(string, index) {
   const result = { is: false, fn: null };
   const before = string.substring(0, index).trim();
   const after = string.substring(index + 1).trim();
-  const beforeMatch = before.match(/([a-zA-Z_-][\w-]*)\([^(){}]+$/);
+  const beforeMatch = before.match(
+    /(?:[a-zA-Z_-][\w-]*\()?(:?[a-zA-Z_-][\w-]*)\(/
+  );
 
   if (beforeMatch && beforeMatch[0] && after.search(/^[^(,]+\)/) !== -1) {
     result.is = true;
@@ -895,10 +934,6 @@ function isDotBefore(before) {
   return before.slice(-1) === ".";
 }
 
-function isProtocolBefore(before) {
-  return before.search(/https?:/) !== -1;
-}
-
 function isFunctionBefore(before) {
   return before.trim().search(/[\w-]\(.*?\)\s*$/) !== -1;
 }


Test patch:
diff --git a/src/rules/operator-no-unspaced/__tests__/index.js b/src/rules/operator-no-unspaced/__tests__/index.js
index 2e8bf2d..8ebdcda 100644
--- a/src/rules/operator-no-unspaced/__tests__/index.js
+++ b/src/rules/operator-no-unspaced/__tests__/index.js
@@ -1323,6 +1323,16 @@ testRule({
       }
       `,
       description: "ignores @at-root"
+    },
+    {
+      code: `
+      .element {
+        @supports selector(:has(*)) {
+          opacity: 0;
+        }
+      }
+      `,
+      description: "issue #709"
     }
   ],
 
diff --git a/src/utils/__tests__/isInsideFunctionCall.test.js b/src/utils/__tests__/isInsideFunctionCall.test.js
new file mode 100644
index 0000000..c87ccc9
--- /dev/null
+++ b/src/utils/__tests__/isInsideFunctionCall.test.js
@@ -0,0 +1,70 @@
+import { isInsideFunctionCall } from "../sassValueParser";
+
+describe("isInsideFunctionCall", () => {
+  it("should handle operators/signs inside url functions", () => {
+    const urlFunctions = [
+      {
+        string: "url(../../img/build/svg/arrow-11-down-dark.svg)",
+        index: 13
+      },
+      {
+        string: "url(../../img/build/svg/arrow-#{$i + 2}-down-dark.svg)",
+        index: 13
+      },
+      {
+        string: "url(https://99-0a.x.y.rackcdn.com/z.jpg)",
+        index: 11
+      },
+      {
+        string:
+          "url(../../img/build/svg/arrow-11-down-dark.svg), url(../../img/build/svg/arrow-11-down-dark.svg)",
+        index: 13
+      },
+      {
+        string:
+          "url(https://99-0a.x.y.rackcdn.com/img/build/svg/arrow-#{$i / 2}-down-dark.svg)",
+        index: 14
+      },
+      {
+        string: "url(../../img/build/svg/arrow-#{$i /2}-down-dark.svg)",
+        index: 13
+      },
+      {
+        string:
+          "url(https://99-0a.x.y.rackcdn.com/img/build/svg/arrow-#{$i * 2}-down-dark.svg)",
+        index: 14
+      },
+      {
+        string:
+          "url(https://99-0a.x.y.rackcdn.com/img/build/svg/arrow-#{$i % 2}-down-dark.svg)",
+        index: 14
+      },
+      {
+        string:
+          "url(https://99-0a.x.y.rackcdn.com/img/build/svg/arrow-#{$i %2}-down-dark.svg)",
+        index: 59
+      }
+    ];
+
+    urlFunctions.forEach(test => {
+      expect(isInsideFunctionCall(test.string, test.index).fn).toBe("url");
+    });
+  });
+
+  it("should handle operators/signs inside translate function", () => {
+    expect(
+      isInsideFunctionCall("translate(-50%, -$no-ui-slider-height)", 16).fn
+    ).toBe("translate");
+  });
+
+  it("should handle operators/signs that are interpolated", () => {
+    expect(isInsideFunctionCall("#{math.acos(0.7-0.5)}", 15).fn).toBe("acos");
+    expect(
+      isInsideFunctionCall("#{scale-color(#fff, $lightness: -75%)}", 32).fn
+    ).toBe("scale-color");
+  });
+
+  it("should handle nested functions", () => {
+    expect(isInsideFunctionCall("selector(:has(*))", 14).fn).toBe(":has");
+  });
+});