refactor(diary): share the console types through internal/consolekit so diary no longer links bonfire (IN-07, quick-261028-rqd)
This commit is contained in:
226
internal/consolekit/consolekit.go
Normal file
226
internal/consolekit/consolekit.go
Normal file
@@ -0,0 +1,226 @@
|
||||
// Package consolekit holds the console types and helpers shared by bonfire
|
||||
// and diary without the TUI stack: the color mode, the palette, color
|
||||
// detection, the control-character sanitizer, the global options and the
|
||||
// hook that prints a line above a running live view. It imports lipgloss and
|
||||
// colorprofile only, so diary (and the security modules that log through
|
||||
// it) never link cobra, bubbletea, bubbles or huh. bonfire re-exports every
|
||||
// identifier under its own name.
|
||||
package consolekit
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
)
|
||||
|
||||
// ColorMode selects how console output decides on color: from the terminal
|
||||
// and the environment, always, or never.
|
||||
type ColorMode int
|
||||
|
||||
const (
|
||||
// ColorAuto colors output when the writer is a terminal and the
|
||||
// environment allows it (NO_COLOR and TERM=dumb disable color,
|
||||
// FORCE_COLOR enables it on any writer).
|
||||
ColorAuto ColorMode = iota
|
||||
// ColorAlways colors output whatever the writer is (--ansi).
|
||||
ColorAlways
|
||||
// ColorNever writes no escape codes at all (--no-ansi).
|
||||
ColorNever
|
||||
)
|
||||
|
||||
// Palette is the shared console look: the status styles of list, help,
|
||||
// widgets and log lines. Render through a writer that applies the color
|
||||
// profile (DetectProfile), because lipgloss always emits ANSI codes.
|
||||
type Palette struct {
|
||||
// Success marks completed work (green).
|
||||
Success lipgloss.Style
|
||||
// Current marks the item in progress and informational lines (cyan).
|
||||
Current lipgloss.Style
|
||||
// Warning marks warnings (yellow).
|
||||
Warning lipgloss.Style
|
||||
// Error marks failures (red).
|
||||
Error lipgloss.Style
|
||||
// ErrorBold marks fatal failures and error levels (bold red).
|
||||
ErrorBold lipgloss.Style
|
||||
// Muted marks secondary detail such as times and keys (gray).
|
||||
Muted lipgloss.Style
|
||||
// Header marks headings and names (bold).
|
||||
Header lipgloss.Style
|
||||
}
|
||||
|
||||
// DefaultPalette returns the console palette, ported from SSU's status
|
||||
// styles: green success, cyan current, yellow warning, red and bold red
|
||||
// errors, gray (240) muted text and bold headers.
|
||||
func DefaultPalette() Palette {
|
||||
return Palette{
|
||||
Success: lipgloss.NewStyle().Foreground(lipgloss.Color("2")),
|
||||
Current: lipgloss.NewStyle().Foreground(lipgloss.Color("6")),
|
||||
Warning: lipgloss.NewStyle().Foreground(lipgloss.Color("3")),
|
||||
Error: lipgloss.NewStyle().Foreground(lipgloss.Color("1")),
|
||||
ErrorBold: lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true),
|
||||
Muted: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
|
||||
Header: lipgloss.NewStyle().Bold(true),
|
||||
}
|
||||
}
|
||||
|
||||
// DetectProfile returns the color profile for console output written to w.
|
||||
// ColorNever gives colorprofile.NoTTY (no escape codes at all). ColorAlways
|
||||
// gives the detected profile raised to at least 256 colors. ColorAuto treats
|
||||
// a non-empty FORCE_COLOR as ColorAlways unless NO_COLOR is set or TERM is
|
||||
// dumb, and otherwise detects the profile from w and environ, which honours
|
||||
// NO_COLOR and TERM=dumb. A nil environ means os.Environ().
|
||||
func DetectProfile(w io.Writer, mode ColorMode, environ []string) colorprofile.Profile {
|
||||
if mode == ColorNever {
|
||||
return colorprofile.NoTTY
|
||||
}
|
||||
if environ == nil {
|
||||
environ = os.Environ()
|
||||
}
|
||||
if mode == ColorAuto && forceColor(environ) {
|
||||
mode = ColorAlways
|
||||
}
|
||||
p := colorprofile.Detect(w, environ)
|
||||
if mode == ColorAlways && p < colorprofile.ANSI256 {
|
||||
p = colorprofile.ANSI256
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func forceColor(environ []string) bool {
|
||||
if envValue(environ, "NO_COLOR") != "" || envValue(environ, "TERM") == "dumb" {
|
||||
return false
|
||||
}
|
||||
return envValue(environ, "FORCE_COLOR") != ""
|
||||
}
|
||||
|
||||
func envValue(environ []string, key string) string {
|
||||
for i := len(environ) - 1; i >= 0; i-- {
|
||||
k, v, ok := strings.Cut(environ[i], "=")
|
||||
if ok && k == key {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Sanitize removes control characters other than tab from s; carriage
|
||||
// returns and line feeds become spaces, so a multi-line value stays on one
|
||||
// line. Text from outside the operator's control (database or remote error
|
||||
// text, names an administrator typed) then cannot move the cursor, clear
|
||||
// the screen or set the window title. Invalid UTF-8 becomes U+FFFD first,
|
||||
// so a raw 8-bit C1 byte (such as 0x9b, CSI) cannot reach a terminal that
|
||||
// honours it, and the result is always valid UTF-8. A clean, valid string
|
||||
// is returned unchanged.
|
||||
func Sanitize(s string) string {
|
||||
clean := utf8.ValidString(s)
|
||||
if clean {
|
||||
for _, r := range s {
|
||||
if r != '\t' && unicode.IsControl(r) {
|
||||
clean = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if clean {
|
||||
return s
|
||||
}
|
||||
s = strings.ToValidUTF8(s, "<22>")
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '\n' || r == '\r':
|
||||
b.WriteByte(' ')
|
||||
case r != '\t' && unicode.IsControl(r):
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// MaxVerbosity caps -v, -vv and -vvv (and repeated --verbose).
|
||||
const MaxVerbosity = 3
|
||||
|
||||
// Globals are the global console options every command accepts: -v, -vv and
|
||||
// -vvv (--verbose), -q (--quiet), --ansi and --no-ansi, -n
|
||||
// (--no-interaction) and --env.
|
||||
type Globals struct {
|
||||
// Verbosity is 0 by default and 1 to 3 for -v, -vv and -vvv.
|
||||
Verbosity int
|
||||
// Quiet suppresses decoration and info lines; it implies NoInteraction.
|
||||
Quiet bool
|
||||
// Color is ColorAlways for --ansi, ColorNever for --no-ansi and
|
||||
// ColorAuto otherwise; the last of the two flags wins.
|
||||
Color ColorMode
|
||||
// NoInteraction makes prompts return their defaults without asking.
|
||||
NoInteraction bool
|
||||
// Env is the --env value: the environment the command runs under.
|
||||
Env string
|
||||
}
|
||||
|
||||
// Normalize caps the verbosity to 0..MaxVerbosity and lets Quiet imply
|
||||
// NoInteraction.
|
||||
func Normalize(g Globals) Globals {
|
||||
g.Verbosity = min(max(g.Verbosity, 0), MaxVerbosity)
|
||||
if g.Quiet {
|
||||
g.NoInteraction = true
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// Args re-serialises the globals as command-line flags, for forwarding to
|
||||
// another program: -v, -vv or -vvv, -q, -n, --ansi or --no-ansi and
|
||||
// --env=<value>. The zero value gives no arguments.
|
||||
func (g Globals) Args() []string {
|
||||
var out []string
|
||||
if v := min(g.Verbosity, MaxVerbosity); v > 0 {
|
||||
out = append(out, "-"+strings.Repeat("v", v))
|
||||
}
|
||||
if g.Quiet {
|
||||
out = append(out, "-q")
|
||||
}
|
||||
if g.NoInteraction && !g.Quiet {
|
||||
out = append(out, "-n")
|
||||
}
|
||||
switch g.Color {
|
||||
case ColorAlways:
|
||||
out = append(out, "--ansi")
|
||||
case ColorNever:
|
||||
out = append(out, "--no-ansi")
|
||||
}
|
||||
if g.Env != "" {
|
||||
out = append(out, "--env="+g.Env)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// printAboveHook is the function PrintAbove calls; bonfire registers its
|
||||
// live view printer when it is linked.
|
||||
var printAboveHook atomic.Pointer[func(string) bool]
|
||||
|
||||
// SetPrintAbove registers fn as the printer PrintAbove calls; nil removes
|
||||
// it. bonfire registers its live view printer in an init function.
|
||||
func SetPrintAbove(fn func(string) bool) {
|
||||
if fn == nil {
|
||||
printAboveHook.Store(nil)
|
||||
return
|
||||
}
|
||||
printAboveHook.Store(&fn)
|
||||
}
|
||||
|
||||
// PrintAbove prints s, one or more lines, above the running live view
|
||||
// through the registered printer and reports whether it did. Without a
|
||||
// printer (bonfire is not linked, so no live view can run) it reports
|
||||
// false and the caller writes s itself.
|
||||
func PrintAbove(s string) bool {
|
||||
if fn := printAboveHook.Load(); fn != nil {
|
||||
return (*fn)(s)
|
||||
}
|
||||
return false
|
||||
}
|
||||
44
internal/consolekit/consolekit_test.go
Normal file
44
internal/consolekit/consolekit_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package consolekit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPrintAboveHook: without a registered printer PrintAbove reports
|
||||
// false; with one it forwards the text and its answer; SetPrintAbove(nil)
|
||||
// removes it again.
|
||||
func TestPrintAboveHook(t *testing.T) {
|
||||
t.Cleanup(func() { SetPrintAbove(nil) })
|
||||
SetPrintAbove(nil)
|
||||
if PrintAbove("x") {
|
||||
t.Fatal("PrintAbove without a printer reported true")
|
||||
}
|
||||
var got []string
|
||||
SetPrintAbove(func(s string) bool {
|
||||
got = append(got, s)
|
||||
return true
|
||||
})
|
||||
if !PrintAbove("line") || strings.Join(got, ",") != "line" {
|
||||
t.Fatalf("PrintAbove with a printer: got %q", got)
|
||||
}
|
||||
SetPrintAbove(nil)
|
||||
if PrintAbove("again") || len(got) != 1 {
|
||||
t.Fatalf("PrintAbove after SetPrintAbove(nil): got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeAndArgs: Normalize caps the verbosity and lets Quiet imply
|
||||
// NoInteraction; Args emits the flags bonfire forwards.
|
||||
func TestNormalizeAndArgs(t *testing.T) {
|
||||
if g := Normalize(Globals{Verbosity: 9, Quiet: true}); g.Verbosity != MaxVerbosity || !g.NoInteraction {
|
||||
t.Errorf("Normalize = %+v", g)
|
||||
}
|
||||
if g := Normalize(Globals{Verbosity: -2}); g.Verbosity != 0 {
|
||||
t.Errorf("Normalize negative = %+v", g)
|
||||
}
|
||||
g := Globals{Verbosity: 2, Quiet: true, NoInteraction: true, Color: ColorNever, Env: "dev"}
|
||||
if got := strings.Join(g.Args(), " "); got != "-vv -q --no-ansi --env=dev" {
|
||||
t.Errorf("Args = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user