Repository: rrd108/vue-mess-detector
Pull request #131 (2024-08-11 23:09:23)

feat: add rrd props drilling rule w tests
This PR includes:
- new rrd ~ props drilling rule
- new rule added in docs
- add code examples for #123
![image](https://github.com/user-attachments/assets/24839a6a-d08c-47f3-a877-15a1a9368cbd)
 
- use or magic-regex for #74 


Patch:
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index a1a6429..2feb72e 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -74,6 +74,7 @@ export default defineConfig({
                 { text: 'Magic Numbers', link: '/rules/rrd/magic-numbers' },
                 { 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: '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 a8f44e9..2f15f0d 100644
--- a/docs/rules/rrd/index.md
+++ b/docs/rules/rrd/index.md
@@ -11,6 +11,7 @@ These ruleset is the most opinionated with rules that are not part of the *offic
 - [Magic Numbers](./magic-numbers.md)
 - [Parameter Count](./parameter-count.md)
 - [Plain Script](./plain-script.md)
+- [Props Drilling](./props-drilling.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/props-drilling.md b/docs/rules/rrd/props-drilling.md
new file mode 100644
index 0000000..a2ad7c6
--- /dev/null
+++ b/docs/rules/rrd/props-drilling.md
@@ -0,0 +1,48 @@
+# Props Drilling
+
+Check if props are being forwarded unmodified to child components.
+
+## 📖 What is props drilling?
+
+Props drilling refers to the practice of passing props from a parent component to a child component without the child component using them directly. Instead, the child component merely forwards these props to its own children, which can lead to unnecessary complexity and decreased maintainability.
+
+## ❓ Why it's good to follow this rule?
+
+- **Clarity:** Forwarding props unmodified can obscure the data flow within your components. By ensuring that props are only passed when necessary, your code becomes clearer and easier to understand.
+- **Performance:** Unused props can lead to unnecessary re-renders in child components. Avoiding props drilling can help optimize your application's performance by reducing the number of props that need to be tracked and updated.
+- **Maintainability:** When components are designed to manage their own data instead of passing it blindly to children, it becomes easier to maintain and refactor the codebase. This practice reduces dependencies between components, leading to a more modular architecture.
+- **State Management:** If a prop is consistently passed down without being modified, it may be a sign that state management should be handled differently—using Vue's `provide/inject` or a store can help streamline data flow.
+
+## 😱 Examples of code for which this rule will throw a warning
+
+::: warning
+The following code forwards the `items` prop to `ChildComponent` without modifying it. What does the `items` prop represent? It should be handled differently.
+:::
+
+```vue
+<script setup>
+const props = defineProps(['items'])
+</script>
+
+<template>
+  <ChildComponent :items="props.items" />
+</template>
+```
+
+## 🤩 How to fix it?
+
+::: tip
+Refactor the code to avoid drilling the prop unmodified. You can either use the prop directly in the parent or use a state management solution (like Pinia or provide/inject) to manage the prop.
+:::
+
+```vue
+<script setup>
+import { computed } from 'vue'
+const props = defineProps(['items'])
+const filteredItems = computed(() => props.items.filter(item => item.active))
+</script>
+
+<template>
+  <ChildComponent :items="filteredItems" />
+</template>
+```
diff --git a/src/rules/rrd/propsDrilling.ts b/src/rules/rrd/propsDrilling.ts
new file mode 100644
index 0000000..07e3ba8
--- /dev/null
+++ b/src/rules/rrd/propsDrilling.ts
@@ -0,0 +1,90 @@
+/* eslint-disable no-cond-assign */
+import type { SFCScriptBlock } from '@vue/compiler-sfc'
+import { charIn, charNotIn, createRegExp, global, maybe, oneOrMore, whitespace, wordChar } from 'magic-regexp'
+import { BG_RESET, BG_WARN, TEXT_INFO, TEXT_RESET, TEXT_WARN } from '../asceeCodes'
+import type { FileCheckResult, Offense } from '../../types'
+
+const results: FileCheckResult[] = []
+
+const checkPropsDrilling = (script: SFCScriptBlock | null, filePath: string) => {
+  if (!script) {
+    return
+  }
+
+  // Matches `defineProps(['prop1', 'prop2', ...])` and extracts the prop names
+  const regexDefineProps = createRegExp(
+    'defineProps(',
+    whitespace.times.any(),
+    '[',
+    whitespace.times.any(),
+    oneOrMore(charIn('\'"'), oneOrMore(wordChar), charIn('\'"'), whitespace.times.any(), maybe(',', whitespace.times.any())),
+    ']',
+    whitespace.times.any(),
+    ')',
+    [global],
+  )
+
+  // Matches props usage in child components, e.g., `<ChildComponent :prop="props.prop" />`
+  const regexPropUsage = createRegExp(
+    '<',
+    oneOrMore(wordChar).grouped(),
+    whitespace,
+    charNotIn('>').times.any(),
+    ':',
+    oneOrMore(wordChar).grouped(),
+    whitespace.times.any(),
+    '=',
+    whitespace.times.any(),
+    '"props.',
+    oneOrMore(wordChar).grouped(),
+    '"',
+    [global],
+  )
+
+  let matchProps
+  const props = new Set<string>()
+
+  // Extract props defined in the component
+  while ((matchProps = regexDefineProps.exec(script.content)) !== null) {
+    const propList = matchProps[0]
+      .replace(/defineProps\(|[)[\]'"\s]/g, '') // Clean up the matched string to extract prop names
+      .split(',')
+    propList.forEach(prop => props.add(prop))
+  }
+
+  let matchUsage
+
+  // Check if props are forwarded to child components unmodified
+  while ((matchUsage = regexPropUsage.exec(script.content)) !== null) {
+    const childComponent = matchUsage[1]
+    const childProp = matchUsage[2]
+    const parentProp = matchUsage[3]
+
+    if (props.has(parentProp) && childProp === parentProp) {
+      results.push({
+        filePath,
+        message: `Prop ${BG_WARN}(${parentProp})${BG_RESET} is being drilled through ${BG_WARN}${childComponent}${BG_RESET} component unmodified.`,
+      })
+    }
+  }
+}
+
+const reportPropsDrilling = () => {
+  const offenses: Offense[] = []
+
+  if (results.length > 0) {
+    results.forEach((result) => {
+      offenses.push({
+        file: result.filePath,
+        rule: `${TEXT_INFO}rrd ~ props drilling${TEXT_RESET}`,
+        description: `👉 ${TEXT_WARN}Props should not be forwarded unmodified. Consider refactoring.${TEXT_RESET}`,
+        message: `${result.message} 🚨`,
+      })
+    })
+  }
+  return offenses
+}
+
+const resetPropsDrilling = () => (results.length = 0)
+
+export { checkPropsDrilling, reportPropsDrilling, resetPropsDrilling }
diff --git a/src/rules/rules.ts b/src/rules/rules.ts
index 16ab11b..347376c 100644
--- a/src/rules/rules.ts
+++ b/src/rules/rules.ts
@@ -24,6 +24,7 @@ export const RULES = {
     'magicNumbers',
     'parameterCount',
     'plainScript',
+    'propsDrilling',
     'scriptLength',
     'shortVariableName',
     'tooManyProps',
diff --git a/src/rulesCheck.ts b/src/rulesCheck.ts
index d2429f5..fc7cf96 100644
--- a/src/rulesCheck.ts
+++ b/src/rulesCheck.ts
@@ -20,6 +20,7 @@ import { checkElementAttributeOrder } from './rules/vue-recommended/elementAttri
 import { checkTooManyProps } from './rules/rrd/tooManyProps'
 import { checkFunctionSize } from './rules/rrd/functionSize'
 import { checkParameterCount } from './rules/rrd/parameterCount'
+import { checkPropsDrilling } from './rules/rrd/propsDrilling'
 import { checkShortVariableName } from './rules/rrd/shortVariableName'
 import { checkSimpleComputed } from './rules/vue-strong/simpleComputed'
 import { checkComponentFiles } from './rules/vue-strong/componentFiles'
@@ -77,6 +78,7 @@ export const checkRules = (descriptor: SFCDescriptor, filePath: string, apply: A
     checkMagicNumbers(script, filePath)
     checkParameterCount(script, filePath)
     checkPlainScript(descriptor.script, filePath)
+    checkPropsDrilling(script, filePath)
     checkScriptLength(script, filePath)
     checkShortVariableName(script, filePath)
     checkTooManyProps(script, filePath)
diff --git a/src/rulesReport.ts b/src/rulesReport.ts
index 98f464a..cc6f9e2 100644
--- a/src/rulesReport.ts
+++ b/src/rulesReport.ts
@@ -19,6 +19,7 @@ import { reportElementAttributeOrder } from './rules/vue-recommended/elementAttr
 import { reportTooManyProps } from './rules/rrd/tooManyProps'
 import { reportFunctionSize } from './rules/rrd/functionSize'
 import { reportParameterCount } from './rules/rrd/parameterCount'
+import { reportPropsDrilling } from './rules/rrd/propsDrilling'
 import { reportShortVariableName } from './rules/rrd/shortVariableName'
 import { reportSimpleComputed } from './rules/vue-strong/simpleComputed'
 import { reportComponentFiles } from './rules/vue-strong/componentFiles'
@@ -26,10 +27,10 @@ import { reportImplicitParentChildCommunication } from './rules/vue-caution/impl
 import { reportDeepIndentation } from './rules/rrd/deepIndentation'
 import type { GroupBy, Offense, OffensesGrouped, ReportFunction } from './types'
 import { reportHtmlLink } from './rules/rrd/htmlLink'
+import { reportIfWithoutCurlyBraces } from './rules/rrd/ifWithoutCurlyBraces'
 import { reportMagicNumbers } from './rules/rrd/magicNumbers'
 import { reportMultiAttributeElements } from './rules/vue-strong/multiAttributeElements'
 import { reportElementSelectorsWithScoped } from './rules/vue-caution/elementSelectorsWithScoped'
-import { reportIfWithoutCurlyBraces } from './rules/rrd/ifWithoutCurlyBraces'
 
 export const reportRules = (groupBy: GroupBy) => {
   let errors = 0
@@ -91,6 +92,7 @@ export const reportRules = (groupBy: GroupBy) => {
   processOffenses(reportMagicNumbers)
   processOffenses(reportParameterCount)
   processOffenses(reportPlainScript)
+  processOffenses(reportPropsDrilling)
   processOffenses(reportScriptLength)
   processOffenses(reportShortVariableName)
   processOffenses(reportTooManyProps)


Test patch:
diff --git a/src/rules/rrd/propsDrilling.test.ts b/src/rules/rrd/propsDrilling.test.ts
new file mode 100644
index 0000000..783d147
--- /dev/null
+++ b/src/rules/rrd/propsDrilling.test.ts
@@ -0,0 +1,93 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import type { SFCScriptBlock } from '@vue/compiler-sfc'
+import { BG_RESET, BG_WARN, TEXT_INFO, TEXT_RESET, TEXT_WARN } from '../asceeCodes'
+import { checkPropsDrilling, reportPropsDrilling, resetPropsDrilling } from './propsDrilling'
+
+describe('checkPropsDrilling', () => {
+  beforeEach(() => {
+    resetPropsDrilling()
+  })
+
+  it('should not report when a prop is used within the component', () => {
+    const script = {
+      content: `
+        <script setup>
+          const props = defineProps(['items'])
+          const itemCount = props.items.length;
+        </script>
+      `,
+    } as SFCScriptBlock
+    const filePath = 'component.vue'
+    checkPropsDrilling(script, filePath)
+    expect(reportPropsDrilling().length).toBe(0)
+    expect(reportPropsDrilling()).toStrictEqual([])
+  })
+
+  it('should not report when a prop is modified before being passed to a child component', () => {
+    const script = {
+      content: `
+        <script setup>
+          import { computed } from 'vue'
+          const props = defineProps(['items'])
+          const filteredItems = computed(() => props.items.filter(item => item.active))
+        </script>
+
+        <template>
+          <ChildComponent :items="filteredItems" />
+        </template>
+      `,
+    } as SFCScriptBlock
+    const filePath = 'component.vue'
+    checkPropsDrilling(script, filePath)
+    expect(reportPropsDrilling().length).toBe(0)
+    expect(reportPropsDrilling()).toStrictEqual([])
+  })
+
+  it('should report when a prop is forwarded unmodified to a child component', () => {
+    const script = {
+      content: `
+        <script setup>
+          const props = defineProps(['items'])
+          const emit = defineEmits(['update'])
+          emit('update', props.items)
+          <ChildComponent :items="props.items" />
+        </script>
+      `,
+    } as SFCScriptBlock
+    const filePath = 'component.vue'
+    checkPropsDrilling(script, filePath)
+    expect(reportPropsDrilling().length).toBe(1)
+    expect(reportPropsDrilling()).toStrictEqual([{
+      file: filePath,
+      rule: `${TEXT_INFO}rrd ~ props drilling${TEXT_RESET}`,
+      description: `👉 ${TEXT_WARN}Props should not be forwarded unmodified. Consider refactoring.${TEXT_RESET}`,
+      message: `Prop ${BG_WARN}(items)${BG_RESET} is being drilled through ${BG_WARN}ChildComponent${BG_RESET} component unmodified. 🚨`,
+    }])
+  })
+
+  it('should report multiple drilled props in the same file', () => {
+    const script = {
+      content: `
+        <script setup>
+          const props = defineProps(['items', 'title'])
+          <ChildComponent :items="props.items" />
+          <AnotherComponent :title="props.title" />
+        </script>
+      `,
+    } as SFCScriptBlock
+    const filePath = 'component.vue'
+    checkPropsDrilling(script, filePath)
+    expect(reportPropsDrilling().length).toBe(2)
+    expect(reportPropsDrilling()).toStrictEqual([{
+      file: filePath,
+      rule: `${TEXT_INFO}rrd ~ props drilling${TEXT_RESET}`,
+      description: `👉 ${TEXT_WARN}Props should not be forwarded unmodified. Consider refactoring.${TEXT_RESET}`,
+      message: `Prop ${BG_WARN}(items)${BG_RESET} is being drilled through ${BG_WARN}ChildComponent${BG_RESET} component unmodified. 🚨`,
+    }, {
+      file: filePath,
+      rule: `${TEXT_INFO}rrd ~ props drilling${TEXT_RESET}`,
+      description: `👉 ${TEXT_WARN}Props should not be forwarded unmodified. Consider refactoring.${TEXT_RESET}`,
+      message: `Prop ${BG_WARN}(title)${BG_RESET} is being drilled through ${BG_WARN}AnotherComponent${BG_RESET} component unmodified. 🚨`,
+    }])
+  })
+})