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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ to builders without direct coupling.
| `scan_context.go` | `ScanCtx` / `NewScanCtx` — loads Go packages via `golang.org/x/tools/go/packages` |
| `index.go` | `TypeIndex` — node classification (meta/route/operation/model/parameters/response) |
| `declaration.go` | `EntityDecl` — wraps a type/value declaration with its enclosing file/package |
| `enum_value.go` | `enumBasicLitValue` — converts a `const Foo Kind = "bar"` RHS into its runtime value (enum discovery) |
| `enum_value.go` | `enumLiteralValue` — converts a `const Foo Kind = "bar"` RHS into its runtime value (enum discovery), unwrapping signed numeric literals (`-1` is a unary minus applied to `1`) and reporting unsupported/unparseable forms so the caller skips them rather than emitting a nil member; `enumBasicLitValue` handles the unsigned case |
| `provenance.go` | `Provenance` — ties a spec JSON pointer to the source position of the Go construct that produced it; emitted via `Options.OnProvenance` (cross-ref linker, source side) |
| `classify/` | Classification predicates usable from both scanner and builders (e.g. `IsAllowedExtension`) |

Expand Down
7 changes: 7 additions & 0 deletions docs/doc-site/maintainers/annotations/swagger-enum.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ collects the type's `const` declarations.
point at it via `$ref` — the general `swagger:model ⇒ definition + $ref`
rule applied to enums.

Only `const` declarations with a literal right-hand side are collected:
string, integer and float literals, including signed ones (`-1`, `+1`).
Integers are read in whatever form Go accepts — decimal, `0x` hexadecimal,
`0b` binary, `0o` and legacy `0` octal, with optional `_` digit separators.
Constants with no explicit literal — most notably `iota`-derived ones —
are not collected, and neither is a value too large to represent.

If `swagger:enum` names a type for which no matching `const` values are
found, the enum semantics are dropped and the type falls through to
ordinary type resolution (typically a plain `$ref`, no `enum` array).
Expand Down
33 changes: 33 additions & 0 deletions fixtures/bugs/3412/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0

// Package bug3412 reproduces go-swagger issue #3412 ("`swagger:enum` seems to
// ignore negative integer constants"): Go never scans a negative numeric
// literal — `-1` is a unary minus applied to `1` — so a signed const reached
// the enum collector as an *ast.UnaryExpr and was silently dropped, leaving
// [0] where [-1, 0, 1] was declared.
package bug3412

// PanDirection is the direction of a pan.
//
// swagger:enum PanDirection
type PanDirection int8

const (
// PanLeft pans to the left.
PanLeft PanDirection = -1

// NoPan does not pan.
NoPan PanDirection = 0

// PanRight pans to the right.
PanRight PanDirection = +1
)

// ControlParams are PTZ control parameters.
//
// swagger:model ControlParams
type ControlParams struct {
// specifies the direction of the pan.
Pan PanDirection `json:"pan,omitempty"`
}
25 changes: 25 additions & 0 deletions fixtures/integration/golden/bugs_3412_schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"swagger": "2.0",
"paths": {},
"definitions": {
"ControlParams": {
"type": "object",
"title": "ControlParams are PTZ control parameters.",
"properties": {
"pan": {
"description": "specifies the direction of the pan.\n-1 PanLeft pans to the left.\n0 NoPan does not pan.\n1 PanRight pans to the right.",
"type": "integer",
"format": "int64",
"enum": [
-1,
0,
1
],
"x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan does not pan.\n1 PanRight pans to the right.",
"x-go-name": "Pan"
}
},
"x-go-package": "github.com/go-openapi/codescan/fixtures/bugs/3412"
}
}
}
43 changes: 43 additions & 0 deletions internal/integration/coverage_bug_3412_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0

package integration_test

import (
"testing"

"github.com/go-openapi/codescan"
"github.com/go-openapi/codescan/internal/scantest"
"github.com/go-openapi/testify/v2/assert"
"github.com/go-openapi/testify/v2/require"
)

// TestCoverage_Bug3412 locks the fix for go-swagger issue #3412 ("swagger:enum seems to ignore
// negative integer constants"): Go's scanner never produces a negative numeric literal — `-1` is a
// unary minus applied to the literal `1` — so `const PanLeft PanDirection = -1` reached the enum
// collector as an *ast.UnaryExpr and was dropped, yielding [0] instead of [-1, 0, 1].
//
// The explicit `+1` spelling exercises the other sign operator and must render without a stray plus.
func TestCoverage_Bug3412(t *testing.T) {
doc, err := codescan.Run(&codescan.Options{
Packages: []string{"./bugs/3412/..."},
WorkDir: scantest.FixturesDir(),
ScanModels: true,
})
require.NoError(t, err)
require.NotNil(t, doc)

props := doc.Definitions["ControlParams"].Properties
pan, ok := props["pan"]
require.True(t, ok)

assert.Equal(t, "integer", pan.Type[0])
assert.Equal(t, []any{int64(-1), int64(0), int64(1)}, pan.Enum,
"a negative const must not be dropped from the enum")
assert.Equal(t,
"-1 PanLeft pans to the left.\n0 NoPan does not pan.\n1 PanRight pans to the right.",
pan.Extensions["x-go-enum-desc"],
"the const→value mapping must carry the sign too")

scantest.CompareOrDumpJSON(t, doc, "bugs_3412_schema.json")
}
77 changes: 68 additions & 9 deletions internal/scanner/enum_value.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,87 @@ package scanner

