Repository: vuejs/eslint-plugin-vue
Pull request #278 (2017-12-02 11:35:40)

[Update] Make `vue/no-shared-component-data` fixable
This PR makes `vue/no-shared-component-data` fixable. The autofix wraps the `data` property's value by a function expression.

At first glance, the unit tests make wrong indentations, but this is intentional. This strategy is same as core rules; one rule does one thing. A combination of rules will make correct autofix.

![image](https://user-images.githubusercontent.com/1937871/33514999-327b146e-d7a0-11e7-88a8-f069bc437bbd.png)



Patch:
diff --git a/README.md b/README.md
index e9a3a7a6..4ba6f752 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,12 @@ Please try it and report any issues that you might have encountered.
 
 If you want to check previous releases [go here](https://github.com/vuejs/eslint-plugin-vue/releases).
 
+## :art: Playground on the Web
+
+You can try this plugin on the Web.
+
+- https://mysticatea.github.io/vue-eslint-demo/
+
 ## :grey_exclamation: Requirements
 
 - [ESLint](http://eslint.org/) `>=3.18.0`.
diff --git a/lib/rules/html-end-tags.js b/lib/rules/html-end-tags.js
index a4c05eec..922f34d8 100644
--- a/lib/rules/html-end-tags.js
+++ b/lib/rules/html-end-tags.js
@@ -22,22 +22,19 @@ const utils = require('../utils')
  * @returns {Object} AST event handlers.
  */
 function create (context) {
+  let hasInvalidEOF = false
+
   return utils.defineTemplateBodyVisitor(context, {
     VElement (node) {
+      if (hasInvalidEOF) {
+        return
+      }
+
       const name = node.name
       const isVoid = utils.isHtmlVoidElementName(name)
       const isSelfClosing = node.startTag.selfClosing
       const hasEndTag = node.endTag != null
 
-      if (isVoid && hasEndTag) {
-        context.report({
-          node: node.endTag,
-          loc: node.endTag.loc,
-          message: "'<{{name}}>' should not have end tag.",
-          data: { name },
-          fix: (fixer) => fixer.remove(node.endTag)
-        })
-      }
       if (!isVoid && !hasEndTag && !isSelfClosing) {
         context.report({
           node: node.startTag,
@@ -48,6 +45,10 @@ function create (context) {
         })
       }
     }
+  }, {
+    Program (node) {
+      hasInvalidEOF = utils.hasInvalidEOF(node)
+    }
   })
 }
 
diff --git a/lib/rules/html-quotes.js b/lib/rules/html-quotes.js
index 9326420b..b0156323 100644
--- a/lib/rules/html-quotes.js
+++ b/lib/rules/html-quotes.js
@@ -26,20 +26,38 @@ function create (context) {
   const double = context.options[0] !== 'single'
   const quoteChar = double ? '"' : "'"
   const quoteName = double ? 'double quotes' : 'single quotes'
+  const quotePattern = double ? /"/g : /'/g
+  const quoteEscaped = double ? '&quot;' : '&apos;'
+  let hasInvalidEOF
 
   return utils.defineTemplateBodyVisitor(context, {
     'VAttribute[value!=null]' (node) {
+      if (hasInvalidEOF) {
+        return
+      }
+
       const text = sourceCode.getText(node.value)
       const firstChar = text[0]
+
       if (firstChar !== quoteChar) {
         context.report({
           node: node.value,
           loc: node.value.loc,
           message: 'Expected to be enclosed by {{kind}}.',
-          data: { kind: quoteName }
+          data: { kind: quoteName },
+          fix (fixer) {
+            const contentText = (firstChar === "'" || firstChar === '"') ? text.slice(1, -1) : text
+            const replacement = quoteChar + contentText.replace(quotePattern, quoteEscaped) + quoteChar
+
+            return fixer.replaceText(node.value, replacement)
+          }
         })
       }
     }
+  }, {
+    Program (node) {
+      hasInvalidEOF = utils.hasInvalidEOF(node)
+    }
   })
 }
 
@@ -54,7 +72,7 @@ module.exports = {
       description: 'enforce quotes style of HTML attributes',
       category: 'recommended'
     },
-    fixable: false,
+    fixable: 'code',
     schema: [
       { enum: ['double', 'single'] }
     ]
diff --git a/lib/rules/html-self-closing.js b/lib/rules/html-self-closing.js
index f311a63f..23263e2b 100644
--- a/lib/rules/html-self-closing.js
+++ b/lib/rules/html-self-closing.js
@@ -88,9 +88,14 @@ function isEmpty (node, sourceCode) {
 function create (context) {
   const sourceCode = context.getSourceCode()
   const options = parseOptions(context.options[0])
+  let hasInvalidEOF = false
 
   return utils.defineTemplateBodyVisitor(context, {
     'VElement' (node) {
+      if (hasInvalidEOF) {
+        return
+      }
+
       const elementType = getElementType(node)
       const mode = options[elementType]
 
@@ -131,6 +136,10 @@ function create (context) {
         })
       }
     }
+  }, {
+    Program (node) {
+      hasInvalidEOF = utils.hasInvalidEOF(node)
+    }
   })
 }
 
diff --git a/lib/rules/mustache-interpolation-spacing.js b/lib/rules/mustache-interpolation-spacing.js
index 87d758d9..2b5e40a4 100644
--- a/lib/rules/mustache-interpolation-spacing.js
+++ b/lib/rules/mustache-interpolation-spacing.js
@@ -29,36 +29,10 @@ module.exports = {
   },
 
   create (context) {
-    const options = context.options[0]
-    const optSpaces = options !== 'never'
-    const template = context.parserServices.getTemplateBodyTokenStore && context.parserServices.getTemplateBodyTokenStore()
-
-    // ----------------------------------------------------------------------
-    // Helpers
-    // ----------------------------------------------------------------------
-
-    function checkTokens (leftToken, rightToken) {
-      if (leftToken.loc.end.line === rightToken.loc.start.line) {
-        const spaces = rightToken.loc.start.column - leftToken.loc.end.column
-        const noSpacesFound = spaces === 0
-
-        if (optSpaces === noSpacesFound) {
-          context.report({
-            node: rightToken,
-            loc: {
-              start: leftToken.loc.end,
-              end: rightToken.loc.start
-            },
-            message: 'Found {{spaces}} whitespaces, {{type}} expected.',
-            data: {
-              spaces: spaces === 0 ? 'none' : spaces,
-              type: optSpaces ? '1' : 'none'
-            },
-            fix: (fixer) => fixer.replaceTextRange([leftToken.range[1], rightToken.range[0]], optSpaces ? ' ' : '')
-          })
-        }
-      }
-    }
+    const options = context.options[0] || 'always'
+    const template =
+      context.parserServices.getTemplateBodyTokenStore &&
+      context.parserServices.getTemplateBodyTokenStore()
 
     // ----------------------------------------------------------------------
     // Public
@@ -66,21 +40,57 @@ module.exports = {
 
     return utils.defineTemplateBodyVisitor(context, {
       'VExpressionContainer[expression!=null]' (node) {
-        const tokens = template.getTokens(node, {
-          includeComments: true,
-          filter: token => token.type !== 'HTMLWhitespace' // When there is only whitespace between ignore it
-        })
+        const openBrace = template.getFirstToken(node)
+        const closeBrace = template.getLastToken(node)
+
+        if (
+          !openBrace ||
+          !closeBrace ||
+          openBrace.type !== 'VExpressionStart' ||
+          closeBrace.type !== 'VExpressionEnd'
+        ) {
+          return
+        }
 
-        const startToken = tokens.shift()
-        if (!startToken || startToken.type !== 'VExpressionStart') return
-        const endToken = tokens.pop()
-        if (!endToken || endToken.type !== 'VExpressionEnd') return
+        const firstToken = template.getTokenAfter(openBrace, { includeComments: true })
+        const lastToken = template.getTokenBefore(closeBrace, { includeComments: true })
 
-        if (tokens.length > 0) {
-          checkTokens(startToken, tokens[0])
-          checkTokens(tokens[tokens.length - 1], endToken)
+        if (options === 'always') {
+          if (openBrace.range[1] === firstToken.range[0]) {
+            context.report({
+              node: openBrace,
+              message: "Expected 1 space after '{{', but not found.",
+              fix: (fixer) => fixer.insertTextAfter(openBrace, ' ')
+            })
+          }
+          if (closeBrace.range[0] === lastToken.range[1]) {
+            context.report({
+              node: closeBrace,
+              message: "Expected 1 space before '}}', but not found.",
+              fix: (fixer) => fixer.insertTextBefore(closeBrace, ' ')
+            })
+          }
         } else {
-          checkTokens(startToken, endToken)
+          if (openBrace.range[1] !== firstToken.range[0]) {
+            context.report({
+              loc: {
+                start: openBrace.loc.start,
+                end: firstToken.loc.start
+              },
+              message: "Expected no space after '{{', but found.",
+              fix: (fixer) => fixer.removeRange([openBrace.range[1], firstToken.range[0]])
+            })
+          }
+          if (closeBrace.range[0] !== lastToken.range[1]) {
+            context.report({
+              loc: {
+                start: lastToken.loc.end,
+                end: closeBrace.loc.end
+              },
+              message: "Expected no space before '}}', but found.",
+              fix: (fixer) => fixer.removeRange([lastToken.range[1], closeBrace.range[0]])
+            })
+          }
         }
       }
     })
diff --git a/lib/rules/no-shared-component-data.js b/lib/rules/no-shared-component-data.js
index 85d97b9d..69d58c97 100644
--- a/lib/rules/no-shared-component-data.js
+++ b/lib/rules/no-shared-component-data.js
@@ -6,10 +6,33 @@
 
 const utils = require('../utils')
 
+function isOpenParen (token) {
+  return token.type === 'Punctuator' && token.value === '('
+}
+
+function isCloseParen (token) {
+  return token.type === 'Punctuator' && token.value === ')'
+}
+
+function getFirstAndLastTokens (node, sourceCode) {
+  let first = sourceCode.getFirstToken(node)
+  let last = sourceCode.getLastToken(node)
+
+  // If the value enclosed by parentheses, update the 'first' and 'last' by the parentheses.
+  while (true) {
+    const prev = sourceCode.getTokenBefore(first)
+    const next = sourceCode.getTokenAfter(last)
+    if (isOpenParen(prev) && isCloseParen(next)) {
+      first = prev
+      last = next
+    } else {
+      return { first, last }
+    }
+  }
+}
+
 function create (context) {
-  // ----------------------------------------------------------------------
-  // Public
-  // ----------------------------------------------------------------------
+  const sourceCode = context.getSourceCode()
 
   return utils.executeOnVueComponent(context, (obj) => {
     obj.properties
@@ -21,10 +44,24 @@ function create (context) {
         p.value.type !== 'ArrowFunctionExpression' &&
         p.value.type !== 'Identifier'
       )
-      .forEach(cp => {
+      .forEach(p => {
         context.report({
-          node: cp.value,
-          message: '`data` property in component must be a function'
+          node: p,
+          message: '`data` property in component must be a function',
+          fix (fixer) {
+            const tokens = getFirstAndLastTokens(p.value, sourceCode)
+
+            // If we can upgrade requirements to `eslint@>4.1.0`, this code can be replaced by:
+            //     return [
+            //       fixer.insertTextBefore(tokens.first, 'function() {\nreturn '),
+            //       fixer.insertTextAfter(tokens.last, ';\n}')
+            //     ]
+            // See: https://eslint.org/blog/2017/06/eslint-v4.1.0-released#applying-multiple-autofixes-simultaneously
+            const range = [tokens.first.range[0], tokens.last.range[1]]
+            const valueText = sourceCode.text.slice(range[0], range[1])
+            const replacement = `function() {\nreturn ${valueText};\n}`
+            return fixer.replaceTextRange(range, replacement)
+          }
         })
       })
   })
@@ -40,7 +77,7 @@ module.exports = {
       description: "enforce component's data property to be a function",
       category: 'essential'
     },
-    fixable: null,  // or "code" or "whitespace"
+    fixable: 'code',
     schema: []
   },
 
diff --git a/lib/rules/valid-v-on.js b/lib/rules/valid-v-on.js
index b9c4f9ca..9986f561 100644
--- a/lib/rules/valid-v-on.js
+++ b/lib/rules/valid-v-on.js
@@ -18,7 +18,7 @@ const utils = require('../utils')
 const VALID_MODIFIERS = new Set([
   'stop', 'prevent', 'capture', 'self', 'ctrl', 'shift', 'alt', 'meta',
   'native', 'once', 'left', 'right', 'middle', 'passive', 'esc', 'tab',
-  'enter', 'space', 'up', 'left', 'right', 'down', 'delete'
+  'enter', 'space', 'up', 'left', 'right', 'down', 'delete', 'exact'
 ])
 const VERB_MODIFIERS = new Set([
   'stop', 'prevent'
diff --git a/lib/utils/index.js b/lib/utils/index.js
index 635707b6..77c9a72b 100644
--- a/lib/utils/index.js
+++ b/lib/utils/index.js
@@ -601,5 +601,18 @@ module.exports = {
    */
   isSingleLine (node) {
     return node.loc.start.line === node.loc.end.line
+  },
+
+  /**
+   * Check whether the templateBody of the program has invalid EOF or not.
+   * @param {Program} node The program node to check.
+   * @returns {boolean} `true` if it has invalid EOF.
+   */
+  hasInvalidEOF (node) {
+    const body = node.templateBody
+    if (body == null || body.errors == null) {
+      return
+    }
+    return body.errors.some(error => typeof error.code === 'string' && error.code.startsWith('eof-'))
   }
 }
diff --git a/package.json b/package.json
index bd396eef..8dbb6667 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "eslint-plugin-vue",
-  "version": "4.0.0-beta.0",
+  "version": "4.0.0-beta.1",
   "description": "Official ESLint plugin for Vue.js",
   "main": "lib/index.js",
   "scripts": {


Test patch:
diff --git a/tests/lib/rules/html-end-tags.js b/tests/lib/rules/html-end-tags.js
index 16cb8fe2..66aad36d 100644
--- a/tests/lib/rules/html-end-tags.js
+++ b/tests/lib/rules/html-end-tags.js
@@ -54,27 +54,21 @@ tester.run('html-end-tags', rule, {
     {
       filename: 'test.vue',
       code: '<template><div><div/></div></template>'
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div a="b>test</div></template>'
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div><!--</div></template>'
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div><svg><![CDATA[test</svg></div></template>'
     }
   ],
   invalid: [
-    // {
-    //   filename: 'test.vue',
-    //   code: '<template><div><hr></hr></div></template>',
-    //   output: '<template><div><hr></template>',
-    //   errors: ["'<hr>' should not have end tag."]
-    // },
-    // {
-    //   filename: 'test.vue',
-    //   code: '<template><div><img></img></div></template>',
-    //   output: '<template><div><img></template>',
-    //   errors: ["'<img>' should not have end tag."]
-    // },
-    // {
-    //   filename: 'test.vue',
-    //   code: '<template><div><input></input></div></template>',
-    //   output: '<template><div><input></template>',
-    //   errors: ["'<input>' should not have end tag."]
-    // },
     {
       filename: 'test.vue',
       code: '<template><div><div></div></template>',
diff --git a/tests/lib/rules/html-quotes.js b/tests/lib/rules/html-quotes.js
index 937bd1d3..a7a65752 100644
--- a/tests/lib/rules/html-quotes.js
+++ b/tests/lib/rules/html-quotes.js
@@ -54,91 +54,116 @@ tester.run('html-quotes', rule, {
       filename: 'test.vue',
       code: "<template><div :class='foo'></div></template>",
       options: ['single']
+    },
+
+    // Invalid EOF
+    {
+      code: '<template><div class="foo></div></template>',
+      options: ['single']
+    },
+    {
+      code: '<template><div class=\'foo></div></template>',
+      options: ['double']
     }
   ],
   invalid: [
     {
       filename: 'test.vue',
       code: '<template><div class=foo></div></template>',
+      output: '<template><div class="foo"></div></template>',
       errors: ['Expected to be enclosed by double quotes.']
     },
     {
       filename: 'test.vue',
       code: "<template><div class='foo'></div></template>",
+      output: '<template><div class="foo"></div></template>',
       errors: ['Expected to be enclosed by double quotes.']
     },
     {
       filename: 'test.vue',
       code: '<template><div :class=foo></div></template>',
+      output: '<template><div :class="foo"></div></template>',
       errors: ['Expected to be enclosed by double quotes.']
     },
     {
       filename: 'test.vue',
       code: "<template><div :class='foo'></div></template>",
+      output: '<template><div :class="foo"></div></template>',
       errors: ['Expected to be enclosed by double quotes.']
     },
     {
       filename: 'test.vue',
       code: '<template><div :class=foo+"bar"></div></template>',
+      output: '<template><div :class="foo+&quot;bar&quot;"></div></template>',
       errors: ['Expected to be enclosed by double quotes.']
     },
     {
       filename: 'test.vue',
       code: '<template><div class=foo></div></template>',
+      output: '<template><div class="foo"></div></template>',
       options: ['double'],
       errors: ['Expected to be enclosed by double quotes.']
     },
     {
       filename: 'test.vue',
       code: "<template><div class='foo'></div></template>",
+      output: '<template><div class="foo"></div></template>',
       options: ['double'],
       errors: ['Expected to be enclosed by double quotes.']
     },
     {
       filename: 'test.vue',
       code: '<template><div :class=foo></div></template>',
+      output: '<template><div :class="foo"></div></template>',
       options: ['double'],
       errors: ['Expected to be enclosed by double quotes.']
     },
     {
       filename: 'test.vue',
       code: "<template><div :class='foo'></div></template>",
+      output: '<template><div :class="foo"></div></template>',
       options: ['double'],
       errors: ['Expected to be enclosed by double quotes.']
     },
     {
       filename: 'test.vue',
       code: '<template><div :class=foo+"bar"></div></template>',
+      output: '<template><div :class="foo+&quot;bar&quot;"></div></template>',
       options: ['double'],
       errors: ['Expected to be enclosed by double quotes.']
     },
     {
       filename: 'test.vue',
       code: '<template><div class=foo></div></template>',
+      output: '<template><div class=\'foo\'></div></template>',
       options: ['single'],
       errors: ['Expected to be enclosed by single quotes.']
     },
     {
       filename: 'test.vue',
       code: '<template><div class="foo"></div></template>',
+      output: '<template><div class=\'foo\'></div></template>',
       options: ['single'],
       errors: ['Expected to be enclosed by single quotes.']
     },
     {
       filename: 'test.vue',
       code: '<template><div :class=foo></div></template>',
+      output: '<template><div :class=\'foo\'></div></template>',
       options: ['single'],
       errors: ['Expected to be enclosed by single quotes.']
     },
     {
       filename: 'test.vue',
       code: '<template><div :class="foo"></div></template>',
+      output: '<template><div :class=\'foo\'></div></template>',
       options: ['single'],
       errors: ['Expected to be enclosed by single quotes.']
     },
     {
       filename: 'test.vue',
       code: "<template><div :class=foo+'bar'></div></template>",
+      output: "<template><div :class='foo+&apos;bar&apos;'></div></template>",
       options: ['single'],
       errors: ['Expected to be enclosed by single quotes.']
     }
diff --git a/tests/lib/rules/html-self-closing.js b/tests/lib/rules/html-self-closing.js
index 3623745c..250236ca 100644
--- a/tests/lib/rules/html-self-closing.js
+++ b/tests/lib/rules/html-self-closing.js
@@ -65,7 +65,11 @@ tester.run('html-self-closing', rule, {
       code: '<template><div><!-- comment --></div></template>',
       output: null,
       options: [{ html: { normal: 'always' }}]
-    }
+    },
+
+    // Invalid EOF
+    '<template><div a=">test</div></template>',
+    '<template><div><!--test</div></template>'
 
     // other cases are in `invalid` tests.
   ],
diff --git a/tests/lib/rules/mustache-interpolation-spacing.js b/tests/lib/rules/mustache-interpolation-spacing.js
index 84c8ef97..33ea041c 100644
--- a/tests/lib/rules/mustache-interpolation-spacing.js
+++ b/tests/lib/rules/mustache-interpolation-spacing.js
@@ -90,79 +90,58 @@ ruleTester.run('mustache-interpolation-spacing', rule, {
       code: '<template><div>{{ text}}</div></template>',
       output: '<template><div>{{ text }}</div></template>',
       options: ['always'],
-      errors: [{
-        message: 'Found none whitespaces, 1 expected.',
-        type: 'VExpressionEnd'
-      }]
+      errors: ["Expected 1 space before '}}', but not found."]
     },
     {
       filename: 'test.vue',
       code: '<template><div>{{text }}</div></template>',
       output: '<template><div>{{ text }}</div></template>',
       options: ['always'],
-      errors: [{
-        message: 'Found none whitespaces, 1 expected.',
-        type: 'Identifier'
-      }]
+      errors: ["Expected 1 space after '{{', but not found."]
     },
     {
       filename: 'test.vue',
       code: '<template><div>{{ text}}</div></template>',
       output: '<template><div>{{text}}</div></template>',
       options: ['never'],
-      errors: [{
-        message: 'Found 1 whitespaces, none expected.',
-        type: 'Identifier'
-      }]
+      errors: ["Expected no space after '{{', but found."]
     },
     {
       filename: 'test.vue',
       code: '<template><div>{{text }}</div></template>',
       output: '<template><div>{{text}}</div></template>',
       options: ['never'],
-      errors: [{
-        message: 'Found 1 whitespaces, none expected.',
-        type: 'VExpressionEnd'
-      }]
+      errors: ["Expected no space before '}}', but found."]
     },
     {
       filename: 'test.vue',
       code: '<template><div>{{text}}</div></template>',
       output: '<template><div>{{ text }}</div></template>',
       options: ['always'],
-      errors: [{
-        message: 'Found none whitespaces, 1 expected.',
-        type: 'Identifier'
-      }, {
-        message: 'Found none whitespaces, 1 expected.',
-        type: 'VExpressionEnd'
-      }]
+      errors: [
+        "Expected 1 space after '{{', but not found.",
+        "Expected 1 space before '}}', but not found."
+      ]
     },
     {
       filename: 'test.vue',
       code: '<template><div>{{ text }}</div></template>',
       output: '<template><div>{{text}}</div></template>',
       options: ['never'],
-      errors: [{
-        message: 'Found 1 whitespaces, none expected.',
-        type: 'Identifier'
-      }, {
-        message: 'Found 1 whitespaces, none expected.',
-        type: 'VExpressionEnd'
-      }]
+      errors: [
+        "Expected no space after '{{', but found.",
+        "Expected no space before '}}', but found."
+      ]
     },
     {
       filename: 'test.vue',
       code: '<template><div>{{   text   }}</div></template>',
       output: '<template><div>{{text}}</div></template>',
       options: ['never'],
-      errors: [{
-        message: 'Found 3 whitespaces, none expected.',
-        type: 'Identifier'
-      }, {
-        message: 'Found 3 whitespaces, none expected.',
-        type: 'VExpressionEnd'
-      }]
+      errors: [
+        "Expected no space after '{{', but found.",
+        "Expected no space before '}}', but found."
+      ]
     }
   ]
 })
diff --git a/tests/lib/rules/no-shared-component-data.js b/tests/lib/rules/no-shared-component-data.js
index cc138028..29d70c9e 100644
--- a/tests/lib/rules/no-shared-component-data.js
+++ b/tests/lib/rules/no-shared-component-data.js
@@ -130,6 +130,15 @@ ruleTester.run('no-shared-component-data', rule, {
           }
         })
       `,
+      output: `
+        Vue.component('some-comp', {
+          data: function() {
+return {
+            foo: 'bar'
+          };
+}
+        })
+      `,
       parserOptions,
       errors: [{
         message: '`data` property in component must be a function',
@@ -145,6 +154,39 @@ ruleTester.run('no-shared-component-data', rule, {
           }
         }
       `,
+      output: `
+        export default {
+          data: function() {
+return {
+            foo: 'bar'
+          };
+}
+        }
+      `,
+      parserOptions,
+      errors: [{
+        message: '`data` property in component must be a function',
+        line: 3
+      }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          data: /*a*/ (/*b*/{
+            foo: 'bar'
+          })
+        }
+      `,
+      output: `
+        export default {
+          data: /*a*/ function() {
+return (/*b*/{
+            foo: 'bar'
+          });
+}
+        }
+      `,
       parserOptions,
       errors: [{
         message: '`data` property in component must be a function',