Repository: nicoespeon/abracadabra (ts)

Issue:
Automatically "Convert to Template String" if we write ${} in a string
## Is this request related to a problem? Please describe.

> In Jetbrains IDEs (aka IntelliJ, WebStorm etc) it will detect that you are inserting a ${} variable and auto-magically switch the quotes to “back ticks”.
> from [Jean-Philippe Veilleux](https://www.linkedin.com/in/ACoAAANEYfMB9FZzTamSXnJ-b0FTG3csYifkA_M)'s [comment on LinkedIn](https://www.linkedin.com/feed/update/urn:li:ugcPost:7193610343711940608?commentUrn=urn%3Ali%3Acomment%3A%28ugcPost%3A7193610343711940608%2C7193709735257231360%29&dashCommentUrn=urn%3Ali%3Afsd_comment%3A%287193709735257231360%2Curn%3Ali%3AugcPost%3A7193610343711940608%29)

I think this is smart. 

We already have the refactoring. But you need to trigger it manually.

Idea: listen to changes and, if we are in a regular string + detect the insertion of `${}` => trigger the refactoring on said string

## Describe the solution you'd like

1. I insert `${}` in a regular string 
2. It magically converts into a template string, so my `${}` is valid

Also, give an option to disable that. Opt-in by default.

Pull request description:
Automatically trigger "Convert to Template Literal"
When adding `${}` in a valid String literal.

It can be disabled in the settings (`abracadabra.autoConvertToTemplateLiteral`) because it's technically not a refactoring—but in most scenarios, this is what we want to do.

It does not handle multi-cursors because the original refactoring doesn't (in fact, none of the refactorings we have support this for now). I think that's OK.

I was considering handling more scenarios, like adding $ to `{someValue}` in a string would also trigger the refactoring… but these don't seem to be the main use-case and may not actually be desirable (again, technically we are modifying the behavior of the code here). So I rollback and stick to the basic one: 

https://github.com/nicoespeon/abracadabra/assets/1094774/476e4755-551c-4446-aa8d-e4552626528e

As usual, I'll iterate based on reported issues, suggestions, and personal usage of this feature 😄

Fixes #1012




Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c52cacb..90547948 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Changed
 
 - When triggering a refactoring from a quick fix (the 💡 icon), Abracadabra will use the code and position you were on when the fix was suggested. This should be invisible for most use-case, but will play nicely with extensions like [cursorless](https://marketplace.visualstudio.com/items?itemName=pokey.cursorless). Thanks @jaresty for reporting, and @pokey for adding more context 😉
+- "Convert to Template String" will now preserve `${}` and `${someValue}` as-is, without escaping it. It's technically a bug (= changes the behavior of the code) but usage indicates these are meant to become interpolated variables. So it's more likely to do what you want.
 
 ## [9.1.3]
 
diff --git a/README.md b/README.md
index ad546dd5..93bd0323 100644
--- a/README.md
+++ b/README.md
@@ -97,12 +97,13 @@ That's why you have a "Refresh Highlights" command. Hit `Shift + Alt + H` and re
 
 ## Configuration
 
-| Setting                         | Description                                                                          | Default                 |
-| ------------------------------- | ------------------------------------------------------------------------------------ | ----------------------- |
-| `abracadabra.ignoredFolders`    | Folders where it won't propose refactorings                                          | `["node_modules"]`      |
-| `abracadabra.ignoredPatterns`   | Glob patterns where it won't propose refactorings                                    | `["dist/*", "build/*"]` |
-| `abracadabra.maxFileLinesCount` | Don't propose refactorings on files that have more lines of code than this threshold | `10.000`                |
-| `abracadabra.getMaxFileSizeKb`  | Don't propose refactorings on files that bigger than this threshold (in kB)          | `250`                   |
+| Setting                                    | Description                                                                          | Default                 |
+| ------------------------------------------ | ------------------------------------------------------------------------------------ | ----------------------- |
+| `abracadabra.ignoredFolders`               | Folders where it won't propose refactorings                                          | `["node_modules"]`      |
+| `abracadabra.ignoredPatterns`              | Glob patterns where it won't propose refactorings                                    | `["dist/*", "build/*"]` |
+| `abracadabra.maxFileLinesCount`            | Don't propose refactorings on files that have more lines of code than this threshold | `10.000`                |
+| `abracadabra.getMaxFileSizeKb`             | Don't propose refactorings on files that bigger than this threshold (in kB)          | `250`                   |
+| `abracadabra.autoConvertToTemplateLiteral` | Automatically convert a string into a template literal when you insert `${}`         | `true`                  |
 
 For the glob patterns, read [glob's documentation](https://github.com/isaacs/node-glob/blob/f5a57d3d6e19b324522a3fa5bdd5075fd1aa79d1/README.md#glob-primer) to see what you can filter out.
 
diff --git a/REFACTORINGS.md b/REFACTORINGS.md
index f24144c0..011df727 100644
--- a/REFACTORINGS.md
+++ b/REFACTORINGS.md
@@ -441,6 +441,10 @@ Hence, Abracadabra is proposing the refactoring for such scenario!
 
 ![][demo-convert-to-template-literal]
 
+By default, this refactoring will automatically apply if you write `${}` in a string literal. This is convenient behavior in most scenarios, but technically changes code behaviour.
+
+If you want to disable the automatic transformation, you can turn OFF `abracadabra.autoConvertToTemplateLiteral` in [VS Code settings][vscode-settings].
+
 [⬆️ Go to Table of Contents](#table-of-contents)
 
 ### Replace Binary with Assignment
diff --git a/package.json b/package.json
index 33907827..3608de17 100644
--- a/package.json
+++ b/package.json
@@ -110,7 +110,8 @@
     "@babel/types": "7.20.7",
     "minimatch": "9.0.3",
     "pluralize": "8.0.0",
-    "recast": "0.23.4"
+    "recast": "0.23.4",
+    "ts-pattern": "5.1.1"
   },
   "activationEvents": [
     "onLanguage:javascript",
@@ -398,6 +399,11 @@
           "default": 250,
           "description": "Don't propose refactorings on files that are bigger than this threshold (kB)."
         },
+        "abracadabra.autoConvertToTemplateLiteral": {
+          "type": "boolean",
+          "default": true,
+          "description": "Automatically convert a string into a template literal when you insert `${}`."
+        },
         "abracadabra.addNumericSeparator.showInQuickFix": {
           "type": "boolean",
           "default": true,
diff --git a/src/editor/adapters/vscode-editor.ts b/src/editor/adapters/vscode-editor.ts
index d0bbb50a..6c1b8525 100644
--- a/src/editor/adapters/vscode-editor.ts
+++ b/src/editor/adapters/vscode-editor.ts
@@ -1,4 +1,5 @@
 import * as vscode from "vscode";
+import { match } from "ts-pattern";
 import { Decoration, Source } from "../../highlights/highlights";
 import { HighlightsRepository } from "../../highlights/highlights-repository";
 import { getIgnoredFolders } from "../../vscode-configuration";
@@ -7,6 +8,7 @@ import { COLORS } from "../colors";
 import {
   Choice,
   Code,
+  CodeChange,
   Command,
   Editor,
   ErrorReason,
@@ -381,24 +383,45 @@ function createSourceChanges(
   change: vscode.TextDocumentContentChangeEvent
 ): SourceChange[] {
   const selection = createSelectionFromVSCode(change.range);
+  const changeType = getCodeChangeFromVSCode(change).type;
+
+  return match(changeType)
+    .with("add", () => [
+      new AddSourceChange(selection.extendToCode(change.text))
+    ])
+    .with("delete", () => [new DeleteSourceChange(selection)])
+    .with("update", () => [
+      new DeleteSourceChange(selection),
+      new AddSourceChange(
+        Selection.cursorAtPosition(selection.start).extendToCode(change.text)
+      )
+    ])
+    .exhaustive();
+}
 
+export function getCodeChangeFromVSCode(
+  change: vscode.TextDocumentContentChangeEvent
+): CodeChange {
   if (change.text.length === 0) {
-    return [new DeleteSourceChange(selection)];
-  }
-
-  if (selection.isEmpty) {
-    return [new AddSourceChange(selection.extendToCode(change.text))];
+    return {
+      type: "delete",
+      offset: change.rangeOffset,
+      length: change.rangeLength
+    };
   }
 
-  return [
-    new DeleteSourceChange(selection),
-    new AddSourceChange(
-      Selection.cursorAtPosition(selection.start).extendToCode(change.text)
-    )
-  ];
+  const selection = createSelectionFromVSCode(change.range);
+  return selection.isEmpty
+    ? { type: "add", offset: change.rangeOffset, text: change.text }
+    : {
+        type: "update",
+        offset: change.rangeOffset,
+        text: change.text,
+        length: change.rangeLength
+      };
 }
 
-function createSelectionFromVSCode(
+export function createSelectionFromVSCode(
   selection: vscode.Selection | vscode.Range
 ): Selection {
   return new Selection(
diff --git a/src/editor/editor.ts b/src/editor/editor.ts
index 5378ddcd..8418b7b1 100644
--- a/src/editor/editor.ts
+++ b/src/editor/editor.ts
@@ -71,3 +71,9 @@ export type SelectedPosition = Omit<
   }>,
   "description" | "icon"
 >;
+
+export type CodeChange =
+  | { type: "add"; offset: number; text: string }
+  | { type: "delete"; offset: number; length: number }
+  | { type: "update"; offset: number; length: number; text: string };
+
diff --git a/src/extension.ts b/src/extension.ts
index daf01378..9bac7084 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -2,7 +2,11 @@ import * as vscode from "vscode";
 
 import { RefactoringActionProvider } from "./action-providers";
 import { createCommand } from "./commands";
-import { VSCodeEditor } from "./editor/adapters/vscode-editor";
+import {
+  VSCodeEditor,
+  createSelectionFromVSCode,
+  getCodeChangeFromVSCode
+} from "./editor/adapters/vscode-editor";
 import refreshHighlights from "./highlights/refresh-highlights";
 import removeAllHighlights from "./highlights/remove-all-highlights";
 import toggleHighlight from "./highlights/toggle-highlight";
@@ -43,6 +47,7 @@ import splitMultipleDeclarations from "./refactorings/split-multiple-declaration
 import toggleBraces from "./refactorings/toggle-braces";
 import wrapInJsxFrament from "./refactorings/wrap-in-jsx-fragment";
 import { Refactoring, RefactoringWithActionProvider } from "./types";
+import { isInsertingVariableInStringLiteral } from "./refactorings/convert-to-template-literal/convert-to-template-literal";
 
 const refactorings: { [key: string]: ConfiguredRefactoring } = {
   typescriptOnly: {
@@ -169,8 +174,28 @@ export function activate(context: vscode.ExtensionContext) {
     VSCodeEditor.repositionHighlights(event);
 
     const activeTextEditor = vscode.window.activeTextEditor;
-    if (activeTextEditor) {
-      VSCodeEditor.restoreHighlightDecorations(activeTextEditor);
+    if (!activeTextEditor) return;
+
+    VSCodeEditor.restoreHighlightDecorations(activeTextEditor);
+
+    if (event.document !== activeTextEditor.document) return;
+
+    const changes = event.contentChanges.map(getCodeChangeFromVSCode);
+    const hasAddsOrUpdates = changes.some((change) => change.type !== "delete");
+    if (!hasAddsOrUpdates) return;
+
+    const code = activeTextEditor.document.getText();
+    const selection = createSelectionFromVSCode(activeTextEditor.selection);
+
+    const canAutoConvert =
+      vscode.workspace
+        .getConfiguration("abracadabra")
+        .get("autoConvertToTemplateLiteral") === true;
+
+    if (canAutoConvert && isInsertingVariableInStringLiteral(code, selection)) {
+      vscode.commands.executeCommand(
+        `abracadabra.${convertToTemplateLiteral.command.key}`
+      );
     }
   });
 }
diff --git a/src/refactorings/convert-to-template-literal/convert-to-template-literal.ts b/src/refactorings/convert-to-template-literal/convert-to-template-literal.ts
index 9e78be96..21605683 100644
--- a/src/refactorings/convert-to-template-literal/convert-to-template-literal.ts
+++ b/src/refactorings/convert-to-template-literal/convert-to-template-literal.ts
@@ -1,4 +1,5 @@
-import { Editor, ErrorReason } from "../../editor/editor";
+import { Code, Editor, ErrorReason } from "../../editor/editor";
+import { Position } from "../../editor/position";
 import { Selection } from "../../editor/selection";
 import * as t from "../../ast";
 
@@ -11,21 +12,32 @@ export async function convertToTemplateLiteral(editor: Editor) {
     return;
   }
 
-  await editor.write(updatedCode.code);
+  const newCode = updatedCode.code.replace(/\$\\{(\w*)}/g, "${$1}");
+  await editor.write(newCode);
+  await editor.moveCursorTo(updatedCode.newCursorPosition);
 }
 
-function updateCode(ast: t.AST, selection: Selection): t.Transformed {
-  return t.transformAST(
+function updateCode(
+  ast: t.AST,
+  selection: Selection
+): t.Transformed & { newCursorPosition: Position } {
+  let newCursorPosition = selection.start;
+
+  const transformedCode = t.transformAST(
     ast,
     createVisitor(selection, (path) => {
+      const sanitizedValue = path.node.value
+        .replace(/`/g, "\\`")
+        .replace(/{/g, "\\{");
       const templateLiteral = t.templateLiteral(
-        [t.templateElement(path.node.value.replace(/`/g, "\\`"))],
+        [t.templateElement(sanitizedValue)],
         []
       );
 
       if (t.isJSXAttribute(path.parentPath)) {
         // Case of <MyComponent prop="test" /> => <MyComponent prop={`test`} />
         path.replaceWith(t.jsxExpressionContainer(templateLiteral));
+        newCursorPosition = newCursorPosition.addCharacters(1);
       } else {
         t.replaceWithPreservingComments(path, templateLiteral);
       }
@@ -33,6 +45,8 @@ function updateCode(ast: t.AST, selection: Selection): t.Transformed {
       path.stop();
     })
   );
+
+  return { ...transformedCode, newCursorPosition };
 }
 
 export function createVisitor(
@@ -53,3 +67,25 @@ export function createVisitor(
     }
   };
 }
+
+export function isInsertingVariableInStringLiteral(
+  code: Code,
+  selection: Selection
+): boolean {
+  const lines = code.split("\n");
+  const currentLine = lines[selection.start.line] ?? "";
+  const previous2Chars =
+    currentLine[selection.start.character - 2] +
+    currentLine[selection.start.character - 1];
+  if (previous2Chars !== "${") return false;
+
+  const nextChar = currentLine[selection.start.character];
+  if (nextChar !== "}") return false;
+
+  let result = false;
+  t.traverseAST(
+    t.parse(code),
+    createVisitor(selection, () => (result = true))
+  );
+  return result;
+}
diff --git a/yarn.lock b/yarn.lock
index cbbe0551..33361268 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7001,7 +7001,7 @@ string-length@^4.0.1:
     char-regex "^1.0.2"
     strip-ansi "^6.0.0"
 
-"string-width-cjs@npm:string-width@^4.2.0":
+"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.2.3:
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -7019,15 +7019,6 @@ string-width@^1.0.1:
     is-fullwidth-code-point "^1.0.0"
     strip-ansi "^3.0.0"
 
-"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.2.3:
-  version "4.2.3"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
-  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
-  dependencies:
-    emoji-regex "^8.0.0"
-    is-fullwidth-code-point "^3.0.0"
-    strip-ansi "^6.0.1"
-
 string-width@^4.1.0, string-width@^4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5"
@@ -7085,7 +7076,7 @@ string_decoder@~1.1.1:
   dependencies:
     safe-buffer "~5.1.0"
 
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -7099,13 +7090,6 @@ strip-ansi@^3.0.0, strip-ansi@^3.0.1:
   dependencies:
     ansi-regex "^2.0.0"
 
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
-  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
-  dependencies:
-    ansi-regex "^5.0.1"
-
 strip-ansi@^7.0.1:
   version "7.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2"
@@ -7341,6 +7325,11 @@ ts-interface-checker@^0.1.9:
   resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699"
   integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
 
+ts-pattern@5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/ts-pattern/-/ts-pattern-5.1.1.tgz#600e0a3d23ab1b0eb30b7c897d0898e940504544"
+  integrity sha512-i+owkHr5RYdQxj8olUgRrqpiWH9x27PuWVfXwDmJ/n/CoF/SAa7WW1i2oUpPDMQpJ4U+bGRUcZkVq7i1m3zFCg==
+
 tslib@^1.9.0:
   version "1.13.0"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
@@ -7768,7 +7757,7 @@ workerpool@6.2.1:
   resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343"
   integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==
 
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -7786,15 +7775,6 @@ wrap-ansi@^6.2.0:
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
 
-wrap-ansi@^7.0.0:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
-  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
-  dependencies:
-    ansi-styles "^4.0.0"
-    string-width "^4.1.0"
-    strip-ansi "^6.0.0"
-
 wrap-ansi@^8.1.0:
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"


Test patch:
diff --git a/src/refactorings/convert-to-template-literal/convert-to-template-literal.test.ts b/src/refactorings/convert-to-template-literal/convert-to-template-literal.test.ts
index 4b0e274a..65e754bf 100644
--- a/src/refactorings/convert-to-template-literal/convert-to-template-literal.test.ts
+++ b/src/refactorings/convert-to-template-literal/convert-to-template-literal.test.ts
@@ -5,7 +5,8 @@ import { testEach } from "../../tests-helpers";
 import * as t from "../../ast";
 import {
   convertToTemplateLiteral,
-  createVisitor
+  createVisitor,
+  isInsertingVariableInStringLiteral
 } from "./convert-to-template-literal";
 
 describe("Convert To Template Literal", () => {
@@ -65,14 +66,24 @@ const lastName = "Doe";`
         code: "const a = 'Hello[cursor] `you`'",
         expected: "const a = `Hello \\`you\\``"
       },
+      {
+        description: "string literal with ${value}",
+        code: "const a = 'Hello[cursor] ${you} ${me2}'",
+        expected: "const a = `Hello ${you} ${me2}`"
+      },
+      {
+        description: "string literal with empty ${}",
+        code: "const a = 'Hello[cursor] ${}'",
+        expected: "const a = `Hello ${}`"
+      },
       {
         description: "preserves comments",
-        code: `const name = 
+        code: `const name =
   // leading comment
   [cursor]"Jane"
   // trailing comment
   ;`,
-        expected: `const name = 
+        expected: `const name =
   // leading comment
   \`Jane\`
   // trailing comment
@@ -118,6 +129,20 @@ const lastName = "Doe";`
     }
   );
 
+  it("should preserve cursor position when it converts a JSX prop", async () => {
+    const editor = new InMemoryEditor(
+      `<TestComponent testProp="t[cursor]est" />`
+    );
+
+    await convertToTemplateLiteral(editor);
+
+    const expectedEditor = new InMemoryEditor(
+      `<TestComponent testProp={\`t[cursor]est\`} />`
+    );
+    expect(editor.code).toBe(expectedEditor.code);
+    expect(editor.selection).toEqual(expectedEditor.selection);
+  });
+
   it("should show an error message if refactoring can't be made", async () => {
     const code = `// This is a comment, can't be refactored`;
     const editor = new InMemoryEditor(code);
@@ -129,4 +154,59 @@ const lastName = "Doe";`
       ErrorReason.DidNotFindStringToConvert
     );
   });
+
+  describe("isInsertingVariableInStringLiteral", () => {
+    it("should return true if selection is inside a ${} in a string literal", () => {
+      const editor = new InMemoryEditor(`const name = "Hello $\{[cursor]}";`);
+
+      const result = isInsertingVariableInStringLiteral(
+        editor.code,
+        editor.selection
+      );
+
+      expect(result).toBeTruthy();
+    });
+
+    it("should return true if selection is inside a ${} in a string with single-quote", () => {
+      const editor = new InMemoryEditor(`const name = 'Hello $\{[cursor]}';`);
+
+      const result = isInsertingVariableInStringLiteral(
+        editor.code,
+        editor.selection
+      );
+
+      expect(result).toBeTruthy();
+    });
+
+    it("should return false if selection after a ${ without a closing }", () => {
+      const editor = new InMemoryEditor(`const name = "Hello $\{[cursor]";`);
+
+      const result = isInsertingVariableInStringLiteral(
+        editor.code,
+        editor.selection
+      );
+
+      expect(result).toBeFalsy();
+    });
+
+    it("should return false if selection is not inside a ${}", () => {
+      const editor = new InMemoryEditor(`const name = "He[cursor]llo $\{}";`);
+
+      const result = isInsertingVariableInStringLiteral(
+        editor.code,
+        editor.selection
+      );
+
+      expect(result).toBeFalsy();
+    });
+
+    it("should return false if selection is invalid", () => {
+      const code = `const name = "Hello $\{}";`;
+      const selection = Selection.cursorAt(12345, 6789);
+
+      const result = isInsertingVariableInStringLiteral(code, selection);
+
+      expect(result).toBeFalsy();
+    });
+  });
 });