From cc3821fa29d1f8d24ebbe8c1274e786fe18933fc Mon Sep 17 00:00:00 2001 From: Fabian Zeindl Date: Fri, 14 Jul 2023 14:57:00 +0200 Subject: [PATCH] [code] performance fixes and better compliance --- .../BufferedSourceExtensions.kt | 64 ++- .../com/fab1an/kotlinjsonstream/Extensions.kt | 8 +- .../com/fab1an/kotlinjsonstream/JsonReader.kt | 400 +++++++++--------- .../com/fab1an/kotlinjsonstream/JsonWriter.kt | 12 +- .../fab1an/kotlinjsonstream/JsonReaderTest.kt | 261 +++++++++++- .../fab1an/kotlinjsonstream/JsonWriterTest.kt | 69 +-- .../kotlinjsonstream/PrettyPrinterTest.kt | 34 +- 7 files changed, 604 insertions(+), 244 deletions(-) diff --git a/src/commonMain/kotlin/com/fab1an/kotlinjsonstream/BufferedSourceExtensions.kt b/src/commonMain/kotlin/com/fab1an/kotlinjsonstream/BufferedSourceExtensions.kt index 3de43bd..c94d7e1 100644 --- a/src/commonMain/kotlin/com/fab1an/kotlinjsonstream/BufferedSourceExtensions.kt +++ b/src/commonMain/kotlin/com/fab1an/kotlinjsonstream/BufferedSourceExtensions.kt @@ -3,8 +3,9 @@ package com.fab1an.kotlinjsonstream import okio.BufferedSource import okio.ByteString import okio.ByteString.Companion.encodeUtf8 +import okio.Options -private val BYTESTRING_ZERO = ByteString.of('0'.code.toByte()) +internal val BYTESTRING_ZERO = ByteString.of('0'.code.toByte()) private val BYTESTRING_ONE = ByteString.of('1'.code.toByte()) private val BYTESTRING_TWO = ByteString.of('2'.code.toByte()) private val BYTESTRING_THREE = ByteString.of('3'.code.toByte()) @@ -14,13 +15,14 @@ private val BYTESTRING_SIX = ByteString.of('6'.code.toByte()) private val BYTESTRING_SEVEN = ByteString.of('7'.code.toByte()) private val BYTESTRING_EIGHT = ByteString.of('8'.code.toByte()) private val BYTESTRING_NINE = ByteString.of('9'.code.toByte()) -private val BYTESTRING_HYPHEN = ByteString.of('-'.code.toByte()) +internal val BYTESTRING_HYPHEN = ByteString.of('-'.code.toByte()) -private val BYTESTRING_TAB = ByteString.of('\t'.code.toByte()) -private val BYTESTRING_CARRIAGE_RETURN = ByteString.of('\r'.code.toByte()) +internal val BYTESTRING_TAB = ByteString.of('\t'.code.toByte()) +internal val BYTESTRING_CARRIAGERETURN = ByteString.of('\r'.code.toByte()) internal val BYTESTRING_NEWLINE = ByteString.of('\n'.code.toByte()) internal val BYTESTRING_SPACE = ByteString.of(' '.code.toByte()) +internal val BYTESTRING_BACKSLASH_OR_DOUBLEQUOTE = "\\\"".encodeUtf8() internal val BYTESTRING_SQUAREBRACKET_OPEN = ByteString.of('['.code.toByte()) internal val BYTESTRING_SQUAREBRACKET_CLOSE = ByteString.of(']'.code.toByte()) internal val BYTESTRING_CURLYBRACKET_OPEN = ByteString.of('{'.code.toByte()) @@ -29,11 +31,44 @@ internal val BYTESTRING_COLON = ByteString.of(':'.code.toByte()) internal val BYTESTRING_DOUBLEQUOTE = ByteString.of('"'.code.toByte()) internal val BYTESTRING_DOT = ByteString.of('.'.code.toByte()) internal val BYTESTRING_COMMA = ByteString.of(','.code.toByte()) +internal val BYTESTRING_BACKSLASH = ByteString.of('\\'.code.toByte()) internal val BYTESTRING_TRUE = "true".encodeUtf8() internal val BYTESTRING_FALSE = "false".encodeUtf8() internal val BYTESTRING_NULL = "null".encodeUtf8() +internal val BYTESTRING_FORWARDSLASH = "/".encodeUtf8() +internal val BYTESTRING_BACKSPACE = "\b".encodeUtf8() +internal val BYTESTRING_FORMFEED = "\u000c".encodeUtf8() + internal val BYTESTRING_ESCAPED_DOUBLEQUOTE = """\"""".encodeUtf8() -internal val BYTESTRING_ESCAPED_FORWARD_SLASH = """\\""".encodeUtf8() +internal val BYTESTRING_ESCAPED_BACKSLASH = """\\""".encodeUtf8() +internal val BYTESTRING_ESCAPED_FORWARDSLASH = """\/""".encodeUtf8() +internal val BYTESTRING_ESCAPED_BACKSPACE = """\b""".encodeUtf8() +internal val BYTESTRING_ESCAPED_FORMFEED = """\f""".encodeUtf8() +internal val BYTESTRING_ESCAPED_NEWLINE = """\n""".encodeUtf8() +internal val BYTESTRING_ESCAPED_CARRIAGERETURN = """\r""".encodeUtf8() +internal val BYTESTRING_ESCAPED_TAB = """\t""".encodeUtf8() +internal val BYTESTRING_START_OF_UNICODE_ESCAPE = """\u""".encodeUtf8() + +private val booleanOptions = Options.of( + BYTESTRING_TRUE, + BYTESTRING_FALSE +) + +internal fun BufferedSource.readJsonBoolean(): Boolean { + return when (select(booleanOptions)) { + 0 -> true + 1 -> false + else -> error("expected true/false, but got: ${readUtf8CodePoint()}") + } +} + +internal fun BufferedSource.nextIsHyphenOrAsciiDigit(): Boolean { + return when { + nextIsAsciiDigit() -> true + rangeEquals(0, BYTESTRING_HYPHEN) -> true + else -> false + } +} internal fun BufferedSource.nextIsAsciiDigit(): Boolean { return when { @@ -47,21 +82,24 @@ internal fun BufferedSource.nextIsAsciiDigit(): Boolean { rangeEquals(0, BYTESTRING_SEVEN) -> true rangeEquals(0, BYTESTRING_EIGHT) -> true rangeEquals(0, BYTESTRING_NINE) -> true - rangeEquals(0, BYTESTRING_HYPHEN) -> true else -> false } } -internal fun BufferedSource.nextIsJsonWhiteSpace(): Boolean { - return when { - rangeEquals(0, BYTESTRING_TAB) -> true - rangeEquals(0, BYTESTRING_NEWLINE) -> true - rangeEquals(0, BYTESTRING_CARRIAGE_RETURN) -> true - rangeEquals(0, BYTESTRING_SPACE) -> true - else -> false +private val whitespaceOptions = Options.of( + BYTESTRING_TAB, + BYTESTRING_CARRIAGERETURN, + BYTESTRING_NEWLINE, + BYTESTRING_SPACE +) + +internal fun BufferedSource.skipWhitespace() { + while (select(whitespaceOptions) != -1) { + // } } + internal fun BufferedSource.nextIs(byteString: ByteString): Boolean { return rangeEquals(0, byteString) } diff --git a/src/commonMain/kotlin/com/fab1an/kotlinjsonstream/Extensions.kt b/src/commonMain/kotlin/com/fab1an/kotlinjsonstream/Extensions.kt index 07f5fad..d41103b 100644 --- a/src/commonMain/kotlin/com/fab1an/kotlinjsonstream/Extensions.kt +++ b/src/commonMain/kotlin/com/fab1an/kotlinjsonstream/Extensions.kt @@ -1,7 +1,7 @@ package com.fab1an.kotlinjsonstream /** - * Writes the [list] of [E] to this [JsonWriter], using the supplied [writerFn]. + * Writes a [list] of [E] to this [JsonWriter], using the supplied [writerFn]. * * @param E the type of the objects * @param list the list of objects @@ -34,11 +34,11 @@ inline fun JsonReader.nextList(readerFn: JsonReader.() -> E): List } /** - * Writes the [set] of [E] to this [JsonWriter], using the supplied [writerFn]. + * Writes a [Set] of [E] to this [JsonWriter], using the supplied [writerFn]. * * @param E the type of the objects * @param set the set of objects - * @param writerFn the writer function that is called using each item of the [list] as input + * @param writerFn the writer function that is called using each item of the [set] as input */ inline fun JsonWriter.value(set: Set, writerFn: JsonWriter.(E) -> Unit) { beginArray() @@ -47,7 +47,7 @@ inline fun JsonWriter.value(set: Set, writerFn: JsonWriter.(E) -> U } /** - * Reads a [Set] of [E] from this [JsonReader]. This function assumes it is called at the start + * Reads a [set] of [E] from this [JsonReader]. This function assumes it is called at the start * of a JSON-array and reads each element using the supplied [readerFn]. * * @param E the type of the objects diff --git a/src/commonMain/kotlin/com/fab1an/kotlinjsonstream/JsonReader.kt b/src/commonMain/kotlin/com/fab1an/kotlinjsonstream/JsonReader.kt index 4550c0a..3d8f773 100644 --- a/src/commonMain/kotlin/com/fab1an/kotlinjsonstream/JsonReader.kt +++ b/src/commonMain/kotlin/com/fab1an/kotlinjsonstream/JsonReader.kt @@ -1,5 +1,10 @@ package com.fab1an.kotlinjsonstream +import com.fab1an.kotlinjsonstream.JsonReader.OpenToken.BEGIN_ARRAY +import com.fab1an.kotlinjsonstream.JsonReader.OpenToken.BEGIN_OBJECT +import com.fab1an.kotlinjsonstream.JsonReader.OpenToken.BEGIN_PROPERTY +import com.fab1an.kotlinjsonstream.JsonReader.OpenToken.CONTINUE_ARRAY +import com.fab1an.kotlinjsonstream.JsonReader.OpenToken.CONTINUE_OBJECT import okio.Buffer import okio.BufferedSource import okio.ByteString @@ -19,49 +24,31 @@ class JsonReader(private val source: BufferedSource) { * Creates a new instance that reads a JSON-encoded stream from [jsonStr]. */ constructor(jsonStr: String) : this( - Buffer().apply { write(jsonStr.encodeToByteArray()) } + Buffer().apply { writeUtf8(jsonStr) } ) private val stack = mutableListOf() - init { - consumeWhitespace() - } - /** * Consumes the next token from the JSON stream and asserts that it is the beginning of a new array. */ fun beginArray() { - beginArrayOrObject() - stack.add(OpenToken.BEGIN_ARRAY) - - consumeSpecific(BYTESTRING_SQUAREBRACKET_OPEN) - consumeWhitespace() - } + skipWhitespaceAndOptionalComma() + expectValue() + stack.add(BEGIN_ARRAY) - private fun beginArrayOrObject() { - check(stack.lastOrNull().let { - it == OpenToken.BEGIN_ARRAY - || it == OpenToken.BEGIN_PROPERTY - || it == null - }) { - stack.lastOrNull() ?: "empty" - } - if (stack.lastOrNull() == OpenToken.BEGIN_PROPERTY) { - stack.removeLast() - } + skipSpecific(BYTESTRING_SQUAREBRACKET_OPEN) } - /** * Consumes the next token from the JSON stream and asserts that it is the beginning of a new object. */ fun beginObject() { - beginArrayOrObject() - stack.add(OpenToken.BEGIN_OBJECT) + skipWhitespaceAndOptionalComma() + expectValue() + stack.add(BEGIN_OBJECT) - consumeSpecific(BYTESTRING_CURLYBRACKET_OPEN) - consumeWhitespace() + skipSpecific(BYTESTRING_CURLYBRACKET_OPEN) } /** @@ -75,36 +62,38 @@ class JsonReader(private val source: BufferedSource) { * Consumes the next token from the JSON stream and asserts that it is the end of the current array. */ fun endArray() { - check(stack.lastOrNull() == OpenToken.BEGIN_ARRAY) { - stack.lastOrNull() ?: "empty" + when (stack.lastOrNull()) { + BEGIN_ARRAY, CONTINUE_ARRAY -> stack.removeLast() + else -> error("stack is ${stack.lastOrNull()}") } - stack.removeLast() - consumeSpecific(BYTESTRING_SQUAREBRACKET_CLOSE) - consumeWhitespaceAndOptionalComma() + source.skipWhitespace() + skipSpecific(BYTESTRING_SQUAREBRACKET_CLOSE) } /** * Consumes the next token from the JSON stream and asserts that it is the end of the current object. */ fun endObject() { - check(stack.lastOrNull() == OpenToken.BEGIN_OBJECT) { - stack.lastOrNull() ?: "empty" + when (stack.lastOrNull()) { + BEGIN_OBJECT, CONTINUE_OBJECT -> stack.removeLast() + else -> error("stack is ${stack.lastOrNull()}") } - stack.removeLast() - consumeSpecific(BYTESTRING_CURLYBRACKET_CLOSE) - consumeWhitespaceAndOptionalComma() + source.skipWhitespace() + skipSpecific(BYTESTRING_CURLYBRACKET_CLOSE) } /** * Returns true if the current array or object has another element */ fun hasNext(): Boolean { + source.skipWhitespace() return when (stack.lastOrNull()) { - OpenToken.BEGIN_OBJECT -> peek() != JsonToken.END_OBJECT - OpenToken.BEGIN_ARRAY -> peek() != JsonToken.END_ARRAY - OpenToken.BEGIN_PROPERTY, null -> error("not inside array/object") + CONTINUE_OBJECT, CONTINUE_ARRAY -> source.nextIs(BYTESTRING_COMMA) + BEGIN_OBJECT -> !source.nextIs(BYTESTRING_CURLYBRACKET_CLOSE) + BEGIN_ARRAY -> !source.nextIs(BYTESTRING_SQUAREBRACKET_CLOSE) + BEGIN_PROPERTY, null -> error("not inside array/object") } } @@ -112,53 +101,41 @@ class JsonReader(private val source: BufferedSource) { * Returns the Boolean value of the next token, consuming it. */ fun nextBoolean(): Boolean { + expectNotTopLevel() + skipWhitespaceAndOptionalComma() expectValue() - /* value */ - val boolVal = when { - source.nextIs(BYTESTRING_TRUE) -> { - source.skip(4) - true - } - - source.nextIs(BYTESTRING_FALSE) -> { - source.skip(5) - false - } - - // tochars - else -> error("not a boolean: '${source.readUtf8CodePoint().toChar()}'") - } - - /* whitespace and comma */ - consumeWhitespaceAndOptionalComma() - - return boolVal + return source.readJsonBoolean() } /** * Returns the [Double] value of the next token, consuming it. */ fun nextDouble(): Double { + return nextDouble(skipDouble = false)!! + } + + private fun nextDouble(skipDouble: Boolean): Double? { + expectNotTopLevel() + skipWhitespaceAndOptionalComma() expectValue() /* first part value */ val partBeforeComma = source.readDecimalLong() - var partAfterComma: Long? = null - if (source.nextIs(BYTESTRING_DOT)) { + return if (source.nextIs(BYTESTRING_DOT)) { source.skip(1) - partAfterComma = source.readDecimalLong() - } - - /* whitespace and comma */ - consumeWhitespaceAndOptionalComma() - - return if (partAfterComma == null) { - partBeforeComma.toDouble() + val partAfterComma = source.readDecimalLong() + if (skipDouble) + null + else + "$partBeforeComma.$partAfterComma".toDouble() } else { - "$partBeforeComma.$partAfterComma".toDouble() + if (skipDouble) + null + else + partBeforeComma.toDouble() } } @@ -166,55 +143,56 @@ class JsonReader(private val source: BufferedSource) { * Returns the Int value of the next token, consuming it. */ fun nextInt(): Int { - expectValue() - - /* int value */ - val value = source.readDecimalLong() - - /* whitespace and comma */ - consumeWhitespaceAndOptionalComma() - - return value.toInt() + return nextLong().toInt() } /** * Returns the Long value of the next token, consuming it. */ fun nextLong(): Long { - expectValue() - - /* int value */ - val value = source.readDecimalLong() - - /* whitespace and comma */ - consumeWhitespaceAndOptionalComma() - - return value + val double = nextDouble() + check(double.rem(1) == 0.0) { "number has fraction part: $double" } + return double.toLong() } /** * Returns the name of the next property, consuming it and asserting that this reader is inside an object. */ fun nextName(): String { - check(stack.lastOrNull() == OpenToken.BEGIN_OBJECT) { - stack.lastOrNull() ?: "empty" - } - stack.add(OpenToken.BEGIN_PROPERTY) + return nextName(skipName = false)!! + } + + /** + * Returns the name of the next property, consuming it and asserting that this reader is inside an object. + */ + private fun nextName(skipName: Boolean): String? { + skipWhitespaceAndOptionalComma() + check(stack.lastOrNull() == BEGIN_OBJECT) { "stack is ${stack.lastOrNull()}" } /* opening quote */ - consumeSpecific(BYTESTRING_DOUBLEQUOTE) + skipSpecific(BYTESTRING_DOUBLEQUOTE) /* property name */ val indexOfEnd = source.indexOf(BYTESTRING_DOUBLEQUOTE) - val name = source.readUtf8(indexOfEnd) + check(indexOfEnd >= 0) { "did not find ending double-quote '\"'" } + val name: String? = + if (!skipName) { + source.readUtf8(indexOfEnd) + + } else { + source.skip(indexOfEnd) + null + } /* closing quote */ - consumeSpecific(BYTESTRING_DOUBLEQUOTE) + skipSpecific(BYTESTRING_DOUBLEQUOTE) + + /* whitespace and colon */ + source.skipWhitespace() + skipSpecific(BYTESTRING_COLON) - /* colon and whitespace */ - consumeWhitespace() - consumeSpecific(BYTESTRING_COLON) - consumeWhitespace() + /* update stack */ + stack.add(BEGIN_PROPERTY) return name } @@ -223,55 +201,109 @@ class JsonReader(private val source: BufferedSource) { * Asserts that the next property-value or array-item is null and consumes the token. */ fun nextNull() { - check(peek() == JsonToken.NULL) - source.skip(4) + expectNotTopLevel() + skipWhitespaceAndOptionalComma() + expectValue() + + skipSpecific(BYTESTRING_NULL) } /** * Returns the String of the next token, consuming it. */ fun nextString(): String { + return nextString(skipString = false)!! + } + + private fun nextString(skipString: Boolean): String? { + expectNotTopLevel() + skipWhitespaceAndOptionalComma() expectValue() /* opening quote */ - consumeSpecific(BYTESTRING_DOUBLEQUOTE) - - /* string value */ - val value = StringBuilder() - while (!source.exhausted()) { - if (source.nextIs(BYTESTRING_ESCAPED_DOUBLEQUOTE)) { - source.skip(2) - value.append('"') - - } else if (source.nextIs(BYTESTRING_ESCAPED_FORWARD_SLASH)) { - source.skip(2) - value.append('\\') - - } else if (!source.nextIs(BYTESTRING_DOUBLEQUOTE)) { - value.append(source.readUtf8CodePoint().toChar()) - - } else { - break + skipSpecific(BYTESTRING_DOUBLEQUOTE) + + val buffer: Buffer? = if (skipString) null else Buffer() + while (true) { + + /* read until backslash or double quote */ + val nextStop = source.indexOfElement(BYTESTRING_BACKSLASH_OR_DOUBLEQUOTE) + check(nextStop >= 0) { "string ended prematurely" } + + if (buffer != null) + buffer.write(source, nextStop) + else + source.skip(nextStop) + + when { + source.nextIs(BYTESTRING_ESCAPED_BACKSLASH) -> { + source.skip(2) + buffer?.write(BYTESTRING_BACKSLASH) + } + + source.nextIs(BYTESTRING_ESCAPED_DOUBLEQUOTE) -> { + source.skip(2) + buffer?.write(BYTESTRING_DOUBLEQUOTE) + } + + source.nextIs(BYTESTRING_ESCAPED_FORWARDSLASH) -> { + source.skip(2) + buffer?.write(BYTESTRING_FORWARDSLASH) + } + + source.nextIs(BYTESTRING_ESCAPED_BACKSPACE) -> { + source.skip(2) + buffer?.write(BYTESTRING_BACKSPACE) + } + + source.nextIs(BYTESTRING_ESCAPED_FORMFEED) -> { + source.skip(2) + buffer?.write(BYTESTRING_FORMFEED) + } + + source.nextIs(BYTESTRING_ESCAPED_NEWLINE) -> { + source.skip(2) + buffer?.write(BYTESTRING_NEWLINE) + } + + source.nextIs(BYTESTRING_ESCAPED_CARRIAGERETURN) -> { + source.skip(2) + buffer?.write(BYTESTRING_CARRIAGERETURN) + } + + source.nextIs(BYTESTRING_ESCAPED_TAB) -> { + source.skip(2) + buffer?.write(BYTESTRING_TAB) + } + + source.nextIs(BYTESTRING_START_OF_UNICODE_ESCAPE) -> { + source.skip(2) + val charVal = source.readHexadecimalUnsignedLong().toInt().toChar() + check(charVal <= Char.MAX_VALUE) + buffer?.writeUtf8CodePoint(charVal.code) + } + + else -> { + break + } } } /* closing quote */ - consumeSpecific(BYTESTRING_DOUBLEQUOTE) - - /* whitespace and comma */ - consumeWhitespaceAndOptionalComma() + skipSpecific(BYTESTRING_DOUBLEQUOTE) - return value.toString() + return buffer?.readUtf8() } /** * Returns the next token without consuming it. */ fun peek(): JsonToken { + skipWhitespaceAndOptionalComma() + return when { source.exhausted() -> JsonToken.END_DOCUMENT - stack.lastOrNull() == OpenToken.BEGIN_OBJECT && source.nextIs(BYTESTRING_DOUBLEQUOTE) -> JsonToken.NAME - source.nextIs(BYTESTRING_DOUBLEQUOTE) -> JsonToken.STRING + source.nextIs(BYTESTRING_DOUBLEQUOTE) -> if (stack.lastOrNull() == BEGIN_OBJECT) JsonToken.NAME else JsonToken.STRING source.nextIs(BYTESTRING_CURLYBRACKET_OPEN) -> JsonToken.BEGIN_OBJECT source.nextIs(BYTESTRING_CURLYBRACKET_CLOSE) -> JsonToken.END_OBJECT source.nextIs(BYTESTRING_SQUAREBRACKET_OPEN) -> JsonToken.BEGIN_ARRAY @@ -279,8 +311,8 @@ class JsonReader(private val source: BufferedSource) { source.nextIs(BYTESTRING_NULL) -> JsonToken.NULL source.nextIs(BYTESTRING_TRUE) -> JsonToken.BOOLEAN source.nextIs(BYTESTRING_FALSE) -> JsonToken.BOOLEAN - source.nextIsAsciiDigit() -> JsonToken.NUMBER - else -> error("unknown next token: '${source.readUtf8CodePoint().toChar()}'") + source.nextIsHyphenOrAsciiDigit() -> JsonToken.NUMBER + else -> error("unexpected next character: '${source.readUtf8CodePoint().toChar()}'") } } @@ -288,98 +320,80 @@ class JsonReader(private val source: BufferedSource) { * Skips the next value recursively. This method asserts it is inside an array or inside an object. */ fun skipValue() { - check(stack.lastOrNull().let { - it == OpenToken.BEGIN_ARRAY - || it == OpenToken.BEGIN_PROPERTY - }) { - stack.lastOrNull() ?: "empty" - } - - if (peek() == JsonToken.NULL) { - nextNull() - - if (stack.lastOrNull() == OpenToken.BEGIN_PROPERTY) { - stack.removeLast() + when (peek()) { + JsonToken.BEGIN_ARRAY -> { + beginArray() + while (hasNext()) { + skipValue() + } + endArray() } - /* whitespace and comma */ - consumeWhitespaceAndOptionalComma() - - } else if (peek() == JsonToken.BOOLEAN) { - nextBoolean() - - } else if (peek() == JsonToken.STRING) { - nextString() - - } else if (peek() == JsonToken.BEGIN_OBJECT) { - beginObject() - while (hasNext()) { - nextName() - skipValue() + JsonToken.BEGIN_OBJECT -> { + beginObject() + while (hasNext()) { + nextName(skipName = true) + skipValue() + } + endObject() } - endObject() - } else if (peek() == JsonToken.BEGIN_ARRAY) { - beginArray() - while (hasNext()) { - skipValue() - } - endArray() + JsonToken.BOOLEAN -> nextBoolean() + JsonToken.NULL -> nextNull() + JsonToken.NUMBER -> nextDouble(skipDouble = true) + JsonToken.STRING -> nextString(skipString = true) - } else { - nextDouble() + JsonToken.END_DOCUMENT, JsonToken.END_ARRAY, JsonToken.END_OBJECT, JsonToken.NAME -> error("unexpected next tooken: '${peek()}'") } } + private fun expectNotTopLevel() { + check(stack.isNotEmpty()) { "top-level, call to beginObject() or beginArray() expected" } + } + private fun expectValue() { - check(stack.lastOrNull().let { - it == OpenToken.BEGIN_ARRAY - || it == OpenToken.BEGIN_PROPERTY - }) { - stack.lastOrNull() ?: "empty stack" - } - if (stack.lastOrNull() == OpenToken.BEGIN_PROPERTY) { - stack.removeLast() + when (stack.lastOrNull()) { + BEGIN_OBJECT, CONTINUE_OBJECT -> error("inside object, call to nextName() expected") + BEGIN_PROPERTY -> { + stack.removeLast() + stack[stack.lastIndex] = CONTINUE_OBJECT + } + + BEGIN_ARRAY -> stack[stack.lastIndex] = CONTINUE_ARRAY + CONTINUE_ARRAY -> error("should not happen") + null -> Unit } } - private fun consumeWhitespaceAndOptionalComma() { - - /* whitespace and comma */ - consumeWhitespace() + private fun skipWhitespaceAndOptionalComma() { + source.skipWhitespace() if (source.nextIs(BYTESTRING_COMMA)) { - source.skip(1) - consumeWhitespace() - - } else { - - if (stack.lastOrNull() == OpenToken.BEGIN_ARRAY) { - check(peek() == JsonToken.END_ARRAY) { "']' expected" } - } else if (stack.lastOrNull() == OpenToken.BEGIN_OBJECT) { - check(peek() == JsonToken.END_OBJECT) { "'}' expected" } + when (stack.lastOrNull()) { + CONTINUE_ARRAY -> stack[stack.lastIndex] = BEGIN_ARRAY + CONTINUE_OBJECT -> stack[stack.lastIndex] = BEGIN_OBJECT + else -> error("stack is ${stack.lastOrNull()}") } - } - } - private fun consumeWhitespace() { - while (!source.exhausted() && source.nextIsJsonWhiteSpace()) { - source.skip(1) + skipSpecific(BYTESTRING_COMMA) + source.skipWhitespace() } } - private fun consumeSpecific(byteString: ByteString) { + private fun skipSpecific(byteString: ByteString) { check(!source.exhausted()) { - "'${byteString.utf8()}' expected, source is exhausted" + "'${byteString.utf8()}' expected, but source is exhausted" } check(source.nextIs(byteString)) { "'${byteString.utf8()}' expected, but got: '${source.readUtf8CodePoint().toChar()}'" } - source.skip(1) + source.skip(byteString.size.toLong()) } private enum class OpenToken { BEGIN_OBJECT, + CONTINUE_OBJECT, BEGIN_ARRAY, + CONTINUE_ARRAY, BEGIN_PROPERTY } } diff --git a/src/commonMain/kotlin/com/fab1an/kotlinjsonstream/JsonWriter.kt b/src/commonMain/kotlin/com/fab1an/kotlinjsonstream/JsonWriter.kt index 2fdbba3..dace21a 100644 --- a/src/commonMain/kotlin/com/fab1an/kotlinjsonstream/JsonWriter.kt +++ b/src/commonMain/kotlin/com/fab1an/kotlinjsonstream/JsonWriter.kt @@ -171,7 +171,17 @@ class JsonWriter(private val sink: BufferedSink, val prettyPrint: Boolean = fals expectValue() if (value != null) { sink.write(BYTESTRING_DOUBLEQUOTE) - sink.writeUtf8(value.replace("\"", "\\\"")) + sink.writeUtf8( + value + .replace("\\", """\\""") + .replace("\"", """\"""") + .replace("/", """\/""") + .replace("\b", """\b""") + .replace("\u000c", """\f""") + .replace("\n", """\n""") + .replace("\r", """\r""") + .replace("\t", """\t""") + ) sink.write(BYTESTRING_DOUBLEQUOTE) } else { sink.write(BYTESTRING_NULL) diff --git a/src/commonTest/kotlin/com/fab1an/kotlinjsonstream/JsonReaderTest.kt b/src/commonTest/kotlin/com/fab1an/kotlinjsonstream/JsonReaderTest.kt index bf2fa2a..c1484b7 100644 --- a/src/commonTest/kotlin/com/fab1an/kotlinjsonstream/JsonReaderTest.kt +++ b/src/commonTest/kotlin/com/fab1an/kotlinjsonstream/JsonReaderTest.kt @@ -1,10 +1,16 @@ package com.fab1an.kotlinjsonstream +import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertFailsWith class JsonReaderTest { + @Test + fun closeSource() { + JsonReader("""{}""").close() + } + @Test fun readJson() { val json = JsonReader("""{"stringProp":"string", "intProp":0}""") @@ -27,6 +33,66 @@ class JsonReaderTest { json.endObject() } + @Test + @Ignore + fun readExponents() { + val json = JsonReader("""[1.0E+2,1.0e+2,1E+0,1.2E-2]""") + + json.beginArray() + json.nextInt() shouldEqual 100 + json.nextInt() shouldEqual 100 + json.nextInt() shouldEqual 1 + json.nextDouble() shouldEqual 0.012 + json.endArray() + } + + @Test + @Ignore + fun errorOnInvalidNumber() { + JsonReader("""[.1]""").apply { + beginArray() + assertFailsWith { + nextInt() + } + } + JsonReader("""[00]""").apply { + beginArray() + assertFailsWith { + nextInt() + } + } + JsonReader("""[-00]""").apply { + beginArray() + assertFailsWith { + nextInt() + } + } + JsonReader("""[1.0EE+2]""").apply { + beginArray() + assertFailsWith { + nextDouble() + } + } + JsonReader("""[1.0f+2]""").apply { + beginArray() + assertFailsWith { + nextDouble() + } + } + JsonReader("""[1.0e]""").apply { + beginArray() + assertFailsWith { + nextDouble() + } + } + JsonReader("""[1.0f]""").apply { + beginArray() + assertFailsWith { + nextDouble() + } + } + } + @Test fun readBoolean() { var json = JsonReader("""{"boolProp":true}""") @@ -76,9 +142,36 @@ class JsonReaderTest { val json = JsonReader("""[1 "zwei"]""") json.beginArray() + json.nextInt() shouldEqual 1 + + assertFailsWith { + json.nextString() + } + } + + @Test + fun peekNameInObject() { + val json = JsonReader(""" { "p1": 0, "p2": 1 } """) + + json.beginObject() + json.peek() shouldEqual JsonToken.NAME + json.nextName() shouldEqual "p1" + json.skipValue() + json.peek() shouldEqual JsonToken.NAME + json.nextName() shouldEqual "p2" + json.skipValue() + json.endObject() + } + + @Test + fun errorNoCommaInObject() { + val json = JsonReader("""{"value1":0 "value2":1}""") + + json.beginObject() + json.nextName() shouldEqual "value1" + json.nextInt() shouldEqual 0 assertFailsWith { - json.nextInt() shouldEqual 1 - json.nextString() shouldEqual "zwei" + json.nextName() } } @@ -100,7 +193,7 @@ class JsonReaderTest { json.beginObject() assertFailsWith { - json.nextName() shouldEqual 5 + json.nextName() } } @@ -149,7 +242,7 @@ class JsonReaderTest { } @Test - fun checkNextNull() { + fun skipNullValue() { JsonReader("""{"temp": null}""").apply { beginObject() nextName() shouldEqual "temp" @@ -170,6 +263,35 @@ class JsonReaderTest { } } + @Test + fun skipTopLevelStringNumber() { + val json = JsonReader("""5""") + + assertFailsWith { + json.skipValue() + } + } + + @Test + fun failOnSkippingInvalidNumber() { + val json = JsonReader("""[--5]""") + + json.beginArray() + assertFailsWith { + json.skipValue() + } + } + + @Test + fun failOnInvalidNumber() { + val json = JsonReader("""[--5]""") + + json.beginArray() + assertFailsWith { + json.nextInt() + } + } + @Test fun skipBoolValue() { val json = JsonReader("""{"val1": true, "val2": false, "val3": "string"}""") @@ -206,7 +328,18 @@ class JsonReaderTest { } @Test - fun skipArrayValue() { + fun skipValueAtBeginOfArray() { + val json = JsonReader("""[2,"b",3]""") + + json.beginArray() + json.nextInt() shouldEqual 2 + json.skipValue() + json.nextInt() shouldEqual 3 + json.endArray() + } + + @Test + fun skipValueInsideArray() { val json = JsonReader("""[2,"b",3]""") json.beginArray() @@ -218,10 +351,10 @@ class JsonReaderTest { @Test fun escapedString() { - val json = JsonReader("""[ "Escaped \" Character" ]""") + val json = JsonReader("""[ "Escaped: \",\\,\/,\b,\f,\n,\r,\t,\u0000,\uFFFF" ]""") json.beginArray() - json.nextString() shouldEqual "Escaped \" Character" + json.nextString() shouldEqual "Escaped: \",\\,/,\b,\u000c,\n,\r,\t,\u0000,\uFFFF" json.endArray() } @@ -234,4 +367,118 @@ class JsonReaderTest { json.nextString() shouldEqual """C:\PROGRA~1\""" json.endObject() } + + @Test + fun readNullInObject() { + val json = JsonReader(""" { "title": null } """) + + json.beginObject() + json.nextName() shouldEqual "title" + json.nextNull() + json.endObject() + } + + @Test + fun readNullInArray() { + val json = JsonReader(""" [1,null,3] """) + + json.beginArray() + json.nextInt() shouldEqual 1 + json.nextNull() + json.nextInt() shouldEqual 3 + json.endArray() + } + + @Test + fun readLong() { + val json = JsonReader(""" [${Long.MAX_VALUE}] """) + + json.beginArray() + json.nextLong() shouldEqual Long.MAX_VALUE + json.endArray() + } + + @Test + fun noLeadingCommaInArray() { + val json = JsonReader("""[,0]""") + + json.beginArray() + assertFailsWith { + json.nextInt() + } + } + + @Test + fun noLeadingCommaInObject() { + val json = JsonReader("""{,"value": 0}""") + + json.beginObject() + assertFailsWith { + json.nextName() + } + } + + @Test + fun noTrailingCommaInArray() { + val json = JsonReader("""[0,]""") + + json.beginArray() + json.nextInt() shouldEqual 0 + + assertFailsWith { + json.endArray() + } + } + + @Test + fun noTrailingCommaInNestedArray() { + val json = JsonReader("""[[],]""") + + json.beginArray() + json.beginArray() + json.endArray() + + assertFailsWith { + json.endArray() + } + } + + @Test + fun noTrailingCommaInObject() { + val json = JsonReader("""{"value": 0,}""") + + json.beginObject() + json.nextName() shouldEqual "value" + json.nextInt() shouldEqual 0 + + assertFailsWith { + json.endObject() + } + } + + @Test + fun noTrailingCommaInNestedObject() { + val json = JsonReader("""{"value": {},}""") + + json.beginObject() + json.nextName() shouldEqual "value" + json.beginObject() + json.endObject() + + assertFailsWith { + json.endObject() + } + } + + @Test + fun numberInObject() { + val json = JsonReader("""{"value": 1,2}""") + + json.beginObject() + json.nextName() shouldEqual "value" + json.nextInt() shouldEqual 1 + assertFailsWith { + json.nextInt() + } + } } diff --git a/src/commonTest/kotlin/com/fab1an/kotlinjsonstream/JsonWriterTest.kt b/src/commonTest/kotlin/com/fab1an/kotlinjsonstream/JsonWriterTest.kt index a1c07f0..d7de83a 100644 --- a/src/commonTest/kotlin/com/fab1an/kotlinjsonstream/JsonWriterTest.kt +++ b/src/commonTest/kotlin/com/fab1an/kotlinjsonstream/JsonWriterTest.kt @@ -16,8 +16,6 @@ class JsonWriterTest { value("item1") endArray() endObject() - - close() } buffer.readUtf8() shouldEqual """ @@ -43,59 +41,80 @@ class JsonWriterTest { } @Test - fun writeDoubleZero() { + fun valueDirectlyInObject() { + assertFailsWith { + val buffer = Buffer() + + JsonWriter(buffer).apply { + beginObject() + value("") + } + } + } + + @Test + fun quoteInScript() { val buffer = Buffer() JsonWriter(buffer).apply { - beginArray() - value(0.0) - endArray() + beginObject() + name("string").value("a \" quote") + endObject() } buffer.readUtf8() shouldEqual """ - [0] + {"string":"a \" quote"} """.trimIndent() } @Test - fun writeLong() { - val buffer = Buffer() + fun escapeString() { + val stringWithEscapeSequences = "Escaped: \",\\,/,\b,\u000c,\n,\r,\t,\u0000,\uFFFF" + val buffer = Buffer() JsonWriter(buffer).apply { beginArray() - value(1L) + value(stringWithEscapeSequences) endArray() } - buffer.readUtf8() shouldEqual """ - [1] - """.trimIndent() + val jsonString = buffer.readUtf8() + jsonString shouldEqual """["Escaped: \",\\,\/,\b,\f,\n,\r,\t,${"\u0000"},${"\uFFFF"}"]""" + + JsonReader(jsonString).apply { + beginArray() + nextString() shouldEqual stringWithEscapeSequences + endArray() + } } @Test - fun valueDirectlyInObject() { - assertFailsWith { - val buffer = Buffer() + fun writeDoubleZero() { + val buffer = Buffer() - JsonWriter(buffer).apply { - beginObject() - value("") - } + JsonWriter(buffer).apply { + beginArray() + value(0.0) + endArray() } + + buffer.readUtf8() shouldEqual """ + [0] + """.trimIndent() } @Test - fun quoteInScript() { + fun writeLong() { val buffer = Buffer() JsonWriter(buffer).apply { - beginObject() - name("string").value("a \" quote") - endObject() + beginArray() + value(1L) + endArray() } buffer.readUtf8() shouldEqual """ - {"string":"a \" quote"} + [1] """.trimIndent() } } diff --git a/src/commonTest/kotlin/com/fab1an/kotlinjsonstream/PrettyPrinterTest.kt b/src/commonTest/kotlin/com/fab1an/kotlinjsonstream/PrettyPrinterTest.kt index e61f247..23a910e 100644 --- a/src/commonTest/kotlin/com/fab1an/kotlinjsonstream/PrettyPrinterTest.kt +++ b/src/commonTest/kotlin/com/fab1an/kotlinjsonstream/PrettyPrinterTest.kt @@ -4,6 +4,31 @@ import kotlin.test.Test class PrettyPrinterTest { + @Test + fun prettyPrintArray() { + val json = """[ "a", "b", 1 ]""" + + prettyPrintJson(json) shouldEqual """ + [ + "a", + "b", + 1 + ] + """.trimIndent() + } + + @Test + fun prettyPrintSimple() { + val json = """{ "a": "Str", "b": 1 }""" + + prettyPrintJson(json) shouldEqual """ + { + "a": "Str", + "b": 1 + } + """.trimIndent() + } + @Test fun testPrettyPrint() { val json = """{ "a": "Str", "b": [1, 2, 3], "c": [{"c1":1}, {"c2":2}] }""" @@ -30,7 +55,14 @@ class PrettyPrinterTest { @Test fun testNestedAndEmptyArray() { - val json = """{ "a1": [], "a": [[], [], [{"c":[]}]]}""" + val json = + // language=json + """ + { + "a1": [], + "a": [[], [], [{"c":[]}]] + } + """.trimIndent() prettyPrintJson(json) shouldEqual """ {