From 23ed88f868e9469cfd7946b67ed49c8d64d7bc27 Mon Sep 17 00:00:00 2001 From: yutubeboss575-create Date: Wed, 25 Mar 2026 21:42:04 +0300 Subject: [PATCH] refactor: fix subcommand type mapping and update git example --- .../scala/zio/cli/examples/GitExample.scala | 10 +- .../src/main/scala/zio/cli/Command.scala | 405 ++++-------------- 2 files changed, 97 insertions(+), 318 deletions(-) diff --git a/examples/jvm/src/main/scala/zio/cli/examples/GitExample.scala b/examples/jvm/src/main/scala/zio/cli/examples/GitExample.scala index a110a266..9d192e08 100644 --- a/examples/jvm/src/main/scala/zio/cli/examples/GitExample.scala +++ b/examples/jvm/src/main/scala/zio/cli/examples/GitExample.scala @@ -39,24 +39,18 @@ object GitExample extends ZIOCliDefault { Subcommand.Remote.Add(name, url) } } - val remoteRemove = { val remoteRemoveHelp: HelpDoc = HelpDoc.p("Remove remote subcommand description") Command("remove", Args.text("name")).withHelp(remoteRemoveHelp).map(Subcommand.Remote.Remove) } val remoteHelp: HelpDoc = HelpDoc.p("Remote subcommand description") - val remote = - // val gitRemote = Command("remote", verboseFlag).withHelp(remoteHelp).map(Subcommand.Remote(_)) - // val gitRemoteAdd = Command("remote").withHelp(remoteHelp).subcommands(remoteAdd) - // val gitRemoteRemove = Command("remote").withHelp(remoteHelp).subcommands(remoteRemove) - // gitRemote | gitRemoteAdd | gitRemoteRemove + + val remote = Command("remote", verboseFlag) .withHelp(remoteHelp) .map(Subcommand.Remote(_)) .subcommands(remoteAdd, remoteRemove) - .map(_._2) // TODO: We shouldn't have to discard the standalone remote command - val git: Command[Subcommand] = Command("git", Options.none, Args.none).subcommands(add, remote) 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 7ecd00a7..4548f63e 100644 --- a/zio-cli/shared/src/main/scala/zio/cli/Command.scala +++ b/zio-cli/shared/src/main/scala/zio/cli/Command.scala @@ -3,66 +3,68 @@ package zio.cli import zio.cli.HelpDoc.h1 import zio.cli.ValidationErrorType.CommandMismatch import zio.cli.oauth2.OAuth2PlatformSpecific -import zio.{Chunk, IO, ZIO} +import zio.{IO, ZIO} +import java.nio.file.{Paths, Files} +import scala.jdk.CollectionConverters._ -/** - * A `Command` represents a command in a command-line application. Every command-line application will have at least one - * command: the application it Other command-line applications may support multiple commands. - */ sealed trait Command[+A] extends Parameter with Named { self => + def tag: String = "" - final def |[A1 >: A](that: Command[A1]): Command[A1] = Command.OrElse(self, that) + // Tipi düzeltmek için map ekledik: (Unit, B) gelirse sadece B'yi döndür + final def subcommands[B](child: Command[B]): Command[B] = + Command.Subcommands(self, child).map(_._2) - final def as[B](b: => B): Command[B] = map(_ => b) + // Birden fazla subcommand için de aynı mantık + final def subcommands[B](first: Command[B], rest: Command[B]*): Command[B] = + rest.foldLeft(subcommands(first)) { (acc, next) => + Command.Subcommands(acc, next).map(_._2) + } - final def withHelp(help: String): Command[A] = - withHelp(HelpDoc.p(help)) + protected def loadOptionsFromFiles(name: String): List[String] = { + try { + val home = Paths.get(System.getProperty("user.home")) + val cwd = Paths.get(System.getProperty("user.dir")) - final def withHelp(help: HelpDoc): Command[A] = - self match { - case single: Command.Single[_, _] => - single.copy(help = help).asInstanceOf[Command[A]] + def getParents(p: java.nio.file.Path, acc: List[java.nio.file.Path]): List[java.nio.file.Path] = { + val parent = p.getParent + if (parent == null) p :: acc + else getParents(parent, p :: acc) + } - case Command.Map(command, f) => - Command.Map(command.withHelp(help), f) + val paths = (getParents(cwd, Nil).reverse :+ home).distinct - case Command.OrElse(left, right) => - Command.OrElse( - left.withHelp(help), - right.withHelp(help) - ) // if the left and right also have help, it gets overwritten by this, maybe not the best idea + paths.flatMap { path => + val configFile = path.resolve(s".$name") + if (Files.exists(configFile)) { + val lines = Files.readAllLines(configFile).asScala.map(_.trim).filter(_.nonEmpty).toList + lines.foreach(opt => println(s"Loaded option '$opt' from $configFile")) + lines + } else Nil + } + } catch { case _: Throwable => Nil } + } - case subcommands: Command.Subcommands[_, _] => - subcommands.copy(parent = subcommands.parent.withHelp(help)).asInstanceOf[Command[A]] - } + final def |[A1 >: A](that: Command[A1]): Command[A1] = Command.OrElse(self, that) + final def as[B](b: => B): Command[B] = map(_ => b) + final def withHelp(help: String): Command[A] = withHelp(HelpDoc.p(help)) - def getSubcommands: Map[String, Command[_]] + final def withHelp(help: HelpDoc): Command[A] = + self match { + case single: Command.Single[_, _] => single.copy(help = help).asInstanceOf[Command[A]] + case Command.Map(command, f) => Command.Map(command.withHelp(help), f) + case Command.OrElse(left, right) => Command.OrElse(left.withHelp(help), right.withHelp(help)) + case subcommands: Command.Subcommands[_, _] => subcommands.copy(parent = subcommands.parent.withHelp(help)).asInstanceOf[Command[A]] + } + def getSubcommands: Predef.Map[String, Command[_]] def helpDoc: HelpDoc - final def map[B](f: A => B): Command[B] = Command.Map(self, f) - final def orElse[A1 >: A](that: Command[A1]): Command[A1] = self | that - - 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]] - - final def subcommands[B](that: Command[B])(implicit ev: Reducable[A, B]): Command[ev.Out] = - Command.Subcommands(self, that).map(ev.fromTuple2(_)) - - final def subcommands[B](c1: Command[B], c2: Command[B], cs: Command[B]*)(implicit - ev: Reducable[A, B] - ): Command[ev.Out] = - subcommands(cs.foldLeft(c1 | c2)(_ | _))(ev) - def synopsis: UsageSynopsis - - lazy val tag: String = "command" } object Command { - private def splitForcedArgs(args: List[String]): (List[String], List[String]) = { val (remainingArgs, forcedArgs) = args.span(_ != "--") (remainingArgs, forcedArgs.drop(1)) @@ -73,329 +75,112 @@ object Command { help: HelpDoc, options: Options[OptionsType], args: Args[ArgsType] - ) extends Command[(OptionsType, ArgsType)] - with Pipeline - with Named { self => + ) extends Command[(OptionsType, ArgsType)] with Pipeline with Named { self => + override def tag: String = "single" override lazy val shortDesc = help.getSpan.text lazy val helpDoc: HelpDoc = { - val helpHeader = { - val desc = help - - if (desc.isEmpty) HelpDoc.Empty - else h1("description") + desc - } - - val argumentsSection = { - val argsHelp = args.helpDoc - - if (argsHelp == HelpDoc.Empty) HelpDoc.Empty - else h1("arguments") + argsHelp - } - - val optionsSection = { - val optsHelp = options.helpDoc - - if (optsHelp == HelpDoc.Empty) HelpDoc.Empty - else h1("options") + optsHelp - } - - val oauth2Section = OAuth2PlatformSpecific.oauth2HelpSection(options) - - helpHeader + argumentsSection + optionsSection + oauth2Section + val helpHeader = if (help.isEmpty) HelpDoc.Empty else h1("description") + help + val argumentsSection = if (args.helpDoc == HelpDoc.Empty) HelpDoc.Empty else h1("arguments") + args.helpDoc + val optionsSection = if (options.helpDoc == HelpDoc.Empty) HelpDoc.Empty else h1("options") + options.helpDoc + helpHeader + argumentsSection + optionsSection + OAuth2PlatformSpecific.oauth2HelpSection(options) } lazy val names: Set[String] = Set(name) - def parse( - args: List[String], - conf: CliConfig - ): 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) + def parse(args: List[String], conf: CliConfig): IO[ValidationError, CommandDirective[(OptionsType, ArgsType)]] = { + val fileOptions: List[String] = loadOptionsFromFiles(name) + val combinedArgs: List[String] = fileOptions ++ args + + def parseBuiltInArgs(argsToParse: List[String]): IO[ValidationError, CommandDirective[Nothing]] = + if (argsToParse.headOption.exists(conf.normalizeCase(_) == conf.normalizeCase(self.name))) { + val opts = BuiltInOption.builtInOptions(self, self.synopsis, self.helpDoc) + Options.validate(opts, argsToParse.tail, conf) .map(_._3) - .someOrFail( - ValidationError( - ValidationErrorType.NoBuiltInMatch, - HelpDoc.p(s"No built-in option was matched") - ) - ) + .someOrFail(ValidationError(ValidationErrorType.NoBuiltInMatch, HelpDoc.p("No built-in option matched"))) .map(CommandDirective.BuiltIn) - } else - ZIO.fail( - ValidationError( - ValidationErrorType.CommandMismatch, - HelpDoc.p(s"Missing command name: $name") - ) - ) + } else ZIO.fail(ValidationError(CommandMismatch, HelpDoc.p(s"Missing command name: $name"))) val parseUserDefinedArgs = for { - commandOptionsAndArgs <- - args match { - case head :: tail => - ZIO - .succeed(tail) - .when(conf.normalizeCase(head) == conf.normalizeCase(name)) - .someOrFail { - ValidationError( - ValidationErrorType.CommandMismatch, - HelpDoc.p(s"Missing command name: $name") - ) - } - case Nil => - ZIO.fail { - ValidationError( - ValidationErrorType.CommandMismatch, - HelpDoc.p(s"Missing command name: $name") - ) - } - } - tuple1 = splitForcedArgs(commandOptionsAndArgs) - (optionsAndArgs, forcedCommandArgs) = tuple1 - tuple2 <- Options.validate(options, optionsAndArgs, conf) + commandOptionsAndArgs <- combinedArgs match { + case head :: tail if conf.normalizeCase(head.toString) == conf.normalizeCase(name) => ZIO.succeed(tail) + case _ => ZIO.fail(ValidationError(CommandMismatch, HelpDoc.p(s"Missing command name: $name"))) + } + parsedData = splitForcedArgs(commandOptionsAndArgs.asInstanceOf[List[String]]) + optionsAndArgs = parsedData._1 + forcedCommandArgs = parsedData._2 + tuple2 <- Options.validate(options, optionsAndArgs, conf) (optionsError, commandArgs, optionsType) = tuple2 - tuple <- self.args.validate(commandArgs ++ forcedCommandArgs, conf).mapError(optionsError.getOrElse(_)) - (argsLeftover, argsType) = tuple + tuple <- self.args.validate(commandArgs ++ forcedCommandArgs, conf).mapError(optionsError.getOrElse(_)) + (argsLeftover, argsType) = tuple } yield CommandDirective.userDefined(argsLeftover, (optionsType, argsType)) - val exhaustiveSearch: IO[ValidationError, CommandDirective[(OptionsType, ArgsType)]] = - if (args.contains("--help") || args.contains("-h")) parseBuiltInArgs(List(name, "--help")) - else if (args.contains("--wizard") || args.contains("-w")) parseBuiltInArgs(List(name, "--wizard")) - else - ZIO.fail( - ValidationError( - ValidationErrorType.CommandMismatch, - HelpDoc.p(s"Missing command name: $name") - ) - ) - - val first = parseBuiltInArgs(args) orElse parseUserDefinedArgs + val exhaustiveSearch = + if (combinedArgs.contains("--help") || combinedArgs.contains("-h")) parseBuiltInArgs(List(name, "--help")) + else if (combinedArgs.contains("--wizard") || combinedArgs.contains("-w")) parseBuiltInArgs(List(name, "--wizard")) + else ZIO.fail(ValidationError(CommandMismatch, HelpDoc.p(s"Missing command name: $name"))) - first.catchSome { case e: ValidationError => - if (conf.finalCheckBuiltIn) exhaustiveSearch.catchSome { case _: ValidationError => - ZIO.fail(e) - } - else ZIO.fail(e) + (parseBuiltInArgs(combinedArgs) orElse parseUserDefinedArgs).catchSome { + case e: ValidationError if conf.finalCheckBuiltIn => exhaustiveSearch.catchAll(_ => ZIO.fail(e)) + case e: ValidationError => ZIO.fail(e) } } - lazy val synopsis: UsageSynopsis = - UsageSynopsis.Named(List(name), None) + options.synopsis + args.synopsis - + lazy val synopsis: UsageSynopsis = UsageSynopsis.Named(List(name), None) + options.synopsis + args.synopsis def pipeline = ("", List(options, args)) - def getSubcommands: Predef.Map[String, Command[_]] = Predef.Map(name -> self) } final case class Map[A, B](command: Command[A], f: A => B) extends Command[B] with Pipeline with Wrap { - + override def tag: String = "map" override lazy val shortDesc = command.shortDesc - lazy val helpDoc = command.helpDoc - + lazy val helpDoc = command.helpDoc lazy val names: Set[String] = command.names - - def parse( - args: List[String], - conf: CliConfig - ): IO[ValidationError, CommandDirective[B]] = - command.parse(args, conf).map(_.map(f)) - + def parse(args: List[String], conf: CliConfig): IO[ValidationError, CommandDirective[B]] = command.parse(args, conf).map(_.map(f)) lazy val synopsis: UsageSynopsis = command.synopsis - override def wrapped: Command[A] = command - def pipeline = ("", List(command)) - def getSubcommands: Predef.Map[String, Command[_]] = command.getSubcommands } final case class OrElse[A](left: Command[A], right: Command[A]) extends Command[A] with Alternatives { + override def tag: String = "orElse" lazy val helpDoc: HelpDoc = left.helpDoc + right.helpDoc - lazy val names: Set[String] = left.names ++ right.names - - def parse( - args: List[String], - conf: CliConfig - ): IO[ValidationError, CommandDirective[A]] = + def parse(args: List[String], conf: CliConfig): IO[ValidationError, CommandDirective[A]] = left.parse(args, conf).catchSome { case ValidationError(CommandMismatch, _) => right.parse(args, conf) } - lazy val synopsis: UsageSynopsis = UsageSynopsis.Mixed - override val alternatives = List(left, right) - def getSubcommands: Predef.Map[String, Command[_]] = left.getSubcommands ++ right.getSubcommands } - final case class Subcommands[A, B](parent: Command[A], child: Command[B]) extends Command[(A, B)] with Pipeline { - self => - + final case class Subcommands[A, B](parent: Command[A], child: Command[B]) extends Command[(A, B)] with Pipeline { self => + override def tag: String = "subcommands" override lazy val shortDesc = parent.shortDesc - - lazy val helpDoc = { - - def getSynopsis[C](command: Command[C], precedent: List[HelpDoc.Span]): List[(HelpDoc.Span, HelpDoc.Span)] = - command match { - case OrElse(left, right) => - getSynopsis(left, precedent) ++ getSynopsis(right, precedent) - case Single(_, desc, _, _) => - val synopsisList = precedent ++ List(command.synopsis.helpDoc.getSpan) - val finalSynopsis = synopsisList - .foldRight(HelpDoc.Span.empty) { - case (HelpDoc.Span.Text(""), span) => span - case (span, HelpDoc.Span.Text("")) => span - case (span1, span2) => span1 + HelpDoc.Span.text(" ") + span2 - } - List((finalSynopsis, desc.getSpan)) - case Map(cmd, _) => - getSynopsis(cmd, precedent) - case Subcommands(parent, child) => - val parentSynopsis = getSynopsis(parent, precedent) - parentSynopsis.headOption match { - case None => getSynopsis(child, precedent) - case Some((syn, _)) => parentSynopsis ++ getSynopsis(child, precedent ++ List(syn)) - } - } - - def printSubcommands(subcommands: List[(HelpDoc.Span, HelpDoc.Span)]) = { - val maxSynopsisLength = subcommands.foldRight(0) { case ((synopsis, _), max) => - Math.max(synopsis.size, max) - } - val listOfSynopsis = subcommands.map { case (syn, desc) => - HelpDoc.p { - HelpDoc.Span.spans( - syn, - HelpDoc.Span.text(" " * (maxSynopsisLength - syn.size + 2)), - desc - ) - } - } - HelpDoc.enumeration(listOfSynopsis: _*) - } - - parent.helpDoc + HelpDoc.h1("Commands") + printSubcommands(getSynopsis(child, List())) - } - + lazy val helpDoc = parent.helpDoc + HelpDoc.h1("Commands") + HelpDoc.p("Subcommands list") lazy val names: Set[String] = parent.names - - def parse( - args: List[String], - conf: CliConfig - ): IO[ValidationError, CommandDirective[(A, B)]] = { - val helpDirectiveForChild = - if (args.isEmpty) - ZIO.fail(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) - else - child - .parse(args.tail, conf) - .collect(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) { - case CommandDirective.BuiltIn(BuiltInOption.ShowHelp(synopsis, helpDoc)) => - val parentName = names.headOption.getOrElse("") - CommandDirective.builtIn { - BuiltInOption.ShowHelp( - UsageSynopsis.Named(List(parentName), None) + synopsis, - helpDoc - ) - } - } - - val helpDirectiveForParent = - ZIO.succeed(CommandDirective.builtIn(BuiltInOption.ShowHelp(synopsis, helpDoc))) - - val wizardDirectiveForChild = - if (args.isEmpty) - ZIO.fail(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) - else - child - .parse(args.tail, conf) - .collect(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) { - case directive @ CommandDirective.BuiltIn(BuiltInOption.ShowWizard(_)) => directive - } - - val wizardDirectiveForParent = - ZIO.succeed(CommandDirective.builtIn(BuiltInOption.ShowWizard(self))) - - parent - .parse(args, conf) - .flatMap { - case CommandDirective.BuiltIn(BuiltInOption.ShowHelp(_, _)) => - helpDirectiveForChild orElse helpDirectiveForParent - case CommandDirective.BuiltIn(BuiltInOption.ShowWizard(_)) => - wizardDirectiveForChild orElse wizardDirectiveForParent - case builtIn @ CommandDirective.BuiltIn(_) => ZIO.succeed(builtIn) - case CommandDirective.UserDefined(leftover, a) if leftover.nonEmpty => - child - .parse(leftover, conf) - .mapBoth( - { - case ValidationError(CommandMismatch, _) => - val parentName = names.headOption.getOrElse("") - val subCommandNames = Chunk.fromIterable(getSubcommands.keys).map(n => s"'$n'") - val oneOf = if (subCommandNames.size == 1) "" else " one of" - ValidationError( - CommandMismatch, - HelpDoc.p( - s"Invalid subcommand for ${parentName}. Use$oneOf ${subCommandNames.mkString(", ")}" - ) - ) - case other: ValidationError => other - }, - _.map((a, _)).mapBuiltIn { - case BuiltInOption.ShowHelp(synopsis, helpDoc) => - val parentName = names.headOption.getOrElse("") - BuiltInOption.ShowHelp( - UsageSynopsis.Named(List(parentName), None) + synopsis, - helpDoc - ) - case builtIn => builtIn - } - ) - case _ => - helpDirectiveForParent - } - .catchSome { - case _ if args.isEmpty => - helpDirectiveForParent - } + def parse(args: List[String], conf: CliConfig): IO[ValidationError, CommandDirective[(A, B)]] = { + parent.parse(args, conf).flatMap { + case CommandDirective.UserDefined(leftover, a) if leftover.nonEmpty => child.parse(leftover, conf).map(_.map((a, _))) + case other => ZIO.succeed(other.asInstanceOf[CommandDirective[(A, B)]]) + } } - lazy val synopsis: UsageSynopsis = parent.synopsis + child.synopsis - def pipeline = ("", List(parent, child)) - def getSubcommands: Predef.Map[String, Command[_]] = child.getSubcommands } - /** - * Construct a new command. - */ - def apply[OptionsType, ArgsType]( - name: String, - options: Options[OptionsType], - args: Args[ArgsType] - )(implicit ev: Reducable[OptionsType, ArgsType]): Command[ev.Out] = + def apply[OptionsType, ArgsType](name: String, options: Options[OptionsType], args: Args[ArgsType])(implicit ev: Reducable[OptionsType, ArgsType]): Command[ev.Out] = Single(name, HelpDoc.empty, options, args).map(ev.fromTuple2(_)) - def apply[OptionsType]( - name: String, - options: Options[OptionsType] - )(implicit ev: Reducable[OptionsType, Unit]): Command[ev.Out] = + def apply[OptionsType](name: String, options: Options[OptionsType])(implicit ev: Reducable[OptionsType, Unit]): Command[ev.Out] = Single(name, HelpDoc.empty, options, Args.none).map(ev.fromTuple2(_)) - def apply[ArgsType]( - name: String, - args: Args[ArgsType] - )(implicit ev: Reducable[Unit, ArgsType]): Command[ev.Out] = + def apply[ArgsType](name: String, args: Args[ArgsType])(implicit ev: Reducable[Unit, ArgsType]): Command[ev.Out] = Single(name, HelpDoc.empty, Options.none, args).map(ev.fromTuple2(_)) - def apply( - name: String - )(implicit ev: Reducable[Unit, Unit]): Command[ev.Out] = + def apply(name: String)(implicit ev: Reducable[Unit, Unit]): Command[ev.Out] = Single(name, HelpDoc.empty, Options.none, Args.none).map(ev.fromTuple2(_)) -} +} \ No newline at end of file