Skip to content
Merged
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ Keys map directly to flag names. **theme** is not an RC key (use
| Feature | CLI Flag | Environment Variable | RC Key | Default |
|---------|----------|----------------------|--------|---------|
| Diff style | `-diff` | `STRUCTALIGN_DIFF` | `diff` | `unified` |
| Output format | `-format` | `STRUCTALIGN_FORMAT` | `format` | `text` |
| Column width | `-width` | `STRUCTALIGN_WIDTH` | `width` | `0` (auto) |
| Color mode | `-color` | `STRUCTALIGN_COLOR` | `color` | `auto` |
| Theme palette | — | `STRUCTALIGN_THEME` | — | `default` |
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ structalign [flags] [packages]
single .go files (defaults the go tool understands)

-diff value diff style: unified|side|none (default "unified")
-format value output format: text|json (default "text")
-width int column width per side for -diff=side (default: auto from terminal)
-color value colorize: auto|always|never (default "auto")
-inspect inspect layout instead of diffing: print each struct as
Expand Down Expand Up @@ -193,6 +194,7 @@ CI), use the `-no-rc` flag. Note that **theme** is not an RC key; set it via the
| Feature | CLI Flag | Environment Variable | RC Key | Default |
|---------|----------|----------------------|--------|---------|
| Diff style | `-diff` | `STRUCTALIGN_DIFF` | `diff` | `unified` |
| Output format | `-format` | `STRUCTALIGN_FORMAT` | `format` | `text` |
| Column width | `-width` | `STRUCTALIGN_WIDTH` | `width` | `0` (auto) |
| Color mode | `-color` | `STRUCTALIGN_COLOR` | `color` | `auto` |
| Theme palette | — | `STRUCTALIGN_THEME` | — | `default` |
Expand Down Expand Up @@ -438,7 +440,10 @@ type Tagged struct { // size: 48, align: 8, padding: 18
```

Tags never affect the layout numbers (size/offset/alignment are independent of
tags), so stripping them changes only the display, never the analysis.
tags), so stripping them changes only the display, never the analysis. The same
flag governs JSON output: with `-format=json`, the inspect document's `tag`
field is emitted only when `-tags` (or `STRUCTALIGN_TAGS=true`, or `tags = true`
in `.structalignrc`) is in effect.

## How it works

Expand Down
2 changes: 1 addition & 1 deletion internal/align/align.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func (a *Aligner) Findings(t common.Target, opts common.Options) ([]common.Findi
// 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, structs map[token.Pos]*types.Struct, nolints map[token.Pos]nolintInfo, opts common.Options) *common.Finding {
f := common.Finding{Fset: t.Fset, Pos: d.Pos, Message: d.Message}
f := common.Finding{Package: t.PkgPath, 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]
f.Pos = e.Pos
Expand Down
44 changes: 29 additions & 15 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ func New(stdout, stderr io.Writer) *App {

type options struct {
diff common.DiffStyle
format common.Format
width int
colorize common.Colorize
typeFilter string
Expand Down Expand Up @@ -99,6 +100,8 @@ func (a *App) Run(args []string) int {
fs.SetOutput(a.Stderr)
opt.diff = common.DiffUnified // zero value is DiffUnified; set for clarity
fs.Var(&opt.diff, "diff", fmt.Sprintf("diff style: %s (default %q)", opt.diff.Type(), opt.diff.String()))
opt.format = common.FormatText // zero value is FormatText; set for clarity
fs.Var(&opt.format, "format", fmt.Sprintf("output format: %s (default %q)", opt.format.Type(), opt.format.String()))
fs.IntVar(&opt.width, "width", 0, "column width per side for -diff=side (0 = auto from terminal)")
opt.colorize = common.ColorizeAuto // zero value is ColorizeAuto; set for clarity
fs.Var(&opt.colorize, "color", fmt.Sprintf("colorize: %s (default %q)", opt.colorize.Type(), opt.colorize.String()))
Expand Down Expand Up @@ -135,7 +138,8 @@ func (a *App) Run(args []string) int {
"examples:\n",
" structalign ./... scan every package in the module\n",
" structalign -diff=side -summary ./... side-by-side diff plus a total\n",
" structalign -inspect -type=Config ./pkg one struct's per-field layout\n\n",
" structalign -inspect -type=Config ./pkg one struct's per-field layout\n",
" structalign -format=json ./... machine-readable findings\n\n",
"flags:\n")
fs.PrintDefaults()
}
Expand Down Expand Up @@ -293,23 +297,33 @@ func (a *App) Run(args []string) int {
}

var total int
if opt.inspect {
total = printer.RenderLayouts(allLayouts, opt.verbose, opt.tags)
} else {
total = printer.RenderFindings(allFindings, opt.diff)
}

if opt.summary && !opt.inspect {
var bytesSaved int64
for _, f := range allFindings {
bytesSaved += savings(f)
if opt.format == common.FormatJSON {
if opt.inspect {
total = len(allLayouts)
printer.RenderJSON(resolveVersion(), nil, allLayouts, opt.tags)
} else {
total = len(allFindings)
printer.RenderJSON(resolveVersion(), allFindings, nil, opt.tags)
}
printer.RenderSummary(total, bytesSaved)
} else if total == 0 {
} else {
if opt.inspect {
fmt.Fprintln(a.Stderr, "no matching structs found")
total = printer.RenderLayouts(allLayouts, opt.verbose, opt.tags)
} else {
fmt.Fprintln(a.Stderr, "no struct reorderings found")
total = printer.RenderFindings(allFindings, opt.diff)
}

if opt.summary && !opt.inspect {
var bytesSaved int64
for _, f := range allFindings {
bytesSaved += savings(f)
}
printer.RenderSummary(total, bytesSaved)
} else if total == 0 {
if opt.inspect {
fmt.Fprintln(a.Stderr, "no matching structs found")
} else {
fmt.Fprintln(a.Stderr, "no struct reorderings found")
}
}
}
if total > 0 && !opt.inspect {
Expand Down
84 changes: 84 additions & 0 deletions internal/app/json_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package app_test

import (
"bytes"
"encoding/json"
"go/types"
"testing"

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

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

type dummySizes struct{ common.Sizes }

func TestRunJSONFormat(t *testing.T) {
var out, errb bytes.Buffer
ml := &mocks.Loader{}
ma := &mocks.Aligner{}
a := &app.App{Loader: ml, Aligner: ma, Stdout: &out, Stderr: &errb}

tgt := common.Target{
PkgPath: "pkg",
Types: &types.Package{},
Sizes: dummySizes{},
}
finding := common.Finding{
Package: "pkg",
Name: "Mixed",
OldSize: 24,
NewSize: 16,
}

ml.On("Load", "pkg").Return([]common.Target{tgt}, nil)
ma.On("Findings", mock.Anything, mock.Anything).Return([]common.Finding{finding}, nil)

code := a.Run([]string{"-format=json", "pkg"})
assert.Equal(t, 1, code, "exit 1 when findings exist")
assert.Empty(t, errb.String(), "no 'no reorderings' message in JSON mode")

var doc struct {
Mode string `json:"mode"`
Findings []any `json:"findings"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &doc))
assert.Equal(t, "diff", doc.Mode)
assert.Len(t, doc.Findings, 1)
}

