\$\begingroup\$

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

, , 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 LibGDX ObjectMap 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

") } } catch (ex: CommandParseException) { ex.formattedOutput.forEach { println("$it

") } }

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

") } } catch (ex: CommandParseException) { ex.formattedOutput.forEach { println("$it

") } }

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: