-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add minimal JSON parser implementation for property exchange.
- Loading branch information
1 parent
aa3a30c
commit ef6ae14
Showing
2 changed files
with
313 additions
and
0 deletions.
There are no files selected for viewing
189 changes: 189 additions & 0 deletions
189
ktmidi/src/commonMain/kotlin/dev/atsushieno/ktmidi/ci/Json.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<JsonValue>() | ||
val emptyMap = mapOf<JsonValue,JsonValue>() | ||
data class JsonToken(val type: TokenType, val offset: Int, val length: Int, val number: Double = 0.0, val seq: Sequence<JsonValue> = emptySequence, val map: Map<JsonValue,JsonValue> = 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 <T>splitEntries(source: String, offset: Int, length: Int, isMap: Boolean): Sequence<T> = 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<T>(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<Pair<JsonValue,JsonValue>>(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 | ||
} | ||
} |
124 changes: 124 additions & 0 deletions
124
ktmidi/src/commonTest/kotlin/dev/atsushieno/ktmidi/ci/JsonTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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") | ||
} | ||
} |