diff --git a/snapshot-lib/src/commonMain/kotlin/com/diffplug/snapshot/SpotlessFile.kt b/snapshot-lib/src/commonMain/kotlin/com/diffplug/snapshot/SpotlessFile.kt index aec512c9b..ce55e5978 100644 --- a/snapshot-lib/src/commonMain/kotlin/com/diffplug/snapshot/SpotlessFile.kt +++ b/snapshot-lib/src/commonMain/kotlin/com/diffplug/snapshot/SpotlessFile.kt @@ -18,10 +18,12 @@ package com.diffplug.snapshot sealed interface SnapshotValue { val isBinary: Boolean get() = this is SnapshotValueBinary + fun valueBinary(): ByteArray fun valueString(): String companion object { + val EMPTY: SnapshotValue = SnapshotValueEmptyString("") fun of(binary: ByteArray): SnapshotValue = SnapshotValueBinary(binary) fun of(string: String): SnapshotValue = SnapshotValueString(string) } @@ -32,8 +34,8 @@ internal data class SnapshotValueBinary(val value: ByteArray) : SnapshotValue { override fun valueString() = throw UnsupportedOperationException("This is a binary value.") override fun hashCode(): Int = value.contentHashCode() override fun equals(other: Any?): Boolean = - if (this === other) true - else (other is SnapshotValueBinary && value.contentEquals(other.value)) + if (this === other) true + else (other is SnapshotValueBinary && value.contentEquals(other.value)) } internal data class SnapshotValueString(val value: String) : SnapshotValue { @@ -41,17 +43,24 @@ internal data class SnapshotValueString(val value: String) : SnapshotValue { override fun valueString(): String = value } +internal data class SnapshotValueEmptyString(val value: String) : SnapshotValue { + override fun valueBinary() = throw UnsupportedOperationException("This is an empty string value.") + override fun valueString() = value +} + data class Snapshot( - val value: SnapshotValue, - private val lensData: ArrayMap + val value: SnapshotValue, + private val lensData: ArrayMap ) { /** A sorted immutable map of extra values. */ val lenses: Map get() = lensData + fun lens(key: String, value: ByteArray) = - Snapshot(this.value, lensData.plus(key, SnapshotValue.of(value))) + Snapshot(this.value, lensData.plus(key, SnapshotValue.of(value))) + fun lens(key: String, value: String) = - Snapshot(this.value, lensData.plus(key, SnapshotValue.of(value))) + Snapshot(this.value, lensData.plus(key, SnapshotValue.of(value))) companion object { fun of(binary: ByteArray) = Snapshot(SnapshotValue.of(binary), ArrayMap.empty()) @@ -62,6 +71,7 @@ data class Snapshot( interface Snapshotter { fun snapshot(value: T): Snapshot } + fun parseSS(valueReader: SnapshotValueReader): ArrayMap = TODO() fun serializeSS(valueWriter: StringWriter, snapshots: ArrayMap): Unit = TODO() @@ -73,23 +83,87 @@ class SnapshotReader(val valueReader: SnapshotValueReader) { /** Provides the ability to parse a snapshot file incrementally. */ class SnapshotValueReader(val lineReader: LineReader) { + var line: String? = null + /** The key of the next value, does not increment anything about the reader's state. */ - fun peekKey(): String? = TODO() + fun peekKey(): String? { + return nextKey(); + } + /** Reads the next value. */ - fun nextValue(): SnapshotValue = TODO() + fun nextValue(): SnapshotValue { + // validate key + nextKey() + resetLine() + // read value + var nextLine = nextLine() + val buffer = StringBuilder() + while (nextLine != null) { + if (nextLine.isNotBlank() && nextLine[0] == headerFirstChar) { + break + } + resetLine() + buffer.append(nextLine).append('\n') + // read next + nextLine = nextLine() + } + if (buffer.isEmpty()) { + return SnapshotValue.EMPTY + } + return SnapshotValue.of(bodyEsc.escape(buffer.toString().trim())) + } + /** Same as nextValue, but faster. */ fun skipValue(): Unit = TODO() + private fun nextKey(): String? { + val line = nextLine() ?: + // TODO: do we really want null? + return null + + // TODO: confirm exception type + val startIndex = line.indexOf(headerStart) + val endIndex = line.indexOf(headerEnd) + if (startIndex == -1) { + throw IllegalStateException("Expected '$headerStart' at line:${lineReader.getLineNumber()}") + } + if (endIndex == -1) { + throw IllegalStateException("Expected '$headerEnd' at line:${lineReader.getLineNumber()}") + } + // valid key + val key = line.substring(startIndex + headerStart.length, endIndex) + if (key.startsWith(" ")) { + throw IllegalStateException("Leading spaces are disallowed: '$key' at line:${lineReader.getLineNumber()}") + } + if (key.endsWith(" ")) { + throw IllegalStateException("Trailing spaces are disallowed: '$key' at line:${lineReader.getLineNumber()}") + } + return nameEsc.escape(key) + } + + private fun nextLine(): String? { + if (line == null) { + line = lineReader.readLine() + } + return line + } + + private fun resetLine() { + line = null + } + companion object { fun of(content: String) = SnapshotValueReader(LineReader.forString(content)) fun of(content: ByteArray) = SnapshotValueReader(LineReader.forBinary(content)) private val headerFirstChar = '╔' private val headerStart = "╔═ " - private val headerEnd = "═╗" + private val headerEnd = " ═╗" + /** * https://github.com/diffplug/spotless-snapshot/blob/f63192a84390901a3d3543066d095ea23bf81d21/snapshot-lib/src/commonTest/resources/com/diffplug/snapshot/scenarios_and_lenses.ss#L11-L29 */ private val nameEsc = PerCharacterEscaper.specifiedEscape("\\\\/∕[(])\nn\tt╔┌╗┐═─") + /** https://github.com/diffplug/spotless-snapshot/issues/2 */ private val bodyEsc = PerCharacterEscaper.selfEscape("\uD801\uDF43\uD801\uDF41") } @@ -97,7 +171,7 @@ class SnapshotValueReader(val lineReader: LineReader) { expect class LineReader { fun getLineNumber(): Int - fun readLine(): String + fun readLine(): String? companion object { fun forString(content: String): LineReader diff --git a/snapshot-lib/src/jvmTest/kotlin/com/diffplug/snapshot/SnapshotValueReaderTest.kt b/snapshot-lib/src/jvmTest/kotlin/com/diffplug/snapshot/SnapshotValueReaderTest.kt index 5dd047eac..2a6b37fe2 100644 --- a/snapshot-lib/src/jvmTest/kotlin/com/diffplug/snapshot/SnapshotValueReaderTest.kt +++ b/snapshot-lib/src/jvmTest/kotlin/com/diffplug/snapshot/SnapshotValueReaderTest.kt @@ -15,7 +15,10 @@ */ package com.diffplug.snapshot +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.should import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.startWith import kotlin.test.Test class SnapshotValueReaderTest { @@ -47,43 +50,86 @@ class SnapshotValueReaderTest { reader.peekKey() shouldBe "01_singleLineString" reader.peekKey() shouldBe "01_singleLineString" reader.nextValue().valueString() shouldBe "this is one line" - // etc + reader.peekKey() shouldBe "02_multiLineStringTrimmed" + reader.nextValue().valueString() shouldBe "Line 1\nLine 2" + reader.peekKey() shouldBe "03_multiLineStringTrailingNewline" + reader.nextValue().valueString() shouldBe "Line 1\nLine 2" + reader.peekKey() shouldBe "04_multiLineStringLeadingNewline" + reader.nextValue().valueString() shouldBe "Line 1\nLine 2" + reader.peekKey() shouldBe "05_notSureHowKotlinMultilineWorks" + reader.nextValue().valueString() shouldBe "" } @Test fun invalidNames() { - /* TODO - ╔═name ═╗ error: Expected '╔═ ' - ╔═ name═╗ error: Expected ' ═╗' - ╔═ name ═╗ error: Leading spaces are disallowed: ' name' - ╔═ name ═╗ error: Trailing spaces are disallowed: 'name ' - ╔═ name ═╗ comment okay - ╔═ name ═╗okay here too - ╔═ name ═╗ okay ╔═ ═╗ (it's the first ' ═╗' that counts) - */ + shouldThrow { + SnapshotValueReader.of("╔═name ═╗").peekKey() + }.let { + it.message should startWith("Expected '╔═ ' at line:1") + } + shouldThrow { + SnapshotValueReader.of("╔═ name═╗").peekKey() + }.let { + it.message should startWith("Expected ' ═╗' at line:1") + } + shouldThrow { + SnapshotValueReader.of("╔═ name ═╗").peekKey() + }.let { + it.message should startWith("Leading spaces are disallowed: ' name' at line:1") + } + shouldThrow { + SnapshotValueReader.of("╔═ name ═╗").peekKey() + }.let { + it.message should startWith("Trailing spaces are disallowed: 'name ' at line:1") + } + SnapshotValueReader.of("╔═ name ═╗ comment okay").peekKey() + SnapshotValueReader.of("╔═ name ═╗okay here too").peekKey() + SnapshotValueReader.of("╔═ name ═╗ okay ╔═ ═╗ (it's the first ' ═╗' that counts)").peekKey() shouldBe "name" } @Test fun escapeCharactersInName() { - /* TODO - ╔═ test with \∕slash\∕ in name ═╗ - ╔═ test with \(square brackets\) in name ═╗ - ╔═ test with \\backslash\\ in name ═╗ - ╔═ test with \nnewline\n in name ═╗ - ╔═ test with \ttab\t in name ═╗ - ╔═ test with \┌\─ ascii art \┐\─ in name ═╗ - */ + val reader = + SnapshotValueReader.of( + """ + ╔═ test with \∕slash\∕ in name ═╗ + ╔═ test with \(square brackets\) in name ═╗ + ╔═ test with \\backslash\\ in name ═╗ + ╔═ test with \nnewline\n in name ═╗ + ╔═ test with \ttab\t in name ═╗ + ╔═ test with \┌\─ ascii art \┐\─ in name ═╗ + """.trimIndent()) + reader.peekKey() shouldBe "test with \\\\∕slash\\\\∕ in name" + reader.nextValue().valueString() shouldBe "" + reader.peekKey() shouldBe "test with \\\\(square brackets\\\\) in name" + reader.nextValue().valueString() shouldBe "" + reader.peekKey() shouldBe "test with \\\\\\\\backslash\\\\\\\\ in name" + reader.nextValue().valueString() shouldBe "" + reader.peekKey() shouldBe "test with \\\\nnewline\\\\n in name" + reader.nextValue().valueString() shouldBe "" + reader.peekKey() shouldBe "test with \\\\ttab\\\\t in name" + reader.nextValue().valueString() shouldBe "" + reader.peekKey() shouldBe "test with \\\\┌\\\\─ ascii art \\\\┐\\\\─ in name" + reader.nextValue().valueString() shouldBe "" } @Test fun escapeCharactersInBody() { - /* TODO - ╔═ ascii art okay ═╗ - ╔══╗ - ╔═ escaped iff on first line ═╗ - 𐝁══╗ - ╔═ body escape characters ═╗ - 𐝃𐝁𐝃𐝃 linear a is dead - */ + val reader = + SnapshotValueReader.of( + """ + ╔═ ascii art okay ═╗ + ╔══╗ + ╔═ escaped iff on first line ═╗ + 𐝁══╗ + ╔═ body escape characters ═╗ + 𐝃𐝁𐝃𐝃 linear a is dead + """.trimIndent()) + reader.peekKey() shouldBe "ascii art okay" + reader.nextValue().valueString() shouldBe "╔══╗" + reader.peekKey() shouldBe "escaped iff on first line" + reader.nextValue().valueString() shouldBe "\uD801\uDF43\uD801\uDF41══╗" + reader.peekKey() shouldBe "body escape characters" + reader.nextValue().valueString() shouldBe "\uD801\uDF43\uD801\uDF43\uD801\uDF43\uD801\uDF41\uD801\uDF43\uD801\uDF43\uD801\uDF43\uD801\uDF43 linear a is dead" } }