Repository: sveltejs/prettier-plugin-svelte (ts)

Issue:
Error when parsing dynamic attributes expressions
I have the following code:

```html
<script>
    import Typewritter from './Typewritter.svelte'
    const texts = ['text1', 'text2']
</script>

<div class="parallax-cover">
    <h1>Random Title</h1>
    <Typewritter {texts} />     <!-- This will be formatted -->
</div>
```

I use the `npm run format` command and the result is:

```html
<script>
    import Typewritter from './Typewritter.svelte'
    const texts = ['text1', 'text2']
</script>

<div class="parallax-cover">
    <h1>Random Title</h1>
    <Typewritter texts="texts" />   <!-- The expected result was texts="{texts}" -->
</div>
```

This changes the expected result, because you are assigning the text string "texts", not the variable texts.

Additional information
```json
"scripts": {
        "start": "sirv public",
        "autobuild": "rollup -c -w",
        "dev": "run-p watch:tailwind autobuild",
        "build": "npm run build:tailwind && rollup -c",
        "format": "prettier --write \"{,!(public)/**/}*.{js,json,svelte}\"",
        "watch:tailwind": "postcss src/tailwind.css -o public/global.css -w",
        "build:tailwind": "cross-env NODE_ENV=production postcss src/tailwind.css -o public/global.css"
    },
"dependencies": {
        "sirv-cli": "^1.0.3"
    },
    "devDependencies": {
        "@fullhuman/postcss-purgecss": "^2.3.0",
        "@rollup/plugin-commonjs": "^14.0.0",
        "@rollup/plugin-node-resolve": "^8.4.0",
        "cross-env": "^7.0.2",
        "cssnano": "^4.1.10",
        "npm-run-all": "^4.1.5",
        "postcss-cli": "^7.1.1",
        "prettier": "^2.0.5",
        "prettier-plugin-svelte": "^1.1.0",
        "rollup": "^2.21.0",
        "rollup-plugin-livereload": "^1.3.0",
        "rollup-plugin-svelte": "^5.2.3",
        "rollup-plugin-terser": "^6.1.0",
        "svelte": "^3.24.0",
        "svelte-preprocess": "^4.0.8",
        "tailwindcss": "^1.5.1"
    }
```

Pull request description:
Add curly braces when attribute shorthand is used but not allowed
Closes #110 

I also added the ability for custom options in formatting tests, which was needed to reproduce this bug.

Patch:
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..1b52c48
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,1 @@
+test/**/*.html
\ No newline at end of file
diff --git a/src/print/index.ts b/src/print/index.ts
index f3eaf27..ec7234f 100644
--- a/src/print/index.ts
+++ b/src/print/index.ts
@@ -1,5 +1,5 @@
 import { FastPath, Doc, doc, ParserOptions } from 'prettier';
-import { Node, MustacheTagNode, IfBlockNode } from './nodes';
+import { Node, IfBlockNode, AttributeNode } from './nodes';
 import { isASTNode, isPreTagContent } from './helpers';
 import { extractAttributes } from '../lib/extractAttributes';
 import { getText } from '../lib/getText';
