package bonfire import ( "fmt" "io" "os" "strings" "charm.land/lipgloss/v2" "github.com/charmbracelet/colorprofile" "golang.org/x/term" ) const ( symCheck = "✓" symCross = "✗" symWarn = "⚠" symInfo = "ℹ" symArrow = "→" symPrompt = "?" ) // Output is the injected console used by commands. Tests supply streams. type Output interface { io.Writer Println(a ...any) Printf(format string, a ...any) Info(message string) Success(message string) Error(message string) Warning(message string) Table(headers []string, rows [][]string) Spinner(message string, fn func() error) error Progress(total int, fn func(Progress) error) error Ask(question, def string) (string, error) Confirm(question string, def bool) (bool, error) Choice(question string, choices []string, def int) (string, error) Secret(question string) (string, error) } // Progress is a single progress bar advanced from a command. type Progress interface { Advance(step int) } type console struct { in io.Reader out io.Writer err io.Writer lookup func(string) string // tty is true when stdout is a terminal; it picks animated widgets // and boxed tables. tty bool // interactive is true when stdin and stdout are terminals. interactive bool // inTTY is true when stdin is a terminal; Secret then reads a hidden // value instead of a line. inTTY bool // color is the environment's color policy (NO_COLOR, TERM=dumb, // FORCE_COLOR, terminal) for ColorAuto. color bool // globals are the parsed global options of the running command, set // by the root before the command runs. globals Globals palette Palette } // NewOutput builds Output from injected streams and the process color policy. func NewOutput(in io.Reader, out, errW io.Writer) Output { return newConsole(in, out, errW, os.Getenv, nil, nil) } func newConsole(in io.Reader, out, errW io.Writer, lookup func(string) string, tty, interactive *bool) *console { if in == nil { in = os.Stdin } if out == nil { out = os.Stdout } if errW == nil { errW = out } if lookup == nil { lookup = os.Getenv } c := &console{in: in, out: out, err: errW, lookup: lookup, palette: DefaultPalette()} if tty != nil { c.tty = *tty } else { c.tty = isTerminal(out) } c.inTTY = isTerminal(in) if interactive != nil { c.interactive = *interactive } else { c.interactive = c.inTTY && c.tty } c.color = supportsColor(lookup, c.tty) return c } func supportsColor(lookup func(string) string, tty bool) bool { if lookup == nil { lookup = os.Getenv } if lookup("NO_COLOR") != "" || lookup("TERM") == "dumb" { return false } if lookup("FORCE_COLOR") != "" { return true } return tty } func isTerminal(rw any) bool { type fd interface{ Fd() uintptr } f, ok := rw.(fd) if !ok { return false } return term.IsTerminal(int(f.Fd())) } // setGlobals records the parsed global options of the running command. func (c *console) setGlobals(g Globals) { c.globals = g } // live reports whether widgets animate: stdout is a terminal and none of // -q, --no-ansi and -n is set (D-03). A live view that is already running // makes nested widgets print plain lines. func (c *console) live() bool { g := c.globals return c.tty && !g.Quiet && g.Color != ColorNever && !g.NoInteraction && !liveActive() } // canPrompt reports whether prompts may wait for input from a person: // stdin and stdout are terminals and -n (or -q) is not set. func (c *console) canPrompt() bool { return c.interactive && !c.globals.NoInteraction } // profile is the color profile of styled output: none for --no-ansi or // when the environment turns color off, at least 256 colors otherwise. func (c *console) profile() colorprofile.Profile { switch c.globals.Color { case ColorNever: return colorprofile.NoTTY case ColorAuto: if !c.color { return colorprofile.NoTTY } } return DetectProfile(c.out, ColorAlways, c.environ()) } // environ rebuilds the color-related environment from lookup. func (c *console) environ() []string { var env []string for _, k := range []string{"TERM", "COLORTERM", "NO_COLOR", "FORCE_COLOR", "CLICOLOR", "CLICOLOR_FORCE"} { if v := c.lookup(k); v != "" { env = append(env, k+"="+v) } } return env } // styled wraps w so palette styles follow the color profile. func (c *console) styled(w io.Writer) io.Writer { return &colorprofile.Writer{Forward: w, Profile: c.profile()} } // emit writes s, a styled line or block, to w. While a live view runs on // stdout, lines for stdout print above it instead of tearing it. func (c *console) emit(w io.Writer, s string) { if w == nil { return } if w == c.out && printAboveLines(s) { return } _, _ = io.WriteString(c.styled(w), s) } func (c *console) Write(p []byte) (int, error) { if c.out == nil { return 0, io.ErrClosedPipe } return c.out.Write(p) } func (c *console) Println(a ...any) { if c.out == nil { return } s := fmt.Sprintln(a...) if printAboveLines(s) { return } _, _ = io.WriteString(c.out, s) } func (c *console) Printf(format string, a ...any) { if c.out == nil { return } s := fmt.Sprintf(format, a...) if strings.HasSuffix(s, "\n") && printAboveLines(s) { return } _, _ = io.WriteString(c.out, s) } // status prints a status line unless -q is set. func (c *console) status(style lipgloss.Style, symbol, message string) { if c.globals.Quiet { return } c.emit(c.out, style.Render(symbol)+" "+message+"\n") } func (c *console) Info(message string) { c.status(c.palette.Current, symInfo, message) } func (c *console) Success(message string) { c.status(c.palette.Success, symCheck, message) } func (c *console) Error(message string) { w := c.err if w == nil { w = c.out } c.emit(w, c.palette.Error.Render(symCross)+" "+message+"\n") } func (c *console) Warning(message string) { c.status(c.palette.Warning, symWarn, message) } func boolPtr(v bool) *bool { return &v }