diff --git a/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/guts/Literals.kt b/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/guts/Literals.kt index 7cae5bd3..ea863050 100644 --- a/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/guts/Literals.kt +++ b/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/guts/Literals.kt @@ -99,29 +99,49 @@ internal object LiteralLong : LiteralFormat() { } private const val TRIPLE_QUOTE = "\"\"\"" +private const val KOTLIN_DOLLAR = "\${'\$'}" +private const val KOTLIN_DOLLARQUOTE = "\${'\"'}" internal object LiteralString : LiteralFormat() { 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) { @@ -132,6 +152,7 @@ internal object LiteralString : LiteralFormat() { '\t' -> source.append("\\t") '\"' -> source.append("\\\"") '\\' -> source.append("\\\\") + '$' -> if (escapeDollars) source.append(KOTLIN_DOLLAR) else source.append('$') else -> if (isControlChar(char)) { source.append("\\u") @@ -147,7 +168,40 @@ internal object LiteralString : LiteralFormat() { 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 = @@ -168,10 +222,29 @@ internal object LiteralString : LiteralFormat() { } 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('\\') @@ -209,7 +282,7 @@ internal object LiteralString : LiteralFormat() { } 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 = @@ -233,6 +306,14 @@ internal object LiteralString : LiteralFormat() { } } } + 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() { diff --git a/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/guts/WriteTracker.kt b/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/guts/WriteTracker.kt index ba5e1ec4..bbd90469 100644 --- a/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/guts/WriteTracker.kt +++ b/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/guts/WriteTracker.kt @@ -87,10 +87,17 @@ class InlineWriteTracker : WriteTracker>() { 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) } diff --git a/selfie-lib/src/commonTest/kotlin/com/diffplug/selfie/guts/KotlinMultilineString.kt b/selfie-lib/src/commonTest/kotlin/com/diffplug/selfie/guts/KotlinMultilineString.kt new file mode 100644 index 00000000..4bf443b1 --- /dev/null +++ b/selfie-lib/src/commonTest/kotlin/com/diffplug/selfie/guts/KotlinMultilineString.kt @@ -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 " + } +} diff --git a/selfie-lib/src/commonTest/kotlin/com/diffplug/selfie/guts/LiteralStringTest.kt b/selfie-lib/src/commonTest/kotlin/com/diffplug/selfie/guts/LiteralStringTest.kt index 019860a3..e17814eb 100644 --- a/selfie-lib/src/commonTest/kotlin/com/diffplug/selfie/guts/LiteralStringTest.kt +++ b/selfie-lib/src/commonTest/kotlin/com/diffplug/selfie/guts/LiteralStringTest.kt @@ -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 } } diff --git a/selfie-runner-junit5/src/test/kotlin/com/diffplug/selfie/junit5/StringLiteralsKotlinTest.kt b/selfie-runner-junit5/src/test/kotlin/com/diffplug/selfie/junit5/StringLiteralsKotlinTest.kt new file mode 100644 index 00000000..a33c693c --- /dev/null +++ b/selfie-runner-junit5/src/test/kotlin/com/diffplug/selfie/junit5/StringLiteralsKotlinTest.kt @@ -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() + } +} diff --git a/undertest-junit5/src/test/java/undertest/junit5/UT_StringLiteralsJavaTest.java b/undertest-junit5/src/test/java/undertest/junit5/UT_StringLiteralsJavaTest.java index 44d2059d..e01eb708 100644 --- a/undertest-junit5/src/test/java/undertest/junit5/UT_StringLiteralsJavaTest.java +++ b/undertest-junit5/src/test/java/undertest/junit5/UT_StringLiteralsJavaTest.java @@ -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" + diff --git a/undertest-junit5/src/test/kotlin/undertest/junit5/UT_StringLiteralsKotlinTest.kt b/undertest-junit5/src/test/kotlin/undertest/junit5/UT_StringLiteralsKotlinTest.kt new file mode 100644 index 00000000..0e98b0a1 --- /dev/null +++ b/undertest-junit5/src/test/kotlin/undertest/junit5/UT_StringLiteralsKotlinTest.kt @@ -0,0 +1,33 @@ +package undertest.junit5 + +import com.diffplug.selfie.Selfie.expectSelfie +import org.junit.jupiter.api.Test + +class UT_StringLiteralsKotlinTest { + @Test fun empty() { + expectSelfie("").toBe_TODO() + } + + @Test fun tabs() { + expectSelfie("\t\t\t").toBe_TODO() + } + + @Test fun spaces() { + expectSelfie(" ").toBe_TODO() + } + + @Test fun newlines() { + expectSelfie("\n").toBe_TODO() + expectSelfie("\n\n").toBe_TODO() + expectSelfie("\n\n\n").toBe_TODO() + } + + @Test fun escapableCharacters() { + expectSelfie(" ' \" $ ").toBe_TODO() + expectSelfie(" ' \" $ \n \"\"\"\"\"\"\"\"\"\t").toBe_TODO() + } + + @Test fun allOfIt() { + expectSelfie(" a\n" + "a \n" + "\t a \t\n").toBe_TODO() + } +}