Skip to content
Open
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ In the default `-color=auto`, color is emitted only when stdout is a terminal an
the [`NO_COLOR`](https://no-color.org) environment variable is unset. `NO_COLOR`
(any non-empty value) disables color; an explicit `-color=always` overrides it.

The palette can be switched with the `STRUCTALIGN_THEME` environment variable —
`default` (the standard colors), `cga` (a bright 16-color CGA look), or `green` /
`amber` (single-hue phosphor-monitor emulations). It only affects *which* colors
are used when color is on; it does not turn color on by itself. An unknown value
warns and falls back to `default`.

Exit code is **1 when reorderings are found**, **0 when none** — so it drops into
CI as a check. Inspect mode is informational and always exits 0.

Expand Down
42 changes: 42 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,33 @@ func (a *App) Run(args []string) int {
return 2
}
}

// Easter-egg theme flags: -cga/-green/-amber select a retro palette. Like
// -fix, they are caught before parsing and stripped from args, so they stay
// invisible in -help and never trip "flag provided but not defined". Last
// one wins; anything after "--" is left untouched (positional args).
themeName := ""
filtered := args[:0:0]
afterDD := false
for _, arg := range args {
switch {
case afterDD:
filtered = append(filtered, arg)
case arg == "--":
afterDD = true
filtered = append(filtered, arg)
case arg == "-cga" || arg == "--cga":
themeName = "cga"
case arg == "-green" || arg == "--green":
themeName = "green"
case arg == "-amber" || arg == "--amber":
themeName = "amber"
default:
filtered = append(filtered, arg)
}
}
args = filtered

if err := fs.Parse(args); err != nil {
return 2
}
Expand All @@ -167,10 +194,25 @@ func (a *App) Run(args []string) int {
width = ui.ResolveWidth(stdoutFile(a.Stdout))
}
patterns := match.ParsePatterns(opt.typeFilter)

// Resolve the theme: egg flag wins over STRUCTALIGN_THEME, else default.
if themeName == "" {
themeName = os.Getenv("STRUCTALIGN_THEME")
}
theme := ui.DefaultTheme()
if themeName != "" && themeName != "default" {
if th, ok := ui.ThemeByName(themeName); ok {
theme = th
} else {
fmt.Fprintf(a.Stderr, "structalign: unknown theme %q, using default\n", themeName)
}
}

printer := &ui.Printer{
Out: a.Stdout,
Color: ui.WantColor(opt.colorize, stdoutFile(a.Stdout)),
Width: width,
Theme: theme,
}

ld := a.Loader
Expand Down
51 changes: 51 additions & 0 deletions internal/app/theme_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package app_test

import (
"bytes"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"

"github.com/peczenyj/structalign/internal/align"
"github.com/peczenyj/structalign/internal/app"
"github.com/peczenyj/structalign/internal/layout"
"github.com/peczenyj/structalign/internal/mocks"
"github.com/peczenyj/structalign/internal/testutil"
"github.com/peczenyj/structalign/pkg/common"
)

func themeApp(t *testing.T, out, errb *bytes.Buffer) *app.App {
t.Helper()
tgt := testutil.Target(t, src)
ml := mocks.NewLoader(t)
ml.EXPECT().Load(mock.Anything).Return([]common.Target{tgt}, nil)
return &app.App{Loader: ml, Aligner: align.New(), Inspector: layout.New(), Stdout: out, Stderr: errb}
}

func TestRunCgaEggAccepted(t *testing.T) {
var out, errb bytes.Buffer
a := themeApp(t, &out, &errb)
// The egg flag is stripped before flag parsing, so the run proceeds normally
// (exit 1 on findings) rather than failing with "flag provided but not defined".
code := a.Run([]string{"-cga", "-type=Mixed", "pkg"})
assert.Equal(t, 1, code)
assert.NotContains(t, errb.String(), "not defined")
}

func TestRunUnknownThemeEnvWarns(t *testing.T) {
var out, errb bytes.Buffer
a := themeApp(t, &out, &errb)
t.Setenv("STRUCTALIGN_THEME", "bogus")
a.Run([]string{"-type=Mixed", "pkg"})
assert.Contains(t, errb.String(), `unknown theme "bogus"`)
}

