Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 135 additions & 3 deletions contract/src/main/kotlin/com/xfl/msgbot/plugin/api/schema/Type.kt
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,78 @@ sealed interface Type {
override fun toString(): String = "map<$value>"
}

/** A string that is one of [values], written `enum<a|b>`. */
data class Enum(val values: List<String>) : Type {
init {
require(values.isNotEmpty()) { "An enum needs at least one value" }
require(values.distinct().size == values.size) { "An enum lists a value twice: $values" }
values.forEach { require(it.isNotEmpty() && it.all { c -> isWordChar(c) }) { "'$it' is not a valid enum value" } }
}

override fun checkAt(value: Value, path: Path): String? =
when {
value !is Value.VString -> mismatch(path, this, value)
value.value in values -> null
else -> "$path: expected one of $values, got '${value.value.take(MAX_SHOWN)}'"
}

override fun toString(): String = values.joinToString("|", "enum<", ">")
}

/**
* A string that names something the host keeps for the plugin, such as a message token, written
* `handle<kind>`. It travels as a string; the kind tells the host which table to look it up in.
*/
data class Handle(val kind: String) : Type {
init {
require(kind.isNotEmpty() && kind.all { c -> isWordChar(c) }) { "'$kind' is not a valid handle kind" }
}

override fun checkAt(value: Value, path: Path): String? = if (value is Value.VString) null else mismatch(path, this, value)

override fun toString(): String = "handle<$kind>"
}

/**
* [inner] with a limit on its size, written `string[..100]`, `list<int>[1..10]` or `int[0..]`: the
* length of a string, bytes, list or map, or the value of an int or double. Either end may be left out.
*/
data class Bounded(val inner: Type, val min: Long?, val max: Long?) : Type {
init {
require(
inner is ListOf || inner is MapOf ||
inner == Primitive.STRING || inner == Primitive.BYTES || inner == Primitive.INT || inner == Primitive.DOUBLE,
) { "'$inner' cannot have a limit" }
require(min != null || max != null) { "A limit needs a minimum or a maximum" }
require(min == null || max == null || min <= max) { "The minimum $min is above the maximum $max" }
}

override fun checkAt(value: Value, path: Path): String? {
inner.checkAt(value, path)?.let { return it }
val (what, measured) =
when (value) {
is Value.VString -> "length" to value.value.length.toDouble()
is Value.VBytes -> "size" to value.value.size.toDouble()
is Value.VArray -> "length" to value.items.size.toDouble()
is Value.VObject -> "size" to value.entries.size.toDouble()
is Value.VInt -> "value" to value.value.toDouble()
is Value.VDouble -> "value" to value.value
else -> return null
}
val shown =
when (value) {
is Value.VInt -> value.value.toString()
is Value.VDouble -> value.value.toString()
else -> measured.toLong().toString()
}
if (min != null && measured < min) return "$path: $what $shown is below the minimum $min"
if (max != null && measured > max) return "$path: $what $shown is above the maximum $max"
return null
}

override fun toString(): String = "$inner[${min ?: ""}..${max ?: ""}]"
}

