[Kotlin] Implement multi-dollar interpolation support for paste handler

KTIJ-30945

GitOrigin-RevId: 9e6d34ffc8af9733c151d81ed32e2c1adacd6c2c
This commit is contained in:
Pavel Kirpichenkov
2024-10-07 17:13:47 +03:00
committed by intellij-monorepo-bot
parent 8c164f9d79
commit fedf6094a8
139 changed files with 1186 additions and 232 deletions

View File

@@ -0,0 +1,256 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.kotlin.idea.codeinsights.impl.base
import com.intellij.openapi.util.text.StringUtil
import org.jetbrains.kotlin.psi.KtBlockStringTemplateEntry
import org.jetbrains.kotlin.psi.KtEscapeStringTemplateEntry
import org.jetbrains.kotlin.psi.KtLiteralStringTemplateEntry
import org.jetbrains.kotlin.psi.KtPsiFactory
import org.jetbrains.kotlin.psi.KtSimpleNameStringTemplateEntry
import org.jetbrains.kotlin.psi.KtStringTemplateEntry
import org.jetbrains.kotlin.psi.KtStringTemplateEntryWithExpression
import org.jetbrains.kotlin.psi.KtStringTemplateExpression
val dollarLiteralExpressions: Array<String> = arrayOf(
"'$'", "\"$\""
)
private object PrefixedStringTemplateUtils
/**
* Convert this string template to a new one with the specified interpolation prefix length.
* The content of the template does not change.
* If the template already has the required prefix, then it is returned as is.
* Otherwise, a new template will be created with all its interpolation entries converted to the new prefix.
* The function is not recursive — it doesn't update nested template expressions.
* In case the passed template contains syntax errors and cannot be recreated with a different prefix, it will be returned unchanged.
*/
fun KtStringTemplateExpression.changeInterpolationPrefix(
newPrefixLength: Int,
isSourceSingleQuoted: Boolean,
isDestinationSingleQuoted: Boolean,
onEntryUpdate: (StringTemplateEntryUpdateInfo) -> Unit = {},
): KtStringTemplateExpression {
require(newPrefixLength >= 0) { "Unexpected string template prefix length: $newPrefixLength" }
if (this.interpolationPrefix?.textLength == newPrefixLength) return this
val factory = KtPsiFactory(project)
val contentText = buildString {
for ((index, entry) in entries.withIndex()) {
val newPrefixLength = maxOf(newPrefixLength, 1)
val newEntries: List<EntryUpdateDiff> = when (entry) {
// prefix length 0 means non-prefixed string — its entries start with a single $
is KtStringTemplateEntryWithExpression -> {
val newEntry = entry.changePrefixLength(newPrefixLength).unescapeIfPossible(newPrefixLength)
listOf(entry.asOneToOneDiff(newEntry.text))
}
is KtLiteralStringTemplateEntry -> {
entry.escapeIfNecessary(newPrefixLength, isSourceSingleQuoted, isDestinationSingleQuoted)
}
is KtEscapeStringTemplateEntry -> {
listOf(entry.unescapeIfPossible(newPrefixLength)).map { unescapedEntry ->
entry.asOneToOneDiff(unescapedEntry.text)
}
}
else -> {
listOf(entry.asOneToOneDiff(entry.text))
}
}
val newText = newEntries.joinToString(separator = "") { it.newText }
append(newText)
if (newText != entry.text) {
onEntryUpdate(StringTemplateEntryUpdateInfo(index, entry, newEntries))
}
}
}
return if (newPrefixLength == 0) {
if (isDestinationSingleQuoted) {
factory.createStringTemplate(contentText)
} else {
// hack until KtPsiFactory has a triple-quoted template generation
factory.createExpression("\"\"\"$contentText\"\"\"") as KtStringTemplateExpression
}
} else {
factory.createMultiDollarStringTemplate(contentText, newPrefixLength, forceMultiQuoted = !isDestinationSingleQuoted)
}
}
class StringTemplateEntryUpdateInfo(
val index: Int,
val oldEntry: KtStringTemplateEntry,
val diffs: List<EntryUpdateDiff>,
) {
override fun toString(): String {
return """
"${oldEntry.text}"
${diffs.joinToString("\n")}
""".trimIndent()
}
}
class EntryUpdateDiff(
val oldRange: IntRange,
val oldText: String,
val newText: String,
) {
override fun toString(): String {
return """$oldRange: "$oldText" -> "$newText""""
}
}
internal fun KtStringTemplateEntry.asOneToOneDiff(newText: String): EntryUpdateDiff {
return EntryUpdateDiff(0..<textLength, text, newText)
}
fun KtStringTemplateEntryWithExpression.changePrefixLength(prefixLength: Int): KtStringTemplateEntryWithExpression {
require(prefixLength > 0) { "Unexpected string template prefix length: $prefixLength" }
val replacement = when (this) {
is KtSimpleNameStringTemplateEntry -> changePrefixLength(prefixLength)
is KtBlockStringTemplateEntry -> changePrefixLength(prefixLength)
else -> this
}
return replacement
}
fun KtBlockStringTemplateEntry.changePrefixLength(prefixLength: Int): KtStringTemplateEntryWithExpression {
require(prefixLength > 0) { "Unexpected string template entry prefix length: $prefixLength" }
val ktPsiFactory = KtPsiFactory(project)
val expression = this.expression
val replacement = if (expression != null) {
ktPsiFactory.createMultiDollarBlockStringTemplateEntry(
expression,
prefixLength = prefixLength,
)
} else {
// In case of incomplete code with no KtExpression inside the block, create a replacement from scratch
val prefix = "$".repeat(prefixLength)
val incompleteExpression = ktPsiFactory.createExpression("$prefix\"$prefix{}\"")
(incompleteExpression as KtStringTemplateExpression).entries.single() as KtBlockStringTemplateEntry
}
return replacement
}
fun KtSimpleNameStringTemplateEntry.changePrefixLength(prefixLength: Int): KtSimpleNameStringTemplateEntry {
require(prefixLength > 0) { "Unexpected string template entry prefix length: $prefixLength" }
val ktPsiFactory = KtPsiFactory(project)
return ktPsiFactory.createMultiDollarSimpleNameStringTemplateEntry(
expression?.text.orEmpty(),
prefixLength = prefixLength,
)
}
fun KtLiteralStringTemplateEntry.escapeIfNecessary(
newPrefixLength: Int,
isSourceSingleQuoted: Boolean,
isDestinationSingleQuoted: Boolean,
): List<EntryUpdateDiff> {
// relying on that $ literal PSI nodes are grouped and consist only of $ chars
if (text.all { ch -> ch == '$' }) {
return escapeDollarIfNecessary(newPrefixLength, isDestinationSingleQuoted)
}
if (!isSourceSingleQuoted && isDestinationSingleQuoted) return escapeSpecialCharacters()
return listOf(this.asOneToOneDiff(text))
}
/**
* If the literal string template entry doesn't contain $ chars or is safe to use with the [newPrefixLength], returns it as is.
* Otherwise, create a new literal entry with an escaped last $.
*/
private fun KtLiteralStringTemplateEntry.escapeDollarIfNecessary(
newPrefixLength: Int,
isDestinationSingleQuoted: Boolean,
): List<EntryUpdateDiff> {
val unchangedDiff = this.asOneToOneDiff(text)
if (textLength < newPrefixLength) return listOf(unchangedDiff)
val nextSibling = nextSibling as? KtLiteralStringTemplateEntry ?: return listOf(unchangedDiff)
if (!nextSibling.canBeConsideredIdentifierOrBlock()) return listOf(unchangedDiff)
val ktPsiFactory = KtPsiFactory(project)
val escapedDollar = if (isDestinationSingleQuoted) """\$""" else "${"$".repeat(newPrefixLength)}{'$'}"
val beforeLast = text.dropLast(1)
val escapedLastDollar = ktPsiFactory.createStringTemplate(escapedDollar).entries.singleOrNull() ?: return listOf(unchangedDiff)
return listOfNotNull(
beforeLast.takeIf { it.isNotEmpty() }?.let { EntryUpdateDiff(0..<textLength - 1, it, it) },
EntryUpdateDiff(textLength - 1..textLength, text.last().toString(), escapedLastDollar.text.orEmpty()),
)
}
fun KtLiteralStringTemplateEntry.escapeSpecialCharacters(): List<EntryUpdateDiff> {
val escaper = StringUtil.escaper(true, "\"")
var from = 0
var to = 0
var nextChunkBuilder = StringBuilder()
val diffs = mutableListOf<EntryUpdateDiff>()
fun dumpSimpleChunk() {
if (from < to) {
val simpleTextChunk = nextChunkBuilder.toString()
nextChunkBuilder = StringBuilder()
diffs.add(EntryUpdateDiff(from..<to, simpleTextChunk, simpleTextChunk))
}
}
for (char in text) {
val oldCharAsString = char.toString()
val escapedCharAsString = escaper.apply(oldCharAsString)
if (oldCharAsString == escapedCharAsString) {
to++
nextChunkBuilder.append(escapedCharAsString)
} else {
dumpSimpleChunk()
diffs.add(EntryUpdateDiff(to..<to + 1, oldCharAsString, escapedCharAsString))
to++
from = to
}
}
dumpSimpleChunk()
return diffs
}
fun KtStringTemplateEntry.unescapeIfPossible(newPrefixLength: Int): KtStringTemplateEntry {
fun previousDollarsCount(): Int {
if (this.prevSibling !is KtLiteralStringTemplateEntry) return 0
return this.prevSibling.text.takeLastWhile { it == '$' }.length
}
return when (this) {
is KtEscapeStringTemplateEntry -> {
if (this.unescapedValue != "$") return this
if (previousDollarsCount() + 1 >= newPrefixLength
&& (nextSibling as? KtLiteralStringTemplateEntry)?.canBeConsideredIdentifierOrBlock() == true
) return this
KtPsiFactory(project).createLiteralStringTemplateEntry("$")
}
is KtBlockStringTemplateEntry -> {
val expression = this.expression ?: return this
if (expression.text !in dollarLiteralExpressions) return this
KtPsiFactory(project).createLiteralStringTemplateEntry("$")
}
else -> this
}
}
/**
* ```
* Identifier
* : (Letter | '_') (Letter | '_' | UnicodeDigit)*
* | '`' ~([\r\n] | '`')+ '`'
* ;
* ```
*
* The function can give false positives in corner cases when a backtick after `$` has no matching closing backtick.
* This tradeoff allows avoiding potentially complicated and error-prone text searches in the file.
* The closing backtick is not limited by the same string template and can come from various places of the PSI tree.
* E.g., in the following case there are two backticks in different expressions that would be part of one string without escaping.
* ```
* println("\$`"); println("`")
* ```
*/
fun KtLiteralStringTemplateEntry.canBeConsideredIdentifierOrBlock(): Boolean {
val firstChar = text.firstOrNull() ?: return false
return firstChar.isLetter() || firstChar == '_' || firstChar == '{' || firstChar == '`'
}

