From 02e51494cb0cd1164f13ec3822e906422420788d Mon Sep 17 00:00:00 2001 From: Evan Laird Date: Thu, 1 Jun 2023 17:48:09 -0400 Subject: [PATCH] [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 --- .../statusbar/commandline/CommandParser.kt | 327 +++++++++++++++ .../statusbar/commandline/Parameters.kt | 195 +++++++++ .../statusbar/commandline/ParseableCommand.kt | 395 ++++++++++++++++++ .../statusbar/commandline/SubCommand.kt | 106 +++++ .../statusbar/commandline/ValueParser.kt | 173 ++++++++ .../commandline/CommandParserTest.kt | 212 ++++++++++ .../statusbar/commandline/ParametersTest.kt | 55 +++ .../commandline/ParseableCommandTest.kt | 317 ++++++++++++++ .../statusbar/commandline/ValueParserTest.kt | 61 +++ 9 files changed, 1841 insertions(+) create mode 100644 packages/SystemUI/src/com/android/systemui/statusbar/commandline/CommandParser.kt create mode 100644 packages/SystemUI/src/com/android/systemui/statusbar/commandline/Parameters.kt create mode 100644 packages/SystemUI/src/com/android/systemui/statusbar/commandline/ParseableCommand.kt create mode 100644 packages/SystemUI/src/com/android/systemui/statusbar/commandline/SubCommand.kt create mode 100644 packages/SystemUI/src/com/android/systemui/statusbar/commandline/ValueParser.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/CommandParserTest.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ParametersTest.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ParseableCommandTest.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ValueParserTest.kt diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/commandline/CommandParser.kt b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/CommandParser.kt new file mode 100644 index 0000000000000..de369c35345ca --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/CommandParser.kt @@ -0,0 +1,327 @@ +/* + * 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() + val flags: List = _flags + private val _params = mutableListOf() + val params: List = _params + private val _subCommands = mutableListOf() + val subCommands: List = _subCommands + + private val tokenSet = mutableSetOf() + + /** + * 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): 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): 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 { + val missingElements = mutableListOf() + + 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 + get() = params.filter { (it is SingleArgParam<*>) && !it.handled } + + private val unhandledSubCmds: List + get() = subCommands.filter { (it is RequiredSubCommand<*> && !it.handled) } + + private val unvalidatedSubCmds: List + 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] into a [SingleArgParam] by converting the [T?] into [T] + * + * @return a [SingleArgParam] property delegate + */ + fun require(old: SingleArgParamOptional): SingleArgParam { + val newParam = + SingleArgParam( + longName = old.longName, + shortName = old.shortName, + description = old.description, + valueParser = old.valueParser, + ) + + replaceWithRequired(old, newParam) + return newParam + } + + private fun replaceWithRequired( + old: SingleArgParamOptional, + new: SingleArgParam, + ) { + _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 require(optional: OptionalSubCommand): RequiredSubCommand { + val newCmd = RequiredSubCommand(optional.cmd) + replaceWithRequired(optional, newCmd) + return newCmd + } + + private fun replaceWithRequired( + old: OptionalSubCommand, + new: RequiredSubCommand, + ) { + _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 param( + longName: String, + shortName: String? = null, + description: String = "", + valueParser: ValueParser, + ): SingleArgParamOptional { + 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 subCommand( + command: T, + ): OptionalSubCommand { + 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 + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/commandline/Parameters.kt b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/Parameters.kt new file mode 100644 index 0000000000000..6ed5eed79c82c --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/Parameters.kt @@ -0,0 +1,195 @@ +/* + * 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) +} + +/** + * 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(val wrapped: MultipleArgParam) : + Param, ReadOnlyProperty { + 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 "" and let the clients describe in the help text + */ + private fun typeDescription() = + when (wrapped.valueParser) { + Type.Int -> "" + Type.Float -> "" + Type.String -> "" + Type.Boolean -> "" + else -> "" + } +} + +/** Required single-arg parameter, delegating a non-null type to the client. */ +class SingleArgParam( + override val longName: String, + override val shortName: String? = null, + override val description: String? = null, + val valueParser: ValueParser, +) : + UnaryParamBase( + 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) { + wrapped.parseArgsFromIter(iterator) + handled = true + } +} + +/** Optional single-argument parameter, delegating a nullable type to the client. */ +class SingleArgParamOptional( + override val longName: String, + override val shortName: String? = null, + override val description: String? = null, + val valueParser: ValueParser, +) : + UnaryParamBase( + 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) { + 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 indicates a command line argument that can be omitted. In that case, the + * inner list is List?, NOT List. If the argument is provided, then the type is always going + * to be parsed into T rather than T?. + */ +class MultipleArgParam( + override val longName: String, + override val shortName: String? = null, + override val numArgs: Int = 1, + override val description: String? = null, + val valueParser: ValueParser, +) : ReadOnlyProperty>, Param { + private val inner: MutableList = mutableListOf() + + override fun getValue(thisRef: Any?, property: KProperty<*>): List = inner + + /** + * Consumes [numArgs] values of the iterator and parses them into [TParsed]. + * + * @throws ArgParseError on the first failure + */ + override fun parseArgsFromIter(iterator: Iterator) { + 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) diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/commandline/ParseableCommand.kt b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/ParseableCommand.kt new file mode 100644 index 0000000000000..ecd3fa6cc299d --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/ParseableCommand.kt @@ -0,0 +1,395 @@ +/* + * 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 java.io.PrintWriter +import java.lang.IllegalArgumentException +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +/** + * An implementation of [Command] that includes a [CommandParser] which can set all delegated + * properties. + * + * As the number of registrants to [CommandRegistry] grows, we should have a default mechanism for + * parsing common command line arguments. We are not expecting to build an arbitrarily-functional + * CLI, nor a GNU arg parse compliant interface here, we simply want to be able to empower clients + * to create simple CLI grammars such as: + * ``` + * $ my_command [-f|--flag] + * $ my_command [-a|--arg] + * $ my_command [subcommand1] [subcommand2] + * $ my_command # not-yet implemented + * ``` + * + * Note that the flags `-h` and `--help` are reserved for the base class. It seems prudent to just + * avoid them in your implementation. + * + * Usage: + * + * The intended usage tries to be clever enough to enable good ergonomics, while not too clever as + * to be unmaintainable. Using the default parser is done using property delegates, and looks like: + * ``` + * class MyCommand( + * onExecute: (cmd: MyCommand, pw: PrintWriter) -> () + * ) : ParseableCommand(name) { + * val flag1 by flag( + * shortName = "-f", + * longName = "--flag", + * required = false, + * ) + * val param1: String by param( + * shortName = "-a", + * longName = "--args", + * valueParser = Type.String + * ).required() + * val param2: Int by param(..., valueParser = Type.Int) + * val subCommand by subCommand(...) + * + * override fun execute(pw: PrintWriter) { + * onExecute(this, pw) + * } + * + * companion object { + * const val name = "my_command" + * } + * } + * + * fun main() { + * fun printArgs(cmd: MyCommand, pw: PrintWriter) { + * pw.println("${cmd.flag1}") + * pw.println("${cmd.param1}") + * pw.println("${cmd.param2}") + * pw.println("${cmd.subCommand}") + * } + * + * commandRegistry.registerCommand(MyCommand.companion.name) { + * MyCommand() { (cmd, pw) -> + * printArgs(cmd, pw) + * } + * } + * } + * + * ``` + */ +abstract class ParseableCommand(val name: String, val description: String? = null) : Command { + val parser: CommandParser = CommandParser() + + val help by flag(longName = "help", shortName = "h", description = "Print help and return") + + /** + * After [execute(pw, args)] is called, this class goes through a parsing stage and sets all + * delegated properties. It is safe to read any delegated properties here. + * + * This method is never called for [SubCommand]s, since they are associated with a top-level + * command that handles [execute] + */ + abstract fun execute(pw: PrintWriter) + + /** + * Given a command string list, [execute] parses the incoming command and validates the input. + * If this command or any of its subcommands is passed `-h` or `--help`, then execute will only + * print the relevant help message and exit. + * + * If any error is thrown during parsing, we will catch and log the error. This process should + * _never_ take down its process. Override [onParseFailed] to handle an [ArgParseError]. + * + * Important: none of the delegated fields can be read before this stage. + */ + override fun execute(pw: PrintWriter, args: List) { + val success: Boolean + try { + success = parser.parse(args) + } catch (e: ArgParseError) { + pw.println(e.message) + onParseFailed(e) + return + } catch (e: Exception) { + pw.println("Unknown exception encountered during parse") + pw.println(e) + return + } + + // Now we've parsed the incoming command without error. There are two things to check: + // 1. If any help is requested, print the help message and return + // 2. Otherwise, make sure required params have been passed in, and execute + + val helpSubCmds = subCmdsRequestingHelp() + + // Top-level help encapsulates subcommands. Otherwise, if _any_ subcommand requests + // help then defer to them. Else, just execute + if (help) { + help(pw) + } else if (helpSubCmds.isNotEmpty()) { + helpSubCmds.forEach { it.help(pw) } + } else { + if (!success) { + parser.generateValidationErrorMessages().forEach { pw.println(it) } + } else { + execute(pw) + } + } + } + + /** + * Returns a list of all commands that asked for help. If non-empty, parsing will stop to print + * help. It is not guaranteed that delegates are fulfilled if help is requested + */ + private fun subCmdsRequestingHelp(): List = + parser.subCommands.filter { it.cmd.help }.map { it.cmd } + + /** Override to do something when parsing fails */ + open fun onParseFailed(error: ArgParseError) {} + + /** Override to print a usage clause. E.g. `usage: my-cmd ` */ + open fun usage(pw: IndentingPrintWriter) {} + + /** + * Print out the list of tokens, their received types if any, and their description in a + * formatted string. + * + * Example: + * ``` + * my-command: + * MyCmd.description + * + * [optional] usage block + * + * Flags: + * -f + * description + * --flag2 + * description + * + * Parameters: + * Required: + * -p1 [Param.Type] + * description + * --param2 [Param.Type] + * description + * Optional: + * same as above + * + * SubCommands: + * Required: + * ... + * Optional: + * ... + * ``` + */ + override fun help(pw: PrintWriter) { + val ipw = IndentingPrintWriter(pw) + ipw.printBoxed(name) + ipw.println() + + // Allow for a simple `usage` block for clients + ipw.indented { usage(ipw) } + + if (description != null) { + ipw.indented { ipw.println(description) } + ipw.println() + } + + val flags = parser.flags + if (flags.isNotEmpty()) { + ipw.println("FLAGS:") + ipw.indented { + flags.forEach { + it.describe(ipw) + ipw.println() + } + } + } + + val (required, optional) = parser.params.partition { it is SingleArgParam<*> } + if (required.isNotEmpty()) { + ipw.println("REQUIRED PARAMS:") + required.describe(ipw) + } + if (optional.isNotEmpty()) { + ipw.println("OPTIONAL PARAMS:") + optional.describe(ipw) + } + + val (reqSub, optSub) = parser.subCommands.partition { it is RequiredSubCommand<*> } + if (reqSub.isNotEmpty()) { + ipw.println("REQUIRED SUBCOMMANDS:") + reqSub.describe(ipw) + } + if (optSub.isNotEmpty()) { + ipw.println("OPTIONAL SUBCOMMANDS:") + optSub.describe(ipw) + } + } + + fun flag( + longName: String, + shortName: String? = null, + description: String = "", + ): Flag { + if (!checkShortName(shortName)) { + throw IllegalArgumentException( + "Flag short name must be one character long, or null. Got ($shortName)" + ) + } + + if (!checkLongName(longName)) { + throw IllegalArgumentException("Flags must not start with '-'. Got $($longName)") + } + + val short = shortName?.let { "-$shortName" } + val long = "--$longName" + + return parser.flag(long, short, description) + } + + fun param( + longName: String, + shortName: String? = null, + description: String = "", + valueParser: ValueParser, + ): SingleArgParamOptional { + if (!checkShortName(shortName)) { + throw IllegalArgumentException( + "Parameter short name must be one character long, or null. Got ($shortName)" + ) + } + + if (!checkLongName(longName)) { + throw IllegalArgumentException("Parameters must not start with '-'. Got $($longName)") + } + + val short = shortName?.let { "-$shortName" } + val long = "--$longName" + + return parser.param(long, short, description, valueParser) + } + + fun subCommand( + command: T, + ) = parser.subCommand(command) + + /** For use in conjunction with [param], makes the parameter required */ + fun SingleArgParamOptional.required(): SingleArgParam = parser.require(this) + + /** For use in conjunction with [subCommand], makes the given [SubCommand] required */ + fun OptionalSubCommand.required(): RequiredSubCommand = + parser.require(this) + + private fun checkShortName(short: String?): Boolean { + return short == null || short.length == 1 + } + + private fun checkLongName(long: String): Boolean { + return !long.startsWith("-") + } + + companion object { + fun Iterable.describe(pw: IndentingPrintWriter) { + pw.indented { + forEach { + it.describe(pw) + pw.println() + } + } + } + } +} + +/** + * A flag is a boolean value passed over the command line. It can have a short form or long form. + * The value is [Boolean.true] if the flag is found, else false + */ +data class Flag( + override val shortName: String? = null, + override val longName: String, + override val description: String? = null, +) : ReadOnlyProperty, Describable { + var inner: Boolean = false + + override fun getValue(thisRef: Any?, property: KProperty<*>) = inner +} + +/** + * Named CLI token. Can have a short or long name. Note: consider renaming to "primary" and + * "secondary" names since we don't actually care what the strings are + * + * Flags and params will have [shortName]s that are always prefixed with a single dash, while + * [longName]s are prefixed by a double dash. E.g., `my_command -f --flag`. + * + * Subcommands do not do any prefixing, and register their name as the [longName] + * + * Can be matched against an incoming token + */ +interface CliNamed { + val shortName: String? + val longName: String + + fun matches(token: String) = shortName == token || longName == token +} + +interface Describable : CliNamed { + val description: String? + + fun describe(pw: IndentingPrintWriter) { + if (shortName != null) { + pw.print("$shortName, ") + } + pw.print(longName) + pw.println() + if (description != null) { + pw.indented { pw.println(description) } + } + } +} + +/** + * Print [s] inside of a unicode character box, like so: + * ``` + * ╔═══════════╗ + * ║ my-string ║ + * ╚═══════════╝ + * ``` + */ +fun PrintWriter.printDoubleBoxed(s: String) { + val length = s.length + println("╔${"═".repeat(length + 2)}╗") + println("║ $s ║") + println("╚${"═".repeat(length + 2)}╝") +} + +/** + * Print [s] inside of a unicode character box, like so: + * ``` + * ┌───────────┐ + * │ my-string │ + * └───────────┘ + * ``` + */ +fun PrintWriter.printBoxed(s: String) { + val length = s.length + println("┌${"─".repeat(length + 2)}┐") + println("│ $s │") + println("└${"─".repeat(length + 2)}┘") +} + +fun IndentingPrintWriter.indented(block: () -> Unit) { + increaseIndent() + block() + decreaseIndent() +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/commandline/SubCommand.kt b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/SubCommand.kt new file mode 100644 index 0000000000000..41bac86fd6c9e --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/SubCommand.kt @@ -0,0 +1,106 @@ +/* + * 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) +} + +/** + * 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( + override val cmd: T, +) : SubCommand, ReadOnlyProperty { + 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) { + 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( + override val cmd: T, +) : SubCommand, ReadOnlyProperty { + 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) { + validationStatus = cmd.parser.parseAsSubCommand(iterator) + handled = true + } + + override fun getValue(thisRef: Any?, property: KProperty<*>): ParseableCommand = cmd + + override fun describe(pw: IndentingPrintWriter) { + cmd.help(pw) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/commandline/ValueParser.kt b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/ValueParser.kt new file mode 100644 index 0000000000000..01083d9a79070 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/commandline/ValueParser.kt @@ -0,0 +1,173 @@ +/* + * 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 kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +/** + * Utilities for parsing the [String] command line arguments. Arguments are related to the + * [Parameter] type, which declares the number of, and resulting type of, the arguments that it + * takes when parsing. For Example: + * ``` + * my-command --param --param2 + * ``` + * + * Defines 2 parameters, the first of which takes a string, and the second requires an int. Because + * fundamentally _everything_ is a string, we have to define a convenient way to get from the + * incoming `StringArg` to the resulting `T`-arg, where `T` is the type required by the client. + * + * Parsing is therefore a relatively straightforward operation: (String) -> T. However, since + * parsing can always fail, the type is actually (String) -> Result. We will always want to fail + * on the first error and propagate it to the caller (typically this results in printing the `help` + * message of the command`). + * + * The identity parsing is trivial: + * ``` + * (s: String) -> String = { s -> s } + * ``` + * + * Basic mappings are actually even provided by Kotlin's stdlib: + * ``` + * (s: String) -> Boolean = { s -> s.toBooleanOrNull() } + * (s: String) -> Int = { s -> s.toIntOrNull() } + * ... + * ``` + * + * In order to properly encode errors, we will ascribe an error type to any `null` values, such that + * parsing looks like this: + * ``` + * val mapping: (String) -> T? = {...} // for some T + * val parser: (String) -> Result = { s -> + * mapping(s)?.let { + * Result.success(it) + * } ?: Result.failure(/* some failure type */) + * } + * ``` + * + * Composition + * + * The ability to compose value parsing enables us to provide a couple of reasonable default parsers + * and allow clients to seamlessly build upon that using map functions. Consider the case where we + * want to validate that a value is an [Int] between 0 and 100. We start with the generic [Int] + * parser, and a validator, of the type (Int) -> Result: + * ``` + * val intParser = { s -> + * s.toStringOrNull().?let {...} ?: ... + * } + * + * val validator = { i -> + * if (i > 100 || i < 0) { + * Result.failure(...) + * } else { + * Result.success(i) + * } + * ``` + * + * In order to combine these functions, we need to define a new [flatMap] function that can get us + * from a `Result` to a `Result`, and short-circuit on any error. We want to see this: + * ``` + * val validatingParser = { s -> + * intParser.invoke(s).flatMap { i -> + * validator(i) + * } + * } + * ``` + * + * The flatMap is relatively simply defined, we can mimic the existing definition for [Result.map], + * though the implementation is uglier because of the `internal` definition for `value` + * + * ``` + * inline fun Result.flatMap(transform: (value: T) -> Result): Result { + * return when { + * isSuccess -> transform(getOrThrow()) + * else -> Result.failure(exceptionOrNull()!!) + * } + * } + * ``` + */ + +/** + * Given a [transform] that returns a [Result], apply the transform to this result, unwrapping the + * return value so that + * + * These [contract] and [callsInPlace] methods are copied from the [Result.map] definition + */ +@OptIn(ExperimentalContracts::class) +inline fun Result.flatMap(transform: (value: T) -> Result): Result { + contract { callsInPlace(transform, InvocationKind.AT_MOST_ONCE) } + + return when { + // Should never throw, we just don't have access to [this.value] + isSuccess -> transform(getOrThrow()) + // Exception should never be null here + else -> Result.failure(exceptionOrNull()!!) + } +} + +/** + * ValueParser turns a [String] into a Result by applying a transform. See the default + * implementations below for starting points. The intention here is to provide the base mappings and + * allow clients to attach their own transforms. They are expected to succeed or return null on + * failure. The failure is propagated to the command parser as a Result and will fail on any + * [Result.failure] + */ +fun interface ValueParser { + fun parseValue(value: String): Result +} + +/** Map a [ValueParser] of type A to one of type B, by applying the given [transform] */ +inline fun ValueParser.map(crossinline transform: (A) -> B?): ValueParser { + return ValueParser { value -> + this.parseValue(value).flatMap { a -> + transform(a)?.let { b -> Result.success(b) } + ?: Result.failure(ArgParseError("Failed to transform value $value")) + } + } +} + +/** + * Base type parsers are provided by the lib, and can be simply composed upon by [ValueParser.map] + * functions on the parser + */ + +/** String parsing always succeeds if the value exists */ +private val parseString: ValueParser = ValueParser { value -> Result.success(value) } + +private val parseBoolean: ValueParser = ValueParser { value -> + value.toBooleanStrictOrNull()?.let { Result.success(it) } + ?: Result.failure(ArgParseError("Failed to parse $value as a boolean")) +} + +private val parseInt: ValueParser = ValueParser { value -> + value.toIntOrNull()?.let { Result.success(it) } + ?: Result.failure(ArgParseError("Failed to parse $value as an int")) +} + +private val parseFloat: ValueParser = ValueParser { value -> + value.toFloatOrNull()?.let { Result.success(it) } + ?: Result.failure(ArgParseError("Failed to parse $value as a float")) +} + +/** Default parsers that can be use as-is, or [map]ped to another type */ +object Type { + val Boolean = parseBoolean + val Int = parseInt + val Float = parseFloat + val String = parseString +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/CommandParserTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/CommandParserTest.kt new file mode 100644 index 0000000000000..cfbe8e36537d4 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/CommandParserTest.kt @@ -0,0 +1,212 @@ +/* + * 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 androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.google.common.truth.Truth.assertThat +import java.io.PrintWriter +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +@SmallTest +class CommandParserTest : SysuiTestCase() { + private val parser = CommandParser() + + @Test + fun registerToken_cannotReuseNames() { + parser.flag("-f") + assertThrows(IllegalArgumentException::class.java) { parser.flag("-f") } + } + + @Test + fun unknownToken_throws() { + assertThrows(ArgParseError::class.java) { parser.parse(listOf("unknown-token")) } + } + + @Test + fun parseSingleFlag_present() { + val flag by parser.flag("-f") + parser.parse(listOf("-f")) + assertTrue(flag) + } + + @Test + fun parseSingleFlag_notPresent() { + val flag by parser.flag("-f") + parser.parse(listOf()) + assertFalse(flag) + } + + @Test + fun parseSingleOptionalParam_present() { + val param by parser.param("-p", valueParser = Type.Int) + parser.parse(listOf("-p", "123")) + assertThat(param).isEqualTo(123) + } + + @Test + fun parseSingleOptionalParam_notPresent() { + val param by parser.param("-p", valueParser = Type.Int) + parser.parse(listOf()) + assertThat(param).isNull() + } + + @Test + fun parseSingleOptionalParam_missingArg_throws() { + val param by parser.param("-p", valueParser = Type.Int) + assertThrows(ArgParseError::class.java) { parser.parse(listOf("-p")) } + } + + @Test + fun parseSingleRequiredParam_present() { + val param by parser.require(parser.param("-p", valueParser = Type.Int)) + parser.parse(listOf("-p", "123")) + assertThat(param).isEqualTo(123) + } + + @Test + fun parseSingleRequiredParam_notPresent_failsValidation() { + val param by parser.require(parser.param("-p", valueParser = Type.Int)) + assertFalse(parser.parse(listOf())) + } + + @Test + fun parseSingleRequiredParam_missingArg_throws() { + val param by parser.require(parser.param("-p", valueParser = Type.Int)) + assertThrows(ArgParseError::class.java) { parser.parse(listOf("-p")) } + } + + @Test + fun parseAsSubCommand_singleFlag_present() { + val flag by parser.flag("-f") + val args = listOf("-f").listIterator() + parser.parseAsSubCommand(args) + + assertTrue(flag) + } + + @Test + fun parseAsSubCommand_singleFlag_notPresent() { + val flag by parser.flag("-f") + val args = listOf("--other-flag").listIterator() + parser.parseAsSubCommand(args) + + assertFalse(flag) + } + + @Test + fun parseAsSubCommand_singleOptionalParam_present() { + val param by parser.param("-p", valueParser = Type.Int) + parser.parseAsSubCommand(listOf("-p", "123", "--other-arg", "321").listIterator()) + assertThat(param).isEqualTo(123) + } + + @Test + fun parseAsSubCommand_singleOptionalParam_notPresent() { + val param by parser.param("-p", valueParser = Type.Int) + parser.parseAsSubCommand(listOf("--other-arg", "321").listIterator()) + assertThat(param).isNull() + } + + @Test + fun parseAsSubCommand_singleRequiredParam_present() { + val param by parser.require(parser.param("-p", valueParser = Type.Int)) + parser.parseAsSubCommand(listOf("-p", "123", "--other-arg", "321").listIterator()) + assertThat(param).isEqualTo(123) + } + + @Test + fun parseAsSubCommand_singleRequiredParam_notPresent() { + parser.require(parser.param("-p", valueParser = Type.Int)) + assertFalse(parser.parseAsSubCommand(listOf("--other-arg", "321").listIterator())) + } + + @Test + fun parseCommandWithSubCommand_required_provided() { + val topLevelFlag by parser.flag("flag", shortName = "-f") + + val cmd = + object : ParseableCommand("test") { + val flag by flag("flag1") + override fun execute(pw: PrintWriter) {} + } + + parser.require(parser.subCommand(cmd)) + parser.parse(listOf("-f", "test", "--flag1")) + + assertTrue(topLevelFlag) + assertThat(cmd).isNotNull() + assertTrue(cmd.flag) + } + + @Test + fun parseCommandWithSubCommand_required_notProvided() { + val topLevelFlag by parser.flag("-f") + + val cmd = + object : ParseableCommand("test") { + val flag by parser.flag("flag1") + override fun execute(pw: PrintWriter) {} + } + + parser.require(parser.subCommand(cmd)) + + assertFalse(parser.parse(listOf("-f"))) + } + + @Test + fun flag_requiredParam_optionalParam_allProvided_failsValidation() { + val flag by parser.flag("-f") + val optionalParam by parser.param("-p", valueParser = Type.Int) + val requiredParam by parser.require(parser.param("-p2", valueParser = Type.Boolean)) + + parser.parse( + listOf( + "-f", + "-p", + "123", + "-p2", + "false", + ) + ) + + assertTrue(flag) + assertThat(optionalParam).isEqualTo(123) + assertFalse(requiredParam) + } + + @Test + fun flag_requiredParam_optionalParam_optionalExcluded() { + val flag by parser.flag("-f") + val optionalParam by parser.param("-p", valueParser = Type.Int) + val requiredParam by parser.require(parser.param("-p2", valueParser = Type.Boolean)) + + parser.parse( + listOf( + "-p2", + "true", + ) + ) + + assertFalse(flag) + assertThat(optionalParam).isNull() + assertTrue(requiredParam) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ParametersTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ParametersTest.kt new file mode 100644 index 0000000000000..e391d6b11cd6a --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ParametersTest.kt @@ -0,0 +1,55 @@ +package com.android.systemui.statusbar.commandline + +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +@SmallTest +class ParametersTest : SysuiTestCase() { + @Test + fun singleArgOptional_returnsNullBeforeParse() { + val optional by SingleArgParamOptional(longName = "longName", valueParser = Type.Int) + assertThat(optional).isNull() + } + + @Test + fun singleArgOptional_returnsParsedValue() { + val param = SingleArgParamOptional(longName = "longName", valueParser = Type.Int) + param.parseArgsFromIter(listOf("3").listIterator()) + val optional by param + assertThat(optional).isEqualTo(3) + } + + @Test + fun singleArgRequired_throwsBeforeParse() { + val req by SingleArgParam(longName = "param", valueParser = Type.Boolean) + assertThrows(IllegalStateException::class.java) { req } + } + + @Test + fun singleArgRequired_returnsParsedValue() { + val param = SingleArgParam(longName = "param", valueParser = Type.Boolean) + param.parseArgsFromIter(listOf("true").listIterator()) + val req by param + assertTrue(req) + } + + @Test + fun param_handledAfterParse() { + val optParam = SingleArgParamOptional(longName = "string1", valueParser = Type.String) + val reqParam = SingleArgParam(longName = "string2", valueParser = Type.Float) + + assertFalse(optParam.handled) + assertFalse(reqParam.handled) + + optParam.parseArgsFromIter(listOf("test").listIterator()) + reqParam.parseArgsFromIter(listOf("1.23").listIterator()) + + assertTrue(optParam.handled) + assertTrue(reqParam.handled) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ParseableCommandTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ParseableCommandTest.kt new file mode 100644 index 0000000000000..86548d0790036 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ParseableCommandTest.kt @@ -0,0 +1,317 @@ +/* + * 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 androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.google.common.truth.Truth.assertThat +import java.io.PrintWriter +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations + +@SmallTest +class ParseableCommandTest : SysuiTestCase() { + @Mock private lateinit var pw: PrintWriter + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + } + + /** + * A little change-detector-y, but this is just a general assertion that building up a command + * parser via its wrapper works as expected. + */ + @Test + fun testFactoryMethods() { + val mySubCommand = + object : ParseableCommand("subCommand") { + val flag by flag("flag") + override fun execute(pw: PrintWriter) {} + } + + val mySubCommand2 = + object : ParseableCommand("subCommand2") { + val flag by flag("flag") + override fun execute(pw: PrintWriter) {} + } + + // Verify that the underlying parser contains the correct types + val myCommand = + object : ParseableCommand("testName") { + val flag by flag("flag", shortName = "f") + val requiredParam by + param(longName = "required-param", shortName = "r", valueParser = Type.String) + .required() + val optionalParam by + param(longName = "optional-param", shortName = "o", valueParser = Type.Boolean) + val optionalSubCommand by subCommand(mySubCommand) + val requiredSubCommand by subCommand(mySubCommand2).required() + + override fun execute(pw: PrintWriter) {} + } + + val flags = myCommand.parser.flags + val params = myCommand.parser.params + val subCommands = myCommand.parser.subCommands + + assertThat(flags).hasSize(2) + assertThat(flags[0]).isInstanceOf(Flag::class.java) + assertThat(flags[1]).isInstanceOf(Flag::class.java) + + assertThat(params).hasSize(2) + val req = params.filter { it is SingleArgParam<*> } + val opt = params.filter { it is SingleArgParamOptional<*> } + assertThat(req).hasSize(1) + assertThat(opt).hasSize(1) + + val reqSub = subCommands.filter { it is RequiredSubCommand<*> } + val optSub = subCommands.filter { it is OptionalSubCommand<*> } + assertThat(reqSub).hasSize(1) + assertThat(optSub).hasSize(1) + } + + @Test + fun factoryMethods_enforceShortNameRules() { + // Short names MUST be one character long + assertThrows(IllegalArgumentException::class.java) { + val myCommand = + object : ParseableCommand("test-command") { + val flag by flag("longName", "invalidShortName") + + override fun execute(pw: PrintWriter) {} + } + } + + assertThrows(IllegalArgumentException::class.java) { + val myCommand = + object : ParseableCommand("test-command") { + val param by param("longName", "invalidShortName", valueParser = Type.String) + + override fun execute(pw: PrintWriter) {} + } + } + } + + @Test + fun factoryMethods_enforceLongNames_notPrefixed() { + // Long names must not start with "-", since they will be added + assertThrows(IllegalArgumentException::class.java) { + val myCommand = + object : ParseableCommand("test-command") { + val flag by flag("--invalid") + + override fun execute(pw: PrintWriter) {} + } + } + + assertThrows(IllegalArgumentException::class.java) { + val myCommand = + object : ParseableCommand("test-command") { + val param by param("-invalid", valueParser = Type.String) + + override fun execute(pw: PrintWriter) {} + } + } + } + + @Test + fun executeDoesNotPropagateExceptions() { + val cmd = + object : ParseableCommand("test-command") { + val flag by flag("flag") + override fun execute(pw: PrintWriter) {} + } + + val throwingCommand = listOf("unknown-token") + + // Given a command that would cause an ArgParseError + assertThrows(ArgParseError::class.java) { cmd.parser.parse(throwingCommand) } + + // The parser consumes that error + cmd.execute(pw, throwingCommand) + } + + @Test + fun executeFailingCommand_callsOnParseFailed() { + val cmd = + object : ParseableCommand("test-command") { + val flag by flag("flag") + + var onParseFailedCalled = false + + override fun execute(pw: PrintWriter) {} + override fun onParseFailed(error: ArgParseError) { + onParseFailedCalled = true + } + } + + val throwingCommand = listOf("unknown-token") + cmd.execute(pw, throwingCommand) + + assertTrue(cmd.onParseFailedCalled) + } + + @Test + fun baseCommand() { + val myCommand = MyCommand() + myCommand.execute(pw, baseCommand) + + assertThat(myCommand.flag1).isFalse() + assertThat(myCommand.singleParam).isNull() + } + + @Test + fun commandWithFlags() { + val command = MyCommand() + command.execute(pw, cmdWithFlags) + + assertThat(command.flag1).isTrue() + assertThat(command.flag2).isTrue() + } + + @Test + fun commandWithArgs() { + val cmd = MyCommand() + cmd.execute(pw, cmdWithSingleArgParam) + + assertThat(cmd.singleParam).isEqualTo("single_param") + } + + @Test + fun commandWithRequiredParam_provided() { + val cmd = + object : ParseableCommand(name) { + val singleRequiredParam: String by + param( + longName = "param1", + shortName = "p", + valueParser = Type.String, + ) + .required() + + override fun execute(pw: PrintWriter) {} + } + + val cli = listOf("-p", "value") + cmd.execute(pw, cli) + + assertThat(cmd.singleRequiredParam).isEqualTo("value") + } + + @Test + fun commandWithRequiredParam_not_provided_throws() { + val cmd = + object : ParseableCommand(name) { + val singleRequiredParam by + param(shortName = "p", longName = "param1", valueParser = Type.String) + .required() + + override fun execute(pw: PrintWriter) {} + + override fun execute(pw: PrintWriter, args: List) { + parser.parse(args) + execute(pw) + } + } + + val cli = listOf("") + assertThrows(ArgParseError::class.java) { cmd.execute(pw, cli) } + } + + @Test + fun commandWithSubCommand() { + val subName = "sub-command" + val subCmd = + object : ParseableCommand(subName) { + val singleOptionalParam: String? by param("param", valueParser = Type.String) + + override fun execute(pw: PrintWriter) {} + } + + val cmd = + object : ParseableCommand(name) { + val subCmd by subCommand(subCmd) + override fun execute(pw: PrintWriter) {} + } + + cmd.execute(pw, listOf("sub-command", "--param", "test")) + assertThat(cmd.subCmd?.singleOptionalParam).isEqualTo("test") + } + + @Test + fun complexCommandWithSubCommands_reusedNames() { + val commandLine = "-f --param1 arg1 sub-command1 -f -p arg2 --param2 arg3".split(" ") + + val subName = "sub-command1" + val subCmd = + object : ParseableCommand(subName) { + val flag1 by flag("flag", shortName = "f") + val param1: String? by param("param1", shortName = "p", valueParser = Type.String) + + override fun execute(pw: PrintWriter) {} + } + + val myCommand = + object : ParseableCommand(name) { + val flag1 by flag(longName = "flag", shortName = "f") + val param1 by param("param1", shortName = "p", valueParser = Type.String).required() + val param2: String? by param(longName = "param2", valueParser = Type.String) + val subCommand by subCommand(subCmd) + + override fun execute(pw: PrintWriter) {} + } + + myCommand.execute(pw, commandLine) + + assertThat(myCommand.flag1).isTrue() + assertThat(myCommand.param1).isEqualTo("arg1") + assertThat(myCommand.param2).isEqualTo("arg3") + assertThat(myCommand.subCommand).isNotNull() + assertThat(myCommand.subCommand?.flag1).isTrue() + assertThat(myCommand.subCommand?.param1).isEqualTo("arg2") + } + + class MyCommand( + private val onExecute: ((MyCommand) -> Unit)? = null, + ) : ParseableCommand(name) { + + val flag1 by flag(shortName = "f", longName = "flag1", description = "flag 1 for test") + val flag2 by flag(shortName = "g", longName = "flag2", description = "flag 2 for test") + val singleParam: String? by + param( + shortName = "a", + longName = "arg1", + valueParser = Type.String, + ) + + override fun execute(pw: PrintWriter) { + onExecute?.invoke(this) + } + } + + companion object { + const val name = "my_command" + val baseCommand = listOf("") + val cmdWithFlags = listOf("-f", "--flag2") + val cmdWithSingleArgParam = listOf("--arg1", "single_param") + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ValueParserTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ValueParserTest.kt new file mode 100644 index 0000000000000..759f0bcd6ea8c --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/commandline/ValueParserTest.kt @@ -0,0 +1,61 @@ +package com.android.systemui.statusbar.commandline + +import android.graphics.Rect +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertTrue +import org.junit.Test + +@SmallTest +class ValueParserTest : SysuiTestCase() { + @Test + fun parseString() { + assertThat(Type.String.parseValue("test")).isEqualTo(Result.success("test")) + } + + @Test + fun parseInt() { + assertThat(Type.Int.parseValue("123")).isEqualTo(Result.success(123)) + + assertTrue(Type.Int.parseValue("not an Int").isFailure) + } + + @Test + fun parseFloat() { + assertThat(Type.Float.parseValue("1.23")).isEqualTo(Result.success(1.23f)) + + assertTrue(Type.Int.parseValue("not a Float").isFailure) + } + + @Test + fun parseBoolean() { + assertThat(Type.Boolean.parseValue("true")).isEqualTo(Result.success(true)) + assertThat(Type.Boolean.parseValue("false")).isEqualTo(Result.success(false)) + + assertTrue(Type.Boolean.parseValue("not a Boolean").isFailure) + } + + @Test + fun mapToComplexType() { + val parseSquare = Type.Int.map { Rect(it, it, it, it) } + + assertThat(parseSquare.parseValue("10")).isEqualTo(Result.success(Rect(10, 10, 10, 10))) + } + + @Test + fun mapToFallibleComplexType() { + val fallibleParseSquare = + Type.Int.map { + if (it > 0) { + Rect(it, it, it, it) + } else { + null + } + } + + assertThat(fallibleParseSquare.parseValue("10")) + .isEqualTo(Result.success(Rect(10, 10, 10, 10))) + assertTrue(fallibleParseSquare.parseValue("-10").isFailure) + } +}