diff --git a/zio-cli/shared/src/main/scala/zio/cli/Args.scala b/zio-cli/shared/src/main/scala/zio/cli/Args.scala index 24945dec..c58c21e2 100644 --- a/zio-cli/shared/src/main/scala/zio/cli/Args.scala +++ b/zio-cli/shared/src/main/scala/zio/cli/Args.scala @@ -43,6 +43,8 @@ sealed trait Args[+A] { self => final def between(min: Int, max: Int): Args[List[A]] = Args.Variadic(self, Some(min), Some(max)) + def generateArgs: UIO[List[String]] + def helpDoc: HelpDoc final def map[B](f: A => B): Args[B] = Args.Map(self, (a: A) => Right(f(a))) @@ -63,6 +65,8 @@ sealed trait Args[+A] { self => def synopsis: UsageSynopsis + def uid: Option[String] + def validate(args: List[String], conf: CliConfig): IO[ValidationError, (List[String], A)] } @@ -100,6 +104,11 @@ object Args { }).mapError(ValidationError(ValidationErrorType.InvalidArgument, _)) private def name: String = "<" + self.pseudoName.getOrElse(self.primType.typeName) + ">" + + def generateArgs: UIO[List[String]] = + (Console.print(s"${self.uid.getOrElse("")} (${self.primType.typeName}): ") *> Console.readLine).orDie.map(List(_)) + + def uid: Option[String] = Some(self.name) } case object Empty extends Args[Unit] { @@ -115,6 +124,10 @@ object Args { def validate(args: List[String], conf: CliConfig): UIO[(List[String], Unit)] = ZIO.succeed((args, ())) + + def generateArgs: UIO[List[String]] = ZIO.succeed(List.empty) + + def uid: Option[String] = None } final case class Both[+A, +B](head: Args[A], tail: Args[B]) extends Args[(A, B)] { self => @@ -135,6 +148,13 @@ object Args { tuple <- self.tail.validate(args, conf) (args, b) = tuple } yield (args, (a, b)) + + def generateArgs: UIO[List[String]] = self.head.generateArgs.zipWith(self.tail.generateArgs)(_ ++ _) + + def uid: Option[String] = self.head.uid.toList ++ self.tail.uid.toList match { + case Nil => None + case list => Some(list.mkString(", ")) + } } final case class Variadic[+A](value: Args[A], min: Option[Int], max: Option[Int]) extends Args[List[A]] { self => @@ -178,6 +198,23 @@ object Args { loop(args, Nil).map { case (args, list) => (args, list.reverse) } } + + def generateArgs: UIO[List[String]] = { + val repetitionsString = + (self.min, self.max) match { + case (Some(min), Some(max)) => s"$min - $max repetitions" + case (Some(1), _) => "1 repetition minimum" + case (Some(min), _) => s"$min repetitions minimum" + case (_, Some(1)) => "1 repetition maximum" + case (_, Some(max)) => s"$max repetitions maximum" + case _ => "" + } + (Console.print(s"${self.uid.getOrElse("")} ($repetitionsString): ") *> Console.readLine).orDie.map { input => + input.split(" ").toList + } + } + + def uid: Option[String] = self.value.uid } final case class Map[A, B](value: Args[A], f: A => Either[HelpDoc, B]) extends Args[B] { self => @@ -198,6 +235,10 @@ object Args { case Right(value) => ZIO.succeed((r, value)) } } + + def generateArgs: UIO[List[String]] = self.value.generateArgs + + def uid: Option[String] = self.value.uid } /** diff --git a/zio-cli/shared/src/main/scala/zio/cli/BuiltInOption.scala b/zio-cli/shared/src/main/scala/zio/cli/BuiltInOption.scala index ba511559..10afd20e 100644 --- a/zio-cli/shared/src/main/scala/zio/cli/BuiltInOption.scala +++ b/zio-cli/shared/src/main/scala/zio/cli/BuiltInOption.scala @@ -8,27 +8,35 @@ object BuiltInOption { final case class ShowHelp(synopsis: UsageSynopsis, helpDoc: HelpDoc) extends BuiltInOption final case class ShowCompletionScript(pathToExecutable: JPath, shellType: ShellType) extends BuiltInOption final case class ShowCompletions(index: Int, shellType: ShellType) extends BuiltInOption + final case class Wizard(command: Command[_]) extends BuiltInOption final case class BuiltIn( help: Boolean, shellCompletionScriptPath: Option[JPath], shellType: Option[ShellType], - shellCompletionIndex: Option[Int] + shellCompletionIndex: Option[Int], + wizard: Boolean ) - def builtInOptions(usageSynopsis: => UsageSynopsis, helpDoc: => HelpDoc): Options[Option[BuiltInOption]] = { + def builtInOptions( + command: => Command[_], + usageSynopsis: => UsageSynopsis, + helpDoc: => HelpDoc + ): Options[Option[BuiltInOption]] = { val options = ( Options.boolean("help").alias("h") ++ Options.file("shell-completion-script").optional ++ ShellType.option.optional ++ - Options.integer("shell-completion-index").map(_.toInt).optional + Options.integer("shell-completion-index").map(_.toInt).optional ++ + Options.boolean("wizard") ).as(BuiltIn.apply _) options.map { - case BuiltIn(true, _, _, _) => Some(ShowHelp(usageSynopsis, helpDoc)) - case BuiltIn(_, Some(path), Some(shellType), _) => Some(ShowCompletionScript(path, shellType)) - case BuiltIn(_, _, Some(shellType), Some(index)) => Some(ShowCompletions(index, shellType)) - case _ => None + case BuiltIn(true, _, _, _, _) => Some(ShowHelp(usageSynopsis, helpDoc)) + case BuiltIn(_, _, _, _, true) => Some(Wizard(command)) + case BuiltIn(_, Some(path), Some(shellType), _, _) => Some(ShowCompletionScript(path, shellType)) + case BuiltIn(_, _, Some(shellType), Some(index), _) => Some(ShowCompletions(index, shellType)) + case _ => None } } } 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 68a14b17..38ecdc1a 100644 --- a/zio-cli/shared/src/main/scala/zio/cli/CliApp.scala +++ b/zio-cli/shared/src/main/scala/zio/cli/CliApp.scala @@ -1,6 +1,6 @@ package zio.cli -import zio.Console.printLine +import zio.Console.{print, printLine, readLine} import zio.System.envs import zio._ import zio.cli.BuiltInOption._ @@ -16,7 +16,7 @@ import scala.annotation.tailrec * requires environment `R`, and may fail with a value of type `E`. */ sealed trait CliApp[-R, +E, +Model] { - def run(args: List[String]): ZIO[R, Nothing, ExitCode] + def run(args: List[String]): ZIO[R, Any, Any] def config(newConfig: CliConfig): CliApp[R, E, Model] @@ -56,8 +56,8 @@ object CliApp { def printDocs(helpDoc: HelpDoc): UIO[Unit] = printLine(helpDoc.toPlaintext(80)).! - def run(args: List[String]): ZIO[R, Nothing, ExitCode] = { - def executeBuiltIn(builtInOption: BuiltInOption): Task[Unit] = + def run(args: List[String]): ZIO[R, Any, Any] = { + def executeBuiltIn(builtInOption: BuiltInOption): ZIO[R, Any, Any] = builtInOption match { case ShowHelp(synopsis, helpDoc) => val fancyName = p(code(self.figFont.render(self.name))) @@ -86,6 +86,21 @@ object CliApp { ZIO.foreachDiscard(completions)(word => printLine(word)) } } + case Wizard(command) => + val subcommands = command.getSubcommands + + for { + subcommandName <- if (subcommands.size == 1) ZIO.succeed(subcommands.keys.head) + else + (print("Command" + subcommands.keys.mkString("(", "|", "): ")) *> readLine).orDie + subcommand <- + ZIO + .fromOption(subcommands.get(subcommandName)) + .orElseFail(ValidationError(ValidationErrorType.InvalidValue, HelpDoc.p("Invalid subcommand"))) + args <- subcommand.generateArgs + _ <- Console.printLine(s"Executing command: ${(prefix(self.command) ++ args).mkString(" ")}") + result <- self.run(args) + } yield result } // prepend a first argument in case the CliApp's command is expected to consume it @@ -104,10 +119,12 @@ object CliApp { e => printDocs(e.error), { case CommandDirective.UserDefined(_, value) => self.execute(value) - case CommandDirective.BuiltIn(x) => executeBuiltIn(x) + case CommandDirective.BuiltIn(x) => + executeBuiltIn(x).catchSome { case e: ValidationError => + printDocs(e.error) + } } ) - .exitCode } def summary(s: HelpDoc.Span): CliApp[R, E, Model] = diff --git a/zio-cli/shared/src/main/scala/zio/cli/Command.scala b/zio-cli/shared/src/main/scala/zio/cli/Command.scala index 14870de0..0b860f25 100644 --- a/zio-cli/shared/src/main/scala/zio/cli/Command.scala +++ b/zio-cli/shared/src/main/scala/zio/cli/Command.scala @@ -2,7 +2,7 @@ package zio.cli import zio.cli.HelpDoc.h1 import zio.cli.ValidationErrorType.CommandMismatch -import zio.{IO, ZIO} +import zio.{IO, UIO, ZIO} /** * A `Command` represents a command in a command-line application. Every command-line application @@ -35,6 +35,10 @@ sealed trait Command[+A] { self => subcommands.copy(parent = subcommands.parent.withHelp(help)).asInstanceOf[Command[A]] } + def generateArgs: UIO[List[String]] + + def getSubcommands: Map[String, Command[_]] + def helpDoc: HelpDoc final def map[B](f: A => B): Command[B] = Command.Map(self, f) @@ -110,7 +114,7 @@ object Command { val parseBuiltInArgs = if (args.headOption.exists(conf.normalizeCase(_) == conf.normalizeCase(self.name))) BuiltInOption - .builtInOptions(self.synopsis, self.helpDoc) + .builtInOptions(self, self.synopsis, self.helpDoc) .validate(args, conf) .mapBoth(_.error, _._2) .some @@ -150,6 +154,14 @@ object Command { lazy val synopsis: UsageSynopsis = UsageSynopsis.Named(List(self.name), None) + self.options.synopsis + self.args.synopsis + + def generateArgs: UIO[List[String]] = + for { + options <- self.options.generateArgs + args <- self.args.generateArgs + } yield List(self.name) ++ options ++ args + + def getSubcommands: Predef.Map[String, Command[_]] = Predef.Map(self.name -> self) } final case class Map[A, B](command: Command[A], f: A => B) extends Command[B] { self => @@ -164,6 +176,10 @@ object Command { self.command.parse(args, conf).map(_.map(f)) lazy val synopsis: UsageSynopsis = self.command.synopsis + + def generateArgs: UIO[List[String]] = self.command.generateArgs + + def getSubcommands: Predef.Map[String, Command[_]] = self.command.getSubcommands } final case class OrElse[A](left: Command[A], right: Command[A]) extends Command[A] { self => @@ -178,6 +194,10 @@ object Command { self.left.parse(args, conf).catchSome { case ValidationError(CommandMismatch, _) => self.right.parse(args, conf) } lazy val synopsis: UsageSynopsis = UsageSynopsis.Mixed + + def generateArgs: UIO[List[String]] = self.left.generateArgs.zipWith(self.right.generateArgs)(_ ++ _) + + def getSubcommands: Predef.Map[String, Command[_]] = self.left.getSubcommands ++ self.right.getSubcommands } final case class Subcommands[A, B](parent: Command[A], child: Command[B]) extends Command[(A, B)] { self => @@ -244,12 +264,28 @@ object Command { val helpDirectiveForParent = ZIO.succeed(CommandDirective.builtIn(BuiltInOption.ShowHelp(self.synopsis, self.helpDoc))) + val wizardDirectiveForChild = { + val safeTail = args match { + case Nil => Nil + case _ :: tail => tail + } + self.child + .parse(safeTail, conf) + .collect(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) { + case directive @ CommandDirective.BuiltIn(BuiltInOption.Wizard(_)) => directive + } + } + + val wizardDirectiveForParent = + ZIO.succeed(CommandDirective.builtIn(BuiltInOption.Wizard(self))) + self.parent .parse(args, conf) .flatMap { case CommandDirective.BuiltIn(BuiltInOption.ShowHelp(_, _)) => helpDirectiveForChild orElse helpDirectiveForParent - case builtIn @ CommandDirective.BuiltIn(_) => ZIO.succeed(builtIn) + case CommandDirective.BuiltIn(_) => + wizardDirectiveForChild orElse wizardDirectiveForParent case CommandDirective.UserDefined(leftover, a) if leftover.nonEmpty => self.child.parse(leftover, conf).map(_.map((a, _))) case _ => @@ -262,6 +298,10 @@ object Command { } lazy val synopsis: UsageSynopsis = self.parent.synopsis + self.child.synopsis + + def generateArgs: UIO[List[String]] = self.parent.generateArgs.zipWith(self.child.generateArgs)(_ ++ _) + + def getSubcommands: Predef.Map[String, Command[_]] = self.child.getSubcommands } /** diff --git a/zio-cli/shared/src/main/scala/zio/cli/HelpDoc.scala b/zio-cli/shared/src/main/scala/zio/cli/HelpDoc.scala index cd906b6f..407e6451 100644 --- a/zio-cli/shared/src/main/scala/zio/cli/HelpDoc.scala +++ b/zio-cli/shared/src/main/scala/zio/cli/HelpDoc.scala @@ -389,6 +389,17 @@ object HelpDoc { case Span.URI(value) => value.toString.length case Span.Sequence(left, right) => left.size + right.size } + + final def text: String = + self match { + case Span.Text(value) => value + case Span.Code(value) => value + case Span.Error(value) => value.text + case Span.Weak(value) => value.text + case Span.Strong(value) => value.text + case Span.URI(value) => value.toString + case Span.Sequence(left, right) => left.text + right.text + } } object Span { final case class Text(value: String) extends Span diff --git a/zio-cli/shared/src/main/scala/zio/cli/Options.scala b/zio-cli/shared/src/main/scala/zio/cli/Options.scala index 3d41f117..a0c218b1 100644 --- a/zio-cli/shared/src/main/scala/zio/cli/Options.scala +++ b/zio-cli/shared/src/main/scala/zio/cli/Options.scala @@ -2,7 +2,7 @@ package zio.cli import zio.cli.HelpDoc.Span._ import zio.cli.HelpDoc.p -import zio.{IO, ZIO, Zippable} +import zio.{Console, IO, UIO, ZIO, Zippable} import java.nio.file.{Path => JPath} import java.time.{ @@ -20,7 +20,6 @@ import java.time.{ ZoneOffset => JZoneOffset, ZonedDateTime => JZonedDateTime } -import scala.collection.immutable.Nil /** * A `Flag[A]` models a command-line flag that produces a value of type `A`. @@ -146,6 +145,15 @@ sealed trait Options[+A] { self => def helpDoc: HelpDoc + final def isBool: Boolean = + self.asInstanceOf[Options[_]] match { + case Options.Empty => false + case Options.WithDefault(options, _) => options.isBool + case Single(_, _, primType, _) => primType.isBool + case Options.Map(value, _) => value.isBool + case _ => false + } + final def map[B](f: A => B): Options[B] = Options.Map(self, (a: A) => Right(f(a))) final def mapOrFail[B](f: A => Either[ValidationError, B]): Options[B] = @@ -155,9 +163,16 @@ sealed trait Options[+A] { self => self.mapOrFail((a: A) => scala.util.Try(f(a)).toEither.left.map(e => ValidationError(ValidationErrorType.InvalidValue, p(e.getMessage))) ) - final def optional: Options[Option[A]] = self.map(Some(_)).withDefault(None) + final def primitiveType: Option[PrimType[A]] = + self match { + case Single(_, _, primType, _) => Some(primType) + case _ => None + } + + def generateArgs: UIO[List[String]] + def synopsis: UsageSynopsis def uid: Option[String] @@ -186,6 +201,8 @@ object Options { override lazy val helpDoc: HelpDoc = HelpDoc.Empty override lazy val uid: Option[String] = None + + override def generateArgs: UIO[List[String]] = ZIO.succeed(List.empty) } final case class WithDefault[A](options: Options[A], default: A) extends Options[A] { self => @@ -207,6 +224,26 @@ object Options { } override lazy val uid: Option[String] = self.options.uid + + override def generateArgs: UIO[List[String]] = { + val typeInfo = + self.options.primitiveType match { + case Some(primType) => s"${primType.typeName}, default: $default" + case None => s"default: $default" + } + + if (self.options.isBool) { + for { + raw <- (Console.print(s"${self.options.uid.getOrElse("")} ($typeInfo): ") *> Console.readLine).orDie + result = if (PrimType.Bool.TrueValues.contains(raw)) List(self.uid.getOrElse("")) else List.empty + } yield result + } else { + for { + value <- (Console.print(s"${self.options.uid.getOrElse("")} ($typeInfo): ") *> Console.readLine).orDie + result = if (value.isEmpty) List.empty else List(self.uid.getOrElse(""), value) + } yield result + } + } } final case class Single[+A]( @@ -286,6 +323,10 @@ object Options { override lazy val helpDoc: HelpDoc = HelpDoc.DescriptionList(List(self.synopsis.helpDoc.getSpan -> (p(self.primType.helpDoc) + self.description))) + + override def generateArgs: UIO[List[String]] = + (Console.print(s"${self.uid.getOrElse("")} (${self.primType.typeName}): ") *> Console.readLine).orDie + .map(List(self.names.head, _)) } final case class OrElse[A, B](left: Options[A], right: Options[B]) extends Options[Either[A, B]] { self => @@ -337,6 +378,14 @@ object Options { case Nil => None case list => Some(list.mkString(", ")) } + + override def generateArgs: UIO[List[String]] = + for { + option <- (Console.print(s"Choose one option ($uid): ") *> Console.readLine).orDie + res <- if (option == self.left.uid.getOrElse("")) left.generateArgs + else if (option == self.right.uid.getOrElse("")) right.generateArgs + else Console.printLine("Invalid option").orDie *> self.generateArgs + } yield res } final case class Both[A, B](left: Options[A], right: Options[B]) extends Options[(A, B)] { self => @@ -368,6 +417,8 @@ object Options { case Nil => None case list => Some(list.mkString(", ")) } + + override def generateArgs: UIO[List[String]] = self.left.generateArgs.zipWith(self.right.generateArgs)(_ ++ _) } final case class Map[A, B](value: Options[A], f: A => Either[ValidationError, B]) extends Options[B] { self => @@ -381,6 +432,8 @@ object Options { override lazy val uid: Option[String] = self.value.uid override lazy val helpDoc: HelpDoc = self.value.helpDoc + + override def generateArgs: UIO[List[String]] = self.value.generateArgs } final case class KeyValueMap(argumentOption: Options.Single[String]) extends Options[Predef.Map[String, String]] { @@ -426,6 +479,10 @@ object Options { override private[cli] def modifySingle(f: SingleModifier) = Options.keyValueMap(f(self.argumentOption)) + + override def generateArgs: UIO[List[String]] = + (Console.print(s"${self.uid.getOrElse("")} (key=value pairs): ") *> Console.readLine).orDie + .map(input => self.uid.getOrElse("") :: input.split(" ").toList) } /** diff --git a/zio-cli/shared/src/main/scala/zio/cli/ZIOCli.scala b/zio-cli/shared/src/main/scala/zio/cli/ZIOCli.scala index 37ac2087..21522668 100644 --- a/zio-cli/shared/src/main/scala/zio/cli/ZIOCli.scala +++ b/zio-cli/shared/src/main/scala/zio/cli/ZIOCli.scala @@ -8,7 +8,7 @@ trait ZIOCli extends ZIOApp { override def run = for { - args <- ZIOAppArgs.getArgs - exitCode <- cliApp.run(args.toList) - } yield exitCode + args <- ZIOAppArgs.getArgs + result <- cliApp.run(args.toList) + } yield result }