View File

@@ -0,0 +1,135 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.kotlin.idea.codeinsights.impl.base
import com.intellij.openapi.util.text.LineTokenizer
import org.jetbrains.annotations.TestOnly
import org.jetbrains.kotlin.lexer.KotlinLexer
import org.jetbrains.kotlin.lexer.KtTokens
import org.jetbrains.kotlin.psi.KtStringTemplateEntry
import kotlin.text.isNotEmpty
class TemplateTokenSequence(
private val inputString: String,
interpolationPrefixLength: Int,
) : Sequence<TemplateChunk> {
val templatePrefix = "$".repeat(interpolationPrefixLength)
val entryPrefixLength = maxOf(templatePrefix.length, 1)
val entryPrefix = "$".repeat(entryPrefixLength)
private fun String.guessIsTemplateEntryStart(): Boolean = when {
startsWith("$entryPrefix{") -> {
true
}
length > entryPrefixLength && substring(0..< entryPrefixLength).all { it == '$' } -> {
val guessedIdentifier = substring(entryPrefixLength)
val tokenType = KotlinLexer().apply { start(guessedIdentifier) }.tokenType
tokenType == KtTokens.IDENTIFIER || tokenType == KtTokens.THIS_KEYWORD
}
else -> false
}
private fun findTemplateEntryEnd(input: String, from: Int): Int {
val wrapped = """$templatePrefix"${input.substring(from)}""""
val lexer = KotlinLexer().apply { start(wrapped) }.apply { advance() }
when (lexer.tokenType) {
KtTokens.SHORT_TEMPLATE_ENTRY_START -> {
lexer.advance()
val tokenType = lexer.tokenType
return if (tokenType == KtTokens.IDENTIFIER || tokenType == KtTokens.THIS_KEYWORD) {
from + lexer.tokenEnd - 1
} else {
-1
}
}
KtTokens.LONG_TEMPLATE_ENTRY_START -> {
var depth = 0
while (lexer.tokenType != null) {
if (lexer.tokenType == KtTokens.LONG_TEMPLATE_ENTRY_START) {
depth++
} else if (lexer.tokenType == KtTokens.LONG_TEMPLATE_ENTRY_END) {
depth--
if (depth == 0) {
return from + lexer.currentPosition.offset
}
}
lexer.advance()
}
return -1
}
else -> return -1
}
}
private suspend fun SequenceScope<TemplateChunk>.yieldLiteral(chunk: String) {
val splitLines = LineTokenizer.tokenize(chunk, false, false)
for (i in splitLines.indices) {
if (i != 0) {
yield(NewLineChunk)
}
splitLines[i].takeIf { it.isNotEmpty() }?.let { yield(LiteralChunk(it)) }
}
}
private fun templateChunkIterator(): Iterator<TemplateChunk> {
return if (inputString.isEmpty()) emptySequence<TemplateChunk>().iterator()
else
iterator {
val likeStackTrace = isAStackTrace(inputString)
var from = 0
var to = 0
while (to < inputString.length) {
val c = inputString[to]
if (c == '\\') {
to += 1
if (to < inputString.length) to += 1
continue
} else if (c == '$') {
val substring = inputString.substring(to)
val guessIsTemplateEntryStart = !likeStackTrace && substring.guessIsTemplateEntryStart()
if (guessIsTemplateEntryStart) {
if (from < to) yieldLiteral(inputString.substring(from until to))
from = to
to = findTemplateEntryEnd(inputString, from)
if (to != -1) {
yield(EntryChunk(inputString.substring(from until to)))
} else {
to = inputString.length
yieldLiteral(inputString.substring(from until to))
}
from = to
continue
}
}
to++
}
if (from < to) {
yieldLiteral(inputString.substring(from until to))
}
}
}
override fun iterator(): Iterator<TemplateChunk> = templateChunkIterator()
private fun isAStackTrace(string: String): Boolean =
stacktracePlaceRegex.containsMatchIn(string)
private val stacktracePlaceRegex = Regex("\\(\\w+\\.\\w+:\\d+\\)")
}
sealed class TemplateChunk
data class LiteralChunk(val text: String) : TemplateChunk()
data class EntryChunk(val text: String) : TemplateChunk()
object NewLineChunk : TemplateChunk()
@TestOnly
fun createTemplateSequenceTokenString(input: String, prefixLength: Int): String {
return TemplateTokenSequence(input, prefixLength).map {
when (it) {
is LiteralChunk -> "LITERAL_CHUNK(${it.text})"
is EntryChunk -> "ENTRY_CHUNK(${it.text})"
is NewLineChunk -> "NEW_LINE()"
}
}.joinToString(separator = "")
}

View File

@@ -2,6 +2,9 @@
package org.jetbrains.kotlin.idea.k2.codeinsight.intentions.multiDollarStrings
import org.jetbrains.kotlin.idea.codeinsights.impl.base.canBeConsideredIdentifierOrBlock
import org.jetbrains.kotlin.idea.codeinsights.impl.base.changePrefixLength
import org.jetbrains.kotlin.idea.codeinsights.impl.base.dollarLiteralExpressions
import org.jetbrains.kotlin.psi.*
import org.jetbrains.kotlin.psi.psiUtil.isSingleQuoted
import org.jetbrains.kotlin.psi.psiUtil.plainContent
@@ -10,9 +13,6 @@ private const val DEFAULT_INTERPOLATION_PREFIX_LENGTH: Int = 2
private const val INTERPOLATION_PREFIX_LENGTH_THRESHOLD: Int = 5
private const val DOLLAR: String = "$"
private val dollarLiteralExpressions: Array<String> = arrayOf(
"'$'", "\"$\""
)
/**
* Convert a string to a multi-dollar string, choosing an appropriate prefix length based on the string's content.
@@ -22,16 +22,14 @@ private val dollarLiteralExpressions: Array<String> = arrayOf(
internal fun convertToMultiDollarString(element: KtStringTemplateExpression): KtStringTemplateExpression {
require(element.interpolationPrefix == null) { "Can't convert the string which already has a prefix to multi-dollar string" }
val ktPsiFactory = KtPsiFactory(element.project)
val longestUnsafeDollarSequence = longestUnsafeDollarSequenceLength(element, threshold = INTERPOLATION_PREFIX_LENGTH_THRESHOLD)
val prefixLength = if (longestUnsafeDollarSequence in DEFAULT_INTERPOLATION_PREFIX_LENGTH..< INTERPOLATION_PREFIX_LENGTH_THRESHOLD)
longestUnsafeDollarSequence + 1 else DEFAULT_INTERPOLATION_PREFIX_LENGTH
replaceExpressionEntries(element, prefixLength, ktPsiFactory)
replaceExpressionEntries(element, prefixLength)
val replaced = element.replace(
ktPsiFactory.createMultiDollarStringTemplate(
KtPsiFactory(element.project).createMultiDollarStringTemplate(
content = element.plainContent,
prefixLength = prefixLength,
forceMultiQuoted = !element.isSingleQuoted(),
@@ -127,41 +125,14 @@ private fun KtBlockStringTemplateEntry.isSimplifiableInterpolatedDollar(): Boole
return this.expression?.text in dollarLiteralExpressions
}
private fun replaceExpressionEntries(stringTemplate: KtStringTemplateExpression, prefixLength: Int, ktPsiFactory: KtPsiFactory) {
private fun replaceExpressionEntries(stringTemplate: KtStringTemplateExpression, prefixLength: Int) {
for (entry in stringTemplate.entries) {
when (entry) {
is KtSimpleNameStringTemplateEntry -> replaceSimpleNameEntry(entry, prefixLength, ktPsiFactory)
is KtBlockStringTemplateEntry -> replaceBlockEntry(entry, prefixLength, ktPsiFactory)
if (entry is KtStringTemplateEntryWithExpression) {
entry.replace(entry.changePrefixLength(prefixLength))
}
}
}
private fun replaceSimpleNameEntry(simpleNameEntry: KtSimpleNameStringTemplateEntry, prefixLength: Int, ktPsiFactory: KtPsiFactory) {
simpleNameEntry.replace(
ktPsiFactory.createMultiDollarSimpleNameStringTemplateEntry(
simpleNameEntry.expression?.text.orEmpty(),
prefixLength = prefixLength,
)
)
}
private fun replaceBlockEntry(blockEntry: KtBlockStringTemplateEntry, prefixLength: Int, ktPsiFactory: KtPsiFactory) {
val blockExpression = blockEntry.expression
val replacement = if (blockExpression != null) {
ktPsiFactory.createMultiDollarBlockStringTemplateEntry(
blockExpression,
prefixLength = prefixLength,
)
} else {
// In case of incomplete code with no KtExpression inside the block, create a replacement from scratch
val prefix = DOLLAR.repeat(prefixLength)
val incompleteExpression = ktPsiFactory.createExpression("$prefix\"$prefix{}\"")
(incompleteExpression as KtStringTemplateExpression).entries.single()
}
blockEntry.replace(replacement)
}
private fun KtEscapeStringTemplateEntry.isEscapedDollar(): Boolean = unescapedValue == DOLLAR
/**
@@ -175,24 +146,3 @@ private fun KtStringTemplateEntry.isSafeToReplaceWithDollar(prefixLength: Int):
val trailingDollarsLength = prevSibling.text.takeLastWhile { it.toString() == DOLLAR }.length
return trailingDollarsLength + 1 < prefixLength
}
/**
* ```
* Identifier
* : (Letter | '_') (Letter | '_' | UnicodeDigit)*
* | '`' ~([\r\n] | '`')+ '`'
* ;
* ```
*
* The function can give false positives in corner cases when a backtick after `$` has no matching closing backtick.
* This tradeoff allows avoiding potentially complicated and error-prone text searches in the file.
* The closing backtick is not limited by the same string template and can come from various places of the PSI tree.
* E.g., in the following case there are two backticks in different expressions that would be part of one string without escaping.
* ```
* println("\$`"); println("`")
* ```
*/
private fun KtLiteralStringTemplateEntry.canBeConsideredIdentifierOrBlock(): Boolean {
val firstChar = text.firstOrNull() ?: return false
return firstChar.isLetter() || firstChar == '_' || firstChar == '{' || firstChar == '`'
}