func TestEggFlagNotInUsage(t *testing.T) {
var out, errb bytes.Buffer
a := &app.App{Stdout: &out, Stderr: &errb}
a.Run(nil) // no args => prints usage to stderr
assert.NotContains(t, errb.String(), "-cga", "easter-egg flags stay invisible in usage")
assert.NotContains(t, errb.String(), "-green")
assert.NotContains(t, errb.String(), "-amber")
}
67 changes: 51 additions & 16 deletions internal/ui/printer.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,43 @@ const (
cBold = "\x1b[1m"
)

// Theme maps semantic roles to ANSI SGR sequences. The zero value resolves to
// DefaultTheme (see Printer.theme), which reproduces the historical palette.
type Theme struct {
Header string // finding header / inspect "type X struct {" line
Added string // "+" diff lines / added side cells
Removed string // "-" diff lines / removed side cells
Meta string // column titles, divider, layout note, "-- assume" marker
Padding string // inspect padding comment / "_" padding line
Label string // the -summary "Summary:" label
}

// DefaultTheme is the byte-for-byte historical palette.
func DefaultTheme() Theme {
return Theme{
Header: cBold + cCyan,
Added: cGreen,
Removed: cRed,
Meta: cDim,
Padding: cRed,
Label: cBold,
}
}

// Printer renders to Out using the given color/width settings.
type Printer struct {
Out io.Writer
Color bool
Width int // per-side column width for side-by-side diffs
Width int // per-side column width for side-by-side diffs
Theme Theme // zero value resolves to DefaultTheme
}

// theme returns the configured theme, or DefaultTheme when unset.
func (p *Printer) theme() Theme {
if (p.Theme == Theme{}) {
return DefaultTheme()
}
return p.Theme
}

// RenderFindings renders each finding in the chosen diff style. Returns the count.
Expand All @@ -50,7 +82,7 @@ func (p *Printer) RenderLayouts(layouts []common.Layout, verbose, keepTags bool)
// label is bold when color is on; counts are pluralized.
func (p *Printer) RenderSummary(structs int, bytesSaved int64) {
fmt.Fprintf(p.Out, "%s %d %s affected, %d %s saved\n", //nolint:errcheck
paint(p.Color, cBold, "Summary:"),
paint(p.Color, p.theme().Label, "Summary:"),
structs, plural(int64(structs), "struct", "structs"),
bytesSaved, plural(bytesSaved, "byte", "bytes"))
}
Expand All @@ -76,7 +108,7 @@ func (p *Printer) renderFinding(f common.Finding, style common.DiffStyle) {
pct := float64(f.OldSize-f.NewSize) / float64(f.OldSize) * 100
header += fmt.Sprintf(" (%02.2f%% smaller)", pct)
}
fmt.Fprintln(p.Out, paint(p.Color, cBold+cCyan, header)) //nolint:errcheck
fmt.Fprintln(p.Out, paint(p.Color, p.theme().Header, header)) //nolint:errcheck

if f.Original == "" || f.Proposed == "" {
fmt.Fprintln(p.Out, " (no suggested fix produced)") //nolint:errcheck
Expand All @@ -102,6 +134,7 @@ func (p *Printer) renderFinding(f common.Finding, style common.DiffStyle) {
}

func (p *Printer) renderUnified(a, b string) {
th := p.theme()
al := strings.Split(a, "\n")
bl := strings.Split(b, "\n")
ops := textdiff.Lines(al, bl)
Expand All @@ -110,14 +143,15 @@ func (p *Printer) renderUnified(a, b string) {
case textdiff.Equal:
fmt.Fprintf(p.Out, " %s\n", op.Text) //nolint:errcheck
case textdiff.Del:
fmt.Fprintln(p.Out, paint(p.Color, cRed, "- "+op.Text)) //nolint:errcheck
fmt.Fprintln(p.Out, paint(p.Color, th.Removed, "- "+op.Text)) //nolint:errcheck
case textdiff.Add:
fmt.Fprintln(p.Out, paint(p.Color, cGreen, "+ "+op.Text)) //nolint:errcheck
fmt.Fprintln(p.Out, paint(p.Color, th.Added, "+ "+op.Text)) //nolint:errcheck
}
}
}

