Skip to content

Commit

Permalink
Add support for Kotlin multiline (and single line) literals (#90)
Browse files Browse the repository at this point in the history
  • Loading branch information
nedtwigg authored Jan 10, 2024
2 parents 4e0b90d + 7b32e5f commit 5370842
Show file tree
Hide file tree
Showing 7 changed files with 289 additions and 44 deletions.
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

0 comments on commit 5370842

Please sign in to comment.