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.

160 lines
6.7 KiB
Markdown

---
draft: false
title: "Putting Lipgloss on a Snake: Prettier Help Output for Cobra"
aliases: ["Putting Lipgloss on a Snake: Prettier Help Output for Cobra" ]
series: []
date: "2023-05-08"
author: "Nick Dumas"
cover: ""
keywords: ["", ""]
description: "Using lipgloss to abstract away the specifics of nice terminal output."
showFullContent: false
tags:
- golang
- cli
---
## What am I Doing?
[Cobra](https://github.com/spf13/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](https://charm.sh/) is a constellation of golang libraries for building terminal applications. My target was [lipgloss](https://github.com/charmbracelet/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:
```go {title="cmd/root.go"}
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.
```go {title="gloss.go"}
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.
```go {title="gloss.go"}
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.
```go {title="template.go"}
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](https://asciinema.org/a/Hyk4epQZiPjjzLTO1MvmuMzaZ.svg)](https://asciinema.org/a/Hyk4epQZiPjjzLTO1MvmuMzaZ)
You can find the full code [here](https://github.com/therealfakemoot/lipgloss-cobra-demo).
## 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.