Repository: vuejs/eslint-plugin-vue (js)

Issue:
Rule proposal: disallow string literal in mustache
**Please describe what the rule should do:**
Disallow string literals in mustache.

**What category should the rule belong to?**
<!-- (place an "X" next to just one item) -->
[x] Enforces code style

**Provide 2-3 code examples that this rule should warn about:**
```vue
<div>{{"foo"}}</div>
<div :class="{{'baz'}}" />
```

**Additional context**
Similar rule in eslint-plugin-react: [react/jsx-curly-presense](https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-curly-brace-presence.md).

Pull request description:
Add `vue/no-useless-mustaches` rule
This PR adds `vue/no-useless-mustaches` rule that reports mustache interpolation with a string literal value.  

close #889

Patch:
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 1e1a92a0..e44b2855 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -293,6 +293,7 @@ For example:
 | [vue/no-unsupported-features](./no-unsupported-features.md) | disallow unsupported Vue.js syntax on the specified version | :wrench: |
 | [vue/no-unused-properties](./no-unused-properties.md) | disallow unused properties |  |
 | [vue/no-useless-v-bind](./no-useless-v-bind.md) | disallow unnecessary `v-bind` directives | :wrench: |
+| [vue/no-useless-mustaches](./no-useless-mustaches.md) | disallow unnecessary mustache interpolations | :wrench: |
 | [vue/padding-line-between-blocks](./padding-line-between-blocks.md) | require or disallow padding lines between blocks | :wrench: |
 | [vue/require-direct-export](./require-direct-export.md) | require the component to be directly exported |  |
 | [vue/require-explicit-emits](./require-explicit-emits.md) | require `emits` option with name triggered by `$emit()` |  |
