Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 02e51494 authored by Evan Laird's avatar Evan Laird
Browse files

[CommandLine] CLI parsing lib to work with Command

This CL adds two main concepts: ParseableCommand and the CommandParser.
A ParseableCommand is a type of Command (which can be registered with
CommandRegistry for interaction with the SystemUI CLI) that owns a
parser (which is where the main work is done).

CommandParser add the ability to define specific command line tokens:
flags, parameters, and subcommands. Flags are boolean values, defaulting
to false, which when present become true. Parameters are currently
limited to single-arg parameters that have associated value parsers
which can be used to create complex parsed types. Finally, subcomands
are fully-formed commands (with flags and parameters) that can be added
as children to a top-level command. In short:

parser.flag() ->  -f, --flag
parser.param() -> -p, --param [args]
parser.subCommand() -> subCommand [fully-formed command]

The returned objects can act as property delegates and handle the
parsing requried to turn the command line string into structured fields
on the command itself, which can then be returned when `execute` is
called.

MyCommand : ParseableCommand() {
  val flag by flag(shortName = "f")
  val singleParam: Int by param(shortName = "p", Type.Int)
  val subCommand: ParseableCommand by subCommand(...)
}

Test: ParseableCommandTest
Test: CommandParserTest
Test: ValueParserTest
Test: ParametersTest
Bug: 288594098
Change-Id: I50c54322b6d640311444169e2850dc8a51aca2ef
parent b1aa9460
Loading
Loading
Loading
Loading
+327 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.statusbar.commandline

/**
 * [CommandParser] defines the collection of tokens which can be parsed from an incoming command
 * list, and parses them into their respective containers. Supported tokens are of the following
 * forms:
 * ```
 * Flag: boolean value, false by default. always optional.
 * Param: named parameter, taking N args all of a given type. Currently only single arg parameters
 *        are supported.
 * SubCommand: named command created by adding a command to a parent. Supports all fields above, but
 *             not other subcommands.
 * ```
 *
 * Tokens are added via the factory methods for each token type. They can be made `required` by
 * calling the [require] method for the appropriate type, as follows:
 * ```
 * val requiredParam = parser.require(parser.param(...))
 * ```
 *
 * The reason for having an explicit require is so that generic type arguments can be handled
 * properly. See [SingleArgParam] and [SingleArgParamOptional] for the difference between an
 * optional parameter and a required one.
 *
 * Typical usage of a required parameter, however, will occur within the context of a
 * [ParseableCommand], which defines a convenience `require()` method:
 * ```
 * class MyCommand : ParseableCommand {
 *   val requiredParam = param(...).require()
 * }
 * ```
 *
 * This parser defines two modes of parsing, both of which validate for required parameters.
 * 1. [parse] is a top-level parsing method. This parser will walk the given arg list and populate
 *    all of the delegate classes based on their type. It will handle SubCommands, and after parsing
 *    will check for any required-but-missing SubCommands or Params.
 *
 *    **This method requires that every received token is represented in its grammar.**
 * 2. [parseAsSubCommand] is a second-level parsing method suitable for any [SubCommand]. This
 *    method will handle _only_ flags and params. It will return parsing control to its parent
 *    parser on the first unknown token rather than throwing.
 */
class CommandParser {
    private val _flags = mutableListOf<Flag>()
    val flags: List<Flag> = _flags
    private val _params = mutableListOf<Param>()
    val params: List<Param> = _params
    private val _subCommands = mutableListOf<SubCommand>()
    val subCommands: List<SubCommand> = _subCommands

    private val tokenSet = mutableSetOf<String>()