func TestRunJSONInspect(t *testing.T) {
var out, errb bytes.Buffer
ml := &mocks.Loader{}
mi := &mocks.Inspector{}
a := &app.App{Loader: ml, Inspector: mi, Stdout: &out, Stderr: &errb}

tgt := common.Target{
PkgPath: "pkg",
Types: &types.Package{},
Sizes: dummySizes{},
}
layout := common.Layout{
Package: "pkg",
Name: "Mixed",
Total: 24,
}

ml.On("Load", "pkg").Return([]common.Target{tgt}, nil)
mi.On("Layouts", mock.Anything, mock.Anything).Return([]common.Layout{layout})

code := a.Run([]string{"-inspect", "-format=json", "pkg"})
assert.Equal(t, 0, code, "inspect always exits 0")

var doc struct {
Mode string `json:"mode"`
Layouts []any `json:"layouts"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &doc))
assert.Equal(t, "inspect", doc.Mode)
assert.Len(t, doc.Layouts, 1)
}
1 change: 1 addition & 0 deletions internal/layout/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ func (i *Inspector) buildLayout(t common.Target, n string, tn *types.TypeName, o
return common.Layout{}, false
}
l := computeLayout(n, st, display, assumed, t.Sizes)
l.Package = t.PkgPath
l.TypeParams = typeParams
l.Note = note
return l, true
Expand Down
138 changes: 138 additions & 0 deletions internal/ui/json.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package ui

import (
"encoding/json"
"fmt"
"os"

"github.com/peczenyj/structalign/pkg/common"
)

type jsonDocument struct {
Version string `json:"version"`
Mode string `json:"mode"`
Findings []jsonFinding `json:"findings,omitempty"`
Layouts []jsonLayout `json:"layouts,omitempty"`
Summary *jsonSummary `json:"summary,omitempty"`
}

type jsonFinding struct {
Package string `json:"package"`
File string `json:"file"`
Line int `json:"line"`
Column int `json:"column"`
Name string `json:"name"`
TypeParams string `json:"typeParams,omitempty"`
Message string `json:"message"`
OldSize int64 `json:"oldSize"`
NewSize int64 `json:"newSize"`
BytesSaved int64 `json:"bytesSaved"`
Original string `json:"original"`
Proposed string `json:"proposed"`
}

type jsonSummary struct {
StructsAffected int `json:"structsAffected"`
BytesSaved int64 `json:"bytesSaved"`
}

type jsonLayout struct {
Package string `json:"package"`
Name string `json:"name"`
TypeParams string `json:"typeParams,omitempty"`
Note string `json:"note,omitempty"`
Size int64 `json:"size"`
Align int64 `json:"align"`
Padding int64 `json:"padding"`
Fields []jsonLayoutField `json:"fields"`
}

type jsonLayoutField struct {
Name string `json:"name"`
Type string `json:"type"`
Tag string `json:"tag,omitempty"`
Assume string `json:"assume,omitempty"`
Offset int64 `json:"offset"`
Size int64 `json:"size"`
Align int64 `json:"align"`
Padding int64 `json:"padding"`
}

// RenderJSON emits a structured JSON document for findings or layouts. When
// keepTags is false, struct field tags are omitted from inspect-mode layouts
// (mirroring the text inspect behavior); diff-mode findings carry tags inside
// `original` / `proposed` only when the upstream align phase preserved them.
func (p *Printer) RenderJSON(version string, findings []common.Finding, layouts []common.Layout, keepTags bool) {
doc := jsonDocument{
Version: version,
}

if layouts != nil {
doc.Mode = "inspect"
doc.Layouts = make([]jsonLayout, len(layouts))
for i, l := range layouts {
doc.Layouts[i] = jsonLayout{
Package: l.Package,
Name: l.Name,
TypeParams: l.TypeParams,
Note: l.Note,
Size: l.Total,
Align: l.Align,
Padding: l.Padding,
Fields: make([]jsonLayoutField, len(l.Fields)),
}
for j, f := range l.Fields {
tag := f.Tag
if !keepTags {
tag = ""
}
doc.Layouts[i].Fields[j] = jsonLayoutField{
Name: f.Name,
Type: f.Type,
Tag: tag,
Assume: f.Assume,
Offset: f.Offset,
Size: f.Size,
Align: f.Align,
Padding: f.Padding,
}
}
}
} else {
doc.Mode = "diff"
doc.Findings = make([]jsonFinding, len(findings))
var totalSaved int64
for i, f := range findings {
pos := f.Fset.Position(f.Pos)
saved := f.OldSize - f.NewSize
if saved < 0 {
saved = 0
}
totalSaved += saved
doc.Findings[i] = jsonFinding{
Package: f.Package,
File: relPath(pos.Filename),
Line: pos.Line,
Column: pos.Column,
Name: f.Name,
TypeParams: f.TypeParams,
Message: f.Message,
OldSize: f.OldSize,
NewSize: f.NewSize,
BytesSaved: saved,
Original: f.Original,
Proposed: f.Proposed,
}
}
doc.Summary = &jsonSummary{
StructsAffected: len(findings),
BytesSaved: totalSaved,
}
}

enc := json.NewEncoder(p.Out)
enc.SetIndent("", " ")
if err := enc.Encode(doc); err != nil {
fmt.Fprintf(os.Stderr, "structalign: json encode: %v\n", err)
}
}
Loading
Loading