Repository: prettier/prettier (JavaScript)
Date: 2020-11-20 17:07:49+00:00
Task type: Bug Fix

Issue:
Extra newline at the end of JavaScript fenced code block with string literal 
**Prettier 2.2.0**
[Playground link](https://prettier.io/playground/#N4Igxg9gdgLgprEAuEBZAhgJwNYBMIDuUAOiVAAaUBWAzqcSAO0AEDpl5IANCBAA4wAltBrJQWTIQAKWBKJToANgXQBPUTwBGmdGGxwYAZT67BUAObIYmAK5wecALaa4uXK4Ay6CzfTm4AGIQmI7oMEIWyCDoNjAQ3CAAFjCOigDqiYLwNCZgcIZyWYIAblmqUWA0GiBmNHCYMFI65qHIAGZKdTy0AB4AQjp6BobojnAeZnDtnfYgvYZm5opwAIo2EPDTil0gJph1mFGhOPhECXyYZjBpgrgwicgAHAAMPBcQdWk6fFEXcAfFKY8ACO63gTX48miNAAtFA4K5XAlMHBQYIUU0-K0kB1trM6o5BFZbPjFss1hspjiZjwYOhNDc7g8kAAmWk6QSKRYAYQgjmxIH+AFYEjY6gAVenyXE7Yp2ACSUHcsEMYEuAgAgkrDDBVMstnUAL6GoA)
```sh
--parser markdown
```

**Input:**

````markdown
Markdown

```js
"· "
```
````

**Output:**

````markdown
Markdown

```js
"· ";

```

````

**Expected behavior:**

I would expect not to have an extra newline added at the end of the fenced code block. This started happening in prettier v2.1.0, I imagine because of `--embedded-language-formatting`. I couldn’t find any other issue opened about this, at least when searching for issues relating to code blocks.

Trying this with more [JavaScript literals](https://prettier.io/playground/#N4Igxg9gdgLgprEAuEBZAhgJwNYBMIDuUAOiVAAaUBWAzqcSPDTA6ZefRdXVAORMxebSp3a02AjlynDy4qADN0AGxpxZo7qRiYAruuma5PKLuXKNZMTwDaAXUuz5wAL6Pp8gKzvrpAJKKAJZQgTAAnj5aUABy6NGRxqS6ULhwCsFwuLIgADQgEAAOMIHQNMigWJiEAApYCGUoKgToYWV5AEaY6GDYcDAAygXdwQDmyDr6eXAAtu2ZqbgAMuhQI7roI3AAYhCY0+gwxavIIOi6MBC5IAAWMNPKAOrXoXA0Q2Bw-fWhgQBuoWETmAaG0QME1JgYNUuiN9sglKo4HlaAAPABCXR6fX66GmcEWGXhKjUyJoKP6o2UcAAiroIPAiYi8kNMBCTvscPgiFcCphgjAHoFcDBrsgABwABmZVTUDy6BROvNecEwvzgVwAjnT4NDCg1TjQALRQOCZTJXTBwLWBS3QjZwpAIkkgNTTQKM500Sk07Xqx3EpGMdDtQXC0VIABMeR06ECylGAGEINMHSBXp4rro1AAVYMNJ2B376AKpWD9MB8ooAQRS-XCVI9cBcLiAA), this seems to happen only when the code block ends with a string literal with single or double quotes, even if there is a comment after the value.


Issue discussion:

Solution patch:
diff --git a/changelog_unreleased/markdown/9736.md b/changelog_unreleased/markdown/9736.md
new file mode 100644
index 000000000000..d60d806790b3
--- /dev/null
+++ b/changelog_unreleased/markdown/9736.md
@@ -0,0 +1,26 @@
+#### Fix extra newline at the end of JavaScript fenced code block with string literal (#9736 by @fisker)
+
+<!-- prettier-ignore -->
+````markdown
+<!-- Input -->
+Markdown
+
+```js
+"· "
+```
+
+<!-- Prettier stable -->
+Markdown
+
+```js
+"· ";
+
+```
+
+<!-- Prettier master -->
+Markdown
+
+```js
+"· ";
+```
+````
diff --git a/src/document/doc-utils.js b/src/document/doc-utils.js
index d9fe00900f66..453db6107b17 100644
--- a/src/document/doc-utils.js
+++ b/src/document/doc-utils.js
@@ -180,41 +180,128 @@ function removeLines(doc) {
   return mapDoc(doc, removeLinesFn);
 }
 
-function getInnerParts(doc) {
-  let { parts } = doc;
-  let lastPart;
-  // Avoid a falsy element like ""
-  for (let i = doc.parts.length; i > 0 && !lastPart; i--) {
-    lastPart = parts[i - 1];
+const isHardline = (doc, nextDoc) =>
+  doc &&
+  doc.type === "line" &&
+  doc.hard &&
+  nextDoc &&
+  nextDoc.type === "break-parent";
+function stripDocTrailingHardlineFromDoc(doc) {
+  if (!doc) {
+    return doc;
   }
-  if (lastPart.type === "group") {
-    parts = lastPart.contents.parts;
+
+  switch (doc.type) {
+    case "concat":
+    case "fill": {
+      const parts = [...doc.parts];
+
+      while (parts.length > 1 && isHardline(...parts.slice(-2))) {
+        parts.length -= 2;
+      }
+
+      if (parts.length > 0) {
+        const lastPart = stripDocTrailingHardlineFromDoc(
+          parts[parts.length - 1]
+        );
+        parts[parts.length - 1] = lastPart;
+      }
+      return { ...doc, parts };
+    }
+    case "align":
+    case "indent":
+    case "group":
+    case "line-suffix": {
+      const contents = stripDocTrailingHardlineFromDoc(doc.contents);
+      return { ...doc, contents };
+    }
+    case "if-break": {
+      const breakContents = stripDocTrailingHardlineFromDoc(doc.breakContents);
+      const flatContents = stripDocTrailingHardlineFromDoc(doc.flatContents);
+      return { ...doc, breakContents, flatContents };
+    }
   }
-  return parts;
+
+  return doc;
 }
 
-function stripTrailingHardline(doc, withInnerParts = false) {
+function stripTrailingHardline(doc) {
   // HACK remove ending hardline, original PR: #1984
-  if (doc.type === "concat" && doc.parts.length !== 0) {
-    const parts = withInnerParts ? getInnerParts(doc) : doc.parts;
-    const lastPart = parts[parts.length - 1];
-    if (lastPart.type === "concat") {
+  return stripDocTrailingHardlineFromDoc(cleanDoc(doc));
+}
+
+const isConcat = (doc) => doc && doc.type === "concat";
+function cleanDocFn(doc) {
+  switch (doc.type) {
+    case "fill":
+      if (doc.parts.length === 0 || doc.parts.every((part) => part === "")) {
+        return "";
+      }
+      break;
+    case "group":
+      if (!doc.contents && !doc.id && !doc.break && !doc.expandedStates) {
+        return "";
+      }
+      // Remove nested only group
       if (
-        lastPart.parts.length === 2 &&
-        lastPart.parts[0].hard &&
-        lastPart.parts[1].type === "break-parent"
+        doc.contents.type === "group" &&
+        doc.contents.id === doc.id &&
+        doc.contents.break === doc.break &&
+        doc.contents.expandedStates === doc.expandedStates
       ) {
-        return { type: "concat", parts: parts.slice(0, -1) };
+        return doc.contents;
+      }
+      break;
+    case "align":
+    case "indent":
+    case "line-suffix":
+      if (!doc.contents) {
+        return "";
       }
+      break;
+    case "if-break":
+      if (!doc.flatContents && !doc.breakContents) {
+        return "";
+      }
+      break;
+  }
+
+  if (!isConcat(doc)) {
+    return doc;
+  }
 
-      return {
-        type: "concat",
-        parts: doc.parts.slice(0, -1).concat(stripTrailingHardline(lastPart)),
-      };
+  const parts = [];
+  for (const part of doc.parts) {
+    if (!part) {
+      continue;
+    }
+    const [currentPart, ...restParts] = isConcat(part) ? part.parts : [part];
+    if (
+      typeof currentPart === "string" &&
+      typeof parts[parts.length - 1] === "string"
+    ) {
+      parts[parts.length - 1] += currentPart;
+    } else {
+      parts.push(currentPart);
     }
+    parts.push(...restParts);
   }
 
-  return doc;
+  if (parts.length === 0) {
+    return "";
+  }
+
+  if (parts.length === 1) {
+    return parts[0];
+  }
+  return { ...doc, parts };
+}
+// A safer version of `normalizeDoc`
+// - `normalizeDoc` concat strings and flat "concat" in `fill`, while `cleanDoc` don't
+// - On `concat` object, `normalizeDoc` always return object with `parts`, `cleanDoc` may return strings
+// - `cleanDoc` also remove nested `group`s and empty `fill`/`align`/`indent`/`line-suffix`/`if-break` if possible
+function cleanDoc(doc) {
+  return mapDoc(doc, (currentDoc) => cleanDocFn(currentDoc));
 }
 
 function normalizeParts(parts) {
@@ -284,5 +371,6 @@ module.exports = {
   stripTrailingHardline,
   normalizeParts,
   normalizeDoc,
+  cleanDoc,
   replaceNewlinesWithLiterallines,
 };
diff --git a/src/language-js/embed/css.js b/src/language-js/embed/css.js
index 3ee4e1fa344c..9eb9608dddcb 100644
--- a/src/language-js/embed/css.js
+++ b/src/language-js/embed/css.js
@@ -2,7 +2,7 @@
 
 const {
   builders: { indent, hardline, softline, concat },
-  utils: { mapDoc, replaceNewlinesWithLiterallines },
+  utils: { mapDoc, replaceNewlinesWithLiterallines, cleanDoc },
 } = require("../../document");
 const { printTemplateExpressions } = require("../print/template-literal");
 
@@ -49,53 +49,25 @@ function replacePlaceholders(quasisDoc, expressionDocs) {
   if (!expressionDocs || !expressionDocs.length) {
     return quasisDoc;
   }
-
   let replaceCounter = 0;
-  const newDoc = mapDoc(quasisDoc, (doc) => {
-    if (!doc || !doc.parts || !doc.parts.length) {
+  const newDoc = mapDoc(cleanDoc(quasisDoc), (doc) => {
+    if (typeof doc !== "string" || !doc.includes("@prettier-placeholder")) {
       return doc;
     }
-
-    let { parts } = doc;
-    const atIndex = parts.indexOf("@");
-    const placeholderIndex = atIndex + 1;
-    if (
-      atIndex > -1 &&
-      typeof parts[placeholderIndex] === "string" &&
-      parts[placeholderIndex].startsWith("prettier-placeholder")
-    ) {
-      // If placeholder is split, join it
-      const at = parts[atIndex];
-      const placeholder = parts[placeholderIndex];
-      const rest = parts.slice(placeholderIndex + 1);
-      parts = parts
-        .slice(0, atIndex)
-        .concat([at + placeholder])
-        .concat(rest);
-    }
-
-    const replacedParts = [];
-    parts.forEach((part) => {
-      if (typeof part !== "string" || !part.includes("@prettier-placeholder")) {
-        replacedParts.push(part);
-        return;
-      }
-
-      // When we have multiple placeholders in one line, like:
-      // ${Child}${Child2}:not(:first-child)
-      part.split(/@prettier-placeholder-(\d+)-id/).forEach((component, idx) => {
+    // When we have multiple placeholders in one line, like:
+    // ${Child}${Child2}:not(:first-child)
+    return concat(
+      doc.split(/@prettier-placeholder-(\d+)-id/).map((component, idx) => {
         // The placeholder is always at odd indices
         if (idx % 2 === 0) {
-          replacedParts.push(replaceNewlinesWithLiterallines(component));
-          return;
+          return replaceNewlinesWithLiterallines(component);
         }
 
         // The component will always be a number at odd index
-        replacedParts.push(expressionDocs[component]);
         replaceCounter++;
-      });
-    });
-    return { ...doc, parts: replacedParts };
+        return expressionDocs[component];
+      })
+    );
   });
   return expressionDocs.length === replaceCounter ? newDoc : null;
 }
diff --git a/src/main/multiparser.js b/src/main/multiparser.js
index 26cc5c3ce8a2..5a5153f8b470 100644
--- a/src/main/multiparser.js
+++ b/src/main/multiparser.js
@@ -68,7 +68,7 @@ function textToDoc(
       return doc.replace(/(?:\r?\n)*$/, "");
     }
 
-    return stripTrailingHardline(doc, true);
+    return stripTrailingHardline(doc);
   }
 
   /* istanbul ignore next */


Test patch:
diff --git a/tests/markdown/multiparser-js/9734.md b/tests/markdown/multiparser-js/9734.md
new file mode 100644
index 000000000000..d57da526ca89
--- /dev/null
+++ b/tests/markdown/multiparser-js/9734.md
@@ -0,0 +1,49 @@
+Markdown
+
+```js
+"test"
+```
+
+```js
+'test'
+```
+
+```js
+`test`
+```
+
+```js
+false
+```
+
+```js
+true
+```
+
+```js
+null
+```
+
+```js
+[]
+```
+
+```js
+{}
+```
+
+```js
+5
+```
+
+```js
+Infinity
+```
+
+```js
+NaN
+```
+
+```js
+undefined
+```
diff --git a/tests/markdown/multiparser-js/__snapshots__/jsfmt.spec.js.snap b/tests/markdown/multiparser-js/__snapshots__/jsfmt.spec.js.snap
index 2c74ae1009f0..8bc7fd4c6dc7 100644
--- a/tests/markdown/multiparser-js/__snapshots__/jsfmt.spec.js.snap
+++ b/tests/markdown/multiparser-js/__snapshots__/jsfmt.spec.js.snap
@@ -1,5 +1,117 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`9734.md - {"trailingComma":"all"} format 1`] = `
+====================================options=====================================
+parsers: ["markdown"]
+printWidth: 80
+trailingComma: "all"
+                                                                                | printWidth
+=====================================input======================================
+Markdown
+
+\`\`\`js
+"test"
+\`\`\`
+
+\`\`\`js
+'test'
+\`\`\`
+
+\`\`\`js
+\`test\`
+\`\`\`
+
+\`\`\`js
+false
+\`\`\`
+
+\`\`\`js
+true
+\`\`\`
+
+\`\`\`js
+null
+\`\`\`
+
+\`\`\`js
+[]
+\`\`\`
+
+\`\`\`js
+{}
+\`\`\`
+
+\`\`\`js
+5
+\`\`\`
+
+\`\`\`js
+Infinity
+\`\`\`
+
+\`\`\`js
+NaN
+\`\`\`
+
+\`\`\`js
+undefined
+\`\`\`
+
+=====================================output=====================================
+Markdown
+
+\`\`\`js
+"test";
+\`\`\`
+
+\`\`\`js
+"test";
+\`\`\`
+
+\`\`\`js
+\`test\`;
+\`\`\`
+
+\`\`\`js
+false;
+\`\`\`
+
+\`\`\`js
+true;
+\`\`\`
+
+\`\`\`js
+null;
+\`\`\`
+
+\`\`\`js
+[];
+\`\`\`
+
+\`\`\`js
+{
+}
+\`\`\`
+
+\`\`\`js
+5;
+\`\`\`
+
+\`\`\`js
+Infinity;
+\`\`\`
+
+\`\`\`js
+NaN;
+\`\`\`
+
+\`\`\`js
+undefined;
+\`\`\`
+
+================================================================================
+`;
+
 exports[`jsx-comment.md - {"trailingComma":"all"} format 1`] = `
 ====================================options=====================================
 parsers: ["markdown"]
diff --git a/tests_integration/__tests__/doc-utils-clean-doc.js b/tests_integration/__tests__/doc-utils-clean-doc.js
new file mode 100644
index 000000000000..69f8e9b0a5d4
--- /dev/null
+++ b/tests_integration/__tests__/doc-utils-clean-doc.js
@@ -0,0 +1,71 @@
+"use strict";
+
+const prettier = require("prettier-local");
+const docBuilders = prettier.doc.builders;
+const docUtils = prettier.doc.utils;
+
+const { cleanDoc } = docUtils;
+const { group, concat, align, indent, lineSuffix, ifBreak, fill } = docBuilders;
+
+describe("cleanDoc", () => {
+  test.each([
+    [
+      "fill",
+      concat([fill(["", ""]), fill([]), fill(["1"]), fill(["2", "3"])]),
+      concat([fill(["1"]), fill(["2", "3"])]),
+    ],
+    ["nested group", group(group("_")), group("_")],
+    [
+      "empty group",
+      concat([
+        group(""),
+        group(concat([""])),
+        group("_", { id: "id" }),
+        group("_", { shouldBreak: true }),
+        group("_", { expandedStates: ["_"] }),
+      ]),
+      concat([
+        group("_", { id: "id" }),
+        group("_", { shouldBreak: true }),
+        group("_", { expandedStates: ["_"] }),
+      ]),
+    ],
+    [
+      "removes empty align/indent/line-suffix",
+      concat([
+        group(
+          concat([
+            align("  ", concat([""])),
+            indent(concat([""])),
+            concat([""]),
+            "",
+            lineSuffix(concat([""])),
+            ifBreak("", concat([""])),
+          ])
+        ),
+        "_",
+      ]),
+      "_",
+    ],
+    [
+      "removes empty string/concat",
+      concat(["", concat(["", concat([concat(["", "_", ""]), ""])]), ""]),
+      "_",
+    ],
+    [
+      "concat string & flat concat",
+      group(
+        concat([
+          group("1"),
+          concat(["2", "3", group("4"), "5", "6"]),
+          concat(["7", "8", group("9"), "10", "11"]),
+        ])
+      ),
+      group(concat([group("1"), "23", group("4"), "5678", group("9"), "1011"])),
+    ],
+  ])("%s", (_, doc, expected) => {
+    const result = cleanDoc(doc);
+
+    expect(result).toEqual(expected);
+  });
+});