Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for reading command-line options from file(s) (#191) #317

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ object CliAssertion {
)(implicit cliConfig: CliConfig): ZIO[R, Throwable, TestResult] =
check(pairs) { case CliRepr(params, assertion) =>
command
.parse(params, cliConfig)
.parse(params, cliConfig, Nil)
.map(Right(_))
.catchAll { case e: ValidationError =>
ZIO.succeed(Left(e))
Expand All @@ -110,7 +110,7 @@ object CliAssertion {
)(implicit cliConfig: CliConfig): ZIO[R, Throwable, TestResult] =
check(pairs) { case CliRepr(params, assertion) =>
command
.parse(params, cliConfig)
.parse(params, cliConfig, Nil)
.map(TestReturn.convert)
.map(Right(_))
.catchSome { case e: ValidationError =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package zio.cli

private[cli] trait FileOptionsPlatformSpecific {
val default: FileOptions = FileOptions.Noop
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package zio.cli

private[cli] trait FileOptionsPlatformSpecific {
val default: FileOptions = FileOptions.Live
}
3 changes: 3 additions & 0 deletions zio-cli/jvm/src/test/scala/zio/cli/LiveFileOptionsSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package zio.cli

object LiveFileOptionsSpec extends LiveFileOptionsSpecShared
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package zio.cli

private[cli] trait FileOptionsPlatformSpecific {
val default: FileOptions = FileOptions.Live
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package zio.cli

object LiveFileOptionsSpec extends LiveFileOptionsSpecShared
46 changes: 42 additions & 4 deletions zio-cli/shared/src/main/scala/zio/cli/CliApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,40 @@ import scala.annotation.tailrec
*/
sealed trait CliApp[-R, +E, +A] { self =>

def run(args: List[String]): ZIO[R, CliError[E], Option[A]]
/**
* [[CliApp]] will use [[FileOptions]] to look for [[Options]] in the file system (on JVM/Native).
*
* TODO : Add support for JS
*
* Example:
* - given:
* - base command: `git`
* - current working directory: `/path/cwd/a/b/c`
* - home directory: `/path/home`
* - then: [[FileOptions]] will look for [[Options]] in the following places:
* - provided command line args
* - `/path/cwd/a/b/c/.git`
* - `/path/cwd/a/b/.git`
* - `/path/cwd/a/.git`
* - `/path/cwd/.git`
* - `/path/.git`
* - `/path/home/.git`
*/
def runWithFileArgs(args: List[String]): ZIO[R & FileOptions, CliError[E], Option[A]]

/**
* Calls [[runWithFileArgs]] with the default [[FileOptions]] for the current platform.
*/
final def run(args: List[String]): ZIO[R, CliError[E], Option[A]] =
runWithFileArgs(args).provideSomeLayer[R](ZLayer.succeed(FileOptions.default))

/**
* Calls [[runWithFileArgs]] with the [[FileOptions.Noop]].
*
* This means no attempt will be made to pull [[Options]] from the file system.
*/
final def runWithoutFileArgs(args: List[String]): ZIO[R, CliError[E], Option[A]] =
runWithFileArgs(args).provideSomeLayer[R](ZLayer.succeed(FileOptions.Noop))

def config(newConfig: CliConfig): CliApp[R, E, A]

Expand Down Expand Up @@ -66,7 +99,7 @@ object CliApp {
private def printDocs(helpDoc: HelpDoc): UIO[Unit] =
printLine(helpDoc.toPlaintext(80)).!

def run(args: List[String]): ZIO[R, CliError[E], Option[A]] = {
override def runWithFileArgs(args: List[String]): ZIO[R & FileOptions, CliError[E], Option[A]] = {
def executeBuiltIn(builtInOption: BuiltInOption): ZIO[R, CliError[E], Option[A]] =
builtInOption match {
case ShowHelp(synopsis, helpDoc) =>
Expand Down Expand Up @@ -125,8 +158,13 @@ object CliApp {
case Command.Subcommands(parent, _) => prefix(parent)
}

self.command
.parse(prefix(self.command) ++ args, self.config)
(self.command.names.headOption match {
case Some(name) => ZIO.serviceWithZIO[FileOptions](_.getOptionsFromFiles(name))
case None => ZIO.succeed(Nil)
})
.flatMap((fromFiles: List[FileOptions.OptionsFromFile]) =>
self.command.parse(prefix(self.command) ++ args, self.config, fromFiles)
)
.foldZIO(
e => printDocs(e.error) *> ZIO.fail(CliError.Parsing(e)),
{
Expand Down
36 changes: 23 additions & 13 deletions zio-cli/shared/src/main/scala/zio/cli/Command.scala
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ sealed trait Command[+A] extends Parameter with Named { self =>

final def orElseEither[B](that: Command[B]): Command[Either[A, B]] = map(Left(_)) | that.map(Right(_))

def parse(args: List[String], conf: CliConfig): IO[ValidationError, CommandDirective[A]]
def parse(
args: List[String],
conf: CliConfig,
fromFiles: List[FileOptions.OptionsFromFile] = Nil
): IO[ValidationError, CommandDirective[A]]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it might be better to have fromFiles as the third parameter with a default value. Probably it's sign of a bad use, but there might be someone calling directly this method and would be a breaking change. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems reasonable


final def subcommands[B](that: Command[B])(implicit ev: Reducable[A, B]): Command[ev.Out] =
Command.Subcommands(self, that).map(ev.fromTuple2(_))
Expand Down Expand Up @@ -110,14 +114,15 @@ object Command {

def parse(
args: List[String],
conf: CliConfig
conf: CliConfig,
fromFiles: List[FileOptions.OptionsFromFile]
): IO[ValidationError, CommandDirective[(OptionsType, ArgsType)]] = {
def parseBuiltInArgs(args: List[String]): IO[ValidationError, CommandDirective[Nothing]] =
if (args.headOption.exists(conf.normalizeCase(_) == conf.normalizeCase(self.name))) {
val options = BuiltInOption
.builtInOptions(self, self.synopsis, self.helpDoc)
Options
.validate(options, args.tail, conf)
.validate(options, args.tail, conf, Nil)
.map(_._3)
.someOrFail(
ValidationError(
Expand Down Expand Up @@ -158,7 +163,7 @@ object Command {
}
tuple1 = splitForcedArgs(commandOptionsAndArgs)
(optionsAndArgs, forcedCommandArgs) = tuple1
tuple2 <- Options.validate(options, optionsAndArgs, conf)
tuple2 <- Options.validate(options, optionsAndArgs, conf, fromFiles)
(optionsError, commandArgs, optionsType) = tuple2
tuple <- self.args.validate(commandArgs ++ forcedCommandArgs, conf).mapError(optionsError.getOrElse(_))
(argsLeftover, argsType) = tuple
Expand Down Expand Up @@ -202,9 +207,10 @@ object Command {

def parse(
args: List[String],
conf: CliConfig
conf: CliConfig,
fromFiles: List[FileOptions.OptionsFromFile]
): IO[ValidationError, CommandDirective[B]] =
command.parse(args, conf).map(_.map(f))
command.parse(args, conf, fromFiles).map(_.map(f))

lazy val synopsis: UsageSynopsis = command.synopsis

Expand All @@ -222,9 +228,12 @@ object Command {

def parse(
args: List[String],
conf: CliConfig
conf: CliConfig,
fromFiles: List[FileOptions.OptionsFromFile]
): IO[ValidationError, CommandDirective[A]] =
left.parse(args, conf).catchSome { case ValidationError(CommandMismatch, _) => right.parse(args, conf) }
left.parse(args, conf, fromFiles).catchSome { case ValidationError(CommandMismatch, _) =>
right.parse(args, conf, fromFiles)
}

lazy val synopsis: UsageSynopsis = UsageSynopsis.Mixed

Expand Down Expand Up @@ -286,15 +295,16 @@ object Command {

def parse(
args: List[String],
conf: CliConfig
conf: CliConfig,
fromFiles: List[FileOptions.OptionsFromFile]
): IO[ValidationError, CommandDirective[(A, B)]] = {
val helpDirectiveForChild = {
val safeTail = args match {
case Nil => Nil
case _ :: tail => tail
}
child
.parse(safeTail, conf)
.parse(safeTail, conf, fromFiles)
.collect(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) {
case CommandDirective.BuiltIn(BuiltInOption.ShowHelp(synopsis, helpDoc)) =>
val parentName = names.headOption.getOrElse("")
Expand All @@ -316,7 +326,7 @@ object Command {
case _ :: tail => tail
}
child
.parse(safeTail, conf)
.parse(safeTail, conf, fromFiles)
.collect(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) {
case directive @ CommandDirective.BuiltIn(BuiltInOption.ShowWizard(_)) => directive
}
Expand All @@ -326,7 +336,7 @@ object Command {
ZIO.succeed(CommandDirective.builtIn(BuiltInOption.ShowWizard(self)))

parent
.parse(args, conf)
.parse(args, conf, fromFiles)
.flatMap {
case CommandDirective.BuiltIn(BuiltInOption.ShowHelp(_, _)) =>
helpDirectiveForChild orElse helpDirectiveForParent
Expand All @@ -335,7 +345,7 @@ object Command {
case builtIn @ CommandDirective.BuiltIn(_) => ZIO.succeed(builtIn)
case CommandDirective.UserDefined(leftover, a) if leftover.nonEmpty =>
child
.parse(leftover, conf)
.parse(leftover, conf, fromFiles)
.mapBoth(
{
case ValidationError(CommandMismatch, _) =>
Expand Down
62 changes: 62 additions & 0 deletions zio-cli/shared/src/main/scala/zio/cli/FileOptions.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package zio.cli

import zio._
import java.nio.file.Path

trait FileOptions {
def getOptionsFromFiles(command: String): UIO[List[FileOptions.OptionsFromFile]]
}
object FileOptions extends FileOptionsPlatformSpecific {

final case class OptionsFromFile(path: String, rawArgs: List[String])

case object Noop extends FileOptions {
override def getOptionsFromFiles(command: String): UIO[List[OptionsFromFile]] = ZIO.succeed(Nil)
}

case object Live extends FileOptions {

private def optReadPath(path: Path): UIO[Option[FileOptions.OptionsFromFile]] =
(for {
_ <- ZIO.logDebug(s"Searching for file options in '$path'")
exists <- ZIO.attempt(path.toFile.exists())
pathString = path.toString
optContents <-
ZIO
.readFile(pathString)
.map(c => FileOptions.OptionsFromFile(pathString, c.split('\n').map(_.trim).filter(_.nonEmpty).toList))
.when(exists)
} yield optContents)
.catchAllCause(ZIO.logErrorCause(s"Error reading options from file '$path', skipping...", _).as(None))

private def getPathAndParents(path: Path): Task[List[Path]] =
for {
parentPath <- ZIO.attempt(Option(path.getParent))
parents <- parentPath match {
case Some(parentPath) => getPathAndParents(parentPath)
case None => ZIO.succeed(Nil)
}
} yield path :: parents

override def getOptionsFromFiles(command: String): UIO[List[OptionsFromFile]] =
(for {
cwd <- System.property("user.dir")
home <- System.property("user.home")
commandFile = s".$command"

pathsFromCWD <- cwd match {
case Some(cwd) => ZIO.attempt(Path.of(cwd)).flatMap(getPathAndParents)
case None => ZIO.succeed(Nil)
}
homePath <- ZIO.foreach(home)(p => ZIO.attempt(Path.of(p)))
allPaths = (pathsFromCWD ::: homePath.toList).distinct

argsFromFiles <- ZIO.foreach(allPaths) { path =>
ZIO.attempt(path.resolve(commandFile)).flatMap(optReadPath)
}
} yield argsFromFiles.flatten)
.catchAllCause(ZIO.logErrorCause(s"Error reading options from files, skipping...", _).as(Nil))

}

}
70 changes: 68 additions & 2 deletions zio-cli/shared/src/main/scala/zio/cli/Options.scala
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,69 @@ object Options extends OptionsPlatformSpecific {
.catchAll(e => ZIO.succeed((Some(e), input, Predef.Map.empty)))
}

private def mergeFileOptions(
queue: List[FileOptions.OptionsFromFile],
acc: Predef.Map[String, (String, List[String])],
options: List[Options[_] with Input],
conf: CliConfig
): IO[ValidationError, Predef.Map[String, (String, List[String])]] =
queue match {
case FileOptions.OptionsFromFile(path, args) :: tail =>
for {
tuple1 <- matchOptions(args, options, conf)
newMap <-
tuple1 match {
case (Some(e), _, _) => ZIO.fail(e)
case (_, rest, _) if rest.nonEmpty =>
ZIO.fail(
ValidationError(
ValidationErrorType.InvalidArgument,
HelpDoc.p("Files can only contain options, not Args") +
HelpDoc.p(s"Found args: ${rest.mkString(", ")}")
)
)
case (_, _, map) => ZIO.succeed(map.map { case (k, v) => (k, (path, v)) })
}
mergedPairs <-
ZIO.foreach((acc.keySet | newMap.keySet).toList.sorted.map(k => (k, acc.get(k), newMap.get(k)))) {
case (k, Some((accPath, _)), Some(newRes @ (newPath, _))) =>
ZIO.logDebug(s"option '$k' : options from path '$accPath' overridden by path '$newPath'") *>
ZIO.some(k -> newRes)
case (k, Some(accRes), None) =>
ZIO.some(k -> accRes)
case (k, None, Some(newRes)) =>
ZIO.some(k -> newRes)
case (_, None, None) =>
ZIO.none
}
mergedMap = mergedPairs.flatten.toMap
res <- mergeFileOptions(tail, mergedMap, options, conf)
} yield res
case Nil =>
ZIO.succeed(acc)
}

private def mergeFileAndInputOptions(
inputOptions: Predef.Map[String, List[String]],
fileOptions: Predef.Map[String, (String, List[String])]
): UIO[Predef.Map[String, List[String]]] =
ZIO
.foreach(
(fileOptions.keySet | inputOptions.keySet).toList.sorted.map(k => (k, fileOptions.get(k), inputOptions.get(k)))
) {
case (k, Some((accPath, _)), Some(inputRes)) =>
ZIO.logDebug(s"option '$k' : options from path '$accPath' overridden by command line") *>
ZIO.some(k -> inputRes)
case (k, Some((accPath, accArgs)), None) =>
ZIO.logInfo(s"option '$k' : using options from path '$accPath' - ${accArgs.mkString(", ")}") *>
ZIO.some(k -> accArgs)
case (k, None, Some(newRes)) =>
ZIO.some(k -> newRes)
case (_, None, None) =>
ZIO.none
}
.map(_.flatten.toMap)

/**
* `Options.validate` parses `args` for `options and returns an `Option[ValidationError]`, the leftover arguments and
* the constructed value of type `A`. The possible error inside `Option[ValidationError]` would only be triggered if
Expand All @@ -306,12 +369,15 @@ object Options extends OptionsPlatformSpecific {
def validate[A](
options: Options[A],
args: List[String],
conf: CliConfig
conf: CliConfig,
fromFiles: List[FileOptions.OptionsFromFile] = Nil
): IO[ValidationError, (Option[ValidationError], List[String], A)] =
for {
matched <- matchOptions(args, options.flatten, conf)
(error, commandArgs, matchedOptions) = matched
a <- options.validate(matchedOptions, conf).mapError(error.getOrElse(_))
mapFromFiles <- mergeFileOptions(fromFiles.reverse, Predef.Map.empty, options.flatten, conf)
mergedOptions <- mergeFileAndInputOptions(matchedOptions, mapFromFiles)
a <- options.validate(mergedOptions, conf).mapError(error.getOrElse(_))
} yield (error, commandArgs, a)

case object Empty extends Options[Unit] with Pipeline {
Expand Down
Loading
Loading