diff --git a/docs/rules/no-useless-mustaches.md b/docs/rules/no-useless-mustaches.md
new file mode 100644
index 00000000..6c9d2689
--- /dev/null
+++ b/docs/rules/no-useless-mustaches.md
@@ -0,0 +1,88 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/no-useless-mustaches
+description: disallow unnecessary mustache interpolations
+---
+# vue/no-useless-mustaches
+> disallow unnecessary mustache interpolations
+
+- :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.
+
+## :book: Rule Details
+
+This rule reports mustache interpolation with a string literal value.  
+The mustache interpolation with a string literal value can be changed to a static contents.
+
+<eslint-code-block fix :rules="{'vue/no-useless-mustaches': ['error']}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  Lorem ipsum
+  {{ foo }}
+
+  <!-- ✗ BAD -->
+  {{ 'Lorem ipsum' }}
+  {{ "Lorem ipsum" }}
+  {{ `Lorem ipsum` }}
+</template>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+```js
+{
+  "vue/no-useless-mustaches": ["error", {
+    "ignoreIncludesComment": false,
+    "ignoreStringEscape": false
+  }]
+}
+```
+
+- `ignoreIncludesComment` ... If `true`, do not report expressions containing comments. default `false`.
+- `ignoreStringEscape` ... If `true`, do not report string literals with useful escapes. default `false`.
+
+### `"ignoreIncludesComment": true`
+
+<eslint-code-block fix :rules="{'vue/no-useless-mustaches': ['error', {ignoreIncludesComment: true}]}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  {{ 'Lorem ipsum'/* comment */ }}
+
+  <!-- ✗ BAD -->
+  {{ 'Lorem ipsum' }}
+</template>
+```
+
+</eslint-code-block>
+
+### `"ignoreStringEscape": true`
+
+<eslint-code-block fix :rules="{'vue/no-useless-mustaches': ['error', {ignoreStringEscape: true}]}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  {{ 'Lorem \n ipsum' }}
+</template>
+```
+
+</eslint-code-block>
+
+## :couple: Related rules
+
+- [vue/no-useless-v-bind]
+- [vue/no-useless-concat]
+
+[vue/no-useless-v-bind]: ./no-useless-v-bind.md
+[vue/no-useless-concat]: ./no-useless-concat.md
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-useless-mustaches.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-useless-mustaches.js)
diff --git a/lib/index.js b/lib/index.js
index 6504e05d..04b4af11 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -97,6 +97,7 @@ module.exports = {
     'no-unused-vars': require('./rules/no-unused-vars'),
     'no-use-v-if-with-v-for': require('./rules/no-use-v-if-with-v-for'),
     'no-useless-concat': require('./rules/no-useless-concat'),
+    'no-useless-mustaches': require('./rules/no-useless-mustaches'),
     'no-useless-v-bind': require('./rules/no-useless-v-bind'),
     'no-v-html': require('./rules/no-v-html'),
     'no-v-model-argument': require('./rules/no-v-model-argument'),
diff --git a/lib/rules/no-useless-mustaches.js b/lib/rules/no-useless-mustaches.js
new file mode 100644
index 00000000..c10889a2
--- /dev/null
+++ b/lib/rules/no-useless-mustaches.js
@@ -0,0 +1,158 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const utils = require('../utils')
+
+/**
+ * @typedef {import('eslint').Rule.RuleContext} RuleContext
+ * @typedef {import('vue-eslint-parser').AST.VExpressionContainer} VExpressionContainer
+ */
+
+/**
+ * Strip quotes string
+ * @param {string} text
+ * @returns {string}
+ */
+function stripQuotesForHTML(text) {
+  if (
+    (text[0] === '"' || text[0] === "'" || text[0] === '`') &&
+    text[0] === text[text.length - 1]
+  ) {
+    return text.slice(1, -1)
+  }
+
+  const re = /^(?:&(?:quot|apos|#\d+|#x[\da-f]+);|["'`])([\s\S]*)(?:&(?:quot|apos|#\d+|#x[\da-f]+);|["'`])$/u.exec(
+    text
+  )
+  if (!re) {
+    return null
+  }
+  return re[1]
+}
+
+module.exports = {
+  meta: {
+    docs: {
+      description: 'disallow unnecessary mustache interpolations',
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/no-useless-mustaches.html'
+    },
+    fixable: 'code',
+    messages: {
+      unexpected:
+        'Unexpected mustache interpolation with a string literal value.'
+    },
+    schema: [
+      {
+        type: 'object',
+        properties: {
+          ignoreIncludesComment: {
+            type: 'boolean'
+          },
+          ignoreStringEscape: {
+            type: 'boolean'
+          }
+        }
+      }
+    ],
+    type: 'suggestion'
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    const opts = context.options[0] || {}
+    const ignoreIncludesComment = opts.ignoreIncludesComment
+    const ignoreStringEscape = opts.ignoreStringEscape
+    const sourceCode = context.getSourceCode()
+
+    /**
+     * Report if the value expression is string literals
+     * @param {VExpressionContainer} node the node to check
+     */
+    function verify(node) {
+      const { expression } = node
+      if (!expression) {
+        return
+      }
+      let strValue, rawValue
+      if (expression.type === 'Literal') {
+        if (typeof expression.value !== 'string') {
+          return
+        }
+        strValue = expression.value
+        rawValue = expression.raw.slice(1, -1)
+      } else if (expression.type === 'TemplateLiteral') {
+        if (expression.expressions.length > 0) {
+          return
+        }
+        strValue = expression.quasis[0].value.cooked
+        rawValue = expression.quasis[0].value.raw
+      } else {
+        return
+      }
+
+      const tokenStore = context.parserServices.getTemplateBodyTokenStore()
+      const hasComment = tokenStore
+        .getTokens(node, { includeComments: true })
+        .some((t) => t.type === 'Block' || t.type === 'Line')
+      if (ignoreIncludesComment && hasComment) {
+        return
+      }
+
+      let hasEscape = false
+      if (rawValue !== strValue) {
+        // check escapes
+        const chars = [...rawValue]
+        let c = chars.shift()
+        while (c) {
+          if (c === '\\') {
+            c = chars.shift()
+            if (
+              c == null ||
+              // ignore "\\", '"', "'", "`" and "$"
+              'nrvtbfux'.includes(c)
+            ) {
+              // has useful escape.
+              hasEscape = true
+              break
+            }
+          }
+          c = chars.shift()
+        }
+      }
+      if (ignoreStringEscape && hasEscape) {
+        return
+      }
+
+      context.report({
+        // @ts-ignore
+        node,
+        messageId: 'unexpected',
+        fix(fixer) {
+          if (hasComment || hasEscape) {
+            // cannot fix
+            return null
+          }
+          context.parserServices.getDocumentFragment()
+          const text = stripQuotesForHTML(sourceCode.getText(expression))
+          if (text == null) {
+            // unknowns
+            return null
+          }
+          if (text.includes('\n') || /^\s|\s$/u.test(text)) {
+            // It doesn't autofix because another rule like indent or eol space might remove spaces.
+            return null
+          }
+
+          return [fixer.replaceText(node, text.replace(/\\([\s\S])/g, '$1'))]
+        }
+      })
+    }
+
+    return utils.defineTemplateBodyVisitor(context, {
+      'VElement > VExpressionContainer': verify
+    })
+  }
+}


Test patch:
diff --git a/tests/lib/rules/no-useless-mustaches.js b/tests/lib/rules/no-useless-mustaches.js
new file mode 100644
index 00000000..e12d33c1
--- /dev/null
+++ b/tests/lib/rules/no-useless-mustaches.js
@@ -0,0 +1,225 @@
+/**
+ * @author Yosuke Ota
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/no-useless-mustaches.js')
+
+// ------------------------------------------------------------------------------
+// Tests
+// ------------------------------------------------------------------------------
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 2020,
+    sourceType: 'module'
+  }
+})
+
+tester.run('no-useless-mustaches', rule, {
+  valid: [
+    `
+    <template>
+      foo
+      'foo'
+      {{ foo }}
+      {{ 'foo'||'bar' }}
+      {{ 1 }}
+      {{  }}
+      {{ . }}
+      {{ null }}
+    </template>`,
+    {
+      code: `
+      <template>
+        {{ 'comment'/*comment*/ }}
+        {{ 'comment'//comment
+        " }}
+      </template>
+      `,
+      options: [{ ignoreIncludesComment: true }]
+    },
+    {
+      code: `
+      <template>
+        {{ '\\n' }}
+        {{ '\\r' }}
+      </template>`,
+      options: [{ ignoreStringEscape: true }]
+    }
+  ],
+  invalid: [
+    {
+      code: `
+      <template>
+        {{ 'foo' }}
+      </template>`,
+      output: `
+      <template>
+        foo
+      </template>`,
+      errors: [
+        {
+          message:
+            'Unexpected mustache interpolation with a string literal value.',
+          line: 3,
+          column: 9,
+          endLine: 3
+        }
+      ]
+    },
+    {
+      code: `
+      <template>
+        {{ 'comment'/*comment*/ }}
+        {{ 'comment'//comment
+         }}
+      </template>
+      `,
+      output: null,
+      errors: [
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.'
+      ]
+    },
+    {
+      code: `
+      <template>
+        {{ '\\n' }}
+        {{ '\\r' }}
+      </template>`,
+      output: null,
+      errors: [
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.'
+      ]
+    },
+    {
+      code: `
+      <template>
+        {{ '&quot;' }}
+        {{ \`&quot;&apos;\` }}
+        {{ '\\\\' }}
+        {{ '\\\\r' }}
+        {{ '\\' }}
+        {{ \`foo\` }}
+        {{ \`foo\${bar}\` }}
+        {{ "&apos;" }}
+        {{ \`foo\` }}
+      </template>`,
+      output: `
+      <template>
+        &quot;
+        &quot;&apos;
+        \\
+        \\r
+        {{ '\\' }}
+        foo
+        {{ \`foo\${bar}\` }}
+        &apos;
+        foo
+      </template>`,
+      errors: [
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.'
+      ]
+    },
+    {
+      code: `
+      <template>
+        {{ &apos;msg&apos; }}
+        {{ &quot;msg&quot; }}
+        {{ &apos;msg' }}
+        {{ &quot;msg" }}
+        {{ 'msg&apos; }}
+        {{ "msg&quot; }}
+        {{ &#39;&lt;msg&gt;&apos; }}
+        {{ &#34;I&apos;m&#x22; }}
+        {{ "no semi&#34 }}
+      </template>`,
+      output: `
+      <template>
+        msg
+        msg
+        msg
+        msg
+        msg
+        msg
+        &lt;msg&gt;
+        I&apos;m
+        {{ "no semi&#34 }}
+      </template>`,
+      errors: [
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.'
+      ]
+    },
+    {
+      code: `
+      <template>
+        {{ 'I\\'m' }}
+        {{ "\\"Happy\\"" }}
+        {{ \`backtick \\\` and dollar \\$\` }}
+        {{ "\\\\" }}
+      </template>`,
+      output: `
+      <template>
+        I'm
+        "Happy"
+        backtick \` and dollar $
+        \\
+      </template>`,
+      errors: [
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.'
+      ]
+    },
+    {
+      code: `
+      <template>
+        {{ \`foo
+bar\` }}
+      </template>
+      `,
+      output: null,
+      errors: ['Unexpected mustache interpolation with a string literal value.']
+    },
+    {
+      code: `
+      <template>
+        {{ 'space ' }}
+        {{ ' space' }}
+        {{ ' space ' }}
+        {{ '  ' }}
+      </template>
+      `,
+      output: null,
+      errors: [
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.',
+        'Unexpected mustache interpolation with a string literal value.'
+      ]
+    }
+  ]
+})