    /**
     * Parse the arg list into the fields defined in the containing class.
     *
     * @return true if all required fields are present after parsing
     * @throws ArgParseError on any failure to process args
     */
    fun parse(args: List<String>): Boolean {
        if (args.isEmpty()) {
            return false
        }

        val iterator = args.listIterator()
        var tokenHandled: Boolean
        while (iterator.hasNext()) {
            val token = iterator.next()
            tokenHandled = false

            flags
                .find { it.matches(token) }
                ?.let {
                    it.inner = true
                    tokenHandled = true
                }

            if (tokenHandled) continue

            params
                .find { it.matches(token) }
                ?.let {
                    it.parseArgsFromIter(iterator)
                    tokenHandled = true
                }

            if (tokenHandled) continue

            subCommands
                .find { it.matches(token) }
                ?.let {
                    it.parseSubCommandArgs(iterator)
                    tokenHandled = true
                }

            if (!tokenHandled) {
                throw ArgParseError("Unknown token: $token")
            }
        }

        return validateRequiredParams()
    }

    /**
     * Parse a subset of the commands that came in from the top-level [parse] method, for the
     * subcommand that this parser represents. Note that subcommands may not contain other
     * subcommands. But they may contain flags and params.
     *
     * @return true if all required fields are present after parsing
     * @throws ArgParseError on any failure to process args
     */
    fun parseAsSubCommand(iter: ListIterator<String>): Boolean {
        // arg[-1] is our subcommand name, so the rest of the args are either for this
        // subcommand, OR for the top-level command to handle. Therefore, we bail on the first
        // failure, but still check our own required params

        // The mere presence of a subcommand (similar to a flag) is a valid subcommand
        if (flags.isEmpty() && params.isEmpty()) {
            return validateRequiredParams()
        }

        var tokenHandled: Boolean
        while (iter.hasNext()) {
            val token = iter.next()
            tokenHandled = false

            flags
                .find { it.matches(token) }
                ?.let {
                    it.inner = true
                    tokenHandled = true
                }

            if (tokenHandled) continue

            params
                .find { it.matches(token) }
                ?.let {
                    it.parseArgsFromIter(iter)
                    tokenHandled = true
                }

            if (!tokenHandled) {
                // Move the cursor position backwards since we've arrived at a token
                // that we don't own
                iter.previous()
                break
            }
        }

        return validateRequiredParams()
    }

    /**
     * If [parse] or [parseAsSubCommand] does not produce a valid result, generate a list of errors
     * based on missing elements
     */
    fun generateValidationErrorMessages(): List<String> {
        val missingElements = mutableListOf<String>()

        if (unhandledParams.isNotEmpty()) {
            val names = unhandledParams.map { it.longName }
            missingElements.add("No values passed for required params: $names")
        }

        if (unhandledSubCmds.isNotEmpty()) {
            missingElements.addAll(unhandledSubCmds.map { it.longName })
            val names = unhandledSubCmds.map { it.shortName }
            missingElements.add("No values passed for required sub-commands: $names")
        }

        return missingElements
    }

    /** Check for any missing, required params, or any invalid subcommands */
    private fun validateRequiredParams(): Boolean =
        unhandledParams.isEmpty() && unhandledSubCmds.isEmpty() && unvalidatedSubCmds.isEmpty()

    // If any required param (aka non-optional) hasn't handled a field, then return false
    private val unhandledParams: List<Param>
        get() = params.filter { (it is SingleArgParam<*>) && !it.handled }

    private val unhandledSubCmds: List<SubCommand>
        get() = subCommands.filter { (it is RequiredSubCommand<*> && !it.handled) }

    private val unvalidatedSubCmds: List<SubCommand>
        get() = subCommands.filter { !it.validationStatus }

    private fun checkCliNames(short: String?, long: String): String? {
        if (short != null && tokenSet.contains(short)) {
            return short
        }

        if (tokenSet.contains(long)) {
            return long
        }

        return null
    }

    private fun subCommandContainsSubCommands(cmd: ParseableCommand): Boolean =
        cmd.parser.subCommands.isNotEmpty()

    private fun registerNames(short: String?, long: String) {
        if (short != null) {
            tokenSet.add(short)
        }
        tokenSet.add(long)
    }

