Repository: prettier/prettier (JavaScript)
Date: 2018-01-10 06:49:00+00:00
Task type: Bug Fix

Issue:
Vue self-closing tags cause double-printing
Was running vue on gitlabhq and ran into this:

**Input:**

```vue
<script>
</script>

<template>
  <br />
</template>
```

**Output:**

```vue
<script>
</script>

<template>
  <br />
</template><script>
</script>

<template>
  <br />
</template>
```
  
/cc @vjeux 


Issue discussion:

Solution patch:
diff --git a/src/language-vue/printer-vue.js b/src/language-vue/printer-vue.js
index ca2075eff2fe..8d63e26d79c9 100644
--- a/src/language-vue/printer-vue.js
+++ b/src/language-vue/printer-vue.js
@@ -3,19 +3,33 @@
 const embed = require("./embed");
 const docBuilders = require("../doc").builders;
 const concat = docBuilders.concat;
+const hardline = docBuilders.hardline;
 
 function genericPrint(path, options, print) {
   const n = path.getValue();
   const res = [];
   let index = n.start;
+  let printParent = typeof n.end === "number";
 
   path.each(childPath => {
     const child = childPath.getValue();
     res.push(options.originalText.slice(index, child.start));
     res.push(childPath.call(print));
-    index = child.end;
+    if (typeof child.end === "number") {
+      index = child.end;
+    } else {
+      printParent = false;
+    }
   }, "children");
-  res.push(options.originalText.slice(index, n.end));
+
+  if (printParent) {
+    res.push(options.originalText.slice(index, n.end));
+  }
+
+  // Only force a trailing newline if there were any contents.
+  if (n.tag === "root" && n.children.length) {
+    res.push(hardline);
+  }
 
   return concat(res);
 }
diff --git a/website/pages/playground/index.html b/website/pages/playground/index.html
index 9401985d0b53..ec53d75a0579 100644
--- a/website/pages/playground/index.html
+++ b/website/pages/playground/index.html
@@ -89,6 +89,7 @@ <h1>Prettier <span id="version"></span></h1>
               <option value="json">json</option>
               <option value="graphql">graphql</option>
               <option value="markdown">markdown</option>
+              <option value="vue">vue</option>
             </select>
           </label>
           <label>--print-width <input type="number" value="80" min="0" id="printWidth"></label>


Test patch:
diff --git a/tests/multiparser_vue/__snapshots__/jsfmt.spec.js.snap b/tests/multiparser_vue/__snapshots__/jsfmt.spec.js.snap
index 938d84cc9e97..dedc3bcf2ca1 100644
--- a/tests/multiparser_vue/__snapshots__/jsfmt.spec.js.snap
+++ b/tests/multiparser_vue/__snapshots__/jsfmt.spec.js.snap
@@ -15,6 +15,7 @@ exports[`template-bind.vue 1`] = `
     <div v-bind:id=" ok ? 'YES' : 'NO' "></div>
     <button @click=" foo ( arg, 'string' ) "></button>
 </template>
+
 `;
 
 exports[`template-class.vue 1`] = `
@@ -37,6 +38,7 @@ exports[`template-class.vue 1`] = `
   >
   </h2>
 </template>
+
 `;
 
 exports[`vue-component.vue 1`] = `
@@ -79,4 +81,5 @@ p {
   text-align: center;
 }
 </style >
