Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 9 additions & 11 deletions internal/generator/enum.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down
99 changes: 89 additions & 10 deletions internal/generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -511,6 +522,7 @@ func (g *Generator) Generate() error {
PublicName: publicName,
Name: titleCaser.String(nameWithoutPrefix),
Index: e.cv.value,
Aliases: e.cv.aliases,
})
}

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading