mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 20:31:45 +03:00
Improve CLI Parameters Parsing (#1117)
This commit is contained in:
parent
60d0c2ae45
commit
2da720b1a9
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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))
|
||||
|
||||
|
@ -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.")
|
||||
)
|
||||
|
||||
}
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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,12 +45,10 @@ object Main {
|
||||
"configuration.",
|
||||
showInUsage = true
|
||||
)
|
||||
(jsonFlag(showInUsage = true), onlyLauncherFlag) mapN {
|
||||
(useJSON, onlyLauncher) => (config: Config) =>
|
||||
Launcher(config).displayVersion(
|
||||
useJSON,
|
||||
hideEngineVersion = onlyLauncher
|
||||
)
|
||||
onlyLauncherFlag map { onlyLauncher => (config: Config) =>
|
||||
Launcher(config).displayVersion(
|
||||
hideEngineVersion = onlyLauncher
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -148,7 +148,7 @@ object Main {
|
||||
private def languageServerCommand: Command[Config => Unit] =
|
||||
Command(
|
||||
"language-server",
|
||||
"Launch the Language Server for a given project." +
|
||||
"Launch the Language Server for a given project. " +
|
||||
"If `auto-confirm` is set, this will install missing engines or " +
|
||||
"runtimes without asking.",
|
||||
related = Seq("server")
|
||||
@ -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,39 +470,29 @@ object Main {
|
||||
|
||||
(
|
||||
internalOpts,
|
||||
help,
|
||||
version,
|
||||
json,
|
||||
ensurePortable,
|
||||
autoConfirm,
|
||||
hideProgress
|
||||
) mapN {
|
||||
(
|
||||
_,
|
||||
help,
|
||||
version,
|
||||
useJSON,
|
||||
shouldEnsurePortable,
|
||||
autoConfirm,
|
||||
hideProgress
|
||||
) => () =>
|
||||
if (shouldEnsurePortable) {
|
||||
Launcher.ensurePortable()
|
||||
}
|
||||
(_, version, useJSON, shouldEnsurePortable, autoConfirm, hideProgress) =>
|
||||
() =>
|
||||
if (shouldEnsurePortable) {
|
||||
Launcher.ensurePortable()
|
||||
}
|
||||
|
||||
val globalCLIOptions = GlobalCLIOptions(
|
||||
autoConfirm = autoConfirm,
|
||||
hideProgress = hideProgress
|
||||
)
|
||||
val globalCLIOptions = GlobalCLIOptions(
|
||||
autoConfirm = autoConfirm,
|
||||
hideProgress = hideProgress,
|
||||
useJSON = useJSON
|
||||
)
|
||||
|
||||
if (help) {
|
||||
printTopLevelHelp()
|
||||
TopLevelBehavior.Halt
|
||||
} else if (version) {
|
||||
Launcher(globalCLIOptions).displayVersion(useJSON)
|
||||
TopLevelBehavior.Halt
|
||||
} else
|
||||
TopLevelBehavior.Continue(globalCLIOptions)
|
||||
if (version) {
|
||||
Launcher(globalCLIOptions).displayVersion(useJSON)
|
||||
TopLevelBehavior.Halt
|
||||
} else
|
||||
TopLevelBehavior.Continue(globalCLIOptions)
|
||||
}
|
||||
}
|
||||
|
||||
@ -509,7 +502,7 @@ object Main {
|
||||
"Enso",
|
||||
"Enso Launcher",
|
||||
topLevelOpts,
|
||||
Seq(
|
||||
NonEmptyList.of(
|
||||
versionCommand,
|
||||
helpCommand,
|
||||
newCommand,
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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(())
|
||||
case TopLevelBehavior.Continue(config) =>
|
||||
val subCommandResult = Parser.parseCommand(
|
||||
this,
|
||||
config,
|
||||
restOfTokens,
|
||||
additionalArguments
|
||||
)
|
||||
subCommandResult.map { run =>
|
||||
run()
|
||||
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) =>
|
||||
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,
|
@ -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`")
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
@ -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"
|
||||
}
|
@ -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)
|
@ -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)
|
@ -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)
|
||||
}
|
||||
}
|
@ -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]
|
||||
}
|
@ -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]
|
||||
}
|
@ -65,20 +65,27 @@ private[cli] object CLIOutputInternal {
|
||||
minimumWrapWidth: Int,
|
||||
minimumTableWidth: Int
|
||||
): Seq[String] = {
|
||||
val prefixLengths = rows.map(_._1.length)
|
||||
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 restLines = wrappedSuffix.tail.map(additionalLinesPadding + _)
|
||||
Seq(firstLine) ++ restLines
|
||||
val firstLine =
|
||||
if (prefix.length >= commmonPrefixLength)
|
||||
Seq(prefix, additionalLinesPadding + wrappedSuffix.head)
|
||||
else
|
||||
Seq(prefixPadded + wrappedSuffix.head)
|
||||
val restLines = wrappedSuffix.tail.map(additionalLinesPadding + _)
|
||||
firstLine ++ restLines
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,126 +160,28 @@ 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))
|
||||
}
|
||||
|
||||
finalResult
|
||||
.appendFullHelp {
|
||||
opts.help(Seq(applicationName))
|
||||
}
|
||||
.appendShortHelp {
|
||||
opts.shortHelp(Seq(applicationName))
|
||||
}
|
||||
}
|
||||
|
||||
appendHelp(
|
||||
appendErrors(
|
||||
opts.result(commandPrefix).map((_, tokenProvider.remaining())),
|
||||
parseErrors.reverse
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(() => ())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
): (Seq[String], 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 {
|
||||
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
@ -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
|
||||
}
|
@ -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()
|
@ -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."
|
@ -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))
|
||||
}
|
@ -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,
|
||||
@ -25,8 +27,8 @@ class Flag(
|
||||
Seq(name -> s"--$name") ++
|
||||
short.map(char => char.toString -> s"-$char").toSeq
|
||||
|
||||
val empty = Right(false)
|
||||
var value: Either[List[String], Boolean] = empty
|
||||
val empty = Right(false)
|
||||
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)
|
||||
)
|
||||
}
|
@ -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()
|
@ -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`."
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
val empty = Right(None)
|
||||
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 = {
|
@ -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)
|
||||
}
|
@ -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 {
|
@ -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()
|
@ -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] = {
|
@ -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
|
||||
val empty = Right(None)
|
||||
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
|
@ -1,4 +1,4 @@
|
||||
package org.enso.cli.internal
|
||||
package org.enso.cli.internal.opts
|
||||
|
||||
class PrefixedParameters(
|
||||
prefix: String,
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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.
|
||||
*/
|
@ -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
|
||||
val empty = Right(Nil)
|
||||
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 = {
|
@ -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)
|
||||
}
|
||||
}
|
@ -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,43 +126,201 @@ 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"))
|
||||
ranCommand.value shouldEqual "SET"
|
||||
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 {
|
||||
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
|
||||
val app = Application(
|
||||
"app",
|
||||
"App",
|
||||
"Test app.",
|
||||
NonEmptyList.of(
|
||||
Command("cmd1", "cmd1") {
|
||||
Opts.pure { _ =>
|
||||
ranCommand = Some("cmd1")
|
||||
}
|
||||
},
|
||||
Command("cmd2", "cmd2") {
|
||||
Opts.positionalArgument[String]("arg") map { arg => _ =>
|
||||
ranCommand = Some(arg)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
val error = app.run(Seq("cmd")).left.value.head
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/*"support related commands" in {
|
||||
|
||||
}*/
|
||||
|
||||
"suggest similar commands on typo" in {
|
||||
var ranCommand: Option[String] = None
|
||||
val app = Application(
|
||||
"app",
|
||||
"App",
|
||||
"Test app.",
|
||||
Seq(
|
||||
Command("cmd1", "cmd1") {
|
||||
Opts.pure { _ =>
|
||||
ranCommand = Some("cmd1")
|
||||
}
|
||||
},
|
||||
Command("cmd2", "cmd2") {
|
||||
Opts.positionalArgument[String]("arg") map { arg => _ =>
|
||||
ranCommand = Some(arg)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
val error = app.run(Seq("cmd")).left.value.head
|
||||
error should include("cmd1")
|
||||
error should include("cmd2")
|
||||
}
|
||||
}
|
||||
|
@ -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,9 +92,12 @@ class CLIOutputSpec extends AnyWordSpec with Matchers {
|
||||
)
|
||||
|
||||
wrapped.mkString(System.lineSeparator()) shouldEqual
|
||||
"""abcdef a b c
|
||||
| d e f
|
||||
|b a""".stripMargin
|
||||
"""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
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user