diff --git a/.golangci.yaml b/.golangci.yaml index 67f9cf0..baf408f 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -14,6 +14,8 @@ linters: - interfacebloat # Disable depguard since this is a simple package without external dependencies - depguard + # Allow if err := ...; err != nil {} blocks + - noinlineerr settings: cyclop: max-complexity: 30 diff --git a/README.md b/README.md index ccb985f..10e08ca 100644 --- a/README.md +++ b/README.md @@ -261,8 +261,13 @@ var ( ```go // Named keys make debugging easier var MyFeature = feature.NewNamedBool("my-feature") -fmt.Println(MyFeature.String()) +fmt.Println(MyFeature) // Output: my-feature + +// Anonymous keys automatically include call site information for debugging +var AnonFeature = feature.NewBool() +fmt.Println(AnonFeature) +// Output: anonymous(/path/to/file.go:42)@0x14000010098 ``` ### 3. Use Type-Safe Value Keys diff --git a/anonymous_test.go b/anonymous_test.go new file mode 100644 index 0000000..589b8dd --- /dev/null +++ b/anonymous_test.go @@ -0,0 +1,107 @@ +package feature_test + +import ( + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/mpyw/feature" +) + +// TestAnonymousKeyCallSite tests that anonymous keys capture the correct call site information. +func TestAnonymousKeyCallSite(t *testing.T) { + t.Parallel() + + t.Run("New without name returns call site info", func(t *testing.T) { + t.Parallel() + + key := feature.New[int]() + assertAnonymousKeyFormat(t, key.String(), "anonymous_test.go", 19) + }) + + t.Run("NewBool returns call site info", func(t *testing.T) { + t.Parallel() + + key := feature.NewBool() + assertAnonymousKeyFormat(t, key.String(), "anonymous_test.go", 26) + }) + + t.Run("NewNamed with empty name returns call site info", func(t *testing.T) { + t.Parallel() + + key := feature.NewNamed[int]("") + assertAnonymousKeyFormat(t, key.String(), "anonymous_test.go", 33) + }) + + t.Run("NewNamedBool with empty name returns call site info", func(t *testing.T) { + t.Parallel() + + key := feature.NewNamedBool("") + assertAnonymousKeyFormat(t, key.String(), "anonymous_test.go", 40) + }) +} + +// assertAnonymousKeyFormat verifies that an anonymous key string has the correct format +// with the expected filename and line number. +// +//nolint:unparam // explicitly keeping wantFile and wantLine for clarity +func assertAnonymousKeyFormat(t *testing.T, str, wantFile string, wantLine int) { + t.Helper() + + // Should have format "anonymous(/full/path/to/file.go:line)@0x..." + if !strings.HasPrefix(str, "anonymous(") { + t.Errorf("String() = %q, want prefix %q", str, "anonymous(") + + return + } + + if !strings.Contains(str, "@0x") { + t.Errorf("String() = %q, want to contain %q", str, "@0x") + + return + } + + // Extract the file path and line number from the string + // Format: anonymous(/path/to/feature_test.go:123)@0xaddress + start := strings.Index(str, "(") + + end := strings.LastIndex(str, ")") + + if start == -1 || end == -1 || start >= end { + t.Errorf("String() = %q, could not extract file:line info", str) + + return + } + + fileLineInfo := str[start+1 : end] + + lastColon := strings.LastIndex(fileLineInfo, ":") + + if lastColon == -1 { + t.Errorf("String() = %q, could not find colon in file:line info %q", str, fileLineInfo) + + return + } + + filePath := fileLineInfo[:lastColon] + lineStr := fileLineInfo[lastColon+1:] + + // Verify filename + baseName := filepath.Base(filePath) + if baseName != wantFile { + t.Errorf("filepath.Base(filePath) = %q, want %q (full path: %q)", baseName, wantFile, filePath) + } + + // Verify line number + var gotLine int + if _, err := fmt.Sscanf(lineStr, "%d", &gotLine); err != nil { + t.Errorf("could not parse line number from %q: %v", lineStr, err) + + return + } + + if gotLine != wantLine { + t.Errorf("line number = %d, want %d (full string: %q)", gotLine, wantLine, str) + } +} diff --git a/feature.go b/feature.go index 985e7fd..c4c102d 100644 --- a/feature.go +++ b/feature.go @@ -22,13 +22,18 @@ // // ctx = MyFeature.WithEnabled(ctx) // -// # WithName Keys for Debugging +// # Named Keys for Debugging // // You can create named keys to help with debugging: // // var MyFeature = feature.NewNamedBool("my-feature") // fmt.Println(MyFeature) // Output: my-feature // +// Anonymous keys (without a name) automatically include call site information: +// +// var AnonFeature = feature.NewBool() +// fmt.Println(AnonFeature) // Output: anonymous(/path/to/file.go:42)@0x14000010098 +// // # Value-Based Feature Flags // // You can also use feature flags with arbitrary types: @@ -48,6 +53,7 @@ package feature import ( "context" "fmt" + "runtime" ) // Key is a type-safe accessor for feature flags stored in context.Context. @@ -127,19 +133,15 @@ type BoolKey interface { WithDisabled(ctx context.Context) context.Context } -// StringerFunc is a function that formats a key name as a string. -// It receives a resolved key name (never empty - anonymous keys are already -// formatted as "anonymous@
") and returns the final string representation. -// The default implementation returns the name as-is. -type StringerFunc func(name string) string - // Option is a function that configures the behavior of a feature flag key. type Option func(*options) // options configures the behavior of a feature flag key. type options struct { - name string - stringer StringerFunc + name string + + // internal use only - tracks the caller depth for name fallback + depth int } // WithName returns an option that sets a debug name for the key. @@ -155,35 +157,19 @@ func WithName(name string) Option { } } -// WithStringer returns an option that sets a custom string formatter for the key name. -// The formatter function receives a resolved key name (never empty) and returns -// the final string representation. -// -// If not provided, the default formatter returns the name as-is. -// -// Example: -// -// customFormatter := func(name string) string { -// return fmt.Sprintf("[%s]", name) -// } -// var MyKey = feature.New[int](feature.WithStringer(customFormatter)) -func WithStringer(f StringerFunc) Option { - return func(o *options) { - o.stringer = f - } -} - -// defaultStringer is the default string formatter for keys. -// It returns the name as-is (identity function). -func defaultStringer(name string) string { - return name +// appendCallerDepthIncr appends an option that increments the caller depth for name fallback. +// This is used internally to ensure correct caller depth when deriving names from call sites. +func appendCallerDepthIncr(opts []Option) []Option { + return append(opts, func(o *options) { + o.depth++ + }) } // defaultOptions returns a new options with default values. func defaultOptions() *options { return &options{ - name: "", - stringer: defaultStringer, + name: "", + depth: 0, } } @@ -197,6 +183,24 @@ func optionsFrom(opts []Option) *options { return o } +func computeKeyName(ident *opaque, name string, depth int) string { + // Resolve the base name (handle anonymous keys) + if name == "" { + // Default fallback + name = fmt.Sprintf("anonymous@%p", ident) + // Enhance with call site info if available. + // depth is the number of stack frames added by wrapper functions. + // Each exported function (New, NewBool, NewNamed, NewNamedBool) calls appendCallerDepthIncr. + // The call stack is: runtime.Caller -> computeKeyName -> New -> [wrappers...] -> user code + // Base offset is 1 (computeKeyName itself), plus depth for wrapper functions. + if _, file, line, ok := runtime.Caller(1 + depth); ok { + name = fmt.Sprintf("anonymous(%s:%d)@%p", file, line, ident) + } + } + + return name +} + // NewBool creates a new boolean feature flag key. // // Each call to NewBool creates a unique key based on pointer identity, preventing collisions. @@ -212,6 +216,8 @@ func optionsFrom(opts []Option) *options { // } // } func NewBool(options ...Option) BoolKey { + options = appendCallerDepthIncr(options) + return boolKey{key: New[bool](options...).downcast()} } @@ -225,6 +231,8 @@ func NewBool(options ...Option) BoolKey { // var EnableNewUI = feature.NewNamedBool("new-ui") // fmt.Println(EnableNewUI) // Output: new-ui func NewNamedBool(name string, options ...Option) BoolKey { + options = appendCallerDepthIncr(options) + return NewBool(append([]Option{WithName(name)}, options...)...) } @@ -239,12 +247,13 @@ func NewNamedBool(name string, options ...Option) BoolKey { // ctx = MaxRetries.WithValue(ctx, 5) // retries := MaxRetries.Get(ctx) // Returns 5 func New[V any](options ...Option) Key[V] { + options = appendCallerDepthIncr(options) opts := optionsFrom(options) + ident := new(opaque) return key[V]{ - name: opts.name, - stringer: opts.stringer, - ident: new(opaque), + name: computeKeyName(ident, opts.name, opts.depth), + ident: ident, } } @@ -258,14 +267,15 @@ func New[V any](options ...Option) Key[V] { // var MaxRetries = feature.NewNamed[int]("max-retries") // fmt.Println(MaxRetries) // Output: max-retries func NewNamed[V any](name string, options ...Option) Key[V] { + options = appendCallerDepthIncr(options) + return New[V](append([]Option{WithName(name)}, options...)...) } // key is the internal implementation of Key[V]. type key[V any] struct { - name string - stringer StringerFunc - ident *opaque + name string + ident *opaque } // boolKey is the internal implementation of BoolKey. @@ -273,23 +283,9 @@ type boolKey struct { key[bool] } -// String returns a string representation of the key name. -// The format can be customized via the WithStringer option. -// By default, it returns the debug name if provided, or "anonymous@" otherwise. +// String returns the debug name of the key. func (k key[V]) String() string { - // Resolve the base name (handle anonymous keys) - name := k.name - if name == "" { - name = fmt.Sprintf("anonymous@%p", k.ident) - } - - // Apply custom stringer if provided - stringer := k.stringer - if stringer == nil { - stringer = defaultStringer - } - - return stringer(name) + return k.name } // DebugValue returns a string representation combining the key name and its value from the context. diff --git a/feature_test.go b/feature_test.go index 831a245..2f1db4e 100644 --- a/feature_test.go +++ b/feature_test.go @@ -458,39 +458,19 @@ func TestBoolKey(t *testing.T) { func TestString(t *testing.T) { t.Parallel() - t.Run("named key returns the name", func(t *testing.T) { + // Named key tests + + t.Run("New with WithName returns the name", func(t *testing.T) { t.Parallel() - key := feature.NewNamed[string]("test-key") + key := feature.New[string](feature.WithName("test-key")) if got := key.String(); got != "test-key" { t.Errorf("String() = %q, want %q", got, "test-key") } }) - t.Run("anonymous key returns address-based name", func(t *testing.T) { - t.Parallel() - - key := feature.New[int]() - str := key.String() - - // Should start with "anonymous@0x" - if !strings.HasPrefix(str, "anonymous@0x") { - t.Errorf("String() = %q, want prefix %q", str, "anonymous@0x") - } - }) - - t.Run("NewNamedBool creates named bool key", func(t *testing.T) { - t.Parallel() - - flag := feature.NewNamedBool("my-feature") - - if got := flag.String(); got != "my-feature" { - t.Errorf("String() = %q, want %q", got, "my-feature") - } - }) - - t.Run("NewNamed creates named value key", func(t *testing.T) { + t.Run("NewNamed returns the name", func(t *testing.T) { t.Parallel() key := feature.NewNamed[int]("max-retries") @@ -500,61 +480,13 @@ func TestString(t *testing.T) { } }) - t.Run("custom stringer can be provided", func(t *testing.T) { - t.Parallel() - - customFormat := func(name string) string { - return fmt.Sprintf("[%s]", name) - } - - key := feature.New[int]( - feature.WithName("test"), - feature.WithStringer(customFormat), - ) - - want := "[test]" - if got := key.String(); got != want { - t.Errorf("String() = %q, want %q", got, want) - } - }) - - t.Run("custom stringer works with anonymous keys", func(t *testing.T) { - t.Parallel() - - customFormat := func(name string) string { - return "custom:" + name - } - key := feature.New[int](feature.WithStringer(customFormat)) - str := key.String() - - // Should have custom prefix and contain anonymous@0x - if !strings.HasPrefix(str, "custom:anonymous@0x") { - t.Errorf("String() = %q, want prefix %q", str, "custom:anonymous@0x") - } - }) - - t.Run("nil stringer falls back to default behavior", func(t *testing.T) { + t.Run("NewNamedBool returns the name", func(t *testing.T) { t.Parallel() - key := feature.New[int]( - feature.WithName("nil-stringer-key"), - feature.WithStringer(nil), - ) - - if got := key.String(); got != "nil-stringer-key" { - t.Errorf("String() = %q, want %q", got, "nil-stringer-key") - } - }) - - t.Run("nil stringer with anonymous key", func(t *testing.T) { - t.Parallel() - - key := feature.New[int](feature.WithStringer(nil)) - str := key.String() + flag := feature.NewNamedBool("my-feature") - // Should still produce anonymous@0x format - if !strings.HasPrefix(str, "anonymous@0x") { - t.Errorf("String() = %q, want prefix %q", str, "anonymous@0x") + if got := flag.String(); got != "my-feature" { + t.Errorf("String() = %q, want %q", got, "my-feature") } }) } @@ -633,7 +565,7 @@ func TestDebugValue(t *testing.T) { } }) - t.Run("anonymous key shows address in name", func(t *testing.T) { + t.Run("anonymous key shows call site info in name", func(t *testing.T) { t.Parallel() ctx := context.Background() @@ -641,9 +573,13 @@ func TestDebugValue(t *testing.T) { ctx = key.WithValue(ctx, "value") debugValue := key.DebugValue(ctx) - // Should contain "anonymous@0x" and ": value" - if !strings.Contains(debugValue, "anonymous@0x") { - t.Errorf("DebugValue() = %q, want to contain %q", debugValue, "anonymous@0x") + // Should contain "anonymous(" (call site info) and "@0x" (address) and ": value" + if !strings.Contains(debugValue, "anonymous(") { + t.Errorf("DebugValue() = %q, want to contain %q", debugValue, "anonymous(") + } + + if !strings.Contains(debugValue, "@0x") { + t.Errorf("DebugValue() = %q, want to contain %q", debugValue, "@0x") } if !strings.Contains(debugValue, ": value") { @@ -689,20 +625,6 @@ func TestNewNamed(t *testing.T) { } }) - t.Run("accepts additional options", func(t *testing.T) { - t.Parallel() - - customStringer := feature.WithStringer(func(name string) string { - return "custom:" + name - }) - key := feature.NewNamed[int]("named-key", customStringer) - want := "custom:named-key" - - if got := key.String(); got != want { - t.Errorf("String() = %q, want %q", got, want) - } - }) - t.Run("is equivalent to New with WithName", func(t *testing.T) { t.Parallel() @@ -859,7 +781,7 @@ func ExampleNewNamedBool() { // Create a named boolean feature flag for easier debugging var EnableBetaFeature = feature.NewNamedBool("beta-feature") - fmt.Println(EnableBetaFeature.String()) + fmt.Println(EnableBetaFeature) // Output: // beta-feature @@ -892,7 +814,7 @@ func ExampleNewNamed() { ctx = MaxRetries.WithValue(ctx, 3) // Retrieve the value and name - fmt.Printf("%s: %d\n", MaxRetries.String(), MaxRetries.Get(ctx)) + fmt.Printf("%s: %d\n", MaxRetries, MaxRetries.Get(ctx)) // Output: // max-retries: 3 @@ -906,29 +828,12 @@ func ExampleWithName() { feature.WithName("api-key"), ) - fmt.Println(APIKey.String()) + fmt.Println(APIKey) // Output: // api-key } -func ExampleWithStringer() { - // Use custom formatter for key names - customFormatter := func(name string) string { - return fmt.Sprintf("[FEATURE:%s]", name) - } - - var FeatureFlag = feature.New[bool]( - feature.WithName("experimental"), - feature.WithStringer(customFormatter), - ) - - fmt.Println(FeatureFlag.String()) - - // Output: - // [FEATURE:experimental] -} - // Key[V] Method Examples func ExampleKey_WithValue() { @@ -1094,29 +999,19 @@ func ExampleKey_DebugValue() { func ExampleKey_String() { // WithName keys show their name namedKey := feature.NewNamed[string]("api-key") - fmt.Println(namedKey.String()) + fmt.Println(namedKey) - // Anonymous keys show their address + // Anonymous keys show their call site info and address anonymousKey := feature.New[int]() str := anonymousKey.String() - // Check that it starts with "anonymous@" - if len(str) > 10 && str[:10] == "anonymous@" { - fmt.Println("Anonymous key format: anonymous@") + // Check that it starts with "anonymous(" (call site info is included) + if strings.HasPrefix(str, "anonymous(") && strings.Contains(str, "@0x") { + fmt.Println("Anonymous key format: anonymous(file:line)@") } - // Custom formatter - customKey := feature.New[int]( - feature.WithName("max-retries"), - feature.WithStringer(func(name string) string { - return fmt.Sprintf("[%s]", name) - }), - ) - fmt.Println(customKey.String()) - // Output: // api-key - // Anonymous key format: anonymous@ - // [max-retries] + // Anonymous key format: anonymous(file:line)@ } // BoolKey Method Examples