Repository: prettier/prettier (JavaScript)

Issue:
Doc of paragraph in markdown violates a rule of `fill()`
**Prettier 3.1.1**
[Playground link](https://prettier.io/playground/#N4Igxg9gdgLgprEAuEBDABB9AdK6BGB6OeY6ZJuxIANCBAA4wCW0AzsqKgE7cQDuABR4IOKVABt+qAJ4c6+bqjABrODADKqALZwAMsyhxkAM0ls4CpavUaGywwHNkMbgFdLIC9uYv3nuAAPBjhuZl1YSQAVUKgeZjgxMwkLOjYnCTgARTcIeFNzTwArNkCNDOzc-KRk1JAARyq4QT4GMTQ2AFojOAATPtoQV1RmCScAYQhtbVRkNAkJQfSoR0yAQRhXZnw3eEFQgyMClM8ACxhtCQB1U+Z4NnswOA1RO+YANzuZObA2eRB3h4AJJQfqwDRgMJMNagjQwGSZY51Bh8CxXJQMOaSaRyQaGCzcGAtVCOGZIzz2bgEuYzbgqXoCKCDFGGGBXZi9GCnZAADgADHRuHBGswhcTSbMaoU6DBUPh2ZzuUgACx0NwWKJypLSkBwbT4Pr9Xp6VArNwkuAAMQg3BmmycWN2EBAAF8XUA)
<!-- prettier-ignore -->
```sh
--parser markdown
--tab-width 4
--prose-wrap always
```

**Input:**
<!-- prettier-ignore -->
```markdown
a a  
 b b  
 c c  

  
```

**Doc:**
<!-- prettier-ignore -->
```markdown
[
  fill([
    "a",
    line,
    "a  ",
    markAsRoot(literalline),
    line,
    "b",
    line,
    "b  ",
    markAsRoot(literalline),
    line,
    "c",
    line,
    "c",
  ]),
  hardline,
]

```

**Expected behavior:**
The result doc should preserve the following invaliant:
https://github.com/prettier/prettier/blob/bab892242a1f9d8fcae50514b9304bf03f2e25ab/src/document/printer.js#L442-L443

I haven't found user-facing problem yet. I found it investigating https://github.com/prettier/prettier/discussions/15836#discussioncomment-7917975.


Hints:
Oh it can be reproduced without `--prose-wrap: always`. I changed the issue title.

**Prettier 3.1.1**
[Playground link](https://prettier.io/playground/#N4Igxg9gdgLgprEAuEBDABKgOlARu3HMdMEAGhAgAcYBLaAZ2VFQCdWIB3ABTYSZSoANp1QBPJhVytUYANZwYAZVQBbOABlaUOMgBmwhnCkz5ipVVnaA5shisArsZBHVtO4+dwAHlTitadVhhABV-KDZaOAEDISMKBhshOABFBwh4fUNnACsGbyUk1PTMpFj4kABHErhuDioBNAYAWh04ABMO8hB7VFohGwBhCFVVVGQ0ISFuxKhrZIBBGHtaXAd4bn8tHSy45wALGFUhAHV92ngGSzA4JX4L2gA3C7EJsAZJEEenAEkoTtgSjAARoC3+ShgYmSuwqVA4RhOMioEzh0X8j10FG0RlYMDqqGsYxhzksrBxEzGrDk7S4UG6cO0MBOtHaMH2yAAHAAGCisODVWh8-GE8ZlbIUGCoXDM1nspAAFgoDiMISlMXFIDgqlwHU67Q0qDmDgJcAAYhBWGNljYJqh1hAQABfR1AA)
<!-- prettier-ignore -->
```sh
--parser markdown
--tab-width 4
```

**Input:**
<!-- prettier-ignore -->
```markdown
a a
b b
c c
```

**Doc:**
```
[
  fill([
    "a a",
    hardlineWithoutBreakParent,
    breakParent,
    "b b",
    hardlineWithoutBreakParent,
    breakParent,
    "c c",
  ]),
  hardline,
]
```
I found I've misunderstood something.
This should be one of the user-facing problems.

**Prettier 3.1.1**
[Playground link](https://prettier.io/playground/#N4Igxg9gdgLgprEAuEACVBDV8AeNtx4FG76nFmGUlUV3kO2NGoA6U6ARvU7zf9UE8Bwoc3xsOqMKNni5fMYoUt26ACYqlI+buV6Rk9HC2mD2i6ckgANCAgAHGAEtoAZ2SgMAJ28QA7gAKPggeKBgANv4YAJ4edpzeGGAA1nAwAMoYALZwADLOUHDIAGaRbnAJSanpGQ7JhQDmyDDeAK6VIBXZzi3tnYQOcN7OubCRACrDUD7OcGFlERV2bk0RcACKbRDwpeWdAFZuOBlrm9u7SIvLIACOF3CBfg5hIBhuALRFcOo-tiCtDDOCJNADCEGy2QwyDeEQi-1WUEa6wAgjBWs5OG14IFhgUinslp0ABYwbIRADqxOc8Dc9TAcAyoRpzgAbjSYjCwG54iBWR0AJJQX6wDJgEZOFHCjIwGLrQk3Bx+CoUpIOGGRaJxf6FCreGBPDCNKEKzr1bx6mFQ7wpdQBKD-JWFGAU5zqGDE5AADgADHZvHB7s4A4bjdCrvs7DAMJxXe7PUgACx2NoVCYxhaRkBwbKcH6-dR5DBItpGuAAMQg3ih6KaGuxEBAAF8m0A)
<!-- prettier-ignore -->
```sh
--parser markdown
--tab-width 4
--prose-wrap always
```

**Input:**
<!-- prettier-ignore -->
```markdown
  a text text text text text text text text text text text text text text text text  
  b text text text text text text text text text text text text text text text text  
  c text text text text text text text text text text text text text text text text  
  d text text text text text text text text text text text text text text text text  
  e text text text text text text text text text text text text text text text text  

```

**Output:**
<!-- prettier-ignore -->
```markdown
a text text text text text text text text text text text text text text text
text  
 b text text text text text text text text text text text text text text text text  

c text text text text text text text text text text text text text text text
text  
 d text text text text text text text text text text text text text text text text  

e text text text text text text text text text text text text text text text
text

```

**Expected behavior:**
Maybe following?
<!-- prettier-ignore -->
```markdown
a text text text text text text text text text text text text text text text
text  
b text text text text text text text text text text text text text text text
text  
c text text text text text text text text text text text text text text text
text  
d text text text text text text text text text text text text text text text
text  
e text text text text text text text text text text text text text text text
text  
```
`normalizeDoc()` is suspicious.
https://github.com/prettier/prettier/blob/81efe72536d11ee673cca57e3bbc2117198f0f1f/src/document/utils.js#L392-L405
As `cleanDoc()` comment implies, `normalizeDoc()` is dangerous.
https://github.com/prettier/prettier/blob/81efe72536d11ee673cca57e3bbc2117198f0f1f/src/document/utils.js#L353-L359
Feel free to kill it, it's known dangerous. https://github.com/prettier/prettier/pull/9736#discussion_r527916210
Yeah, I'm trying. It might be hard because Markdown printer seems to rely on this bug.
Fixing Markdown printer, we will be able to erase it🧹.
The originally version was for markdown, there might be difference with current one. https://github.com/prettier/prettier/pull/8496/files

I also tried to move it back, didn't finish  https://github.com/prettier/prettier/pull/13701

Patch:
diff --git a/src/document/utils.js b/src/document/utils.js
--- a/src/document/utils.js
+++ b/src/document/utils.js
@@ -337,10 +337,11 @@ function cleanDocFn(doc) {
 
   return doc;
 }
-// A safer version of `normalizeDoc`
-// - `normalizeDoc` concat strings and flat array in `fill`, while `cleanDoc` don't
-// - On array, `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
+
+// - concat strings
+// - flat arrays except for parts of `fill`
+// - merge arrays of strings into single strings
+// - remove nested `group`s and empty `fill`/`align`/`indent`/`line-suffix`/`if-break` if possible
 function cleanDoc(doc) {
   return mapDoc(doc, (currentDoc) => cleanDocFn(currentDoc));
 }
@@ -376,21 +377,6 @@ function normalizeParts(parts) {
   return newParts;
 }
 
-function normalizeDoc(doc) {
-  return mapDoc(doc, (currentDoc) => {
-    if (Array.isArray(currentDoc)) {
-      return normalizeParts(currentDoc);
-    }
-    if (!currentDoc.parts) {
-      return currentDoc;
-    }
-    return {
-      ...currentDoc,
-      parts: normalizeParts(currentDoc.parts),
-    };
-  });
-}
-
 function replaceEndOfLine(doc, replacement = literalline) {
   return mapDoc(doc, (currentDoc) =>
     typeof currentDoc === "string"
@@ -422,7 +408,6 @@ export {
   getDocType,
   inheritLabel,
   mapDoc,
-  normalizeDoc,
   normalizeParts,
   propagateBreaks,
   removeLines,
diff --git a/src/language-markdown/print-paragraph.js b/src/language-markdown/print-paragraph.js
--- a/src/language-markdown/print-paragraph.js
+++ b/src/language-markdown/print-paragraph.js
@@ -1,5 +1,6 @@
 import { fill } from "../document/builders.js";
-import { normalizeDoc } from "../document/utils.js";
+import { DOC_TYPE_ARRAY, DOC_TYPE_FILL } from "../document/constants.js";
+import { getDocType } from "../document/utils.js";
 
 /**
  * @typedef {import("../common/ast-path.js").default} AstPath
@@ -14,7 +15,41 @@ import { normalizeDoc } from "../document/utils.js";
  */
 function printParagraph(path, options, print) {
   const parts = path.map(print, "children");
-  return normalizeDoc(fill(parts));
+  return flattenFill(parts);
+}
+
+/**
+ * @param {Doc[]} docs
+ * @returns {Doc}
+ */
+function flattenFill(docs) {
+  /*
+   * We assume parts always meet following conditions:
+   * - parts.length is odd
+   * - odd elements are line-like doc that comes from odd element off inner fill
+   */
+  /** @type {Doc[]} */
+  const parts = [""];
+
+  (function rec(/** @type {*} */ docArray) {
+    for (const doc of docArray) {
+      const docType = getDocType(doc);
+      if (docType === DOC_TYPE_ARRAY) {
+        rec(doc);
+        continue;
+      }
+
+      let head = doc;
+      let rest = [];
+      if (docType === DOC_TYPE_FILL) {
+        [head, ...rest] = doc.parts;
+      }
+
+      parts.push([parts.pop(), head], ...rest);
+    }
+  })(docs);
+
+  return fill(parts);
 }
 
 export { printParagraph };
diff --git a/src/language-markdown/print-sentence.js b/src/language-markdown/print-sentence.js
new file mode 100644
--- /dev/null
+++ b/src/language-markdown/print-sentence.js
@@ -0,0 +1,37 @@
+/**
+ * @typedef {import("../common/ast-path.js").default} AstPath
+ * @typedef {import("../document/builders.js").Doc} Doc
+ */
+
+import { fill } from "../document/builders.js";
+import { DOC_TYPE_STRING } from "../document/constants.js";
+import { getDocType } from "../document/utils.js";
+
+/**
+ * @param {AstPath} path
+ * @param {*} print
+ * @returns {Doc}
+ */
+function printSentence(path, print) {
+  /** @type {Doc[]} */
+  const parts = [""];
+
+  path.each(() => {
+    const { node } = path;
+    const doc = print();
+    switch (node.type) {
+      case "whitespace":
+        if (getDocType(doc) !== DOC_TYPE_STRING) {
+          parts.push(doc, "");
+          break;
+        }
+      // fallthrough
+      default:
+        parts.push([parts.pop(), doc]);
+    }
+  }, "children");
+
+  return fill(parts);
+}
+
+export { printSentence };
diff --git a/src/language-markdown/printer-markdown.js b/src/language-markdown/printer-markdown.js
--- a/src/language-markdown/printer-markdown.js
+++ b/src/language-markdown/printer-markdown.js
@@ -2,6 +2,7 @@ import collapseWhiteSpace from "collapse-white-space";
 
 import {
   align,
+  fill,
   group,
   hardline,
   indent,
@@ -10,7 +11,8 @@ import {
   markAsRoot,
   softline,
 } from "../document/builders.js";
-import { replaceEndOfLine } from "../document/utils.js";
+import { DOC_TYPE_STRING } from "../document/constants.js";
+import { getDocType, replaceEndOfLine } from "../document/utils.js";
 import getMaxContinuousCount from "../utils/get-max-continuous-count.js";
 import getMinNotPresentContinuousCount from "../utils/get-min-not-present-continuous-count.js";
 import getPreferredQuote from "../utils/get-preferred-quote.js";
@@ -24,6 +26,7 @@ import { insertPragma } from "./pragma.js";
 import { printTable } from "./print/table.js";
 import { printParagraph } from "./print-paragraph.js";
 import preprocess from "./print-preprocess.js";
+import { printSentence } from "./print-sentence.js";
 import { printWhitespace } from "./print-whitespace.js";
 import {
   getFencedCodeBlockValue,
@@ -48,16 +51,27 @@ function genericPrint(path, options, print) {
   const { node } = path;
 
   if (shouldRemainTheSameContent(path)) {
-    return splitText(
+    /** @type {Doc} */
+    const parts = [""];
+    const textsNodes = splitText(
       options.originalText.slice(
         node.position.start.offset,
         node.position.end.offset,
       ),
-    ).map((node) =>
-      node.type === "word"
-        ? node.value
-        : printWhitespace(path, node.value, options.proseWrap, true),
     );
+    for (const node of textsNodes) {
+      if (node.type === "word") {
+        parts.push([parts.pop(), node.value]);
+        continue;
+      }
+      const doc = printWhitespace(path, node.value, options.proseWrap, true);
+      if (getDocType(doc) === DOC_TYPE_STRING) {
+        parts.push([parts.pop(), doc]);
+        continue;
+      }
+      parts.push(doc);
+    }
+    return fill(parts);
   }
 
   switch (node.type) {
@@ -75,7 +89,7 @@ function genericPrint(path, options, print) {
     case "paragraph":
       return printParagraph(path, options, print);
     case "sentence":
-      return printChildren(path, options, print);
+      return printSentence(path, print);
     case "word": {
       let escapedValue = node.value
         .replaceAll("*", "\\*") // escape all `*`


Test patch:
diff --git a/tests/format/markdown/break/__snapshots__/jsfmt.spec.js.snap b/tests/format/markdown/break/__snapshots__/jsfmt.spec.js.snap
--- a/tests/format/markdown/break/__snapshots__/jsfmt.spec.js.snap
+++ b/tests/format/markdown/break/__snapshots__/jsfmt.spec.js.snap
@@ -28,3 +28,29 @@ proseWrap: "always"
 
 ================================================================================
 `;
+
+exports[`wrap.md - {"proseWrap":"always"} format 1`] = `
+====================================options=====================================
+parsers: ["markdown"]
+printWidth: 80
+proseWrap: "always"
+                                                                                | printWidth
+=====================================input======================================
+a  
+ very-very-very-very-very-very-very-very-very-very-long-word very-very-very-very-very-very-very-very-very-very-long-word very-very-very-very-very-very-very-very-very-very-long-word
+
+\\
+word very-very-very-very-very-very-very-very-very-very-long-word very-very-very-very-very-very-very-very-very-very-long-word
+
+=====================================output=====================================
+a  
+ very-very-very-very-very-very-very-very-very-very-long-word
+very-very-very-very-very-very-very-very-very-very-long-word
+very-very-very-very-very-very-very-very-very-very-long-word
+
+\\
+word very-very-very-very-very-very-very-very-very-very-long-word
+very-very-very-very-very-very-very-very-very-very-long-word
+
+================================================================================
+`;
diff --git a/tests/format/markdown/break/wrap.md b/tests/format/markdown/break/wrap.md
new file mode 100644
--- /dev/null
+++ b/tests/format/markdown/break/wrap.md
@@ -0,0 +1,5 @@
+a  
+ very-very-very-very-very-very-very-very-very-very-long-word very-very-very-very-very-very-very-very-very-very-long-word very-very-very-very-very-very-very-very-very-very-long-word
+
+\
+word very-very-very-very-very-very-very-very-very-very-long-word very-very-very-very-very-very-very-very-very-very-long-word
diff --git a/tests/integration/__tests__/normalize-doc.js b/tests/integration/__tests__/normalize-doc.js
deleted file mode 100644
--- a/tests/integration/__tests__/normalize-doc.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import { fill, group } from "../../../src/document/builders.js";
-import { normalizeDoc } from "../../../src/document/utils.js";
-
-describe("normalizeDoc", () => {
-  test.each([
-    [
-      "removes empty strings",
-      ["", "foo", fill(["", "bar", ""]), ""],
-      ["foo", fill(["bar"])],
-    ],
-    [
-      "flattens nested concat",
-      ["foo ", "", ["bar ", "", ["baz", ""]]],
-      ["foo bar baz"],
-    ],
-    [
-      "flattens nested concat in other docs",
-      group(["foo ", ["bar ", "", ["baz", ""]]]),
-      group(["foo bar baz"]),
-    ],
-    [
-      "keeps groups",
-      [group("foo"), group("bar"), group("baz")],
-      [group("foo"), group("bar"), group("baz")],
-    ],
-    [
-      "keeps fills",
-      fill(["foo", fill(["bar", fill(["baz"])])]),
-      fill(["foo", fill(["bar", fill(["baz"])])]),
-    ],
-  ])("%s", (_, doc, expected) => {
-    const result = normalizeDoc(doc);
-
-    expect(result).toEqual(expected);
-  });
-});