    /**
     * Turns a [SingleArgParamOptional]<T> into a [SingleArgParam] by converting the [T?] into [T]
     *
     * @return a [SingleArgParam] property delegate
     */
    fun <T : Any> require(old: SingleArgParamOptional<T>): SingleArgParam<T> {
        val newParam =
            SingleArgParam(
                longName = old.longName,
                shortName = old.shortName,
                description = old.description,
                valueParser = old.valueParser,
            )

        replaceWithRequired(old, newParam)
        return newParam
    }

    private fun <T : Any> replaceWithRequired(
        old: SingleArgParamOptional<T>,
        new: SingleArgParam<T>,
    ) {
        _params.remove(old)
        _params.add(new)
    }

    /**
     * Turns an [OptionalSubCommand] into a [RequiredSubCommand] by converting the [T?] in to [T]
     *
     * @return a [RequiredSubCommand] property delegate
     */
    fun <T : ParseableCommand> require(optional: OptionalSubCommand<T>): RequiredSubCommand<T> {
        val newCmd = RequiredSubCommand(optional.cmd)
        replaceWithRequired(optional, newCmd)
        return newCmd
    }

    private fun <T : ParseableCommand> replaceWithRequired(
        old: OptionalSubCommand<T>,
        new: RequiredSubCommand<T>,
    ) {
        _subCommands.remove(old)
        _subCommands.add(new)
    }

    internal fun flag(
        longName: String,
        shortName: String? = null,
        description: String = "",
    ): Flag {
        checkCliNames(shortName, longName)?.let {
            throw IllegalArgumentException("Detected reused flag name ($it)")
        }
        registerNames(shortName, longName)

        val flag = Flag(shortName, longName, description)
        _flags.add(flag)
        return flag
    }

    internal fun <T : Any> param(
        longName: String,
        shortName: String? = null,
        description: String = "",
        valueParser: ValueParser<T>,
    ): SingleArgParamOptional<T> {
        checkCliNames(shortName, longName)?.let {
            throw IllegalArgumentException("Detected reused param name ($it)")
        }
        registerNames(shortName, longName)

        val param =
            SingleArgParamOptional(
                shortName = shortName,
                longName = longName,
                description = description,
                valueParser = valueParser,
            )
        _params.add(param)
        return param
    }

    internal fun <T : ParseableCommand> subCommand(
        command: T,
    ): OptionalSubCommand<T> {
        checkCliNames(null, command.name)?.let {
            throw IllegalArgumentException("Cannot re-use name for subcommand ($it)")
        }

        if (subCommandContainsSubCommands(command)) {
            throw IllegalArgumentException(
                "SubCommands may not contain other SubCommands. $command"
            )
        }

        registerNames(null, command.name)

        val subCmd = OptionalSubCommand(command)
        _subCommands.add(subCmd)
        return subCmd
    }
}
+195 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.statusbar.commandline

import android.util.IndentingPrintWriter
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty

/**
 * Definitions for all parameter types usable by [ParseableCommand]. Parameters are command line
 * tokens that accept a fixed number of arguments and convert them to a parsed type.
 *
 * Example:
 * ```
 * my_command --single-arg-param arg
 * ```
 *
 * In the example, `my_command` is the name of the command, `--single-arg-param` is the parameter,
 * and `arg` is the value parsed by that parameter into its eventual type.
 *
 * Note on generics: The intended usage for parameters is to be able to return the parsed type from
 * the given command as a `val` via property delegation. For example, let's say we have a command
 * that has one optional and one required parameter:
 * ```
 * class MyCommand : ParseableCommand {
 *   val requiredParam: Int by parser.param(...).required()
 *   val optionalParam: Int? by parser.param(...)
 * }
 * ```
 *
 * In order to make the simple `param` method return the correct type, we need to do two things:
 * 1. Break out the generic type into 2 pieces (TParsed and T)
 * 2. Create two different underlying Parameter subclasses to handle the property delegation. One
 *    handles `T?` and the other handles `T`. Note that in both cases, `TParsed` is always non-null
 *    since the value parsed from the argument will throw an exception if missing or if it cannot be
 *    parsed.
 */

