Skip to content

Commit b05e858

Browse files
committed
Remove WithStringer and enhance anonymous key debugging
BREAKING CHANGES: - Remove WithStringer option and StringerFunc type The WithStringer option was redundant since named keys already provide clear identification, and anonymous keys now include call site info. Enhancements: - Anonymous keys now include file path and line number in their name (e.g., "anonymous(/path/to/file.go:42)@0x...") - Add runtime.Caller integration with depth tracking for accurate call site resolution across New, NewBool, NewNamed, NewNamedBool Other changes: - Extract anonymous key tests to anonymous_test.go for stable line numbers - Update doc comments and README with new anonymous key format
1 parent b1c56c1 commit b05e858

5 files changed

Lines changed: 192 additions & 187 deletions

File tree

.golangci.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ linters:
1414
- interfacebloat
1515
# Disable depguard since this is a simple package without external dependencies
1616
- depguard
17+
# Allow if err := ...; err != nil {} blocks
18+
- noinlineerr
1719
settings:
1820
cyclop:
1921
max-complexity: 30

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,8 +261,13 @@ var (
261261
```go
262262
// Named keys make debugging easier
263263
var MyFeature = feature.NewNamedBool("my-feature")
264-
fmt.Println(MyFeature.String())
264+
fmt.Println(MyFeature)
265265
// Output: my-feature
266+
267+
// Anonymous keys automatically include call site information for debugging
268+
var AnonFeature = feature.NewBool()
269+
fmt.Println(AnonFeature)
270+
// Output: anonymous(/path/to/file.go:42)@0x14000010098
266271
```
267272

268273
### 3. Use Type-Safe Value Keys

anonymous_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package feature_test
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"github.com/mpyw/feature"
10+
)
11+
12+
// TestAnonymousKeyCallSite tests that anonymous keys capture the correct call site information.
13+
func TestAnonymousKeyCallSite(t *testing.T) {
14+
t.Parallel()
15+
16+
t.Run("New without name returns call site info", func(t *testing.T) {
17+
t.Parallel()
18+
19+
key := feature.New[int]()
20+
assertAnonymousKeyFormat(t, key.String(), "anonymous_test.go", 19)
21+
})
22+
23+
t.Run("NewBool returns call site info", func(t *testing.T) {
24+
t.Parallel()
25+
26+
key := feature.NewBool()
27+
assertAnonymousKeyFormat(t, key.String(), "anonymous_test.go", 26)
28+
})
29+
30+
t.Run("NewNamed with empty name returns call site info", func(t *testing.T) {
31+
t.Parallel()
32+
33+
key := feature.NewNamed[int]("")
34+
assertAnonymousKeyFormat(t, key.String(), "anonymous_test.go", 33)
35+
})
36+
37+
t.Run("NewNamedBool with empty name returns call site info", func(t *testing.T) {
38+
t.Parallel()
39+
40+
key := feature.NewNamedBool("")
41+
assertAnonymousKeyFormat(t, key.String(), "anonymous_test.go", 40)
42+
})
43+
}
44+
45+
// assertAnonymousKeyFormat verifies that an anonymous key string has the correct format
46+
// with the expected filename and line number.
47+
//
48+
//nolint:unparam // explicitly keeping wantFile and wantLine for clarity
49+
func assertAnonymousKeyFormat(t *testing.T, str, wantFile string, wantLine int) {
50+
t.Helper()
51+
52+
// Should have format "anonymous(/full/path/to/file.go:line)@0x..."
53+
if !strings.HasPrefix(str, "anonymous(") {
54+
t.Errorf("String() = %q, want prefix %q", str, "anonymous(")
55+
56+
return
57+
}
58+
59+
if !strings.Contains(str, "@0x") {
60+
t.Errorf("String() = %q, want to contain %q", str, "@0x")
61+
62+
return
63+
}
64+
65+
// Extract the file path and line number from the string
66+
// Format: anonymous(/path/to/feature_test.go:123)@0xaddress
67+
start := strings.Index(str, "(")
68+
69+
end := strings.LastIndex(str, ")")
70+
71+
if start == -1 || end == -1 || start >= end {
72+
t.Errorf("String() = %q, could not extract file:line info", str)
73+
74+
return
75+
}
76+
77+
fileLineInfo := str[start+1 : end]
78+
79+
lastColon := strings.LastIndex(fileLineInfo, ":")
80+
81+
if lastColon == -1 {
82+
t.Errorf("String() = %q, could not find colon in file:line info %q", str, fileLineInfo)
83+
84+
return
85+
}
86+
87+
filePath := fileLineInfo[:lastColon]
88+
lineStr := fileLineInfo[lastColon+1:]
89+
90+
// Verify filename
91+
baseName := filepath.Base(filePath)
92+
if baseName != wantFile {
93+
t.Errorf("filepath.Base(filePath) = %q, want %q (full path: %q)", baseName, wantFile, filePath)
94+
}
95+
96+
// Verify line number
97+
var gotLine int
98+
if _, err := fmt.Sscanf(lineStr, "%d", &gotLine); err != nil {
99+
t.Errorf("could not parse line number from %q: %v", lineStr, err)
100+
101+
return
102+
}
103+
104+
if gotLine != wantLine {
105+
t.Errorf("line number = %d, want %d (full string: %q)", gotLine, wantLine, str)
106+
}
107+
}

