From f7d7315202d5d72dec95caffa87bb5e06c9da29d Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 16 Feb 2023 11:25:19 +0100 Subject: [PATCH 1/4] Ensure short and full help can have their options filtered separately --- .../scala-3/caseapp/core/Scala3Helpers.scala | 3 + .../main/scala/caseapp/core/help/Help.scala | 4 +- .../scala/caseapp/core/help/HelpFormat.scala | 3 +- .../src/test/scala/caseapp/HelpTests.scala | 80 ++++++++++++++----- 4 files changed, 69 insertions(+), 21 deletions(-) diff --git a/core/shared/src/main/scala-3/caseapp/core/Scala3Helpers.scala b/core/shared/src/main/scala-3/caseapp/core/Scala3Helpers.scala index eb5420f7..a8a8d7c3 100644 --- a/core/shared/src/main/scala-3/caseapp/core/Scala3Helpers.scala +++ b/core/shared/src/main/scala-3/caseapp/core/Scala3Helpers.scala @@ -61,6 +61,9 @@ object Scala3Helpers { def withFilterArgs(filterArgs: Option[Arg => Boolean]): HelpFormat = helpFormat.copy(filterArgs = filterArgs) + + def withFilterArgsWhenShowHidden(filterArgs: Option[Arg => Boolean]): HelpFormat = + helpFormat.copy(filterArgsWhenShowHidden = filterArgs) } implicit class OptionParserWithOps[T](private val parser: OptionParser[T]) { diff --git a/core/shared/src/main/scala/caseapp/core/help/Help.scala b/core/shared/src/main/scala/caseapp/core/help/Help.scala index 9bd9d80c..9f24fbf8 100644 --- a/core/shared/src/main/scala/caseapp/core/help/Help.scala +++ b/core/shared/src/main/scala/caseapp/core/help/Help.scala @@ -135,7 +135,9 @@ import caseapp.HelpMessage def printOptions(b: StringBuilder, format: HelpFormat, showHidden: Boolean): Unit = if (args.nonEmpty) { - val filteredArgs = format.filterArgs.map(args.filter).getOrElse(args) + val filteredArgs = + if (showHidden) format.filterArgsWhenShowHidden.map(args.filter).getOrElse(args) + else format.filterArgs.map(args.filter).getOrElse(args) val groupedArgs = filteredArgs.groupBy(_.group.fold("")(_.name)) val groups = format.sortGroupValues(groupedArgs.toVector) val sortedGroups = groups.filter(_._1.nonEmpty) ++ groupedArgs.get("").toSeq.map("" -> _) diff --git a/core/shared/src/main/scala/caseapp/core/help/HelpFormat.scala b/core/shared/src/main/scala/caseapp/core/help/HelpFormat.scala index 9ff39e6d..00a8b506 100644 --- a/core/shared/src/main/scala/caseapp/core/help/HelpFormat.scala +++ b/core/shared/src/main/scala/caseapp/core/help/HelpFormat.scala @@ -17,7 +17,8 @@ import dataclass._ sortedCommandGroups: Option[Seq[String]] = None, hidden: fansi.Attrs = fansi.Attrs.Empty, terminalWidthOpt: Option[Int] = None, - @since filterArgs: Option[Arg => Boolean] = None + @since filterArgs: Option[Arg => Boolean] = None, + @since filterArgsWhenShowHidden: Option[Arg => Boolean] = None ) { private def sortValues[T]( sortGroups: Option[Seq[String] => Seq[String]], diff --git a/tests/shared/src/test/scala/caseapp/HelpTests.scala b/tests/shared/src/test/scala/caseapp/HelpTests.scala index bd7dcdad..57c84fd4 100644 --- a/tests/shared/src/test/scala/caseapp/HelpTests.scala +++ b/tests/shared/src/test/scala/caseapp/HelpTests.scala @@ -400,7 +400,7 @@ object HelpTests extends TestSuite { assert(help == expected) } - test("help message with filtered args") { + test("short help message with filtered args") { val entryPoint: CommandsEntryPoint = new CommandsEntryPoint { def progName = "foo" @@ -410,25 +410,67 @@ object HelpTests extends TestSuite { } val filterArgsFunction = (a: Arg) => !a.tags.exists(_.name == "foo") val formatWithHiddenGroup = format.withFilterArgs(Some(filterArgsFunction)) - val help = entryPoint.help.help(formatWithHiddenGroup) - val expected = - """Usage: foo [options] - | - |Help options: - | --usage Print usage and exit - | -h, -help, --help Print help message and exit - | - |Other options: - | --bar int - | - |Aa commands: - | first - | third Third help message - | - |Bb commands: - | second""".stripMargin + val shortHelp = entryPoint.help.help(formatWithHiddenGroup) + val fullHelp = entryPoint.help.help(formatWithHiddenGroup, showHidden = true) + val fooEntry = + """ + | -f, --foo string""".stripMargin + def expected(showHidden: Boolean) = + s"""Usage: foo [options] + | + |Help options: + | --usage Print usage and exit + | -h, -help, --help Print help message and exit + | + |Other options:${if (showHidden) fooEntry else ""} + | --bar int + | + |Aa commands: + | first + | third Third help message + | + |Bb commands: + | second""".stripMargin + assert(shortHelp == expected(showHidden = false)) + assert(fullHelp == expected(showHidden = + true + )) // the filter shouldn't be applied for full help + } + test("full help message with filtered args") { + val entryPoint: CommandsEntryPoint = new CommandsEntryPoint { + def progName = "foo" - assert(help == expected) + override def defaultCommand = Some(CommandGroups.First) + + def commands = Seq(CommandGroups.First, CommandGroups.Second, CommandGroups.Third) + } + val filterArgsFunction = (a: Arg) => !a.tags.exists(_.name == "foo") + val formatWithHiddenGroup = format.withFilterArgsWhenShowHidden(Some(filterArgsFunction)) + val shortHelp = entryPoint.help.help(formatWithHiddenGroup) + val fullHelp = entryPoint.help.help(formatWithHiddenGroup, showHidden = true) + val fooEntry = + """ + | -f, --foo string""".stripMargin + def expected(showHidden: Boolean) = + s"""Usage: foo [options] + | + |Help options: + | --usage Print usage and exit + | -h, -help, --help Print help message and exit + | + |Other options:${if (showHidden) "" else fooEntry} + | --bar int + | + |Aa commands: + | first + | third Third help message + | + |Bb commands: + | second""".stripMargin + assert(shortHelp == expected(showHidden = + false + )) // the filter shouldn't be applied for short help + assert(fullHelp == expected(showHidden = true)) } test("hidden commands in help message") { val entryPoint = new CommandsEntryPoint { From bb033c1096cf6f205870c7012d5aab8c6ce8a66b Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 16 Feb 2023 11:44:46 +0100 Subject: [PATCH 2/4] Ensure short and full help can have their help groups hidden separately --- .../scala-3/caseapp/core/Scala3Helpers.scala | 3 + .../main/scala/caseapp/core/help/Help.scala | 2 +- .../scala/caseapp/core/help/HelpFormat.scala | 18 +++-- .../core/help/RuntimeCommandsHelp.scala | 3 +- .../src/test/scala/caseapp/HelpTests.scala | 75 +++++++++++++++---- 5 files changed, 77 insertions(+), 24 deletions(-) diff --git a/core/shared/src/main/scala-3/caseapp/core/Scala3Helpers.scala b/core/shared/src/main/scala-3/caseapp/core/Scala3Helpers.scala index a8a8d7c3..2cb21444 100644 --- a/core/shared/src/main/scala-3/caseapp/core/Scala3Helpers.scala +++ b/core/shared/src/main/scala-3/caseapp/core/Scala3Helpers.scala @@ -64,6 +64,9 @@ object Scala3Helpers { def withFilterArgsWhenShowHidden(filterArgs: Option[Arg => Boolean]): HelpFormat = helpFormat.copy(filterArgsWhenShowHidden = filterArgs) + + def withHiddenGroupsWhenShowHidden(hiddenGroups: Option[Seq[String]]): HelpFormat = + helpFormat.copy(hiddenGroupsWhenShowHidden = hiddenGroups) } implicit class OptionParserWithOps[T](private val parser: OptionParser[T]) { diff --git a/core/shared/src/main/scala/caseapp/core/help/Help.scala b/core/shared/src/main/scala/caseapp/core/help/Help.scala index 9f24fbf8..398170c8 100644 --- a/core/shared/src/main/scala/caseapp/core/help/Help.scala +++ b/core/shared/src/main/scala/caseapp/core/help/Help.scala @@ -139,7 +139,7 @@ import caseapp.HelpMessage if (showHidden) format.filterArgsWhenShowHidden.map(args.filter).getOrElse(args) else format.filterArgs.map(args.filter).getOrElse(args) val groupedArgs = filteredArgs.groupBy(_.group.fold("")(_.name)) - val groups = format.sortGroupValues(groupedArgs.toVector) + val groups = format.sortGroupValues(groupedArgs.toVector, showHidden) val sortedGroups = groups.filter(_._1.nonEmpty) ++ groupedArgs.get("").toSeq.map("" -> _) for { ((groupName, groupArgs), groupIdx) <- sortedGroups.zipWithIndex diff --git a/core/shared/src/main/scala/caseapp/core/help/HelpFormat.scala b/core/shared/src/main/scala/caseapp/core/help/HelpFormat.scala index 00a8b506..db7c80c4 100644 --- a/core/shared/src/main/scala/caseapp/core/help/HelpFormat.scala +++ b/core/shared/src/main/scala/caseapp/core/help/HelpFormat.scala @@ -18,12 +18,14 @@ import dataclass._ hidden: fansi.Attrs = fansi.Attrs.Empty, terminalWidthOpt: Option[Int] = None, @since filterArgs: Option[Arg => Boolean] = None, - @since filterArgsWhenShowHidden: Option[Arg => Boolean] = None + @since filterArgsWhenShowHidden: Option[Arg => Boolean] = None, + hiddenGroupsWhenShowHidden: Option[Seq[String]] = None ) { private def sortValues[T]( sortGroups: Option[Seq[String] => Seq[String]], sortedGroups: Option[Seq[String]], - elems: Seq[(String, T)] + elems: Seq[(String, T)], + showHidden: Boolean ): Seq[(String, T)] = { val sortedGroups0 = sortGroups match { case None => @@ -38,13 +40,15 @@ import dataclass._ val sorted = sort(elems.map(_._1)).zipWithIndex.toMap elems.sortBy { case (group, _) => sorted.getOrElse(group, Int.MaxValue) } } - sortedGroups0.filter { case (group, _) => hiddenGroups.forall(!_.contains(group)) } + sortedGroups0.filter { case (group, _) => + (if (showHidden) hiddenGroupsWhenShowHidden else hiddenGroups).forall(!_.contains(group)) + } } - def sortGroupValues[T](elems: Seq[(String, T)]): Seq[(String, T)] = - sortValues(sortGroups, sortedGroups, elems) - def sortCommandGroupValues[T](elems: Seq[(String, T)]): Seq[(String, T)] = - sortValues(sortCommandGroups, sortedCommandGroups, elems) + def sortGroupValues[T](elems: Seq[(String, T)], showHidden: Boolean): Seq[(String, T)] = + sortValues(sortGroups, sortedGroups, elems, showHidden) + def sortCommandGroupValues[T](elems: Seq[(String, T)], showHidden: Boolean): Seq[(String, T)] = + sortValues(sortCommandGroups, sortedCommandGroups, elems, showHidden) } object HelpFormat { diff --git a/core/shared/src/main/scala/caseapp/core/help/RuntimeCommandsHelp.scala b/core/shared/src/main/scala/caseapp/core/help/RuntimeCommandsHelp.scala index 337bf288..9d2382e7 100644 --- a/core/shared/src/main/scala/caseapp/core/help/RuntimeCommandsHelp.scala +++ b/core/shared/src/main/scala/caseapp/core/help/RuntimeCommandsHelp.scala @@ -75,7 +75,8 @@ import dataclass._ commands .filter(c => showHidden || !c.hidden) .groupBy(_.group) - .toVector + .toVector, + showHidden ) def table(commands: Seq[RuntimeCommandHelp[_]]) = diff --git a/tests/shared/src/test/scala/caseapp/HelpTests.scala b/tests/shared/src/test/scala/caseapp/HelpTests.scala index 57c84fd4..70e99b7a 100644 --- a/tests/shared/src/test/scala/caseapp/HelpTests.scala +++ b/tests/shared/src/test/scala/caseapp/HelpTests.scala @@ -373,7 +373,7 @@ object HelpTests extends TestSuite { assert(help == expected) } - test("help message with hidden group") { + test("short help message with hidden group") { val entryPoint = new CommandsEntryPoint { def progName = "foo" override def defaultCommand = Some(CommandGroups.First) @@ -383,22 +383,67 @@ object HelpTests extends TestSuite { CommandGroups.First.group, CommandGroups.Third.group ))) - val help = entryPoint.help.help(formatWithHiddenGroup) - val expected = - """Usage: foo [options] - | - |Help options: - | --usage Print usage and exit - | -h, -help, --help Print help message and exit - | - |Other options: - | -f, --foo string - | --bar int + val shortHelp = entryPoint.help.help(formatWithHiddenGroup) + val fullHelp = entryPoint.help.help(formatWithHiddenGroup, showHidden = true) + val hiddenGroupEntries = """ + | + |Aa commands: + | first + | third Third help message""".stripMargin + def expected(showHidden: Boolean) = + s"""Usage: foo [options] + | + |Help options: + | --usage Print usage and exit + | -h, -help, --help Print help message and exit + | + |Other options: + | -f, --foo string + | --bar int${if (showHidden) hiddenGroupEntries else ""} + | + |Bb commands: + | second""".stripMargin + + assert(shortHelp == expected(showHidden = false)) + assert(fullHelp == expected(showHidden = true)) + } + test("full help message with hidden group") { + val entryPoint = new CommandsEntryPoint { + def progName = "foo" + + override def defaultCommand = Some(CommandGroups.First) + + def commands = Seq(CommandGroups.First, CommandGroups.Second, CommandGroups.Third) + } + val formatWithHiddenGroup = format.withHiddenGroupsWhenShowHidden(Some(Seq( + CommandGroups.First.group, + CommandGroups.Third.group + ))) + val shortHelp = entryPoint.help.help(formatWithHiddenGroup) + val fullHelp = entryPoint.help.help(formatWithHiddenGroup, showHidden = true) + val hiddenGroupEntries = + """ | - |Bb commands: - | second""".stripMargin + |Aa commands: + | first + | third Third help message""".stripMargin - assert(help == expected) + def expected(showHidden: Boolean) = + s"""Usage: foo [options] + | + |Help options: + | --usage Print usage and exit + | -h, -help, --help Print help message and exit + | + |Other options: + | -f, --foo string + | --bar int${if (showHidden) "" else hiddenGroupEntries} + | + |Bb commands: + | second""".stripMargin + + assert(shortHelp == expected(showHidden = false)) + assert(fullHelp == expected(showHidden = true)) } test("short help message with filtered args") { val entryPoint: CommandsEntryPoint = new CommandsEntryPoint { From d83b49829681f550c39e31b4c526dffd8c6dba89 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 16 Feb 2023 15:14:08 +0100 Subject: [PATCH 3/4] Add an optional detailed message for the `@HelpMessage` annotation (to be used with `showHidden = true`) --- .../src/main/scala/caseapp/Annotations.scala | 3 +- .../caseapp/core/help/HelpCompanion.scala | 10 +++++-- .../parser/LowPriorityParserImplicits.scala | 10 +++++-- .../main/scala/caseapp/core/help/Help.scala | 8 ++++- .../src/test/scala/caseapp/Definitions.scala | 8 ++++- .../src/test/scala/caseapp/HelpTests.scala | 30 +++++++++++++++++++ 6 files changed, 62 insertions(+), 7 deletions(-) diff --git a/annotations/shared/src/main/scala/caseapp/Annotations.scala b/annotations/shared/src/main/scala/caseapp/Annotations.scala index 3235e029..0ccc162e 100644 --- a/annotations/shared/src/main/scala/caseapp/Annotations.scala +++ b/annotations/shared/src/main/scala/caseapp/Annotations.scala @@ -20,7 +20,8 @@ object ValueDescription { * @messageMd * not used by case-app itself, only there as a convenience for case-app users */ -final case class HelpMessage(message: String, messageMd: String = "") extends StaticAnnotation +final case class HelpMessage(message: String, messageMd: String = "", detailedMessage: String = "") + extends StaticAnnotation /** Name for the annotated case class of arguments E.g. MyApp */ diff --git a/core/shared/src/main/scala-3/caseapp/core/help/HelpCompanion.scala b/core/shared/src/main/scala-3/caseapp/core/help/HelpCompanion.scala index 4e119739..94fb620e 100644 --- a/core/shared/src/main/scala-3/caseapp/core/help/HelpCompanion.scala +++ b/core/shared/src/main/scala-3/caseapp/core/help/HelpCompanion.scala @@ -52,8 +52,14 @@ object HelpCompanion { val helpMessage = sym.annotations .find(_.tpe =:= TypeRepr.of[caseapp.HelpMessage]) .collect { - case Apply(_, List(arg, argMd)) => - '{ caseapp.HelpMessage(${ arg.asExprOf[String] }, ${ argMd.asExprOf[String] }) } + case Apply(_, List(arg, argMd, argDetailed)) => + '{ + caseapp.HelpMessage( + ${ arg.asExprOf[String] }, + ${ argMd.asExprOf[String] }, + ${ argDetailed.asExprOf[String] } + ) + } } '{ val parser = $parserExpr diff --git a/core/shared/src/main/scala-3/caseapp/core/parser/LowPriorityParserImplicits.scala b/core/shared/src/main/scala-3/caseapp/core/parser/LowPriorityParserImplicits.scala index 7f34ff24..76ae555d 100644 --- a/core/shared/src/main/scala-3/caseapp/core/parser/LowPriorityParserImplicits.scala +++ b/core/shared/src/main/scala-3/caseapp/core/parser/LowPriorityParserImplicits.scala @@ -125,8 +125,14 @@ object LowPriorityParserImplicits { val helpMessage = sym.annotations .find(_.tpe =:= TypeRepr.of[caseapp.HelpMessage]) .collect { - case Apply(_, List(arg, argMd)) => - '{ caseapp.HelpMessage(${ arg.asExprOf[String] }, ${ argMd.asExprOf[String] }) } + case Apply(_, List(arg, argMd, argDetailed)) => + '{ + caseapp.HelpMessage( + ${ arg.asExprOf[String] }, + ${ argMd.asExprOf[String] }, + ${ argDetailed.asExprOf[String] } + ) + } } val hidden = sym.annotations.exists(_.tpe =:= TypeRepr.of[caseapp.Hidden]) val group = sym.annotations diff --git a/core/shared/src/main/scala/caseapp/core/help/Help.scala b/core/shared/src/main/scala/caseapp/core/help/Help.scala index 398170c8..26ec930f 100644 --- a/core/shared/src/main/scala/caseapp/core/help/Help.scala +++ b/core/shared/src/main/scala/caseapp/core/help/Help.scala @@ -111,7 +111,13 @@ import caseapp.HelpMessage printUsage(b, format) b.append(format.newLine) - for (desc <- helpMessage.map(_.message)) + val helpDescription = helpMessage.map { + case HelpMessage(_, _, detailedMessage) if showHidden && detailedMessage.nonEmpty => + detailedMessage + case HelpMessage(message, _, _) => message + } + + for (desc <- helpDescription) Help.printDescription( b, desc, diff --git a/tests/shared/src/test/scala/caseapp/Definitions.scala b/tests/shared/src/test/scala/caseapp/Definitions.scala index 17aa71b1..92e6b9e7 100644 --- a/tests/shared/src/test/scala/caseapp/Definitions.scala +++ b/tests/shared/src/test/scala/caseapp/Definitions.scala @@ -115,12 +115,18 @@ object Definitions { bar: Int ) - @HelpMessage("Example help message") + @HelpMessage("Example help message", "", "Example detailed help message") final case class ExampleWithHelpMessage( foo: String, bar: Int ) + @HelpMessage("Example help message") + final case class SimpleExampleWithHelpMessage( + foo: String, + bar: Int + ) + sealed trait Command case class First( diff --git a/tests/shared/src/test/scala/caseapp/HelpTests.scala b/tests/shared/src/test/scala/caseapp/HelpTests.scala index 70e99b7a..ee1e3faf 100644 --- a/tests/shared/src/test/scala/caseapp/HelpTests.scala +++ b/tests/shared/src/test/scala/caseapp/HelpTests.scala @@ -65,6 +65,36 @@ object HelpTests extends TestSuite { checkLines(message, expectedMessage) } + test("generate a help message with detailed description") { + + val message = Help[ExampleWithHelpMessage].help(format, showHidden = true) + + val expectedMessage = + """Usage: example-with-help-message [options] + |Example detailed help message + | + |Options: + | --foo string + | --bar int""".stripMargin + + checkLines(message, expectedMessage) + } + + test("generate a help message falling back to standard description") { + + val message = Help[SimpleExampleWithHelpMessage].help(format, showHidden = true) + + val expectedMessage = + """Usage: simple-example-with-help-message [options] + |Example help message + | + |Options: + | --foo string + | --bar int""".stripMargin + + checkLines(message, expectedMessage) + } + test("group options") { val orderedGroups = Seq("Something", "Bb").zipWithIndex.toMap From bb834a7da20cc8f5f96854852a1a4726ce6bb7b9 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 16 Feb 2023 16:04:22 +0100 Subject: [PATCH 4/4] Reset MiMa --- project/Mima.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/Mima.scala b/project/Mima.scala index 4ed7838f..c193bcce 100644 --- a/project/Mima.scala +++ b/project/Mima.scala @@ -8,7 +8,7 @@ import scala.sys.process._ object Mima { def binaryCompatibilityVersions: Set[String] = - Seq("git", "tag", "--merged", "HEAD^", "--contains", "c199a3037771d09af0a190a2b99fa8b287e6812f") + Seq("git", "tag", "--merged", "HEAD^", "--contains", "d83b49829681f550c39e31b4c526dffd8c6dba89") .!! .linesIterator .map(_.trim)