/** A param type knows the number of arguments it expects */
sealed interface Param : Describable {
    val numArgs: Int

    /**
     * Consume [numArgs] items from the iterator and relay the result into its corresponding
     * delegated type.
     */
    fun parseArgsFromIter(iterator: Iterator<String>)
}

/**
 * Base class for required and optional SingleArgParam classes. For convenience, UnaryParam is
 * defined as a [MultipleArgParam] where numArgs = 1. The benefit is that we can define the parsing
 * in a single place, and yet on the client side we can unwrap the underlying list of params
 * automatically.
 */
abstract class UnaryParamBase<out T, out TParsed : T>(val wrapped: MultipleArgParam<T, TParsed>) :
    Param, ReadOnlyProperty<Any?, T> {
    var handled = false

    override fun describe(pw: IndentingPrintWriter) {
        if (shortName != null) {
            pw.print("$shortName, ")
        }
        pw.print(longName)
        pw.println(" ${typeDescription()}")
        if (description != null) {
            pw.indented { pw.println(description) }
        }
    }

    /**
     * Try to describe the arg type. We can know if it's one of the base types what kind of input it
     * takes. Otherwise just print "<arg>" and let the clients describe in the help text
     */
    private fun typeDescription() =
        when (wrapped.valueParser) {
            Type.Int -> "<int>"
            Type.Float -> "<float>"
            Type.String -> "<string>"
            Type.Boolean -> "<boolean>"
            else -> "<arg>"
        }
}

/** Required single-arg parameter, delegating a non-null type to the client. */
class SingleArgParam<out T : Any>(
    override val longName: String,
    override val shortName: String? = null,
    override val description: String? = null,
    val valueParser: ValueParser<T>,
) :
    UnaryParamBase<T, T>(
        MultipleArgParam(
            longName,
            shortName,
            1,
            description,
            valueParser,
        )
    ) {

    override fun getValue(thisRef: Any?, property: KProperty<*>): T =
        if (handled) {
            wrapped.getValue(thisRef, property)[0]
        } else {
            throw IllegalStateException("Attempt to read property before parse() has executed")
        }

    override val numArgs: Int = 1

    override fun parseArgsFromIter(iterator: Iterator<String>) {
        wrapped.parseArgsFromIter(iterator)
        handled = true
    }
}

/** Optional single-argument parameter, delegating a nullable type to the client. */
class SingleArgParamOptional<out T : Any>(
    override val longName: String,
    override val shortName: String? = null,
    override val description: String? = null,
    val valueParser: ValueParser<T>,
) :
    UnaryParamBase<T?, T>(
        MultipleArgParam(
            longName,
            shortName,
            1,
            description,
            valueParser,
        )
    ) {
    override fun getValue(thisRef: Any?, property: KProperty<*>): T? =
        wrapped.getValue(thisRef, property).getOrNull(0)

    override val numArgs: Int = 1

    override fun parseArgsFromIter(iterator: Iterator<String>) {
        wrapped.parseArgsFromIter(iterator)
        handled = true
    }
}

/**
 * Parses a list of args into the underlying [T] data type. The resultant value is an ordered list
 * of type [TParsed].
 *
 * [T] and [TParsed] are split out here in the case where the entire param is optional. I.e., a
 * MultipleArgParam<T?, T> indicates a command line argument that can be omitted. In that case, the
 * inner list is List<T>?, NOT List<T?>. If the argument is provided, then the type is always going
 * to be parsed into T rather than T?.
 */
