For a game I'm writing using LibGDX framework and Kotlin language, I've decided to make a dev console. This is a WIP line parser. Since there's already a decent amount of code written I've decided to show it here to see what I'm doing wrong and what can be improved.
Intention:
- Parse literals such as
true
,false
,null
into their appropriate values - Support strings
- Support more complex structures such as arrays and maps and allow nesting
Currently missing:
- Parse command - parse from first non-whitespace token until first whitespace. This will be the name of the command while everything after that will be the arguments
Parse map - parse{key:value}
JSON Object like structure into LibGDXObjectMap
type.
Code:
import com.badlogic.gdx.utils.*
import com.badlogic.gdx.utils.Array as GdxArray
class CommandParser(val input: String) {
private enum class ArrayState {
EXPECTING_VALUE,
EXPECTING_LIST_SEPARATOR,
FINISHED
}
private enum class MapState {
EXPECTING_KEY,
EXPECTING_KEY_VALUE_SEPARATOR,
EXPECTING_VALUE,
EXPECTING_LIST_SEPARATOR,
FINISHED
}
companion object {
// SYMBOLS
private const val OPEN_ARRAY_SYMBOL = '['
private const val CLOSE_ARRAY_SYMBOL = ']'
private const val STRING_WRAP_SYMBOL = '"'
private const val OPEN_MAP_SYMBOL = '{'
private const val CLOSE_MAP_SYMBOL = '}'
// SEPARATORS
private const val LIST_SEPARATOR = ','
private const val DECIMAL_SEPARATOR = '.'
private const val KEY_VALUE_SEPARATOR = ':'
// LITERALS
private const val BOOLEAN_TRUE_KEYWORD = "true"
private const val BOOLEAN_FALSE_KEYWORD = "false"
private const val NULL_KEYWORD = "null"
// ADD-ONS
private const val MINUS = '-'
}
private var index = 0
fun parse(): GdxArray<Any?> {
val args = GdxArray<Any?>()
while (hasNext()) {
skipWhitespace()
args.add(parseInternal())
}
return args
}
/**
* Parses non-whitespace tokens into appropriate types
*
* Current supported types are:
* * [com.badlogic.gdx.utils.Array] as `[*,*,*]` where `*` represents any available type (including arrays)
* * [com.badlogic.gdx.utils.ObjectMap] as `{*:*, *:*}`where `*` represents any available type (including maps)
* * [kotlin.Number] as `*(.*)?` where any integer will be parsed as [kotlin.Int] and any decimal number will be parsed as [kotlin.Float]
* * [kotlin.String] as `"*"`
* * [kotlin.Boolean] as `true|false` literals
* * null as `null` literal
*/
private fun parseInternal(): Any? {
val char = input[index]
return when {
char.equals(STRING_WRAP_SYMBOL) -> parseString()
char.equals(OPEN_ARRAY_SYMBOL) -> parseArray()
char.equals(OPEN_MAP_SYMBOL) -> parseMap()
matchesLiteral(BOOLEAN_TRUE_KEYWORD) -> parseBoolean(BOOLEAN_TRUE_KEYWORD, true)
matchesLiteral(BOOLEAN_FALSE_KEYWORD) -> parseBoolean(BOOLEAN_FALSE_KEYWORD, false)
matchesLiteral(NULL_KEYWORD) -> parseNull()
char.equals(MINUS) || char.isDigit() -> parseNumber()
else -> throw CommandParseException(input,
index,
"Unexpected token '${input[index]}' on column ${index + 1}")
}
}
private fun skipWhitespace() {
while (input[index].isWhitespace()) {
++index
}
}
private fun hasNext(): Boolean {
return index < input.length
}
/**
* Skip over double quotes and get everything in between
*/
private fun parseString(): String {
val output = StringBuilder()
var char: Char
++index
while (hasNext()) {
char = input[index]
if (char == STRING_WRAP_SYMBOL) {
++index
break
}
output.append(char)
++index
}
return output.toString()
}
private fun parseBoolean(literal: String, value: Boolean): Boolean {
index += literal.length
return value
}
private fun parseNull(): Any? {
index += NULL_KEYWORD.length
return null
}
/**
* Check if number starts with `-` or a digit. Continue taking digits until first non-digit token encountered.
*
* If decimal dot encountered once, take it and continue. If encountered twice throw exception.
*
* If no numbers follow the decimal dot, throw exception.
*
* If no numbers follow `-` throw exception.
*/
private fun parseNumber(): Number {
val numberBuilder = StringBuilder()
var isDecimal = false
if (input[index] == MINUS) {
if (!input[index + 1].isDigit()) {
throw CommandParseException(input,
index,
"Expected number on column ${index + 2} but instead found ${input[index + 1]}")
} else {
numberBuilder.append(input[index])
++index
}
}
while (hasNext()) {
if (input[index].isDigit()) {
numberBuilder.append(input[index])
} else if (input[index] == DECIMAL_SEPARATOR) {
if (!isDecimal && input[index - 1].isDigit() && (index + 1) < input.length && input[index + 1].isDigit()) {
isDecimal = true
numberBuilder.append(input[index])
} else {
throw CommandParseException(input, index, "Unexpected '$DECIMAL_SEPARATOR' on column ${index + 1}")
}
} else {
break
}
++index
}
if (isDecimal) {
return numberBuilder.toString().toFloat()
} else {
return numberBuilder.toString().toInt()
}
}
private fun matchesLiteral(literal: String): Boolean {
return input.regionMatches(index, literal, 0, literal.length)
}
/**
* Skip over `[` and `]` and call [parseInternal] for everything in between skipping over `,`.
*
* If extraneous `,` found, throw exception.
*
* If no `]` found, throw exception.
*/
private fun parseArray(): GdxArray<Any?> {
val output = GdxArray<Any?>()
var arrayState = ArrayState.EXPECTING_VALUE
val arrayOpenIndex = index
++index
while (hasNext()) {
skipWhitespace()
if (input[index] == CLOSE_ARRAY_SYMBOL) {
++index
arrayState = ArrayState.FINISHED
break
}
if (input[index] == LIST_SEPARATOR) {
if (arrayState == ArrayState.EXPECTING_LIST_SEPARATOR) {
++index
arrayState = ArrayState.EXPECTING_VALUE
continue
} else {
throw CommandParseException(input, index, "Unexpected '$LIST_SEPARATOR' on column ${index + 1}")
}
}
output.add(parseInternal())
arrayState = ArrayState.EXPECTING_LIST_SEPARATOR
}
if (arrayState != ArrayState.FINISHED) {
throw CommandParseException(input,
arrayOpenIndex,
"Missing '$CLOSE_ARRAY_SYMBOL' for array opened on column ${arrayOpenIndex + 1}")
}
return output
}
/**
* Skip over `{` and `}` and call [parseInternal] for keys and values while skipping over `,`.
*
* If extraneous `,` found, throw exception.
*
* If extraneous `:` found, throw exception.
*
* If key is parsed as null, throw exception
*
* If no `}` found, throw exception.
*/
private fun parseMap(): ObjectMap<Any, Any?> {
val output = ObjectMap<Any, Any?>()
var mapState = MapState.EXPECTING_KEY
val mapOpenIndex = index
++index
var key: Any? = null
while (hasNext()) {
skipWhitespace()
if (input[index] == CLOSE_MAP_SYMBOL) {
if(mapState != MapState.EXPECTING_KEY_VALUE_SEPARATOR && mapState != MapState.EXPECTING_VALUE) {
++index
mapState = MapState.FINISHED
break
} else {
throw CommandParseException(input, index, "Unexpected '$CLOSE_MAP_SYMBOL' on column ${index + 1}")
}
}
if (input[index] == LIST_SEPARATOR) {
if (mapState == MapState.EXPECTING_LIST_SEPARATOR) {
++index
mapState = MapState.EXPECTING_KEY
continue
} else {
throw CommandParseException(input, index, "Unexpected '$LIST_SEPARATOR' on column ${index + 1}")
}
}
if (input[index] == KEY_VALUE_SEPARATOR) {
if (mapState == MapState.EXPECTING_KEY_VALUE_SEPARATOR) {
++index
mapState = MapState.EXPECTING_VALUE
continue
} else {
throw CommandParseException(input, index, "Unexpected '$KEY_VALUE_SEPARATOR' on column ${index + 1}")
}
}
if (mapState == MapState.EXPECTING_KEY) {
val keyIndex = index
key = parseInternal()
if (key != null) {
mapState = MapState.EXPECTING_KEY_VALUE_SEPARATOR
continue
} else {
throw CommandParseException(input,
keyIndex,
"Invalid value on column ${keyIndex + 1}. Map keys can't be $NULL_KEYWORD")
}
}
if (mapState == MapState.EXPECTING_VALUE) {
if (key != null) {
val value = parseInternal()
output.put(key, value)
mapState = MapState.EXPECTING_LIST_SEPARATOR
continue
} else {
throw CommandParseException(input, index, "Key for this associated value is somehow null.")
}
}
}
if (mapState != MapState.FINISHED) {
throw CommandParseException(input,
mapOpenIndex,
"Missing '$CLOSE_MAP_SYMBOL' for map opened on column ${mapOpenIndex + 1}")
}
return output
}
}
Usage example
Valid input:
try {
val args = CommandParser("\"Hello World\" true null [1, -1.5, 2, [\"ABC\", \"BCD\"]]").parse()
args.forEach { println("${it?.javaClass} - $it\n") }
} catch (ex: CommandParseException) {
ex.formattedOutput.forEach {
println("$it\n")
}
}
Output:
class java.lang.String - Hello World
class java.lang.Boolean - true
null - null
class com.badlogic.gdx.utils.Array - [1, -1.5, 2, [ABC, BCD]]
Invalid input:
try {
val args = CommandParser("[1,, -1.5, 2, [\"ABC\", \"BCD\"]]").parse()
args.forEach { println("${it?.javaClass} - $it\n") }
} catch (ex: CommandParseException) {
ex.formattedOutput.forEach {
println("$it\n")
}
}
Output:
Unexpected ',' on column 4
[1,, -1.5, 2, ["ABC", "BCD"]]
^
Update:
- Implemented map parser
- Changed the names of constants to say what they represent, instead of their value
Bugs:
- Arrays and maps can have a hanging
,
at the end and they'll still be parsed properly. Not sure if I should treat this as a "feature" but I have no idea on how to treat trailing commas as errors without significantly complicating the code.