Skip to content

Commit c8e150e

Browse files
authored
Merge pull request #16 from diffplug/feat/carriage-return
Carriage return handling
2 parents 61fba2b + 3f951d6 commit c8e150e

File tree

8 files changed

+252
-8
lines changed

8 files changed

+252
-8
lines changed

selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/SnapshotFile.kt

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ sealed interface SnapshotValue {
3838

3939
companion object {
4040
fun of(binary: ByteArray): SnapshotValue = SnapshotValueBinary(binary)
41-
fun of(string: String): SnapshotValue = SnapshotValueString(string)
41+
fun of(string: String): SnapshotValue = SnapshotValueString(unixNewlines(string))
4242
}
4343
}
4444

@@ -66,7 +66,8 @@ data class Snapshot(
6666
get() = lensData
6767
fun lens(key: String, value: ByteArray) = lens(key, SnapshotValue.of(value))
6868
fun lens(key: String, value: String) = lens(key, SnapshotValue.of(value))
69-
fun lens(key: String, value: SnapshotValue) = Snapshot(this.value, lensData.plus(key, value))
69+
fun lens(key: String, value: SnapshotValue) =
70+
Snapshot(this.value, lensData.plus(unixNewlines(key), value))
7071
override fun toString(): String = "[${value} ${lenses}]"
7172

7273
companion object {
@@ -82,12 +83,17 @@ private fun String.efficientReplace(find: String, replaceWith: String): String {
8283
val idx = this.indexOf(find)
8384
return if (idx == -1) this else this.replace(find, replaceWith)
8485
}
86+
private fun unixNewlines(str: String) = str.efficientReplace("\r\n", "\n")
8587

8688
class SnapshotFile {
89+
internal var unixNewlines = true
8790
// this will probably become `<String, JsonObject>` we'll cross that bridge when we get to it
8891
var metadata: Map.Entry<String, String>? = null
8992
var snapshots = ArrayMap.empty<String, Snapshot>()
90-
fun serialize(valueWriter: StringWriter) {
93+
fun serialize(valueWriterRaw: StringWriter) {
94+
val valueWriter =
95+
if (unixNewlines) valueWriterRaw
96+
else StringWriter { valueWriterRaw.write(it.efficientReplace("\n", "\r\n")) }
9197
metadata?.let {
9298
writeKey(valueWriter, "📷 ${it.key}", null)
9399
writeValue(valueWriter, SnapshotValue.of(it.value))
@@ -147,6 +153,7 @@ class SnapshotFile {
147153
fun parse(valueReader: SnapshotValueReader): SnapshotFile {
148154
try {
149155
val result = SnapshotFile()
156+
result.unixNewlines = valueReader.unixNewlines
150157
val reader = SnapshotReader(valueReader)
151158
// only if the first value starts with 📷
152159
if (reader.peekKey()?.startsWith(HEADER_PREFIX) == true) {
@@ -162,6 +169,11 @@ class SnapshotFile {
162169
throw if (e is ParseException) e else ParseException(valueReader.lineReader, e)
163170
}
164171
}
172+
fun createEmptyWithUnixNewlines(unixNewlines: Boolean): SnapshotFile {
173+
val result = SnapshotFile()
174+
result.unixNewlines = unixNewlines
175+
return result
176+
}
165177
}
166178
}
167179

@@ -207,6 +219,7 @@ class SnapshotReader(val valueReader: SnapshotValueReader) {
207219
/** Provides the ability to parse a snapshot file incrementally. */
208220
class SnapshotValueReader(val lineReader: LineReader) {
209221
var line: String? = null
222+
val unixNewlines = lineReader.unixNewlines()
210223

211224
/** The key of the next value, does not increment anything about the reader's state. */
212225
fun peekKey(): String? {
@@ -311,6 +324,7 @@ class SnapshotValueReader(val lineReader: LineReader) {
311324
expect class LineReader {
312325
fun getLineNumber(): Int
313326
fun readLine(): String?
327+
fun unixNewlines(): Boolean
314328

315329
companion object {
316330
fun forString(content: String): LineReader

selfie-lib/src/jsMain/kotlin/com/diffplug/selfie/SnapshotFile.js.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@ actual class LineReader {
2222
}
2323
actual fun getLineNumber(): Int = TODO()
2424
actual fun readLine(): String? = TODO()
25+
actual fun unixNewlines(): Boolean = TODO()
2526
}

selfie-lib/src/jvmMain/kotlin/com/diffplug/selfie/SnapshotFile.jvm.kt

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,74 @@
1515
*/
1616
package com.diffplug.selfie
1717

18+
import java.io.BufferedReader
1819
import java.io.InputStreamReader
1920
import java.io.LineNumberReader
2021
import java.io.Reader
2122
import java.io.StringReader
23+
import java.nio.CharBuffer
2224
import java.nio.charset.StandardCharsets
2325

24-
actual class LineReader(reader: Reader) : LineNumberReader(reader) {
26+
actual class LineReader(reader: Reader) {
27+
private val reader = LineTerminatorAware(LineTerminatorReader(reader))
28+
2529
actual companion object {
2630
actual fun forString(content: String) = LineReader(StringReader(content))
2731
actual fun forBinary(content: ByteArray) =
2832
LineReader(InputStreamReader(content.inputStream(), StandardCharsets.UTF_8))
2933
}
34+
actual fun getLineNumber(): Int = reader.lineNumber
35+
actual fun readLine(): String? = reader.readLine()
36+
actual fun unixNewlines(): Boolean = reader.lineTerminator.unixNewlines()
37+
}
38+
39+
/**
40+
* Keep track of carriage return char to figure it out if we need unix new line or not. The first
41+
* line is kept in memory until we require the next line.
42+
*/
43+
private open class LineTerminatorAware(val lineTerminator: LineTerminatorReader) :
44+
LineNumberReader(lineTerminator) {
45+
/** First line is initialized as soon as possible. */
46+
private var firstLine: String? = super.readLine()
47+
override fun readLine(): String? {
48+
if (this.firstLine != null) {
49+
val result = this.firstLine
50+
this.firstLine = null
51+
return result
52+
}
53+
return super.readLine()
54+
}
55+
}
56+
57+
/**
58+
* Override all read operations to find the carriage return. We want to keep lazy/incremental reads.
59+
*/
60+
private class LineTerminatorReader(reader: Reader) : BufferedReader(reader) {
61+
private val CR: Int = '\r'.code
62+
private var unixNewlines = true
63+
override fun read(cbuf: CharArray): Int {
64+
val result = super.read(cbuf)
65+
unixNewlines = cbuf.indexOf(CR.toChar()) == -1
66+
return result
67+
}
68+
override fun read(target: CharBuffer): Int {
69+
val result = super.read(target)
70+
unixNewlines = target.indexOf(CR.toChar()) == -1
71+
return result
72+
}
73+
override fun read(cbuf: CharArray, off: Int, len: Int): Int {
74+
val result = super.read(cbuf, off, len)
75+
unixNewlines = cbuf.indexOf(CR.toChar()) == -1
76+
return result
77+
}
78+
override fun read(): Int {
79+
val ch = super.read()
80+
if (ch == CR) {
81+
unixNewlines = false
82+
}
83+
return ch
84+
}
85+
fun unixNewlines(): Boolean {
86+
return unixNewlines
87+
}
3088
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Copyright (C) 2023 DiffPlug
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.diffplug.selfie
17+
18+
import io.kotest.matchers.shouldBe
19+
import kotlin.test.Test
20+
21+
class LineReaderTest {
22+
23+
@Test
24+
fun shouldFindUnixSeparatorFromBinary() {
25+
val reader = LineReader.forBinary("This is a new line\n".encodeToByteArray())
26+
reader.unixNewlines() shouldBe true
27+
reader.readLine() shouldBe "This is a new line"
28+
}
29+
30+
@Test
31+
fun shouldFindWindowsSeparatorFromBinary() {
32+
val reader = LineReader.forBinary("This is a new line\r\n".encodeToByteArray())
33+
reader.unixNewlines() shouldBe false
34+
reader.readLine() shouldBe "This is a new line"
35+
}
36+
37+
@Test
38+
fun shouldFindUnixSeparatorFromString() {
39+
val reader = LineReader.forString("This is a new line\n")
40+
reader.unixNewlines() shouldBe true
41+
reader.readLine() shouldBe "This is a new line"
42+
}
43+
44+
@Test
45+
fun shouldFindWindowsSeparatorFromString() {
46+
val reader = LineReader.forString("This is a new line\r\n")
47+
reader.unixNewlines() shouldBe false
48+
reader.readLine() shouldBe "This is a new line"
49+
}
50+
51+
@Test
52+
fun shouldGetUnixLineSeparatorWhenThereIsNone() {
53+
val reader = LineReader.forBinary("This is a new line".encodeToByteArray())
54+
reader.unixNewlines() shouldBe true
55+
reader.readLine() shouldBe "This is a new line"
56+
}
57+
58+
@Test
59+
fun shouldReadNextLineWithoutProblem() {
60+
val reader = LineReader.forBinary("First\r\nSecond\r\n".encodeToByteArray())
61+
reader.unixNewlines() shouldBe false
62+
reader.readLine() shouldBe "First"
63+
reader.unixNewlines() shouldBe false
64+
reader.readLine() shouldBe "Second"
65+
reader.unixNewlines() shouldBe false
66+
}
67+
68+
@Test
69+
fun shouldUseFirstLineSeparatorAndIgnoreNext() {
70+
val reader = LineReader.forBinary("First\r\nAnother separator\n".encodeToByteArray())
71+
reader.unixNewlines() shouldBe false
72+
reader.readLine() shouldBe "First"
73+
reader.unixNewlines() shouldBe false
74+
reader.readLine() shouldBe "Another separator"
75+
reader.unixNewlines() shouldBe false
76+
}
77+
}

selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SelfieConfig.kt

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ import java.nio.file.Files
1919
import java.nio.file.Path
2020
import java.nio.file.Paths
2121

22-
internal class SnapshotFileLayout(val rootFolder: Path, val snapshotFolderName: String?) {
22+
internal class SnapshotFileLayout(
23+
val rootFolder: Path,
24+
val snapshotFolderName: String?,
25+
val unixNewlines: Boolean
26+
) {
2327
val extension: String = ".ss"
2428
fun snapshotPathForClass(className: String): Path {
2529
val lastDot = className.lastIndexOf('.')
@@ -64,12 +68,21 @@ internal class SnapshotFileLayout(val rootFolder: Path, val snapshotFolderName:
6468
"src/test/scala",
6569
"src/test/resources")
6670
fun initialize(className: String): SnapshotFileLayout {
67-
val selfieDotProp = SnapshotFileLayout.javaClass.getResource("/selfie.properties")
71+
val selfieDotProp = SnapshotFileLayout::class.java.getResource("/selfie.properties")
6872
val properties = java.util.Properties()
6973
selfieDotProp?.openStream()?.use { properties.load(selfieDotProp.openStream()) }
7074
val snapshotFolderName = snapshotFolderName(properties.getProperty("snapshot-dir"))
7175
val snapshotRootFolder = rootFolder(properties.getProperty("output-dir"))
72-
return SnapshotFileLayout(snapshotRootFolder, snapshotFolderName)
76+
// it's pretty easy to preserve the line endings of existing snapshot files, but it's
77+
// a bit harder to create a fresh snapshot file with the correct line endings.
78+
val cr =
79+
snapshotRootFolder
80+
.resolve(snapshotFolderName!!)
81+
.toFile()
82+
.walkTopDown()
83+
.filter { it.isFile }
84+
.any { it.readText().contains('\r') }
85+
return SnapshotFileLayout(snapshotRootFolder, snapshotFolderName, !cr)
7386
}
7487
private fun snapshotFolderName(snapshotDir: String?): String? {
7588
if (snapshotDir == null) {

selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SelfieTestExecutionListener.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ internal class ClassProgress(val className: String) {
171171
val content = Files.readAllBytes(snapshotPath)
172172
SnapshotFile.parse(SnapshotValueReader.of(content))
173173
} else {
174-
SnapshotFile()
174+
SnapshotFile.createEmptyWithUnixNewlines(Router.layout!!.unixNewlines)
175175
}
176176
}
177177
return file!!
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* Copyright (C) 2023 DiffPlug
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.diffplug.selfie.junit5
17+
18+
import kotlin.test.Test
19+
import org.junit.jupiter.api.MethodOrderer
20+
import org.junit.jupiter.api.Order
21+
import org.junit.jupiter.api.TestMethodOrder
22+
import org.junitpioneer.jupiter.DisableIfTestFails
23+
24+
/** Verify selfie's carriage-return handling. */
25+
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
26+
@DisableIfTestFails
27+
class CarriageReturnTest : Harness("undertest-junit5") {
28+
@Test @Order(1)
29+
fun noSelfie() {
30+
ut_snapshot().deleteIfExists()
31+
ut_snapshot().assertDoesNotExist()
32+
}
33+
val expectedContent =
34+
"""
35+
╔═ git_makes_carriage_returns_unrepresentable ═╗
36+
hard
37+
to
38+
preserve
39+
this
40+
41+
╔═ [end of file] ═╗
42+
43+
"""
44+
.trimIndent()
45+
46+
@Test @Order(2)
47+
fun write_and_assert_ss_has_unix_newlines() {
48+
gradleWriteSS()
49+
ut_snapshot().assertContent(expectedContent)
50+
}
51+
52+
@Test @Order(3)
53+
fun if_ss_has_cr_then_it_will_stay_cr() {
54+
val contentWithCr = expectedContent.replace("\n", "\r\n")
55+
ut_snapshot().setContent(contentWithCr)
56+
gradleWriteSS()
57+
ut_snapshot().assertContent(contentWithCr)
58+
}
59+
60+
@Test @Order(4)
61+
fun go_back_to_unix_and_it_stays_unix() {
62+
ut_snapshot().setContent(expectedContent)
63+
gradleWriteSS()
64+
ut_snapshot().assertContent(expectedContent)
65+
}
66+
67+
@Test @Order(5)
68+
fun deleteSelfie() {
69+
ut_snapshot().deleteIfExists()
70+
}
71+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package undertest.junit5
2+
3+
import com.diffplug.selfie.expectSelfie
4+
import kotlin.test.Test
5+
6+
class UT_CarriageReturnTest {
7+
@Test fun git_makes_carriage_returns_unrepresentable() {
8+
expectSelfie("hard\r\nto\npreserve\r\nthis\r\n").toMatchDisk()
9+
}
10+
}

0 commit comments

Comments
 (0)