Improve CLI Parameters Parsing (#1117)

This commit is contained in:
Radosław Waśko 2020-09-03 12:44:21 +02:00 committed by GitHub
parent 60d0c2ae45
commit 2da720b1a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 1615 additions and 738 deletions

View File

@ -501,7 +501,8 @@ lazy val cli = project
libraryDependencies ++= Seq(
"org.scalatest" %% "scalatest" % scalatestVersion % Test,
"org.typelevel" %% "cats-core" % catsVersion
)
),
parallelExecution in Test := false
)
.settings(licenseSettings)

View File

@ -34,8 +34,12 @@ This document describes available command-line options of the Enso launcher.
- [`version`](#version)
- [`help`](#help)
- [General Options](#general-options)
- [`--version`](#--version)
- [`--use-enso-version`](#--use-enso-version)
- [`--use-system-jvm`](#--use-system-jvm)
- [`--auto-confirm`](#--auto-confirm)
- [`--hide-progress`](#--hide-progress)
- [`--ensure-portable`](#--ensure-portable)
- [Options From Newer Versions](#options-from-newer-versions)
- [JVM Options](#jvm-options)
<!-- /MarkdownTOC -->
@ -224,9 +228,11 @@ Launcher has been downgraded to version 2.0.1.
Prints the version of the installed launcher as well as the full version string
of the currently selected Enso distribution.
Flag `--json` can be added to get the output in JSON format, instead of the
human-readable format that is the default.
```bash
> enso version
Enso Launcher
Version: 0.0.1
Built with: scala-2.13.3 for GraalVM 20.1.0
@ -260,6 +266,21 @@ command.
Tells the launcher to use the default JVM (based on `JAVA_HOME`) instead of the
managed one. Will not work if the set-up JVM version is not GraalVM.
### `--auto-confirm`
Tells the launcher to not ask questions, but proceed with defaults. Useful for
automation.
### `--hide-progress`
Suppresses displaying progress bars for downloads and other long running
actions. May be needed if program output is piped.
### `--ensure-portable`
Checks if the launcher is run in portable mode and if it is not, terminates the
application.
## Options From Newer Versions
For commands that launch an Enso component inside a JVM (`repl`, `run` and

View File

@ -327,16 +327,14 @@ case class Launcher(cliOptions: GlobalCLIOptions) {
/**
* Displays the version string of the launcher.
*
* @param useJSON specifies whether the output should use JSON or a
* human-readable format
* @param hideEngineVersion if set, does not look for installed engines to
* display the current version; this can be used to
* avoid making network requests
*/
def displayVersion(
useJSON: Boolean,
hideEngineVersion: Boolean = false
): Unit = {
val useJSON = cliOptions.useJSON
val runtimeVersionParameter =
if (hideEngineVersion) None else Some(getEngineVersion(useJSON))

View File

@ -1,7 +1,7 @@
package org.enso.launcher.cli
import nl.gn0s1s.bump.SemVer
import org.enso.cli.Argument
import org.enso.cli.arguments.{Argument, OptsParseError}
object Arguments {
@ -11,6 +11,7 @@ object Arguments {
*/
implicit val semverArgument: Argument[SemVer] = (string: String) =>
SemVer(string).toRight(
List(s"`$string` is not a valid semantic version string.")
OptsParseError(s"`$string` is not a valid semantic version string.")
)
}

View File

@ -8,5 +8,11 @@ package org.enso.launcher.cli
* must be explained in the help text for each command
* @param hideProgress if this flag is set, progress bars should not be
* printed
* @param useJSON specifies if output should be in JSON format, if it is
* supported (currently only the version command supports JSON)
*/
case class GlobalCLIOptions(autoConfirm: Boolean, hideProgress: Boolean)
case class GlobalCLIOptions(
autoConfirm: Boolean,
hideProgress: Boolean,
useJSON: Boolean
)

View File

@ -3,11 +3,11 @@ package org.enso.launcher.cli
import java.io.IOException
import java.nio.file.{Files, NoSuchFileException, Path}
import org.enso.cli.Opts
import org.enso.cli.Opts.implicits._
import cats.implicits._
import org.enso.launcher.{FileSystem, OS}
import org.enso.cli.arguments.Opts
import org.enso.cli.arguments.Opts.implicits._
import org.enso.launcher.FileSystem.PathSyntax
import org.enso.launcher.{FileSystem, OS}
/**
* Implements internal options that the launcher may use when running another

View File

@ -3,10 +3,19 @@ package org.enso.launcher.cli
import java.nio.file.Path
import java.util.UUID
import cats.data.NonEmptyList
import cats.implicits._
import nl.gn0s1s.bump.SemVer
import org.enso.cli.Opts.implicits._
import org.enso.cli.arguments.Opts.implicits._
import org.enso.cli._
import org.enso.cli.arguments.{
Application,
Argument,
Command,
Opts,
OptsParseError,
TopLevelBehavior
}
import org.enso.launcher.cli.Arguments._
import org.enso.launcher.components.runner.LanguageServerOptions
import org.enso.launcher.config.DefaultVersion
@ -22,13 +31,6 @@ import org.enso.launcher.{Launcher, Logger}
* Defines the CLI commands and options for the program and its entry point.
*/
object Main {
private def jsonFlag(showInUsage: Boolean): Opts[Boolean] =
Opts.flag(
"json",
"Use JSON instead of plain text for version output.",
showInUsage
)
type Config = GlobalCLIOptions
private def versionCommand: Command[Config => Unit] =
@ -43,10 +45,8 @@ object Main {
"configuration.",
showInUsage = true
)
(jsonFlag(showInUsage = true), onlyLauncherFlag) mapN {
(useJSON, onlyLauncher) => (config: Config) =>
onlyLauncherFlag map { onlyLauncher => (config: Config) =>
Launcher(config).displayVersion(
useJSON,
hideEngineVersion = onlyLauncher
)
}
@ -278,10 +278,10 @@ object Main {
}
}
private def installEngineCommand: Subcommand[Config => Unit] =
Subcommand(
private def installEngineCommand: Command[Config => Unit] =
Command(
"engine",
"Installs the specified engine VERSION, defaulting to the latest if " +
"Install the specified engine VERSION, defaulting to the latest if " +
"unspecified."
) {
val version = Opts.optionalArgument[SemVer]("VERSION")
@ -295,10 +295,10 @@ object Main {
}
}
private def installDistributionCommand: Subcommand[Config => Unit] =
Subcommand(
private def installDistributionCommand: Command[Config => Unit] =
Command(
"distribution",
"Installs Enso on the system, deactivating portable mode."
"Install Enso on the system, deactivating portable mode."
) {
implicit val bundleActionParser: Argument[BundleAction] = {
@ -306,10 +306,10 @@ object Main {
case "copy" => DistributionInstaller.CopyBundles.asRight
case "ignore" => DistributionInstaller.IgnoreBundles.asRight
case other =>
List(
OptsParseError.left(
s"`$other` is not a valid bundle-install-mode value. " +
s"Possible values are: `move`, `copy`, `ignore`."
).asLeft
)
}
val bundleAction = Opts.optionalParameter[BundleAction](
"bundle-install-mode",
@ -349,10 +349,10 @@ object Main {
Opts.subcommands(installEngineCommand, installDistributionCommand)
}
private def uninstallEngineCommand: Subcommand[Config => Unit] =
Subcommand(
private def uninstallEngineCommand: Command[Config => Unit] =
Command(
"engine",
"Uninstalls the provided engine version. If the corresponding runtime " +
"Uninstall the provided engine version. If the corresponding runtime " +
"is not used by any remaining engine installations, it is also removed."
) {
val version = Opts.positionalArgument[SemVer]("VERSION")
@ -361,10 +361,10 @@ object Main {
}
}
private def uninstallDistributionCommand: Subcommand[Config => Unit] =
Subcommand(
private def uninstallDistributionCommand: Command[Config => Unit] =
Command(
"distribution",
"Uninstalls whole Enso distribution and all components managed by " +
"Uninstall whole Enso distribution and all components managed by " +
"it. If `auto-confirm` is set, it will not attempt to remove the " +
"ENSO_DATA_DIRECTORY and ENSO_CONFIG_DIRECTORY if they contain any " +
"unexpected files."
@ -394,10 +394,10 @@ object Main {
case "engine" => EnsoComponents.asRight
case "runtime" => RuntimeComponents.asRight
case other =>
List(
OptsParseError.left(
s"Unknown argument `$other` - expected `engine`, `runtime` " +
"or no argument to print a general summary."
).asLeft
)
}
val what = Opts.optionalArgument[Components](
@ -441,10 +441,13 @@ object Main {
}
private def topLevelOpts: Opts[() => TopLevelBehavior[Config]] = {
val help = Opts.flag("help", 'h', "Display help.", showInUsage = true)
val version =
Opts.flag("version", 'V', "Display version.", showInUsage = true)
val json = jsonFlag(showInUsage = false)
val json = Opts.flag(
"json",
"Use JSON instead of plain text for version output.",
showInUsage = false
)
val ensurePortable = Opts.flag(
"ensure-portable",
"Ensures that the launcher is run in portable mode.",
@ -467,35 +470,25 @@ object Main {
(
internalOpts,
help,
version,
json,
ensurePortable,
autoConfirm,
hideProgress
) mapN {
(
_,
help,
version,
useJSON,
shouldEnsurePortable,
autoConfirm,
hideProgress
) => () =>
(_, version, useJSON, shouldEnsurePortable, autoConfirm, hideProgress) =>
() =>
if (shouldEnsurePortable) {
Launcher.ensurePortable()
}
val globalCLIOptions = GlobalCLIOptions(
autoConfirm = autoConfirm,
hideProgress = hideProgress
hideProgress = hideProgress,
useJSON = useJSON
)
if (help) {
printTopLevelHelp()
TopLevelBehavior.Halt
} else if (version) {
if (version) {
Launcher(globalCLIOptions).displayVersion(useJSON)
TopLevelBehavior.Halt
} else
@ -509,7 +502,7 @@ object Main {
"Enso",
"Enso Launcher",
topLevelOpts,
Seq(
NonEmptyList.of(
versionCommand,
helpCommand,
newCommand,

View File

@ -2,17 +2,18 @@ package org.enso.launcher.cli
import java.nio.file.{Files, Path}
import org.enso.cli.{CommandHelp, PluginBehaviour, PluginNotFound}
import org.enso.cli.arguments
import org.enso.cli.arguments.CommandHelp
import org.enso.launcher.{Environment, FileSystem}
import scala.sys.process._
import scala.util.Try
/**
* Implements an [[org.enso.cli.PluginManager]] using the given
* Implements an [[arguments.PluginManager]] using the given
* [[Environment]].
*/
class PluginManager(env: Environment) extends org.enso.cli.PluginManager {
class PluginManager(env: Environment) extends arguments.PluginManager {
/**
* Checks if the provided name represents a valid plugin and tries to run it.
@ -20,18 +21,26 @@ class PluginManager(env: Environment) extends org.enso.cli.PluginManager {
* @param name name of the plugin
* @param args arguments that should be passed to it
*/
override def tryRunningPlugin(
override def runPlugin(
name: String,
args: Seq[String]
): PluginBehaviour =
): Nothing =
findPlugin(name) match {
case Some(PluginDescription(commandName, _)) =>
val exitCode = (Seq(commandName) ++ args).!
sys.exit(exitCode)
case None =>
PluginNotFound
throw new RuntimeException(
"Internal error: Could not find the plugin. " +
"This should not happen if hasPlugin returned true earlier."
)
}
/**
* @inheritdoc
*/
override def hasPlugin(name: String): Boolean = findPlugin(name).isDefined
private val pluginPrefix = "enso-"
private val synopsisOption: String = "--synopsis"
@ -56,21 +65,38 @@ class PluginManager(env: Environment) extends org.enso.cli.PluginManager {
} yield CommandHelp(pluginName, description.synopsis)
}
/**
* @inheritdoc
*/
override def pluginsNames(): Seq[String] = pluginsHelp().map(_.name)
/**
* A short description of a plugin consisting of its command name and
* synopsis.
*/
case class PluginDescription(executableName: String, synopsis: String)
private val pluginsCache
: collection.mutable.HashMap[String, Option[PluginDescription]] =
collection.mutable.HashMap.empty
/**
* Checks if the plugin with the given name is installed and valid.
*
* It tries to execute it (checking various command extensions depending on
* the OS) and check if it returns a synopsis.
*
* Results of this function are cached to avoid executing the plugin's
* `--synopsis` multiple times.
*
* @param name name of the plugin
* @return [[PluginDescription]] containing the command name that should be
* used to call the plugin and its synopsis
*/
private def findPlugin(name: String): Option[PluginDescription] = {
private def findPlugin(name: String): Option[PluginDescription] =
pluginsCache.getOrElseUpdate(name, lookupPlugin(name))
private def lookupPlugin(name: String): Option[PluginDescription] = {
def canonicalizeDescription(description: String): String =
description.replace("\n", " ").trim
val noOpLogger = new ProcessLogger {

View File

@ -3,9 +3,9 @@ package org.enso.launcher.config
import io.circe.{Decoder, Encoder, Json}
import io.circe.syntax._
import nl.gn0s1s.bump.SemVer
import org.enso.cli.arguments.Argument
import org.enso.launcher.cli.Arguments._
import org.enso.pkg.SemVerJson._
import org.enso.cli.Argument
/**
* Default version that is used when launching Enso outside of projects and

View File

@ -53,7 +53,11 @@ class ComponentsManagerTest
FakeReleaseProvider(fakeReleasesRoot.resolve("graalvm"))
)
val componentsManager = new ComponentsManager(
GlobalCLIOptions(autoConfirm = true, hideProgress = true),
GlobalCLIOptions(
autoConfirm = true,
hideProgress = true,
useJSON = false
),
distributionManager,
engineProvider,
runtimeProvider

View File

@ -15,10 +15,10 @@ object CLIOutput {
* aligned, so that the second column of each row starts at the same
* indentation level (determined by the longest cell in the first column).
* Only two-column tables are supported (more columns can be included, but
* they will not be aligned). If the first column is so wide, that the second
* column would be thinner than [[minimumColumnWrapWidth]], the second column
* is wrapped to [[minimumColumnWrapWidth]] exceeding the [[terminalWidth]]
* limit.
* they will not be aligned). If an entry in the first column is so wide,
* that the second column would be thinner than [[minimumColumnWrapWidth]],
* the second column is started at the next line, padded with maximum
* permitted padding ({{{ terminalWidth - minimumColumnWrapWidth }}}).
*
* If a word without spaces is longer than [[terminalWidth]] it is also not
* wrapped. This is done to avoid breaking long URLs when wrapping text.

View File

@ -1,6 +1,8 @@
package org.enso.cli
package org.enso.cli.arguments
import cats.data.NonEmptyList
import org.enso.cli.internal.Parser
import org.enso.cli.internal.opts.TopLevelCommandsOpt
/**
* Represents a CLI application with multiple commands.
@ -44,10 +46,18 @@ class Application[Config](
val prettyName: String,
val helpHeader: String,
val topLevelOpts: Opts[() => TopLevelBehavior[Config]],
val commands: Seq[Command[Config => Unit]],
val commands: NonEmptyList[Command[Config => Unit]],
val pluginManager: Option[PluginManager]
) {
private val combinedOpts =
new TopLevelCommandsOpt(
topLevelOpts,
commands,
pluginManager,
helpHeader
)
/**
* A helper overload that accepts the array as provided to the main function.
*/
@ -59,6 +69,9 @@ class Application[Config](
* Runs the application logic. Parses the top level options and depending on
* its result, possibly runs a command or a plugin.
*
* If a plugin is run, this function does not return (the application exits
* with the exit code returned by the plugin).
*
* @return either a list of errors encountered when parsing the options or
* [[Unit]] if it succeeded
*/
@ -66,163 +79,66 @@ class Application[Config](
args: Seq[String]
): Either[List[String], Unit] = {
val (tokens, additionalArguments) = Parser.tokenize(args)
val topLevelParseResult =
val parseResult =
Parser.parseOpts(
topLevelOpts,
combinedOpts,
tokens,
Seq(),
isTopLevel = true,
commandPrefix = Seq(commandName)
additionalArguments,
applicationName = commandName
)
topLevelParseResult.flatMap {
case (run, restOfTokens) =>
run() match {
case TopLevelBehavior.Halt => Right(())
val finalResult = parseResult.flatMap {
case ((topLevelAction, commandResult), pluginIntercepted) =>
pluginIntercepted match {
case Some(pluginHandler) =>
pluginHandler()
case None =>
val topLevelBehavior = topLevelAction()
topLevelBehavior match {
case TopLevelBehavior.Halt =>
Right(())
case TopLevelBehavior.Continue(config) =>
val subCommandResult = Parser.parseCommand(
this,
config,
restOfTokens,
additionalArguments
)
subCommandResult.map { run =>
run()
commandResult match {
case Some(action) =>
Right(action(config))
case None =>
Left(OptsParseError("Expected a command.", renderHelp()))
}
}
}
}
finalResult.toErrorList
}
/**
* Generates a help text summarizing the usage of the application and listing
* available commands and top-level options.
*/
def renderHelp(): String = {
val usageOptions = topLevelOpts.commandLineOptions().stripLeading()
val usage = s"Usage: $commandName\t${usageOptions} COMMAND [ARGS]\n"
val subCommands = commands.map(_.topLevelHelp) ++ pluginManager
.map(_.pluginsHelp())
.getOrElse(Seq())
val commandDescriptions =
subCommands.map(_.toString).map(CLIOutput.indent + _ + "\n").mkString
val topLevelOptionsHelp =
topLevelOpts.helpExplanations(addHelpOption = false)
val sb = new StringBuilder
sb.append(helpHeader + "\n")
sb.append(usage)
sb.append("\nAvailable commands:\n")
sb.append(commandDescriptions)
sb.append(topLevelOptionsHelp)
sb.append(
s"\nFor more information on a specific command listed above," +
s" please run `$commandName COMMAND --help`."
)
sb.toString()
}
/**
* Generates a help text for an unknown command, including, if available,
* suggestions of similar commands.
*
* @param typo the unrecognized command name
*/
def commandSuggestions(typo: String): String = {
val header =
s"`$typo` is not a valid $prettyName command. See " +
s"`$commandName --help`.\n\n"
val similar = Spelling.selectClosestMatches(typo, gatherCommandNames())
val suggestions =
if (similar.isEmpty) ""
else {
"The most similar commands are\n" +
similar.map(CLIOutput.indent + _ + "\n").mkString
}
header + suggestions
}
private def gatherCommandNames(): Seq[String] =
commands.map(_.name) ++ pluginManager.map(_.pluginsNames()).getOrElse(Seq())
}
/**
* The datatype returned by the [[PluginManager]], specifies whether a plugin
* has been executed.
*/
sealed trait PluginBehaviour
/**
* Returned if the plugin was not found.
*/
case object PluginNotFound extends PluginBehaviour
/**
* Returned if a plugin has been found and has been run.
*/
case object PluginInterceptedFlow extends PluginBehaviour
/**
* A plugin manager that handles finding and running plugins.
*/
trait PluginManager {
/**
* Tries to run a given plugin with provided arguments.
*
* @param name name of the plugin
* @param args arguments that should be passed to it
*/
def tryRunningPlugin(name: String, args: Seq[String]): PluginBehaviour
/**
* Lists names of plugins found in the system.
*/
def pluginsNames(): Seq[String]
/**
* Lists names and short descriptions of plugins found on the system.
*/
def pluginsHelp(): Seq[CommandHelp]
}
/**
* Defines the behaviour of parsing top-level application options.
*
* @tparam Config type of configuration that is passed to commands
*/
trait TopLevelBehavior[+Config]
object TopLevelBehavior {
/**
* If top-level options return a value of this class, the application should
* continue execution. The provided value of `Config` should be passed to the
* executed commands.
*
* @param withConfig the configuration that is passed to commands
* @tparam Config type of configuration that is passed to commands
*/
case class Continue[Config](withConfig: Config)
extends TopLevelBehavior[Config]
/**
* If top-level options return a value of this class, it means that the
* top-level options have handled the execution and commands should not be
* parsed further. This can be useful to implement top-level options like
* `--version`.
*/
case object Halt extends TopLevelBehavior[Nothing]
def renderHelp(): String = combinedOpts.topLevelHelp(Seq(commandName))
}
object Application {
/**
* Helper constructor for [[Application]].
*
* @param commandName default name of the application for use in commands
* @param prettyName pretty name of the application for use in text
* @param helpHeader short description of the application included at the
* top of the help text
* @param topLevelOpts the top-level options that are used to parse a global
* config which is passed to every command; can also be
* used to execute different bahavior than commands; see
* [[TopLevelBehavior]] for more information
* @param commands a sequence of commands supported by the application
* @param pluginManager a plugin manager for resolving non-native command
* extensions
*/
def apply[Config](
commandName: String,
prettyName: String,
helpHeader: String,
topLevelOpts: Opts[() => TopLevelBehavior[Config]],
commands: Seq[Command[Config => Unit]],
commands: NonEmptyList[Command[Config => Unit]],
pluginManager: PluginManager
): Application[Config] =
new Application(
@ -234,12 +150,27 @@ object Application {
Some(pluginManager)
)
/**
* Helper constructor for [[Application]].
*
* Creates an application without plugin support.
*
* @param commandName default name of the application for use in commands
* @param prettyName pretty name of the application for use in text
* @param helpHeader short description of the application included at the
* top of the help text
* @param topLevelOpts the top-level options that are used to parse a global
* config which is passed to every command; can also be
* used to execute different bahavior than commands; see
* [[TopLevelBehavior]] for more information
* @param commands a sequence of commands supported by the application
*/
def apply[Config](
commandName: String,
prettyName: String,
helpHeader: String,
topLevelOpts: Opts[() => TopLevelBehavior[Config]],
commands: Seq[Command[Config => Unit]]
commands: NonEmptyList[Command[Config => Unit]]
): Application[Config] =
new Application(
commandName,
@ -250,11 +181,23 @@ object Application {
None
)
/**
* Helper constructor for [[Application]].
*
* Creates an application without any top-level options and without plugin
* support.
*
* @param commandName default name of the application for use in commands
* @param prettyName pretty name of the application for use in text
* @param helpHeader short description of the application included at the
* top of the help text
* @param commands a sequence of commands supported by the application
*/
def apply(
commandName: String,
prettyName: String,
helpHeader: String,
commands: Seq[Command[Unit => Unit]]
commands: NonEmptyList[Command[Unit => Unit]]
): Application[()] =
new Application(
commandName,

View File

@ -1,4 +1,4 @@
package org.enso.cli
package org.enso.cli.arguments
import java.nio.file.{InvalidPathException, Path}
import java.util.UUID
@ -13,7 +13,7 @@ trait Argument[A] {
/**
* Tries to convert the given string into a value of type A.
*/
def read(string: String): Either[List[String], A]
def read(string: String): Either[OptsParseError, A]
}
object Argument {
@ -34,7 +34,7 @@ object Argument {
string.toInt.asRight
} catch {
case _: NumberFormatException =>
List(s"Invalid number `$string`").asLeft
OptsParseError.left(s"Invalid number `$string`")
}
/**
@ -46,9 +46,9 @@ object Argument {
Path.of(string).asRight
} catch {
case invalidPathException: InvalidPathException =>
List(
OptsParseError.left(
s"Invalid path `$string`: ${invalidPathException.getMessage}"
).asLeft
)
}
/**
@ -58,6 +58,6 @@ object Argument {
try { UUID.fromString(string).asRight }
catch {
case _: IllegalArgumentException | _: NumberFormatException =>
List(s"Invalid UUID `$string`").asLeft
OptsParseError.left(s"Invalid UUID `$string`")
}
}

View File

@ -1,4 +1,4 @@
package org.enso.cli
package org.enso.cli.arguments
/**
* Represents a top-level command in the CLI.
@ -17,15 +17,6 @@ case class Command[A](
related: Seq[String]
) {
/**
* Generates a help text for the command, including usage, available options
* and any additional help lines.
*
* @param applicationName name of the application for usage
*/
def help(applicationName: String): String =
comment + "\n" + opts.help(Seq(applicationName, name))
/**
* Returns a top-level help entry for the application help text. It includes
* a short description of the command.
@ -33,17 +24,6 @@ case class Command[A](
def topLevelHelp: CommandHelp = CommandHelp(name, comment)
}
/**
* A stripped-down alternative to [[Command]] that is used in
* [[Opts.subcommands]].
*
* @param name name of the subcommand
* @param comment a help comment displayed in the commands help text
* @param opts parsing logic for the subcommand's options
* @tparam A type returned by the command
*/
case class Subcommand[A](name: String, comment: String)(val opts: Opts[A])
object Command {
/**
@ -98,13 +78,3 @@ object Command {
s"To show available commands, run `$command --help`."
}
}
/**
* A help entry used in the top-level help text.
*
* @param name name of a command
* @param comment a short description of that command
*/
case class CommandHelp(name: String, comment: String) {
override def toString: String = s"$name\t$comment"
}

View File

@ -0,0 +1,11 @@
package org.enso.cli.arguments
/**
* A help entry used in the top-level help text.
*
* @param name name of a command
* @param comment a short description of that command
*/
case class CommandHelp(name: String, comment: String) {
override def toString: String = s"$name\t$comment"
}

View File

@ -0,0 +1,7 @@
package org.enso.cli.arguments
/**
* Exception that is reported when Opts are combined in an illegal way.
*/
case class IllegalOptsStructure(message: String, cause: Throwable = null)
extends RuntimeException(message, cause)

View File

@ -1,15 +1,11 @@
package org.enso.cli
package org.enso.cli.arguments
import cats.data.NonEmptyList
import cats.{Functor, Semigroupal}
import cats.implicits._
import cats.{Functor, Semigroupal}
import org.enso.cli.CLIOutput
import org.enso.cli.internal._
/**
* Exception that is reported when Opts are combined in an illegal way.
*/
case class IllegalOptsStructure(message: String, cause: Throwable = null)
extends RuntimeException(message, cause)
import org.enso.cli.internal.opts._
/**
* Represents a set of options (flags, parameters, arguments) and the logic
@ -18,7 +14,7 @@ case class IllegalOptsStructure(message: String, cause: Throwable = null)
* Opts instances are allowed to use internal mutable state for parsing. They
* can be parsed multiple times, but they are not thread-safe.
*/
trait Opts[A] {
trait Opts[+A] {
/**
* Maps flag names to callbacks that are run for that flag.
@ -46,7 +42,10 @@ trait Opts[A] {
*
* Should not be called if [[wantsArgument]] returns false.
*/
private[cli] def consumeArgument(arg: String): Unit
private[cli] def consumeArgument(
arg: String,
commandPrefix: Seq[String]
): ParserContinuation
/**
* An optional callback for additional arguments. If it is provided, all
@ -67,7 +66,7 @@ trait Opts[A] {
* the right moment to do final validation (for example, detecting missing
* options).
*/
private[cli] def result(commandPrefix: Seq[String]): Either[List[String], A]
private[cli] def result(commandPrefix: Seq[String]): Either[OptsParseError, A]
/**
* Lists options that should be printed in the usage [[commandLines]].
@ -131,10 +130,15 @@ trait Opts[A] {
*
* @return a string representing the options usage
*/
private[cli] def commandLineOptions(): String = {
val allOptions = parameters.size + flags.size + prefixedParameters.size
private[cli] def commandLineOptions(
alwaysIncludeOtherOptions: Boolean
): String = {
val flagsWithoutDuplicates = flags.keys.count(_.length > 1)
val allOptions =
parameters.size + flagsWithoutDuplicates + prefixedParameters.size
val otherOptions =
if (allOptions > usageOptions.size)
if (alwaysIncludeOtherOptions || allOptions > usageOptions.size)
" [options]"
else ""
otherOptions + usageOptions.map(" " + _).mkString
@ -147,8 +151,10 @@ trait Opts[A] {
* @return a non-empty list of available usages of this option set. Multiple
* entries may be returned in presence of subcommands.
*/
def commandLines(): NonEmptyList[String] = {
val options = commandLineOptions()
def commandLines(
alwaysIncludeOtherOptions: Boolean = false
): NonEmptyList[String] = {
val options = commandLineOptions(alwaysIncludeOtherOptions)
val required = requiredArguments.map(arg => s" $arg").mkString
val optional = optionalArguments.map(arg => s" [$arg]").mkString
val trailing = trailingArguments.map(args => s" [$args...]").getOrElse("")
@ -166,17 +172,10 @@ trait Opts[A] {
/**
* Generates explanations of parameters to be included in the help message.
*
* @param addHelpOption specifies whether an additional `--help` option
* should be included
*/
def helpExplanations(addHelpOption: Boolean): String = {
val additionalHelpOption =
if (addHelpOption) Seq("[--help | -h]\tPrint this help message.")
else Seq()
val optionExplanations =
additionalHelpOption ++ availableOptionsHelp()
val options = optionExplanations.map(CLIOutput.indent + _).mkString("\n")
def helpExplanations(): String = {
val options =
availableOptionsHelp().map(CLIOutput.indent + _).mkString("\n")
val optionsHelp =
if (options.isEmpty) "" else "\nAvailable options:\n" + options + "\n"
@ -210,8 +209,14 @@ trait Opts[A] {
firstLine + usages.head +
usages.tail.map("\n" + padding + _).mkString + "\n"
usage + helpExplanations(addHelpOption = true).stripTrailing()
usage + helpExplanations().stripTrailing()
}
/**
* Renders text explaining how to display the help.
*/
def shortHelp(commandPrefix: Seq[String]): String =
s"See `${commandPrefix.mkString(" ")} --help` for usage explanation."
}
/**
@ -485,8 +490,8 @@ object Opts {
* @param otherCommands any following subcommands
*/
def subcommands[A](
firstCommand: Subcommand[A],
otherCommands: Subcommand[A]*
firstCommand: Command[A],
otherCommands: Command[A]*
): Opts[A] = {
val nonEmptyCommands = NonEmptyList.of(firstCommand, otherCommands: _*)
new SubcommandOpt[A](nonEmptyCommands)

View File

@ -0,0 +1,198 @@
package org.enso.cli.arguments
import cats.data.NonEmptyList
import cats.implicits._
import cats.kernel.Semigroup
/**
* Aggregates errors encountered when parsing [[Opts]] and allows to attach
* help texts.
*
* @param errors list of parse errors
* @param fullHelpRequested specifies if attaching a full help text was
* requested
* @param fullHelpAppended specifies if a full help text has already been
* attached
*/
case class OptsParseError(
errors: NonEmptyList[String],
fullHelpRequested: Boolean = false,
fullHelpAppended: Boolean = false
) {
/**
* Creates a copy with additional errors from the provided list.
*/
def withErrors(additionalErrors: List[String]): OptsParseError =
copy(errors = errors ++ additionalErrors)
/**
* Creates a copy with additional errors.
*/
def withErrors(additionalErrors: String*): OptsParseError =
withErrors(additionalErrors.toList)
/**
* Specifies if short help should be appended.
*
* Short help is appended if it was not already mentioned in any of the
* errors and if the full help is not added (or requested to be added later).
*/
def shouldAppendShortHelp: Boolean = {
val isHelpMentionedAlready = errors.exists(_.contains("--help"))
val isHelpHandled =
isHelpMentionedAlready || fullHelpAppended || fullHelpRequested
!isHelpHandled
}
/**
* Creates a copy with short help appended.
*/
def withShortHelp(helpString: String): OptsParseError =
withErrors(helpString)
/**
* Creates a copy with full help appended.
*/
def withFullHelp(helpString: String): OptsParseError =
withErrors(helpString).copy(
fullHelpRequested = false,
fullHelpAppended = true
)
}
object OptsParseError {
/**
* Creates a parse error containing the provided errors.
*/
def apply(error: String, errors: String*): OptsParseError =
OptsParseError(NonEmptyList.of(error, errors: _*))
/**
* Creates a parse error containing the provided error and indicating that
* full help text should be appended to it.
*/
def requestingFullHelp(error: String): OptsParseError =
OptsParseError(NonEmptyList.one(error), fullHelpRequested = true)
/**
* Helper method that creates a parse error and wraps it in a [[Left]].
*/
def left[A](error: String): Either[OptsParseError, A] = Left(apply(error))
/**
* [[Semigroup]] instance for [[OptsParseError]] that allows to merge
* multiple errors into one.
*/
implicit val semigroup: Semigroup[OptsParseError] =
(x: OptsParseError, y: OptsParseError) => {
val fullHelpAppended = x.fullHelpAppended || y.fullHelpAppended
OptsParseError(
x.errors ++ y.errors.toList,
if (fullHelpAppended) false
else x.fullHelpRequested || y.fullHelpRequested,
fullHelpAppended
)
}
/**
* Syntax extensions that add helper functions to objects of type
* `Either[OptsParseError, A]` (parsing results).
*/
implicit class ParseErrorSyntax[A](val result: Either[OptsParseError, A]) {
/**
* Add errors from the specified list to the result.
*
* If the result was [[Left]], the errors are appended. If the result was
* [[Right]] and the list is non-empty, the modified result is [[Left]]
* containing the new errors.
*/
def addErrors(errors: List[String]): Either[OptsParseError, A] =
result match {
case Left(value) => Left(value.withErrors(errors))
case Right(value) =>
val nel = NonEmptyList.fromList(errors)
nel match {
case Some(errorsList) => Left(OptsParseError(errorsList))
case None => Right(value)
}
}
/**
* Appends the provided short help if it should be added.
*/
def appendShortHelp(help: => String): Either[OptsParseError, A] =
result.left.map { value =>
if (value.shouldAppendShortHelp) value.withShortHelp(help) else value
}
/**
* Appends the provided full help text if it was requested.
*/
def appendFullHelp(help: => String): Either[OptsParseError, A] =
result.left.map { value =>
if (value.fullHelpRequested) value.withFullHelp(help) else value
}
/**
* Converts to an [[Either]] containing just a list of errors on failure.
*
* Makes sure that all help requests have been handled.
*/
def toErrorList: Either[List[String], A] =
result match {
case Left(value) =>
if (value.shouldAppendShortHelp || value.fullHelpRequested)
throw new IllegalStateException(
"Internal error: Help was not handled."
)
else
Left(value.errors.toList)
case Right(value) => Right(value)
}
}
/**
* Merges two parse results into one that returns the pair created from
* arguments' results on success.
*
* If any of the arguments is failed, the result is also a failure. If both
* are failed, their errors are merged.
*/
def product[A, B](
a: Either[OptsParseError, A],
b: Either[OptsParseError, B]
): Either[OptsParseError, (A, B)] =
(a, b) match {
case (Right(a), Right(b)) => Right((a, b))
case (Left(a), Left(b)) => Left(a |+| b)
case (Left(a), _) => Left(a)
case (_, Left(b)) => Left(b)
}
/**
* Combines two parse results ensuring that the old one was not set already.
*
* If any of the results is failed, the final result is also a failure. If
* both results are successful and the first one is empty, the second one is
* returned; if the first one is non-empty, the duplicate error is reported.
*
* This helper is used to implement parameters that should hold only one
* result.
*/
def combineWithoutDuplicates[A](
old: Either[OptsParseError, Option[A]],
newer: Either[OptsParseError, A],
duplicateErrorMessage: String
): Either[OptsParseError, Option[A]] =
(old, newer) match {
case (Left(oldErrors), Left(newErrors)) => Left(newErrors |+| oldErrors)
case (Right(None), Right(v)) => Right(Some(v))
case (Right(Some(_)), Right(_)) =>
Left(OptsParseError(duplicateErrorMessage))
case (Left(errors), Right(_)) => Left(errors)
case (Right(_), Left(errors)) => Left(errors)
}
}

View File

@ -0,0 +1,33 @@
package org.enso.cli.arguments
/**
* A plugin manager that handles finding and running plugins.
*/
trait PluginManager {
/**
* Tries to run a given plugin with provided arguments.
*
* It never returns - either it exits with the plugin's exit code or throws
* an exception if it was not possible to run the plugin.
*
* @param name name of the plugin
* @param args arguments that should be passed to it
*/
def runPlugin(name: String, args: Seq[String]): Nothing
/**
* Returns whether the plugin of the given `name` is available in the system.
*/
def hasPlugin(name: String): Boolean
/**
* Lists names of plugins found in the system.
*/
def pluginsNames(): Seq[String]
/**
* Lists names and short descriptions of plugins found on the system.
*/
def pluginsHelp(): Seq[CommandHelp]
}

View File

@ -0,0 +1,30 @@
package org.enso.cli.arguments
/**
* Defines the behaviour of parsing top-level application options.
*
* @tparam Config type of configuration that is passed to commands
*/
sealed trait TopLevelBehavior[+Config]
object TopLevelBehavior {
/**
* If top-level options return a value of this class, the application should
* continue execution. The provided value of `Config` should be passed to the
* executed commands.
*
* @param withConfig the configuration that is passed to commands
* @tparam Config type of configuration that is passed to commands
*/
case class Continue[Config](withConfig: Config)
extends TopLevelBehavior[Config]
/**
* If top-level options return a value of this class, it means that the
* top-level options have handled the execution and commands should not be
* parsed further. This can be useful to implement top-level options like
* `--version`.
*/
case object Halt extends TopLevelBehavior[Nothing]
}

View File

@ -66,19 +66,26 @@ private[cli] object CLIOutputInternal {
minimumTableWidth: Int
): Seq[String] = {
val prefixLengths = rows.map(_._1.length)
val maximumPrefixLength = wrapLength - minimumWrapWidth
val commmonPrefixLength =
Seq(prefixLengths.max + 1, minimumTableWidth).max
val commonSuffixLength =
Seq(wrapLength - commmonPrefixLength, minimumWrapWidth).max
Seq(
Seq(prefixLengths.max + 1, minimumTableWidth).max,
maximumPrefixLength
).min
val commonSuffixLength = wrapLength - commmonPrefixLength
val additionalLinesPadding = " " * commmonPrefixLength
rows.flatMap {
case (prefix, suffix) =>
val prefixPadded = rightPad(prefix, commmonPrefixLength)
val wrappedSuffix = wrapLine(suffix, commonSuffixLength)
val firstLine = prefixPadded + wrappedSuffix.head
val firstLine =
if (prefix.length >= commmonPrefixLength)
Seq(prefix, additionalLinesPadding + wrappedSuffix.head)
else
Seq(prefixPadded + wrappedSuffix.head)
val restLines = wrappedSuffix.tail.map(additionalLinesPadding + _)
Seq(firstLine) ++ restLines
firstLine ++ restLines
}
}

View File

@ -1,86 +1,7 @@
package org.enso.cli.internal
import org.enso.cli.{
Application,
CLIOutput,
Command,
Opts,
PluginInterceptedFlow,
PluginNotFound,
Spelling
}
/**
* A token used in the parser.
*/
sealed trait Token {
/**
* The original value that this token has been created from.
*
* Used to reverse the tokenization process.
*/
def originalValue: String
}
case class PlainToken(override val originalValue: String) extends Token
case class ParameterOrFlag(parameter: String)(
override val originalValue: String
) extends Token
case class MistypedParameter(parameter: String)(
override val originalValue: String
) extends Token
case class ParameterWithValue(parameter: String, value: String)(
override val originalValue: String
) extends Token
/**
* A mutable stream of tokens.
* @param initialTokens initial sequence of tokens
* @param errorReporter a function used for reporting errors
*/
class TokenStream(initialTokens: Seq[Token], errorReporter: String => Unit) {
var tokens: List[Token] = initialTokens.toList
/**
* Returns true if there are more tokens available.
*/
def hasTokens: Boolean = tokens.nonEmpty
/**
* Returns the next token. Cannot be called if [[hasTokens]] is false.
*/
def consumeToken(): Token = {
val token = tokens.head
tokens = tokens.tail
token
}
/**
* Returns the next token, but does not remove it from the stream yet. Cannot
* be called if [[hasTokens]] is false.
*/
def peekToken(): Token = tokens.head
/**
* If the next available token is an argument, returns it. Otherwise returns
* None and reports a specified error message.
*/
def tryConsumeArgument(errorMessage: String): Option[String] = {
tokens.headOption match {
case Some(PlainToken(arg)) =>
tokens = tokens.tail
Some(arg)
case _ =>
errorReporter(errorMessage)
None
}
}
/**
* Returns a sequence of remaining tokens.
*/
def remaining(): Seq[Token] = tokens
}
import org.enso.cli.arguments.{Opts, OptsParseError}
import org.enso.cli.{CLIOutput, Spelling}
object Parser {
@ -91,29 +12,34 @@ object Parser {
* @param tokens the sequence of tokens to parse
* @param additionalArguments additional arguments that may be needed by the
* [[Opts.additionalArguments]] option
* @param isTopLevel determines if `opts` are top-level options or options
* for a command; when an argument is encountered when
* parsing top-level options, parsing is stopped to return
* the top-level options and possibly continue it with
* command options
* @param commandPrefix the sequence of subcommand names, used for
* displaying help messages
* @return returns either the result value of `opts` and remaining tokens or
* a list of errors on failure; the remaining tokens are non-empty
* only if `isTopLevel` is true
* @param applicationName application name, used for displaying help
* messages
* @return returns either the result value of `opts` and an optional closure
* that defines the plugin handler behaviour or a parse error
*/
def parseOpts[A](
opts: Opts[A],
tokens: Seq[Token],
additionalArguments: Seq[String],
isTopLevel: Boolean,
commandPrefix: Seq[String]
): Either[List[String], (A, Seq[Token])] = {
applicationName: String
): Either[OptsParseError, (A, Option[() => Nothing])] = {
var parseErrors: List[String] = Nil
def addError(error: String): Unit = {
parseErrors = error :: parseErrors
}
/**
* Flag used to avoid issuing an 'unexpected argument' error if it was
* preceded by a potential parameter.
*
* It is used because if we find an unknown parameter, we do not know if it
* is a parameter or flag and cannot make any assumptions. The argument
* coming after it can be a plain argument ora value to the parameter - to
* avoid confusion in the latter case, we skip the unexpected argument
* error.
*/
var suppressUnexpectedArgument = false
def reportUnknownParameter(
parameter: String,
original: String
@ -156,21 +82,22 @@ object Parser {
opts.reset()
val tokenProvider = new TokenStream(tokens, addError)
/**
* Specifies whether the parser should parse the next argument.
* In top-level, we want to break when encountering the first positional
* argument (which is the command).
* Outside of top-level, we proceed always.
*/
def shouldProceed(): Boolean =
if (isTopLevel) !tokenProvider.peekToken().isInstanceOf[PlainToken]
else true
var escapeParsing: Option[(Seq[Token], Seq[String]) => Nothing] = None
var parsingStopped: Boolean = false
while (tokenProvider.hasTokens && shouldProceed()) {
while (!parsingStopped && tokenProvider.hasTokens) {
tokenProvider.consumeToken() match {
case PlainToken(value) =>
if (opts.wantsArgument()) {
opts.consumeArgument(value)
val continuation = opts.consumeArgument(value, Seq(applicationName))
continuation match {
case ParserContinuation.ContinueNormally =>
case ParserContinuation.Stop =>
parsingStopped = true
case ParserContinuation.Escape(cont) =>
escapeParsing = Some(cont)
parsingStopped = true
}
} else if (!suppressUnexpectedArgument) {
addError(s"Unexpected argument `$value`.")
}
@ -190,7 +117,7 @@ object Parser {
} else if (opts.parameters.contains(parameter)) {
for (
value <- tokenProvider.tryConsumeArgument(
s"Expected a value for parameter $parameter."
s"Expected a value for parameter `$parameter`."
)
) opts.parameters(parameter)(value)
} else if (hasPrefix(parameter)) {
@ -198,7 +125,7 @@ object Parser {
if (opts.prefixedParameters.contains(prefix)) {
for (
value <- tokenProvider.tryConsumeArgument(
s"Expected a value for parameter $parameter."
s"Expected a value for parameter `$parameter`."
)
) opts.prefixedParameters(prefix)(rest, value)
} else {
@ -224,7 +151,7 @@ object Parser {
}
}
if (!isTopLevel) {
if (escapeParsing.isEmpty) {
opts.additionalArguments match {
case Some(additionalArgumentsHandler) =>
additionalArgumentsHandler(additionalArguments)
@ -233,125 +160,27 @@ object Parser {
addError("Additional arguments (after --) were not expected.")
}
}
} else if (additionalArguments.nonEmpty) {
throw new IllegalArgumentException(
"Additional arguments should only be provided for subcommand parsing," +
" not at top level."
)
}
def appendHelp[T](
result: Either[List[String], T]
): Either[List[String], T] = {
val help =
s"See `${commandPrefix.mkString(" ")} --help` for usage explanation."
result match {
case Left(errors) =>
val shouldAddHelp = !errors.exists(_.contains("Usage:"))
if (shouldAddHelp)
Left(errors ++ Seq(help))
else Left(errors)
case Right(value) => Right(value)
}
val result =
opts.result(Seq(applicationName)).addErrors(parseErrors.reverse)
val finalResult = (escapeParsing, result) match {
case (Some(cont), Right(_)) =>
val pluginHandler =
() => cont(tokenProvider.remaining(), additionalArguments)
result.map((_, Some(pluginHandler)))
case _ => result.map((_, None))
}
appendHelp(
appendErrors(
opts.result(commandPrefix).map((_, tokenProvider.remaining())),
parseErrors.reverse
)
)
finalResult
.appendFullHelp {
opts.help(Seq(applicationName))
}
/**
* Parses a command for the application.
*
* First tries to find a command in [[Application.commands]], if that fails,
* it tries [[Command.related]] and later tries the
* [[Application.pluginManager]] if available.
*/
def parseCommand[Config](
application: Application[Config],
config: Config,
tokens: Seq[Token],
additionalArguments: Seq[String]
): Either[List[String], () => Unit] =
tokens match {
case Seq() =>
singleError(
s"Expected a command.\n\n" + application.renderHelp()
)
case Seq(PlainToken(commandName), commandArgs @ _*) =>
application.commands.find(_.name == commandName) match {
case Some(command) =>
if (wantsHelp(commandArgs)) {
Right(() => {
CLIOutput.println(command.help(application.commandName))
})
} else {
Parser
.parseOpts(
command.opts,
commandArgs,
additionalArguments,
isTopLevel = false,
Seq(application.commandName, commandName)
)
.map(_._1)
.map(runner => () => runner(config))
}
case None =>
val possiblyRelated = Command.formatRelated(
commandName,
Seq(application.commandName),
application.commands
)
possiblyRelated match {
case Some(relatedCommandMessage) =>
singleError(relatedCommandMessage)
case None =>
val additionalArgs =
if (additionalArguments.nonEmpty)
Seq("--") ++ additionalArguments
else Seq()
val pluginBehaviour = application.pluginManager
.map(
_.tryRunningPlugin(
commandName,
untokenize(commandArgs) ++ additionalArgs
)
)
.getOrElse(PluginNotFound)
pluginBehaviour match {
case PluginNotFound =>
singleError(application.commandSuggestions(commandName))
case PluginInterceptedFlow => Right(() => ())
.appendShortHelp {
opts.shortHelp(Seq(applicationName))
}
}
}
}
def wantsHelp(args: Seq[Token]): Boolean =
args.exists {
case ParameterOrFlag("help") => true
case ParameterOrFlag("h") => true
case _ => false
}
private def appendErrors[B](
result: Either[List[String], B],
errors: List[String]
): Either[List[String], B] =
if (errors.isEmpty) result
else
result match {
case Left(theirErrors) => Left(errors ++ theirErrors)
case Right(_) => Left(errors)
}
private def singleError[B](message: String): Either[List[String], B] =
Left(List(message))
private def splitAdditionalArguments(
args: Seq[String]
@ -373,6 +202,15 @@ object Parser {
private val paramWithValue = """--([\w-.]+)=(.*)""".r
private val mistypedLongParam = """-([\w-.]+)""".r
private val mistypedParamWithValue = """-([\w-.]+)=(.*)""".r
/**
* Converts a sequence of arguments into a sequence of tokens and additional
* arguments.
*
* All arguments are converted into tokens until a `--` argument is
* encountered. Any further arguments (including additional instances of
* `--`) are treated as additional arguments.
*/
def tokenize(args: Seq[String]): (Seq[Token], Seq[String]) = {
def toToken(arg: String): Token =
arg match {

View File

@ -0,0 +1,42 @@
package org.enso.cli.internal
/**
* Specifies parser behaviour after parsing a plain token.
*/
sealed trait ParserContinuation
object ParserContinuation {
/**
* Specifies to continue parsing.
*
* This variant is returned most of the time in the usual parsing flow.
*/
case object ContinueNormally extends ParserContinuation
/**
* Specifies to stop parsing and return partial results.
*
* This can be used to abstain from handling further errors. For example, if
* a command name is misspelled, any further parameters would be reported as
* unknown even though they are defined for the right command. To avoid such
* misleading situations, we stop parsing.
*/
case object Stop extends ParserContinuation
/**
* Specifies that the parser should stop parsing immediately, gather the
* partial results and finish by returning a closure that will call the
* provided `continuation`.
*
* The continuation is given the sequence of remaining tokens and additional
* arguments and never returns.
*
* This special case is used to implement plugins - when a plugin invocation
* is detected, all further tokens should be handled by the plugin that will
* be invoked.
*/
case class Escape(
continuation: (Seq[Token], Seq[String]) => Nothing
) extends ParserContinuation
}

View File

@ -1,100 +0,0 @@
package org.enso.cli.internal
import cats.data.NonEmptyList
import org.enso.cli.{CLIOutput, Opts, Spelling, Subcommand}
class SubcommandOpt[A](subcommands: NonEmptyList[Subcommand[A]])
extends Opts[A] {
var selectedCommand: Option[Subcommand[A]] = None
var errors: List[String] = Nil
def addError(error: String): Unit = errors ::= error
override private[cli] def flags =
selectedCommand.map(_.opts.flags).getOrElse(Map.empty)
override private[cli] def parameters =
selectedCommand.map(_.opts.parameters).getOrElse(Map.empty)
override private[cli] def prefixedParameters =
selectedCommand.map(_.opts.prefixedParameters).getOrElse(Map.empty)
override private[cli] def gatherOptions =
selectedCommand.map(_.opts.gatherOptions).getOrElse(Seq())
override private[cli] def gatherPrefixedParameters =
selectedCommand.map(_.opts.gatherPrefixedParameters).getOrElse(Seq())
override private[cli] val usageOptions =
subcommands.toList.flatMap(_.opts.usageOptions).distinct
override private[cli] def wantsArgument() =
selectedCommand.map(_.opts.wantsArgument()).getOrElse(true)
override private[cli] def consumeArgument(arg: String): Unit =
selectedCommand match {
case Some(command) => command.opts.consumeArgument(arg)
case None =>
subcommands.find(_.name == arg) match {
case Some(command) =>
selectedCommand = Some(command)
case None =>
val similar =
Spelling
.selectClosestMatches(arg, subcommands.toList.map(_.name))
val suggestions =
if (similar.isEmpty)
"\n\nPossible subcommands are\n" +
subcommands.toList
.map(CLIOutput.indent + _.name + "\n")
.mkString
else
"\n\nThe most similar subcommands are\n" +
similar.map(CLIOutput.indent + _ + "\n").mkString
addError(s"`$arg` is not a valid subcommand." + suggestions)
}
}
override private[cli] def requiredArguments =
selectedCommand.map(_.opts.requiredArguments).getOrElse(Seq())
override private[cli] def optionalArguments =
selectedCommand.map(_.opts.optionalArguments).getOrElse(Seq())
override private[cli] def trailingArguments =
selectedCommand.flatMap(_.opts.trailingArguments)
override private[cli] def additionalArguments =
selectedCommand.flatMap(_.opts.additionalArguments)
override private[cli] def reset(): Unit = {
subcommands.map(_.opts.reset())
selectedCommand = None
errors = Nil
}
override private[cli] def result(commandPrefix: Seq[String]) =
if (errors.nonEmpty)
Left(errors.reverse)
else
selectedCommand match {
case Some(command) => command.opts.result(commandPrefix)
case None =>
Left(List("Expected a subcommand.", help(commandPrefix)))
}
override def availableOptionsHelp(): Seq[String] =
subcommands.toList.flatMap(_.opts.availableOptionsHelp()).distinct
override def availablePrefixedParametersHelp(): Seq[String] =
subcommands.toList
.flatMap(_.opts.availablePrefixedParametersHelp())
.distinct
override def additionalHelp(): Seq[String] =
subcommands.toList.flatMap(_.opts.additionalHelp()).distinct
override def commandLines(): NonEmptyList[String] = {
def prefixedCommandLines(command: Subcommand[_]): NonEmptyList[String] = {
val prefix = command.name + " "
val suffix = s"\n\t${command.comment}"
command.opts
.commandLines()
.map(commandLine => prefix + commandLine + suffix)
}
subcommands.flatMap(prefixedCommandLines)
}
}

View File

@ -0,0 +1,45 @@
package org.enso.cli.internal
/**
* A token used in the parser.
*/
sealed trait Token {
/**
* The original value that this token has been created from.
*
* Used to reverse the tokenization process.
*/
def originalValue: String
}
/**
* Plain token, usually treated as an argument or value for a parameter that is
* preceding it.
*/
case class PlainToken(override val originalValue: String) extends Token
/**
* A token representing a parameter or a flag.
*
* It has form `--abc`.
*/
case class ParameterOrFlag(parameter: String)(
override val originalValue: String
) extends Token
/**
* A token representing a mistyped parameter, i.e. `-abc`.
*
* Used for better error handling.
*/
case class MistypedParameter(parameter: String)(
override val originalValue: String
) extends Token
/**
* A token representing a parameter with a value, i.e. `--key=value`.
*/
case class ParameterWithValue(parameter: String, value: String)(
override val originalValue: String
) extends Token

View File

@ -0,0 +1,50 @@
package org.enso.cli.internal
/**
* A mutable stream of tokens.
* @param initialTokens initial sequence of tokens
* @param errorReporter a function used for reporting errors
*/
class TokenStream(initialTokens: Seq[Token], errorReporter: String => Unit) {
var tokens: List[Token] = initialTokens.toList
/**
* Returns true if there are more tokens available.
*/
def hasTokens: Boolean = tokens.nonEmpty
/**
* Returns the next token. Cannot be called if [[hasTokens]] is false.
*/
def consumeToken(): Token = {
val token = tokens.head
tokens = tokens.tail
token
}
/**
* Returns the next token, but does not remove it from the stream yet. Cannot
* be called if [[hasTokens]] is false.
*/
def peekToken(): Token = tokens.head
/**
* If the next available token is an argument, returns it. Otherwise returns
* None and reports a specified error message.
*/
def tryConsumeArgument(errorMessage: String): Option[String] = {
tokens.headOption match {
case Some(PlainToken(arg)) =>
tokens = tokens.tail
Some(arg)
case _ =>
errorReporter(errorMessage)
None
}
}
/**
* Returns a sequence of remaining tokens.
*/
def remaining(): Seq[Token] = tokens
}

View File

@ -1,4 +1,4 @@
package org.enso.cli.internal
package org.enso.cli.internal.opts
class AdditionalArguments(helpComment: String) extends BaseOpts[Seq[String]] {
var value: Seq[String] = Seq()

View File

@ -1,6 +1,7 @@
package org.enso.cli.internal
package org.enso.cli.internal.opts
import org.enso.cli.Opts
import org.enso.cli.arguments.Opts
import org.enso.cli.internal.ParserContinuation
abstract class BaseOpts[A] extends Opts[A] {
override private[cli] val flags: Map[String, () => Unit] = Map.empty
@ -13,7 +14,10 @@ abstract class BaseOpts[A] extends Opts[A] {
Seq()
override private[cli] def wantsArgument() = false
override private[cli] def consumeArgument(arg: String): Unit =
override private[cli] def consumeArgument(
arg: String,
commandPrefix: Seq[String]
): ParserContinuation =
throw new IllegalStateException(
"Internal error: " +
"Argument provided even though it was marked as not expected."

View File

@ -0,0 +1,154 @@
package org.enso.cli.internal.opts
import cats.data.NonEmptyList
import org.enso.cli.arguments.{Command, Opts}
import org.enso.cli.internal.ParserContinuation
/**
* Implements common logic for options that can take a command and modify their
* further parsing behavior based on that command.
* @tparam A returned type
* @tparam B type returned by the commands
*/
trait BaseSubcommandOpt[A, B] extends Opts[A] {
/**
* Lists all available commands.
*/
def availableSubcommands: NonEmptyList[Command[B]]
/**
* Handles an unknown command.
*
* Executed when a command is given that does not match any of
* [[availableSubcommands]].
*/
def handleUnknownCommand(command: String): ParserContinuation
/**
* The command that has been selected, if any.
*
* If no command was selected, is set to None.
*/
var selectedCommand: Option[Command[B]] = None
/**
* List of errors reported when parsing this set of options.
*/
var errors: List[String] = Nil
/**
* Adds an error that will be reported when getting the result.
*/
def addError(error: String): Unit = errors ::= error
/**
* Extends a command prefix from the call with the currently selected command
* (if a command is selected).
*/
def extendPrefix(commandPrefix: Seq[String]): Seq[String] =
commandPrefix ++ selectedCommand.map(_.name)
override private[cli] def flags =
selectedCommand.map(_.opts.flags).getOrElse(Map.empty)
override private[cli] def parameters =
selectedCommand.map(_.opts.parameters).getOrElse(Map.empty)
override private[cli] def prefixedParameters =
selectedCommand.map(_.opts.prefixedParameters).getOrElse(Map.empty)
override private[cli] def gatherOptions =
selectedCommand.map(_.opts.gatherOptions).getOrElse(Seq())
override private[cli] def gatherPrefixedParameters =
selectedCommand.map(_.opts.gatherPrefixedParameters).getOrElse(Seq())
override private[cli] def usageOptions =
availableSubcommands.toList.flatMap(_.opts.usageOptions).distinct
override private[cli] def wantsArgument() =
selectedCommand.map(_.opts.wantsArgument()).getOrElse(true)
override private[cli] def consumeArgument(
arg: String,
commandPrefix: Seq[String]
): ParserContinuation = {
val prefix = extendPrefix(commandPrefix)
selectedCommand match {
case Some(command) =>
command.opts.consumeArgument(arg, prefix)
case None =>
availableSubcommands.find(_.name == arg) match {
case Some(command) =>
selectedCommand = Some(command)
ParserContinuation.ContinueNormally
case None =>
Command.formatRelated(
arg,
prefix,
availableSubcommands.toList
) match {
case Some(relatedMessage) =>
addError(relatedMessage)
ParserContinuation.Stop
case None =>
handleUnknownCommand(arg)
}
}
}
}
override private[cli] def requiredArguments =
selectedCommand.map(_.opts.requiredArguments).getOrElse(Seq())
override private[cli] def optionalArguments =
selectedCommand.map(_.opts.optionalArguments).getOrElse(Seq())
override private[cli] def trailingArguments =
selectedCommand.flatMap(_.opts.trailingArguments)
override private[cli] def additionalArguments =
selectedCommand.flatMap(_.opts.additionalArguments)
override private[cli] def reset(): Unit = {
availableSubcommands.map(_.opts.reset())
selectedCommand = None
errors = Nil
}
/**
* @inheritdoc
*/
override def availableOptionsHelp(): Seq[String] =
availableSubcommands.toList.flatMap(_.opts.availableOptionsHelp()).distinct
/**
* @inheritdoc
*/
override def availablePrefixedParametersHelp(): Seq[String] =
availableSubcommands.toList
.flatMap(_.opts.availablePrefixedParametersHelp())
.distinct
/**
* @inheritdoc
*/
override def additionalHelp(): Seq[String] =
availableSubcommands.toList.flatMap(_.opts.additionalHelp()).distinct
/**
* @inheritdoc
*/
override def commandLines(
alwaysIncludeOtherOptions: Boolean = false
): NonEmptyList[String] = {
def prefixedCommandLines(command: Command[_]): NonEmptyList[String] = {
val prefix = command.name + " "
val suffix = s"\n\t${command.comment}"
command.opts
.commandLines(alwaysIncludeOtherOptions)
.map(commandLine => prefix + commandLine + suffix)
}
availableSubcommands.flatMap(prefixedCommandLines)
}
/**
* @inheritdoc
*/
override def shortHelp(commandPrefix: Seq[String]): String =
super.shortHelp(extendPrefix(commandPrefix))
}

View File

@ -1,4 +1,6 @@
package org.enso.cli.internal
package org.enso.cli.internal.opts
import org.enso.cli.arguments.OptsParseError
class Flag(
name: String,
@ -26,7 +28,7 @@ class Flag(
short.map(char => char.toString -> s"-$char").toSeq
val empty = Right(false)
var value: Either[List[String], Boolean] = empty
var value: Either[OptsParseError, Boolean] = empty
override private[cli] def reset(): Unit = {
value = empty
@ -34,7 +36,7 @@ class Flag(
private def update(): Unit = {
value = value.flatMap(
if (_) Left(List(s"Flag $name is set more than once"))
if (_) OptsParseError.left(s"Flag $name is set more than once")
else Right(true)
)
}

View File

@ -1,6 +1,7 @@
package org.enso.cli.internal
package org.enso.cli.internal.opts
import org.enso.cli.Opts
import org.enso.cli.arguments.Opts
import org.enso.cli.internal.ParserContinuation
class HiddenOpts[A](opts: Opts[A]) extends Opts[A] {
override private[cli] def flags = opts.flags
@ -13,8 +14,11 @@ class HiddenOpts[A](opts: Opts[A]) extends Opts[A] {
Seq()
override private[cli] def wantsArgument() = opts.wantsArgument()
override private[cli] def consumeArgument(arg: String): Unit =
opts.consumeArgument(arg)
override private[cli] def consumeArgument(
arg: String,
commandPrefix: Seq[String]
): ParserContinuation =
opts.consumeArgument(arg, commandPrefix)
override private[cli] val requiredArguments: Seq[String] = Seq()
override private[cli] val optionalArguments: Seq[String] = Seq()

View File

@ -1,6 +1,6 @@
package org.enso.cli.internal
package org.enso.cli.internal.opts
import org.enso.cli.Argument
import org.enso.cli.arguments.{Argument, OptsParseError}
class OptionalParameter[A: Argument](
name: String,
@ -23,17 +23,17 @@ class OptionalParameter[A: Argument](
val empty = Right(None)
var value: Either[List[String], Option[A]] = empty
var value: Either[OptsParseError, Option[A]] = empty
override private[cli] def reset(): Unit = {
value = empty
}
private def update(newValue: String): Unit = {
value = combineWithoutDuplicates(
value = OptsParseError.combineWithoutDuplicates(
value,
Argument[A].read(newValue),
s"Multiple values for parameter $name."
s"Multiple values for parameter `$name`."
)
}

View File

@ -1,13 +1,14 @@
package org.enso.cli.internal
package org.enso.cli.internal.opts
import org.enso.cli.Argument
import org.enso.cli.arguments.{Argument, OptsParseError}
import org.enso.cli.internal.ParserContinuation
class OptionalPositionalArgument[A: Argument](
metavar: String,
helpComment: Option[String]
) extends BaseOpts[Option[A]] {
val empty = Right(None)
var value: Either[List[String], Option[A]] = empty
var value: Either[OptsParseError, Option[A]] = empty
override private[cli] val optionalArguments = Seq(metavar)
@ -17,10 +18,14 @@ class OptionalPositionalArgument[A: Argument](
case _ => false
}
override private[cli] def consumeArgument(arg: String): Unit = {
override private[cli] def consumeArgument(
arg: String,
commandPrefix: Seq[String]
): ParserContinuation = {
value = for {
parsed <- Argument[A].read(arg)
} yield Some(parsed)
ParserContinuation.ContinueNormally
}
override private[cli] def reset(): Unit = {

View File

@ -1,6 +1,8 @@
package org.enso.cli.internal
package org.enso.cli.internal.opts
import org.enso.cli.Opts
import cats.data.NonEmptyList
import org.enso.cli.arguments.{Opts, OptsParseError}
import org.enso.cli.internal.ParserContinuation
class OptsMap[A, B](a: Opts[A], f: A => B) extends Opts[B] {
override private[cli] def flags = a.flags
@ -13,8 +15,11 @@ class OptsMap[A, B](a: Opts[A], f: A => B) extends Opts[B] {
a.gatherPrefixedParameters
override private[cli] def wantsArgument() = a.wantsArgument()
override private[cli] def consumeArgument(arg: String): Unit =
a.consumeArgument(arg)
override private[cli] def consumeArgument(
arg: String,
commandPrefix: Seq[String]
): ParserContinuation =
a.consumeArgument(arg, commandPrefix)
override private[cli] def requiredArguments: Seq[String] = a.requiredArguments
override private[cli] def optionalArguments: Seq[String] = a.optionalArguments
override private[cli] def trailingArguments: Option[String] =
@ -26,10 +31,14 @@ class OptsMap[A, B](a: Opts[A], f: A => B) extends Opts[B] {
override private[cli] def result(
commandPrefix: Seq[String]
): Either[List[String], B] = a.result(commandPrefix).map(f)
): Either[OptsParseError, B] = a.result(commandPrefix).map(f)
override def availableOptionsHelp(): Seq[String] = a.availableOptionsHelp()
override def availablePrefixedParametersHelp(): Seq[String] =
a.availablePrefixedParametersHelp()
override def additionalHelp(): Seq[String] = a.additionalHelp()
override def commandLines(
alwaysIncludeOtherOptions: Boolean = false
): NonEmptyList[String] = a.commandLines(alwaysIncludeOtherOptions)
}

View File

@ -1,6 +1,8 @@
package org.enso.cli.internal
package org.enso.cli.internal.opts
import org.enso.cli.{IllegalOptsStructure, Opts}
import cats.data.NonEmptyList
import org.enso.cli.arguments.{IllegalOptsStructure, Opts, OptsParseError}
import org.enso.cli.internal.ParserContinuation
class OptsProduct[A, B](lhs: Opts[A], rhs: Opts[B]) extends Opts[(A, B)] {
override private[cli] def flags = lhs.flags ++ rhs.flags
@ -16,9 +18,12 @@ class OptsProduct[A, B](lhs: Opts[A], rhs: Opts[B]) extends Opts[(A, B)] {
override private[cli] def wantsArgument() =
lhs.wantsArgument() || rhs.wantsArgument()
override private[cli] def consumeArgument(arg: String): Unit =
if (lhs.wantsArgument()) lhs.consumeArgument(arg)
else rhs.consumeArgument(arg)
override private[cli] def consumeArgument(
arg: String,
commandPrefix: Seq[String]
): ParserContinuation =
if (lhs.wantsArgument()) lhs.consumeArgument(arg, commandPrefix)
else rhs.consumeArgument(arg, commandPrefix)
override private[cli] def requiredArguments: Seq[String] =
lhs.requiredArguments ++ rhs.requiredArguments
override private[cli] def optionalArguments: Seq[String] =
@ -44,7 +49,7 @@ class OptsProduct[A, B](lhs: Opts[A], rhs: Opts[B]) extends Opts[(A, B)] {
override private[cli] def result(
commandPrefix: Seq[String]
): Either[List[String], (A, B)] =
): Either[OptsParseError, (A, B)] =
for {
l <- lhs.result(commandPrefix)
r <- rhs.result(commandPrefix)
@ -57,6 +62,35 @@ class OptsProduct[A, B](lhs: Opts[A], rhs: Opts[B]) extends Opts[(A, B)] {
rhs.availablePrefixedParametersHelp()
override def additionalHelp(): Seq[String] =
lhs.additionalHelp() ++ rhs.additionalHelp()
/**
* A helper function that gathers all options and arguments definitions, to
* display a command line for showing in the usage section of the help.
*
* This variant is special to ensure proper handling of subcommands.
*
* Subcommands can be theoretically nested, but they cannot be grouped next
* to each other (because it would cause ordering problems and would be
* unintuitive for users). So in a product, at most one of the pair can be a
* subcommand. We compute command lines for both elements and check if one of
* them has more than one command line - that would indicate a subcommand. If
* we find a subcommand, we use its command lines. Otherwise, we just call
* the original implementation.
*/
override def commandLines(
alwaysIncludeOtherOptions: Boolean = false
): NonEmptyList[String] = {
val rightHasOptions = rhs.gatherOptions.nonEmpty
val leftHasOptions = lhs.gatherOptions.nonEmpty
val left = lhs.commandLines(rightHasOptions || alwaysIncludeOtherOptions)
if (left.size > 1) left
else {
val right = rhs.commandLines(leftHasOptions || alwaysIncludeOtherOptions)
if (right.size > 1) right
else super.commandLines(alwaysIncludeOtherOptions)
}
}
}
object OptsProduct {

View File

@ -1,9 +1,11 @@
package org.enso.cli.internal
package org.enso.cli.internal.opts
import org.enso.cli.arguments.OptsParseError
class OptsPure[A](v: A) extends BaseOpts[A] {
override private[cli] def result(
commandPrefix: Seq[String]
): Either[List[String], A] = Right(v)
): Either[OptsParseError, A] = Right(v)
override private[cli] def reset(): Unit = {}
override def availableOptionsHelp(): Seq[String] = Seq()

View File

@ -1,6 +1,6 @@
package org.enso.cli.internal
package org.enso.cli.internal.opts
import org.enso.cli.Argument
import org.enso.cli.arguments.{Argument, OptsParseError}
class Parameter[A: Argument](
name: String,
@ -22,24 +22,25 @@ class Parameter[A: Argument](
val empty = Right(None)
var value: Either[List[String], Option[A]] = empty
var value: Either[OptsParseError, Option[A]] = empty
override private[cli] def reset(): Unit = {
value = empty
}
private def update(newValue: String): Unit = {
value = combineWithoutDuplicates(
value = OptsParseError.combineWithoutDuplicates(
value,
Argument[A].read(newValue),
s"Multiple values for parameter $name."
s"Multiple values for parameter `$name`."
)
}
override private[cli] def result(commandPrefix: Seq[String]) =
value.flatMap {
case Some(value) => Right(value)
case None => Left(List(s"Missing required parameter $name"))
case None =>
OptsParseError.left(s"Missing required parameter `--$name`.")
}
override def availableOptionsHelp(): Seq[String] = {

View File

@ -1,13 +1,14 @@
package org.enso.cli.internal
package org.enso.cli.internal.opts
import org.enso.cli.Argument
import org.enso.cli.arguments.{Argument, OptsParseError}
import org.enso.cli.internal.ParserContinuation
class PositionalArgument[A: Argument](
metavar: String,
helpComment: Option[String]
) extends BaseOpts[A] {
val empty = Right(None)
var value: Either[List[String], Option[A]] = empty
var value: Either[OptsParseError, Option[A]] = empty
override private[cli] val requiredArguments = Seq(metavar)
@ -17,10 +18,14 @@ class PositionalArgument[A: Argument](
case _ => false
}
override private[cli] def consumeArgument(arg: String): Unit = {
override private[cli] def consumeArgument(
arg: String,
commandPrefix: Seq[String]
): ParserContinuation = {
value = for {
parsed <- Argument[A].read(arg)
} yield Some(parsed)
ParserContinuation.ContinueNormally
}
override private[cli] def reset(): Unit = {
@ -30,7 +35,7 @@ class PositionalArgument[A: Argument](
override private[cli] def result(commandPrefix: Seq[String]) =
value.flatMap {
case Some(value) => Right(value)
case None => Left(List(s"Missing required argument <$metavar>."))
case None => OptsParseError.left(s"Missing required argument <$metavar>.")
}
override def additionalHelp(): Seq[String] = helpComment.toSeq

View File

@ -1,4 +1,4 @@
package org.enso.cli.internal
package org.enso.cli.internal.opts
class PrefixedParameters(
prefix: String,

View File

@ -0,0 +1,59 @@
package org.enso.cli.internal.opts
import cats.data.NonEmptyList
import org.enso.cli.arguments.{Command, OptsParseError}
import org.enso.cli.internal.ParserContinuation
import org.enso.cli.{CLIOutput, Spelling}
class SubcommandOpt[A](subcommands: NonEmptyList[Command[A]])
extends BaseSubcommandOpt[A, A] {
/**
* @inheritdoc
*/
override def availableSubcommands: NonEmptyList[Command[A]] = subcommands
/**
* A flag that can be set to indicate that a command was provided, but it was
* invalid.
*
* It is used for error reporting to differentiate invalid commands from
* missing commands.
*/
private var commandProvidedButInvalid: Boolean = false
/**
* @inheritdoc
*/
override def handleUnknownCommand(command: String): ParserContinuation = {
val similar =
Spelling
.selectClosestMatches(command, subcommands.toList.map(_.name))
val suggestions =
if (similar.isEmpty)
"\n\nAvailable subcommands are\n" +
subcommands.toList
.map(CLIOutput.indent + _.name + "\n")
.mkString
else
"\n\nThe most similar subcommands are\n" +
similar.map(CLIOutput.indent + _ + "\n").mkString
addError(s"`$command` is not a valid subcommand." + suggestions)
commandProvidedButInvalid = true
ParserContinuation.Stop
}
override private[cli] def result(commandPrefix: Seq[String]) = {
val prefix = extendPrefix(commandPrefix)
selectedCommand match {
case Some(command) =>
command.opts.result(prefix).addErrors(errors.reverse)
case None =>
if (commandProvidedButInvalid)
Left(OptsParseError(NonEmptyList.fromListUnsafe(errors.reverse)))
else
Left(OptsParseError.requestingFullHelp("Expected a subcommand."))
.addErrors(errors.reverse)
}
}
}

View File

@ -0,0 +1,247 @@
package org.enso.cli.internal.opts
import cats.Semigroupal
import cats.data.NonEmptyList
import org.enso.cli._
import org.enso.cli.arguments.Opts.implicits._
import org.enso.cli.arguments._
import org.enso.cli.internal.{Parser, ParserContinuation}
/**
* Implements the entry point of options parsing for an [[Application]].
*
* @param toplevelOpts top-level options that define a global configuration
* and can override commands
* @param commands list of commands
* @param pluginManager optional plugin manager that can handle unknown
* commands by launching plugins available on system path
* @param helpHeader header added to the top-level help message
* @tparam A type returned by top-level options
* @tparam B type of the config that the commands use
*/
class TopLevelCommandsOpt[A, B](
toplevelOpts: Opts[A],
commands: NonEmptyList[Command[B => Unit]],
pluginManager: Option[PluginManager],
helpHeader: String
) extends BaseSubcommandOpt[(A, Option[B => Unit]), B => Unit] {
private def helpOpt: Opts[Boolean] =
Opts.flag("help", 'h', "Print this help message.", showInUsage = true)
/**
* Top-level options extended with an option for requesting help.
*/
private val toplevelWithHelp =
implicitly[Semigroupal[Opts]].product(toplevelOpts, helpOpt)
/**
* @inheritdoc
*/
override def availableSubcommands: NonEmptyList[Command[B => Unit]] = commands
/**
* Handles an unknown command.
*
* First tries to find a plugin with the given name and if it finds it,
* parsing is stopped to invoke that plugin. Otherwise it just reports an
* error with suggestions of similar command names, if any.
*/
override def handleUnknownCommand(command: String): ParserContinuation = {
val pluginAvailable = pluginManager.exists(_.hasPlugin(command))
if (pluginAvailable) {
ParserContinuation.Escape((remainingTokens, additionalArguments) =>
pluginManager.get.runPlugin(
command,
Parser.untokenize(remainingTokens) ++ additionalArguments
)
)
} else {
addError(commandSuggestions(command))
ParserContinuation.Stop
}
}
override private[cli] def result(
commandPrefix: Seq[String]
): Either[OptsParseError, (A, Option[B => Unit])] = {
val prefix = extendPrefix(commandPrefix)
val topLevelResultWithHelp = toplevelWithHelp.result(prefix)
val topLevelResult = topLevelResultWithHelp.map(_._1)
val commandResult = selectedCommand.map(_.opts.result(prefix))
val result = (topLevelResultWithHelp, commandResult) match {
case (Right((result, true)), _) =>
def displayHelp(): Unit = {
val helpText = help(commandPrefix)
CLIOutput.println(helpText)
}
Right((result, Some((_: B) => displayHelp())))
case (_, Some(value)) =>
OptsParseError.product(topLevelResult, value.map(Some(_)))
case (_, None) =>
topLevelResult.map((_, None))
}
result.addErrors(errors.reverse)
}
/**
* Renders the help text, depending on if a command has been selected or not.
*
* If no commands were selected, renders the top-level help text. Otherwise,
* renders the help text for the selected command.
*/
override def help(commandPrefix: Seq[String]): String =
selectedCommand match {
case Some(command) =>
commandHelp(command, commandPrefix)
case None =>
topLevelHelp(commandPrefix)
}
/**
* Generates a help text for an unknown command, including, if available,
* suggestions of similar commands.
*
* @param typo the unrecognized command name
*/
def commandSuggestions(typo: String): String = {
val header = s"`$typo` is not a valid command."
val plugins = pluginManager.map(_.pluginsNames()).getOrElse(Seq())
val possibleCommands = availableSubcommands.toList.map(_.name) ++ plugins
val similar = Spelling.selectClosestMatches(
typo,
possibleCommands
)
val suggestions =
if (similar.isEmpty) "\n\n" + availableCommands()
else {
"\n\nThe most similar commands are\n" +
similar.map(CLIOutput.indent + _ + "\n").mkString
}
header + suggestions
}
override private[cli] def flags =
super.flags ++ toplevelWithHelp.flags
override private[cli] def parameters =
super.parameters ++ toplevelWithHelp.parameters
override private[cli] def prefixedParameters =
super.prefixedParameters ++ toplevelWithHelp.prefixedParameters
override private[cli] def gatherOptions =
super.gatherOptions ++ toplevelWithHelp.gatherOptions
override private[cli] def gatherPrefixedParameters =
super.gatherPrefixedParameters ++ toplevelWithHelp.gatherPrefixedParameters
override private[cli] def usageOptions =
super.usageOptions ++ toplevelWithHelp.usageOptions
// Note [Arguments in Top-Level Options]
private def validateNoArguments(): Unit = {
val topLevelHasArguments =
toplevelWithHelp.requiredArguments.nonEmpty ||
toplevelWithHelp.optionalArguments.nonEmpty ||
toplevelWithHelp.trailingArguments.nonEmpty ||
toplevelWithHelp.additionalArguments.nonEmpty
if (topLevelHasArguments) {
throw new IllegalArgumentException(
"Internal error: " +
"The top level options are not allowed to take arguments."
)
}
}
validateNoArguments()
override private[cli] def reset(): Unit = {
super.reset()
toplevelWithHelp.reset()
}
/**
* @inheritdoc
*/
override def availableOptionsHelp(): Seq[String] =
super.availableOptionsHelp() ++ toplevelWithHelp.availableOptionsHelp()
/**
* @inheritdoc
*/
override def availablePrefixedParametersHelp(): Seq[String] =
super.availablePrefixedParametersHelp() ++
toplevelWithHelp.availablePrefixedParametersHelp()
/**
* @inheritdoc
*/
override def additionalHelp(): Seq[String] =
super.additionalHelp() ++ toplevelWithHelp.additionalHelp()
/**
* @inheritdoc
*/
override def commandLines(
alwaysIncludeOtherOptions: Boolean = false
): NonEmptyList[String] = {
val include =
alwaysIncludeOtherOptions || toplevelWithHelp.gatherOptions.nonEmpty
super.commandLines(alwaysIncludeOtherOptions = include)
}
/**
* Renders help text for the specific command.
*/
def commandHelp(command: Command[_], commandPrefix: Seq[String]): String = {
val applicationName = commandPrefix.head
val mergedOpts =
implicitly[Semigroupal[Opts]].product(command.opts, toplevelWithHelp)
command.comment + "\n" + mergedOpts.help(Seq(applicationName, command.name))
}
/**
* Renders the part of the top-level help text listing the available
* commands.
*/
def availableCommands(): String = {
val pluginsHelp = pluginManager.map(_.pluginsHelp()).getOrElse(Seq())
val subCommands = commands.toList.map(_.topLevelHelp) ++ pluginsHelp
val commandDescriptions =
subCommands.map(_.toString).map(CLIOutput.indent + _ + "\n").mkString
"Available commands:\n" + commandDescriptions
}
/**
* Renders top-level help.
*/
def topLevelHelp(commandPrefix: Seq[String]): String = {
val usageOptions = toplevelWithHelp
.commandLineOptions(alwaysIncludeOtherOptions = false)
.stripLeading()
val commandName = commandPrefix.head
val usage =
s"Usage: $commandName\t$usageOptions COMMAND [ARGS...]\n"
val topLevelOptionsHelp =
toplevelWithHelp.helpExplanations()
val sb = new StringBuilder
sb.append(helpHeader + "\n")
sb.append(usage)
sb.append("\n" + availableCommands())
sb.append(topLevelOptionsHelp)
sb.append(
s"\nFor more information on a specific command listed above," +
s" please run `$commandName COMMAND --help`."
)
sb.toString()
}
}
/*
* Note [Arguments in Top-Level Options]
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* We do not override the functions handling arguments, because we just need to
* handle arguments of the subcommand. By definition, the top level options
* cannot include arguments.
*/

View File

@ -1,23 +1,28 @@
package org.enso.cli.internal
package org.enso.cli.internal.opts
import org.enso.cli.Argument
import org.enso.cli.arguments.{Argument, OptsParseError}
import org.enso.cli.internal.ParserContinuation
class TrailingArguments[A: Argument](
metavar: String,
helpComment: Option[String]
) extends BaseOpts[Seq[A]] {
val empty = Right(Nil)
var value: Either[List[String], List[A]] = empty
var value: Either[OptsParseError, List[A]] = empty
override private[cli] val trailingArguments = Some(metavar)
override private[cli] def wantsArgument() = true
override private[cli] def consumeArgument(arg: String): Unit = {
override private[cli] def consumeArgument(
arg: String,
commandPrefix: Seq[String]
): ParserContinuation = {
value = for {
currentArguments <- value
parsed <- Argument[A].read(arg)
} yield parsed :: currentArguments
ParserContinuation.ContinueNormally
}
override private[cli] def reset(): Unit = {

View File

@ -1,16 +0,0 @@
package org.enso.cli
package object internal {
def combineWithoutDuplicates[A](
old: Either[List[String], Option[A]],
newer: Either[List[String], A],
duplicateErrorMessage: String
): Either[List[String], Option[A]] =
(old, newer) match {
case (Left(oldErrors), Left(newErrors)) => Left(newErrors ++ oldErrors)
case (Right(None), Right(v)) => Right(Some(v))
case (Right(Some(_)), Right(_)) => Left(List(duplicateErrorMessage))
case (Left(errors), Right(_)) => Left(errors)
case (Right(_), Left(errors)) => Left(errors)
}
}

View File

@ -1,7 +1,16 @@
package org.enso.cli
import cats.data.NonEmptyList
import cats.implicits._
import org.enso.cli.Opts.implicits._
import org.enso.cli.arguments.Opts.implicits._
import org.enso.cli.arguments.{
Application,
Command,
CommandHelp,
Opts,
PluginManager,
TopLevelBehavior
}
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
import org.scalatest.{EitherValues, OptionValues}
@ -11,6 +20,13 @@ class ApplicationSpec
with Matchers
with OptionValues
with EitherValues {
private def captureOutput(thunk: => Unit): String = {
val stream = new java.io.ByteArrayOutputStream()
Console.withOut(stream)(thunk)
stream.toString()
}
"Application" should {
"delegate to correct commands" in {
var ranCommand: Option[String] = None
@ -18,7 +34,7 @@ class ApplicationSpec
"app",
"App",
"Test app.",
Seq(
NonEmptyList.of(
Command("cmd1", "cmd1") {
Opts.pure { _ =>
ranCommand = Some("cmd1")
@ -39,7 +55,54 @@ class ApplicationSpec
ranCommand.value shouldEqual "argvalue"
}
"handle top-level options" in {
"handle plugins" in {
case class PluginRanException(name: String, args: Seq[String])
extends RuntimeException
val plugins = Seq("plugin1", "plugin2")
val pluginManager = new PluginManager {
override def runPlugin(name: String, args: Seq[String]): Nothing =
if (plugins.contains(name))
throw PluginRanException(name, args)
else throw new RuntimeException("Plugin not found.")
override def hasPlugin(name: String): Boolean =
plugins.contains(name)
override def pluginsNames(): Seq[String] = plugins
override def pluginsHelp(): Seq[CommandHelp] =
plugins.map(CommandHelp(_, ""))
}
val app = Application[Unit](
"app",
"App",
"Test app.",
Opts.pure[() => org.enso.cli.arguments.TopLevelBehavior[Unit]] { () =>
TopLevelBehavior.Continue(())
},
NonEmptyList.of(
Command[Unit => Unit]("cmd", "cmd") {
Opts.pure { _ => () }
}
),
pluginManager
)
val pluginRun = intercept[PluginRanException] {
app.run(Seq("plugin1", "arg1", "--flag"))
}
pluginRun.name shouldEqual "plugin1"
pluginRun.args shouldEqual Seq("arg1", "--flag")
val output = captureOutput {
app.run(Seq("--help"))
}
output should include("plugin1")
output should include("plugin2")
}
"handle top-level options (before and after the command)" in {
var ranCommand: Option[String] = None
val app = Application[String](
"app",
@ -54,7 +117,7 @@ class ApplicationSpec
else TopLevelBehavior.Continue(setting.getOrElse("none"))
}
},
Seq(
NonEmptyList.of(
Command[String => Unit]("cmd1", "cmd1") {
Opts.pure { setting =>
ranCommand = Some(setting)
@ -63,20 +126,50 @@ class ApplicationSpec
)
)
app.run(Seq("--halt", "cmd1"))
assert(
app.run(Seq("--halt", "cmd1")).isRight,
"Should parse successfully."
)
ranCommand should not be defined
app.run(Seq("cmd1"))
assert(app.run(Seq("cmd1")).isRight, "Should parse successfully.")
ranCommand.value shouldEqual "none"
app.run(Seq("--setting=SET", "cmd1"))
withClue("top-level option before command:") {
ranCommand = None
assert(
app.run(Seq("--setting=SET", "cmd1")).isRight,
"Should parse successfully."
)
ranCommand.value shouldEqual "SET"
}
withClue("top-level option after command:") {
ranCommand = None
assert(
app.run(Seq("cmd1", "--setting=SET")).isRight,
"Should parse successfully."
)
ranCommand.value shouldEqual "SET"
}
}
/*"support related commands" in {
"support related commands" in {
val app = Application(
"app",
"App",
"Test app.",
NonEmptyList.of(
Command("cmd", "cmd", related = Seq("related")) {
Opts.pure { _ => }
}
)
)
}*/
app.run(Seq("related")).left.value.head should include(
"You may be looking for `app cmd`."
)
}
"suggest similar commands on typo" in {
var ranCommand: Option[String] = None
@ -84,7 +177,7 @@ class ApplicationSpec
"app",
"App",
"Test app.",
Seq(
NonEmptyList.of(
Command("cmd1", "cmd1") {
Opts.pure { _ =>
ranCommand = Some("cmd1")
@ -102,4 +195,132 @@ class ApplicationSpec
error should include("cmd1")
error should include("cmd2")
}
def appWithSubcommands(): Application[_] = {
val sub1 = Command[Boolean => Unit]("sub1", "Sub1.") {
val flag = Opts.flag("inner-flag", "Inner.", showInUsage = true)
flag map { _ => _ => () }
}
val sub2 = Command[Boolean => Unit]("sub2", "Sub2.") {
val arg = Opts.optionalArgument[String]("ARG")
arg map { _ => _ => () }
}
val topLevelOpts =
Opts.flag("toplevel-flag", "Top.", showInUsage = true) map {
flag => () =>
TopLevelBehavior.Continue(flag)
}
val app = Application(
"app",
"App",
"Top Header",
topLevelOpts,
NonEmptyList.of(
Command("cmd", "Cmd.")(Opts.subcommands(sub1, sub2))
)
)
app
}
"handle errors nicely" in {
val app = appWithSubcommands()
def runErrors(args: String*): String =
CLIOutput.alignAndWrap(app.run(args).left.value.mkString("\n"))
withClue("no commands reports it and displays help") {
runErrors() should (include(
"Expected a command."
) and include("Top Header"))
}
withClue("show similar commands if available") {
runErrors("cmd1") should (include(
"`cmd1` is not a valid command."
) and include(
"""The most similar commands are
| cmd
|""".stripMargin
))
}
withClue("show available commands if no similar available") {
runErrors("very-strange-command-name") should (include(
"`very-strange-command-name` is not a valid command."
) and include("""Available commands:
| cmd Cmd.
|""".stripMargin))
}
withClue("show command help if subcommand is missing") {
runErrors("cmd") should (include("Expected a subcommand.") and include(
"Cmd."
))
}
withClue("show similar subcommands if available") {
runErrors("cmd", "sub") should (include(
"is not a valid subcommand."
) and include(
"""The most similar subcommands are
| sub1
| sub2
|""".stripMargin
))
}
withClue("show available subcommands if no similar ones") {
runErrors("cmd", "very-strange-subcommand") should (include(
"is not a valid subcommand."
) and include(
"""Available subcommands are
| sub1
| sub2
|""".stripMargin
))
}
}
"correctly handle help for subcommands, including top-level options" in {
val app = appWithSubcommands()
val cmdOutput = captureOutput {
assert(app.run(Seq("cmd", "--help")).isRight)
}
val cmdHelp =
"""Cmd.
|Usage: app cmd sub1 [options] [--inner-flag]
| Sub1.
| app cmd sub2 [options] [ARG]
| Sub2.
|
|Available options:
| [--inner-flag] Inner.
| [--toplevel-flag] Top.
| [-h | --help] Print this help message.
|""".stripMargin
cmdOutput shouldEqual cmdHelp
val topOutput = captureOutput {
assert(app.run(Seq("--help")).isRight)
}
val topHelp =
"""Top Header
|Usage: app [--toplevel-flag] [--help] COMMAND [ARGS...]
|
|Available commands:
| cmd Cmd.
|
|Available options:
| [--toplevel-flag] Top.
| [-h | --help] Print this help message.
|
|For more information on a specific command listed above, please run `app COMMAND
|--help`.
|""".stripMargin
topOutput shouldEqual topHelp
}
}
}

View File

@ -83,6 +83,7 @@ class CLIOutputSpec extends AnyWordSpec with Matchers {
val wrapped = CLIOutputInternal.alignAndWrapTable(
Seq(
("abcdef", "a b c d e f"),
("abcde", "f"),
("b", "a")
),
10,
@ -91,8 +92,11 @@ class CLIOutputSpec extends AnyWordSpec with Matchers {
)
wrapped.mkString(System.lineSeparator()) shouldEqual
"""abcdef a b c
"""abcdef
| a b c
| d e f
|abcde
| f
|b a""".stripMargin
}
}
@ -111,6 +115,12 @@ class CLIOutputSpec extends AnyWordSpec with Matchers {
CLIOutput.alignAndWrap(unaligned) shouldEqual aligned
}
"align single row tables too" in {
val unaligned = s"a${tabulation}b"
val aligned = "a b"
CLIOutput.alignAndWrap(unaligned) shouldEqual aligned
}
"align tables independently" in {
val unaligned =
s"""Table One

View File

@ -1,7 +1,8 @@
package org.enso.cli
import cats.implicits._
import org.enso.cli.Opts.implicits._
import org.enso.cli.arguments.{Command, Opts}
import org.enso.cli.arguments.Opts.implicits._
import org.enso.cli.internal.Parser
import org.scalactic.source
import org.scalatest.exceptions.{StackDepthException, TestFailedException}
@ -22,15 +23,15 @@ class OptsSpec
def parse(args: Seq[String]): Either[List[String], A] = {
val (tokens, additionalArguments) = Parser.tokenize(args)
Parser
val result = Parser
.parseOpts(
opts,
tokens,
additionalArguments,
isTopLevel = false,
Seq("???")
"<root>"
)
.map(_._1)
result.toErrorList
}
def parseSuccessfully(line: String)(implicit pos: source.Position): A =
@ -242,10 +243,10 @@ class OptsSpec
"subcommands" should {
val opt = Opts.subcommands(
Subcommand("cmd1", "cmd1 help") {
Command("cmd1", "cmd1 help") {
Opts.flag("flag1", "", showInUsage = true).map((1, _))
},
Subcommand("cmd2", "cmd1 help") {
Command("cmd2", "cmd1 help") {
Opts.flag("flag2", "", showInUsage = true).map((2, _))
}
)
@ -282,8 +283,9 @@ class OptsSpec
"handle errors nicely" in {
opt.parseFailing("").last should include("Usage:")
opt.parseFailing("cmd").head should (include("cmd1") and include("cmd2"))
opt.parseFailing("cmd").last should include("--help")
val cmdFailed = opt.parseFailing("cmd")
cmdFailed.head should (include("cmd1") and include("cmd2"))
cmdFailed.last should include("--help")
}
}