Repository: rrd108/vue-mess-detector (ts)

Issue:
add rule (rrd) use css repeated values
We can detect if some values can be extracted to variables.
For example if in the codebase we have multiple `padding: 1em` it can be extracted out

Pull request description:
feat: add new rule rrd css repeated values with tests and docs (215)
### Summary
Add new rrd rule for repeated css values

### Description
- Added rule file
- Added test file
- Added doc file

### Related Issues
Fixes #215 

### Type of Change
- [X] New feature (non-breaking change which adds functionality)
- [X] Documentation update

### Screenshots (if applicable)
N/A


Patch:
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 86e18cd..3ede16c 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -93,6 +93,7 @@ export default defineConfig({
                 { text: 'Parameter Count', link: '/rules/rrd/parameter-count' },
                 { text: 'Plain Script', link: '/rules/rrd/plain-script' },
                 { text: 'Props Drilling', link: '/rules/rrd/props-drilling' },
+                { text: 'Repeated CSS', link: '/rules/rrd/repeated-css' },
                 { text: 'Script Length', link: '/rules/rrd/script-length' },
                 { text: 'Short Variable Name', link: '/rules/rrd/short-variable-name' },
                 { text: 'Too Many Props', link: '/rules/rrd/too-many-props' },
diff --git a/docs/rules/rrd/index.md b/docs/rules/rrd/index.md
index a10c70e..ad6d69c 100644
--- a/docs/rules/rrd/index.md
+++ b/docs/rules/rrd/index.md
@@ -27,6 +27,7 @@ These ruleset is the most opinionated with rules that are not part of the _offic
 - [Parameter Count](./parameter-count.md)
 - [Plain Script](./plain-script.md)
 - [Props Drilling](./props-drilling.md)
+- [Repeated CSS](./repeated-css.md)
 - [Script Length](./script-length.md)
 - [Short Variable Name](./short-variable-name.md)
 - [Too Many Props](./too-many-props.md)
diff --git a/docs/rules/rrd/repeated-css.md b/docs/rules/rrd/repeated-css.md
new file mode 100644
index 0000000..4ffa3bd
--- /dev/null
+++ b/docs/rules/rrd/repeated-css.md
@@ -0,0 +1,96 @@
+# Repeated Css
+
+Checks if any CSS `property: value` pair is repeated in the codebase. 
+
+## ❓ Why it's good to follow this rule?
+
+Repeating CSS properties across multiple components or files can lead to:
+1. Inconsistent styling
+2. Increased maintenance and change difficulty
+3. Larger bundle size
+
+By avoiding repeated CSS, you can improve code maintainability, reduce file sizes, and ensure consistent styling throughout your application.
+
+## 😱 Examples of code for which this rule will throw a warning
+
+::: warning
+The following code contains repeated CSS properties in different components:
+:::
+
+```vue
+<template>
+  <div class="component-a">
+    <!-- Component content -->
+  </div>
+</template>
+
+<style scoped>
+div {
+  background-color: #000;
+  color: red;
+}
+</style>
+```
+
+```vue
+<template>
+  <div class="component-b">
+    <!-- Component content -->
+  </div>
+</template>
+
+<style scoped>
+div {
+  background-color: #000;
+  color: red;
+}
+</style>
+```
+
+Both components have `background-color: #000;` and `color: red;` CSS properties.
+
+## 🤩 How to fix it?
+
+::: tip
+Refactor repeated CSS by creating a shared CSS class or CSS variables.
+:::
+
+```vue
+<template>
+  <div class="warning">
+    <!-- Component content -->
+  </div>
+</template>
+```
+
+### Use a shared CSS class.
+
+By creating a shared CSS class, you can avoid repeating the same styles across multiple components.
+
+```css
+/* style.css */
+.warning {
+  background-color: #000;
+  color: red;
+}
+```
+
+### Use CSS variables
+
+CSS variables allow you to define custom properties in the CSS file and reuse them across multiple components, classes or files.
+
+```css
+/* style.css */
+:root {
+  --warning-background-color: #000;
+  --warning-color: red;
+}
+
+.warning {
+  background-color: var(--warning-background-color);
+  color: var(--warning-color);
+}
+```
+
+
+
diff --git a/src/helpers/calculateCodeHealth.ts b/src/helpers/calculateCodeHealth.ts
index f6bd8bb..2b50bb5 100644
--- a/src/helpers/calculateCodeHealth.ts
+++ b/src/helpers/calculateCodeHealth.ts
@@ -15,12 +15,12 @@ export const calculateCodeHealth = (health: Health[], linesCount: number, filesC
     return { codeHealth, output }
   }
 
-  const codeHealthPoints = Math.ceil((1 - (errors * ERROR_WEIGHT + warnings) / linesCount) * 100)
+  const codeHealthPoints = Math.ceil((1 - Math.min(1, (errors * ERROR_WEIGHT + warnings) / linesCount)) * 100)
   codeHealth.points = codeHealthPoints
 
   const BAR_WIDTH = 60
-  const yellowLength = !warnings ? 0 : Math.max(1, Math.ceil(warnings / linesCount * BAR_WIDTH))
-  const redLength = !errors ? 0 : Math.max(1, BAR_WIDTH - Math.ceil(codeHealthPoints * BAR_WIDTH / 100) - yellowLength)
+  const yellowLength = !warnings ? 0 : Math.max(1, Math.ceil(Math.min(1, warnings / linesCount) * BAR_WIDTH))
+  const redLength = !errors ? 0 : Math.max(0, BAR_WIDTH - Math.ceil(codeHealthPoints * BAR_WIDTH / 100) - yellowLength)
   const greenLength = BAR_WIDTH - redLength - yellowLength
 
   const healthBar = `<bg_ok>${'_'.repeat(greenLength)}</bg_ok><bg_warn>${'_'.repeat(yellowLength)}</bg_warn><bg_err>${'_'.repeat(redLength)}</bg_err>`
diff --git a/src/rules/rrd/index.ts b/src/rules/rrd/index.ts
index 38b7f70..30ac178 100644
--- a/src/rules/rrd/index.ts
+++ b/src/rules/rrd/index.ts
@@ -23,6 +23,7 @@ export * from './noVarDeclaration'
 export * from './parameterCount'
 export * from './plainScript'
 export * from './propsDrilling'
+export * from './repeatedCss'
 export * from './scriptLength'
 export * from './shortVariableName'
 export * from './tooManyProps'
diff --git a/src/rules/rrd/repeatedCss.ts b/src/rules/rrd/repeatedCss.ts
new file mode 100644
index 0000000..aa4a87b
--- /dev/null
+++ b/src/rules/rrd/repeatedCss.ts
@@ -0,0 +1,70 @@
+import type { SFCStyleBlock } from '@vue/compiler-sfc'
+import type { FileCheckResult, Offense } from '../../types'
+import { createRegExp, maybe, oneOrMore, wordBoundary, wordChar } from 'magic-regexp'
+import { skipComments } from '../../helpers/skipComments'
+
+const results: FileCheckResult[] = []
+const cssProperties: Map<string, Set<string>> = new Map()
+
+const resetResults = () => {
+  results.length = 0
+  cssProperties.clear()
+}
+
+const checkRepeatedCss = (styles: SFCStyleBlock[] | null, filePath: string) => {
+  if (!styles) {
+    return
+  }
+
+  const propertyRegex = createRegExp(
+    wordBoundary,
+    oneOrMore(wordChar),
+    maybe('-'),
+    maybe(oneOrMore(wordChar)),
+    maybe(' '),
+    ':',
+    maybe(' '),
+    oneOrMore(wordChar),
+    ';',
+    ['g', 's'],
+  )
+
+  styles.forEach((style) => {
+    const content = skipComments(style.content)
+    const matches = content.match(propertyRegex)
+
+    matches?.forEach((match) => {
+      const cssValue = match.replaceAll(' ', '')
+
+      if (!cssProperties.has(cssValue)) {
+        cssProperties.set(cssValue, new Set([filePath]))
+      }
+      else {
+        cssProperties.get(cssValue)?.add(filePath)
+      }
+    })
+  })
+}
+
+const reportRepeatedCss = () => {
+  const offenses: Offense[] = []
+
+  cssProperties.forEach((files, cssValue) => {
+    if (files.size > 3) {
+      console.log(Array.from(files).join(', '))
+      console.log(cssValue)
+
+      offenses.push({
+        file: Array.from(files).join(', '),
+        rule: `<text_info>rrd ~ repeated CSS</text_info>`,
+        description: `👉 <text_warn>Avoid repeating CSS properties with the same value more than 3 times across files. Consider using CSS variables or a common class.</text_warn> See: https://vue-mess-detector.webmania.cc/rules/rrd/repeated-css.html`,
+        message: `repeated CSS: (${cssValue}) in ${files.size} files 🚨`,
+      })
+    }
+  })
+
+  resetResults()
+  return offenses
+}
+
+export { checkRepeatedCss, reportRepeatedCss, resetResults }
diff --git a/src/rules/rules.ts b/src/rules/rules.ts
index 0c21ddd..a6451ad 100644
--- a/src/rules/rules.ts
+++ b/src/rules/rules.ts
@@ -52,6 +52,7 @@ export const RULES = {
     'parameterCount',
     'plainScript',
     'propsDrilling',
+    'repeatedCss',
     'scriptLength',
     'shortVariableName',
     'tooManyProps',
diff --git a/src/rulesCheck.ts b/src/rulesCheck.ts
index 4ec34d2..781e3c4 100644
--- a/src/rulesCheck.ts
+++ b/src/rulesCheck.ts
@@ -1,7 +1,7 @@
 import type { SFCDescriptor } from '@vue/compiler-sfc'
 import type { OverrideConfig } from './types/Override'
 import { getHasServer, getIsNuxt } from './context'
-import { checkAmountOfComments, checkBigVif, checkBigVshow, checkComplicatedConditions, checkComputedSideEffects, checkCyclomaticComplexity, checkDeepIndentation, checkElseCondition, checkFunctionSize, checkHtmlImageElements, checkHtmlLink, checkHugeFiles, checkIfWithoutCurlyBraces, checkMagicNumbers, checkNestedTernary, checkNoDirectDomAccess, checkNoImportant, checkNoInlineStyles, checkNoPropDestructure, checkNoSkippedTests, checkNoTsLang, checkNoVarDeclaration, checkParameterCount, checkPlainScript, checkPropsDrilling, checkScriptLength, checkShortVariableName, checkTooManyProps, checkVForExpression, checkVForWithIndexKey, checkZeroLengthComparison } from './rules/rrd'
+import { checkAmountOfComments, checkBigVif, checkBigVshow, checkComplicatedConditions, checkComputedSideEffects, checkCyclomaticComplexity, checkDeepIndentation, checkElseCondition, checkFunctionSize, checkHtmlImageElements, checkHtmlLink, checkHugeFiles, checkIfWithoutCurlyBraces, checkMagicNumbers, checkNestedTernary, checkNoDirectDomAccess, checkNoImportant, checkNoInlineStyles, checkNoPropDestructure, checkNoSkippedTests, checkNoTsLang, checkNoVarDeclaration, checkParameterCount, checkPlainScript, checkPropsDrilling, checkRepeatedCss, checkScriptLength, checkShortVariableName, checkTooManyProps, checkVForExpression, checkVForWithIndexKey, checkZeroLengthComparison } from './rules/rrd'
 import { checkApiWithoutMethod, checkRateLimiter } from './rules/security'
 import { checkElementSelectorsWithScoped, checkImplicitParentChildCommunication } from './rules/vue-caution'
 import { checkGlobalStyle, checkSimpleProp, checkSingleNameComponent, checkVforNoKey, checkVifWithVfor } from './rules/vue-essential'
@@ -69,6 +69,7 @@ export const checkRules = (descriptor: SFCDescriptor, filePath: string, apply: s
     parameterCount: () => checkParameterCount(script, filePath, override.maxParameterCount),
     plainScript: () => isVueFile && checkPlainScript(descriptor.script, filePath),
     propsDrilling: () => checkPropsDrilling(script, filePath),
+    repeatedCss: () => checkRepeatedCss(descriptor.styles, filePath),
     scriptLength: () => checkScriptLength(script, filePath, override.maxScriptLength),
     shortVariableName: () => checkShortVariableName(script, filePath, override.minVariableName),
     tooManyProps: () => checkTooManyProps(script, filePath, override.maxPropsCount),
diff --git a/src/rulesReport.ts b/src/rulesReport.ts
index 9c695a9..d723058 100644
--- a/src/rulesReport.ts
+++ b/src/rulesReport.ts
@@ -1,7 +1,7 @@
 import type { GroupBy, Health, Offense, OffensesGrouped, OutputLevel, ReportFunction, SortBy } from './types'
 import type { OverrideConfig } from './types/Override'
 import type { ReportOutput } from './types/ReportOutput'
-import { reportAmountOfComments, reportBigVif, reportBigVshow, reportComplicatedConditions, reportComputedSideEffects, reportCyclomaticComplexity, reportDeepIndentation, reportElseCondition, reportFunctionSize, reportHtmlImageElements, reportHtmlLink, reportHugeFiles, reportIfWithoutCurlyBraces, reportMagicNumbers, reportNestedTernary, reportNoDirectDomAccess, reportNoImportant, reportNoInlineStyles, reportNoPropDestructure, reportNoSkippedTests, reportNoTsLang, reportNoVarDeclaration, reportParameterCount, reportPlainScript, reportPropsDrilling, reportScriptLength, reportShortVariableName, reportTooManyProps, reportVForExpression, reportVForWithIndexKey, reportZeroLengthComparison } from './rules/rrd'
+import { reportAmountOfComments, reportBigVif, reportBigVshow, reportComplicatedConditions, reportComputedSideEffects, reportCyclomaticComplexity, reportDeepIndentation, reportElseCondition, reportFunctionSize, reportHtmlImageElements, reportHtmlLink, reportHugeFiles, reportIfWithoutCurlyBraces, reportMagicNumbers, reportNestedTernary, reportNoDirectDomAccess, reportNoImportant, reportNoInlineStyles, reportNoPropDestructure, reportNoSkippedTests, reportNoTsLang, reportNoVarDeclaration, reportParameterCount, reportPlainScript, reportPropsDrilling, reportRepeatedCss, reportScriptLength, reportShortVariableName, reportTooManyProps, reportVForExpression, reportVForWithIndexKey, reportZeroLengthComparison } from './rules/rrd'
 import { reportApiWithoutMethod, reportRateLimiter } from './rules/security'
 import { reportElementSelectorsWithScoped, reportImplicitParentChildCommunication } from './rules/vue-caution'
 import { reportGlobalStyle, reportSimpleProp, reportSingleNameComponent, reportVforNoKey, reportVifWithVfor } from './rules/vue-essential'
@@ -84,6 +84,7 @@ export const reportRules = (groupBy: GroupBy, sortBy: SortBy, level: OutputLevel
   processOffenses(() => reportParameterCount(override.maxParameterCount))
   processOffenses(reportPlainScript)
   processOffenses(reportPropsDrilling)
+  processOffenses(reportRepeatedCss)
   processOffenses(() => reportScriptLength(override.maxScriptLength))
   processOffenses(() => reportShortVariableName(override.minVariableName))
   processOffenses(reportTooManyProps)


Test patch:
diff --git a/src/rules/rrd/repeatedCss.test.ts b/src/rules/rrd/repeatedCss.test.ts
new file mode 100644
index 0000000..735394a
--- /dev/null
+++ b/src/rules/rrd/repeatedCss.test.ts
@@ -0,0 +1,151 @@
+import type { SFCStyleBlock } from '@vue/compiler-sfc'
+import { beforeEach, describe, expect, it } from 'vitest'
+import { checkRepeatedCss, reportRepeatedCss, resetResults } from './repeatedCss'
+
+describe('checkRepeatedCss', () => {
+  beforeEach(() => {
+    resetResults()
+  })
+
+  it('should not report when css is not repeated more than 3 times across components', () => {
+    const style1 = {
+      content: `<style>
+          .foo { color: red; }
+        </style>`,
+    } as SFCStyleBlock
+    const style2 = {
+      content: `<style>
+          .bar { color: red; }
+        </style>`,
+    } as SFCStyleBlock
+    const style3 = {
+      content: `<style>
+          .baz { color: red; }
+        </style>`,
+    } as SFCStyleBlock
+
+    checkRepeatedCss([style1], 'component1.vue')
+    checkRepeatedCss([style2], 'component2.vue')
+    checkRepeatedCss([style3], 'component3.vue')
+
+    const result = reportRepeatedCss()
+    expect(result.length).toBe(0)
+    expect(result).toStrictEqual([])
+  })
+
+  it('should not report when css is not repeated more than 3 times in the component', () => {
+    const style = {
+      content: `<style>
+          .foo {
+            color: red;
+          }
+          .bar {
+            background-color: red;
+            color: red;
+          }
+        </style>`,
+    } as SFCStyleBlock
+    const filePath = 'repeatedCssSameComponent.vue'
+    checkRepeatedCss([style], filePath)
+    const result = reportRepeatedCss()
+    expect(result.length).toBe(0)
+    expect(result).toStrictEqual([])
+  })
+
+  it('should not report when css is repeated more than 3 times in the same component', () => {
+    const style = {
+      content: `<style>
+          .foo {
+            color: red;
+          }
+          .bar {
+            background-color: red;
+            color: red;
+          }
+          .baz {
+            color: red;
+          }
+          .qux {
+            color: red;
+          }
+        </style>`,
+    } as SFCStyleBlock
+    const filePath = 'repeatedCssSameComponent.vue'
+    checkRepeatedCss([style], filePath)
+    const result = reportRepeatedCss()
+    expect(result.length).toBe(0)
+    expect(result).toStrictEqual([])
+  })
+
+  it('should report when css is repeated more than 3 times across components', () => {
+    const style1 = {
+      content: `<style>
+          .foo { color: red; }
+        </style>`,
+    } as SFCStyleBlock
+    const style2 = {
+      content: `<style>
+          .bar { color: red; }
+        </style>`,
+    } as SFCStyleBlock
+    const style3 = {
+      content: `<style>
+          .baz { color: red; }
+        </style>`,
+    } as SFCStyleBlock
+    const style4 = {
+      content: `<style>
+          .qux { color: red; }
+        </style>`,
+    } as SFCStyleBlock
+
+    checkRepeatedCss([style1], 'component1.vue')
+    checkRepeatedCss([style2], 'component2.vue')
+    checkRepeatedCss([style3], 'component3.vue')
+    checkRepeatedCss([style4], 'component4.vue')
+
+    const result = reportRepeatedCss()
+    expect(result.length).toBe(1)
+    expect(result).toStrictEqual([
+      {
+        file: 'component1.vue, component2.vue, component3.vue, component4.vue',
+        rule: `<text_info>rrd ~ repeated CSS</text_info>`,
+        description: `👉 <text_warn>Avoid repeating CSS properties with the same value more than 3 times across files. Consider using CSS variables or a common class.</text_warn> See: https://vue-mess-detector.webmania.cc/rules/rrd/repeated-css.html`,
+        message: `repeated CSS: (color:red;) in 4 files 🚨`,
+      },
+    ])
+  })
+
+  it('should report when css is repeated in more than 3 files', () => {
+    const style = {
+      content: `<style>.foo { color: red; }</style>`,
+    } as SFCStyleBlock
+
+    for (let i = 1; i <= 5; i++) {
+      checkRepeatedCss([style], `component${i}.vue`)
+    }
+
+    const result = reportRepeatedCss()
+    expect(result.length).toBe(1)
+    expect(result).toStrictEqual([{
+      file: 'component1.vue, component2.vue, component3.vue, component4.vue, component5.vue',
+      rule: `<text_info>rrd ~ repeated CSS</text_info>`,
+      description: `👉 <text_warn>Avoid repeating CSS properties with the same value more than 3 times across files. Consider using CSS variables or a common class.</text_warn> See: https://vue-mess-detector.webmania.cc/rules/rrd/repeated-css.html`,
+      message: 'repeated CSS: (color:red;) in 5 files 🚨',
+    }])
+  })
+
+  it('should not report when css is repeated in 3 or fewer files', () => {
+    const style = {
+      content: `<style>.foo { color: blue; }</style>`,
+    } as SFCStyleBlock
+
+    for (let i = 1; i <= 3; i++) {
+      checkRepeatedCss([style], `component${i}.vue`)
+    }
+
+    const result = reportRepeatedCss()
+    expect(result.length).toBe(0)
+    expect(result).toStrictEqual([])
+  })
+})