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