diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4d8a5fb..8f12cf5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -189,6 +189,8 @@ composeCompiler { dependencies { implementation(project(mapOf("path" to ":selector"))) + implementation(project(mapOf("path" to ":json5"))) + implementation(libs.androidx.appcompat) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt b/app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt index e8ea2f3..2137289 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt @@ -16,8 +16,8 @@ import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.long import li.songe.gkd.service.checkSelector import li.songe.gkd.util.json -import li.songe.gkd.util.json5ToJson import li.songe.gkd.util.toast +import li.songe.json5.Json5 import li.songe.selector.Selector import net.objecthunter.exp4j.Expression import net.objecthunter.exp4j.ExpressionBuilder @@ -798,8 +798,9 @@ data class RawSubscription( } fun parse(source: String, json5: Boolean = true): RawSubscription { - val text = if (json5) json5ToJson(source) else source - val subscription = jsonToSubscriptionRaw(json.parseToJsonElement(text).jsonObject) + val element = + if (json5) Json5.parseToJson5Element(source) else json.parseToJsonElement(source) + val subscription = jsonToSubscriptionRaw(element.jsonObject) subscription.categories.findDuplicatedItem { v -> v.key }?.let { v -> error("id=${subscription.id}, duplicated category: key=${v.key}") } @@ -840,9 +841,8 @@ data class RawSubscription( return a } - fun parseRawApp(source: String, json5: Boolean = true): RawApp { - val text = if (json5) json5ToJson(source) else source - return parseApp(json.parseToJsonElement(text).jsonObject) + fun parseRawApp(source: String): RawApp { + return parseApp(Json5.parseToJson5Element(source).jsonObject) } fun parseGroup(jsonObject: JsonObject): RawAppGroup { @@ -853,14 +853,12 @@ data class RawSubscription( return g } - fun parseRawGroup(source: String, json5: Boolean = true): RawAppGroup { - val text = if (json5) json5ToJson(source) else source - return parseGroup(json.parseToJsonElement(text).jsonObject) + fun parseRawGroup(source: String): RawAppGroup { + return parseGroup(Json5.parseToJson5Element(source).jsonObject) } - fun parseRawGlobalGroup(source: String, json5: Boolean = true): RawGlobalGroup { - val text = if (json5) json5ToJson(source) else source - val g = jsonToGlobalGroups(json.parseToJsonElement(text).jsonObject, 0) + fun parseRawGlobalGroup(source: String): RawGlobalGroup { + val g = jsonToGlobalGroups(Json5.parseToJson5Element(source).jsonObject, 0) g.rules.findDuplicatedItem { v -> v.key }?.let { v -> error("duplicated global rule: key=${v.key}") } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppItemPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppItemPage.kt index f970327..87dd14f 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppItemPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppItemPage.kt @@ -75,15 +75,15 @@ import li.songe.gkd.util.LocalMainViewModel import li.songe.gkd.util.LocalNavController import li.songe.gkd.util.ProfileTransitions import li.songe.gkd.util.appInfoCacheFlow -import li.songe.gkd.util.encodeToJson5String +import li.songe.json5.encodeToJson5String import li.songe.gkd.util.getGroupRawEnable import li.songe.gkd.util.json -import li.songe.gkd.util.json5ToJson import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.launchTry import li.songe.gkd.util.throttle import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubscription +import li.songe.json5.Json5 @RootNavGraph @Destination(style = ProfileTransitions::class) @@ -285,7 +285,7 @@ fun AppItemPage( }, ) } - if (editable && subsItem != null && subsRaw != null) { + if (editable && subsRaw != null) { DropdownMenuItem( text = { Text(text = "删除", color = MaterialTheme.colorScheme.error) @@ -435,8 +435,9 @@ fun AppItemPage( setEditGroupRaw(null) return@launchAsFn } + val element = try { - json.parseToJsonElement(json5ToJson(source)).jsonObject + Json5.parseToJson5Element(source).jsonObject } catch (e: Exception) { LogUtils.d(e) error("非法JSON:${e.message}") diff --git a/app/src/main/kotlin/li/songe/gkd/ui/GlobalRulePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/GlobalRulePage.kt index 54ad05e..396e89a 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/GlobalRulePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/GlobalRulePage.kt @@ -72,7 +72,7 @@ import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.util.LocalMainViewModel import li.songe.gkd.util.LocalNavController import li.songe.gkd.util.ProfileTransitions -import li.songe.gkd.util.encodeToJson5String +import li.songe.json5.encodeToJson5String import li.songe.gkd.util.json import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.launchTry @@ -260,7 +260,7 @@ fun GlobalRulePage(subsItemId: Long, focusGroupKey: Int? = null) { error = true, ) updateSubscription( - rawSubs!!.copy( + rawSubs.copy( globalGroups = rawSubs.globalGroups.filter { g -> g.key != group.key } ) ) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsPage.kt index bd027aa..3a2d3b0 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsPage.kt @@ -65,7 +65,7 @@ import li.songe.gkd.util.LocalNavController import li.songe.gkd.util.ProfileTransitions import li.songe.gkd.util.SortTypeOption import li.songe.gkd.util.appInfoCacheFlow -import li.songe.gkd.util.encodeToJson5String +import li.songe.json5.encodeToJson5String import li.songe.gkd.util.json import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.launchTry diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt index 8693128..39fd91f 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt @@ -41,7 +41,7 @@ import li.songe.gkd.data.AppInfo import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.SubsConfig import li.songe.gkd.ui.style.appItemPadding -import li.songe.gkd.util.encodeToJson5String +import li.songe.json5.encodeToJson5String import li.songe.gkd.util.json import li.songe.gkd.util.toast diff --git a/app/src/main/kotlin/li/songe/gkd/util/Json5.kt b/app/src/main/kotlin/li/songe/gkd/util/Json5.kt deleted file mode 100644 index 32802c6..0000000 --- a/app/src/main/kotlin/li/songe/gkd/util/Json5.kt +++ /dev/null @@ -1,99 +0,0 @@ -package li.songe.gkd.util - -import blue.endless.jankson.Jankson -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.serializer - -private val json5IdentifierReg = Regex("[a-zA-Z_][a-zA-Z0-9_]*") - -/** - * https://spec.json5.org/#strings - */ -private fun escapeString(value: String): String { - val wrapChar = '\'' - val sb = StringBuilder() - sb.append(wrapChar) - value.forEach { c -> - val escapeChar = when (c) { - wrapChar -> wrapChar - '\n' -> 'n' - '\r' -> 'r' - '\t' -> 't' - '\b' -> 'b' - '\\' -> '\\' - else -> null - } - if (escapeChar != null) { - sb.append("\\" + escapeChar) - } else { - when (c.code) { - in 0..0xf -> { - sb.append("\\x0" + c.code.toString(16)) - } - - in 0..0x1f -> { - sb.append("\\x" + c.code.toString(16)) - } - - else -> { - sb.append(c) - } - } - } - } - sb.append(wrapChar) - return sb.toString() -} - -fun convertJsonElementToJson5(element: JsonElement, indent: Int = 2): String { - val spaces = "\u0020".repeat(indent) - return when (element) { - is JsonPrimitive -> { - val content = element.content - if (element.isString) { - escapeString(content) - } else { - content - } - } - - is JsonObject -> { - if (element.isEmpty()) { - "{}" - } else { - val entries = element.entries.joinToString(",\n") { (key, value) -> - // If key is a valid identifier, no quotes are needed - if (key.matches(json5IdentifierReg)) { - "$key: ${convertJsonElementToJson5(value, indent)}" - } else { - "${escapeString(key)}: ${convertJsonElementToJson5(value, indent)}" - } - }.lineSequence().map { l -> spaces + l }.joinToString("\n") - "{\n$entries\n}" - } - } - - is JsonArray -> { - if (element.isEmpty()) { - "[]" - } else { - val elements = - element.joinToString(",\n") { convertJsonElementToJson5(it, indent) } - .lineSequence().map { l -> spaces + l }.joinToString("\n") - "[\n$elements\n]" - } - } - } -} - -inline fun Json.encodeToJson5String(value: T): String { - return convertJsonElementToJson5(encodeToJsonElement(serializersModule.serializer(), value)) -} - -fun json5ToJson(source: String): String { - return Jankson.builder().build().load(source).toJson() -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt b/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt index 0eb6810..efdc4cf 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt @@ -32,6 +32,7 @@ import li.songe.gkd.data.SubsConfig import li.songe.gkd.data.SubsItem import li.songe.gkd.data.SubsVersion import li.songe.gkd.db.DbSet +import li.songe.json5.decodeFromJson5String import java.net.URI val subsItemsFlow by lazy { @@ -121,7 +122,7 @@ fun getGroupRawEnable( enable } else { null - } ?: group.enable != false + } ?: group.enable ?: true } data class RuleSummary( @@ -190,7 +191,7 @@ val ruleSummaryFlow by lazy { mutableMapOf>() rawSubs.globalGroups.filter { g -> (subGlobalSubsConfigs.find { c -> c.groupKey == g.key }?.enable - ?: g.enable != false) && g.valid + ?: g.enable ?: true) && g.valid }.forEach { groupRaw -> val config = subGlobalSubsConfigs.find { c -> c.groupKey == groupRaw.key } val g = ResolvedGlobalGroup( @@ -347,10 +348,8 @@ private suspend fun updateSubs(subsEntry: SubsEntry): RawSubscription? { val checkUpdateUrl = subsEntry.checkUpdateUrl if (checkUpdateUrl != null && subsRaw != null) { try { - val subsVersion = json.decodeFromString( - json5ToJson( - client.get(checkUpdateUrl).bodyAsText() - ) + val subsVersion = json.decodeFromJson5String( + client.get(checkUpdateUrl).bodyAsText() ) LogUtils.d( "快速检测更新:id=${subsRaw.id},version=${subsRaw.version}", diff --git a/build.gradle.kts b/build.gradle.kts index 61106fa..4628a9c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,6 +29,7 @@ plugins { alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.rikka.refine) apply false + alias(libs.plugins.jetbrains.kotlin.jvm) apply false } // can not work with Kotlin Multiplatform diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3e1130d..f5178be 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] kotlin = "2.0.10" ksp = "2.0.10-1.0.24" -android = "8.5.1" +android = "8.5.2" compose = "1.6.8" rikka = "4.4.0" room = "2.6.1" @@ -10,12 +10,14 @@ ktor = "2.3.12" hilt = "2.52" destinations = "1.10.2" coil = "2.7.0" +jetbrainsKotlinJvm = "2.0.10" [libraries] android_gradle = { module = "com.android.tools.build:gradle", version.ref = "android" } kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin_serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" } kotlin_stdlib_common = { module = "org.jetbrains.kotlin:kotlin-stdlib-common", version.ref = "kotlin" } +kotlin_test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } compose_ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } compose_preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } compose_tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } @@ -80,3 +82,4 @@ rikka_refine = { id = "dev.rikka.tools.refine", version.ref = "rikka" } google_ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } google_hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } androidx_room = { id = "androidx.room", version.ref = "room" } +jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" } diff --git a/json5/.gitignore b/json5/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/json5/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/json5/build.gradle.kts b/json5/build.gradle.kts new file mode 100644 index 0000000..5b8922e --- /dev/null +++ b/json5/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + jvm() + + sourceSets { + val commonMain by getting { + dependencies { + api(libs.kotlinx.serialization.json) + } + } + val commonTest by getting { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.serialization.json) + } + } + } +} diff --git a/json5/src/commonMain/kotlin/li/songe/json5/Json5.kt b/json5/src/commonMain/kotlin/li/songe/json5/Json5.kt new file mode 100644 index 0000000..fd6bd60 --- /dev/null +++ b/json5/src/commonMain/kotlin/li/songe/json5/Json5.kt @@ -0,0 +1,64 @@ +package li.songe.json5 + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +object Json5 { + fun parseToJson5Element(string: String): JsonElement { + return Json5Decoder(string).read() + } + + fun encodeToString(element: JsonElement, indent: Int = 2) = encodeToString(element, indent, 0) + + private fun encodeToString(element: JsonElement, indent: Int = 2, depth: Int = 0): String { + val lineSeparator = if (indent == 0) "" else "\n" + val keySeparator = if (indent == 0) ":" else ": " + val prefixSpaces = if (indent == 0) "" else " ".repeat(indent * (depth + 1)) + val closingSpaces = if (indent == 0) "" else " ".repeat(indent * depth) + + return when (element) { + is JsonPrimitive -> { + if (element.isString) { + stringifyString(element.content) + } else { + element.content + } + } + + is JsonObject -> { + if (element.isEmpty()) { + "{}" + } else { + element.entries.joinToString(",$lineSeparator") { (key, value) -> + "${prefixSpaces}${stringifyKey(key)}${keySeparator}${ + encodeToString( + value, + indent, + depth + 1 + ) + }" + }.let { + "{$lineSeparator$it$lineSeparator$closingSpaces}" + } + } + } + + is JsonArray -> { + if (element.isEmpty()) { + "[]" + } else { + element.joinToString(",$lineSeparator") { + "${prefixSpaces}${encodeToString(it, indent, depth + 1)}" + }.let { + "[$lineSeparator$it$lineSeparator$closingSpaces]" + } + } + } + } + } + +} + + diff --git a/json5/src/commonMain/kotlin/li/songe/json5/Json5Decoder.kt b/json5/src/commonMain/kotlin/li/songe/json5/Json5Decoder.kt new file mode 100644 index 0000000..6010712 --- /dev/null +++ b/json5/src/commonMain/kotlin/li/songe/json5/Json5Decoder.kt @@ -0,0 +1,539 @@ +package li.songe.json5 + +import kotlinx.serialization.json.* +import java.lang.StringBuilder +import kotlin.collections.set +import kotlin.let +import kotlin.ranges.contains +import kotlin.text.endsWith +import kotlin.text.getOrNull +import kotlin.text.substring +import kotlin.text.toDouble +import kotlin.text.toInt +import kotlin.text.toLong +import kotlin.text.trimEnd + +// https://spec.json5.org/ +internal class Json5Decoder(private val input: CharSequence) { + private var i = 0 + private val char: Char? + get() = input.getOrNull(i) + private val end: Boolean + get() = i >= input.length + + private fun stop(): Nothing { + if (end) { + error("Unexpected Char: EOF") + } + error("Unexpected Char: $char at index $i") + } + + fun read(): JsonElement { + val root = i == 0 + readUseless() + val element = when (char) { + '{' -> readObject() + '[' -> readArray() + '"', '\'' -> JsonPrimitive(readString()) + in '0'..'9', '-', '+', '.', 'N', 'I' -> JsonPrimitive(readNumber()) + 't' -> { // true + i++ + next('r') + next('u') + next('e') + JsonPrimitive(true) + } + + 'f' -> { // false + i++ + next('a') + next('l') + next('s') + next('e') + JsonPrimitive(false) + } + + 'n' -> { // null + i++ + next('u') + next('l') + next('l') + JsonNull + } + + else -> stop() + } + if (root) { + readUseless() + if (!end) { + stop() + } + } + return element + } + + private fun next(c: Char) { + if (c == char) { + i++ + return + } + stop() + } + + private fun readObject(): JsonObject { + i++ + readUseless() + if (char == '}') { + i++ + return JsonObject(emptyMap()) + } + val map = mutableMapOf() + while (true) { + readUseless() + val key = readObjectKey() + readUseless() + next(':') + readUseless() + val value = read() + map[key] = value + readUseless() + if (char == '}') { + i++ + break + } else if (char == ',') { + i++ + readUseless() + if (char == '}') { + i++ + break + } + } else { + stop() + } + } + return JsonObject(map) + } + + private fun readObjectKey(): String { + val c = char + if (c == '\'' || c == '"') { + return readString() + } + val sb = StringBuilder() + if (c == '\\') { + i++ + next('u') + repeat(4) { + if (!isHexDigit(char)) { + stop() + } + i++ + } + val n = input.substring(i - 4, i).toInt(16).toChar() + if (!isIdStartChar(n)) { + stop() + } + sb.append(n) + } else if (!isIdStartChar(c)) { + stop() + } else { + sb.append(c) + } + i++ + while (!end) { + if (char == '\\') { + i++ + next('u') + repeat(4) { + if (!isHexDigit(char)) { + stop() + } + i++ + } + val n = input.substring(i - 4, i).toInt(16).toChar() + if (!isIdContinueChar(n)) { + stop() + } + sb.append(n) + } else if (isIdContinueChar(char)) { + sb.append(char) + i++ + } else { + break + } + } + return sb.toString() + } + + private fun readArray(): JsonArray { + i++ + readUseless() + if (char == ']') { + i++ + return JsonArray(emptyList()) + } + val list = mutableListOf() + while (true) { + readUseless() + list.add(read()) + readUseless() + if (char == ']') { + i++ + break + } else if (char == ',') { + i++ + readUseless() + if (char == ']') { + i++ + break + } + } else { + stop() + } + } + return JsonArray(list) + } + + private fun readString(): String { + val wrapChar = char!! + i++ + val sb = StringBuilder() + while (true) { + when (char) { + null -> stop() + wrapChar -> { + i++ + break + } + + '\\' -> { + i++ + when (char) { + null -> stop() + wrapChar -> { + sb.append(wrapChar) + i++ + } + + 'x' -> { + i++ + repeat(2) { + if (!isHexDigit(char)) { + stop() + } + i++ + } + val hex = input.substring(i - 2, i) + sb.append(hex.toInt(16).toChar()) + } + + 'u' -> { + i++ + repeat(4) { + if (!isHexDigit(char)) { + stop() + } + i++ + } + val hex = input.substring(i - 4, i) + sb.append(hex.toInt(16).toChar()) + } + + '\'' -> { + sb.append('\'') + i++ + } + + '\"' -> { + sb.append('\"') + i++ + } + + '\\' -> { + sb.append('\\') + i++ + } + + 'b' -> { + sb.append('\b') + i++ + } + + 'f' -> { + sb.append('\u000C') + i++ + } + + 'n' -> { + sb.append('\n') + i++ + } + + 'r' -> { + sb.append('\r') + i++ + } + + 't' -> { + sb.append('\t') + i++ + } + + 'v' -> { + sb.append('\u000B') + i++ + } + + '0' -> { + sb.append('\u0000') + i++ + if (isDigit(char)) { + stop() + } + } + + // multiline string + '\u000D' -> {// \r + i++ + if (char == '\u000A') {// \n + i++ + } + } + + // multiline string + '\u000A', '\u2028', '\u2029' -> { + i++ + } + + in '1'..'9' -> stop() + + else -> { + sb.append(char) + } + } + } + + else -> { + sb.append(char) + i++ + } + } + } + return sb.toString() + } + + private fun readNumber(signal: Boolean = false): Number { + return when (char) { + '-' -> { + if (!signal) { + i++ + val n = readNumber(true) + if (n is Double) { + return -n + } + if (n is Long) { + return -n + } + if (n is Int) { + return -n + } + stop() + } else { + stop() + } + } + + '+' -> { + if (!signal) { + i++ + return readNumber(true) + } else { + stop() + } + } + + 'N' -> {// NaN + i++ + next('a') + next('N') + Double.NaN + } + + 'I' -> {// Infinity + i++ + next('n') + next('f') + next('i') + next('n') + next('i') + next('t') + next('y') + Double.POSITIVE_INFINITY + } + + '.' -> { + var start = i + i++ + readInteger() + val numPart = input.substring(start, i).trimEnd('0').let { + if (it == ".") { // .0 -> 0 + "0" + } else { + it + } + } + if (numPart == "0") { + 0L + } else { + if (isPowerStartChar(char)) { + start = i + 1 + readNumberPower() + val power = input.substring(start, i) + (numPart + power).toDouble() + } else { + input.substring(start, i).toDouble() + } + } + } + + in '0'..'9' -> { + var start = i + var hasHex = false + if (char == '0') { // 0x11 + i++ + if (isDigit(char)) {// not allow 00 01 + stop() + } else if (isHexStartChar(char)) { + i++ + hasHex = true + } + } + if (hasHex) { + if (!isHexDigit(char)) { + stop() + } + i++ + while (!end && isHexDigit(char)) { + i++ + } + input.substring(start + 2, i).toLong(16) + } else { + var hasPoint = false // 1.2 + while (!end) { + if (char == '.') { + if (!hasPoint) { + hasPoint = true + } else { + stop() + } + } else if (!isDigit(char)) { + break + } + i++ + } + val hasEndPoint = hasPoint && input[i - 1] == '.' // kotlin not support 1. + val numPart = if (hasEndPoint) { + hasPoint = false + input.substring(start, i - 1) // 1. -> 1 + } else { + if (hasPoint) { + input.substring(start, i).trimEnd('0').let { // 1.10 -> 1.1, 1.0 -> 1. + if (it.endsWith('.')) { // 1. -> 1 + hasPoint = false + it.substring(0, it.length - 1) + } else { + it + } + } + } else { + input.substring(start, i) + } + } + if (isPowerStartChar(char)) { + start = i + readNumberPower() + val power = input.substring(start, i) + (numPart + power).toDouble() + } else { + if (hasPoint) { + numPart.toDouble() + } else { + numPart.run { toLongOrNull() ?: toDouble() } + } + } + } + } + + else -> stop() + } + } + + private fun readInteger() { + val start = i + while (isDigit(char)) { + i++ + } + if (start == i) { + stop() + } + } + + private fun readNumberPower() { + i++ + if (char == '-' || char == '+') { + i++ + } + readInteger() + } + + private fun readUseless() { + while (true) { + val oldIndex = i + readCommentOrWhitespace() + if (oldIndex == i) { + return + } + } + } + + private fun readCommentOrWhitespace() { + when { + char == '/' -> { + i++ + when (char) { + '/' -> { + i++ + while (!isNewLine(char) && !end) { + i++ + } + } + + '*' -> { + i++ + while (true) { + when (char) { + null -> stop() + '*' -> { + if (input.getOrNull(i + 1) == '/') { + i += 2 + break + } + } + } + i++ + } + } + + else -> stop() + } + } + + isWhiteSpace(char) -> { + i++ + while (isWhiteSpace(char)) { + i++ + } + } + } + } +} diff --git a/json5/src/commonMain/kotlin/li/songe/json5/JsonExt.kt b/json5/src/commonMain/kotlin/li/songe/json5/JsonExt.kt new file mode 100644 index 0000000..ee4aa87 --- /dev/null +++ b/json5/src/commonMain/kotlin/li/songe/json5/JsonExt.kt @@ -0,0 +1,15 @@ +package li.songe.json5 + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.serializer + +inline fun Json.encodeToJson5String(value: T): String { + return Json5.encodeToString( + encodeToJsonElement(serializersModule.serializer(), value), + ) +} + +inline fun Json.decodeFromJson5String(value: String): T { + return decodeFromJsonElement(Json5.parseToJson5Element(value)) +} diff --git a/json5/src/commonMain/kotlin/li/songe/json5/Util.kt b/json5/src/commonMain/kotlin/li/songe/json5/Util.kt new file mode 100644 index 0000000..4037a0a --- /dev/null +++ b/json5/src/commonMain/kotlin/li/songe/json5/Util.kt @@ -0,0 +1,149 @@ +package li.songe.json5 + +import kotlin.text.category + +private val unicodeLetterCategories = hashSetOf( + CharCategory.UPPERCASE_LETTER, + CharCategory.LOWERCASE_LETTER, + CharCategory.TITLECASE_LETTER, + CharCategory.MODIFIER_LETTER, + CharCategory.OTHER_LETTER, + CharCategory.LETTER_NUMBER, +) + +private val unicodeIdCategories = hashSetOf( + CharCategory.NON_SPACING_MARK, + CharCategory.COMBINING_SPACING_MARK, + CharCategory.DECIMAL_DIGIT_NUMBER, + CharCategory.CONNECTOR_PUNCTUATION, +) + +internal fun isIdStartChar(c: Char?): Boolean { + c ?: return false + return c.category in unicodeLetterCategories || c == '_' || c == '$' +} + +internal fun isIdContinueChar(c: Char?): Boolean { + c ?: return false + return isIdStartChar(c) || c.category in unicodeIdCategories || c == '\u200C' || c == '\u200D' +} + +internal fun isDigit(c: Char?): Boolean { + c ?: return false + return c in '0'..'9' +} + +internal fun isHexDigit(c: Char?): Boolean { + c ?: return false + return (c in '0'..'9') || (c in 'A'..'F') || (c in 'a'..'f') +} + +internal fun isPowerStartChar(c: Char?): Boolean { + c ?: return false + return c == 'e' || c == 'E' +} + +internal fun isHexStartChar(c: Char?): Boolean { + c ?: return false + return c == 'x' || c == 'X' +} + +internal fun isWhiteSpace(c: Char?): Boolean { + c ?: return false + return when (c) { + '\u0009' -> true + in '\u000A'..'\u000D' -> true + '\u0020' -> true + '\u00A0' -> true + '\u2028' -> true + '\u2029' -> true + '\uFEFF' -> true + + '\u1680' -> true + in '\u2000'..'\u200A' -> true + '\u202F' -> true + '\u205F' -> true + '\u3000' -> true + else -> false + } +} + +internal fun isNewLine(c: Char?): Boolean { + c ?: return false + return when (c) { + '\u000A' -> true + '\u000D' -> true + '\u2028' -> true + '\u2029' -> true + else -> false + } +} + +private val escapeReplacements = hashMapOf( + '\\' to "\\\\", + '\b' to "\\b", + '\u000C' to "\\f", + '\n' to "\\n", + '\r' to "\\r", + '\t' to "\\t", + '\u000B' to "\\v", + '\u0000' to "\\0", + '\u2028' to "\\u2028", + '\u2029' to "\\u2029", +) + +internal fun stringifyString(value: String, singleQuote: Boolean = true): String { + // https://github.com/json5/json5/blob/main/lib/stringify.js + val wrapChar = if (singleQuote) '\'' else '"' + val sb = StringBuilder() + sb.append(wrapChar) + value.forEachIndexed { i, c -> + when { + c == wrapChar -> { + sb.append("\\$wrapChar") + } + + c == '\u0000' -> { + if (isDigit(value.getOrNull(i + 1))) { + // "\u00002" -> \x002 + sb.append("\\x00") + } else { + sb.append("\\0") + } + } + + c in escapeReplacements.keys -> { + sb.append(escapeReplacements[c]) + } + + c.code in 0..0xf -> { + sb.append("\\x0" + c.code.toString(16)) + } + + c.code in 0..0x1f -> { + sb.append("\\x" + c.code.toString(16)) + } + + else -> { + sb.append(c) + } + } + } + sb.append(wrapChar) + return sb.toString() +} + +internal fun stringifyKey(key: String, singleQuote: Boolean = true): String { + if (key.isEmpty()) { + return stringifyString(key, singleQuote) + } + if (!isIdStartChar(key[0])) { + return stringifyString(key, singleQuote) + } + for (c in key) { + if (!isIdContinueChar(c)) { + return stringifyString(key, singleQuote) + } + } + return key +} diff --git a/json5/src/commonTest/kotlin/li/songe/json5/Json5Test.kt b/json5/src/commonTest/kotlin/li/songe/json5/Json5Test.kt new file mode 100644 index 0000000..61d12c4 --- /dev/null +++ b/json5/src/commonTest/kotlin/li/songe/json5/Json5Test.kt @@ -0,0 +1,21 @@ +package li.songe.json5 + +import kotlin.test.Test + +class Json5Test { + + @Test + fun parse() { + // https://github.com/json5/json5/blob/main/test/parse.js + val element = Json5.parseToJson5Element("[1,2,3,'\\x002\\n']/*23*///1") + println("element: $element") + } + + @Test + fun format() { + val element = Json5.parseToJson5Element("{'a-1':1,b:{c:['d',{f:233}]}}") + println("element: $element") + val formatted = Json5.encodeToString(element, 2) + println("formatted:\n$formatted") + } +} diff --git a/selector/build.gradle.kts b/selector/build.gradle.kts index 540669c..645370b 100644 --- a/selector/build.gradle.kts +++ b/selector/build.gradle.kts @@ -7,12 +7,7 @@ plugins { } kotlin { - jvm { - @OptIn(ExperimentalKotlinGradlePluginApi::class) - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - } - } + jvm() // https://kotlinlang.org/docs/js-to-kotlin-interop.html#kotlin-types-in-javascript js(IR) { binaries.executable() diff --git a/settings.gradle.kts b/settings.gradle.kts index 986b215..e49f1f7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,3 +23,4 @@ dependencyResolutionManagement { } } +include(":json5")