From dc3da221dbfc3438a9469a8b98bc3a4819390060 Mon Sep 17 00:00:00 2001 From: Tiago Peczenyj Date: Tue, 26 May 2026 14:21:24 +0200 Subject: [PATCH 1/3] feat: add -sort to order diff findings by bytes saved -sort (default off) collects findings across all scanned packages and presents them largest-first by absolute bytes saved (OldSize-NewSize), using a stable sort so equal-savings structs keep source order and unknown-size findings sink to the bottom. Diff mode only for now; inspect support follows. Part of #30 Co-Authored-By: Claude Opus 4.7 --- README.md | 1 + internal/app/app.go | 8 +++++ internal/app/sort_test.go | 62 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 internal/app/sort_test.go diff --git a/README.md b/README.md index 5141b02..20d68a8 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ structalign [flags] [packages] -verbose in -inspect mode, show padding on its own `_` line -tags preserve struct field tags in output (default: strip them) -summary in diff mode, print a one-line summary after the diffs + -sort in diff mode, present structs largest-first (by bytes saved) -type string only consider named structs matching these comma-separated glob patterns (e.g. "*Request,Config"); empty means all diff --git a/internal/app/app.go b/internal/app/app.go index af16598..4e69798 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -71,6 +71,7 @@ type options struct { generated bool skipCachePadded bool summary bool + sort bool } // savings is the absolute bytes a finding saves, or 0 when sizes are unknown or @@ -106,6 +107,7 @@ func (a *App) Run(args []string) int { fs.BoolVar(&opt.generated, "generated", false, "also analyze generated files (// Code generated ... DO NOT EDIT.)") fs.BoolVar(&opt.skipCachePadded, "skip-cache-padded", false, "skip structs containing a golang.org/x/sys/cpu.CacheLinePad field") 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)") 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") @@ -195,6 +197,12 @@ func (a *App) Run(args []string) int { } } + if opt.sort && !opt.inspect { + sort.SliceStable(allFindings, func(i, j int) bool { + return savings(allFindings[i]) > savings(allFindings[j]) + }) + } + var total int if opt.inspect { total = printer.RenderLayouts(allLayouts, opt.verbose, opt.tags) diff --git a/internal/app/sort_test.go b/internal/app/sort_test.go new file mode 100644 index 0000000..d696b06 --- /dev/null +++ b/internal/app/sort_test.go @@ -0,0 +1,62 @@ +package app_test + +import ( + "bytes" + "strings" + "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" +) + +// Small saves 8 bytes (24->16); Big saves 16 (40->24) — more interleaved fields. +const sortSrc = `package sample + +type Small struct { + A bool + B int64 + C bool +} + +type Big struct { + A bool + B int64 + C bool + D int64 + E bool +} +` + +func sortApp(t *testing.T, out, errb *bytes.Buffer) *app.App { + t.Helper() + tgt := testutil.Target(t, sortSrc) + 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 TestRunSortDiffOrdersBySavings(t *testing.T) { + var out, errb bytes.Buffer + a := sortApp(t, &out, &errb) + code := a.Run([]string{"-sort", "pkg"}) + assert.Equal(t, 1, code) + s := out.String() + assert.Less(t, strings.Index(s, "Big"), strings.Index(s, "Small"), + "with -sort, the bigger-saving struct must render first") +} + +func TestRunDiffDefaultOrderUnchanged(t *testing.T) { + var out, errb bytes.Buffer + a := sortApp(t, &out, &errb) + _ = a.Run([]string{"pkg"}) + s := out.String() + assert.Less(t, strings.Index(s, "Small"), strings.Index(s, "Big"), + "without -sort, source order (Small before Big) is preserved") +} From e7748d8027e88b809ed34c39d95fd729fd85daca Mon Sep 17 00:00:00 2001 From: Tiago Peczenyj Date: Tue, 26 May 2026 14:31:14 +0200 Subject: [PATCH 2/3] feat: extend -sort to order inspect layouts by struct size In -inspect mode, -sort now collects all layouts across packages and orders them largest-first by Layout.Total (stable sort). Updates the flag help to cover both modes. Diff behavior unchanged. Closes #30 Co-Authored-By: Claude Opus 4.7 --- README.md | 3 ++- internal/app/app.go | 16 ++++++++----- internal/app/sort_test.go | 47 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 20d68a8..1077063 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,8 @@ structalign [flags] [packages] -verbose in -inspect mode, show padding on its own `_` line -tags preserve struct field tags in output (default: strip them) -summary in diff mode, print a one-line summary after the diffs - -sort in diff mode, present structs largest-first (by bytes saved) + -sort present results largest-first (diff: by bytes saved; + inspect: by struct size) -type string only consider named structs matching these comma-separated glob patterns (e.g. "*Request,Config"); empty means all diff --git a/internal/app/app.go b/internal/app/app.go index 4e69798..2873d74 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -107,7 +107,7 @@ func (a *App) Run(args []string) int { fs.BoolVar(&opt.generated, "generated", false, "also analyze generated files (// Code generated ... DO NOT EDIT.)") fs.BoolVar(&opt.skipCachePadded, "skip-cache-padded", false, "skip structs containing a golang.org/x/sys/cpu.CacheLinePad field") 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)") + fs.BoolVar(&opt.sort, "sort", false, "present results largest-first (diff: by bytes saved; inspect: by struct size)") 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") @@ -197,10 +197,16 @@ func (a *App) Run(args []string) int { } } - if opt.sort && !opt.inspect { - sort.SliceStable(allFindings, func(i, j int) bool { - return savings(allFindings[i]) > savings(allFindings[j]) - }) + if opt.sort { + if opt.inspect { + sort.SliceStable(allLayouts, func(i, j int) bool { + return allLayouts[i].Total > allLayouts[j].Total + }) + } else { + sort.SliceStable(allFindings, func(i, j int) bool { + return savings(allFindings[i]) > savings(allFindings[j]) + }) + } } var total int diff --git a/internal/app/sort_test.go b/internal/app/sort_test.go index d696b06..8858102 100644 --- a/internal/app/sort_test.go +++ b/internal/app/sort_test.go @@ -60,3 +60,50 @@ func TestRunDiffDefaultOrderUnchanged(t *testing.T) { assert.Less(t, strings.Index(s, "Small"), strings.Index(s, "Big"), "without -sort, source order (Small before Big) is preserved") } + +// inspectSortSrc names the small struct alphabetically first (Alpha, 24 bytes) +// and the large one last (Zeta, 40 bytes), so alphabetical order (the inspect +// default) and size-descending order genuinely differ. +const inspectSortSrc = `package sample + +type Alpha struct { + A bool + B int64 + C bool +} + +type Zeta struct { + A bool + B int64 + C bool + D int64 + E bool +} +` + +func inspectSortApp(t *testing.T, out, errb *bytes.Buffer) *app.App { + t.Helper() + tgt := testutil.Target(t, inspectSortSrc) + 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 TestRunSortInspectOrdersBySize(t *testing.T) { + var out, errb bytes.Buffer + a := inspectSortApp(t, &out, &errb) + code := a.Run([]string{"-inspect", "-sort", "pkg"}) + assert.Equal(t, 0, code) + s := out.String() + assert.Less(t, strings.Index(s, "type Zeta"), strings.Index(s, "type Alpha"), + "with -inspect -sort, the larger struct (Zeta, 40) renders before Alpha (24)") +} + +func TestRunInspectDefaultOrderUnchanged(t *testing.T) { + var out, errb bytes.Buffer + a := inspectSortApp(t, &out, &errb) + _ = a.Run([]string{"-inspect", "pkg"}) + s := out.String() + assert.Less(t, strings.Index(s, "type Alpha"), strings.Index(s, "type Zeta"), + "without -sort, inspect keeps its default (alphabetical) order: Alpha before Zeta") +} From 2a38ce5b12194eb775bebb1daca7c7056eca4fa6 Mon Sep 17 00:00:00 2001 From: Tiago Peczenyj Date: Tue, 26 May 2026 14:32:38 +0200 Subject: [PATCH 3/3] feat: add -threshold to filter diff findings by bytes saved -threshold N (int, default 0, diff-only) drops findings that save fewer than N bytes. Negative values are treated as 0 (no filtering). The filter runs before sort and summary, so -summary counts only the kept structs and the exit code is 0 when everything is filtered out. Closes #31 Co-Authored-By: Claude Opus 4.7 --- README.md | 2 ++ internal/app/app.go | 13 ++++++++ internal/app/threshold_test.go | 55 ++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 internal/app/threshold_test.go diff --git a/README.md b/README.md index 1077063..f0f1580 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,8 @@ structalign [flags] [packages] -summary in diff mode, print a one-line summary after the diffs -sort present results largest-first (diff: by bytes saved; inspect: by struct size) + -threshold int in diff mode, only show structs that save at least N bytes + (default 0; negatives treated as 0) -type string only consider named structs matching these comma-separated glob patterns (e.g. "*Request,Config"); empty means all diff --git a/internal/app/app.go b/internal/app/app.go index 2873d74..e585b7a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -72,6 +72,7 @@ type options struct { skipCachePadded bool summary bool sort bool + threshold int } // savings is the absolute bytes a finding saves, or 0 when sizes are unknown or @@ -108,6 +109,7 @@ func (a *App) Run(args []string) int { fs.BoolVar(&opt.skipCachePadded, "skip-cache-padded", false, "skip structs containing a golang.org/x/sys/cpu.CacheLinePad field") 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.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") @@ -197,6 +199,17 @@ func (a *App) Run(args []string) int { } } + if !opt.inspect && opt.threshold > 0 { + min := int64(opt.threshold) + kept := allFindings[:0] + for _, f := range allFindings { + if savings(f) >= min { + kept = append(kept, f) + } + } + allFindings = kept + } + if opt.sort { if opt.inspect { sort.SliceStable(allLayouts, func(i, j int) bool { diff --git a/internal/app/threshold_test.go b/internal/app/threshold_test.go new file mode 100644 index 0000000..d6eb1c1 --- /dev/null +++ b/internal/app/threshold_test.go @@ -0,0 +1,55 @@ +package app_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// These reuse sortApp / sortSrc from sort_test.go, where Small saves 8 bytes +// (24->16) and Big saves 16 (40->24). + +func TestRunThresholdFiltersSmallSavings(t *testing.T) { + var out, errb bytes.Buffer + a := sortApp(t, &out, &errb) + // 16 is inclusive: keeps Big (saves 16), drops Small (saves 8). + code := a.Run([]string{"-threshold=16", "pkg"}) + assert.Equal(t, 1, code) + s := out.String() + assert.Contains(t, s, "Big") + assert.NotContains(t, s, "Small") +} + +func TestRunThresholdZeroShowsAll(t *testing.T) { + var out, errb bytes.Buffer + a := sortApp(t, &out, &errb) + _ = a.Run([]string{"-threshold=0", "pkg"}) + s := out.String() + assert.Contains(t, s, "Big") + assert.Contains(t, s, "Small") +} + +func TestRunThresholdNegativeIsZero(t *testing.T) { + var out, errb bytes.Buffer + a := sortApp(t, &out, &errb) + _ = a.Run([]string{"-threshold=-5", "pkg"}) + assert.Contains(t, out.String(), "Small", "a negative threshold behaves like 0 (no filtering)") +} + +func TestRunThresholdExitsZeroWhenAllFiltered(t *testing.T) { + var out, errb bytes.Buffer + a := sortApp(t, &out, &errb) + code := a.Run([]string{"-threshold=10000", "pkg"}) + assert.Equal(t, 0, code, "all filtered out => no findings => exit 0") + assert.Equal(t, "", strings.TrimSpace(out.String())) +} + +func TestRunThresholdSummaryReflectsFiltered(t *testing.T) { + var out, errb bytes.Buffer + a := sortApp(t, &out, &errb) + _ = a.Run([]string{"-threshold=16", "-summary", "pkg"}) + assert.Contains(t, out.String(), "Summary: 1 struct affected, 16 bytes saved", + "summary counts only the structs that pass the threshold") +}