3
\$\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\$
2
  • \$\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, 2016 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, 2016 at 4:32

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Browse other questions tagged or ask your own question.