Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Kotlin multiline (and single line) literals #90

Merged
merged 10 commits into from
Jan 10, 2024
113 changes: 97 additions & 16 deletions selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/guts/Literals.kt
Original file line number Diff line number Diff line change
Expand Up @@ -99,29 +99,49 @@ internal object LiteralLong : LiteralFormat<Long>() {
}

private const val TRIPLE_QUOTE = "\"\"\""
private const val KOTLIN_DOLLAR = "\${'\$'}"
private const val KOTLIN_DOLLARQUOTE = "\${'\"'}"

internal object LiteralString : LiteralFormat<String>() {
override fun encode(value: String, language: Language): String =
if (value.indexOf('\n') == -1)
when (language) {
Language.SCALA, // scala only does $ substitution for s" and f" strings
Language.JAVA_PRE15,
Language.JAVA -> singleLineJavaToSource(value)
Language.JAVA -> encodeSingleJava(value)
Language.GROOVY,
Language.SCALA,
Language.KOTLIN -> singleLineJavaToSource(value)
Language.KOTLIN -> encodeSingleJavaWithDollars(value)
}
else
when (language) {
Language.GROOVY,
Language.SCALA,
Language.JAVA_PRE15 -> singleLineJavaToSource(value)
Language.JAVA -> multiLineJavaToSource(value)
Language.KOTLIN -> multiLineJavaToSource(value)
Language.JAVA_PRE15 -> encodeSingleJava(value)
Language.JAVA -> encodeMultiJava(value)
Language.GROOVY,
Language.KOTLIN -> encodeMultiKotlin(value)
}
override fun parse(str: String, language: Language): String =
if (str.startsWith(TRIPLE_QUOTE)) multiLineJavaFromSource(str)
else singleLineJavaFromSource(str)
fun singleLineJavaToSource(value: String): String {
if (!str.startsWith(TRIPLE_QUOTE))
when (language) {
Language.SCALA,
Language.JAVA_PRE15,
Language.JAVA -> parseSingleJava(str)
Language.GROOVY,
Language.KOTLIN -> parseSingleJavaWithDollars(str)
}
else
when (language) {
Language.SCALA ->
throw UnsupportedOperationException(
"Selfie doesn't support triple-quoted strings in Scala")
Language.JAVA_PRE15,
Language.JAVA -> parseMultiJava(str)
Language.GROOVY,
Language.KOTLIN -> parseMultiKotlin(str)
}
fun encodeSingleJava(value: String): String = encodeSingleJavaish(value, false)
fun encodeSingleJavaWithDollars(value: String) = encodeSingleJavaish(value, true)
private fun encodeSingleJavaish(value: String, escapeDollars: Boolean): String {
val source = StringBuilder()
source.append("\"")
for (char in value) {
Expand All @@ -132,6 +152,7 @@ internal object LiteralString : LiteralFormat<String>() {
'\t' -> source.append("\\t")
'\"' -> source.append("\\\"")
'\\' -> source.append("\\\\")
'$' -> if (escapeDollars) source.append(KOTLIN_DOLLAR) else source.append('$')
else ->
if (isControlChar(char)) {
source.append("\\u")
Expand All @@ -147,7 +168,40 @@ internal object LiteralString : LiteralFormat<String>() {
private fun isControlChar(c: Char): Boolean {
return c in '\u0000'..'\u001F' || c == '\u007F'
}
fun multiLineJavaToSource(arg: String): String {
fun parseSingleJava(sourceWithQuotes: String) = parseSingleJavaish(sourceWithQuotes, false)
fun parseSingleJavaWithDollars(sourceWithQuotes: String) =
parseSingleJavaish(sourceWithQuotes, true)
private fun parseSingleJavaish(sourceWithQuotes: String, removeDollars: Boolean): String {
check(sourceWithQuotes.startsWith('"'))
check(sourceWithQuotes.endsWith('"'))
val source = sourceWithQuotes.substring(1, sourceWithQuotes.length - 1)
val toUnescape = if (removeDollars) inlineDollars(source) else source
return unescapeJava(toUnescape)
}
fun encodeMultiKotlin(arg: String): String {
val escapeDollars = arg.replace("$", KOTLIN_DOLLAR)
val escapeTripleQuotes =
escapeDollars.replace(
TRIPLE_QUOTE, "$KOTLIN_DOLLARQUOTE$KOTLIN_DOLLARQUOTE$KOTLIN_DOLLARQUOTE")
val protectWhitespace =
escapeTripleQuotes.lines().joinToString("\n") { line ->
val protectTrailingWhitespace =
if (line.endsWith(" ")) {
line.dropLast(1) + "\${' '}"
} else if (line.endsWith("\t")) {
line.dropLast(1) + "\${'\\t'}"
} else line
val protectLeadingWhitespace =
if (protectTrailingWhitespace.startsWith(" ")) {
"\${' '}" + protectTrailingWhitespace.drop(1)
} else if (protectTrailingWhitespace.startsWith("\t")) {
"\${'\\t'}" + protectTrailingWhitespace.drop(1)
} else protectTrailingWhitespace
protectLeadingWhitespace
}
return "$TRIPLE_QUOTE$protectWhitespace$TRIPLE_QUOTE"
}
fun encodeMultiJava(arg: String): String {
val escapeBackslashes = arg.replace("\\", "\\\\")
val escapeTripleQuotes = escapeBackslashes.replace(TRIPLE_QUOTE, "\\\"\\\"\\\"")
val protectWhitespace =
Expand All @@ -168,10 +222,29 @@ internal object LiteralString : LiteralFormat<String>() {
}
return "$TRIPLE_QUOTE\n$protectWhitespace$TRIPLE_QUOTE"
}
fun singleLineJavaFromSource(sourceWithQuotes: String): String {
check(sourceWithQuotes.startsWith('"'))
check(sourceWithQuotes.endsWith('"'))
return unescapeJava(sourceWithQuotes.substring(1, sourceWithQuotes.length - 1))
private val charLiteralRegex = """\$\{'(\\?.)'\}""".toRegex()
private fun inlineDollars(source: String): String {
if (source.indexOf('$') == -1) {
return source
}
return charLiteralRegex.replace(source) { matchResult ->
val charLiteral = matchResult.groupValues[1]
when {
charLiteral.length == 1 -> charLiteral
charLiteral.length == 2 && charLiteral[0] == '\\' ->
when (charLiteral[1]) {
't' -> "\t"
'b' -> "\b"
'n' -> "\n"
'r' -> "\r"
'\'' -> "'"
'\\' -> "\\"
'$' -> "$"
else -> charLiteral
}
else -> throw IllegalArgumentException("Unknown character literal $charLiteral")
}
}
}
private fun unescapeJava(source: String): String {
val firstEscape = source.indexOf('\\')
Expand Down Expand Up @@ -209,7 +282,7 @@ internal object LiteralString : LiteralFormat<String>() {
}
return value.toString()
}
fun multiLineJavaFromSource(sourceWithQuotes: String): String {
fun parseMultiJava(sourceWithQuotes: String): String {
check(sourceWithQuotes.startsWith("$TRIPLE_QUOTE\n"))
check(sourceWithQuotes.endsWith(TRIPLE_QUOTE))
val source =
Expand All @@ -233,6 +306,14 @@ internal object LiteralString : LiteralFormat<String>() {
}
}
}
fun parseMultiKotlin(sourceWithQuotes: String): String {
check(sourceWithQuotes.startsWith(TRIPLE_QUOTE))
check(sourceWithQuotes.endsWith(TRIPLE_QUOTE))
val source =
sourceWithQuotes.substring(
TRIPLE_QUOTE.length, sourceWithQuotes.length - TRIPLE_QUOTE.length)
return inlineDollars(source)
}
}

internal object LiteralBoolean : LiteralFormat<Boolean>() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,17 @@ class InlineWriteTracker : WriteTracker<CallLocation, LiteralValue<*>>() {
if (literalValue.expected != null) {
// if expected == null, it's a `toBe_TODO()`, so there's nothing to check
val content = SourceFile(layout.fs.name(file), layout.fs.fileRead(file))
val parsedValue = content.parseToBe(call.location.line).parseLiteral(literalValue.format)
val parsedValue =
try {
content.parseToBe(call.location.line).parseLiteral(literalValue.format)
} catch (e: Exception) {
throw AssertionError(
"Error while parsing the literal at ${call.location.ideLink(layout)}. Please report this error at https://github.com/diffplug/selfie",
e)
}
if (parsedValue != literalValue.expected) {
throw layout.fs.assertFailed(
"There is likely a bug in Selfie's literal parsing.",
"Selfie cannot modify the literal at ${call.location.ideLink(layout)} because Selfie has a parsing bug. Please report this error at https://github.com/diffplug/selfie",
literalValue.expected,
parsedValue)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright (C) 2024 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.diffplug.selfie.guts

import io.kotest.matchers.shouldBe
import kotlin.test.Test

class KotlinMultilineString {
@Test
fun newlines() {
"""first
""" shouldBe "first\n"
"""
second""" shouldBe "\nsecond"
}

@Test
fun indentation() {
"""
""" shouldBe "\n"
"""
""" shouldBe "\n "
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,48 +20,83 @@ import kotlin.test.Test

class LiteralStringTest {
@Test
fun singleLineJavaToSource() {
singleLineJavaToSource("1", "'1'")
singleLineJavaToSource("\\", "'\\\\'")
singleLineJavaToSource("1\n\tABC", "'1\\n\\tABC'")
fun encodeSingleJava() {
encodeSingleJava("1", "'1'")
encodeSingleJava("\\", "'\\\\'")
encodeSingleJava("1\n\tABC", "'1\\n\\tABC'")
}
private fun singleLineJavaToSource(value: String, expected: String) {
val actual = LiteralString.singleLineJavaToSource(value)
private fun encodeSingleJava(value: String, expected: String) {
val actual = LiteralString.encodeSingleJava(value)
actual shouldBe expected.replace("'", "\"")
}

@Test
fun multiLineJavaToSource() {
multiLineJavaToSource("1", "'''\n1'''")
multiLineJavaToSource("\\", "'''\n\\\\'''")
multiLineJavaToSource(" leading\ntrailing ", "'''\n" + "\\s leading\n" + "trailing \\s'''")
fun encodeSingleJavaWithDollars() {
encodeSingleJavaWithDollars("1", "`1`")
encodeSingleJavaWithDollars("\\", "`\\\\`")
encodeSingleJavaWithDollars("$", "`s{'s'}`".replace('s', '$'))
encodeSingleJavaWithDollars("1\n\tABC", "`1\\n\\tABC`")
}
private fun multiLineJavaToSource(value: String, expected: String) {
val actual = LiteralString.multiLineJavaToSource(value)
private fun encodeSingleJavaWithDollars(value: String, expected: String) {
val actual = LiteralString.encodeSingleJavaWithDollars(value)
actual shouldBe expected.replace("`", "\"")
}

@Test
fun encodeMultiJava() {
encodeMultiJava("1", "'''\n1'''")
encodeMultiJava("\\", "'''\n\\\\'''")
encodeMultiJava(" leading\ntrailing ", "'''\n" + "\\s leading\n" + "trailing \\s'''")
}
private fun encodeMultiJava(value: String, expected: String) {
val actual = LiteralString.encodeMultiJava(value)
actual shouldBe expected.replace("'", "\"")
}
private val KOTLIN_DOLLAR = "s{'s'}".replace('s', '$')

@Test
fun encodeMultiKotlin() {
encodeMultiKotlin("1", "```1```")
encodeMultiKotlin("$", "```$KOTLIN_DOLLAR```")
}
private fun encodeMultiKotlin(value: String, expected: String) {
val actual = LiteralString.encodeMultiKotlin(value)
actual shouldBe expected.replace("`", "\"")
}

@Test
fun parseSingleJava() {
parseSingleJava("1", "1")
parseSingleJava("\\\\", "\\")
parseSingleJava("1\\n\\tABC", "1\n\tABC")
}
private fun parseSingleJava(value: String, expected: String) {
val actual = LiteralString.parseSingleJava("\"${value.replace("'", "\"")}\"")
actual shouldBe expected
}

@Test
fun singleLineJavaFromSource() {
singleLineJavaFromSource("1", "1")
singleLineJavaFromSource("\\\\", "\\")
singleLineJavaFromSource("1\\n\\tABC", "1\n\tABC")
fun parseMultiJava() {
parseMultiJava("\n123\nabc", "123\nabc")
parseMultiJava("\n 123\n abc", "123\nabc")
parseMultiJava("\n 123 \n abc\t", "123\nabc")
parseMultiJava("\n 123 \n abc\t", "123\nabc")
parseMultiJava("\n 123 \\s\n abc\t\\s", "123 \nabc\t ")
}
private fun singleLineJavaFromSource(value: String, expected: String) {
val actual = LiteralString.singleLineJavaFromSource("\"${value.replace("'", "\"")}\"")
private fun parseMultiJava(value: String, expected: String) {
val actual = LiteralString.parseMultiJava("\"\"\"${value.replace("'", "\"")}\"\"\"")
actual shouldBe expected
}

@Test
fun multiLineJavaFromSource() {
multiLineJavaFromSource("\n123\nabc", "123\nabc")
multiLineJavaFromSource("\n 123\n abc", "123\nabc")
multiLineJavaFromSource("\n 123 \n abc\t", "123\nabc")
multiLineJavaFromSource("\n 123 \n abc\t", "123\nabc")
multiLineJavaFromSource("\n 123 \\s\n abc\t\\s", "123 \nabc\t ")
fun parseSingleJavaWithDollars() {
parseSingleJavaWithDollars("1", "1")
parseSingleJavaWithDollars("\\\\", "\\")
parseSingleJavaWithDollars("s{'s'}".replace('s', '$'), "$")
parseSingleJavaWithDollars("1\\n\\tABC", "1\n\tABC")
}
private fun multiLineJavaFromSource(value: String, expected: String) {
val actual = LiteralString.multiLineJavaFromSource("\"\"\"${value.replace("'", "\"")}\"\"\"")
private fun parseSingleJavaWithDollars(value: String, expected: String) {
val actual = LiteralString.parseSingleJavaWithDollars("\"${value}\"")
actual shouldBe expected
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright (C) 2024 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.diffplug.selfie.junit5

import kotlin.test.Test
import org.junit.jupiter.api.MethodOrderer
import org.junit.jupiter.api.Order
import org.junit.jupiter.api.TestMethodOrder
import org.junitpioneer.jupiter.DisableIfTestFails

@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
@DisableIfTestFails
class StringLiteralsKotlinTest : Harness("undertest-junit5") {
@Test @Order(1)
fun readFailsBecauseTodo() {
gradleReadSSFail()
}

@Test @Order(2)
fun writeSucceeds() {
gradleWriteSS()
}

@Test @Order(3)
fun nowReadSucceeds() {
gradleReadSS()
}

@Test @Order(4)
fun cleanup() {
ut_mirror().restoreFromGit()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ public void newlines() {
expectSelfie("\n\n\n").toBe_TODO();
}

@Test
public void escapableCharacters() {
expectSelfie(" ' \" $ ").toBe_TODO();
expectSelfie(" ' \" $ \n \"\"\"\"\"\"\"\"\"\t").toBe_TODO();
}

@Test
public void allOfIt() {
expectSelfie(" a\n" +
Expand Down
Loading