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 multiline java #87

Merged
merged 22 commits into from
Jan 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
77e6054
Improve the tests for our existing single-line code.
nedtwigg Jan 9, 2024
3dc7f93
We can now read java multiline strings.
nedtwigg Jan 9, 2024
8704a1a
Make `LiteralFormat`'s methods internal so that we can change their i…
nedtwigg Jan 9, 2024
17e307d
Add tests for multiline java strings.
nedtwigg Jan 9, 2024
0cde50e
Keep `"` and `"""` included as part of the string literal.
nedtwigg Jan 9, 2024
7cd0789
Add a test for various challenging string literals.
nedtwigg Jan 9, 2024
5ce90cf
Wire up the java string literals and use them in an integration test.
nedtwigg Jan 9, 2024
01abf8e
Fix test cleanup.
nedtwigg Jan 9, 2024
abf7c2f
Merge branch 'feat/toBeParsing' into feat/multiline
nedtwigg Jan 9, 2024
1a0886e
Only use multiline string literals when appropriate.
nedtwigg Jan 9, 2024
c2ac8ff
Remove `InlineStringTest` since it duplicates JavaStringLiteralsTest.
nedtwigg Jan 9, 2024
8393d17
Rename `JavaStringLiteralsTest` to `StringLiteralsJavaTest`.
nedtwigg Jan 9, 2024
2402192
Fix silly mistake in LiteralString.
nedtwigg Jan 9, 2024
fac76b0
Fix bad rename.
nedtwigg Jan 9, 2024
b51a639
Better null safety and error messages for `sourcePathForCall`.
nedtwigg Jan 9, 2024
6c47e30
Support multiple test source roots.
nedtwigg Jan 9, 2024
1740201
Merge branch 'main' into feat/multiline
nedtwigg Jan 9, 2024
5f2cf27
Better error message.
nedtwigg Jan 9, 2024
05d6319
Move snapshot files now that we have a `src/test/java` folder where t…
nedtwigg Jan 9, 2024
990790a
Fix compile error.
nedtwigg Jan 9, 2024
6c6fa72
Detect the actual running JVM.
nedtwigg Jan 9, 2024
b48b6a1
Remove this test because it only compiles for Java 11, and we are imp…
nedtwigg Jan 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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