Skip to content

Commit 54d9763

Browse files
authored
Merge pull request #122 from dmarcotte/kson-json-schema
Json Schema Draft 7 Validation for Kson
2 parents b5ee462 + 4858eb4 commit 54d9763

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+2976
-5597
lines changed

buildSrc/src/main/kotlin/org/kson/jsonsuite/JsonTestSuiteGenerator.kt

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,8 @@ private fun generateSchemaSuiteTestClasses(
230230
): String {
231231
return """package $testClassPackage
232232
233-
import org.kson.CoreCompileConfig
234-
import org.kson.Kson
233+
import org.kson.schema.JsonSchemaTest
235234
import kotlin.test.Test
236-
import kotlin.test.assertEquals
237235
238236
/**
239237
* DO NOT MANUALLY EDIT. This class is GENERATED by `./gradlew generateJsonTestSuite` task
@@ -243,12 +241,12 @@ import kotlin.test.assertEquals
243241
* removing exclusions from [org.kson.jsonsuite.schemaTestSuiteExclusions]
244242
*/
245243
@Suppress("UNREACHABLE_CODE") // unreachable code is okay here until we complete the above TODO
246-
class SchemaSuiteTest {
244+
class SchemaSuiteTest : JsonSchemaTest {
247245
248246
${ tests.joinToString("\n") {
249247
val theTests = ArrayList<String>()
250248
for (schema in it.schemaTestGroups) {
251-
val schemaComment = if (schema.comment != null) "// " + schema.comment + "\n" else ""
249+
val schemaComment = if (schema.comment != null) "// " + schema.comment else ""
252250
for (test in schema.tests) {
253251
// construct a legal and unique name for this test
254252
val schemaTestName = "${formatForTestName(it.testFileName)}_${formatForTestName(schema.description)}_${formatForTestName(test.description)}"
@@ -272,12 +270,13 @@ ${ tests.joinToString("\n") {
272270
else {
273271
""
274272
}}
273+
| $schemaComment
275274
| assertKsonEnforcesSchema(
276275
| ${"\"\"\""}
277276
| ${formatForTest(test.data)}
278277
| ${"\"\"\""},
279278
| ${"\"\"\""}
280-
| ${schemaComment}${formatForTest(schema.schema)}
279+
| ${formatForTest(schema.schema)}
281280
| ${"\"\"\""},
282281
| ${test.valid},
283282
| ${"\"\"\""}${formatForTest(schema.description)} -> ${formatForTest(test.description)}${"\"\"\""})
@@ -288,22 +287,6 @@ ${ tests.joinToString("\n") {
288287
}
289288
theTests.joinToString("\n\n")
290289
}}
291-
292-
private fun assertKsonEnforcesSchema(ksonSource: String,
293-
schemaJson: String,
294-
shouldAcceptAsValid: Boolean,
295-
description: String) {
296-
// accepted as valid if and only if we parsed without error
297-
val acceptedAsValid = !Kson.parseToAst(
298-
ksonSource.trimIndent(),
299-
coreCompileConfig = CoreCompileConfig(schemaJson = schemaJson.trimIndent()))
300-
.hasErrors()
301-
302-
assertEquals(
303-
shouldAcceptAsValid,
304-
acceptedAsValid,
305-
description)
306-
}
307290
}
308291
"""
309292
}

buildSrc/src/main/kotlin/org/kson/jsonsuite/SchemaTestSuiteExclusionsList.kt

Lines changed: 4 additions & 790 deletions
Large diffs are not rendered by default.

src/commonMain/kotlin/org/kson/Kson.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import org.kson.CompileTarget.Kson
55
import org.kson.ast.*
66
import org.kson.parser.*
77
import org.kson.parser.messages.MessageType
8+
import org.kson.schema.SchemaParser
89
import org.kson.tools.KsonFormatterConfig
910

