Improve CLI Parameters Parsing (#1117)

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

View File

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

View File

@ -34,8 +34,12 @@ This document describes available command-line options of the Enso launcher.
- [`version`](#version) - [`version`](#version)
- [`help`](#help) - [`help`](#help)
- [General Options](#general-options) - [General Options](#general-options)
- [`--version`](#--version) - [`--use-enso-version`](#--use-enso-version)
- [`--use-system-jvm`](#--use-system-jvm) - [`--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) - [JVM Options](#jvm-options)
<!-- /MarkdownTOC --> <!-- /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 Prints the version of the installed launcher as well as the full version string
of the currently selected Enso distribution. 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 ```bash
> enso version > enso version
Enso Launcher Enso Launcher
Version: 0.0.1 Version: 0.0.1
Built with: scala-2.13.3 for GraalVM 20.1.0 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 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. 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 ## Options From Newer Versions
For commands that launch an Enso component inside a JVM (`repl`, `run` and For commands that launch an Enso component inside a JVM (`repl`, `run` and

View File

@ -327,16 +327,14 @@ case class Launcher(cliOptions: GlobalCLIOptions) {
/** /**
* Displays the version string of the launcher. * 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 * @param hideEngineVersion if set, does not look for installed engines to
* display the current version; this can be used to * display the current version; this can be used to
* avoid making network requests * avoid making network requests
*/ */
def displayVersion( def displayVersion(
useJSON: Boolean,
hideEngineVersion: Boolean = false hideEngineVersion: Boolean = false
): Unit = { ): Unit = {
val useJSON = cliOptions.useJSON
val runtimeVersionParameter = val runtimeVersionParameter =
if (hideEngineVersion) None else Some(getEngineVersion(useJSON)) if (hideEngineVersion) None else Some(getEngineVersion(useJSON))

View File

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

View File

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

View File

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

View File

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

View File

@ -2,17 +2,18 @@ package org.enso.launcher.cli
import java.nio.file.{Files, Path} 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 org.enso.launcher.{Environment, FileSystem}
import scala.sys.process._ import scala.sys.process._
import scala.util.Try import scala.util.Try
/** /**
* Implements an [[org.enso.cli.PluginManager]] using the given * Implements an [[arguments.PluginManager]] using the given
* [[Environment]]. * [[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. * 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 name name of the plugin
* @param args arguments that should be passed to it * @param args arguments that should be passed to it
*/ */
override def tryRunningPlugin( override def runPlugin(
name: String, name: String,
args: Seq[String] args: Seq[String]
): PluginBehaviour = ): Nothing =
findPlugin(name) match { findPlugin(name) match {
case Some(PluginDescription(commandName, _)) => case Some(PluginDescription(commandName, _)) =>
val exitCode = (Seq(commandName) ++ args).! val exitCode = (Seq(commandName) ++ args).!
sys.exit(exitCode) sys.exit(exitCode)
case None => 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 pluginPrefix = "enso-"
private val synopsisOption: String = "--synopsis" private val synopsisOption: String = "--synopsis"
@ -56,21 +65,38 @@ class PluginManager(env: Environment) extends org.enso.cli.PluginManager {
} yield CommandHelp(pluginName, description.synopsis) } yield CommandHelp(pluginName, description.synopsis)
} }
/**
* @inheritdoc
*/
override def pluginsNames(): Seq[String] = pluginsHelp().map(_.name) 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) 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. * Checks if the plugin with the given name is installed and valid.
* *
* It tries to execute it (checking various command extensions depending on * It tries to execute it (checking various command extensions depending on
* the OS) and check if it returns a synopsis. * 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 * @param name name of the plugin
* @return [[PluginDescription]] containing the command name that should be * @return [[PluginDescription]] containing the command name that should be
* used to call the plugin and its synopsis * 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 = def canonicalizeDescription(description: String): String =
description.replace("\n", " ").trim description.replace("\n", " ").trim
val noOpLogger = new ProcessLogger { val noOpLogger = new ProcessLogger {

View File

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

View File

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

View File

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

View File

@ -1,6 +1,8 @@
package org.enso.cli package org.enso.cli.arguments
import cats.data.NonEmptyList
import org.enso.cli.internal.Parser import org.enso.cli.internal.Parser
import org.enso.cli.internal.opts.TopLevelCommandsOpt
/** /**
* Represents a CLI application with multiple commands. * Represents a CLI application with multiple commands.
@ -44,10 +46,18 @@ class Application[Config](
val prettyName: String, val prettyName: String,
val helpHeader: String, val helpHeader: String,
val topLevelOpts: Opts[() => TopLevelBehavior[Config]], val topLevelOpts: Opts[() => TopLevelBehavior[Config]],
val commands: Seq[Command[Config => Unit]], val commands: NonEmptyList[Command[Config => Unit]],
val pluginManager: Option[PluginManager] 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. * 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 * Runs the application logic. Parses the top level options and depending on
* its result, possibly runs a command or a plugin. * 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 * @return either a list of errors encountered when parsing the options or
* [[Unit]] if it succeeded * [[Unit]] if it succeeded
*/ */
@ -66,163 +79,66 @@ class Application[Config](
args: Seq[String] args: Seq[String]
): Either[List[String], Unit] = { ): Either[List[String], Unit] = {
val (tokens, additionalArguments) = Parser.tokenize(args) val (tokens, additionalArguments) = Parser.tokenize(args)
val topLevelParseResult = val parseResult =
Parser.parseOpts( Parser.parseOpts(
topLevelOpts, combinedOpts,
tokens, tokens,
Seq(), additionalArguments,
isTopLevel = true, applicationName = commandName
commandPrefix = Seq(commandName)
) )
topLevelParseResult.flatMap { val finalResult = parseResult.flatMap {
case (run, restOfTokens) => case ((topLevelAction, commandResult), pluginIntercepted) =>
run() match { pluginIntercepted match {
case TopLevelBehavior.Halt => Right(()) case Some(pluginHandler) =>
pluginHandler()
case None =>
val topLevelBehavior = topLevelAction()
topLevelBehavior match {
case TopLevelBehavior.Halt =>
Right(())
case TopLevelBehavior.Continue(config) => case TopLevelBehavior.Continue(config) =>
val subCommandResult = Parser.parseCommand( commandResult match {
this, case Some(action) =>
config, Right(action(config))
restOfTokens, case None =>
additionalArguments Left(OptsParseError("Expected a command.", renderHelp()))
)
subCommandResult.map { run =>
run()
} }
} }
} }
} }
finalResult.toErrorList
}
/** /**
* Generates a help text summarizing the usage of the application and listing * Generates a help text summarizing the usage of the application and listing
* available commands and top-level options. * available commands and top-level options.
*/ */
def renderHelp(): String = { def renderHelp(): String = combinedOpts.topLevelHelp(Seq(commandName))
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]
} }
object Application { 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]( def apply[Config](
commandName: String, commandName: String,
prettyName: String, prettyName: String,
helpHeader: String, helpHeader: String,
topLevelOpts: Opts[() => TopLevelBehavior[Config]], topLevelOpts: Opts[() => TopLevelBehavior[Config]],
commands: Seq[Command[Config => Unit]], commands: NonEmptyList[Command[Config => Unit]],
pluginManager: PluginManager pluginManager: PluginManager
): Application[Config] = ): Application[Config] =
new Application( new Application(
@ -234,12 +150,27 @@ object Application {
Some(pluginManager) 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]( def apply[Config](
commandName: String, commandName: String,
prettyName: String, prettyName: String,
helpHeader: String, helpHeader: String,
topLevelOpts: Opts[() => TopLevelBehavior[Config]], topLevelOpts: Opts[() => TopLevelBehavior[Config]],
commands: Seq[Command[Config => Unit]] commands: NonEmptyList[Command[Config => Unit]]
): Application[Config] = ): Application[Config] =
new Application( new Application(
commandName, commandName,
@ -250,11 +181,23 @@ object Application {
None 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( def apply(
commandName: String, commandName: String,
prettyName: String, prettyName: String,
helpHeader: String, helpHeader: String,
commands: Seq[Command[Unit => Unit]] commands: NonEmptyList[Command[Unit => Unit]]
): Application[()] = ): Application[()] =
new Application( new Application(
commandName, commandName,

View File

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

View File

@ -1,4 +1,4 @@
package org.enso.cli package org.enso.cli.arguments
/** /**
* Represents a top-level command in the CLI. * Represents a top-level command in the CLI.
@ -17,15 +17,6 @@ case class Command[A](
related: Seq[String] 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 * Returns a top-level help entry for the application help text. It includes
* a short description of the command. * a short description of the command.
@ -33,17 +24,6 @@ case class Command[A](
def topLevelHelp: CommandHelp = CommandHelp(name, comment) 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 { object Command {
/** /**
@ -98,13 +78,3 @@ object Command {
s"To show available commands, run `$command --help`." s"To show available commands, run `$command --help`."
} }
} }
/**
* A help entry used in the top-level help text.
*
* @param name name of a command
* @param comment a short description of that command
*/
case class CommandHelp(name: String, comment: String) {
override def toString: String = s"$name\t$comment"
}

View File

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

View File

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

View File

@ -1,15 +1,11 @@
package org.enso.cli package org.enso.cli.arguments
import cats.data.NonEmptyList import cats.data.NonEmptyList
import cats.{Functor, Semigroupal}
import cats.implicits._ import cats.implicits._
import cats.{Functor, Semigroupal}
import org.enso.cli.CLIOutput
import org.enso.cli.internal._ import org.enso.cli.internal._
import org.enso.cli.internal.opts._
/**
* Exception that is reported when Opts are combined in an illegal way.
*/
case class IllegalOptsStructure(message: String, cause: Throwable = null)
extends RuntimeException(message, cause)
/** /**
* Represents a set of options (flags, parameters, arguments) and the logic * 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 * Opts instances are allowed to use internal mutable state for parsing. They
* can be parsed multiple times, but they are not thread-safe. * 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. * 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. * 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 * 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 * the right moment to do final validation (for example, detecting missing
* options). * 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]]. * Lists options that should be printed in the usage [[commandLines]].
@ -131,10 +130,15 @@ trait Opts[A] {
* *
* @return a string representing the options usage * @return a string representing the options usage
*/ */
private[cli] def commandLineOptions(): String = { private[cli] def commandLineOptions(
val allOptions = parameters.size + flags.size + prefixedParameters.size alwaysIncludeOtherOptions: Boolean
): String = {
val flagsWithoutDuplicates = flags.keys.count(_.length > 1)
val allOptions =
parameters.size + flagsWithoutDuplicates + prefixedParameters.size
val otherOptions = val otherOptions =
if (allOptions > usageOptions.size) if (alwaysIncludeOtherOptions || allOptions > usageOptions.size)
" [options]" " [options]"
else "" else ""
otherOptions + usageOptions.map(" " + _).mkString otherOptions + usageOptions.map(" " + _).mkString
@ -147,8 +151,10 @@ trait Opts[A] {
* @return a non-empty list of available usages of this option set. Multiple * @return a non-empty list of available usages of this option set. Multiple
* entries may be returned in presence of subcommands. * entries may be returned in presence of subcommands.
*/ */
def commandLines(): NonEmptyList[String] = { def commandLines(
val options = commandLineOptions() alwaysIncludeOtherOptions: Boolean = false
): NonEmptyList[String] = {
val options = commandLineOptions(alwaysIncludeOtherOptions)
val required = requiredArguments.map(arg => s" $arg").mkString val required = requiredArguments.map(arg => s" $arg").mkString
val optional = optionalArguments.map(arg => s" [$arg]").mkString val optional = optionalArguments.map(arg => s" [$arg]").mkString
val trailing = trailingArguments.map(args => s" [$args...]").getOrElse("") 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. * 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 = { def helpExplanations(): String = {
val additionalHelpOption = val options =
if (addHelpOption) Seq("[--help | -h]\tPrint this help message.") availableOptionsHelp().map(CLIOutput.indent + _).mkString("\n")
else Seq()
val optionExplanations =
additionalHelpOption ++ availableOptionsHelp()
val options = optionExplanations.map(CLIOutput.indent + _).mkString("\n")
val optionsHelp = val optionsHelp =
if (options.isEmpty) "" else "\nAvailable options:\n" + options + "\n" if (options.isEmpty) "" else "\nAvailable options:\n" + options + "\n"
@ -210,8 +209,14 @@ trait Opts[A] {
firstLine + usages.head + firstLine + usages.head +
usages.tail.map("\n" + padding + _).mkString + "\n" 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 * @param otherCommands any following subcommands
*/ */
def subcommands[A]( def subcommands[A](
firstCommand: Subcommand[A], firstCommand: Command[A],
otherCommands: Subcommand[A]* otherCommands: Command[A]*
): Opts[A] = { ): Opts[A] = {
val nonEmptyCommands = NonEmptyList.of(firstCommand, otherCommands: _*) val nonEmptyCommands = NonEmptyList.of(firstCommand, otherCommands: _*)
new SubcommandOpt[A](nonEmptyCommands) new SubcommandOpt[A](nonEmptyCommands)

View File

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

View File

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

View File

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

View File

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

View File

@ -1,86 +1,7 @@
package org.enso.cli.internal package org.enso.cli.internal
import org.enso.cli.{ import org.enso.cli.arguments.{Opts, OptsParseError}
Application, import org.enso.cli.{CLIOutput, Spelling}
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
}
object Parser { object Parser {
@ -91,29 +12,34 @@ object Parser {
* @param tokens the sequence of tokens to parse * @param tokens the sequence of tokens to parse
* @param additionalArguments additional arguments that may be needed by the * @param additionalArguments additional arguments that may be needed by the
* [[Opts.additionalArguments]] option * [[Opts.additionalArguments]] option
* @param isTopLevel determines if `opts` are top-level options or options * @param applicationName application name, used for displaying help
* for a command; when an argument is encountered when * messages
* parsing top-level options, parsing is stopped to return * @return returns either the result value of `opts` and an optional closure
* the top-level options and possibly continue it with * that defines the plugin handler behaviour or a parse error
* 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
*/ */
def parseOpts[A]( def parseOpts[A](
opts: Opts[A], opts: Opts[A],
tokens: Seq[Token], tokens: Seq[Token],
additionalArguments: Seq[String], additionalArguments: Seq[String],
isTopLevel: Boolean, applicationName: String
commandPrefix: Seq[String] ): Either[OptsParseError, (A, Option[() => Nothing])] = {
): Either[List[String], (A, Seq[Token])] = {
var parseErrors: List[String] = Nil var parseErrors: List[String] = Nil
def addError(error: String): Unit = { def addError(error: String): Unit = {
parseErrors = error :: parseErrors 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 var suppressUnexpectedArgument = false
def reportUnknownParameter( def reportUnknownParameter(
parameter: String, parameter: String,
original: String original: String
@ -156,21 +82,22 @@ object Parser {
opts.reset() opts.reset()
val tokenProvider = new TokenStream(tokens, addError) val tokenProvider = new TokenStream(tokens, addError)
/** var escapeParsing: Option[(Seq[Token], Seq[String]) => Nothing] = None
* Specifies whether the parser should parse the next argument. var parsingStopped: Boolean = false
* 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
while (tokenProvider.hasTokens && shouldProceed()) { while (!parsingStopped && tokenProvider.hasTokens) {
tokenProvider.consumeToken() match { tokenProvider.consumeToken() match {
case PlainToken(value) => case PlainToken(value) =>
if (opts.wantsArgument()) { 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) { } else if (!suppressUnexpectedArgument) {
addError(s"Unexpected argument `$value`.") addError(s"Unexpected argument `$value`.")
} }
@ -190,7 +117,7 @@ object Parser {
} else if (opts.parameters.contains(parameter)) { } else if (opts.parameters.contains(parameter)) {
for ( for (
value <- tokenProvider.tryConsumeArgument( value <- tokenProvider.tryConsumeArgument(
s"Expected a value for parameter $parameter." s"Expected a value for parameter `$parameter`."
) )
) opts.parameters(parameter)(value) ) opts.parameters(parameter)(value)
} else if (hasPrefix(parameter)) { } else if (hasPrefix(parameter)) {
@ -198,7 +125,7 @@ object Parser {
if (opts.prefixedParameters.contains(prefix)) { if (opts.prefixedParameters.contains(prefix)) {
for ( for (
value <- tokenProvider.tryConsumeArgument( value <- tokenProvider.tryConsumeArgument(
s"Expected a value for parameter $parameter." s"Expected a value for parameter `$parameter`."
) )
) opts.prefixedParameters(prefix)(rest, value) ) opts.prefixedParameters(prefix)(rest, value)
} else { } else {
@ -224,7 +151,7 @@ object Parser {
} }
} }
if (!isTopLevel) { if (escapeParsing.isEmpty) {
opts.additionalArguments match { opts.additionalArguments match {
case Some(additionalArgumentsHandler) => case Some(additionalArgumentsHandler) =>
additionalArgumentsHandler(additionalArguments) additionalArgumentsHandler(additionalArguments)
@ -233,125 +160,27 @@ object Parser {
addError("Additional arguments (after --) were not expected.") 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]( val result =
result: Either[List[String], T] opts.result(Seq(applicationName)).addErrors(parseErrors.reverse)
): Either[List[String], T] = {
val help = val finalResult = (escapeParsing, result) match {
s"See `${commandPrefix.mkString(" ")} --help` for usage explanation." case (Some(cont), Right(_)) =>
result match { val pluginHandler =
case Left(errors) => () => cont(tokenProvider.remaining(), additionalArguments)
val shouldAddHelp = !errors.exists(_.contains("Usage:")) result.map((_, Some(pluginHandler)))
if (shouldAddHelp) case _ => result.map((_, None))
Left(errors ++ Seq(help))
else Left(errors)
case Right(value) => Right(value)
}
} }
appendHelp( finalResult
appendErrors( .appendFullHelp {
opts.result(commandPrefix).map((_, tokenProvider.remaining())), opts.help(Seq(applicationName))
parseErrors.reverse
)
)
} }
.appendShortHelp {
/** opts.shortHelp(Seq(applicationName))
* Parses a command for the application.
*
* First tries to find a command in [[Application.commands]], if that fails,
* it tries [[Command.related]] and later tries the
* [[Application.pluginManager]] if available.
*/
def parseCommand[Config](
application: Application[Config],
config: Config,
tokens: Seq[Token],
additionalArguments: Seq[String]
): Either[List[String], () => Unit] =
tokens match {
case Seq() =>
singleError(
s"Expected a command.\n\n" + application.renderHelp()
)
case Seq(PlainToken(commandName), commandArgs @ _*) =>
application.commands.find(_.name == commandName) match {
case Some(command) =>
if (wantsHelp(commandArgs)) {
Right(() => {
CLIOutput.println(command.help(application.commandName))
})
} else {
Parser
.parseOpts(
command.opts,
commandArgs,
additionalArguments,
isTopLevel = false,
Seq(application.commandName, commandName)
)
.map(_._1)
.map(runner => () => runner(config))
}
case None =>
val possiblyRelated = Command.formatRelated(
commandName,
Seq(application.commandName),
application.commands
)
possiblyRelated match {
case Some(relatedCommandMessage) =>
singleError(relatedCommandMessage)
case None =>
val additionalArgs =
if (additionalArguments.nonEmpty)
Seq("--") ++ additionalArguments
else Seq()
val pluginBehaviour = application.pluginManager
.map(
_.tryRunningPlugin(
commandName,
untokenize(commandArgs) ++ additionalArgs
)
)
.getOrElse(PluginNotFound)
pluginBehaviour match {
case PluginNotFound =>
singleError(application.commandSuggestions(commandName))
case PluginInterceptedFlow => Right(() => ())
} }
} }
}
}
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( private def splitAdditionalArguments(
args: Seq[String] args: Seq[String]
@ -373,6 +202,15 @@ object Parser {
private val paramWithValue = """--([\w-.]+)=(.*)""".r private val paramWithValue = """--([\w-.]+)=(.*)""".r
private val mistypedLongParam = """-([\w-.]+)""".r private val mistypedLongParam = """-([\w-.]+)""".r
private val mistypedParamWithValue = """-([\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 tokenize(args: Seq[String]): (Seq[Token], Seq[String]) = {
def toToken(arg: String): Token = def toToken(arg: String): Token =
arg match { arg match {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,8 @@
package org.enso.cli.internal package org.enso.cli.internal.opts
import org.enso.cli.Opts import cats.data.NonEmptyList
import org.enso.cli.arguments.{Opts, OptsParseError}
import org.enso.cli.internal.ParserContinuation
class OptsMap[A, B](a: Opts[A], f: A => B) extends Opts[B] { class OptsMap[A, B](a: Opts[A], f: A => B) extends Opts[B] {
override private[cli] def flags = a.flags 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 a.gatherPrefixedParameters
override private[cli] def wantsArgument() = a.wantsArgument() override private[cli] def wantsArgument() = a.wantsArgument()
override private[cli] def consumeArgument(arg: String): Unit = override private[cli] def consumeArgument(
a.consumeArgument(arg) arg: String,
commandPrefix: Seq[String]
): ParserContinuation =
a.consumeArgument(arg, commandPrefix)
override private[cli] def requiredArguments: Seq[String] = a.requiredArguments override private[cli] def requiredArguments: Seq[String] = a.requiredArguments
override private[cli] def optionalArguments: Seq[String] = a.optionalArguments override private[cli] def optionalArguments: Seq[String] = a.optionalArguments
override private[cli] def trailingArguments: Option[String] = 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( override private[cli] def result(
commandPrefix: Seq[String] 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 availableOptionsHelp(): Seq[String] = a.availableOptionsHelp()
override def availablePrefixedParametersHelp(): Seq[String] = override def availablePrefixedParametersHelp(): Seq[String] =
a.availablePrefixedParametersHelp() a.availablePrefixedParametersHelp()
override def additionalHelp(): Seq[String] = a.additionalHelp() override def additionalHelp(): Seq[String] = a.additionalHelp()
override def commandLines(
alwaysIncludeOtherOptions: Boolean = false
): NonEmptyList[String] = a.commandLines(alwaysIncludeOtherOptions)
} }

View File

@ -1,6 +1,8 @@
package org.enso.cli.internal package org.enso.cli.internal.opts
import org.enso.cli.{IllegalOptsStructure, Opts} import cats.data.NonEmptyList
import org.enso.cli.arguments.{IllegalOptsStructure, Opts, OptsParseError}
import org.enso.cli.internal.ParserContinuation
class OptsProduct[A, B](lhs: Opts[A], rhs: Opts[B]) extends Opts[(A, B)] { class OptsProduct[A, B](lhs: Opts[A], rhs: Opts[B]) extends Opts[(A, B)] {
override private[cli] def flags = lhs.flags ++ rhs.flags 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() = override private[cli] def wantsArgument() =
lhs.wantsArgument() || rhs.wantsArgument() lhs.wantsArgument() || rhs.wantsArgument()
override private[cli] def consumeArgument(arg: String): Unit = override private[cli] def consumeArgument(
if (lhs.wantsArgument()) lhs.consumeArgument(arg) arg: String,
else rhs.consumeArgument(arg) commandPrefix: Seq[String]
): ParserContinuation =
if (lhs.wantsArgument()) lhs.consumeArgument(arg, commandPrefix)
else rhs.consumeArgument(arg, commandPrefix)
override private[cli] def requiredArguments: Seq[String] = override private[cli] def requiredArguments: Seq[String] =
lhs.requiredArguments ++ rhs.requiredArguments lhs.requiredArguments ++ rhs.requiredArguments
override private[cli] def optionalArguments: Seq[String] = 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( override private[cli] def result(
commandPrefix: Seq[String] commandPrefix: Seq[String]
): Either[List[String], (A, B)] = ): Either[OptsParseError, (A, B)] =
for { for {
l <- lhs.result(commandPrefix) l <- lhs.result(commandPrefix)
r <- rhs.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() rhs.availablePrefixedParametersHelp()
override def additionalHelp(): Seq[String] = override def additionalHelp(): Seq[String] =
lhs.additionalHelp() ++ rhs.additionalHelp() 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 { object OptsProduct {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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