import (
"go/ast"
"go/token"
"strconv"
"strings"
)

// enumLiteralValue converts the RHS expression of a `const Foo Kind = <expr>` declaration into its
// runtime value, reporting whether the expression is a literal this scanner understands.
//
// Go's scanner never produces a negative numeric literal: `-1` is a unary minus APPLIED to the
// literal `1`, so it reaches the AST as *ast.UnaryExpr wrapping *ast.BasicLit (same for the
// explicit `+1` spelling). Signed numeric literals are therefore unwrapped here — otherwise a
// `const PanLeft PanDirection = -1` would be dropped from the enum (go-swagger#3412).
//
// Anything else (identifiers — including iota-derived constants — calls, arithmetic) is reported as
// unsupported, and the caller skips that const. So is a numeric literal whose text does not parse,
// so that a const is never emitted as a nil enum member.
func enumLiteralValue(expr ast.Expr) (any, bool) {
switch e := expr.(type) {
case *ast.BasicLit:
return enumBasicLitValue(e)

case *ast.UnaryExpr:
if e.Op != token.SUB && e.Op != token.ADD {
return nil, false
}

basicLit, ok := e.X.(*ast.BasicLit)
if !ok {
return nil, false
}

// A sign is only meaningful on a number: `-'a'` is legal Go but not a meaningful enum entry.
if basicLit.Kind != token.INT && basicLit.Kind != token.FLOAT {
return nil, false
}

sign := ""
if e.Op == token.SUB {
sign = "-"
}

return enumSignedLitValue(basicLit, sign)

default:
return nil, false
}
}

// enumBasicLitValue converts the RHS of a `const Foo Kind = "bar"` declaration into its runtime
// value — int64 / float64 / unquoted string — for emission as an enum entry on the Swagger
// schema the scanner is building.
//
// Returns nil when the literal kind is INT or FLOAT but the textual value fails to parse (rare —
// Go's own parser would have caught it upstream, but the safety net is cheap).
func enumBasicLitValue(basicLit *ast.BasicLit) any {
// Reports false when the literal kind is INT or FLOAT but the textual value fails to parse, so that
// the caller skips the const rather than emitting a nil enum member.
func enumBasicLitValue(basicLit *ast.BasicLit) (any, bool) {
return enumSignedLitValue(basicLit, "")
}

// enumSignedLitValue is enumBasicLitValue with the sign carried by an enclosing unary operator
// folded back into the literal text.
//
// The sign is prepended rather than applied after parsing so that the whole int64 range round-trips:
// `-9223372036854775808` parses, whereas parsing `9223372036854775808` alone would overflow.
//
// Integers are parsed with base 0 so that the literal is read exactly as Go's own scanner wrote it:
// that accepts the `0x` / `0b` / `0o` prefixes, the legacy `0` octal form, and `_` digit
// separators. Base 10 would reject every one of those and, before the sign was handled here, quietly
// turned `017` into 17 where Go means 15.
func enumSignedLitValue(basicLit *ast.BasicLit, sign string) (any, bool) {
switch basicLit.Kind.String() {
case "INT":
if result, err := strconv.ParseInt(basicLit.Value, 10, 64); err == nil {
return result
if result, err := strconv.ParseInt(sign+basicLit.Value, 0, 64); err == nil {
return result, true
}
case "FLOAT":
if result, err := strconv.ParseFloat(basicLit.Value, 64); err == nil {
return result
if result, err := strconv.ParseFloat(sign+basicLit.Value, 64); err == nil {
return result, true
}
default:
return strings.Trim(basicLit.Value, "\"")
return strings.Trim(basicLit.Value, "\""), true
}
return nil

return nil, false
}
8 changes: 5 additions & 3 deletions internal/scanner/scan_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,10 @@ func (s *ScanCtx) FindEnumValues(pkg *packages.Package, enumName string) (list [
// — each sharing the spec's doc comment.
// The Go compiler guarantees len(Names) == len(Values) when Values is non-empty, so out-of-parity
// specs are ignored defensively.
//
// Values are collected from literals only: a signed numeric literal is unwrapped, since Go models
// `-1` as a unary operator applied to `1` rather than as a negative literal. Any other RHS form
// (identifier — including iota-derived constants — call, arithmetic) is skipped.
func (s *ScanCtx) findEnumValue(spec ast.Spec, enumName string) (values []any, descriptions []string, positions []token.Pos) {
vs, ok := spec.(*ast.ValueSpec)
if !ok {
Expand All @@ -954,13 +958,11 @@ func (s *ScanCtx) findEnumValue(spec ast.Spec, enumName string) (values []any, d
docSuffix := buildEnumDocSuffix(vs.Doc, vs.Names)

for i, nameIdent := range vs.Names {
bl, ok := vs.Values[i].(*ast.BasicLit)
literalValue, ok := enumLiteralValue(vs.Values[i])
if !ok {
continue
}

literalValue := enumBasicLitValue(bl)

var desc strings.Builder
fmt.Fprintf(&desc, "%v %s", literalValue, nameIdent.Name)
desc.WriteString(docSuffix)
Expand Down
Loading