Skip to content

Commit 710bd25

Browse files
Copilothossain-khan
andcommitted
Implement kotlinx.serialization support for JSON5
- Add JSON5Format class implementing StringFormat interface - Bridge existing JSON5Parser/Serializer with kotlinx.serialization - Support encoding/decoding @serializable data classes to/from JSON5 - Handle number type preservation (Int, Long, Double) - Support JSON5 features like comments, trailing commas, unquoted keys - Add comprehensive tests for various data types and JSON5 features Co-authored-by: hossain-khan <[email protected]>
1 parent 4f8ace9 commit 710bd25

File tree

3 files changed

+348
-0
lines changed

3 files changed

+348
-0
lines changed

lib/src/main/kotlin/io/github/json5/kotlin/JSON5.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
package io.github.json5.kotlin
22

3+
import kotlinx.serialization.*
4+
35
/**
46
* JSON5 Implementation for Kotlin
57
*
68
* This is the main entry point for working with JSON5 data.
79
*/
810
object JSON5 {
11+
/**
12+
* Default JSON5 format for kotlinx.serialization.
13+
*/
14+
private val format = DefaultJSON5Format
15+
916
/**
1017
* Parses a JSON5 string into a Kotlin object.
1118
*
@@ -39,4 +46,26 @@ object JSON5 {
3946
fun stringify(value: Any?, space: Any? = null): String {
4047
return JSON5Serializer.stringify(value, space)
4148
}
49+
50+
/**
51+
* Encodes the given [value] to JSON5 string using kotlinx.serialization.
52+
*
53+
* @param serializer The serializer for type [T]
54+
* @param value The value to encode
55+
* @return JSON5 string representation
56+
*/
57+
fun <T> encodeToString(serializer: SerializationStrategy<T>, value: T): String {
58+
return format.encodeToString(serializer, value)
59+
}
60+
61+
/**
62+
* Decodes the given JSON5 [string] to a value of type [T] using kotlinx.serialization.
63+
*
64+
* @param deserializer The deserializer for type [T]
65+
* @param string JSON5 string to decode
66+
* @return Decoded value of type [T]
67+
*/
68+
fun <T> decodeFromString(deserializer: DeserializationStrategy<T>, string: String): T {
69+
return format.decodeFromString(deserializer, string)
70+
}
4271
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package io.github.json5.kotlin
2+
3+
import kotlinx.serialization.*
4+
import kotlinx.serialization.descriptors.*
5+
import kotlinx.serialization.encoding.*
6+
import kotlinx.serialization.json.*
7+
import kotlinx.serialization.modules.*
8+
9+
/**
10+
* JSON5 serialization format for kotlinx.serialization.
11+
*
12+
* This format allows encoding and decoding of @Serializable classes to/from JSON5 format.
13+
* It builds on top of the existing JSON5Parser and JSON5Serializer implementations.
14+
*/
15+
@OptIn(ExperimentalSerializationApi::class)
16+
class JSON5Format(
17+
private val configuration: JSON5Configuration = JSON5Configuration.Default
18+
) : StringFormat {
19+
20+
override val serializersModule: SerializersModule = EmptySerializersModule()
21+
22+
/**
23+
* Encodes the given [value] to JSON5 string using the serializer retrieved from reified type parameter.
24+
*/
25+
override fun <T> encodeToString(serializer: SerializationStrategy<T>, value: T): String {
26+
val jsonElement = JSON5Encoder(configuration).encodeToJsonElement(serializer, value)
27+
return jsonElementToJson5String(jsonElement)
28+
}
29+
30+
/**
31+
* Decodes the given JSON5 [string] to a value of type [T] using the given [deserializer].
32+
*/
33+
override fun <T> decodeFromString(deserializer: DeserializationStrategy<T>, string: String): T {
34+
val jsonElement = json5StringToJsonElement(string)
35+
return JSON5Decoder(configuration, jsonElement).decodeSerializableValue(deserializer)
36+
}
37+
38+
private fun jsonElementToJson5String(element: JsonElement): String {
39+
// Convert JsonElement to a regular Kotlin object that JSON5Serializer can handle
40+
val kotlinObject = jsonElementToKotlinObject(element)
41+
return JSON5Serializer.stringify(kotlinObject, configuration.prettyPrint)
42+
}
43+
44+
private fun json5StringToJsonElement(string: String): JsonElement {
45+
// Parse JSON5 string to Kotlin object, then convert to JsonElement
46+
val kotlinObject = JSON5Parser.parse(string)
47+
return kotlinObjectToJsonElement(kotlinObject)
48+
}
49+
50+
private fun jsonElementToKotlinObject(element: JsonElement): Any? {
51+
return when (element) {
52+
is JsonNull -> null
53+
is JsonPrimitive -> when {
54+
element.isString -> element.content
55+
element.content == "true" -> true
56+
element.content == "false" -> false
57+
else -> {
58+
// Try to preserve the original numeric type
59+
val content = element.content
60+
// Handle scientific notation that should be parsed as Long
61+
if (content.contains('E') || content.contains('e')) {
62+
val doubleValue = content.toDoubleOrNull()
63+
if (doubleValue != null && doubleValue.isFinite() && doubleValue % 1.0 == 0.0) {
64+
when {
65+
doubleValue >= Int.MIN_VALUE && doubleValue <= Int.MAX_VALUE -> doubleValue.toInt()
66+
doubleValue >= Long.MIN_VALUE && doubleValue <= Long.MAX_VALUE -> doubleValue.toLong()
67+
else -> doubleValue
68+
}
69+
} else {
70+
doubleValue ?: content
71+
}
72+
} else {
73+
content.toIntOrNull()
74+
?: content.toLongOrNull()
75+
?: content.toDoubleOrNull()
76+
?: content
77+
}
78+
}
79+
}
80+
is JsonObject -> element.mapValues { jsonElementToKotlinObject(it.value) }
81+
is JsonArray -> element.map { jsonElementToKotlinObject(it) }
82+
}
83+
}
84+
85+
private fun kotlinObjectToJsonElement(obj: Any?): JsonElement {
86+
return when (obj) {
87+
null -> JsonNull
88+
is Boolean -> JsonPrimitive(obj)
89+
is Int -> JsonPrimitive(obj)
90+
is Long -> JsonPrimitive(obj)
91+
is Double -> {
92+
// If the double is actually a whole number, try to represent it as int or long if it fits
93+
if (obj.isFinite() && obj % 1.0 == 0.0) {
94+
when {
95+
obj >= Int.MIN_VALUE && obj <= Int.MAX_VALUE -> JsonPrimitive(obj.toInt())
96+
obj >= Long.MIN_VALUE && obj <= Long.MAX_VALUE -> JsonPrimitive(obj.toLong())
97+
else -> JsonPrimitive(obj)
98+
}
99+
} else {
100+
JsonPrimitive(obj)
101+
}
102+
}
103+
is Float -> {
104+
// Similar handling for float
105+
if (obj.isFinite() && obj % 1.0f == 0.0f) {
106+
when {
107+
obj >= Int.MIN_VALUE && obj <= Int.MAX_VALUE -> JsonPrimitive(obj.toInt())
108+
obj >= Long.MIN_VALUE && obj <= Long.MAX_VALUE -> JsonPrimitive(obj.toLong())
109+
else -> JsonPrimitive(obj)
110+
}
111+
} else {
112+
JsonPrimitive(obj)
113+
}
114+
}
115+
is Number -> JsonPrimitive(obj)
116+
is String -> JsonPrimitive(obj)
117+
is Map<*, *> -> {
118+
val jsonObject = mutableMapOf<String, JsonElement>()
119+
@Suppress("UNCHECKED_CAST")
120+
val map = obj as Map<String, Any?>
121+
for ((key, value) in map) {
122+
jsonObject[key] = kotlinObjectToJsonElement(value)
123+
}
124+
JsonObject(jsonObject)
125+
}
126+
is List<*> -> {
127+
JsonArray(obj.map { kotlinObjectToJsonElement(it) })
128+
}
129+
else -> JsonPrimitive(obj.toString())
130+
}
131+
}
132+
}
133+
134+
/**
135+
* Configuration for JSON5 serialization.
136+
*/
137+
data class JSON5Configuration(
138+
val prettyPrint: Boolean = false,
139+
val prettyPrintIndent: String = " "
140+
) {
141+
companion object {
142+
val Default = JSON5Configuration()
143+
}
144+
}
145+
146+
/**
147+
* Encoder implementation that uses kotlinx.serialization's JSON encoder as a bridge.
148+
*/
149+
private class JSON5Encoder(private val configuration: JSON5Configuration) {
150+
private val json = Json {
151+
encodeDefaults = true
152+
isLenient = true
153+
allowSpecialFloatingPointValues = true
154+
}
155+
156+
fun <T> encodeToJsonElement(serializer: SerializationStrategy<T>, value: T): JsonElement {
157+
return json.encodeToJsonElement(serializer, value)
158+
}
159+
}
160+
161+
/**
162+
* Decoder implementation that uses kotlinx.serialization's JSON decoder as a bridge.
163+
*/
164+
private class JSON5Decoder(
165+
private val configuration: JSON5Configuration,
166+
private val element: JsonElement
167+
) {
168+
private val json = Json {
169+
ignoreUnknownKeys = true
170+
isLenient = true
171+
allowSpecialFloatingPointValues = true
172+
}
173+
174+
fun <T> decodeSerializableValue(deserializer: DeserializationStrategy<T>): T {
175+
return json.decodeFromJsonElement(deserializer, element)
176+
}
177+
}
178+
179+
/**
180+
* Default JSON5 format instance.
181+
*/
182+
val DefaultJSON5Format = JSON5Format()
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package io.github.json5.kotlin
2+
3+
import io.kotest.matchers.shouldBe
4+
import kotlinx.serialization.Serializable
5+
import org.junit.jupiter.api.Test
6+
import org.junit.jupiter.api.DisplayName
7+
8+
/**
9+
* Tests for JSON5 kotlinx.serialization integration.
10+
* This class tests that @Serializable data classes can be encoded/decoded
11+
* to/from JSON5 format using this library.
12+
*/
13+
@DisplayName("JSON5 kotlinx.serialization")
14+
class JSON5SerializationTest {
15+
16+
@Serializable
17+
data class Person(val name: String, val age: Int, val isActive: Boolean = true)
18+
19+
@Serializable
20+
data class NestedData(val person: Person, val tags: List<String>)
21+
22+
@Serializable
23+
data class NumberTypes(
24+
val intValue: Int,
25+
val longValue: Long,
26+
val doubleValue: Double,
27+
val floatValue: Float
28+
)
29+
30+
/**
31+
* Tests basic serialization of a simple data class to JSON5.
32+
*/
33+
@Test
34+
fun `should serialize simple data class to JSON5`() {
35+
val person = Person("Alice", 30)
36+
37+
val json5String = JSON5.encodeToString(Person.serializer(), person)
38+
39+
// JSON5 should use unquoted keys where possible and single quotes for strings
40+
json5String shouldBe "{name:'Alice',age:30,isActive:true}"
41+
}
42+
43+
/**
44+
* Tests basic deserialization of JSON5 to a data class.
45+
*/
46+
@Test
47+
fun `should deserialize JSON5 to data class`() {
48+
val json5String = "{name:'Bob',age:25,isActive:false}"
49+
50+
val person = JSON5.decodeFromString(Person.serializer(), json5String)
51+
52+
person shouldBe Person("Bob", 25, false)
53+
}
54+
55+
/**
56+
* Tests serialization with nested objects and arrays.
57+
*/
58+
@Test
59+
fun `should handle nested objects and arrays`() {
60+
val nested = NestedData(
61+
person = Person("Charlie", 35),
62+
tags = listOf("dev", "kotlin")
63+
)
64+
65+
val json5String = JSON5.encodeToString(NestedData.serializer(), nested)
66+
val decoded = JSON5.decodeFromString(NestedData.serializer(), json5String)
67+
68+
decoded shouldBe nested
69+
}
70+
71+
/**
72+
* Tests various number types to ensure proper handling.
73+
*/
74+
@Test
75+
fun `should handle different number types`() {
76+
val numbers = NumberTypes(
77+
intValue = 42,
78+
longValue = 1234567890123L,
79+
doubleValue = 3.14159,
80+
floatValue = 2.718f
81+
)
82+
83+
val json5String = JSON5.encodeToString(NumberTypes.serializer(), numbers)
84+
val decoded = JSON5.decodeFromString(NumberTypes.serializer(), json5String)
85+
86+
decoded shouldBe numbers
87+
}
88+
89+
/**
90+
* Tests that JSON5-specific features like comments are properly ignored during deserialization.
91+
*/
92+
@Test
93+
fun `should handle JSON5 features like comments`() {
94+
val json5String = """
95+
{
96+
// This is a comment
97+
name: 'Dave', /* another comment */
98+
age: 40,
99+
isActive: true
100+
}
101+
""".trimIndent()
102+
103+
val person = JSON5.decodeFromString(Person.serializer(), json5String)
104+
105+
person shouldBe Person("Dave", 40, true)
106+
}
107+
108+
/**
109+
* Tests JSON5 trailing commas support.
110+
*/
111+
@Test
112+
fun `should handle trailing commas`() {
113+
val json5String = """
114+
{
115+
name: 'Eve',
116+
age: 28,
117+
isActive: false,
118+
}
119+
""".trimIndent()
120+
121+
val person = JSON5.decodeFromString(Person.serializer(), json5String)
122+
123+
person shouldBe Person("Eve", 28, false)
124+
}
125+
126+
/**
127+
* Tests JSON5 unquoted property names.
128+
*/
129+
@Test
130+
fun `should handle unquoted property names`() {
131+
val json5String = "{name:'Frank',age:33,isActive:true}"
132+
133+
val person = JSON5.decodeFromString(Person.serializer(), json5String)
134+
135+
person shouldBe Person("Frank", 33, true)
136+
}
137+
}

0 commit comments

Comments
 (0)