View File

@@ -131,6 +131,7 @@
serviceImplementation="org.jetbrains.kotlin.idea.k2.codeinsight.K2NameValidatorProviderImpl"/>
<copyPastePostProcessor implementation="org.jetbrains.kotlin.idea.k2.codeinsight.copyPaste.KotlinCopyPasteReferenceProcessor"/>
<copyPastePostProcessor implementation="org.jetbrains.kotlin.idea.k2.codeinsight.copyPaste.KotlinCopyPasteStringTemplatePostProcessor"/>
<registryKey key="kotlin.k2.allow.constant.computation.on.EDT" defaultValue="true" description="When enabled, error about analysis on EDT is disabled"/>

View File

@@ -0,0 +1,279 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.kotlin.idea.k2.codeinsight.copyPaste
import com.intellij.codeInsight.editorActions.CopyPastePostProcessor
import com.intellij.codeInsight.editorActions.TextBlockTransferableData
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.diagnostic.ControlFlowException
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.RangeMarker
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Ref
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiFile
import com.intellij.psi.SmartPsiElementPointer
import com.intellij.psi.createSmartPointer
import com.intellij.psi.util.startOffset
import org.jetbrains.kotlin.analysis.utils.printer.parentOfType
import org.jetbrains.kotlin.idea.codeinsights.impl.base.EntryUpdateDiff
import org.jetbrains.kotlin.idea.codeinsights.impl.base.changeInterpolationPrefix
import org.jetbrains.kotlin.psi.*
import org.jetbrains.kotlin.psi.psiUtil.isSingleQuoted
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.Transferable
import java.awt.datatransfer.UnsupportedFlavorException
import java.io.IOException
internal class KotlinStringTemplateTransferableData(
val selectionDataForSelections: List<StringTemplateSelectionData>,
) : TextBlockTransferableData {
override fun getFlavor(): DataFlavor = dataFlavor
companion object {
val dataFlavor: DataFlavor by lazy {
val transferableDataJavaClass = KotlinStringTemplateTransferableData::class.java
DataFlavor(
DataFlavor.javaJVMLocalObjectMimeType + ";class=" + transferableDataJavaClass.name,
transferableDataJavaClass.simpleName,
transferableDataJavaClass.classLoader,
)
}
}
}
internal class StringTemplateSelectionData(
val range: TextRange,
val prefixLength: Int,
val selectedText: String,
val templateText: String,
val startOffsetRelativeToTemplate: Int,
val endOffsetRelativeToTemplate: Int,
val isSingleQuoted: Boolean,
)
internal val LOG = Logger.getInstance(KotlinCopyPasteStringTemplatePostProcessor::class.java)
/**
* Copy-paste post processor for handling transfers between Kotlin strings.
* Uses [Transferable] information to update pasted text with respect to different string quoting and interpolation prefixes.
*/
internal class KotlinCopyPasteStringTemplatePostProcessor : CopyPastePostProcessor<KotlinStringTemplateTransferableData>() {
override fun requiresAllDocumentsToBeCommitted(editor: Editor, project: Project): Boolean = false
override fun collectTransferableData(
file: PsiFile,
editor: Editor,
startOffsets: IntArray,
endOffsets: IntArray
): List<KotlinStringTemplateTransferableData?> {
if (file !is KtFile) return emptyList()
val templateSelectionData = startOffsets.zip(endOffsets).mapNotNull { (start, end) ->
createSelectionData(file, editor, start, end)
}
return listOf(KotlinStringTemplateTransferableData(templateSelectionData))
}
// pass info for selections that belong to one string template
private fun createSelectionData(file: KtFile, editor: Editor, start: Int, end: Int): StringTemplateSelectionData? {
val range = TextRange(start, end)
val startKtElement = file.findElementAt(start)?.parentOfType<KtStringTemplateEntry>(withSelf = true) ?: return null
val endKtElement = file.findElementAt(end - 1)?.parentOfType<KtStringTemplateEntry>(withSelf = true) ?: return null
val parentStringTemplate = startKtElement.parent as? KtStringTemplateExpression ?: return null
if (endKtElement.parent !== parentStringTemplate) return null
val selectedText = editor.document.getText(range)
val parentTemplateText = parentStringTemplate.text
val startOffsetRelative = start - parentStringTemplate.startOffset
val endOffsetRelative = end - parentStringTemplate.startOffset
return StringTemplateSelectionData(
range,
parentStringTemplate.interpolationPrefix?.textLength ?: 0,
selectedText,
parentTemplateText,
startOffsetRelative,
endOffsetRelative,
parentStringTemplate.isSingleQuoted(),
)
}
override fun extractTransferableData(content: Transferable): List<KotlinStringTemplateTransferableData?> {
if (content.isDataFlavorSupported(KotlinStringTemplateTransferableData.dataFlavor)) {
try {
return listOf(
content.getTransferData(KotlinStringTemplateTransferableData.dataFlavor) as KotlinStringTemplateTransferableData
)
} catch (_: IOException) { // fall through if transfer data is unavailable after the isDataFlavorSupported check
} catch (_: UnsupportedFlavorException) {
}
}
return emptyList()
}
/**
* In cases of the same prefix length and the same quotes return the original text unchanged.
* If only quotes change, use only preprocessor, though there are known poorly handled cases, such as `trimMargin`.
* When the prefix lengths are different:
* * update interpolation prefixes of the template and its entries
* * unescape `$` that no longer require escaping (prefix length increased)
* * escape `$` that now require escaping (prefix length decreased — certain old unescaped $ can be misinterpreted as entries)
* * handle escaped characters when pasting between strings with different quote lengths
*
* Multi-caret cases are not supported — preprocessor results are kept as-is with no specific expectations.
*/
override fun processTransferableData(
project: Project,
editor: Editor,
bounds: RangeMarker,
caretOffset: Int,
indented: Ref<in Boolean>,
values: List<KotlinStringTemplateTransferableData?>
) {
val stringTemplateData = values.singleOrNull() ?: return
val document = editor.document
val targetFile = PsiDocumentManager.getInstance(project).getPsiFile(editor.document) as? KtFile ?: return
val replacementResult = try {
prepareReplacement(targetFile, bounds, stringTemplateData)
} catch (e: Throwable) {
if (e is ControlFlowException) throw e
LOG.error(e)
ReplacementResult.KeepPreprocessed
}
val replacementText = when (replacementResult) {
is ReplacementResult.KeepPreprocessed -> return
is ReplacementResult.KeepOriginal -> replacementResult.originalText
is ReplacementResult.ReplaceWith -> replacementResult.updatedText
}
ApplicationManager.getApplication().runWriteAction {
document.replaceString(bounds.startOffset, bounds.endOffset, replacementText)
PsiDocumentManager.getInstance(project).commitDocument(document)
}
}
private sealed class ReplacementResult {
/**
* @see [org.jetbrains.kotlin.idea.editor.KotlinLiteralCopyPasteProcessor]
*/
object KeepPreprocessed : ReplacementResult()
class KeepOriginal(val originalText: String) : ReplacementResult()
class ReplaceWith(val updatedText: String) : ReplacementResult()
}
private fun prepareReplacement(
file: KtFile,
bounds: RangeMarker,
stringTemplateData: KotlinStringTemplateTransferableData
): ReplacementResult {
val startElement = file.findElementAt(bounds.startOffset)
// Keep preprocessor results when pasting outside string templates
val destinationStringTemplate = startElement?.parentOfType<KtStringTemplateExpression>(withSelf = true)
?: return ReplacementResult.KeepPreprocessed
// Don't try applying string-to-string pasting logic in multi-caret cases
val selectionData = stringTemplateData.selectionDataForSelections.singleOrNull() ?: return ReplacementResult.KeepPreprocessed
val destinationStringPrefixLength = destinationStringTemplate.interpolationPrefix?.textLength ?: 0
val originalStringPrefixLength = selectionData.prefixLength
val quotesChanged = selectionData.isSingleQuoted != destinationStringTemplate.isSingleQuoted()
// return the original text, reverting preprocessor changes, when prefix lengths and quotes are the same
// copy-pasting from "" to $"" or vise versa follows the same rule
if (destinationStringPrefixLength == originalStringPrefixLength
|| destinationStringPrefixLength + originalStringPrefixLength == 1
) {
if (quotesChanged) return ReplacementResult.KeepPreprocessed
else return ReplacementResult.KeepOriginal(selectionData.selectedText)
}
return prepareReplacementForDifferentPrefixes(file, selectionData, destinationStringTemplate)
}
private fun prepareReplacementForDifferentPrefixes(
file: KtFile,
selectionData: StringTemplateSelectionData,
destinationStringTemplate: KtStringTemplateExpression,
): ReplacementResult.ReplaceWith {
val originalTemplate = KtPsiFactory(file.project).createExpression(selectionData.templateText) as KtStringTemplateExpression
val nonContentAdjustment = calculateNonContentAdjustment(selectionData, destinationStringTemplate)
var startOffsetAdjustment = nonContentAdjustment
var endOffsetAdjustment = nonContentAdjustment
val replacedTemplate = originalTemplate.changeInterpolationPrefix(
newPrefixLength = destinationStringTemplate.interpolationPrefix?.textLength ?: 0,
isSourceSingleQuoted = originalTemplate.isSingleQuoted(),
isDestinationSingleQuoted = destinationStringTemplate.isSingleQuoted(),
) { updateInfo ->
updateInfo.diffs.forEach { diff ->
val adjustment = calculateSelectionAdjustmentsForEntryDiff(updateInfo.oldEntry, diff, selectionData)
startOffsetAdjustment += adjustment.start
endOffsetAdjustment += adjustment.end
}
}
val newText = replacedTemplate.text.substring(
selectionData.startOffsetRelativeToTemplate + startOffsetAdjustment,
selectionData.endOffsetRelativeToTemplate + endOffsetAdjustment
)
return ReplacementResult.ReplaceWith(newText)
}
private fun calculateNonContentAdjustment(
selectionData: StringTemplateSelectionData,
destinationStringTemplate: KtStringTemplateExpression,
): Int {
val originalStringPrefixLength = selectionData.prefixLength
val destinationStringPrefixLength = destinationStringTemplate.interpolationPrefix?.textLength ?: 0
val prefixLengthDiff = destinationStringPrefixLength - originalStringPrefixLength
val isSourceTemplateSingleQuoted = selectionData.isSingleQuoted
val isDestinationTemplateSingleQuoted = destinationStringTemplate.isSingleQuoted()
val quoteLengthDiff = when {
isSourceTemplateSingleQuoted && !isDestinationTemplateSingleQuoted -> 2
!isSourceTemplateSingleQuoted && isDestinationTemplateSingleQuoted -> -2
else -> 0
}
return prefixLengthDiff + quoteLengthDiff
}
private class OffsetAdjustments(
val start: Int,
val end: Int,
) {
override fun toString(): String = "(%+d,%+d)".format(start, end)
}
private fun calculateSelectionAdjustmentsForEntryDiff(
oldEntry: KtStringTemplateEntry,
diff: EntryUpdateDiff,
selectionData: StringTemplateSelectionData,
): OffsetAdjustments {
val range = diff.oldRange
val diffStartOffset = oldEntry.startOffsetInParent + range.first
val diffEndOffset = oldEntry.startOffsetInParent + range.last + 1
val textLengthDiff = diff.newText.length - diff.oldText.length
return when {
// the entire diff entry is to the left of the selection — add the whole text length difference to both borders
diffEndOffset <= selectionData.startOffsetRelativeToTemplate -> {
OffsetAdjustments(textLengthDiff, textLengthDiff)
}
// the entire diff entry is to the right of the selection — no changes needed
diffStartOffset >= selectionData.endOffsetRelativeToTemplate -> OffsetAdjustments(0, 0)
// one or both borders of the selection are inside the entry — adjust to include the whole replacement
else -> {
OffsetAdjustments(
// for the selection start: get rid of a partial start offset if present
-maxOf(selectionData.startOffsetRelativeToTemplate - diffStartOffset, 0),
// for the selection end: remove a possible partial selection and add text length diff
maxOf(diffEndOffset - selectionData.endOffsetRelativeToTemplate, 0) + textLengthDiff,
)
}
}
}
}

