From e07c3e8db8412c22a512bb2ddd9ec299588e0177 Mon Sep 17 00:00:00 2001 From: Umputun Date: Sat, 29 Nov 2025 15:47:35 -0600 Subject: [PATCH] add alias support for enum parsing allows enum constants to have comment annotations like // enum:alias=rw,read-write that enable parsing multiple string representations for the same enum value. - add parseAliasComment() to extract aliases from inline comments - add validateAliases() to check for conflicts and duplicates - update template to include aliases in parse map - make parsing always case-insensitive (fixes lowercase mode bug) - add comprehensive tests for alias feature - update README with alias documentation --- README.md | 46 +++- internal/generator/enum.go.tmpl | 20 +- internal/generator/generator.go | 99 ++++++++- internal/generator/generator_test.go | 201 +++++++++++++++++- internal/generator/integration_test.go | 59 ++++- .../testdata/integration/permission.go | 10 + .../testdata/integration/priority_enum.go | 3 +- .../testdata/integration/status_enum.go | 8 +- 8 files changed, 412 insertions(+), 34 deletions(-) create mode 100644 internal/generator/testdata/integration/permission.go diff --git a/README.md b/README.md index e3bb58d..99b109a 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - Text marshaling/unmarshaling (JSON works via TextMarshaler) - Optional SQL, BSON (MongoDB), and YAML support via flags - Case-sensitive or case-insensitive string representations +- Alias support for parsing multiple string representations - Panic-free parsing with error handling - Must-style parsing variants for convenience - Declaration order preservation (enums maintain source code order, not alphabetical) @@ -163,16 +164,57 @@ _ = coll.FindOne(ctx, bson.M{"status": "active"}).Decode(&out) // decodes via Un ### Case Sensitivity -By default, the generator creates case-sensitive string representations. Use `-lower` flag for lowercase output: +The `-lower` flag controls the output format of `String()` method: ```go -// default (case-sensitive) +// default (without -lower) StatusActive.String() // returns "Active" // with -lower flag StatusActive.String() // returns "active" ``` +**Parsing is always case-insensitive** regardless of the `-lower` flag: + +```go +// all of these work, with or without -lower flag +s1, _ := ParseStatus("active") // works +s2, _ := ParseStatus("Active") // works +s3, _ := ParseStatus("ACTIVE") // works +``` + +### Parsing Aliases + +You can define alternative string representations for enum values using inline comments with the `enum:alias=` directive. This is useful when you need to accept multiple input formats for the same value: + +```go +type permission int + +const ( + permissionNone permission = iota // enum:alias=n + permissionRead // enum:alias=r + permissionWrite // enum:alias=w + permissionReadWrite // enum:alias=rw,read-write +) +``` + +With aliases defined, parsing accepts both canonical names and aliases: + +```go +// all of these parse to PermissionReadWrite +p1, _ := ParsePermission("ReadWrite") // canonical name +p2, _ := ParsePermission("rw") // alias +p3, _ := ParsePermission("RW") // alias (case-insensitive) +p4, _ := ParsePermission("read-write") // alias +``` + +Alias rules: +- Multiple aliases are separated by commas: `// enum:alias=rw,read-write` +- Parsing is always case-insensitive +- An alias cannot conflict with another constant's canonical name +- Duplicate aliases across different constants cause generation to fail +- The `String()` method always returns the canonical name, not aliases + ### Getter Generation The `-getter` flag enables the generation of an additional function, `Get{{Type}}ByID`, which attempts to find the corresponding enum element by its underlying integer ID. If no matching element is found, an error is returned. diff --git a/internal/generator/enum.go.tmpl b/internal/generator/enum.go.tmpl index 3b2d4fa..f1c40c1 100644 --- a/internal/generator/enum.go.tmpl +++ b/internal/generator/enum.go.tmpl @@ -14,9 +14,7 @@ import ( {{- if .GenerateYAML }} "gopkg.in/yaml.v3" {{- end}} - {{- if .LowerCase | not }} "strings" - {{- end}} ) // {{.Type | title}} is the exported type for the enum @@ -124,22 +122,22 @@ func (e *{{.Type | title}}) UnmarshalYAML(value *yaml.Node) error { // _{{.Type}}ParseMap is used for efficient string to enum conversion var _{{.Type}}ParseMap = map[string]{{.Type | title}}{ -{{range .Values -}} - "{{.Name | ToLower}}": {{.PublicName}}, +{{range $v := .Values -}} + "{{$v.Name | ToLower}}": {{$v.PublicName}}, +{{- range $alias := $v.Aliases}} +{{- if ne ($alias | ToLower) ($v.Name | ToLower)}} + "{{$alias | ToLower}}": {{$v.PublicName}}, +{{- end}} +{{- end}} {{end}} } -// Parse{{.Type | title}} converts string to {{.Type}} enum value +// Parse{{.Type | title}} converts string to {{.Type}} enum value. +// Parsing is always case-insensitive. func Parse{{.Type | title}}(v string) ({{.Type | title}}, error) { -{{if .LowerCase}} - if val, ok := _{{.Type}}ParseMap[v]; ok { - return val, nil - } -{{else}} if val, ok := _{{.Type}}ParseMap[strings.ToLower(v)]; ok { return val, nil } -{{end}} return {{.Type | title}}{}, fmt.Errorf("invalid {{.Type}}: %s", v) } diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 3218293..91dfdd9 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -42,8 +42,9 @@ type Generator struct { // constValue holds metadata about a const during parsing type constValue struct { - value int // the numeric value - pos token.Pos // source position for ordering + value int // the numeric value + pos token.Pos // source position for ordering + aliases []string // aliases from comment annotation } // constExprType represents the type of constant expression @@ -73,10 +74,11 @@ type constParseState struct { // Value represents a single enum value type Value struct { - PrivateName string // e.g., "statusActive" - PublicName string // e.g., "StatusActive" - Name string // e.g., "Active" - Index int // enum index value + PrivateName string // e.g., "statusActive" + PublicName string // e.g., "StatusActive" + Name string // e.g., "Active" + Index int // enum index value + Aliases []string // e.g., ["rw", "read-write"] from // enum:alias=rw,read-write } // New creates a new Generator instance @@ -120,7 +122,7 @@ func (g *Generator) SetGenerateYAML(v bool) { g.generateYAML = v } // the const name and its iota value, for example: {"statusActive": 1, "statusInactive": 2} func (g *Generator) Parse(dir string) error { fset := token.NewFileSet() - pkgs, err := parser.ParseDir(fset, dir, nil, 0) + pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments) if err != nil { return fmt.Errorf("failed to parse directory: %w", err) } @@ -181,6 +183,9 @@ func (g *Generator) parseConstBlock(decl *ast.GenDecl) { continue } + // parse aliases from inline comment (vspec.Comment is the inline comment) + aliases := parseAliasComment(vspec.Comment) + // process all names in this spec for i, name := range vspec.Names { // skip underscore placeholders @@ -196,10 +201,11 @@ func (g *Generator) parseConstBlock(decl *ast.GenDecl) { // process value based on expression enumValue := g.processConstValue(vspec, i, state) - // store the value with its position + // store the value with its position and aliases g.values[name.Name] = &constValue{ - value: enumValue, - pos: name.Pos(), + value: enumValue, + pos: name.Pos(), + aliases: aliases, } } @@ -460,6 +466,11 @@ func EvaluateBinaryExpr(expr *ast.BinaryExpr, iotaVal int) (value int, usesIota // - exported const values (e.g., StatusActive) // - helper functions to get all values and names func (g *Generator) Generate() error { + // validate aliases: no duplicates and no conflicts with canonical names + if err := g.validateAliases(); err != nil { + return err + } + // to avoid an undefined behavior for a Getter, we need to check if the values are unique if g.generateGetter { valuesCounter := make(map[int][]string) @@ -511,6 +522,7 @@ func (g *Generator) Generate() error { PublicName: publicName, Name: titleCaser.String(nameWithoutPrefix), Index: e.cv.value, + Aliases: e.cv.aliases, }) } @@ -633,6 +645,73 @@ func getFileNameForType(typeName string) string { return strings.Join(words, "_") + "_enum.go" } +// validateAliases checks for duplicate aliases and conflicts with canonical names +func (g *Generator) validateAliases() error { + // collect all canonical names first (case-insensitive) + canonicalNames := make(map[string]string) // lowercase -> constant name + for name := range g.values { + nameWithoutPrefix := strings.TrimPrefix(name, g.Type) + canonicalNames[strings.ToLower(nameWithoutPrefix)] = name + } + + // validate aliases + aliasToConst := make(map[string]string) // lowercase alias -> constant name + var errs []error + + for name, cv := range g.values { + for _, alias := range cv.aliases { + lowerAlias := strings.ToLower(alias) + + // check if alias conflicts with a DIFFERENT constant's canonical name + if existingName, ok := canonicalNames[lowerAlias]; ok && existingName != name { + errs = append(errs, fmt.Errorf("alias %q for %s conflicts with canonical name of %s", alias, name, existingName)) + continue + } + + // check for duplicate aliases + if existingName, ok := aliasToConst[lowerAlias]; ok { + errs = append(errs, fmt.Errorf("duplicate alias %q: used by both %s and %s", alias, existingName, name)) + continue + } + + aliasToConst[lowerAlias] = name + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} + +// parseAliasComment extracts aliases from an inline comment like "// enum:alias=rw,read-write" +func parseAliasComment(comment *ast.CommentGroup) []string { + if comment == nil { + return nil + } + for _, c := range comment.List { + text := strings.TrimSpace(strings.TrimPrefix(c.Text, "//")) + if strings.HasPrefix(text, "enum:alias=") { + aliasStr := strings.TrimPrefix(text, "enum:alias=") + if aliasStr == "" { + return nil + } + aliases := strings.Split(aliasStr, ",") + result := make([]string, 0, len(aliases)) + for _, a := range aliases { + if trimmed := strings.TrimSpace(a); trimmed != "" { + result = append(result, trimmed) + } + } + if len(result) == 0 { + return nil + } + return result + } + } + return nil +} + // isValidGoIdentifier checks if a string is a valid Go identifier: // - must start with a letter or underscore // - can contain letters, digits, and underscores diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go index a6c435d..6850ee8 100644 --- a/internal/generator/generator_test.go +++ b/internal/generator/generator_test.go @@ -583,11 +583,11 @@ func TestGeneratorLowerCase(t *testing.T) { // check parse map has lowercase keys assert.Contains(t, string(content), `"active": StatusActive`) - // for lowercase mode, we don't use strings.ToLower in Parse function + // parsing is always case-insensitive, so strings.ToLower is always used parseIdx := bytes.Index(content, []byte("func ParseStatus")) parseEnd := bytes.Index(content[parseIdx:], []byte("}")) parseFunc := string(content[parseIdx : parseIdx+parseEnd]) - assert.NotContains(t, parseFunc, "strings.ToLower") + assert.Contains(t, parseFunc, "strings.ToLower") }) t.Run("regular case values", func(t *testing.T) { @@ -1777,6 +1777,203 @@ func TestParseConstBlockWithImportSpec(t *testing.T) { assert.Empty(t, gen.values) } +func TestParseAliasComment(t *testing.T) { + tests := []struct { + name string + comment string + expected []string + }{ + {"basic alias", "// enum:alias=rw", []string{"rw"}}, + {"multiple aliases", "// enum:alias=rw,read-write", []string{"rw", "read-write"}}, + {"with whitespace", "// enum:alias= rw , read-write ", []string{"rw", "read-write"}}, + {"empty value", "// enum:alias=", nil}, + {"empty between commas", "// enum:alias=a,,b", []string{"a", "b"}}, + {"no alias directive", "// some comment", nil}, + {"nil comment", "", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var comment *ast.CommentGroup + if tt.comment != "" { + comment = &ast.CommentGroup{ + List: []*ast.Comment{{Text: tt.comment}}, + } + } + result := parseAliasComment(comment) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseWithAliases(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.go") + src := `package test +type status int +const ( + statusActive status = iota // enum:alias=a,on + statusInactive // enum:alias=i,off +) +` + require.NoError(t, os.WriteFile(testFile, []byte(src), 0o644)) + + gen, err := New("status", "") + require.NoError(t, err) + err = gen.Parse(tmpDir) + require.NoError(t, err) + + // verify aliases are extracted + assert.Equal(t, []string{"a", "on"}, gen.values["statusActive"].aliases) + assert.Equal(t, []string{"i", "off"}, gen.values["statusInactive"].aliases) +} + +func TestParseWithoutAliases(t *testing.T) { + // ensure backward compatibility - constants without aliases should have nil aliases + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.go") + src := `package test +type status int +const ( + statusActive status = iota + statusInactive +) +` + require.NoError(t, os.WriteFile(testFile, []byte(src), 0o644)) + + gen, err := New("status", "") + require.NoError(t, err) + err = gen.Parse(tmpDir) + require.NoError(t, err) + + // verify no aliases + assert.Nil(t, gen.values["statusActive"].aliases) + assert.Nil(t, gen.values["statusInactive"].aliases) +} + +func TestGenerateWithDuplicateAliases(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.go") + src := `package test +type status int +const ( + statusA status = iota // enum:alias=x + statusB // enum:alias=x +) +` + require.NoError(t, os.WriteFile(testFile, []byte(src), 0o644)) + + gen, err := New("status", tmpDir) + require.NoError(t, err) + err = gen.Parse(tmpDir) + require.NoError(t, err) + + err = gen.Generate() + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate alias") + assert.Contains(t, err.Error(), "x") +} + +func TestGenerateWithCanonicalConflict(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.go") + src := `package test +type status int +const ( + statusActive status = iota // enum:alias=inactive + statusInactive +) +` + require.NoError(t, os.WriteFile(testFile, []byte(src), 0o644)) + + gen, err := New("status", tmpDir) + require.NoError(t, err) + err = gen.Parse(tmpDir) + require.NoError(t, err) + + err = gen.Generate() + require.Error(t, err) + assert.Contains(t, err.Error(), "conflicts with canonical") +} + +func TestGenerateAliasesInParseMap(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.go") + src := `package test +type status int +const ( + statusActive status = iota // enum:alias=a,on + statusInactive // enum:alias=i,off +) +` + require.NoError(t, os.WriteFile(testFile, []byte(src), 0o644)) + + gen, err := New("status", tmpDir) + require.NoError(t, err) + err = gen.Parse(tmpDir) + require.NoError(t, err) + + err = gen.Generate() + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(tmpDir, "status_enum.go")) + require.NoError(t, err) + + // verify parse map contains canonical names + assert.Contains(t, string(content), `"active":`) + assert.Contains(t, string(content), `"inactive":`) + + // verify parse map contains aliases + assert.Contains(t, string(content), `"a":`) + assert.Contains(t, string(content), `"on":`) + assert.Contains(t, string(content), `"i":`) + assert.Contains(t, string(content), `"off":`) + + // verify aliases point to correct enum values + assert.Contains(t, string(content), `"a": StatusActive`) + assert.Contains(t, string(content), `"on": StatusActive`) + assert.Contains(t, string(content), `"i": StatusInactive`) + assert.Contains(t, string(content), `"off": StatusInactive`) +} + +func TestGenerateLowerCaseWithMixedCaseAlias(t *testing.T) { + // test that mixed-case aliases work with -lower flag + // this was a bug: parse map keys are always lowercase but Parse() didn't normalize input + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.go") + src := `package test +type permission int +const ( + permissionNone permission = iota // enum:alias=n + permissionReadWrite // enum:alias=RW,read-write +) +` + require.NoError(t, os.WriteFile(testFile, []byte(src), 0o644)) + + gen, err := New("permission", tmpDir) + require.NoError(t, err) + gen.SetLowerCase(true) // enable -lower flag + err = gen.Parse(tmpDir) + require.NoError(t, err) + + err = gen.Generate() + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(tmpDir, "permission_enum.go")) + require.NoError(t, err) + + // verify generated parse function uses strings.ToLower + assert.Contains(t, string(content), "strings.ToLower(v)") + + // verify aliases are stored lowercase in map + assert.Contains(t, string(content), `"rw":`) + assert.Contains(t, string(content), `"read-write":`) + + // verify parse function will handle mixed case input correctly by checking the template output + // the parse function should always use strings.ToLower(v) for lookup + assert.Contains(t, string(content), `_permissionParseMap[strings.ToLower(v)]`) +} + func TestApplyIotaOperationDivisionByZeroRightSide(t *testing.T) { gen, err := New("test", "") require.NoError(t, err) diff --git a/internal/generator/integration_test.go b/internal/generator/integration_test.go index a4810a1..1226f79 100644 --- a/internal/generator/integration_test.go +++ b/internal/generator/integration_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -411,7 +412,7 @@ func TestRuntimeIntegration(t *testing.T) { // 2. Create a temp package for testing pkgDir := t.TempDir() - // Copy enum definitions from testdata + // copy enum definitions from testdata statusSrc, err := os.ReadFile("testdata/integration/status.go") require.NoError(t, err) err = os.WriteFile(filepath.Join(pkgDir, "status.go"), statusSrc, 0o644) @@ -423,19 +424,19 @@ func TestRuntimeIntegration(t *testing.T) { require.NoError(t, err) // 3. Generate enums using the built binary - // Generate status enum + // generate status enum cmd = exec.Command(binPath, "-type=status", "-lower", "-sql", "-bson", "-yaml") cmd.Dir = pkgDir output, err = cmd.CombinedOutput() require.NoError(t, err, "failed to generate status enum: %s", output) - // Generate priority enum + // generate priority enum cmd = exec.Command(binPath, "-type=priority", "-sql", "-bson", "-yaml") cmd.Dir = pkgDir output, err = cmd.CombinedOutput() require.NoError(t, err, "failed to generate priority enum: %s", output) - // Verify generated files exist + // verify generated files exist require.FileExists(t, filepath.Join(pkgDir, "status_enum.go")) require.FileExists(t, filepath.Join(pkgDir, "priority_enum.go")) @@ -550,6 +551,56 @@ type empty int }) } +// TestAliasIntegration tests that generated enums with aliases work correctly +func TestAliasIntegration(t *testing.T) { + testDir := t.TempDir() + testFile := filepath.Join(testDir, "permission.go") + src := `package test +type permission int +const ( + permissionNone permission = iota // enum:alias=n + permissionRead // enum:alias=r + permissionWrite // enum:alias=w + permissionReadWrite // enum:alias=rw,read-write +) +` + err := os.WriteFile(testFile, []byte(src), 0o644) + require.NoError(t, err) + + gen, err := New("permission", testDir) + require.NoError(t, err) + gen.SetLowerCase(true) + + err = gen.Parse(testDir) + require.NoError(t, err) + + err = gen.Generate() + require.NoError(t, err) + + // verify generated file exists + content, err := os.ReadFile(filepath.Join(testDir, "permission_enum.go")) + require.NoError(t, err) + contentStr := string(content) + + // verify parse map contains canonical names + assert.Contains(t, contentStr, `"none":`) + assert.Contains(t, contentStr, `"read":`) + assert.Contains(t, contentStr, `"write":`) + assert.Contains(t, contentStr, `"readwrite":`) + + // verify parse map contains aliases + assert.Contains(t, contentStr, `"n":`) + assert.Contains(t, contentStr, `"r":`) + assert.Contains(t, contentStr, `"w":`) + assert.Contains(t, contentStr, `"rw":`) + assert.Contains(t, contentStr, `"read-write":`) + + // verify generated code compiles (no duplicate map keys) + // the generated map should have unique entries for each key + assert.Equal(t, 1, strings.Count(contentStr, `"none":`), "canonical name 'none' should appear exactly once") + assert.Equal(t, 1, strings.Count(contentStr, `"n":`), "alias 'n' should appear exactly once") +} + // TestErrorHandling tests various error conditions func TestErrorHandling(t *testing.T) { t.Run("invalid enum type", func(t *testing.T) { diff --git a/internal/generator/testdata/integration/permission.go b/internal/generator/testdata/integration/permission.go new file mode 100644 index 0000000..890fbd4 --- /dev/null +++ b/internal/generator/testdata/integration/permission.go @@ -0,0 +1,10 @@ +package integration + +type permission int + +const ( + permissionNone permission = iota // enum:alias=n,none + permissionRead // enum:alias=r + permissionWrite // enum:alias=w + permissionReadWrite // enum:alias=rw,read-write +) diff --git a/internal/generator/testdata/integration/priority_enum.go b/internal/generator/testdata/integration/priority_enum.go index fba2afa..0d9a439 100644 --- a/internal/generator/testdata/integration/priority_enum.go +++ b/internal/generator/testdata/integration/priority_enum.go @@ -4,10 +4,11 @@ package integration import ( "database/sql/driver" "fmt" + "strings" + "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/bsontype" "gopkg.in/yaml.v3" - "strings" ) // Priority is the exported type for the enum diff --git a/internal/generator/testdata/integration/status_enum.go b/internal/generator/testdata/integration/status_enum.go index 71577d7..fb2314d 100644 --- a/internal/generator/testdata/integration/status_enum.go +++ b/internal/generator/testdata/integration/status_enum.go @@ -7,6 +7,7 @@ import ( "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/bsontype" "gopkg.in/yaml.v3" + "strings" ) // Status is the exported type for the enum @@ -117,13 +118,12 @@ var _statusParseMap = map[string]Status{ "archived": StatusArchived, } -// ParseStatus converts string to status enum value +// ParseStatus converts string to status enum value. +// Parsing is always case-insensitive. func ParseStatus(v string) (Status, error) { - - if val, ok := _statusParseMap[v]; ok { + if val, ok := _statusParseMap[strings.ToLower(v)]; ok { return val, nil } - return Status{}, fmt.Errorf("invalid status: %s", v) }