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
41 changes: 40 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ The program is split into small, decoupled packages:
(runs the analyzer → findings), `layout` (computes struct layouts), `sizes`
(`go/types` sizing adapter), `textdiff` (go-udiff line diff), `match` (glob
filtering), `structfilter` (generated-file and `cpu.CacheLinePad` predicates),
`config` (.structalignrc and env var mapping),
`ui` (the `Printer` — all rendering + color/width helpers), `app` (flag parsing
+ wiring). Plus `testutil` (in-process `Target` builder for tests) and `mocks`
(mockery-generated, test-only).
Expand Down Expand Up @@ -112,6 +113,40 @@ deterministic implementation.
Package load/type errors are surfaced on each `Target.Errors` and printed to
stderr but are non-fatal — a partially-resolved package can still produce findings.

**Layered Configuration:** defaults are loaded from four layers before parsing
CLI arguments (highest precedence wins):
1. **CLI flags** (e.g. `-sort`)
2. **Environment variables** (`STRUCTALIGN_<FLAG>`, e.g. `STRUCTALIGN_SORT=true`)
3. **CWD RC file** (`./.structalignrc`, key=value format)
4. **Home RC file** (`~/.structalignrc`)
5. **Built-in defaults**

The `-no-rc` flag (detected early) disables loading both `.structalignrc` files.
RC files use `key = value` lines; `#` comments and blank lines are ignored.
Keys map directly to flag names. **theme** is not an RC key (use
`STRUCTALIGN_THEME`).

| Feature | CLI Flag | Environment Variable | RC Key | Default |
|---------|----------|----------------------|--------|---------|
| Diff style | `-diff` | `STRUCTALIGN_DIFF` | `diff` | `unified` |
| Column width | `-width` | `STRUCTALIGN_WIDTH` | `width` | `0` (auto) |
| Color mode | `-color` | `STRUCTALIGN_COLOR` | `color` | `auto` |
| Theme palette | — | `STRUCTALIGN_THEME` | — | `default` |
| Inspect mode | `-inspect` | `STRUCTALIGN_INSPECT` | `inspect` | `false` |
| Verbose inspect | `-verbose` | `STRUCTALIGN_VERBOSE` | `verbose` | `false` |
| Keep tags | `-tags` | `STRUCTALIGN_TAGS` | `tags` | `false` |
| Show summary | `-summary` | `STRUCTALIGN_SUMMARY` | `summary` | `false` |
| Largest-first sort | `-sort` | `STRUCTALIGN_SORT` | `sort` | `false` |
| Min bytes saved | `-threshold` | `STRUCTALIGN_THRESHOLD` | `threshold` | `0` |
| Type filter | `-type` | `STRUCTALIGN_TYPE` | `type` | (empty) |
| Package exclude | `-exclude` | `STRUCTALIGN_EXCLUDE` | `exclude` | `^unsafe$\|^builtin$` |
| Include generated | `-generated` | `STRUCTALIGN_GENERATED` | `generated` | `false` |
| Include tests | `-tests` | `STRUCTALIGN_TESTS` | `tests` | `false` |
| Skip cache padded | `-skip-cache-padded` | `STRUCTALIGN_SKIP_CACHE_PADDED` | `skip-cache-padded` | `false` |
| Show //nolint | `-show-nolint` | `STRUCTALIGN_SHOW_NOLINT` | `show-nolint` | `false` |
| Nolint linters | `-nolint-linters` | `STRUCTALIGN_NOLINT_LINTERS` | `nolint-linters` | `fieldalignment` |