+
 `;
diff --git a/tests/vue_examples/__snapshots__/jsfmt.spec.js.snap b/tests/vue_examples/__snapshots__/jsfmt.spec.js.snap
new file mode 100644
index 000000000000..93bc6e13414a
--- /dev/null
+++ b/tests/vue_examples/__snapshots__/jsfmt.spec.js.snap
@@ -0,0 +1,353 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`board_card.vue 1`] = `
+<script>
+import './issue_card_inner';
+import eventHub from '../eventhub';
+
+const Store = gl.issueBoards.BoardsStore;
+
+export default {
+  name: 'BoardsIssueCard',
+  components: {
+    'issue-card-inner': gl.issueBoards.IssueCardInner,
+  },
+  props: {
+    list: Object,
+    issue: Object,
+    issueLinkBase: String,
+    disabled: Boolean,
+    index: Number,
+    rootPath: String,
+  },
+  data() {
+    return {
+      showDetail: false,
+      detailIssue: Store.detail,
+    };
+  },
+  computed: {
+    issueDetailVisible() {
+      return (
+        this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id
+      );
+    },
+  },
+  methods: {
+    mouseDown() {
+      this.showDetail = true;
+    },
+    mouseMove() {
+      this.showDetail = false;
+    },
+    showIssue(e) {
+      if (e.target.classList.contains('js-no-trigger')) return;
+
+      if (this.showDetail) {
+        this.showDetail = false;
+
+        if (Store.detail.issue && Store.detail.issue.id === this.issue.id) {
+          eventHub.$emit('clearDetailIssue');
+        } else {
+          eventHub.$emit('newDetailIssue', this.issue);
+          Store.detail.list = this.list;
+        }
+      }
+    },
+  },
+};
+</script>
+
+<template>
+  <li class="card"
+    :class="{ 'user-can-drag': !disabled && issue.id, 'is-disabled': disabled || !issue.id, 'is-active': issueDetailVisible }"
+    :index="index"
+    :data-issue-id="issue.id"
+    @mousedown="mouseDown"
+    @mousemove="mouseMove"
+    @mouseup="showIssue($event)">
+    <issue-card-inner
+      :list="list"
+      :issue="issue"
+      :issue-link-base="issueLinkBase"
+      :root-path="rootPath"
+      :update-filters="true" />
+  </li>
+</template>
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+<script>
+import "./issue_card_inner";
+import eventHub from "../eventhub";
+
+const Store = gl.issueBoards.BoardsStore;
+
+export default {
+  name: "BoardsIssueCard",
+  components: {
+    "issue-card-inner": gl.issueBoards.IssueCardInner
+  },
+  props: {
+    list: Object,
+    issue: Object,
+    issueLinkBase: String,
+    disabled: Boolean,
+    index: Number,
+    rootPath: String
+  },
+  data() {
+    return {
+      showDetail: false,
+      detailIssue: Store.detail
+    };
+  },
+  computed: {
+    issueDetailVisible() {
+      return (
+        this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id
+      );
+    }
+  },
+  methods: {
+    mouseDown() {
+      this.showDetail = true;
+    },
+    mouseMove() {
+      this.showDetail = false;
+    },
+    showIssue(e) {
+      if (e.target.classList.contains("js-no-trigger")) return;
+
+      if (this.showDetail) {
+        this.showDetail = false;
+
+        if (Store.detail.issue && Store.detail.issue.id === this.issue.id) {
+          eventHub.$emit("clearDetailIssue");
+        } else {
+          eventHub.$emit("newDetailIssue", this.issue);
+          Store.detail.list = this.list;
+        }
+      }
+    }
+  }
+};
+</script>
+
+<template>
+  <li class="card"
+    :class="{ 'user-can-drag': !disabled && issue.id, 'is-disabled': disabled || !issue.id, 'is-active': issueDetailVisible }"
+    :index="index"
+    :data-issue-id="issue.id"
+    @mousedown="mouseDown"
+    @mousemove="mouseMove"
+    @mouseup="showIssue($event)">
+    <issue-card-inner
+      :list="list"
+      :issue="issue"
+      :issue-link-base="issueLinkBase"
+      :root-path="rootPath"
+      :update-filters="true" />
+  </li>
+</template>
+
+`;
+
+exports[`board_card.vue 2`] = `
+<script>
+import './issue_card_inner';
+import eventHub from '../eventhub';
+
+const Store = gl.issueBoards.BoardsStore;
+
+export default {
+  name: 'BoardsIssueCard',
+  components: {
+    'issue-card-inner': gl.issueBoards.IssueCardInner,
+  },
+  props: {
+    list: Object,
+    issue: Object,
+    issueLinkBase: String,
+    disabled: Boolean,
+    index: Number,
+    rootPath: String,
+  },
+  data() {
+    return {
+      showDetail: false,
+      detailIssue: Store.detail,
+    };
+  },
+  computed: {
+    issueDetailVisible() {
+      return (
+        this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id
+      );
+    },
+  },
+  methods: {
+    mouseDown() {
+      this.showDetail = true;
+    },
+    mouseMove() {
+      this.showDetail = false;
+    },
+    showIssue(e) {
+      if (e.target.classList.contains('js-no-trigger')) return;
+
+      if (this.showDetail) {
+        this.showDetail = false;
+
+        if (Store.detail.issue && Store.detail.issue.id === this.issue.id) {
+          eventHub.$emit('clearDetailIssue');
+        } else {
+          eventHub.$emit('newDetailIssue', this.issue);
+          Store.detail.list = this.list;
+        }
+      }
+    },
+  },
+};
+</script>
+
+<template>
+  <li class="card"
+    :class="{ 'user-can-drag': !disabled && issue.id, 'is-disabled': disabled || !issue.id, 'is-active': issueDetailVisible }"
+    :index="index"
+    :data-issue-id="issue.id"
+    @mousedown="mouseDown"
+    @mousemove="mouseMove"
+    @mouseup="showIssue($event)">
+    <issue-card-inner
+      :list="list"
+      :issue="issue"
+      :issue-link-base="issueLinkBase"
+      :root-path="rootPath"
+      :update-filters="true" />
+  </li>
+</template>
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+<script>
+import "./issue_card_inner";
+import eventHub from "../eventhub";
+
+const Store = gl.issueBoards.BoardsStore;
+
+export default {
+  name: "BoardsIssueCard",
+  components: {
+    "issue-card-inner": gl.issueBoards.IssueCardInner,
+  },
+  props: {
+    list: Object,
+    issue: Object,
+    issueLinkBase: String,
+    disabled: Boolean,
+    index: Number,
+    rootPath: String,
+  },
+  data() {
+    return {
+      showDetail: false,
+      detailIssue: Store.detail,
+    };
+  },
+  computed: {
+    issueDetailVisible() {
+      return (
+        this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id
+      );
+    },
+  },
+  methods: {
+    mouseDown() {
+      this.showDetail = true;
+    },
+    mouseMove() {
+      this.showDetail = false;
+    },
+    showIssue(e) {
+      if (e.target.classList.contains("js-no-trigger")) return;
+
+      if (this.showDetail) {
+        this.showDetail = false;
+
+        if (Store.detail.issue && Store.detail.issue.id === this.issue.id) {
+          eventHub.$emit("clearDetailIssue");
+        } else {
+          eventHub.$emit("newDetailIssue", this.issue);
+          Store.detail.list = this.list;
+        }
+      }
+    },
+  },
+};
+</script>
+
+<template>
+  <li class="card"
+    :class="{ 'user-can-drag': !disabled && issue.id, 'is-disabled': disabled || !issue.id, 'is-active': issueDetailVisible }"
+    :index="index"
+    :data-issue-id="issue.id"
+    @mousedown="mouseDown"
+    @mousemove="mouseMove"
+    @mouseup="showIssue($event)">
+    <issue-card-inner
+      :list="list"
+      :issue="issue"
+      :issue-link-base="issueLinkBase"
+      :root-path="rootPath"
+      :update-filters="true" />
+  </li>
+</template>
+
+`;
+
+exports[`test.vue 1`] = `
+<script>
+</script>
+
+<template>
+  <br />
+  <footer>
+    foo
+    <br/>
+  </footer>
+</template>
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+<script>
+</script>
+
+<template>
+  <br />
+  <footer>
+    foo
+    <br/>
+  </footer>
+</template>
+
+`;
+
+exports[`test.vue 2`] = `
+<script>
+</script>
+
+<template>
+  <br />
+  <footer>
+    foo
+    <br/>
+  </footer>
+</template>
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+<script>
+</script>
+
+<template>
+  <br />
+  <footer>
+    foo
+    <br/>
+  </footer>
+</template>
+
+`;
diff --git a/tests/vue_examples/board_card.vue b/tests/vue_examples/board_card.vue
new file mode 100644
index 000000000000..c1c34e5122ca
--- /dev/null
+++ b/tests/vue_examples/board_card.vue
@@ -0,0 +1,73 @@
+<script>
+import './issue_card_inner';
+import eventHub from '../eventhub';
+
+const Store = gl.issueBoards.BoardsStore;
+
+export default {
+  name: 'BoardsIssueCard',
+  components: {
+    'issue-card-inner': gl.issueBoards.IssueCardInner,
+  },
+  props: {
+    list: Object,
+    issue: Object,
+    issueLinkBase: String,
+    disabled: Boolean,
+    index: Number,
+    rootPath: String,
+  },
+  data() {
+    return {
+      showDetail: false,
+      detailIssue: Store.detail,
+    };
+  },
+  computed: {
+    issueDetailVisible() {
+      return (
+        this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id
+      );
+    },
+  },
+  methods: {
+    mouseDown() {
+      this.showDetail = true;
+    },
+    mouseMove() {
+      this.showDetail = false;
+    },
+    showIssue(e) {
+      if (e.target.classList.contains('js-no-trigger')) return;
+
+      if (this.showDetail) {
+        this.showDetail = false;
+
+        if (Store.detail.issue && Store.detail.issue.id === this.issue.id) {
+          eventHub.$emit('clearDetailIssue');
+        } else {
+          eventHub.$emit('newDetailIssue', this.issue);
+          Store.detail.list = this.list;
+        }
+      }
+    },
+  },
+};
+</script>
+
+<template>
+  <li class="card"
+    :class="{ 'user-can-drag': !disabled && issue.id, 'is-disabled': disabled || !issue.id, 'is-active': issueDetailVisible }"
+    :index="index"
+    :data-issue-id="issue.id"
+    @mousedown="mouseDown"
+    @mousemove="mouseMove"
+    @mouseup="showIssue($event)">
+    <issue-card-inner
+      :list="list"
+      :issue="issue"
+      :issue-link-base="issueLinkBase"
+      :root-path="rootPath"
+      :update-filters="true" />
+  </li>
+</template>
diff --git a/tests/vue_examples/jsfmt.spec.js b/tests/vue_examples/jsfmt.spec.js
new file mode 100644
index 000000000000..89a7dccff386
--- /dev/null
+++ b/tests/vue_examples/jsfmt.spec.js
@@ -0,0 +1,2 @@
+run_spec(__dirname, ["vue"]);
+run_spec(__dirname, ["vue"], { trailingComma: "es5" });
diff --git a/tests/vue_examples/test.vue b/tests/vue_examples/test.vue
new file mode 100644
index 000000000000..9aab7e1c37cf
--- /dev/null
+++ b/tests/vue_examples/test.vue
@@ -0,0 +1,10 @@
+<script>
+</script>
+
+<template>
+  <br />
+  <footer>
+    foo
+    <br/>
+  </footer>
+</template>