From adce460fa27a4a2708e6a293ede09ccf87a5e8d0 Mon Sep 17 00:00:00 2001 From: Onur Cinar Date: Sun, 6 Sep 2026 15:33:56 +0000 Subject: [PATCH 1/4] Add checkergen module for reflection-free code generation (#264) Adds checkergen/, a new isolated module (own go.mod, taskfile.yml, replace ../, needs golang.org/x/tools for go/packages type info, same pattern as checkerlint) that generates a Check(v *Type) (checker.CheckErrors, bool) function per eligible struct from its checkers/validate tags -- calling the same plain checker/normalizer functions (IsEmail, MinLen[string](8), ...) directly via checker.Check, instead of walking the struct with reflect at runtime through CheckStruct. Design, per the scoping discussion on #264: - A self-contained per-checker call-spec mapping table lives in checkergen itself (callspec.go), not the core module -- emitting Go source text is a different kind of concern than reflecting into a Schema, so this keeps the core module's job unchanged. - v1 scope: a field is only eligible if its Go type is exactly one of the predeclared scalar types (string, bool, int/uint/float kinds) -- not a named/defined type, a pointer, or a nested struct/slice/map. A struct with an ineligible field or unmapped checker is skipped with a clear diagnostic, not generated incorrectly, and doesn't block generation for the rest of the package. - A malformed tag parameter (e.g. after-field missing its ":field" half) panics the same way the equivalent runtime maker does; Generate recovers this per-struct into a skip, mirroring the cli module's CheckWithConfig panic recovery (see CLAUDE.md). - eq-field's generated call uses == (comparable), matching IsEq/IsGte's style, instead of the reflect-based eq-field tag path's reflect.DeepEqual -- documented as a deliberate, practically inconsequential divergence (only differs for a slice/map/struct field, which isn't eligible for generation anyway). - eq/ne/oneof generated calls support any comparable field type via the fully generic IsEq/IsNe/IsOneOf, even though CheckStruct's own reflect-based eq/ne/oneof implementation only supports string fields today (checkEq/checkNe/checkOneof hardcode reflectString) -- a capability superset, not a behavior change for anything CheckStruct already validates. Verified with a differential test suite (testdata/fixture): for a battery of inputs, the generated code and checker.CheckStruct are asserted to report errors under identical field keys and normalize values identically. A separate drift test regenerates the committed fixture into a scratch copy and byte-compares it against the checked -in version, to catch the fixture falling out of sync with the generator itself. Benchmarked (checkergen/benchmark_test.go) against CheckStruct on the same struct/input: ~3x faster with 4-8x fewer allocations. Numbers included in both this module's README and the root README's new "Code Generation" section. Also fixes an unrelated gosec finding this surfaced (G306, WriteFile permissions) by tightening checkergen's own generated-file write to 0600, and adds a proactive -exclude-dir=checkergen entry to the root taskfile.yml's gosec run -- learned from the nethttp/fiber miss that broke main's CI after #276. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ARLam6G4Bu9afnWHpmJWeo --- .github/workflows/ci.yml | 26 ++ CLAUDE.md | 35 +- README.md | 32 ++ checkergen/README.md | 107 +++++ checkergen/benchmark_test.go | 111 +++++ checkergen/callspec.go | 385 ++++++++++++++++++ checkergen/checkergen.go | 114 ++++++ checkergen/cmd/checkergen/main.go | 71 ++++ checkergen/differential_test.go | 180 ++++++++ checkergen/emit.go | 192 +++++++++ checkergen/generate_test.go | 219 ++++++++++ checkergen/go.mod | 15 + checkergen/go.sum | 8 + checkergen/internal_test.go | 190 +++++++++ checkergen/scan.go | 221 ++++++++++ checkergen/taskfile.yml | 32 ++ checkergen/testdata/empty/empty.go | 12 + .../testdata/fixture/checkergen_generated.go | 213 ++++++++++ checkergen/testdata/fixture/coverage.go | 39 ++ checkergen/testdata/fixture/signup.go | 16 + .../ineligible/checkergen_generated.go | 35 ++ checkergen/testdata/ineligible/ineligible.go | 73 ++++ taskfile.yml | 2 +- 23 files changed, 2318 insertions(+), 10 deletions(-) create mode 100644 checkergen/README.md create mode 100644 checkergen/benchmark_test.go create mode 100644 checkergen/callspec.go create mode 100644 checkergen/checkergen.go create mode 100644 checkergen/cmd/checkergen/main.go create mode 100644 checkergen/differential_test.go create mode 100644 checkergen/emit.go create mode 100644 checkergen/generate_test.go create mode 100644 checkergen/go.mod create mode 100644 checkergen/go.sum create mode 100644 checkergen/internal_test.go create mode 100644 checkergen/scan.go create mode 100644 checkergen/taskfile.yml create mode 100644 checkergen/testdata/empty/empty.go create mode 100644 checkergen/testdata/fixture/checkergen_generated.go create mode 100644 checkergen/testdata/fixture/coverage.go create mode 100644 checkergen/testdata/fixture/signup.go create mode 100644 checkergen/testdata/ineligible/checkergen_generated.go create mode 100644 checkergen/testdata/ineligible/ineligible.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16dc66a..5497139 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,6 +194,32 @@ jobs: - name: Run task run: go run github.com/go-task/task/v3/cmd/task@v3.38.0 + checkergen: + runs-on: ubuntu-latest + defaults: + run: + working-directory: checkergen + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: ./checkergen/go.mod + + - name: Setup cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-checkergen-${{ hashFiles('checkergen/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-checkergen- + + - name: Run task + run: go run github.com/go-task/task/v3/cmd/task@v3.38.0 + cli: runs-on: ubuntu-latest defaults: diff --git a/CLAUDE.md b/CLAUDE.md index 1915ccc..24430bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,8 +7,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Checker is a zero-dependency Go library (`github.com/cinar/checker/v2`) for validating and normalizing user input, driven by struct tags (`checkers:"..."`) or plain function calls. It ships 23 translated locales, JSON Schema generation from checker tags, separately-versioned `gin`/`echo`/`nethttp`/`fiber` framework adapter -modules, an opt-in `nfkc` checker module, a `checkerlint` static analyzer, and a standalone `cli` -command-line interface. +modules, an opt-in `nfkc` checker module, a `checkerlint` static analyzer, a `checkergen` reflection-free +code generator, and a standalone `cli` command-line interface. ## Repository layout @@ -36,23 +36,40 @@ This is a multi-module repo: it. Its own module, not part of the core, because NFKC needs `golang.org/x/text/unicode/norm`; keeping it isolated keeps the core module's zero-dependency promise intact for callers who don't opt in. Same `replace ../` pattern as the other adapters. +- `checkergen/` — `github.com/cinar/checker/v2/checkergen`, generates a `Check(v *Type) + (checker.CheckErrors, bool)` function per eligible struct, calling the same checker/normalizer plain + functions (`checker.IsEmail`, `checker.MinLen[string](8)`, ...) directly via `checker.Check` instead of + walking the struct with `reflect` at runtime — coexists with `CheckStruct`, not a replacement for it. Same + `replace ../` pattern as `checkerlint`, and needs the same newer Go toolchain for `golang.org/x/tools` + (`go/packages`, for type info). Its `testdata/fixture/` and `testdata/ineligible/` hold committed, + regenerated-by-test `checkergen_generated.go` fixtures (see `generate_test.go`'s + `TestGenerateMatchesCommittedFixture` for the drift check, and `differential_test.go` for the + behavioral-parity-with-`CheckStruct` check) — regenerate them with + `go run ./cmd/checkergen ./testdata/` after a `callSpecs` change, same as any other generated-and-committed + code. A test that calls `Generate` with anything other than a plain unfiltered rerun (e.g. a `-type` filter) + must target a scratch copy (`copyToScratch` in `generate_test.go`), never a real `testdata` package directly, + since `Generate` overwrites that package's committed, shared output file as a side effect. - `locales/` — the `locales` package, one file per locale (e.g. `en_us.go`) plus `locales.go` and `locales_test.go`. Each module has its own `taskfile.yml` and is built/linted/tested independently (see `.github/workflows/ci.yml`, -which runs parallel jobs per module: root, `gin/`, `echo/`, `nethttp/`, `fiber/`, `nfkc/`, `checkerlint/`, `cli/`). +which runs parallel jobs per module: root, `gin/`, `echo/`, `nethttp/`, `fiber/`, `nfkc/`, `checkerlint/`, +`checkergen/`, `cli/`). ## Commands All commands use [Task](https://taskfile.dev) (`go run github.com/go-task/task/v3/cmd/task@v3.38.0`), run from -the module's own directory (root, `gin/`, `echo/`, `nethttp/`, `fiber/`, `nfkc/`, `checkerlint/`, or `cli/`): +the module's own directory (root, `gin/`, `echo/`, `nethttp/`, `fiber/`, `nfkc/`, `checkerlint/`, `checkergen/`, +or `cli/`): - `task` (default) — runs `fmt`, `lint`, then `test`, in that order. - `task fmt` — `go fix ./...` -- `task lint` — `go vet`, `gosec` (excludes `gin`/`echo`/`examples`/`checkerlint`/`cli`/`nethttp`/`fiber`/`nfkc` - dirs when run from root — `gosec` doesn't respect Go module boundaries or the `testdata` convention the way - `go build`/`go vet`/`go test` do, so any subdirectory with its own `go.mod`, or a `testdata` tree with fixtures - that don't type-check against the root module, needs an explicit `-exclude-dir`), `staticcheck`, and `revive` - (config in `revive.toml`). +- `task lint` — `go vet`, `gosec` (excludes + `gin`/`echo`/`examples`/`checkerlint`/`cli`/`nethttp`/`fiber`/`nfkc`/`checkergen` dirs when run from root — + `gosec` doesn't respect Go module boundaries or the `testdata` convention the way `go build`/`go vet`/`go test` + do, so any subdirectory with its own `go.mod`, or a `testdata` tree with fixtures that don't type-check + against the root module, needs an explicit `-exclude-dir` — **a new module here needs this list updated in + both the root `taskfile.yml` and this file, or the root `build` CI job breaks**, as happened after the + `nethttp`/`fiber` modules landed), `staticcheck`, and `revive` (config in `revive.toml`). - `task test` — `go test -cover -coverprofile=coverage.out ./...` then prints per-func coverage. Equivalent plain `go` commands work too, e.g.: diff --git a/README.md b/README.md index d1bf29d..f44dfea 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ - **Trojan Source & Unicode normalization** — the built-in `strip-invisible` normalizer strips zero-width/bidi spoofing characters, and the opt-in `nfkc` module folds compatibility characters (fullwidth digits, ligatures, ...) into their canonical form. - **Context-aware pipelines** — `Pipeline[T]` reuses the same checkers for domain rules that need a `context.Context` (a DB lookup, a tenant claim) struct tags can't carry. - **Static analysis** — the separate `checkerlint` module catches unknown checker names, type mismatches, and dangling cross-field targets at build time, not runtime. +- **Code generation** — the separate `checkergen` module turns `checkers` tags into reflection-free Go validation code, ~3x faster with far fewer allocations, for structs on a hot path. - **Command-line interface** — the separate `checker` binary runs any checker or normalizer from a shell script, CI pipeline, or Git hook, with no Go code and no runtime dependency of its own. ## Table of Contents @@ -58,6 +59,7 @@ - [Framework Integration](#framework-integration) - [Unicode Normalization (NFKC)](#unicode-normalization-nfkc) - [Static Analysis](#static-analysis) +- [Code Generation](#code-generation) - [Command-Line Interface](#command-line-interface) - [Performance](#performance) - [Changelog](#changelog) @@ -855,6 +857,36 @@ checkerlint ./... See [checkerlint/README.md](checkerlint/README.md) for `go vet -vettool` and `golangci-lint` module-plugin integration. +## Code Generation + +`CheckStruct` walks a struct with `reflect` on every call (a cached execution plan keeps this fast — see [Performance](#performance) below — but it's still reflection). [`checkergen`](checkergen/), a separate module, instead generates a `Check` function that calls the same checkers directly, with no reflection at runtime at all: + +```bash +go get github.com/cinar/checker/v2/checkergen +``` + +```golang +//go:generate go run github.com/cinar/checker/v2/checkergen/cmd/checkergen + +type SignupRequest struct { + Email string `json:"email" checkers:"trim lower required email"` + Password string `json:"password" checkers:"required min-len:8"` +} +``` + +```bash +go generate ./... +``` + +Benchmarked against the equivalent `CheckStruct` call, on the same struct and input: + +| Struct | `CheckStruct` | Generated | Speedup | +| :--- | ---: | ---: | :---: | +| 5-field signup form | 1830 ns/op, 1304 B/op, 24 allocs/op | 562 ns/op, 168 B/op, 7 allocs/op | **~3.3x faster, ~8x less memory** | +| 25-field mixed checker coverage | 8200 ns/op, 4928 B/op, 59 allocs/op | 2730 ns/op, 760 B/op, 9 allocs/op | **~3.0x faster, ~6.5x less memory** | + +`checkergen` and `CheckStruct` are meant to coexist — generate code for the structs on your hot paths, leave everything else on `CheckStruct`. A struct with a field outside `checkergen`'s scope (a nested struct, a slice/map field, or a named type like `type Email string`, for now) is skipped with a clear reason instead of generated incorrectly. See [checkergen/README.md](checkergen/README.md) for the full scope, the exact benchmark setup, and how to reproduce the numbers above. + ## Command-Line Interface [`checker`](cli/), a separate module, is a standalone command-line interface to the library, for running any checker or normalizer from a shell script, CI pipeline, or Git hook without writing Go code. It ships as a single, dependency-free static binary. diff --git a/checkergen/README.md b/checkergen/README.md new file mode 100644 index 0000000..e06e8dc --- /dev/null +++ b/checkergen/README.md @@ -0,0 +1,107 @@ +# Checkergen + +`checkergen` generates reflection-free Go validation code from [Checker](https://github.com/cinar/checker)'s `checkers` (and `validate` fallback) struct tags. For an eligible struct, it emits a `Check(v *Type) (checker.CheckErrors, bool)` function that calls the same checker/normalizer functions `CheckStruct` would run through reflection — but resolved and called directly at compile time via [`checker.Check`](https://pkg.go.dev/github.com/cinar/checker/v2#Check), instead of walking the struct's fields with `reflect` at runtime. + +`checkergen` and `CheckStruct` are meant to coexist. Generate code for the structs on your hot paths; leave everything else on `CheckStruct`. A struct with a field outside `checkergen`'s scope (see below) is skipped with a clear reason, not generated incorrectly. + +## Why + +Every checker/normalizer already has a plain, non-reflection Go function alongside the `reflect.Value`-based one `CheckStruct` uses internally — `Required[T](value T)`, `IsEmail(value string)`, `MinLen[T](n int) CheckFunc[T]`, and so on. `checkergen`'s job is mostly templating: read a struct's tags, and emit a call to the right one, in order, via `checker.Check`: + +```golang +type SignupRequest struct { + Email string `json:"email" checkers:"trim lower required email"` + Password string `json:"password" checkers:"required min-len:8"` +} +``` + +generates: + +```golang +// Code generated by checkergen. DO NOT EDIT. + +func CheckSignupRequest(v *SignupRequest) (checker.CheckErrors, bool) { + errs := make(checker.CheckErrors) + + { + newValue, err := checker.Check(v.Email, checker.TrimSpace, checker.Lower, checker.Required, checker.IsEmail) + v.Email = newValue + if err != nil { + errs["email"] = err + } + } + + { + newValue, err := checker.Check(v.Password, checker.Required, checker.MinLen[string](8)) + v.Password = newValue + if err != nil { + errs["password"] = err + } + } + + return errs, len(errs) == 0 +} +``` + +No reflection at runtime — every call is resolved and type-checked at compile time. + +### Performance + +Benchmarked against the equivalent `CheckStruct` call, on the same struct and input, in this module's own `checkergen` package: + +| Struct | `CheckStruct` | Generated | Speedup | +| :--- | ---: | ---: | :---: | +| 5-field signup form | 1830 ns/op, 1304 B/op, 24 allocs/op | 562 ns/op, 168 B/op, 7 allocs/op | **~3.3x faster, ~8x less memory** | +| 25-field mixed checker coverage | 8200 ns/op, 4928 B/op, 59 allocs/op | 2730 ns/op, 760 B/op, 9 allocs/op | **~3.0x faster, ~6.5x less memory** | + +Run `go test -bench . -benchmem ./checkergen` from the repository root to reproduce. + +## Install + +```bash +go get github.com/cinar/checker/v2/checkergen +``` + +## Usage + +Add a `//go:generate` directive to the package containing the struct(s) to generate for, then run `go generate`: + +```golang +//go:generate go run github.com/cinar/checker/v2/checkergen/cmd/checkergen + +type SignupRequest struct { + Email string `json:"email" checkers:"trim lower required email"` + Password string `json:"password" checkers:"required min-len:8"` +} +``` + +```bash +go generate ./... +``` + +This scans the current package for every struct with at least one `checkers`/`validate`-tagged field and writes `checkergen_generated.go`, containing a `Check` function for each one it could generate. Restrict generation to specific structs with `-type`: + +```bash +go run github.com/cinar/checker/v2/checkergen/cmd/checkergen -type=SignupRequest,LoginRequest +``` + +A struct it can't generate for is skipped, with the reason printed to stderr — it isn't written as broken code, and doesn't stop the rest of the package's structs from being generated. + +## Scope + +A struct field is only eligible for generation if its Go type is exactly one of the predeclared scalar types: `string`, `bool`, or one of the `int`/`uint`/`float` kinds. Not eligible, for now: + +- A named/defined type (`type Email string`) — the reflect-based path handles these for free via `reflect.Value.String()`; static codegen would need explicit conversions this version doesn't generate yet. +- A pointer field. +- A nested struct, slice, or map field. + +Every checker and normalizer with a plain, non-reflection function is supported; a checker without one (including a custom one registered only via `checker.RegisterMaker` at runtime, which `checkergen` can't see at generate time) fails generation for that struct with a clear diagnostic rather than silently producing incomplete validation. + +Two checkers behave slightly differently from `CheckStruct`, both deliberately: + +- `eq-field` compares with `==` (`comparable`) via the generated code's `IsEqField`, instead of the `reflect.DeepEqual` the `eq-field` struct tag uses at runtime through `CheckStruct` — matching how every other comparison checker (`IsEq`, `IsGte`, ...) already works. This only differs in practice for a slice/map/struct-typed field, which isn't eligible for generation in the first place. +- `eq`, `ne`, and `oneof` support any field type via `checkergen`'s generated call to the fully generic `IsEq`/`IsNe`/`IsOneOf`, even though the `checkers`-tag path through `CheckStruct` currently only supports a string field for these three (their reflect-based implementation hardcodes a string conversion). Generated code is a strict capability superset here, not a behavior change for anything `CheckStruct` already validates correctly. + +## License + +This library is free to use, modify, and distribute under the terms of the MIT license found in the [LICENSE](../LICENSE) file of the parent [Checker](https://github.com/cinar/checker) repository. diff --git a/checkergen/benchmark_test.go b/checkergen/benchmark_test.go new file mode 100644 index 0000000..3d3df79 --- /dev/null +++ b/checkergen/benchmark_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2023-2026 Onur Cinar. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +// https://github.com/cinar/checker + +package checkergen_test + +import ( + "testing" + + checker "github.com/cinar/checker/v2" + "github.com/cinar/checker/v2/checkergen/testdata/fixture" +) + +func BenchmarkCheckStruct_SignupRequest(b *testing.B) { + req := fixture.SignupRequest{ + Email: " ALICE@EXAMPLE.COM ", + Password: "supersecret", + ConfirmPassword: "supersecret", + Age: 30, + Nickname: "ali", + } + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + r := req + _, _ = checker.CheckStruct(&r) + } +} + +func BenchmarkGenerated_SignupRequest(b *testing.B) { + req := fixture.SignupRequest{ + Email: " ALICE@EXAMPLE.COM ", + Password: "supersecret", + ConfirmPassword: "supersecret", + Age: 30, + Nickname: "ali", + } + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + r := req + _, _ = fixture.CheckSignupRequest(&r) + } +} + +func BenchmarkCheckStruct_Coverage(b *testing.B) { + cov := fixture.Coverage{ + Handle: "@onur", + Code: "ABC", + Sum: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + Zip: "94103", + Status: "active", + Excluded: "allowed", + Role: "admin", + Weight: 10, + Ratio: 4, + Threshold: 0, + Country: "US", + State: "CA", + Type: "member", + Email: "onur@example.com", + ReturnAt: "2024-06-01", + DepartAt: "2024-01-01", + BornAt: "2000-01-01", + DiesAt: "2090-01-01", + } + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + c := cov + _, _ = checker.CheckStruct(&c) + } +} + +func BenchmarkGenerated_Coverage(b *testing.B) { + cov := fixture.Coverage{ + Handle: "@onur", + Code: "ABC", + Sum: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + Zip: "94103", + Status: "active", + Excluded: "allowed", + Role: "admin", + Weight: 10, + Ratio: 4, + Threshold: 0, + Country: "US", + State: "CA", + Type: "member", + Email: "onur@example.com", + ReturnAt: "2024-06-01", + DepartAt: "2024-01-01", + BornAt: "2000-01-01", + DiesAt: "2090-01-01", + } + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + c := cov + _, _ = fixture.CheckCoverage(&c) + } +} diff --git a/checkergen/callspec.go b/checkergen/callspec.go new file mode 100644 index 0000000..bb02105 --- /dev/null +++ b/checkergen/callspec.go @@ -0,0 +1,385 @@ +// Copyright (c) 2023-2026 Onur Cinar. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +// https://github.com/cinar/checker + +package checkergen + +import ( + "fmt" + "strconv" + "strings" +) + +// sibling resolves a sibling field's Go access expression (e.g. +// "v.Password"), for a field-relative checker's target field name. It +// returns an error if no field with that Go name exists on the struct +// currently being generated. Deliberately doesn't also resolve the +// sibling's json-tag-facing name: a field-relative checker's error message +// uses the field name exactly as written in the tag (the Go field name +// used to look it up), same as CheckStruct/IsEqField/IsRequiredIf/ +// IsRequiredUnless do at runtime -- it's the raw params/name text callers +// below already have, not something sibling needs to look up. +type sibling func(name string) (expr string, err error) + +// callSpec turns one checkers-tag token (already split into name and +// params) into a Go source expression of type checker.CheckFunc[fieldType], +// suitable for splicing into a checker.Check(fieldExpr, , ...) call. +// fieldType is the field's Go type exactly as it should appear in +// generated source (e.g. "string", "int"); see the package doc for the +// (deliberately narrow, for v1) set of types this ever gets called with. +type callSpec func(params, fieldType string, sib sibling) (string, error) + +// bare returns a callSpec for a checker/normalizer whose exported function +// already matches checker.CheckFunc[T] exactly (single T argument, (T, +// error) result) and takes no parameters from the tag: the bare qualified +// identifier is used directly, relying on Go's type inference from +// checker.Check's other arguments to instantiate any generic type +// parameter. Panics if params is non-empty, mirroring how a runtime +// make ignores or rejects unexpected parameters. +func bare(funcName string) callSpec { + return func(params, _ string, _ sibling) (string, error) { + return "checker." + funcName, nil + } +} + +// closure returns a callSpec that wraps funcName in a closure literal, +// splicing in args (already-rendered Go argument expressions, in call +// order) built from the tag's params. +func closure(funcName string, args func(params string) ([]string, error)) callSpec { + return func(params, fieldType string, _ sibling) (string, error) { + argExprs, err := args(params) + if err != nil { + return "", err + } + + return fmt.Sprintf( + "func(value %s) (%s, error) { return checker.%s(%s) }", + fieldType, fieldType, funcName, strings.Join(argExprs, ", "), + ), nil + } +} + +// curried returns a callSpec for one of the generic "maker" functions +// (MinLen, MaxLen, Len, Default) that take the tag's parameter directly and +// return a checker.CheckFunc[T] themselves: T is spliced in explicitly, +// since (unlike the checkers wrapped by closure/bare) none of these +// functions' own arguments are of type T for Go to infer it from. +func curried(funcName string, arg func(params, fieldType string) (string, error)) callSpec { + return func(params, fieldType string, _ sibling) (string, error) { + argExpr, err := arg(params, fieldType) + if err != nil { + return "", err + } + + return fmt.Sprintf("checker.%s[%s](%s)", funcName, fieldType, argExpr), nil + } +} + +// requireType wraps spec so it fails with a clear checkergen-level error if +// fieldType isn't exactly want, instead of letting a mismatched call +// through to fail as a confusing compile error in the generated file. Used +// for the handful of checkers (finite, int, multiple-of) whose exported +// function is hardcoded to float64 rather than generic over T, unlike +// every other numeric checker (gt, gte, lt, lte, ...), which accepts any +// cmp.Ordered T. +func requireType(checkerName, want string, spec callSpec) callSpec { + return func(params, fieldType string, sib sibling) (string, error) { + if fieldType != want { + return "", fmt.Errorf("%s requires a %s field, but the field's type is %s", checkerName, want, fieldType) + } + + return spec(params, fieldType, sib) + } +} + +// splitParam splits params on ":" into exactly two non-empty parts, +// mirroring the strings.Cut+panic pattern every two-part runtime maker +// (after, after-field, required-if, ...) already uses, so a malformed tag +// fails at generate time with the same clarity it would at runtime. +func splitParam(checkerName, params string) (first, second string) { + first, second, found := strings.Cut(params, ":") + if !found || first == "" || second == "" { + panic(fmt.Sprintf("%s requires two ':'-separated parameters, got %q", checkerName, params)) + } + + return first, second +} + +// numberLiteral returns params verbatim, after confirming it parses as a +// number: an untyped Go numeric literal spliced into a call adapts to +// whatever numeric field type it's assigned/compared against, so the +// source text itself doesn't need to change per field type. +func numberLiteral(checkerName, params string) (string, error) { + if _, err := strconv.ParseFloat(params, 64); err != nil { + return "", fmt.Errorf("%s: parameter %q is not a number", checkerName, params) + } + + return params, nil +} + +// intLiteral returns params verbatim, after confirming it parses as an +// integer. +func intLiteral(checkerName, params string) (string, error) { + if _, err := strconv.Atoi(params); err != nil { + return "", fmt.Errorf("%s: parameter %q is not an integer", checkerName, params) + } + + return params, nil +} + +// callSpecs maps every checker/normalizer name checkergen knows how to +// generate to its callSpec. A name with no entry here fails generation for +// the struct it's on with a clear diagnostic (see Generate), rather than +// silently producing incomplete validation -- extending this table for an +// unmapped checker, including a custom one registered via +// checker.RegisterMaker, is the intended way to teach checkergen about it. +var callSpecs = map[string]callSpec{ + // Normalizers: bare, no parameters, string only. + "trim": bare("TrimSpace"), + "trim-left": bare("TrimLeft"), + "trim-right": bare("TrimRight"), + "lower": bare("Lower"), + "upper": bare("Upper"), + "title": bare("Title"), + "html-escape": bare("HTMLEscape"), + "html-unescape": bare("HTMLUnescape"), + "url-escape": bare("URLEscape"), + "url-unescape": bare("URLUnescape"), + "strip-invisible": bare("StripInvisible"), + + // Checkers: bare, no parameters. + "required": bare("Required"), + "finite": requireType("finite", "float64", bare("IsFinite")), + "int": requireType("int", "float64", bare("IsInt")), + "positive": bare("IsPositive"), + "negative": bare("IsNegative"), + "nonnegative": bare("IsNonnegative"), + "ascii": bare("IsASCII"), + "alpha": bare("IsAlpha"), + "alphanumeric": bare("IsAlphanumeric"), + "base64": bare("IsBase64"), + "base64-url": bare("IsBase64URL"), + "cidr": bare("IsCIDR"), + "digits": bare("IsDigits"), + "email": bare("IsEmail"), + "eoa": bare("IsEOA"), + "fqdn": bare("IsFQDN"), + "hex": bare("IsHex"), + "hex-color": bare("IsHexColor"), + "iban": bare("IsIBAN"), + "ip": bare("IsIP"), + "ipv4": bare("IsIPv4"), + "ipv6": bare("IsIPv6"), + "isbn": bare("IsISBN"), + "iso3166-1-alpha-2": bare("IsISO31661Alpha2"), + "iso3166-1-alpha-3": bare("IsISO31661Alpha3"), + "iso639-1": bare("IsISO6391"), + "jwt": bare("IsJWT"), + "luhn": bare("IsLUHN"), + "mac": bare("IsMAC"), + "mongo-id": bare("IsMongoID"), + "numeric": bare("IsNumeric"), + "semver": bare("IsSemver"), + "slug": bare("IsSlug"), + "ulid": bare("IsULID"), + "url": bare("IsURL"), + "uuid": bare("IsUUID"), + "e164": bare("IsE164"), + "credit-card": bare("IsAnyCreditCard"), + + // Checkers needing one parameter, spliced ahead of value. + "contains": closure("IsContains", oneStringArgBefore("contains")), + "starts-with": closure("IsStartsWith", oneStringArgBefore("starts-with")), + "ends-with": closure("IsEndsWith", oneStringArgBefore("ends-with")), + "regexp": closure("IsRegexp", oneStringArgBefore("regexp")), + "time": closure("IsTime", oneStringArgBefore("time")), + + "hash": closure("IsHash", oneStringArgBefore("hash")), + "postal-code": closure("IsPostalCode", oneStringArgBefore("postal-code")), + + // Checkers needing one parameter, spliced after value. + "eq": closure("IsEq", oneNumberOrStringArgAfter("eq")), + "ne": closure("IsNe", oneNumberOrStringArgAfter("ne")), + "gt": closure("IsGt", oneNumberArgAfter("gt")), + "gte": closure("IsGte", oneNumberArgAfter("gte")), + "lt": closure("IsLt", oneNumberArgAfter("lt")), + "lte": closure("IsLte", oneNumberArgAfter("lte")), + "multiple-of": requireType("multiple-of", "float64", closure("IsMultipleOf", oneNumberArgAfter("multiple-of"))), + + "oneof": closure("IsOneOf", oneOfArgs), + + // Checkers needing two ":"-separated parameters. + "after": closure("IsAfter", twoStringArgsBefore("after")), + "before": closure("IsBefore", twoStringArgsBefore("before")), + + // Curried "maker" generics: T spliced in explicitly. + "min-len": curried("MinLen", intArg("min-len")), + "max-len": curried("MaxLen", intArg("max-len")), + "len": curried("Len", intArg("len")), + "default": curried("Default", defaultArg), +} + +// fieldCallSpecs maps the five field-relative checker names to their +// callSpec, kept separate from callSpecs since they're the only ones that +// use the sib parameter to resolve a sibling field's access expression and +// tag-facing name instead of a literal from params. +var fieldCallSpecs = map[string]callSpec{ + "eq-field": func(params, fieldType string, sib sibling) (string, error) { + expr, err := sib(params) + if err != nil { + return "", err + } + + // The error's field name is params (the sibling's Go field name, + // exactly as written in the tag), not its json-tag-facing name -- + // matching IsEqField/checkEqField's own runtime behavior. + return fmt.Sprintf( + "func(value %s) (%s, error) { return checker.IsEqField(value, %s, %s) }", + fieldType, fieldType, expr, strconv.Quote(params), + ), nil + }, + + "required-if": func(params, fieldType string, sib sibling) (string, error) { + name, expected := splitParam("required-if", params) + + expr, err := sib(name) + if err != nil { + return "", err + } + + return fmt.Sprintf( + "func(value %s) (%s, error) { return checker.IsRequiredIf(value, %s, %s) }", + fieldType, fieldType, expr, strconv.Quote(expected), + ), nil + }, + + "required-unless": func(params, fieldType string, sib sibling) (string, error) { + name, expected := splitParam("required-unless", params) + + expr, err := sib(name) + if err != nil { + return "", err + } + + return fmt.Sprintf( + "func(value %s) (%s, error) { return checker.IsRequiredUnless(value, %s, %s) }", + fieldType, fieldType, expr, strconv.Quote(expected), + ), nil + }, + + "after-field": func(params, fieldType string, sib sibling) (string, error) { + layout, name := splitParam("after-field", params) + + expr, err := sib(name) + if err != nil { + return "", err + } + + return fmt.Sprintf( + "func(value %s) (%s, error) { return checker.IsAfterField(%s, %s, value) }", + fieldType, fieldType, strconv.Quote(layout), expr, + ), nil + }, + + "before-field": func(params, fieldType string, sib sibling) (string, error) { + layout, name := splitParam("before-field", params) + + expr, err := sib(name) + if err != nil { + return "", err + } + + return fmt.Sprintf( + "func(value %s) (%s, error) { return checker.IsBeforeField(%s, %s, value) }", + fieldType, fieldType, strconv.Quote(layout), expr, + ), nil + }, +} + +// oneStringArgBefore returns an args func for a checker whose Go signature +// is func(param, value string) (string, error): the tag's whole params +// string is quoted and passed ahead of "value". +func oneStringArgBefore(string) func(string) ([]string, error) { + return func(params string) ([]string, error) { + return []string{strconv.Quote(params), "value"}, nil + } +} + +// twoStringArgsBefore returns an args func for a checker whose Go +// signature is func(a, b, value string) (string, error): the tag's params +// splits on ":" into two string literals, both passed ahead of "value". +func twoStringArgsBefore(checkerName string) func(string) ([]string, error) { + return func(params string) ([]string, error) { + a, b := splitParam(checkerName, params) + return []string{strconv.Quote(a), strconv.Quote(b), "value"}, nil + } +} + +// oneNumberArgAfter returns an args func for a checker whose Go signature +// is func(value, n T) (T, error): "value" first, then the tag's params +// spliced in verbatim as an untyped numeric literal. +func oneNumberArgAfter(checkerName string) func(string) ([]string, error) { + return func(params string) ([]string, error) { + lit, err := numberLiteral(checkerName, params) + if err != nil { + return nil, err + } + + return []string{"value", lit}, nil + } +} + +// oneNumberOrStringArgAfter is like oneNumberArgAfter, but for eq/ne, whose +// parameter may be a string, a bool, or a number depending on the field's +// type: a valid number is spliced in as a numeric literal (so it works for +// a numeric field), otherwise it's quoted as a string literal (so it works +// for a string field). Go rejects the mismatched case (e.g. a quoted +// string literal compared against an int field) at compile time, same as +// any other type error in generated code. +func oneNumberOrStringArgAfter(checkerName string) func(string) ([]string, error) { + return func(params string) ([]string, error) { + if lit, err := numberLiteral(checkerName, params); err == nil { + return []string{"value", lit}, nil + } + + return []string{"value", strconv.Quote(params)}, nil + } +} + +// oneOfArgs builds the arguments for IsOneOf(value T, allowed ...T): value +// first, then the tag's comma-separated allowed list, each element quoted. +// Numeric allowed values are unquoted instead, using the same +// number-or-string heuristic as oneNumberOrStringArgAfter. +func oneOfArgs(params string) ([]string, error) { + args := []string{"value"} + + for _, allowed := range strings.Split(params, ",") { + if lit, err := numberLiteral("oneof", allowed); err == nil { + args = append(args, lit) + } else { + args = append(args, strconv.Quote(allowed)) + } + } + + return args, nil +} + +// intArg returns an arg func for a curried maker taking a single integer +// parameter (min-len, max-len, len). +func intArg(checkerName string) func(params, fieldType string) (string, error) { + return func(params, _ string) (string, error) { + return intLiteral(checkerName, params) + } +} + +// defaultArg builds the fallback argument for Default[T](fallback T): a +// quoted string literal for a string field, an unquoted (bool/numeric) +// literal otherwise. +func defaultArg(params, fieldType string) (string, error) { + if fieldType == "string" { + return strconv.Quote(params), nil + } + + return params, nil +} diff --git a/checkergen/checkergen.go b/checkergen/checkergen.go new file mode 100644 index 0000000..34c7dcf --- /dev/null +++ b/checkergen/checkergen.go @@ -0,0 +1,114 @@ +// Copyright (c) 2023-2026 Onur Cinar. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +// https://github.com/cinar/checker + +// Package checkergen generates reflection-free Go validation code from +// Checker's "checkers" (and "validate" fallback) struct tags. For a struct +// eligible for generation, it emits a Check(v *Type) +// (checker.CheckErrors, bool) function that calls the same checker/ +// normalizer functions CheckStruct would run through reflection, but +// resolved and called directly at compile time via checker.Check. +// +// It's a separate module, not part of the core checker package: emitting +// Go source is a different kind of concern than validating or reflecting +// into a JSON Schema, and this tool needs golang.org/x/tools (go/packages) +// for reliable type information, which the core module never depends on. +// +// v1 scope: a struct field is only eligible for generation if its Go type +// is exactly one of the predeclared scalar types (string, bool, the int/ +// uint/float kinds) -- not a named/defined type (type Email string), a +// pointer, a nested struct, or a slice/map. checkergen and CheckStruct are +// meant to coexist: a struct with a field outside that scope is skipped +// (with a diagnostic explaining why) rather than generated incorrectly, +// and stays on the reflect-based CheckStruct path. +package checkergen + +import ( + "fmt" + "os" + "path/filepath" +) + +// GeneratedFileName is the name of the single output file Generate writes +// into the target package's directory, carrying every struct it +// successfully generated code for. +const GeneratedFileName = "checkergen_generated.go" + +// Result summarizes one Generate call: the structs it successfully +// generated code for, and the structs it skipped, each with the reason. +type Result struct { + // Generated lists the names of every struct a Check function was + // emitted for, in the order they were declared. + Generated []string + + // Skipped maps a struct name to the reason it was skipped: a tagged + // field with an unmapped checker name, or a type outside v1's scope + // (see the package doc). A struct with no checkers/validate tag on any + // field at all isn't a candidate in the first place, so it's not + // recorded here. + Skipped map[string]string +} + +// Generate scans the package at dir (a directory, loaded as its own +// go/packages pattern) for every named struct type with at least one +// checkers/validate-tagged field, optionally restricted to typeFilter +// (struct names; empty means every struct), and writes GeneratedFileName +// into dir containing a Check function for each one it could +// generate. A struct it can't generate (see Result.Skipped) is left out of +// the file, not written as broken code, and doesn't prevent the rest of +// the package's structs from being generated. +func Generate(dir string, typeFilter []string) (*Result, error) { + filter := make(map[string]bool, len(typeFilter)) + for _, name := range typeFilter { + filter[name] = true + } + + structs, pkgName, err := findStructs(dir, filter) + if err != nil { + return nil, err + } + + result := &Result{} + + var names []string + + plansByStruct := make(map[string][]fieldPlan) + + for _, st := range structs { + plans, err := generateStructRecovered(st) + if err != nil { + skipf(result, st.name, "%v", err) + continue + } + + names = append(names, st.name) + plansByStruct[st.name] = plans + result.Generated = append(result.Generated, st.name) + } + + if len(names) == 0 { + return result, nil + } + + data, err := emitFile(pkgName, names, plansByStruct) + if err != nil { + return nil, fmt.Errorf("formatting generated source: %w", err) + } + + if err := os.WriteFile(filepath.Join(dir, GeneratedFileName), data, 0o600); err != nil { + return nil, fmt.Errorf("writing %s: %w", GeneratedFileName, err) + } + + return result, nil +} + +// skipf records that a struct is being skipped, for a reason string built +// from format/args. +func skipf(result *Result, structName, format string, args ...any) { + if result.Skipped == nil { + result.Skipped = make(map[string]string) + } + + result.Skipped[structName] = fmt.Sprintf(format, args...) +} diff --git a/checkergen/cmd/checkergen/main.go b/checkergen/cmd/checkergen/main.go new file mode 100644 index 0000000..8536f81 --- /dev/null +++ b/checkergen/cmd/checkergen/main.go @@ -0,0 +1,71 @@ +// Copyright (c) 2023-2026 Onur Cinar. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +// https://github.com/cinar/checker + +// Command checkergen generates reflection-free Go validation code from +// Checker's "checkers" (and "validate" fallback) struct tags. Add a +// generate directive in the package containing the struct(s) to generate +// for: +// +// //go:generate go run github.com/cinar/checker/v2/checkergen/cmd/checkergen +// +// See the checkergen package doc for what makes a struct field eligible +// for generation. +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "github.com/cinar/checker/v2/checkergen" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +// run is checkergen's whole CLI logic, exercised directly from tests (no +// os.Exit inside it) and wrapped by main. +func run(args []string, stdout, stderr *os.File) int { + flags := flag.NewFlagSet("checkergen", flag.ContinueOnError) + flags.SetOutput(stderr) + + typeFlag := flags.String("type", "", "comma-separated struct names to generate (default: every eligible struct)") + + if err := flags.Parse(args); err != nil { + return 2 + } + + dir := "." + if flags.NArg() > 0 { + dir = flags.Arg(0) + } + + var typeFilter []string + if *typeFlag != "" { + typeFilter = strings.Split(*typeFlag, ",") + } + + result, err := checkergen.Generate(dir, typeFilter) + if err != nil { + fmt.Fprintf(stderr, "checkergen: %v\n", err) + return 1 + } + + for _, name := range result.Generated { + fmt.Fprintf(stdout, "checkergen: generated Check%s\n", name) + } + + for name, reason := range result.Skipped { + fmt.Fprintf(stderr, "checkergen: skipped %s: %s\n", name, reason) + } + + if len(result.Generated) == 0 && len(result.Skipped) == 0 { + fmt.Fprintln(stderr, "checkergen: no struct with a checkers/validate tag found") + } + + return 0 +} diff --git a/checkergen/differential_test.go b/checkergen/differential_test.go new file mode 100644 index 0000000..2a640fe --- /dev/null +++ b/checkergen/differential_test.go @@ -0,0 +1,180 @@ +// Copyright (c) 2023-2026 Onur Cinar. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +// https://github.com/cinar/checker + +package checkergen_test + +import ( + "testing" + + checker "github.com/cinar/checker/v2" + "github.com/cinar/checker/v2/checkergen/testdata/fixture" +) + +// assertMatches is a differential check: for the same starting value, it +// runs both the reflect-based checker.CheckStruct and generated (a +// generated Check function for the same type) against separate +// copies, and asserts they report errors under exactly the same field keys +// and leave the value normalized identically. This is checked instead of +// hand-verifying generated source, since it directly proves the generated +// code is behaviorally faithful to CheckStruct for everything it covers. +func assertMatches[T comparable](t *testing.T, generated func(*T) (checker.CheckErrors, bool), value T) { + t.Helper() + + reflectValue := value + generatedValue := value + + reflectErrs, reflectOK := checker.CheckStruct(&reflectValue) + generatedErrs, generatedOK := generated(&generatedValue) + + if reflectOK != generatedOK { + t.Fatalf("ok mismatch: CheckStruct=%v generated=%v", reflectOK, generatedOK) + } + + if len(reflectErrs) != len(generatedErrs) { + t.Fatalf("error count mismatch: CheckStruct=%v generated=%v", reflectErrs, generatedErrs) + } + + for key := range reflectErrs { + if _, ok := generatedErrs[key]; !ok { + t.Fatalf("generated code missing error for %q, CheckStruct reported %v", key, reflectErrs[key]) + } + } + + if reflectValue != generatedValue { + t.Fatalf("normalized value mismatch:\nCheckStruct: %+v\ngenerated: %+v", reflectValue, generatedValue) + } +} + +func TestGeneratedMatchesCheckStructSignupRequest(t *testing.T) { + tests := []struct { + name string + req fixture.SignupRequest + }{ + { + name: "all valid", + req: fixture.SignupRequest{ + Email: " ALICE@EXAMPLE.COM ", + Password: "supersecret", + ConfirmPassword: "supersecret", + Age: 18, + Nickname: "ali", + }, + }, + { + name: "all valid, omitempty nickname absent", + req: fixture.SignupRequest{ + Email: "alice@example.com", + Password: "supersecret", + ConfirmPassword: "supersecret", + Age: 30, + }, + }, + { + name: "invalid email, short password, mismatched confirm, underage, short nickname", + req: fixture.SignupRequest{ + Email: "not-an-email", + Password: "short", + ConfirmPassword: "different", + Age: 10, + Nickname: "a", + }, + }, + { + name: "password too short only", + req: fixture.SignupRequest{ + Email: "alice@example.com", + Password: "short", + ConfirmPassword: "short", + Age: 18, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assertMatches(t, fixture.CheckSignupRequest, test.req) + }) + } +} + +func TestGeneratedMatchesCheckStructCoverage(t *testing.T) { + valid := fixture.Coverage{ + Handle: "@onur", + Code: "ABC", + Sum: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + Zip: "94103", + Status: "active", + Excluded: "allowed", + Role: "admin", + Weight: 10, + Ratio: 4, + Greeting: "", + Threshold: 0, + Country: "US", + State: "CA", + Type: "member", + Email: "onur@example.com", + ReturnAt: "2024-06-01", + DepartAt: "2024-01-01", + BornAt: "2000-01-01", + DiesAt: "2090-01-01", + } + + tests := []struct { + name string + cov fixture.Coverage + }{ + {name: "all valid", cov: valid}, + { + name: "contains/starts-with/ends-with/regexp/hash/postal-code/eq/ne/oneof failures", + cov: func() fixture.Coverage { + c := valid + c.Handle = "nope" + c.Code = "abcd" + c.Sum = "not-a-hash" + c.Zip = "not-a-zip" + c.Status = "inactive" + c.Excluded = "banned" + c.Role = "superuser" + return c + }(), + }, + { + name: "multiple-of/finite/int failures", + cov: func() fixture.Coverage { + c := valid + c.Weight = 11 + c.Ratio = 3.5 + return c + }(), + }, + { + name: "default fills the zero value", + cov: func() fixture.Coverage { + c := valid + c.Greeting = "" + c.Threshold = 0 + return c + }(), + }, + { + name: "required-if/required-unless/before-field/after-field failures", + cov: func() fixture.Coverage { + c := valid + c.State = "" + c.Email = "" + c.DepartAt = "2024-12-01" + c.DiesAt = "1999-01-01" + return c + }(), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assertMatches(t, fixture.CheckCoverage, test.cov) + }) + } +} diff --git a/checkergen/emit.go b/checkergen/emit.go new file mode 100644 index 0000000..7a64145 --- /dev/null +++ b/checkergen/emit.go @@ -0,0 +1,192 @@ +// Copyright (c) 2023-2026 Onur Cinar. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +// https://github.com/cinar/checker + +package checkergen + +import ( + "fmt" + "go/format" + "strings" +) + +// fieldPlan is one tagged, eligible field's resolved generation plan. +type fieldPlan struct { + goName string // the field's Go identifier, e.g. "Email" + tagName string // the error-map key, e.g. "email" + goType string // one of scalarGoTypes + omitEmpty bool + callExprs []string // Go source, one checker.CheckFunc[goType] expression per tag token, in order +} + +// generateStructRecovered calls generateStruct, converting a panic (a +// malformed tag parameter, e.g. after-field missing its ":field" half -- +// the same "closer to user input than the compile-time struct tag the core +// module assumes" situation the cli module's CheckWithConfig recovery +// handles, see CLAUDE.md) into a returned error, so one struct's malformed +// tag can't crash generation for the rest of the package. +func generateStructRecovered(st loadedStruct) (plans []fieldPlan, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("%v", r) + } + }() + + return generateStruct(st) +} + +// generateStruct builds fieldPlans for every eligible field of st, or +// returns an error naming the first field that isn't eligible (an unmapped +// checker name, or a field whose type isn't one of scalarGoTypes) -- the +// caller skips the whole struct on error, per the package doc. +func generateStruct(st loadedStruct) ([]fieldPlan, error) { + // All field declarations are resolved to a Go type first, in one pass, + // so a field-relative checker can reference a sibling declared later + // in the struct. + var plans []fieldPlan + + // tagged[i] mirrors plans[i]: whether that field actually has a + // checkers/validate tag to fill in during the second pass below. + var tagged []bool + + byName := make(map[string]int) + + for _, field := range st.fields { + if len(field.Names) != 1 { + // Embedded or multi-name field declaration; out of scope for v1. + continue + } + + config, hasTag := tagConfig(field) + + goType, eligible := goTypeName(st.info.TypeOf(field.Type)) + if hasTag && config != "" && !eligible { + return nil, fmt.Errorf("field %s: type %s is not eligible for generation (must be a predeclared scalar type)", field.Names[0].Name, goType) + } + + byName[field.Names[0].Name] = len(plans) + + plans = append(plans, fieldPlan{ + goName: field.Names[0].Name, + tagName: jsonName(field), + goType: goType, + }) + + tagged = append(tagged, hasTag && config != "") + } + + sib := func(name string) (expr string, err error) { + i, ok := byName[name] + if !ok { + return "", fmt.Errorf("field %s not found", name) + } + + return "v." + plans[i].goName, nil + } + + for _, field := range st.fields { + if len(field.Names) != 1 { + continue + } + + i := byName[field.Names[0].Name] + if !tagged[i] { + continue + } + + config, _ := tagConfig(field) + + if err := fillCallExprs(&plans[i], config, sib); err != nil { + return nil, fmt.Errorf("field %s: %w", plans[i].goName, err) + } + } + + return plans, nil +} + +// fillCallExprs parses config's space-separated tokens (extracting +// omitempty) and resolves each remaining one to a Go source expression via +// callSpecs/fieldCallSpecs, in order, appending to plan.callExprs. Returns +// an error naming the first unmapped checker. +func fillCallExprs(plan *fieldPlan, config string, sib sibling) error { + for _, token := range strings.Fields(config) { + if token == omitEmptyName { + plan.omitEmpty = true + continue + } + + name, params, _ := strings.Cut(token, ":") + + spec, ok := callSpecs[name] + if !ok { + spec, ok = fieldCallSpecs[name] + } + + if !ok { + return fmt.Errorf("checker %q has no checkergen mapping", name) + } + + expr, err := spec(params, plan.goType, sib) + if err != nil { + return fmt.Errorf("checker %q: %w", name, err) + } + + plan.callExprs = append(plan.callExprs, expr) + } + + return nil +} + +// emitFunc renders the Check(v *Name) (checker.CheckErrors, bool) +// function for a struct's resolved field plans. +func emitFunc(structName string, plans []fieldPlan) string { + var b strings.Builder + + fmt.Fprintf(&b, "func Check%s(v *%s) (checker.CheckErrors, bool) {\n", structName, structName) + b.WriteString("errs := make(checker.CheckErrors)\n\n") + + for _, plan := range plans { + if len(plan.callExprs) == 0 { + continue + } + + b.WriteString("{\n") + + if plan.omitEmpty { + fmt.Fprintf(&b, "if v.%s != %s {\n", plan.goName, zeroLiteral(plan.goType)) + } + + fmt.Fprintf(&b, "newValue, err := checker.Check(v.%s, %s)\n", plan.goName, strings.Join(plan.callExprs, ", ")) + fmt.Fprintf(&b, "v.%s = newValue\n", plan.goName) + fmt.Fprintf(&b, "if err != nil {\n errs[%q] = err\n}\n", plan.tagName) + + if plan.omitEmpty { + b.WriteString("}\n") + } + + b.WriteString("}\n\n") + } + + b.WriteString("return errs, len(errs) == 0\n}\n") + + return b.String() +} + +// emitFile renders a complete generated Go source file for pkgName, +// containing one Check function per struct in plansByStruct (in the +// given order). +func emitFile(pkgName string, structNames []string, plansByStruct map[string][]fieldPlan) ([]byte, error) { + var b strings.Builder + + b.WriteString("// Code generated by checkergen. DO NOT EDIT.\n\n") + fmt.Fprintf(&b, "package %s\n\n", pkgName) + b.WriteString("import (\n\tchecker \"github.com/cinar/checker/v2\"\n)\n\n") + + for _, name := range structNames { + b.WriteString(emitFunc(name, plansByStruct[name])) + b.WriteString("\n") + } + + return format.Source([]byte(b.String())) +} diff --git a/checkergen/generate_test.go b/checkergen/generate_test.go new file mode 100644 index 0000000..57eb250 --- /dev/null +++ b/checkergen/generate_test.go @@ -0,0 +1,219 @@ +// Copyright (c) 2023-2026 Onur Cinar. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +// https://github.com/cinar/checker + +package checkergen_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cinar/checker/v2/checkergen" +) + +// copyToScratch copies every non-generated file from srcDir into a fresh +// directory under testdata/, and returns its path. The scratch directory +// has to live inside this module's own tree, not under t.TempDir() (which +// lands outside it, under the system temp directory): go/packages needs a +// directory it can resolve within a module. Tests that call +// checkergen.Generate with anything other than a plain, unfiltered rerun +// (e.g. a -type filter) must target a scratch copy like this one, never a +// real testdata package directly -- Generate overwrites that package's +// committed, shared checkergen_generated.go file as a side effect. +func copyToScratch(t *testing.T, srcDir string) string { + t.Helper() + + scratch, err := os.MkdirTemp("testdata", "scratch-") + if err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { + _ = os.RemoveAll(scratch) + }) + + entries, err := os.ReadDir(srcDir) + if err != nil { + t.Fatal(err) + } + + for _, entry := range entries { + if entry.IsDir() || entry.Name() == checkergen.GeneratedFileName { + continue + } + + data, err := os.ReadFile(filepath.Join(srcDir, entry.Name())) + if err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(scratch, entry.Name()), data, 0o644); err != nil { + t.Fatal(err) + } + } + + return scratch +} + +// TestGenerateSkipsIneligibleStructs confirms a struct with an unmapped +// checker or an ineligible field type is skipped with a clear reason, +// without preventing other structs in the same package from being +// generated. +func TestGenerateSkipsIneligibleStructs(t *testing.T) { + result, err := checkergen.Generate("testdata/ineligible", nil) + if err != nil { + t.Fatal(err) + } + + wantGenerated := map[string]bool{"Eligible": true, "WithEmbedded": true} + + if len(result.Generated) != len(wantGenerated) { + t.Fatalf("expected %v to be generated, got %v", wantGenerated, result.Generated) + } + + for _, name := range result.Generated { + if !wantGenerated[name] { + t.Fatalf("unexpected struct %s in generated list %v", name, result.Generated) + } + } + + wantSkipped := map[string]string{ + "UnknownChecker": "no checkergen mapping", + "NamedType": "not eligible for generation", + "SliceField": "not eligible for generation", + "BadIntParam": "not an integer", + "BadNumberParam": "not a number", + "MalformedTwoPart": "two ':'-separated parameters", + "WrongNumericType": "requires a float64 field", + "MissingSibling": "field Password not found", + } + + if len(result.Skipped) != len(wantSkipped) { + t.Fatalf("actual %v expected %v", result.Skipped, wantSkipped) + } + + for name, substr := range wantSkipped { + reason, ok := result.Skipped[name] + if !ok { + t.Fatalf("expected %s to be skipped, got %v", name, result.Skipped) + } + + if !strings.Contains(reason, substr) { + t.Fatalf("%s: actual reason %q does not contain %q", name, reason, substr) + } + } +} + +// TestGenerateTypeFilter confirms -type restricts generation to the named +// struct(s), leaving other eligible structs in the package alone. Runs +// against a scratch copy of testdata/fixture, since a filtered Generate +// call would otherwise overwrite that package's committed, shared +// checkergen_generated.go with a partial result. +func TestGenerateTypeFilter(t *testing.T) { + scratch := copyToScratch(t, "testdata/fixture") + + result, err := checkergen.Generate(scratch, []string{"SignupRequest"}) + if err != nil { + t.Fatal(err) + } + + if len(result.Generated) != 1 || result.Generated[0] != "SignupRequest" { + t.Fatalf("expected only SignupRequest to be generated, got %v", result.Generated) + } + + data, err := os.ReadFile(filepath.Join(scratch, checkergen.GeneratedFileName)) + if err != nil { + t.Fatal(err) + } + + if strings.Contains(string(data), "CheckCoverage") { + t.Fatal("expected the type filter to exclude Coverage") + } +} + +// TestGenerateNoEligibleStructs confirms Generate is a no-op (no output +// file, no error) for a package with no checkers/validate tag anywhere. +func TestGenerateNoEligibleStructs(t *testing.T) { + result, err := checkergen.Generate("testdata/empty", nil) + if err != nil { + t.Fatal(err) + } + + if len(result.Generated) != 0 || len(result.Skipped) != 0 { + t.Fatalf("expected no generated or skipped structs, got %+v", result) + } + + if _, err := os.Stat(filepath.Join("testdata/empty", checkergen.GeneratedFileName)); err == nil { + t.Fatal("expected no output file to be written") + } +} + +// TestGenerateBadDirectory confirms Generate surfaces a package load +// failure as a returned error instead of panicking. +func TestGenerateBadDirectory(t *testing.T) { + if _, err := checkergen.Generate("testdata/does-not-exist", nil); err == nil { + t.Fatal("expected an error for a nonexistent directory") + } +} + +// TestGenerateWriteFailure confirms Generate surfaces an output-write +// failure as a returned error: dir itself, passed as the output file's +// parent directory, can't be created as a regular file. +func TestGenerateWriteFailure(t *testing.T) { + scratch := copyToScratch(t, "testdata/fixture") + + blocker := filepath.Join(scratch, checkergen.GeneratedFileName) + if err := os.Mkdir(blocker, 0o755); err != nil { + t.Fatal(err) + } + + if _, err := checkergen.Generate(scratch, nil); err == nil { + t.Fatal("expected an error writing over a directory") + } +} + +// TestGenerateMatchesCommittedFixture regenerates testdata/fixture's +// checkergen_generated.go into a scratch copy of the fixture package and +// byte-compares the result against the version committed to the repo. +// Go's build model compiles a test binary before running it, so a test +// can't regenerate a file and then exercise the newly generated code in +// the same run (see TestGeneratedMatchesCheckStruct, which exercises the +// committed copy directly) -- this test instead exists to catch the +// committed fixture drifting out of sync with the generator itself, e.g. +// after a callSpecs change that wasn't followed by re-running go generate +// in testdata/fixture. +func TestGenerateMatchesCommittedFixture(t *testing.T) { + const fixtureDir = "testdata/fixture" + + committed, err := os.ReadFile(filepath.Join(fixtureDir, checkergen.GeneratedFileName)) + if err != nil { + t.Fatal(err) + } + + scratch := copyToScratch(t, fixtureDir) + + result, err := checkergen.Generate(scratch, nil) + if err != nil { + t.Fatal(err) + } + + if len(result.Skipped) != 0 { + t.Fatalf("unexpected skips regenerating the fixture: %v", result.Skipped) + } + + regenerated, err := os.ReadFile(filepath.Join(scratch, checkergen.GeneratedFileName)) + if err != nil { + t.Fatal(err) + } + + if string(regenerated) != string(committed) { + t.Fatalf( + "testdata/fixture/%s is out of date with the current generator; "+ + "run `go run ./cmd/checkergen %s` and commit the result.\n\ngot:\n%s\n\nwant:\n%s", + checkergen.GeneratedFileName, fixtureDir, regenerated, committed, + ) + } +} diff --git a/checkergen/go.mod b/checkergen/go.mod new file mode 100644 index 0000000..910017e --- /dev/null +++ b/checkergen/go.mod @@ -0,0 +1,15 @@ +module github.com/cinar/checker/v2/checkergen + +go 1.25.0 + +replace github.com/cinar/checker/v2 => ../ + +require ( + github.com/cinar/checker/v2 v2.0.1 + golang.org/x/tools v0.49.0 +) + +require ( + golang.org/x/mod v0.39.0 // indirect + golang.org/x/sync v0.22.0 // indirect +) diff --git a/checkergen/go.sum b/checkergen/go.sum new file mode 100644 index 0000000..14aad15 --- /dev/null +++ b/checkergen/go.sum @@ -0,0 +1,8 @@ +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74= +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= diff --git a/checkergen/internal_test.go b/checkergen/internal_test.go new file mode 100644 index 0000000..22e2498 --- /dev/null +++ b/checkergen/internal_test.go @@ -0,0 +1,190 @@ +// Copyright (c) 2023-2026 Onur Cinar. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +// https://github.com/cinar/checker + +package checkergen + +import ( + "go/ast" + "testing" +) + +func TestDirPattern(t *testing.T) { + tests := []struct { + in, want string + }{ + {".", "."}, + {"..", ".."}, + {"./sub", "./sub"}, + {"../sub", "../sub"}, + {"/abs/path", "/abs/path"}, + {"sub", "./sub"}, + } + + for _, test := range tests { + if got := dirPattern(test.in); got != test.want { + t.Errorf("dirPattern(%q) = %q, want %q", test.in, got, test.want) + } + } +} + +func TestZeroLiteral(t *testing.T) { + tests := []struct { + goType, want string + }{ + {"string", `""`}, + {"bool", "false"}, + {"int", "0"}, + {"float64", "0"}, + } + + for _, test := range tests { + if got := zeroLiteral(test.goType); got != test.want { + t.Errorf("zeroLiteral(%q) = %q, want %q", test.goType, got, test.want) + } + } +} + +func TestTagConfigNoTag(t *testing.T) { + field := &ast.Field{} + + if _, ok := tagConfig(field); ok { + t.Fatal("expected no config for a field with no tag at all") + } +} + +func TestTagConfigMalformedLiteral(t *testing.T) { + field := &ast.Field{Tag: &ast.BasicLit{Value: "not-a-quoted-string"}} + + if _, ok := tagConfig(field); ok { + t.Fatal("expected no config for an unparsable tag literal") + } +} + +func TestTagConfigValidateFallback(t *testing.T) { + field := &ast.Field{Tag: &ast.BasicLit{Value: "`validate:\"required\"`"}} + + config, ok := tagConfig(field) + if !ok || config != "required" { + t.Fatalf("actual (%q, %v) expected (\"required\", true)", config, ok) + } +} + +func TestJSONNameNoTagAtAll(t *testing.T) { + field := &ast.Field{Names: []*ast.Ident{{Name: "Name"}}} + + if got := jsonName(field); got != "Name" { + t.Fatalf("actual %q expected %q", got, "Name") + } +} + +func TestJSONNameEmptyJSONTag(t *testing.T) { + field := &ast.Field{ + Names: []*ast.Ident{{Name: "Name"}}, + Tag: &ast.BasicLit{Value: "`json:\"\"`"}, + } + + if got := jsonName(field); got != "Name" { + t.Fatalf("actual %q expected %q", got, "Name") + } +} + +func TestJSONNameMalformedLiteral(t *testing.T) { + field := &ast.Field{ + Names: []*ast.Ident{{Name: "Name"}}, + Tag: &ast.BasicLit{Value: "not-a-quoted-string"}, + } + + if got := jsonName(field); got != "Name" { + t.Fatalf("actual %q expected %q", got, "Name") + } +} + +func TestJSONNameNoJSONTag(t *testing.T) { + field := &ast.Field{ + Names: []*ast.Ident{{Name: "Name"}}, + Tag: &ast.BasicLit{Value: "`checkers:\"required\"`"}, + } + + if got := jsonName(field); got != "Name" { + t.Fatalf("actual %q expected %q", got, "Name") + } +} + +func TestJSONNameCommaOnly(t *testing.T) { + field := &ast.Field{ + Names: []*ast.Ident{{Name: "Name"}}, + Tag: &ast.BasicLit{Value: "`json:\",omitempty\"`"}, + } + + if got := jsonName(field); got != "Name" { + t.Fatalf("actual %q expected %q", got, "Name") + } +} + +func TestJSONNameDash(t *testing.T) { + field := &ast.Field{ + Names: []*ast.Ident{{Name: "Name"}}, + Tag: &ast.BasicLit{Value: "`json:\"-\"`"}, + } + + if got := jsonName(field); got != "Name" { + t.Fatalf("actual %q expected %q", got, "Name") + } +} + +func TestAnyFieldTaggedNone(t *testing.T) { + st := &ast.StructType{ + Fields: &ast.FieldList{ + List: []*ast.Field{ + {Names: []*ast.Ident{{Name: "Name"}}}, + }, + }, + } + + if anyFieldTagged(st) { + t.Fatal("expected no tagged fields") + } +} + +// TestOneNumberOrStringArgAfterNumeric confirms eq/ne's arg builder emits +// an unquoted numeric literal when the tag's parameter parses as a number +// (for a numeric field), even though it's untestable differentially: the +// core module's reflect-based eq/ne checker (checkEq/checkNe) hardcodes +// reflectString, so CheckStruct only ever supports eq/ne on a string +// field via a struct tag, unlike the fully generic exported IsEq/IsNe +// this generates a call to. +func TestOneNumberOrStringArgAfterNumeric(t *testing.T) { + args, err := oneNumberOrStringArgAfter("eq")("42") + if err != nil { + t.Fatal(err) + } + + want := []string{"value", "42"} + if len(args) != len(want) || args[0] != want[0] || args[1] != want[1] { + t.Fatalf("actual %v expected %v", args, want) + } +} + +// TestOneOfArgsNumeric is the oneof equivalent of +// TestOneNumberOrStringArgAfterNumeric, same reasoning: checkOneOf also +// hardcodes reflectString. +func TestOneOfArgsNumeric(t *testing.T) { + args, err := oneOfArgs("1,2,3") + if err != nil { + t.Fatal(err) + } + + want := []string{"value", "1", "2", "3"} + + if len(args) != len(want) { + t.Fatalf("actual %v expected %v", args, want) + } + + for i := range want { + if args[i] != want[i] { + t.Fatalf("actual %v expected %v", args, want) + } + } +} diff --git a/checkergen/scan.go b/checkergen/scan.go new file mode 100644 index 0000000..f8ee910 --- /dev/null +++ b/checkergen/scan.go @@ -0,0 +1,221 @@ +// Copyright (c) 2023-2026 Onur Cinar. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +// https://github.com/cinar/checker + +package checkergen + +import ( + "fmt" + "go/ast" + "go/token" + "go/types" + "path/filepath" + "reflect" + "strconv" + "strings" + + "golang.org/x/tools/go/packages" +) + +// dirPattern turns dir into a go/packages directory pattern: it needs a +// "./" or "../" prefix (or to be absolute) to be recognized as a +// filesystem path rather than an import path. +func dirPattern(dir string) string { + if filepath.IsAbs(dir) || strings.HasPrefix(dir, "."+string(filepath.Separator)) || strings.HasPrefix(dir, ".."+string(filepath.Separator)) || dir == "." || dir == ".." { + return dir + } + + return "." + string(filepath.Separator) + dir +} + +const ( + checkerTag = "checkers" + validateTag = "validate" + omitEmptyName = "omitempty" + sliceConfigPrefix = "@" +) + +// scalarGoTypes is the set of predeclared scalar type names a field's Go +// type must match exactly (see the package doc) for checkergen to generate +// code for it. +var scalarGoTypes = map[string]bool{ + "string": true, "bool": true, + "int": true, "int8": true, "int16": true, "int32": true, "int64": true, + "uint": true, "uint8": true, "uint16": true, "uint32": true, "uint64": true, + "float32": true, "float64": true, +} + +// zeroLiteral returns the Go zero-value literal for one of scalarGoTypes, +// used to generate an omitempty guard. +func zeroLiteral(goType string) string { + switch goType { + case "string": + return `""` + case "bool": + return "false" + default: + return "0" + } +} + +// loadedStruct is one named struct type found in a loaded package, along +// with the type info needed to resolve its fields' Go types. +type loadedStruct struct { + name string + fields []*ast.Field + info *types.Info +} + +// findStructs loads pkgPattern (a go/packages pattern, e.g. "." for the +// current directory's package) and returns every named struct type +// declared in it, optionally restricted to typeFilter (struct names; a nil +// or empty filter means every struct). +func findStructs(dir string, typeFilter map[string]bool) ([]loadedStruct, string, error) { + cfg := &packages.Config{ + Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | + packages.NeedTypes | packages.NeedTypesInfo, + } + + // go/packages treats a bare relative path as an import path, not a + // directory, unless it's prefixed with "./" or "../" -- normalize so + // a caller passing a plain relative directory (as the checkergen CLI's + // own default "." already does, but a caller-supplied one might not) + // doesn't need to know that. + pkgPattern := dirPattern(dir) + + pkgs, err := packages.Load(cfg, pkgPattern) + if err != nil { + return nil, "", fmt.Errorf("loading package %s: %w", pkgPattern, err) + } + + if packages.PrintErrors(pkgs) > 0 { + return nil, "", fmt.Errorf("package %s has errors, see above", pkgPattern) + } + + if len(pkgs) != 1 { + return nil, "", fmt.Errorf("pattern %s must resolve to exactly one package, got %d", pkgPattern, len(pkgs)) + } + + pkg := pkgs[0] + + var structs []loadedStruct + + for _, file := range pkg.Syntax { + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + continue + } + + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + continue + } + + if len(typeFilter) > 0 && !typeFilter[typeSpec.Name.Name] { + continue + } + + if !anyFieldTagged(structType) { + continue + } + + structs = append(structs, loadedStruct{ + name: typeSpec.Name.Name, + fields: structType.Fields.List, + info: pkg.TypesInfo, + }) + } + } + } + + return structs, pkg.Name, nil +} + +// anyFieldTagged reports whether at least one field of st has a non-empty +// checkers or validate tag, so a struct with no validation rules at all is +// skipped without a diagnostic (it's simply not a candidate, same as +// CheckStruct silently doing nothing for such a field). +func anyFieldTagged(st *ast.StructType) bool { + for _, field := range st.Fields.List { + if config, ok := tagConfig(field); ok && config != "" { + return true + } + } + + return false +} + +// tagConfig returns the effective checkers/validate tag config for field, +// and whether a "checkers" or "validate" tag was present at all -- mirroring +// the core module's fieldConfig, which prefers "checkers" and falls back to +// "validate". +func tagConfig(field *ast.Field) (string, bool) { + if field.Tag == nil { + return "", false + } + + unquoted, err := strconv.Unquote(field.Tag.Value) + if err != nil { + return "", false + } + + tag := reflect.StructTag(unquoted) + + if config, ok := tag.Lookup(checkerTag); ok { + return config, true + } + + config, ok := tag.Lookup(validateTag) + return config, ok +} + +// jsonName returns field's tag-facing name -- CheckStruct's own +// localFieldName logic: the json tag's property name if present (falling +// back to the Go field name for an empty or "-" json tag, or one with no +// name before the first comma), otherwise the Go field name. +func jsonName(field *ast.Field) string { + goName := field.Names[0].Name + + if field.Tag == nil { + return goName + } + + unquoted, err := strconv.Unquote(field.Tag.Value) + if err != nil { + return goName + } + + tag, ok := reflect.StructTag(unquoted).Lookup("json") + if !ok || tag == "" || tag == "-" { + return goName + } + + name, _, _ := strings.Cut(tag, ",") + if name == "" { + return goName + } + + return name +} + +// goTypeName returns t's Go source-level type name, and whether it's one +// of scalarGoTypes: only a *types.Basic whose name is directly in that set +// qualifies, so a named/defined type (type Email string) or a pointer is +// reported as ineligible rather than silently treated as its underlying +// type -- see the package doc. +func goTypeName(t types.Type) (string, bool) { + basic, ok := t.(*types.Basic) + if !ok { + return t.String(), false + } + + return basic.Name(), scalarGoTypes[basic.Name()] +} diff --git a/checkergen/taskfile.yml b/checkergen/taskfile.yml new file mode 100644 index 0000000..ae755ca --- /dev/null +++ b/checkergen/taskfile.yml @@ -0,0 +1,32 @@ +version: '3' +output: 'prefixed' + +tasks: + default: + cmds: + - task: fmt + - task: lint + - task: test + + action: + deps: [lint, test] + + fmt: + cmds: + - go fix ./... + + lint: + cmds: + - go vet ./... + - go run github.com/securego/gosec/v2/cmd/gosec@v2.29.0 -exclude-dir=testdata ./... + - go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./... + - go run github.com/mgechev/revive@v1.3.4 -config=../revive.toml ./... + + test: + cmds: + # Scoped to the checkergen package itself: cmd/checkergen is a + # trivial os.Exit(run(...)) wrapper with no logic of its own to + # cover, and including it in a whole-module -cover run trips a + # "no such tool covdata" error for the untested package. + - go test -cover -coverprofile=coverage.out . + - go tool cover -func=coverage.out diff --git a/checkergen/testdata/empty/empty.go b/checkergen/testdata/empty/empty.go new file mode 100644 index 0000000..df516fa --- /dev/null +++ b/checkergen/testdata/empty/empty.go @@ -0,0 +1,12 @@ +// Copyright (c) 2023-2026 Onur Cinar. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +// https://github.com/cinar/checker + +// Package empty has no struct with a checkers/validate tag at all, to +// exercise Generate's no-op path: no output file is written. +package empty + +type Plain struct { + Name string +} diff --git a/checkergen/testdata/fixture/checkergen_generated.go b/checkergen/testdata/fixture/checkergen_generated.go new file mode 100644 index 0000000..df77b3a --- /dev/null +++ b/checkergen/testdata/fixture/checkergen_generated.go @@ -0,0 +1,213 @@ +// Code generated by checkergen. DO NOT EDIT. + +package fixture + +import ( + checker "github.com/cinar/checker/v2" +) + +func CheckCoverage(v *Coverage) (checker.CheckErrors, bool) { + errs := make(checker.CheckErrors) + + { + newValue, err := checker.Check(v.Handle, func(value string) (string, error) { return checker.IsContains("@", value) }, func(value string) (string, error) { return checker.IsStartsWith("@", value) }, func(value string) (string, error) { return checker.IsEndsWith(".com", value) }) + v.Handle = newValue + if err != nil { + errs["Handle"] = err + } + } + + { + newValue, err := checker.Check(v.Code, func(value string) (string, error) { return checker.IsRegexp("^[A-Z]{3}$", value) }) + v.Code = newValue + if err != nil { + errs["Code"] = err + } + } + + { + newValue, err := checker.Check(v.Sum, func(value string) (string, error) { return checker.IsHash("sha256", value) }) + v.Sum = newValue + if err != nil { + errs["Sum"] = err + } + } + + { + newValue, err := checker.Check(v.Zip, func(value string) (string, error) { return checker.IsPostalCode("US", value) }) + v.Zip = newValue + if err != nil { + errs["Zip"] = err + } + } + + { + newValue, err := checker.Check(v.Status, func(value string) (string, error) { return checker.IsEq(value, "active") }) + v.Status = newValue + if err != nil { + errs["Status"] = err + } + } + + { + newValue, err := checker.Check(v.Excluded, func(value string) (string, error) { return checker.IsNe(value, "banned") }) + v.Excluded = newValue + if err != nil { + errs["Excluded"] = err + } + } + + { + newValue, err := checker.Check(v.Role, func(value string) (string, error) { return checker.IsOneOf(value, "admin", "editor", "viewer") }) + v.Role = newValue + if err != nil { + errs["Role"] = err + } + } + + { + newValue, err := checker.Check(v.Weight, func(value float64) (float64, error) { return checker.IsMultipleOf(value, 5) }) + v.Weight = newValue + if err != nil { + errs["Weight"] = err + } + } + + { + newValue, err := checker.Check(v.Ratio, checker.IsFinite, checker.IsInt) + v.Ratio = newValue + if err != nil { + errs["Ratio"] = err + } + } + + { + newValue, err := checker.Check(v.Greeting, checker.Default[string]("hello")) + v.Greeting = newValue + if err != nil { + errs["Greeting"] = err + } + } + + { + newValue, err := checker.Check(v.Threshold, checker.Default[int](10)) + v.Threshold = newValue + if err != nil { + errs["Threshold"] = err + } + } + + { + newValue, err := checker.Check(v.Country, checker.Required) + v.Country = newValue + if err != nil { + errs["Country"] = err + } + } + + { + newValue, err := checker.Check(v.State, func(value string) (string, error) { return checker.IsRequiredIf(value, v.Country, "US") }) + v.State = newValue + if err != nil { + errs["State"] = err + } + } + + { + newValue, err := checker.Check(v.Type, checker.Required) + v.Type = newValue + if err != nil { + errs["Type"] = err + } + } + + { + newValue, err := checker.Check(v.Email, func(value string) (string, error) { return checker.IsRequiredUnless(value, v.Type, "guest") }) + v.Email = newValue + if err != nil { + errs["Email"] = err + } + } + + { + newValue, err := checker.Check(v.ReturnAt, checker.Required) + v.ReturnAt = newValue + if err != nil { + errs["ReturnAt"] = err + } + } + + { + newValue, err := checker.Check(v.DepartAt, func(value string) (string, error) { return checker.IsBeforeField("DateOnly", v.ReturnAt, value) }) + v.DepartAt = newValue + if err != nil { + errs["DepartAt"] = err + } + } + + { + newValue, err := checker.Check(v.BornAt, checker.Required) + v.BornAt = newValue + if err != nil { + errs["BornAt"] = err + } + } + + { + newValue, err := checker.Check(v.DiesAt, func(value string) (string, error) { return checker.IsAfterField("DateOnly", v.BornAt, value) }) + v.DiesAt = newValue + if err != nil { + errs["DiesAt"] = err + } + } + + return errs, len(errs) == 0 +} + +func CheckSignupRequest(v *SignupRequest) (checker.CheckErrors, bool) { + errs := make(checker.CheckErrors) + + { + newValue, err := checker.Check(v.Email, checker.TrimSpace, checker.Lower, checker.Required, checker.IsEmail) + v.Email = newValue + if err != nil { + errs["email"] = err + } + } + + { + newValue, err := checker.Check(v.Password, checker.Required, checker.MinLen[string](8)) + v.Password = newValue + if err != nil { + errs["password"] = err + } + } + + { + newValue, err := checker.Check(v.ConfirmPassword, checker.Required, func(value string) (string, error) { return checker.IsEqField(value, v.Password, "Password") }) + v.ConfirmPassword = newValue + if err != nil { + errs["confirm_password"] = err + } + } + + { + newValue, err := checker.Check(v.Age, func(value int) (int, error) { return checker.IsGte(value, 18) }) + v.Age = newValue + if err != nil { + errs["age"] = err + } + } + + { + if v.Nickname != "" { + newValue, err := checker.Check(v.Nickname, checker.MinLen[string](3)) + v.Nickname = newValue + if err != nil { + errs["nickname"] = err + } + } + } + + return errs, len(errs) == 0 +} diff --git a/checkergen/testdata/fixture/coverage.go b/checkergen/testdata/fixture/coverage.go new file mode 100644 index 0000000..525eb41 --- /dev/null +++ b/checkergen/testdata/fixture/coverage.go @@ -0,0 +1,39 @@ +// Copyright (c) 2023-2026 Onur Cinar. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +// https://github.com/cinar/checker + +package fixture + +// Coverage exercises the remaining checkergen-eligible checkers not +// already covered by SignupRequest: two-parameter checkers, "value after +// param" checkers, oneof, default, and the remaining field-relative +// checkers (required-if, required-unless, before-field, after-field). +type Coverage struct { + Handle string `checkers:"contains:@ starts-with:@ ends-with:.com"` + Code string `checkers:"regexp:^[A-Z]{3}$"` + Sum string `checkers:"hash:sha256"` + Zip string `checkers:"postal-code:US"` + Status string `checkers:"eq:active"` + Excluded string `checkers:"ne:banned"` + Role string `checkers:"oneof:admin,editor,viewer"` + Weight float64 `checkers:"multiple-of:5"` + Ratio float64 `checkers:"finite int"` + Greeting string `checkers:"default:hello"` + Threshold int `checkers:"default:10"` + + Country string `checkers:"required"` + State string `checkers:"required-if:Country:US"` + Type string `checkers:"required"` + Email string `checkers:"required-unless:Type:guest"` + + ReturnAt string `checkers:"required"` + DepartAt string `checkers:"before-field:DateOnly:ReturnAt"` + BornAt string `checkers:"required"` + DiesAt string `checkers:"after-field:DateOnly:BornAt"` + + // Untouched has no checkers/validate tag at all: findStructs still + // walks it (it's a field on an eligible struct), but there's nothing + // to generate for it. + Untouched string +} diff --git a/checkergen/testdata/fixture/signup.go b/checkergen/testdata/fixture/signup.go new file mode 100644 index 0000000..7706ae7 --- /dev/null +++ b/checkergen/testdata/fixture/signup.go @@ -0,0 +1,16 @@ +// Copyright (c) 2023-2026 Onur Cinar. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +// https://github.com/cinar/checker + +package fixture + +// SignupRequest exercises a representative mix of checkergen-eligible +// checkers/normalizers, including field-relative ones. +type SignupRequest struct { + Email string `json:"email" checkers:"trim lower required email"` + Password string `json:"password" checkers:"required min-len:8"` + ConfirmPassword string `json:"confirm_password" checkers:"required eq-field:Password"` + Age int `json:"age" checkers:"gte:18"` + Nickname string `json:"nickname" checkers:"omitempty min-len:3"` +} diff --git a/checkergen/testdata/ineligible/checkergen_generated.go b/checkergen/testdata/ineligible/checkergen_generated.go new file mode 100644 index 0000000..bbe63f2 --- /dev/null +++ b/checkergen/testdata/ineligible/checkergen_generated.go @@ -0,0 +1,35 @@ +// Code generated by checkergen. DO NOT EDIT. + +package ineligible + +import ( + checker "github.com/cinar/checker/v2" +) + +func CheckEligible(v *Eligible) (checker.CheckErrors, bool) { + errs := make(checker.CheckErrors) + + { + newValue, err := checker.Check(v.Name, checker.Required) + v.Name = newValue + if err != nil { + errs["Name"] = err + } + } + + return errs, len(errs) == 0 +} + +func CheckWithEmbedded(v *WithEmbedded) (checker.CheckErrors, bool) { + errs := make(checker.CheckErrors) + + { + newValue, err := checker.Check(v.Name, checker.Required) + v.Name = newValue + if err != nil { + errs["Name"] = err + } + } + + return errs, len(errs) == 0 +} diff --git a/checkergen/testdata/ineligible/ineligible.go b/checkergen/testdata/ineligible/ineligible.go new file mode 100644 index 0000000..30f8bdb --- /dev/null +++ b/checkergen/testdata/ineligible/ineligible.go @@ -0,0 +1,73 @@ +// Copyright (c) 2023-2026 Onur Cinar. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +// https://github.com/cinar/checker + +package ineligible + +// Eligible has a valid, generatable field, to prove one struct being +// skipped doesn't prevent generation for others in the same package. +type Eligible struct { + Name string `checkers:"required"` +} + +// UnknownChecker has a checkers tag naming a checker checkergen has no +// mapping for. +type UnknownChecker struct { + Name string `checkers:"is-fruit"` +} + +// NamedType has a tagged field whose Go type is a named string type, not +// the plain string type itself -- outside v1's scope (see the checkergen +// package doc). +type NamedType struct { + Email EmailAddress `checkers:"required"` +} + +// EmailAddress is a named string type, used only to make NamedType's field +// ineligible for generation. +type EmailAddress string + +// SliceField has a tagged slice field -- outside v1's scope. +type SliceField struct { + Roles []string `checkers:"required"` +} + +// BadIntParam has a curried checker (min-len) with a non-integer parameter. +type BadIntParam struct { + Name string `checkers:"min-len:abc"` +} + +// BadNumberParam has a numeric checker (gte) with a non-numeric parameter. +type BadNumberParam struct { + Age int `checkers:"gte:abc"` +} + +// MalformedTwoPart has a checker (after) requiring two ":"-separated +// parameters, given only one -- this panics at generate time, the same way +// the equivalent runtime maker panics on a malformed tag, recovered into a +// skip rather than crashing generation for the rest of the package. +type MalformedTwoPart struct { + BornAt string `checkers:"after:DateOnly"` +} + +// WrongNumericType has "multiple-of" (hardcoded to float64) applied to an +// int field. +type WrongNumericType struct { + Weight int `checkers:"multiple-of:5"` +} + +// MissingSibling has a field-relative checker referencing a sibling field +// that doesn't exist on the struct. +type MissingSibling struct { + ConfirmPassword string `checkers:"eq-field:Password"` +} + +// WithEmbedded has an embedded field (out of scope for v1, silently +// skipped rather than treated as ineligible, since it has no tag of its +// own) alongside a normal eligible field, to prove the embedded field +// doesn't block generation for the rest of the struct. +type WithEmbedded struct { + Eligible + Name string `checkers:"required"` +} diff --git a/taskfile.yml b/taskfile.yml index 5ded548..90e113e 100644 --- a/taskfile.yml +++ b/taskfile.yml @@ -18,7 +18,7 @@ tasks: lint: cmds: - go vet ./... - - go run github.com/securego/gosec/v2/cmd/gosec@v2.20.0 -exclude-dir=gin -exclude-dir=echo -exclude-dir=examples -exclude-dir=checkerlint -exclude-dir=cli -exclude-dir=nethttp -exclude-dir=fiber -exclude-dir=nfkc ./... + - go run github.com/securego/gosec/v2/cmd/gosec@v2.20.0 -exclude-dir=gin -exclude-dir=echo -exclude-dir=examples -exclude-dir=checkerlint -exclude-dir=cli -exclude-dir=nethttp -exclude-dir=fiber -exclude-dir=nfkc -exclude-dir=checkergen ./... - go run honnef.co/go/tools/cmd/staticcheck@v0.5.1 ./... - go run github.com/mgechev/revive@v1.3.4 -config=revive.toml ./... From 192e15fea99e4a9b7aab2c295949f620f623fc37 Mon Sep 17 00:00:00 2001 From: Onur Cinar Date: Sun, 6 Sep 2026 15:42:46 +0000 Subject: [PATCH 2/4] Document checkergen's findings in its README, add a dev.to draft checkergen/README.md: new "What Building This Found" section calling out the three real issues the differential-test harness surfaced while building the generator -- a wrong field name in eq-field's generated error data, an unrecovered panic that could take down generation for a whole package on one malformed tag, and the pre-existing eq/ne/oneof string-only limitation in the core module's reflect-based checkers (not a checkergen bug, but found via it). articles/reflection-free-go-validation-with-checkergen.md: a dev.to-formatted draft (published: false, matching the existing draft convention in this directory) walking through the same three findings in narrative form, plus the benchmark numbers and usage. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ARLam6G4Bu9afnWHpmJWeo --- ...tion-free-go-validation-with-checkergen.md | 105 ++++++++++++++++++ checkergen/README.md | 8 ++ 2 files changed, 113 insertions(+) create mode 100644 articles/reflection-free-go-validation-with-checkergen.md diff --git a/articles/reflection-free-go-validation-with-checkergen.md b/articles/reflection-free-go-validation-with-checkergen.md new file mode 100644 index 0000000..e186908 --- /dev/null +++ b/articles/reflection-free-go-validation-with-checkergen.md @@ -0,0 +1,105 @@ +--- +title: I Built a Code Generator for My Go Validation Library — It Found 3 Real Bugs +published: false +description: checkergen turns Checker's struct tags into reflection-free Go validation code, ~3x faster with 4-8x fewer allocations. Building it — and differential-testing it against the reflection path it replaces — surfaced two bugs in the generator and one pre-existing gap in the core library itself. +tags: go, golang, codegen, performance +canonical_url: +cover_image: +--- + +[Checker](https://github.com/cinar/checker) validates Go structs from tags: + +```go +type SignupRequest struct { + Email string `json:"email" checkers:"trim lower required email"` + Password string `json:"password" checkers:"required min-len:8"` +} + +errs, ok := checker.CheckStruct(&req) +``` + +`CheckStruct` does this with `reflect`: walk the struct's fields, look up each tag token in a registry, run the resolved checker function through a `reflect.Value`. It's cached per struct type after the first call, so it's fast — but it's still reflection on every call after that, and reflection has a floor on how fast it can go. + +Almost every checker already has a plain, non-reflection Go function sitting right next to the reflect-based one `CheckStruct` uses — `IsEmail(value string)`, `MinLen[T](n int) CheckFunc[T]`, and so on. So the question was: can a generator just read the tags once, at build time, and emit a function that calls those directly? + +## What it generates + +[`checkergen`](https://github.com/cinar/checker/tree/main/checkergen), a new module in the same repo, does exactly that: + +```go +//go:generate go run github.com/cinar/checker/v2/checkergen/cmd/checkergen +``` + +```go +// Code generated by checkergen. DO NOT EDIT. + +func CheckSignupRequest(v *SignupRequest) (checker.CheckErrors, bool) { + errs := make(checker.CheckErrors) + + { + newValue, err := checker.Check(v.Email, checker.TrimSpace, checker.Lower, checker.Required, checker.IsEmail) + v.Email = newValue + if err != nil { + errs["email"] = err + } + } + + { + newValue, err := checker.Check(v.Password, checker.Required, checker.MinLen[string](8)) + v.Password = newValue + if err != nil { + errs["password"] = err + } + } + + return errs, len(errs) == 0 +} +``` + +No `reflect` anywhere. Every call is a real, statically-typed Go function call, checked by the compiler like any other code in your package. `checkergen` and `CheckStruct` are meant to coexist — generate code for the structs on a hot path, leave everything else on `CheckStruct`. + +Benchmarked against the `CheckStruct` call it replaces, on the same struct and input: + +| Struct | `CheckStruct` | Generated | Speedup | +| :--- | ---: | ---: | :---: | +| 5-field signup form | 1830 ns/op, 1304 B/op, 24 allocs/op | 562 ns/op, 168 B/op, 7 allocs/op | **~3.3x faster, ~8x less memory** | +| 25-field mixed checker coverage | 8200 ns/op, 4928 B/op, 59 allocs/op | 2730 ns/op, 760 B/op, 9 allocs/op | **~3.0x faster, ~6.5x less memory** | + +## The interesting part: what building it found + +The core of `checkergen` is a table mapping each checker's tag name to a Go call expression — templating, mostly. That part came together fast. What actually surfaced real bugs was writing a *differential test*: for a batch of struct values, run both `CheckStruct` and the generated function against separate copies, and assert they report errors under the same keys and normalize the value identically. Once that harness existed, it started finding things. + +**1. A wrong field name in every non-English error message.** `eq-field:Password` compares a field against a sibling and reports which sibling failed: + +```go +type Registration struct { + Password string `checkers:"required"` + ConfirmPassword string `json:"confirm_password" checkers:"required eq-field:Password"` +} +``` + +The first draft of the generated closure used the sibling's *json* tag name for that report — `field: "password"` — instead of the field name exactly as written in the tag, `field: "Password"`, which is what `CheckStruct` actually puts there. Nothing about this would fail a naive test that only checks *whether* an error occurred. It only shows up once you check *what the error says* — and a `{{ .field }}` placeholder in a translated locale message would have silently rendered the wrong name, forever, in every language but English. The differential test caught it because it compares generated output against `CheckStruct`'s field-for-field, not just pass/fail. + +**2. One bad tag could take down generation for an entire package.** `after-field:DateOnly` — missing the `:field` half of its parameter — panics at generate time, on purpose, the same way the equivalent *runtime* checker panics on the same malformed tag (a struct tag is closer to a typo than to untrusted input; failing loudly is the right call). The bug was that the panic wasn't recovered anywhere in `checkergen` itself, so it took the whole run down — every other, perfectly valid struct in the package failed to generate too, because of one unrelated typo. The fix mirrors a pattern this repo already uses in its CLI module: recover the panic per-struct, turn it into a skip with a diagnostic, and keep going. + +**3. The generator turned out to be more correct than the library it wraps.** While building fixtures to differential-test `eq`, `ne`, and `oneof`, I tried them on an `int` field: + +```go +type Coverage struct { + Priority int `checkers:"eq:1"` +} + +checker.CheckStruct(&Coverage{Priority: 1}) // panic: string expected +``` + +That's not a `checkergen` bug — it's a bug the differential test *exposed* in the core library. The exported `IsEq[T comparable]`, `IsNe[T comparable]`, and `IsOneOf[T comparable]` are fully generic. But the reflect-based functions the `eq`/`ne`/`oneof` *struct tags* actually run through — `checkEq`, `checkNe`, `checkOneOf` — hardcode a string conversion, so they only ever worked on string fields, despite the underlying checkers supporting any comparable type. `checkergen`'s generated code calls the generic functions directly, so it doesn't inherit this limitation — it validates strictly more than the tag path it's supposed to be a faster version of. That's documented as a known divergence in the `checkergen` README until the core library's reflect path catches up. + +None of these three were found by writing more unit tests for the generator in isolation. They were found by making the generator's output *provably behave the same* as the thing it replaces, for real inputs — which is really just testing-in-depth applied to code that writes code. + +## Try it + +```bash +go get github.com/cinar/checker/v2/checkergen +``` + +Full scope (which struct field types are eligible today, and the two other deliberate, documented divergences from `CheckStruct`) is in the [checkergen README](https://github.com/cinar/checker/tree/main/checkergen). It's a separate, independently versioned module — nothing about generating code for one struct adds a dependency to the core `checker` library the rest of your code imports. diff --git a/checkergen/README.md b/checkergen/README.md index e06e8dc..5581d11 100644 --- a/checkergen/README.md +++ b/checkergen/README.md @@ -102,6 +102,14 @@ Two checkers behave slightly differently from `CheckStruct`, both deliberately: - `eq-field` compares with `==` (`comparable`) via the generated code's `IsEqField`, instead of the `reflect.DeepEqual` the `eq-field` struct tag uses at runtime through `CheckStruct` — matching how every other comparison checker (`IsEq`, `IsGte`, ...) already works. This only differs in practice for a slice/map/struct-typed field, which isn't eligible for generation in the first place. - `eq`, `ne`, and `oneof` support any field type via `checkergen`'s generated call to the fully generic `IsEq`/`IsNe`/`IsOneOf`, even though the `checkers`-tag path through `CheckStruct` currently only supports a string field for these three (their reflect-based implementation hardcodes a string conversion). Generated code is a strict capability superset here, not a behavior change for anything `CheckStruct` already validates correctly. +## What Building This Found + +Every bug below was caught before merge, by generating real code from real struct tags and diffing its behavior against `CheckStruct` for the same input — not by reading the generator's source and reasoning about it. + +- **A field-name bug in the generated `eq-field` closure.** The first draft used the sibling field's *json*-tag name for the error message (`errs["confirmPassword"] = ...` reporting `field: "password"`) instead of the field name exactly as written in the tag (`field: "Password"`) — what `CheckStruct`/`IsEqField` actually puts there. A `{{ .field }}` placeholder in a translated error message would have silently shown the wrong name in every non-English locale. Caught by the differential test comparing generated output field-for-field against `CheckStruct`'s. +- **A malformed tag could crash generation for an entire package.** `after-field:DateOnly` (missing the `:field` half) panics at generate time, exactly like the equivalent runtime `after-field` maker panics on the same malformed tag — but an unrecovered panic during `Generate` took down the whole run, not just the one struct. Fixed with the same panic-recovery pattern the `cli` module already uses for `CheckWithConfig` (see the root `CLAUDE.md`): one struct's bad tag is now a skip with a diagnostic, not a crash for every other struct in the package. +- **`eq`, `ne`, and `oneof` are string-only today, even on a numeric field — in the core library, not just here.** Building the differential-test fixtures, `checker.CheckStruct` panicked with `string expected` on a field like `Priority int checkers:"eq:1"`. `checkEq`/`checkNe`/`checkOneOf` — the reflect-based functions the `eq`/`ne`/`oneof` struct tags actually run through — hardcode a `reflectString` conversion, even though the exported `IsEq`/`IsNe`/`IsOneOf` are fully generic. This is a pre-existing limitation in the core module, not something `checkergen` introduced: `checkergen`'s generated code for these three checkers calls the generic functions directly, so it validates strictly more than the `checkers`-tag path can today (see [Scope](#scope) above). Worth fixing at the source; documented here as a known divergence until then. + ## License This library is free to use, modify, and distribute under the terms of the MIT license found in the [LICENSE](../LICENSE) file of the parent [Checker](https://github.com/cinar/checker) repository. From ac285b001d8280f01d9350c66dc05d0413572f51 Mon Sep 17 00:00:00 2001 From: Onur Cinar Date: Sun, 6 Sep 2026 15:58:17 +0000 Subject: [PATCH 3/4] Rewrite checkergen article: benchmarks and framework integration focus Drops the "I built this, it found bugs in my own code" framing. Leads with the reflection cost CheckStruct pays, the benchmark numbers, and a worked example swapping checker.CheckStruct for generated code inside a Gin handler, with the same swap spelled out for Echo, Fiber, and net/http -- tying checkergen directly to the existing adapter modules instead of presenting it in isolation. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ARLam6G4Bu9afnWHpmJWeo --- ...tion-free-go-validation-with-checkergen.md | 80 ++++++++++++------- 1 file changed, 52 insertions(+), 28 deletions(-) diff --git a/articles/reflection-free-go-validation-with-checkergen.md b/articles/reflection-free-go-validation-with-checkergen.md index e186908..e51b03c 100644 --- a/articles/reflection-free-go-validation-with-checkergen.md +++ b/articles/reflection-free-go-validation-with-checkergen.md @@ -1,13 +1,13 @@ --- -title: I Built a Code Generator for My Go Validation Library — It Found 3 Real Bugs +title: 3x Faster Struct Validation in Go — Same Rules, No Reflection published: false -description: checkergen turns Checker's struct tags into reflection-free Go validation code, ~3x faster with 4-8x fewer allocations. Building it — and differential-testing it against the reflection path it replaces — surfaced two bugs in the generator and one pre-existing gap in the core library itself. -tags: go, golang, codegen, performance +description: checkergen turns Checker's struct tags into plain Go validation code — same rules, ~3x faster and 4-8x fewer allocations than the reflection-based path, with a one-line swap into your existing Gin, Echo, Fiber, or net/http handlers. +tags: go, golang, performance, webdev canonical_url: cover_image: --- -[Checker](https://github.com/cinar/checker) validates Go structs from tags: +[Checker](https://github.com/cinar/checker) validates a Go struct from its tags: ```go type SignupRequest struct { @@ -18,13 +18,13 @@ type SignupRequest struct { errs, ok := checker.CheckStruct(&req) ``` -`CheckStruct` does this with `reflect`: walk the struct's fields, look up each tag token in a registry, run the resolved checker function through a `reflect.Value`. It's cached per struct type after the first call, so it's fast — but it's still reflection on every call after that, and reflection has a floor on how fast it can go. +`CheckStruct` does this with `reflect`: walk the struct's fields, look up each tag token, run the resolved checker through a `reflect.Value`. It caches the resolved execution plan per struct type after the first call, so it's not doing that lookup work on every request — but every field access, every checker invocation, still goes through `reflect.Value`. On a hot API path validating thousands of requests a second, that adds up: extra allocations for boxing values, indirect calls the compiler can't inline, and CPU time spent in `reflect` internals that a plain function call doesn't pay. -Almost every checker already has a plain, non-reflection Go function sitting right next to the reflect-based one `CheckStruct` uses — `IsEmail(value string)`, `MinLen[T](n int) CheckFunc[T]`, and so on. So the question was: can a generator just read the tags once, at build time, and emit a function that calls those directly? +[`checkergen`](https://github.com/cinar/checker/tree/main/checkergen) removes that cost entirely, without changing a single validation rule. ## What it generates -[`checkergen`](https://github.com/cinar/checker/tree/main/checkergen), a new module in the same repo, does exactly that: +Every checker already has a plain, non-reflection Go function sitting next to the reflect-based one `CheckStruct` uses — `IsEmail(value string)`, `MinLen[T](n int) CheckFunc[T]`, and so on. `checkergen` reads a struct's tags once, at build time, and emits a function that calls those directly: ```go //go:generate go run github.com/cinar/checker/v2/checkergen/cmd/checkergen @@ -56,45 +56,65 @@ func CheckSignupRequest(v *SignupRequest) (checker.CheckErrors, bool) { } ``` -No `reflect` anywhere. Every call is a real, statically-typed Go function call, checked by the compiler like any other code in your package. `checkergen` and `CheckStruct` are meant to coexist — generate code for the structs on a hot path, leave everything else on `CheckStruct`. +`CheckSignupRequest` returns the exact same `checker.CheckErrors` type as `CheckStruct` — same error codes, same `{{ .field }}`-templated locale messages, same `.JSON()` method for an API response. Nothing about the shape of your rules or your error handling changes. Only how validation runs changes: real function calls the compiler can see and inline, not a struct walked with `reflect` at runtime. -Benchmarked against the `CheckStruct` call it replaces, on the same struct and input: +## The numbers + +Benchmarked against the `CheckStruct` call it replaces, on the same struct and the same input: | Struct | `CheckStruct` | Generated | Speedup | | :--- | ---: | ---: | :---: | | 5-field signup form | 1830 ns/op, 1304 B/op, 24 allocs/op | 562 ns/op, 168 B/op, 7 allocs/op | **~3.3x faster, ~8x less memory** | | 25-field mixed checker coverage | 8200 ns/op, 4928 B/op, 59 allocs/op | 2730 ns/op, 760 B/op, 9 allocs/op | **~3.0x faster, ~6.5x less memory** | -## The interesting part: what building it found +That's not just lower latency — it's roughly a quarter of the garbage collector pressure per validated request, which matters more the higher your request rate climbs. + +## Using it with your framework -The core of `checkergen` is a table mapping each checker's tag name to a Go call expression — templating, mostly. That part came together fast. What actually surfaced real bugs was writing a *differential test*: for a batch of struct values, run both `CheckStruct` and the generated function against separate copies, and assert they report errors under the same keys and normalize the value identically. Once that harness existed, it started finding things. +Checker already ships thin adapter modules for [Gin](https://github.com/cinar/checker/tree/main/gin), [Echo](https://github.com/cinar/checker/tree/main/echo), [Fiber](https://github.com/cinar/checker/tree/main/fiber), and plain [`net/http`](https://github.com/cinar/checker/tree/main/nethttp). Each one's `Bind` does two things: decode the request body with the framework's own binder, then run `checker.CheckStruct` and write a `400` JSON response on failure. Swapping in generated code means keeping the framework's binder and swapping only that second step. -**1. A wrong field name in every non-English error message.** `eq-field:Password` compares a field against a sibling and reports which sibling failed: +**Gin**, before: ```go -type Registration struct { - Password string `checkers:"required"` - ConfirmPassword string `json:"confirm_password" checkers:"required eq-field:Password"` -} +router.POST("/signup", func(c *gin.Context) { + var req SignupRequest + if !checkergin.Bind(c, &req) { + return // 400 already written + } + c.JSON(http.StatusOK, req) +}) ``` -The first draft of the generated closure used the sibling's *json* tag name for that report — `field: "password"` — instead of the field name exactly as written in the tag, `field: "Password"`, which is what `CheckStruct` actually puts there. Nothing about this would fail a naive test that only checks *whether* an error occurred. It only shows up once you check *what the error says* — and a `{{ .field }}` placeholder in a translated locale message would have silently rendered the wrong name, forever, in every language but English. The differential test caught it because it compares generated output against `CheckStruct`'s field-for-field, not just pass/fail. - -**2. One bad tag could take down generation for an entire package.** `after-field:DateOnly` — missing the `:field` half of its parameter — panics at generate time, on purpose, the same way the equivalent *runtime* checker panics on the same malformed tag (a struct tag is closer to a typo than to untrusted input; failing loudly is the right call). The bug was that the panic wasn't recovered anywhere in `checkergen` itself, so it took the whole run down — every other, perfectly valid struct in the package failed to generate too, because of one unrelated typo. The fix mirrors a pattern this repo already uses in its CLI module: recover the panic per-struct, turn it into a skip with a diagnostic, and keep going. - -**3. The generator turned out to be more correct than the library it wraps.** While building fixtures to differential-test `eq`, `ne`, and `oneof`, I tried them on an `int` field: +**Gin**, with generated validation: ```go -type Coverage struct { - Priority int `checkers:"eq:1"` -} +router.POST("/signup", func(c *gin.Context) { + var req SignupRequest + if err := c.ShouldBind(&req); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } -checker.CheckStruct(&Coverage{Priority: 1}) // panic: string expected + if errs, ok := CheckSignupRequest(&req); !ok { + data, _ := errs.JSON() + c.Abort() + c.Data(http.StatusBadRequest, "application/json; charset=utf-8", data) + return + } + + c.JSON(http.StatusOK, req) +}) ``` -That's not a `checkergen` bug — it's a bug the differential test *exposed* in the core library. The exported `IsEq[T comparable]`, `IsNe[T comparable]`, and `IsOneOf[T comparable]` are fully generic. But the reflect-based functions the `eq`/`ne`/`oneof` *struct tags* actually run through — `checkEq`, `checkNe`, `checkOneOf` — hardcode a string conversion, so they only ever worked on string fields, despite the underlying checkers supporting any comparable type. `checkergen`'s generated code calls the generic functions directly, so it doesn't inherit this limitation — it validates strictly more than the tag path it's supposed to be a faster version of. That's documented as a known divergence in the `checkergen` README until the core library's reflect path catches up. +Same status codes, same error body shape, same `gin.Context`. The only line that changed is `checker.CheckStruct(&req)` becoming `CheckSignupRequest(&req)`. + +The same substitution drops into the other three exactly as directly — decode with the framework's own binder, then call the generated function instead of `checker.CheckStruct`: + +- **Echo**: `c.Bind(&req)` for decoding, `CheckSignupRequest(&req)` in place of `checker.CheckStruct(&req)`, write the failure with `c.JSONBlob(http.StatusBadRequest, data)`. +- **Fiber**: `c.Bind().Body(&req)` for decoding, same swap, write the failure with `c.Status(fiber.StatusBadRequest).Send(data)` after `c.Set("Content-Type", ...)`. +- **net/http**: `json.NewDecoder(r.Body).Decode(&req)` for decoding, same swap, write the failure with `w.WriteHeader(http.StatusBadRequest)` and `w.Write(data)`. -None of these three were found by writing more unit tests for the generator in isolation. They were found by making the generator's output *provably behave the same* as the thing it replaces, for real inputs — which is really just testing-in-depth applied to code that writes code. +You don't have to convert a whole API at once, either. `checkergen` and `CheckStruct` are meant to coexist in the same codebase — generate code for the handful of structs on your busiest endpoints, and leave everything else exactly as it is on `CheckStruct`. A struct outside `checkergen`'s current scope (a nested struct, a slice/map field, or a named type like `type Email string`) is skipped at generate time with a clear reason, not silently mishandled, so mixing the two in one project is the expected way to use it, not a fallback. ## Try it @@ -102,4 +122,8 @@ None of these three were found by writing more unit tests for the generator in i go get github.com/cinar/checker/v2/checkergen ``` -Full scope (which struct field types are eligible today, and the two other deliberate, documented divergences from `CheckStruct`) is in the [checkergen README](https://github.com/cinar/checker/tree/main/checkergen). It's a separate, independently versioned module — nothing about generating code for one struct adds a dependency to the core `checker` library the rest of your code imports. +```bash +go run github.com/cinar/checker/v2/checkergen/cmd/checkergen +``` + +Full scope and setup details are in the [checkergen README](https://github.com/cinar/checker/tree/main/checkergen). It's a separate, independently versioned module — generating code for one struct adds nothing to the dependency footprint of the core `checker` library the rest of your code already imports. From 24347e0f5064dbb81966cacc3aed2baeb49bd9b6 Mon Sep 17 00:00:00 2001 From: Onur Cinar Date: Sun, 6 Sep 2026 16:11:01 +0000 Subject: [PATCH 4/4] Mark the checkergen dev.to article as published with its canonical URL Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ARLam6G4Bu9afnWHpmJWeo --- articles/reflection-free-go-validation-with-checkergen.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/articles/reflection-free-go-validation-with-checkergen.md b/articles/reflection-free-go-validation-with-checkergen.md index e51b03c..07e4818 100644 --- a/articles/reflection-free-go-validation-with-checkergen.md +++ b/articles/reflection-free-go-validation-with-checkergen.md @@ -1,9 +1,9 @@ --- title: 3x Faster Struct Validation in Go — Same Rules, No Reflection -published: false +published: true description: checkergen turns Checker's struct tags into plain Go validation code — same rules, ~3x faster and 4-8x fewer allocations than the reflection-based path, with a one-line swap into your existing Gin, Echo, Fiber, or net/http handlers. tags: go, golang, performance, webdev -canonical_url: +canonical_url: https://dev.to/onurcinar/3x-faster-struct-validation-in-go-same-rules-no-reflection-43hf cover_image: ---