**Why go-udiff and not x/tools' own diff:** Go's internal-package rule forbids
importing `golang.org/x/tools/internal/diff` from a module not rooted under
`golang.org/x/tools/`. `fieldalignment`'s *own* internal imports are fine because
Expand All @@ -136,7 +171,11 @@ to swap it back for the internal package — it won't compile from this module.
default** (`-generated` opts in); `_test.go` is loaded only with `-tests`
(`loader.New(tests)`); `-exclude` drops packages by import-path regexp in `app`.
Add a new scan knob to `Options`, not as another positional arg.
- **`//nolint` is respected by default (diff only).** `align.nolintIndex` maps
- **Config discovery lives in `internal/config`.** It handles `.structalignrc`
parsing and env-name derivation (`-skip-cache-padded` →
`STRUCTALIGN_SKIP_CACHE_PADDED`). `app.Run` wires these as defaults via
`fs.Set` before calling `fs.Parse`.
- **//nolint is respected by default (diff only).** `align.nolintIndex` maps
`StructType.Pos()` to the directive parsed from the type's doc comment
(`TypeSpec.Doc` / grouped `GenDecl.Doc`) **and** any comment on the type's
opening line (a trailing `type T struct { //nolint`, matched by line since the
Expand Down
50 changes: 48 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,15 +157,61 @@ structalign [flags] [packages]
-nolint-linters string
//nolint tokens that suppress a finding (default
"fieldalignment"; a bare //nolint always counts)

-version print version and exit
-version print version and exit
-no-rc skip loading .structalignrc files
```

In the default `-color=auto`, color is emitted only when stdout is a terminal and
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.

### Configuration

`structalign` supports persistent defaults via environment variables and
`.structalignrc` files. Precedence (highest wins):

1. **CLI flags** (e.g. `structalign -sort`)
2. **Environment variables**: `STRUCTALIGN_<FLAG>`, e.g. `STRUCTALIGN_SORT=true`.
3. **Local config**: `.structalignrc` in the current directory.
4. **Global config**: `~/.structalignrc`.

The configuration files use a simple `key = value` format:

```ini
# .structalignrc example
sort = true
threshold = 8
skip-cache-padded = true
```

Keys map directly to flag names. To skip loading configuration files (e.g. in
CI), use the `-no-rc` flag. Note that **theme** is not an RC key; set it via the
`STRUCTALIGN_THEME` environment variable.

#### Configuration Reference

| Feature | CLI Flag | Environment Variable | RC Key | Default |
|---------|----------|----------------------|--------|---------|
| Diff style | `-diff` | `STRUCTALIGN_DIFF` | `diff` | `unified` |
| Column width | `-width` | `STRUCTALIGN_WIDTH` | `width` | `0` (auto) |
| Color mode | `-color` | `STRUCTALIGN_COLOR` | `color` | `auto` |
| Theme palette | — | `STRUCTALIGN_THEME` | — | `default` |
| Inspect mode | `-inspect` | `STRUCTALIGN_INSPECT` | `inspect` | `false` |
| Verbose inspect | `-verbose` | `STRUCTALIGN_VERBOSE` | `verbose` | `false` |
| Keep tags | `-tags` | `STRUCTALIGN_TAGS` | `tags` | `false` |
| Show summary | `-summary` | `STRUCTALIGN_SUMMARY` | `summary` | `false` |
| Largest-first sort | `-sort` | `STRUCTALIGN_SORT` | `sort` | `false` |
| Min bytes saved | `-threshold` | `STRUCTALIGN_THRESHOLD` | `threshold` | `0` |
| Type filter | `-type` | `STRUCTALIGN_TYPE` | `type` | (empty) |
| Package exclude | `-exclude` | `STRUCTALIGN_EXCLUDE` | `exclude` | `^unsafe$\|^builtin$` |
| Include generated | `-generated` | `STRUCTALIGN_GENERATED` | `generated` | `false` |
| Include tests | `-tests` | `STRUCTALIGN_TESTS` | `tests` | `false` |
| Skip cache padded | `-skip-cache-padded` | `STRUCTALIGN_SKIP_CACHE_PADDED` | `skip-cache-padded` | `false` |
| Show //nolint | `-show-nolint` | `STRUCTALIGN_SHOW_NOLINT` | `show-nolint` | `false` |
| Nolint linters | `-nolint-linters` | `STRUCTALIGN_NOLINT_LINTERS` | `nolint-linters` | `fieldalignment` |

The palette can be switched with the `STRUCTALIGN_THEME` environment variable —
... Applied fuzzy match at line 147.
`default` (the standard colors), `cga` (the iconic cyan/magenta/white CGA palette,
with a reverse-video header bar), or `green` / `amber` (single-hue phosphor-monitor
emulations). It only affects *which* colors
Expand Down
40 changes: 34 additions & 6 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"strings"

"github.com/peczenyj/structalign/internal/align"
"github.com/peczenyj/structalign/internal/config"
"github.com/peczenyj/structalign/internal/layout"
"github.com/peczenyj/structalign/internal/loader"
"github.com/peczenyj/structalign/internal/match"
Expand Down Expand Up @@ -155,7 +156,28 @@ func (a *App) Run(args []string) int {
// 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".
themeName, args := a.stripEggFlags(args)
// Also scans for -no-rc to disable RC loading.
themeName, noRC, args := a.scanEarlyFlags(args)

// Apply configuration layers as defaults.
if !noRC {
home, _ := os.UserHomeDir()
cwd, _ := os.Getwd()
for k, v := range config.Load(home, cwd) {
if err := fs.Set(k, v); err != nil {
fmt.Fprintf(a.Stderr, "structalign: config: %s: %v\n", k, err)
}
}
}

// Apply environment variables.
fs.VisitAll(func(f *flag.Flag) {
if val := os.Getenv(config.EnvName(f.Name)); val != "" {
if err := fs.Set(f.Name, val); err != nil {
fmt.Fprintf(a.Stderr, "structalign: env: %s: %v\n", f.Name, err)
}
}
})

if err := fs.Parse(args); err != nil {
return 2
Expand Down Expand Up @@ -308,10 +330,10 @@ func cmp[T ~int | ~int64](a, b T) int {

var eggRE = regexp.MustCompile(`^--?([^=]+)(?:=(.*))?$`)

// stripEggFlags scans args for retro-theme "easter egg" flags, returning the
// chosen theme name and the args slice with those flags removed. It stops at
// the first "--" separator.
func (a *App) stripEggFlags(args []string) (theme string, filtered []string) {
// scanEarlyFlags scans args for retro-theme "easter egg" flags and the -no-rc
// flag, returning the chosen theme name, whether RC loading is disabled, and
// the args slice with those flags removed. It stops at the first "--" separator.
func (a *App) scanEarlyFlags(args []string) (theme string, noRC bool, filtered []string) {
filtered = make([]string, 0, len(args))
afterDD := false
for _, arg := range args {
Expand All @@ -323,6 +345,12 @@ func (a *App) stripEggFlags(args []string) (theme string, filtered []string) {

if m := eggRE.FindStringSubmatch(arg); m != nil {
name, val := m[1], m[2]
if name == "no-rc" {
if !strings.Contains(arg, "=") || val == "true" || val == "1" {
noRC = true
}
continue
}
if name == "cga" || name == "green" || name == "amber" {
if !strings.Contains(arg, "=") || val == "true" || val == "1" {
theme = name
Expand All @@ -332,7 +360,7 @@ func (a *App) stripEggFlags(args []string) (theme string, filtered []string) {
}
filtered = append(filtered, arg)
}
return theme, filtered
return theme, noRC, filtered
}

// stdoutFile returns the *os.File behind w for terminal queries, or nil when
Expand Down
74 changes: 74 additions & 0 deletions internal/app/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package app_test

import (
"bytes"
"os"
"path/filepath"
"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"
)

func TestRunLayeredConfig(t *testing.T) {
tmp := t.TempDir()
home := filepath.Join(tmp, "home")
cwd := filepath.Join(tmp, "cwd")
require.NoError(t, os.Mkdir(home, 0o755))
require.NoError(t, os.Mkdir(cwd, 0o755))

// Mock HOME for config.Load
t.Setenv("HOME", home)

// Mock CWD
oldCWD, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(cwd))
t.Cleanup(func() { _ = os.Chdir(oldCWD) })

// 1. Home RC: threshold = 8
require.NoError(t, os.WriteFile(filepath.Join(home, ".structalignrc"), []byte("threshold = 8\n"), 0o644))

// 2. CWD RC: sort = true, threshold = 16 (overrides home)
require.NoError(t, os.WriteFile(filepath.Join(cwd, ".structalignrc"), []byte("sort = true\nthreshold = 16\n"), 0o644))

// 3. Env Var: STRUCTALIGN_THRESHOLD = 32 (overrides CWD RC)
t.Setenv("STRUCTALIGN_THRESHOLD", "32")

// We want to verify these defaults are set in the flagset.
// Since we can't easily inspect the internal 'opt' struct, we can check
// the usage message or use an easter egg if we had one.
// Actually, we can check if it warns on invalid values from these layers.

var out, errb bytes.Buffer
ml := &mocks.Loader{}
ma := &mocks.Aligner{}
a := &app.App{Loader: ml, Aligner: ma, Stdout: &out, Stderr: &errb}

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

t.Run("Precedence", func(t *testing.T) {
t.Setenv("STRUCTALIGN_THRESHOLD", "garbage")
a.Run([]string{"pkg"})
assert.Contains(t, errb.String(), "structalign: env: threshold: parse error")
errb.Reset()
})

t.Run("NoRC", func(t *testing.T) {
// With -no-rc, threshold should come from Env (garbage -> error)
// but sort (from CWD RC) should NOT be set.
t.Setenv("STRUCTALIGN_THRESHOLD", "32")
// We'll use an invalid key in RC to see if it warns
require.NoError(t, os.WriteFile(filepath.Join(cwd, ".structalignrc"), []byte("invalid-key = true\n"), 0o644))

a.Run([]string{"-no-rc", "pkg"})
assert.NotContains(t, errb.String(), "config: invalid-key")
errb.Reset()
})
}
1 change: 1 addition & 0 deletions internal/app/usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func TestUsageHasManPageSections(t *testing.T) {
func TestUsageOmitsEasterEggs(t *testing.T) {
u := usageText(t)
assert.NotContains(t, u, "-fix", "the -fix egg stays out of help")
assert.NotContains(t, u, "-no-rc", "the -no-rc flag stays out of help")
assert.NotContains(t, u, "-cga", "theme eggs stay out of help")
assert.NotContains(t, u, "-green")
assert.NotContains(t, u, "-amber")
Expand Down
73 changes: 73 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Package config handles persistent defaults via environment variables and
// .structalignrc files.
package config

import (
"bufio"
"os"
"path/filepath"
"strings"
)

// EnvName derives the environment variable name for a flag name,
// e.g. "skip-cache-padded" -> "STRUCTALIGN_SKIP_CACHE_PADDED".
func EnvName(flagName string) string {
name := strings.ReplaceAll(flagName, "-", "_")
return "STRUCTALIGN_" + strings.ToUpper(name)
}

// Load reads and merges .structalignrc files from the home directory and the
// current working directory. CWD settings override home settings.
// Returns the merged key-value map.
func Load(home, cwd string) map[string]string {
merged := make(map[string]string)

// Home directory rc (personal base)
if home != "" {
merge(merged, parseRC(filepath.Join(home, ".structalignrc")))
}

// CWD directory rc (project overrides)
if cwd != "" {
merge(merged, parseRC(filepath.Join(cwd, ".structalignrc")))
}

return merged
}

func merge(dst, src map[string]string) {
for k, v := range src {
dst[k] = v
}
}

// parseRC reads a key = value file, ignoring # comments and blank lines.
func parseRC(path string) map[string]string {
kv := make(map[string]string)
f, err := os.Open(path)
if err != nil {
return kv
}
defer f.Close() //nolint:errcheck

sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}

// Split at the first '='
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}

k := strings.TrimSpace(parts[0])
v := strings.TrimSpace(parts[1])
if k != "" {
kv[k] = v
}
}
return kv
}
Loading
Loading