Run components through the launcher (#1073)

This commit is contained in:
Radosław Waśko 2020-08-19 14:24:31 +02:00 committed by GitHub
parent 6da3b9252f
commit c979938527
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
102 changed files with 2384 additions and 457 deletions

View File

@ -105,7 +105,7 @@ jobs:
shell: bash
run: |
chmod +x enso
DIST_VERSION=$(./enso version --json | jq -r '.version')
DIST_VERSION=$(./enso version --json --only-launcher | jq -r '.version')
echo ::set-env name=DIST_VERSION::$DIST_VERSION
- name: Prepare Distribution Version (Windows)
@ -113,7 +113,7 @@ jobs:
if: runner.os == 'Windows'
shell: bash
run: |
DIST_VERSION=$(./enso.exe version --json | jq -r '.version')
DIST_VERSION=$(./enso.exe version --json --only-launcher | jq -r '.version')
echo ::set-env name=DIST_VERSION::$DIST_VERSION
# Currently the only architecture supported by Github runners is amd64

View File

@ -201,18 +201,23 @@ jobs:
run: sbt -no-colors syntaxJS/fullOptJS
# Prepare distributions
# The version used in filenames is based on the version of the launcher.
# Currently launcher and engine versions are tied to each other so they
# can be used interchangeably like this. If in the future the versions
# become independent, this may require updating to use proper versions
# for each component.
- name: Prepare Distribution Version (Unix)
if: runner.os != 'Windows'
shell: bash
run: |
DIST_VERSION=$(./enso version --json | jq -r '.version')
DIST_VERSION=$(./enso version --json --only-launcher | jq -r '.version')
echo ::set-env name=DIST_VERSION::$DIST_VERSION
- name: Prepare Distribution Version (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
DIST_VERSION=$(./enso.exe version --json | jq -r '.version')
DIST_VERSION=$(./enso.exe version --json --only-launcher | jq -r '.version')
echo ::set-env name=DIST_VERSION::$DIST_VERSION
# Currently the only architecture supported by Github runners is amd64

View File

@ -485,7 +485,8 @@ lazy val pkg = (project in file("lib/scala/pkg"))
mainClass in (Compile, run) := Some("org.enso.pkg.Main"),
version := "0.1",
libraryDependencies ++= circe ++ Seq(
"io.circe" %% "circe-yaml" % circeYamlVersion, // separate from other circe deps because its independent project with its own versioning
"nl.gn0s1s" %% "bump" % bumpVersion,
"io.circe" %% "circe-yaml" % circeYamlVersion, // separate from other circe deps because its independent project with its own versionin
"commons-io" % "commons-io" % commonsIoVersion
)
)

View File

@ -1 +1,9 @@
minimum-launcher-version: 0.0.1
jvm-options:
- value: "-Dpolyglot.engine.IterativePartialEscape=true"
- value: "-Dtruffle.class.path.append=$enginePackagePath\\component\\runtime.jar"
os: "windows"
- value: "-Dtruffle.class.path.append=$enginePackagePath/component/runtime.jar"
os: "linux"
- value: "-Dtruffle.class.path.append=$enginePackagePath/component/runtime.jar"
os: "macos"

View File

@ -1,5 +1,6 @@
license: APLv2
name: Base
enso-version: default
version: "0.0.1"
author: "Enso Team <contact@enso.org>"
maintainer: "Enso Team <contact@enso.org>"

View File

@ -259,13 +259,22 @@ command.
Tells the launcher to use the default JVM (based on `JAVA_HOME`) instead of the
managed one. Will not work if the set-up JVM version is not GraalVM.
## JVM Options
## Options From Newer Versions
For commands that launch an Enso component inside a JVM (`repl`, `run` and
`language-server`), parameters that the launcher does not know about (for
example introduced in versions of Enso newer than the launcher knows about) may
be passed after a double dash (`--`), i.e. `enso repl -- --someUnknownFlag`.
## JVM Options
If an environment variable `ENSO_JVM_OPTS` is defined, JVM options defined there
are passed to the launcher JVM.
> Note: Currently the `ENSO_JVM_OPTS` are parsed by splitting on the space
> character, so individual options listed in this environment variable should
> not contain spaces or they may be interpreted incorrectly.
Moreover, it is possible to pass parameters to the JVM that is used to launch
these components, which may be helpful with debugging. A parameter of the form
`--jvm.argumentName=argumentValue` will be passed to the JVM as

View File

@ -92,7 +92,7 @@ configuration based on user's config.
### Per-Project Enso Version
Project configuration can specify the exact Enso version that should be used
Project configuration must specify the exact Enso version that should be used
inside that project. The launcher automatically detects if it is in a project
(by traversing the directory structure). The current project can also be
specified by the `--path` parameter. All components launched inside a project

View File

@ -95,6 +95,7 @@ The following is an example of this manifest file.
license: MIT
name: My_Package
version: 1.0.1
enso-version: 0.1.0
author: "John Doe <john.doe@example.com>"
maintainer: "Jane Doe <jane.doe@example.com>"
resolver: lts-1.2.0
@ -117,6 +118,13 @@ fields cannot be published.
package. Defaults to `None`, meaning the package is not safe for use by third
parties.
#### enso-version
**Optional (required for publishing)** _String_: Specifies the Enso version that
should be used for this project. If not set or set to `default`, the default
locally installed Enso version will be used. The version should not be `default`
if the package is to be published.
#### version
**Optional (required for publishing)** _String_: The

View File

@ -105,12 +105,31 @@ root of an Enso version package. It has at least the following fields:
- `graal-java-version` - as GraalVM versions may have different variants for
different Java versions, this specifies which variant to use.
It can also contain the following additional fields:
- `jvm-options` - specifies a list of options that should be passed to the JVM
running the engine. These options can be used to fine-tune version specific
optimization settings etc. Each option must have a key called `value` which
specifies what option should be passed. That value can include a variable
`$enginePackagePath` which is substituted with the absolute path to the root
of the engine package that is being launched. Optionally, the option may
define `os` which will restrict this option only to the provided operating
system. Possible `os` values are `linux`, `macos` and `windows`.
For example:
```yaml
minimum-launcher-version: 0.0.1
graal-vm-version: 20.1.0
graal-java-version: 11
jvm-options:
- value: "-Dpolyglot.engine.IterativePartialEscape=true"
- value: "-Dtruffle.class.path.append=$enginePackagePath\\component\\runtime.jar"
os: "windows"
- value: "-Dtruffle.class.path.append=$enginePackagePath/component/runtime.jar"
os: "linux"
- value: "-Dtruffle.class.path.append=$enginePackagePath/component/runtime.jar"
os: "macos"
```
The `minimum-launcher-version` should be updated whenever a new version of Enso

View File

@ -118,7 +118,7 @@ object DirectoriesConfig {
*
* @param contentRoots a mapping between content root id and absolute path to
* the content root
* @param fileManager the file manater config
* @param fileManager the file manager config
* @param pathWatcher the path watcher config
* @param executionContext the executionContext config
* @param directories the configuration of internal directories

View File

@ -1,15 +1,43 @@
package org.enso.launcher
import nl.gn0s1s.bump.SemVer
import org.enso.launcher.components.ComponentsManager
/**
* Manages the global configuration of the distribution which includes the
* default engine version and default project metadata used for new projects.
*
* TODO [RW] This is a stub. It will be implemented in #977
*/
class GlobalConfigurationManager(componentsManager: ComponentsManager) {
/**
* Returns the default Enso version that should be used when running Enso
* outside a project and when creating a new project.
*
* The default can be set by `enso default <version>` (TODO [RW] #977). If
* the default is not set, the latest installed version is used. If no
* versions are installed, the release provider is queried for the latest
* available version.
*/
def defaultVersion: SemVer = {
val latestInstalled =
componentsManager.listInstalledEngines().map(_.version).sorted.lastOption
latestInstalled.getOrElse {
val latestAvailable = componentsManager.fetchLatestEngineVersion()
Logger.warn(
s"No Enso versions installed, defaulting to the latest available " +
s"release: $latestAvailable."
)
latestAvailable
}
}
}
object GlobalConfigurationManager {
/**
* Name of the main global configuration file.
*/
def globalConfigName: String = "global-config.yml"
val globalConfigName: String = "global-config.yml"
}

View File

@ -2,13 +2,19 @@ package org.enso.launcher
import java.nio.file.Path
import org.enso.launcher.installation.DistributionManager
import org.enso.pkg.PackageManager
import org.enso.version.{VersionDescription, VersionDescriptionParameter}
import buildinfo.Info
import nl.gn0s1s.bump.SemVer
import org.enso.launcher.cli.GlobalCLIOptions
import org.enso.launcher.components.DefaultComponentsManager
import org.enso.launcher.components.runner.{
JVMSettings,
LanguageServerOptions,
Runner,
WhichEngine
}
import org.enso.launcher.installation.DistributionManager
import org.enso.launcher.project.ProjectManager
import org.enso.version.{VersionDescription, VersionDescriptionParameter}
/**
* Implements launcher commands that are run from CLI and can be affected by
@ -18,6 +24,30 @@ import org.enso.launcher.components.DefaultComponentsManager
*/
case class Launcher(cliOptions: GlobalCLIOptions) {
private lazy val componentsManager = DefaultComponentsManager(cliOptions)
private lazy val configurationManager =
new GlobalConfigurationManager(componentsManager)
private lazy val projectManager = new ProjectManager(configurationManager)
private lazy val runner =
new Runner(
projectManager,
configurationManager,
componentsManager,
Environment
)
/**
* Creates a new project with the given `name` in the given `path`.
*
* If `path` is not set, the project is created in a directory called `name`
* in the current directory.
*
* TODO [RW] this is not the final implementation, it will be finished in
* #977
*/
def newProject(name: String, path: Option[Path]): Unit = {
val actualPath = path.getOrElse(Launcher.workingDirectory.resolve(name))
projectManager.newProject(name, actualPath)
}
/**
* Prints a list of installed engines.
@ -78,7 +108,7 @@ case class Launcher(cliOptions: GlobalCLIOptions) {
*/
def installLatestEngine(): Unit = {
val latest = componentsManager.fetchLatestEngineVersion()
Logger.info(s"Installing Enso engine $latest")
Logger.info(s"Installing Enso engine $latest.")
installEngine(latest)
}
@ -90,19 +120,175 @@ case class Launcher(cliOptions: GlobalCLIOptions) {
def uninstallEngine(version: SemVer): Unit =
componentsManager.uninstallEngine(version)
/**
* Runs the Enso REPL.
*
* If ran outside of a project, uses the default configured version. If run
* inside a project or provided with an explicit projectPath, the Enso
* version associated with the project is run.
*
* @param projectPath if provided, the REPL is run in context of that project
* @param versionOverride if provided, overrides the default engine version
* that would have been used
* @param useSystemJVM if set, forces to use the default configured JVM,
* instead of the JVM associated with the engine version
* @param jvmOpts additional options to pass to the launched JVM
* @param additionalArguments additional arguments to pass to the runner
*/
def runRepl(
pathHint: Option[Path],
jvmArguments: Seq[(String, String)],
projectPath: Option[Path],
versionOverride: Option[SemVer],
useSystemJVM: Boolean,
jvmOpts: Seq[(String, String)],
additionalArguments: Seq[String]
): Unit = {
// TODO [RW] this is just a stub, it will be implemented in #976
val path = pathHint.getOrElse(Path.of(".")).toAbsolutePath
val detectedVersion = SemVer(0, 1, 0) // TODO [RW] default version etc.
val engine = componentsManager.findOrInstallEngine(detectedVersion)
val runtime = componentsManager.findOrInstallRuntime(engine)
println(s"Will launch the REPL in $path")
println(s"with $engine with additional arguments $additionalArguments")
println(s"using $runtime with JVM arguments $jvmArguments")
val exitCode = runner
.createCommand(
runner.repl(projectPath, versionOverride, additionalArguments).get,
JVMSettings(useSystemJVM, jvmOpts)
)
.run()
.get
sys.exit(exitCode)
}
/**
* Runs an Enso script or project.
*
* If ran inside a project without a path, or with a path pointing to a
* project, runs that project. If the provided path points to a file, that
* file is executed as an Enso script. If the file is located inside of a
* project, it is executed in the context of that project. Otherwise it is
* run as a standalone script and the default engine version is used.
*
* @param path specifies what to run
* @param versionOverride if provided, overrides the default engine version
* that would have been used
* @param useSystemJVM if set, forces to use the default configured JVM,
* instead of the JVM associated with the engine version
* @param jvmOpts additional options to pass to the launched JVM
* @param additionalArguments additional arguments to pass to the runner
*/
def runRun(
path: Option[Path],
versionOverride: Option[SemVer],
useSystemJVM: Boolean,
jvmOpts: Seq[(String, String)],
additionalArguments: Seq[String]
): Unit = {
val exitCode = runner
.createCommand(
runner.run(path, versionOverride, additionalArguments).get,
JVMSettings(useSystemJVM, jvmOpts)
)
.run()
.get
sys.exit(exitCode)
}
/**
* Runs the Language Server.
*
* Unless overridden, uses the Enso version associated with the project
* located at `options.path`.
*
* @param options configuration required by the language server
* @param versionOverride if provided, overrides the default engine version
* that would have been used
* @param useSystemJVM if set, forces to use the default configured JVM,
* instead of the JVM associated with the engine version
* @param jvmOpts additional options to pass to the launched JVM
* @param additionalArguments additional arguments to pass to the runner
*/
def runLanguageServer(
options: LanguageServerOptions,
versionOverride: Option[SemVer],
useSystemJVM: Boolean,
jvmOpts: Seq[(String, String)],
additionalArguments: Seq[String]
): Unit = {
val exitCode = runner
.createCommand(
runner
.languageServer(options, versionOverride, additionalArguments)
.get,
JVMSettings(useSystemJVM, jvmOpts)
)
.run()
.get
sys.exit(exitCode)
}
/**
* Sets the default Enso version.
*/
def setDefaultVersion(version: SemVer): Unit = {
val _ = version
Logger.error("This feature is not implemented yet.")
sys.exit(1)
}
/**
* Prints the default Enso version.
*/
def printDefaultVersion(): Unit = {
println(configurationManager.defaultVersion)
}
/**
* Displays the version string of the launcher.
*
* @param useJSON specifies whether the output should use JSON or a
* human-readable format
* @param hideEngineVersion if set, does not look for installed engines to
* display the current version; this can be used to
* avoid making network requests
*/
def displayVersion(
useJSON: Boolean,
hideEngineVersion: Boolean = false
): Unit = {
val runtimeVersionParameter =
if (hideEngineVersion) None else Some(getEngineVersion(useJSON))
val versionDescription = VersionDescription.make(
"Enso Launcher",
includeRuntimeJVMInfo = false,
additionalParameters = runtimeVersionParameter.toSeq
)
println(versionDescription.asString(useJSON))
}
private def getEngineVersion(
useJSON: Boolean
): VersionDescriptionParameter = {
val (runtimeVersionRunSettings, whichEngine) = runner.version(useJSON).get
val isEngineInstalled =
componentsManager.findEngine(runtimeVersionRunSettings.version).isDefined
val runtimeVersionString = if (isEngineInstalled) {
val runtimeVersionCommand = runner.createCommand(
runtimeVersionRunSettings,
JVMSettings(useSystemJVM = false, jvmOptions = Seq.empty)
)
val output = runtimeVersionCommand.captureOutput().get
if (useJSON) output else "\n" + output.stripTrailing()
} else {
if (useJSON) "null"
else "Not installed."
}
VersionDescriptionParameter(
humanReadableName = whichEngine match {
case WhichEngine.FromProject(name) =>
s"Enso engine from project $name"
case WhichEngine.Default => "Current default Enso engine"
},
jsonName = "runtime",
value = runtimeVersionString
)
}
}
@ -118,48 +304,8 @@ object Launcher {
throw new IllegalStateException("Cannot parse the built-in version.")
}
private val packageManager = PackageManager.Default
private val workingDirectory: Path = Path.of(".")
/**
* Creates a new project with the given `name` in the given `path`.
*
* If `path` is not set, the project is created in a directory called `name`
* in the current directory.
*
* TODO [RW] this is not the final implementation, it will be finished in
* #977
*/
def newProject(name: String, path: Option[Path]): Unit = {
val actualPath = path.getOrElse(workingDirectory.resolve(name))
packageManager.create(actualPath.toFile, name)
Logger.info(s"Project created in $actualPath")
}
/**
* Displays the version string of the launcher.
*
* @param useJSON specifies whether the output should use JSON or a
* human-readable format
*/
def displayVersion(useJSON: Boolean): Unit = {
val runtimeVersionParameter = VersionDescriptionParameter(
humanReadableName = "Currently selected Enso version",
humandReadableValue =
"\nRuntime component is not yet implemented in the launcher.",
jsonName = "runtime",
jsonValue = "\"<not implemented yet>\"" // TODO [RW] add with #976
)
val versionDescription = VersionDescription.make(
"Enso Launcher",
includeRuntimeJVMInfo = false,
additionalParameters = Seq(runtimeVersionParameter)
)
println(versionDescription.asString(useJSON))
}
/**
* Checks if the launcher is running in portable mode and exits if it is not.
*/

View File

@ -1,5 +1,7 @@
package org.enso.launcher
import java.io.PrintStream
/**
* This is a temporary object that should be at some point replaced with the
* actual logging service.
@ -8,17 +10,17 @@ package org.enso.launcher
* is implemented in #1031
*/
object Logger {
private case class Level(name: String, level: Int)
private val Debug = Level("debug", 1)
private val Info = Level("info", 2)
private val Warning = Level("warn", 3)
private val Error = Level("error", 4)
private case class Level(name: String, level: Int, stream: PrintStream)
private val Debug = Level("debug", 1, System.err)
private val Info = Level("info", 2, System.out)
private val Warning = Level("warn", 3, System.err)
private val Error = Level("error", 4, System.err)
private var logLevel = Info
private def log(level: Level, msg: => String): Unit =
if (level.level >= logLevel.level) {
System.out.println(s"[${level.name}] $msg")
System.out.flush()
level.stream.println(s"[${level.name}] $msg")
level.stream.flush()
}
/**
@ -68,6 +70,11 @@ object Logger {
/**
* Runs the provided action with a log level that will allow only for errors
* and returns its result.
*
* Warning: This function is not thread safe, so using it in tests that are
* run in parallel without forking may lead to an inconsistency in logging.
* This is just a *temporary* solution until a fully-fledged logging service
* is developed #1031.
*/
def suppressWarnings[R](action: => R): R = {
val oldLevel = logLevel

View File

@ -1,17 +1,66 @@
package org.enso.launcher
import io.circe.{Decoder, DecodingFailure}
/**
* Represents one of the supported platforms (operating systems).
*/
sealed trait OS {
def name: String
/**
* Name of this operating system as included in the configuration.
*/
def configName: String
/**
* Checks if the provided `os.name` matches this operating system.
*/
def matches(osName: String): Boolean = osName.toLowerCase.contains(configName)
}
/**
* Gathers helper functions useful for dealing with platform-specific
* behaviour.
*/
object OS {
/**
* Represents the Linux operating system.
*/
case object Linux extends OS {
def name: String = "linux"
/**
* @inheritdoc
*/
def configName: String = "linux"
}
/**
* Represents the macOS operating system.
*/
case object MacOS extends OS {
def name: String = "mac"
/**
* @inheritdoc
*/
def configName: String = "macos"
/**
* @inheritdoc
*/
override def matches(osName: String): Boolean =
osName.toLowerCase.contains("mac")
}
/**
* Represents the Windows operating system.
*/
case object Windows extends OS {
def name: String = "windows"
/**
* @inheritdoc
*/
def configName: String = "windows"
}
/**
@ -30,12 +79,15 @@ object OS {
private val ENSO_OPERATING_SYSTEM = "ENSO_OPERATING_SYSTEM"
private val knownOS = Seq(Linux, MacOS, Windows)
private lazy val knownOSPossibleValuesString =
knownOS.map(os => s"`${os.configName}`").mkString(", ")
private def detectOS: OS = {
val knownOS = Seq(Linux, MacOS, Windows)
val overridenName = Option(System.getenv(ENSO_OPERATING_SYSTEM))
overridenName match {
case Some(value) =>
knownOS.find(_.name == value.toLowerCase) match {
knownOS.find(value.toLowerCase == _.configName) match {
case Some(overriden) =>
Logger.debug(
s"OS overriden by $ENSO_OPERATING_SYSTEM to $overriden."
@ -43,16 +95,15 @@ object OS {
return overriden
case None =>
Logger.warn(
s"$ENSO_OPERATING_SYSTEM is set to an unknown value $value, " +
s"ignoring."
s"$ENSO_OPERATING_SYSTEM is set to an unknown value `$value`, " +
s"ignoring. Possible values are $knownOSPossibleValuesString."
)
}
case None =>
}
val name = System.getProperty("os.name").toLowerCase
def nameMatches(os: OS): Boolean = name.contains(os.name)
val possibleOS = knownOS.filter(nameMatches)
val name = System.getProperty("os.name")
val possibleOS = knownOS.filter(_.matches(name))
if (possibleOS.length == 1) {
possibleOS.head
} else {
@ -61,7 +112,7 @@ object OS {
s"the OS you are running is supported. You can try to manually " +
s"override the operating system detection by setting an environment " +
s"variable `$ENSO_OPERATING_SYSTEM` to one of the possible values " +
s"`linux`, `mac`, `windows` depending on the system that your OS " +
s"$knownOSPossibleValuesString depending on the system that your OS " +
s"most behaves like."
)
throw new IllegalStateException(
@ -90,4 +141,20 @@ object OS {
*/
def executableName(baseName: String): String =
if (isWindows) baseName + ".exe" else baseName
/**
* A [[Decoder]] instance allowing to parse the OS name from JSON and YAML
* configuration.
*/
implicit val decoder: Decoder[OS] = { json =>
json.as[String].flatMap { string =>
knownOS.find(_.configName == string).toRight {
DecodingFailure(
s"`$string` is not a valid OS name. " +
s"Possible values are $knownOSPossibleValuesString.",
json.history
)
}
}
}
}

View File

@ -8,6 +8,7 @@ import nl.gn0s1s.bump.SemVer
import org.enso.cli.Opts.implicits._
import org.enso.cli._
import org.enso.launcher.cli.Arguments._
import org.enso.launcher.components.runner.LanguageServerOptions
import org.enso.launcher.installation.DistributionInstaller.BundleAction
import org.enso.launcher.installation.{
DistributionInstaller,
@ -33,8 +34,19 @@ object Main {
"version",
"Print version of the launcher and currently selected Enso distribution."
) {
jsonFlag(showInUsage = true) map { useJSON => (_: Config) =>
Launcher.displayVersion(useJSON)
val onlyLauncherFlag = Opts.flag(
"only-launcher",
"If set, shows only the launcher version, skipping checking for " +
"engine versions which may involve network requests depending on " +
"configuration.",
showInUsage = true
)
(jsonFlag(showInUsage = true), onlyLauncherFlag) mapN {
(useJSON, onlyLauncher) => (config: Config) =>
Launcher(config).displayVersion(
useJSON,
hideEngineVersion = onlyLauncher
)
}
}
@ -47,16 +59,30 @@ object Main {
"called NAME is created in the current directory."
)
(nameOpt, pathOpt) mapN { (name, path) => (_: Config) =>
Launcher.newProject(name, path)
(nameOpt, pathOpt) mapN { (name, path) => (config: Config) =>
Launcher(config).newProject(name, path)
}
}
private def jvmArgs =
private def jvmOpts =
Opts.prefixedParameters(
"jvm",
"These parameters will be passed to the launched JVM as -DKEY=VALUE."
)
private def systemJVMOverride =
Opts.flag(
"use-system-jvm",
"Setting this flag runs the Enso engine using the system-configured " +
"JVM instead of the one managed by the launcher. " +
"Advanced option, use carefully.",
showInUsage = false
)
private def versionOverride =
Opts.optionalParameter[SemVer](
"use-enso-version",
"VERSION",
"Overrides the Enso version that would normally be used"
)
private def runCommand: Command[Config => Unit] =
Command(
@ -68,18 +94,29 @@ object Main {
) {
val pathOpt = Opts.optionalArgument[Path](
"PATH",
"If PATH points to a file, that file is run as a script. " +
"If it points to a directory, the project from that directory is " +
"run. If a PATH is not provided, a project in the current working " +
"directory is run."
"If PATH points to a file, that file is run as a script (if that " +
"script is located inside of a project, the script is run in the " +
"context of that project). If it points to a directory, the project " +
"from that directory is run. If a PATH is not provided, a project in " +
"the current working directory is run."
)
val additionalArgs = Opts.additionalArguments()
(pathOpt, jvmArgs, additionalArgs) mapN {
(path, jvmArgs, additionalArgs) => (_: Config) =>
val enginesRoot = DistributionManager.paths.engines
println(s"Launch runner for $path")
println(s"JVM=$jvmArgs, additionalArgs=$additionalArgs")
println(s"Engines are located at $enginesRoot")
(
pathOpt,
versionOverride,
systemJVMOverride,
jvmOpts,
additionalArgs
) mapN {
(path, versionOverride, systemJVMOverride, jvmOpts, additionalArgs) =>
(config: Config) =>
Launcher(config).runRun(
path = path,
versionOverride = versionOverride,
useSystemJVM = systemJVMOverride,
jvmOpts = jvmOpts,
additionalArguments = additionalArgs
)
}
}
@ -98,19 +135,20 @@ object Main {
Opts.optionalParameter[String](
"interface",
"INTERFACE",
"Interface for processing all incoming connections."
"Interface for processing all incoming connections. " +
"Defaults to `127.0.0.1`."
)
val rpcPort =
Opts.optionalParameter[Int](
"rpc-port",
"PORT",
"RPC port for processing all incoming connections."
"RPC port for processing all incoming connections. Defaults to 8080."
)
val dataPort =
Opts.optionalParameter[Int](
"data-port",
"PORT",
"Data port for visualisation protocol."
"Data port for visualisation protocol. Defaults to 8081."
)
val additionalArgs = Opts.additionalArguments()
(
@ -119,45 +157,80 @@ object Main {
interface,
rpcPort,
dataPort,
jvmArgs,
versionOverride,
systemJVMOverride,
jvmOpts,
additionalArgs
) mapN {
(rootId, path, interface, rpcPort, dataPort, jvmArgs, additionalArgs) =>
(_: Config) =>
println(s"Launch language server in $path with id=$rootId.")
println(
s"interface=$interface, rpcPort=$rpcPort, dataPort=$dataPort"
)
println(s"JVM=$jvmArgs, additionalArgs=$additionalArgs")
(
rootId,
path,
interface,
rpcPort,
dataPort,
versionOverride,
systemJVMOverride,
jvmOpts,
additionalArgs
) => (config: Config) =>
Launcher(config).runLanguageServer(
options = LanguageServerOptions(
rootId = rootId,
path = path,
interface = interface.getOrElse("127.0.0.1"),
rpcPort = rpcPort.getOrElse(8080),
dataPort = dataPort.getOrElse(8081)
),
versionOverride = versionOverride,
useSystemJVM = systemJVMOverride,
jvmOpts = jvmOpts,
additionalArguments = additionalArgs
)
}
}
private def replCommand: Command[Config => Unit] =
Command(
"repl",
"Launch an Enso REPL." +
"Launch an Enso REPL. " +
"If `auto-confirm` is set, this will install missing engines or " +
"runtimes without asking."
) {
val path = Opts.optionalParameter[Path]("path", "PATH", "Project path.")
val path = Opts.optionalParameter[Path](
"path",
"PATH",
"Specifying this option runs the REPL in context of a project " +
"located at the given path. The REPL is also run in context of a " +
"project if it is launched from within a directory inside a project."
)
val additionalArgs = Opts.additionalArguments()
(path, jvmArgs, additionalArgs) mapN {
(path, jvmArgs, additionalArgs) => (config: Config) =>
Launcher(config).runRepl(path, jvmArgs, additionalArgs)
(path, versionOverride, systemJVMOverride, jvmOpts, additionalArgs) mapN {
(path, versionOverride, systemJVMOverride, jvmOpts, additionalArgs) =>
(config: Config) =>
Launcher(config).runRepl(
projectPath = path,
versionOverride = versionOverride,
useSystemJVM = systemJVMOverride,
jvmOpts = jvmOpts,
additionalArguments = additionalArgs
)
}
}
private def defaultCommand: Command[Config => Unit] =
Command("default", "Print or change the default Enso version.") {
val version = Opts.optionalArgument[String](
val version = Opts.optionalArgument[SemVer](
"VERSION",
"If provided, sets default version to VERSION. " +
"Otherwise, current default is displayed."
)
version map { version => (_: Config) =>
version map { version => (config: Config) =>
val launcher = Launcher(config)
version match {
case Some(version) => println(s"Set version to $version")
case None => println("Print current version")
case Some(version) =>
launcher.setDefaultVersion(version)
case None =>
launcher.printDefaultVersion()
}
}
}
@ -181,8 +254,8 @@ object Main {
Subcommand("engine") {
val version = Opts.optionalArgument[SemVer](
"VERSION",
"The version to install. If not provided, the latest version is " +
"installed."
"VERSION specifies the engine version to install. If not provided, the" +
"latest version is installed."
)
version map { version => (config: Config) =>
version match {
@ -365,19 +438,19 @@ object Main {
Launcher.ensurePortable()
}
val globalCLIOptions = GlobalCLIOptions(
autoConfirm = autoConfirm,
hideProgress = hideProgress
)
if (help) {
printTopLevelHelp()
TopLevelBehavior.Halt
} else if (version) {
Launcher.displayVersion(useJSON)
Launcher(globalCLIOptions).displayVersion(useJSON)
TopLevelBehavior.Halt
} else
TopLevelBehavior.Continue(
GlobalCLIOptions(
autoConfirm = autoConfirm,
hideProgress = hideProgress
)
)
TopLevelBehavior.Continue(globalCLIOptions)
}
}

View File

@ -54,5 +54,18 @@ case class LauncherUpgradeRequiredError(expectedVersion: SemVer)
case class UnrecognizedComponentError(message: String, cause: Throwable = null)
extends ComponentsException(message, cause)
/**
* Indicates that the component is installed, but its installation is
* corrupted.
*
* Most common reason for this exception is that some critical files are
* missing.
*/
case class CorruptedComponentError(message: String, cause: Throwable = null)
extends ComponentsException(message, cause)
/**
* Indicates the requested component is not installed.
*/
case class ComponentMissingError(message: String, cause: Throwable = null)
extends ComponentsException(message, cause)

View File

@ -15,39 +15,8 @@ import org.enso.launcher.releases.{
}
import org.enso.launcher.{FileSystem, Launcher, Logger}
import scala.util.{Failure, Success, Try}
import scala.util.control.NonFatal
/**
* Represents a runtime component.
*
* @param version version of the component
* @param path path to the component
*/
case class Runtime(version: RuntimeVersion, path: Path) {
/**
* @inheritdoc
*/
override def toString: String =
s"GraalVM ${version.graal}-java${version.java}"
}
/**
* Represents an engine component.
*
* @param version version of the component
* @param path path to the component
* @param manifest manifest of the engine release
*/
case class Engine(version: SemVer, path: Path, manifest: Manifest) {
/**
* @inheritdoc
*/
override def toString: String =
s"Enso Engine $version"
}
import scala.util.{Failure, Success, Try}
/**
* Manages runtime and engine components.
@ -85,11 +54,24 @@ class ComponentsManager(
val name = runtimeNameForVersion(version)
val path = distributionManager.paths.runtimes / name
if (Files.exists(path)) {
// TODO [RW] add a sanity check if runtime is in a working state - check
// if it at least has the `java` executable, in #976 do the check and
// throw an exception on failure, in #1052 offer to repair the broken
// installation
// TODO [RW] for now an exception is thrown if the installation is
// corrupted, in #1052 offer to repair the broken installation
loadGraalRuntime(path)
.map(Some(_))
.recoverWith {
case e: Exception =>
Failure(
UnrecognizedComponentError(
s"The runtime $version is already installed, but cannot be " +
s"loaded due to $e. Until the launcher gets an auto-repair " +
s"feature, please try reinstalling the runtime by " +
s"uninstalling all engines that use it and installing them " +
s"again, or manually removing `$path`.",
e
)
)
}
.get
} else None
}
@ -136,7 +118,7 @@ class ComponentsManager(
val name = engineNameForVersion(version)
val path = distributionManager.paths.engines / name
if (Files.exists(path)) {
// TODO [RW] right now we throw an exception, in the future (#1052) we
// TODO [RW] right now we return an exception, in the future (#1052) we
// will try recovery
loadEngine(path)
} else Failure(ComponentMissingError(s"Engine $version is not installed."))
@ -208,30 +190,40 @@ class ComponentsManager(
def listInstalledRuntimes(): Seq[Runtime] =
FileSystem
.listDirectory(distributionManager.paths.runtimes)
.flatMap(loadGraalRuntime)
.map(path => (path, loadGraalRuntime(path)))
.flatMap(handleErrorsAsWarnings[Runtime]("A runtime"))
/**
* Lists all installed engines.
* @return
*/
def listInstalledEngines(): Seq[Engine] = {
def handleErrorsAsWarnings(path: Path, result: Try[Engine]): Seq[Engine] =
result match {
case Failure(exception) =>
Logger.warn(
s"An engine at $path has been skipped due to the " +
s"following error: $exception"
)
Seq()
case Success(value) => Seq(value)
}
FileSystem
.listDirectory(distributionManager.paths.engines)
.map(path => (path, loadEngine(path)))
.flatMap((handleErrorsAsWarnings _).tupled)
.flatMap(handleErrorsAsWarnings[Engine]("An engine"))
}
/**
* A helper function that is used when listing components.
*
* A component error is non-fatal in context of listing, so it is issued as a
* warning and the component is treated as non-existent in the list.
*/
private def handleErrorsAsWarnings[A](name: String)(
result: (Path, Try[A])
): Seq[A] =
result match {
case (path, Failure(exception)) =>
Logger.warn(
s"$name at $path has been skipped due to the following error: " +
s"$exception"
)
Seq()
case (_, Success(value)) => Seq(value)
}
/**
* Finds the latest released version of the engine, by asking the
* [[engineReleaseProvider]].
@ -269,7 +261,7 @@ class ComponentsManager(
FileSystem.withTemporaryDirectory("enso-install") { directory =>
Logger.debug(s"Downloading packages to $directory")
val enginePackage = directory / engineRelease.packageFileName
Logger.info(s"Downloading ${enginePackage.getFileName}")
Logger.info(s"Downloading ${enginePackage.getFileName}.")
engineReleaseProvider
.downloadPackage(engineRelease, enginePackage)
.waitForResult(showProgress)
@ -278,7 +270,7 @@ class ComponentsManager(
val engineDirectoryName =
engineDirectoryNameForVersion(engineRelease.version)
Logger.info(s"Extracting engine")
Logger.info(s"Extracting the engine.")
Archive
.extractArchive(
enginePackage,
@ -353,14 +345,25 @@ class ComponentsManager(
/**
* Loads the GraalVM runtime definition.
*
* Returns None on failure.
*/
private def loadGraalRuntime(path: Path): Option[Runtime] = {
private def loadGraalRuntime(path: Path): Try[Runtime] = {
val name = path.getFileName.toString
def verifyRuntime(runtime: Runtime): Try[Unit] =
if (runtime.isValid) {
Success(())
} else {
Failure(CorruptedComponentError(s"Runtime $runtime is corrupted."))
}
for {
version <- parseGraalRuntimeVersionString(name)
} yield Runtime(version, path)
.toRight(
UnrecognizedComponentError(s"Invalid runtime component name `$name`.")
)
.toTry
runtime = Runtime(version, path)
_ <- verifyRuntime(runtime)
} yield runtime
}
/**
@ -391,14 +394,24 @@ class ComponentsManager(
/**
* Loads the engine definition.
*
* Returns None on failure.
*/
private def loadEngine(path: Path): Try[Engine] =
private def loadEngine(path: Path): Try[Engine] = {
def verifyEngine(engine: Engine): Try[Unit] =
if (!engine.isValid) {
Failure(
CorruptedComponentError(s"Engine ${engine.version} is corrupted.")
)
} else {
Success(())
}
for {
version <- parseEngineVersion(path)
manifest <- loadAndCheckEngineManifest(path)
} yield Engine(version, path, manifest)
engine = Engine(version, path, manifest)
_ <- verifyEngine(engine)
} yield engine
}
/**
* Gets the engine version from its path.
@ -441,7 +454,7 @@ class ComponentsManager(
FileSystem.withTemporaryDirectory("enso-install-runtime") { directory =>
val runtimePackage =
directory / runtimeReleaseProvider.packageFileName(runtimeVersion)
Logger.info(s"Downloading ${runtimePackage.getFileName}")
Logger.info(s"Downloading ${runtimePackage.getFileName}.")
runtimeReleaseProvider
.downloadPackage(runtimeVersion, runtimePackage)
.waitForResult(showProgress)
@ -449,7 +462,7 @@ class ComponentsManager(
val runtimeDirectoryName = graalDirectoryForVersion(runtimeVersion)
Logger.info(s"Extracting runtime")
Logger.info(s"Extracting the runtime.")
Archive
.extractArchive(
runtimePackage,
@ -470,7 +483,7 @@ class ComponentsManager(
try {
val temporaryRuntime = loadGraalRuntime(runtimeTemporaryPath)
if (temporaryRuntime.isEmpty) {
if (temporaryRuntime.isFailure) {
throw InstallationError(
"Cannot load the installed runtime. The package may have been " +
"corrupted. Reverting installation."

View File

@ -0,0 +1,49 @@
package org.enso.launcher.components
import java.nio.file.{Files, Path}
import nl.gn0s1s.bump.SemVer
import org.enso.launcher.FileSystem.PathSyntax
/**
* Represents an engine component.
*
* @param version version of the component
* @param path path to the component
* @param manifest manifest of the engine release
*/
case class Engine(version: SemVer, path: Path, manifest: Manifest) {
/**
* @inheritdoc
*/
override def toString: String =
s"Enso Engine $version"
/**
* The runtime version that is associated with this engine and should be used
* for running it.
*/
def graalRuntimeVersion: RuntimeVersion = manifest.runtimeVersion
/**
* A set of JVM options that should be added when running this engine.
*/
def defaultJVMOptions: Seq[Manifest.JVMOption] = manifest.jvmOptions
/**
* Path to the runner JAR.
*/
def runnerPath: Path = path / "component" / "runner.jar"
/**
* Path to the runtime JAR.
*/
def runtimePath: Path = path / "component" / "runtime.jar"
/**
* Returns if the installation seems not-corrupted.
*/
def isValid: Boolean =
Files.exists(runnerPath) && Files.exists(runtimePath)
}

View File

@ -3,26 +3,15 @@ package org.enso.launcher.components
import java.io.FileReader
import java.nio.file.Path
import io.circe.{yaml, Decoder, DecodingFailure}
import cats.Show
import io.circe.{yaml, Decoder}
import nl.gn0s1s.bump.SemVer
import org.enso.launcher.OS
import org.enso.launcher.components.Manifest.JVMOption
import org.enso.pkg.SemVerDecoder._
import scala.util.{Failure, Try, Using}
/**
* Version information identifying the runtime that can be used with an engine
* release.
*
* @param graal version of the GraalVM
* @param java Java version of the GraalVM flavour that should be used
*/
case class RuntimeVersion(graal: SemVer, java: String) {
/**
* @inheritdoc
*/
override def toString: String = s"GraalVM $graal Java $java"
}
/**
* Contains release metadata read from the manifest file that is attached to
* each release.
@ -35,11 +24,14 @@ case class RuntimeVersion(graal: SemVer, java: String) {
* @param graalVMVersion the version of the GraalVM runtime that has to be
* used with this engine
* @param graalJavaVersion the java version of that GraalVM runtime
* @param jvmOptions a list of JVM options that should be added when running
* this engine
*/
case class Manifest(
minimumLauncherVersion: SemVer,
graalVMVersion: SemVer,
graalJavaVersion: String
graalJavaVersion: String,
jvmOptions: Seq[JVMOption]
) {
/**
@ -57,6 +49,66 @@ object Manifest {
*/
val DEFAULT_MANIFEST_NAME = "manifest.yaml"
/**
* Context used to substitute context-dependent variables in an JVM option.
*
* The context depends on what engine is being run on the JVM.
*
* @param enginePackagePath absolute path to the engine that is being
* launched
*/
case class JVMOptionsContext(enginePackagePath: Path)
/**
* Represents an option that is added to the JVM running an engine.
*
* @param value option value, possibly containing variables that will be
* substituted
* @param osRestriction the option is added only on the specified operating
* system, if it is None, it applies to all systems
*/
case class JVMOption(value: String, osRestriction: Option[OS]) {
/**
* Checks if the option applies on the operating system that is currently
* running.
*/
def isRelevant: Boolean =
osRestriction.isEmpty || osRestriction.contains(OS.operatingSystem)
/**
* Substitutes any variables based on the provided `context`.
*/
def substitute(context: JVMOptionsContext): String =
value.replace(
"$enginePackagePath",
context.enginePackagePath.toAbsolutePath.normalize.toString
)
}
object JVMOption {
private object Fields {
val os = "os"
val value = "value"
}
/**
* [[Decoder]] instance that allows to parse the [[JVMOption]] from the
* YAML manifest.
*/
implicit val decoder: Decoder[JVMOption] = { json =>
val hasOSKey = json.keys.exists { keyList: Iterable[String] =>
keyList.toSeq.contains(Fields.os)
}
for {
value <- json.get[String](Fields.value)
osRestriction <-
if (hasOSKey) json.get[OS](Fields.os).map(Some(_)) else Right(None)
} yield JVMOption(value, osRestriction)
}
}
/**
* Tries to load the manifest at the given path.
*
@ -68,8 +120,9 @@ object Manifest {
.parse(reader)
.flatMap(_.as[Manifest])
.toTry
.recoverWith { error => Failure(ManifestLoadingError(error)) }
}.flatten
}.flatten.recoverWith { error =>
Failure(ManifestLoadingError.fromThrowable(error))
}
/**
* Parses the manifest from a string containing a YAML definition.
@ -81,30 +134,52 @@ object Manifest {
.parse(yamlString)
.flatMap(_.as[Manifest])
.toTry
.recoverWith { error => Failure(ManifestLoadingError(error)) }
.recoverWith { error =>
Failure(ManifestLoadingError.fromThrowable(error))
}
}
case class ManifestLoadingError(cause: Throwable)
extends RuntimeException(s"Could not load the manifest: $cause", cause)
/**
* Indicates an error that prevented loading the engine manifest.
*/
case class ManifestLoadingError(message: String, cause: Throwable)
extends RuntimeException(message, cause) {
/**
* @inheritdoc
*/
override def toString: String = message
}
object ManifestLoadingError {
/**
* Creates a [[ManifestLoadingError]] by wrapping another [[Throwable]].
*
* Special logic is used for [[io.circe.Error]] to display the error
* summary in a human-readable way.
*/
def fromThrowable(throwable: Throwable): ManifestLoadingError =
throwable match {
case decodingError: io.circe.Error =>
val errorMessage =
implicitly[Show[io.circe.Error]].show(decodingError)
ManifestLoadingError(
s"Could not parse the manifest: $errorMessage",
decodingError
)
case other =>
ManifestLoadingError(s"Could not load the manifest: $other", other)
}
}
private object Fields {
val minimumLauncherVersion = "minimum-launcher-version"
val jvmOptions = "jvm-options"
val graalVMVersion = "graal-vm-version"
val graalJavaVersion = "graal-java-version"
}
implicit private val semverDecoder: Decoder[SemVer] = { json =>
for {
string <- json.as[String]
version <- SemVer(string).toRight(
DecodingFailure(
s"`$string` is not a valid semver version.",
json.history
)
)
} yield version
}
implicit private val decoder: Decoder[Manifest] = { json =>
for {
minimumLauncherVersion <- json.get[SemVer](Fields.minimumLauncherVersion)
@ -113,10 +188,12 @@ object Manifest {
json
.get[String](Fields.graalJavaVersion)
.orElse(json.get[Int](Fields.graalJavaVersion).map(_.toString))
jvmOptions <- json.getOrElse[Seq[JVMOption]](Fields.jvmOptions)(Seq())
} yield Manifest(
minimumLauncherVersion = minimumLauncherVersion,
graalVMVersion = graalVMVersion,
graalJavaVersion = graalJavaVersion
graalJavaVersion = graalJavaVersion,
jvmOptions = jvmOptions
)
}
}

View File

@ -0,0 +1,45 @@
package org.enso.launcher.components
import java.nio.file.{Files, Path}
import org.enso.launcher.FileSystem.PathSyntax
import org.enso.launcher.OS
/**
* Represents a runtime component.
*
* @param version version of the component
* @param path path to the component
*/
case class Runtime(version: RuntimeVersion, path: Path) {
/**
* @inheritdoc
*/
override def toString: String =
s"GraalVM ${version.graal}-java${version.java}"
/**
* The path to the JAVA_HOME directory associated with this runtime.
*/
def javaHome: Path =
OS.operatingSystem match {
case OS.Linux => path
case OS.MacOS => path / "Contents" / "Home"
case OS.Windows => path
}
/**
* The path to the `java` executable associated with this runtime.
*/
def javaExecutable: Path = {
val executableName = if (OS.isWindows) "java.exe" else "java"
javaHome / "bin" / executableName
}
/**
* Returns if the installation seems not-corrupted.
*/
def isValid: Boolean =
Files.exists(javaExecutable) && Files.isExecutable(javaExecutable)
}

View File

@ -0,0 +1,18 @@
package org.enso.launcher.components
import nl.gn0s1s.bump.SemVer
/**
* Version information identifying the runtime that can be used with an engine
* release.
*
* @param graal version of the GraalVM
* @param java Java version of the GraalVM flavour that should be used
*/
case class RuntimeVersion(graal: SemVer, java: String) {
/**
* @inheritdoc
*/
override def toString: String = s"GraalVM $graal Java $java"
}

View File

@ -0,0 +1,74 @@
package org.enso.launcher.components.runner
import org.enso.launcher.Logger
import scala.sys.process.{Process, ProcessBuilder}
import scala.util.{Failure, Try}
/**
* Represents information required to run a system command.
*
* @param command the command and its arguments that should be executed
* @param extraEnv environment variables that should be overridden
*/
case class Command(command: Seq[String], extraEnv: Seq[(String, String)]) {
/**
* Runs the command and returns its exit code.
*
* May return an exception if it is impossible to run the command (for
* example due to insufficient permissions or nonexistent executable).
*/
def run(): Try[Int] =
runProcess { processBuilder =>
processBuilder.run(connectInput = true).exitValue()
}
/**
* Runs the command and returns its standard output as [[String]].
*
* The standard error is printed to the console.
*
* May return an exception if it is impossible to run the command or the
* command returned non-zero exit code.
*/
def captureOutput(): Try[String] =
runProcess { processBuilder =>
processBuilder.!!
}
/**
* Prepares to run a process and invokes `action` on the created
* [[ProcessBuilder]], capturing any errors.
*
* On success, the result of `action` is returned.
*/
private def runProcess[R](action: ProcessBuilder => R): Try[R] = {
val result = Try {
Logger.debug(s"Executing $toString")
val processBuilder = Process(command, None, extraEnv: _*)
action(processBuilder)
}
result.recoverWith(error =>
Failure(
RunnerError(
s"Could not run the command $toString due to: $error",
error
)
)
)
}
/**
* A textual representation of the command in a format that can be copied in
* to a terminal and executed.
*/
override def toString: String = {
def escapeQuotes(string: String): String =
"\"" + string.replace("\"", "\\\"") + "\""
val environmentDescription =
extraEnv.map(v => s"${v._1}=${escapeQuotes(v._2)} ").mkString
val commandDescription = command.map(escapeQuotes).mkString(" ")
environmentDescription + commandDescription
}
}

View File

@ -0,0 +1,10 @@
package org.enso.launcher.components.runner
/**
* Represents settings that are used to launch the runtime JVM.
*
* @param useSystemJVM if set, the system configured JVM is used instead of
* the one managed by the launcher
* @param jvmOptions options that should be added to the launched JVM
*/
case class JVMSettings(useSystemJVM: Boolean, jvmOptions: Seq[(String, String)])

View File

@ -0,0 +1,21 @@
package org.enso.launcher.components.runner
import java.nio.file.Path
import java.util.UUID
/**
* Options that are passed to the language server.
*
* @param rootId an id of content root
* @param path a path to the content root
* @param interface a interface that the server listen to
* @param rpcPort an RPC port that the server listen to
* @param dataPort a data port that the server listen to
*/
case class LanguageServerOptions(
rootId: UUID,
path: Path,
interface: String,
rpcPort: Int,
dataPort: Int
)

View File

@ -0,0 +1,11 @@
package org.enso.launcher.components.runner
import nl.gn0s1s.bump.SemVer
/**
* Represents settings that are used to launch the runner JAR.
*
* @param version Enso engine version to use
* @param runnerArguments arguments that should be passed to the runner
*/
case class RunSettings(version: SemVer, runnerArguments: Seq[String])

View File

@ -0,0 +1,266 @@
package org.enso.launcher.components.runner
import java.nio.file.{Files, Path}
import nl.gn0s1s.bump.SemVer
import org.enso.launcher.components.{ComponentsManager, Manifest, Runtime}
import org.enso.launcher.project.ProjectManager
import org.enso.launcher.{Environment, GlobalConfigurationManager, Logger}
import scala.util.Try
/**
* A helper class that prepares settings for running Enso components and
* converts these settings to actual commands that launch the component inside
* of a JVM.
*/
class Runner(
projectManager: ProjectManager,
configurationManager: GlobalConfigurationManager,
componentsManager: ComponentsManager,
environment: Environment
) {
/**
* The current working directory that is a starting point when checking if
* the command is launched inside of a project.
*
* Can be overridden in tests.
*/
protected val currentWorkingDirectory: Path = Path.of(".")
/**
* Creates [[RunSettings]] for launching the REPL.
*
* See [[org.enso.launcher.Launcher.runRepl]] for more details.
*/
def repl(
projectPath: Option[Path],
versionOverride: Option[SemVer],
additionalArguments: Seq[String]
): Try[RunSettings] =
Try {
val inProject = projectPath match {
case Some(value) =>
Some(projectManager.loadProject(value).get)
case None =>
projectManager.findProject(currentWorkingDirectory).get
}
val version =
versionOverride.getOrElse {
inProject
.map(_.version)
.getOrElse(configurationManager.defaultVersion)
}
val arguments = inProject match {
case Some(project) =>
val projectPackagePath =
project.path.toAbsolutePath.normalize.toString
Seq("--repl", "--in-project", projectPackagePath)
case None =>
Seq("--repl")
}
RunSettings(version, arguments ++ additionalArguments)
}
/**
* Creates [[RunSettings]] for running Enso projects or scripts.
*
* See [[org.enso.launcher.Launcher.runRun]] for more details.
*/
def run(
path: Option[Path],
versionOverride: Option[SemVer],
additionalArguments: Seq[String]
): Try[RunSettings] =
Try {
val actualPath = path
.getOrElse {
projectManager
.findProject(currentWorkingDirectory)
.get
.getOrElse {
throw RunnerError(
"The current directory is not inside any project. `enso run` " +
"should either get a path to a project or script to run, or " +
"be run inside of a project to run that project."
)
}
.path
}
.toAbsolutePath
.normalize()
if (!Files.exists(actualPath)) {
throw RunnerError(s"$actualPath does not exist")
}
val projectMode = Files.isDirectory(actualPath)
val project =
if (projectMode) Some(projectManager.loadProject(actualPath).get)
else projectManager.findProject(actualPath).get
val version = versionOverride
.orElse(project.map(_.version))
.getOrElse(configurationManager.defaultVersion)
val arguments =
if (projectMode) Seq("--run", actualPath.toString)
else
project match {
case Some(project) =>
Seq(
"--run",
actualPath.toString,
"--in-project",
project.path.toAbsolutePath.normalize().toString
)
case None =>
Seq("--run", actualPath.toString)
}
RunSettings(version, arguments ++ additionalArguments)
}
/**
* Creates [[RunSettings]] for launching the Language Server.
*
* See [[org.enso.launcher.Launcher.runLanguageServer]] for more details.
*/
def languageServer(
options: LanguageServerOptions,
versionOverride: Option[SemVer],
additionalArguments: Seq[String]
): Try[RunSettings] =
Try {
val project = projectManager.loadProject(options.path).get
val version = versionOverride.getOrElse(project.version)
val arguments = Seq(
"--server",
"--root-id",
options.rootId.toString,
"--path",
options.path.toAbsolutePath.normalize.toString,
"--interface",
options.interface,
"--rpc-port",
options.rpcPort.toString,
"--data-port",
options.dataPort.toString
)
RunSettings(version, arguments ++ additionalArguments)
}
/**
* Creates [[RunSettings]] for querying the currently selected engine
* version.
*
* If the current working directory is inside of a project, the engine
* associated with the project is queried, otherwise the default engine is
* queried.
*
* @param useJSON if set to true, the returned [[RunSettings]] will request
* the version in JSON format, otherwise human readable text
* format will be used
* @return the [[RunSettings]] and a [[WhichEngine]] indicating if the used
* engine was from a project (true) or the default one (false)
*/
def version(useJSON: Boolean): Try[(RunSettings, WhichEngine)] = {
for {
project <- projectManager.findProject(currentWorkingDirectory)
} yield {
val version =
project.map(_.version).getOrElse(configurationManager.defaultVersion)
val arguments =
Seq("--version") ++ (if (useJSON) Seq("--json") else Seq())
val whichEngine =
project match {
case Some(value) => WhichEngine.FromProject(value.name)
case None => WhichEngine.Default
}
(RunSettings(version, arguments), whichEngine)
}
}
final private val JVM_OPTIONS_ENV_VAR = "ENSO_JVM_OPTS"
/**
* Creates a command that can be used to launch the component.
*
* Combines the [[RunSettings]] for the runner with the [[JVMSettings]] for
* the underlying JVM to get the full command for launching the component.
*/
def createCommand(
runSettings: RunSettings,
jvmSettings: JVMSettings
): Command = {
val engine = componentsManager.findOrInstallEngine(runSettings.version)
val javaCommand =
if (jvmSettings.useSystemJVM) systemJavaCommand
else {
val runtime = componentsManager.findOrInstallRuntime(engine)
javaCommandForRuntime(runtime)
}
val jvmOptsFromEnvironment = environment.getEnvVar(JVM_OPTIONS_ENV_VAR)
jvmOptsFromEnvironment.foreach { opts =>
Logger.debug(
s"Picking up additional JVM options ($opts) from the " +
s"$JVM_OPTIONS_ENV_VAR environment variable."
)
}
val runnerJar = engine.runnerPath.toAbsolutePath.normalize.toString
def translateJVMOption(option: (String, String)): String = {
val name = option._1
val value = option._2
s"-D$name=$value"
}
val context = Manifest.JVMOptionsContext(enginePackagePath = engine.path)
val manifestOptions =
engine.defaultJVMOptions.filter(_.isRelevant).map(_.substitute(context))
val environmentOptions =
jvmOptsFromEnvironment.map(_.split(' ').toIndexedSeq).getOrElse(Seq())
val commandLineOptions = jvmSettings.jvmOptions.map(translateJVMOption)
val jvmArguments =
manifestOptions ++ environmentOptions ++ commandLineOptions ++
Seq("-jar", runnerJar)
val command = Seq(javaCommand.executableName) ++
jvmArguments ++ runSettings.runnerArguments
val extraEnvironmentOverrides =
javaCommand.javaHomeOverride.map("JAVA_HOME" -> _).toSeq
Command(command, extraEnvironmentOverrides)
}
/**
* Represents a way of launching the JVM.
*
* Stores the name of the `java` executable to run and a possible JAVA_HOME
* environment variable override.
*/
private case class JavaCommand(
executableName: String,
javaHomeOverride: Option[String]
)
/**
* The [[JavaCommand]] representing the system-configured JVM.
*/
private def systemJavaCommand: JavaCommand = JavaCommand("java", None)
/**
* The [[JavaCommand]] representing a managed [[Runtime]].
*/
private def javaCommandForRuntime(runtime: Runtime): JavaCommand =
JavaCommand(
executableName = runtime.javaExecutable.toAbsolutePath.normalize.toString,
javaHomeOverride =
Some(runtime.javaHome.toAbsolutePath.normalize.toString)
)
}

View File

@ -0,0 +1,13 @@
package org.enso.launcher.components.runner
/**
* Represents an error encountered when running the component.
*/
case class RunnerError(message: String, cause: Throwable = null)
extends RuntimeException(message, cause) {
/**
* @inheritdoc
*/
override def toString: String = message
}

View File

@ -0,0 +1,11 @@
package org.enso.launcher.components.runner
/**
* Returned by [[Runner.version]], specifies if the engine that is queried for
* version is from a project or the default one.
*/
sealed trait WhichEngine
object WhichEngine {
case class FromProject(name: String) extends WhichEngine
case object Default extends WhichEngine
}

View File

@ -284,14 +284,14 @@ class DistributionInstaller(
if (bundleAction.copy) {
for (engine <- engines) {
Logger.info(s"Copying bundled Enso engine ${engine.getFileName}")
Logger.info(s"Copying bundled Enso engine ${engine.getFileName}.")
FileSystem.copyDirectory(
engine,
enginesDirectory / engine.getFileName
)
}
for (runtime <- runtimes) {
Logger.info(s"Copying bundled runtime ${runtime.getFileName}")
Logger.info(s"Copying bundled runtime ${runtime.getFileName}.")
FileSystem.copyDirectory(
runtime,
runtimesDirectory / runtime.getFileName

View File

@ -0,0 +1,45 @@
package org.enso.launcher.project
import java.io.File
import java.nio.file.Path
import nl.gn0s1s.bump.SemVer
import org.enso.launcher.GlobalConfigurationManager
import org.enso.pkg.{DefaultEnsoVersion, Package, SemVerEnsoVersion}
/**
* Represents an Enso project.
*
* @param pkg the package associated with the project
* @param globalConfigurationManager the configuration manager that is
* necessary when the project version is set
* to `default`
*/
class Project(
pkg: Package[File],
globalConfigurationManager: GlobalConfigurationManager
) {
/**
* The Enso engine version associated with the project.
*
* If the version in the configuration is set to `default`, the locally
* default Enso version is used.
*/
def version: SemVer =
pkg.config.ensoVersion match {
case DefaultEnsoVersion =>
globalConfigurationManager.defaultVersion
case SemVerEnsoVersion(version) => version
}
/**
* The package name of the project.
*/
def name: String = pkg.name
/**
* The path to the content root of the project.
*/
def path: Path = pkg.root.toPath
}

View File

@ -0,0 +1,18 @@
package org.enso.launcher.project
import java.nio.file.Path
/**
* Indicates that it was impossible to load the project at a specified path.
*/
case class ProjectLoadingError(path: Path, cause: Throwable)
extends RuntimeException(
s"Cannot load an Enso project at `$path` due to: $cause",
cause
) {
/**
* @inheritdoc
*/
override def toString: String = getMessage
}

View File

@ -0,0 +1,77 @@
package org.enso.launcher.project
import java.nio.file.Path
import nl.gn0s1s.bump.SemVer
import org.enso.launcher.{GlobalConfigurationManager, Logger}
import org.enso.pkg.{PackageManager, SemVerEnsoVersion}
import scala.util.{Failure, Try}
/**
* A helper class for project management.
*
* It allows to create new project, open existing ones or traverse the
* directory tree to find a project based on a path inside it.
*/
class ProjectManager(globalConfigurationManager: GlobalConfigurationManager) {
private val packageManager = PackageManager.Default
/**
* Creates a new project at the specified path with the given name.
*
* If the version is not provided, the default Enso engine version is used.
*
* @param name specifies the name of the project
* @param path specifies where the project should be created
* @param ensoVersion if provided, specifies an exact Enso version that the
* project should be associated with
*/
def newProject(
name: String,
path: Path,
ensoVersion: Option[SemVer] = None
): Unit = {
packageManager.create(
root = path.toFile,
name = name,
ensoVersion = SemVerEnsoVersion(
ensoVersion.getOrElse(globalConfigurationManager.defaultVersion)
)
)
Logger.info(s"Project created in `$path`.")
}
/**
* Tries to load the project at the provided `path`.
*/
def loadProject(path: Path): Try[Project] =
packageManager
.loadPackage(path.toFile)
.map(new Project(_, globalConfigurationManager))
.recoverWith(error => Failure(ProjectLoadingError(path, error)))
/**
* Traverses the directory tree looking for a project in one of the ancestors
* of the provided `path`.
*
* If a package file is missing in a directory, its ancestors are searched
* recursively. However if a package file exists in some directory, but there
* are errors preventing from loading it, that error is reported.
*/
def findProject(path: Path): Try[Option[Project]] =
tryFindingProject(path.toAbsolutePath.normalize).map(Some(_)).recover {
case PackageManager.PackageNotFound() => None
}
private def tryFindingProject(root: Path): Try[Project] =
packageManager
.loadPackage(root.toFile)
.map(new Project(_, globalConfigurationManager))
.recoverWith {
case PackageManager.PackageNotFound() if root.getParent != null =>
tryFindingProject(root.getParent)
case otherError => Failure(otherError)
}
}

View File

@ -1,3 +0,0 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 1.0.0
graal-java-version: 11

View File

@ -1,3 +0,0 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 1.0.0
graal-java-version: 11

View File

@ -1,3 +0,0 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 1.0.0
graal-java-version: 11

View File

@ -1,3 +1,12 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 1.0.0
graal-java-version: 11
jvm-options:
- value: "-Dtruffle.class.path.append=$enginePackagePath\\component\\runtime.jar"
os: "windows"
- value: "-Dtruffle.class.path.append=$enginePackagePath/component/runtime.jar"
os: "linux"
- value: "-Dtruffle.class.path.append=$enginePackagePath/component/runtime.jar"
os: "macos"
- value: "-Doptions-added-from-manifest=42"
- value: "-Xanother-one"

View File

@ -1,3 +0,0 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 2.0.0
graal-java-version: 11

View File

@ -1,3 +0,0 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 2.0.0
graal-java-version: 11

View File

@ -1,3 +0,0 @@
minimum-launcher-version: 0.0.1
graal-vm-version: 2.0.0
graal-java-version: 11

View File

@ -29,22 +29,28 @@ trait FakeEnvironment { self: WithTemporaryDirectory =>
/**
* Returns an [[Environment]] instance that overrides the `ENSO_*`
* directories to be inside the temporary directory for the test.
*
* Additionall environment overrides may be passed that will also be added to
* the environment. Note, however, that the `ENSO_*` directories that are
* defined in this function take precedence over whatever is passed to
* `extraOverrides`.
*/
def fakeInstalledEnvironment(): Environment = {
def fakeInstalledEnvironment(
extraOverrides: Map[String, String] = Map.empty
): Environment = {
val executable = fakeExecutablePath()
val dataDir = getTestDirectory / "test_data"
val configDir = getTestDirectory / "test_config"
val binDir = getTestDirectory / "test_bin"
val env = extraOverrides
.updated("ENSO_DATA_DIRECTORY", dataDir.toString)
.updated("ENSO_CONFIG_DIRECTORY", configDir.toString)
.updated("ENSO_BIN_DIRECTORY", binDir.toString)
val fakeEnvironment = new Environment {
override def getPathToRunningExecutable: Path = executable
override def getEnvVar(key: String): Option[String] =
key match {
case "ENSO_DATA_DIRECTORY" => Some(dataDir.toString)
case "ENSO_CONFIG_DIRECTORY" => Some(configDir.toString)
case "ENSO_BIN_DIRECTORY" => Some(binDir.toString)
case _ => super.getEnvVar(key)
}
env.orElse(Function.unlift(super.getEnvVar)).lift(key)
}
fakeEnvironment

View File

@ -1,22 +0,0 @@
package org.enso.launcher
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
class LauncherSpec
extends AnyWordSpec
with Matchers
with WithTemporaryDirectory {
"new command" should {
"create a new project with correct structure" in {
Logger.suppressWarnings {
val projectDir = getTestDirectory.resolve("proj1")
Launcher.newProject("TEST", Some(projectDir))
projectDir.toFile should exist
projectDir.resolve("src").resolve("Main.enso").toFile should exist
}
}
}
}

View File

@ -1,16 +1,22 @@
package org.enso.launcher
import buildinfo.Info
import io.circe.parser
class NativeLauncherSpec extends NativeTest {
"native launcher" should {
"display its version" in {
val run = runLauncher(Seq("--version"))
val run = runLauncher(Seq("version", "--json", "--only-launcher"))
run should returnSuccess
run.stdout should include("Enso Launcher")
run.stdout should include("Version:")
run.stdout should include(Info.ensoVersion)
val version = parser.parse(run.stdout).getOrElse {
throw new RuntimeException("Version should be a valid JSON string.")
}
version.asObject.get
.apply("version")
.get
.asString
.get shouldEqual Info.ensoVersion
}
}
}

View File

@ -1,53 +1,9 @@
package org.enso.launcher.components
import java.nio.file.Path
import nl.gn0s1s.bump.SemVer
import org.enso.launcher.cli.GlobalCLIOptions
import org.enso.launcher.installation.DistributionManager
import org.enso.launcher.releases.{
EngineReleaseProvider,
GraalCEReleaseProvider
}
import org.enso.launcher.{FakeEnvironment, Logger, WithTemporaryDirectory}
import org.scalatest.OptionValues
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
import org.enso.launcher.Logger
class ComponentsManagerSpec
extends AnyWordSpec
with Matchers
with OptionValues
with WithTemporaryDirectory
with FakeEnvironment {
private def makeManagers(): (DistributionManager, ComponentsManager) = {
val distributionManager = new DistributionManager(
fakeInstalledEnvironment()
)
val fakeReleasesRoot =
Path.of(
getClass
.getResource("fake-releases")
.toURI
)
val engineProvider = new EngineReleaseProvider(
FakeReleaseProvider(fakeReleasesRoot.resolve("enso"))
)
val runtimeProvider = new GraalCEReleaseProvider(
FakeReleaseProvider(fakeReleasesRoot.resolve("graalvm"))
)
val componentsManager = new ComponentsManager(
GlobalCLIOptions(autoConfirm = true, hideProgress = true),
distributionManager,
engineProvider,
runtimeProvider
)
(distributionManager, componentsManager)
}
def makeComponentsManager(): ComponentsManager = makeManagers()._2
class ComponentsManagerSpec extends ComponentsManagerTest {
"ComponentsManager" should {
"find the latest engine version in semver ordering" in {
@ -59,7 +15,7 @@ class ComponentsManagerSpec
"install the engine and a matching runtime for it" in {
Logger.suppressWarnings {
val (distributionManager, componentsManager) = makeManagers()
val (distributionManager, componentsManager, _) = makeManagers()
val version = SemVer(0, 0, 1)
val engine = componentsManager.findOrInstallEngine(SemVer(0, 0, 1))

View File

@ -0,0 +1,69 @@
package org.enso.launcher.components
import java.nio.file.Path
import org.enso.launcher.cli.GlobalCLIOptions
import org.enso.launcher.installation.DistributionManager
import org.enso.launcher.releases.{
EngineReleaseProvider,
GraalCEReleaseProvider
}
import org.enso.launcher.{Environment, FakeEnvironment, WithTemporaryDirectory}
import org.scalatest.OptionValues
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
class ComponentsManagerTest
extends AnyWordSpec
with Matchers
with OptionValues
with WithTemporaryDirectory
with FakeEnvironment {
/**
* Creates the [[DistributionManager]], [[ComponentsManager]] and an
* [[Environment]] for use in the tests.
*
* Should be called separately for each test case, as the components use
* temporary directories which are separate for each test case.
*
* Additional environment variables may be provided that are added to the
* [[Environment]] for the created managers.
*/
def makeManagers(
environmentOverrides: Map[String, String] = Map.empty
): (DistributionManager, ComponentsManager, Environment) = {
val env = fakeInstalledEnvironment(environmentOverrides)
val distributionManager = new DistributionManager(env)
val fakeReleasesRoot =
Path.of(
getClass
.getResource("/org/enso/launcher/components/fake-releases")
.toURI
)
val engineProvider = new EngineReleaseProvider(
FakeReleaseProvider(
fakeReleasesRoot.resolve("enso"),
copyIntoArchiveRoot = Seq("manifest.yaml")
)
)
val runtimeProvider = new GraalCEReleaseProvider(
FakeReleaseProvider(fakeReleasesRoot.resolve("graalvm"))
)
val componentsManager = new ComponentsManager(
GlobalCLIOptions(autoConfirm = true, hideProgress = true),
distributionManager,
engineProvider,
runtimeProvider
)
(distributionManager, componentsManager, env)
}
/**
* Returns just the [[ComponentsManager]].
*
* See [[makeManagers]] for details.
*/
def makeComponentsManager(): ComponentsManager = makeManagers()._2
}

View File

@ -1,22 +1,36 @@
package org.enso.launcher.components
import java.nio.file.{Files, Path}
import java.nio.file.{Files, Path, StandardCopyOption}
import org.enso.cli.{ProgressListener, TaskProgress}
import org.enso.launcher.{FileSystem, OS}
import org.enso.launcher.releases.{
Asset,
Release,
ReleaseProvider,
ReleaseProviderException
}
import org.enso.launcher.{FileSystem, OS}
import scala.io.Source
import scala.sys.process._
import scala.util.{Success, Try, Using}
import sys.process._
case class FakeReleaseProvider(releasesRoot: Path) extends ReleaseProvider {
private val releases = FileSystem.listDirectory(releasesRoot).map(FakeRelease)
/**
* A release provider that creates fake releases from the defined resources.
*
* @param releasesRoot path to the directory containing subdirectories for each
* release
* @param copyIntoArchiveRoot list of filenames that will be copied to the root
* of each created archive
*/
case class FakeReleaseProvider(
releasesRoot: Path,
copyIntoArchiveRoot: Seq[String] = Seq.empty
) extends ReleaseProvider {
private val releases =
FileSystem
.listDirectory(releasesRoot)
.map(FakeRelease(_, copyIntoArchiveRoot))
/**
* @inheritdoc
@ -33,7 +47,15 @@ case class FakeReleaseProvider(releasesRoot: Path) extends ReleaseProvider {
override def listReleases(): Try[Seq[Release]] = Success(releases)
}
case class FakeRelease(path: Path) extends Release {
/**
* The release created by [[FakeReleaseProvider]].
*
* @param path path to the release root, each file or directory inside of it
* represents a [[FakeAsset]]
* @param copyIntoArchiveRoot list of
*/
case class FakeRelease(path: Path, copyIntoArchiveRoot: Seq[String] = Seq.empty)
extends Release {
/**
* @inheritdoc
@ -43,11 +65,26 @@ case class FakeRelease(path: Path) extends Release {
/**
* @inheritdoc
*/
override def assets: Seq[Asset] =
FileSystem.listDirectory(path).map(FakeAsset)
override def assets: Seq[Asset] = {
val pathsToCopy = copyIntoArchiveRoot.map(path.resolve)
FileSystem.listDirectory(path).map(FakeAsset(_, pathsToCopy))
}
}
case class FakeAsset(source: Path) extends Asset {
/**
* Represents an asset of the [[FakeRelease]].
*
* If it is a file, 'downloading' it just copies it to the destination.
* Fetching it reads it as text.
*
* If it is a directory, it is treated as a fake archive - an archive with all
* files contained within that directory is created at the specified
* destination. Any paths from `copyIntoArchiveRoot` are also added into the
* root of that created archive. This allows to avoid maintaining additional
* copies of shared files like the manifest.
*/
case class FakeAsset(source: Path, copyIntoArchiveRoot: Seq[Path] = Seq.empty)
extends Asset {
/**
* @inheritdoc
@ -69,33 +106,62 @@ case class FakeAsset(source: Path) extends Asset {
}
private def copyFakeAsset(destination: Path): Unit =
if (Files.isDirectory(source)) {
val directoryName = source.getFileName.toString
if (directoryName.endsWith(".tar.gz") && OS.isUNIX)
packTarGz(source, destination)
else if (directoryName.endsWith(".zip") && OS.isWindows)
packZip(source, destination)
else {
throw new IllegalArgumentException(
s"Fake-archive format $directoryName is not supported on " +
s"${OS.operatingSystem}."
if (Files.isDirectory(source))
copyArchive(destination)
else
copyNormalFile(destination)
private def copyArchive(destination: Path): Unit = {
val directoryName = source.getFileName.toString
lazy val innerRoot = {
val roots = FileSystem.listDirectory(source).filter(Files.isDirectory(_))
if (roots.length > 1) {
throw new IllegalStateException(
"Cannot copy files into the root if there are more than one root."
)
}
} else {
FileSystem.copyFile(source, destination)
roots.headOption.getOrElse(source)
}
for (sourceToCopy <- copyIntoArchiveRoot) {
Files.copy(
sourceToCopy,
innerRoot.resolve(sourceToCopy.getFileName),
StandardCopyOption.REPLACE_EXISTING
)
}
if (directoryName.endsWith(".tar.gz") && OS.isUNIX)
packTarGz(source, destination)
else if (directoryName.endsWith(".zip") && OS.isWindows)
packZip(source, destination)
else {
throw new IllegalArgumentException(
s"Fake-archive format $directoryName is not supported on " +
s"${OS.operatingSystem}."
)
}
}
private def copyNormalFile(destination: Path): Unit =
FileSystem.copyFile(source, destination)
/**
* @inheritdoc
*/
override def fetchAsText(): TaskProgress[String] = {
val txt = Using(Source.fromFile(source.toFile)) { src =>
src.getLines().mkString("\n")
override def fetchAsText(): TaskProgress[String] =
if (Files.isDirectory(source))
throw new IllegalStateException(
"Cannot fetch a fake archive (a directory) as text."
)
else {
val txt = Using(Source.fromFile(source.toFile)) { src =>
src.getLines().mkString("\n")
}
(listener: ProgressListener[String]) => {
listener.done(txt)
}
}
(listener: ProgressListener[String]) => {
listener.done(txt)
}
}
private def packTarGz(source: Path, destination: Path): Unit = {
val files = FileSystem.listDirectory(source)

View File

@ -0,0 +1,358 @@
package org.enso.launcher.components.runner
import java.nio.file.{Files, Path}
import java.util.UUID
import nl.gn0s1s.bump.SemVer
import org.enso.launcher.FileSystem.PathSyntax
import org.enso.launcher.{GlobalConfigurationManager, Logger}
import org.enso.launcher.components.ComponentsManagerTest
import org.enso.launcher.project.ProjectManager
class RunnerSpec extends ComponentsManagerTest {
private val defaultEngineVersion = SemVer(0, 0, 0, Some("default"))
case class TestSetup(runner: Runner, projectManager: ProjectManager)
def makeFakeRunner(
cwdOverride: Option[Path] = None,
extraEnv: Map[String, String] = Map.empty
): TestSetup = {
val (_, componentsManager, env) = makeManagers(extraEnv)
val configurationManager =
new GlobalConfigurationManager(componentsManager) {
override def defaultVersion: SemVer = defaultEngineVersion
}
val projectManager = new ProjectManager(configurationManager)
val cwd = cwdOverride.getOrElse(getTestDirectory)
val runner =
new Runner(projectManager, configurationManager, componentsManager, env) {
override protected val currentWorkingDirectory: Path = cwd
}
TestSetup(runner, projectManager)
}
"Runner" should {
"create a command from settings" in {
Logger.suppressWarnings {
val envOptions = "-Xfrom-env -Denv=env"
val TestSetup(runner, _) =
makeFakeRunner(extraEnv = Map("ENSO_JVM_OPTS" -> envOptions))
val runSettings = RunSettings(SemVer(0, 0, 0), Seq("arg1", "--flag2"))
val jvmOptions = Seq(("locally-added-options", "value1"))
val systemCommand = runner.createCommand(
runSettings,
JVMSettings(useSystemJVM = true, jvmOptions = jvmOptions)
)
systemCommand.command.head shouldEqual "java"
val managedCommand = runner.createCommand(
runSettings,
JVMSettings(useSystemJVM = false, jvmOptions = jvmOptions)
)
managedCommand.command.head should include("java")
managedCommand.extraEnv.find(_._1 == "JAVA_HOME").value._2 should
include("graalvm-ce")
val enginePath =
getTestDirectory / "test_data" / "dist" / "0.0.0"
val runtimePath =
(enginePath / "component" / "runtime.jar").toAbsolutePath.normalize
val runnerPath =
(enginePath / "component" / "runner.jar").toAbsolutePath.normalize
for (command <- Seq(systemCommand, managedCommand)) {
val commandLine = command.command.mkString(" ")
val arguments = command.command.tail
arguments should contain("-Xfrom-env")
arguments should contain("-Denv=env")
arguments should contain("-Dlocally-added-options=value1")
arguments should contain("-Dlocally-added-options=value1")
arguments should contain("-Doptions-added-from-manifest=42")
arguments should contain("-Xanother-one")
commandLine should endWith("arg1 --flag2")
arguments should contain(s"-Dtruffle.class.path.append=$runtimePath")
arguments.filter(
_.contains("truffle.class.path.append")
) should have length 1
commandLine should include(s"-jar $runnerPath")
}
}
}
"run repl with default version and additional arguments" in {
val TestSetup(runner, _) = makeFakeRunner()
val runSettings = runner
.repl(
projectPath = None,
versionOverride = None,
additionalArguments = Seq("arg", "--flag")
)
.get
runSettings.version shouldEqual defaultEngineVersion
runSettings.runnerArguments should (contain("arg") and contain("--flag"))
runSettings.runnerArguments.mkString(" ") should
(include("--repl") and not include (s"--in-project"))
}
"run repl in project context" in {
val TestSetup(runnerOutside, projectManager) = makeFakeRunner()
val version = SemVer(0, 0, 0, Some("repl-test"))
version should not equal defaultEngineVersion // sanity check
val projectPath = getTestDirectory / "project"
val normalizedPath = projectPath.toAbsolutePath.normalize.toString
projectManager.newProject(
"test",
projectPath,
Some(version)
)
val outsideProject = runnerOutside
.repl(
projectPath = Some(projectPath),
versionOverride = None,
additionalArguments = Seq()
)
.get
outsideProject.version shouldEqual version
outsideProject.runnerArguments.mkString(" ") should
(include(s"--in-project $normalizedPath") and include("--repl"))
val TestSetup(runnerInside, _) = makeFakeRunner(Some(projectPath))
val insideProject = runnerInside
.repl(
projectPath = None,
versionOverride = None,
additionalArguments = Seq()
)
.get
insideProject.version shouldEqual version
insideProject.runnerArguments.mkString(" ") should
(include(s"--in-project $normalizedPath") and include("--repl"))
val overridden = SemVer(0, 0, 0, Some("overridden"))
val overriddenRun = runnerInside
.repl(
projectPath = Some(projectPath),
versionOverride = Some(overridden),
additionalArguments = Seq()
)
.get
overriddenRun.version shouldEqual overridden
overriddenRun.runnerArguments.mkString(" ") should
(include(s"--in-project $normalizedPath") and include("--repl"))
}
"run language server" in {
val TestSetup(runner, projectManager) = makeFakeRunner()
val version = SemVer(0, 0, 0, Some("language-server-test"))
val projectPath = getTestDirectory / "project"
projectManager.newProject(
"test",
projectPath,
Some(version)
)
val options = LanguageServerOptions(
rootId = UUID.randomUUID(),
path = projectPath,
interface = "127.0.0.2",
rpcPort = 1234,
dataPort = 4321
)
val runSettings = runner
.languageServer(
options,
versionOverride = None,
additionalArguments = Seq("additional")
)
.get
runSettings.version shouldEqual version
val commandLine = runSettings.runnerArguments.mkString(" ")
commandLine should include(s"--interface ${options.interface}")
commandLine should include(s"--rpc-port ${options.rpcPort}")
commandLine should include(s"--data-port ${options.dataPort}")
commandLine should include(s"--root-id ${options.rootId}")
val normalizedPath = options.path.toAbsolutePath.normalize.toString
commandLine should include(s"--path $normalizedPath")
runSettings.runnerArguments.lastOption.value shouldEqual "additional"
val overridden = SemVer(0, 0, 0, Some("overridden"))
runner
.languageServer(
options,
versionOverride = Some(overridden),
additionalArguments = Seq()
)
.get
.version shouldEqual overridden
}
"run a project" in {
val TestSetup(runnerOutside, projectManager) = makeFakeRunner()
val version = SemVer(0, 0, 0, Some("run-test"))
val projectPath = getTestDirectory / "project"
val normalizedPath = projectPath.toAbsolutePath.normalize.toString
projectManager.newProject(
"test",
projectPath,
Some(version)
)
val outsideProject = runnerOutside
.run(
path = Some(projectPath),
versionOverride = None,
additionalArguments = Seq()
)
.get
outsideProject.version shouldEqual version
outsideProject.runnerArguments.mkString(" ") should
include(s"--run $normalizedPath")
val TestSetup(runnerInside, _) = makeFakeRunner(Some(projectPath))
val insideProject = runnerInside
.run(
path = None,
versionOverride = None,
additionalArguments = Seq()
)
.get
insideProject.version shouldEqual version
insideProject.runnerArguments.mkString(" ") should
include(s"--run $normalizedPath")
val overridden = SemVer(0, 0, 0, Some("overridden"))
val overriddenRun = runnerInside
.run(
path = Some(projectPath),
versionOverride = Some(overridden),
additionalArguments = Seq()
)
.get
overriddenRun.version shouldEqual overridden
overriddenRun.runnerArguments.mkString(" ") should
include(s"--run $normalizedPath")
assert(
runnerOutside
.run(
path = None,
versionOverride = None,
additionalArguments = Seq()
)
.isFailure,
"Running outside project without providing any paths should be an error"
)
}
"run a script outside of a project even if cwd is inside project" in {
val version = SemVer(0, 0, 0, Some("run-test"))
val projectPath = getTestDirectory / "project"
val TestSetup(runnerInside, projectManager) =
makeFakeRunner(cwdOverride = Some(projectPath))
projectManager.newProject(
"test",
projectPath,
Some(version)
)
val outsideFile = getTestDirectory / "Main.enso"
val normalizedPath = outsideFile.toAbsolutePath.normalize.toString
Files.copy(
projectPath / "src" / "Main.enso",
outsideFile
)
val runSettings = runnerInside
.run(
path = Some(outsideFile),
versionOverride = None,
additionalArguments = Seq()
)
.get
runSettings.version shouldEqual defaultEngineVersion
runSettings.runnerArguments.mkString(" ") should
(include(s"--run $normalizedPath") and (not(include("--in-project"))))
}
"run a script inside of a project" in {
val version = SemVer(0, 0, 0, Some("run-test"))
val projectPath = getTestDirectory / "project"
val normalizedProjectPath = projectPath.toAbsolutePath.normalize.toString
val TestSetup(runnerOutside, projectManager) = makeFakeRunner()
projectManager.newProject(
"test",
projectPath,
Some(version)
)
val insideFile = projectPath / "src" / "Main.enso"
val normalizedFilePath = insideFile.toAbsolutePath.normalize.toString
val runSettings = runnerOutside
.run(
path = Some(insideFile),
versionOverride = None,
additionalArguments = Seq()
)
.get
runSettings.version shouldEqual version
runSettings.runnerArguments.mkString(" ") should
(include(s"--run $normalizedFilePath") and
include(s"--in-project $normalizedProjectPath"))
}
"get default version outside of project" in {
val TestSetup(runner, _) = makeFakeRunner()
val (runSettings, whichEngine) = runner
.version(useJSON = true)
.get
runSettings.version shouldEqual defaultEngineVersion
runSettings.runnerArguments should
(contain("--version") and contain("--json"))
whichEngine shouldEqual WhichEngine.Default
}
"get project version inside of project" in {
val version = SemVer(0, 0, 0, Some("version-test"))
val projectPath = getTestDirectory / "project"
val name = "Testname"
val TestSetup(runnerInside, projectManager) =
makeFakeRunner(cwdOverride = Some(projectPath))
projectManager.newProject(
name,
projectPath,
Some(version)
)
val (runSettings, whichEngine) = runnerInside
.version(useJSON = false)
.get
runSettings.version shouldEqual version
runSettings.runnerArguments should
(contain("--version") and not(contain("--json")))
whichEngine shouldEqual WhichEngine.FromProject(name)
}
}
}

View File

@ -0,0 +1,47 @@
package org.enso.launcher.project
import nl.gn0s1s.bump.SemVer
import org.enso.launcher.{GlobalConfigurationManager, WithTemporaryDirectory}
import org.scalatest.Inside
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
class ProjectManagerSpec
extends AnyWordSpec
with Matchers
with WithTemporaryDirectory
with Inside {
private val defaultEnsoVersion = SemVer(0, 0, 0, Some("default"))
def makeProjectManager(): ProjectManager = {
val fakeConfigurationManager = new GlobalConfigurationManager(null) {
override def defaultVersion: SemVer = defaultEnsoVersion
}
new ProjectManager(fakeConfigurationManager)
}
"ProjectManager" should {
"create a new project with correct structure" in {
val projectManager = makeProjectManager()
val projectDir = getTestDirectory.resolve("proj1")
projectManager.newProject("Test Project", projectDir)
projectDir.toFile should exist
projectDir.resolve("src").resolve("Main.enso").toFile should exist
val project = projectManager.loadProject(projectDir).get
project.version shouldEqual defaultEnsoVersion
}
"find projects in parent directories" in {
val projectManager = makeProjectManager()
val projectDir = getTestDirectory.resolve("proj1")
projectManager.newProject("Test Project", projectDir)
projectManager.findProject(projectDir).get should be(defined)
projectManager.findProject(projectDir.resolve("src")).get should
be(defined)
projectManager.findProject(getTestDirectory).get should be(empty)
}
}
}

View File

@ -28,6 +28,7 @@ object Main {
private val DATA_PORT_OPTION = "data-port"
private val ROOT_ID_OPTION = "root-id"
private val ROOT_PATH_OPTION = "path"
private val IN_PROJECT_OPTION = "in-project"
private val VERSION_OPTION = "version"
private val JSON_OPTION = "json"
@ -99,6 +100,16 @@ object Main {
.longOpt(ROOT_PATH_OPTION)
.desc("Path to the content root.")
.build()
val inProjectOption = CliOption.builder
.hasArg(true)
.numberOfArgs(1)
.argName("project-path")
.longOpt(IN_PROJECT_OPTION)
.desc(
"Setting this option when running the REPL or an Enso script, runs it" +
"in context of the specified project."
)
.build()
val version = CliOption.builder
.longOpt(VERSION_OPTION)
.desc("Checks the version of the Enso executable.")
@ -120,6 +131,7 @@ object Main {
.addOption(dataPortOption)
.addOption(uuidOption)
.addOption(pathOption)
.addOption(inProjectOption)
.addOption(version)
.addOption(json)
@ -135,13 +147,10 @@ object Main {
new HelpFormatter().printHelp(LanguageInfo.ID, options)
/** Terminates the process with a failure exit code. */
private def exitFail(): Nothing = {
System.exit(1)
throw new IllegalStateException("impossible to reach here")
}
private def exitFail(): Nothing = sys.exit(1)
/** Terminates the process with a success exit code. */
private def exitSuccess(): Unit = System.exit(0)
private def exitSuccess(): Unit = sys.exit(0)
/**
* Handles the `--new` CLI option.
@ -156,9 +165,14 @@ object Main {
/**
* Handles the `--run` CLI option.
*
* If `path` is a directory, so a project is run, a conflicting (pointing to
* another project) `projectPath` should not be provided.
*
* @param path path of the project or file to execute
* @param projectPath if specified, the script is run in context of a
* project located at that path
*/
private def run(path: String): Unit = {
private def run(path: String, projectPath: Option[String]): Unit = {
val file = new File(path)
if (!file.exists) {
println(s"File $file does not exist.")
@ -166,8 +180,19 @@ object Main {
}
val projectMode = file.isDirectory
val packagePath =
if (projectMode) file.getAbsolutePath
else ""
if (projectMode) {
projectPath match {
case Some(inProject) if inProject != path =>
println(
"It is not possible to run a project in context of another " +
"project, please do not use the `--in-project` option for " +
"running projects."
)
exitFail()
case _ =>
}
file.getAbsolutePath
} else projectPath.getOrElse("")
val context = new ContextFactory().create(
packagePath,
System.in,
@ -258,9 +283,13 @@ object Main {
}
}
private def runMain(mainModule: Module, rootPkgPath: Option[File]): Value = {
private def runMain(
mainModule: Module,
rootPkgPath: Option[File],
mainMethodName: String = "main"
): Value = {
val mainCons = mainModule.getAssociatedConstructor
val mainFun = mainModule.getMethod(mainCons, "main")
val mainFun = mainModule.getMethod(mainCons, mainMethodName)
try {
mainFun.execute(mainCons.newInstance())
} catch {
@ -272,15 +301,25 @@ object Main {
/**
* Handles the `--repl` CLI option
*
* @param projectPath if specified, the REPL is run in context of a project
* at the given path
*/
private def runRepl(): Unit = {
val dummySourceToTriggerRepl = "main = Debug.breakpoint"
val replModuleName = "Repl"
private def runRepl(projectPath: Option[String]): Unit = {
val mainMethodName = "internal_repl_entry_point___"
val dummySourceToTriggerRepl = s"$mainMethodName = Debug.breakpoint"
val replModuleName = "Internal_Repl_Module___"
val packagePath = projectPath.getOrElse("")
val context =
new ContextFactory().create("", System.in, System.out, Repl(TerminalIO()))
new ContextFactory().create(
packagePath,
System.in,
System.out,
Repl(TerminalIO())
)
val mainModule =
context.evalModule(dummySourceToTriggerRepl, replModuleName)
runMain(mainModule, None)
runMain(mainModule, None, mainMethodName = mainMethodName)
exitSuccess()
}
@ -366,10 +405,13 @@ object Main {
createNew(line.getOptionValue(NEW_OPTION))
}
if (line.hasOption(RUN_OPTION)) {
run(line.getOptionValue(RUN_OPTION))
run(
line.getOptionValue(RUN_OPTION),
Option(line.getOptionValue(IN_PROJECT_OPTION))
)
}
if (line.hasOption(REPL_OPTION)) {
runRepl()
runRepl(Option(line.getOptionValue(IN_PROJECT_OPTION)))
}
if (line.hasOption(LANGUAGE_SERVER_OPTION)) {
runLanguageServer(line)

View File

@ -1,5 +1,6 @@
name: TestNonImportedOverloads
license: APLv2
enso-version: default
version: "0.0.1"
author: "Enso Team <contact@enso.org>"
maintainer: "Enso Team <contact@enso.org>"

View File

@ -1,5 +1,6 @@
name: TestNonImportedOwnMethods
license: APLv2
enso-version: default
version: "0.0.1"
author: "Enso Team <contact@enso.org>"
maintainer: "Enso Team <contact@enso.org>"

View File

@ -1,5 +1,6 @@
name: TestSimpleImports
license: APLv2
enso-version: default
version: "0.0.1"
author: "Enso Team <contact@enso.org>"
maintainer: "Enso Team <contact@enso.org>"

View File

@ -1,5 +1,6 @@
name: Test_Hiding_Error
license: APLv2
enso-version: default
version: "0.0.1"
author: "Enso Team <contact@enso.org>"
maintainer: "Enso Team <contact@enso.org>"

View File

@ -1,5 +1,6 @@
name: Test_Hiding_Success
license: APLv2
enso-version: default
version: "0.0.1"
author: "Enso Team <contact@enso.org>"
maintainer: "Enso Team <contact@enso.org>"

View File

@ -1,5 +1,6 @@
name: Test_Qualified_Error
license: APLv2
enso-version: default
version: "0.0.1"
author: "Enso Team <contact@enso.org>"
maintainer: "Enso Team <contact@enso.org>"

View File

@ -1,5 +1,6 @@
name: Test_Rename
license: APLv2
enso-version: default
version: "0.0.1"
author: "Enso Team <contact@enso.org>"
maintainer: "Enso Team <contact@enso.org>"

View File

@ -1,5 +1,6 @@
name: Test_Rename_Error
license: APLv2
enso-version: default
version: "0.0.1"
author: "Enso Team <contact@enso.org>"
maintainer: "Enso Team <contact@enso.org>"

View File

@ -71,8 +71,8 @@ class Application[Config](
topLevelOpts,
tokens,
Seq(),
isTopLevel = true,
command = Seq(commandName)
isTopLevel = true,
commandPrefix = Seq(commandName)
)
topLevelParseResult.flatMap {
case (run, restOfTokens) =>

View File

@ -23,19 +23,8 @@ case class Command[A](
*
* @param applicationName name of the application for usage
*/
def help(applicationName: String): String = {
val tableDivider = "\t"
val usages =
opts.commandLines().map(s"$applicationName $name$tableDivider" + _)
val firstLine = "Usage: "
val padding = " " * firstLine.length
val usage =
firstLine + usages.head +
usages.tail.map("\n" + padding + _).mkString + "\n"
comment + "\n" + usage +
opts.helpExplanations(addHelpOption = true).stripTrailing()
}
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

View File

@ -67,7 +67,7 @@ trait Opts[A] {
* the right moment to do final validation (for example, detecting missing
* options).
*/
private[cli] def result(): Either[List[String], A]
private[cli] def result(commandPrefix: Seq[String]): Either[List[String], A]
/**
* Lists options that should be printed in the usage [[commandLines]].
@ -191,6 +191,28 @@ trait Opts[A] {
val additionalText = if (additional.isEmpty) "" else "\n" + additional
optionsHelp + prefixedHelp + additionalText
}
/**
* Generates a help text for the command, including usage, available options
* and any additional help lines.
*
* @param commandPrefix list of command names that should prefix the
* commandline in usage. The first entry in that list
* should be the application name and the following
* entries are commands/subcommands.
*/
def help(commandPrefix: Seq[String]): String = {
val tableDivider = "\t"
val prefix = commandPrefix.mkString(" ")
val usages = commandLines().map(s"$prefix$tableDivider" + _)
val firstLine = "Usage: "
val padding = " " * firstLine.length
val usage =
firstLine + usages.head +
usages.tail.map("\n" + padding + _).mkString + "\n"
usage + helpExplanations(addHelpOption = true).stripTrailing()
}
}
/**

View File

@ -4,8 +4,9 @@ class AdditionalArguments(helpComment: String) extends BaseOpts[Seq[String]] {
var value: Seq[String] = Seq()
override private[cli] val additionalArguments = Some(args => value = args)
override private[cli] def result() = Right(value)
override private[cli] def reset(): Unit = value = Seq()
override private[cli] def result(commandPrefix: Seq[String]) = Right(value)
override private[cli] def reset(): Unit =
value = Seq()
override def availableOptionsHelp(): Seq[String] =
if (helpComment.nonEmpty) {

View File

@ -39,7 +39,7 @@ class Flag(
)
}
override private[cli] def result() = value
override private[cli] def result(commandPrefix: Seq[String]) = value
override def availableOptionsHelp(): Seq[String] =
short match {

View File

@ -29,5 +29,6 @@ class HiddenOpts[A](opts: Opts[A]) extends Opts[A] {
override private[cli] def reset(): Unit = opts.reset()
override private[cli] def result() = opts.result()
override private[cli] def result(commandPrefix: Seq[String]) =
opts.result(commandPrefix)
}

View File

@ -37,7 +37,7 @@ class OptionalParameter[A: Argument](
)
}
override private[cli] def result() = value
override private[cli] def result(commandPrefix: Seq[String]) = value
override def availableOptionsHelp(): Seq[String] = {
Seq(s"[--$name $metavar]\t$helpComment")

View File

@ -27,7 +27,7 @@ class OptionalPositionalArgument[A: Argument](
value = empty
}
override private[cli] def result() =
override private[cli] def result(commandPrefix: Seq[String]) =
value
override def additionalHelp(): Seq[String] = helpComment.toSeq

View File

@ -24,7 +24,9 @@ class OptsMap[A, B](a: Opts[A], f: A => B) extends Opts[B] {
override private[cli] def reset(): Unit = a.reset()
override private[cli] def result() = a.result().map(f)
override private[cli] def result(
commandPrefix: Seq[String]
): Either[List[String], B] = a.result(commandPrefix).map(f)
override def availableOptionsHelp(): Seq[String] = a.availableOptionsHelp()
override def availablePrefixedParametersHelp(): Seq[String] =

View File

@ -42,10 +42,12 @@ class OptsProduct[A, B](lhs: Opts[A], rhs: Opts[B]) extends Opts[(A, B)] {
rhs.reset()
}
override private[cli] def result() =
override private[cli] def result(
commandPrefix: Seq[String]
): Either[List[String], (A, B)] =
for {
l <- lhs.result()
r <- rhs.result()
l <- lhs.result(commandPrefix)
r <- rhs.result(commandPrefix)
} yield (l, r)
override def availableOptionsHelp(): Seq[String] =

View File

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

View File

@ -36,7 +36,7 @@ class Parameter[A: Argument](
)
}
override private[cli] def result() =
override private[cli] def result(commandPrefix: Seq[String]) =
value.flatMap {
case Some(value) => Right(value)
case None => Left(List(s"Missing required parameter $name"))

View File

@ -96,8 +96,8 @@ object Parser {
* parsing top-level options, parsing is stopped to return
* the top-level options and possibly continue it with
* command options
* @param command the sequance of subcommand names, used for displaying help
* messages
* @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
@ -107,7 +107,7 @@ object Parser {
tokens: Seq[Token],
additionalArguments: Seq[String],
isTopLevel: Boolean,
command: Seq[String]
commandPrefix: Seq[String]
): Either[List[String], (A, Seq[Token])] = {
var parseErrors: List[String] = Nil
def addError(error: String): Unit = {
@ -243,16 +243,21 @@ object Parser {
def appendHelp[T](
result: Either[List[String], T]
): Either[List[String], T] = {
val help = s"See `${command.mkString(" ")} --help` for usage explanation."
val help =
s"See `${commandPrefix.mkString(" ")} --help` for usage explanation."
result match {
case Left(errors) => Left(errors ++ Seq(help))
case Left(errors) =>
val shouldAddHelp = !errors.exists(_.contains("Usage:"))
if (shouldAddHelp)
Left(errors ++ Seq(help))
else Left(errors)
case Right(value) => Right(value)
}
}
appendHelp(
appendErrors(
opts.result().map((_, tokenProvider.remaining())),
opts.result(commandPrefix).map((_, tokenProvider.remaining())),
parseErrors.reverse
)
)

View File

@ -27,7 +27,7 @@ class PositionalArgument[A: Argument](
value = empty
}
override private[cli] def result() =
override private[cli] def result(commandPrefix: Seq[String]) =
value.flatMap {
case Some(value) => Right(value)
case None => Left(List(s"Missing required argument <$metavar>."))

View File

@ -20,7 +20,8 @@ class PrefixedParameters(
currentValue = (name, value) :: currentValue
}
override private[cli] def result() = Right(currentValue.reverse)
override private[cli] def result(commandPrefix: Seq[String]) =
Right(currentValue.reverse)
override private[cli] def gatherPrefixedParameters =
Seq(prefix -> s"--$prefix.$keyMetavar=$valueMetavar")

View File

@ -37,7 +37,11 @@ class SubcommandOpt[A](subcommands: NonEmptyList[Subcommand[A]])
Spelling
.selectClosestMatches(arg, subcommands.toList.map(_.name))
val suggestions =
if (similar.isEmpty) ""
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
@ -61,14 +65,14 @@ class SubcommandOpt[A](subcommands: NonEmptyList[Subcommand[A]])
errors = Nil
}
override private[cli] def result() =
override private[cli] def result(commandPrefix: Seq[String]) =
if (errors.nonEmpty)
Left(errors.reverse)
else
selectedCommand match {
case Some(command) => command.opts.result()
case Some(command) => command.opts.result(commandPrefix)
case None =>
Left(List("Expected a subcommand."))
Left(List("Expected a subcommand.", help(commandPrefix)))
}
override def availableOptionsHelp(): Seq[String] =

View File

@ -24,7 +24,7 @@ class TrailingArguments[A: Argument](
value = empty
}
override private[cli] def result() =
override private[cli] def result(commandPrefix: Seq[String]) =
value.map(_.reverse)
override def additionalHelp(): Seq[String] = helpComment.toSeq

View File

@ -23,7 +23,13 @@ class OptsSpec
def parse(args: Seq[String]): Either[List[String], A] = {
val (tokens, additionalArguments) = Parser.tokenize(args)
Parser
.parseOpts(opts, tokens, additionalArguments, isTopLevel = false, Seq("???"))
.parseOpts(
opts,
tokens,
additionalArguments,
isTopLevel = false,
Seq("???")
)
.map(_._1)
}
@ -164,10 +170,10 @@ class OptsSpec
}
"parse when put anywhere between arguments" in {
val arg1 = Opts.positionalArgument[String]("arg1")
val arg2 = Opts.positionalArgument[String]("arg2")
val arg1 = Opts.positionalArgument[String]("arg1")
val arg2 = Opts.positionalArgument[String]("arg2")
val param = Opts.parameter[String]("p", "x", "help")
val opts = (arg1, arg2, param) mapN { (_, _, p) => p }
val opts = (arg1, arg2, param) mapN { (_, _, p) => p }
opts.parseSuccessfully("arg1 arg2 --p value") shouldEqual "value"
opts.parseSuccessfully("arg1 --p value arg2") shouldEqual "value"
opts.parseSuccessfully("--p value arg1 arg2") shouldEqual "value"
@ -222,13 +228,15 @@ class OptsSpec
"pure" should {
"return its value" in {
Opts.pure("A").parseSuccessfully("") shouldEqual("A")
Opts.pure("A").parseSuccessfully("") shouldEqual "A"
}
"not consume any options" in {
Opts.pure("A").parseFailing("arg").head should include("Unexpected")
Opts.pure("A").parseFailing("--flag").head should include("Unknown")
Opts.pure("A").parseFailing("--parameter=x").head should include("Unknown")
Opts.pure("A").parseFailing("--parameter=x").head should include(
"Unknown"
)
}
}
@ -265,23 +273,31 @@ class OptsSpec
opts.parseSuccessfully("cmd1") shouldEqual ((false, (1, false)))
opts.parseSuccessfully("--flag3 cmd1 --flag1") shouldEqual
((true, (1, true)))
((true, (1, true)))
opts.parseSuccessfully("cmd1 --flag3 --flag1") shouldEqual
((true, (1, true)))
((true, (1, true)))
opts.parseFailing("--flag1 cmd1")
}
"handle errors nicely" in {
opt.parseFailing("").last should include("Usage:")
opt.parseFailing("cmd").head should (include("cmd1") and include("cmd2"))
opt.parseFailing("cmd").last should include("--help")
}
}
"withDefault" should {
"return the default value if the result is missing" in {
Opts.optionalArgument[Int]("arg")
Opts
.optionalArgument[Int]("arg")
.withDefault(1)
.parseSuccessfully("") shouldEqual 1
}
"return the original value if provided" in {
Opts.optionalArgument[Int]("arg")
Opts
.optionalArgument[Int]("arg")
.withDefault(1)
.parseSuccessfully("0") shouldEqual 0
}

View File

@ -5,16 +5,37 @@ import io.circe.generic.auto._
import io.circe.{yaml, Decoder, Encoder, Json}
import io.circe.yaml.Printer
import scala.util.Try
case class Dependency(name: String, version: String)
/**
* Represents a package configuration stored in the `package.yaml` file.
*
* @param name package name
* @param version package version
* @param ensoVersion version of the Enso engine associated with the package,
* can be set to `default` which defaults to the locally
* installed version
* @param license package license
* @param author name and contact information of the package author(s)
* @param maintainer name and contact information of current package
* maintainer(s)
* @param dependencies a list of package dependencies
*/
case class Config(
name: String,
version: String,
ensoVersion: EnsoVersion,
license: String,
author: List[String],
maintainer: List[String],
dependencies: List[Dependency]
) {
/**
* Converts the configuration into a YAML representation.
*/
def toYaml: String =
Printer.spaces2.copy(preserveOrder = true).pretty(Config.encoder(this))
}
@ -23,6 +44,7 @@ object Config {
private object JsonFields {
val name: String = "name"
val version: String = "version"
val ensoVersion: String = "enso-version"
val license: String = "license"
val author: String = "author"
val maintainer: String = "maintainer"
@ -44,9 +66,11 @@ object Config {
implicit val decoder: Decoder[Config] = { json =>
for {
name <- json.get[String](JsonFields.name)
version <- json.get[String](JsonFields.version)
license <- json.getOrElse(JsonFields.license)("")
name <- json.get[String](JsonFields.name)
version <- json.getOrElse[String](JsonFields.version)("dev")
ensoVersion <-
json.getOrElse[EnsoVersion](JsonFields.ensoVersion)(DefaultEnsoVersion)
license <- json.getOrElse(JsonFields.license)("")
author <- json.getOrElse[List[String]](JsonFields.author)(List())(
decodeContactsList
)
@ -59,6 +83,7 @@ object Config {
} yield Config(
name,
version,
ensoVersion,
license,
author,
maintainer,
@ -68,11 +93,12 @@ object Config {
implicit val encoder: Encoder[Config] = { config =>
val base = Json.obj(
JsonFields.name -> config.name.asJson,
JsonFields.version -> config.version.asJson,
JsonFields.license -> config.license.asJson,
JsonFields.author -> encodeContactsList(config.author),
JsonFields.maintainer -> encodeContactsList(config.maintainer)
JsonFields.name -> config.name.asJson,
JsonFields.version -> config.version.asJson,
JsonFields.ensoVersion -> config.ensoVersion.asJson,
JsonFields.license -> config.license.asJson,
JsonFields.author -> encodeContactsList(config.author),
JsonFields.maintainer -> encodeContactsList(config.maintainer)
)
val withDeps =
if (config.dependencies.nonEmpty)
@ -83,7 +109,7 @@ object Config {
withDeps
}
def fromYaml(yamlString: String): Option[Config] = {
yaml.parser.parse(yamlString).flatMap(_.as[Config]).toOption
def fromYaml(yamlString: String): Try[Config] = {
yaml.parser.parse(yamlString).flatMap(_.as[Config]).toTry
}
}

View File

@ -0,0 +1,66 @@
package org.enso.pkg
import io.circe.syntax._
import io.circe.{Decoder, DecodingFailure, Encoder}
import nl.gn0s1s.bump.SemVer
/**
* Represents the engine version that is associated with a project.
*/
sealed trait EnsoVersion
/**
* Represents `default` Enso version.
*
* If `enso-version` is set to `default`, the locally default Enso engine
* version is used for the project.
*/
case object DefaultEnsoVersion extends EnsoVersion {
private val defaultEnsoVersion = "default"
/**
* @inheritdoc
*/
override def toString: String = defaultEnsoVersion
}
/**
* An exact semantic versioning string.
*/
case class SemVerEnsoVersion(version: SemVer) extends EnsoVersion {
/**
* @inheritdoc
*/
override def toString: String = version.toString
}
object EnsoVersion {
/**
* [[Decoder]] instance allowing to parse [[EnsoVersion]].
*/
implicit val decoder: Decoder[EnsoVersion] = { json =>
json.as[String].flatMap { string =>
if (string == DefaultEnsoVersion.toString)
Right(DefaultEnsoVersion)
else
SemVer(string)
.map(SemVerEnsoVersion)
.toRight(
DecodingFailure(
s"`$string` is not a valid version string. Possible values are " +
s"`default` or a semantic versioning string.",
json.history
)
)
}
}
/**
* [[Encoder]] instance allowing to convert [[EnsoVersion]] to JSON or YAML.
*/
implicit val encoder: Encoder[EnsoVersion] = { version =>
version.toString.asJson
}
}

View File

@ -2,11 +2,13 @@ package org.enso.pkg
import java.io.File
import cats.Show
import scala.jdk.CollectionConverters._
import org.enso.filesystem.FileSystem
import scala.io.Source
import scala.util.Try
import scala.util.{Failure, Try, Using}
object CouldNotCreateDirectory extends Exception
@ -41,7 +43,7 @@ case class Package[F](
* Sets the package name.
*
* @param newName the new package name
* @return a packge with the updated name
* @return a package with the updated name
*/
def setPackageName(newName: String): Package[F] =
this.copy(config = config.copy(name = newName))
@ -200,11 +202,13 @@ class PackageManager[F](implicit val fileSystem: FileSystem[F]) {
def create(
root: F,
name: String,
version: String = "0.0.1"
version: String = "0.0.1",
ensoVersion: EnsoVersion = DefaultEnsoVersion
): Package[F] = {
val config = Config(
name = normalizeName(name),
version = version,
ensoVersion = ensoVersion,
license = "",
author = List(),
maintainer = List(),
@ -217,18 +221,60 @@ class PackageManager[F](implicit val fileSystem: FileSystem[F]) {
* Tries to parse package structure from a given root location.
*
* @param root the root location to get package info from.
* @return `Some(pkg)` if the location represents a package, `None` otherwise.
* @return `Some(pkg)` if the location represents a package, `None`
* otherwise.
*/
def fromDirectory(root: F): Option[Package[F]] = {
if (!root.exists) return None
val configFile = root.getChild(Package.configFileName)
val reader = Try(configFile.newBufferedReader)
val resultStr = reader
.flatMap(rd => Try(rd.lines().iterator().asScala.mkString("\n")))
.toOption
val result = resultStr.flatMap(Config.fromYaml)
reader.map(_.close())
result.map(Package(root, _, fileSystem))
def fromDirectory(root: F): Option[Package[F]] =
loadPackage(root).toOption
/**
* Loads the package structure at the given root location and reports any
* errors.
*
* @param root the root location to get package info from.
* @return `Success(pkg)` if the location represents a valid package, and
* `Failure` otherwise. If the package file or its parent directory do
* not exist, a `PackageNotFound` exception is returned. Otherwise,
* the exception that made it not possible to load the package is
* returned.
*/
def loadPackage(root: F): Try[Package[F]] = {
val result =
if (!root.exists) Failure(PackageManager.PackageNotFound())
else {
def readConfig(file: F): Try[String] =
if (file.exists)
Using(file.newBufferedReader) { reader =>
reader.lines().iterator().asScala.mkString("\n")
}
else Failure(PackageManager.PackageNotFound())
val configFile = root.getChild(Package.configFileName)
for {
resultStr <- readConfig(configFile)
result <- Config.fromYaml(resultStr)
} yield Package(root, result, fileSystem)
}
result.recoverWith {
case packageLoadingException: PackageManager.PackageLoadingException =>
Failure(packageLoadingException)
case decodingError: io.circe.Error =>
val errorMessage =
implicitly[Show[io.circe.Error]].show(decodingError)
Failure(
PackageManager.PackageLoadingFailure(
s"Cannot decode the package config: $errorMessage",
decodingError
)
)
case otherError =>
Failure(
PackageManager.PackageLoadingFailure(
s"Cannot load the package: $otherError",
otherError
)
)
}
}
/**
@ -270,6 +316,30 @@ class PackageManager[F](implicit val fileSystem: FileSystem[F]) {
object PackageManager {
val Default = new PackageManager[File]()(FileSystem.Default)
/**
* A general exception indicating that a package cannot be loaded.
*/
class PackageLoadingException(message: String, cause: Throwable)
extends RuntimeException(message, cause) {
/**
* @inheritdoc
*/
override def toString: String = message
}
/**
* The error indicating that the requested package does not exist.
*/
case class PackageNotFound()
extends PackageLoadingException(s"The package file does not exist.", null)
/**
* The error indicating that the package exists, but cannot be loaded.
*/
case class PackageLoadingFailure(message: String, cause: Throwable)
extends PackageLoadingException(message, cause)
}
/**

View File

@ -0,0 +1,22 @@
package org.enso.pkg
import io.circe.{Decoder, DecodingFailure}
import nl.gn0s1s.bump.SemVer
object SemVerDecoder {
/**
* [[Decoder]] instance allowing to parse semantic versioning strings.
*/
implicit val semverDecoder: Decoder[SemVer] = { json =>
for {
string <- json.as[String]
version <- SemVer(string).toRight(
DecodingFailure(
s"`$string` is not a valid semantic versioning string.",
json.history
)
)
} yield version
}
}

View File

@ -10,22 +10,31 @@ trait VersionDescription {
if (useJson) asJSONString else asHumanReadableString
}
/**
* Defines an additional parameter for the version description.
*
* @param humanReadableName the human readable prefix added when printing this
* parameter in human-readable format
* @param jsonName the key when outputting the parameter in JSON format
* @param value the value to use for the parameter; depending on if the whole
* version description will be queried as a human-readable version
* or in JSON, this value should be in the right format
*/
case class VersionDescriptionParameter(
humanReadableName: String,
humandReadableValue: String,
jsonName: String,
jsonValue: String
value: String
)
object VersionDescription {
def formatParameterAsJSONString(
parameter: VersionDescriptionParameter
): String =
s""""${parameter.jsonName}": ${parameter.jsonValue}"""
s""""${parameter.jsonName}": ${parameter.value}"""
def formatParameterAsHumanReadableString(
parameter: VersionDescriptionParameter
): String =
s"${parameter.humanReadableName}: ${parameter.humandReadableValue}"
s"${parameter.humanReadableName}: ${parameter.value}"
def make(
header: String,

Some files were not shown because too many files have changed in this diff Show More