Repository: prettier/prettier (JavaScript)
Date: 2020-04-20 15:33:16+00:00
Task type: Bug Fix

Issue:
Inconsistent parsing of injected expressions in styled-components' css
note the presence/absence of `;` after injected expression
```ts
const Header = styled.div`
  ${something()};
margin:4px;
`
```
is parsed and formatted as
```ts
const Header = styled.div`
  ${something()};
  margin: 4px;
`;
```
---
```ts
const Header = styled.div`
  ${something()}
margin:4px;
`
```
is parsed and formatted as
```ts
const Header = styled.div`
  ${something()}
  margin:4px;
`;
```
---
```ts
const Header = styled.div`
  ${something()};
  & > ${Child}:not(:first-child) {
margin-left:5px;
}
`
```
is parsed and formatted as
```ts
const Header = styled.div`
  ${something()};
  & > ${Child}:not(:first-child) {
    margin-left: 5px;
  }
`;
```
---
```ts
const Header = styled.div`
  ${something()}
  & > ${Child}:not(:first-child) {
margin-left:5px;
}
`
```
is not parsed
```
Error: Couldn't insert all the expressions
    at transformCssDoc (https://prettier.io/lib/standalone.js:22171:11)
    at Object.embed$4 [as embed] (https://prettier.io/lib/standalone.js:22011:18)
    at Object.printSubtree (https://prettier.io/lib/standalone.js:15781:31)
    at callPluginPrintFunction (https://prettier.io/lib/standalone.js:15898:29)
    at https://prettier.io/lib/standalone.js:15863:16
    at Object.printComments (https://prettier.io/lib/standalone.js:15586:17)
    at printGenerically (https://prettier.io/lib/standalone.js:15862:22)
    at FastPath.call (https://prettier.io/lib/standalone.js:15700:16)
    at printPathNoParens (https://prettier.io/lib/standalone.js:25178:91)
    at Object.genericPrint$3 [as print] (https://prettier.io/lib/standalone.js:23541:28)
```


Issue discussion:
Note that this error is shown only on the playground for debugging purposes. In production, Prettier just skips formatting this template literal. And I'd say this is the right thing to do because how can we know what `something()` can be? It can be a part of the selector or a separate rule set, so it's not clear how to format it. Leaving it as is the safest option.
The error occurs when you have multiple string concatenations. If you remove `${Child}` or `${something()}`, there's no error. As such, this works without an issue:

```js
const Header = styled.div`
  ${something()}
  & > $Child:not(:first-child) {
margin-left:5px;
}
`
```
@thorn0 Can I claim this?
@thecodrr I'm already looking into it.
Oops. Okay good luck.

Solution patch:
diff --git a/changelog_unreleased/javascript/pr-8097.md b/changelog_unreleased/javascript/pr-8097.md
new file mode 100644
index 000000000000..7e3db9efca65
--- /dev/null
+++ b/changelog_unreleased/javascript/pr-8097.md
@@ -0,0 +1,44 @@
+#### Fix inconsistent parsing of injected expressions in styled-components template literals ([#8097](https://github.com/prettier/prettier/pull/8097) by [@thecodrr](https://github.com/thecodrr))
+
+<!-- prettier-ignore -->
+```js
+// Input
+const SingleConcat = styled.div`
+  ${something()}
+  & > ${Child}:not(:first-child) {
+margin-left:5px;
+}
+`
+
+const MultiConcats = styled.div`
+  ${something()}
+  & > ${Child}${Child2}:not(:first-child) {
+margin-left:5px;
+}
+`
+
+const SeparatedConcats = styled.div`
+font-family: "${a}", "${b}";
+`
+
+// Prettier stable -- same as input
+
+// Prettier master
+const SingleConcat = styled.div`
+  ${something()}
+  & > ${Child}:not(:first-child) {
+    margin-left: 5px;
+  }
+`;
+
+const MultiConcats = styled.div`
+  ${something()}
+  & > ${Child}${Child2}:not(:first-child) {
+    margin-left: 5px;
+  }
+`;
+
+const SeparatedConcats = styled.div`
+  font-family: "${a}", "${b}";
+`;
+```
diff --git a/src/language-js/embed.js b/src/language-js/embed.js
index 55fde9021264..abccc3851c6e 100644
--- a/src/language-js/embed.js
+++ b/src/language-js/embed.js
@@ -261,12 +261,12 @@ function replacePlaceholders(quasisDoc, expressionDocs) {
     return quasisDoc;
   }
 
