Skip to content

Commit

Permalink
Methods for parsing a full snapshot (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
nedtwigg committed Aug 23, 2023
2 parents a0aaadc + efdb625 commit 0231de8
Show file tree
Hide file tree
Showing 3 changed files with 252 additions and 36 deletions.
153 changes: 117 additions & 36 deletions selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/SnapshotFile.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,17 @@
*/
package com.diffplug.selfie

class ParseException(val line: Int, message: String) : IllegalArgumentException(message) {
constructor(lineReader: LineReader, message: String) : this(lineReader.getLineNumber(), message)
class ParseException private constructor(val line: Int, message: String?, cause: Throwable?) :
IllegalArgumentException(message, cause) {
constructor(
lineReader: LineReader,
message: String
) : this(lineReader.getLineNumber(), message, null)

constructor(
lineReader: LineReader,
cause: Exception
) : this(lineReader.getLineNumber(), null, cause)
override val message: String
get() = "L${line}:${super.message}"
}
Expand Down Expand Up @@ -54,10 +63,9 @@ data class Snapshot(
/** A sorted immutable map of extra values. */
val lenses: Map<String, SnapshotValue>
get() = lensData
fun lens(key: String, value: ByteArray) =
Snapshot(this.value, lensData.plus(key, SnapshotValue.of(value)))
fun lens(key: String, value: String) =
Snapshot(this.value, lensData.plus(key, SnapshotValue.of(value)))
fun lens(key: String, value: ByteArray) = lens(key, SnapshotValue.of(value))
fun lens(key: String, value: String) = lens(key, SnapshotValue.of(value))
fun lens(key: String, value: SnapshotValue) = Snapshot(this.value, lensData.plus(key, value))

companion object {
fun of(binary: ByteArray) = Snapshot(SnapshotValue.of(binary), ArrayMap.empty())
Expand All @@ -68,36 +76,109 @@ data class Snapshot(
interface Snapshotter<T> {
fun snapshot(value: T): Snapshot
}
private fun String.efficientReplace(find: String, replaceWith: String): String {
val idx = this.indexOf(find)
return if (idx == -1) this else this.replace(find, replaceWith)
}

class SnapshotFile {
// this will probably become `<String, JsonObject>` we'll cross that bridge when we get to it
var metadata: Map.Entry<String, String>? = null
var snapshots = ArrayMap.empty<String, Snapshot>()
fun serialize(valueWriter: StringWriter): Unit = TODO()
fun serialize(valueWriter: StringWriter) {
metadata?.let {
writeKey(valueWriter, "📷 ${it.key}", null, true)
writeValue(valueWriter, SnapshotValue.of(it.value))
}
for ((index, entry) in snapshots.entries.withIndex()) {
val isFirst = metadata == null && index == 0
writeKey(valueWriter, entry.key, null, isFirst)
writeValue(valueWriter, entry.value.value)
for (lens in entry.value.lenses.entries) {
writeKey(valueWriter, entry.key, lens.key, false)
writeValue(valueWriter, lens.value)
}
}
}
private fun writeKey(valueWriter: StringWriter, key: String, lens: String?, first: Boolean) {
valueWriter.write(if (first) "╔═ " else "\n╔═ ")
valueWriter.write(SnapshotValueReader.nameEsc.escape(key))
if (lens != null) {
valueWriter.write("[")
valueWriter.write(SnapshotValueReader.nameEsc.escape(lens))
valueWriter.write("]")
}
valueWriter.write(" ═╗\n")
}
private fun writeValue(valueWriter: StringWriter, value: SnapshotValue) {
if (value.isBinary) {
TODO("BASE64")
} else {
val escaped =
SnapshotValueReader.bodyEsc
.escape(value.valueString())
.efficientReplace("\n", "\n\uD801\uDF41")
valueWriter.write(escaped)
}
}

companion object {
private val HEADER_PREFIX = """📷 """
private const val HEADER_PREFIX = """📷 """
fun parse(valueReader: SnapshotValueReader): SnapshotFile {
val result = SnapshotFile()
val reader = SnapshotReader(valueReader)
// only if the first value starts with 📷
if (reader.peekKey()?.startsWith(HEADER_PREFIX) == true) {
val metadataName = reader.peekKey()!!.substring(HEADER_PREFIX.length)
val metadataValue = reader.valueReader.nextValue().valueString()
result.metadata = entry(metadataName, metadataValue)
}
while (reader.peekKey() != null) {
result.snapshots = result.snapshots.plus(reader.peekKey()!!, reader.nextSnapshot())
try {
val result = SnapshotFile()
val reader = SnapshotReader(valueReader)
// only if the first value starts with 📷
if (reader.peekKey()?.startsWith(HEADER_PREFIX) == true) {
val metadataName = reader.peekKey()!!.substring(HEADER_PREFIX.length)
val metadataValue = reader.valueReader.nextValue().valueString()
result.metadata = entry(metadataName, metadataValue)
}
while (reader.peekKey() != null) {
result.snapshots = result.snapshots.plus(reader.peekKey()!!, reader.nextSnapshot())
}
return result
} catch (e: IllegalArgumentException) {
throw if (e is ParseException) e else ParseException(valueReader.lineReader, e)
}
return result
}
}
}

class SnapshotReader(val valueReader: SnapshotValueReader) {
fun peekKey(): String? = TODO()
fun nextSnapshot(): Snapshot = TODO()
fun skipSnapshot(): Unit = TODO()
fun peekKey(): String? {
val next = valueReader.peekKey() ?: return null
require(next.indexOf('[') == -1) {
"Missing root snapshot, square brackets not allowed: '$next'"
}
return next
}
fun nextSnapshot(): Snapshot {
val rootName = peekKey()
var snapshot = Snapshot(valueReader.nextValue(), ArrayMap.empty())
while (true) {
val nextKey = valueReader.peekKey() ?: return snapshot
val lensIdx = nextKey.indexOf('[')
if (lensIdx == -1) {
return snapshot
}
val lensRoot = nextKey.substring(0, lensIdx)
require(lensRoot == rootName) {
"Expected '$nextKey' to come after '$lensRoot', not '$rootName'"
}
val lensEndIdx = nextKey.indexOf(']', lensIdx + 1)
require(lensEndIdx != -1) { "Missing ] in $nextKey" }
val lensName = nextKey.substring(lensIdx + 1, lensEndIdx)
snapshot = snapshot.lens(lensName, valueReader.nextValue().valueString())
}
}
fun skipSnapshot() {
val rootName = peekKey()!!
valueReader.skipValue()
while (peekKey()?.startsWith("$rootName[") == true) {
valueReader.skipValue()
}
}
}

/** Provides the ability to parse a snapshot file incrementally. */
Expand All @@ -119,7 +200,7 @@ class SnapshotValueReader(val lineReader: LineReader) {
val buffer = StringBuilder()
scanValue { line ->
if (line.length >= 2 && line[0] == '\uD801' && line[1] == '\uDF41') { // "\uD801\uDF41" = "𐝁"
buffer.append('')
buffer.append(KEY_FIRST_CHAR)
buffer.append(line, 2, line.length)
} else {
buffer.append(line)
Expand Down Expand Up @@ -147,7 +228,7 @@ class SnapshotValueReader(val lineReader: LineReader) {
private inline fun scanValue(consumer: (String) -> Unit) {
// read next
var nextLine = nextLine()
while (nextLine != null && nextLine.indexOf(headerFirstChar) != 0) {
while (nextLine != null && nextLine.indexOf(KEY_FIRST_CHAR) != 0) {
resetLine()

consumer(nextLine)
Expand All @@ -158,16 +239,16 @@ class SnapshotValueReader(val lineReader: LineReader) {
}
private fun nextKey(): String? {
val line = nextLine() ?: return null
val startIndex = line.indexOf(headerStart)
val endIndex = line.indexOf(headerEnd)
val startIndex = line.indexOf(KEY_START)
val endIndex = line.indexOf(KEY_END)
if (startIndex == -1) {
throw ParseException(lineReader, "Expected to start with '$headerStart'")
throw ParseException(lineReader, "Expected to start with '$KEY_START'")
}
if (endIndex == -1) {
throw ParseException(lineReader, "Expected to contain '$headerEnd'")
throw ParseException(lineReader, "Expected to contain '$KEY_END'")
}
// valid key
val key = line.substring(startIndex + headerStart.length, endIndex)
val key = line.substring(startIndex + KEY_START.length, endIndex)
return if (key.startsWith(" ")) {
throw ParseException(lineReader, "Leading spaces are disallowed: '$key'")
} else if (key.endsWith(" ")) {
Expand All @@ -189,17 +270,18 @@ class SnapshotValueReader(val lineReader: LineReader) {
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 const val KEY_FIRST_CHAR = ''
private const val KEY_START = "╔═ "
private const val KEY_END = " ═╗"

/**
* https://github.com/diffplug/selfie/blob/f63192a84390901a3d3543066d095ea23bf81d21/snapshot-lib/src/commonTest/resources/com/diffplug/snapshot/scenarios_and_lenses.ss#L11-L29
*/
private val nameEsc = PerCharacterEscaper.specifiedEscape("\\\\/∕[(])\nn\tt╔┌╗┐═─")
internal val nameEsc = PerCharacterEscaper.specifiedEscape("\\\\/∕[(])\nn\tt╔┌╗┐═─")

/** https://github.com/diffplug/selfie/issues/2 */
private val bodyEsc = PerCharacterEscaper.selfEscape("\uD801\uDF43\uD801\uDF41")
internal val bodyEsc = PerCharacterEscaper.selfEscape("\uD801\uDF43\uD801\uDF41")
}
}

Expand All @@ -212,7 +294,6 @@ expect class LineReader {
fun forBinary(content: ByteArray): LineReader
}
}

interface StringWriter {
fun interface StringWriter {
fun write(string: String)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* 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.selfie

import io.kotest.matchers.shouldBe
import kotlin.test.Test

class SnapshotFileTest {
@Test
fun readWithMetadata() {
val file =
SnapshotFile.parse(
SnapshotValueReader.of(
"""
╔═ 📷 com.acme.AcmeTest ═╗
{"header":"data"}
╔═ Apple ═╗
Granny Smith
╔═ Apple[color] ═╗
green
╔═ Apple[crisp] ═╗
yes
╔═ Orange ═╗
Orange
"""
.trimIndent()))
file.metadata shouldBe entry("com.acme.AcmeTest", """{"header":"data"}""")
file.snapshots.keys shouldBe setOf("Apple", "Orange")
}

@Test
fun readWithoutMetadata() {
val file =
SnapshotFile.parse(
SnapshotValueReader.of(
"""
╔═ Apple ═╗
Apple
╔═ Apple[color] ═╗
green
╔═ Apple[crisp] ═╗
yes
╔═ Orange ═╗
Orange
"""
.trimIndent()))
file.metadata shouldBe null
file.snapshots.keys shouldBe setOf("Apple", "Orange")
}

@Test
fun write() {
val underTest = SnapshotFile()
underTest.metadata = entry("com.acme.AcmeTest", """{"header":"data"}""")
underTest.snapshots =
underTest.snapshots.plus(
"Apple", Snapshot.of("Granny Smith").lens("color", "green").lens("crisp", "yes"))
underTest.snapshots = underTest.snapshots.plus("Orange", Snapshot.of("Orange"))
val buffer = StringBuffer()
underTest.serialize { line -> buffer.append(line) }
buffer.toString() shouldBe
"""
╔═ 📷 com.acme.AcmeTest ═╗
{"header":"data"}
╔═ Apple ═╗
Granny Smith
╔═ Apple[color] ═╗
green
╔═ Apple[crisp] ═╗
yes
╔═ Orange ═╗
Orange
"""
.trimIndent()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* 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.selfie

import io.kotest.matchers.shouldBe
import kotlin.test.Test

class SnapshotReaderTest {
@Test
fun lens() {
val reader =
SnapshotReader(
SnapshotValueReader.of(
"""
╔═ Apple ═╗
Apple
╔═ Apple[color] ═╗
green
╔═ Apple[crisp] ═╗
yes
╔═ Orange ═╗
Orange
"""
.trimIndent()))
reader.peekKey() shouldBe "Apple"
reader.peekKey() shouldBe "Apple"
reader.nextSnapshot() shouldBe Snapshot.of("Apple").lens("color", "green").lens("crisp", "yes")
reader.peekKey() shouldBe "Orange"
reader.peekKey() shouldBe "Orange"
reader.nextSnapshot() shouldBe Snapshot.of("Orange")
reader.peekKey() shouldBe null
}
}

0 comments on commit 0231de8

Please sign in to comment.