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/articles/reflection-free-go-validation-with-checkergen.md b/articles/reflection-free-go-validation-with-checkergen.md new file mode 100644 index 0000000..07e4818 --- /dev/null +++ b/articles/reflection-free-go-validation-with-checkergen.md @@ -0,0 +1,129 @@ +--- +title: 3x Faster Struct Validation in Go — Same Rules, No Reflection +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: https://dev.to/onurcinar/3x-faster-struct-validation-in-go-same-rules-no-reflection-43hf +cover_image: +--- + +[Checker](https://github.com/cinar/checker) validates a Go struct from its 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, 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. + +[`checkergen`](https://github.com/cinar/checker/tree/main/checkergen) removes that cost entirely, without changing a single validation rule. + +## What it generates + +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 +``` + +```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 +} +``` + +`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. + +## 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** | + +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 + +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. + +**Gin**, before: + +```go +router.POST("/signup", func(c *gin.Context) { + var req SignupRequest + if !checkergin.Bind(c, &req) { + return // 400 already written + } + c.JSON(http.StatusOK, req) +}) +``` + +**Gin**, with generated validation: + +```go +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 + } + + 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) +}) +``` + +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)`. + +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 + +```bash +go get github.com/cinar/checker/v2/checkergen +``` + +```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. diff --git a/checkergen/README.md b/checkergen/README.md new file mode 100644 index 0000000..5581d11 --- /dev/null +++ b/checkergen/README.md @@ -0,0 +1,115 @@ +# 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. + +## 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. 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 ./...