Skip to content
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
@@ -0,0 +1,7 @@
package zio.cli

private[cli] trait FileOptionsPlatformSpecific {
// Scala.js has no filesystem in the browser and only a virtual one in Node, so we default to a no-op
// and let users opt in to a custom implementation via `runWithFileArgs`.
val default: FileOptions = FileOptions.Noop
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package zio.cli

private[cli] trait FileOptionsPlatformSpecific {
// JVM has full filesystem access, so the live implementation walks dotfiles for real.
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,6 @@
package zio.cli

private[cli] trait FileOptionsPlatformSpecific {
// Scala Native exposes a usable subset of java.nio.file, so the live implementation works there too.
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
64 changes: 57 additions & 7 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,39 @@ import scala.annotation.tailrec
*/
sealed trait CliApp[-R, +E, +A] { self =>

def run(args: List[String]): ZIO[R, CliError[E], Option[A]]
/**
* Runs the CLI app, looking up dotfile defaults via the [[FileOptions]] service in the environment. This is the
* primitive runner — `run` and `runWithoutFileArgs` below are convenience wrappers that pin a specific
* [[FileOptions]] implementation.
*
* The current behaviour is:
* - if the underlying command has a single, deterministic top-level name (e.g. `git`, or `git push` whose root is
* `git`), [[FileOptions]] is asked for `.<name>` files in the cwd, all of its parents, and the user home
* directory, in that priority order;
* - if the root is an `OrElse` of distinct top-level commands (e.g. `(start | stop)`), there is no single name to
* anchor the lookup, so the file-options pass is silently skipped — addresses the design concern raised by the
* maintainer on the original PR (#317).
*
* Args originating from files are merged before user-supplied flags, with explicit command-line input always taking
* precedence; closer files (cwd) override farther ones (parents, then home).
*/
def runWithFileArgs(args: List[String]): ZIO[R & FileOptions, CliError[E], Option[A]]

/**
* Runs the CLI app with the platform-default [[FileOptions]] implementation: real file-system access on JVM and Scala
* Native, no-op on Scala.js. Source-compatible with pre-#191 callers — the only behavioural change is that
* `.<command>` files in the cwd / parents / home directory are now consulted on JVM/Native. Existing applications
* that don't put any such files on disk see no difference at runtime.
*/
final def run(args: List[String]): ZIO[R, CliError[E], Option[A]] =
runWithFileArgs(args).provideSomeLayer[R](ZLayer.succeed(FileOptions.default))

/**
* Runs the CLI app with [[FileOptions.Noop]] explicitly wired in, suppressing all dotfile lookups. Useful in tests,
* sandboxes, and any context where reading from the local filesystem is undesirable.
*/
final def runWithoutFileArgs(args: List[String]): ZIO[R, CliError[E], Option[A]] =
runWithFileArgs(args).provideSomeLayer[R](ZLayer.succeed[FileOptions](FileOptions.Noop))

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

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

def run(args: List[String]): ZIO[R, CliError[E], Option[A]] = {
def executeBuiltIn(builtInOption: BuiltInOption): 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 & FileOptions, CliError[E], Option[A]] =
builtInOption match {
case ShowHelp(synopsis, helpDoc) =>
val fancyName = p(code(self.figFont.render(self.name)))
Expand Down Expand Up @@ -109,13 +141,14 @@ object CliApp {
(for {
parameters <-
Wizard(command, config, fancyName + header + explanation).execute.mapError(CliError.BuiltIn(_))
output <- run(parameters)
output <- runWithFileArgs(parameters)
} yield output).catchSome { case CliError.BuiltIn(_) =>
ZIO.none
}
}

// prepend a first argument in case the CliApp's command is expected to consume it
// prepend a first argument in case the CliApp's command is expected to consume it; also serves as the
// unique-top-level-name probe consumed by file-options lookup below
@tailrec
def prefix(command: Command[_]): List[String] =
command match {
Expand All @@ -125,8 +158,25 @@ object CliApp {
case Command.Subcommands(parent, _) => prefix(parent)
}

self.command
.parse(prefix(self.command) ++ args, self.config)
val rootName = prefix(self.command)

// Only consult dotfiles when the root has a single deterministic name. For an `OrElse` root we'd otherwise be
// arbitrarily picking one alias — see Kalin-Rudnicki's review of #317 for the original framing.
// Avoid `case Some(name)` here: `name` is already a member of `CliAppImpl` and
// Scala 2.13 (with `-Werror`) refuses to silently shadow the outer scope.
val getFromFiles: ZIO[FileOptions, Nothing, List[FileOptions.OptionsFromFile]] =
rootName.headOption match {
case Some(cmdName) => ZIO.serviceWithZIO[FileOptions](_.getOptionsFromFiles(cmdName))
case None =>
ZIO.logDebug(
"Skipping file-options lookup: top-level command has no unique name (`OrElse` root)."
) *> ZIO.succeed(Nil)
}

getFromFiles.flatMap { fromFiles =>
self.command
.parse(rootName ++ args, self.config, fromFiles)
}
.foldZIO(
e => printDocs(e.error) *> ZIO.fail(CliError.Parsing(e)),
{
Expand Down
42 changes: 29 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,17 @@ 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]]
/**
* Parses `args` into a [[CommandDirective]]. The optional `fromFiles` parameter carries dotfile-derived option
* defaults — see [[FileOptions]] — which are merged before user-supplied flags, with the latter taking precedence.
* The default empty list preserves source compatibility for existing callers (tests, third-party code) that don't yet
* thread file options through.
*/
def parse(
args: List[String],
conf: CliConfig,
fromFiles: List[FileOptions.OptionsFromFile] = Nil
): IO[ValidationError, CommandDirective[A]]

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 +120,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 +169,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 +213,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 +234,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,14 +301,15 @@ object Command {

def parse(
args: List[String],
conf: CliConfig
conf: CliConfig,
fromFiles: List[FileOptions.OptionsFromFile]
): IO[ValidationError, CommandDirective[(A, B)]] = {
val helpDirectiveForChild =
if (args.isEmpty)
ZIO.fail(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty))
else
child
.parse(args.tail, conf)
.parse(args.tail, conf, fromFiles)
.collect(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) {
case CommandDirective.BuiltIn(BuiltInOption.ShowHelp(synopsis, helpDoc)) =>
val parentName = names.headOption.getOrElse("")
Expand All @@ -313,7 +329,7 @@ object Command {
ZIO.fail(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty))
else
child
.parse(args.tail, conf)
.parse(args.tail, conf, fromFiles)
.collect(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) {
case directive @ CommandDirective.BuiltIn(BuiltInOption.ShowWizard(_)) => directive
}
Expand All @@ -322,7 +338,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 @@ -331,7 +347,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
101 changes: 101 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,101 @@
package zio.cli

import zio._

import java.nio.file.Path

/**
* `FileOptions` is the service [[CliApp]] uses to look up command-line option defaults from "dotfiles" on disk.
*
* The contract is intentionally narrow: given the *unique* top-level command name (e.g. `git`), return the contents of
* any matching `.<name>` files found in a well-defined search path (current working directory, all of its parents, then
* the user home directory). Each line of those files is treated as a single CLI argument.
*
* Files closer to the working directory take precedence over more distant ones, and explicit command-line arguments
* always take precedence over file-derived ones. Files that do not exist are silently skipped; files that exist but are
* unreadable produce a debug log entry but do not abort parsing.
*
* The service abstraction allows callers to substitute [[FileOptions.Noop]] (e.g. in tests, in a sandbox, or on JS
* where there is no file system) without touching the `CliApp` itself.
*/
trait FileOptions {

/**
* Returns the dotfile contents associated with the given top-level command name, in priority order from highest (most
* specific / closest to the cwd) to lowest. Each [[FileOptions.OptionsFromFile]] carries the path it came from so
* downstream merging logic can report provenance.
*/
def getOptionsFromFiles(command: String): UIO[List[FileOptions.OptionsFromFile]]
}

object FileOptions extends FileOptionsPlatformSpecific {

/**
* A single dotfile worth of arguments. `path` is purely informational — used only for logging which file an
* overridden option originated from.
*/
final case class OptionsFromFile(path: String, rawArgs: List[String])

/**
* No-op implementation. Useful for tests and for the JS platform, which has no file-system access.
*/
case object Noop extends FileOptions {
override def getOptionsFromFiles(command: String): UIO[List[OptionsFromFile]] = ZIO.succeed(Nil)
}

/**
* Default JVM/Native implementation that walks the file system to discover `.<command>` files.
*
* Search order, highest priority first:
* 1. `<cwd>/.<command>`
* 2. `<each parent of cwd, walking upward>/.<command>`
* 3. `<user.home>/.<command>`
*
* This ordering means project-local overrides win over user-global defaults — the convention most CLI tools follow.
*/
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("Error reading options from files, skipping...", _).as(Nil))

}

}
Loading