class MultipleArgParam<out T, out TParsed : T>(
    override val longName: String,
    override val shortName: String? = null,
    override val numArgs: Int = 1,
    override val description: String? = null,
    val valueParser: ValueParser<TParsed>,
) : ReadOnlyProperty<Any?, List<TParsed>>, Param {
    private val inner: MutableList<TParsed> = mutableListOf()

    override fun getValue(thisRef: Any?, property: KProperty<*>): List<TParsed> = inner

    /**
     * Consumes [numArgs] values of the iterator and parses them into [TParsed].
     *
     * @throws ArgParseError on the first failure
     */
    override fun parseArgsFromIter(iterator: Iterator<String>) {
        if (!iterator.hasNext()) {
            throw ArgParseError("no argument provided for $shortName")
        }
        for (i in 0 until numArgs) {
            valueParser
                .parseValue(iterator.next())
                .fold(onSuccess = { inner.add(it) }, onFailure = { throw it })
        }
    }
}

data class ArgParseError(override val message: String) : Exception(message)
+395 −0

File added.

Preview size limit exceeded, changes collapsed.

+106 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.statusbar.commandline

import android.util.IndentingPrintWriter
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty

/**
 * Sub commands wrap [ParseableCommand]s and are attached to a parent [ParseableCommand]. As such
 * they have their own parser which will parse the args as a subcommand. I.e., the subcommand's
 * parser will consume the iterator created by the parent, reversing the index when it reaches an
 * unknown token.
 *
 * In order to keep subcommands relatively simple and not have to do complicated validation, sub
 * commands will return control to the parent parser as soon as they discover a token that they do
 * not own. They will throw an [ArgParseError] if parsing fails or if they don't receive arguments
 * for a required parameter.
 */
sealed interface SubCommand : Describable {
    val cmd: ParseableCommand

    /** Checks if all of the required elements were passed in to [parseSubCommandArgs] */
    var validationStatus: Boolean

    /**
     * To keep parsing simple, [parseSubCommandArgs] requires a [ListIterator] so that it can rewind
     * the iterator when it yields control upwards
     */
    fun parseSubCommandArgs(iterator: ListIterator<String>)
}

/**
 * Note that the delegated type from the subcommand is `T: ParseableCommand?`. SubCommands are
 * created via adding a fully-formed [ParseableCommand] to parent command.
 *
 * At this point in time, I don't recommend nesting subcommands.
 */
class OptionalSubCommand<T : ParseableCommand>(
    override val cmd: T,
) : SubCommand, ReadOnlyProperty<Any?, ParseableCommand?> {
    override val shortName: String? = null
    override val longName: String = cmd.name
    override val description: String? = cmd.description
    override var validationStatus = true

    private var isPresent = false

    /** Consume tokens from the iterator and pass them to the wrapped command */
    override fun parseSubCommandArgs(iterator: ListIterator<String>) {
        validationStatus = cmd.parser.parseAsSubCommand(iterator)
        isPresent = true
    }

    override fun getValue(thisRef: Any?, property: KProperty<*>): T? =
        if (isPresent) {
            cmd
        } else {
            null
        }

    override fun describe(pw: IndentingPrintWriter) {
        cmd.help(pw)
    }
}

/**
 * Non-optional subcommand impl. Top-level parser is expected to throw [ArgParseError] if this token
 * is not present in the incoming command
 */
class RequiredSubCommand<T : ParseableCommand>(
    override val cmd: T,
) : SubCommand, ReadOnlyProperty<Any?, ParseableCommand> {
    override val shortName: String? = null
    override val longName: String = cmd.name
    override val description: String? = cmd.description
    override var validationStatus = true

    /** Unhandled, required subcommands are an error */
    var handled = false

    override fun parseSubCommandArgs(iterator: ListIterator<String>) {
        validationStatus = cmd.parser.parseAsSubCommand(iterator)
        handled = true
    }

    override fun getValue(thisRef: Any?, property: KProperty<*>): ParseableCommand = cmd

    override fun describe(pw: IndentingPrintWriter) {
        cmd.help(pw)
    }
}
+173 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading