Skip to content

Commit

Permalink
Add support for multiline java (#87)
Browse files Browse the repository at this point in the history
  • Loading branch information
nedtwigg committed Jan 9, 2024
2 parents 707e747 + b48b6a1 commit 8237d79
Show file tree
Hide file tree
Showing 18 changed files with 263 additions and 82 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ enum class Mode {
readonly -> {
if (storage.sourceFileHasWritableComment(call)) {
val layout = storage.layout
val path = layout.sourcePathForCall(call.location)!!
val path = layout.sourcePathForCall(call.location)
val (comment, line) = CommentTracker.commentString(path, storage.fs)
throw storage.fs.assertFailed(
"Selfie is in readonly mode, so `$comment` is illegal at ${call.location.withLine(line).ideLink(layout)}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ package com.diffplug.selfie.guts

import kotlin.math.abs

internal expect fun jreVersion(): Int

enum class Language {
JAVA,
JAVA_PRE15,
Expand All @@ -28,7 +30,7 @@ enum class Language {
companion object {
fun fromFilename(filename: String): Language {
return when (filename.substringAfterLast('.')) {
"java" -> JAVA_PRE15 // TODO: detect JRE and use JAVA if JVM >= 15
"java" -> if (jreVersion() < 15) JAVA_PRE15 else JAVA
"kt" -> KOTLIN
"groovy",
"gvy",
Expand Down Expand Up @@ -101,14 +103,24 @@ internal object LiteralLong : LiteralFormat<Long>() {
}
}

private const val TRIPLE_QUOTE = "\"\"\""

internal object LiteralString : LiteralFormat<String>() {
override fun encode(value: String, language: Language): String {
return singleLineJavaToSource(value)
}
override fun parse(str: String, language: Language): String {
return singleLineJavaFromSource(str)
}
private fun singleLineJavaToSource(value: String): String {
override fun encode(value: String, language: Language): String =
if (value.indexOf('\n') == -1) singleLineJavaToSource(value)
else
when (language) {
Language.GROOVY,
Language.SCALA,
Language.CLOJURE,
Language.JAVA_PRE15 -> singleLineJavaToSource(value)
Language.JAVA -> multiLineJavaToSource(value)
Language.KOTLIN -> multiLineJavaToSource(value)
}
override fun parse(str: String, language: Language): String =
if (str.startsWith(TRIPLE_QUOTE)) multiLineJavaFromSource(str)
else singleLineJavaFromSource(str)
fun singleLineJavaToSource(value: String): String {
val source = StringBuilder()
source.append("\"")
for (char in value) {
Expand All @@ -134,34 +146,92 @@ internal object LiteralString : LiteralFormat<String>() {
private fun isControlChar(c: Char): Boolean {
return c in '\u0000'..'\u001F' || c == '\u007F'
}
private fun singleLineJavaFromSource(source: String): String {
fun multiLineJavaToSource(arg: String): String {
val escapeBackslashes = arg.replace("\\", "\\\\")
val escapeTripleQuotes = escapeBackslashes.replace(TRIPLE_QUOTE, "\\\"\\\"\\\"")
val protectWhitespace =
escapeTripleQuotes.lines().joinToString("\n") { line ->
val protectTrailingWhitespace =
if (line.endsWith(" ")) {
line.dropLast(1) + "\\s"
} else if (line.endsWith("\t")) {
line.dropLast(1) + "\\t"
} else line
val protectLeadingWhitespace =
if (protectTrailingWhitespace.startsWith(" ")) {
"\\s" + protectTrailingWhitespace.drop(1)
} else if (protectTrailingWhitespace.startsWith("\t")) {
"\\t" + protectTrailingWhitespace.drop(1)
} else protectTrailingWhitespace
protectLeadingWhitespace
}
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 fun unescapeJava(source: String): String {
val firstEscape = source.indexOf('\\')
if (firstEscape == -1) {
return source
}
val value = StringBuilder()
var i = 0
value.append(source.substring(0, firstEscape))
var i = firstEscape
while (i < source.length) {
var c = source[i]
if (c == '\\') {
i++
c = source[i]
when (c) {
'\"' -> value.append('\"')
'\\' -> value.append('\\')
'b' -> value.append('\b')
'f' -> value.append('\u000c')
'n' -> value.append('\n')
'r' -> value.append('\r')
's' -> value.append(' ')
't' -> value.append('\t')
'\"' -> value.append('\"')
'\\' -> value.append('\\')
'u' -> {
val code = source.substring(i + 1, i + 5).toInt(16)
value.append(code.toChar())
i += 4
}
else -> throw IllegalArgumentException("Unknown escape sequence $c")
}
} else if (c != '\"') {
} else {
value.append(c)
}
i++
}
return value.toString()
}
fun multiLineJavaFromSource(sourceWithQuotes: String): String {
check(sourceWithQuotes.startsWith("$TRIPLE_QUOTE\n"))
check(sourceWithQuotes.endsWith(TRIPLE_QUOTE))
val source =
sourceWithQuotes.substring(
TRIPLE_QUOTE.length + 1, sourceWithQuotes.length - TRIPLE_QUOTE.length)
val lines = source.lines()
val commonPrefix =
lines
.mapNotNull { line ->
if (line.isNotBlank()) line.takeWhile { it.isWhitespace() } else null
}
.minOrNull() ?: ""
return lines.joinToString("\n") { line ->
if (line.isBlank()) {
""
} else {
val removedPrefix = if (commonPrefix.isEmpty()) line else line.removePrefix(commonPrefix)
val removeTrailingWhitespace = removedPrefix.trimEnd()
val handleEscapeSequences = unescapeJava(removeTrailingWhitespace)
handleEscapeSequences
}
}
}
}

internal object LiteralBoolean : LiteralFormat<Boolean>() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,6 @@ interface SnapshotFileLayout {
val rootFolder: Path
val fs: FS
val allowMultipleEquivalentWritesToOneLocation: Boolean
fun sourcePathForCall(call: CallLocation): Path?
fun sourcePathForCall(call: CallLocation): Path
fun sourcePathForCallMaybe(call: CallLocation): Path?
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,7 @@ class InlineWriteTracker : WriteTracker<CallLocation, LiteralValue<*>>() {
recordInternal(call.location, literalValue, call, layout)
// assert that the value passed at runtime matches the value we parse at compile time
// because if that assert fails, we've got no business modifying test code
val file =
layout.sourcePathForCall(call.location)
?: throw Error("Unable to find source file for ${call.location.ideLink(layout)}")
val file = layout.sourcePathForCall(call.location)
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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,42 +20,48 @@ import kotlin.test.Test

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

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

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

@Test
fun decode() {
decode(
"""
"1"
"""
.trimIndent(),
"1")
decode(
"""
"1\n\tABC"
"""
.trimIndent(),
"1\n\tABC")
}
private fun decode(value: String, expected: String) {
val actual = LiteralString.parse(value, Language.JAVA)
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 ")
}
private fun multiLineJavaFromSource(value: String, expected: String) {
val actual = LiteralString.multiLineJavaFromSource("\"\"\"${value.replace("'", "\"")}\"\"\"")
actual shouldBe expected
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* 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

/**
* If the user is hip enough to run javascript, they're probably hip enough for multiline string
* literals.
*/
internal actual fun jreVersion(): Int = 15
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* 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

internal actual fun jreVersion(): Int {
val versionStr = System.getProperty("java.version")
return if (versionStr.startsWith("1.")) {
if (versionStr.startsWith("1.8")) 8 else throw Error("Unsupported java version: $versionStr")
} else {
versionStr.substringBefore('.').toInt()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ actual data class CallLocation(
if (fileName != null) {
return fileName
}
return layout.sourcePathForCall(this)?.let { layout.fs.name(it) }
return layout.sourcePathForCallMaybe(this)?.let { layout.fs.name(it) }
?: "${clazz.substringAfterLast('.')}.class"
}

Expand Down
1 change: 1 addition & 0 deletions selfie-lib/src/jvmTest/kotlin/testpkg/RecordCallTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class RecordCallTest {
override val allowMultipleEquivalentWritesToOneLocation: Boolean
get() = TODO()
override fun sourcePathForCall(call: CallLocation) = Path("testpkg/RecordCallTest.kt")
override fun sourcePathForCallMaybe(call: CallLocation): Path? = sourcePathForCall(call)
}
stack.location.ideLink(layout) shouldBe
"testpkg.RecordCallTest.testRecordCall(RecordCallTest.kt:30)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ open class SelfieSettingsAPI {
"Could not find a standard test directory, 'user.dir' is equal to $userDir, looked in $STANDARD_DIRS")
}

/**
* If Selfie should look for test sourcecode in places other than the rootFolder, you can specify
* them here.
*/
open val otherSourceRoots: List<File>
get() {
return buildList {
val rootDir = rootFolder
val userDir = File(System.getProperty("user.dir"))
for (standardDir in STANDARD_DIRS) {
val candidate = userDir.resolve(standardDir)
if (candidate.isDirectory && candidate != rootDir) {
add(candidate)
}
}
}
}

internal companion object {
private val STANDARD_DIRS =
listOf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,21 @@ import com.diffplug.selfie.guts.SnapshotFileLayout
class SnapshotFileLayoutJUnit5(settings: SelfieSettingsAPI, override val fs: FS) :
SnapshotFileLayout {
override val rootFolder = settings.rootFolder
private val otherSourceRoots = settings.otherSourceRoots
override val allowMultipleEquivalentWritesToOneLocation =
settings.allowMultipleEquivalentWritesToOneLocation
val snapshotFolderName = settings.snapshotFolderName
internal val unixNewlines = inferDefaultLineEndingIsUnix(settings.rootFolder, fs)
val extension: String = ".ss"
private val cache = ThreadLocal<Pair<CallLocation, Path>?>()
override fun sourcePathForCall(call: CallLocation): Path? {
override fun sourcePathForCall(call: CallLocation): Path {
val nonNull =
sourcePathForCallMaybe(call)
?: throw fs.assertFailed(
"Couldn't find source file for $call, looked in $rootFolder and $otherSourceRoots, maybe there are other source roots?")
return nonNull
}
override fun sourcePathForCallMaybe(call: CallLocation): Path? {
val cached = cache.get()
if (cached?.first?.samePathAs(call) == true) {
return cached.second
Expand All @@ -41,16 +49,22 @@ class SnapshotFileLayoutJUnit5(settings: SelfieSettingsAPI, override val fs: FS)
path
}
}
private fun computePathForCall(call: CallLocation): Path? {
private fun computePathForCall(call: CallLocation): Path? =
sequence {
yield(rootFolder)
yieldAll(otherSourceRoots)
}
.firstNotNullOfOrNull { computePathForCall(it, call) }
private fun computePathForCall(folder: Path, call: CallLocation): Path? {
if (call.fileName != null) {
return fs.fileWalk(rootFolder) { walk ->
return fs.fileWalk(folder) { walk ->
walk.filter { fs.name(it) == call.fileName }.firstOrNull()
}
}
val fileWithoutExtension = call.clazz.substringAfterLast('.').substringBefore('$')
val likelyExtensions = listOf("kt", "java", "scala", "groovy", "clj", "cljc")
val possibleNames = likelyExtensions.map { "$fileWithoutExtension.$it" }.toSet()
return fs.fileWalk(rootFolder) { walk ->
return fs.fileWalk(folder) { walk ->
walk.filter { fs.name(it) in possibleNames }.firstOrNull()
}
}
Expand Down
Loading

0 comments on commit 8237d79

Please sign in to comment.