Repository: vuejs/eslint-plugin-vue
Pull request #1446 (2021-03-01 05:39:29)

Fix false negatives for member access with `$` in `vue/this-in-template` rule
This PR fixes false negatives for the `vue/this-in-template` rule.

The following code was not reported.

```vue
<template>
  {{ this.$foo }}
</template>
```


Patch:
diff --git a/docs/rules/no-unused-properties.md b/docs/rules/no-unused-properties.md
index 932fb956..da8fa7d8 100644
--- a/docs/rules/no-unused-properties.md
+++ b/docs/rules/no-unused-properties.md
@@ -55,18 +55,20 @@ This rule cannot be checked for use in other components (e.g. `mixins`, Property
 {
   "vue/no-unused-properties": ["error", {
     "groups": ["props"],
-    "deepData": false
+    "deepData": false,
+    "ignorePublicMembers": false
   }]
 }
 ```
 
-- `"groups"` (`string[]`) Array of groups to search for properties. Default is `["props"]`. The value of the array is some of the following strings:
+- `groups` (`string[]`) Array of groups to search for properties. Default is `["props"]`. The value of the array is some of the following strings:
   - `"props"`
   - `"data"`
   - `"computed"`
   - `"methods"`
   - `"setup"`
-- `"deepData"` (`boolean`) If `true`, the object of the property defined in `data` will be searched deeply. Default is `false`. Include `"data"` in `groups` to use this option.
+- `deepData` (`boolean`) If `true`, the object of the property defined in `data` will be searched deeply. Default is `false`. Include `"data"` in `groups` to use this option.
+- `ignorePublicMembers` (`boolean`) If `true`, members marked with a [JSDoc `/** @public */` tag](https://jsdoc.app/tags-public.html) will be ignored. Default is `false`.
 
 ### `"groups": ["props", "data"]`
 
@@ -188,6 +190,34 @@ This rule cannot be checked for use in other components (e.g. `mixins`, Property
 
 </eslint-code-block>
 
+### `{ "groups": ["props", "methods"], "ignorePublicMembers": true }`
+
+<eslint-code-block :rules="{'vue/no-unused-properties': ['error', {groups: ['props', 'methods'], ignorePublicMembers: true}]}">
+
+```vue
+<!-- ✓ GOOD -->
+<template>
+  <button @click="usedInTemplate()" />
+</template>
+<script>
+  export default {
+    methods: {
+      /* ✓ GOOD */
+      usedInTemplate() {},
+      
+      /* ✓ GOOD */
+      /** @public */
+      publicMethod() {},
+      
+      /* ✗ BAD */
+      unusedMethod() {}
+    }
+  }
+</script>
+```
+
+</eslint-code-block>
+
 ## :rocket: Version
 
 This rule was introduced in eslint-plugin-vue v7.0.0
diff --git a/lib/rules/no-unused-properties.js b/lib/rules/no-unused-properties.js
index f89c5187..be18224e 100644
--- a/lib/rules/no-unused-properties.js
+++ b/lib/rules/no-unused-properties.js
@@ -459,6 +459,91 @@ function getObjectPatternPropertyPatternTracker(pattern) {
   return () => new UsedProperties({ unknown: true })
 }
 
+/**
+ * Check if the given component property is marked as `@public` in JSDoc comments.
+ * @param {ComponentPropertyData} property
+ * @param {SourceCode} sourceCode
+ */
+function isPublicMember(property, sourceCode) {
+  if (
+    property.type === 'object' &&
+    // Props do not support @public.
+    property.groupName !== 'props'
+  ) {
+    return isPublicProperty(property.property, sourceCode)
+  }
+  return false
+}
+
+/**
+ * Check if the given property node is marked as `@public` in JSDoc comments.
+ * @param {Property} node
+ * @param {SourceCode} sourceCode
+ */
+function isPublicProperty(node, sourceCode) {
+  const jsdoc = getJSDocFromProperty(node, sourceCode)
+  if (jsdoc) {
+    return /(?:^|\s|\*)@public\b/u.test(jsdoc.value)
+  }
+  return false
+}
+
+/**
+ * Get the JSDoc comment for a given property node.
+ * @param {Property} node
+ * @param {SourceCode} sourceCode
+ */
+function getJSDocFromProperty(node, sourceCode) {
+  const jsdoc = findJSDocComment(node, sourceCode)
+  if (jsdoc) {
+    return jsdoc
+  }
+  if (
+    node.value.type === 'FunctionExpression' ||
+    node.value.type === 'ArrowFunctionExpression'
+  ) {
+    return findJSDocComment(node.value, sourceCode)
+  }
+
+  return null
+}
+
+/**
+ * Finds a JSDoc comment for the given node.
+ * @param {ASTNode} node
+ * @param {SourceCode} sourceCode
+ * @returns {Comment | null}
+ */
+function findJSDocComment(node, sourceCode) {
+  /** @type {ASTNode | Token} */
+  let currentNode = node
+  let tokenBefore = null
+
+  while (currentNode) {
+    tokenBefore = sourceCode.getTokenBefore(currentNode, {
+      includeComments: true
+    })
+    if (!tokenBefore || !eslintUtils.isCommentToken(tokenBefore)) {
+      return null
+    }
+    if (tokenBefore.type === 'Line') {
+      currentNode = tokenBefore
+      continue
+    }
+    break
+  }
+
+  if (
+    tokenBefore &&
+    tokenBefore.type === 'Block' &&
+    tokenBefore.value.charAt(0) === '*'
+  ) {
+    return tokenBefore
+  }
+
+  return null
+}
+
 // ------------------------------------------------------------------------------
 // Rule Definition
 // ------------------------------------------------------------------------------
@@ -490,7 +575,8 @@ module.exports = {
             additionalItems: false,
             uniqueItems: true
           },
-          deepData: { type: 'boolean' }
+          deepData: { type: 'boolean' },
+          ignorePublicMembers: { type: 'boolean' }
         },
         additionalProperties: false
       }
@@ -504,6 +590,7 @@ module.exports = {
     const options = context.options[0] || {}
     const groups = new Set(options.groups || [GROUP_PROPERTY])
     const deepData = Boolean(options.deepData)
+    const ignorePublicMembers = Boolean(options.ignorePublicMembers)
 
     /** @type {Map<FunctionDeclaration | FunctionExpression | ArrowFunctionExpression, ParamsUsedProperties>} */
     const paramsUsedPropertiesMap = new Map()
@@ -626,6 +713,12 @@ module.exports = {
             // used template refs
             continue
           }
+          if (
+            ignorePublicMembers &&
+            isPublicMember(property, context.getSourceCode())
+          ) {
+            continue
+          }
           if (usedProperties.isUsed(property.name)) {
             // used
             if (
diff --git a/lib/rules/this-in-template.js b/lib/rules/this-in-template.js
index 0efa0303..8ed42345 100644
--- a/lib/rules/this-in-template.js
+++ b/lib/rules/this-in-template.js
@@ -83,7 +83,7 @@ module.exports = {
                 !propertyName ||
                 scopeStack.nodes.some((el) => el.name === propertyName) ||
                 RESERVED_NAMES.has(propertyName) || // this.class | this['class']
-                /^[0-9].*$|[^a-zA-Z0-9_]/.test(propertyName) // this['0aaaa'] | this['foo-bar bas']
+                /^[0-9].*$|[^a-zA-Z0-9_$]/.test(propertyName) // this['0aaaa'] | this['foo-bar bas']
               ) {
                 return
               }
diff --git a/typings/eslint-utils/index.d.ts b/typings/eslint-utils/index.d.ts
index 884fc60c..a9752287 100644
--- a/typings/eslint-utils/index.d.ts
+++ b/typings/eslint-utils/index.d.ts
@@ -1,4 +1,5 @@
 import * as VAST from '../eslint-plugin-vue/util-types/ast'
+import { Token, Comment } from '../eslint-plugin-vue/util-types/node'
 import { ParserServices } from '../eslint-plugin-vue/util-types/parser-services'
 import eslint from 'eslint'
 
@@ -70,3 +71,5 @@ export namespace ReferenceTracker {
   const CONSTRUCT: unique symbol
   const ESM: unique symbol
 }
+
+export function isCommentToken(token: Token): token is Comment


Test patch:
diff --git a/tests/lib/rules/no-unused-properties.js b/tests/lib/rules/no-unused-properties.js
index eae813bb..e52a4bbf 100644
--- a/tests/lib/rules/no-unused-properties.js
+++ b/tests/lib/rules/no-unused-properties.js
@@ -1428,6 +1428,113 @@ tester.run('no-unused-properties', rule, {
       }
       </script>`,
       options: deepDataOptions
+    },
+
+    // ignore public members
+    {
+      filename: 'test.vue',
+      code: `
+        <script>
+        export default {
+          data: () => ({
+            /** @public */
+            publicData: 'foo'
+          })
+        }
+        </script>
+      `,
+      options: [{ groups: ['data'], ignorePublicMembers: true }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <script>
+        export default {
+          computed: {
+            /** @public */
+            unusedComputed() {
+              return 'foo'
+            }
+          }
+        }
+        </script>
+      `,
+      options: [{ groups: ['computed'], ignorePublicMembers: true }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <script>
+        export default {
+          methods: {
+            /** @public */
+            publicMethod() {}
+          }
+        }
+        </script>
+      `,
+      options: [{ groups: ['methods'], ignorePublicMembers: true }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <script>
+        export default {
+          setup() {
+            return {
+              /** @public */
+              publicRef: 3
+            }
+          }
+        }
+        </script>
+      `,
+      options: [{ groups: ['setup'], ignorePublicMembers: true }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+      </template>
+      <script>
+      export default {
+        props: {
+          a: String,
+        },
+        setup() {
+          return {
+            /** @public */
+            b: 42
+          }
+        },
+        data () {
+          return {
+            /** @public */
+            c: 42
+          }
+        },
+        computed: {
+          /** @public */
+          d () { return this.a }
+        },
+        methods: {
+          /** @public */
+          e () {},
+          f:
+            /** @public */
+            function () {},
+          g:
+            /** @public */
+            () => {},
+        },
+      }
+      </script>`,
+      options: [
+        {
+          groups: ['props', 'data', 'computed', 'methods', 'setup'],
+          ignorePublicMembers: true
+        }
+      ]
     }
   ],
 
@@ -2194,6 +2301,105 @@ tester.run('no-unused-properties', rule, {
           column: 17
         }
       ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+      </template>
+      <script>
+      export default {
+        props: {
+          _a: String,
+          /** @public Props do not support public. */
+          a: String,
+        },
+        setup() {
+          return {
+            /** @public */
+            b: 42,
+            _b: 42
+          }
+        },
+        data () {
+          return {
+            c: {
+              /** @public */
+              _c1: 42,
+              /** @public */
+              _c2: 42
+            },
+            _c: 42
+          }
+        },
+        computed: {
+          /** @public */
+          // _eslint-disable-next-line foo
+          d () { return this.c._c1 },
+          /* non jsdoc @public */
+          _d () { return 42 }
+        },
+        methods: {
+          /** @public */
+          e () {},
+          _e () {},
+          f:
+            /** @public */
+            function () {},
+          _f: function () {},
+          g:
+            /** @public */
+            () => {},
+          _g: () => {},
+        },
+      }
+      </script>`,
+      options: [
+        {
+          groups: ['props', 'data', 'computed', 'methods', 'setup'],
+          ignorePublicMembers: true,
+          deepData: true
+        }
+      ],
+      errors: [
+        "'_a' of property found, but never used.",
+        "'a' of property found, but never used.",
+        "'_b' of property returned from `setup()` found, but never used.",
+        "'c._c2' of data found, but never used.",
+        "'_c' of data found, but never used.",
+        "'_d' of computed property found, but never used.",
+        "'_e' of method found, but never used.",
+        "'_f' of method found, but never used.",
+        "'_g' of method found, but never used."
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+      </template>
+      <script>
+      export default {
+        // Edge case
+        data: [
+          /** @public doesn't public supports array style */
+          'a',
+          /** @public doesn't public supports array style */
+          'b'
+        ]
+      }
+      </script>`,
+      options: [
+        {
+          groups: ['data'],
+          ignorePublicMembers: true,
+          deepData: true
+        }
+      ],
+      errors: [
+        "'a' of data found, but never used.",
+        "'b' of data found, but never used."
+      ]
     }
   ]
 })
diff --git a/tests/lib/rules/this-in-template.js b/tests/lib/rules/this-in-template.js
index 5f36b570..f2a40ff6 100644
--- a/tests/lib/rules/this-in-template.js
+++ b/tests/lib/rules/this-in-template.js
@@ -222,4 +222,16 @@ ruleTester.run('this-in-template', rule, {
     .concat(
       createInvalidTests('', ['always'], "Expected 'this'.", 'Identifier')
     )
+    .concat([
+      {
+        code: `<template><div v-if="fn(this.$foo)"></div></template><!-- never -->`,
+        errors: ["Unexpected usage of 'this'."],
+        options: ['never']
+      },
+      {
+        code: `<template><div :class="{ foo: this.$foo }"></div></template><!-- never -->`,
+        errors: ["Unexpected usage of 'this'."],
+        options: ['never']
+      }
+    ])
 })