diff --git a/demo-videos/pr-558-demo-fullscreen-terminal.mp4 b/demo-videos/pr-558-demo-fullscreen-terminal.mp4 new file mode 100644 index 00000000..157cc398 Binary files /dev/null and b/demo-videos/pr-558-demo-fullscreen-terminal.mp4 differ diff --git a/docs/config-files.md b/docs/config-files.md new file mode 100644 index 00000000..d1a36048 --- /dev/null +++ b/docs/config-files.md @@ -0,0 +1,31 @@ +--- +id: config-files +title: "Configuration Files" +--- + +ZIO CLI can load default option values from dotfiles named `.`. + +For an app named `wc`, ZIO CLI will look for `.wc` in: + +1. The user's home directory. +2. The current directory's parent chain (from root down to the current working directory). + +Priority is: + +1. Command-line arguments (highest) +2. Current working directory dotfile +3. Parent directory dotfiles +4. Home directory dotfile (lowest) + +If the same option appears in multiple files, the highest-priority source wins. +If the same option appears on the CLI, the CLI value wins. + +Supported line formats in config files: + +- `--key=value` +- `--key value` +- `--flag` + +Blank lines and lines starting with `#` are ignored. + +When configuration is applied, ZIO CLI prints the resolved values and their source files. diff --git a/docs/sidebars.js b/docs/sidebars.js index f48957c1..6cfc974c 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -12,6 +12,7 @@ const sidebars = { "helpdoc", "built-in-commands", "cli-config", + "config-files", "auth", "bash-and-zsh-completion", "sbt-plugin", diff --git a/zio-cli/js/src/main/scala/zio/cli/config/ConfigFileResolverPlatformSpecific.scala b/zio-cli/js/src/main/scala/zio/cli/config/ConfigFileResolverPlatformSpecific.scala new file mode 100644 index 00000000..4998529f --- /dev/null +++ b/zio-cli/js/src/main/scala/zio/cli/config/ConfigFileResolverPlatformSpecific.scala @@ -0,0 +1,10 @@ +package zio.cli.config + +import zio._ + +private[cli] trait ConfigFileResolverPlatformSpecific { + def resolveAndParse(commandName: String): Task[List[ConfigOption]] = { + val _ = commandName + ZIO.succeed(Nil) + } +} diff --git a/zio-cli/jvm/src/main/scala/zio/cli/config/ConfigFileResolverPlatformSpecific.scala b/zio-cli/jvm/src/main/scala/zio/cli/config/ConfigFileResolverPlatformSpecific.scala new file mode 100644 index 00000000..c7cbab59 --- /dev/null +++ b/zio-cli/jvm/src/main/scala/zio/cli/config/ConfigFileResolverPlatformSpecific.scala @@ -0,0 +1,49 @@ +package zio.cli.config + +import zio._ + +import java.io.File +import scala.annotation.tailrec +import scala.io.Source + +private[cli] trait ConfigFileResolverPlatformSpecific { + + def resolveAndParse(commandName: String): Task[List[ConfigOption]] = + ZIO.attempt { + val fileName = s".$commandName" + val homeFile = Option(java.lang.System.getProperty("user.home")).map(home => new File(home, fileName)).toList + val cwd = new File(Option(java.lang.System.getProperty("user.dir")).getOrElse(".")).getAbsoluteFile + + val candidates = distinctByPath(homeFile ++ directoriesFromRootTo(cwd).map(new File(_, fileName))) + .filter(file => file.exists() && file.isFile) + + candidates.zipWithIndex.flatMap { case (file, priority) => + val source = Source.fromFile(file, "UTF-8") + val lines = + try source.getLines().toList + finally source.close() + ConfigParser.parseLines(lines, file.getAbsolutePath, priority) + } + } + + private def distinctByPath(files: List[File]): List[File] = { + val seen = scala.collection.mutable.HashSet.empty[String] + files.filter { file => + val path = file.getAbsolutePath + if (seen.contains(path)) false + else { + seen += path + true + } + } + } + + private def directoriesFromRootTo(cwd: File): List[File] = { + @tailrec + def loop(current: File, acc: List[File]): List[File] = + if (current == null) acc + else loop(current.getParentFile, current :: acc) + + loop(cwd, Nil) + } +} diff --git a/zio-cli/jvm/src/test/scala/zio/cli/config/ConfigFileResolverSpec.scala b/zio-cli/jvm/src/test/scala/zio/cli/config/ConfigFileResolverSpec.scala new file mode 100644 index 00000000..17374269 --- /dev/null +++ b/zio-cli/jvm/src/test/scala/zio/cli/config/ConfigFileResolverSpec.scala @@ -0,0 +1,104 @@ +package zio.cli.config + +import zio._ +import zio.cli._ +import zio.test.Assertion._ +import zio.test._ + +import java.nio.charset.StandardCharsets +import java.nio.file.{Files, Path} + +object ConfigFileResolverSpec extends ZIOSpecDefault { + + private def writeFile(path: Path, lines: List[String]): Task[Unit] = + ZIO.attempt { + Files.createDirectories(path.getParent) + Files.write(path, lines.mkString("\n").getBytes(StandardCharsets.UTF_8)) + () + } + + override def spec: Spec[TestEnvironment with Scope, Any] = + suite("ConfigFileResolverSpec")( + test("resolve from home + parent chain, with CLI args taking highest priority") { + val commandName = "dotcfg_test_app" + + for { + base <- ZIO.attempt(Files.createTempDirectory("zio-cli-config-test")) + home <- ZIO.attempt(Files.createDirectory(base.resolve("home"))) + root <- ZIO.attempt(Files.createDirectory(base.resolve("workspace"))) + parent <- ZIO.attempt(Files.createDirectory(root.resolve("parent"))) + cwd <- ZIO.attempt(Files.createDirectory(parent.resolve("cwd"))) + + _ <- writeFile(home.resolve(s".$commandName"), List("--line-ending=LF", "--color=blue")) + _ <- writeFile(root.resolve(s".$commandName"), List("--line-ending=CRLF")) + _ <- writeFile(parent.resolve(s".$commandName"), List("--verbosity=debug")) + _ <- writeFile(cwd.resolve(s".$commandName"), List("--line-ending=POSIX")) + + oldHome <- ZIO.attempt(Option(java.lang.System.getProperty("user.home"))) + oldDir <- ZIO.attempt(Option(java.lang.System.getProperty("user.dir"))) + + result <- ( + for { + _ <- ZIO.attempt(java.lang.System.setProperty("user.home", home.toString)) + _ <- ZIO.attempt(java.lang.System.setProperty("user.dir", cwd.toString)) + parsed <- ConfigFileResolver.resolveAndParse(commandName) + merged = ConfigMerger.merge(parsed, List("--line-ending", "CLI")) + } yield (parsed, merged) + ).ensuring( + ZIO.attempt { + oldHome match { + case Some(value) => java.lang.System.setProperty("user.home", value) + case None => java.lang.System.clearProperty("user.home") + } + oldDir match { + case Some(value) => java.lang.System.setProperty("user.dir", value) + case None => java.lang.System.clearProperty("user.dir") + } + }.orDie + ) + + (parsed, merged) = result + } yield assert(parsed.map(_.key).toSet)(equalTo(Set("--line-ending", "--color", "--verbosity"))) && + assert(merged.toSet)(equalTo(Set("--color=blue", "--verbosity=debug", "--line-ending", "CLI"))) + }, + test("CliApp consumes config values when option is not passed on CLI") { + val appName = "configdriven" + + val command = Command("configdriven", Options.text("line-ending")) + + val app = CliApp.make( + name = appName, + version = "0.0.1", + summary = HelpDoc.Span.text("config test"), + command = command + )(lineEnding => ZIO.succeed(lineEnding)) + + for { + base <- ZIO.attempt(Files.createTempDirectory("zio-cli-config-app")) + _ <- writeFile(base.resolve(s".$appName"), List("--line-ending=LF")) + + oldHome <- ZIO.attempt(Option(java.lang.System.getProperty("user.home"))) + oldDir <- ZIO.attempt(Option(java.lang.System.getProperty("user.dir"))) + + result <- ( + for { + _ <- ZIO.attempt(java.lang.System.setProperty("user.home", base.toString)) + _ <- ZIO.attempt(java.lang.System.setProperty("user.dir", base.toString)) + output <- app.run(Nil) + } yield output + ).ensuring( + ZIO.attempt { + oldHome match { + case Some(value) => java.lang.System.setProperty("user.home", value) + case None => java.lang.System.clearProperty("user.home") + } + oldDir match { + case Some(value) => java.lang.System.setProperty("user.dir", value) + case None => java.lang.System.clearProperty("user.dir") + } + }.orDie + ) + } yield assert(result)(isSome(equalTo("LF"))) + } + ) @@ TestAspect.sequential +} diff --git a/zio-cli/native/src/main/scala/zio/cli/config/ConfigFileResolverPlatformSpecific.scala b/zio-cli/native/src/main/scala/zio/cli/config/ConfigFileResolverPlatformSpecific.scala new file mode 100644 index 00000000..c7cbab59 --- /dev/null +++ b/zio-cli/native/src/main/scala/zio/cli/config/ConfigFileResolverPlatformSpecific.scala @@ -0,0 +1,49 @@ +package zio.cli.config + +import zio._ + +import java.io.File +import scala.annotation.tailrec +import scala.io.Source + +private[cli] trait ConfigFileResolverPlatformSpecific { + + def resolveAndParse(commandName: String): Task[List[ConfigOption]] = + ZIO.attempt { + val fileName = s".$commandName" + val homeFile = Option(java.lang.System.getProperty("user.home")).map(home => new File(home, fileName)).toList + val cwd = new File(Option(java.lang.System.getProperty("user.dir")).getOrElse(".")).getAbsoluteFile + + val candidates = distinctByPath(homeFile ++ directoriesFromRootTo(cwd).map(new File(_, fileName))) + .filter(file => file.exists() && file.isFile) + + candidates.zipWithIndex.flatMap { case (file, priority) => + val source = Source.fromFile(file, "UTF-8") + val lines = + try source.getLines().toList + finally source.close() + ConfigParser.parseLines(lines, file.getAbsolutePath, priority) + } + } + + private def distinctByPath(files: List[File]): List[File] = { + val seen = scala.collection.mutable.HashSet.empty[String] + files.filter { file => + val path = file.getAbsolutePath + if (seen.contains(path)) false + else { + seen += path + true + } + } + } + + private def directoriesFromRootTo(cwd: File): List[File] = { + @tailrec + def loop(current: File, acc: List[File]): List[File] = + if (current == null) acc + else loop(current.getParentFile, current :: acc) + + loop(cwd, Nil) + } +} diff --git a/zio-cli/shared/src/main/scala/zio/cli/CliApp.scala b/zio-cli/shared/src/main/scala/zio/cli/CliApp.scala index ce9be328..38a9cd86 100644 --- a/zio-cli/shared/src/main/scala/zio/cli/CliApp.scala +++ b/zio-cli/shared/src/main/scala/zio/cli/CliApp.scala @@ -7,6 +7,7 @@ import zio.cli.BuiltInOption._ import zio.cli.HelpDoc.Span.{code, text} import zio.cli.HelpDoc.{h1, p} import zio.cli.completion.{Completion, CompletionScript} +import zio.cli.config.{ConfigDiagnostics, ConfigFileResolver, ConfigMerger} import zio.cli.figlet.FigFont import scala.annotation.tailrec @@ -66,6 +67,12 @@ object CliApp { private def printDocs(helpDoc: HelpDoc): UIO[Unit] = printLine(helpDoc.toPlaintext(80)).! + private def usesBuiltInOnly(args: List[String]): Boolean = + args.exists { + case "-h" | "--help" | "--wizard" | "--compgen" | "--generate-completion" => true + case _ => false + } + def run(args: List[String]): ZIO[R, CliError[E], Option[A]] = { def executeBuiltIn(builtInOption: BuiltInOption): ZIO[R, CliError[E], Option[A]] = builtInOption match { @@ -80,9 +87,18 @@ object CliApp { .map(HelpDoc.p) .foldRight(HelpDoc.empty)(_ + _) + val configHelpDoc = + h1("configuration") + + p( + s"Defaults may be loaded from .$name files found in the home directory and in the current working directory hierarchy." + ) + + p("Priority is: CLI arguments > current directory > parent directories > home directory.") + + p("Applied configuration values are printed with their source file.") + // TODO add rendering of built-in options such as help printLine( - (fancyName + header + synopsisHelpDoc + helpDoc + self.footer).toPlaintext(columnWidth = 300) + (fancyName + header + synopsisHelpDoc + helpDoc + configHelpDoc + self.footer) + .toPlaintext(columnWidth = 300) ).mapBoth(CliError.IO(_), _ => None) case ShowCompletionScript(path, shellType) => printLine( @@ -125,19 +141,34 @@ object CliApp { case Command.Subcommands(parent, _) => prefix(parent) } - self.command - .parse(prefix(self.command) ++ args, self.config) - .foldZIO( - e => printDocs(e.error) *> ZIO.fail(CliError.Parsing(e)), - { - case CommandDirective.UserDefined(_, value) => - self.execute(value).mapBoth(CliError.Execution(_), Some(_)) - case CommandDirective.BuiltIn(x) => - executeBuiltIn(x).catchSome { case err @ CliError.Parsing(e) => - printDocs(e.error) *> ZIO.fail(err) + val effectiveArgs: UIO[List[String]] = + if (usesBuiltInOnly(args)) ZIO.succeed(args) + else + ConfigFileResolver + .resolveAndParse(self.name) + .foldZIO( + _ => ZIO.succeed(args), + options => { + val (mergedArgs, diagnostics) = ConfigMerger.mergeWithDiagnostics(options, args) + ConfigDiagnostics.printDiagnostics(diagnostics) *> ZIO.succeed(mergedArgs) } - } - ) + ) + + effectiveArgs.flatMap { finalArgs => + self.command + .parse(prefix(self.command) ++ finalArgs, self.config) + .foldZIO( + e => printDocs(e.error) *> ZIO.fail(CliError.Parsing(e)), + { + case CommandDirective.UserDefined(_, value) => + self.execute(value).mapBoth(CliError.Execution(_), Some(_)) + case CommandDirective.BuiltIn(x) => + executeBuiltIn(x).catchSome { case err @ CliError.Parsing(e) => + printDocs(e.error) *> ZIO.fail(err) + } + } + ) + } } override def flatMap[R1 <: R, E1 >: E, B](f: A => ZIO[R1, E1, B]): CliApp[R1, E1, B] = diff --git a/zio-cli/shared/src/main/scala/zio/cli/config/ConfigDiagnostics.scala b/zio-cli/shared/src/main/scala/zio/cli/config/ConfigDiagnostics.scala new file mode 100644 index 00000000..23955e6a --- /dev/null +++ b/zio-cli/shared/src/main/scala/zio/cli/config/ConfigDiagnostics.scala @@ -0,0 +1,46 @@ +package zio.cli.config + +import zio._ + +final case class ConfigOption( + key: String, + arguments: List[String], + source: String, + priority: Int +) + +final case class ConfigConflict( + key: String, + overridden: List[ConfigOption], + winner: Option[ConfigOption], + cliOverride: Boolean +) + +final case class ConfigDiagnostics( + resolvedOptions: List[ConfigOption], + conflicts: List[ConfigConflict], + cliOverrides: List[String] +) { + def nonEmpty: Boolean = resolvedOptions.nonEmpty || conflicts.nonEmpty || cliOverrides.nonEmpty +} + +object ConfigDiagnostics { + + def printDiagnostics(diagnostics: ConfigDiagnostics): UIO[Unit] = { + val printResolved = ZIO.foreachDiscard(diagnostics.resolvedOptions) { option => + val rendered = option.arguments.mkString(" ") + Console.printLine(s"config: $rendered (from ${option.source})").ignore + } + + val printOverrides = ZIO.foreachDiscard(diagnostics.cliOverrides.distinct.sorted) { key => + Console.printLine(s"config: CLI overrides $key").ignore + } + + val printConflicts = ZIO.foreachDiscard(diagnostics.conflicts.filterNot(_.cliOverride)) { conflict => + val winnerSource = conflict.winner.map(_.source).getOrElse("unknown") + Console.printLine(s"config: resolved ${conflict.key} from $winnerSource").ignore + } + + (printResolved *> printOverrides *> printConflicts).when(diagnostics.nonEmpty).unit + } +} diff --git a/zio-cli/shared/src/main/scala/zio/cli/config/ConfigFileResolver.scala b/zio-cli/shared/src/main/scala/zio/cli/config/ConfigFileResolver.scala new file mode 100644 index 00000000..fc3ba43d --- /dev/null +++ b/zio-cli/shared/src/main/scala/zio/cli/config/ConfigFileResolver.scala @@ -0,0 +1,3 @@ +package zio.cli.config + +object ConfigFileResolver extends ConfigFileResolverPlatformSpecific diff --git a/zio-cli/shared/src/main/scala/zio/cli/config/ConfigMerger.scala b/zio-cli/shared/src/main/scala/zio/cli/config/ConfigMerger.scala new file mode 100644 index 00000000..67a1b1a0 --- /dev/null +++ b/zio-cli/shared/src/main/scala/zio/cli/config/ConfigMerger.scala @@ -0,0 +1,54 @@ +package zio.cli.config + +object ConfigMerger { + + def merge(fileOptions: List[ConfigOption], cliArgs: List[String]): List[String] = + mergeWithDiagnostics(fileOptions, cliArgs)._1 + + def mergeWithDiagnostics( + fileOptions: List[ConfigOption], + cliArgs: List[String] + ): (List[String], ConfigDiagnostics) = { + val cliKeys = cliArgs.flatMap(arg => if (arg.startsWith("-")) Some(ConfigParser.optionKey(arg)) else None).toSet + + val groupedByKey = fileOptions.groupBy(_.key) + + val resolvedByKey = groupedByKey.collect { + case (key, options) if !cliKeys.contains(key) => + key -> pickWinner(options) + } + + val resolvedOptions = + fileOptions.filter(option => resolvedByKey.get(option.key).contains(option)) + + val conflicts = groupedByKey.toList.sortBy(_._1).flatMap { case (key, options) => + val sortedByPriority = options.sortBy(_.priority) + if (cliKeys.contains(key)) { + Some(ConfigConflict(key, sortedByPriority, None, cliOverride = true)) + } else if (sortedByPriority.size > 1) { + val winner = pickWinner(sortedByPriority) + Some(ConfigConflict(key, sortedByPriority.filterNot(_ == winner), Some(winner), cliOverride = false)) + } else { + None + } + } + + val cliOverrides = conflicts.collect { case conflict if conflict.cliOverride => conflict.key } + + val mergedArgs = resolvedOptions.flatMap(_.arguments) ++ cliArgs + + ( + mergedArgs, + ConfigDiagnostics( + resolvedOptions = resolvedOptions, + conflicts = conflicts, + cliOverrides = cliOverrides + ) + ) + } + + private def pickWinner(options: List[ConfigOption]): ConfigOption = + options.reduceLeft { (current, next) => + if (next.priority >= current.priority) next else current + } +} diff --git a/zio-cli/shared/src/main/scala/zio/cli/config/ConfigParser.scala b/zio-cli/shared/src/main/scala/zio/cli/config/ConfigParser.scala new file mode 100644 index 00000000..42065b71 --- /dev/null +++ b/zio-cli/shared/src/main/scala/zio/cli/config/ConfigParser.scala @@ -0,0 +1,64 @@ +package zio.cli.config + +object ConfigParser { + + def parseLines(lines: List[String], source: String, priority: Int): List[ConfigOption] = + lines.flatMap(parseLine(_, source, priority)) + + private[config] def parseLine(line: String, source: String, priority: Int): Option[ConfigOption] = { + val trimmed = line.trim + + if (trimmed.isEmpty || trimmed.startsWith("#")) None + else { + val tokens = tokenize(trimmed) + + tokens match { + case Nil => + None + case head :: _ if !head.startsWith("-") => + None + case head :: Nil => + Some(ConfigOption(optionKey(head), head :: Nil, source, priority)) + case head :: _ if head.contains("=") => + None + case head :: tail => + Some(ConfigOption(optionKey(head), List(head, tail.mkString(" ")), source, priority)) + } + } + } + + private[config] def optionKey(token: String): String = { + val index = token.indexOf('=') + if (index > 0) token.substring(0, index) else token + } + + private[config] def tokenize(line: String): List[String] = { + val builder = scala.collection.mutable.ListBuffer.empty[String] + val current = new StringBuilder + + var inSingle = false + var inDouble = false + + def flush(): Unit = + if (current.nonEmpty) { + builder += current.toString + current.clear() + } + + line.foreach { ch => + ch match { + case '\'' if !inDouble => + inSingle = !inSingle + case '"' if !inSingle => + inDouble = !inDouble + case c if c.isWhitespace && !inSingle && !inDouble => + flush() + case c => + current.append(c) + } + } + + flush() + builder.toList + } +} diff --git a/zio-cli/shared/src/test/scala/zio/cli/config/ConfigParserMergerSpec.scala b/zio-cli/shared/src/test/scala/zio/cli/config/ConfigParserMergerSpec.scala new file mode 100644 index 00000000..8ee18682 --- /dev/null +++ b/zio-cli/shared/src/test/scala/zio/cli/config/ConfigParserMergerSpec.scala @@ -0,0 +1,63 @@ +package zio.cli.config + +import zio.Scope +import zio.test.Assertion._ +import zio.test._ + +object ConfigParserMergerSpec extends ZIOSpecDefault { + + override def spec: Spec[TestEnvironment with Scope, Any] = + suite("ConfigParserMergerSpec")( + test("parse --key=value") { + val parsed = ConfigParser.parseLines(List("--line-ending=\\n"), "/tmp/.wc", priority = 0) + assert(parsed)( + equalTo(List(ConfigOption("--line-ending", List("--line-ending=\\n"), "/tmp/.wc", 0))) + ) + }, + test("parse --key value and preserve spaces inside quotes") { + val parsed = ConfigParser.parseLines(List("--name \"john doe\""), "/tmp/.wc", priority = 1) + assert(parsed)( + equalTo(List(ConfigOption("--name", List("--name", "john doe"), "/tmp/.wc", 1))) + ) + }, + test("ignore comments and invalid lines") { + val parsed = ConfigParser.parseLines( + List("# comment", "", "line-ending=lf", "--ok"), + "/tmp/.wc", + priority = 2 + ) + + assert(parsed)(equalTo(List(ConfigOption("--ok", List("--ok"), "/tmp/.wc", 2)))) + }, + test("higher-priority files override lower-priority files") { + val options = List( + ConfigOption("--count", List("--count=1"), "/home/.wc", priority = 0), + ConfigOption("--count", List("--count=2"), "/work/.wc", priority = 1), + ConfigOption("--verbose", List("--verbose"), "/home/.wc", priority = 0) + ) + + val (merged, diagnostics) = ConfigMerger.mergeWithDiagnostics(options, Nil) + + assert(merged.toSet)(equalTo(Set("--count=2", "--verbose"))) && + assert(diagnostics.resolvedOptions.map(_.key).toSet)(equalTo(Set("--count", "--verbose"))) + }, + test("CLI args override file options") { + val options = List( + ConfigOption("--count", List("--count=1"), "/home/.wc", priority = 0), + ConfigOption("--verbose", List("--verbose"), "/home/.wc", priority = 0) + ) + + val (merged, diagnostics) = ConfigMerger.mergeWithDiagnostics(options, List("--count", "3")) + + assert(merged)(equalTo(List("--verbose", "--count", "3"))) && + assert(diagnostics.cliOverrides)(contains("--count")) + }, + test("CLI args are not reported as overrides when no file options are present") { + val (merged, diagnostics) = ConfigMerger.mergeWithDiagnostics(Nil, List("-a", "A", "-b", "B")) + + assert(merged)(equalTo(List("-a", "A", "-b", "B"))) && + assert(diagnostics.cliOverrides)(isEmpty) && + assert(diagnostics.conflicts)(isEmpty) + } + ) +}