You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
blog.ndumas.com/content/posts/integrating-cobra-and-lipgl...

6.7 KiB

draft title aliases series date author cover keywords description showFullContent tags
false Putting Lipgloss on a Snake: Prettier Help Output for Cobra
Putting Lipgloss on a Snake: Prettier Help Output for Cobra
2023-05-08 Nick Dumas
Using lipgloss to abstract away the specifics of nice terminal output. false
golang
cli

What am I Doing?

Cobra is a library for building command line applications in Go. It's pretty fully featured with sub-commands, automatic completion generators for zsh and bash, and more.

I'm building a helper application to work with my blog publishing pipeline and to keep things modular I'm trying to break it down into smaller utilities.

Why does it work?

Cobra is a well established library and feature-rich. It's got a great helper utility (cobra-cli) to set up a skeleton you can use to jump in.

One thing that Cobra automatically handles in particular is help output. Veering away from the tradition of docopt, you build a series of objects, set callbacks and configuration values, and Cobra works backwards from that to generate a nice help output listing flags and all the usual stuff.

Cobra lets you override a few members on the Command structs to further customize the outputs: Short and Long.

Readable Line Length

What tripped me up as I prototyped the first few commands was the fact that Cobra will simply print whatever strings to provide to Short and Long verbatim.

I don't appreciate this because most of my displays are pretty wide. Paragraphs get smeared across the entire screen and become really challenging to read.

What are the options?

The naïve solution is to just manually terminate my lines at 80 or 120 or some other count. This is "simple" and portable, but extremely tedious.

The other option, as always, is "delegate". I know that there's a few different toolkits out there for terminal interaction, but this time I knew what I wanted to use.

Charming serpents...

charm is a constellation of golang libraries for building terminal applications. My target was lipgloss which handles the bulk of aligning, positioning, and styling terminal output for the rest of Charm's suite.

lipgloss is nice to work with, it has a pretty simple API that seems to mostly stay out of your way. Cobra, on the other hand, is an extremely opinionated library with lots of (mostly optional) wiring.

The lipgloss documentation has plenty of good examples so for brevity I'm going to jump right into where things got interesting.

is easier than taming them

The parts of Cobra we care about here are related to the help and usage outputs. These are handled publicly by Command.Set{Help,Usage}Func, and privately by a handful of unexported functions that take *cobra.Command and shove that into a template.

Setting our helpers

Telling Cobra to use our custom Usage and Help functions is pretty straightforward:

func init() {
        rootCmd.SetUsageFunc(demo.CharmUsage)
        rootCmd.SetHelpFunc(demo.CharmHelp)
}

One helpful feature of Cobra is that child commands inherit properties from their parent, including Usage and Help funcs. This means you only have to set this up once on your root command, and your entire application will be able to leverage this.

Help and Usage

Below, we have the definitions for each function. As you can see, I've managed to cleverly abstract away the hard work and real knowledge by yet another layer; each simply calls out to tmpl() and pretty(), both of which will be explained further.

Because tmpl() is unexported, I had to dig into the Cobra source and copy it out, but that's coming up. For now, it's enough to say that it takes a writer, a template string, and a cobra.Command and executes the template.

The only particularly clever part of this code is leveraging UsageTemplate() and HelpTemplate(). My original implementation copied those templates verbatim as well. If all you need to do is wrap the standard output, you can get the built-in template this way.

package demo

import (
	"bytes"
	"fmt"
	"io"

	"github.com/charmbracelet/lipgloss"
	"github.com/spf13/cobra"
)

func CharmUsage(c *cobra.Command) error {
	var b bytes.Buffer
	err := tmpl(&b, c.UsageTemplate(), c)
	if err != nil {
		c.PrintErrln(err)
		return err
	}
	pretty(c.ErrOrStderr(), b.String())
	return nil
}

func CharmHelp(c *cobra.Command, a []string) {
	var b bytes.Buffer
	// The help should be sent to stdout
	// See https://github.com/spf13/cobra/issues/1002
	err := tmpl(&b, c.HelpTemplate(), c)
	if err != nil {
		c.PrintErrln(err)
	}
	pretty(c.ErrOrStderr(), b.String())
}

pretty()

Below we'll find the implementation of pretty(), a very straightforward function. Take a string and write out the Render()'d version.


var BaseStyle = lipgloss.NewStyle().Bold(true).BorderStyle(lipgloss.RoundedBorder()).Width(60).PaddingLeft(1).PaddingRight(1).PaddingBottom(2)

func pretty(w io.Writer, s string) {
	fmt.Fprintf(w, "%s\n", BaseStyle.Render(s))
}

tmpl() and friends

cobra implements a set of template helper functions, and tmpl(w io.Writer, text string, data interface{}) error which simply executes a template against a writer.

package demo

import (
	"fmt"
	"io"
	"reflect"
	"strconv"
	"strings"
	"text/template"
	"unicode"
)

var templateFuncs = template.FuncMap{
	"trim":                    strings.TrimSpace,
	"trimRightSpace":          trimRightSpace,
	"trimTrailingWhitespaces": trimRightSpace,
	"appendIfNotPresent":      appendIfNotPresent,
	"rpad":                    rpad,
	"gt":                      Gt,
	"eq":                      Eq,
}

// i'm eliding the bodies of these functions for brevity
func Gt(a interface{}, b interface{}) bool {}
func Eq(a interface{}, b interface{}) bool {}
func trimRightSpace(s string) string {}
func appendIfNotPresent(s, stringToAppend string) string {}
func rpad(s string, padding int) string {}

// tmpl executes the given template text on data, writing the result to w.
func tmpl(w io.Writer, text string, data interface{}) error {
	t := template.New("top")
	t.Funcs(templateFuncs)
	template.Must(t.Parse(text))
	return t.Execute(w, data)
}

How does it look?

Not bad:

asciicast

You can find the full code here.

Success Story???

I'm not sure if this is even particularly useful yet. There's edge cases where adding a border causes things to break, and probably more. I'm pretty satisfied with learning more about how cobra is wired up.