K2 intention: Handle convert-to-concatenated-string-intention

^KTIJ-22940 fixed

GitOrigin-RevId: 1644ca792280ad47ccba1674511cd1bc2ed18460
This commit is contained in:
aleksandrina-streltsova
2022-10-12 17:49:41 +00:00
committed by intellij-monorepo-bot
parent be665e781e
commit 63e8b16ace
17 changed files with 403 additions and 95 deletions
@@ -1452,6 +1452,7 @@ redundant.getter=Redundant getter
remove.redundant.if.text=Remove redundant 'if' statement
redundant.if.statement=Redundant 'if' statement
redundant.if.statement.analyzing.type=Analyzing condition type...
convert.to.concatenated.string.statement.analyzing.entry.type=Analyzing entry type...
delete.fix.family.name=Remove arrow
redundant.lambda.arrow=Redundant lambda arrow
remove.let.call=Remove 'let' call
@@ -0,0 +1,128 @@
// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package org.jetbrains.kotlin.idea.codeinsights.impl.base
import com.intellij.codeInsight.intention.LowPriorityAction
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.editor.Editor
import com.intellij.psi.PsiFile
import org.jetbrains.kotlin.idea.base.resources.KotlinBundle
import org.jetbrains.kotlin.idea.codeinsight.api.classic.intentions.SelfTargetingOffsetIndependentIntention
import org.jetbrains.kotlin.idea.util.application.runWriteActionIfPhysical
import org.jetbrains.kotlin.lexer.KtTokens
import org.jetbrains.kotlin.psi.*
abstract class ConvertToConcatenatedStringIntentionBase : SelfTargetingOffsetIndependentIntention<KtStringTemplateExpression>(
KtStringTemplateExpression::class.java,
KotlinBundle.lazyMessage("convert.template.to.concatenated.string")
), LowPriorityAction {
override fun isApplicableTo(element: KtStringTemplateExpression): Boolean {
if (element.lastChild.node.elementType != KtTokens.CLOSING_QUOTE) return false // not available for unclosed literal
return element.entries.any { it is KtStringTemplateEntryWithExpression }
}
override fun startInWriteAction(): Boolean = false
override fun getElementToMakeWritable(currentFile: PsiFile) = currentFile
override fun applyTo(element: KtStringTemplateExpression, editor: Editor?) {
val tripleQuoted = isTripleQuoted(element.text ?: error("Failed to get template expression's text"))
val quote = if (tripleQuoted) "\"\"\"" else "\""
val entries = element.entries.filterNot { it is KtStringTemplateEntryWithExpression && it.expression == null }
val convertFirstEntryExplicitly = (entries.firstOrNull() as? KtStringTemplateEntryWithExpression)?.expression?.let {
!checkIfExpressionIsStringFromModalView(it)
} ?: false
val entryTexts = entries.mapIndexed { index, entry ->
val entryText = entry.toSeparateString(quote, convertExplicitly = (index == 0) && convertFirstEntryExplicitly)
val entryIsString = entryText.startsWith(quote) && entryText.endsWith(quote) && entry.isStringLiteral()
entryText to entryIsString
}
// merge all consecutive string literals
val targetTexts = entryTexts.foldIndexed(mutableListOf<String>()) { index, texts, (currText, currIsString) ->
val prevIsString = entryTexts.getOrNull(index - 1)?.second ?: false
val nextIsString = entryTexts.getOrNull(index + 1)?.second ?: false
var textToBeMerged = currText
if (currIsString && nextIsString) textToBeMerged = textToBeMerged.removeSuffix(quote)
if (currIsString && prevIsString) {
textToBeMerged = textToBeMerged.removePrefix(quote)
texts[texts.lastIndex] += textToBeMerged
} else {
texts.add(textToBeMerged)
}
texts
}
val text = targetTexts.joinToString("+")
val replacement = KtPsiFactory(element).createExpression(text).safeDeparenthesizeOperands()
runWriteActionIfPhysical(element) {
element.replace(replacement)
}
}
private fun KtExpression.safeDeparenthesizeOperands(): KtExpression {
if (this is KtBinaryExpression) {
val deparenthesizedLeft = this.left!!.safeDeparenthesizeOperands()
val deparenthesizedRight = this.right!!.safeDeparenthesizeOperands()
return KtPsiFactory(this.project).createExpressionByPattern(
"$0+$1",
deparenthesizedLeft.text,
deparenthesizedRight.text
)
}
if (this is KtParenthesizedExpression && KtPsiUtil.areParenthesesUseless(this)) {
return KtPsiUtil.safeDeparenthesize(this, true)
}
return this
}
private fun KtStringTemplateEntry.isStringLiteral(): Boolean = expression == null || expression is KtStringTemplateExpression
private fun isTripleQuoted(str: String): Boolean = str.startsWith("\"\"\"") && str.endsWith("\"\"\"")
private fun KtStringTemplateEntry.toSeparateString(quote: String, convertExplicitly: Boolean): String {
if (this !is KtStringTemplateEntryWithExpression) return text.quote(quote)
val expression = expression!! // checked before
val text = if (needsParenthesis(expression))
"(${expression.text})"
else
expression.text
return if (convertExplicitly)
"$text.toString()"
else
text
}
private fun needsParenthesis(expression: KtExpression): Boolean = when (expression) {
is KtPostfixExpression -> false
is KtAnnotatedExpression,
is KtLabeledExpression,
is KtOperationExpression -> true
is KtIfExpression -> expression.`else` !is KtBlockExpression
else -> false
}
private fun String.quote(quote: String): String = quote + this + quote
private fun checkIfExpressionIsStringFromModalView(expression: KtExpression): Boolean {
return ActionUtil.underModalProgress(
expression.project,
KotlinBundle.message("convert.to.concatenated.string.statement.analyzing.entry.type")
) {
isExpressionOfStringType(expression)
}
}
/**
* Tells whether given [expression] is of the string type.
*
* Called from cancellable modal progress and under read action, so it's safe to use resolve here.
*/
abstract fun isExpressionOfStringType(expression: KtExpression): Boolean
}
@@ -83,5 +83,12 @@
<bundleName>messages.KotlinBundle</bundleName>
<categoryKey>group.names.kotlin</categoryKey>
</intentionAction>
<intentionAction>
<language>kotlin</language>
<className>org.jetbrains.kotlin.idea.k2.codeinsight.intentions.ConvertToConcatenatedStringIntention</className>
<bundleName>messages.KotlinBundle</bundleName>
<categoryKey>group.names.kotlin</categoryKey>
</intentionAction>
</extensions>
</idea-plugin>
@@ -0,0 +1,13 @@
// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package org.jetbrains.kotlin.idea.k2.codeinsight.intentions
import org.jetbrains.kotlin.analysis.api.analyze
import org.jetbrains.kotlin.idea.codeinsights.impl.base.ConvertToConcatenatedStringIntentionBase
import org.jetbrains.kotlin.psi.KtExpression
class ConvertToConcatenatedStringIntention : ConvertToConcatenatedStringIntentionBase() {
override fun isExpressionOfStringType(expression: KtExpression): Boolean = analyze(expression) {
expression.getKtType()?.isString == true
}
}
@@ -1150,6 +1150,199 @@ public abstract class K2IntentionTestGenerated extends AbstractK2IntentionTest {
}
}
@RunWith(JUnit3RunnerWithInners.class)
@TestMetadata("../../../idea/tests/testData/intentions/convertToConcatenatedString")
public static class ConvertToConcatenatedString extends AbstractK2IntentionTest {
private void runTest(String testDataFilePath) throws Exception {
KotlinTestUtils.runTest(this::doTest, this, testDataFilePath);
}
@TestMetadata("embeddedIf.kt")
public void testEmbeddedIf() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/embeddedIf.kt");
}
@TestMetadata("embeddedIfBraces.kt")
public void testEmbeddedIfBraces() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/embeddedIfBraces.kt");
}
@TestMetadata("embeddedIfSingle.kt")
public void testEmbeddedIfSingle() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/embeddedIfSingle.kt");
}
@TestMetadata("emptyBraces.kt")
public void testEmptyBraces() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/emptyBraces.kt");
}
@TestMetadata("handlesEscapeString.kt")
public void testHandlesEscapeString() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/handlesEscapeString.kt");
}
@TestMetadata("hasEmbeddedConcatenation.kt")
public void testHasEmbeddedConcatenation() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/hasEmbeddedConcatenation.kt");
}
@TestMetadata("hasEmbeddedHigherPrecedence.kt")
public void testHasEmbeddedHigherPrecedence() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/hasEmbeddedHigherPrecedence.kt");
}
@TestMetadata("hasEmbeddedLowerPrecedence.kt")
public void testHasEmbeddedLowerPrecedence() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/hasEmbeddedLowerPrecedence.kt");
}
@TestMetadata("hasEmptyTemplateEntry.kt")
public void testHasEmptyTemplateEntry() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/hasEmptyTemplateEntry.kt");
}
@TestMetadata("hasParenthesesInTemplateEntry.kt")
public void testHasParenthesesInTemplateEntry() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/hasParenthesesInTemplateEntry.kt");
}
@TestMetadata("hasPlusSign.kt")
public void testHasPlusSign() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/hasPlusSign.kt");
}
@TestMetadata("hasPlusSignInRawString.kt")
public void testHasPlusSignInRawString() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/hasPlusSignInRawString.kt");
}
@TestMetadata("interpolate2StringConstants.kt")
public void testInterpolate2StringConstants() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/interpolate2StringConstants.kt");
}
@TestMetadata("interpolate3.kt")
public void testInterpolate3() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/interpolate3.kt");
}
@TestMetadata("interpolateChar.kt")
public void testInterpolateChar() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/interpolateChar.kt");
}
@TestMetadata("interpolateConstants.kt")
public void testInterpolateConstants() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/interpolateConstants.kt");
}
@TestMetadata("interpolateMapAccess.kt")
public void testInterpolateMapAccess() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/interpolateMapAccess.kt");
}
@TestMetadata("interpolateMethodInvoke.kt")
public void testInterpolateMethodInvoke() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/interpolateMethodInvoke.kt");
}
@TestMetadata("interpolateSimpleWithBraces.kt")
public void testInterpolateSimpleWithBraces() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/interpolateSimpleWithBraces.kt");
}
@TestMetadata("interpolateStringWithFloat.kt")
public void testInterpolateStringWithFloat() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/interpolateStringWithFloat.kt");
}
@TestMetadata("interpolateStringWithInt.kt")
public void testInterpolateStringWithInt() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/interpolateStringWithInt.kt");
}
@TestMetadata("lastExprIsNamedExpression.kt")
public void testLastExprIsNamedExpression() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/lastExprIsNamedExpression.kt");
}
@TestMetadata("multilineString.kt")
public void testMultilineString() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/multilineString.kt");
}
@TestMetadata("multilineWhenExpr.kt")
public void testMultilineWhenExpr() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/multilineWhenExpr.kt");
}
@TestMetadata("namedExprBetweenConstants.kt")
public void testNamedExprBetweenConstants() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/namedExprBetweenConstants.kt");
}
@TestMetadata("notApplicableForErrorElement.kt")
public void testNotApplicableForErrorElement() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/notApplicableForErrorElement.kt");
}
@TestMetadata("notApplicableForSimple.kt")
public void testNotApplicableForSimple() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/notApplicableForSimple.kt");
}
@TestMetadata("notAvailableForDollarSignLiteral.kt")
public void testNotAvailableForDollarSignLiteral() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/notAvailableForDollarSignLiteral.kt");
}
@TestMetadata("singleVar.kt")
public void testSingleVar() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/singleVar.kt");
}
@TestMetadata("startsWithAnnotatedExpression.kt")
public void testStartsWithAnnotatedExpression() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/startsWithAnnotatedExpression.kt");
}
@TestMetadata("startsWithEmbeddedConcatenation.kt")
public void testStartsWithEmbeddedConcatenation() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/startsWithEmbeddedConcatenation.kt");
}
@TestMetadata("startsWithInt.kt")
public void testStartsWithInt() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/startsWithInt.kt");
}
@TestMetadata("startsWithPostfixExpression.kt")
public void testStartsWithPostfixExpression() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/startsWithPostfixExpression.kt");
}
@TestMetadata("startsWithStringExpression.kt")
public void testStartsWithStringExpression() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/startsWithStringExpression.kt");
}
@TestMetadata("stringTemplateInCurryBraces.kt")
public void testStringTemplateInCurryBraces() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/stringTemplateInCurryBraces.kt");
}
@TestMetadata("tricky.kt")
public void testTricky() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/tricky.kt");
}
@TestMetadata("withAndWithoutBraces.kt")
public void testWithAndWithoutBraces() throws Exception {
runTest("../../../idea/tests/testData/intentions/convertToConcatenatedString/withAndWithoutBraces.kt");
}
}
@RunWith(JUnit3RunnerWithInners.class)
@TestMetadata("testData/intentions")
public abstract static class Intentions extends AbstractK2IntentionTest {
@@ -2,101 +2,11 @@
package org.jetbrains.kotlin.idea.intentions
import com.intellij.codeInsight.intention.LowPriorityAction
import com.intellij.openapi.editor.Editor
import org.jetbrains.kotlin.builtins.KotlinBuiltIns
import org.jetbrains.kotlin.idea.base.resources.KotlinBundle
import org.jetbrains.kotlin.idea.caches.resolve.analyze
import org.jetbrains.kotlin.idea.codeinsight.api.classic.intentions.SelfTargetingOffsetIndependentIntention
import org.jetbrains.kotlin.lexer.KtTokens
import org.jetbrains.kotlin.psi.*
import org.jetbrains.kotlin.idea.codeinsights.impl.base.ConvertToConcatenatedStringIntentionBase
import org.jetbrains.kotlin.psi.KtExpression
class ConvertToConcatenatedStringIntention : SelfTargetingOffsetIndependentIntention<KtStringTemplateExpression>(
KtStringTemplateExpression::class.java,
KotlinBundle.lazyMessage("convert.template.to.concatenated.string")
), LowPriorityAction {
override fun isApplicableTo(element: KtStringTemplateExpression): Boolean {
if (element.lastChild.node.elementType != KtTokens.CLOSING_QUOTE) return false // not available for unclosed literal
return element.entries.any { it is KtStringTemplateEntryWithExpression }
}
override fun applyTo(element: KtStringTemplateExpression, editor: Editor?) {
val tripleQuoted = isTripleQuoted(element.text!!)
val quote = if (tripleQuoted) "\"\"\"" else "\""
val entries = element.entries
val targetEntries = entries
.filterNot { it is KtStringTemplateEntryWithExpression && it.expression == null }
.mapIndexed { index, entry -> entry to entry.toSeparateString(quote, isFirstEntry = (index == 0)) }
var numberOfOperands = 1
val text = buildString {
targetEntries.forEachIndexed { index, (entry, entryText) ->
var toBeAppended = entryText
val prevEntryText = targetEntries.getOrNull(index - 1)?.second
if (entryText.startsWith(quote) && prevEntryText?.endsWith(quote) == true && entry.isStringLiteral()) {
toBeAppended = toBeAppended.removePrefix(quote)
} else if (prevEntryText != null) {
append("+")
numberOfOperands++
}
val (nextEntry, nextEntryText) = targetEntries.getOrNull(index + 1) ?: (null to null)
if (entryText.endsWith(quote) && nextEntryText?.startsWith(quote) == true && nextEntry?.isStringLiteral() == true) {
toBeAppended = toBeAppended.removeSuffix(quote)
}
append(toBeAppended)
}
}
val replacement = KtPsiFactory(element).createExpression(text).safeDeparenthesizeOperands(numberOfOperands)
element.replace(replacement)
}
private fun KtExpression.safeDeparenthesizeOperands(numberOfOperands: Int): KtExpression {
if (numberOfOperands > 1 && this is KtBinaryExpression) {
val deparenthesizedLeft = this.left!!.safeDeparenthesizeOperands(numberOfOperands - 1)
val deparenthesizedRight = this.right!!.safeDeparenthesizeOperands(1)
return KtPsiFactory(this.project).createExpressionByPattern(
"$0+$1",
deparenthesizedLeft.text,
deparenthesizedRight.text
)
} else {
if (this is KtParenthesizedExpression && KtPsiUtil.areParenthesesUseless(this)) {
return this.expression ?: this
} else {
return this
}
}
}
private fun KtStringTemplateEntry.isStringLiteral() = expression == null || expression is KtStringTemplateExpression
private fun isTripleQuoted(str: String) = str.startsWith("\"\"\"") && str.endsWith("\"\"\"")
private fun KtStringTemplateEntry.toSeparateString(quote: String, isFirstEntry: Boolean): String {
if (this !is KtStringTemplateEntryWithExpression) return text.quote(quote)
val expression = expression!! // checked before
val text = if (needsParenthesis(expression))
"(" + expression.text + ")"
else
expression.text
return if (isFirstEntry && !expression.isStringExpression())
"$text.toString()"
else
text
}
private fun needsParenthesis(expression: KtExpression): Boolean = when (expression) {
is KtOperationExpression -> true
is KtIfExpression -> expression.`else` !is KtBlockExpression
else -> false
}
private fun String.quote(quote: String) = quote + this + quote
private fun KtExpression.isStringExpression() = KotlinBuiltIns.isString(analyze().getType(this))
}
class ConvertToConcatenatedStringIntention : ConvertToConcatenatedStringIntentionBase() {
override fun isExpressionOfStringType(expression: KtExpression): Boolean = KotlinBuiltIns.isString(expression.analyze().getType(expression))
}
@@ -8212,6 +8212,16 @@ public abstract class IntentionTestGenerated extends AbstractIntentionTest {
runTest("testData/intentions/convertToConcatenatedString/hasEmbeddedLowerPrecedence.kt");
}
@TestMetadata("hasEmptyTemplateEntry.kt")
public void testHasEmptyTemplateEntry() throws Exception {
runTest("testData/intentions/convertToConcatenatedString/hasEmptyTemplateEntry.kt");
}
@TestMetadata("hasParenthesesInTemplateEntry.kt")
public void testHasParenthesesInTemplateEntry() throws Exception {
runTest("testData/intentions/convertToConcatenatedString/hasParenthesesInTemplateEntry.kt");
}
@TestMetadata("hasPlusSign.kt")
public void testHasPlusSign() throws Exception {
runTest("testData/intentions/convertToConcatenatedString/hasPlusSign.kt");
@@ -8307,6 +8317,11 @@ public abstract class IntentionTestGenerated extends AbstractIntentionTest {
runTest("testData/intentions/convertToConcatenatedString/singleVar.kt");
}
@TestMetadata("startsWithAnnotatedExpression.kt")
public void testStartsWithAnnotatedExpression() throws Exception {
runTest("testData/intentions/convertToConcatenatedString/startsWithAnnotatedExpression.kt");
}
@TestMetadata("startsWithEmbeddedConcatenation.kt")
public void testStartsWithEmbeddedConcatenation() throws Exception {
runTest("testData/intentions/convertToConcatenatedString/startsWithEmbeddedConcatenation.kt");
@@ -8317,6 +8332,11 @@ public abstract class IntentionTestGenerated extends AbstractIntentionTest {
runTest("testData/intentions/convertToConcatenatedString/startsWithInt.kt");
}
@TestMetadata("startsWithPostfixExpression.kt")
public void testStartsWithPostfixExpression() throws Exception {
runTest("testData/intentions/convertToConcatenatedString/startsWithPostfixExpression.kt");
}
@TestMetadata("startsWithStringExpression.kt")
public void testStartsWithStringExpression() throws Exception {
runTest("testData/intentions/convertToConcatenatedString/startsWithStringExpression.kt");
@@ -0,0 +1 @@
org.jetbrains.kotlin.idea.k2.codeinsight.intentions.ConvertToConcatenatedStringIntention
@@ -0,0 +1,3 @@
fun test(): String {
return <caret>"${}${1 + 1}abc"
}
@@ -0,0 +1,3 @@
fun test(): String {
return (1 + 1).toString() + "abc"
}
@@ -0,0 +1,3 @@
fun test(): String {
return <caret>"${("a" + "b") + "c"}"
}
@@ -0,0 +1,3 @@
fun test(): String {
return ("a" + "b") + "c"
}
@@ -0,0 +1,7 @@
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.EXPRESSION)
annotation class Foo
fun test(): String {
return "<caret>${@Foo 1}a"
}
@@ -0,0 +1,7 @@
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.EXPRESSION)
annotation class Foo
fun test(): String {
return (@Foo 1).toString() + "a"
}
@@ -0,0 +1,4 @@
fun test(): String {
var l = 0
return "<caret>${l++}a$l"
}
@@ -0,0 +1,4 @@
fun test(): String {
var l = 0
return l++.toString() + "a" + l
}
@@ -21,6 +21,7 @@ internal fun MutableTWorkspace.generateK2IntentionTests() {
model("${idea}intentions/importMember", pattern = pattern)
model("${idea}intentions/convertToBlockBody", pattern = pattern)
model("${idea}intentions/addWhenRemainingBranches", pattern = pattern)
model("${idea}intentions/convertToConcatenatedString", pattern = pattern)
model("code-insight/intentions-k2/tests/testData/intentions", pattern = pattern)
}
}