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

Implement SnapshotValueReader #4

Merged
merged 16 commits into from
Aug 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
license=apache
kotlin.experimental.tryK2=true
#kotlin.experimental.tryK2=true
2 changes: 1 addition & 1 deletion gradle/spotless.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ spotless {
licenseHeaderFile 干.file("spotless/license-${license}.java")
ktfmt()
for (modifier in ['', 'override ', 'public ', 'protected ', 'private ', 'internal ', 'expected ', 'actual ']) {
for (key in ['fun', 'val', 'override']) {
for (key in ['inline', 'fun', 'val', 'override']) {
String toCheck = "$modifier$key"
replaceRegex("dense $toCheck", "\n\n(\\s*)$toCheck ", "\n\$1$toCheck ")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,27 @@ package com.diffplug.snapshot

import kotlin.collections.binarySearch

internal expect abstract class ListBackedSet<T>() : Set<T>, AbstractList<T> {}
internal abstract class ListBackedSet<T>() : AbstractSet<T>() {
abstract fun get(index: Int): T
override fun iterator(): Iterator<T> =
object : Iterator<T> {
private var index = 0
override fun hasNext(): Boolean = index < size
override fun next(): T {
if (!hasNext()) throw NoSuchElementException()
return get(index++)
}
}
}
internal fun <T : Comparable<T>> ListBackedSet<T>.binarySearch(element: T): Int {
val list =
object : AbstractList<T>() {
override val size: Int
get() = this@binarySearch.size
override fun get(index: Int): T = this@binarySearch.get(index)
}
return list.binarySearch(element)
}

internal expect fun <K, V> entry(key: K, value: V): Map.Entry<K, V>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
*/
package com.diffplug.snapshot

class ParseException(val line: Int, message: String) : IllegalArgumentException(message) {
constructor(lineReader: LineReader, message: String) : this(lineReader.getLineNumber(), message)
override val message: String
get() = "L${line}:${super.message}"
}

sealed interface SnapshotValue {
val isBinary: Boolean
get() = this is SnapshotValueBinary
Expand All @@ -41,6 +47,11 @@ 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<String, SnapshotValue>
Expand Down Expand Up @@ -73,22 +84,110 @@ 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
val buffer = StringBuilder()
scanValue { line ->
if (line.length >= 2 && line[0] == '\uD801' && line[1] == '\uDF41') { // "\uD801\uDF41" = "𐝁"
buffer.append('╔')
buffer.append(line, 2, line.length)
} else {
buffer.append(line)
}
buffer.append('\n')
}
return SnapshotValue.of(
if (buffer.isEmpty()) ""
else {
buffer.setLength(buffer.length - 1)
bodyEsc.unescape(buffer.toString())
})
}

/** Same as nextValue, but faster. */
fun skipValue(): Unit = TODO()
fun skipValue() {
// Ignore key
nextKey()
resetLine()

scanValue {
// ignore it
}
}
private inline fun scanValue(consumer: (String) -> Unit) {
// read next
var nextLine = nextLine()
while (nextLine != null && nextLine.indexOf(headerFirstChar) != 0) {
resetLine()

consumer(nextLine)

// read next
nextLine = nextLine()
}
}
private fun nextKey(): String? {
val line = nextLine() ?: return null
val startIndex = line.indexOf(headerStart)
val endIndex = line.indexOf(headerEnd)
if (startIndex == -1) {
throw ParseException(lineReader, "Expected to start with '$headerStart'")
}
if (endIndex == -1) {
throw ParseException(lineReader, "Expected to contain '$headerEnd'")
}
// valid key
val key = line.substring(startIndex + headerStart.length, endIndex)
return if (key.startsWith(" ")) {
throw ParseException(lineReader, "Leading spaces are disallowed: '$key'")
} else if (key.endsWith(" ")) {
throw ParseException(lineReader, "Trailing spaces are disallowed: '$key'")
} else {
nameEsc.unescape(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 = " ═╗"

/**
* 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")
}
}

expect class LineReader {
fun getLineNumber(): Int
fun readLine(): String
fun readLine(): String?

companion object {
fun forString(content: String): LineReader
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@
*/
package com.diffplug.snapshot

internal actual abstract class ListBackedSet<T> actual constructor() : Set<T>, AbstractList<T>()

internal actual fun <K, V> entry(key: K, value: V): Map.Entry<K, V> = E(key, value)

class E<K, V>(override val key: K, override val value: V) : Map.Entry<K, V> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@ actual class LineReader {
actual fun forBinary(content: ByteArray): LineReader = TODO()
}
actual fun getLineNumber(): Int = TODO()
actual fun readLine(): String = TODO()
actual fun readLine(): String? = TODO()
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,5 @@
*/
package com.diffplug.snapshot

import java.util.*
import kotlin.collections.AbstractList

internal actual abstract class ListBackedSet<T> actual constructor() : Set<T>, AbstractList<T>() {
override fun spliterator(): Spliterator<T> = (this as AbstractList<T>).spliterator()
}

internal actual fun <K, V> entry(key: K, value: V): Map.Entry<K, V> =
java.util.Map.entry(key, value)
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*
* Copyright (C) 2023 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.snapshot

import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import kotlin.test.Test

class SnapshotValueReaderTest {
@Test
fun noEscapingNeeded() {
val reader =
SnapshotValueReader.of(
"""
╔═ 00_empty ═╗
╔═ 01_singleLineString ═╗
this is one line
╔═ 01a_singleLineLeadingSpace ═╗
the leading space is significant
╔═ 01b_singleLineTrailingSpace ═╗
the trailing space is significant
╔═ 02_multiLineStringTrimmed ═╗
Line 1
Line 2
╔═ 03_multiLineStringTrailingNewline ═╗
Line 1
Line 2

╔═ 04_multiLineStringLeadingNewline ═╗

Line 1
Line 2
╔═ 05_notSureHowKotlinMultilineWorks ═╗
"""
.trimIndent())
reader.peekKey() shouldBe "00_empty"
reader.peekKey() shouldBe "00_empty"
reader.nextValue().valueString() shouldBe ""
reader.peekKey() shouldBe "01_singleLineString"
reader.peekKey() shouldBe "01_singleLineString"
reader.nextValue().valueString() shouldBe "this is one line"
reader.peekKey() shouldBe "01a_singleLineLeadingSpace"
reader.nextValue().valueString() shouldBe " the leading space is significant"
reader.peekKey() shouldBe "01b_singleLineTrailingSpace"
reader.nextValue().valueString() shouldBe "the trailing space is significant "
reader.peekKey() shouldBe "02_multiLineStringTrimmed"
reader.nextValue().valueString() shouldBe "Line 1\nLine 2"
// note that leading and trailing newlines in the snapshots are significant
// this is critical so that snapshots can accurately capture the exact number of newlines
reader.peekKey() shouldBe "03_multiLineStringTrailingNewline"
reader.nextValue().valueString() shouldBe "Line 1\nLine 2\n"
reader.peekKey() shouldBe "04_multiLineStringLeadingNewline"
reader.nextValue().valueString() shouldBe "\nLine 1\nLine 2"
reader.peekKey() shouldBe "05_notSureHowKotlinMultilineWorks"
reader.nextValue().valueString() shouldBe ""
}

@Test
fun invalidNames() {
shouldThrow<ParseException> { SnapshotValueReader.of("╔═name ═╗").peekKey() }
.let { it.message shouldBe "L1:Expected to start with '╔═ '" }
shouldThrow<ParseException> { SnapshotValueReader.of("╔═ name═╗").peekKey() }
.let { it.message shouldBe "L1:Expected to contain ' ═╗'" }
shouldThrow<ParseException> { SnapshotValueReader.of("╔═ name ═╗").peekKey() }
.let { it.message shouldBe "L1:Leading spaces are disallowed: ' name'" }
shouldThrow<ParseException> { SnapshotValueReader.of("╔═ name ═╗").peekKey() }
.let { it.message shouldBe "L1:Trailing spaces are disallowed: 'name '" }
SnapshotValueReader.of("╔═ name ═╗ comment okay").peekKey() shouldBe "name"
SnapshotValueReader.of("╔═ name ═╗okay here too").peekKey() shouldBe "name"
SnapshotValueReader.of("╔═ name ═╗ okay ╔═ ═╗ (it's the first ' ═╗' that counts)")
.peekKey() shouldBe "name"
}

@Test
fun escapeCharactersInName() {
val reader =
SnapshotValueReader.of(
"""
╔═ test with \∕slash\∕ in name ═╗
╔═ test with \(square brackets\) in name ═╗
╔═ test with \\backslash\\ in name ═╗
╔═ test with\nnewline\nin 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
newline
in name
"""
.trimIndent()
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() {
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 """╔══╗"""
reader.peekKey() shouldBe "body escape characters"
reader.nextValue().valueString() shouldBe """𐝁𐝃 linear a is dead"""
}

@Test
fun skipValues() {
val testContent =
"""
╔═ 00_empty ═╗
╔═ 01_singleLineString ═╗
this is one line
╔═ 02_multiLineStringTrimmed ═╗
Line 1
Line 2
╔═ 05_notSureHowKotlinMultilineWorks ═╗
"""
.trimIndent()
assertKeyValueWithSkip(testContent, "00_empty", "")
assertKeyValueWithSkip(testContent, "01_singleLineString", "this is one line")
assertKeyValueWithSkip(testContent, "02_multiLineStringTrimmed", "Line 1\nLine 2")
}
private fun assertKeyValueWithSkip(input: String, key: String, value: String) {
val reader = SnapshotValueReader.of(input)
while (reader.peekKey() != key) {
reader.skipValue()
}
reader.peekKey() shouldBe key
reader.nextValue().valueString() shouldBe value
while (reader.peekKey() != null) {
reader.skipValue()
}
}
}