Skip to content

Commit

Permalink
Add minimal JSON parser implementation for property exchange.
Browse files Browse the repository at this point in the history
  • Loading branch information
atsushieno committed Dec 11, 2023
1 parent aa3a30c commit ef6ae14
Show file tree
Hide file tree
Showing 2 changed files with 313 additions and 0 deletions.
189 changes: 189 additions & 0 deletions ktmidi/src/commonMain/kotlin/dev/atsushieno/ktmidi/ci/Json.kt
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 ktmidi/src/commonTest/kotlin/dev/atsushieno/ktmidi/ci/JsonTest.kt
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")
}
}

0 comments on commit ef6ae14

Please sign in to comment.