1011
/**
@@ -39,7 +40,13 @@ class Kson {
3940
if (coreCompileConfig.schemaJson == NO_SCHEMA) {
4041
return AstParseResult(ast, tokens, messageSink)
4142
} else {
42-
TODO("Json Schema support for Kson not yet implemented")
43+
val schemaParseResult = SchemaParser.parse(coreCompileConfig.schemaJson)
44+
val jsonSchema = schemaParseResult.jsonSchema
45+
?: // schema todo make a schema parser entry point and suggest they run this through it to troubleshoot
46+
throw IllegalStateException("Schema parse failed:\n" + LoggedMessage.print(schemaParseResult.messages))
47+
// validate against our schema, logging any errors to our message sink
48+
jsonSchema.validate(ast?.toKsonApi() as KsonValue, messageSink)
49+
return AstParseResult(ast, tokens, messageSink)
4350
}
4451
}
4552

src/commonMain/kotlin/org/kson/ast/KsonApi.kt

Lines changed: 117 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,134 @@ import org.kson.parser.NumberParser
1010
*/
1111
sealed class KsonApi(val location: Location)
1212

13-
abstract class KsonValue(location: Location) : KsonApi(location)
13+
abstract class KsonValue(location: Location) : KsonApi(location) {
14+
/**
15+
* Ensure all our [KsonValue] classes implement their [equals] and [hashCode]
16+
*/
17+
abstract override fun equals(other: Any?): Boolean
18+
abstract override fun hashCode(): Int
19+
}
1420

