From af33a3b9cbed7156df2a0d85bbcdaaa67184ff25 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Wed, 19 Aug 2026 02:02:38 +0100 Subject: [PATCH 1/4] Evaluate enum constants with go/constant Previously the generator understood only iota, plain literals and a binary expression with one of + - * / around iota. Anything else silently produced 0: shifts, bitwise operators, parentheses, references to other constants, and every literal that is not plain decimal, since the literal converter scanned with %d and stopped at the first character it did not understand. After this change constant expressions are evaluated with go/constant, which covers the whole integer operator set and keeps values exact, so a uint64 enum past MaxInt64 keeps its value. A spec without an expression now repeats the expression of the previous spec, the rule the language itself uses, instead of replaying a recorded operation. Values that cannot be evaluated, or that do not fit the underlying type, are reported as errors instead of becoming 0. --- README.md | 19 ++ internal/generator/constexpr.go | 310 ++++++++++++++++++ internal/generator/constexpr_test.go | 362 +++++++++++++++++++++ internal/generator/generator.go | 351 ++++---------------- internal/generator/generator_test.go | 463 +++------------------------ 5 files changed, 794 insertions(+), 711 deletions(-) create mode 100644 internal/generator/constexpr.go create mode 100644 internal/generator/constexpr_test.go diff --git a/README.md b/README.md index 403f8c9..9594544 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,25 @@ go generate ./... By default the generated type supports `encoding.TextMarshaler`/`Unmarshaler` (used by `encoding/json`). To include other integrations, enable flags as needed (see below). +### Constant Values + +Enum constants may use any integer constant expression the language allows: `iota` with arithmetic, shifts and bitwise +operators, literals in any base, references to other constants of the same package, and conversions. As in Go, a constant +without an expression repeats the expression of the preceding one. + +```go +type permission uint8 + +const ( + permissionRead permission = 1 << iota // 1 + permissionWrite // 2 + permissionAdmin // 4 +) +``` + +The generator reports an error when a value cannot be evaluated, for instance when it refers to a constant of another +package, and when a value does not fit the underlying type of the enum. + ### Generator Options - `-type` (required): the name of the type to generate enum for (must be lowercase/private) diff --git a/internal/generator/constexpr.go b/internal/generator/constexpr.go new file mode 100644 index 0000000..ae704cf --- /dev/null +++ b/internal/generator/constexpr.go @@ -0,0 +1,310 @@ +package generator + +import ( + "fmt" + "go/ast" + "go/constant" + "go/token" + "strconv" +) + +// maxShiftCount caps shift expressions so a malformed source can't ask for an enormous allocation +const maxShiftCount = 512 + +// intTypeInfo describes the width and signedness of a builtin integer type +type intTypeInfo struct { + bits int + signed bool +} + +// intTypes maps builtin integer type names to their width. int, uint and uintptr are sized as 64 bit, +// which is the widest they can be on a supported platform. +var intTypes = map[string]intTypeInfo{ + "int": {bits: 64, signed: true}, + "int8": {bits: 8, signed: true}, + "int16": {bits: 16, signed: true}, + "int32": {bits: 32, signed: true}, + "int64": {bits: 64, signed: true}, + "rune": {bits: 32, signed: true}, + "uint": {bits: 64, signed: false}, + "uint8": {bits: 8, signed: false}, + "uint16": {bits: 16, signed: false}, + "uint32": {bits: 32, signed: false}, + "uint64": {bits: 64, signed: false}, + "uintptr": {bits: 64, signed: false}, + "byte": {bits: 8, signed: false}, +} + +// constDecl is a constant declaration together with the iota value in effect where it appears +type constDecl struct { + expr ast.Expr // expression to evaluate, inherited from the previous spec when omitted + iotaVal int64 // iota value of the spec holding this declaration +} + +// constResolver evaluates constant expressions with go/constant, which keeps values exact and covers +// every operator the language allows on integer constants. it holds every constant declared in the +// package so enum values can reference other constants by name. +type constResolver struct { + decls map[string]constDecl // constant name to its defining expression + typeNames map[string]struct{} // type names declared in the package, for conversions + cache map[string]constant.Value // already resolved constants + resolving map[string]struct{} // names being resolved, to detect reference cycles +} + +// newConstResolver makes an empty resolver, files are added with addFile +func newConstResolver() *constResolver { + return &constResolver{ + decls: map[string]constDecl{}, + typeNames: map[string]struct{}{}, + cache: map[string]constant.Value{}, + resolving: map[string]struct{}{}, + } +} + +// addFile records the constant and type declarations of a single file +func (r *constResolver) addFile(file *ast.File) { + ast.Inspect(file, func(n ast.Node) bool { + decl, ok := n.(*ast.GenDecl) + if !ok { + return true + } + switch decl.Tok { + case token.TYPE: + for _, spec := range decl.Specs { + if tspec, ok := spec.(*ast.TypeSpec); ok { + r.typeNames[tspec.Name.Name] = struct{}{} + } + } + case token.CONST: + r.addConstBlock(decl) + } + return false + }) +} + +// addConstBlock records every constant of a single const block. a spec without an expression repeats +// the expression list of the previous spec, which is how iota based blocks are defined by the language. +func (r *constResolver) addConstBlock(decl *ast.GenDecl) { + var last []ast.Expr + for i, spec := range decl.Specs { + vspec, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + if len(vspec.Values) > 0 { + last = vspec.Values + } + for j, name := range vspec.Names { + if name.Name == "_" || j >= len(last) { + continue + } + // the first declaration wins, a name may be repeated by a block in another scope + if _, ok := r.decls[name.Name]; ok { + continue + } + r.decls[name.Name] = constDecl{expr: last[j], iotaVal: int64(i)} + } + } +} + +// resolve evaluates a constant by name +func (r *constResolver) resolve(name string) (constant.Value, error) { + if v, ok := r.cache[name]; ok { + return v, nil + } + decl, ok := r.decls[name] + if !ok { + return nil, fmt.Errorf("unknown constant %s", name) + } + if _, ok := r.resolving[name]; ok { + return nil, fmt.Errorf("constant %s refers to itself", name) + } + r.resolving[name] = struct{}{} + defer delete(r.resolving, name) + + v, err := r.eval(decl.expr, decl.iotaVal) + if err != nil { + return nil, fmt.Errorf("constant %s: %w", name, err) + } + r.cache[name] = v + return v, nil +} + +// eval evaluates a constant expression with the given iota value +func (r *constResolver) eval(expr ast.Expr, iotaVal int64) (constant.Value, error) { + switch e := expr.(type) { + case *ast.BasicLit: + return literalValue(e) + case *ast.Ident: + if e.Name == "iota" { + return constant.MakeInt64(iotaVal), nil + } + return r.resolve(e.Name) + case *ast.ParenExpr: + return r.eval(e.X, iotaVal) + case *ast.UnaryExpr: + return r.evalUnary(e, iotaVal) + case *ast.BinaryExpr: + return r.evalBinary(e, iotaVal) + case *ast.CallExpr: + return r.evalConversion(e, iotaVal) + } + return nil, fmt.Errorf("unsupported expression %T", expr) +} + +// evalUnary evaluates +x, -x and ^x +func (r *constResolver) evalUnary(e *ast.UnaryExpr, iotaVal int64) (constant.Value, error) { + x, err := r.eval(e.X, iotaVal) + if err != nil { + return nil, err + } + if x, err = toInt(x); err != nil { + return nil, err + } + switch e.Op { + case token.ADD, token.SUB, token.XOR: + return constant.UnaryOp(e.Op, x, 0), nil + } + return nil, fmt.Errorf("unsupported unary operator %s", e.Op) +} + +// evalBinary evaluates the arithmetic and bitwise operators defined for integer constants +func (r *constResolver) evalBinary(e *ast.BinaryExpr, iotaVal int64) (constant.Value, error) { + x, err := r.eval(e.X, iotaVal) + if err != nil { + return nil, err + } + if x, err = toInt(x); err != nil { + return nil, err + } + + if e.Op == token.SHL || e.Op == token.SHR { + return r.evalShift(e, x, iotaVal) + } + + y, err := r.eval(e.Y, iotaVal) + if err != nil { + return nil, err + } + if y, err = toInt(y); err != nil { + return nil, err + } + + switch e.Op { + case token.ADD, token.SUB, token.MUL, token.AND, token.OR, token.XOR, token.AND_NOT: + return constant.BinaryOp(x, e.Op, y), nil + case token.QUO, token.REM: + if constant.Sign(y) == 0 { + return nil, fmt.Errorf("division by zero") + } + if e.Op == token.REM { + return constant.BinaryOp(x, token.REM, y), nil + } + // QUO_ASSIGN keeps the result an integer, plain QUO on two integers yields a rational + return constant.BinaryOp(x, token.QUO_ASSIGN, y), nil + } + return nil, fmt.Errorf("unsupported binary operator %s", e.Op) +} + +// evalShift evaluates x << n and x >> n, x is already known to be an integer +func (r *constResolver) evalShift(e *ast.BinaryExpr, x constant.Value, iotaVal int64) (constant.Value, error) { + y, err := r.eval(e.Y, iotaVal) + if err != nil { + return nil, err + } + if y, err = toInt(y); err != nil { + return nil, err + } + if constant.Sign(y) < 0 { + return nil, fmt.Errorf("negative shift count %s", y.ExactString()) + } + count, exact := constant.Uint64Val(y) + if !exact || count > maxShiftCount { + return nil, fmt.Errorf("shift count %s is too large", y.ExactString()) + } + return constant.Shift(x, e.Op, uint(count)), nil +} + +// evalConversion evaluates a single argument conversion such as status(3) or uint8(1 << 2) +func (r *constResolver) evalConversion(e *ast.CallExpr, iotaVal int64) (constant.Value, error) { + ident, ok := e.Fun.(*ast.Ident) + if !ok || len(e.Args) != 1 { + return nil, fmt.Errorf("unsupported call expression") + } + _, declared := r.typeNames[ident.Name] + if _, builtin := intTypes[ident.Name]; !declared && !builtin { + return nil, fmt.Errorf("unsupported call to %s", ident.Name) + } + v, err := r.eval(e.Args[0], iotaVal) + if err != nil { + return nil, err + } + return toInt(v) +} + +// literalValue converts an integer or character literal, covering every base and digit separator +// the language allows +func literalValue(lit *ast.BasicLit) (constant.Value, error) { + switch lit.Kind { + case token.INT: + v := constant.MakeFromLiteral(lit.Value, lit.Kind, 0) + if v.Kind() == constant.Unknown { + return nil, fmt.Errorf("invalid literal %s", lit.Value) + } + return v, nil + case token.CHAR: + // go/constant ignores anything after the first character of a rune literal, unquote it here + // instead so a literal holding more than one character is rejected + if len(lit.Value) < 3 || lit.Value[0] != '\'' { + return nil, fmt.Errorf("invalid literal %s", lit.Value) + } + r, _, tail, err := strconv.UnquoteChar(lit.Value[1:], '\'') + if err != nil || tail != "'" { + return nil, fmt.Errorf("invalid literal %s", lit.Value) + } + return constant.MakeInt64(int64(r)), nil + } + return nil, fmt.Errorf("literal %s is not an integer", lit.Value) +} + +// toInt converts a value to an integer, go/constant panics on operands of mismatched kinds so +// everything is checked before it reaches an operator +func toInt(v constant.Value) (constant.Value, error) { + iv := constant.ToInt(v) + if iv.Kind() != constant.Int { + return nil, fmt.Errorf("value %s is not an integer", v.String()) + } + return iv, nil +} + +// checkIntRange reports whether a value fits the underlying type of the enum. an out of range value +// would produce generated code that does not compile. unknown type names are left alone. +func checkIntRange(v constant.Value, underlyingType string) error { + if underlyingType == "" { + underlyingType = "int" // the template falls back to int when the type has no explicit underlying type + } + info, ok := intTypes[underlyingType] + if !ok { + return nil + } + + if !info.signed { + if constant.Sign(v) < 0 { + return fmt.Errorf("value %s is negative but the type is %s", v.ExactString(), underlyingType) + } + n, exact := constant.Uint64Val(v) + if !exact || (info.bits < 64 && n >= uint64(1)< 1<<(info.bits-1)-1) { + return fmt.Errorf("value %s overflows %s", v.ExactString(), underlyingType) + } + return nil +} diff --git a/internal/generator/constexpr_test.go b/internal/generator/constexpr_test.go new file mode 100644 index 0000000..7cb0183 --- /dev/null +++ b/internal/generator/constexpr_test.go @@ -0,0 +1,362 @@ +package generator + +import ( + "go/ast" + "go/constant" + "go/parser" + "go/token" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// constVal returns the parsed value of a constant as int64 +func constVal(t *testing.T, gen *Generator, name string) int64 { + t.Helper() + cv, ok := gen.values[name] + require.True(t, ok, "constant %s not found", name) + v, exact := constant.Int64Val(cv.value) + require.True(t, exact, "value of %s does not fit int64: %s", name, cv.value.ExactString()) + return v +} + +// resolveSrc parses a go source and resolves a single constant from it +func resolveSrc(t *testing.T, src, name string) (constant.Value, error) { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), "src.go", src, parser.ParseComments) + require.NoError(t, err) + r := newConstResolver() + r.addFile(file) + return r.resolve(name) +} + +func TestConstResolverValues(t *testing.T) { + tests := []struct { + name string + expr string + expected string + }{ + {"decimal", "42", "42"}, + {"negative", "-42", "-42"}, + {"unary plus", "+42", "42"}, + {"hex", "0x10", "16"}, + {"hex upper", "0XFF", "255"}, + {"binary", "0b1010", "10"}, + {"octal", "0o17", "15"}, + {"octal legacy", "017", "15"}, + {"underscored", "1_000_000", "1000000"}, + {"underscored hex", "0xff_ff", "65535"}, + {"char", "'A'", "65"}, + {"char escape", "'\\n'", "10"}, + {"char byte escape", "'\\x80'", "128"}, + {"char unicode", "'\\u00e9'", "233"}, + {"shift left", "1 << 4", "16"}, + {"shift right", "256 >> 4", "16"}, + {"shift by constant", "1 << 62", "4611686018427387904"}, + {"bitwise or", "5 | 2", "7"}, + {"bitwise and", "6 & 3", "2"}, + {"bitwise xor", "7 ^ 2", "5"}, + {"bitwise clear", "7 &^ 2", "5"}, + {"bitwise not", "^0", "-1"}, + {"remainder", "10 % 3", "1"}, + {"integer division", "7 / 2", "3"}, + {"nested", "(1 + 2) * (3 + 4)", "21"}, + {"deeply nested", "((1 << 3) | (1 << 1)) - 2", "8"}, + {"conversion builtin", "uint8(3)", "3"}, + {"conversion nested", "uint16(1 << 9)", "512"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v, err := resolveSrc(t, "package p\nconst x = "+tt.expr+"\n", "x") + require.NoError(t, err) + assert.Equal(t, tt.expected, v.ExactString()) + }) + } +} + +func TestConstResolverErrors(t *testing.T) { + tests := []struct { + name string + src string + errText string + }{ + {"division by zero", "const x = 1 / 0", "division by zero"}, + {"remainder by zero", "const x = 1 % 0", "division by zero"}, + {"negative shift", "const x = 1 << -1", "negative shift count"}, + {"huge shift", "const x = 1 << 100000", "too large"}, + {"string literal", `const x = "str"`, "not an integer"}, + {"float literal", "const x = 3.14", "not an integer"}, + {"float expression", "const x = 1.5 + 1.5", "not an integer"}, + {"unknown constant", "const x = missing + 1", "unknown constant missing"}, + {"package reference", "const x = math.MaxInt8", "unsupported expression"}, + {"function call", `const x = len("ab")`, "unsupported call to len"}, + {"comparison operator", "const x = 1 < 2", "unsupported binary operator"}, + {"self reference", "const x = x + 1", "refers to itself"}, + {"reference cycle", "const (\n\tx = y\n\ty = x\n)", "refers to itself"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := resolveSrc(t, "package p\n"+tt.src+"\n", "x") + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errText) + }) + } +} + +func TestConstResolverReferences(t *testing.T) { + src := `package p + +const base = 100 + +const ( + first = base + iota // 100 + second // 101 +) + +const shifted = first << 1 + +const converted = myType(second) + +type myType int +` + for name, expected := range map[string]string{ + "base": "100", "first": "100", "second": "101", "shifted": "200", "converted": "101", + } { + v, err := resolveSrc(t, src, name) + require.NoError(t, err, name) + assert.Equal(t, expected, v.ExactString(), name) + } +} + +func TestConstResolverSpecWithoutValue(t *testing.T) { + // a const spec with no expression repeats the previous one, iota moves on + src := `package p +const ( + a = 1 << iota + b + c + _ + e +) +` + for name, expected := range map[string]string{"a": "1", "b": "2", "c": "4", "e": "16"} { + v, err := resolveSrc(t, src, name) + require.NoError(t, err, name) + assert.Equal(t, expected, v.ExactString(), name) + } +} + +func TestCheckIntRange(t *testing.T) { + tests := []struct { + name string + value string + underlyingType string + wantErr string + }{ + {"int8 max", "127", "int8", ""}, + {"int8 min", "-128", "int8", ""}, + {"int8 overflow", "128", "int8", "overflows int8"}, + {"int8 underflow", "-129", "int8", "overflows int8"}, + {"uint8 max", "255", "uint8", ""}, + {"uint8 overflow", "256", "uint8", "overflows uint8"}, + {"uint8 negative", "-1", "uint8", "negative"}, + {"uint64 max", "18446744073709551615", "uint64", ""}, + {"uint64 overflow", "18446744073709551616", "uint64", "overflows uint64"}, + {"int64 max", "9223372036854775807", "int64", ""}, + {"int64 overflow", "9223372036854775808", "int64", "overflows int64"}, + {"default type is int", "9223372036854775808", "", "overflows int"}, + {"unknown type is skipped", "9223372036854775808", "myType", ""}, + {"rune", "1114111", "rune", ""}, + {"byte overflow", "256", "byte", "overflows byte"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := constant.MakeFromLiteral(tt.value, token.INT, 0) + require.Equal(t, constant.Int, v.Kind()) + err := checkIntRange(v, tt.underlyingType) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestConstResolverUnsupportedNodes(t *testing.T) { + r := newConstResolver() + + _, err := r.eval(&ast.FuncLit{}, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported expression") + + _, err = r.eval(&ast.UnaryExpr{Op: token.NOT, X: &ast.BasicLit{Kind: token.INT, Value: "1"}}, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported unary operator") + + _, err = r.eval(&ast.CallExpr{Fun: &ast.SelectorExpr{}, Args: []ast.Expr{&ast.BasicLit{Kind: token.INT, Value: "1"}}}, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported call expression") + + _, err = literalValue(&ast.BasicLit{Kind: token.CHAR, Value: "'ab'"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid literal") + + _, err = literalValue(&ast.BasicLit{Kind: token.STRING, Value: `"str"`}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not an integer") + + _, err = r.resolve("nothing") + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown constant nothing") +} + +// parseSrc writes a single source file to a temp dir and parses it with the generator +func parseSrc(t *testing.T, typeName, src string) (*Generator, error) { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "enum.go"), []byte(src), 0o600)) + gen, err := New(typeName, dir) + require.NoError(t, err) + err = gen.Parse(dir) + return gen, err +} + +func TestParseBitmaskEnum(t *testing.T) { + src := `package test +type perm uint8 +const ( + permRead perm = 1 << iota + permWrite + permExecute +) +` + gen, err := parseSrc(t, "perm", src) + require.NoError(t, err) + + assert.Equal(t, int64(1), constVal(t, gen, "permRead")) + assert.Equal(t, int64(2), constVal(t, gen, "permWrite")) + assert.Equal(t, int64(4), constVal(t, gen, "permExecute")) + + gen.SetGenerateGetter(true) + require.NoError(t, gen.Generate()) + content, err := os.ReadFile(filepath.Join(gen.Path, "perm_enum.go")) + require.NoError(t, err) + assert.Contains(t, string(content), `Perm{name: "Write", value: 2}`) + assert.Contains(t, string(content), `Perm{name: "Execute", value: 4}`) + assert.Contains(t, string(content), "case 4:") +} + +func TestParseNonDecimalLiterals(t *testing.T) { + src := `package test +type code uint16 +const ( + codeA code = 0x10 + codeB code = 0b1000_0000 + codeC code = 1_000 +) +` + gen, err := parseSrc(t, "code", src) + require.NoError(t, err) + + assert.Equal(t, int64(16), constVal(t, gen, "codeA")) + assert.Equal(t, int64(128), constVal(t, gen, "codeB")) + assert.Equal(t, int64(1000), constVal(t, gen, "codeC")) +} + +func TestParseWithConstantReference(t *testing.T) { + src := `package test + +const offset = 1 << 8 + +type code uint16 +const ( + codeA code = offset + codeB code = offset + 1 +) +` + gen, err := parseSrc(t, "code", src) + require.NoError(t, err) + + assert.Equal(t, int64(256), constVal(t, gen, "codeA")) + assert.Equal(t, int64(257), constVal(t, gen, "codeB")) +} + +func TestParseValuesBeyondInt64(t *testing.T) { + src := `package test +type flag uint64 +const ( + flagNone flag = 0 + flagHigh flag = 1 << 63 + flagAll flag = 1<<64 - 1 +) +` + gen, err := parseSrc(t, "flag", src) + require.NoError(t, err) + + assert.Equal(t, "9223372036854775808", gen.values["flagHigh"].value.ExactString()) + assert.Equal(t, "18446744073709551615", gen.values["flagAll"].value.ExactString()) + + require.NoError(t, gen.Generate()) + content, err := os.ReadFile(filepath.Join(gen.Path, "flag_enum.go")) + require.NoError(t, err) + assert.Contains(t, string(content), "value: 9223372036854775808") + assert.Contains(t, string(content), "value: 18446744073709551615") +} + +func TestParseValueOutOfRange(t *testing.T) { + src := `package test +type small int8 +const ( + smallA small = 100 + smallB small = 200 +) +` + _, err := parseSrc(t, "small", src) + require.Error(t, err) + assert.Contains(t, err.Error(), "const smallB: value 200 overflows int8") +} + +func TestParseUnresolvableValue(t *testing.T) { + src := `package test + +import "math" + +type code uint8 +const ( + codeA code = 1 + codeB code = math.MaxUint8 +) +` + _, err := parseSrc(t, "code", src) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to evaluate value of const codeB") +} + +func TestParseCrossFileReference(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "base.go"), []byte("package test\n\nconst codeBase = 0x100\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "enum.go"), []byte(`package test + +type code uint16 + +const ( + codeA code = codeBase + codeB code = codeBase + 1 +) +`), 0o600)) + + gen, err := New("code", dir) + require.NoError(t, err) + require.NoError(t, gen.Parse(dir)) + + assert.Equal(t, int64(256), constVal(t, gen, "codeA")) + assert.Equal(t, int64(257), constVal(t, gen, "codeB")) +} diff --git a/internal/generator/generator.go b/internal/generator/generator.go index fe0578e..3500235 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -8,13 +8,13 @@ import ( "errors" "fmt" "go/ast" + "go/constant" "go/format" "go/parser" "go/token" "os" "path/filepath" "sort" - "strconv" "strings" "text/template" "unicode" @@ -42,35 +42,10 @@ 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 - aliases []string // aliases from comment annotation - comment string // free-text doc comment (enum: directives excluded) -} - -// constExprType represents the type of constant expression -type constExprType int - -const ( - exprTypeNone constExprType = iota // no expression type determined yet - exprTypePlain // plain value without iota - exprTypeIota // plain iota - exprTypeIotaOp // iota with operation (e.g., iota + 1) -) - -// iotaOperation encapsulates a binary operation with iota -type iotaOperation struct { - op token.Token // operation type (ADD, SUB, MUL, QUO) - operand int // the non-iota operand - iotaOnLeft bool // whether iota is on the left side -} - -// constParseState holds the state while parsing a const block -type constParseState struct { - iotaVal int // current iota value for this const block - lastExprType constExprType // type of the last expression - lastValue int // the last computed value - iotaOp *iotaOperation // current iota operation if any + value constant.Value // the exact numeric value + pos token.Pos // source position for ordering + aliases []string // aliases from comment annotation + comment string // free-text doc comment (enum: directives excluded) } // Value represents a single enum value @@ -78,7 +53,7 @@ type Value struct { PrivateName string // e.g., "statusActive" PublicName string // e.g., "StatusActive" Name string // e.g., "Active" - Index int // enum index value + Index string // enum value, rendered as a go integer literal Aliases []string // e.g., ["rw", "read-write"] from // enum:alias=rw,read-write Comment string // doc comment for the generated public constant } @@ -145,8 +120,19 @@ func (g *Generator) Parse(dir string) error { // process each package for pkgName, files := range pkgFiles { g.pkgName = pkgName + + // collect declarations of the whole package first, an enum const may reference a constant + // declared in another file and the underlying type may live in another file as well + resolver := newConstResolver() + for _, file := range files { + resolver.addFile(file) + g.extractUnderlyingType(file) + } + for _, file := range files { - g.parseFile(file) + if err := g.parseFile(file, resolver); err != nil { + return err + } } } @@ -158,17 +144,19 @@ func (g *Generator) Parse(dir string) error { } // parseFile processes a single file for enum declarations -func (g *Generator) parseFile(file *ast.File) { - // first pass: look for the type declaration to get underlying type - g.extractUnderlyingType(file) - - // second pass: extract const values +func (g *Generator) parseFile(file *ast.File, resolver *constResolver) error { + var err error ast.Inspect(file, func(n ast.Node) bool { + if err != nil { + return false + } if decl, ok := n.(*ast.GenDecl); ok && decl.Tok == token.CONST { - g.parseConstBlock(decl) + err = g.parseConstBlock(decl, resolver) + return false } return true }) + return err } // extractUnderlyingType finds the type declaration and extracts its underlying type @@ -188,15 +176,20 @@ func (g *Generator) extractUnderlyingType(file *ast.File) { }) } -// parseConstBlock extracts enum values from a const block -func (g *Generator) parseConstBlock(decl *ast.GenDecl) { - state := &constParseState{} +// parseConstBlock extracts enum values from a const block. a spec without an expression repeats the +// expression list of the previous spec, which is how the language defines iota based blocks. +func (g *Generator) parseConstBlock(decl *ast.GenDecl, resolver *constResolver) error { + var lastValues []ast.Expr - for _, spec := range decl.Specs { + // the index of a spec inside the block is the value of iota for that spec + for iotaVal, spec := range decl.Specs { vspec, ok := spec.(*ast.ValueSpec) if !ok || len(vspec.Names) == 0 { continue } + if len(vspec.Values) > 0 { + lastValues = vspec.Values + } // parse aliases from inline comment (vspec.Comment is the inline comment) aliases := parseAliasComment(vspec.Comment) @@ -219,263 +212,28 @@ func (g *Generator) parseConstBlock(decl *ast.GenDecl) { continue } - // process value based on expression - enumValue := g.processConstValue(vspec, i, state) + if i >= len(lastValues) { + return fmt.Errorf("no value for const %s", name.Name) + } + + value, err := resolver.eval(lastValues[i], int64(iotaVal)) + if err != nil { + return fmt.Errorf("failed to evaluate value of const %s: %w", name.Name, err) + } + if err := checkIntRange(value, g.underlyingType); err != nil { + return fmt.Errorf("const %s: %w", name.Name, err) + } // store the value with its position, aliases, and comment g.values[name.Name] = &constValue{ - value: enumValue, + value: value, pos: name.Pos(), aliases: aliases, comment: comment, } } - - // always increment iota after each value spec - state.iotaVal++ - } -} - -// processConstValue extracts the value for a single constant -func (g *Generator) processConstValue(vspec *ast.ValueSpec, index int, state *constParseState) int { - // handle explicit expression if present - if index < len(vspec.Values) && vspec.Values[index] != nil { - return g.processExplicitValue(vspec.Values[index], state) - } - - // handle implicit expression based on previous state - return g.processImplicitValue(state) -} - -// processExplicitValue handles a constant with an explicit value expression -func (g *Generator) processExplicitValue(expr ast.Expr, state *constParseState) int { - switch e := expr.(type) { - case *ast.Ident: - if e.Name == "iota" { - state.lastExprType = exprTypeIota - state.lastValue = state.iotaVal - state.iotaOp = nil - return state.iotaVal - } - case *ast.BasicLit: - if val, err := ConvertLiteralToInt(e); err == nil { - state.lastExprType = exprTypePlain - state.lastValue = val - state.iotaOp = nil - return val - } - case *ast.BinaryExpr: - if val, op := g.processBinaryExpr(e, state); op != nil { - state.lastExprType = exprTypeIotaOp - state.lastValue = val - state.iotaOp = op - return val - } else if val != 0 || op == nil { - // plain binary expression without iota - state.lastExprType = exprTypePlain - state.lastValue = val - state.iotaOp = nil - return val - } - case *ast.UnaryExpr: - // handle negative numbers like -1 - if e.Op == token.SUB { - if lit, ok := e.X.(*ast.BasicLit); ok { - if val, err := ConvertLiteralToInt(lit); err == nil { - state.lastExprType = exprTypePlain - state.lastValue = -val - state.iotaOp = nil - return -val - } - // if conversion fails, fall through to return 0 (same as BasicLit case) - } - } - } - return 0 -} - -// processImplicitValue handles a constant without an explicit value -func (g *Generator) processImplicitValue(state *constParseState) int { - switch state.lastExprType { - case exprTypeIota: - // plain iota continues - return state.iotaVal - case exprTypeIotaOp: - // apply the operation with current iota - return g.applyIotaOperation(state.iotaOp, state.iotaVal) - default: - // repeat last plain value - return state.lastValue } -} - -// processBinaryExpr processes a binary expression and returns the value and operation if it uses iota -func (g *Generator) processBinaryExpr(expr *ast.BinaryExpr, state *constParseState) (int, *iotaOperation) { - val, usesIota, err := EvaluateBinaryExpr(expr, state.iotaVal) - if err != nil { - return 0, nil - } - - if !usesIota { - return val, nil - } - - // extract operation details for iota expressions - op := &iotaOperation{op: expr.Op} - - if ident, ok := expr.X.(*ast.Ident); ok && ident.Name == "iota" { - // iota op value - op.iotaOnLeft = true - if lit, ok := expr.Y.(*ast.BasicLit); ok { - if opVal, err := ConvertLiteralToInt(lit); err == nil { - op.operand = opVal - } - } - } else if ident, ok := expr.Y.(*ast.Ident); ok && ident.Name == "iota" { - // value op iota - op.iotaOnLeft = false - if lit, ok := expr.X.(*ast.BasicLit); ok { - if opVal, err := ConvertLiteralToInt(lit); err == nil { - op.operand = opVal - } - } - } - - return val, op -} - -// applyIotaOperation applies a stored operation to a new iota value -func (g *Generator) applyIotaOperation(op *iotaOperation, iotaVal int) int { - if op == nil { - return iotaVal - } - - switch op.op { - case token.ADD: - return iotaVal + op.operand - case token.SUB: - if op.iotaOnLeft { - return iotaVal - op.operand - } - return op.operand - iotaVal - case token.MUL: - return iotaVal * op.operand - case token.QUO: - if op.operand != 0 { - if op.iotaOnLeft { - return iotaVal / op.operand - } - // note: integer division by iota could be 0 for large iota values - if iotaVal != 0 { - return op.operand / iotaVal - } - } - return 0 // division by zero - } - return iotaVal -} - -// ConvertLiteralToInt tries to convert a basic literal to an integer value -func ConvertLiteralToInt(lit *ast.BasicLit) (int, error) { - switch lit.Kind { - case token.INT: - var val int - if _, err := fmt.Sscanf(lit.Value, "%d", &val); err == nil { - return val, nil - } - return 0, fmt.Errorf("cannot convert %s to int", lit.Value) - case token.CHAR: - // handle character literals like 'A' - // strconv.Unquote handles all escape sequences properly - unquoted, err := strconv.Unquote(lit.Value) - if err != nil { - return 0, fmt.Errorf("cannot parse character literal %s: %w", lit.Value, err) - } - // use utf8.DecodeRuneInString for safer UTF-8 handling - r, size := utf8.DecodeRuneInString(unquoted) - if r == utf8.RuneError { - return 0, fmt.Errorf("invalid UTF-8 in character literal %s", lit.Value) - } - if size != len(unquoted) { - return 0, fmt.Errorf("character literal %s contains multiple characters", lit.Value) - } - return int(r), nil - default: - return 0, fmt.Errorf("unsupported literal kind: %v", lit.Kind) - } -} - -// EvaluateBinaryExpr evaluates binary expressions like iota + 1 -// Returns: -// - value: the computed value of the expression -// - usesIota: whether the expression uses iota -// - error: any error encountered -func EvaluateBinaryExpr(expr *ast.BinaryExpr, iotaVal int) (value int, usesIota bool, err error) { - // handle left side of expression - var leftVal int - var leftIsIota bool - - switch left := expr.X.(type) { - case *ast.Ident: - if left.Name == "iota" { - leftVal = iotaVal - leftIsIota = true - } else { - return 0, false, fmt.Errorf("unsupported identifier in binary expression: %s", left.Name) - } - case *ast.BasicLit: - var err error - leftVal, err = ConvertLiteralToInt(left) - if err != nil { - return 0, false, err - } - default: - return 0, false, fmt.Errorf("unsupported expression type on left side: %T", left) - } - - // handle right side of expression - var rightVal int - var rightIsIota bool - - switch right := expr.Y.(type) { - case *ast.Ident: - if right.Name == "iota" { - rightVal = iotaVal - rightIsIota = true - } else { - return 0, false, fmt.Errorf("unsupported identifier in binary expression: %s", right.Name) - } - case *ast.BasicLit: - var err error - rightVal, err = ConvertLiteralToInt(right) - if err != nil { - return 0, false, err - } - default: - return 0, false, fmt.Errorf("unsupported expression type on right side: %T", right) - } - - // check if expression uses iota - usesIota = leftIsIota || rightIsIota - - // evaluate the expression based on the operator - switch expr.Op { - case token.ADD: - value = leftVal + rightVal - case token.SUB: - value = leftVal - rightVal - case token.MUL: - value = leftVal * rightVal - case token.QUO: - if rightVal == 0 { - return 0, false, fmt.Errorf("division by zero") - } - value = leftVal / rightVal - default: - return 0, false, fmt.Errorf("unsupported binary operator: %v", expr.Op) - } - - return value, usesIota, nil + return nil } // Generate creates the enum code file. it takes the const values found in Parse and creates @@ -495,19 +253,20 @@ func (g *Generator) Generate() error { // 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) + valuesCounter := make(map[string][]string) // check if multiple names exist for the same value for name, cv := range g.values { - if _, ok := valuesCounter[cv.value]; !ok { - valuesCounter[cv.value] = []string{} + val := cv.value.ExactString() + if _, ok := valuesCounter[val]; !ok { + valuesCounter[val] = []string{} } - valuesCounter[cv.value] = append(valuesCounter[cv.value], name) + valuesCounter[val] = append(valuesCounter[val], name) } var errs []error for val, names := range valuesCounter { if len(names) > 1 { errs = append( - errs, fmt.Errorf("multiple names for value %d: %s", val, strings.Join(names, ", ")), + errs, fmt.Errorf("multiple names for value %s: %s", val, strings.Join(names, ", ")), ) } } @@ -543,7 +302,7 @@ func (g *Generator) Generate() error { PrivateName: privateName, PublicName: publicName, Name: titleCaser.String(nameWithoutPrefix), - Index: e.cv.value, + Index: e.cv.value.ExactString(), Aliases: e.cv.aliases, Comment: e.cv.comment, }) diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go index 1c65e52..f09c2b7 100644 --- a/internal/generator/generator_test.go +++ b/internal/generator/generator_test.go @@ -381,10 +381,10 @@ func TestGeneratorValues(t *testing.T) { err = gen.Parse("testdata") require.NoError(t, err) - assert.Equal(t, 0, gen.values["statusUnknown"].value, "unknown should be 0") - assert.Equal(t, 1, gen.values["statusActive"].value, "active should be 1") - assert.Equal(t, 2, gen.values["statusInactive"].value, "inactive should be 2") - assert.Equal(t, 3, gen.values["statusBlocked"].value, "blocked should be 3") + assert.Equal(t, int64(0), constVal(t, gen, "statusUnknown"), "unknown should be 0") + assert.Equal(t, int64(1), constVal(t, gen, "statusActive"), "active should be 1") + assert.Equal(t, int64(2), constVal(t, gen, "statusInactive"), "inactive should be 2") + assert.Equal(t, int64(3), constVal(t, gen, "statusBlocked"), "blocked should be 3") } func TestRepeatValues(t *testing.T) { @@ -396,10 +396,10 @@ func TestRepeatValues(t *testing.T) { err = gen.Parse("testdata") require.NoError(t, err) - assert.Equal(t, 10, gen.values["repeatValuesFirst"].value, "First should be 10") - assert.Equal(t, 10, gen.values["repeatValuesSecond"].value, "Second should repeat the value 10") - assert.Equal(t, 20, gen.values["repeatValuesThird"].value, "Third should be 20") - assert.Equal(t, 20, gen.values["repeatValuesFourth"].value, "Fourth should repeat the value 20") + assert.Equal(t, int64(10), constVal(t, gen, "repeatValuesFirst"), "First should be 10") + assert.Equal(t, int64(10), constVal(t, gen, "repeatValuesSecond"), "Second should repeat the value 10") + assert.Equal(t, int64(20), constVal(t, gen, "repeatValuesThird"), "Third should be 20") + assert.Equal(t, int64(20), constVal(t, gen, "repeatValuesFourth"), "Fourth should repeat the value 20") } func TestSQLNullHandling(t *testing.T) { @@ -511,9 +511,9 @@ func TestBinaryExprValues(t *testing.T) { assert.Contains(t, gen.values, "binaryExprThird", "Third value should be found") // check that values are correct (iota + 1) - assert.Equal(t, 1, gen.values["binaryExprFirst"].value, "First should be 1") - assert.Equal(t, 2, gen.values["binaryExprSecond"].value, "Second should be 2") - assert.Equal(t, 3, gen.values["binaryExprThird"].value, "Third should be 3") + assert.Equal(t, int64(1), constVal(t, gen, "binaryExprFirst"), "First should be 1") + assert.Equal(t, int64(2), constVal(t, gen, "binaryExprSecond"), "Second should be 2") + assert.Equal(t, int64(3), constVal(t, gen, "binaryExprThird"), "Third should be 3") // generate the enum and verify it contains all constants err = gen.Generate() @@ -854,10 +854,10 @@ const ( require.NoError(t, err) // verify negative value was parsed correctly - assert.Equal(t, -1, gen.values["errorCodeNone"].value) - assert.Equal(t, 0, gen.values["errorCodeOK"].value) - assert.Equal(t, 400, gen.values["errorCodeBadRequest"].value) - assert.Equal(t, 404, gen.values["errorCodeNotFound"].value) + assert.Equal(t, int64(-1), constVal(t, gen, "errorCodeNone")) + assert.Equal(t, int64(0), constVal(t, gen, "errorCodeOK")) + assert.Equal(t, int64(400), constVal(t, gen, "errorCodeBadRequest")) + assert.Equal(t, int64(404), constVal(t, gen, "errorCodeNotFound")) err = gen.Generate() require.NoError(t, err) @@ -876,7 +876,7 @@ const ( t.Run("invalid negative expression", func(t *testing.T) { tmpDir := t.TempDir() - // create enum with invalid negative expression (should default to 0) + // create enum with an expression that has no integer value enumFile := filepath.Join(tmpDir, "test.go") err := os.WriteFile(enumFile, []byte(`package test @@ -891,13 +891,9 @@ const ( gen, err := New("status", tmpDir) require.NoError(t, err) - // this should parse but the invalid value should become 0 err = gen.Parse(tmpDir) - require.NoError(t, err) - - // verify invalid negative expression defaulted to 0 - assert.Equal(t, 0, gen.values["statusInvalid"].value) - assert.Equal(t, 1, gen.values["statusOK"].value) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to evaluate value of const statusInvalid") }) } @@ -1150,9 +1146,9 @@ func TestBinaryExpressionEdgeCases(t *testing.T) { require.NoError(t, err) // check values - assert.Equal(t, 0, gen.values["mulDivTypeA"].value) - assert.Equal(t, 2, gen.values["mulDivTypeB"].value) - assert.Equal(t, 4, gen.values["mulDivTypeC"].value) + assert.Equal(t, int64(0), constVal(t, gen, "mulDivTypeA")) + assert.Equal(t, int64(2), constVal(t, gen, "mulDivTypeB")) + assert.Equal(t, int64(4), constVal(t, gen, "mulDivTypeC")) }) t.Run("right-side iota addition", func(t *testing.T) { @@ -1164,8 +1160,8 @@ func TestBinaryExpressionEdgeCases(t *testing.T) { require.NoError(t, err) // check values - assert.Equal(t, 10, gen.values["rightIotaTypeX"].value) - assert.Equal(t, 11, gen.values["rightIotaTypeY"].value) + assert.Equal(t, int64(10), constVal(t, gen, "rightIotaTypeX")) + assert.Equal(t, int64(11), constVal(t, gen, "rightIotaTypeY")) }) t.Run("subtraction with iota", func(t *testing.T) { @@ -1177,79 +1173,12 @@ func TestBinaryExpressionEdgeCases(t *testing.T) { require.NoError(t, err) // check values - assert.Equal(t, 100, gen.values["subTypeA"].value) - assert.Equal(t, 99, gen.values["subTypeB"].value) - assert.Equal(t, 98, gen.values["subTypeC"].value) + assert.Equal(t, int64(100), constVal(t, gen, "subTypeA")) + assert.Equal(t, int64(99), constVal(t, gen, "subTypeB")) + assert.Equal(t, int64(98), constVal(t, gen, "subTypeC")) }) } -func TestConvertLiteralToInt(t *testing.T) { - tests := []struct { - name string - literal *ast.BasicLit - expected int - expectErr bool - }{ - { - name: "integer literal", - literal: &ast.BasicLit{Kind: token.INT, Value: "42"}, - expected: 42, - }, - { - name: "character literal single quote", - literal: &ast.BasicLit{Kind: token.CHAR, Value: "'A'"}, - expected: 65, - }, - { - name: "character literal escape", - literal: &ast.BasicLit{Kind: token.CHAR, Value: "'\\n'"}, - expected: 10, - }, - { - name: "invalid integer format", - literal: &ast.BasicLit{Kind: token.INT, Value: "not_a_number"}, - expectErr: true, - }, - { - name: "multi-character literal", - literal: &ast.BasicLit{Kind: token.CHAR, Value: "'AB'"}, - expectErr: true, - }, - { - name: "invalid character literal", - literal: &ast.BasicLit{Kind: token.CHAR, Value: "invalid"}, - expectErr: true, - }, - { - name: "unsupported literal kind", - literal: &ast.BasicLit{Kind: token.FLOAT, Value: "3.14"}, - expectErr: true, - }, - { - name: "character literal tab", - literal: &ast.BasicLit{Kind: token.CHAR, Value: "'\\t'"}, - expected: 9, - }, - { - name: "character literal null", - literal: &ast.BasicLit{Kind: token.CHAR, Value: "'\\x00'"}, - expected: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := ConvertLiteralToInt(tt.literal) - if tt.expectErr { - require.Error(t, err) - } else { - require.NoError(t, err) - assert.Equal(t, tt.expected, result) - } - }) - } -} - func TestUnderscorePlaceholderConstants(t *testing.T) { // test that underscore placeholders are skipped tmpDir := t.TempDir() @@ -1271,9 +1200,9 @@ func TestUnderscorePlaceholderConstants(t *testing.T) { require.NoError(t, err) // check that underscore placeholders were skipped but iota still incremented - assert.Equal(t, 0, gen.values["statusFirst"].value) - assert.Equal(t, 2, gen.values["statusSecond"].value) // iota=2 (after _ at iota=1) - assert.Equal(t, 4, gen.values["statusThird"].value) // iota=4 (after _ at iota=3) + assert.Equal(t, int64(0), constVal(t, gen, "statusFirst")) + assert.Equal(t, int64(2), constVal(t, gen, "statusSecond")) // iota=2 (after _ at iota=1) + assert.Equal(t, int64(4), constVal(t, gen, "statusThird")) // iota=4 (after _ at iota=3) _, exists := gen.values["_"] assert.False(t, exists, "underscore should not be in values") } @@ -1298,10 +1227,10 @@ func TestDivisionOperationsWithIota(t *testing.T) { require.NoError(t, err) // iota/2: 0/2=0, 1/2=0, 2/2=1, 3/2=1 - assert.Equal(t, 0, gen.values["divTypeA"].value) - assert.Equal(t, 0, gen.values["divTypeB"].value) - assert.Equal(t, 1, gen.values["divTypeC"].value) - assert.Equal(t, 1, gen.values["divTypeD"].value) + assert.Equal(t, int64(0), constVal(t, gen, "divTypeA")) + assert.Equal(t, int64(0), constVal(t, gen, "divTypeB")) + assert.Equal(t, int64(1), constVal(t, gen, "divTypeC")) + assert.Equal(t, int64(1), constVal(t, gen, "divTypeD")) } func TestSubtractionWithIota(t *testing.T) { @@ -1324,11 +1253,11 @@ func TestSubtractionWithIota(t *testing.T) { err = gen.Parse(tmpDir) require.NoError(t, err) - assert.Equal(t, 10, gen.values["subTypeA"].value) - assert.Equal(t, 9, gen.values["subTypeB"].value) - assert.Equal(t, 8, gen.values["subTypeC"].value) - assert.Equal(t, 2, gen.values["subTypeD"].value) - assert.Equal(t, 3, gen.values["subTypeE"].value) + assert.Equal(t, int64(10), constVal(t, gen, "subTypeA")) + assert.Equal(t, int64(9), constVal(t, gen, "subTypeB")) + assert.Equal(t, int64(8), constVal(t, gen, "subTypeC")) + assert.Equal(t, int64(2), constVal(t, gen, "subTypeD")) + assert.Equal(t, int64(3), constVal(t, gen, "subTypeE")) } func TestEmptyConstBlock(t *testing.T) { @@ -1350,7 +1279,7 @@ func TestEmptyConstBlock(t *testing.T) { err = gen.Parse(tmpDir) require.NoError(t, err) - assert.Equal(t, 0, gen.values["emptyTypeFirst"].value) + assert.Equal(t, int64(0), constVal(t, gen, "emptyTypeFirst")) } func TestZeroBinaryExpression(t *testing.T) { @@ -1370,188 +1299,8 @@ func TestZeroBinaryExpression(t *testing.T) { err = gen.Parse(tmpDir) require.NoError(t, err) - assert.Equal(t, 0, gen.values["zeroTypeA"].value) - assert.Equal(t, 1, gen.values["zeroTypeB"].value) -} - -func TestEvaluateBinaryExpr(t *testing.T) { - tests := []struct { - name string - expr *ast.BinaryExpr - iotaVal int - expectedVal int - expectedIota bool - expectErr bool - }{ - { - name: "iota + 1", - expr: &ast.BinaryExpr{ - X: &ast.Ident{Name: "iota"}, - Op: token.ADD, - Y: &ast.BasicLit{Kind: token.INT, Value: "1"}, - }, - iotaVal: 0, - expectedVal: 1, - expectedIota: true, - }, - { - name: "iota * 2", - expr: &ast.BinaryExpr{ - X: &ast.Ident{Name: "iota"}, - Op: token.MUL, - Y: &ast.BasicLit{Kind: token.INT, Value: "2"}, - }, - iotaVal: 3, - expectedVal: 6, - expectedIota: true, - }, - { - name: "100 - iota", - expr: &ast.BinaryExpr{ - X: &ast.BasicLit{Kind: token.INT, Value: "100"}, - Op: token.SUB, - Y: &ast.Ident{Name: "iota"}, - }, - iotaVal: 2, - expectedVal: 98, - expectedIota: true, - }, - { - name: "iota - 5", - expr: &ast.BinaryExpr{ - X: &ast.Ident{Name: "iota"}, - Op: token.SUB, - Y: &ast.BasicLit{Kind: token.INT, Value: "5"}, - }, - iotaVal: 10, - expectedVal: 5, - expectedIota: true, - }, - { - name: "10 + iota", - expr: &ast.BinaryExpr{ - X: &ast.BasicLit{Kind: token.INT, Value: "10"}, - Op: token.ADD, - Y: &ast.Ident{Name: "iota"}, - }, - iotaVal: 2, - expectedVal: 12, - expectedIota: true, - }, - { - name: "iota / 2", - expr: &ast.BinaryExpr{ - X: &ast.Ident{Name: "iota"}, - Op: token.QUO, - Y: &ast.BasicLit{Kind: token.INT, Value: "2"}, - }, - iotaVal: 4, - expectedVal: 2, - expectedIota: true, - }, - { - name: "division by zero", - expr: &ast.BinaryExpr{ - X: &ast.Ident{Name: "iota"}, - Op: token.QUO, - Y: &ast.BasicLit{Kind: token.INT, Value: "0"}, - }, - iotaVal: 1, - expectErr: true, - }, - { - name: "unsupported operator", - expr: &ast.BinaryExpr{ - X: &ast.Ident{Name: "iota"}, - Op: token.REM, - Y: &ast.BasicLit{Kind: token.INT, Value: "2"}, - }, - iotaVal: 1, - expectErr: true, - }, - { - name: "unsupported left identifier", - expr: &ast.BinaryExpr{ - X: &ast.Ident{Name: "unknown"}, - Op: token.ADD, - Y: &ast.BasicLit{Kind: token.INT, Value: "1"}, - }, - iotaVal: 0, - expectErr: true, - }, - { - name: "unsupported right identifier", - expr: &ast.BinaryExpr{ - X: &ast.BasicLit{Kind: token.INT, Value: "1"}, - Op: token.ADD, - Y: &ast.Ident{Name: "unknown"}, - }, - iotaVal: 0, - expectErr: true, - }, - { - name: "invalid left literal", - expr: &ast.BinaryExpr{ - X: &ast.BasicLit{Kind: token.INT, Value: "invalid"}, - Op: token.ADD, - Y: &ast.Ident{Name: "iota"}, - }, - iotaVal: 0, - expectErr: true, - }, - { - name: "invalid right literal", - expr: &ast.BinaryExpr{ - X: &ast.Ident{Name: "iota"}, - Op: token.ADD, - Y: &ast.BasicLit{Kind: token.INT, Value: "invalid"}, - }, - iotaVal: 0, - expectErr: true, - }, - { - name: "unsupported left type", - expr: &ast.BinaryExpr{ - X: &ast.CallExpr{}, - Op: token.ADD, - Y: &ast.BasicLit{Kind: token.INT, Value: "1"}, - }, - iotaVal: 0, - expectErr: true, - }, - { - name: "unsupported right type", - expr: &ast.BinaryExpr{ - X: &ast.BasicLit{Kind: token.INT, Value: "1"}, - Op: token.ADD, - Y: &ast.CallExpr{}, - }, - iotaVal: 0, - expectErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - val, usesIota, err := EvaluateBinaryExpr(tt.expr, tt.iotaVal) - if tt.expectErr { - require.Error(t, err) - } else { - require.NoError(t, err) - assert.Equal(t, tt.expectedVal, val) - assert.Equal(t, tt.expectedIota, usesIota) - } - }) - } -} - -func TestApplyIotaOperationNil(t *testing.T) { - gen, err := New("test", "") - require.NoError(t, err) - - // test nil operation returns iotaVal unchanged - result := gen.applyIotaOperation(nil, 42) - assert.Equal(t, 42, result) + assert.Equal(t, int64(0), constVal(t, gen, "zeroTypeA")) + assert.Equal(t, int64(1), constVal(t, gen, "zeroTypeB")) } func TestDivisionByZeroInQUO(t *testing.T) { @@ -1568,36 +1317,8 @@ const ( gen, err := New("divZero", "") require.NoError(t, err) err = gen.Parse(tmpDir) - require.NoError(t, err) - - // should handle division by zero gracefully - assert.Equal(t, 0, gen.values["divZeroA"].value) -} - -func TestInvalidUTF8CharacterLiteral(t *testing.T) { - // test ConvertLiteralToInt with a hex value that is valid - lit := &ast.BasicLit{ - Kind: token.CHAR, - Value: "'\\x80'", // this is handled correctly by strconv.Unquote - } - - val, err := ConvertLiteralToInt(lit) - require.Error(t, err) // should error because \x80 is not valid UTF-8 for a char - assert.Contains(t, err.Error(), "invalid UTF-8") - assert.Equal(t, 0, val) -} - -func TestMultipleCharactersInLiteral(t *testing.T) { - // test ConvertLiteralToInt with multiple characters - lit := &ast.BasicLit{ - Kind: token.CHAR, - Value: "'ab'", // invalid: multiple characters - } - - val, err := ConvertLiteralToInt(lit) require.Error(t, err) - assert.Contains(t, err.Error(), "cannot parse character literal") - assert.Equal(t, 0, val) + assert.Contains(t, err.Error(), "division by zero") } func TestGenerateWriteFileError(t *testing.T) { @@ -1639,52 +1360,7 @@ const ( err = gen.Parse(tmpDir) require.NoError(t, err) - assert.Equal(t, 0, gen.values["emptySpecA"].value) -} - -func TestProcessExplicitValueDefaultReturn(t *testing.T) { - gen, err := New("test", "") - require.NoError(t, err) - - state := &constParseState{} - - // test with an unsupported expression type to trigger default return - expr := &ast.ParenExpr{} // unsupported type - result := gen.processExplicitValue(expr, state) - assert.Equal(t, 0, result) -} - -func TestApplyIotaOperationDefaultCase(t *testing.T) { - gen, err := New("test", "") - require.NoError(t, err) - - // test with unsupported operation to trigger default case - op := &iotaOperation{ - op: token.AND, // unsupported operation - operand: 5, - iotaOnLeft: true, - } - - result := gen.applyIotaOperation(op, 10) - assert.Equal(t, 10, result) // should return iotaVal unchanged -} - -func TestProcessBinaryExprError(t *testing.T) { - gen, err := New("test", "") - require.NoError(t, err) - - state := &constParseState{iotaVal: 5} - - // create an invalid binary expression - expr := &ast.BinaryExpr{ - X: &ast.FuncLit{}, // unsupported type - Op: token.ADD, - Y: &ast.BasicLit{Kind: token.INT, Value: "10"}, - } - - val, op := gen.processBinaryExpr(expr, state) - assert.Equal(t, 0, val) - assert.Nil(t, op) + assert.Equal(t, int64(0), constVal(t, gen, "emptySpecA")) } func TestRightSideDivisionByIota(t *testing.T) { @@ -1706,24 +1382,10 @@ const ( err = gen.Parse(tmpDir) require.NoError(t, err) - assert.Equal(t, 0, gen.values["divByIotaA"].value) - assert.Equal(t, 10, gen.values["divByIotaB"].value) - assert.Equal(t, 5, gen.values["divByIotaC"].value) - assert.Equal(t, 3, gen.values["divByIotaD"].value) -} - -func TestMultipleCharactersError(t *testing.T) { - // directly test the multiple characters check in ConvertLiteralToInt - // we need to craft a value that passes strconv.Unquote but has multiple runes - lit := &ast.BasicLit{ - Kind: token.CHAR, - Value: "'\\u0041\\u0042'", // 'AB' - two unicode characters - } - - val, err := ConvertLiteralToInt(lit) - require.Error(t, err) - assert.Contains(t, err.Error(), "character literal") - assert.Equal(t, 0, val) + assert.Equal(t, int64(0), constVal(t, gen, "divByIotaA")) + assert.Equal(t, int64(10), constVal(t, gen, "divByIotaB")) + assert.Equal(t, int64(5), constVal(t, gen, "divByIotaC")) + assert.Equal(t, int64(3), constVal(t, gen, "divByIotaD")) } func TestWriteFilePermissionError(t *testing.T) { @@ -1771,7 +1433,7 @@ func TestParseConstBlockWithImportSpec(t *testing.T) { } // this should not panic and should handle gracefully - gen.parseConstBlock(decl) + require.NoError(t, gen.parseConstBlock(decl, newConstResolver())) // no values should be added assert.Empty(t, gen.values) @@ -2216,32 +1878,3 @@ const ( // 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) - - // test division when iota is 0 and iota is on the right side - op := &iotaOperation{ - op: token.QUO, - operand: 10, - iotaOnLeft: false, // operand / iota - } - - // when iota is 0, division by zero should return 0 - result := gen.applyIotaOperation(op, 0) - assert.Equal(t, 0, result) -} - -func TestConvertLiteralToIntMultipleRunes(t *testing.T) { - // test the case where strconv.Unquote returns an error - lit := &ast.BasicLit{ - Kind: token.CHAR, - Value: "'\\U00010000\\U00010001'", // invalid: two unicode code points - } - - val, err := ConvertLiteralToInt(lit) - require.Error(t, err) - assert.Contains(t, err.Error(), "cannot parse character literal") - assert.Equal(t, 0, val) -} From 129b0d1af35728e543c71bfbca5763ad70d07613 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Wed, 19 Aug 2026 02:11:38 +0100 Subject: [PATCH 2/4] Keep the type of a converted constant, resolve names at package scope The complement of an unsigned constant depends on the width of its type, so ^perm(0) of a uint8 based enum is 255 and not -1. Values now carry the builtin type they were converted to, and a conversion reports the values that type cannot hold, matching what the compiler accepts. Constants declared inside a function are no longer collected: they are not in scope for the enum values and a local name would shadow the package level one. Arithmetic on constants with a fractional literal is evaluated exactly, as the language does, so 1.5 * 2 is 3 rather than an error, and len of a string literal is supported. --- internal/generator/constexpr.go | 300 +++++++++++++++++++-------- internal/generator/constexpr_test.go | 80 ++++++- internal/generator/generator.go | 2 +- 3 files changed, 288 insertions(+), 94 deletions(-) diff --git a/internal/generator/constexpr.go b/internal/generator/constexpr.go index ae704cf..ec8a9f7 100644 --- a/internal/generator/constexpr.go +++ b/internal/generator/constexpr.go @@ -35,109 +35,154 @@ var intTypes = map[string]intTypeInfo{ "byte": {bits: 8, signed: false}, } +// typedValue is a constant value together with the builtin integer type it carries. the type is empty +// for an untyped constant, it only becomes known through a conversion or a typed declaration, and it +// matters for ^x, which complements within the width of its operand. +type typedValue struct { + value constant.Value + typ string +} + // constDecl is a constant declaration together with the iota value in effect where it appears type constDecl struct { expr ast.Expr // expression to evaluate, inherited from the previous spec when omitted + typ string // builtin type of the declaration, empty when untyped iotaVal int64 // iota value of the spec holding this declaration } // constResolver evaluates constant expressions with go/constant, which keeps values exact and covers -// every operator the language allows on integer constants. it holds every constant declared in the -// package so enum values can reference other constants by name. +// every operator the language allows on integer constants. it holds the constants and types declared +// at package level so enum values can refer to them. type constResolver struct { - decls map[string]constDecl // constant name to its defining expression - typeNames map[string]struct{} // type names declared in the package, for conversions - cache map[string]constant.Value // already resolved constants - resolving map[string]struct{} // names being resolved, to detect reference cycles + decls map[string]constDecl // constant name to its defining expression + types map[string]string // declared type name to the type it is defined as + cache map[string]typedValue // already resolved constants + resolving map[string]struct{} // names being resolved, to detect reference cycles } // newConstResolver makes an empty resolver, files are added with addFile func newConstResolver() *constResolver { return &constResolver{ decls: map[string]constDecl{}, - typeNames: map[string]struct{}{}, - cache: map[string]constant.Value{}, + types: map[string]string{}, + cache: map[string]typedValue{}, resolving: map[string]struct{}{}, } } -// addFile records the constant and type declarations of a single file +// addFile records the package level constant and type declarations of a single file. declarations +// inside a function are left out, they are not in scope for the enum constants and would shadow the +// package level ones of the same name. func (r *constResolver) addFile(file *ast.File) { - ast.Inspect(file, func(n ast.Node) bool { - decl, ok := n.(*ast.GenDecl) + for _, d := range file.Decls { + decl, ok := d.(*ast.GenDecl) if !ok { - return true + continue } switch decl.Tok { case token.TYPE: for _, spec := range decl.Specs { - if tspec, ok := spec.(*ast.TypeSpec); ok { - r.typeNames[tspec.Name.Name] = struct{}{} + tspec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + if ident, ok := tspec.Type.(*ast.Ident); ok { + r.types[tspec.Name.Name] = ident.Name } } case token.CONST: r.addConstBlock(decl) } - return false - }) + } } // addConstBlock records every constant of a single const block. a spec without an expression repeats // the expression list of the previous spec, which is how iota based blocks are defined by the language. func (r *constResolver) addConstBlock(decl *ast.GenDecl) { var last []ast.Expr + var lastType ast.Expr for i, spec := range decl.Specs { vspec, ok := spec.(*ast.ValueSpec) if !ok { continue } if len(vspec.Values) > 0 { - last = vspec.Values + last, lastType = vspec.Values, vspec.Type + } + typ := "" + if ident, ok := lastType.(*ast.Ident); ok { + typ = ident.Name } for j, name := range vspec.Names { if name.Name == "_" || j >= len(last) { continue } - // the first declaration wins, a name may be repeated by a block in another scope if _, ok := r.decls[name.Name]; ok { - continue + continue // a name declared twice at package level does not compile, keep the first } - r.decls[name.Name] = constDecl{expr: last[j], iotaVal: int64(i)} + r.decls[name.Name] = constDecl{expr: last[j], typ: typ, iotaVal: int64(i)} + } + } +} + +// builtinOf resolves a type name to the builtin integer type it is defined as, empty when it is not +// an integer type or the definition is not visible +func (r *constResolver) builtinOf(name string) string { + for i := 0; name != "" && i < 10; i++ { + if _, ok := intTypes[name]; ok { + return name } + name = r.types[name] } + return "" } // resolve evaluates a constant by name -func (r *constResolver) resolve(name string) (constant.Value, error) { +func (r *constResolver) resolve(name string) (typedValue, error) { if v, ok := r.cache[name]; ok { return v, nil } decl, ok := r.decls[name] if !ok { - return nil, fmt.Errorf("unknown constant %s", name) + return typedValue{}, fmt.Errorf("unknown constant %s", name) } if _, ok := r.resolving[name]; ok { - return nil, fmt.Errorf("constant %s refers to itself", name) + return typedValue{}, fmt.Errorf("constant %s refers to itself", name) } r.resolving[name] = struct{}{} defer delete(r.resolving, name) v, err := r.eval(decl.expr, decl.iotaVal) if err != nil { - return nil, fmt.Errorf("constant %s: %w", name, err) + return typedValue{}, fmt.Errorf("constant %s: %w", name, err) + } + if typ := r.builtinOf(decl.typ); typ != "" { + if v, err = r.convert(v, typ); err != nil { + return typedValue{}, fmt.Errorf("constant %s: %w", name, err) + } } r.cache[name] = v return v, nil } +// evalInt evaluates a constant expression and returns its exact integer value +func (r *constResolver) evalInt(expr ast.Expr, iotaVal int64) (constant.Value, error) { + v, err := r.eval(expr, iotaVal) + if err != nil { + return nil, err + } + return toInt(v.value) +} + // eval evaluates a constant expression with the given iota value -func (r *constResolver) eval(expr ast.Expr, iotaVal int64) (constant.Value, error) { +func (r *constResolver) eval(expr ast.Expr, iotaVal int64) (typedValue, error) { switch e := expr.(type) { case *ast.BasicLit: - return literalValue(e) + v, err := literalValue(e) + return typedValue{value: v}, err case *ast.Ident: if e.Name == "iota" { - return constant.MakeInt64(iotaVal), nil + return typedValue{value: constant.MakeInt64(iotaVal)}, nil } return r.resolve(e.Name) case *ast.ParenExpr: @@ -147,35 +192,44 @@ func (r *constResolver) eval(expr ast.Expr, iotaVal int64) (constant.Value, erro case *ast.BinaryExpr: return r.evalBinary(e, iotaVal) case *ast.CallExpr: - return r.evalConversion(e, iotaVal) + return r.evalCall(e, iotaVal) } - return nil, fmt.Errorf("unsupported expression %T", expr) + return typedValue{}, fmt.Errorf("unsupported expression %T", expr) } -// evalUnary evaluates +x, -x and ^x -func (r *constResolver) evalUnary(e *ast.UnaryExpr, iotaVal int64) (constant.Value, error) { +// evalUnary evaluates +x, -x and ^x. the complement of a typed unsigned operand is taken within the +// width of its type, so ^perm(0) of a uint8 based type is 255 rather than -1 +func (r *constResolver) evalUnary(e *ast.UnaryExpr, iotaVal int64) (typedValue, error) { x, err := r.eval(e.X, iotaVal) if err != nil { - return nil, err - } - if x, err = toInt(x); err != nil { - return nil, err + return typedValue{}, err } + switch e.Op { - case token.ADD, token.SUB, token.XOR: - return constant.UnaryOp(e.Op, x, 0), nil + case token.ADD, token.SUB: + if err := requireNumeric(x.value); err != nil { + return typedValue{}, err + } + return typedValue{value: constant.UnaryOp(e.Op, x.value, 0), typ: x.typ}, nil + case token.XOR: + v, err := toInt(x.value) + if err != nil { + return typedValue{}, err + } + prec := 0 + if info, ok := intTypes[x.typ]; ok && !info.signed { + prec = info.bits + } + return typedValue{value: constant.UnaryOp(e.Op, v, uint(prec)), typ: x.typ}, nil } - return nil, fmt.Errorf("unsupported unary operator %s", e.Op) + return typedValue{}, fmt.Errorf("unsupported unary operator %s", e.Op) } // evalBinary evaluates the arithmetic and bitwise operators defined for integer constants -func (r *constResolver) evalBinary(e *ast.BinaryExpr, iotaVal int64) (constant.Value, error) { +func (r *constResolver) evalBinary(e *ast.BinaryExpr, iotaVal int64) (typedValue, error) { x, err := r.eval(e.X, iotaVal) if err != nil { - return nil, err - } - if x, err = toInt(x); err != nil { - return nil, err + return typedValue{}, err } if e.Op == token.SHL || e.Op == token.SHR { @@ -184,69 +238,132 @@ func (r *constResolver) evalBinary(e *ast.BinaryExpr, iotaVal int64) (constant.V y, err := r.eval(e.Y, iotaVal) if err != nil { - return nil, err + return typedValue{}, err } - if y, err = toInt(y); err != nil { - return nil, err + + typ := x.typ + if typ == "" { + typ = y.typ } switch e.Op { - case token.ADD, token.SUB, token.MUL, token.AND, token.OR, token.XOR, token.AND_NOT: - return constant.BinaryOp(x, e.Op, y), nil - case token.QUO, token.REM: - if constant.Sign(y) == 0 { - return nil, fmt.Errorf("division by zero") + case token.ADD, token.SUB, token.MUL, token.QUO: + if err := requireNumeric(x.value); err != nil { + return typedValue{}, err } - if e.Op == token.REM { - return constant.BinaryOp(x, token.REM, y), nil + if err := requireNumeric(y.value); err != nil { + return typedValue{}, err } - // QUO_ASSIGN keeps the result an integer, plain QUO on two integers yields a rational - return constant.BinaryOp(x, token.QUO_ASSIGN, y), nil + if e.Op != token.QUO { + return typedValue{value: constant.BinaryOp(x.value, e.Op, y.value), typ: typ}, nil + } + if constant.Sign(y.value) == 0 { + return typedValue{}, fmt.Errorf("division by zero") + } + op := e.Op + if x.value.Kind() == constant.Int && y.value.Kind() == constant.Int { + op = token.QUO_ASSIGN // division of two integers is integer division, plain QUO yields a rational + } + return typedValue{value: constant.BinaryOp(x.value, op, y.value), typ: typ}, nil + case token.REM, token.AND, token.OR, token.XOR, token.AND_NOT: + xv, err := toInt(x.value) + if err != nil { + return typedValue{}, err + } + yv, err := toInt(y.value) + if err != nil { + return typedValue{}, err + } + if e.Op == token.REM && constant.Sign(yv) == 0 { + return typedValue{}, fmt.Errorf("division by zero") + } + return typedValue{value: constant.BinaryOp(xv, e.Op, yv), typ: typ}, nil } - return nil, fmt.Errorf("unsupported binary operator %s", e.Op) + return typedValue{}, fmt.Errorf("unsupported binary operator %s", e.Op) } -// evalShift evaluates x << n and x >> n, x is already known to be an integer -func (r *constResolver) evalShift(e *ast.BinaryExpr, x constant.Value, iotaVal int64) (constant.Value, error) { - y, err := r.eval(e.Y, iotaVal) +// evalShift evaluates x << n and x >> n +func (r *constResolver) evalShift(e *ast.BinaryExpr, x typedValue, iotaVal int64) (typedValue, error) { + xv, err := toInt(x.value) if err != nil { - return nil, err + return typedValue{}, err } - if y, err = toInt(y); err != nil { - return nil, err + y, err := r.evalInt(e.Y, iotaVal) + if err != nil { + return typedValue{}, err } if constant.Sign(y) < 0 { - return nil, fmt.Errorf("negative shift count %s", y.ExactString()) + return typedValue{}, fmt.Errorf("negative shift count %s", y.ExactString()) } count, exact := constant.Uint64Val(y) if !exact || count > maxShiftCount { - return nil, fmt.Errorf("shift count %s is too large", y.ExactString()) + return typedValue{}, fmt.Errorf("shift count %s is too large", y.ExactString()) } - return constant.Shift(x, e.Op, uint(count)), nil + return typedValue{value: constant.Shift(xv, e.Op, uint(count)), typ: x.typ}, nil } -// evalConversion evaluates a single argument conversion such as status(3) or uint8(1 << 2) -func (r *constResolver) evalConversion(e *ast.CallExpr, iotaVal int64) (constant.Value, error) { - ident, ok := e.Fun.(*ast.Ident) +// evalCall evaluates a conversion such as status(3) or uint8(1 << 2), and len of a string literal +func (r *constResolver) evalCall(e *ast.CallExpr, iotaVal int64) (typedValue, error) { + ident, ok := unparen(e.Fun).(*ast.Ident) if !ok || len(e.Args) != 1 { - return nil, fmt.Errorf("unsupported call expression") + return typedValue{}, fmt.Errorf("unsupported call expression") + } + + if ident.Name == "len" { + lit, ok := unparen(e.Args[0]).(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return typedValue{}, fmt.Errorf("len is only supported for a string literal") + } + s, err := strconv.Unquote(lit.Value) + if err != nil { + return typedValue{}, fmt.Errorf("invalid literal %s", lit.Value) + } + return typedValue{value: constant.MakeInt64(int64(len(s)))}, nil } - _, declared := r.typeNames[ident.Name] + + _, declared := r.types[ident.Name] if _, builtin := intTypes[ident.Name]; !declared && !builtin { - return nil, fmt.Errorf("unsupported call to %s", ident.Name) + return typedValue{}, fmt.Errorf("unsupported call to %s", ident.Name) } v, err := r.eval(e.Args[0], iotaVal) if err != nil { - return nil, err + return typedValue{}, err + } + typ := r.builtinOf(ident.Name) + if typ == "" { + return typedValue{}, fmt.Errorf("%s is not an integer type", ident.Name) + } + return r.convert(v, typ) +} + +// unparen strips the parentheses around an expression +func unparen(expr ast.Expr) ast.Expr { + for { + p, ok := expr.(*ast.ParenExpr) + if !ok { + return expr + } + expr = p.X } - return toInt(v) +} + +// convert gives a value the named builtin type, reporting the values it cannot hold +func (r *constResolver) convert(v typedValue, typ string) (typedValue, error) { + iv, err := toInt(v.value) + if err != nil { + return typedValue{}, err + } + if err := checkIntRange(iv, typ); err != nil { + return typedValue{}, err + } + return typedValue{value: iv, typ: typ}, nil } // literalValue converts an integer or character literal, covering every base and digit separator // the language allows func literalValue(lit *ast.BasicLit) (constant.Value, error) { switch lit.Kind { - case token.INT: + case token.INT, token.FLOAT: v := constant.MakeFromLiteral(lit.Value, lit.Kind, 0) if v.Kind() == constant.Unknown { return nil, fmt.Errorf("invalid literal %s", lit.Value) @@ -264,11 +381,20 @@ func literalValue(lit *ast.BasicLit) (constant.Value, error) { } return constant.MakeInt64(int64(r)), nil } - return nil, fmt.Errorf("literal %s is not an integer", lit.Value) + return nil, fmt.Errorf("literal %s is not a number", lit.Value) +} + +// requireNumeric rejects values arithmetic is not defined for, go/constant panics on operands of +// mismatched kinds so everything is checked before it reaches an operator +func requireNumeric(v constant.Value) error { + switch v.Kind() { + case constant.Int, constant.Float: + return nil + } + return fmt.Errorf("value %s is not a number", v.String()) } -// toInt converts a value to an integer, go/constant panics on operands of mismatched kinds so -// everything is checked before it reaches an operator +// toInt converts a value to an integer, a fractional or non numeric value has no integer form func toInt(v constant.Value) (constant.Value, error) { iv := constant.ToInt(v) if iv.Kind() != constant.Int { @@ -277,34 +403,34 @@ func toInt(v constant.Value) (constant.Value, error) { return iv, nil } -// checkIntRange reports whether a value fits the underlying type of the enum. an out of range value -// would produce generated code that does not compile. unknown type names are left alone. -func checkIntRange(v constant.Value, underlyingType string) error { - if underlyingType == "" { - underlyingType = "int" // the template falls back to int when the type has no explicit underlying type +// checkIntRange reports whether a value fits the given integer type. an out of range value would +// produce generated code that does not compile. unknown type names are left alone. +func checkIntRange(v constant.Value, typ string) error { + if typ == "" { + typ = "int" // the template falls back to int when the type has no explicit underlying type } - info, ok := intTypes[underlyingType] + info, ok := intTypes[typ] if !ok { return nil } if !info.signed { if constant.Sign(v) < 0 { - return fmt.Errorf("value %s is negative but the type is %s", v.ExactString(), underlyingType) + return fmt.Errorf("value %s is negative but the type is %s", v.ExactString(), typ) } n, exact := constant.Uint64Val(v) if !exact || (info.bits < 64 && n >= uint64(1)< 1<<(info.bits-1)-1) { - return fmt.Errorf("value %s overflows %s", v.ExactString(), underlyingType) + return fmt.Errorf("value %s overflows %s", v.ExactString(), typ) } return nil } diff --git a/internal/generator/constexpr_test.go b/internal/generator/constexpr_test.go index 7cb0183..bdabce2 100644 --- a/internal/generator/constexpr_test.go +++ b/internal/generator/constexpr_test.go @@ -23,14 +23,18 @@ func constVal(t *testing.T, gen *Generator, name string) int64 { return v } -// resolveSrc parses a go source and resolves a single constant from it +// resolveSrc parses a go source and resolves a single constant from it to its integer value func resolveSrc(t *testing.T, src, name string) (constant.Value, error) { t.Helper() file, err := parser.ParseFile(token.NewFileSet(), "src.go", src, parser.ParseComments) require.NoError(t, err) r := newConstResolver() r.addFile(file) - return r.resolve(name) + v, err := r.resolve(name) + if err != nil { + return nil, err + } + return toInt(v.value) } func TestConstResolverValues(t *testing.T) { @@ -67,6 +71,18 @@ func TestConstResolverValues(t *testing.T) { {"deeply nested", "((1 << 3) | (1 << 1)) - 2", "8"}, {"conversion builtin", "uint8(3)", "3"}, {"conversion nested", "uint16(1 << 9)", "512"}, + {"complement of unsigned", "^uint8(0)", "255"}, + {"complement of uint64", "^uint64(0)", "18446744073709551615"}, + {"complement of signed", "^int8(0)", "-1"}, + {"complement of untyped", "^0", "-1"}, + {"float with integer value", "1.5 * 2", "3"}, + {"float division", "5.0 / 2 * 2", "5"}, + {"len of a string", `len("abc")`, "3"}, + {"len with parentheses", `(len)(("abc"))`, "3"}, + {"conversion with parentheses", "(uint8)(7)", "7"}, + {"character arithmetic", "'a' + 1", "98"}, + {"right shift of a negative", "-4 >> 1", "-2"}, + {"beyond int64", "1<<62*4 - 1", "18446744073709551615"}, } for _, tt := range tests { @@ -88,12 +104,17 @@ func TestConstResolverErrors(t *testing.T) { {"remainder by zero", "const x = 1 % 0", "division by zero"}, {"negative shift", "const x = 1 << -1", "negative shift count"}, {"huge shift", "const x = 1 << 100000", "too large"}, - {"string literal", `const x = "str"`, "not an integer"}, + {"string literal", `const x = "str"`, "not a number"}, {"float literal", "const x = 3.14", "not an integer"}, - {"float expression", "const x = 1.5 + 1.5", "not an integer"}, + {"fractional expression", "const x = 7.0 / 2", "not an integer"}, + {"fractional sum", "const x = 1 + 1.5", "not an integer"}, {"unknown constant", "const x = missing + 1", "unknown constant missing"}, {"package reference", "const x = math.MaxInt8", "unsupported expression"}, - {"function call", `const x = len("ab")`, "unsupported call to len"}, + {"len of a constant", "const s = \"ab\"\nconst x = len(s)", "len is only supported"}, + {"unsupported function", "const x = min(1, 2)", "unsupported call expression"}, + {"conversion out of range", "const x = uint8(300)", "overflows uint8"}, + {"conversion of a negative", "const x = uint8(-1)", "negative"}, + {"typed declaration out of range", "const x int8 = 200", "overflows int8"}, {"comparison operator", "const x = 1 < 2", "unsupported binary operator"}, {"self reference", "const x = x + 1", "refers to itself"}, {"reference cycle", "const (\n\tx = y\n\ty = x\n)", "refers to itself"}, @@ -151,6 +172,53 @@ const ( } } +func TestConstResolverTypedConstants(t *testing.T) { + src := `package p + +type flag uint8 + +const ( + flagNone flag = 0 + flagOne flag = 1 +) + +const ( + all = ^flagNone // 255, the complement is taken within uint8 + notOne = ^flagOne // 254 + untyped = ^0 // -1, no type to complement within + wide = ^myUint64(0) // 18446744073709551615 +) + +type myUint64 = uint64 +` + for name, expected := range map[string]string{"all": "255", "notOne": "254", "untyped": "-1", "wide": "18446744073709551615"} { + v, err := resolveSrc(t, src, name) + require.NoError(t, err, name) + assert.Equal(t, expected, v.ExactString(), name) + } +} + +func TestConstResolverIgnoresLocalConstants(t *testing.T) { + // a constant declared inside a function is out of scope for the enum values + src := `package p + +const base = 1 + +func f() { + const base = 2 + _ = base +} + +const derived = base + 10 +` + v, err := resolveSrc(t, src, "derived") + require.NoError(t, err) + assert.Equal(t, "11", v.ExactString()) + + _, err = resolveSrc(t, src, "missing") + require.Error(t, err) +} + func TestCheckIntRange(t *testing.T) { tests := []struct { name string @@ -211,7 +279,7 @@ func TestConstResolverUnsupportedNodes(t *testing.T) { _, err = literalValue(&ast.BasicLit{Kind: token.STRING, Value: `"str"`}) require.Error(t, err) - assert.Contains(t, err.Error(), "not an integer") + assert.Contains(t, err.Error(), "not a number") _, err = r.resolve("nothing") require.Error(t, err) diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 3500235..7102391 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -216,7 +216,7 @@ func (g *Generator) parseConstBlock(decl *ast.GenDecl, resolver *constResolver) return fmt.Errorf("no value for const %s", name.Name) } - value, err := resolver.eval(lastValues[i], int64(iotaVal)) + value, err := resolver.evalInt(lastValues[i], int64(iotaVal)) if err != nil { return fmt.Errorf("failed to evaluate value of const %s: %w", name.Name, err) } From af247850e7869886fb557e372299298c77a77b6d Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Wed, 19 Aug 2026 02:23:27 +0100 Subject: [PATCH 3/4] Follow the number type of a constant through the expression A typed operand decides the type of an operation, so x / 2.0 is an integer division when x is a typed integer, and a value reached through a float keeps its exact result. A typed float holds its value at the width of its type, both after a conversion and after each operation. Conversions to float and string are evaluated rather than refused, len works on any string constant, min and max are evaluated, and a type declared by the package is followed before a builtin of the same name, through an alias chain of any length. A right shift is no longer capped: shifting past the width of a value settles at 0, or -1 when it is negative, which is what the compiler computes. The cap on a left shift stays, it bounds the size of the value being built. The complement of uint and uintptr is refused, it is a different value on a 32 and a 64 bit target while the generated file holds one number. --- README.md | 6 +- internal/generator/constexpr.go | 219 ++++++++++++++++++++++----- internal/generator/constexpr_test.go | 70 ++++++++- 3 files changed, 253 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 9594544..3a5a568 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,9 @@ By default the generated type supports `encoding.TextMarshaler`/`Unmarshaler` (u ### Constant Values -Enum constants may use any integer constant expression the language allows: `iota` with arithmetic, shifts and bitwise -operators, literals in any base, references to other constants of the same package, and conversions. As in Go, a constant -without an expression repeats the expression of the preceding one. +Enum constants may be written as constant expressions: `iota` with arithmetic, shifts and bitwise operators, literals in +any base, `len`, `min` and `max`, references to other constants of the same package, and conversions. As in Go, a +constant without an expression repeats the expression of the preceding one. ```go type permission uint8 diff --git a/internal/generator/constexpr.go b/internal/generator/constexpr.go index ec8a9f7..68c6d36 100644 --- a/internal/generator/constexpr.go +++ b/internal/generator/constexpr.go @@ -5,11 +5,14 @@ import ( "go/ast" "go/constant" "go/token" + "math/big" "strconv" + "unicode/utf8" ) -// maxShiftCount caps shift expressions so a malformed source can't ask for an enormous allocation -const maxShiftCount = 512 +// maxShiftLeft caps a left shift so a malformed source can't ask for an enormous allocation. a right +// shift needs no cap, shifting past the width of a value settles at 0 or -1. +const maxShiftLeft = 512 // intTypeInfo describes the width and signedness of a builtin integer type type intTypeInfo struct { @@ -43,6 +46,14 @@ type typedValue struct { typ string } +// archSizedUnsigned are the unsigned types whose width is set by the target architecture, which makes +// their complement a different value on a 32 and a 64 bit target +var archSizedUnsigned = map[string]bool{"uint": true, "uintptr": true} + +// floatTypes are the builtin floating point types, they take part in constant arithmetic but the +// value of an enum has to come out of it as an integer +var floatTypes = map[string]bool{"float32": true, "float64": true} + // constDecl is a constant declaration together with the iota value in effect where it appears type constDecl struct { expr ast.Expr // expression to evaluate, inherited from the previous spec when omitted @@ -86,7 +97,7 @@ func (r *constResolver) addFile(file *ast.File) { if !ok { continue } - if ident, ok := tspec.Type.(*ast.Ident); ok { + if ident, ok := unparen(tspec.Type).(*ast.Ident); ok { r.types[tspec.Name.Name] = ident.Name } } @@ -110,7 +121,7 @@ func (r *constResolver) addConstBlock(decl *ast.GenDecl) { last, lastType = vspec.Values, vspec.Type } typ := "" - if ident, ok := lastType.(*ast.Ident); ok { + if ident, ok := unparen(lastType).(*ast.Ident); ok { typ = ident.Name } for j, name := range vspec.Names { @@ -125,14 +136,27 @@ func (r *constResolver) addConstBlock(decl *ast.GenDecl) { } } -// builtinOf resolves a type name to the builtin integer type it is defined as, empty when it is not -// an integer type or the definition is not visible +// builtinOf resolves a type name to the builtin number type it is defined as, empty when it is not a +// number type or the definition is not visible. a type declared by the package is followed first, it +// shadows a builtin of the same name. func (r *constResolver) builtinOf(name string) string { - for i := 0; name != "" && i < 10; i++ { + seen := map[string]struct{}{} + for name != "" { + if _, ok := seen[name]; ok { + return "" // a cycle, which does not compile either + } + seen[name] = struct{}{} + if next, ok := r.types[name]; ok { + name = next + continue + } if _, ok := intTypes[name]; ok { return name } - name = r.types[name] + if floatTypes[name] || name == "string" { + return name + } + return "" } return "" } @@ -218,6 +242,11 @@ func (r *constResolver) evalUnary(e *ast.UnaryExpr, iotaVal int64) (typedValue, } prec := 0 if info, ok := intTypes[x.typ]; ok && !info.signed { + if archSizedUnsigned[x.typ] { + // ^uint(0) is 2^32-1 on a 32 bit target and 2^64-1 on a 64 bit one, there is no + // single value to write into the generated code + return typedValue{}, fmt.Errorf("complement of %s depends on the target architecture", x.typ) + } prec = info.bits } return typedValue{value: constant.UnaryOp(e.Op, v, uint(prec)), typ: x.typ}, nil @@ -241,11 +270,26 @@ func (r *constResolver) evalBinary(e *ast.BinaryExpr, iotaVal int64) (typedValue return typedValue{}, err } + if e.Op == token.ADD && x.value.Kind() == constant.String && y.value.Kind() == constant.String { + return typedValue{value: constant.BinaryOp(x.value, e.Op, y.value)}, nil + } + typ := x.typ if typ == "" { typ = y.typ } + // a typed operand decides the type of the operation, the other one is converted to it. that is + // what makes x / 2.0 an integer division when x is a typed integer. + if typ != "" && typ != "string" { + if x, err = r.convert(x, typ); err != nil { + return typedValue{}, err + } + if y, err = r.convert(y, typ); err != nil { + return typedValue{}, err + } + } + switch e.Op { case token.ADD, token.SUB, token.MUL, token.QUO: if err := requireNumeric(x.value); err != nil { @@ -254,17 +298,20 @@ func (r *constResolver) evalBinary(e *ast.BinaryExpr, iotaVal int64) (typedValue if err := requireNumeric(y.value); err != nil { return typedValue{}, err } - if e.Op != token.QUO { - return typedValue{value: constant.BinaryOp(x.value, e.Op, y.value), typ: typ}, nil - } - if constant.Sign(y.value) == 0 { - return typedValue{}, fmt.Errorf("division by zero") - } op := e.Op - if x.value.Kind() == constant.Int && y.value.Kind() == constant.Int { - op = token.QUO_ASSIGN // division of two integers is integer division, plain QUO yields a rational + if e.Op == token.QUO { + if constant.Sign(y.value) == 0 { + return typedValue{}, fmt.Errorf("division by zero") + } + if x.value.Kind() == constant.Int && y.value.Kind() == constant.Int { + op = token.QUO_ASSIGN // division of two integers is integer division, plain QUO yields a rational + } + } + v := constant.BinaryOp(x.value, op, y.value) + if floatTypes[typ] { + v = roundFloat(v, typ) // a typed float holds every result at the width of its type } - return typedValue{value: constant.BinaryOp(x.value, op, y.value), typ: typ}, nil + return typedValue{value: v, typ: typ}, nil case token.REM, token.AND, token.OR, token.XOR, token.AND_NOT: xv, err := toInt(x.value) if err != nil { @@ -296,46 +343,121 @@ func (r *constResolver) evalShift(e *ast.BinaryExpr, x typedValue, iotaVal int64 return typedValue{}, fmt.Errorf("negative shift count %s", y.ExactString()) } count, exact := constant.Uint64Val(y) - if !exact || count > maxShiftCount { + switch { + case e.Op == token.SHL && (!exact || count > maxShiftLeft): return typedValue{}, fmt.Errorf("shift count %s is too large", y.ExactString()) + case e.Op == token.SHR: + // a shift wider than the value itself keeps its result, clamp it to stay in range of uint + if width := bitLen(xv) + 1; !exact || count > width { + count = width + } } return typedValue{value: constant.Shift(xv, e.Op, uint(count)), typ: x.typ}, nil } -// evalCall evaluates a conversion such as status(3) or uint8(1 << 2), and len of a string literal +// evalCall evaluates a conversion such as status(3) or uint8(1 << 2), and the constant builtins func (r *constResolver) evalCall(e *ast.CallExpr, iotaVal int64) (typedValue, error) { ident, ok := unparen(e.Fun).(*ast.Ident) - if !ok || len(e.Args) != 1 { + if !ok { return typedValue{}, fmt.Errorf("unsupported call expression") } - if ident.Name == "len" { - lit, ok := unparen(e.Args[0]).(*ast.BasicLit) - if !ok || lit.Kind != token.STRING { - return typedValue{}, fmt.Errorf("len is only supported for a string literal") + if !r.shadowed(ident.Name) { + switch ident.Name { + case "len": + return r.evalLen(e, iotaVal) + case "min", "max": + return r.evalMinMax(e, ident.Name, iotaVal) } - s, err := strconv.Unquote(lit.Value) - if err != nil { - return typedValue{}, fmt.Errorf("invalid literal %s", lit.Value) - } - return typedValue{value: constant.MakeInt64(int64(len(s)))}, nil } - _, declared := r.types[ident.Name] - if _, builtin := intTypes[ident.Name]; !declared && !builtin { + if len(e.Args) != 1 { + return typedValue{}, fmt.Errorf("unsupported call expression") + } + typ := r.builtinOf(ident.Name) + if typ == "" { return typedValue{}, fmt.Errorf("unsupported call to %s", ident.Name) } v, err := r.eval(e.Args[0], iotaVal) if err != nil { return typedValue{}, err } - typ := r.builtinOf(ident.Name) - if typ == "" { - return typedValue{}, fmt.Errorf("%s is not an integer type", ident.Name) - } return r.convert(v, typ) } +// shadowed reports whether the package declares something of its own under a predeclared name +func (r *constResolver) shadowed(name string) bool { + if _, ok := r.types[name]; ok { + return true + } + _, ok := r.decls[name] + return ok +} + +// evalLen evaluates len of a string constant +func (r *constResolver) evalLen(e *ast.CallExpr, iotaVal int64) (typedValue, error) { + if len(e.Args) != 1 { + return typedValue{}, fmt.Errorf("len takes a single argument") + } + v, err := r.eval(e.Args[0], iotaVal) + if err != nil { + return typedValue{}, err + } + if v.value.Kind() != constant.String { + return typedValue{}, fmt.Errorf("len is only supported for a string constant") + } + return typedValue{value: constant.MakeInt64(int64(len(constant.StringVal(v.value))))}, nil +} + +// evalMinMax evaluates the min and max builtins over constant arguments +func (r *constResolver) evalMinMax(e *ast.CallExpr, name string, iotaVal int64) (typedValue, error) { + if len(e.Args) == 0 { + return typedValue{}, fmt.Errorf("%s takes at least one argument", name) + } + op := token.LSS + if name == "max" { + op = token.GTR + } + + var best typedValue + for i, arg := range e.Args { + v, err := r.eval(arg, iotaVal) + if err != nil { + return typedValue{}, err + } + if err := requireNumeric(v.value); err != nil { + return typedValue{}, err + } + if i == 0 || constant.Compare(v.value, op, best.value) { + best = v + } + } + return best, nil +} + +// roundFloat drops the precision a float type cannot hold, the compiler stores a typed float +// constant at the width of its type +func roundFloat(v constant.Value, typ string) constant.Value { + if typ == "float32" { + f, _ := constant.Float32Val(v) + return constant.MakeFloat64(float64(f)) + } + f, _ := constant.Float64Val(v) + return constant.MakeFloat64(f) +} + +// bitLen is the number of bits an integer value occupies +func bitLen(v constant.Value) uint64 { + i, ok := constant.Val(v).(*big.Int) + if !ok { + return 64 // anything go/constant keeps as an int64 + } + if n := i.BitLen(); n > 0 { + return uint64(n) + } + return 0 +} + // unparen strips the parentheses around an expression func unparen(expr ast.Expr) ast.Expr { for { @@ -349,6 +471,27 @@ func unparen(expr ast.Expr) ast.Expr { // convert gives a value the named builtin type, reporting the values it cannot hold func (r *constResolver) convert(v typedValue, typ string) (typedValue, error) { + if typ == "string" { + if v.value.Kind() == constant.Int { + // converting a number to a string gives the utf-8 encoding of that code point + n, ok := constant.Int64Val(v.value) + if !ok || n < 0 || n > utf8.MaxRune { + n = utf8.RuneError + } + return typedValue{value: constant.MakeString(string(rune(n))), typ: typ}, nil + } + if v.value.Kind() != constant.String { + return typedValue{}, fmt.Errorf("value %s is not a string", v.value.String()) + } + return typedValue{value: v.value, typ: typ}, nil + } + if floatTypes[typ] { + f := constant.ToFloat(v.value) + if f.Kind() == constant.Unknown { + return typedValue{}, fmt.Errorf("value %s is not a number", v.value.String()) + } + return typedValue{value: roundFloat(f, typ), typ: typ}, nil + } iv, err := toInt(v.value) if err != nil { return typedValue{}, err @@ -369,6 +512,12 @@ func literalValue(lit *ast.BasicLit) (constant.Value, error) { return nil, fmt.Errorf("invalid literal %s", lit.Value) } return v, nil + case token.STRING: + v := constant.MakeFromLiteral(lit.Value, lit.Kind, 0) + if v.Kind() == constant.Unknown { + return nil, fmt.Errorf("invalid literal %s", lit.Value) + } + return v, nil case token.CHAR: // go/constant ignores anything after the first character of a rune literal, unquote it here // instead so a literal holding more than one character is rejected diff --git a/internal/generator/constexpr_test.go b/internal/generator/constexpr_test.go index bdabce2..4ded83b 100644 --- a/internal/generator/constexpr_test.go +++ b/internal/generator/constexpr_test.go @@ -74,6 +74,7 @@ func TestConstResolverValues(t *testing.T) { {"complement of unsigned", "^uint8(0)", "255"}, {"complement of uint64", "^uint64(0)", "18446744073709551615"}, {"complement of signed", "^int8(0)", "-1"}, + {"complement of int", "^int(0)", "-1"}, {"complement of untyped", "^0", "-1"}, {"float with integer value", "1.5 * 2", "3"}, {"float division", "5.0 / 2 * 2", "5"}, @@ -83,6 +84,18 @@ func TestConstResolverValues(t *testing.T) { {"character arithmetic", "'a' + 1", "98"}, {"right shift of a negative", "-4 >> 1", "-2"}, {"beyond int64", "1<<62*4 - 1", "18446744073709551615"}, + {"shift right past the width", "1 >> 1000", "0"}, + {"shift right of a negative past the width", "-1 >> 1000", "-1"}, + {"float conversion", "1 / float64(2) * 6", "3"}, + {"len of a concatenation", `len("a" + "b")`, "2"}, + {"typed integer and float operand", "uint8(5) & 3.0", "1"}, + {"min", "min(0, 1)", "0"}, + {"max", "max(3, 2, 7)", "7"}, + {"min of mixed kinds", "min(1, 2.5)", "1"}, + {"float32 rounds", "int(float32(16777217))", "16777216"}, + {"float64 rounds", "int(float64(1<<62 + 1))", "4611686018427387904"}, + {"typed float arithmetic", "int(float32(1) / 3 * 3)", "1"}, + {"string of a code point", `len(string(0x100))`, "2"}, } for _, tt := range tests { @@ -104,17 +117,20 @@ func TestConstResolverErrors(t *testing.T) { {"remainder by zero", "const x = 1 % 0", "division by zero"}, {"negative shift", "const x = 1 << -1", "negative shift count"}, {"huge shift", "const x = 1 << 100000", "too large"}, - {"string literal", `const x = "str"`, "not a number"}, + {"string literal", `const x = "str"`, "not an integer"}, + {"len of a number", "const x = len(1)", "only supported for a string"}, {"float literal", "const x = 3.14", "not an integer"}, {"fractional expression", "const x = 7.0 / 2", "not an integer"}, {"fractional sum", "const x = 1 + 1.5", "not an integer"}, {"unknown constant", "const x = missing + 1", "unknown constant missing"}, {"package reference", "const x = math.MaxInt8", "unsupported expression"}, - {"len of a constant", "const s = \"ab\"\nconst x = len(s)", "len is only supported"}, - {"unsupported function", "const x = min(1, 2)", "unsupported call expression"}, + {"unsupported function", "const x = real(3+4i)", "unsupported call to real"}, + {"len of two arguments", `const x = len("a", "b")`, "single argument"}, {"conversion out of range", "const x = uint8(300)", "overflows uint8"}, {"conversion of a negative", "const x = uint8(-1)", "negative"}, {"typed declaration out of range", "const x int8 = 200", "overflows int8"}, + {"complement of uint", "const x = ^uint(0)", "depends on the target architecture"}, + {"complement of uintptr", "const x = ^uintptr(0)", "depends on the target architecture"}, {"comparison operator", "const x = 1 < 2", "unsupported binary operator"}, {"self reference", "const x = x + 1", "refers to itself"}, {"reference cycle", "const (\n\tx = y\n\ty = x\n)", "refers to itself"}, @@ -198,6 +214,52 @@ type myUint64 = uint64 } } +func TestConstResolverNumberTypes(t *testing.T) { + // arithmetic follows the type of its operands, division by a float is not integer division + src := `package p + +type uint8 = uint16 +type lvl int32 +type l1 = l2 +type l2 = l3 +type l3 = l4 +type l4 = l5 +type l5 = l6 +type l6 = l7 +type l7 = l8 +type l8 = l9 +type l9 = l10 +type l10 = l11 +type l11 = uint8 + +const ( + d float64 = 2 + viaD = 5 / d * 2 // 5, not 4 + whole int = 5 + halved = whole / 2.0 // 2, a typed integer divides as an integer + tl lvl = 7 + scaled = tl*2 + 1 // 15 + shadow = ^uint8(0) // 65535, the package declaration shadows the builtin + text = "hello" + size = len(text) + named = len(str("abcd")) + paren (uint8) = 0 + compl = ^paren // 65535, uint8 here is the package declaration + chained = ^l1(0) // the same, reached through eleven aliases +) + +type str (string) +` + for name, expected := range map[string]string{ + "viaD": "5", "halved": "2", "scaled": "15", "shadow": "65535", "size": "5", "named": "4", + "compl": "65535", "chained": "65535", + } { + v, err := resolveSrc(t, src, name) + require.NoError(t, err, name) + assert.Equal(t, expected, v.ExactString(), name) + } +} + func TestConstResolverIgnoresLocalConstants(t *testing.T) { // a constant declared inside a function is out of scope for the enum values src := `package p @@ -277,7 +339,7 @@ func TestConstResolverUnsupportedNodes(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "invalid literal") - _, err = literalValue(&ast.BasicLit{Kind: token.STRING, Value: `"str"`}) + _, err = literalValue(&ast.BasicLit{Kind: token.IMAG, Value: "1i"}) require.Error(t, err) assert.Contains(t, err.Error(), "not a number") From a947fab9bf01dd9a680c65a4419b7dc19a116913 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Wed, 19 Aug 2026 02:45:47 +0100 Subject: [PATCH 4/4] Apply the declared type of an enum constant to its value The type a constant is declared with shapes its value: a float one holds it at the width of that type, so a constant declared float32 rounds the way the compiler rounds it. min and max return their result in the type of a typed argument, and the underlying type of the enum is read through parentheses, so type code (uint8) is recognised as unsigned and rejects a negative value. --- internal/generator/constexpr.go | 20 +++++++++++++++++- internal/generator/constexpr_test.go | 31 ++++++++++++++++++++++++++++ internal/generator/generator.go | 11 +++++++--- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/internal/generator/constexpr.go b/internal/generator/constexpr.go index 68c6d36..28a3a60 100644 --- a/internal/generator/constexpr.go +++ b/internal/generator/constexpr.go @@ -191,10 +191,21 @@ func (r *constResolver) resolve(name string) (typedValue, error) { // evalInt evaluates a constant expression and returns its exact integer value func (r *constResolver) evalInt(expr ast.Expr, iotaVal int64) (constant.Value, error) { + return r.evalTypedInt(expr, "", iotaVal) +} + +// evalTypedInt evaluates a constant expression declared with the given type and returns its exact +// integer value. the declared type matters, a float one holds the value at the width of its type. +func (r *constResolver) evalTypedInt(expr ast.Expr, declaredType string, iotaVal int64) (constant.Value, error) { v, err := r.eval(expr, iotaVal) if err != nil { return nil, err } + if typ := r.builtinOf(declaredType); typ != "" { + if v, err = r.convert(v, typ); err != nil { + return nil, err + } + } return toInt(v.value) } @@ -420,6 +431,7 @@ func (r *constResolver) evalMinMax(e *ast.CallExpr, name string, iotaVal int64) } var best typedValue + typ := "" for i, arg := range e.Args { v, err := r.eval(arg, iotaVal) if err != nil { @@ -428,11 +440,17 @@ func (r *constResolver) evalMinMax(e *ast.CallExpr, name string, iotaVal int64) if err := requireNumeric(v.value); err != nil { return typedValue{}, err } + if typ == "" { + typ = v.typ // a typed argument gives the result its type + } if i == 0 || constant.Compare(v.value, op, best.value) { best = v } } - return best, nil + if typ == "" { + return best, nil + } + return r.convert(best, typ) } // roundFloat drops the precision a float type cannot hold, the compiler stores a typed float diff --git a/internal/generator/constexpr_test.go b/internal/generator/constexpr_test.go index 4ded83b..d90aef6 100644 --- a/internal/generator/constexpr_test.go +++ b/internal/generator/constexpr_test.go @@ -96,6 +96,8 @@ func TestConstResolverValues(t *testing.T) { {"float64 rounds", "int(float64(1<<62 + 1))", "4611686018427387904"}, {"typed float arithmetic", "int(float32(1) / 3 * 3)", "1"}, {"string of a code point", `len(string(0x100))`, "2"}, + {"min of a typed argument", "^max(2, uint8(1))", "253"}, + {"max of a typed argument", "min(uint8(7), 9) + 1", "8"}, } for _, tt := range tests { @@ -441,6 +443,35 @@ const ( assert.Contains(t, string(content), "value: 18446744073709551615") } +func TestParseDeclaredTypeApplied(t *testing.T) { + // the declared type of a constant shapes its value, a float one holds it at the width of the type + src := `package test +type status int32 +const ( + statusRounded (float32) = 16777217 + statusPlain status = 5 +) +` + gen, err := parseSrc(t, "status", src) + require.NoError(t, err) + assert.Equal(t, int64(16777216), constVal(t, gen, "statusRounded")) + assert.Equal(t, int64(5), constVal(t, gen, "statusPlain")) +} + +func TestParseParenthesisedUnderlyingType(t *testing.T) { + // the underlying type is read through parentheses, a negative value does not fit it + src := `package test +type code (uint8) +const ( + codeA code = 200 + codeB code = -1 +) +` + _, err := parseSrc(t, "code", src) + require.Error(t, err) + assert.Contains(t, err.Error(), "const codeB: value -1 is negative but the type is uint8") +} + func TestParseValueOutOfRange(t *testing.T) { src := `package test type small int8 diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 7102391..14de95a 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -166,7 +166,7 @@ func (g *Generator) extractUnderlyingType(file *ast.File) { for _, spec := range decl.Specs { if tspec, ok := spec.(*ast.TypeSpec); ok && tspec.Name.Name == g.Type { // found our type, extract the underlying type - if ident, ok := tspec.Type.(*ast.Ident); ok { + if ident, ok := unparen(tspec.Type).(*ast.Ident); ok { g.underlyingType = ident.Name } } @@ -180,6 +180,7 @@ func (g *Generator) extractUnderlyingType(file *ast.File) { // expression list of the previous spec, which is how the language defines iota based blocks. func (g *Generator) parseConstBlock(decl *ast.GenDecl, resolver *constResolver) error { var lastValues []ast.Expr + var lastType ast.Expr // the index of a spec inside the block is the value of iota for that spec for iotaVal, spec := range decl.Specs { @@ -188,7 +189,7 @@ func (g *Generator) parseConstBlock(decl *ast.GenDecl, resolver *constResolver) continue } if len(vspec.Values) > 0 { - lastValues = vspec.Values + lastValues, lastType = vspec.Values, vspec.Type } // parse aliases from inline comment (vspec.Comment is the inline comment) @@ -216,7 +217,11 @@ func (g *Generator) parseConstBlock(decl *ast.GenDecl, resolver *constResolver) return fmt.Errorf("no value for const %s", name.Name) } - value, err := resolver.evalInt(lastValues[i], int64(iotaVal)) + declaredType := "" + if ident, ok := unparen(lastType).(*ast.Ident); ok { + declaredType = ident.Name + } + value, err := resolver.evalTypedInt(lastValues[i], declaredType, int64(iotaVal)) if err != nil { return fmt.Errorf("failed to evaluate value of const %s: %w", name.Name, err) }