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

Issue:
Add `no-duplicate-at-import-rules` to support other load rules.
`no-duplicate-load-rules` would disallow duplicate `@import`, `@use`, and `@forward` rules.

Pull request:
Add `no-duplicate-load-rules` rule.
Closes #1079

Patch:
diff --git a/src/rules/index.js b/src/rules/index.js
index b3cc39b..fd9dfce 100644
--- a/src/rules/index.js
+++ b/src/rules/index.js
@@ -62,6 +62,7 @@ const rules = {
   "media-feature-value-dollar-variable": require("./media-feature-value-dollar-variable"),
   "no-dollar-variables": require("./no-dollar-variables"),
   "no-duplicate-dollar-variables": require("./no-duplicate-dollar-variables"),
+  "no-duplicate-load-rules": require("./no-duplicate-load-rules"),
   "no-duplicate-mixins": require("./no-duplicate-mixins"),
   "no-global-function-names": require("./no-global-function-names"),
   "no-unused-private-members": require("./no-unused-private-members"),
diff --git a/src/rules/no-duplicate-load-rules/README.md b/src/rules/no-duplicate-load-rules/README.md
new file mode 100644
index 0000000..f4bb41a
--- /dev/null
+++ b/src/rules/no-duplicate-load-rules/README.md
@@ -0,0 +1,143 @@
+# no-duplicate-load-rules
+
+Disallow duplicate `@import`, `@use` and `@forward` rules.
+
+<!-- prettier-ignore -->
+```css
+    @import "a.css";
+    @import "a.css";
+/** ↑
+ * These are duplicates */
+```
+
+<!-- prettier-ignore -->
+```scss
+    @use "a" as a;
+    @use "a" as b;
+/** ↑
+ * These are duplicates */
+```
+
+<!-- prettier-ignore -->
+```scss
+    @forward "a" with ($blue: #333);
+    @forward "a" with ($blue: #333);
+/** ↑
+ * With @forward, they are only considered duplicates if they have the exact same confuguration. */
+```
+
+The [`message` secondary option](https://github.com/stylelint/stylelint/blob/main/docs/user-guide/configure.md#message) can accept the arguments of this rule.
+
+## Options
+
+### `true`
+
+The following patterns are considered problems:
+
+<!-- prettier-ignore -->
+```css
+@import 'a.css';
+@import 'a.css';
+```
+
+<!-- prettier-ignore -->
+```css
+@import url("a.css");
+@import url("a.css");
+```
+
+<!-- prettier-ignore -->
+```css
+@import "a.css";
+@import 'a.css';
+```
+
+<!-- prettier-ignore -->
+```css
+@import "a.css";
+@import 'b.css';
+@import url(a.css);
+```
+
+<!-- prettier-ignore -->
+```scss
+@use "a";
+@use "a";
+```
+
+<!-- prettier-ignore -->
+```scss
+@use "a" as abc;
+@use "a" as xyz;
+```
+
+<!-- prettier-ignore -->
+```scss
+@use 'a' with (
+$black: #222,
+$border-radius: 0.1rem
+);
+@use 'a' with ($blue: #333);
+```
+
+<!-- prettier-ignore -->
+```scss
+@forward 'a' with ($blue: #333);
+@forward 'a' with ($blue: #333);
+```
+
+<!-- prettier-ignore -->
+```scss
+@forward "a" hide $vertical-list-gap;
+@forward "a" hide $vertical-list-gap;
+```
+
+The following patterns are _not_ considered problems:
+
+<!-- prettier-ignore -->
+```css
+@import "a.css";
+@import "b.css";
+```
+
+<!-- prettier-ignore -->
+```css
+@import url('a.css') projection;
+@import url('a.css') tv;
+```
+
+<!-- prettier-ignore -->
+```scss
+@use "a";
+@use "b";
+```
+
+<!-- prettier-ignore -->
+```scss
+@use "a" as abc;
+@use "b" as xyz;
+```
+
+<!-- prettier-ignore -->
+```scss
+@use 'a' with (
+$black: #222,
+$border-radius: 0.1rem
+);
+@use 'b' with ($blue: #333);
+```
+
+<!-- prettier-ignore -->
+```scss
+@forward 'a' with (
+$black: #222,
+$border-radius: 0.1rem
+);
+@forward 'a' with ($blue: #333);
+```
+
+<!-- prettier-ignore -->
+```scss
+@forward "a" hide list-reset, $horizontal-list-gap;
+@forward "a" hide $vertical-list-gap;
+```
diff --git a/src/rules/no-duplicate-load-rules/index.js b/src/rules/no-duplicate-load-rules/index.js
new file mode 100644
index 0000000..23269c1
--- /dev/null
+++ b/src/rules/no-duplicate-load-rules/index.js
@@ -0,0 +1,161 @@
+"use strict";
+
+const { utils } = require("stylelint");
+const valueParser = require("postcss-value-parser");
+const getAtRuleParams = require("../../utils/getAtRuleParams");
+const namespace = require("../../utils/namespace");
+const ruleUrl = require("../../utils/ruleUrl");
+
+const ruleName = namespace("no-duplicate-load-rules");
+const messages = utils.ruleMessages(ruleName, {
+  rejected: duplicate => `Unexpected duplicate load rule ${duplicate}`
+});
+
+const meta = {
+  url: ruleUrl(ruleName)
+};
+
+function rule(primary) {
+  return (root, result) => {
+    const validOptions = utils.validateOptions(result, ruleName, {
+      actual: primary
+    });
+
+    if (!validOptions) {
+      return;
+    }
+
+    const imports = {};
+    const hasExplicitNamespace = new RegExp(
+      /\s+(as|with|show|hide)\s+\(?[^;]*\)?/g
+    );
+
+    root.walkAtRules(/^(forward|import|use)$/i, atRule => {
+      // Ignore explicit namespaces for @use
+      const [firstParam, ...restParams] = valueParser(
+        getAtRuleParams(
+          atRule.name !== "forward"
+            ? {
+                ...atRule,
+                params: atRule.params.replace(hasExplicitNamespace, "")
+              }
+            : atRule
+        )
+      ).nodes;
+
+      if (!firstParam) {
+        return;
+      }
+
+      // extract uri from url() if exists
+      const uri =
+        firstParam.type === "function" &&
+        firstParam.value === "url" &&
+        firstParam.nodes[0]
+          ? firstParam.nodes[0].value
+          : firstParam.value;
+
+      const media = listImportConditions(restParams);
+
+      let importedUris = imports[uri];
+      const isDuplicate = media.length
+        ? media.some(q => importedUris && importedUris.includes(q))
+        : importedUris;
+
+      if (isDuplicate) {
+        utils.report({
+          message: messages.rejected,
+          messageArgs: [uri],
+          node: atRule,
+          result,
+          ruleName,
+          word: atRule.toString()
+        });
+
+        return;
+      }
+
+      if (!importedUris) {
+        importedUris = imports[uri] = [];
+      }
+
+      importedUris.push(...media);
+    });
+  };
+}
+
+function stringifyCondition(node) {
+  // remove whitespace to get a more consistent key
+  return valueParser.stringify(node).replace(/\s/g, "");
+}
+
+function listImportConditions(params) {
+  if (!params.length) return [];
+
+  const separator = " ";
+  /** @type {Array<string>} */
+  const sharedConditions = [];
+  /** @type {Array<string>} */
+  const media = [];
+  /** @type {Array<Node>} */
+  let lastMediaQuery = [];
+
+  for (const param of params) {
+    // remove top level whitespace and comments to get a more consistent key
+    if (param.type === "space" || param.type === "comment") {
+      continue;
+    }
+
+    // layer and supports conditions must precede media query conditions
+    if (!media.length) {
+      // @import url(...) layer(base) supports(display: flex)
+      if (
+        param.type === "function" &&
+        (param.value === "supports" || param.value === "layer")
+      ) {
+        sharedConditions.push(stringifyCondition(param));
+        continue;
+      }
+
+      // @import url(...) layer
+      if (param.type === "word" && param.value === "layer") {
+        sharedConditions.push(stringifyCondition(param));
+        continue;
+      }
+    }
+
+    if (param.type === "div" && param.value === ",") {
+      media.push(stringifyCondition(lastMediaQuery));
+      lastMediaQuery = [];
+      continue;
+    }
+
+    lastMediaQuery.push(param);
+  }
+
+  if (lastMediaQuery.length) {
+    media.push(stringifyCondition(lastMediaQuery));
+  }
+
+  // Only media query conditions
+  if (media.length && !sharedConditions.length) {
+    return media;
+  }
+
+  // Only layer and supports conditions
+  if (!media.length && sharedConditions.length) {
+    return [sharedConditions.join(separator)];
+  }
+
+  const sharedConditionsString = sharedConditions.join(separator);
+
+  return media.map(m => {
+    return sharedConditionsString + separator + m;
+  });
+}
+
+rule.ruleName = ruleName;
+rule.messages = messages;
+rule.meta = meta;
+
+module.exports = rule;


Test patch:
diff --git a/src/rules/no-duplicate-load-rules/__tests__/index.js b/src/rules/no-duplicate-load-rules/__tests__/index.js
new file mode 100644
index 0000000..f412b9e
--- /dev/null
+++ b/src/rules/no-duplicate-load-rules/__tests__/index.js
@@ -0,0 +1,410 @@
+"use strict";
+
+const { messages, ruleName } = require("..");
+
+// @import
+testRule({
+  ruleName,
+  config: [true],
+
+  accept: [
+    {
+      code: '@import "a.css";'
+    },
+    {
+      code: "@import url('a.css');"
+    },
+    {
+      code: "@import url(a.css);"
+    },
+    {
+      code: '@import url("a.css") projection, tv;'
+    },
+    {
+      code: "@import 'a.css'; @import 'b.css';"
+    },
+    {
+      code: "@import url('a.css') projection; @import url('a.css') tv;"
+    },
+    {
+      code: '@import "a.css" screen; @import "b.css" tv; @import "a.css" tv;'
+    },
+    {
+      code: '@IMPORT "a.css"; @ImPoRt "b.css";'
+    },
+    {
+      code: '@import "a.css" supports(display: flex) tv; @import "a.css" layer tv;'
+    },
+    {
+      code: '@import "a.css" supports(display: flex); @import "a.css" layer;'
+    },
+    {
+      code: '@import "a.css" supports(display: flex) tv; @import "a.css" supports(display: grid) tv;'
+    }
+  ],
+
+  reject: [
+    {
+      code: "@import 'a.css'; @import 'a.css';",
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 18,
+      endLine: 1,
+      endColumn: 33
+    },
+    {
+      code: '@import url("a.css"); @import url("a.css");',
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 23,
+      endLine: 1,
+      endColumn: 43
+    },
+    {
+      code: "@import \"a.css\";\n@import 'a.css';",
+      message: messages.rejected(`a.css`),
+      line: 2,
+      column: 1,
+      endLine: 2,
+      endColumn: 16
+    },
+    {
+      code: "@import \"a.css\"; @import 'b.css'; @import url(a.css);",
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 35,
+      endLine: 1,
+      endColumn: 53
+    },
+    {
+      code: "@import url('a.css') tv; @import 'a.css' tv;",
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 26
+    },
+    {
+      code: "@import url('a.css') tv, projection; @import 'a.css' projection, tv;",
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 38
+    },
+    {
+      code: "@import url('a.css') tv, projection; @import 'a.css' projection, screen, tv;",
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 38
+    },
+    {
+      code: "@import url('a.css') tv, projection; @import 'a.css' screen, tv;",
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 38
+    },
+    {
+      code: "@import url('a.css') /* a comment */ tv; @import 'a.css' tv /* a comment */;",
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 42
+    },
+    {
+      code: '@import "a.css" (min-width : 500px);@import url(a.css) (  min-width:500px   );',
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 37
+    },
+    {
+      code: '@import "a.css" tv, (min-width : 500px);@import url(a.css) (  min-width:500px   ), tv;',
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 41
+    },
+    {
+      code: "@IMPORT 'a.css'; @ImPoRt 'a.css';",
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 18
+    },
+    {
+      code: '@import url("a.css") layer; @import url("a.css") layer;',
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 29,
+      endLine: 1,
+      endColumn: 55
+    },
+    {
+      code: '@import url("a.css") layer(base); @import url("a.css") layer(base);',
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 35,
+      endLine: 1,
+      endColumn: 67
+    },
+    {
+      code: '@import url("a.css") layer(base) supports(display: grid); @import url("a.css") layer(base) supports(display: grid);',
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 59,
+      endLine: 1,
+      endColumn: 115
+    },
+    {
+      code: '@import url("a.css") supports(display: grid); @import url("a.css") supports(display: grid);',
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 47,
+      endLine: 1,
+      endColumn: 91
+    },
+    {
+      code: '@import url("a.css") layer tv; @import url("a.css") layer tv;',
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 32,
+      endLine: 1,
+      endColumn: 61
+    },
+    {
+      code: '@import url("a.css") layer(base) tv; @import url("a.css") layer(base) tv;',
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 38,
+      endLine: 1,
+      endColumn: 73
+    },
+    {
+      code: '@import url("a.css") layer(base) supports(display: grid) tv; @import url("a.css") layer(base) supports(display: grid) tv;',
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 62,
+      endLine: 1,
+      endColumn: 121
+    },
+    {
+      code: '@import url("a.css") layer(base) supports(display: grid) screen, tv; @import url("a.css") layer(base) supports(display: grid) tv;',
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 70,
+      endLine: 1,
+      endColumn: 129
+    },
+    {
+      code: '@import url("a.css") layer; @import url("a.css") layer;',
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 29,
+      endLine: 1,
+      endColumn: 55
+    },
+    {
+      code: '@import url("a.css") supports(display: grid); @import url("a.css") supports(display: grid);',
+      message: messages.rejected(`a.css`),
+      line: 1,
+      column: 47,
+      endLine: 1,
+      endColumn: 91
+    }
+  ]
+});
+
+// @use
+testRule({
+  ruleName,
+  config: [true],
+
+  accept: [
+    {
+      code: '@use "a";'
+    },
+    {
+      code: '@use "a";\n@use "b";'
+    },
+    {
+      code: `
+      @use "sass:map" as abc;
+      @use "sass:math" as xyz;
+      `
+    },
+    {
+      code: `
+      @use 'library' with (
+        $black: #222,
+        $border-radius: 0.1rem
+      );
+      @use 'lib' with ($blue: #333);`
+    }
+  ],
+  reject: [
+    {
+      code: `
+      @use "a.css";
+      @use "a.css";
+      `,
+      message: messages.rejected("a.css"),
+      line: 3,
+      column: 7,
+      endLine: 3,
+      endColumn: 19
+    },
+    {
+      code: `
+      @use "a";
+      @use "a";
+      `,
+      message: messages.rejected("a"),
+      line: 3,
+      column: 7,
+      endLine: 3,
+      endColumn: 15
+    },
+    {
+      code: `
+      @use "sass:math" as abc;
+      @use "sass:math" as xyz;
+      `,
+      message: messages.rejected("sass:math"),
+      line: 3,
+      column: 7,
+      endLine: 3,
+      endColumn: 30
+    },
+    {
+      code: `
+      @use 'library' with (
+        $black: #222,
+        $border-radius: 0.1rem
+      );
+      @use 'library' with ($blue: #333);`,
+      message: messages.rejected("library"),
+      line: 6,
+      column: 7,
+      endLine: 6,
+      endColumn: 40
+    }
+  ]
+});
+
+// @forward
+testRule({
+  ruleName,
+  config: [true],
+
+  accept: [
+    {
+      code: '@forward "src/list";'
+    },
+    {
+      code: '@forward "a";\n@forward "b";'
+    },
+    {
+      code: `
+      @forward "a" as list-*;
+      @forward "b" as list-2;
+      `
+    },
+    {
+      code: `
+      @forward "a" hide list-reset, $horizontal-list-gap;
+      @forward "b" hide $vertical-list-gap;
+      `
+    },
+    {
+      code: `
+      @forward "a" show list-reset, $horizontal-list-gap;
+      @forward "b" show $vertical-list-gap;
+      `
+    },
+    {
+      code: `
+      @forward 'a' with (
+        $black: #222,
+        $border-radius: 0.1rem
+      );
+      @forward 'b' with ($blue: #333);`
+    },
+    {
+      code: `
+      @forward "src/list" as list-*;
+      @forward "src/list" as list-2;
+      `
+    },
+    {
+      code: `
+      @forward "a" hide list-reset, $horizontal-list-gap;
+      @forward "a" hide $vertical-list-gap;`
+    },
+    {
+      code: `
+      @forward "a" show list-reset, $horizontal-list-gap;
+      @forward "a" show $vertical-list-gap;
+      `
+    },
+    {
+      code: `
+      @forward 'src/list' with (
+        $black: #222,
+        $border-radius: 0.1rem
+      );
+      @forward 'src/list' with ($blue: #333);`
+    }
+  ],
+  reject: [
+    {
+      code: '@forward "src/list";\n@forward "src/list";',
+      message: messages.rejected("src/list"),
+      line: 2,
+      column: 1,
+      endLine: 2,
+      endColumn: 20
+    },
+    {
+      code: `
+      @forward "src/list" as list-*;
+      @forward "src/list" as list-*;
+      `,
+      message: messages.rejected("src/list"),
+      line: 3,
+      column: 7,
+      endLine: 3,
+      endColumn: 36
+    },
+    {
+      code: `
+      @forward "a" hide list-reset, $horizontal-list-gap;
+      @forward "a" hide list-reset, $horizontal-list-gap;
+      `,
+      message: messages.rejected("a"),
+      line: 3,
+      column: 7,
+      endLine: 3,
+      endColumn: 57
+    },
+    {
+      code: `
+      @forward "a" show list-reset, $horizontal-list-gap;
+      @forward "a" show list-reset, $horizontal-list-gap;
+      `,
+      message: messages.rejected("a"),
+      line: 3,
+      column: 7,
+      endLine: 3,
+      endColumn: 57
+    },
+    {
+      code: `
+      @forward 'src/list' with (
+        $black: #222,
+        $border-radius: 0.1rem
+      );
+      @forward 'src/list' with (
+        $black: #222,
+        $border-radius: 0.1rem
+      );`,
+      message: messages.rejected("src/list"),
+      line: 6,
+      column: 7,
+      endLine: 9,
+      endColumn: 8
+    }
+  ]
+});