diff --git a/ktmidi/src/commonMain/kotlin/dev/atsushieno/ktmidi/ci/Json.kt b/ktmidi/src/commonMain/kotlin/dev/atsushieno/ktmidi/ci/Json.kt new file mode 100644 index 000000000..a4aebc130 --- /dev/null +++ b/ktmidi/src/commonMain/kotlin/dev/atsushieno/ktmidi/ci/Json.kt @@ -0,0 +1,189 @@ +package dev.atsushieno.ktmidi.ci + +class JsonParserException(message: String = "JSON parser exception", innerException: Exception? = null) : Exception(message, innerException) + +object Json { + enum class TokenType { Null, False, True, Number, String, Array, Object } + + val emptySequence = sequenceOf() + val emptyMap = mapOf() + data class JsonToken(val type: TokenType, val offset: Int, val length: Int, val number: Double = 0.0, val seq: Sequence = emptySequence, val map: Map = emptyMap) + data class JsonValue(val source: String, val token: JsonToken) + + fun parse(source: String) = parse(source, 0, source.length) + + private val splitChecked = charArrayOf(',', '{', '[', '}', ']', '"', ':') + private fun splitEntries(source: String, offset: Int, length: Int, isMap: Boolean): Sequence = sequence { + val range = IntRange(offset, offset + length - 1) + val sliced = source.slice(range) + val pos = sliced.indexOf(',') + offset + if (pos < offset) { + if (isMap && skipWhitespace(sliced, offset) == sliced.length) + return@sequence + else if (!isMap) { + yield(parse(source, offset, length) as T) + return@sequence + } + } + + // there might be commas within nested split or string literal + var lastTokenPos = offset + var p = offset + val end = offset + length + var inQuote = false + var openBrace = 0 + var openCurly = 0 + var key: JsonValue? = null // object key + + while (p < end) { + var t = source.indexOfAny(splitChecked, p) + if (t < 0 || t >= end) { + if (inQuote || openCurly > 0 || openBrace > 0) + throw JsonParserException("Incomplete content within ${if (isMap) "object" else "array"} (begins at $offset)") + } + else when (source[t]) { + '[' -> if (!inQuote) openBrace++ + ']' -> if (!inQuote) openBrace-- + '{' -> if (!inQuote) openCurly++ + '}' -> if (!inQuote) openCurly-- + '\\' -> if (inQuote) t++ // skip next character, which may be " + '"' -> inQuote = !inQuote + ':' -> if (isMap && openBrace == 0 && openCurly == 0 && !inQuote) { + key = parse(source, offset, t - offset) + lastTokenPos = t + 1 + } + ',' -> if (openBrace == 0 && openCurly == 0 && !inQuote) { + val entryOrKey = parse(source, lastTokenPos, t - lastTokenPos) + if (isMap) { + if (key == null) + key = entryOrKey + else + yield(Pair(key, entryOrKey) as T) + } + else + yield(entryOrKey as T) + splitEntries(source, t + 1, length - (t - offset) - 1, isMap).forEach { yield(it) } + return@sequence + } + else -> {} + } + p = t + 1 + } + + val entryOrKey = parse(source, lastTokenPos, length - (lastTokenPos - offset)) + if (isMap) { + if (key == null) + throw JsonParserException("An entry in JSON object misses the key (begins at $offset)") + else + yield(Pair(key, entryOrKey) as T) + } + else + yield(entryOrKey as T) + } + + private fun parse(source: String, offset: Int, length: Int) : JsonValue { + val pos = skipWhitespace(source, offset) + if (pos == source.length) + throw JsonParserException("Unexpected empty content in JSON (at offset $offset)") + return when (source[pos]) { + '{' -> { + val start = skipWhitespace(source, pos + 1) + val end = source.lastIndexOf('}', start + length - (start - offset)) + checkRange(source, offset, length, pos, end, "Incomplete JSON object token") + JsonValue(source, JsonToken(TokenType.Object, pos, end - pos + 1, map = splitEntries>(source, start, end - start, true).toMap())) + } + '[' -> { + val start = skipWhitespace(source, pos + 1) + val end = source.lastIndexOf(']', start + length - (start - offset)) + checkRange(source, offset, length, pos, end, "Incomplete JSON array token") + JsonValue(source, JsonToken(TokenType.Array, pos, end - pos + 1, seq = splitEntries(source, start, end - start, false))) + } + '"' -> { + val end = findStringTerminator(source, pos + 1, length - (pos - offset - 1)) + checkRange(source, offset, length, pos, end, "Incomplete JSON string token") + JsonValue(source, JsonToken(TokenType.String, pos, end - pos + 1)) + } + '-', in '9' downTo '0' -> { + /* + val neg = source[pos] == '-' + val start = if (neg) pos + 1 else pos + val end = offset + length - (start - offset) + val value = source.slice(IntRange(start, end - 1)).toDouble() + */ + // FIXME: it does not strictly conform to the JSON number specification. + val range = IntRange(pos, pos + length - (pos - offset) - 1) + val sliced = source.slice(range) + val value = sliced.toDouble() + JsonValue(source, JsonToken(TokenType.Number, range.first, range.last - range.first + 1, number = value)) + } + 'n' -> { + if (source.slice(IntRange(pos, pos + length - (pos - offset) - 1)) != "null") + throw JsonParserException("Unexpected token in JSON (at offset $offset)") + JsonValue(source, JsonToken(TokenType.Null, offset, length)) + } + 't' -> { + if (source.slice(IntRange(pos, pos + length - (pos - offset) - 1)) != "true") + throw JsonParserException("Unexpected token in JSON (at offset $offset)") + JsonValue(source, JsonToken(TokenType.True, offset, length)) + } + 'f' -> { + if (source.slice(IntRange(pos, pos + length - (pos - offset) - 1)) != "false") + throw JsonParserException("Unexpected token in JSON (at offset $offset)") + JsonValue(source, JsonToken(TokenType.False, offset, length)) + } + else -> throw JsonParserException("Unexpected character in JSON (at offset $offset)") + } + } + + private fun checkRange(source: String, offset: Int, length: Int, pos: Int, end: Int, incompleteError: String) { + if (end < 0 || end > skipWhitespace(source, offset + length)) + throw JsonParserException("$incompleteError (begins at offset $pos)") + if (skipWhitespace(source, end + 1) < offset + length) + throw JsonParserException("Extraneous JSON token (begins at offset ${end + 1})") + } + + private fun findStringTerminator(source: String, offset: Int, length: Int) : Int { + var ret = offset + val end = offset + length + while(ret < end) { + if (source[ret] == '\\') + ret++ + else if (source[ret] == '"') + return ret + ret++ + } + return ret + } + + fun getUnescapedString(value: JsonValue) = + getUnescapedString(value.source.substring(value.token.offset + 1, value.token.offset + value.token.length - 1)) + + // here we do not pass index and offset as we will need substring instance anyway. + fun getUnescapedString(source: String) = + if (!source.contains('\\')) source + else source.split('\\').mapIndexed { index, s -> + if (index == 0 || s.isEmpty()) + s + else when (s[0]) { + '"', '\\', '/' -> s + 'b' -> '\b' + s.substring(1) + 'f' -> '\u000c' + s.substring(1) + 'n' -> '\n' + s.substring(1) + 'r' -> '\r' + s.substring(1) + 't' -> '\t' + s.substring(1) + 'u' -> s.substring(1, 5).toInt(16).toChar() + s.substring(5) + else -> throw JsonParserException("Invalid string escape ('\\${s[0]}')") + } + }.joinToString("") + + private fun skipWhitespace(source: String, offset: Int) : Int { + var ret = offset + while(ret < source.length) { + when(source[ret]) { + ' ', '\t', '\r', '\n' -> ret++ + else -> return ret + } + } + return source.length + } +} diff --git a/ktmidi/src/commonTest/kotlin/dev/atsushieno/ktmidi/ci/JsonTest.kt b/ktmidi/src/commonTest/kotlin/dev/atsushieno/ktmidi/ci/JsonTest.kt new file mode 100644 index 000000000..7c164daac --- /dev/null +++ b/ktmidi/src/commonTest/kotlin/dev/atsushieno/ktmidi/ci/JsonTest.kt @@ -0,0 +1,124 @@ +package dev.atsushieno.ktmidi.ci + +import kotlin.test.Test +import kotlin.test.assertEquals + +class JsonTest { + @Test + fun parseString() { + val str1 = Json.parse("\"TEST1\"") + assertEquals(Json.TokenType.String, str1.token.type, "str1.token.type") + assertEquals(0, str1.token.offset, "str1.token.offset") + assertEquals(7, str1.token.length, "str1.token.length") + assertEquals("TEST1", Json.getUnescapedString(str1)) + + val str2 = Json.parse("\"TEST2\\\\r\\n\\t\\/\\b\\f\\u1234\\uFEDC\"") + assertEquals(Json.TokenType.String, str2.token.type, "str2.token.type") + assertEquals(0, str2.token.offset, "str2.token.offset") + assertEquals(32, str2.token.length, "str2.token.length") + assertEquals("TEST2\r\n\t/\b\u000c\u1234\uFEDC", Json.getUnescapedString(str2)) + } + @Test + fun parseNull() { + val nullVal = Json.parse("null") + assertEquals(Json.TokenType.Null, nullVal.token.type, "nullVal.token.type") + assertEquals(0, nullVal.token.offset, "nullVal.token.offset") + assertEquals(4, nullVal.token.length, "nullVal.token.length") + } + @Test + fun parseBoolean() { + val trueVal = Json.parse("true") + assertEquals(Json.TokenType.True, trueVal.token.type, "trueVal.token.type") + assertEquals(0, trueVal.token.offset, "trueVal.token.offset") + assertEquals(4, trueVal.token.length, "trueVal.token.length") + + val falseVal = Json.parse("false") + assertEquals(Json.TokenType.False, falseVal.token.type, "falseVal.token.type") + assertEquals(0, falseVal.token.offset, "falseVal.token.offset") + assertEquals(5, falseVal.token.length, "falseVal.token.length") + } + @Test + fun parseNumber() { + val num1 = Json.parse("0") + assertEquals(Json.TokenType.Number, num1.token.type, "num1.token.type") + assertEquals(0, num1.token.offset, "num1.token.offset") + assertEquals(1, num1.token.length, "num1.token.length") + assertEquals(0.0, num1.token.number, "num1.token.number") + + val num2 = Json.parse("10") + assertEquals(10.0, num2.token.number, "num2.token.number") + val num3 = Json.parse("10.0") + assertEquals(10.0, num3.token.number, "num3.token.number") + val num4 = Json.parse("-1") + assertEquals(-1.0, num4.token.number, "num4.token.number") + val num5 = Json.parse("-0") + assertEquals(-0.0, num5.token.number, "num5.token.number") + val num6 = Json.parse("0.1") + assertEquals(0.1, num6.token.number, "num6.token.number") + val num7 = Json.parse("-0.1") + assertEquals(-0.1, num7.token.number, "num7.token.number") + val num8 = Json.parse("-0.1e12") + assertEquals(-0.1e12, num8.token.number, "num8.token.number") + val num9 = Json.parse("-0.1e-12") + assertEquals(-0.1e-12, num9.token.number, "num9.token.number") + val num10 = Json.parse("-0e-12") + assertEquals(-0e-12, num10.token.number, "num10.token.number") + val num11 = Json.parse("1e+1") + assertEquals(1e+1, num11.token.number, "num11.token.number") + } + + @Test + fun parseObject() { + val obj1 = Json.parse("{ }") + assertEquals(Json.TokenType.Object, obj1.token.type, "obj1.token.type") + assertEquals(0, obj1.token.offset, "obj1.token.offset") + assertEquals(3, obj1.token.length, "obj1.token.length") + assertEquals(0, obj1.token.map.size, "obj1.token.map.size") + + val obj2 = Json.parse("{\"x,y\": 5, \"a,\\b\": 7}") + assertEquals(Json.TokenType.Object, obj2.token.type, "obj2.token.type") + assertEquals(0, obj2.token.offset, "obj2.token.offset") + assertEquals(21, obj2.token.length, "obj2.token.length") + val obj2Map = obj2.token.map.toList() + assertEquals(2, obj2Map.size, "arr2Items.size") + assertEquals(obj2.source, obj2Map[0].second.source, "arr2.source") + assertEquals(Json.TokenType.Number, obj2Map[0].second.token.type, "obj2Map[0].token.type") + val obj2At0First = obj2Map[0].first + assertEquals("x,y", Json.getUnescapedString(obj2At0First), "obj2Map[0] as string") + assertEquals(5.0, obj2Map[0].second.token.number, "obj2Map[0].token.number") + val obj2At1First = obj2Map[1].first + val s = Json.getUnescapedString(obj2At1First) + assertEquals("a,\b", s, "obj2Map[1] as string") + assertEquals(7.0, obj2Map[1].second.token.number, "obj2Map[1].token.number") + + val obj3 = Json.parse("{\"key1\": null, \"key2\": {\"key2-1\": true}, \"key3\": {\"key3-1\": {}, \"key3-2\": []} }") + assertEquals(3, obj3.token.map.size, "obj3.token.map.size") + } + + @Test + fun parseArray() { + val arr1 = Json.parse("[1,2,3,4,5]") + assertEquals(Json.TokenType.Array, arr1.token.type, "arr1.token.type") + assertEquals(0, arr1.token.offset, "arr1.token.offset") + assertEquals(11, arr1.token.length, "arr1.token.length") + val arr1Items = arr1.token.seq.toList() + assertEquals(5, arr1Items.size, "arr1Items.size") + assertEquals(arr1.source, arr1Items[0].source, "arr1.source") + assertEquals(Json.TokenType.Number, arr1Items[0].token.type, "arr1Items[0].token.type") + assertEquals(1.0, arr1Items[0].token.number, "arr1Items[0].token.number") + assertEquals(5.0, arr1Items[4].token.number, "arr1Items[4].token.number") + + val arr2 = Json.parse("[\"1\",2,[3,4],{\"x,y\": 5, \"a,\\b\": 7}]") + assertEquals(Json.TokenType.Array, arr2.token.type, "arr2.token.type") + assertEquals(0, arr2.token.offset, "arr2.token.offset") + assertEquals(35, arr2.token.length, "arr2.token.length") + val arr2Items = arr2.token.seq.toList() + assertEquals(4, arr2Items.size, "arr2Items.size") + assertEquals(arr2.source, arr2Items[0].source, "arr2.source") + assertEquals(Json.TokenType.String, arr2Items[0].token.type, "arr2Items[0].token.type") + assertEquals("1", Json.getUnescapedString(arr2Items[0]), "arr2Items[0] as string") + assertEquals(Json.TokenType.Number, arr2Items[1].token.type, "arr2Items[1].token.type") + assertEquals(Json.TokenType.Array, arr2Items[2].token.type, "arr2Items[2].token.type") + assertEquals(Json.TokenType.Object, arr2Items[3].token.type, "arr2Items[3].token.type") + } +} \ No newline at end of file