diff --git a/README.md b/README.md index aeceda5..6a471e0 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,11 @@ structalign [flags] [packages] -tests also analyze _test.go files (skipped by default) -skip-cache-padded skip structs with a golang.org/x/sys/cpu.CacheLinePad field + -show-nolint show structs even when their type carries a recognized + //nolint directive (directives are respected by default) + -nolint-linters string + //nolint tokens that suppress a finding (default + "fieldalignment"; a bare //nolint always counts) -version print version and exit ``` @@ -122,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. @@ -313,6 +324,13 @@ structalign -skip-cache-padded ./... # skip structs guarded by cpu.Cache - **`-skip-cache-padded`** leaves structs with a [`cpu.CacheLinePad`](https://pkg.go.dev/golang.org/x/sys/cpu#CacheLinePad) field alone, since reordering would move the pad and defeat its false-sharing guard. +- **`//nolint` directives are respected by default** (diff mode): a struct whose + type declaration carries a recognized `//nolint` — `//nolint:fieldalignment` or + a bare `//nolint` — is suppressed, matching golangci-lint. `-nolint-linters` + customizes which named tokens count (default `fieldalignment`; e.g. + `-nolint-linters=fieldalignment,betteralign`); a bare `//nolint` always counts. + `-show-nolint` reveals suppressed structs (audit mode). Inspect mode ignores + these directives. ### Field tags diff --git a/internal/align/align.go b/internal/align/align.go index 58252a0..c7c836a 100644 --- a/internal/align/align.go +++ b/internal/align/align.go @@ -43,6 +43,7 @@ func (a *Aligner) Findings(t common.Target, opts common.Options) ([]common.Findi return nil, nil } names := structNameIndex(t.Syntax) + nolints := nolintIndex(t.Syntax, t.Fset) insp := inspector.New(t.Syntax) var findings []common.Finding @@ -55,7 +56,7 @@ func (a *Aligner) Findings(t common.Target, opts common.Options) ([]common.Findi TypesSizes: t.Sizes, // common.Sizes satisfies types.Sizes ResultOf: map[*analysis.Analyzer]any{inspect.Analyzer: insp}, Report: func(d analysis.Diagnostic) { - f := buildFinding(t, d, names, opts) + f := buildFinding(t, d, names, nolints, opts) if f == nil { return } @@ -78,7 +79,7 @@ func (a *Aligner) Findings(t common.Target, opts common.Options) ([]common.Findi // buildFinding converts one analyzer diagnostic into a Finding, applying tag // stripping and all active filters. Returns nil when the finding should be // suppressed. -func buildFinding(t common.Target, d analysis.Diagnostic, names map[token.Pos]string, opts common.Options) *common.Finding { +func buildFinding(t common.Target, d analysis.Diagnostic, names map[token.Pos]string, nolints map[token.Pos]nolintInfo, opts common.Options) *common.Finding { f := common.Finding{Fset: t.Fset, Pos: d.Pos, Message: d.Message} if len(d.SuggestedFixes) > 0 && len(d.SuggestedFixes[0].TextEdits) > 0 { e := d.SuggestedFixes[0].TextEdits[0] @@ -107,6 +108,11 @@ func buildFinding(t common.Target, d analysis.Diagnostic, names map[token.Pos]st if opts.SkipCachePadded && isCachePadded(t, f.Name) { return nil } + if opts.RespectNolint { + if info, ok := nolints[f.Pos]; ok && info.suppressed(opts.NolintLinters) { + return nil + } + } return &f } diff --git a/internal/align/nolint.go b/internal/align/nolint.go new file mode 100644 index 0000000..073449a --- /dev/null +++ b/internal/align/nolint.go @@ -0,0 +1,123 @@ +package align + +import ( + "go/ast" + "go/token" + "strings" +) + +// nolintInfo records the //nolint directive found on a struct type declaration. +type nolintInfo struct { + bare bool // a bare //nolint (suppresses all linters) + tokens map[string]struct{} // named tokens, e.g. {"fieldalignment"} +} + +// suppressed reports whether this directive suppresses a finding given the set +// of honored named linters. A bare //nolint always suppresses. +func (info nolintInfo) suppressed(linters []string) bool { + if info.bare { + return true + } + for _, l := range linters { + if _, ok := info.tokens[l]; ok { + return true + } + } + return false +} + +// nolintIndex maps each named struct type's StructType.Pos() to the //nolint +// directive on its declaration. A directive is recognized from the type's doc +// comment (TypeSpec.Doc, or the enclosing GenDecl.Doc for grouped `type ( ... )` +// blocks) and from any comment on the type's opening line (e.g. a trailing +// `type T struct { //nolint`, which the AST does not attach to TypeSpec.Comment). +// The analyzer reports at StructType.Pos(), so this key matches a Finding's Pos. +func nolintIndex(files []*ast.File, fset *token.FileSet) map[token.Pos]nolintInfo { + index := make(map[token.Pos]nolintInfo) + for _, f := range files { + // Directives keyed by source line, from every comment in the file — + // used to catch a trailing directive on a struct's opening line. + byLine := make(map[int]nolintInfo) + for _, cg := range f.Comments { + for _, c := range cg.List { + line := fset.Position(c.Pos()).Line + info := byLine[line] + parseNolint(c.Text, &info) + byLine[line] = info + } + } + for _, decl := range f.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok != token.TYPE { + continue + } + for _, spec := range gd.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + continue + } + info := collectNolint(ts.Doc, gd.Doc, ts.Comment) + mergeNolint(&info, byLine[fset.Position(st.Pos()).Line]) + if info.bare || len(info.tokens) > 0 { + index[st.Pos()] = info + } + } + } + } + return index +} + +// collectNolint scans the given comment groups for //nolint directives. +func collectNolint(groups ...*ast.CommentGroup) nolintInfo { + var info nolintInfo + for _, g := range groups { + if g == nil { + continue + } + for _, c := range g.List { + parseNolint(c.Text, &info) + } + } + return info +} + +// mergeNolint folds src into dst (bare wins, tokens unioned). +func mergeNolint(dst *nolintInfo, src nolintInfo) { + if src.bare { + dst.bare = true + } + for tok := range src.tokens { + if dst.tokens == nil { + dst.tokens = make(map[string]struct{}) + } + dst.tokens[tok] = struct{}{} + } +} + +// parseNolint reads one "//nolint" or "//nolint:a,b,c" comment into info. +// "//nolint" (alone or followed by space) is bare; "//nolint:list" adds named +// tokens. A comment like "//nolintfoo" is not a directive. +func parseNolint(text string, info *nolintInfo) { + text = strings.TrimSpace(strings.TrimPrefix(text, "//")) + if !strings.HasPrefix(text, "nolint") { + return + } + rest := strings.TrimPrefix(text, "nolint") + switch { + case rest == "" || strings.HasPrefix(rest, " "): + info.bare = true + case strings.HasPrefix(rest, ":"): + for tok := range strings.SplitSeq(rest[1:], ",") { + if tok = strings.TrimSpace(tok); tok != "" { + if info.tokens == nil { + info.tokens = make(map[string]struct{}) + } + info.tokens[tok] = struct{}{} + } + } + } +} diff --git a/internal/align/nolint_test.go b/internal/align/nolint_test.go new file mode 100644 index 0000000..a8687fc --- /dev/null +++ b/internal/align/nolint_test.go @@ -0,0 +1,95 @@ +package align_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/peczenyj/structalign/internal/align" + "github.com/peczenyj/structalign/internal/testutil" + "github.com/peczenyj/structalign/pkg/common" +) + +const nolintSrc = `package sample + +//nolint:fieldalignment +type Suppressed struct { + A bool + B int64 + C bool +} + +type Visible struct { + A bool + B int64 + C bool +} + +//nolint:errcheck +type Unrelated struct { + A bool + B int64 + C bool +} + +type Trailing struct { //nolint + A bool + B int64 + C bool +} + +type TrailingNamed struct { //nolint:fieldalignment + A bool + B int64 + C bool +} + +// A non-struct type declaration the index must skip without panicking. +type Celsius float64 +` + +func findingNames(fs []common.Finding) map[string]bool { + m := make(map[string]bool) + for _, f := range fs { + m[f.Name] = true + } + return m +} + +func TestFindingsRespectNolintByDefault(t *testing.T) { + tgt := testutil.Target(t, nolintSrc) + fs, err := align.New().Findings(tgt, common.Options{ + RespectNolint: true, + NolintLinters: []string{"fieldalignment"}, + }) + require.NoError(t, err) + names := findingNames(fs) + assert.False(t, names["Suppressed"], "//nolint:fieldalignment must suppress") + assert.True(t, names["Visible"], "unmarked struct stays") + assert.True(t, names["Unrelated"], "//nolint:errcheck must NOT suppress fieldalignment") + assert.False(t, names["Trailing"], "a bare //nolint (trailing) must suppress") + assert.False(t, names["TrailingNamed"], "a trailing //nolint:fieldalignment must suppress") +} + +func TestFindingsShowNolint(t *testing.T) { + tgt := testutil.Target(t, nolintSrc) + fs, err := align.New().Findings(tgt, common.Options{RespectNolint: false}) + require.NoError(t, err) + names := findingNames(fs) + assert.True(t, names["Suppressed"], "respect off => suppressed struct reappears") + assert.True(t, names["Trailing"], "respect off => bare-nolint struct reappears") +} + +func TestFindingsNolintLintersConfigurable(t *testing.T) { + tgt := testutil.Target(t, nolintSrc) + // Only honoring "betteralign" => :fieldalignment no longer suppresses. + fs, err := align.New().Findings(tgt, common.Options{ + RespectNolint: true, + NolintLinters: []string{"betteralign"}, + }) + require.NoError(t, err) + names := findingNames(fs) + assert.True(t, names["Suppressed"], "fieldalignment token not in the configured set => not suppressed") + assert.False(t, names["Trailing"], "bare //nolint is always honored regardless of the configured set") +} diff --git a/internal/app/app.go b/internal/app/app.go index e585b7a..2f55bce 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -10,6 +10,7 @@ import ( "regexp" "runtime/debug" "sort" + "strings" "github.com/peczenyj/structalign/internal/align" "github.com/peczenyj/structalign/internal/layout" @@ -73,6 +74,19 @@ type options struct { summary bool sort bool threshold int + showNolint bool + nolintLinters string +} + +// splitCSV splits a comma-separated list, trimming spaces and dropping empties. +func splitCSV(s string) []string { + var out []string + for p := range strings.SplitSeq(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out } // savings is the absolute bytes a finding saves, or 0 when sizes are unknown or @@ -110,6 +124,8 @@ func (a *App) Run(args []string) int { fs.BoolVar(&opt.summary, "summary", false, "in diff mode, print a one-line summary after the diffs") fs.BoolVar(&opt.sort, "sort", false, "present results largest-first (diff: by bytes saved; inspect: by struct size)") fs.IntVar(&opt.threshold, "threshold", 0, "in diff mode, only show structs that save at least this many bytes") + fs.BoolVar(&opt.showNolint, "show-nolint", false, "show structs even when their type carries a recognized //nolint directive") + fs.StringVar(&opt.nolintLinters, "nolint-linters", "fieldalignment", "comma-separated //nolint tokens that suppress a finding (bare //nolint always counts)") fs.Usage = func() { fmt.Fprintf(a.Stderr, "structalign: print field-aligned struct reorderings (no file changes)\n\n") fmt.Fprintf(a.Stderr, "usage: structalign [flags] [packages]\n\n") @@ -128,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 } @@ -151,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 @@ -186,6 +244,8 @@ func (a *App) Run(args []string) int { KeepTags: opt.tags, IncludeGenerated: opt.generated, SkipCachePadded: opt.skipCachePadded, + RespectNolint: !opt.showNolint, + NolintLinters: splitCSV(opt.nolintLinters), } if opt.inspect { allLayouts = append(allLayouts, a.Inspector.Layouts(t, o)...) diff --git a/internal/app/nolint_test.go b/internal/app/nolint_test.go new file mode 100644 index 0000000..c8cc91c --- /dev/null +++ b/internal/app/nolint_test.go @@ -0,0 +1,66 @@ +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" +) + +const appNolintSrc = `package sample + +//nolint:fieldalignment +type Hidden struct { + A bool + B int64 + C bool +} +` + +func nolintApp(t *testing.T, out, errb *bytes.Buffer) *app.App { + t.Helper() + tgt := testutil.Target(t, appNolintSrc) + 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 TestRunHidesNolintByDefault(t *testing.T) { + var out, errb bytes.Buffer + a := nolintApp(t, &out, &errb) + code := a.Run([]string{"pkg"}) + assert.Equal(t, 0, code, "only struct is suppressed => no findings => exit 0") + assert.NotContains(t, out.String(), "Hidden") +} + +func TestRunShowNolintReveals(t *testing.T) { + var out, errb bytes.Buffer + a := nolintApp(t, &out, &errb) + code := a.Run([]string{"-show-nolint", "pkg"}) + assert.Equal(t, 1, code) + assert.Contains(t, out.String(), "Hidden") +} + +func TestRunNolintLintersOptOut(t *testing.T) { + var out, errb bytes.Buffer + a := nolintApp(t, &out, &errb) + // Honor only betteralign => :fieldalignment no longer suppresses Hidden. + code := a.Run([]string{"-nolint-linters=betteralign", "pkg"}) + assert.Equal(t, 1, code) + assert.Contains(t, out.String(), "Hidden") +} + +func TestRunInspectIgnoresNolint(t *testing.T) { + var out, errb bytes.Buffer + a := nolintApp(t, &out, &errb) + _ = a.Run([]string{"-inspect", "pkg"}) + assert.Contains(t, out.String(), "type Hidden struct", "inspect ignores //nolint") +} diff --git a/internal/app/theme_test.go b/internal/app/theme_test.go new file mode 100644 index 0000000..6e3c428 --- /dev/null +++ b/internal/app/theme_test.go @@ -0,0 +1,78 @@ +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 TestRunGreenAndAmberEggsAccepted(t *testing.T) { + for _, egg := range []string{"-green", "-amber"} { + var out, errb bytes.Buffer + a := themeApp(t, &out, &errb) + code := a.Run([]string{egg, "-type=Mixed", "pkg"}) + assert.Equal(t, 1, code, "%s should be stripped and the run proceed", egg) + assert.NotContains(t, errb.String(), "not defined") + } +} + +func TestRunValidThemeEnvNoWarning(t *testing.T) { + var out, errb bytes.Buffer + a := themeApp(t, &out, &errb) + t.Setenv("STRUCTALIGN_THEME", "green") + a.Run([]string{"-type=Mixed", "pkg"}) + assert.NotContains(t, errb.String(), "unknown theme", "a valid theme must not warn") +} + +func TestRunDoubleDashStopsEggStripping(t *testing.T) { + var out, errb bytes.Buffer + a := themeApp(t, &out, &errb) + // After "--", args are positional (package) args; the loop's afterDD branch + // passes them through untouched. + code := a.Run([]string{"-type=Mixed", "--", "pkg"}) + assert.Equal(t, 1, code) +} + +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") +} diff --git a/internal/ui/printer.go b/internal/ui/printer.go index ff88d9f..f99c53b 100644 --- a/internal/ui/printer.go +++ b/internal/ui/printer.go @@ -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. @@ -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")) } @@ -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 @@ -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) @@ -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) @@ -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 @@ -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) @@ -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 " " (plus tag when -tags is set); align // all comments to the widest declaration. decls := make([]string, len(l.Fields)) @@ -213,13 +248,13 @@ 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) @@ -227,18 +262,18 @@ func (p *Printer) renderLayout(l common.Layout, verbose, keepTags bool) { 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 diff --git a/internal/ui/theme_test.go b/internal/ui/theme_test.go new file mode 100644 index 0000000..5740797 --- /dev/null +++ b/internal/ui/theme_test.go @@ -0,0 +1,56 @@ +package ui_test + +import ( + "bytes" + "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) +} + +// Rendering with a non-default theme set on the Printer must use that theme's +// codes (exercises Printer.theme()'s "theme is set" branch). +func TestPrinterUsesSetTheme(t *testing.T) { + cga, ok := ui.ThemeByName("cga") + assert.True(t, ok) + var buf bytes.Buffer + p := &ui.Printer{Out: &buf, Color: true, Theme: cga} + p.RenderSummary(1, 8) + assert.Contains(t, buf.String(), "\x1b[1m\x1b[97m", "cga Label (bold bright white) should be used") +} + +// 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") + } +} diff --git a/internal/ui/themes.go b/internal/ui/themes.go new file mode 100644 index 0000000..05b1427 --- /dev/null +++ b/internal/ui/themes.go @@ -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 +} diff --git a/pkg/common/options.go b/pkg/common/options.go index 1e4e77b..41de051 100644 --- a/pkg/common/options.go +++ b/pkg/common/options.go @@ -6,4 +6,6 @@ type Options struct { KeepTags bool // preserve struct field tags in rendered text IncludeGenerated bool // analyze structs in generated files (default: skip them) SkipCachePadded bool // skip structs containing a golang.org/x/sys/cpu.CacheLinePad field + RespectNolint bool // suppress findings on types carrying a recognized //nolint directive + NolintLinters []string // named //nolint tokens that trigger suppression (bare //nolint always counts) }