Skip to content

Commit 024b8c1

Browse files
Merge pull request #30565 from D0nGiovanni/m-template-literal-2
add refactoring: string concatenation to template literals
2 parents ded072e + ff4fa1f commit 024b8c1

File tree

25 files changed

+461
-0
lines changed

25 files changed

+461
-0
lines changed

src/compiler/diagnosticMessages.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5349,6 +5349,10 @@
53495349
"category": "Message",
53505350
"code": 95095
53515351
},
5352+
"Convert to template string": {
5353+
"category": "Message",
5354+
"code": 95096
5355+
},
53525356

53535357
"No value exists in scope for the shorthand property '{0}'. Either declare one or provide an initializer.": {
53545358
"category": "Error",
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/* @internal */
2+
namespace ts.refactor.convertStringOrTemplateLiteral {
3+
const refactorName = "Convert to template string";
4+
const refactorDescription = getLocaleSpecificMessage(Diagnostics.Convert_to_template_string);
5+
6+
registerRefactor(refactorName, { getEditsForAction, getAvailableActions });
7+
8+
function getAvailableActions(context: RefactorContext): readonly ApplicableRefactorInfo[] {
9+
const { file, startPosition } = context;
10+
const node = getNodeOrParentOfParentheses(file, startPosition);
11+
const maybeBinary = getParentBinaryExpression(node);
12+
const refactorInfo: ApplicableRefactorInfo = { name: refactorName, description: refactorDescription, actions: [] };
13+
14+
if ((isBinaryExpression(maybeBinary) || isStringLiteral(maybeBinary)) && isStringConcatenationValid(maybeBinary)) {
15+
refactorInfo.actions.push({ name: refactorName, description: refactorDescription });
16+
return [refactorInfo];
17+
}
18+
return emptyArray;
19+
}
20+
21+
function getNodeOrParentOfParentheses(file: SourceFile, startPosition: number) {
22+
const node = getTokenAtPosition(file, startPosition);
23+
const nestedBinary = getParentBinaryExpression(node);
24+
const isNonStringBinary = !isStringConcatenationValid(nestedBinary);
25+
26+
if (
27+
isNonStringBinary &&
28+
isParenthesizedExpression(nestedBinary.parent) &&
29+
isBinaryExpression(nestedBinary.parent.parent)
30+
) {
31+
return nestedBinary.parent.parent;
32+
}
33+
return node;
34+
}
35+
36+
function getEditsForAction(context: RefactorContext, actionName: string): RefactorEditInfo | undefined {
37+
const { file, startPosition } = context;
38+
const node = getNodeOrParentOfParentheses(file, startPosition);
39+
40+
switch (actionName) {
41+
case refactorDescription:
42+
return { edits: getEditsForToTemplateLiteral(context, node) };
43+
default:
44+
return Debug.fail("invalid action");
45+
}
46+
}
47+
48+
function getEditsForToTemplateLiteral(context: RefactorContext, node: Node) {
49+
const maybeBinary = getParentBinaryExpression(node);
50+
const file = context.file;
51+
52+
const templateLiteral = nodesToTemplate(treeToArray(maybeBinary), file);
53+
const trailingCommentRanges = getTrailingCommentRanges(file.text, maybeBinary.end);
54+
55+
if (trailingCommentRanges) {
56+
const lastComment = trailingCommentRanges[trailingCommentRanges.length - 1];
57+
const trailingRange = { pos: trailingCommentRanges[0].pos, end: lastComment.end };
58+
59+
// since suppressTrailingTrivia(maybeBinary) does not work, the trailing comment is removed manually
60+
// otherwise it would have the trailing comment twice
61+
return textChanges.ChangeTracker.with(context, t => {
62+
t.deleteRange(file, trailingRange);
63+
t.replaceNode(file, maybeBinary, templateLiteral);
64+
});
65+
}
66+
else {
67+
return textChanges.ChangeTracker.with(context, t => t.replaceNode(file, maybeBinary, templateLiteral));
68+
}
69+
}
70+
71+
function isNotEqualsOperator(node: BinaryExpression) {
72+
return node.operatorToken.kind !== SyntaxKind.EqualsToken;
73+
}
74+
75+
function getParentBinaryExpression(expr: Node) {
76+
while (isBinaryExpression(expr.parent) && isNotEqualsOperator(expr.parent)) {
77+
expr = expr.parent;
78+
}
79+
return expr;
80+
}
81+
82+
function isStringConcatenationValid(node: Node): boolean {
83+
const { containsString, areOperatorsValid } = treeToArray(node);
84+
return containsString && areOperatorsValid;
85+
}
86+
87+
function treeToArray(current: Node): { nodes: Expression[], operators: Token<BinaryOperator>[], containsString: boolean, areOperatorsValid: boolean} {
88+
if (isBinaryExpression(current)) {
89+
const { nodes, operators, containsString: leftHasString, areOperatorsValid: leftOperatorValid } = treeToArray(current.left);
90+
91+
if (!leftHasString && !isStringLiteral(current.right)) {
92+
return { nodes: [current], operators: [], containsString: false, areOperatorsValid: true };
93+
}
94+
95+
const currentOperatorValid = current.operatorToken.kind === SyntaxKind.PlusToken;
96+
const areOperatorsValid = leftOperatorValid && currentOperatorValid;
97+
98+
nodes.push(current.right);
99+
operators.push(current.operatorToken);
100+
101+
return { nodes, operators, containsString: true, areOperatorsValid };
102+
}
103+
104+
return { nodes: [current as Expression], operators: [], containsString: isStringLiteral(current), areOperatorsValid: true };
105+
}
106+
107+
// to copy comments following the operator
108+
// "foo" + /* comment */ "bar"
109+
const copyTrailingOperatorComments = (operators: Token<BinaryOperator>[], file: SourceFile) => (index: number, targetNode: Node) => {
110+
if (index < operators.length) {
111+
copyTrailingComments(operators[index], targetNode, file, SyntaxKind.MultiLineCommentTrivia, /* hasTrailingNewLine */ false);
112+
}
113+
};
114+
115+
// to copy comments following the string
116+
// "foo" /* comment */ + "bar" /* comment */ + "bar2"
117+
const copyCommentFromMultiNode = (nodes: readonly Expression[], file: SourceFile, copyOperatorComments: (index: number, targetNode: Node) => void) =>
118+
(indexes: number[], targetNode: Node) => {
119+
while (indexes.length > 0) {
120+
const index = indexes.shift()!;
121+
copyTrailingComments(nodes[index], targetNode, file, SyntaxKind.MultiLineCommentTrivia, /* hasTrailingNewLine */ false);
122+
copyOperatorComments(index, targetNode);
123+
}
124+
};
125+
126+
function concatConsecutiveString(index: number, nodes: readonly Expression[]): [number, string, number[]] {
127+
let text = "";
128+
const indexes = [];
129+
130+
while (index < nodes.length && isStringLiteral(nodes[index])) {
131+
const stringNode = nodes[index] as StringLiteral;
132+
text = text + stringNode.text;
133+
indexes.push(index);
134+
index++;
135+
}
136+
137+
text = escapeString(text);
138+
return [index, text, indexes];
139+
}
140+
141+
function nodesToTemplate({nodes, operators}: {nodes: readonly Expression[], operators: Token<BinaryOperator>[]}, file: SourceFile) {
142+
const copyOperatorComments = copyTrailingOperatorComments(operators, file);
143+
const copyCommentFromStringLiterals = copyCommentFromMultiNode(nodes, file, copyOperatorComments);
144+
const [begin, headText, headIndexes] = concatConsecutiveString(0, nodes);
145+
146+
if (begin === nodes.length) {
147+
const noSubstitutionTemplateLiteral = createNoSubstitutionTemplateLiteral(headText);
148+
copyCommentFromStringLiterals(headIndexes, noSubstitutionTemplateLiteral);
149+
return noSubstitutionTemplateLiteral;
150+
}
151+
152+
const templateSpans: TemplateSpan[] = [];
153+
const templateHead = createTemplateHead(headText);
154+
copyCommentFromStringLiterals(headIndexes, templateHead);
155+
156+
for (let i = begin; i < nodes.length; i++) {
157+
const currentNode = getExpressionFromParenthesesOrExpression(nodes[i]);
158+
copyOperatorComments(i, currentNode);
159+
160+
const [newIndex, subsequentText, stringIndexes] = concatConsecutiveString(i + 1, nodes);
161+
i = newIndex - 1;
162+
163+
const templatePart = i === nodes.length - 1 ? createTemplateTail(subsequentText) : createTemplateMiddle(subsequentText);
164+
copyCommentFromStringLiterals(stringIndexes, templatePart);
165+
templateSpans.push(createTemplateSpan(currentNode, templatePart));
166+
}
167+
168+
return createTemplateExpression(templateHead, templateSpans);
169+
}
170+
171+
// to copy comments following the opening & closing parentheses
172+
// "foo" + ( /* comment */ 5 + 5 ) /* comment */ + "bar"
173+
function copyCommentsWhenParenthesized(node: ParenthesizedExpression) {
174+
const file = node.getSourceFile();
175+
copyTrailingComments(node, node.expression, file, SyntaxKind.MultiLineCommentTrivia, /* hasTrailingNewLine */ false);
176+
copyTrailingAsLeadingComments(node.expression, node.expression, file, SyntaxKind.MultiLineCommentTrivia, /* hasTrailingNewLine */ false);
177+
}
178+
179+
function getExpressionFromParenthesesOrExpression(node: Expression) {
180+
if (isParenthesizedExpression(node)) {
181+
copyCommentsWhenParenthesized(node);
182+
node = node.expression;
183+
}
184+
return node;
185+
}
186+
}

src/services/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
"refactors/moveToNewFile.ts",
9696
"refactors/addOrRemoveBracesToArrowFunction.ts",
9797
"refactors/convertParamsToDestructuredObject.ts",
98+
"refactors/convertStringOrTemplateLiteral.ts",
9899
"services.ts",
99100
"breakpoints.ts",
100101
"transform.ts",
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/// <reference path='fourslash.ts' />
2+
3+
//// console.log("/*x*/f/*y*/oobar is " + 32 + " years old")
4+
5+
goTo.select("x", "y");
6+
edit.applyRefactor({
7+
refactorName: "Convert to template string",
8+
actionName: "Convert to template string",
9+
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
10+
newContent:
11+
`console.log(\`foobar is \${32} years old\`)`,
12+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/// <reference path='fourslash.ts' />
2+
3+
//// const foo = "/*x*/w/*y*/ith back`tick"
4+
5+
goTo.select("x", "y");
6+
edit.applyRefactor({
7+
refactorName: "Convert to template string",
8+
actionName: "Convert to template string",
9+
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
10+
newContent:
11+
"const foo = `with back\\`tick`",
12+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/// <reference path='fourslash.ts' />
2+
3+
//// const foo = "/*x*/f/*y*/oobar is " + (42 + 6) + " years old"
4+
5+
goTo.select("x", "y");
6+
edit.applyRefactor({
7+
refactorName: "Convert to template string",
8+
actionName: "Convert to template string",
9+
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
10+
newContent:
11+
`const foo = \`foobar is \${42 + 6} years old\``,
12+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/// <reference path='fourslash.ts' />
2+
3+
//// const foo = "/*x*/f/*y*/oobar is " + (42 + 6)
4+
5+
goTo.select("x", "y");
6+
edit.applyRefactor({
7+
refactorName: "Convert to template string",
8+
actionName: "Convert to template string",
9+
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
10+
newContent:
11+
"const foo = `foobar is \${42 + 6}`",
12+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/// <reference path='fourslash.ts' />
2+
3+
//// const foo = /* C0 */ /*x*/"/*y*/foo" /* C1 */ + " is" /* C2 */ + 42 /* C3 */ + " and bar" /* C4 */ + " is" /* C5 */ + 52/* C6 */
4+
5+
goTo.select("x", "y");
6+
edit.applyRefactor({
7+
refactorName: "Convert to template string",
8+
actionName: "Convert to template string",
9+
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
10+
newContent:
11+
"const foo = /* C0 */ `foo is\${ /* C1 */ /* C2 */42 /* C3 */} and bar is\${ /* C4 */ /* C5 */52 /* C6 */}`",
12+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/// <reference path='fourslash.ts' />
2+
3+
//// const foo = /* C0 */ /*x*/"/*y*/foo" + /* C1 */ " is" + /* C2 */ 42 + /* C3 */ " and bar" + /* C4 */ " is" + /* C5 */ 52/* C6 */
4+
5+
goTo.select("x", "y");
6+
edit.applyRefactor({
7+
refactorName: "Convert to template string",
8+
actionName: "Convert to template string",
9+
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
10+
newContent:
11+
"const foo = /* C0 */ `foo is\${ /* C1 */ /* C2 */42 /* C3 */} and bar is\${ /* C4 */ /* C5 */52 /* C6 */}`",
12+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/// <reference path='fourslash.ts' />
2+
3+
//// const foo = /* C0 */ /*x*/"/*y*/foo" /* C1 */ + " is"/* C2 */ /* C3 */
4+
5+
goTo.select("x", "y");
6+
edit.applyRefactor({
7+
refactorName: "Convert to template string",
8+
actionName: "Convert to template string",
9+
actionDescription: ts.Diagnostics.Convert_to_template_string.message,
10+
newContent:
11+
"const foo = /* C0 */ `foo is` /* C1 */ /* C2 */ /* C3 */",
12+
});

0 commit comments

Comments
 (0)