• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1import {
2    ApplicableRefactorInfo, BinaryExpression, BinaryOperator, copyTrailingAsLeadingComments, copyTrailingComments,
3    Debug, Diagnostics, emptyArray, Expression, factory, findAncestor, getLocaleSpecificMessage, getTextOfNode,
4    getTokenAtPosition, getTrailingCommentRanges, isBinaryExpression, isNoSubstitutionTemplateLiteral,
5    isParenthesizedExpression, isStringLiteral, isStringLiteralLike, isTemplateExpression, isTemplateHead,
6    isTemplateMiddle, map, Node, ParenthesizedExpression, RefactorContext, RefactorEditInfo, SourceFile, SyntaxKind,
7    TemplateHead, TemplateMiddle, TemplateSpan, TemplateTail, textChanges, Token,
8} from "../_namespaces/ts";
9import { registerRefactor } from "../_namespaces/ts.refactor";
10
11const refactorName = "Convert to template string";
12const refactorDescription = getLocaleSpecificMessage(Diagnostics.Convert_to_template_string);
13
14const convertStringAction = {
15    name: refactorName,
16    description: refactorDescription,
17    kind: "refactor.rewrite.string"
18};
19registerRefactor(refactorName, {
20    kinds: [convertStringAction.kind],
21    getEditsForAction: getRefactorEditsToConvertToTemplateString,
22    getAvailableActions: getRefactorActionsToConvertToTemplateString
23});
24
25function getRefactorActionsToConvertToTemplateString(context: RefactorContext): readonly ApplicableRefactorInfo[] {
26    const { file, startPosition } = context;
27    const node = getNodeOrParentOfParentheses(file, startPosition);
28    const maybeBinary = getParentBinaryExpression(node);
29    const refactorInfo: ApplicableRefactorInfo = { name: refactorName, description: refactorDescription, actions: [] };
30
31    if (isBinaryExpression(maybeBinary) && treeToArray(maybeBinary).isValidConcatenation) {
32        refactorInfo.actions.push(convertStringAction);
33        return [refactorInfo];
34    }
35    else if (context.preferences.provideRefactorNotApplicableReason) {
36        refactorInfo.actions.push({ ...convertStringAction,
37            notApplicableReason: getLocaleSpecificMessage(Diagnostics.Can_only_convert_string_concatenation)
38        });
39        return [refactorInfo];
40    }
41    return emptyArray;
42}
43
44function getNodeOrParentOfParentheses(file: SourceFile, startPosition: number) {
45    const node = getTokenAtPosition(file, startPosition);
46    const nestedBinary = getParentBinaryExpression(node);
47    const isNonStringBinary = !treeToArray(nestedBinary).isValidConcatenation;
48
49    if (
50        isNonStringBinary &&
51        isParenthesizedExpression(nestedBinary.parent) &&
52        isBinaryExpression(nestedBinary.parent.parent)
53    ) {
54        return nestedBinary.parent.parent;
55    }
56    return node;
57}
58
59function getRefactorEditsToConvertToTemplateString(context: RefactorContext, actionName: string): RefactorEditInfo | undefined {
60    const { file, startPosition } = context;
61    const node = getNodeOrParentOfParentheses(file, startPosition);
62
63    switch (actionName) {
64        case refactorDescription:
65            return { edits: getEditsForToTemplateLiteral(context, node) };
66        default:
67            return Debug.fail("invalid action");
68    }
69}
70
71function getEditsForToTemplateLiteral(context: RefactorContext, node: Node) {
72    const maybeBinary = getParentBinaryExpression(node);
73    const file = context.file;
74
75    const templateLiteral = nodesToTemplate(treeToArray(maybeBinary), file);
76    const trailingCommentRanges = getTrailingCommentRanges(file.text, maybeBinary.end);
77
78    if (trailingCommentRanges) {
79        const lastComment = trailingCommentRanges[trailingCommentRanges.length - 1];
80        const trailingRange = { pos: trailingCommentRanges[0].pos, end: lastComment.end };
81
82        // since suppressTrailingTrivia(maybeBinary) does not work, the trailing comment is removed manually
83        // otherwise it would have the trailing comment twice
84        return textChanges.ChangeTracker.with(context, t => {
85            t.deleteRange(file, trailingRange);
86            t.replaceNode(file, maybeBinary, templateLiteral);
87        });
88    }
89    else {
90        return textChanges.ChangeTracker.with(context, t => t.replaceNode(file, maybeBinary, templateLiteral));
91    }
92}
93
94function isNotEqualsOperator(node: BinaryExpression) {
95    return node.operatorToken.kind !== SyntaxKind.EqualsToken;
96}
97
98function getParentBinaryExpression(expr: Node) {
99    const container = findAncestor(expr.parent, n => {
100        switch (n.kind) {
101            case SyntaxKind.PropertyAccessExpression:
102            case SyntaxKind.ElementAccessExpression:
103                return false;
104            case SyntaxKind.TemplateExpression:
105            case SyntaxKind.BinaryExpression:
106                return !(isBinaryExpression(n.parent) && isNotEqualsOperator(n.parent));
107            default:
108                return "quit";
109        }
110    });
111
112    return (container || expr) as Expression;
113}
114
115function treeToArray(current: Expression) {
116    const loop = (current: Node): { nodes: Expression[], operators: Token<BinaryOperator>[], hasString: boolean, validOperators: boolean} => {
117        if (!isBinaryExpression(current)) {
118            return { nodes: [current as Expression], operators: [], validOperators: true,
119                     hasString: isStringLiteral(current) || isNoSubstitutionTemplateLiteral(current) };
120        }
121        const { nodes, operators, hasString: leftHasString, validOperators: leftOperatorValid } = loop(current.left);
122
123        if (!(leftHasString || isStringLiteral(current.right) || isTemplateExpression(current.right))) {
124            return { nodes: [current], operators: [], hasString: false, validOperators: true };
125        }
126
127        const currentOperatorValid = current.operatorToken.kind === SyntaxKind.PlusToken;
128        const validOperators = leftOperatorValid && currentOperatorValid;
129
130        nodes.push(current.right);
131        operators.push(current.operatorToken);
132
133        return { nodes, operators, hasString: true, validOperators };
134    };
135    const { nodes, operators, validOperators, hasString } = loop(current);
136    return { nodes, operators, isValidConcatenation: validOperators && hasString };
137}
138
139// to copy comments following the operator
140// "foo" + /* comment */ "bar"
141const copyTrailingOperatorComments = (operators: Token<BinaryOperator>[], file: SourceFile) => (index: number, targetNode: Node) => {
142    if (index < operators.length) {
143         copyTrailingComments(operators[index], targetNode, file, SyntaxKind.MultiLineCommentTrivia, /* hasTrailingNewLine */ false);
144    }
145};
146
147// to copy comments following the string
148// "foo" /* comment */ + "bar" /* comment */ + "bar2"
149const copyCommentFromMultiNode = (nodes: readonly Expression[], file: SourceFile, copyOperatorComments: (index: number, targetNode: Node) => void) =>
150(indexes: number[], targetNode: Node) => {
151    while (indexes.length > 0) {
152        const index = indexes.shift()!;
153        copyTrailingComments(nodes[index], targetNode, file, SyntaxKind.MultiLineCommentTrivia, /* hasTrailingNewLine */ false);
154        copyOperatorComments(index, targetNode);
155    }
156};
157
158function escapeRawStringForTemplate(s: string) {
159    // Escaping for $s in strings that are to be used in template strings
160    // Naive implementation: replace \x by itself and otherwise $ and ` by \$ and \`.
161    // But to complicate it a bit, this should work for raw strings too.
162    return s.replace(/\\.|[$`]/g, m => m[0] === "\\" ? m : "\\" + m);
163    // Finally, a less-backslash-happy version can work too, doing only ${ instead of all $s:
164    //     s.replace(/\\.|\${|`/g, m => m[0] === "\\" ? m : "\\" + m);
165    // but `\$${foo}` is likely more clear than the more-confusing-but-still-working `$${foo}`.
166}
167
168function getRawTextOfTemplate(node: TemplateHead | TemplateMiddle | TemplateTail) {
169    // in these cases the right side is ${
170    const rightShaving = isTemplateHead(node) || isTemplateMiddle(node) ? -2 : -1;
171    return getTextOfNode(node).slice(1, rightShaving);
172}
173
174function concatConsecutiveString(index: number, nodes: readonly Expression[]): [nextIndex: number, text: string, rawText: string, usedIndexes: number[]] {
175    const indexes = [];
176    let text = "", rawText = "";
177    while (index < nodes.length) {
178        const node = nodes[index];
179        if (isStringLiteralLike(node)) { // includes isNoSubstitutionTemplateLiteral(node)
180            text += node.text;
181            rawText += escapeRawStringForTemplate(getTextOfNode(node).slice(1, -1));
182            indexes.push(index);
183            index++;
184        }
185        else if (isTemplateExpression(node)) {
186            text += node.head.text;
187            rawText += getRawTextOfTemplate(node.head);
188            break;
189        }
190        else {
191            break;
192        }
193    }
194    return [index, text, rawText, indexes];
195}
196
197function nodesToTemplate({ nodes, operators }: { nodes: readonly Expression[], operators: Token<BinaryOperator>[] }, file: SourceFile) {
198    const copyOperatorComments = copyTrailingOperatorComments(operators, file);
199    const copyCommentFromStringLiterals = copyCommentFromMultiNode(nodes, file, copyOperatorComments);
200    const [begin, headText, rawHeadText, headIndexes] = concatConsecutiveString(0, nodes);
201
202    if (begin === nodes.length) {
203        const noSubstitutionTemplateLiteral = factory.createNoSubstitutionTemplateLiteral(headText, rawHeadText);
204        copyCommentFromStringLiterals(headIndexes, noSubstitutionTemplateLiteral);
205        return noSubstitutionTemplateLiteral;
206    }
207
208    const templateSpans: TemplateSpan[] = [];
209    const templateHead = factory.createTemplateHead(headText, rawHeadText);
210    copyCommentFromStringLiterals(headIndexes, templateHead);
211
212    for (let i = begin; i < nodes.length; i++) {
213        const currentNode = getExpressionFromParenthesesOrExpression(nodes[i]);
214        copyOperatorComments(i, currentNode);
215
216        const [newIndex, subsequentText, rawSubsequentText, stringIndexes] = concatConsecutiveString(i + 1, nodes);
217        i = newIndex - 1;
218        const isLast = i === nodes.length - 1;
219
220        if (isTemplateExpression(currentNode)) {
221            const spans = map(currentNode.templateSpans, (span, index) => {
222                copyExpressionComments(span);
223                const isLastSpan = index === currentNode.templateSpans.length - 1;
224                const text = span.literal.text + (isLastSpan ? subsequentText : "");
225                const rawText = getRawTextOfTemplate(span.literal) + (isLastSpan ? rawSubsequentText : "");
226                return factory.createTemplateSpan(span.expression, isLast && isLastSpan
227                    ? factory.createTemplateTail(text, rawText)
228                    : factory.createTemplateMiddle(text, rawText));
229            });
230            templateSpans.push(...spans);
231        }
232        else {
233            const templatePart = isLast
234                ? factory.createTemplateTail(subsequentText, rawSubsequentText)
235                : factory.createTemplateMiddle(subsequentText, rawSubsequentText);
236            copyCommentFromStringLiterals(stringIndexes, templatePart);
237            templateSpans.push(factory.createTemplateSpan(currentNode, templatePart));
238        }
239    }
240
241    return factory.createTemplateExpression(templateHead, templateSpans);
242}
243
244// to copy comments following the opening & closing parentheses
245// "foo" + ( /* comment */ 5 + 5 ) /* comment */ + "bar"
246function copyExpressionComments(node: ParenthesizedExpression | TemplateSpan) {
247    const file = node.getSourceFile();
248    copyTrailingComments(node, node.expression, file, SyntaxKind.MultiLineCommentTrivia, /* hasTrailingNewLine */ false);
249    copyTrailingAsLeadingComments(node.expression, node.expression, file, SyntaxKind.MultiLineCommentTrivia, /* hasTrailingNewLine */ false);
250}
251
252function getExpressionFromParenthesesOrExpression(node: Expression) {
253    if (isParenthesizedExpression(node)) {
254        copyExpressionComments(node);
255        node = node.expression;
256    }
257    return node;
258}
259