func (p *Printer) renderSideBySide(a, b string) {
th := p.theme()
al := strings.Split(a, "\n")
bl := strings.Split(b, "\n")
ops := textdiff.Lines(al, bl)
Expand Down Expand Up @@ -156,15 +190,15 @@ func (p *Printer) renderSideBySide(a, b string) {
var l, r string
var lc, rc string
if k < len(dels) {
l, lc = dels[k], cRed
l, lc = dels[k], th.Removed
}
if k < len(adds) {
r, rc = adds[k], cGreen
r, rc = adds[k], th.Added
}
rows = append(rows, row{l, r, lc, rc})
}
case textdiff.Add:
rows = append(rows, row{"", op.Text, "", cGreen})
rows = append(rows, row{"", op.Text, "", th.Added})
i++
}
_ = pendDel
Expand All @@ -177,10 +211,10 @@ func (p *Printer) renderSideBySide(a, b string) {
// Pad the header text manually (not via %-*s) so it stays correct even
// when paint() wraps it in ANSI escapes, which %-*s would miscount.
fmt.Fprintf(p.Out, " %s%s%s\n", //nolint:errcheck
paint(p.Color, cDim, truncPad("current", p.Width)),
paint(p.Color, th.Meta, truncPad("current", p.Width)),
sep,
paint(p.Color, cDim, "proposed"))
fmt.Fprintf(p.Out, " %s\n", paint(p.Color, cDim, //nolint:errcheck
paint(p.Color, th.Meta, "proposed"))
fmt.Fprintf(p.Out, " %s\n", paint(p.Color, th.Meta, //nolint:errcheck
strings.Repeat("─", p.Width)+"─┼─"+strings.Repeat("─", p.Width)))
for _, r := range rows {
left := truncPad(r.l, p.Width)
Expand All @@ -196,6 +230,7 @@ func (p *Printer) renderSideBySide(a, b string) {
}

func (p *Printer) renderLayout(l common.Layout, verbose, keepTags bool) {
th := p.theme()
// Field declaration is "<name> <type>" (plus tag when -tags is set); align
// all comments to the widest declaration.
decls := make([]string, len(l.Fields))
Expand All @@ -213,32 +248,32 @@ func (p *Printer) renderLayout(l common.Layout, verbose, keepTags bool) {

// Optional caveat (e.g. the generic-type disclaimer) above the declaration.
if l.Note != "" {
fmt.Fprintln(p.Out, paint(p.Color, cDim, "// "+l.Note)) //nolint:errcheck
fmt.Fprintln(p.Out, paint(p.Color, th.Meta, "// "+l.Note)) //nolint:errcheck
}

// Header: the struct opening line carries size/align/padding.
header := fmt.Sprintf("type %s%s struct { // size: %d, align: %d, padding: %d",
l.Name, l.TypeParams, l.Total, l.Align, l.Padding)
fmt.Fprintln(p.Out, paint(p.Color, cBold+cCyan, header)) //nolint:errcheck
fmt.Fprintln(p.Out, paint(p.Color, th.Header, header)) //nolint:errcheck

comments, commentWidth := layoutComments(l.Fields, verbose)

for i, f := range l.Fields {
comment := comments[i]
rendered := comment
if !verbose && f.Padding > 0 {
rendered = paint(p.Color, cRed, comment)
rendered = paint(p.Color, th.Padding, comment)
}
line := fmt.Sprintf("\t%-*s // %s", declWidth, decls[i], rendered)
if f.Assume != "" {
pad := strings.Repeat(" ", commentWidth-len(comment))
line += pad + " " + paint(p.Color, cDim, "-- assume "+f.Assume)
line += pad + " " + paint(p.Color, th.Meta, "-- assume "+f.Assume)
}
fmt.Fprintln(p.Out, line) //nolint:errcheck
if verbose && f.Padding > 0 {
// Field line carries no padding; padding gets its own `_` line.
pad := fmt.Sprintf("\t%-*s // %d byte padding", declWidth, "_", f.Padding)
fmt.Fprintln(p.Out, paint(p.Color, cRed, pad)) //nolint:errcheck
fmt.Fprintln(p.Out, paint(p.Color, th.Padding, pad)) //nolint:errcheck
}
}
fmt.Fprintln(p.Out, "}") //nolint:errcheck
Expand Down
44 changes: 44 additions & 0 deletions internal/ui/theme_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package ui_test

import (
"testing"

"github.com/stretchr/testify/assert"

"github.com/peczenyj/structalign/internal/ui"
)

// The default theme must reproduce the historical hardcoded ANSI palette, so
// existing golden output is byte-for-byte unchanged.
func TestDefaultThemeMatchesLegacyConstants(t *testing.T) {
th := ui.DefaultTheme()
assert.Equal(t, "\x1b[1m\x1b[36m", th.Header, "header was bold+cyan")
assert.Equal(t, "\x1b[32m", th.Added, "added was green")
assert.Equal(t, "\x1b[31m", th.Removed, "removed was red")
assert.Equal(t, "\x1b[2m", th.Meta, "meta was dim")
assert.Equal(t, "\x1b[31m", th.Padding, "padding was red")
assert.Equal(t, "\x1b[1m", th.Label, "label was bold")
}

func TestThemeByNameKnown(t *testing.T) {
for _, name := range []string{"default", "cga", "green", "amber"} {
_, ok := ui.ThemeByName(name)
assert.True(t, ok, "theme %q should exist", name)
}
}

func TestThemeByNameUnknown(t *testing.T) {
_, ok := ui.ThemeByName("nope")
assert.False(t, ok)
}

// The green (P1 phosphor) theme is monochrome: it must never use red (31),
// since add/removed are distinguished by intensity + the +/- prefixes.
func TestGreenThemeIsMonochrome(t *testing.T) {
th, ok := ui.ThemeByName("green")
assert.True(t, ok)
for _, sgr := range []string{th.Header, th.Added, th.Removed, th.Meta, th.Padding, th.Label} {
assert.NotContains(t, sgr, "31", "green theme must not use red")
assert.Contains(t, sgr, "32", "green theme uses the green family")
}
}
38 changes: 38 additions & 0 deletions internal/ui/themes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package ui

// builtinThemes holds the named palettes. "default" mirrors DefaultTheme().
// The monochrome themes (green/amber) use a single hue and rely on intensity
// (bold vs dim) plus the +/- diff prefixes to distinguish added from removed.
var builtinThemes = map[string]Theme{
"default": DefaultTheme(),
"cga": {
Header: "\x1b[1m\x1b[96m", // bright cyan
Added: "\x1b[92m", // bright green
Removed: "\x1b[91m", // bright red
Meta: "\x1b[90m", // bright black (gray)
Padding: "\x1b[91m", // bright red
Label: "\x1b[1m\x1b[97m", // bright white
},
"green": {
Header: "\x1b[1m\x1b[32m", // bold green
Added: "\x1b[1m\x1b[32m", // bold green (distinguished by the "+" prefix)
Removed: "\x1b[2m\x1b[32m", // dim green (distinguished by the "-" prefix)
Meta: "\x1b[2m\x1b[32m", // dim green
Padding: "\x1b[2m\x1b[32m", // dim green
Label: "\x1b[1m\x1b[32m", // bold green
},
"amber": {
Header: "\x1b[1m\x1b[38;5;214m", // bold amber (256-color)
Added: "\x1b[1m\x1b[38;5;214m",
Removed: "\x1b[2m\x1b[38;5;214m", // dim amber
Meta: "\x1b[2m\x1b[38;5;214m",
Padding: "\x1b[2m\x1b[38;5;214m",
Label: "\x1b[1m\x1b[38;5;214m",
},
}

// ThemeByName returns a built-in theme and whether it was found.
func ThemeByName(name string) (Theme, bool) {
th, ok := builtinThemes[name]
return th, ok
}
Loading