View File

@@ -0,0 +1,7 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.kotlin.idea.fir.copyPaste
import org.jetbrains.kotlin.idea.conversion.copy.AbstractLiteralKotlinToKotlinCopyPasteTest
abstract class AbstractFirKotlinToKotlinMultiDollarStringsCopyPasteTest : AbstractLiteralKotlinToKotlinCopyPasteTest()

View File

@@ -0,0 +1,232 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.kotlin.idea.fir.copyPaste;
import com.intellij.testFramework.TestDataPath;
import org.jetbrains.kotlin.idea.base.plugin.KotlinPluginMode;
import org.jetbrains.kotlin.idea.base.test.TestRoot;
import org.jetbrains.kotlin.idea.test.JUnit3RunnerWithInners;
import org.jetbrains.kotlin.idea.test.KotlinTestUtils;
import org.jetbrains.kotlin.test.TestMetadata;
import org.junit.runner.RunWith;
/**
* This class is generated by {@link org.jetbrains.kotlin.testGenerator.generator.TestGenerator}.
* DO NOT MODIFY MANUALLY.
*/
@SuppressWarnings("all")
@TestRoot("fir/tests")
@TestDataPath("$CONTENT_ROOT")
@RunWith(JUnit3RunnerWithInners.class)
@TestMetadata("../../idea/tests/testData/copyPaste/multiDollar")
public class FirKotlinToKotlinMultiDollarStringsCopyPasteTestGenerated extends AbstractFirKotlinToKotlinMultiDollarStringsCopyPasteTest {
@java.lang.Override
@org.jetbrains.annotations.NotNull
public final KotlinPluginMode getPluginMode() {
return KotlinPluginMode.K2;
}
private void runTest(String testDataFilePath) throws Exception {
KotlinTestUtils.runTest(this::doTest, this, testDataFilePath);
}
@TestMetadata("EscapeDollarMultiQuoted.kt")
public void testEscapeDollarMultiQuoted() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/EscapeDollarMultiQuoted.kt");
}
@TestMetadata("EscapeDollarSequenceMultiQuoted.kt")
public void testEscapeDollarSequenceMultiQuoted() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/EscapeDollarSequenceMultiQuoted.kt");
}
@TestMetadata("EscapeDollarSequenceSingleQuoted.kt")
public void testEscapeDollarSequenceSingleQuoted() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/EscapeDollarSequenceSingleQuoted.kt");
}
@TestMetadata("EscapeDollarSingleQuoted.kt")
public void testEscapeDollarSingleQuoted() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/EscapeDollarSingleQuoted.kt");
}
@TestMetadata("EscapeNotIfNotBeforeIdentifierMultiQuoted.kt")
public void testEscapeNotIfNotBeforeIdentifierMultiQuoted() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/EscapeNotIfNotBeforeIdentifierMultiQuoted.kt");
}
@TestMetadata("EscapeNotIfNotBeforeIdentifierSingleQuoted.kt")
public void testEscapeNotIfNotBeforeIdentifierSingleQuoted() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/EscapeNotIfNotBeforeIdentifierSingleQuoted.kt");
}
@TestMetadata("EscapedCharsInSingleQuotedToMultiQuoted.kt")
public void testEscapedCharsInSingleQuotedToMultiQuoted() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/EscapedCharsInSingleQuotedToMultiQuoted.kt");
}
@TestMetadata("FromPrefix2ToPrefix3EscapedLiterals.kt")
public void testFromPrefix2ToPrefix3EscapedLiterals() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromPrefix2ToPrefix3EscapedLiterals.kt");
}
@TestMetadata("FromPrefix2ToPrefix3LongTemplateEntry.kt")
public void testFromPrefix2ToPrefix3LongTemplateEntry() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromPrefix2ToPrefix3LongTemplateEntry.kt");
}
@TestMetadata("FromPrefix2ToPrefix3PlainText.kt")
public void testFromPrefix2ToPrefix3PlainText() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromPrefix2ToPrefix3PlainText.kt");
}
@TestMetadata("FromPrefix2ToPrefix3ShortNameEntry.kt")
public void testFromPrefix2ToPrefix3ShortNameEntry() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromPrefix2ToPrefix3ShortNameEntry.kt");
}
@TestMetadata("FromPrefix3ToPrefix2EscapedLiterals.kt")
public void testFromPrefix3ToPrefix2EscapedLiterals() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromPrefix3ToPrefix2EscapedLiterals.kt");
}
@TestMetadata("FromPrefix3ToPrefix2LongTemplateEntry.kt")
public void testFromPrefix3ToPrefix2LongTemplateEntry() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromPrefix3ToPrefix2LongTemplateEntry.kt");
}
@TestMetadata("FromPrefix3ToPrefix2PlainText.kt")
public void testFromPrefix3ToPrefix2PlainText() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromPrefix3ToPrefix2PlainText.kt");
}
@TestMetadata("FromPrefix3ToPrefix2ShortNameEntry.kt")
public void testFromPrefix3ToPrefix2ShortNameEntry() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromPrefix3ToPrefix2ShortNameEntry.kt");
}
@TestMetadata("FromPrefixedToPrefixedEscapedLiterals.kt")
public void testFromPrefixedToPrefixedEscapedLiterals() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromPrefixedToPrefixedEscapedLiterals.kt");
}
@TestMetadata("FromPrefixedToPrefixedLongTemplateEntry.kt")
public void testFromPrefixedToPrefixedLongTemplateEntry() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromPrefixedToPrefixedLongTemplateEntry.kt");
}
@TestMetadata("FromPrefixedToPrefixedPlainText.kt")
public void testFromPrefixedToPrefixedPlainText() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromPrefixedToPrefixedPlainText.kt");
}
@TestMetadata("FromPrefixedToPrefixedShortNameEntry.kt")
public void testFromPrefixedToPrefixedShortNameEntry() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromPrefixedToPrefixedShortNameEntry.kt");
}
@TestMetadata("FromPrefixedToSimpleDangerousDollar.kt")
public void testFromPrefixedToSimpleDangerousDollar() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromPrefixedToSimpleDangerousDollar.kt");
}
@TestMetadata("FromPrefixedToSimpleDangerousDollarLongPrefix.kt")
public void testFromPrefixedToSimpleDangerousDollarLongPrefix() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromPrefixedToSimpleDangerousDollarLongPrefix.kt");
}
@TestMetadata("FromPrefixedToSimpleEscapedLiterals.kt")
public void testFromPrefixedToSimpleEscapedLiterals() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromPrefixedToSimpleEscapedLiterals.kt");
}
@TestMetadata("FromPrefixedToSimpleLongTemplateEntry.kt")
public void testFromPrefixedToSimpleLongTemplateEntry() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromPrefixedToSimpleLongTemplateEntry.kt");
}
@TestMetadata("FromPrefixedToSimplePlainText.kt")
public void testFromPrefixedToSimplePlainText() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromPrefixedToSimplePlainText.kt");
}
@TestMetadata("FromPrefixedToSimpleShortNameEntry.kt")
public void testFromPrefixedToSimpleShortNameEntry() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromPrefixedToSimpleShortNameEntry.kt");
}
@TestMetadata("FromSimpleToPrefixedEscapedLiterals.kt")
public void testFromSimpleToPrefixedEscapedLiterals() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromSimpleToPrefixedEscapedLiterals.kt");
}
@TestMetadata("FromSimpleToPrefixedLongTemplateEntry.kt")
public void testFromSimpleToPrefixedLongTemplateEntry() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromSimpleToPrefixedLongTemplateEntry.kt");
}
@TestMetadata("FromSimpleToPrefixedPlainText.kt")
public void testFromSimpleToPrefixedPlainText() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromSimpleToPrefixedPlainText.kt");
}
@TestMetadata("FromSimpleToPrefixedShortNameEntry.kt")
public void testFromSimpleToPrefixedShortNameEntry() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/FromSimpleToPrefixedShortNameEntry.kt");
}
@TestMetadata("PrefixedRawToSimpleEscapeNonEntry.kt")
public void testPrefixedRawToSimpleEscapeNonEntry() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/PrefixedRawToSimpleEscapeNonEntry.kt");
}
@TestMetadata("UnescapeDollarSingleQuoteInsideSelection.kt")
public void testUnescapeDollarSingleQuoteInsideSelection() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/UnescapeDollarSingleQuoteInsideSelection.kt");
}
@TestMetadata("UnescapeDollarSingleQuoteSelectionAfter.kt")
public void testUnescapeDollarSingleQuoteSelectionAfter() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/UnescapeDollarSingleQuoteSelectionAfter.kt");
}
@TestMetadata("UnescapeDollarSingleQuoteSelectionBefore.kt")
public void testUnescapeDollarSingleQuoteSelectionBefore() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/UnescapeDollarSingleQuoteSelectionBefore.kt");
}
@TestMetadata("UnescapeDollarSingleSelectionEndBreaksEscaping.kt")
public void testUnescapeDollarSingleSelectionEndBreaksEscaping() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/UnescapeDollarSingleSelectionEndBreaksEscaping.kt");
}
@TestMetadata("UnescapeDollarSingleSelectionStartBreaksEscaping.kt")
public void testUnescapeDollarSingleSelectionStartBreaksEscaping() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/UnescapeDollarSingleSelectionStartBreaksEscaping.kt");
}
@TestMetadata("UnescapeDollarTripleQuoteInsideSelection.kt")
public void testUnescapeDollarTripleQuoteInsideSelection() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/UnescapeDollarTripleQuoteInsideSelection.kt");
}
@TestMetadata("UnescapeDollarTripleQuoteSelectionAfter.kt")
public void testUnescapeDollarTripleQuoteSelectionAfter() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/UnescapeDollarTripleQuoteSelectionAfter.kt");
}
@TestMetadata("UnescapeDollarTripleQuoteSelectionBefore.kt")
public void testUnescapeDollarTripleQuoteSelectionBefore() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/UnescapeDollarTripleQuoteSelectionBefore.kt");
}
@TestMetadata("UnescapeDollarTripleQuoteSelectionEndBreaksEscaping.kt")
public void testUnescapeDollarTripleQuoteSelectionEndBreaksEscaping() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/UnescapeDollarTripleQuoteSelectionEndBreaksEscaping.kt");
}
@TestMetadata("UnescapeDollarTripleQuoteSelectionStartBreaksEscaping.kt")
public void testUnescapeDollarTripleQuoteSelectionStartBreaksEscaping() throws Exception {
runTest("../../idea/tests/testData/copyPaste/multiDollar/UnescapeDollarTripleQuoteSelectionStartBreaksEscaping.kt");
}
}

