Repository: vuejs/eslint-plugin-vue
Pull request #1769 (2022-01-21 00:31:50)

Add `vue/quote-props` rule
This PR adds `vue/quote-props` rule that applies `quote-props` rule to expressions in `<template>`.


Patch:
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 66a0eda1..b7407544 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -399,6 +399,7 @@ The following rules extend the rules provided by ESLint itself and apply them to
 | [vue/object-shorthand](./object-shorthand.md) | require or disallow method and property shorthand syntax for object literals in `<template>` | :wrench: |
 | [vue/operator-linebreak](./operator-linebreak.md) | enforce consistent linebreak style for operators in `<template>` | :wrench: |
 | [vue/prefer-template](./prefer-template.md) | require template literals instead of string concatenation in `<template>` | :wrench: |
+| [vue/quote-props](./quote-props.md) | require quotes around object literal property names in `<template>` | :wrench: |
 | [vue/space-in-parens](./space-in-parens.md) | enforce consistent spacing inside parentheses in `<template>` | :wrench: |
 | [vue/space-infix-ops](./space-infix-ops.md) | require spacing around infix operators in `<template>` | :wrench: |
 | [vue/space-unary-ops](./space-unary-ops.md) | enforce consistent spacing before or after unary operators in `<template>` | :wrench: |
