diff --git a/.changeset/moody-seas-kick.md b/.changeset/moody-seas-kick.md
new file mode 100644
index 000000000..aebc93044
--- /dev/null
+++ b/.changeset/moody-seas-kick.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-svelte": minor
+---
+
+feat: added the no-unused-class-name rule
diff --git a/.eslintignore b/.eslintignore
index 06758f473..9382feba5 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -8,6 +8,8 @@
/prettier-playground
/tests/fixtures/rules/indent/invalid/ts
/tests/fixtures/rules/indent/invalid/ts-v5
+/tests/fixtures/rules/no-unused-class-name/valid/invalid-style01-input.svelte
+/tests/fixtures/rules/no-unused-class-name/valid/unknown-lang01-input.svelte
/tests/fixtures/rules/valid-compile/invalid/ts
/tests/fixtures/rules/valid-compile/valid/babel
/tests/fixtures/rules/valid-compile/valid/ts
diff --git a/README.md b/README.md
index f512560a2..a1f1b1038 100644
--- a/README.md
+++ b/README.md
@@ -343,6 +343,7 @@ These rules relate to better ways of doing things to help you avoid problems:
| [svelte/no-immutable-reactive-statements](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-immutable-reactive-statements/) | disallow reactive statements that don't reference reactive values. | |
| [svelte/no-reactive-functions](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-reactive-functions/) | it's not necessary to define functions in reactive statements | :bulb: |
| [svelte/no-reactive-literals](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-reactive-literals/) | don't assign literal values in reactive statements | :bulb: |
+| [svelte/no-unused-class-name](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-unused-class-name/) | disallow the use of a class in the template without a corresponding style | |
| [svelte/no-unused-svelte-ignore](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-unused-svelte-ignore/) | disallow unused svelte-ignore comments | :star: |
| [svelte/no-useless-mustaches](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-useless-mustaches/) | disallow unnecessary mustache interpolations | :wrench: |
| [svelte/prefer-destructured-store-props](https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-destructured-store-props/) | destructure values from object stores for better change tracking & fewer redraws | :bulb: |
diff --git a/docs/rules.md b/docs/rules.md
index 644183301..d35419cad 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -56,6 +56,7 @@ These rules relate to better ways of doing things to help you avoid problems:
| [svelte/no-immutable-reactive-statements](./rules/no-immutable-reactive-statements.md) | disallow reactive statements that don't reference reactive values. | |
| [svelte/no-reactive-functions](./rules/no-reactive-functions.md) | it's not necessary to define functions in reactive statements | :bulb: |
| [svelte/no-reactive-literals](./rules/no-reactive-literals.md) | don't assign literal values in reactive statements | :bulb: |
+| [svelte/no-unused-class-name](./rules/no-unused-class-name.md) | disallow the use of a class in the template without a corresponding style | |
| [svelte/no-unused-svelte-ignore](./rules/no-unused-svelte-ignore.md) | disallow unused svelte-ignore comments | :star: |
| [svelte/no-useless-mustaches](./rules/no-useless-mustaches.md) | disallow unnecessary mustache interpolations | :wrench: |
| [svelte/prefer-destructured-store-props](./rules/prefer-destructured-store-props.md) | destructure values from object stores for better change tracking & fewer redraws | :bulb: |
diff --git a/docs/rules/no-unused-class-name.md b/docs/rules/no-unused-class-name.md
new file mode 100644
index 000000000..7e02863a2
--- /dev/null
+++ b/docs/rules/no-unused-class-name.md
@@ -0,0 +1,61 @@
+---
+pageClass: "rule-details"
+sidebarDepth: 0
+title: "svelte/no-unused-class-name"
+description: "disallow the use of a class in the template without a corresponding style"
+---
+
+# svelte/no-unused-class-name
+
+> disallow the use of a class in the template without a corresponding style
+
+- :exclamation: **_This rule has not been released yet._**
+
+## :book: Rule Details
+
+This rule is aimed at reducing unused classes in the HTML template. While `svelte-check` will produce the `css-unused-selector` if your `
+```
+
+
+
+## :wrench: Options
+
+Nothing.
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/src/rules/no-unused-class-name.ts)
+- [Test source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/tests/src/rules/no-unused-class-name.ts)
diff --git a/package.json b/package.json
index 264cce58c..e83924ab2 100644
--- a/package.json
+++ b/package.json
@@ -75,6 +75,7 @@
"postcss": "^8.4.5",
"postcss-load-config": "^3.1.4",
"postcss-safe-parser": "^6.0.0",
+ "postcss-selector-parser": "^6.0.11",
"svelte-eslint-parser": "^0.31.0"
},
"devDependencies": {
diff --git a/src/rules/no-unused-class-name.ts b/src/rules/no-unused-class-name.ts
new file mode 100644
index 000000000..541f54a05
--- /dev/null
+++ b/src/rules/no-unused-class-name.ts
@@ -0,0 +1,120 @@
+import { createRule } from "../utils"
+import type {
+ SourceLocation,
+ SvelteAttribute,
+ SvelteDirective,
+ SvelteShorthandAttribute,
+ SvelteSpecialDirective,
+ SvelteSpreadAttribute,
+ SvelteStyleDirective,
+} from "svelte-eslint-parser/lib/ast"
+import type { AnyNode } from "postcss"
+import {
+ default as selectorParser,
+ type Node as SelectorNode,
+} from "postcss-selector-parser"
+
+export default createRule("no-unused-class-name", {
+ meta: {
+ docs: {
+ description:
+ "disallow the use of a class in the template without a corresponding style",
+ category: "Best Practices",
+ recommended: false,
+ },
+ schema: [],
+ messages: {},
+ type: "suggestion",
+ },
+ create(context) {
+ const classesUsedInTemplate: Record = {}
+
+ return {
+ SvelteElement(node) {
+ if (node.kind !== "html") {
+ return
+ }
+ const classes = node.startTag.attributes.flatMap(findClassesInAttribute)
+ for (const className of classes) {
+ classesUsedInTemplate[className] = node.startTag.loc
+ }
+ },
+ "Program:exit"() {
+ const styleContext = context.parserServices.getStyleContext()
+ if (["parse-error", "unknown-lang"].includes(styleContext.status)) {
+ return
+ }
+ const classesUsedInStyle =
+ styleContext.sourceAst != null
+ ? findClassesInPostCSSNode(styleContext.sourceAst)
+ : []
+ for (const className in classesUsedInTemplate) {
+ if (!classesUsedInStyle.includes(className)) {
+ context.report({
+ loc: classesUsedInTemplate[className],
+ message: `Unused class "${className}".`,
+ })
+ }
+ }
+ },
+ }
+ },
+})
+
+/**
+ * Extract all class names used in a HTML element attribute.
+ */
+function findClassesInAttribute(
+ attribute:
+ | SvelteAttribute
+ | SvelteShorthandAttribute
+ | SvelteSpreadAttribute
+ | SvelteDirective
+ | SvelteStyleDirective
+ | SvelteSpecialDirective,
+): string[] {
+ if (attribute.type === "SvelteAttribute" && attribute.key.name === "class") {
+ return attribute.value.flatMap((value) =>
+ value.type === "SvelteLiteral" ? value.value.trim().split(/\s+/u) : [],
+ )
+ }
+ if (attribute.type === "SvelteDirective" && attribute.kind === "Class") {
+ return [attribute.key.name.name]
+ }
+ return []
+}
+
+/**
+ * Extract all class names used in a PostCSS node.
+ */
+function findClassesInPostCSSNode(node: AnyNode): string[] {
+ if (node.type === "rule") {
+ let classes = node.nodes.flatMap(findClassesInPostCSSNode)
+ const processor = selectorParser()
+ classes = classes.concat(
+ findClassesInSelector(processor.astSync(node.selector)),
+ )
+ return classes
+ }
+ if (node.type === "root" || node.type === "atrule") {
+ return node.nodes.flatMap(findClassesInPostCSSNode)
+ }
+ return []
+}
+
+/**
+ * Extract all class names used in a PostCSS selector.
+ */
+function findClassesInSelector(node: SelectorNode): string[] {
+ if (node.type === "class") {
+ return [node.value]
+ }
+ if (
+ node.type === "pseudo" ||
+ node.type === "root" ||
+ node.type === "selector"
+ ) {
+ return node.nodes.flatMap(findClassesInSelector)
+ }
+ return []
+}
diff --git a/src/utils/rules.ts b/src/utils/rules.ts
index 3e4141f65..647c69c6c 100644
--- a/src/utils/rules.ts
+++ b/src/utils/rules.ts
@@ -41,6 +41,7 @@ import noStoreAsync from "../rules/no-store-async"
import noTargetBlank from "../rules/no-target-blank"
import noTrailingSpaces from "../rules/no-trailing-spaces"
import noUnknownStyleDirectiveProperty from "../rules/no-unknown-style-directive-property"
+import noUnusedClassName from "../rules/no-unused-class-name"
import noUnusedSvelteIgnore from "../rules/no-unused-svelte-ignore"
import noUselessMustaches from "../rules/no-useless-mustaches"
import preferClassDirective from "../rules/prefer-class-directive"
@@ -101,6 +102,7 @@ export const rules = [
noTargetBlank,
noTrailingSpaces,
noUnknownStyleDirectiveProperty,
+ noUnusedClassName,
noUnusedSvelteIgnore,
noUselessMustaches,
preferClassDirective,
diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/class-directive01-errors.yaml b/tests/fixtures/rules/no-unused-class-name/invalid/class-directive01-errors.yaml
new file mode 100644
index 000000000..4f97ff145
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/invalid/class-directive01-errors.yaml
@@ -0,0 +1,8 @@
+- message: Unused class "first".
+ line: 1
+ column: 1
+ suggestions: null
+- message: Unused class "second".
+ line: 3
+ column: 1
+ suggestions: null
diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/class-directive01-input.svelte b/tests/fixtures/rules/no-unused-class-name/invalid/class-directive01-input.svelte
new file mode 100644
index 000000000..0a52a4226
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/invalid/class-directive01-input.svelte
@@ -0,0 +1,3 @@
+Hello
+
+World!
diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/multiline-class-names01-errors.yaml b/tests/fixtures/rules/no-unused-class-name/invalid/multiline-class-names01-errors.yaml
new file mode 100644
index 000000000..67d002d3a
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/invalid/multiline-class-names01-errors.yaml
@@ -0,0 +1,12 @@
+- message: Unused class "div-class-two".
+ line: 2
+ column: 1
+ suggestions: null
+- message: Unused class "span-class-two".
+ line: 4
+ column: 1
+ suggestions: null
+- message: Unused class "span-class-three".
+ line: 4
+ column: 1
+ suggestions: null
diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/multiline-class-names01-input.svelte b/tests/fixtures/rules/no-unused-class-name/invalid/multiline-class-names01-input.svelte
new file mode 100644
index 000000000..5a9a1c58a
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/invalid/multiline-class-names01-input.svelte
@@ -0,0 +1,19 @@
+
+Hello
+
+World!
+
+
diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/multiple-class-names01-errors.yaml b/tests/fixtures/rules/no-unused-class-name/invalid/multiple-class-names01-errors.yaml
new file mode 100644
index 000000000..5408f7e8b
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/invalid/multiple-class-names01-errors.yaml
@@ -0,0 +1,12 @@
+- message: Unused class "div-class-two".
+ line: 1
+ column: 1
+ suggestions: null
+- message: Unused class "span-class-two".
+ line: 3
+ column: 1
+ suggestions: null
+- message: Unused class "span-class-three".
+ line: 3
+ column: 1
+ suggestions: null
diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/multiple-class-names01-input.svelte b/tests/fixtures/rules/no-unused-class-name/invalid/multiple-class-names01-input.svelte
new file mode 100644
index 000000000..d10cb63b2
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/invalid/multiple-class-names01-input.svelte
@@ -0,0 +1,13 @@
+Hello
+
+World!
+
+
diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/same-name-id01-errors.yaml b/tests/fixtures/rules/no-unused-class-name/invalid/same-name-id01-errors.yaml
new file mode 100644
index 000000000..b51081d84
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/invalid/same-name-id01-errors.yaml
@@ -0,0 +1,8 @@
+- message: Unused class "div-class".
+ line: 1
+ column: 1
+ suggestions: null
+- message: Unused class "span-class".
+ line: 3
+ column: 1
+ suggestions: null
diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/same-name-id01-input.svelte b/tests/fixtures/rules/no-unused-class-name/invalid/same-name-id01-input.svelte
new file mode 100644
index 000000000..de285fe09
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/invalid/same-name-id01-input.svelte
@@ -0,0 +1,13 @@
+Hello
+
+World!
+
+
diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/unused-class-name01-errors.yaml b/tests/fixtures/rules/no-unused-class-name/invalid/unused-class-name01-errors.yaml
new file mode 100644
index 000000000..b51081d84
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/invalid/unused-class-name01-errors.yaml
@@ -0,0 +1,8 @@
+- message: Unused class "div-class".
+ line: 1
+ column: 1
+ suggestions: null
+- message: Unused class "span-class".
+ line: 3
+ column: 1
+ suggestions: null
diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/unused-class-name01-input.svelte b/tests/fixtures/rules/no-unused-class-name/invalid/unused-class-name01-input.svelte
new file mode 100644
index 000000000..a486966cf
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/invalid/unused-class-name01-input.svelte
@@ -0,0 +1,3 @@
+Hello
+
+World!
diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/used-unrelated-class-name01-errors.yaml b/tests/fixtures/rules/no-unused-class-name/invalid/used-unrelated-class-name01-errors.yaml
new file mode 100644
index 000000000..b51081d84
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/invalid/used-unrelated-class-name01-errors.yaml
@@ -0,0 +1,8 @@
+- message: Unused class "div-class".
+ line: 1
+ column: 1
+ suggestions: null
+- message: Unused class "span-class".
+ line: 3
+ column: 1
+ suggestions: null
diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/used-unrelated-class-name01-input.svelte b/tests/fixtures/rules/no-unused-class-name/invalid/used-unrelated-class-name01-input.svelte
new file mode 100644
index 000000000..829cc3ed3
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/invalid/used-unrelated-class-name01-input.svelte
@@ -0,0 +1,9 @@
+Hello
+
+World!
+
+
diff --git a/tests/fixtures/rules/no-unused-class-name/valid/adjacent-sibling-combinator01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/adjacent-sibling-combinator01-input.svelte
new file mode 100644
index 000000000..a535d1b47
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/valid/adjacent-sibling-combinator01-input.svelte
@@ -0,0 +1,9 @@
+Hello
+
+World!
+
+
diff --git a/tests/fixtures/rules/no-unused-class-name/valid/child-combinator01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/child-combinator01-input.svelte
new file mode 100644
index 000000000..66f147ea9
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/valid/child-combinator01-input.svelte
@@ -0,0 +1,9 @@
+
+
+
diff --git a/tests/fixtures/rules/no-unused-class-name/valid/descendant-combinator01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/descendant-combinator01-input.svelte
new file mode 100644
index 000000000..28dcb26a8
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/valid/descendant-combinator01-input.svelte
@@ -0,0 +1,9 @@
+
+
+
diff --git a/tests/fixtures/rules/no-unused-class-name/valid/general-sibling-combinator01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/general-sibling-combinator01-input.svelte
new file mode 100644
index 000000000..9532972bd
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/valid/general-sibling-combinator01-input.svelte
@@ -0,0 +1,9 @@
+Hello
+
+World!
+
+
diff --git a/tests/fixtures/rules/no-unused-class-name/valid/invalid-style01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/invalid-style01-input.svelte
new file mode 100644
index 000000000..a3184dbd2
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/valid/invalid-style01-input.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/tests/fixtures/rules/no-unused-class-name/valid/multiple-class-names01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/multiple-class-names01-input.svelte
new file mode 100644
index 000000000..f4b324327
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/valid/multiple-class-names01-input.svelte
@@ -0,0 +1,21 @@
+Hello
+
+World!
+
+
diff --git a/tests/fixtures/rules/no-unused-class-name/valid/no-class-name01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/no-class-name01-input.svelte
new file mode 100644
index 000000000..f9297796e
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/valid/no-class-name01-input.svelte
@@ -0,0 +1,3 @@
+Hello
+
+World!
diff --git a/tests/fixtures/rules/no-unused-class-name/valid/pseudo-classes01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/pseudo-classes01-input.svelte
new file mode 100644
index 000000000..7b5aec359
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/valid/pseudo-classes01-input.svelte
@@ -0,0 +1,14 @@
+Hello
+
+World
+
+!
+
+
diff --git a/tests/fixtures/rules/no-unused-class-name/valid/pseudo-elements01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/pseudo-elements01-input.svelte
new file mode 100644
index 000000000..881e0aec6
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/valid/pseudo-elements01-input.svelte
@@ -0,0 +1,12 @@
+Hello
+
+World!
+
+
diff --git a/tests/fixtures/rules/no-unused-class-name/valid/scss-class-name01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/scss-class-name01-input.svelte
new file mode 100644
index 000000000..45bbe8605
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/valid/scss-class-name01-input.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/tests/fixtures/rules/no-unused-class-name/valid/selector-list01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/selector-list01-input.svelte
new file mode 100644
index 000000000..e5b42f52a
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/valid/selector-list01-input.svelte
@@ -0,0 +1,10 @@
+Hello
+
+World!
+
+
diff --git a/tests/fixtures/rules/no-unused-class-name/valid/unknown-lang01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/unknown-lang01-input.svelte
new file mode 100644
index 000000000..43e1850c4
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/valid/unknown-lang01-input.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/tests/fixtures/rules/no-unused-class-name/valid/used-class-name01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/used-class-name01-input.svelte
new file mode 100644
index 000000000..3f4f103e0
--- /dev/null
+++ b/tests/fixtures/rules/no-unused-class-name/valid/used-class-name01-input.svelte
@@ -0,0 +1,13 @@
+Hello
+
+World!
+
+
diff --git a/tests/src/rules/no-unused-class-name.ts b/tests/src/rules/no-unused-class-name.ts
new file mode 100644
index 000000000..0920a419c
--- /dev/null
+++ b/tests/src/rules/no-unused-class-name.ts
@@ -0,0 +1,16 @@
+import { RuleTester } from "eslint"
+import rule from "../../../src/rules/no-unused-class-name"
+import { loadTestCases } from "../../utils/utils"
+
+const tester = new RuleTester({
+ parserOptions: {
+ ecmaVersion: 2020,
+ sourceType: "module",
+ },
+})
+
+tester.run(
+ "no-unused-class-name",
+ rule as any,
+ loadTestCases("no-unused-class-name"),
+)