Repository: eslint/css
Pull request #4 (2024-11-06 21:46:11)

feat: Add no-duplicate-imports rule
<!--
    Thank you for contributing!

    ESLint adheres to the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct).
-->

#### Prerequisites checklist

-   [x] I have read the [contributing guidelines](https://github.com/eslint/eslint/blob/HEAD/CONTRIBUTING.md).

<!--
    Please ensure your pull request is ready:

    - Read the pull request guide (https://eslint.org/docs/latest/contribute/pull-requests)
    - Update or create tests
    - If performance-related, include a benchmark
    - Update documentation for this change (if appropriate)
-->

<!--
    The following is required for all pull requests:
-->

#### What is the purpose of this pull request?

Add a new `no-duplicate-imports` rule.

#### What changes did you make? (Give an overview)

- Created `no-duplicate-imports` rule
- Added tests for the rule
- Added docs for the rule


#### Related Issues

<!-- include tags like "fixes #123" or "refs #123" -->

#### Is there anything you'd like reviewers to focus on?

<!-- markdownlint-disable-file MD004 -->



Patch:
diff --git a/README.md b/README.md
index a69e8cb..3cae642 100644
--- a/README.md
+++ b/README.md
@@ -56,9 +56,10 @@ export default [
 
 <!-- Rule Table Start -->
 
-| **Rule Name**                                        | **Description**        | **Recommended** |
-| :--------------------------------------------------- | :--------------------- | :-------------: |
-| [`no-empty-blocks`](./docs/rules/no-empty-blocks.md) | Disallow empty blocks. |       yes       |
+| **Rule Name**                                                  | **Description**                   | **Recommended** |
+| :------------------------------------------------------------- | :-------------------------------- | :-------------: |
+| [`no-duplicate-imports`](./docs/rules/no-duplicate-imports.md) | Disallow duplicate @import rules. |       yes       |
+| [`no-empty-blocks`](./docs/rules/no-empty-blocks.md)           | Disallow empty blocks.            |       yes       |
 
 <!-- Rule Table End -->
 
@@ -144,8 +145,8 @@ to get your logo on our READMEs and [website](https://eslint.org/sponsors).
 <h3>Platinum Sponsors</h3>
 <p><a href="https://automattic.com"><img src="https://images.opencollective.com/automattic/d0ef3e1/logo.png" alt="Automattic" height="128"></a> <a href="https://www.airbnb.com/"><img src="https://images.opencollective.com/airbnb/d327d66/logo.png" alt="Airbnb" height="128"></a></p><h3>Gold Sponsors</h3>
 <p><a href="https://trunk.io/"><img src="https://images.opencollective.com/trunkio/fb92d60/avatar.png" alt="trunk.io" height="96"></a></p><h3>Silver Sponsors</h3>
-<p><a href="https://www.jetbrains.com/"><img src="https://images.opencollective.com/jetbrains/fe76f99/logo.png" alt="JetBrains" height="64"></a> <a href="https://liftoff.io/"><img src="https://images.opencollective.com/liftoff/5c4fa84/logo.png" alt="Liftoff" height="64"></a> <a href="https://americanexpress.io"><img src="https://avatars.githubusercontent.com/u/3853301?v=4" alt="American Express" height="64"></a> <a href="https://www.workleap.com"><img src="https://avatars.githubusercontent.com/u/53535748?u=d1e55d7661d724bf2281c1bfd33cb8f99fe2465f&v=4" alt="Workleap" height="64"></a></p><h3>Bronze Sponsors</h3>
-<p><a href="https://www.wordhint.net/"><img src="https://images.opencollective.com/wordhint/be86813/avatar.png" alt="WordHint" height="32"></a> <a href="https://www.crosswordsolver.org/anagram-solver/"><img src="https://images.opencollective.com/anagram-solver/2666271/logo.png" alt="Anagram Solver" height="32"></a> <a href="https://icons8.com/"><img src="https://images.opencollective.com/icons8/7fa1641/logo.png" alt="Icons8" height="32"></a> <a href="https://discord.com"><img src="https://images.opencollective.com/discordapp/f9645d9/logo.png" alt="Discord" height="32"></a> <a href="https://www.gitbook.com"><img src="https://avatars.githubusercontent.com/u/7111340?v=4" alt="GitBook" height="32"></a> <a href="https://nx.dev"><img src="https://avatars.githubusercontent.com/u/23692104?v=4" alt="Nx" height="32"></a> <a href="https://herocoders.com"><img src="https://avatars.githubusercontent.com/u/37549774?v=4" alt="HeroCoders" height="32"></a> <a href="https://usenextbase.com"><img src="https://avatars.githubusercontent.com/u/145838380?v=4" alt="Nextbase Starter Kit" height="32"></a></p>
+<p><a href="https://www.serptriumph.com/"><img src="https://images.opencollective.com/serp-triumph5/fea3074/logo.png" alt="SERP Triumph" height="64"></a> <a href="https://www.jetbrains.com/"><img src="https://images.opencollective.com/jetbrains/fe76f99/logo.png" alt="JetBrains" height="64"></a> <a href="https://liftoff.io/"><img src="https://images.opencollective.com/liftoff/5c4fa84/logo.png" alt="Liftoff" height="64"></a> <a href="https://americanexpress.io"><img src="https://avatars.githubusercontent.com/u/3853301?v=4" alt="American Express" height="64"></a> <a href="https://www.workleap.com"><img src="https://avatars.githubusercontent.com/u/53535748?u=d1e55d7661d724bf2281c1bfd33cb8f99fe2465f&v=4" alt="Workleap" height="64"></a></p><h3>Bronze Sponsors</h3>
+<p><a href="https://cybozu.co.jp/"><img src="https://images.opencollective.com/cybozu/933e46d/logo.png" alt="Cybozu" height="32"></a> <a href="https://www.wordhint.net/"><img src="https://images.opencollective.com/wordhint/be86813/avatar.png" alt="WordHint" height="32"></a> <a href="https://www.crosswordsolver.org/anagram-solver/"><img src="https://images.opencollective.com/anagram-solver/2666271/logo.png" alt="Anagram Solver" height="32"></a> <a href="https://icons8.com/"><img src="https://images.opencollective.com/icons8/7fa1641/logo.png" alt="Icons8" height="32"></a> <a href="https://discord.com"><img src="https://images.opencollective.com/discordapp/f9645d9/logo.png" alt="Discord" height="32"></a> <a href="https://www.gitbook.com"><img src="https://avatars.githubusercontent.com/u/7111340?v=4" alt="GitBook" height="32"></a> <a href="https://nx.dev"><img src="https://avatars.githubusercontent.com/u/23692104?v=4" alt="Nx" height="32"></a> <a href="https://herocoders.com"><img src="https://avatars.githubusercontent.com/u/37549774?v=4" alt="HeroCoders" height="32"></a></p>
 <h3>Technology Sponsors</h3>
 Technology sponsors allow us to use their products and services for free as part of a contribution to the open source ecosystem and our work.
 <p><a href="https://netlify.com"><img src="https://raw.githubusercontent.com/eslint/eslint.org/main/src/assets/images/techsponsors/netlify-icon.svg" alt="Netlify" height="32"></a> <a href="https://algolia.com"><img src="https://raw.githubusercontent.com/eslint/eslint.org/main/src/assets/images/techsponsors/algolia-icon.svg" alt="Algolia" height="32"></a> <a href="https://1password.com"><img src="https://raw.githubusercontent.com/eslint/eslint.org/main/src/assets/images/techsponsors/1password-icon.svg" alt="1Password" height="32"></a></p>
diff --git a/docs/rules/no-duplicate-imports.md b/docs/rules/no-duplicate-imports.md
new file mode 100644
index 0000000..8ca68e3
--- /dev/null
+++ b/docs/rules/no-duplicate-imports.md
@@ -0,0 +1,41 @@
+# no-duplicate-imports
+
+Disallow duplicate `@import` rules.
+
+## Background
+
+CSS files can import rules from other CSS files using the `@import` rule and specifying a URL from which to load. In a file with many `@import` rules it can be difficult to see if you've accidentally imported the same URL twice, for example:
+
+```css
+@import url(a.css);
+@import "b.css";
+@import url("c.css");
+@import "a.css";
+```
+
+There is no reason to import the same URL twice, so this is a mistake.
+
+## Rule Details
+
+This rule warns when it finds an `@import` rule that imports the same URL as a previous `@import` rule. This includes all of the URL forms (`"a.css"`, `url("a.css")`, and `url(a.css)`).
+
+Examples of incorrect code:
+
+```css
+@import url(a.css);
+@import "b.css";
+@import url("c.css");
+
+/* duplicates */
+@import "a.css";
+@import url(b.css);
+@import "c.css";
+```
+
+## When Not to Use It
+
+If you aren't concerned with duplicate `@import` rules, you can safely disable this rule.
+
+## Prior Art
+
+-   [`no-duplicate-at-import-rules`](https://stylelint.io/user-guide/rules/no-duplicate-at-import-rules)
diff --git a/src/index.js b/src/index.js
index fcb6986..5c150e5 100644
--- a/src/index.js
+++ b/src/index.js
@@ -10,6 +10,7 @@
 import { CSSLanguage } from "./languages/css-language.js";
 import { CSSSourceCode } from "./languages/css-source-code.js";
 import noEmptyBlocks from "./rules/no-empty-blocks.js";
+import noDuplicateImports from "./rules/no-duplicate-imports.js";
 
 //-----------------------------------------------------------------------------
 // Plugin
@@ -25,6 +26,7 @@ const plugin = {
 	},
 	rules: {
 		"no-empty-blocks": noEmptyBlocks,
+		"no-duplicate-imports": noDuplicateImports,
 	},
 	configs: {},
 };
@@ -34,6 +36,7 @@ Object.assign(plugin.configs, {
 		plugins: { css: plugin },
 		rules: {
 			"css/no-empty-blocks": "error",
+			"css/no-duplicate-imports": "error",
 		},
 	},
 });
diff --git a/src/rules/no-duplicate-imports.js b/src/rules/no-duplicate-imports.js
new file mode 100644
index 0000000..d638567
--- /dev/null
+++ b/src/rules/no-duplicate-imports.js
@@ -0,0 +1,39 @@
+/**
+ * @fileoverview Rule to prevent duplicate imports in CSS.
+ * @author Nicholas C. Zakas
+ */
+
+export default {
+	meta: {
+		type: "problem",
+
+		docs: {
+			description: "Disallow duplicate @import rules.",
+			recommended: true,
+		},
+
+		messages: {
+			duplicateImport: "Unexpected duplicate @import rule for {{url}}.",
+		},
+	},
+
+	create(context) {
+		const imports = new Set();
+
+		return {
+			"Atrule[name=import]"(node) {
+				const url = node.prelude.children[0].value;
+
+				if (imports.has(url)) {
+					context.report({
+						loc: node.loc,
+						messageId: "duplicateImport",
+						data: { url },
+					});
+				} else {
+					imports.add(url);
+				}
+			},
+		};
+	},
+};


Test patch:
diff --git a/tests/rules/no-duplicate-imports.test.js b/tests/rules/no-duplicate-imports.test.js
new file mode 100644
index 0000000..98be8f6
--- /dev/null
+++ b/tests/rules/no-duplicate-imports.test.js
@@ -0,0 +1,101 @@
+/**
+ * @fileoverview Tests for no-duplicate-imports rule.
+ * @author Nicholas C. Zakas
+ */
+
+//------------------------------------------------------------------------------
+// Imports
+//------------------------------------------------------------------------------
+
+import rule from "../../src/rules/no-duplicate-imports.js";
+import css from "../../src/index.js";
+import { RuleTester } from "eslint";
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester({
+	plugins: {
+		css,
+	},
+	language: "css/css",
+});
+
+ruleTester.run("no-duplicate-imports", rule, {
+	valid: [
+		"@import url('x.css');",
+		"@import url('x.css'); @import url('y.css');",
+		"@import 'x.css'; @import url('y.css'); @import 'z.css';",
+	],
+	invalid: [
+		{
+			code: "@import url('x.css');\n@import url('x.css');",
+			errors: [
+				{
+					messageId: "duplicateImport",
+					data: { url: "x.css" },
+					line: 2,
+					column: 1,
+					endLine: 2,
+					endColumn: 22,
+				},
+			],
+		},
+		{
+			code: "@import url('x.css');\n@import 'x.css';",
+			errors: [
+				{
+					messageId: "duplicateImport",
+					data: { url: "x.css" },
+					line: 2,
+					column: 1,
+					endLine: 2,
+					endColumn: 17,
+				},
+			],
+		},
+		{
+			code: "@import url('x.css');\n@import 'x.css';\n@import 'x.css';",
+			errors: [
+				{
+					messageId: "duplicateImport",
+					data: { url: "x.css" },
+					line: 2,
+					column: 1,
+					endLine: 2,
+					endColumn: 17,
+				},
+				{
+					messageId: "duplicateImport",
+					data: { url: "x.css" },
+					line: 3,
+					column: 1,
+					endLine: 3,
+					endColumn: 17,
+				},
+			],
+		},
+		{
+			code: "@import url('x.css');\n@import 'x.css';\n@import url('y.css');\n@import 'y.css';",
+			errors: [
+				{
+					messageId: "duplicateImport",
+					data: { url: "x.css" },
+					line: 2,
+					column: 1,
+					endLine: 2,
+					endColumn: 17,
+				},
+				{
+					messageId: "duplicateImport",
+					data: { url: "y.css" },
+					line: 4,
+					column: 1,
+					endLine: 4,
+					endColumn: 17,
+				},
+			],
+		},
+	],
+});