Repository: vuejs/eslint-plugin-vue
Pull request #1158 (2020-05-23 09:08:35)

Add `vue/no-extra-parens` rule
This PR adds the wrapper rule of the [no-extra-parens](https://eslint.org/docs/rules/no-extra-parens) core rule to apply to the expressions in `<template>`.


Patch:
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 57890dec..9b32f097 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -292,6 +292,7 @@ For example:
 | [vue/no-boolean-default](./no-boolean-default.md) | disallow boolean defaults | :wrench: |
 | [vue/no-duplicate-attr-inheritance](./no-duplicate-attr-inheritance.md) | enforce `inheritAttrs` to be set to `false` when using `v-bind="$attrs"` |  |
 | [vue/no-empty-pattern](./no-empty-pattern.md) | disallow empty destructuring patterns |  |
+| [vue/no-extra-parens](./no-extra-parens.md) | disallow unnecessary parentheses | :wrench: |
 | [vue/no-irregular-whitespace](./no-irregular-whitespace.md) | disallow irregular whitespace |  |
 | [vue/no-potential-component-option-typo](./no-potential-component-option-typo.md) | disallow a potential typo in your component property |  |
 | [vue/no-reserved-component-names](./no-reserved-component-names.md) | disallow the use of reserved names in component definitions |  |
diff --git a/docs/rules/no-extra-parens.md b/docs/rules/no-extra-parens.md
new file mode 100644
index 00000000..4726907d
--- /dev/null
+++ b/docs/rules/no-extra-parens.md
@@ -0,0 +1,45 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/no-extra-parens
+description: disallow unnecessary parentheses
+---
+# vue/no-extra-parens
+> disallow unnecessary parentheses
+
+- :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 [no-extra-parens] rule but it applies to the expressions in `<template>`.
+
+## :book: Rule Details
+
+This rule restricts the use of parentheses to only where they are necessary.  
+This rule extends the core [no-extra-parens] rule and applies it to the `<template>`. This rule also checks some Vue.js syntax.
+
+<eslint-code-block fix :rules="{'vue/no-extra-parens': ['error']}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <div :class="foo + bar" />
+  {{ foo + bar }}
+  {{ foo + bar | filter }}
+  <!-- ✗ BAD -->
+  <div :class="(foo + bar)" />
+  {{ (foo + bar) }}
+  {{ (foo + bar) | filter }}
+</template>
+```
+
+</eslint-code-block>
+
+## :books: Further reading
+
+- [no-extra-parens]
+
+[no-extra-parens]: https://eslint.org/docs/rules/no-extra-parens
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-extra-parens.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-extra-parens.js)
diff --git a/lib/configs/no-layout-rules.js b/lib/configs/no-layout-rules.js
index 57ea8002..e3b3fd9b 100644
--- a/lib/configs/no-layout-rules.js
+++ b/lib/configs/no-layout-rules.js
@@ -27,6 +27,7 @@ module.exports = {
     'vue/max-len': 'off',
     'vue/multiline-html-element-content-newline': 'off',
     'vue/mustache-interpolation-spacing': 'off',
+    'vue/no-extra-parens': 'off',
     'vue/no-multi-spaces': 'off',
     'vue/no-spaces-around-equal-signs-in-attribute': 'off',
     'vue/object-curly-spacing': 'off',
diff --git a/lib/index.js b/lib/index.js
index 5e52f514..d73a7f83 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -64,6 +64,7 @@ module.exports = {
     'no-duplicate-attr-inheritance': require('./rules/no-duplicate-attr-inheritance'),
     'no-duplicate-attributes': require('./rules/no-duplicate-attributes'),
     'no-empty-pattern': require('./rules/no-empty-pattern'),
+    'no-extra-parens': require('./rules/no-extra-parens'),
     'no-irregular-whitespace': require('./rules/no-irregular-whitespace'),
     'no-lifecycle-after-await': require('./rules/no-lifecycle-after-await'),
     'no-multi-spaces': require('./rules/no-multi-spaces'),
diff --git a/lib/rules/no-extra-parens.js b/lib/rules/no-extra-parens.js
new file mode 100644
index 00000000..d875659b
--- /dev/null
+++ b/lib/rules/no-extra-parens.js
@@ -0,0 +1,177 @@
+/**
+ * @author Yosuke Ota
+ */
+'use strict'
+
+const { isParenthesized } = require('eslint-utils')
+const { wrapCoreRule } = require('../utils')
+
+// eslint-disable-next-line no-invalid-meta, no-invalid-meta-docs-categories
+module.exports = wrapCoreRule(
+  require('eslint/lib/rules/no-extra-parens'),
+  {
+    skipDynamicArguments: true,
+    create: createForVueSyntax
+  }
+)
+
+/**
+ * @typedef {import('vue-eslint-parser').AST.Token} Token
+ * @typedef {import('vue-eslint-parser').AST.ESLintExpression} Expression
+ * @typedef {import('vue-eslint-parser').AST.VExpressionContainer} VExpressionContainer
+ * @typedef {import('vue-eslint-parser').AST.VFilterSequenceExpression} VFilterSequenceExpression
+ */
+
+/**
+ * Check whether the given token is a left parenthesis.
+ * @param {Token} token The token to check.
+ * @returns {boolean} `true` if the token is a left parenthesis.
+ */
+function isLeftParen (token) {
+  return token.type === 'Punctuator' && token.value === '('
+}
+
+/**
+ * Check whether the given token is a right parenthesis.
+ * @param {Token} token The token to check.
+ * @returns {boolean} `true` if the token is a right parenthesis.
+ */
+function isRightParen (token) {
+  return token.type === 'Punctuator' && token.value === ')'
+}
+
+/**
+ * Check whether the given token is a left brace.
+ * @param {Token} token The token to check.
+ * @returns {boolean} `true` if the token is a left brace.
+ */
+function isLeftBrace (token) {
+  return token.type === 'Punctuator' && token.value === '{'
+}
+
+/**
+ * Check whether the given token is a right brace.
+ * @param {Token} token The token to check.
+ * @returns {boolean} `true` if the token is a right brace.
+ */
+function isRightBrace (token) {
+  return token.type === 'Punctuator' && token.value === '}'
+}
+
+/**
+ * Check whether the given token is a left bracket.
+ * @param {Token} token The token to check.
+ * @returns {boolean} `true` if the token is a left bracket.
+ */
+function isLeftBracket (token) {
+  return token.type === 'Punctuator' && token.value === '['
+}
+
+/**
+ * Check whether the given token is a right bracket.
+ * @param {Token} token The token to check.
+ * @returns {boolean} `true` if the token is a right bracket.
+ */
+function isRightBracket (token) {
+  return token.type === 'Punctuator' && token.value === ']'
+}
+
+/**
+ * Determines if a given expression node is an IIFE
+ * @param {ASTNode} node The node to check
+ * @returns {boolean} `true` if the given node is an IIFE
+ */
+function isIIFE (node) {
+  return node.type === 'CallExpression' && node.callee.type === 'FunctionExpression'
+}
+
+function createForVueSyntax (context) {
+  const tokenStore = context.parserServices.getTemplateBodyTokenStore && context.parserServices.getTemplateBodyTokenStore()
+
+  /**
+   * Checks if the given node turns into a filter when unwraped.
+   * @param {Expression} node node to evaluate
+   * @returns {boolean} `true` if the given node turns into a filter when unwraped.
+   */
+  function isUnwrapChangeToFilter (expression) {
+    let parenStack = null
+    for (const token of tokenStore.getTokens(expression)) {
+      if (!parenStack) {
+        if (token.value === '|') {
+          return true
+        }
+      } else {
+        if (parenStack.isUpToken(token)) {
+          parenStack = parenStack.upper
+          continue
+        }
+      }
+      if (isLeftParen(token)) {
+        parenStack = { isUpToken: isRightParen, upper: parenStack }
+      } else if (isLeftBracket(token)) {
+        parenStack = { isUpToken: isRightBracket, upper: parenStack }
+      } else if (isLeftBrace(token)) {
+        parenStack = { isUpToken: isRightBrace, upper: parenStack }
+      }
+    }
+    return false
+  }
+  /**
+   * @param {VExpressionContainer} node
+   */
+  function verify (node) {
+    let expression = node.expression
+    if (!expression) {
+      return
+    }
+
+    if (expression.type === 'VFilterSequenceExpression') {
+      expression = expression.expression
+    }
+
+    if (!isParenthesized(expression, tokenStore)) {
+      return
+    }
+
+    if (!isParenthesized(2, expression, tokenStore)) {
+      if (isIIFE(expression) && !isParenthesized(expression.callee, tokenStore)) {
+        return
+      }
+      if (isUnwrapChangeToFilter(expression)) {
+        return
+      }
+    }
+    report(expression)
+  }
+
+  /**
+   * Report the node
+   * @param {Expression} node node to evaluate
+   * @returns {void}
+   * @private
+   */
+  function report (node) {
+    const sourceCode = context.getSourceCode()
+    const leftParenToken = tokenStore.getTokenBefore(node)
+    const rightParenToken = tokenStore.getTokenAfter(node)
+
+    context.report({
+      node,
+      loc: leftParenToken.loc,
+      messageId: 'unexpected',
+      fix (fixer) {
+        const parenthesizedSource = sourceCode.text.slice(leftParenToken.range[1], rightParenToken.range[0])
+
+        return fixer.replaceTextRange([
+          leftParenToken.range[0],
+          rightParenToken.range[1]
+        ], parenthesizedSource)
+      }
+    })
+  }
+
+  return {
+    "VAttribute[directive=true][key.name.name='bind'] > VExpressionContainer": verify,
+    'VElement > VExpressionContainer': verify
+  }
+}


Test patch:
diff --git a/tests/lib/rules/no-extra-parens.js b/tests/lib/rules/no-extra-parens.js
new file mode 100644
index 00000000..b4851272
--- /dev/null
+++ b/tests/lib/rules/no-extra-parens.js
@@ -0,0 +1,195 @@
+/**
+ * @author Yosuke Ota
+ */
+'use strict'
+
+const { RuleTester } = require('eslint')
+const rule = require('../../../lib/rules/no-extra-parens')
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: { ecmaVersion: 2015 }
+})
+
+tester.run('no-extra-parens', rule, {
+  valid: [
+    `<template>
+      <button
+        :class="{
+          a: b || c,
+          [d + e]: f
+        }"
+      />
+    </template>`,
+    `<template>
+      <button
+        :class="a + b + c * d"
+        :class="[a + b + c * d]"
+      />
+    </template>`,
+    `<template>
+      <button
+        :[(a+b)+c]="foo"
+      />
+    </template>`,
+    `<template>
+      <button
+        :[(a+b)]="foo"
+      />
+    </template>`,
+
+    '<template><button :class="(a+b | bitwise)" /></template>',
+    '<template><button>{{ (foo + bar | bitwise) }}</button></template>',
+    '<template><button>{{ (foo | bitwise) | filter }}</button></template>',
+    '<template><button>{{ (function () {} ()) }}</button></template>'
+  ],
+  invalid: [
+    {
+      code: `
+      <template>
+        <button
+          :class="a + b + (c * d)"
+          :class="[a + b + (c * d)]"
+        />
+      </template>`,
+      output: `
+      <template>
+        <button
+          :class="a + b + c * d"
+          :class="[a + b + c * d]"
+        />
+      </template>`,
+      errors: [
+        {
+          messageId: 'unexpected',
+          line: 4
+        },
+        {
+          messageId: 'unexpected',
+          line: 5
+        }
+      ]
+    },
+    {
+      code: `
+      <template>
+        <button
+          :class="{
+            a: (b || c),
+            // [(d + e)]: f // valid in eslint v6.0
+          }"
+        />
+      </template>`,
+      output: `
+      <template>
+        <button
+          :class="{
+            a: b || c,
+            // [(d + e)]: f // valid in eslint v6.0
+          }"
+        />
+      </template>`,
+      errors: [
+        {
+          messageId: 'unexpected',
+          line: 5
+        }
+        // valid in eslint v6.0
+        // {
+        //   messageId: 'unexpected',
+        //   line: 6
+        // }
+      ]
+    },
+    {
+      code: `
+      <template>
+        <button
+          :class="(a+b)+c"
+        />
+      </template>`,
+      output: `
+      <template>
+        <button
+          :class="a+b+c"
+        />
+      </template>`,
+      errors: [
+        {
+          messageId: 'unexpected',
+          line: 4
+        }
+      ]
+    },
+    {
+      code: '<template><button :class="(a+b)" /></template>',
+      output: '<template><button :class="a+b" /></template>',
+      errors: [
+        {
+          messageId: 'unexpected',
+          column: 27
+        }
+      ]
+    },
+    {
+      code: '<template><button :class="(a+b) | filter" /></template>',
+      output: '<template><button :class="a+b | filter" /></template>',
+      errors: [{ messageId: 'unexpected' }]
+    },
+    {
+      code: '<template><button :class="((a+b | bitwise))" /></template>',
+      output: '<template><button :class="(a+b | bitwise)" /></template>',
+      errors: [{ messageId: 'unexpected' }]
+    },
+    {
+      code: '<template><button>{{ (foo + bar) }}</button></template>',
+      output: '<template><button>{{ foo + bar }}</button></template>',
+      errors: [
+        {
+          messageId: 'unexpected',
+          column: 22
+        }
+      ]
+    },
+    {
+      code: '<template><button>{{ (foo + bar) | filter }}</button></template>',
+      output: '<template><button>{{ foo + bar | filter }}</button></template>',
+      errors: [{ messageId: 'unexpected' }]
+    },
+    {
+      code: '<template><button>{{ ((foo + bar | bitwise)) }}</button></template>',
+      output: '<template><button>{{ (foo + bar | bitwise) }}</button></template>',
+      errors: [{ messageId: 'unexpected' }]
+    },
+    {
+      code: '<template><button>{{ ((foo | bitwise)) | filter }}</button></template>',
+      output: '<template><button>{{ (foo | bitwise) | filter }}</button></template>',
+      errors: [{ messageId: 'unexpected' }]
+    },
+    {
+      code: '<template><button>{{ (foo(bar|bitwise)) }}</button></template>',
+      output: '<template><button>{{ foo(bar|bitwise) }}</button></template>',
+      errors: [{ messageId: 'unexpected' }]
+    },
+    {
+      code: '<template><button>{{ ([foo|bitwise]) }}</button></template>',
+      output: '<template><button>{{ [foo|bitwise] }}</button></template>',
+      errors: [{ messageId: 'unexpected' }]
+    },
+    {
+      code: '<template><button>{{ ({foo:bar|bitwise}) }}</button></template>',
+      output: '<template><button>{{ {foo:bar|bitwise} }}</button></template>',
+      errors: [{ messageId: 'unexpected' }]
+    },
+    {
+      code: '<template><button>{{ ((function () {} ())) }}</button></template>',
+      output: '<template><button>{{ (function () {} ()) }}</button></template>',
+      errors: [{ messageId: 'unexpected' }]
+    },
+    {
+      code: '<template><button>{{ ((function () {})()) }}</button></template>',
+      output: '<template><button>{{ (function () {})() }}</button></template>',
+      errors: [{ messageId: 'unexpected' }]
+    }
+  ]
+})