View File

@@ -30,6 +30,11 @@ public class FirLiteralTextToKotlinCopyPasteTestGenerated extends AbstractFirLit
KotlinTestUtils.runTest(this::doTest, this, testDataFilePath);
}
@TestMetadata("AlreadyPrefixed.txt")
public void testAlreadyPrefixed() throws Exception {
runTest("../../idea/tests/testData/copyPaste/plainTextLiteral/AlreadyPrefixed.txt");
}
@TestMetadata("BrokenEntries.txt")
public void testBrokenEntries() throws Exception {
runTest("../../idea/tests/testData/copyPaste/plainTextLiteral/BrokenEntries.txt");
@@ -40,6 +45,11 @@ public class FirLiteralTextToKotlinCopyPasteTestGenerated extends AbstractFirLit
runTest("../../idea/tests/testData/copyPaste/plainTextLiteral/CustomTrimIndent.txt");
}
@TestMetadata("IsolatedDollarsToPrefixedString.txt")
public void testIsolatedDollarsToPrefixedString() throws Exception {
runTest("../../idea/tests/testData/copyPaste/plainTextLiteral/IsolatedDollarsToPrefixedString.txt");
}
@TestMetadata("MultiLine.txt")
public void testMultiLine() throws Exception {
runTest("../../idea/tests/testData/copyPaste/plainTextLiteral/MultiLine.txt");

View File

@@ -30,6 +30,11 @@ public class LiteralTextToKotlinCopyPasteTestGenerated extends AbstractLiteralTe
KotlinTestUtils.runTest(this::doTest, this, testDataFilePath);
}
@TestMetadata("AlreadyPrefixed.txt")
public void testAlreadyPrefixed() throws Exception {
runTest("testData/copyPaste/plainTextLiteral/AlreadyPrefixed.txt");
}
@TestMetadata("BrokenEntries.txt")
public void testBrokenEntries() throws Exception {
runTest("testData/copyPaste/plainTextLiteral/BrokenEntries.txt");
@@ -40,6 +45,11 @@ public class LiteralTextToKotlinCopyPasteTestGenerated extends AbstractLiteralTe
runTest("testData/copyPaste/plainTextLiteral/CustomTrimIndent.txt");
}
@TestMetadata("IsolatedDollarsToPrefixedString.txt")
public void testIsolatedDollarsToPrefixedString() throws Exception {
runTest("testData/copyPaste/plainTextLiteral/IsolatedDollarsToPrefixedString.txt");
}
@TestMetadata("MultiLine.txt")
public void testMultiLine() throws Exception {
runTest("testData/copyPaste/plainTextLiteral/MultiLine.txt");

View File

@@ -3,12 +3,13 @@
package org.jetbrains.kotlin.idea.editor
import junit.framework.TestCase
import org.jetbrains.kotlin.idea.codeinsights.impl.base.createTemplateSequenceTokenString
import org.junit.Assert
class TemplateTokenSequenceTest : TestCase() {
fun doTest(input: String, expected: String) {
val output = createTemplateSequenceTokenString(input)
val output = createTemplateSequenceTokenString(input, 0)
Assert.assertEquals("Unexpected template sequence output for $input: ", expected, output)
}

View File

@@ -0,0 +1 @@
val to = """start Foo${'$'}Bar end"""

View File

@@ -0,0 +1 @@
val from = $$"<selection>Foo$Bar</selection>"

View File

@@ -0,0 +1 @@
val to = """start <caret> end"""

View File

@@ -0,0 +1 @@
val to = """start Foo$$${'$'}Bar end"""

View File

@@ -0,0 +1 @@
val from = $$$$"<selection>Foo$$$Bar</selection>"

View File

@@ -0,0 +1 @@
val to = """start <caret> end"""

View File

@@ -0,0 +1 @@
val to = "start Foo$$\$Bar end"

View File

@@ -0,0 +1 @@
val from = $$$$"<selection>Foo$$$Bar</selection>"

View File

@@ -0,0 +1 @@
val to = "start <caret> end"

View File

@@ -0,0 +1 @@
val to = "start Foo\$Bar end"

View File

@@ -0,0 +1 @@
val from = $$"<selection>Foo$Bar</selection>"

View File

@@ -0,0 +1 @@
val to = "start <caret> end"

View File

@@ -0,0 +1 @@
val to = """start Foo$ Bar end"""

View File

@@ -0,0 +1 @@
val from = $$"<selection>Foo$ Bar</selection>"

View File

@@ -0,0 +1 @@
val to = """start <caret> end"""

View File

@@ -0,0 +1 @@
val from = $$"<selection>Foo$ Bar</selection>"

View File

@@ -0,0 +1 @@
val to = "start <caret> end"

View File

@@ -0,0 +1,5 @@
// KTIJ-31607
val to = """
|Foo\
Bar
""".trimMargin()

View File

@@ -0,0 +1 @@
val from = "<selection>Foo\\\t\nBar</selection>"

View File

@@ -0,0 +1,4 @@
// KTIJ-31607
val to = """
|<caret>
""".trimMargin()

View File

@@ -0,0 +1 @@
val to = $$$"start Lorem Ipsum \n $ \\ \t end"

View File

@@ -0,0 +1 @@
val from = $$"<selection>Lorem Ipsum \n $ \\ \t</selection> Dolor"

View File

@@ -0,0 +1 @@
val to = $$$"start <caret> end"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = $$$"start Lorem $$${i} Ipsum end"

View File

@@ -0,0 +1,2 @@
val i = 1
val from = $$"<selection>Lorem $${i} Ipsum</selection> Dolor"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = $$$"start <caret> end"

View File

@@ -0,0 +1 @@
val to = $$$"start Lorem Ipsum end"

View File

@@ -0,0 +1 @@
val from = $$"<selection>Lorem Ipsum</selection> Dolor"

View File

@@ -0,0 +1 @@
val to = $$$"start <caret> end"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = $$$"start Lorem $$$i Ipsum end"

View File

@@ -0,0 +1,2 @@
val i = 1
val from = $$"<selection>Lorem $$i Ipsum</selection> Dolor"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = $$$"start <caret> end"

View File

@@ -0,0 +1 @@
val to = $$"start Lorem Ipsum \n $ \\ \t end"

View File

@@ -0,0 +1 @@
val from = $$$"<selection>Lorem Ipsum \n $ \\ \t</selection> Dolor"

View File

@@ -0,0 +1 @@
val to = $$"start <caret> end"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = $$"start Lorem $${i} Ipsum end"

View File

@@ -0,0 +1,2 @@
val i = 1
val from = $$$"<selection>Lorem $$${i} Ipsum</selection> Dolor"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = $$"start <caret> end"

View File

@@ -0,0 +1 @@
val to = $$"start Lorem Ipsum end"

View File

@@ -0,0 +1 @@
val from = $$$"<selection>Lorem Ipsum</selection> Dolor"

View File

@@ -0,0 +1 @@
val to = $$"start <caret> end"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = $$"start Lorem $$i Ipsum end"

View File

@@ -0,0 +1,2 @@
val i = 1
val from = $$$"<selection>Lorem $$$i Ipsum</selection> Dolor"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = $$"start <caret> end"

View File

@@ -0,0 +1 @@
val to = $$"start Lorem Ipsum \n \$ \\ \t end"

View File

@@ -0,0 +1 @@
val from = $$"<selection>Lorem Ipsum \n \$ \\ \t</selection> Dolor"

View File

@@ -0,0 +1 @@
val to = $$"start <caret> end"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = $$"start Lorem $${i} Ipsum end"

View File

@@ -0,0 +1,2 @@
val i = 1
val from = $$"<selection>Lorem $${i} Ipsum</selection> Dolor"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = $$"start <caret> end"

View File

@@ -0,0 +1 @@
val to = $$"start Lorem Ipsum end"

View File

@@ -0,0 +1 @@
val from = $$"<selection>Lorem Ipsum</selection> Dolor"

View File

@@ -0,0 +1 @@
val to = $$"start <caret> end"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = $$"start Lorem $$i Ipsum end"

View File

@@ -0,0 +1,2 @@
val i = 1
val from = $$"<selection>Lorem $$i Ipsum</selection> Dolor"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = $$"start <caret> end"

View File

@@ -0,0 +1 @@
val target = "start df$foo Not\$Entry end"

View File

@@ -0,0 +1 @@
val source = $$"as<selection>df$$foo Not$Entry</selection> $$bar"

View File

@@ -0,0 +1 @@
val target = "start <caret> end"

View File

@@ -0,0 +1 @@
val target = $$"start df$$foo Not$$\$Entry end"

View File

@@ -0,0 +1 @@
val source = $$$$$"as<selection>df$$$$$foo Not$$$Entry</selection> $$$$$bar"

View File

@@ -0,0 +1 @@
val target = $$"start <caret> end"

View File

@@ -0,0 +1 @@
val to = "start Lorem Ipsum \n $ \\ \t end"

View File

@@ -0,0 +1 @@
val from = $$"<selection>Lorem Ipsum \n \$ \\ \t</selection> Dolor"

View File

@@ -0,0 +1 @@
val to = "start <caret> end"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = "start Lorem ${i} Ipsum end"

View File

@@ -0,0 +1,2 @@
val i = 1
val from = $$"<selection>Lorem $${i} Ipsum</selection> Dolor"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = "start <caret> end"

View File

@@ -0,0 +1 @@
val to = "start Lorem Ipsum end"

View File

@@ -0,0 +1 @@
val from = $$"<selection>Lorem Ipsum</selection> Dolor"

View File

@@ -0,0 +1 @@
val to = "start <caret> end"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = "start Lorem $i Ipsum end"

View File

@@ -0,0 +1,2 @@
val i = 1
val from = $$"<selection>Lorem $$i Ipsum</selection> Dolor"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = "start <caret> end"

View File

@@ -0,0 +1 @@
val to = $$"start Lorem Ipsum \n $ \\ \t end"

View File

@@ -0,0 +1 @@
val from = "<selection>Lorem Ipsum \n \$ \\ \t</selection> Dolor"

View File

@@ -0,0 +1 @@
val to = $$"start <caret> end"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = $$"start Lorem $${i} Ipsum end"

View File

@@ -0,0 +1,2 @@
val i = 1
val from = "<selection>Lorem ${i} Ipsum</selection> Dolor"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = $$"start <caret> end"

View File

@@ -0,0 +1 @@
val to = $$"start Lorem Ipsum end"

View File

@@ -0,0 +1 @@
val from = "<selection>Lorem Ipsum</selection> Dolor"

View File

@@ -0,0 +1 @@
val to = $$"start <caret> end"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = $$"start Lorem $$i Ipsum end"

View File

@@ -0,0 +1,2 @@
val i = 1
val from = "<selection>Lorem $i Ipsum</selection> Dolor"

View File

@@ -0,0 +1,2 @@
val i = 1
val to = $$"start <caret> end"

View File

@@ -0,0 +1 @@
val from = $$"""<selection>${11}</selection>"""

Some files were not shown because too many files have changed in this diff Show More