mirror of
https://github.com/enso-org/enso.git
synced 2025-01-03 23:22:15 +03:00
Improve CLI Parameters Parsing (#1117)
This commit is contained in:
parent
60d0c2ae45
commit
2da720b1a9
@ -501,7 +501,8 @@ lazy val cli = project
|
|||||||
libraryDependencies ++= Seq(
|
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)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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))
|
||||||
|
|
||||||
|
@ -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.")
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
)
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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.
|
||||||
|
@ -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,
|
@ -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`")
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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"
|
|
||||||
}
|
|
@ -0,0 +1,11 @@
|
|||||||
|
package org.enso.cli.arguments
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A help entry used in the top-level help text.
|
||||||
|
*
|
||||||
|
* @param name name of a command
|
||||||
|
* @param comment a short description of that command
|
||||||
|
*/
|
||||||
|
case class CommandHelp(name: String, comment: String) {
|
||||||
|
override def toString: String = s"$name\t$comment"
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
package org.enso.cli.arguments
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception that is reported when Opts are combined in an illegal way.
|
||||||
|
*/
|
||||||
|
case class IllegalOptsStructure(message: String, cause: Throwable = null)
|
||||||
|
extends RuntimeException(message, cause)
|
@ -1,15 +1,11 @@
|
|||||||
package org.enso.cli
|
package org.enso.cli.arguments
|
||||||
|
|
||||||
import cats.data.NonEmptyList
|
import cats.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)
|
@ -0,0 +1,198 @@
|
|||||||
|
package org.enso.cli.arguments
|
||||||
|
|
||||||
|
import cats.data.NonEmptyList
|
||||||
|
import cats.implicits._
|
||||||
|
import cats.kernel.Semigroup
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregates errors encountered when parsing [[Opts]] and allows to attach
|
||||||
|
* help texts.
|
||||||
|
*
|
||||||
|
* @param errors list of parse errors
|
||||||
|
* @param fullHelpRequested specifies if attaching a full help text was
|
||||||
|
* requested
|
||||||
|
* @param fullHelpAppended specifies if a full help text has already been
|
||||||
|
* attached
|
||||||
|
*/
|
||||||
|
case class OptsParseError(
|
||||||
|
errors: NonEmptyList[String],
|
||||||
|
fullHelpRequested: Boolean = false,
|
||||||
|
fullHelpAppended: Boolean = false
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a copy with additional errors from the provided list.
|
||||||
|
*/
|
||||||
|
def withErrors(additionalErrors: List[String]): OptsParseError =
|
||||||
|
copy(errors = errors ++ additionalErrors)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a copy with additional errors.
|
||||||
|
*/
|
||||||
|
def withErrors(additionalErrors: String*): OptsParseError =
|
||||||
|
withErrors(additionalErrors.toList)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies if short help should be appended.
|
||||||
|
*
|
||||||
|
* Short help is appended if it was not already mentioned in any of the
|
||||||
|
* errors and if the full help is not added (or requested to be added later).
|
||||||
|
*/
|
||||||
|
def shouldAppendShortHelp: Boolean = {
|
||||||
|
val isHelpMentionedAlready = errors.exists(_.contains("--help"))
|
||||||
|
val isHelpHandled =
|
||||||
|
isHelpMentionedAlready || fullHelpAppended || fullHelpRequested
|
||||||
|
!isHelpHandled
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a copy with short help appended.
|
||||||
|
*/
|
||||||
|
def withShortHelp(helpString: String): OptsParseError =
|
||||||
|
withErrors(helpString)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a copy with full help appended.
|
||||||
|
*/
|
||||||
|
def withFullHelp(helpString: String): OptsParseError =
|
||||||
|
withErrors(helpString).copy(
|
||||||
|
fullHelpRequested = false,
|
||||||
|
fullHelpAppended = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
object OptsParseError {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a parse error containing the provided errors.
|
||||||
|
*/
|
||||||
|
def apply(error: String, errors: String*): OptsParseError =
|
||||||
|
OptsParseError(NonEmptyList.of(error, errors: _*))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a parse error containing the provided error and indicating that
|
||||||
|
* full help text should be appended to it.
|
||||||
|
*/
|
||||||
|
def requestingFullHelp(error: String): OptsParseError =
|
||||||
|
OptsParseError(NonEmptyList.one(error), fullHelpRequested = true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method that creates a parse error and wraps it in a [[Left]].
|
||||||
|
*/
|
||||||
|
def left[A](error: String): Either[OptsParseError, A] = Left(apply(error))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [[Semigroup]] instance for [[OptsParseError]] that allows to merge
|
||||||
|
* multiple errors into one.
|
||||||
|
*/
|
||||||
|
implicit val semigroup: Semigroup[OptsParseError] =
|
||||||
|
(x: OptsParseError, y: OptsParseError) => {
|
||||||
|
val fullHelpAppended = x.fullHelpAppended || y.fullHelpAppended
|
||||||
|
OptsParseError(
|
||||||
|
x.errors ++ y.errors.toList,
|
||||||
|
if (fullHelpAppended) false
|
||||||
|
else x.fullHelpRequested || y.fullHelpRequested,
|
||||||
|
fullHelpAppended
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syntax extensions that add helper functions to objects of type
|
||||||
|
* `Either[OptsParseError, A]` (parsing results).
|
||||||
|
*/
|
||||||
|
implicit class ParseErrorSyntax[A](val result: Either[OptsParseError, A]) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add errors from the specified list to the result.
|
||||||
|
*
|
||||||
|
* If the result was [[Left]], the errors are appended. If the result was
|
||||||
|
* [[Right]] and the list is non-empty, the modified result is [[Left]]
|
||||||
|
* containing the new errors.
|
||||||
|
*/
|
||||||
|
def addErrors(errors: List[String]): Either[OptsParseError, A] =
|
||||||
|
result match {
|
||||||
|
case Left(value) => Left(value.withErrors(errors))
|
||||||
|
case Right(value) =>
|
||||||
|
val nel = NonEmptyList.fromList(errors)
|
||||||
|
nel match {
|
||||||
|
case Some(errorsList) => Left(OptsParseError(errorsList))
|
||||||
|
case None => Right(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends the provided short help if it should be added.
|
||||||
|
*/
|
||||||
|
def appendShortHelp(help: => String): Either[OptsParseError, A] =
|
||||||
|
result.left.map { value =>
|
||||||
|
if (value.shouldAppendShortHelp) value.withShortHelp(help) else value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends the provided full help text if it was requested.
|
||||||
|
*/
|
||||||
|
def appendFullHelp(help: => String): Either[OptsParseError, A] =
|
||||||
|
result.left.map { value =>
|
||||||
|
if (value.fullHelpRequested) value.withFullHelp(help) else value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts to an [[Either]] containing just a list of errors on failure.
|
||||||
|
*
|
||||||
|
* Makes sure that all help requests have been handled.
|
||||||
|
*/
|
||||||
|
def toErrorList: Either[List[String], A] =
|
||||||
|
result match {
|
||||||
|
case Left(value) =>
|
||||||
|
if (value.shouldAppendShortHelp || value.fullHelpRequested)
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Internal error: Help was not handled."
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Left(value.errors.toList)
|
||||||
|
case Right(value) => Right(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges two parse results into one that returns the pair created from
|
||||||
|
* arguments' results on success.
|
||||||
|
*
|
||||||
|
* If any of the arguments is failed, the result is also a failure. If both
|
||||||
|
* are failed, their errors are merged.
|
||||||
|
*/
|
||||||
|
def product[A, B](
|
||||||
|
a: Either[OptsParseError, A],
|
||||||
|
b: Either[OptsParseError, B]
|
||||||
|
): Either[OptsParseError, (A, B)] =
|
||||||
|
(a, b) match {
|
||||||
|
case (Right(a), Right(b)) => Right((a, b))
|
||||||
|
case (Left(a), Left(b)) => Left(a |+| b)
|
||||||
|
case (Left(a), _) => Left(a)
|
||||||
|
case (_, Left(b)) => Left(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines two parse results ensuring that the old one was not set already.
|
||||||
|
*
|
||||||
|
* If any of the results is failed, the final result is also a failure. If
|
||||||
|
* both results are successful and the first one is empty, the second one is
|
||||||
|
* returned; if the first one is non-empty, the duplicate error is reported.
|
||||||
|
*
|
||||||
|
* This helper is used to implement parameters that should hold only one
|
||||||
|
* result.
|
||||||
|
*/
|
||||||
|
def combineWithoutDuplicates[A](
|
||||||
|
old: Either[OptsParseError, Option[A]],
|
||||||
|
newer: Either[OptsParseError, A],
|
||||||
|
duplicateErrorMessage: String
|
||||||
|
): Either[OptsParseError, Option[A]] =
|
||||||
|
(old, newer) match {
|
||||||
|
case (Left(oldErrors), Left(newErrors)) => Left(newErrors |+| oldErrors)
|
||||||
|
case (Right(None), Right(v)) => Right(Some(v))
|
||||||
|
case (Right(Some(_)), Right(_)) =>
|
||||||
|
Left(OptsParseError(duplicateErrorMessage))
|
||||||
|
case (Left(errors), Right(_)) => Left(errors)
|
||||||
|
case (Right(_), Left(errors)) => Left(errors)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
package org.enso.cli.arguments
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plugin manager that handles finding and running plugins.
|
||||||
|
*/
|
||||||
|
trait PluginManager {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to run a given plugin with provided arguments.
|
||||||
|
*
|
||||||
|
* It never returns - either it exits with the plugin's exit code or throws
|
||||||
|
* an exception if it was not possible to run the plugin.
|
||||||
|
*
|
||||||
|
* @param name name of the plugin
|
||||||
|
* @param args arguments that should be passed to it
|
||||||
|
*/
|
||||||
|
def runPlugin(name: String, args: Seq[String]): Nothing
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the plugin of the given `name` is available in the system.
|
||||||
|
*/
|
||||||
|
def hasPlugin(name: String): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists names of plugins found in the system.
|
||||||
|
*/
|
||||||
|
def pluginsNames(): Seq[String]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists names and short descriptions of plugins found on the system.
|
||||||
|
*/
|
||||||
|
def pluginsHelp(): Seq[CommandHelp]
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
package org.enso.cli.arguments
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the behaviour of parsing top-level application options.
|
||||||
|
*
|
||||||
|
* @tparam Config type of configuration that is passed to commands
|
||||||
|
*/
|
||||||
|
sealed trait TopLevelBehavior[+Config]
|
||||||
|
|
||||||
|
object TopLevelBehavior {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If top-level options return a value of this class, the application should
|
||||||
|
* continue execution. The provided value of `Config` should be passed to the
|
||||||
|
* executed commands.
|
||||||
|
*
|
||||||
|
* @param withConfig the configuration that is passed to commands
|
||||||
|
* @tparam Config type of configuration that is passed to commands
|
||||||
|
*/
|
||||||
|
case class Continue[Config](withConfig: Config)
|
||||||
|
extends TopLevelBehavior[Config]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If top-level options return a value of this class, it means that the
|
||||||
|
* top-level options have handled the execution and commands should not be
|
||||||
|
* parsed further. This can be useful to implement top-level options like
|
||||||
|
* `--version`.
|
||||||
|
*/
|
||||||
|
case object Halt extends TopLevelBehavior[Nothing]
|
||||||
|
}
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -0,0 +1,42 @@
|
|||||||
|
package org.enso.cli.internal
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies parser behaviour after parsing a plain token.
|
||||||
|
*/
|
||||||
|
sealed trait ParserContinuation
|
||||||
|
|
||||||
|
object ParserContinuation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies to continue parsing.
|
||||||
|
*
|
||||||
|
* This variant is returned most of the time in the usual parsing flow.
|
||||||
|
*/
|
||||||
|
case object ContinueNormally extends ParserContinuation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies to stop parsing and return partial results.
|
||||||
|
*
|
||||||
|
* This can be used to abstain from handling further errors. For example, if
|
||||||
|
* a command name is misspelled, any further parameters would be reported as
|
||||||
|
* unknown even though they are defined for the right command. To avoid such
|
||||||
|
* misleading situations, we stop parsing.
|
||||||
|
*/
|
||||||
|
case object Stop extends ParserContinuation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies that the parser should stop parsing immediately, gather the
|
||||||
|
* partial results and finish by returning a closure that will call the
|
||||||
|
* provided `continuation`.
|
||||||
|
*
|
||||||
|
* The continuation is given the sequence of remaining tokens and additional
|
||||||
|
* arguments and never returns.
|
||||||
|
*
|
||||||
|
* This special case is used to implement plugins - when a plugin invocation
|
||||||
|
* is detected, all further tokens should be handled by the plugin that will
|
||||||
|
* be invoked.
|
||||||
|
*/
|
||||||
|
case class Escape(
|
||||||
|
continuation: (Seq[Token], Seq[String]) => Nothing
|
||||||
|
) extends ParserContinuation
|
||||||
|
}
|
@ -1,100 +0,0 @@
|
|||||||
package org.enso.cli.internal
|
|
||||||
|
|
||||||
import cats.data.NonEmptyList
|
|
||||||
import org.enso.cli.{CLIOutput, Opts, Spelling, Subcommand}
|
|
||||||
|
|
||||||
class SubcommandOpt[A](subcommands: NonEmptyList[Subcommand[A]])
|
|
||||||
extends Opts[A] {
|
|
||||||
var selectedCommand: Option[Subcommand[A]] = None
|
|
||||||
var errors: List[String] = Nil
|
|
||||||
def addError(error: String): Unit = errors ::= error
|
|
||||||
|
|
||||||
override private[cli] def flags =
|
|
||||||
selectedCommand.map(_.opts.flags).getOrElse(Map.empty)
|
|
||||||
override private[cli] def parameters =
|
|
||||||
selectedCommand.map(_.opts.parameters).getOrElse(Map.empty)
|
|
||||||
override private[cli] def prefixedParameters =
|
|
||||||
selectedCommand.map(_.opts.prefixedParameters).getOrElse(Map.empty)
|
|
||||||
override private[cli] def gatherOptions =
|
|
||||||
selectedCommand.map(_.opts.gatherOptions).getOrElse(Seq())
|
|
||||||
override private[cli] def gatherPrefixedParameters =
|
|
||||||
selectedCommand.map(_.opts.gatherPrefixedParameters).getOrElse(Seq())
|
|
||||||
override private[cli] val usageOptions =
|
|
||||||
subcommands.toList.flatMap(_.opts.usageOptions).distinct
|
|
||||||
|
|
||||||
override private[cli] def wantsArgument() =
|
|
||||||
selectedCommand.map(_.opts.wantsArgument()).getOrElse(true)
|
|
||||||
|
|
||||||
override private[cli] def consumeArgument(arg: String): Unit =
|
|
||||||
selectedCommand match {
|
|
||||||
case Some(command) => command.opts.consumeArgument(arg)
|
|
||||||
case None =>
|
|
||||||
subcommands.find(_.name == arg) match {
|
|
||||||
case Some(command) =>
|
|
||||||
selectedCommand = Some(command)
|
|
||||||
case None =>
|
|
||||||
val similar =
|
|
||||||
Spelling
|
|
||||||
.selectClosestMatches(arg, subcommands.toList.map(_.name))
|
|
||||||
val suggestions =
|
|
||||||
if (similar.isEmpty)
|
|
||||||
"\n\nPossible subcommands are\n" +
|
|
||||||
subcommands.toList
|
|
||||||
.map(CLIOutput.indent + _.name + "\n")
|
|
||||||
.mkString
|
|
||||||
else
|
|
||||||
"\n\nThe most similar subcommands are\n" +
|
|
||||||
similar.map(CLIOutput.indent + _ + "\n").mkString
|
|
||||||
addError(s"`$arg` is not a valid subcommand." + suggestions)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override private[cli] def requiredArguments =
|
|
||||||
selectedCommand.map(_.opts.requiredArguments).getOrElse(Seq())
|
|
||||||
override private[cli] def optionalArguments =
|
|
||||||
selectedCommand.map(_.opts.optionalArguments).getOrElse(Seq())
|
|
||||||
override private[cli] def trailingArguments =
|
|
||||||
selectedCommand.flatMap(_.opts.trailingArguments)
|
|
||||||
override private[cli] def additionalArguments =
|
|
||||||
selectedCommand.flatMap(_.opts.additionalArguments)
|
|
||||||
|
|
||||||
override private[cli] def reset(): Unit = {
|
|
||||||
subcommands.map(_.opts.reset())
|
|
||||||
selectedCommand = None
|
|
||||||
errors = Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
override private[cli] def result(commandPrefix: Seq[String]) =
|
|
||||||
if (errors.nonEmpty)
|
|
||||||
Left(errors.reverse)
|
|
||||||
else
|
|
||||||
selectedCommand match {
|
|
||||||
case Some(command) => command.opts.result(commandPrefix)
|
|
||||||
case None =>
|
|
||||||
Left(List("Expected a subcommand.", help(commandPrefix)))
|
|
||||||
}
|
|
||||||
|
|
||||||
override def availableOptionsHelp(): Seq[String] =
|
|
||||||
subcommands.toList.flatMap(_.opts.availableOptionsHelp()).distinct
|
|
||||||
|
|
||||||
override def availablePrefixedParametersHelp(): Seq[String] =
|
|
||||||
subcommands.toList
|
|
||||||
.flatMap(_.opts.availablePrefixedParametersHelp())
|
|
||||||
.distinct
|
|
||||||
|
|
||||||
override def additionalHelp(): Seq[String] =
|
|
||||||
subcommands.toList.flatMap(_.opts.additionalHelp()).distinct
|
|
||||||
|
|
||||||
override def commandLines(): NonEmptyList[String] = {
|
|
||||||
def prefixedCommandLines(command: Subcommand[_]): NonEmptyList[String] = {
|
|
||||||
val prefix = command.name + " "
|
|
||||||
val suffix = s"\n\t${command.comment}"
|
|
||||||
command.opts
|
|
||||||
.commandLines()
|
|
||||||
.map(commandLine => prefix + commandLine + suffix)
|
|
||||||
}
|
|
||||||
|
|
||||||
subcommands.flatMap(prefixedCommandLines)
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,45 @@
|
|||||||
|
package org.enso.cli.internal
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A token used in the parser.
|
||||||
|
*/
|
||||||
|
sealed trait Token {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The original value that this token has been created from.
|
||||||
|
*
|
||||||
|
* Used to reverse the tokenization process.
|
||||||
|
*/
|
||||||
|
def originalValue: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain token, usually treated as an argument or value for a parameter that is
|
||||||
|
* preceding it.
|
||||||
|
*/
|
||||||
|
case class PlainToken(override val originalValue: String) extends Token
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A token representing a parameter or a flag.
|
||||||
|
*
|
||||||
|
* It has form `--abc`.
|
||||||
|
*/
|
||||||
|
case class ParameterOrFlag(parameter: String)(
|
||||||
|
override val originalValue: String
|
||||||
|
) extends Token
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A token representing a mistyped parameter, i.e. `-abc`.
|
||||||
|
*
|
||||||
|
* Used for better error handling.
|
||||||
|
*/
|
||||||
|
case class MistypedParameter(parameter: String)(
|
||||||
|
override val originalValue: String
|
||||||
|
) extends Token
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A token representing a parameter with a value, i.e. `--key=value`.
|
||||||
|
*/
|
||||||
|
case class ParameterWithValue(parameter: String, value: String)(
|
||||||
|
override val originalValue: String
|
||||||
|
) extends Token
|
@ -0,0 +1,50 @@
|
|||||||
|
package org.enso.cli.internal
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mutable stream of tokens.
|
||||||
|
* @param initialTokens initial sequence of tokens
|
||||||
|
* @param errorReporter a function used for reporting errors
|
||||||
|
*/
|
||||||
|
class TokenStream(initialTokens: Seq[Token], errorReporter: String => Unit) {
|
||||||
|
var tokens: List[Token] = initialTokens.toList
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if there are more tokens available.
|
||||||
|
*/
|
||||||
|
def hasTokens: Boolean = tokens.nonEmpty
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the next token. Cannot be called if [[hasTokens]] is false.
|
||||||
|
*/
|
||||||
|
def consumeToken(): Token = {
|
||||||
|
val token = tokens.head
|
||||||
|
tokens = tokens.tail
|
||||||
|
token
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the next token, but does not remove it from the stream yet. Cannot
|
||||||
|
* be called if [[hasTokens]] is false.
|
||||||
|
*/
|
||||||
|
def peekToken(): Token = tokens.head
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the next available token is an argument, returns it. Otherwise returns
|
||||||
|
* None and reports a specified error message.
|
||||||
|
*/
|
||||||
|
def tryConsumeArgument(errorMessage: String): Option[String] = {
|
||||||
|
tokens.headOption match {
|
||||||
|
case Some(PlainToken(arg)) =>
|
||||||
|
tokens = tokens.tail
|
||||||
|
Some(arg)
|
||||||
|
case _ =>
|
||||||
|
errorReporter(errorMessage)
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a sequence of remaining tokens.
|
||||||
|
*/
|
||||||
|
def remaining(): Seq[Token] = tokens
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package org.enso.cli.internal
|
package org.enso.cli.internal.opts
|
||||||
|
|
||||||
class AdditionalArguments(helpComment: String) extends BaseOpts[Seq[String]] {
|
class AdditionalArguments(helpComment: String) extends BaseOpts[Seq[String]] {
|
||||||
var value: Seq[String] = Seq()
|
var value: Seq[String] = Seq()
|
@ -1,6 +1,7 @@
|
|||||||
package org.enso.cli.internal
|
package org.enso.cli.internal.opts
|
||||||
|
|
||||||
import org.enso.cli.Opts
|
import org.enso.cli.arguments.Opts
|
||||||
|
import org.enso.cli.internal.ParserContinuation
|
||||||
|
|
||||||
abstract class BaseOpts[A] extends Opts[A] {
|
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."
|
@ -0,0 +1,154 @@
|
|||||||
|
package org.enso.cli.internal.opts
|
||||||
|
|
||||||
|
import cats.data.NonEmptyList
|
||||||
|
import org.enso.cli.arguments.{Command, Opts}
|
||||||
|
import org.enso.cli.internal.ParserContinuation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements common logic for options that can take a command and modify their
|
||||||
|
* further parsing behavior based on that command.
|
||||||
|
* @tparam A returned type
|
||||||
|
* @tparam B type returned by the commands
|
||||||
|
*/
|
||||||
|
trait BaseSubcommandOpt[A, B] extends Opts[A] {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all available commands.
|
||||||
|
*/
|
||||||
|
def availableSubcommands: NonEmptyList[Command[B]]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles an unknown command.
|
||||||
|
*
|
||||||
|
* Executed when a command is given that does not match any of
|
||||||
|
* [[availableSubcommands]].
|
||||||
|
*/
|
||||||
|
def handleUnknownCommand(command: String): ParserContinuation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The command that has been selected, if any.
|
||||||
|
*
|
||||||
|
* If no command was selected, is set to None.
|
||||||
|
*/
|
||||||
|
var selectedCommand: Option[Command[B]] = None
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of errors reported when parsing this set of options.
|
||||||
|
*/
|
||||||
|
var errors: List[String] = Nil
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an error that will be reported when getting the result.
|
||||||
|
*/
|
||||||
|
def addError(error: String): Unit = errors ::= error
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends a command prefix from the call with the currently selected command
|
||||||
|
* (if a command is selected).
|
||||||
|
*/
|
||||||
|
def extendPrefix(commandPrefix: Seq[String]): Seq[String] =
|
||||||
|
commandPrefix ++ selectedCommand.map(_.name)
|
||||||
|
|
||||||
|
override private[cli] def flags =
|
||||||
|
selectedCommand.map(_.opts.flags).getOrElse(Map.empty)
|
||||||
|
override private[cli] def parameters =
|
||||||
|
selectedCommand.map(_.opts.parameters).getOrElse(Map.empty)
|
||||||
|
override private[cli] def prefixedParameters =
|
||||||
|
selectedCommand.map(_.opts.prefixedParameters).getOrElse(Map.empty)
|
||||||
|
override private[cli] def gatherOptions =
|
||||||
|
selectedCommand.map(_.opts.gatherOptions).getOrElse(Seq())
|
||||||
|
override private[cli] def gatherPrefixedParameters =
|
||||||
|
selectedCommand.map(_.opts.gatherPrefixedParameters).getOrElse(Seq())
|
||||||
|
override private[cli] def usageOptions =
|
||||||
|
availableSubcommands.toList.flatMap(_.opts.usageOptions).distinct
|
||||||
|
|
||||||
|
override private[cli] def wantsArgument() =
|
||||||
|
selectedCommand.map(_.opts.wantsArgument()).getOrElse(true)
|
||||||
|
|
||||||
|
override private[cli] def consumeArgument(
|
||||||
|
arg: String,
|
||||||
|
commandPrefix: Seq[String]
|
||||||
|
): ParserContinuation = {
|
||||||
|
val prefix = extendPrefix(commandPrefix)
|
||||||
|
selectedCommand match {
|
||||||
|
case Some(command) =>
|
||||||
|
command.opts.consumeArgument(arg, prefix)
|
||||||
|
case None =>
|
||||||
|
availableSubcommands.find(_.name == arg) match {
|
||||||
|
case Some(command) =>
|
||||||
|
selectedCommand = Some(command)
|
||||||
|
ParserContinuation.ContinueNormally
|
||||||
|
case None =>
|
||||||
|
Command.formatRelated(
|
||||||
|
arg,
|
||||||
|
prefix,
|
||||||
|
availableSubcommands.toList
|
||||||
|
) match {
|
||||||
|
case Some(relatedMessage) =>
|
||||||
|
addError(relatedMessage)
|
||||||
|
ParserContinuation.Stop
|
||||||
|
case None =>
|
||||||
|
handleUnknownCommand(arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override private[cli] def requiredArguments =
|
||||||
|
selectedCommand.map(_.opts.requiredArguments).getOrElse(Seq())
|
||||||
|
override private[cli] def optionalArguments =
|
||||||
|
selectedCommand.map(_.opts.optionalArguments).getOrElse(Seq())
|
||||||
|
override private[cli] def trailingArguments =
|
||||||
|
selectedCommand.flatMap(_.opts.trailingArguments)
|
||||||
|
override private[cli] def additionalArguments =
|
||||||
|
selectedCommand.flatMap(_.opts.additionalArguments)
|
||||||
|
|
||||||
|
override private[cli] def reset(): Unit = {
|
||||||
|
availableSubcommands.map(_.opts.reset())
|
||||||
|
selectedCommand = None
|
||||||
|
errors = Nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
override def availableOptionsHelp(): Seq[String] =
|
||||||
|
availableSubcommands.toList.flatMap(_.opts.availableOptionsHelp()).distinct
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
override def availablePrefixedParametersHelp(): Seq[String] =
|
||||||
|
availableSubcommands.toList
|
||||||
|
.flatMap(_.opts.availablePrefixedParametersHelp())
|
||||||
|
.distinct
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
override def additionalHelp(): Seq[String] =
|
||||||
|
availableSubcommands.toList.flatMap(_.opts.additionalHelp()).distinct
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
override def commandLines(
|
||||||
|
alwaysIncludeOtherOptions: Boolean = false
|
||||||
|
): NonEmptyList[String] = {
|
||||||
|
def prefixedCommandLines(command: Command[_]): NonEmptyList[String] = {
|
||||||
|
val prefix = command.name + " "
|
||||||
|
val suffix = s"\n\t${command.comment}"
|
||||||
|
command.opts
|
||||||
|
.commandLines(alwaysIncludeOtherOptions)
|
||||||
|
.map(commandLine => prefix + commandLine + suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
availableSubcommands.flatMap(prefixedCommandLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
override def shortHelp(commandPrefix: Seq[String]): String =
|
||||||
|
super.shortHelp(extendPrefix(commandPrefix))
|
||||||
|
}
|
@ -1,4 +1,6 @@
|
|||||||
package org.enso.cli.internal
|
package org.enso.cli.internal.opts
|
||||||
|
|
||||||
|
import org.enso.cli.arguments.OptsParseError
|
||||||
|
|
||||||
class Flag(
|
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)
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -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()
|
@ -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`."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -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 = {
|
@ -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)
|
||||||
}
|
}
|
@ -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 {
|
@ -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()
|
@ -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] = {
|
@ -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
|
@ -1,4 +1,4 @@
|
|||||||
package org.enso.cli.internal
|
package org.enso.cli.internal.opts
|
||||||
|
|
||||||
class PrefixedParameters(
|
class PrefixedParameters(
|
||||||
prefix: String,
|
prefix: String,
|
@ -0,0 +1,59 @@
|
|||||||
|
package org.enso.cli.internal.opts
|
||||||
|
|
||||||
|
import cats.data.NonEmptyList
|
||||||
|
import org.enso.cli.arguments.{Command, OptsParseError}
|
||||||
|
import org.enso.cli.internal.ParserContinuation
|
||||||
|
import org.enso.cli.{CLIOutput, Spelling}
|
||||||
|
|
||||||
|
class SubcommandOpt[A](subcommands: NonEmptyList[Command[A]])
|
||||||
|
extends BaseSubcommandOpt[A, A] {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
override def availableSubcommands: NonEmptyList[Command[A]] = subcommands
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A flag that can be set to indicate that a command was provided, but it was
|
||||||
|
* invalid.
|
||||||
|
*
|
||||||
|
* It is used for error reporting to differentiate invalid commands from
|
||||||
|
* missing commands.
|
||||||
|
*/
|
||||||
|
private var commandProvidedButInvalid: Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
override def handleUnknownCommand(command: String): ParserContinuation = {
|
||||||
|
val similar =
|
||||||
|
Spelling
|
||||||
|
.selectClosestMatches(command, subcommands.toList.map(_.name))
|
||||||
|
val suggestions =
|
||||||
|
if (similar.isEmpty)
|
||||||
|
"\n\nAvailable subcommands are\n" +
|
||||||
|
subcommands.toList
|
||||||
|
.map(CLIOutput.indent + _.name + "\n")
|
||||||
|
.mkString
|
||||||
|
else
|
||||||
|
"\n\nThe most similar subcommands are\n" +
|
||||||
|
similar.map(CLIOutput.indent + _ + "\n").mkString
|
||||||
|
addError(s"`$command` is not a valid subcommand." + suggestions)
|
||||||
|
commandProvidedButInvalid = true
|
||||||
|
ParserContinuation.Stop
|
||||||
|
}
|
||||||
|
|
||||||
|
override private[cli] def result(commandPrefix: Seq[String]) = {
|
||||||
|
val prefix = extendPrefix(commandPrefix)
|
||||||
|
selectedCommand match {
|
||||||
|
case Some(command) =>
|
||||||
|
command.opts.result(prefix).addErrors(errors.reverse)
|
||||||
|
case None =>
|
||||||
|
if (commandProvidedButInvalid)
|
||||||
|
Left(OptsParseError(NonEmptyList.fromListUnsafe(errors.reverse)))
|
||||||
|
else
|
||||||
|
Left(OptsParseError.requestingFullHelp("Expected a subcommand."))
|
||||||
|
.addErrors(errors.reverse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,247 @@
|
|||||||
|
package org.enso.cli.internal.opts
|
||||||
|
|
||||||
|
import cats.Semigroupal
|
||||||
|
import cats.data.NonEmptyList
|
||||||
|
import org.enso.cli._
|
||||||
|
import org.enso.cli.arguments.Opts.implicits._
|
||||||
|
import org.enso.cli.arguments._
|
||||||
|
import org.enso.cli.internal.{Parser, ParserContinuation}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements the entry point of options parsing for an [[Application]].
|
||||||
|
*
|
||||||
|
* @param toplevelOpts top-level options that define a global configuration
|
||||||
|
* and can override commands
|
||||||
|
* @param commands list of commands
|
||||||
|
* @param pluginManager optional plugin manager that can handle unknown
|
||||||
|
* commands by launching plugins available on system path
|
||||||
|
* @param helpHeader header added to the top-level help message
|
||||||
|
* @tparam A type returned by top-level options
|
||||||
|
* @tparam B type of the config that the commands use
|
||||||
|
*/
|
||||||
|
class TopLevelCommandsOpt[A, B](
|
||||||
|
toplevelOpts: Opts[A],
|
||||||
|
commands: NonEmptyList[Command[B => Unit]],
|
||||||
|
pluginManager: Option[PluginManager],
|
||||||
|
helpHeader: String
|
||||||
|
) extends BaseSubcommandOpt[(A, Option[B => Unit]), B => Unit] {
|
||||||
|
|
||||||
|
private def helpOpt: Opts[Boolean] =
|
||||||
|
Opts.flag("help", 'h', "Print this help message.", showInUsage = true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level options extended with an option for requesting help.
|
||||||
|
*/
|
||||||
|
private val toplevelWithHelp =
|
||||||
|
implicitly[Semigroupal[Opts]].product(toplevelOpts, helpOpt)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
override def availableSubcommands: NonEmptyList[Command[B => Unit]] = commands
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles an unknown command.
|
||||||
|
*
|
||||||
|
* First tries to find a plugin with the given name and if it finds it,
|
||||||
|
* parsing is stopped to invoke that plugin. Otherwise it just reports an
|
||||||
|
* error with suggestions of similar command names, if any.
|
||||||
|
*/
|
||||||
|
override def handleUnknownCommand(command: String): ParserContinuation = {
|
||||||
|
val pluginAvailable = pluginManager.exists(_.hasPlugin(command))
|
||||||
|
if (pluginAvailable) {
|
||||||
|
ParserContinuation.Escape((remainingTokens, additionalArguments) =>
|
||||||
|
pluginManager.get.runPlugin(
|
||||||
|
command,
|
||||||
|
Parser.untokenize(remainingTokens) ++ additionalArguments
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
addError(commandSuggestions(command))
|
||||||
|
ParserContinuation.Stop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override private[cli] def result(
|
||||||
|
commandPrefix: Seq[String]
|
||||||
|
): Either[OptsParseError, (A, Option[B => Unit])] = {
|
||||||
|
val prefix = extendPrefix(commandPrefix)
|
||||||
|
val topLevelResultWithHelp = toplevelWithHelp.result(prefix)
|
||||||
|
val topLevelResult = topLevelResultWithHelp.map(_._1)
|
||||||
|
val commandResult = selectedCommand.map(_.opts.result(prefix))
|
||||||
|
val result = (topLevelResultWithHelp, commandResult) match {
|
||||||
|
case (Right((result, true)), _) =>
|
||||||
|
def displayHelp(): Unit = {
|
||||||
|
val helpText = help(commandPrefix)
|
||||||
|
CLIOutput.println(helpText)
|
||||||
|
}
|
||||||
|
|
||||||
|
Right((result, Some((_: B) => displayHelp())))
|
||||||
|
case (_, Some(value)) =>
|
||||||
|
OptsParseError.product(topLevelResult, value.map(Some(_)))
|
||||||
|
case (_, None) =>
|
||||||
|
topLevelResult.map((_, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
result.addErrors(errors.reverse)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the help text, depending on if a command has been selected or not.
|
||||||
|
*
|
||||||
|
* If no commands were selected, renders the top-level help text. Otherwise,
|
||||||
|
* renders the help text for the selected command.
|
||||||
|
*/
|
||||||
|
override def help(commandPrefix: Seq[String]): String =
|
||||||
|
selectedCommand match {
|
||||||
|
case Some(command) =>
|
||||||
|
commandHelp(command, commandPrefix)
|
||||||
|
case None =>
|
||||||
|
topLevelHelp(commandPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a help text for an unknown command, including, if available,
|
||||||
|
* suggestions of similar commands.
|
||||||
|
*
|
||||||
|
* @param typo the unrecognized command name
|
||||||
|
*/
|
||||||
|
def commandSuggestions(typo: String): String = {
|
||||||
|
val header = s"`$typo` is not a valid command."
|
||||||
|
val plugins = pluginManager.map(_.pluginsNames()).getOrElse(Seq())
|
||||||
|
val possibleCommands = availableSubcommands.toList.map(_.name) ++ plugins
|
||||||
|
val similar = Spelling.selectClosestMatches(
|
||||||
|
typo,
|
||||||
|
possibleCommands
|
||||||
|
)
|
||||||
|
val suggestions =
|
||||||
|
if (similar.isEmpty) "\n\n" + availableCommands()
|
||||||
|
else {
|
||||||
|
"\n\nThe most similar commands are\n" +
|
||||||
|
similar.map(CLIOutput.indent + _ + "\n").mkString
|
||||||
|
}
|
||||||
|
|
||||||
|
header + suggestions
|
||||||
|
}
|
||||||
|
|
||||||
|
override private[cli] def flags =
|
||||||
|
super.flags ++ toplevelWithHelp.flags
|
||||||
|
override private[cli] def parameters =
|
||||||
|
super.parameters ++ toplevelWithHelp.parameters
|
||||||
|
override private[cli] def prefixedParameters =
|
||||||
|
super.prefixedParameters ++ toplevelWithHelp.prefixedParameters
|
||||||
|
override private[cli] def gatherOptions =
|
||||||
|
super.gatherOptions ++ toplevelWithHelp.gatherOptions
|
||||||
|
override private[cli] def gatherPrefixedParameters =
|
||||||
|
super.gatherPrefixedParameters ++ toplevelWithHelp.gatherPrefixedParameters
|
||||||
|
override private[cli] def usageOptions =
|
||||||
|
super.usageOptions ++ toplevelWithHelp.usageOptions
|
||||||
|
|
||||||
|
// Note [Arguments in Top-Level Options]
|
||||||
|
private def validateNoArguments(): Unit = {
|
||||||
|
val topLevelHasArguments =
|
||||||
|
toplevelWithHelp.requiredArguments.nonEmpty ||
|
||||||
|
toplevelWithHelp.optionalArguments.nonEmpty ||
|
||||||
|
toplevelWithHelp.trailingArguments.nonEmpty ||
|
||||||
|
toplevelWithHelp.additionalArguments.nonEmpty
|
||||||
|
if (topLevelHasArguments) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Internal error: " +
|
||||||
|
"The top level options are not allowed to take arguments."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validateNoArguments()
|
||||||
|
|
||||||
|
override private[cli] def reset(): Unit = {
|
||||||
|
super.reset()
|
||||||
|
toplevelWithHelp.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
override def availableOptionsHelp(): Seq[String] =
|
||||||
|
super.availableOptionsHelp() ++ toplevelWithHelp.availableOptionsHelp()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
override def availablePrefixedParametersHelp(): Seq[String] =
|
||||||
|
super.availablePrefixedParametersHelp() ++
|
||||||
|
toplevelWithHelp.availablePrefixedParametersHelp()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
override def additionalHelp(): Seq[String] =
|
||||||
|
super.additionalHelp() ++ toplevelWithHelp.additionalHelp()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
override def commandLines(
|
||||||
|
alwaysIncludeOtherOptions: Boolean = false
|
||||||
|
): NonEmptyList[String] = {
|
||||||
|
val include =
|
||||||
|
alwaysIncludeOtherOptions || toplevelWithHelp.gatherOptions.nonEmpty
|
||||||
|
super.commandLines(alwaysIncludeOtherOptions = include)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders help text for the specific command.
|
||||||
|
*/
|
||||||
|
def commandHelp(command: Command[_], commandPrefix: Seq[String]): String = {
|
||||||
|
val applicationName = commandPrefix.head
|
||||||
|
val mergedOpts =
|
||||||
|
implicitly[Semigroupal[Opts]].product(command.opts, toplevelWithHelp)
|
||||||
|
command.comment + "\n" + mergedOpts.help(Seq(applicationName, command.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the part of the top-level help text listing the available
|
||||||
|
* commands.
|
||||||
|
*/
|
||||||
|
def availableCommands(): String = {
|
||||||
|
val pluginsHelp = pluginManager.map(_.pluginsHelp()).getOrElse(Seq())
|
||||||
|
val subCommands = commands.toList.map(_.topLevelHelp) ++ pluginsHelp
|
||||||
|
val commandDescriptions =
|
||||||
|
subCommands.map(_.toString).map(CLIOutput.indent + _ + "\n").mkString
|
||||||
|
"Available commands:\n" + commandDescriptions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders top-level help.
|
||||||
|
*/
|
||||||
|
def topLevelHelp(commandPrefix: Seq[String]): String = {
|
||||||
|
val usageOptions = toplevelWithHelp
|
||||||
|
.commandLineOptions(alwaysIncludeOtherOptions = false)
|
||||||
|
.stripLeading()
|
||||||
|
val commandName = commandPrefix.head
|
||||||
|
val usage =
|
||||||
|
s"Usage: $commandName\t$usageOptions COMMAND [ARGS...]\n"
|
||||||
|
|
||||||
|
val topLevelOptionsHelp =
|
||||||
|
toplevelWithHelp.helpExplanations()
|
||||||
|
|
||||||
|
val sb = new StringBuilder
|
||||||
|
sb.append(helpHeader + "\n")
|
||||||
|
sb.append(usage)
|
||||||
|
sb.append("\n" + availableCommands())
|
||||||
|
sb.append(topLevelOptionsHelp)
|
||||||
|
sb.append(
|
||||||
|
s"\nFor more information on a specific command listed above," +
|
||||||
|
s" please run `$commandName COMMAND --help`."
|
||||||
|
)
|
||||||
|
|
||||||
|
sb.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Note [Arguments in Top-Level Options]
|
||||||
|
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
* We do not override the functions handling arguments, because we just need to
|
||||||
|
* handle arguments of the subcommand. By definition, the top level options
|
||||||
|
* cannot include arguments.
|
||||||
|
*/
|
@ -1,23 +1,28 @@
|
|||||||
package org.enso.cli.internal
|
package org.enso.cli.internal.opts
|
||||||
|
|
||||||
import org.enso.cli.Argument
|
import org.enso.cli.arguments.{Argument, OptsParseError}
|
||||||
|
import org.enso.cli.internal.ParserContinuation
|
||||||
|
|
||||||
class TrailingArguments[A: Argument](
|
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 = {
|
@ -1,16 +0,0 @@
|
|||||||
package org.enso.cli
|
|
||||||
|
|
||||||
package object internal {
|
|
||||||
def combineWithoutDuplicates[A](
|
|
||||||
old: Either[List[String], Option[A]],
|
|
||||||
newer: Either[List[String], A],
|
|
||||||
duplicateErrorMessage: String
|
|
||||||
): Either[List[String], Option[A]] =
|
|
||||||
(old, newer) match {
|
|
||||||
case (Left(oldErrors), Left(newErrors)) => Left(newErrors ++ oldErrors)
|
|
||||||
case (Right(None), Right(v)) => Right(Some(v))
|
|
||||||
case (Right(Some(_)), Right(_)) => Left(List(duplicateErrorMessage))
|
|
||||||
case (Left(errors), Right(_)) => Left(errors)
|
|
||||||
case (Right(_), Left(errors)) => Left(errors)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,7 +1,16 @@
|
|||||||
package org.enso.cli
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user