-  const expressions = expressionDocs.slice();
   let replaceCounter = 0;
   const newDoc = mapDoc(quasisDoc, (doc) => {
     if (!doc || !doc.parts || !doc.parts.length) {
       return doc;
     }
+
     let { parts } = doc;
     const atIndex = parts.indexOf("@");
     const placeholderIndex = atIndex + 1;
@@ -284,32 +284,31 @@ function replacePlaceholders(quasisDoc, expressionDocs) {
         .concat([at + placeholder])
         .concat(rest);
     }
-    const atPlaceholderIndex = parts.findIndex(
-      (part) =>
-        typeof part === "string" && part.startsWith("@prettier-placeholder")
-    );
-    if (atPlaceholderIndex > -1) {
-      const placeholder = parts[atPlaceholderIndex];
-      const rest = parts.slice(atPlaceholderIndex + 1);
-      const placeholderMatch = placeholder.match(
-        /@prettier-placeholder-(.+)-id([\S\s]*)/
-      );
-      const placeholderID = placeholderMatch[1];
-      // When the expression has a suffix appended, like:
-      // animation: linear ${time}s ease-out;
-      const suffix = placeholderMatch[2];
-      const expression = expressions[placeholderID];
 
-      replaceCounter++;
-      parts = parts
-        .slice(0, atPlaceholderIndex)
-        .concat(["${", expression, "}" + suffix])
-        .concat(rest);
-    }
-    return { ...doc, parts };
-  });
+    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) => {
+        // The placeholder is always at odd indices
+        if (idx % 2 === 0) {
+          replacedParts.push(component);
+          return;
+        }
 
-  return expressions.length === replaceCounter ? newDoc : null;
+        // The component will always be a number at odd index
+        replacedParts.push("${", expressionDocs[component], "}");
+        replaceCounter++;
+      });
+    });
+    return { ...doc, parts: replacedParts };
+  });
+  return expressionDocs.length === replaceCounter ? newDoc : null;
 }
 
 function printGraphqlComments(lines) {


Test patch:
diff --git a/tests/multiparser_js_css/__snapshots__/jsfmt.spec.js.snap b/tests/multiparser_js_css/__snapshots__/jsfmt.spec.js.snap
index 8eb72caa98fa..320ae7e00961 100644
--- a/tests/multiparser_js_css/__snapshots__/jsfmt.spec.js.snap
+++ b/tests/multiparser_js_css/__snapshots__/jsfmt.spec.js.snap
@@ -2,7 +2,7 @@
 
 exports[`colons-after-substitutions.js format 1`] = `
 ====================================options=====================================
-parsers: ["babel"]
+parsers: ["babel", "typescript", "flow"]
 printWidth: 80
                                                                                 | printWidth
 =====================================input======================================
@@ -47,7 +47,7 @@ const Icon = styled.div\`
 
 exports[`colons-after-substitutions2.js format 1`] = `
 ====================================options=====================================
-parsers: ["babel"]
+parsers: ["babel", "typescript", "flow"]
 printWidth: 80
                                                                                 | printWidth
 =====================================input======================================
@@ -103,9 +103,144 @@ const Icon3 = styled.div\`
 ================================================================================
 `;
 
+exports[`issue-2636.js format 1`] = `
+====================================options=====================================
+parsers: ["babel", "typescript", "flow"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+export const ButtonWrapper = styled.button\`
+  \${base}
+  \${hover}
+  \${opaque}
+  \${block}
+  \${active}
+  \${disabled}
+  \${outline}
+  \${dashed}
+  \${spacing}
+\`;
+
+export const ButtonWrapper2 = styled.button\`
+  \${base} \${hover} \${opaque} \${block} \${active} \${disabled} \${outline} \${dashed} \${spacing}
+\`;
+
+=====================================output=====================================
+export const ButtonWrapper = styled.button\`
+  \${base}
+  \${hover}
+  \${opaque}
+  \${block}
+  \${active}
+  \${disabled}
+  \${outline}
+  \${dashed}
+  \${spacing}
+\`;
+
+export const ButtonWrapper2 = styled.button\`
+  \${base} \${hover} \${opaque} \${block} \${active} \${disabled} \${outline} \${dashed} \${spacing}
+\`;
+
+================================================================================
+`;
+
+exports[`issue-2883.js format 1`] = `
+====================================options=====================================
+parsers: ["babel", "typescript", "flow"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+export const foo = css\`
+&.foo .\${bar}::before,&.foo[value="hello"] .\${bar}::before {
+	position: absolute;
+}
+\`;
+
+export const foo2 = css\`
+a.\${bar}:focus,a.\${bar}:hover {
+  color: red;
+}
+\`;
+
+export const global = css\`
+button.\${foo}.\${bar} {
+  color: #fff;
+}
+\`;
+
+=====================================output=====================================
+export const foo = css\`
+  &.foo .\${bar}::before,&.foo[value="hello"] .\${bar}::before {
+    position: absolute;
+  }
+\`;
+
+export const foo2 = css\`
+  a.\${bar}:focus,a.\${bar}:hover {
+    color: red;
+  }
+\`;
+
+export const global = css\`
+  button.\${foo}.\${bar} {
+    color: #fff;
+  }
+\`;
+
+================================================================================
+`;
+
+exports[`issue-5697.js format 1`] = `
+====================================options=====================================
+parsers: ["babel", "typescript", "flow"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+const StyledH1 = styled.div\`
+  font-size: 2.5em;
+  font-weight: \${(props) => (props.strong ? 500 : 100)};
+  font-family: \${constants.text.displayFont.fontFamily};
+  letter-spacing: \${(props) => (props.light ? '0.04em' : 0)};
+  color: \${(props) => props.textColor};
+  \${(props) =>
+    props.center
+      ? \` display: flex;
+                align-items: center;
+                justify-content: center;
+                text-align: center;\`
+      : ''}
+  @media (max-width: \${(props) => (props.noBreakPoint ? '0' : constants.layout.breakpoint.break1)}px) {
+    font-size: 2em;
+  }
+\`;
+
+=====================================output=====================================
+const StyledH1 = styled.div\`
+  font-size: 2.5em;
+  font-weight: \${(props) => (props.strong ? 500 : 100)};
+  font-family: \${constants.text.displayFont.fontFamily};
+  letter-spacing: \${(props) => (props.light ? "0.04em" : 0)};
+  color: \${(props) => props.textColor};
+  \${(props) =>
+    props.center
+      ? \` display: flex;
+                align-items: center;
+                justify-content: center;
+                text-align: center;\`
+      : ""}
+  @media (max-width: \${(props) =>
+    props.noBreakPoint ? "0" : constants.layout.breakpoint.break1}px) {
+    font-size: 2em;
+  }
+\`;
+
+================================================================================
+`;
+
 exports[`issue-6259.js format 1`] = `
 ====================================options=====================================
-parsers: ["babel"]
+parsers: ["babel", "typescript", "flow"]
 printWidth: 80
                                                                                 | printWidth
 =====================================input======================================
@@ -163,7 +298,7 @@ const yesFrame = (
 
 exports[`styled-components.js format 1`] = `
 ====================================options=====================================
-parsers: ["babel"]
+parsers: ["babel", "typescript", "flow"]
 printWidth: 80
                                                                                 | printWidth
 =====================================input======================================
@@ -467,7 +602,7 @@ styled.div\`
 \`;
 
 styled.div\`
-   /* prettier-ignore */
+  /* prettier-ignore */
   color: \${(props) => props.theme.colors.paragraph};
   \${(props) => (props.small ? "font-size: 0.8em;" : "")};
 \`;
@@ -672,9 +807,69 @@ const Foo = styled.p\`
 ================================================================================
 `;
 
+exports[`styled-components-multiple-expressions.js format 1`] = `
+====================================options=====================================
+parsers: ["babel", "typescript", "flow"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+const Header = styled.div\`
+  \${something()}
+  & > \${Child}:not(:first-child) {
+margin-left:5px;
+}
+\`
+
+const Header = styled.div\`
+  \${something()}
+  & > \${Child}\${Child2}:not(:first-child) {
+margin-left:5px;
+}
+\`
+
+styled.div\`\${foo}-idle { }\`
+
+styled.div\`\${foo}-0-idle { }\`
+
+styled.div\`
+font-family: "\${a}", "\${b}";
+\`
+
+=====================================output=====================================
+const Header = styled.div\`
+  \${something()}
+  & > \${Child}:not(:first-child) {
+    margin-left: 5px;
+  }
+\`;
+
+const Header = styled.div\`
+  \${something()}
+  & > \${Child}\${Child2}:not(:first-child) {
+    margin-left: 5px;
+  }
+\`;
+
+styled.div\`
+  \${foo}-idle {
+  }
+\`;
+
+styled.div\`
+  \${foo}-0-idle {
+  }
+\`;
+
+styled.div\`
+  font-family: "\${a}", "\${b}";
+\`;
+
+================================================================================
+`;
+
 exports[`var.js format 1`] = `
 ====================================options=====================================
-parsers: ["babel"]
+parsers: ["babel", "typescript", "flow"]
 printWidth: 80
                                                                                 | printWidth
 =====================================input======================================
diff --git a/tests/multiparser_js_css/issue-2636.js b/tests/multiparser_js_css/issue-2636.js
new file mode 100644
index 000000000000..2e7e6bd65611
--- /dev/null
+++ b/tests/multiparser_js_css/issue-2636.js
@@ -0,0 +1,15 @@
+export const ButtonWrapper = styled.button`
+  ${base}
+  ${hover}
+  ${opaque}
+  ${block}
+  ${active}
+  ${disabled}
+  ${outline}
+  ${dashed}
+  ${spacing}
+`;
+
+export const ButtonWrapper2 = styled.button`
+  ${base} ${hover} ${opaque} ${block} ${active} ${disabled} ${outline} ${dashed} ${spacing}
+`;
diff --git a/tests/multiparser_js_css/issue-2883.js b/tests/multiparser_js_css/issue-2883.js
new file mode 100644
index 000000000000..3c2567b01dbe
--- /dev/null
+++ b/tests/multiparser_js_css/issue-2883.js
@@ -0,0 +1,17 @@
+export const foo = css`
+&.foo .${bar}::before,&.foo[value="hello"] .${bar}::before {
+	position: absolute;
+}
+`;
+
+export const foo2 = css`
+a.${bar}:focus,a.${bar}:hover {
+  color: red;
+}
+`;
+
+export const global = css`
+button.${foo}.${bar} {
+  color: #fff;
+}
+`;
diff --git a/tests/multiparser_js_css/issue-5697.js b/tests/multiparser_js_css/issue-5697.js
new file mode 100644
index 000000000000..9cd1f8a0ae83
--- /dev/null
+++ b/tests/multiparser_js_css/issue-5697.js
@@ -0,0 +1,17 @@
+const StyledH1 = styled.div`
+  font-size: 2.5em;
+  font-weight: ${(props) => (props.strong ? 500 : 100)};
+  font-family: ${constants.text.displayFont.fontFamily};
+  letter-spacing: ${(props) => (props.light ? '0.04em' : 0)};
+  color: ${(props) => props.textColor};
+  ${(props) =>
+    props.center
+      ? ` display: flex;
+                align-items: center;
+                justify-content: center;
+                text-align: center;`
+      : ''}
+  @media (max-width: ${(props) => (props.noBreakPoint ? '0' : constants.layout.breakpoint.break1)}px) {
+    font-size: 2em;
+  }
+`;
diff --git a/tests/multiparser_js_css/jsfmt.spec.js b/tests/multiparser_js_css/jsfmt.spec.js
index 8382eddeb1db..61966a7d00c3 100644
--- a/tests/multiparser_js_css/jsfmt.spec.js
+++ b/tests/multiparser_js_css/jsfmt.spec.js
@@ -1,1 +1,1 @@
-run_spec(__dirname, ["babel"]);
+run_spec(__dirname, ["babel", "typescript", "flow"]);
diff --git a/tests/multiparser_js_css/styled-components-multiple-expressions.js b/tests/multiparser_js_css/styled-components-multiple-expressions.js
new file mode 100644
index 000000000000..77d63de36337
--- /dev/null
+++ b/tests/multiparser_js_css/styled-components-multiple-expressions.js
@@ -0,0 +1,21 @@
+const Header = styled.div`
+  ${something()}
+  & > ${Child}:not(:first-child) {
+margin-left:5px;
+}
+`
+
+const Header = styled.div`
+  ${something()}
+  & > ${Child}${Child2}:not(:first-child) {
+margin-left:5px;
+}
+`
+
+styled.div`${foo}-idle { }`
+
+styled.div`${foo}-0-idle { }`
+
+styled.div`
+font-family: "${a}", "${b}";
+`