Skip to content
257 changes: 203 additions & 54 deletions core/shared/src/main/scala/com/monovore/decline/Help.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,70 +3,130 @@ package com.monovore.decline
import cats.Show
import cats.data.NonEmptyList
import cats.syntax.all._
import com.monovore.decline.HelpFormat.Plain
import com.monovore.decline.HelpFormat.Colors

case class Help(
errors: List[String],
prefix: NonEmptyList[String],
usage: List[String],
body: List[String]
body: List[String],
args: Help.HelpArgs
) {

def withErrors(moreErrors: List[String]) = copy(errors = errors ++ moreErrors)

def withPrefix(prefix: List[String]) = copy(prefix = prefix.foldRight(this.prefix) { _ :: _ })

override def toString = {
val maybeErrors = if (errors.isEmpty) Nil else List(errors.mkString("\n"))
val prefixString = prefix.toList.mkString(" ")
val usageString = usage match {
case Nil => s"Usage: $prefixString" // :(
case only :: Nil => s"Usage: $prefixString $only"
case _ => ("Usage:" :: usage).mkString(s"\n $prefixString ")
}

(maybeErrors ::: (usageString :: body)).mkString("\n\n")
}
}

object Help {

implicit val declineHelpShow: Show[Help] =
Show.fromToString[Help]
override def toString = render(HelpFormat.Plain)

def fromCommand(parser: Command[_]): Help = {
def render(format: HelpFormat = HelpFormat.AutoColors()): String = {
val theme = Theme.forRenderer(format)

val commands = commandList(parser.options)
import args._
import Help.withIndent

val commandHelp =
if (commands.isEmpty) Nil
val commandSection =
if (commandsHelp.isEmpty) Nil
else {
val texts = commands.flatMap { command =>
List(withIndent(4, command.name), withIndent(8, command.header))
val texts = commandsHelp.flatMap { command =>
Help.withIndent(4, command.show(theme))
}
List((theme.sectionHeading("Subcommands:") :: texts).mkString("\n"))
}

def intersperseList[A](xs: List[A], x: A): List[A] = {
val bld = List.newBuilder[A]
val it = xs.iterator
if (it.hasNext) {
bld += it.next
while (it.hasNext) {
bld += x
bld += it.next
}
List(("Subcommands:" :: texts).mkString("\n"))
}
bld.result
}

val optionsSection = {
val optionHelpLines =
intersperseList(
optionHelp.map(optHelp => withIndent(4, optHelp.show(theme))),
List("")
).flatten

if (optionHelp.isEmpty) Nil
else (theme.sectionHeading("Options and flags:") :: optionHelpLines).mkString("\n") :: Nil
}

val optionsHelp = {
val optionsDetail = detail(parser.options)
if (optionsDetail.isEmpty) Nil
else ("Options and flags:" :: optionsDetail).mkString("\n") :: Nil
val envSection = {
if (envHelp.isEmpty) Nil
else
(theme.sectionHeading("Environment Variables:") :: envHelp
.flatMap(_.show(theme))
.map(withIndent(4, _)))
.mkString("\n") :: Nil
}

val envVarHelp = {
val envVarHelpLines = environmentVarHelpLines(parser.options).distinct
if (envVarHelpLines.isEmpty) Nil
else ("Environment Variables:" :: envVarHelpLines.map(" " ++ _)).mkString("\n") :: Nil
val prefixString = prefix.mkString_(" ")

val usageSection = {
theme.sectionHeading("Usage:") :: usages.flatMap(us =>
us.show.map(line => withIndent(4, prefixString + " " + line))
)
}

val errorsSection = if (args.errors.isEmpty) Nil else args.errors.map(theme.error(_))

val descriptionSection = List(description)

List(
errorsSection.mkString("\n"),
usageSection.mkString("\n"),
descriptionSection.mkString("\n"),
optionsSection.mkString("\n"),
envSection.mkString("\n"),
commandSection.mkString("\n")
).filter(_.nonEmpty)
.mkString("\n\n")

}

}

object Help {

implicit val declineHelpShow: Show[Help] =
Show.fromToString[Help]

def fromCommand(parser: Command[_]): Help = {
Help(
errors = Nil,
prefix = NonEmptyList(parser.name, Nil),
usage = Usage.fromOpts(parser.options).flatMap { _.show },
body = parser.header :: (optionsHelp ::: envVarHelp ::: commandHelp)
prefix = NonEmptyList.of(parser.name),
usage = Nil,
body = Nil,
args = HelpArgs(
errors = Nil,
optionHelp = collectOptHelp(parser.options),
commandsHelp = collectCommandHelp(parser.options),
envHelp = collectEnvOptions(parser.options).distinct,
usages = Usage.fromOpts(parser.options),
description = parser.header
)
)

}

def optionList(opts: Opts[_]): Option[List[(Opt[_], Boolean)]] = opts match {
private[decline] case class HelpArgs(
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat! This seems like something with this shape could eventually be cleaned up and made public for some of the fancier help-rendering ideas folks have had (HTML generation, etc.) but leaving it private for now seems like the right call.

errors: List[String],
optionHelp: List[OptHelp],
commandsHelp: List[CommandHelp],
envHelp: List[EnvOptionHelp],
usages: List[Usage],
description: String
)

private def optionList(opts: Opts[_]): Option[List[(Opt[_], Boolean)]] = opts match {
case Opts.Pure(_) => Some(Nil)
case Opts.Missing => None
case Opts.HelpFlag(a) => optionList(a)
Expand All @@ -79,41 +139,98 @@ object Help {
case Opts.Env(_, _, _) => Some(Nil)
}

def commandList(opts: Opts[_]): List[Command[_]] = opts match {
case Opts.HelpFlag(a) => commandList(a)
case Opts.Subcommand(command) => List(command)
case Opts.App(f, a) => commandList(f) ++ commandList(a)
case Opts.OrElse(f, a) => commandList(f) ++ commandList(a)
case Opts.Validate(a, _) => commandList(a)
private def collectOptHelp(opts: Opts[_]): List[OptHelp] = {
optionList(opts).getOrElse(Nil).distinct.flatMap {
case (Opt.Regular(names, metavar, help, _), _) =>
Some(OptHelp(names.map { _.toString() -> Some(s" <$metavar>") }, help))
case (Opt.Flag(names, help, _), _) =>
Some(OptHelp(names.map(n => n.toString -> None), help))
case (Opt.OptionalOptArg(names, metavar, help, _), _) =>
Some(
OptHelp(
names
.map {
case Opts.ShortName(flag) => s"-$flag" -> Some(s"[<$metavar>]")
case Opts.LongName(flag) => s"--$flag" -> Some(s"[=<$metavar>]")
},
help
)
)
case (Opt.Argument(_), _) => None

}
}

private def collectCommandHelp(opts: Opts[_]): List[CommandHelp] = opts match {
case Opts.HelpFlag(a) => collectCommandHelp(a)
case Opts.Subcommand(command) => List(CommandHelp(command.name, command.header))
case Opts.App(f, a) => collectCommandHelp(f) ++ collectCommandHelp(a)
case Opts.OrElse(f, a) => collectCommandHelp(f) ++ collectCommandHelp(a)
case Opts.Validate(a, _) => collectCommandHelp(a)
case _ => Nil
}

def environmentVarHelpLines(opts: Opts[_]): List[String] = opts match {
private def collectEnvOptions(opts: Opts[_]): List[EnvOptionHelp] =
opts match {
case Opts.Pure(_) => List()
case Opts.Missing => List()
case Opts.HelpFlag(a) => collectEnvOptions(a)
case Opts.App(f, a) => collectEnvOptions(f) |+| collectEnvOptions(a)
case Opts.OrElse(a, b) =>
collectEnvOptions(a) |+| collectEnvOptions(b)
case Opts.Single(opt) => List()
case Opts.Repeated(opt) => List()
case Opts.Validate(a, _) => collectEnvOptions(a)
case Opts.Subcommand(_) => List()
case Opts.Env(name, help, metavar) =>
List(EnvOptionHelp(name = name, metavar = metavar, help = help))
}

private def environmentVarHelpLines(opts: Opts[_]): List[String] =
environmentVarHelpLines(opts, PlainTheme)

private def environmentVarHelpLines(opts: Opts[_], theme: Theme): List[String] = opts match {
case Opts.Pure(_) => List()
case Opts.Missing => List()
case Opts.HelpFlag(a) => environmentVarHelpLines(a)
case Opts.App(f, a) => environmentVarHelpLines(f) |+| environmentVarHelpLines(a)
case Opts.OrElse(a, b) => environmentVarHelpLines(a) |+| environmentVarHelpLines(b)
case Opts.HelpFlag(a) => environmentVarHelpLines(a, theme)
case Opts.App(f, a) => environmentVarHelpLines(f, theme) |+| environmentVarHelpLines(a, theme)
case Opts.OrElse(a, b) =>
environmentVarHelpLines(a, theme) |+| environmentVarHelpLines(b, theme)
case Opts.Single(opt) => List()
case Opts.Repeated(opt) => List()
case Opts.Validate(a, _) => environmentVarHelpLines(a)
case Opts.Validate(a, _) => environmentVarHelpLines(a, theme)
case Opts.Subcommand(_) => List()
case Opts.Env(name, help, metavar) => List(s"$name=<$metavar>", withIndent(4, help))
case Opts.Env(name, help, metavar) =>
List(theme.envName(name) + s"=<$metavar>", withIndent(4, help))
}

def detail(opts: Opts[_]): List[String] =
private def detail(opts: Opts[_]): List[String] = detail(opts, PlainTheme)
private def detail(opts: Opts[_], theme: Theme): List[String] = {
def optionName(name: String) = theme.optionName(name, Theme.ArgumentRenderingLocation.InOptions)
def metavarName(name: String) = theme.metavar(name, Theme.ArgumentRenderingLocation.InOptions)

optionList(opts)
.getOrElse(Nil)
.distinct
.flatMap {
case (Opt.Regular(names, metavar, help, _), _) =>
List(
withIndent(4, names.map(name => s"$name <$metavar>").mkString(", ")),
withIndent(
4,
names
.map(name => s"${optionName(name.toString)} ${metavarName(s"<$metavar>")}")
.mkString(", ")
),
withIndent(8, help)
)
case (Opt.Flag(names, help, _), _) =>
List(
withIndent(4, names.mkString(", ")),
withIndent(
4,
names
.map(n => theme.optionName(n.toString(), Theme.ArgumentRenderingLocation.InOptions))
.mkString(", ")
),
withIndent(8, help)
)
case (Opt.OptionalOptArg(names, metavar, help, _), _) =>
Expand All @@ -122,17 +239,49 @@ object Help {
4,
names
.map {
case Opts.ShortName(flag) => s"-$flag[<$metavar>]"
case Opts.LongName(flag) => s"--$flag[=<$metavar>]"
case Opts.ShortName(flag) => optionName(s"-$flag") + metavarName(s"[<$metavar>]")
case Opts.LongName(flag) => optionName(s"--$flag") + metavarName(s"[=<$metavar>]")
}
.mkString(", ")
),
withIndent(8, help)
)
case (Opt.Argument(_), _) => Nil
}
}

private def withIndent(indent: Int, s: String): String =
// Predef.augmentString = work around scala/bug#11125
augmentString(s).linesIterator.map(" " * indent + _).mkString("\n")

private def withIndent(indent: Int, lines: List[String]): List[String] =
lines.map(line => withIndent(indent, line))

private[decline] case class OptHelp(variants: List[(String, Option[String])], help: String) {
def show(theme: Theme): List[String] = {
val newValue = Theme.ArgumentRenderingLocation.InOptions

val argLine = variants
.map { case (name, metavarOpt) =>
theme.optionName(name, newValue) + metavarOpt
.map { metavar =>
val spaces = metavar.takeWhile(_.isWhitespace).length
(" " * spaces) + theme.metavar(metavar.trim, newValue)
}
.getOrElse("")
}
.mkString(", ")

List(argLine, withIndent(4, help))
}
}

private[decline] case class EnvOptionHelp(name: String, metavar: String, help: String) {
def show(theme: Theme): List[String] =
List(theme.envName(name) + s"=<$metavar>", withIndent(4, help))
}
private[decline] case class CommandHelp(name: String, help: String) {
def show(theme: Theme): List[String] =
List(theme.subcommandName(name), withIndent(4, help))
}
}
27 changes: 27 additions & 0 deletions core/shared/src/main/scala/com/monovore/decline/HelpFormat.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.monovore.decline

sealed abstract class HelpFormat extends Product with Serializable {
Comment thread
keynmol marked this conversation as resolved.
Outdated
def colorsEnabled: Boolean
}
object HelpFormat {
case object Plain extends HelpFormat {
override def colorsEnabled: Boolean = false
}
case object Colors extends HelpFormat {
override def colorsEnabled: Boolean = true
}
case class AutoColors(env: Map[String, String] = sys.env) extends HelpFormat {
Comment thread
keynmol marked this conversation as resolved.
Outdated
/*

http://no-color.org/

"Command-line software which adds ANSI color to its output by default should
check for a NO_COLOR environment variable that, when present and not an empty
string (regardless of its value), prevents the addition of ANSI color."

*/

override def colorsEnabled: Boolean = env.get("NO_COLOR").exists(_.nonEmpty)

}
}
Loading