@@ -12,6 +12,8 @@ import {
     isInlineElement,
     isInlineNode,
     isEmptyNode,
+    isLoneMustacheTag,
+    isOrCanBeConvertedToShorthand,
 } from './node-helpers';
 import {
     isLine,
@@ -224,43 +226,26 @@ export function print(path: FastPath, options: ParserOptions, print: PrintFn): D
             return node.expression.name;
         }
         case 'Attribute': {
-            const hasLoneMustacheTag =
-                node.value !== true &&
-                node.value.length === 1 &&
-                node.value[0].type === 'MustacheTag';
-            let isAttributeShorthand =
-                node.value !== true &&
-                node.value.length === 1 &&
-                node.value[0].type === 'AttributeShorthand';
-
-            // Convert a={a} into {a}
-            if (hasLoneMustacheTag) {
-                const expression = (node.value as [MustacheTagNode])[0].expression;
-                isAttributeShorthand =
-                    expression.type === 'Identifier' && expression.name === node.name;
-            }
-
-            if (isAttributeShorthand && options.svelteAllowShorthand) {
-                return concat([line, '{', node.name, '}']);
+            if (isOrCanBeConvertedToShorthand(node)) {
+                if (options.svelteStrictMode) {
+                    return concat([line, node.name, '="{', node.name, '}"']);
+                } else if (options.svelteAllowShorthand) {
+                    return concat([line, '{', node.name, '}']);
+                } else {
+                    return concat([line, node.name, '={', node.name, '}']);
+                }
             } else {
-                const def: Doc[] = [line, node.name];
-                if (node.value !== true) {
-                    def.push('=');
-                    const quotes = !hasLoneMustacheTag || options.svelteStrictMode;
-
-                    quotes && def.push('"');
-
-                    const valueDocs = path.map((childPath) => childPath.call(print), 'value');
-
-                    if (!quotes || !formattableAttributes.includes(node.name)) {
-                        def.push(concat(valueDocs));
-                    } else {
-                        def.push(indent(group(concat(trim(valueDocs, isLine)))));
-                    }
+                if (node.value === true) {
+                    return concat([line, node.name]);
+                }
 
-                    quotes && def.push('"');
+                const quotes = !isLoneMustacheTag(node.value) || options.svelteStrictMode;
+                const attrNodeValue = printAttributeNodeValue(path, print, quotes, node);
+                if (quotes) {
+                    return concat([line, node.name, '=', '"', attrNodeValue, '"']);
+                } else {
+                    return concat([line, node.name, '=', attrNodeValue]);
                 }
-                return concat(def);
             }
         }
         case 'MustacheTag':
@@ -488,6 +473,21 @@ export function print(path: FastPath, options: ParserOptions, print: PrintFn): D
     throw new Error('unknown node type: ' + node.type);
 }
 
+function printAttributeNodeValue(
+    path: FastPath<any>,
+    print: PrintFn,
+    quotes: boolean,
+    node: AttributeNode,
+) {
+    const valueDocs = path.map((childPath) => childPath.call(print), 'value');
+
+    if (!quotes || !formattableAttributes.includes(node.name)) {
+        return concat(valueDocs);
+    } else {
+        return indent(group(concat(trim(valueDocs, isLine))));
+    }
+}
+
 function printChildren(path: FastPath, print: PrintFn): Doc[] {
     let childDocs: Doc[] = [];
     let currentGroup: { doc: Doc; node: Node }[] = [];
diff --git a/src/print/node-helpers.ts b/src/print/node-helpers.ts
index 78c1264..e6e2de7 100644
--- a/src/print/node-helpers.ts
+++ b/src/print/node-helpers.ts
@@ -1,54 +1,78 @@
-import { Node } from './nodes';
+import { Node, MustacheTagNode, AttributeShorthandNode, AttributeNode } from './nodes';
 import { inlineElements, TagName } from '../lib/elements';
 
 export function isInlineElement(node: Node) {
-  return node.type === 'Element' && inlineElements.includes(node.name as TagName);
+    return node.type === 'Element' && inlineElements.includes(node.name as TagName);
 }
 
 export function isWhitespaceChar(ch: string) {
-  return ' \t\n\r'.indexOf(ch) >= 0;
+    return ' \t\n\r'.indexOf(ch) >= 0;
 }
 
 export function canBreakAfter(node: Node) {
-  switch (node.type) {
-      case 'Text':
-          return isWhitespaceChar(node.raw[node.raw.length - 1]);
-      case 'Element':
-          return !isInlineElement(node);
-      default:
-          return true;
-  }
+    switch (node.type) {
+        case 'Text':
+            return isWhitespaceChar(node.raw[node.raw.length - 1]);
+        case 'Element':
+            return !isInlineElement(node);
+        default:
+            return true;
+    }
 }
 
 export function canBreakBefore(node: Node) {
-  switch (node.type) {
-      case 'Text':
-          return isWhitespaceChar(node.raw[0]);
-      case 'Element':
-          return !isInlineElement(node);
-      default:
-          return true;
-  }
+    switch (node.type) {
+        case 'Text':
+            return isWhitespaceChar(node.raw[0]);
+        case 'Element':
+            return !isInlineElement(node);
+        default:
+            return true;
+    }
 }
 
 export function isInlineNode(node: Node): boolean {
-  switch (node.type) {
-      case 'Text':
-          const text = node.raw || node.data;
-          const isAllWhitespace = text.trim() === ''
-
-          return !isAllWhitespace || text === '';
-      case 'MustacheTag':
-      case 'EachBlock':
-      case 'IfBlock':
-          return true;
-      case 'Element':
-          return isInlineElement(node);
-      default:
-          return false;
-  }
+    switch (node.type) {
+        case 'Text':
+            const text = node.raw || node.data;
+            const isAllWhitespace = text.trim() === '';
+
+            return !isAllWhitespace || text === '';
+        case 'MustacheTag':
+        case 'EachBlock':
+        case 'IfBlock':
+            return true;
+        case 'Element':
+            return isInlineElement(node);
+        default:
+            return false;
+    }
 }
 
 export function isEmptyNode(node: Node): boolean {
-  return node.type === 'Text' && (node.raw || node.data).trim() === '';
+    return node.type === 'Text' && (node.raw || node.data).trim() === '';
+}
+
+export function isLoneMustacheTag(node: true | Node[]): node is [MustacheTagNode] {
+    return node !== true && node.length === 1 && node[0].type === 'MustacheTag';
+}
+
+export function isAttributeShorthand(node: true | Node[]): node is [AttributeShorthandNode] {
+    return node !== true && node.length === 1 && node[0].type === 'AttributeShorthand';
+}
+
+/**
+ * True if node is of type `{a}` or `a={a}`
+ */
+export function isOrCanBeConvertedToShorthand(node: AttributeNode): boolean {
+    if (isAttributeShorthand(node.value)) {
+        return true;
+    }
+
+    if (isLoneMustacheTag(node.value)) {
+        const expression = node.value[0].expression;
+        return expression.type === 'Identifier' && expression.name === node.name;
+    }
+
+    return false;
 }


Test patch:
diff --git a/test/formatting/index.ts b/test/formatting/index.ts
index a215a2d..13ce2a8 100644
--- a/test/formatting/index.ts
+++ b/test/formatting/index.ts
@@ -1,5 +1,5 @@
 import test from 'ava';
-import { readdirSync, readFileSync } from 'fs';
+import { readdirSync, readFileSync, existsSync } from 'fs';
 import { format } from 'prettier';
 
 const dirs = readdirSync('test/formatting/samples');
@@ -13,13 +13,24 @@ for (const dir of dirs) {
         `test/formatting/samples/${dir}/output.html`,
         'utf-8',
     ).replace(/\r?\n/g, '\n');
+    const options = readOptions(`test/formatting/samples/${dir}/options.json`);
 
     test(`formatting: ${dir}`, t => {
         const actualOutput = format(input, {
             parser: 'svelte' as any,
             plugins: [require.resolve('../../src')],
             tabWidth: 4,
+            ...options,
         });
         t.is(expectedOutput, actualOutput);
     });
 }
+
+function readOptions(fileName: string) {
+    if (!existsSync(fileName)) {
+        return {};
+    }
+
+    const fileContents = readFileSync(fileName, 'utf-8');
+    return JSON.parse(fileContents);
+}
diff --git a/test/formatting/samples/allow-shorthand-false/input.html b/test/formatting/samples/allow-shorthand-false/input.html
new file mode 100644
index 0000000..7ffed02
--- /dev/null
+++ b/test/formatting/samples/allow-shorthand-false/input.html
@@ -0,0 +1,5 @@
+<input value />
+<input value="string" />
+<input {value} />
+<input value="{value}" />
+<input value={value} />
diff --git a/test/formatting/samples/allow-shorthand-false/options.json b/test/formatting/samples/allow-shorthand-false/options.json
new file mode 100644
index 0000000..e495e72
--- /dev/null
+++ b/test/formatting/samples/allow-shorthand-false/options.json
@@ -0,0 +1,3 @@
+{
+    "svelteAllowShorthand": false
+}
diff --git a/test/formatting/samples/allow-shorthand-false/output.html b/test/formatting/samples/allow-shorthand-false/output.html
new file mode 100644
index 0000000..cd9d2ef
--- /dev/null
+++ b/test/formatting/samples/allow-shorthand-false/output.html
@@ -0,0 +1,5 @@
+<input value />
+<input value="string" />
+<input value={value} />
+<input value={value} />
+<input value={value} />
diff --git a/test/formatting/samples/strict-mode-true/input.html b/test/formatting/samples/strict-mode-true/input.html
new file mode 100644
index 0000000..620e528
--- /dev/null
+++ b/test/formatting/samples/strict-mode-true/input.html
@@ -0,0 +1,5 @@
+<input value />
+<input value="string" />
+<input {value} />
+<input value={value} />
+<input value="{value}" />
diff --git a/test/formatting/samples/strict-mode-true/options.json b/test/formatting/samples/strict-mode-true/options.json
new file mode 100644
index 0000000..7149251
--- /dev/null
+++ b/test/formatting/samples/strict-mode-true/options.json
@@ -0,0 +1,3 @@
+{
+    "svelteStrictMode": true
+}
diff --git a/test/formatting/samples/strict-mode-true/output.html b/test/formatting/samples/strict-mode-true/output.html
new file mode 100644
index 0000000..c293a2a
--- /dev/null
+++ b/test/formatting/samples/strict-mode-true/output.html
@@ -0,0 +1,5 @@
+<input value />
+<input value="string" />
+<input value="{value}" />
+<input value="{value}" />
+<input value="{value}" />