diff --git a/docs/rules/no-duplicate-imports.md b/docs/rules/no-duplicate-imports.md index 60b4174d..6ac14127 100644 --- a/docs/rules/no-duplicate-imports.md +++ b/docs/rules/no-duplicate-imports.md @@ -25,13 +25,23 @@ Examples of **incorrect** code: /* eslint css/no-duplicate-imports: "error" */ @import url(a.css); -@import "b.css"; -@import url("c.css"); +@import "b.css" print; +@import url("c.css") print, screen; /* duplicates */ @import "a.css"; @import url(b.css); -@import "c.css"; +@import "c.css" print; +``` + +Examples of **correct** code: + +```css +/* eslint css/no-duplicate-imports: "error" */ + +@import url(a.css); +@import "b.css"; +@import url("c.css") print; ``` ## When Not to Use It diff --git a/src/rules/no-duplicate-imports.js b/src/rules/no-duplicate-imports.js index 0e1749a1..095bf4e9 100644 --- a/src/rules/no-duplicate-imports.js +++ b/src/rules/no-duplicate-imports.js @@ -9,10 +9,73 @@ /** * @import { CSSRuleDefinition } from "../types.js" - * @typedef {"duplicateImport"} NoDuplicateKeysMessageIds + * @typedef {"duplicateImport" | "removeDuplicateImportWithConditions" | "removeDuplicateImportWithoutConditions"} NoDuplicateKeysMessageIds * @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoDuplicateKeysMessageIds }>} NoDuplicateImportsRuleDefinition */ +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +/** + * Get the end index of import statement including a following newline if present. + * @param {string} text The full text of the source code. + * @param {number} end The end index of the import statement. + * @returns {number} The end index of the import statement including a following newline. + */ +function getImportEnd(text, end) { + let removeEnd = end; + + // Remove the node, and also remove a following newline if present + if (text[removeEnd] === "\r") { + removeEnd += text[removeEnd + 1] === "\n" ? 2 : 1; + } else if (text[removeEnd] === "\n" || text[removeEnd] === "\f") { + removeEnd += 1; + } + + return removeEnd; +} + +/** + * Get the conditions of an import statement. + * @param {Object} importNode The import node to get conditions from. + * @param {Object} sourceCode The source code object. + * @returns {string[]} An array of conditions for the import statement. + */ +function getImportConditions(importNode, sourceCode) { + const importConditions = []; + + const importHasConditions = importNode.prelude.children.length > 1; + + if (importHasConditions) { + importNode.prelude.children.slice(1).forEach(condition => { + const conditionText = sourceCode.getText(condition).trim(); + importConditions.push(conditionText); + }); + } + + return importConditions; +} + +/** + * Get the fix for a duplicate import statement. + * @param {Object} fixer The fixer object. + * @param {string} text The full text of the source code. + * @param {number} start The start index of the import statement to fix. + * @param {number} end The end index of the import statement to fix. + * @param {boolean} condition A boolean indicating whether the import statement has conditions that differ from the original import. + * @returns {Object|null} A fix object if a fix is applicable, or null if no fix should be applied. + */ +function getFixForImport(fixer, text, start, end, condition) { + const removeEnd = getImportEnd(text, end); + + if (condition) { + return fixer.removeRange([start, removeEnd]); + } + + return null; +} + //----------------------------------------------------------------------------- // Rule //----------------------------------------------------------------------------- @@ -25,6 +88,7 @@ export default { type: "problem", fixable: "code", + hasSuggestions: true, docs: { description: "Disallow duplicate @import rules", @@ -34,42 +98,124 @@ export default { messages: { duplicateImport: "Unexpected duplicate @import rule for '{{url}}'.", + removeDuplicateImportWithConditions: + "Remove duplicate @import rule with condition(s) - {{conditions}}.", + removeDuplicateImportWithoutConditions: + "Remove duplicate @import rule without conditions.", }, }, create(context) { const { sourceCode } = context; - const imports = new Set(); + const imports = []; return { "Atrule[name=/^import$/i]"(node) { const url = node.prelude.children[0].value; + const hasImport = imports.some( + importNode => importNode.prelude.children[0].value === url, + ); + + if (hasImport) { + const firstImportNode = imports.find( + importNode => + importNode.prelude.children[0].value === url, + ); + const [firstImportStart, firstImportEnd] = + sourceCode.getRange(firstImportNode); + + const firstImporthHasConditions = + firstImportNode.prelude.children.length > 1; + const nodeHasConditions = node.prelude.children.length > 1; + + const [start, end] = sourceCode.getRange(node); + const text = sourceCode.text; + + const firstImportConditions = getImportConditions( + firstImportNode, + sourceCode, + ); + const duplicateImportConditions = getImportConditions( + node, + sourceCode, + ); + + const hasSameConditions = + firstImportConditions.length === + duplicateImportConditions.length && + firstImportConditions.every( + (condition, index) => + condition === duplicateImportConditions[index], + ); - if (imports.has(url)) { context.report({ loc: node.loc, messageId: "duplicateImport", data: { url }, fix(fixer) { - const [start, end] = sourceCode.getRange(node); - const text = sourceCode.text; - // Remove the node, and also remove a following newline if present - let removeEnd = end; - if (text[removeEnd] === "\r") { - removeEnd += - text[removeEnd + 1] === "\n" ? 2 : 1; - } else if ( - text[removeEnd] === "\n" || - text[removeEnd] === "\f" - ) { - removeEnd += 1; - } - - return fixer.removeRange([start, removeEnd]); + const condition = + (!firstImporthHasConditions && + !nodeHasConditions) || + hasSameConditions; + + return getFixForImport( + fixer, + text, + start, + end, + condition, + ); }, + suggest: [ + { + messageId: firstImporthHasConditions + ? "removeDuplicateImportWithConditions" + : "removeDuplicateImportWithoutConditions", + data: { + conditions: firstImportConditions.join(" "), + }, + fix(fixer) { + const condition = + (firstImporthHasConditions || + nodeHasConditions) && + !hasSameConditions; + + return getFixForImport( + fixer, + text, + firstImportStart, + firstImportEnd, + condition, + ); + }, + }, + { + messageId: nodeHasConditions + ? "removeDuplicateImportWithConditions" + : "removeDuplicateImportWithoutConditions", + data: { + conditions: + duplicateImportConditions.join(" "), + }, + fix(fixer) { + const condition = + (firstImporthHasConditions || + nodeHasConditions) && + !hasSameConditions; + + return getFixForImport( + fixer, + text, + start, + end, + condition, + ); + }, + }, + ], }); } else { - imports.add(url); + imports.push(node); } }, }; diff --git a/tests/rules/no-duplicate-imports.test.js b/tests/rules/no-duplicate-imports.test.js index 6b2bb3ce..df26d74f 100644 --- a/tests/rules/no-duplicate-imports.test.js +++ b/tests/rules/no-duplicate-imports.test.js @@ -280,5 +280,378 @@ ruleTester.run("no-duplicate-imports", rule, { }, ], }, + { + code: "@import url('a.css') print;\n@import url('a.css') print;\n@import url('b.css');", + output: "@import url('a.css') print;\n@import url('b.css');", + errors: [ + { + messageId: "duplicateImport", + data: { url: "a.css" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 28, + }, + ], + }, + { + code: "@import 'a.css' print;\n@import url('a.css') print;\n@import url('b.css');", + output: "@import 'a.css' print;\n@import url('b.css');", + errors: [ + { + messageId: "duplicateImport", + data: { url: "a.css" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 28, + }, + ], + }, + { + code: "@import url('a.css') print;\n@import url('a.css');\n@import url('b.css');", + errors: [ + { + messageId: "duplicateImport", + data: { url: "a.css" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 22, + suggestions: [ + { + messageId: "removeDuplicateImportWithConditions", + data: { conditions: "print" }, + output: "@import url('a.css');\n@import url('b.css');", + }, + { + messageId: "removeDuplicateImportWithoutConditions", + output: "@import url('a.css') print;\n@import url('b.css');", + }, + ], + }, + ], + }, + { + code: "@import url('a.css');\n@import url('a.css') print;\n@import url('b.css');", + errors: [ + { + messageId: "duplicateImport", + data: { url: "a.css" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 28, + suggestions: [ + { + messageId: "removeDuplicateImportWithoutConditions", + output: "@import url('a.css') print;\n@import url('b.css');", + }, + { + messageId: "removeDuplicateImportWithConditions", + data: { conditions: "print" }, + output: "@import url('a.css');\n@import url('b.css');", + }, + ], + }, + ], + }, + { + code: "@import url('a.css') print, screen;\n@import url('a.css');\n@import url('b.css');", + errors: [ + { + messageId: "duplicateImport", + data: { url: "a.css" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 22, + suggestions: [ + { + messageId: "removeDuplicateImportWithConditions", + data: { conditions: "print, screen" }, + output: "@import url('a.css');\n@import url('b.css');", + }, + { + messageId: "removeDuplicateImportWithoutConditions", + output: "@import url('a.css') print, screen;\n@import url('b.css');", + }, + ], + }, + ], + }, + { + code: "@import url('a.css');\n@import url('a.css') print, screen;\n@import url('b.css');", + errors: [ + { + messageId: "duplicateImport", + data: { url: "a.css" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 36, + suggestions: [ + { + messageId: "removeDuplicateImportWithoutConditions", + output: "@import url('a.css') print, screen;\n@import url('b.css');", + }, + { + messageId: "removeDuplicateImportWithConditions", + data: { conditions: "print, screen" }, + output: "@import url('a.css');\n@import url('b.css');", + }, + ], + }, + ], + }, + { + code: "@import url('a.css') print, screen;\n@import url('a.css') print, screen and (width > 800px);\n@import url('b.css');", + errors: [ + { + messageId: "duplicateImport", + data: { url: "a.css" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 56, + suggestions: [ + { + messageId: "removeDuplicateImportWithConditions", + data: { conditions: "print, screen" }, + output: "@import url('a.css') print, screen and (width > 800px);\n@import url('b.css');", + }, + { + messageId: "removeDuplicateImportWithConditions", + data: { + conditions: "print, screen and (width > 800px)", + }, + output: "@import url('a.css') print, screen;\n@import url('b.css');", + }, + ], + }, + ], + }, + { + code: "@import url('a.css') layer(foo);\n@import url('a.css') layer(foo);\n@import url('b.css');", + output: "@import url('a.css') layer(foo);\n@import url('b.css');", + errors: [ + { + messageId: "duplicateImport", + data: { url: "a.css" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 33, + }, + ], + }, + { + code: "@import url('a.css');\n@import url('a.css') layer(foo);\n@import url('b.css');", + errors: [ + { + messageId: "duplicateImport", + data: { url: "a.css" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 33, + suggestions: [ + { + messageId: "removeDuplicateImportWithoutConditions", + output: "@import url('a.css') layer(foo);\n@import url('b.css');", + }, + { + messageId: "removeDuplicateImportWithConditions", + data: { + conditions: "layer(foo)", + }, + output: "@import url('a.css');\n@import url('b.css');", + }, + ], + }, + ], + }, + { + code: "@import url('a.css') layer(foo);\n@import url('a.css');\n@import url('b.css');", + errors: [ + { + messageId: "duplicateImport", + data: { url: "a.css" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 22, + suggestions: [ + { + messageId: "removeDuplicateImportWithConditions", + data: { + conditions: "layer(foo)", + }, + output: "@import url('a.css');\n@import url('b.css');", + }, + { + messageId: "removeDuplicateImportWithoutConditions", + output: "@import url('a.css') layer(foo);\n@import url('b.css');", + }, + ], + }, + ], + }, + { + code: "@import url('a.css') layer;\n@import url('a.css') layer(foo);\n@import url('b.css');", + errors: [ + { + messageId: "duplicateImport", + data: { url: "a.css" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 33, + suggestions: [ + { + messageId: "removeDuplicateImportWithConditions", + data: { + conditions: "layer", + }, + output: "@import url('a.css') layer(foo);\n@import url('b.css');", + }, + { + messageId: "removeDuplicateImportWithConditions", + data: { + conditions: "layer(foo)", + }, + output: "@import url('a.css') layer;\n@import url('b.css');", + }, + ], + }, + ], + }, + { + code: "@import url('a.css') supports(display: grid);\n@import url('a.css') supports(display: grid);\n@import url('b.css');", + output: "@import url('a.css') supports(display: grid);\n@import url('b.css');", + errors: [ + { + messageId: "duplicateImport", + data: { url: "a.css" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 46, + }, + ], + }, + { + code: "@import url('a.css');\n@import url('a.css') supports(display: grid);\n@import url('b.css');", + errors: [ + { + messageId: "duplicateImport", + data: { url: "a.css" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 46, + suggestions: [ + { + messageId: "removeDuplicateImportWithoutConditions", + output: "@import url('a.css') supports(display: grid);\n@import url('b.css');", + }, + { + messageId: "removeDuplicateImportWithConditions", + data: { + conditions: "supports(display: grid)", + }, + output: "@import url('a.css');\n@import url('b.css');", + }, + ], + }, + ], + }, + { + code: "@import url('a.css') supports(display: grid);\n@import url('a.css');\n@import url('b.css');", + errors: [ + { + messageId: "duplicateImport", + data: { url: "a.css" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 22, + suggestions: [ + { + messageId: "removeDuplicateImportWithConditions", + data: { + conditions: "supports(display: grid)", + }, + output: "@import url('a.css');\n@import url('b.css');", + }, + { + messageId: "removeDuplicateImportWithoutConditions", + output: "@import url('a.css') supports(display: grid);\n@import url('b.css');", + }, + ], + }, + ], + }, + { + code: "@import url('a.css') layer(foo) supports(display: grid);\n@import url('a.css') layer(foo);\n@import url('b.css');", + errors: [ + { + messageId: "duplicateImport", + data: { url: "a.css" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 33, + suggestions: [ + { + messageId: "removeDuplicateImportWithConditions", + data: { + conditions: + "layer(foo) supports(display: grid)", + }, + output: "@import url('a.css') layer(foo);\n@import url('b.css');", + }, + { + messageId: "removeDuplicateImportWithConditions", + data: { + conditions: "layer(foo)", + }, + output: "@import url('a.css') layer(foo) supports(display: grid);\n@import url('b.css');", + }, + ], + }, + ], + }, + { + code: "@import url('a.css') layer(foo) supports(display: grid) screen and (width > 600px);\n@import url('a.css') layer(foo) supports(display: grid);\n@import url('b.css');", + errors: [ + { + messageId: "duplicateImport", + data: { url: "a.css" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 57, + suggestions: [ + { + messageId: "removeDuplicateImportWithConditions", + data: { + conditions: + "layer(foo) supports(display: grid) screen and (width > 600px)", + }, + output: "@import url('a.css') layer(foo) supports(display: grid);\n@import url('b.css');", + }, + { + messageId: "removeDuplicateImportWithConditions", + data: { + conditions: + "layer(foo) supports(display: grid)", + }, + output: "@import url('a.css') layer(foo) supports(display: grid) screen and (width > 600px);\n@import url('b.css');", + }, + ], + }, + ], + }, ], });