/** Unknown fields are rejected, since they are usually typos. */
data class Struct(val fields: List<Field>) : Type {
init {
Expand Down Expand Up @@ -136,6 +208,14 @@ sealed interface Type {

fun struct(vararg fields: Field): Type = Struct(fields.toList())

fun enum(vararg values: String): Type = Enum(values.toList())

fun handle(kind: String): Type = Handle(kind)

internal const val MAX_SHOWN = 40

internal fun isWordChar(c: Char): Boolean = c.isLetterOrDigit() || c == '_'

/** Parses the text form. Throws [IllegalArgumentException] with the error position. */
fun parse(text: String): Type = TypeParser(text).parseAll()

Expand Down Expand Up @@ -238,6 +318,7 @@ fun Type.conform(value: Value): Value =
when {
value is Value.VNull -> value
this is Type.Nullable -> inner.conform(value)
this is Type.Bounded -> inner.conform(value)
this is Type.ListOf && value is Value.VArray -> Value.VArray(value.items.map(item::conform))
this is Type.MapOf && value is Value.VObject -> Value.VObject(value.entries.mapValues { (_, v) -> this.value.conform(v) })
this is Type.Struct && value is Value.VObject -> Value.VObject(conformFields(fields, value.entries))
Expand Down Expand Up @@ -273,21 +354,72 @@ private class TypeParser(private val text: String) {
parseStruct()
} else {
when (val word = word()) {
"enum" -> parseEnum()
"handle" -> {
expect('<')
val kind = word()
expect('>')
Type.Handle(kind)
}
"list" -> Type.ListOf(parseArgument())
"map" -> Type.MapOf(parseArgument())
else -> Type.Primitive.entries.firstOrNull { it.keyword == word } ?: fail("unknown type '$word'")
}
}
skipSpace()
val sized = if (peek() == '[') parseBounds(base) else base
depth--
return if (peek() == '?') {
pos++
Type.Nullable(base)
Type.Nullable(sized)
} else {
base
sized
}
}

private fun parseEnum(): Type {
expect('<')
val values = mutableListOf(word())
while (true) {
skipSpace()
when (peek()) {
'|' -> {
pos++
values += word()
}
'>' -> {
pos++
return Type.Enum(values)
}
else -> fail("expected '|' or '>'")
}
}
}

private fun parseBounds(base: Type): Type {
expect('[')
val min = number()
expect('.')
expect('.')
val max = number()
expect(']')
skipSpace()
return try {
Type.Bounded(base, min, max)
} catch (e: IllegalArgumentException) {
fail(e.message ?: "bad limit")
}
}

private fun number(): Long? {
skipSpace()
val start = pos
if (peek() == '-') pos++
while (pos < text.length && text[pos].isDigit()) pos++
if (pos == start) return null
return text.substring(start, pos).toLongOrNull() ?: fail("not a number")
}

private fun parseArgument(): Type {
expect('<')
val type = parseType()
Expand Down Expand Up @@ -323,7 +455,7 @@ private class TypeParser(private val text: String) {
private fun word(): String {
skipSpace()
val start = pos
while (pos < text.length && (text[pos].isLetterOrDigit() || text[pos] == '_')) pos++
while (pos < text.length && Type.isWordChar(text[pos])) pos++
if (start == pos) fail("expected a name")
return text.substring(start, pos)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,73 @@ class TypeTest {
assertTrue(error.startsWith("value.${"k".repeat(64)}…[1]: expected int"), error)
}
}

class TypeConstraintTest {
@Test
fun `enums, limits and handles read back to the same text`() {
listOf(
"enum<ko|en|ja>",
"enum<a>?",
"string[..100]",
"string[1..100]?",
"int[0..]",
"int[-5..5]",
"list<string>[..10]",
"map<int>[1..]",
"bytes[..1024]",
"handle<reply>",
"{kind: enum<a|b>, name: string[..8], tokens: list<handle<image>>[..3]}",
).forEach { text -> assertEquals(text, Type.parse(text).toString()) }
}

@Test
fun `malformed constraints are refused`() {
listOf(
"enum<>", "enum<a|a>", "enum<a b>", "enum<a", "bool[..1]", "{a: int}[..1]", "string[..]", "string[5..1]", "string[1.2]",
"handle<>", "handle<a b>", "any[..1]", "string[..99999999999999999999]",
).forEach { text -> assertFailsWith<IllegalArgumentException>(text) { Type.parse(text) } }
}

@Test
fun `an enum takes only its own values`() {
val type = Type.parse("enum<ko|en>")
assertNull(type.check(Value.VString("ko")))
assertTrue(type.check(Value.VString("fr"))!!.contains("expected one of"))
assertTrue(type.check(Value.VInt(1))!!.contains("expected enum<ko|en>"))
}

@Test
fun `a limit checks length, size or value`() {
assertNull(Type.parse("string[..3]").check(Value.VString("abc")))
assertEquals("value: length 4 is above the maximum 3", Type.parse("string[..3]").check(Value.VString("abcd")))
assertEquals("value: length 0 is below the minimum 1", Type.parse("list<int>[1..]").check(vArray()))
assertEquals("value: size 2 is above the maximum 1", Type.parse("map<int>[..1]").check(vObject("a" to 1, "b" to 2)))
assertEquals("value: size 3 is above the maximum 2", Type.parse("bytes[..2]").check(Value.VBytes(byteArrayOf(1, 2, 3))))
assertNull(Type.parse("int[0..100]").check(Value.VInt(100)))
assertEquals("value: value 101 is above the maximum 100", Type.parse("int[0..100]").check(Value.VInt(101)))
assertTrue(Type.parse("double[0..1]").check(Value.VDouble(1.5))!!.contains("above the maximum"))
assertTrue(Type.parse("string[..3]").check(Value.VInt(1))!!.contains("expected string"))
assertNull(Type.parse("string[..3]?").check(Value.VNull))
}

@Test
fun `a limit applies to the items of a list too`() {
val type = Type.parse("list<string[..2]>[..2]")
assertNull(type.check(vArray("a", "bc")))
assertEquals("value[1]: length 3 is above the maximum 2", type.check(vArray("a", "bcd")))
assertTrue(type.check(vArray("a", "b", "c"))!!.contains("above the maximum 2"))
}

@Test
fun `a handle is a string that names something the host keeps`() {
val type = Type.parse("handle<reply>")
assertNull(type.check(Value.VString("provider-1")))
assertTrue(type.check(Value.VInt(1))!!.contains("expected handle<reply>"))
}

@Test
fun `conform still drops unknown struct fields under a limit`() {
val type = Type.parse("list<{a: int}>[..5]")
assertEquals(vArray(vObject("a" to 1)), type.conform(vArray(vObject("a" to 1, "b" to 2))))
}
}
Loading