Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: render colours in help #539

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 200 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,127 @@ 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): 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 =
optionHelp.map(optHelp => withIndent(4, optHelp.show(theme))).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
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 +136,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 +236,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))
}
}
19 changes: 19 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,19 @@
package com.monovore.decline

sealed abstract class HelpFormat {
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
}

def autoColors(env: Map[String, String]) =
new HelpFormat {
override def colorsEnabled: Boolean = env.get("NO_COLOR").exists(_.nonEmpty)
}
}
Loading
Loading