From ef1175d00cae1ab8c1c583eef197fa8fdbe7bb62 Mon Sep 17 00:00:00 2001 From: djmil Date: Wed, 29 Jul 2026 18:53:22 +0000 Subject: [PATCH] asserts --- .vscode/settings.json | 2 +- Makefile | 18 ++++++++- pkg/assert/assert.go | 25 +++++++++++++ pkg/assert/assert_disabled.go | 11 ++++++ pkg/assert/assert_disabled_test.go | 19 ++++++++++ pkg/assert/assert_test.go | 59 ++++++++++++++++++++++++++++++ pkg/assert/doc.go | 18 +++++++++ 7 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 pkg/assert/assert.go create mode 100644 pkg/assert/assert_disabled.go create mode 100644 pkg/assert/assert_disabled_test.go create mode 100644 pkg/assert/assert_test.go create mode 100644 pkg/assert/doc.go diff --git a/.vscode/settings.json b/.vscode/settings.json index 1fa7aa0..3ecb4f2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,7 +3,7 @@ "go.lintTool": "golangci-lint", "go.lintFlags": ["--fast"], "go.lintOnSave": "workspace", - "go.testFlags": ["-race"], + "go.testFlags": ["-race", "-v"], "go.coverOnSave": false, // ── Editor ───────────────────────────────────────────────────────────────── diff --git a/Makefile b/Makefile index 3bf5b0c..37feeb1 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,22 @@ LDFLAGS := -ldflags "\ CMDS := $(shell find cmd -mindepth 1 -maxdepth 1 -type d 2>/dev/null) BINS := $(patsubst cmd/%,bin/%,$(CMDS)) +# ── Build tags ───────────────────────────────────────────────────────────────── +# Pass TAGS= to select optional build variants for the binary: +# make build TAGS=assert_disable # disable assertions (production) +# make build TAGS=result_goexit # Goexit-based result exits +# make build TAGS=assert_disable,result_goexit +# +# Available tags: +# assert_disable pkg/assert: compile out all assertion checks (zero cost) +# result_goexit pkg/result: use runtime.Goexit instead of panic for exits +# +# Tests always run with the default tags (assertions on, panic exits). +# The only reason to pass TAGS to a test target is profiling, where assertion +# overhead must be excluded from the measurement. +TAGS ?= +TAGS_FLAG = $(if $(TAGS),-tags $(TAGS)) + # ── Default target ───────────────────────────────────────────────────────────── help: ## Show this help message @grep -E '^[a-zA-Z_-]+:.*?## .*$$' Makefile | \ @@ -46,7 +62,7 @@ build: $(BINS) ## Compile all cmd/* binaries to ./bin/ (stamped with version, co bin/%: cmd/% @mkdir -p bin - go build $(LDFLAGS) -o $@ ./$< + go build $(TAGS_FLAG) $(LDFLAGS) -o $@ ./$< # ── Test ─────────────────────────────────────────────────────────────────────── test: ## Run all tests diff --git a/pkg/assert/assert.go b/pkg/assert/assert.go new file mode 100644 index 0000000..5a32858 --- /dev/null +++ b/pkg/assert/assert.go @@ -0,0 +1,25 @@ +//go:build !assert_disable + +package assert + +import ( + "fmt" + "runtime" +) + +// That panics if condition is false, reporting msg as the violated invariant. +func That(condition bool, msg string) { + if !condition { + _, file, line, _ := runtime.Caller(1) + panic(fmt.Sprintf("assertion violated at %s:%d: %s", file, line, msg)) + } +} + +// Thatf panics if condition is false, reporting a formatted string as the +// violated invariant. Arguments are formatted as in [fmt.Sprintf]. +func Thatf(condition bool, format string, args ...any) { + if !condition { + _, file, line, _ := runtime.Caller(1) + panic(fmt.Sprintf("assertion violated at %s:%d: %s", file, line, fmt.Sprintf(format, args...))) + } +} diff --git a/pkg/assert/assert_disabled.go b/pkg/assert/assert_disabled.go new file mode 100644 index 0000000..68091d4 --- /dev/null +++ b/pkg/assert/assert_disabled.go @@ -0,0 +1,11 @@ +//go:build assert_disable + +package assert + +// That is a no-op in assert_disable builds; the compiler eliminates the call +// and its condition entirely. +func That(_ bool, _ string) {} + +// Thatf is a no-op in assert_disable builds; the compiler eliminates the call +// and its condition entirely. +func Thatf(_ bool, _ string, _ ...any) {} diff --git a/pkg/assert/assert_disabled_test.go b/pkg/assert/assert_disabled_test.go new file mode 100644 index 0000000..1068c66 --- /dev/null +++ b/pkg/assert/assert_disabled_test.go @@ -0,0 +1,19 @@ +//go:build assert_disable + +package assert_test + +import ( + "testing" + + "gitea.djmil.dev/go/template/pkg/assert" +) + +// In a disabled build, That and Thatf must not panic regardless of condition. + +func TestThat_disabled(t *testing.T) { + assert.That(false, "must be silently ignored") +} + +func TestThatf_disabled(t *testing.T) { + assert.Thatf(false, "must be silently ignored: %d", 42) +} diff --git a/pkg/assert/assert_test.go b/pkg/assert/assert_test.go new file mode 100644 index 0000000..9ce3d62 --- /dev/null +++ b/pkg/assert/assert_test.go @@ -0,0 +1,59 @@ +//go:build !assert_disable + +package assert_test + +import ( + "strings" + "testing" + + "gitea.djmil.dev/go/template/pkg/assert" +) + +func TestThat_passes(t *testing.T) { + assert.That(true, "should not panic") +} + +func TestThat_panics(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic, got none") + } + msg, ok := r.(string) + if !ok { + t.Fatalf("expected string panic value, got %T", r) + } + if !strings.Contains(msg, "ring must be non-empty") { + t.Errorf("panic message %q does not contain invariant text", msg) + } + if !strings.Contains(msg, "assert_test.go") { + t.Errorf("panic message %q does not contain caller file", msg) + } + }() + assert.That(false, "ring must be non-empty") +} + +func TestThatf_passes(t *testing.T) { + n := 1 + assert.Thatf(n > 0, "should not panic: %d", n) +} + +func TestThatf_panics(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic, got none") + } + msg, ok := r.(string) + if !ok { + t.Fatalf("expected string panic value, got %T", r) + } + if !strings.Contains(msg, "len=5 cap=3") { + t.Errorf("panic message %q does not contain formatted args", msg) + } + if !strings.Contains(msg, "assert_test.go") { + t.Errorf("panic message %q does not contain caller file", msg) + } + }() + assert.Thatf(false, "buffer overflowed: len=%d cap=%d", 5, 3) +} diff --git a/pkg/assert/doc.go b/pkg/assert/doc.go new file mode 100644 index 0000000..561a857 --- /dev/null +++ b/pkg/assert/doc.go @@ -0,0 +1,18 @@ +// Package assert provides compile-time-removable invariant checks. +// +// Use assert to document programmer contracts — preconditions, postconditions, +// and internal invariants that should be logically impossible to violate if the +// code is correct. A triggered assertion is always a bug, never a user error. +// +// Assertions are enabled by default (dev/debug builds). Disable them in +// production builds by passing -tags assert_disable to the Go toolchain: +// +// go build -tags assert_disable ./... +// +// In a disabled build both [That] and [Thatf] are compiled out entirely — no +// condition is evaluated and no allocation occurs. +// +// Contrast with [gitea.djmil.dev/go/template/pkg/result], which handles runtime +// correctness (I/O, parsing, external inputs). Use result for things that can +// legitimately fail; use assert for things that must never fail. +package assert