2
\$\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
  • 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\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.
\$\endgroup\$
  • \$\begingroup\$ Why not use an existing JSON library? e.g. klaxon, kotson, jackson-module-kotlin, gson, etc. Doing so would help you avoid bugs e.g. not accounting for escaped double quotes in string literals, etc. \$\endgroup\$ – mfulton26 Aug 31 '16 at 0:02
  • \$\begingroup\$ Because I'm not building a json parser. I'm building a specialized command line parser for my in-game console. \$\endgroup\$ – MrPlow Aug 31 '16 at 4:32

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.