feature.go

Lines changed: 51 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,18 @@
2222
//
2323
// ctx = MyFeature.WithEnabled(ctx)
2424
//
25-
// # WithName Keys for Debugging
25+
// # Named Keys for Debugging
2626
//
2727
// You can create named keys to help with debugging:
2828
//
2929
// var MyFeature = feature.NewNamedBool("my-feature")
3030
// fmt.Println(MyFeature) // Output: my-feature
3131
//
32+
// Anonymous keys (without a name) automatically include call site information:
33+
//
34+
// var AnonFeature = feature.NewBool()
35+
// fmt.Println(AnonFeature) // Output: anonymous(/path/to/file.go:42)@0x14000010098
36+
//
3237
// # Value-Based Feature Flags
3338
//
3439
// You can also use feature flags with arbitrary types:
@@ -48,6 +53,7 @@ package feature
4853
import (
4954
"context"
5055
"fmt"
56+
"runtime"
5157
)
5258

5359
// Key is a type-safe accessor for feature flags stored in context.Context.
@@ -127,19 +133,15 @@ type BoolKey interface {
127133
WithDisabled(ctx context.Context) context.Context
128134
}
129135

130-
// StringerFunc is a function that formats a key name as a string.
131-
// It receives a resolved key name (never empty - anonymous keys are already
132-
// formatted as "anonymous@<address>") and returns the final string representation.
133-
// The default implementation returns the name as-is.
134-
type StringerFunc func(name string) string
135-
136136
// Option is a function that configures the behavior of a feature flag key.
137137
type Option func(*options)
138138

139139
// options configures the behavior of a feature flag key.
140140
type options struct {
141-
name string
142-
stringer StringerFunc
141+
name string
142+
143+
// internal use only - tracks the caller depth for name fallback
144+
depth int
143145
}
144146

145147
// WithName returns an option that sets a debug name for the key.
@@ -155,35 +157,19 @@ func WithName(name string) Option {
155157
}
156158
}
157159

158-
// WithStringer returns an option that sets a custom string formatter for the key name.
159-
// The formatter function receives a resolved key name (never empty) and returns
160-
// the final string representation.
161-
//
162-
// If not provided, the default formatter returns the name as-is.
163-
//
164-
// Example:
165-
//
166-
// customFormatter := func(name string) string {
167-
// return fmt.Sprintf("[%s]", name)
168-
// }
169-
// var MyKey = feature.New[int](feature.WithStringer(customFormatter))
170-
func WithStringer(f StringerFunc) Option {
171-
return func(o *options) {
172-
o.stringer = f
173-
}
174-
}
175-
176-
// defaultStringer is the default string formatter for keys.
177-
// It returns the name as-is (identity function).
178-
func defaultStringer(name string) string {
179-
return name
160+
// appendCallerDepthIncr appends an option that increments the caller depth for name fallback.
161+
// This is used internally to ensure correct caller depth when deriving names from call sites.
162+
func appendCallerDepthIncr(opts []Option) []Option {
163+
return append(opts, func(o *options) {
164+
o.depth++
165+
})
180166
}
181167

182168
// defaultOptions returns a new options with default values.
183169
func defaultOptions() *options {
184170
return &options{
185-
name: "",
186-
stringer: defaultStringer,
171+
name: "",
172+
depth: 0,
187173
}
188174
}
189175

@@ -197,6 +183,24 @@ func optionsFrom(opts []Option) *options {
197183
return o
198184
}
199185

186+
func computeKeyName(ident *opaque, name string, depth int) string {
187+
// Resolve the base name (handle anonymous keys)
188+
if name == "" {
189+
// depth is the number of stack frames added by wrapper functions.
190+
// Each exported function (New, NewBool, NewNamed, NewNamedBool) calls appendCallerDepthIncr.
191+
// The call stack is: runtime.Caller -> computeKeyName -> New -> [wrappers...] -> user code
192+
// Base offset is 1 (computeKeyName itself), plus depth for wrapper functions.
193+
_, file, line, ok := runtime.Caller(1 + depth)
194+
if ok {
195+
name = fmt.Sprintf("anonymous(%s:%d)@%p", file, line, ident)
196+
} else {
197+
name = fmt.Sprintf("anonymous@%p", ident)
198+
}
199+
}
200+
201+
return name
202+
}
203+
200204
// NewBool creates a new boolean feature flag key.
201205
//
202206
// Each call to NewBool creates a unique key based on pointer identity, preventing collisions.
@@ -212,6 +216,8 @@ func optionsFrom(opts []Option) *options {
212216
// }
213217
// }
214218
func NewBool(options ...Option) BoolKey {
219+
options = appendCallerDepthIncr(options)
220+
215221
return boolKey{key: New[bool](options...).downcast()}
216222
}
217223

@@ -225,6 +231,8 @@ func NewBool(options ...Option) BoolKey {
225231
// var EnableNewUI = feature.NewNamedBool("new-ui")
226232
// fmt.Println(EnableNewUI) // Output: new-ui
227233
func NewNamedBool(name string, options ...Option) BoolKey {
234+
options = appendCallerDepthIncr(options)
235+
228236
return NewBool(append([]Option{WithName(name)}, options...)...)
229237
}
230238

@@ -239,12 +247,13 @@ func NewNamedBool(name string, options ...Option) BoolKey {
239247
// ctx = MaxRetries.WithValue(ctx, 5)
240248
// retries := MaxRetries.Get(ctx) // Returns 5
241249
func New[V any](options ...Option) Key[V] {
250+
options = appendCallerDepthIncr(options)
242251
opts := optionsFrom(options)
252+
ident := new(opaque)
243253

244254
return key[V]{
245-
name: opts.name,
246-
stringer: opts.stringer,
247-
ident: new(opaque),
255+
name: computeKeyName(ident, opts.name, opts.depth),
256+
ident: ident,
248257
}
249258
}
250259

@@ -258,38 +267,25 @@ func New[V any](options ...Option) Key[V] {
258267
// var MaxRetries = feature.NewNamed[int]("max-retries")
259268
// fmt.Println(MaxRetries) // Output: max-retries
260269
func NewNamed[V any](name string, options ...Option) Key[V] {
270+
options = appendCallerDepthIncr(options)
271+
261272
return New[V](append([]Option{WithName(name)}, options...)...)
262273
}
263274

264275
// key is the internal implementation of Key[V].
265276
type key[V any] struct {
266-
name string
267-
stringer StringerFunc
268-
ident *opaque
277+
name string
278+
ident *opaque
269279
}
270280

271281
// boolKey is the internal implementation of BoolKey.
272282
type boolKey struct {
273283
key[bool]
274284
}
275285

276-
// String returns a string representation of the key name.
277-
// The format can be customized via the WithStringer option.
278-
// By default, it returns the debug name if provided, or "anonymous@<address>" otherwise.
286+
// String returns the debug name of the key.
279287
func (k key[V]) String() string {
280-
// Resolve the base name (handle anonymous keys)
281-
name := k.name
282-
if name == "" {
283-
name = fmt.Sprintf("anonymous@%p", k.ident)
284-
}
285-
286-
// Apply custom stringer if provided
287-
stringer := k.stringer
288-
if stringer == nil {
289-
stringer = defaultStringer
290-
}
291-
292-
return stringer(name)
288+
return k.name
293289
}
294290

295291
// DebugValue returns a string representation combining the key name and its value from the context.

0 commit comments

Comments
 (0)