diff --git a/docs/rules/quote-props.md b/docs/rules/quote-props.md
new file mode 100644
index 00000000..79b73810
--- /dev/null
+++ b/docs/rules/quote-props.md
@@ -0,0 +1,27 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/quote-props
+description: require quotes around object literal property names in `<template>`
+---
+# vue/quote-props
+
+> require quotes around object literal property names in `<template>`
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
+
+This rule is the same rule as core [quote-props] rule but it applies to the expressions in `<template>`.
+
+## :books: Further Reading
+
+- [quote-props]
+
+[quote-props]: https://eslint.org/docs/rules/quote-props
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/quote-props.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/quote-props.js)
+
+<sup>Taken with ❤️ [from ESLint core](https://eslint.org/docs/rules/quote-props)</sup>
diff --git a/lib/index.js b/lib/index.js
index c04b18ca..5d770e8b 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -160,6 +160,7 @@ module.exports = {
     'prefer-separate-static-class': require('./rules/prefer-separate-static-class'),
     'prefer-template': require('./rules/prefer-template'),
     'prop-name-casing': require('./rules/prop-name-casing'),
+    'quote-props': require('./rules/quote-props'),
     'require-component-is': require('./rules/require-component-is'),
     'require-default-prop': require('./rules/require-default-prop'),
     'require-direct-export': require('./rules/require-direct-export'),
diff --git a/lib/rules/quote-props.js b/lib/rules/quote-props.js
new file mode 100644
index 00000000..aabfc2bf
--- /dev/null
+++ b/lib/rules/quote-props.js
@@ -0,0 +1,54 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const { wrapCoreRule, flatten } = require('../utils')
+
+// eslint-disable-next-line no-invalid-meta, no-invalid-meta-docs-categories
+module.exports = wrapCoreRule('quote-props', {
+  skipDynamicArguments: true,
+  preprocess(context, { wrapContextToOverrideProperties, defineVisitor }) {
+    const sourceCode = context.getSourceCode()
+    /**
+     * @type {'"' | "'" | null}
+     */
+    let htmlQuote = null
+    defineVisitor({
+      /** @param {VExpressionContainer} node */
+      'VAttribute > VExpressionContainer.value'(node) {
+        const text = sourceCode.getText(node)
+        const firstChar = text[0]
+        htmlQuote = firstChar === "'" || firstChar === '"' ? firstChar : null
+      },
+      'VAttribute > VExpressionContainer.value:exit'() {
+        htmlQuote = null
+      }
+    })
+
+    wrapContextToOverrideProperties({
+      // Override the report method and replace the quotes in the fixed text with safe quotes.
+      report(descriptor) {
+        if (htmlQuote) {
+          const expectedQuote = htmlQuote === '"' ? "'" : '"'
+          context.report({
+            ...descriptor,
+            *fix(fixer) {
+              for (const fix of flatten(
+                descriptor.fix && descriptor.fix(fixer)
+              )) {
+                yield fixer.replaceTextRange(
+                  fix.range,
+                  fix.text.replace(/["']/gu, expectedQuote)
+                )
+              }
+            }
+          })
+        } else {
+          context.report(descriptor)
+        }
+      }
+    })
+  }
+})
diff --git a/lib/utils/index.js b/lib/utils/index.js
index e8ca57f8..bacb892b 100644
--- a/lib/utils/index.js
+++ b/lib/utils/index.js
@@ -79,6 +79,41 @@ function getCoreRule(name) {
   return map.get(name) || null
 }
 
+/**
+ * @template {object} T
+ * @param {T} target
+ * @param {Partial<T>[]} propsArray
+ * @returns {T}
+ */
+function newProxy(target, ...propsArray) {
+  const result = new Proxy(
+    {},
+    {
+      get(_object, key) {
+        for (const props of propsArray) {
+          if (key in props) {
+            // @ts-expect-error
+            return props[key]
+          }
+        }
+        // @ts-expect-error
+        return target[key]
+      },
+
+      has(_object, key) {
+        return key in target
+      },
+      ownKeys(_object) {
+        return Reflect.ownKeys(target)
+      },
+      getPrototypeOf(_object) {
+        return Reflect.getPrototypeOf(target)
+      }
+    }
+  )
+  return /** @type {T} */ (result)
+}
+
 /**
  * Wrap the rule context object to override methods which access to tokens (such as getTokenAfter).
  * @param {RuleContext} context The rule context object.
@@ -147,18 +182,16 @@ function wrapContextToOverrideTokenMethods(context, tokenStore, options) {
     })
     return result
   }
-  const sourceCode = new Proxy(Object.assign({}, eslintSourceCode), {
-    get(_object, key) {
-      if (key === 'tokensAndComments') {
+  const sourceCode = newProxy(
+    eslintSourceCode,
+    {
+      get tokensAndComments() {
         return getTokensAndComments()
-      }
-      if (key === 'getNodeByRangeIndex') {
-        return getNodeByRangeIndex
-      }
-      // @ts-expect-error
-      return key in tokenStore ? tokenStore[key] : eslintSourceCode[key]
-    }
-  })
+      },
+      getNodeByRangeIndex
+    },
+    tokenStore
+  )
 
   const containerScopes = new WeakMap()
 
@@ -183,23 +216,13 @@ function wrapContextToOverrideTokenMethods(context, tokenStore, options) {
       const eslintScope = createRequire(require.resolve('eslint'))(
         'eslint-scope'
       )
-      const expStmt = new Proxy(exprContainer, {
-        get(_object, key) {
-          if (key === 'type') {
-            return 'ExpressionStatement'
-          }
-          // @ts-expect-error
-          return exprContainer[key]
-        }
+      const expStmt = newProxy(exprContainer, {
+        // @ts-expect-error
+        type: 'ExpressionStatement'
       })
-      const scopeProgram = new Proxy(programNode, {
-        get(_object, key) {
-          if (key === 'body') {
-            return [expStmt]
-          }
-          // @ts-expect-error
-          return programNode[key]
-        }
+      const scopeProgram = newProxy(programNode, {
+        // @ts-expect-error
+        body: [expStmt]
       })
       const scope = eslintScope.analyze(scopeProgram, {
         ignoreEval: true,
@@ -218,9 +241,7 @@ function wrapContextToOverrideTokenMethods(context, tokenStore, options) {
 
     return null
   }
-  return {
-    // @ts-expect-error
-    __proto__: context,
+  return newProxy(context, {
     getSourceCode() {
       return sourceCode
     },
@@ -232,7 +253,7 @@ function wrapContextToOverrideTokenMethods(context, tokenStore, options) {
 
       return context.getDeclaredVariables(node)
     }
-  }
+  })
 }
 
 /**
@@ -262,9 +283,7 @@ function wrapContextToOverrideReportMethodToSkipDynamicArgument(context) {
     leaveNode() {}
   })
 
-  return {
-    // @ts-expect-error
-    __proto__: context,
+  return newProxy(context, {
     report(descriptor, ...args) {
       let range = null
       if (descriptor.loc) {
@@ -289,7 +308,7 @@ function wrapContextToOverrideReportMethodToSkipDynamicArgument(context) {
       }
       context.report(descriptor, ...args)
     }
-  }
+  })
 }
 
 // ------------------------------------------------------------------------------
@@ -322,6 +341,25 @@ module.exports = {
    */
   defineDocumentVisitor,
 
+  /**
+   * @callback WrapCoreRuleCreate
+   * @param {RuleContext} ruleContext
+   * @param {WrapCoreRuleCreateContext} wrapContext
+   * @returns {TemplateListener}
+   *
+   * @typedef {object} WrapCoreRuleCreateContext
+   * @property {RuleListener} coreHandlers
+   */
+  /**
+   * @callback WrapCoreRulePreprocess
+   * @param {RuleContext} ruleContext
+   * @param {WrapCoreRulePreprocessContext} wrapContext
+   * @returns {void}
+   *
+   * @typedef {object} WrapCoreRulePreprocessContext
+   * @property { (override: Partial<RuleContext>) => RuleContext } wrapContextToOverrideProperties Wrap the rule context object to override
+   * @property { (visitor: TemplateListener) => void } defineVisitor Define template body visitor
+   */
   /**
    * Wrap a given core rule to apply it to Vue.js template.
    * @param {string} coreRuleName The name of the core rule implementation to wrap.
@@ -330,7 +368,8 @@ module.exports = {
    * @param {boolean} [options.skipDynamicArguments] If `true`, skip validation within dynamic arguments.
    * @param {boolean} [options.skipDynamicArgumentsReport] If `true`, skip report within dynamic arguments.
    * @param {boolean} [options.applyDocument] If `true`, apply check to document fragment.
-   * @param { (context: RuleContext, options: { coreHandlers: RuleListener }) => TemplateListener } [options.create] If define, extend core rule.
+   * @param {WrapCoreRulePreprocess} [options.preprocess] Preprocess to calling create of core rule.
+   * @param {WrapCoreRuleCreate} [options.create] If define, extend core rule.
    * @returns {RuleModule} The wrapped rule implementation.
    */
   wrapCoreRule(coreRuleName, options) {
@@ -366,6 +405,7 @@ module.exports = {
       skipDynamicArguments,
       skipDynamicArgumentsReport,
       applyDocument,
+      preprocess,
       create
     } = options || {}
     return {
@@ -387,12 +427,25 @@ module.exports = {
             wrapContextToOverrideReportMethodToSkipDynamicArgument(context)
         }
 
-        // Move `Program` handlers to `VElement[parent.type!='VElement']`
+        /** @type {TemplateListener} */
+        const handlers = {}
+
+        if (preprocess) {
+          preprocess(context, {
+            wrapContextToOverrideProperties(override) {
+              context = newProxy(context, override)
+              return context
+            },
+            defineVisitor(visitor) {
+              compositingVisitors(handlers, visitor)
+            }
+          })
+        }
+
         const coreHandlers = coreRule.create(context)
+        compositingVisitors(handlers, coreHandlers)
 
-        const handlers = /** @type {TemplateListener} */ (
-          Object.assign({}, coreHandlers)
-        )
+        // Move `Program` handlers to `VElement[parent.type!='VElement']`
         if (handlers.Program) {
           handlers[
             applyDocument
@@ -462,6 +515,13 @@ module.exports = {
    * @returns {v is T}
    */
   isDef,
+  /**
+   * Flattens arrays, objects and iterable objects.
+   * @template T
+   * @param {T | Iterable<T> | null | undefined} v
+   * @returns {T[]}
+   */
+  flatten,
   /**
    * Get the previous sibling element of the given element.
    * @param {VElement} node The element node to get the previous sibling element.
@@ -1837,6 +1897,33 @@ function isDef(v) {
   return v != null
 }
 
+/**
+ * Flattens arrays, objects and iterable objects.
+ * @template T
+ * @param {T | Iterable<T> | null | undefined} v
+ * @returns {T[]}
+ */
+function flatten(v) {
+  /** @type {T[]} */
+  const result = []
+  if (v) {
+    if (isIterable(v)) {
+      result.push(...v)
+    } else {
+      result.push(v)
+    }
+  }
+  return result
+}
+
+/**
+ * @param {*} v
+ * @returns {v is Iterable<any>}
+ */
+function isIterable(v) {
+  return v && Symbol.iterator in v
+}
+
 // ------------------------------------------------------------------------------
 // Nodejs Helpers
 // ------------------------------------------------------------------------------
diff --git a/typings/eslint-plugin-vue/util-types/parser-services.ts b/typings/eslint-plugin-vue/util-types/parser-services.ts
index 4c938f33..d59d76d3 100644
--- a/typings/eslint-plugin-vue/util-types/parser-services.ts
+++ b/typings/eslint-plugin-vue/util-types/parser-services.ts
@@ -124,8 +124,8 @@ export namespace ParserServices {
       left: VNODE.HasLocation,
       right: VNODE.HasLocation
     ): boolean
-    getCommentsBefore(nodeOrToken: VNODE.HasLocation): VNODE.Token[]
-    getCommentsAfter(nodeOrToken: VNODE.HasLocation): VNODE.Token[]
-    getCommentsInside(node: VNODE.HasLocation): VNODE.Token[]
+    getCommentsBefore(nodeOrToken: VNODE.HasLocation): VNODE.Comment[]
+    getCommentsAfter(nodeOrToken: VNODE.HasLocation): VNODE.Comment[]
+    getCommentsInside(node: VNODE.HasLocation): VNODE.Comment[]
   }
 }
diff --git a/typings/eslint/index.d.ts b/typings/eslint/index.d.ts
index 0a1cd36c..7bfda024 100644
--- a/typings/eslint/index.d.ts
+++ b/typings/eslint/index.d.ts
@@ -116,6 +116,8 @@ export class SourceCode /*extends ESLintSourceCode*/ {
 
   static splitLines(text: string): string[]
 
+  tokensAndComments: (AST.Token | VNODE.Comment)[]
+
   getText(
     node?: VNODE.HasLocation,
     beforeCount?: number,


Test patch:
diff --git a/tests/lib/rules/quote-props.js b/tests/lib/rules/quote-props.js
new file mode 100644
index 00000000..378affe7
--- /dev/null
+++ b/tests/lib/rules/quote-props.js
@@ -0,0 +1,122 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/quote-props')
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 2020,
+    sourceType: 'module'
+  }
+})
+
+tester.run('quote-props', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div :style="{'height': '100vh'}"></div>
+      </template>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div :style="{height: '100vh'}"></div>
+      </template>
+      `,
+      options: ['as-needed']
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div :style="{height: '100vh'}"></div>
+        <div :style='{height: "100vh"}'></div>
+        <div :style={height:"100vh"}></div>
+      </template>
+      `,
+      output: `
+      <template>
+        <div :style="{'height': '100vh'}"></div>
+        <div :style='{"height": "100vh"}'></div>
+        <div :style={"height":"100vh"}></div>
+      </template>
+      `,
+      errors: [
+        {
+          message: "Unquoted property 'height' found.",
+          line: 3,
+          column: 23
+        },
+        {
+          message: "Unquoted property 'height' found.",
+          line: 4,
+          column: 23
+        },
+        {
+          message: "Unquoted property 'height' found.",
+          line: 5,
+          column: 22
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div :style="{'height': '100vh'}"></div>
+        <div :style='{"height": "100vh"}'></div>
+      </template>
+      `,
+      output: `
+      <template>
+        <div :style="{height: '100vh'}"></div>
+        <div :style='{height: "100vh"}'></div>
+      </template>
+      `,
+      options: ['as-needed'],
+      errors: [
+        {
+          message: "Unnecessarily quoted property 'height' found.",
+          line: 3,
+          column: 23
+        },
+        {
+          message: "Unnecessarily quoted property 'height' found.",
+          line: 4,
+          column: 23
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        {{ ({foo:42}).foo }}
+      </template>
+      `,
+      output: `
+      <template>
+        {{ ({"foo":42}).foo }}
+      </template>
+      `,
+      errors: [
+        {
+          message: "Unquoted property 'foo' found.",
+          line: 3,
+          column: 14
+        }
+      ]
+    }
+  ]
+})