15-
class KsonObject(private val propertyList: List<KsonObjectProperty>, location: Location) : KsonValue(location) {
21+
class KsonObject(val propertyList: List<KsonObjectProperty>, location: Location) : KsonValue(location) {
1622
val propertyMap: Map<String, KsonValue> by lazy {
1723
propertyList.associate { it.name.value to it.ksonValue }
1824
}
25+
26+
override fun equals(other: Any?): Boolean {
27+
if (this === other) return true
28+
if (other !is KsonObject) return false
29+
30+
if (propertyMap.size != other.propertyMap.size) return false
31+
32+
return propertyMap.all { (key, value) ->
33+
other.propertyMap[key]?.let { value == it } ?: false
34+
}
35+
}
36+
37+
override fun hashCode(): Int {
38+
return propertyMap.hashCode()
39+
}
40+
}
41+
42+
class KsonList(val elements: List<KsonListElement>, location: Location) : KsonValue(location) {
43+
override fun equals(other: Any?): Boolean {
44+
if (this === other) return true
45+
if (other !is KsonList) return false
46+
47+
if (elements.size != other.elements.size) return false
48+
49+
return elements.zip(other.elements).all { (a, b) ->
50+
a.ksonValue == b.ksonValue
51+
}
52+
}
53+
54+
override fun hashCode(): Int {
55+
return elements.map { it.ksonValue.hashCode() }.hashCode()
56+
}
1957
}
20-
class KsonList(val elements: List<KsonListElement>, location: Location) : KsonValue(location)
58+
2159
class KsonListElement(val ksonValue: KsonValue, location: Location) : KsonApi(location)
2260
class KsonObjectProperty(val name: KsonString,
2361
val ksonValue: KsonValue,
2462
location: Location) :KsonApi(location)
2563
class EmbedBlock(val embedTag: String,
2664
val embedContent: String,
27-
location: Location) : KsonValue(location)
28-
class KsonString(val value: String, location: Location) : KsonValue(location)
29-
class KsonNumber(val value: NumberParser.ParsedNumber, location: Location) : KsonValue(location)
30-
class KsonBoolean(val value: Boolean, location: Location) : KsonValue(location)
31-
class KsonNull(location: Location) : KsonValue(location)
65+
location: Location) : KsonValue(location) {
66+
override fun equals(other: Any?): Boolean {
67+
if (this === other) return true
68+
if (other !is EmbedBlock) return false
69+
70+
return embedTag == other.embedTag && embedContent == other.embedContent
71+
}
72+
73+
override fun hashCode(): Int {
74+
return 31 * embedTag.hashCode() + embedContent.hashCode()
75+
}
76+
}
77+
78+
class KsonString(val value: String, location: Location) : KsonValue(location) {
79+
override fun equals(other: Any?): Boolean {
80+
if (this === other) return true
81+
if (other !is KsonString) return false
82+
83+
return value == other.value
84+
}
85+
86+
override fun hashCode(): Int {
87+
return value.hashCode()
88+
}
89+
}
90+
91+
class KsonNumber(val value: NumberParser.ParsedNumber, location: Location) : KsonValue(location) {
92+
override fun equals(other: Any?): Boolean {
93+
if (this === other) return true
94+
if (other !is KsonNumber) return false
95+
96+
// Numbers are equal if their numeric values are equal (supporting cross-type comparison)
97+
val thisValue = when (value) {
98+
is NumberParser.ParsedNumber.Integer -> value.value.toDouble()
99+
is NumberParser.ParsedNumber.Decimal -> value.value
100+
}
101+
val otherValue = when (other.value) {
102+
is NumberParser.ParsedNumber.Integer -> other.value.value.toDouble()
103+
is NumberParser.ParsedNumber.Decimal -> other.value.value
104+
}
105+
106+
return thisValue == otherValue
107+
}
108+
109+
override fun hashCode(): Int {
110+
// Use the double value for consistent hashing across integer/decimal representations
111+
val doubleValue = when (value) {
112+
is NumberParser.ParsedNumber.Integer -> value.value.toDouble()
113+
is NumberParser.ParsedNumber.Decimal -> value.value
114+
}
115+
return doubleValue.hashCode()
116+
}
117+
}
118+
119+
class KsonBoolean(val value: Boolean, location: Location) : KsonValue(location) {
120+
override fun equals(other: Any?): Boolean {
121+
if (this === other) return true
122+
if (other !is KsonBoolean) return false
123+
124+
return value == other.value
125+
}
126+
127+
override fun hashCode(): Int {
128+
return value.hashCode()
129+
}
130+
}
131+
132+
class KsonNull(location: Location) : KsonValue(location) {
133+
override fun equals(other: Any?): Boolean {
134+
return other is KsonNull
135+
}
136+
137+
override fun hashCode(): Int {
138+
return KsonNull::class.hashCode()
139+
}
140+
}
32141

33142
fun AstNode.toKsonApi(): KsonApi {
34143
if (this !is AstNodeImpl) {

src/commonMain/kotlin/org/kson/parser/NumberParser.kt

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,20 @@ class NumberParser(private val numberCandidate: String) {
2929
private var hasDecimalPoint = false
3030
private var hasExponent = false
3131

32-
sealed interface ParsedNumber {
33-
val asString: String
32+
sealed class ParsedNumber {
33+
abstract val asString: String
3434

35-
class Integer(rawString: String) : ParsedNumber {
35+
/**
36+
* Get this [ParsedNumber] as a [Double]
37+
*/
38+
val asDouble: Double by lazy {
39+
when (this) {
40+
is Decimal -> value
41+
is Integer -> value.toDouble()
42+
}
43+
}
44+
45+
class Integer(rawString: String) : ParsedNumber() {
3646
override val asString = trimLeadingZeros(rawString)
3747
val value = convertToLong(rawString.trimStart('0').ifEmpty { "0" })
3848

@@ -49,7 +59,7 @@ class NumberParser(private val numberCandidate: String) {
4959
}
5060
}
5161

52-
class Decimal(rawString: String) : ParsedNumber {
62+
class Decimal(rawString: String) : ParsedNumber() {
5363
override val asString = trimLeadingZeros(rawString)
5464
val value: Double by lazy {
5565
asString.toDouble()

0 commit comments

Comments
 (0)