diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 101b92dd..f26dacb6 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -220,6 +220,18 @@ malformed input, the petstore, aliased schemas, go123-specific forms, and cross- (`types.LookupFieldOrMethod`), never against the emitted names. Placed on the embed (plain field names) or on the declaration (dotted embed paths); embeds only. Unresolved / behind-`$ref` targets raise Hints. See `internal/builders/schema/README.md#omit`. +- A `swagger:enum` schema takes its `type`/`format` from the **declared** Go type (`int8` → + `integer/int8`, `float32` → `number/float`), never from the parsed const values, and each member is + normalised to that type — typing from the first value let the const block's declaration order + decide the schema type. Member *values* come from the type-checker + (`TypesInfo.Defs[name].(*types.Const).Val()`), never from the literal syntax, and membership is + decided per name from the constant's own type — which is what makes `iota` blocks (where only the + first spec carries a type and a value) visible at all. So `iota`, expressions (`1 << 3`), + references to earlier members, rune literals (`'a'` → 97), `true`/`false` (identifiers, not + literals), raw/escaped strings, every integer base and above-`MaxInt64` members all resolve + (go-swagger#3412). A literal reader survives only as the degraded-load fallback. See + `internal/scanner/README.md#enum-values`, `internal/builders/schema/README.md#enum-typing` and + `internal/builders/validations/README.md#enum-const-values`. - The scanner works at the AST / `go/types` level — it never executes or compiles scanned code. - Parsers never import builders; they write through the interfaces in `internal/ifaces`. When adding a new annotation, extend the relevant builder's `taggers.go` rather than reaching diff --git a/docs/doc-site/annotation-index/_index.md b/docs/doc-site/annotation-index/_index.md index 83990dc2..f3fad0b9 100644 --- a/docs/doc-site/annotation-index/_index.md +++ b/docs/doc-site/annotation-index/_index.md @@ -17,7 +17,7 @@ tutorial that shows the annotation as runnable Go next to the spec it produces; | `swagger:allOf` | embedded field / struct | an `allOf` composition | [example]({{% relref "/tutorials/model-definitions#swaggerallof" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-allof" %}}) | | `swagger:default` | value / field doc | a default-value anchor | [example]({{% relref "/tutorials/examples-and-defaults#swaggerdefault" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-default" %}}) | | `swagger:description` | type / field / response doc | overrides the `description` (verbatim body with `\|`) | [how-to]({{% relref "overriding-titles-and-descriptions" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-description" %}}) | -| `swagger:enum` | named type | an `enum` array (+ `x-go-enum-desc`) | [example]({{% relref "/tutorials/model-definitions#swaggerenum" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-enum" %}}) | +| `swagger:enum` | named type | an `enum` array (+ `x-go-enum-desc`) | [example]({{% relref "/tutorials/enumerations" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-enum" %}}) | | `swagger:file` | param / response field | `{type: file}` | [example]({{% relref "/tutorials/routes-and-operations#swaggerfile" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-file" %}}) | | `swagger:ignore` | type / field doc | excludes the declaration | [example]({{% relref "/tutorials/model-definitions#swaggerignore" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-ignore" %}}) | | `swagger:meta` | package doc | top-level `info`, `host`, `basePath`, `schemes`, … | [example]({{% relref "/tutorials/document-metadata#swaggermeta" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-meta" %}}) | diff --git a/docs/doc-site/maintainers/annotations/swagger-enum.md b/docs/doc-site/maintainers/annotations/swagger-enum.md index 5a6b5861..efcdf9d6 100644 --- a/docs/doc-site/maintainers/annotations/swagger-enum.md +++ b/docs/doc-site/maintainers/annotations/swagger-enum.md @@ -12,8 +12,24 @@ description: "Marks a named type as an enum and collects its const values." ## What it does -Marks a string-typed (or integer-typed) named type as an enum and -collects the type's `const` declarations. +Marks a named type over a string, integer, number or boolean as an enum +and collects the type's `const` declarations. + +Values come from the Go type-checker, so any constant expression is +collected — `iota` (including the implicit specs, which carry neither a +type nor a value), computed members (`1 << 3`), references to earlier +members, negative values, every integer base, values above `MaxInt64` in +an unsigned enum, rune literals (as code points), `true` / `false`, and +both string forms. The emitted `type` / `format` come from the **declared +Go type**, never from the members, so an `int8` enum is +`{integer, int8}` and reordering the const block cannot change the type. +A type declared over another named type keeps that type's format +(`type Kind strfmt.UUID` stays `format: uuid`). + +Two shapes do not work: an alias to a basic type cannot host an enum (the +type-checker erases the alias, leaving nothing to collect), and a `rune` +or `byte` enum emits integers, which is what those types are on the wire. +See [Enumerations]({{% relref "/tutorials/enumerations" %}}). - **Without `swagger:model`** (the default): the values are applied **inline on each model field that references the type** — the property diff --git a/docs/doc-site/tutorials/enumerations.md b/docs/doc-site/tutorials/enumerations.md new file mode 100644 index 00000000..ed13461b --- /dev/null +++ b/docs/doc-site/tutorials/enumerations.md @@ -0,0 +1,134 @@ +--- +title: Enumerations +weight: 12 +description: | + Publish a Go const block as a spec enum — any constant expression, the type + and format taken from the declaration, and the same members inline on + parameters and headers. +--- + +An enum in Go is a named type plus a block of constants. `swagger:enum` turns +that pair into an `enum` array on every schema, parameter and header the type +reaches. This page covers what the scanner accepts on the value side, what +decides the emitted `type` / `format`, and the two shapes that do not work. + +Every Go snippet below comes from the test-covered +[`docs/examples/concepts/enums`](https://github.com/go-openapi/codescan/tree/master/docs/examples/concepts/enums) +package, and every JSON pane is a golden file a test regenerates. + +## swagger:enum + +`swagger:enum ` collects the `const` values declared with that type. A +bare `swagger:enum` on the type declaration works too — the name is inferred +from the declaration it sits on. + +The enum type is emitted **because something points at it**: a model field, a +parameter, a header. On its own it is unreachable, and unreachable types are not +published. Add `swagger:model` to the enum type to make it a first-class +definition (carrying the `enum` array) that fields `$ref` instead — the general +`swagger:model ⇒ definition + $ref` rule. + +Each member's doc comment becomes a line of the `x-go-enum-desc` extension, and +is appended to the property description. Set +[`SkipEnumDescriptions`]({{% relref "/maintainers/options" %}}) to keep the +mapping on the extension only. + +## Any constant expression, not just literals + +The values come from the Go **type-checker**, which has already evaluated the +const block. So the members do not have to be written as literals — anything the +compiler can fold is collected. + +`iota` is the case that matters most, because after the first line there is +nothing left in the source to read: the following specs carry neither a type nor +a value, and inherit both implicitly. + +{{< example go="concepts/enums/enums.go" goregion="iota" + json="concepts/enums/testdata/iota.json" jsonlabel="#/definitions/Schedule" >}} + +Constant expressions and references to earlier members are collected the same +way. + +{{< example go="concepts/enums/enums.go" goregion="expressions" + json="concepts/enums/testdata/expressions.json" jsonlabel="#/definitions/Threshold" >}} + +The same goes for every other constant form Go offers: negative values, the +non-decimal bases (`0x2a`, `0b101010`, `0o52`) and digit separators, values +above `MaxInt64` in an unsigned enum, `true` / `false`, rune literals, and both +the raw and the escaped string forms. + +Negative members are worth a pane of their own, since a signed constant is not a +literal in the Go grammar — it is an expression wrapping one: + +{{< example go="concepts/enums/enums.go" goregion="signed" + json="concepts/enums/testdata/signed.json" jsonlabel="#/definitions/Camera" >}} + +## The type comes from the declaration + +`type` and `format` are read from the Go type you declared, never from the +members. `PanDirection` above is an `int8`, so the property is +`{integer, int8}` — even though every member would fit in a smaller or larger +box. + +This is also what makes the const block **safe to reorder**. `Zoom` is a +`float32` whose first member is written `0`, an integer literal; the schema is a +number enum regardless of which member comes first: + +{{< example go="concepts/enums/enums.go" goregion="width" + json="concepts/enums/testdata/width.json" jsonlabel="#/definitions/Lens" >}} + +A type declared over another **named** type keeps what that type contributed. An +enum written over a string format is still that format: + +{{< example go="concepts/enums/enums.go" goregion="strfmt" + json="concepts/enums/testdata/strfmt.json" jsonlabel="#/definitions/Label" >}} + +## Parameters and headers + +OpenAPI 2.0 forbids a `$ref` on a non-body parameter or a response header, so +there the members and the format are written **inline**. Nothing changes on the +annotation side — the same enum type reaches `in: query`, `path`, `header` and +`formData`, and the `items` of an array-typed one: + +{{< example go="concepts/enums/enums.go" goregion="params" + json="concepts/enums/testdata/params.json" jsonlabel="GET /cameras/search — parameters" + full="concepts/enums/testdata/full.json" >}} + +## Two shapes that do not work + +**A `rune` or `byte` enum emits integers.** It is collected like any other, and +`'a'` reaches the spec as `97`: + +{{< example go="concepts/enums/enums.go" goregion="runes" + json="concepts/enums/testdata/runes.json" jsonlabel="#/definitions/Glyph" >}} + +That is unlikely to be what you pictured, and it is the only faithful answer: a +scalar `rune` is an `int32` on the wire as much as in Go, so `json.Marshal` +writes `97`, and `encoding/json` **refuses** to unmarshal `"a"` back into the +field. A string-typed schema would describe a payload your own server rejects. +If you meant characters, declare the type over `string` +(`type Letter string`, `LetterA Letter = "a"`) — that changes the wire, and the +schema follows. + +**An alias to a basic type cannot host an enum.** + +```go +type Unsigned = uint64 // an alias, not a new type + +// swagger:enum Unsigned // ← collects nothing +const Zero Unsigned = 0 +``` + +The Go type-checker erases the alias, so `Zero` is indistinguishable from any +other `uint64` constant and there is no set of members to collect. Declare a +real type instead (`type Unsigned uint64`). An alias to a *named* enum type is +fine — the named type survives. + +## Where to go next + +- [Validations]({{% relref "/tutorials/validations" %}}) — the other constraints + a property can carry, and the reduced surface parameters accept. +- [Model definitions]({{% relref "/tutorials/model-definitions" %}}) — the rest + of the per-type annotations. +- [`swagger:enum` reference]({{% relref "/maintainers/annotations/swagger-enum" %}}) + — the exhaustive rule. diff --git a/docs/doc-site/tutorials/model-definitions.md b/docs/doc-site/tutorials/model-definitions.md index 4e67700b..90fbd9a6 100644 --- a/docs/doc-site/tutorials/model-definitions.md +++ b/docs/doc-site/tutorials/model-definitions.md @@ -59,6 +59,11 @@ Here the values inline on the referencing field. Add `swagger:model` to the enum type to make it a first-class definition (carrying the `enum` array) that fields `$ref` — again the `swagger:model ⇒ definition + $ref` rule. +Enums have more to them than a string const block: `iota` and computed members, +what decides the emitted `type` / `format`, and the inline form parameters and +headers take. They get their own page — +[Enumerations]({{% relref "/tutorials/enumerations" %}}). + ## swagger:allOf Embedding base types under `swagger:allOf` composes a schema. Each embedded base diff --git a/docs/examples/concepts/enums/enums.go b/docs/examples/concepts/enums/enums.go new file mode 100644 index 00000000..0cebc98a --- /dev/null +++ b/docs/examples/concepts/enums/enums.go @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package enums backs the "Enumerations" tutorial: one Go const block per +// enum-shaping rule, each paired with the fragment the scanner emits from it. +package enums + +import "github.com/go-openapi/strfmt" + +// snippet:iota + +// Weekday is an iota enum: only the first spec carries a type and a value, and +// every following one inherits both implicitly. +// +// swagger:enum Weekday +type Weekday int + +const ( + // Sunday is the first day. + Sunday Weekday = iota + // Monday is the second day. + Monday + // Tuesday is the third day. + Tuesday +) + +// Schedule carries the enum, which is what makes it reachable and so emitted. +// +// swagger:model +type Schedule struct { + // Day the job runs on. + Day Weekday `json:"day"` +} + +// endsnippet:iota + +// snippet:expressions + +// Level is built from a constant expression and from a reference to an earlier +// member — neither of which is a literal. +// +// swagger:enum Level +type Level int + +const ( + // LevelLow is the floor. + LevelLow Level = 1 << 3 + // LevelHigh doubles it. + LevelHigh Level = LevelLow * 2 +) + +// Threshold carries the computed enum. +// +// swagger:model +type Threshold struct { + // Level to alert at. + Level Level `json:"level"` +} + +// endsnippet:expressions + +// snippet:signed + +// PanDirection straddles zero. A signed constant is not a literal in the Go +// grammar — it is an expression wrapping one — so these are the members that +// used to go missing. +// +// swagger:enum PanDirection +type PanDirection int8 + +const ( + // PanLeft pans to the left. + PanLeft PanDirection = -1 + // NoPan holds the current position. + NoPan PanDirection = 0 + // PanRight pans to the right. + PanRight PanDirection = 1 +) + +// Camera carries the signed enum, declared int8. +// +// swagger:model +type Camera struct { + // Pan direction of the camera. + Pan PanDirection `json:"pan"` +} + +// endsnippet:signed + +// snippet:width + +// Zoom is a float32 enum whose FIRST member is written as an integer literal. +// The schema type follows the declared type, so the block can be reordered +// freely. +// +// swagger:enum Zoom +type Zoom float32 + +const ( + // ZoomNone is the neutral step. + ZoomNone Zoom = 0 + // ZoomOut steps back. + ZoomOut Zoom = -1.5 + // ZoomIn steps in. + ZoomIn Zoom = 1.5 +) + +// Lens carries the float enum. +// +// swagger:model +type Lens struct { + // Zoom step of the lens. + Zoom Zoom `json:"zoom"` +} + +// endsnippet:width + +// snippet:strfmt + +// Kind is an enum written over a string format rather than over a plain string: +// the format of the type it is declared over comes with it. +// +// swagger:enum Kind +type Kind strfmt.UUID + +const ( + // KindPrimary is the primary kind. + KindPrimary Kind = "0a8bcf1e-0000-0000-0000-000000000000" + // KindSecondary is the secondary kind. + KindSecondary Kind = "0a8bcf1e-1111-1111-1111-111111111111" +) + +// Label carries the formatted enum. +// +// swagger:model +type Label struct { + // Kind of the label. + Kind Kind `json:"kind"` +} + +// endsnippet:strfmt + +// snippet:runes + +// Letter is a rune enum. A rune is an int32, on the wire as much as in Go, so +// the members are code points — 'a' is 97. +// +// swagger:enum Letter +type Letter rune + +const ( + // LetterA is the first letter. + LetterA Letter = 'a' + // LetterB is the second letter. + LetterB Letter = 'b' +) + +// Glyph carries the rune enum. +// +// swagger:model +type Glyph struct { + // Letter of the glyph. + Letter Letter `json:"letter"` +} + +// endsnippet:runes + +// swagger:route GET /cameras/search cameras search +// +// Searches cameras by pan direction. +// +// responses: +// +// 200: cameraList + +// CameraList is the response body. +// +// swagger:response cameraList +type CameraList struct { + // in: body + Body []Camera `json:"body"` +} + +// snippet:params + +// SearchParams consumes an enum from a non-body parameter, where OpenAPI 2.0 +// forbids a $ref: the members and the format ship inline. +// +// swagger:parameters search +type SearchParams struct { + // Direction to pan while searching. + // + // in: query + Pan PanDirection `json:"pan"` + + // Directions the client accepts. + // + // in: query + Accepted []PanDirection `json:"accepted"` +} + +// endsnippet:params diff --git a/docs/examples/concepts/enums/enums_test.go b/docs/examples/concepts/enums/enums_test.go new file mode 100644 index 00000000..03bb8126 --- /dev/null +++ b/docs/examples/concepts/enums/enums_test.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 + +package enums + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func examplesRoot(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok) + return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..")) +} + +func scanEnums(t *testing.T) *spec.Swagger { + t.Helper() + doc, err := codescan.Run(&codescan.Options{ + WorkDir: examplesRoot(t), + Packages: []string{"./concepts/enums"}, + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + return doc +} + +// goldenJSON marshals v and compares it to (or, under UPDATE_GOLDEN, rewrites) +// testdata/.json — the fragment the "Enumerations" tutorial renders +// next to the annotated source. +// +// Regenerate with: UPDATE_GOLDEN=1 go test ./... +func goldenJSON(t *testing.T, feature string, v any) { + t.Helper() + got, err := json.MarshalIndent(v, "", " ") + require.NoError(t, err) + got = append(got, '\n') + + golden := filepath.Join("testdata", feature+".json") + if os.Getenv("UPDATE_GOLDEN") != "" { + require.NoError(t, os.WriteFile(golden, got, 0o600)) + } + want, err := os.ReadFile(golden) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(got)) +} + +func definitionOf(t *testing.T, doc *spec.Swagger, name string) spec.Schema { + t.Helper() + schema, ok := doc.Definitions[name] + require.Truef(t, ok, "definition %q not found", name) + return schema +} + +// TestEnumFragments emits and verifies the golden fragments the tutorial pairs +// with each source region: one per shaping rule, plus the simple-schema +// parameter surface where the enum ships inline. +func TestEnumFragments(t *testing.T) { + doc := scanEnums(t) + + require.NotNil(t, doc.Paths) + search, ok := doc.Paths.Paths["/cameras/search"] + require.True(t, ok, "GET /cameras/search missing") + require.NotNil(t, search.Get) + + goldenJSON(t, "iota", definitionOf(t, doc, "Schedule")) // values the syntax does not carry + goldenJSON(t, "expressions", definitionOf(t, doc, "Threshold")) // computed members + goldenJSON(t, "signed", definitionOf(t, doc, "Camera")) // negative member, int8 width + goldenJSON(t, "width", definitionOf(t, doc, "Lens")) // float32 typed from the declaration + goldenJSON(t, "strfmt", definitionOf(t, doc, "Label")) // format carried over from strfmt.UUID + goldenJSON(t, "runes", definitionOf(t, doc, "Glyph")) // rune members are code points + goldenJSON(t, "params", search.Get.Parameters) // inline on a non-body parameter + + goldenJSON(t, "full", doc) // whole spec for the tutorial's live "SwaggerUI" tab +} + +// TestDeclarationOrderDoesNotDecideTheType pins the rule the tutorial states in +// prose: Zoom's first member is written as an integer literal, and the schema is +// a number enum all the same, because the type comes from the declaration. +func TestDeclarationOrderDoesNotDecideTheType(t *testing.T) { + doc := scanEnums(t) + + zoom := definitionOf(t, doc, "Lens").Properties["zoom"] + assert.Equal(t, []string{"number"}, []string(zoom.Type)) + assert.Equal(t, "float", zoom.Format) +} diff --git a/docs/examples/concepts/enums/testdata/expressions.json b/docs/examples/concepts/enums/testdata/expressions.json new file mode 100644 index 00000000..328d0b17 --- /dev/null +++ b/docs/examples/concepts/enums/testdata/expressions.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "title": "Threshold carries the computed enum.", + "properties": { + "level": { + "description": "Level to alert at.\n8 LevelLow is the floor.\n16 LevelHigh doubles it.", + "type": "integer", + "format": "int64", + "enum": [ + 8, + 16 + ], + "x-go-enum-desc": "8 LevelLow is the floor.\n16 LevelHigh doubles it.", + "x-go-name": "Level" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" +} diff --git a/docs/examples/concepts/enums/testdata/full.json b/docs/examples/concepts/enums/testdata/full.json new file mode 100644 index 00000000..f856933a --- /dev/null +++ b/docs/examples/concepts/enums/testdata/full.json @@ -0,0 +1,175 @@ +{ + "swagger": "2.0", + "paths": { + "/cameras/search": { + "get": { + "tags": [ + "cameras" + ], + "summary": "Searches cameras by pan direction.", + "operationId": "search", + "parameters": [ + { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8", + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Pan", + "description": "Direction to pan while searching.\n-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "name": "pan", + "in": "query" + }, + { + "type": "array", + "items": { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8" + }, + "x-go-name": "Accepted", + "description": "Directions the client accepts.", + "name": "accepted", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/cameraList" + } + } + } + } + }, + "definitions": { + "Camera": { + "type": "object", + "title": "Camera carries the signed enum, declared int8.", + "properties": { + "pan": { + "description": "Pan direction of the camera.\n-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "type": "integer", + "format": "int8", + "enum": [ + -1, + 0, + 1 + ], + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Pan" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" + }, + "Glyph": { + "type": "object", + "title": "Glyph carries the rune enum.", + "properties": { + "letter": { + "description": "Letter of the glyph.\n97 LetterA is the first letter.\n98 LetterB is the second letter.", + "type": "integer", + "format": "int32", + "enum": [ + 97, + 98 + ], + "x-go-enum-desc": "97 LetterA is the first letter.\n98 LetterB is the second letter.", + "x-go-name": "Letter" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" + }, + "Label": { + "type": "object", + "title": "Label carries the formatted enum.", + "properties": { + "kind": { + "description": "Kind of the label.\n0a8bcf1e-0000-0000-0000-000000000000 KindPrimary is the primary kind.\n0a8bcf1e-1111-1111-1111-111111111111 KindSecondary is the secondary kind.", + "type": "string", + "format": "uuid", + "enum": [ + "0a8bcf1e-0000-0000-0000-000000000000", + "0a8bcf1e-1111-1111-1111-111111111111" + ], + "x-go-enum-desc": "0a8bcf1e-0000-0000-0000-000000000000 KindPrimary is the primary kind.\n0a8bcf1e-1111-1111-1111-111111111111 KindSecondary is the secondary kind.", + "x-go-name": "Kind" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" + }, + "Lens": { + "type": "object", + "title": "Lens carries the float enum.", + "properties": { + "zoom": { + "description": "Zoom step of the lens.\n0 ZoomNone is the neutral step.\n-1.5 ZoomOut steps back.\n1.5 ZoomIn steps in.", + "type": "number", + "format": "float", + "enum": [ + 0, + -1.5, + 1.5 + ], + "x-go-enum-desc": "0 ZoomNone is the neutral step.\n-1.5 ZoomOut steps back.\n1.5 ZoomIn steps in.", + "x-go-name": "Zoom" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" + }, + "Schedule": { + "type": "object", + "title": "Schedule carries the enum, which is what makes it reachable and so emitted.", + "properties": { + "day": { + "description": "Day the job runs on.\n0 Sunday is the first day.\n1 Monday is the second day.\n2 Tuesday is the third day.", + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1, + 2 + ], + "x-go-enum-desc": "0 Sunday is the first day.\n1 Monday is the second day.\n2 Tuesday is the third day.", + "x-go-name": "Day" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" + }, + "Threshold": { + "type": "object", + "title": "Threshold carries the computed enum.", + "properties": { + "level": { + "description": "Level to alert at.\n8 LevelLow is the floor.\n16 LevelHigh doubles it.", + "type": "integer", + "format": "int64", + "enum": [ + 8, + 16 + ], + "x-go-enum-desc": "8 LevelLow is the floor.\n16 LevelHigh doubles it.", + "x-go-name": "Level" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" + } + }, + "responses": { + "cameraList": { + "description": "CameraList is the response body.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Camera" + } + } + } + } +} diff --git a/docs/examples/concepts/enums/testdata/iota.json b/docs/examples/concepts/enums/testdata/iota.json new file mode 100644 index 00000000..3c21f1d4 --- /dev/null +++ b/docs/examples/concepts/enums/testdata/iota.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "title": "Schedule carries the enum, which is what makes it reachable and so emitted.", + "properties": { + "day": { + "description": "Day the job runs on.\n0 Sunday is the first day.\n1 Monday is the second day.\n2 Tuesday is the third day.", + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1, + 2 + ], + "x-go-enum-desc": "0 Sunday is the first day.\n1 Monday is the second day.\n2 Tuesday is the third day.", + "x-go-name": "Day" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" +} diff --git a/docs/examples/concepts/enums/testdata/params.json b/docs/examples/concepts/enums/testdata/params.json new file mode 100644 index 00000000..88f7b172 --- /dev/null +++ b/docs/examples/concepts/enums/testdata/params.json @@ -0,0 +1,32 @@ +[ + { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8", + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Pan", + "description": "Direction to pan while searching.\n-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "name": "pan", + "in": "query" + }, + { + "type": "array", + "items": { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8" + }, + "x-go-name": "Accepted", + "description": "Directions the client accepts.", + "name": "accepted", + "in": "query" + } +] diff --git a/docs/examples/concepts/enums/testdata/runes.json b/docs/examples/concepts/enums/testdata/runes.json new file mode 100644 index 00000000..edba67df --- /dev/null +++ b/docs/examples/concepts/enums/testdata/runes.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "title": "Glyph carries the rune enum.", + "properties": { + "letter": { + "description": "Letter of the glyph.\n97 LetterA is the first letter.\n98 LetterB is the second letter.", + "type": "integer", + "format": "int32", + "enum": [ + 97, + 98 + ], + "x-go-enum-desc": "97 LetterA is the first letter.\n98 LetterB is the second letter.", + "x-go-name": "Letter" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" +} diff --git a/docs/examples/concepts/enums/testdata/signed.json b/docs/examples/concepts/enums/testdata/signed.json new file mode 100644 index 00000000..f9986ba7 --- /dev/null +++ b/docs/examples/concepts/enums/testdata/signed.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "title": "Camera carries the signed enum, declared int8.", + "properties": { + "pan": { + "description": "Pan direction of the camera.\n-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "type": "integer", + "format": "int8", + "enum": [ + -1, + 0, + 1 + ], + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Pan" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" +} diff --git a/docs/examples/concepts/enums/testdata/strfmt.json b/docs/examples/concepts/enums/testdata/strfmt.json new file mode 100644 index 00000000..27620cb2 --- /dev/null +++ b/docs/examples/concepts/enums/testdata/strfmt.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "title": "Label carries the formatted enum.", + "properties": { + "kind": { + "description": "Kind of the label.\n0a8bcf1e-0000-0000-0000-000000000000 KindPrimary is the primary kind.\n0a8bcf1e-1111-1111-1111-111111111111 KindSecondary is the secondary kind.", + "type": "string", + "format": "uuid", + "enum": [ + "0a8bcf1e-0000-0000-0000-000000000000", + "0a8bcf1e-1111-1111-1111-111111111111" + ], + "x-go-enum-desc": "0a8bcf1e-0000-0000-0000-000000000000 KindPrimary is the primary kind.\n0a8bcf1e-1111-1111-1111-111111111111 KindSecondary is the secondary kind.", + "x-go-name": "Kind" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" +} diff --git a/docs/examples/concepts/enums/testdata/width.json b/docs/examples/concepts/enums/testdata/width.json new file mode 100644 index 00000000..4c13a8a8 --- /dev/null +++ b/docs/examples/concepts/enums/testdata/width.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "title": "Lens carries the float enum.", + "properties": { + "zoom": { + "description": "Zoom step of the lens.\n0 ZoomNone is the neutral step.\n-1.5 ZoomOut steps back.\n1.5 ZoomIn steps in.", + "type": "number", + "format": "float", + "enum": [ + 0, + -1.5, + 1.5 + ], + "x-go-enum-desc": "0 ZoomNone is the neutral step.\n-1.5 ZoomOut steps back.\n1.5 ZoomIn steps in.", + "x-go-name": "Zoom" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" +} diff --git a/fixtures/bugs/3412/constforms/api.go b/fixtures/bugs/3412/constforms/api.go new file mode 100644 index 00000000..3fa48520 --- /dev/null +++ b/fixtures/bugs/3412/constforms/api.go @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package constforms exercises the const shapes a `swagger:enum` member can take beyond a plain +// literal — the shapes that are invisible to a reader working on literal syntax alone, and which +// the go-swagger#3412 investigation surfaced one after the other: +// +// - `iota`, where only the first spec of the block carries a type and a value at all and every +// following spec inherits both implicitly; +// - constant expressions and references to earlier members (`1 << 3`, `LevelLow * 2`); +// - rune literals, whose constant is an integer (`'a'` is 97, not the three characters `'a'`); +// - `true` / `false`, which are predeclared identifiers rather than literals — Go has no boolean +// literal token; +// - raw and escaped strings, whose delimiters and escape sequences are part of the syntax, not of +// the value. +// +// Values come from the type-checker, which has already evaluated every one of these exactly. +package constforms + +import "github.com/go-openapi/codescan/fixtures/bugs/3412/constforms/foreign" + +// Weekday is an iota enum. +// +// swagger:enum Weekday +type Weekday int + +const ( + Sunday Weekday = iota + Monday + Tuesday +) + +// ForeignDay is declared HERE but typed with an imported type that happens to be called Weekday +// too. Membership is decided by type identity, not by name, so it is not a member of the enum +// above. +const ForeignDay foreign.Weekday = 13 + +// Level is built from a constant expression and a reference to an earlier member. +// +// swagger:enum Level +type Level int + +const ( + LevelLow Level = 1 << 3 + LevelHigh Level = LevelLow * 2 +) + +// Letter is a rune enum. +// +// swagger:enum Letter +type Letter rune + +const ( + LetterA Letter = 'a' + LetterTab Letter = '\t' +) + +// Toggle is a bool enum. +// +// swagger:enum Toggle +type Toggle bool + +const ( + ToggleOn Toggle = true + ToggleOff Toggle = false +) + +// Label is a string enum in the raw and escaped literal forms. +// +// swagger:enum Label +type Label string + +const ( + LabelRaw Label = `raw` + LabelEscaped Label = "a\tb" +) + +// Byte is a byte enum, whose members are written as rune literals. +// +// swagger:enum Byte +type Byte byte + +const ( + ByteNUL Byte = 0 + ByteX Byte = 'x' +) + +// Settings carries one property per const form. +// +// swagger:model Settings +type Settings struct { + // The day of the week. + Weekday Weekday `json:"weekday"` + + // The level. + Level Level `json:"level"` + + // The letter. + Letter Letter `json:"letter"` + + // The toggle. + Toggle Toggle `json:"toggle"` + + // The label. + Label Label `json:"label"` + + // The byte. + Byte Byte `json:"byte"` +} diff --git a/fixtures/bugs/3412/constforms/foreign/foreign.go b/fixtures/bugs/3412/constforms/foreign/foreign.go new file mode 100644 index 00000000..57873202 --- /dev/null +++ b/fixtures/bugs/3412/constforms/foreign/foreign.go @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package foreign declares a type sharing its name with an enum type of the parent package, so the +// membership test cannot be name-only. +package foreign + +// Weekday is unrelated to constforms.Weekday beyond the name. +type Weekday int + +// TheThirteenth is a member of THIS Weekday, not of the annotated enum next door. +const TheThirteenth Weekday = 13 diff --git a/fixtures/bugs/3412/negative/api.go b/fixtures/bugs/3412/negative/api.go new file mode 100644 index 00000000..0085e8c9 --- /dev/null +++ b/fixtures/bugs/3412/negative/api.go @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package negative reproduces go-swagger issue #3412 ("negative enum values +// are dropped"): a signed constant such as `PanLeft PanDirection = -1` is not +// a literal in the Go grammar — it reaches the AST as a unary expression +// wrapping the literal — so the enum member was silently skipped and only the +// non-negative values survived. +// +// The enum type is consumed from every enum-carrying target — a response +// header, a schema property (in: body) and a non-body parameter (in: query) — +// so the value must survive the per-target coercion, not just the scan. +// +// A float enum covers the FLOAT literal branch, and a non-decimal enum covers +// the base-detected integer forms (hex, binary, octal, digit separators). +package negative + +// swagger:route GET /pan/{pan} pan setPan +// +// Sets the pan direction. +// +// responses: +// +// 200: ControlParams +func SetPan() {} + +// PanDirection is a signed enum: its members straddle zero. +// +// swagger:enum PanDirection +type PanDirection int8 + +const ( + // PanLeft pans to the left. + PanLeft PanDirection = -1 + + // NoPan holds the current position. + NoPan PanDirection = 0 + + // PanRight pans to the right. + PanRight PanDirection = +1 +) + +// Tilt is a signed float enum. +// +// swagger:enum Tilt +type Tilt float64 + +const ( + TiltDown Tilt = -0.5 + TiltFlat Tilt = 0 + TiltUp Tilt = 0.5 +) + +// Mask is an integer enum written in the non-decimal Go literal forms. +// +// swagger:enum Mask +type Mask int64 + +const ( + MaskHex Mask = 0x2a + MaskBinary Mask = 0b101010 + MaskOctal Mask = 0o52 + MaskUnderscore Mask = 1_000 +) + +// Zoom is a float32 enum whose FIRST member is written as an integer literal. +// +// Typing the schema from the first parsed value made this `{type: integer}` with fractional +// members — a schema no validator can satisfy — and moving ZoomNone down the block silently +// changed the emitted type. Both are settled by typing from the declared Go type instead. +// +// swagger:enum Zoom +type Zoom float32 + +const ( + ZoomNone Zoom = 0 + ZoomOut Zoom = -1.5 + ZoomIn Zoom = 1.5 +) + +// Aperture is an unsigned enum spanning the full uint64 range: its top member does not fit an +// int64, the signed parse the scanner tries first. +// +// swagger:enum Aperture +type Aperture uint64 + +const ( + ApertureClosed Aperture = 0 + ApertureOpen Aperture = 18446744073709551615 +) + +// ControlParams carries the enums as response headers. +// +// PTZ control parameters +// +// swagger:response +type ControlParams struct { + // specifies the direction of the pan. -1 is pan left, 0 is no pan, 1 is pan right. + // + // in: header + Pan PanDirection `json:"pan,omitempty"` + + // specifies the tilt angle. + // + // in: header + Tilt Tilt `json:"tilt,omitempty"` +} + +// ControlState carries the enums as schema properties. +// +// swagger:model ControlState +type ControlState struct { + // The pan direction. + Pan PanDirection `json:"pan"` + + // The tilt angle. + Tilt Tilt `json:"tilt"` + + // The active mask. + Mask Mask `json:"mask"` + + // The zoom step. + Zoom Zoom `json:"zoom"` + + // The aperture. + Aperture Aperture `json:"aperture"` +} + +// PanParams carries the enum as a query parameter. +// +// swagger:parameters setPan +type PanParams struct { + // The requested pan direction. + // + // in: query + Pan PanDirection `json:"pan"` +} diff --git a/fixtures/bugs/3412/simpleschema/api.go b/fixtures/bugs/3412/simpleschema/api.go new file mode 100644 index 00000000..a0415f29 --- /dev/null +++ b/fixtures/bugs/3412/simpleschema/api.go @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package simpleschema pins the propagation surface of `swagger:enum`: every place an enum type +// can be consumed must carry the members AND the type/format of the declared Go type. +// +// The SimpleSchema targets are the interesting half. OAS v2 forbids `$ref` on a non-body parameter +// or a response header, so the enum ships inline there through a different builder path than a +// schema property — one per `in:` location, plus the `items` of an array-typed one. A regression in +// any single target is invisible to a definitions-only assertion, which is why this fixture is +// pinned by a whole-spec golden rather than by property lookups. +package simpleschema + +// swagger:route GET /pan/{pan} pan setPan +// +// Sets the pan direction. +// +// responses: +// +// 200: PanResponse +func SetPan() {} + +// swagger:route POST /pan upload uploadPan +// +// Uploads a pan setting. +// +// responses: +// +// 200: PanResponse +func UploadPan() {} + +// PanDirection is a signed int8 enum: negative member (#3412) and a width the members themselves +// cannot carry (they all arrive as int64). +// +// swagger:enum PanDirection +type PanDirection int8 + +const ( + // PanLeft pans to the left. + PanLeft PanDirection = -1 + + // NoPan holds the current position. + NoPan PanDirection = 0 + + // PanRight pans to the right. + PanRight PanDirection = +1 +) + +// PanParams consumes the enum from every non-body parameter location. +// +// swagger:parameters setPan +type PanParams struct { + // in: path + Pan PanDirection `json:"pan"` + + // in: query + Preferred PanDirection `json:"preferred"` + + // in: header + Fallback PanDirection `json:"fallback"` + + // in: query + Allowed []PanDirection `json:"allowed"` +} + +// UploadParams consumes the enum from a form parameter. +// +// swagger:parameters uploadPan +type UploadParams struct { + // in: formData + Requested PanDirection `json:"requested"` +} + +// PanResponse consumes the enum from response headers — scalar and array — and from a body schema. +// +// swagger:response +type PanResponse struct { + // in: header + Applied PanDirection `json:"applied"` + + // in: header + Rejected []PanDirection `json:"rejected"` + + // in: body + Body PanState `json:"body"` +} + +// PanState consumes the enum from a schema property, scalar and array. +// +// swagger:model PanState +type PanState struct { + // The current direction. + Current PanDirection `json:"current"` + + // The directions still available. + Available []PanDirection `json:"available"` +} diff --git a/fixtures/bugs/3412/strfmtenum/api.go b/fixtures/bugs/3412/strfmtenum/api.go new file mode 100644 index 00000000..376c1a38 --- /dev/null +++ b/fixtures/bugs/3412/strfmtenum/api.go @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package strfmtenum covers an enum whose declared type is written OVER another named type rather +// than directly over a basic one. +// +// `type Kind strfmt.UUID` reaches go/types with `string` as its underlying: the intermediate — and +// with it the `uuid` format the strfmt declaration carries — is not in that view. The same type +// without the enum annotation keeps its format, because the ordinary path resolves the declaration's +// right-hand side; the enum arm has to resolve it too, or the annotation silently costs the author +// their format. +package strfmtenum + +import "github.com/go-openapi/strfmt" + +// Kind is an enum over a strfmt type. +// +// swagger:enum Kind +type Kind strfmt.UUID + +const ( + // KindPrimary is the primary kind. + KindPrimary Kind = "0a8bcf1e-0000-0000-0000-000000000000" + + // KindSecondary is the secondary kind. + KindSecondary Kind = "0a8bcf1e-1111-1111-1111-111111111111" +) + +// Contact is an enum two redefinitions away from the strfmt: the walk to the right has to repeat. +// +// swagger:enum Contact +type Contact Address + +// Address is the intermediate redefinition, carrying no annotation of its own. +type Address strfmt.Email + +const ( + // ContactSupport is the support address. + ContactSupport Contact = "support@example.com" +) + +// Plain is an enum straight over a basic type: the control, whose format stays the basic one. +// +// swagger:enum Plain +type Plain string + +const ( + // PlainOn is on. + PlainOn Plain = "on" +) + +// Labels carries the enums as schema properties. +// +// swagger:model Labels +type Labels struct { + // The kind. + Kind Kind `json:"kind"` + + // The contact. + Contact Contact `json:"contact"` + + // The plain label. + Plain Plain `json:"plain"` +} diff --git a/fixtures/integration/golden/bugs_3412_constforms.json b/fixtures/integration/golden/bugs_3412_constforms.json new file mode 100644 index 00000000..1f4c8a6b --- /dev/null +++ b/fixtures/integration/golden/bugs_3412_constforms.json @@ -0,0 +1,78 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Settings": { + "type": "object", + "title": "Settings carries one property per const form.", + "properties": { + "byte": { + "description": "The byte.\n0 ByteNUL\n120 ByteX", + "type": "integer", + "format": "uint8", + "enum": [ + 0, + 120 + ], + "x-go-enum-desc": "0 ByteNUL\n120 ByteX", + "x-go-name": "Byte" + }, + "label": { + "description": "The label.\nraw LabelRaw\na\tb LabelEscaped", + "type": "string", + "enum": [ + "raw", + "a\tb" + ], + "x-go-enum-desc": "raw LabelRaw\na\tb LabelEscaped", + "x-go-name": "Label" + }, + "letter": { + "description": "The letter.\n97 LetterA\n9 LetterTab", + "type": "integer", + "format": "int32", + "enum": [ + 97, + 9 + ], + "x-go-enum-desc": "97 LetterA\n9 LetterTab", + "x-go-name": "Letter" + }, + "level": { + "description": "The level.\n8 LevelLow\n16 LevelHigh", + "type": "integer", + "format": "int64", + "enum": [ + 8, + 16 + ], + "x-go-enum-desc": "8 LevelLow\n16 LevelHigh", + "x-go-name": "Level" + }, + "toggle": { + "description": "The toggle.\ntrue ToggleOn\nfalse ToggleOff", + "type": "boolean", + "enum": [ + true, + false + ], + "x-go-enum-desc": "true ToggleOn\nfalse ToggleOff", + "x-go-name": "Toggle" + }, + "weekday": { + "description": "The day of the week.\n0 Sunday\n1 Monday\n2 Tuesday", + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1, + 2 + ], + "x-go-enum-desc": "0 Sunday\n1 Monday\n2 Tuesday", + "x-go-name": "Weekday" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/bugs/3412/constforms" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/bugs_3412_negative.json b/fixtures/integration/golden/bugs_3412_negative.json new file mode 100644 index 00000000..4d61e4f9 --- /dev/null +++ b/fixtures/integration/golden/bugs_3412_negative.json @@ -0,0 +1,133 @@ +{ + "swagger": "2.0", + "paths": { + "/pan/{pan}": { + "get": { + "tags": [ + "pan" + ], + "summary": "Sets the pan direction.", + "operationId": "setPan", + "parameters": [ + { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8", + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Pan", + "description": "The requested pan direction.\n-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "name": "pan", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ControlParams" + } + } + } + } + }, + "definitions": { + "ControlState": { + "type": "object", + "title": "ControlState carries the enums as schema properties.", + "properties": { + "aperture": { + "description": "The aperture.\n0 ApertureClosed\n18446744073709551615 ApertureOpen", + "type": "integer", + "format": "uint64", + "enum": [ + 0, + 18446744073709551615 + ], + "x-go-enum-desc": "0 ApertureClosed\n18446744073709551615 ApertureOpen", + "x-go-name": "Aperture" + }, + "mask": { + "description": "The active mask.\n42 MaskHex\n42 MaskBinary\n42 MaskOctal\n1000 MaskUnderscore", + "type": "integer", + "format": "int64", + "enum": [ + 42, + 42, + 42, + 1000 + ], + "x-go-enum-desc": "42 MaskHex\n42 MaskBinary\n42 MaskOctal\n1000 MaskUnderscore", + "x-go-name": "Mask" + }, + "pan": { + "description": "The pan direction.\n-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "type": "integer", + "format": "int8", + "enum": [ + -1, + 0, + 1 + ], + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Pan" + }, + "tilt": { + "description": "The tilt angle.\n-0.5 TiltDown\n0 TiltFlat\n0.5 TiltUp", + "type": "number", + "format": "double", + "enum": [ + -0.5, + 0, + 0.5 + ], + "x-go-enum-desc": "-0.5 TiltDown\n0 TiltFlat\n0.5 TiltUp", + "x-go-name": "Tilt" + }, + "zoom": { + "description": "The zoom step.\n0 ZoomNone\n-1.5 ZoomOut\n1.5 ZoomIn", + "type": "number", + "format": "float", + "enum": [ + 0, + -1.5, + 1.5 + ], + "x-go-enum-desc": "0 ZoomNone\n-1.5 ZoomOut\n1.5 ZoomIn", + "x-go-name": "Zoom" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/bugs/3412/negative" + } + }, + "responses": { + "ControlParams": { + "description": "ControlParams carries the enums as response headers.\n\nPTZ control parameters", + "headers": { + "pan": { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8", + "description": "specifies the direction of the pan. -1 is pan left, 0 is no pan, 1 is pan right.", + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right." + }, + "tilt": { + "enum": [ + -0.5, + 0, + 0.5 + ], + "type": "number", + "format": "double", + "description": "specifies the tilt angle.", + "x-go-enum-desc": "-0.5 TiltDown\n0 TiltFlat\n0.5 TiltUp" + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/bugs_3412_simpleschema.json b/fixtures/integration/golden/bugs_3412_simpleschema.json new file mode 100644 index 00000000..e34ce4a4 --- /dev/null +++ b/fixtures/integration/golden/bugs_3412_simpleschema.json @@ -0,0 +1,177 @@ +{ + "swagger": "2.0", + "paths": { + "/pan": { + "post": { + "tags": [ + "upload" + ], + "summary": "Uploads a pan setting.", + "operationId": "uploadPan", + "parameters": [ + { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8", + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Requested", + "description": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "name": "requested", + "in": "formData" + } + ], + "responses": { + "200": { + "$ref": "#/responses/PanResponse" + } + } + } + }, + "/pan/{pan}": { + "get": { + "tags": [ + "pan" + ], + "summary": "Sets the pan direction.", + "operationId": "setPan", + "parameters": [ + { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8", + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Pan", + "description": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "name": "pan", + "in": "path", + "required": true + }, + { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8", + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Preferred", + "description": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "name": "preferred", + "in": "query" + }, + { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8", + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Fallback", + "description": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "name": "fallback", + "in": "header" + }, + { + "type": "array", + "items": { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8" + }, + "x-go-name": "Allowed", + "name": "allowed", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/PanResponse" + } + } + } + } + }, + "definitions": { + "PanState": { + "type": "object", + "title": "PanState consumes the enum from a schema property, scalar and array.", + "properties": { + "available": { + "description": "The directions still available.", + "type": "array", + "items": { + "type": "integer", + "format": "int8", + "enum": [ + -1, + 0, + 1 + ], + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right." + }, + "x-go-name": "Available" + }, + "current": { + "description": "The current direction.\n-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "type": "integer", + "format": "int8", + "enum": [ + -1, + 0, + 1 + ], + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Current" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/bugs/3412/simpleschema" + } + }, + "responses": { + "PanResponse": { + "description": "PanResponse consumes the enum from response headers — scalar and array — and from a body schema.", + "schema": { + "$ref": "#/definitions/PanState" + }, + "headers": { + "applied": { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8", + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right." + }, + "rejected": { + "type": "array", + "items": { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/bugs_3412_strfmtenum.json b/fixtures/integration/golden/bugs_3412_strfmtenum.json new file mode 100644 index 00000000..17e4bc1a --- /dev/null +++ b/fixtures/integration/golden/bugs_3412_strfmtenum.json @@ -0,0 +1,43 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Labels": { + "type": "object", + "title": "Labels carries the enums as schema properties.", + "properties": { + "contact": { + "description": "The contact.\nsupport@example.com ContactSupport is the support address.", + "type": "string", + "format": "email", + "enum": [ + "support@example.com" + ], + "x-go-enum-desc": "support@example.com ContactSupport is the support address.", + "x-go-name": "Contact" + }, + "kind": { + "description": "The kind.\n0a8bcf1e-0000-0000-0000-000000000000 KindPrimary is the primary kind.\n0a8bcf1e-1111-1111-1111-111111111111 KindSecondary is the secondary kind.", + "type": "string", + "format": "uuid", + "enum": [ + "0a8bcf1e-0000-0000-0000-000000000000", + "0a8bcf1e-1111-1111-1111-111111111111" + ], + "x-go-enum-desc": "0a8bcf1e-0000-0000-0000-000000000000 KindPrimary is the primary kind.\n0a8bcf1e-1111-1111-1111-111111111111 KindSecondary is the secondary kind.", + "x-go-name": "Kind" + }, + "plain": { + "description": "The plain label.\non PlainOn is on.", + "type": "string", + "enum": [ + "on" + ], + "x-go-enum-desc": "on PlainOn is on.", + "x-go-name": "Plain" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/bugs/3412/strfmtenum" + } + } +} \ No newline at end of file diff --git a/internal/builders/schema/README.md b/internal/builders/schema/README.md index 63b1fe02..8e75bc33 100644 --- a/internal/builders/schema/README.md +++ b/internal/builders/schema/README.md @@ -1429,6 +1429,107 @@ annotation of interest. | `classifierNamedStructStrfmt` | `buildNamedStruct` strfmt-first branch | `swagger:strfmt` | | `scanFieldDoc` | field-level FieldWalker (`applyFieldCarrier`) | `swagger:ignore`, `swagger:name`, `swagger:strfmt`, `swagger:type` (with the same single-word filter), `swagger:allOf` | +### Enum typing — the declared Go type wins + +`classifierNamedBasic`'s `swagger:enum` arm delegates to +`applyEnum`, which resolves the schema's `type` / `format` from +`utitpe` — the enum's **declared** underlying basic type — and then +normalises every member to it via +`validations.CoerceConstant`. + +This is deliberate, and it is the opposite of what the code did +before. + +**What the scanner loses is the value's width, not the type.** An +enum member arrives as an `any` holding one of five Go +representations — `int64`, `uint64`, `float64`, `string`, `bool` — +because that is what `go/constant` converts to +(`constant.Int64Val`, `Float64Val`, …). A member of an `int8` enum +and a member of an `int64` one are both an `int64` in that box; +nothing about the box says which. The **declared Go type is still +right there** on the builder side (`utitpe`), and it is the only +place `format: int8` can come from — so the value is the wrong +thing to ask. + +Typing the schema from `reflect.TypeOf(values[0])` — asking the +box — therefore: + +- collapsed `int8` / `uint16` / `float32` enums to + `int64` / `int64` / `double`, while the *same* Go types on a + plain field resolved correctly through + `resolvers.SwaggerSchemaForType`; +- made the emitted type depend on the **declaration order** of the + const block — a `float64` enum whose first member was written + `= 0` (an INT literal) emitted `{type: integer}` while still + carrying its fractional members, a schema no validator can + satisfy, and reordering the block silently changed the output. + +Both call sites (`buildFromDecl`'s named-basic arm and the field +site) pass the declared `*types.Basic`, so definitions, non-body +parameters and response headers all get the same treatment. On the +SimpleSchema targets that means the full set — `in: path` / +`query` / `header` / `formData`, the `items` of an array-typed one, +and response headers including their `items` — all carry the +members with the declared `type` / `format`. + +### The underlying type is not always the whole answer + +"Declared type" means the declaration, not `Underlying()`. The two +differ as soon as a type is written **over another named type**: + +```go +type Kind strfmt.UUID // Underlying() == string. The uuid is gone. +``` + +`strfmt.UUID` carries the `swagger:strfmt uuid` annotation, and +go/types offers no way back to it — `Underlying()` jumps straight +to the bottom `string`. Without the enum annotation this type +still emits `{type: string, format: uuid}`, because the ordinary +declaration path (`buildFromDecl`) resolves `Spec.Type` — the +right-hand side — rather than the underlying. The enum arm typed +from the underlying alone, so **annotating a type as an enum cost +the author their format**, silently. + +`applyEnumType` closes that: for a string-domain enum it first +asks `inheritedStrfmt`, which walks the chain of declarations to +the right (`Spec.Type`, repeatedly) looking for a +`swagger:strfmt`, so an indirection several redefinitions long +resolves like a single one. The walk stops at the basic bottom, at +a declaration the scan cannot see the source of, or at a repeat. +Only `swagger:strfmt` is inherited — a type-*changing* annotation +on an intermediate would contradict the enum's own members rather +than decorate them — and only onto a string, which is all a strfmt +can legally decorate. + +`basicSchemaType` maps that type to the Swagger value domain from +go/types' own kind bits (`IsInteger` / `IsFloat` / `IsString` / +`IsBoolean`) rather than a name table, so `byte` and `rune` need no +special casing and a domain-less type (complex) yields `""` — the +cue to leave values untouched. + +### A `rune` / `byte` enum is an integer enum, and that is correct + +```go +type Letter rune +const LetterA Letter = 'a' // → {type: integer, format: int32, enum: [97]} +``` + +An author who writes character literals usually expects +`enum: ["a"]`, and gets `97`. That surprise is Go's, not ours: a +scalar `rune` is an `int32` on the wire, so +`json.Marshal(Letter('a'))` is `97`, and `encoding/json` **refuses** +to unmarshal `"a"` into that field. A string-typed schema here +would describe a payload the server rejects — the integer is the +only faithful answer, and no rule about enums can change it. + +The author's remedy is a type whose *wire form* is a string: +`type Letter string` with `LetterA Letter = "a"`. Left visible +rather than papered over; the `byte` / `rune` cases in +`fixtures/bugs/3412/constforms` pin the behaviour. + +See [§enum-const-values](../validations/README.md#enum-const-values) +for the coercion rules on the values themselves. + --- ## §quirks — known behavioural caveats diff --git a/internal/builders/schema/schema.go b/internal/builders/schema/schema.go index 2b260a13..0be83123 100644 --- a/internal/builders/schema/schema.go +++ b/internal/builders/schema/schema.go @@ -206,7 +206,7 @@ func (s *Builder) buildFromDecl(schema *oaispec.Schema) error { return nil } case *types.Basic: - if s.classifierNamedBasic(s.Decl.Comments, s.Decl.Pkg, ut, defTgt, tpe.Obj().Name()) { + if s.classifierNamedBasic(s.Decl.Comments, s.Decl.Pkg, tpe, ut, defTgt) { return nil } } @@ -509,7 +509,7 @@ func (s *Builder) buildNamedType(titpe *types.Named, target ifaces.SwaggerTypabl s.warnUnsupportedGoType("buildNamedType", tio) return nil } - if !refModel && s.classifierNamedBasic(cmt, pkg, utitpe, target, tio.Name()) { + if !refModel && s.classifierNamedBasic(cmt, pkg, titpe, utitpe, target) { return nil } return s.resolveRefOr(tio, target, func() error { diff --git a/internal/builders/schema/walker_classifiers.go b/internal/builders/schema/walker_classifiers.go index fd55c30c..1079734c 100644 --- a/internal/builders/schema/walker_classifiers.go +++ b/internal/builders/schema/walker_classifiers.go @@ -7,11 +7,11 @@ import ( "go/ast" "go/token" "go/types" - "reflect" "strconv" "strings" "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/builders/validations" "github.com/go-openapi/codescan/internal/ifaces" "github.com/go-openapi/codescan/internal/parsers/grammar" "github.com/go-openapi/codescan/internal/scanner" @@ -154,22 +154,189 @@ func (s *Builder) recordEnumOrigins(enumPos []token.Pos) { } } -func (s *Builder) classifierNamedBasic(cg *ast.CommentGroup, pkg *packages.Package, utitpe *types.Basic, tgt ifaces.SwaggerTypable, declTypeName string) (resolved bool) { +// applyEnum writes the enum members of enumName onto tgt, together with the type/format of the +// enum's own declared Go type. +// +// Type and format come from utitpe — the DECLARED underlying basic type — never from the values. +// The scanner hands over evaluated constants, so every integer width collapses to int64 and every +// float width to float64 on the way out; typing the schema from the first value made an +// `int8` enum an `int64` one, a `float32` enum a `double`, and — when a float enum's first member +// happened to be written as an integer literal (`= 0`) — produced `{type: integer}` carrying +// fractional members, a schema no validator can satisfy. Each value is then normalised to that +// declared type (go-swagger#3412 follow-up). +// +// Returns false when the enum has no usable member, leaving tgt untouched so the caller can fall +// through to the type-resolution engine. +// +// # Details +// +// See [§enum-typing](./README.md#enum-typing) — why the declared type wins over the parsed values, +// and the two defects that rule settles. +func (s *Builder) applyEnum(pkg *packages.Package, declared *types.Named, utitpe *types.Basic, tgt ifaces.SwaggerTypable, enumName string) (resolved bool) { + enumValues, enumDesces, enumPos, _ := s.Ctx.FindEnumValues(pkg, enumName) + if len(enumValues) == 0 { + return false + } + + schemaType := basicSchemaType(utitpe) + values := make([]any, 0, len(enumValues)) + descs := make([]string, 0, len(enumValues)) + positions := make([]token.Pos, 0, len(enumValues)) + + for i, raw := range enumValues { + value, ok := validations.CoerceConstant(raw, schemaType) + if !ok { + // Unreachable from code that compiles: Go rejects a const whose value does not fit its + // declared type. Reported rather than emitted, so a malformed member can never contradict + // the schema's own type. + s.RecordDiagnostic(grammar.Warnf(s.declPos(), grammar.CodeInvalidEnumOption, + "swagger:enum %s: const value %v is not representable as %s; member dropped", + enumName, raw, utitpe.Name())) + continue + } + + values = append(values, value) + if i < len(enumDesces) { + descs = append(descs, enumDesces[i]) + } + if i < len(enumPos) { + positions = append(positions, enumPos[i]) + } + } + + if len(values) == 0 { + return false + } + + if !s.applyEnumType(declared, utitpe, tgt, schemaType, enumName) { + return false + } + + tgt.WithEnum(values...) + if len(descs) > 0 { + tgt.WithEnumDescription(strings.Join(descs, "\n")) + } + s.recordEnumOrigins(positions) + + return true +} + +// applyEnumType writes the enum's `type` / `format` onto tgt, resolved from the enum's declared Go +// type. +// +// The underlying basic type is the usual answer, but it is not always the whole one: a type +// declared OVER another named type (`type Kind strfmt.UUID`) reaches go/types with `string` as its +// underlying, and everything the intermediate contributed — here the `uuid` format — is gone from +// that view. The same type without the enum annotation keeps its format, because the ordinary path +// resolves the declaration's right-hand side rather than its underlying; inheritedStrfmt is the +// enum arm's equivalent of that, restricted to what a strfmt can legally decorate (a string). +// +// Reports false when the underlying type has no Swagger representation at all (a complex64 enum): +// there is no schema to write members onto, and the caller falls through so the type-resolution +// engine reports the unsupported type. +func (s *Builder) applyEnumType(declared *types.Named, utitpe *types.Basic, tgt ifaces.SwaggerTypable, schemaType, enumName string) bool { + if schemaType == "string" { + if format, ok := s.inheritedStrfmt(declared); ok { + tgt.Typed("string", format) + + return true + } + } + + if err := resolvers.SwaggerSchemaForType(utitpe.Name(), tgt); err != nil { + // e.g. a complex64 enum: no JSON representation, so there is no schema to write the members + // onto. The type-resolution engine reports the unsupported type on the fallthrough. + s.RecordDiagnostic(grammar.Warnf(s.declPos(), grammar.CodeUnsupportedGoType, + "swagger:enum %s: underlying type %s has no Swagger representation: %v", + enumName, utitpe.Name(), err)) + + return false + } + + return true +} + +// inheritedStrfmt returns the strfmt format that declared inherits from the type it is written +// over, walking the chain of declarations to its right. +// +// `type Kind strfmt.UUID` carries no `swagger:strfmt` of its own — the annotation sits on +// `strfmt.UUID`, one declaration to the right — and go/types offers no way back to it, since +// Underlying() jumps straight to the bottom `string`. The AST does: a declaration's Spec.Type is +// the type it was written over, and the walk repeats from there, so an indirection chain several +// redefinitions long resolves like a single one. +// +// The walk stops at the first type that is not itself a named declaration (the basic bottom), at +// one the scan cannot see the source of, or at a repeat — Go forbids a cyclic type declaration, but +// nothing guarantees the AST handed to us came from code that compiles. +// +// Only `swagger:strfmt` is inherited: a type-changing annotation (`swagger:type`) on an intermediate +// would contradict the enum's own members rather than decorate them. +func (s *Builder) inheritedStrfmt(declared *types.Named) (string, bool) { + seen := make(map[types.Type]struct{}) + + for current := types.Type(declared); current != nil; { + if _, dup := seen[current]; dup { + return "", false + } + seen[current] = struct{}{} + + decl, ok := s.Ctx.DeclForType(current) + if !ok || decl == nil || decl.Spec == nil || decl.Pkg == nil { + return "", false + } + + // The enum's own declaration is the first step: its swagger:strfmt (if any) already won in + // classifierNamedBasic's strfmt-first arm, so a match here can only come from further right. + if current != types.Type(declared) { + if format, ok := s.findAnnotationArg(decl.Comments, grammar.AnnStrfmt); ok { + return format, true + } + } + + rhs, ok := decl.Pkg.TypesInfo.Types[decl.Spec.Type] + if !ok { + return "", false + } + + switch rhs.Type.(type) { + case *types.Named, *types.Alias: + current = rhs.Type + default: + return "", false + } + } + + return "", false +} + +// basicSchemaType maps a Go basic type to the Swagger type whose value domain it belongs to. +// +// Driven by go/types' own kind bits rather than a name table, so every integer width (including +// `byte` / `rune`) lands on "integer" without enumerating them. Types with no Swagger value domain +// (complex, unsafe.Pointer) yield "" — the caller's cue to leave values untouched. +func basicSchemaType(utitpe *types.Basic) string { + switch info := utitpe.Info(); { + case info&types.IsInteger != 0: + return "integer" + case info&types.IsFloat != 0: + return "number" + case info&types.IsString != 0: + return "string" + case info&types.IsBoolean != 0: + return "boolean" + default: + return "" + } +} + +func (s *Builder) classifierNamedBasic(cg *ast.CommentGroup, pkg *packages.Package, declared *types.Named, utitpe *types.Basic, tgt ifaces.SwaggerTypable) (resolved bool) { if name, ok := s.findAnnotationArg(cg, grammar.AnnStrfmt); ok { tgt.Typed("string", name) return true } - if enumName, ok := s.enumName(cg, declTypeName); ok { - enumValues, enumDesces, enumPos, _ := s.Ctx.FindEnumValues(pkg, enumName) - if len(enumValues) > 0 { - tgt.WithEnum(enumValues...) - enumTypeName := reflect.TypeOf(enumValues[0]).String() - _ = resolvers.SwaggerSchemaForType(enumTypeName, tgt) - if len(enumDesces) > 0 { - tgt.WithEnumDescription(strings.Join(enumDesces, "\n")) - } - s.recordEnumOrigins(enumPos) + if enumName, ok := s.enumName(cg, declared.Obj().Name()); ok { + if s.applyEnum(pkg, declared, utitpe, tgt, enumName) { return true } // swagger:enum with no matching const values. diff --git a/internal/builders/validations/README.md b/internal/builders/validations/README.md index 6cfac9c4..3191c06a 100644 --- a/internal/builders/validations/README.md +++ b/internal/builders/validations/README.md @@ -11,7 +11,10 @@ items/headers code paths. Its two halves are: - **Value coercion** (`coerce.go`) — turns raw annotation text into the Go value implied by the target schema's `type` + `format`, for keywords whose payload is a primitive literal (`default:`, - `example:`, `enum:`). + `example:`, `enum:`). `const_values.go` is the sibling half of + this concern: it normalises values the scanner read out of a + Go **const block** rather than out of annotation text + (see [§enum-const-values](#enum-const-values)). - **Shape legality** (`shape.go`) — answers "is this keyword legal on a schema of this type?" against the JSON-Schema draft-4 domain rules that Swagger 2.0 inherits. @@ -23,6 +26,7 @@ items/headers code paths. Its two halves are: - [§contract](#contract) — why these helpers live here and not in the grammar - [§coercion-dispatch](#coercion-dispatch) — `CoerceValue` / `ParseDefault` / `ParseEnumValues` routing - [§enum-shapes](#enum-shapes) — JSON-array form vs comma-list form +- [§enum-const-values](#enum-const-values) — `CoerceConstant` and the declared-type rule - [§format-axis](#format-axis) — why `Format` is reserved but not consulted today - [§format-compat](#format-compat) — `IsFormatCompatible` type×format legality - [§type-domain-table](#type-domain-table) — the keyword-vs-type legality table @@ -130,6 +134,53 @@ Per-element coercion is the same `CoerceValue` path as `default:` / `example:`, so type-aware typing applies uniformly across the three keywords. +## §enum-const-values — `CoerceConstant` and the declared-type rule + +`swagger:enum` members do not come from annotation text: the +scanner reads them off a Go const block. It has two readings of +that block, and `CoerceConstant` is what keeps them agreeing. + +The **primary reading** takes the values from the type-checker +(`go/types` constants). There, a constant's kind already follows +its declared type — a `float64`-typed `= 0` is a `constant.Float`, +an `int`-typed `= 42.0` is a `constant.Int` — so `CoerceConstant` +has nothing to do and passes the value through. + +The **degraded reading** (a partially loaded package, where the +type-checker has no value) falls back to literal syntax, and there +the declared type is invisible: + +```go +type Tilt float64 + +const ( + TiltFlat Tilt = 0 // INT literal → int64(0) + TiltDown Tilt = -0.5 // FLOAT literal → float64(-0.5) +) +``` + +One enum, two Go kinds, one declared type. `CoerceConstant` +normalises against the Swagger type resolved from that declared +type, so the degraded reading emits what the primary one would. + +Two rules make this safe: + +- **The declared type wins, always.** The caller + (`schema.applyEnum`) resolves `type` / `format` from the Go + type, never from the values. Before this rule existed, the type + was read off `reflect.TypeOf(values[0])`, which collapsed `int8` + to `int64` and `float32` to `double` — and let the *declaration + order* of the const block decide the schema type, so a float + enum whose first member was written `= 0` emitted + `{type: integer}` carrying fractional members. +- **A non-representable value is never emitted.** `(nil, false)` + tells the caller to drop the member and raise a diagnostic + rather than write something the schema's own `type` forbids. + +An unresolved (empty) Swagger type passes values through +untouched: with no type to normalise against, guessing would be +worse than leaving the scanner's reading intact. + ## §format-axis — `Format` is reserved but not routed `ParseDefault` and `ParseEnumValues` accept a `schemaFormat` diff --git a/internal/builders/validations/const_values.go b/internal/builders/validations/const_values.go new file mode 100644 index 00000000..1b2c6405 --- /dev/null +++ b/internal/builders/validations/const_values.go @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package validations + +import "math" + +// int64 bounds expressed in float64 so a float constant can be range-checked before conversion: +// converting an out-of-range float64 to int64 is implementation-defined in Go, so the check must +// happen first. The upper bound is exclusive — 2^63 is exactly representable as a float64 but is +// one past math.MaxInt64. +const ( + minInt64AsFloat = -9223372036854775808.0 + maxInt64AsFloat = 9223372036854775808.0 +) + +// CoerceConstant normalises a Go constant value — as extracted from an enum's const block — to the +// representation implied by the enum's declared Swagger type. +// +// It is the invariant guard on what reaches an `enum` member: whatever the scanner read, a value +// that contradicts the schema's own `type` is never emitted. The scanner's primary reading takes +// its values from the type-checker, which already normalises a constant's kind to its declared type +// (`TiltFlat Tilt = 0` on a `float64` enum is a Float constant, `Answer Count = 42.0` on an integer +// one is an Int), so on that path this is a no-op. It earns its keep on the degraded reading, which +// falls back to literal syntax — where `= 0` is an int64 whatever type it was declared with. See +// [§enum-const-values](./README.md#enum-const-values). +// +// schemaType is the Swagger type ("integer", "number", "string", "boolean"); any other value — +// including the empty string for a type that did not resolve — passes the value through untouched. +// +// Reports (nil, false) when the value is not representable in the target type. Code that compiles +// cannot produce that (Go rejects `const x IntType = 0.5`), so a caller should treat it as a defect +// worth a diagnostic rather than a routine outcome. +func CoerceConstant(value any, schemaType string) (any, bool) { + switch schemaType { + case "integer": + switch v := value.(type) { + case int64, uint64: + return v, true + case float64: + // An integral float literal is legal Go for an integer constant (`= 42.0`). + if v != math.Trunc(v) || v < minInt64AsFloat || v >= maxInt64AsFloat { + return nil, false + } + return int64(v), true + default: + return nil, false + } + + case "number": + switch v := value.(type) { + case float64: + return v, true + case int64: + return float64(v), true + case uint64: + return float64(v), true + default: + return nil, false + } + + case "string": + if v, ok := value.(string); ok { + return v, true + } + return nil, false + + case "boolean": + if v, ok := value.(bool); ok { + return v, true + } + return nil, false + + default: + // No resolved type to normalise against (or a type with no numeric domain): leave the value + // exactly as the scanner produced it rather than guessing. + return value, true + } +} diff --git a/internal/builders/validations/const_values_test.go b/internal/builders/validations/const_values_test.go new file mode 100644 index 00000000..48c5daa4 --- /dev/null +++ b/internal/builders/validations/const_values_test.go @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package validations_test + +import ( + "math" + "testing" + + "github.com/go-openapi/codescan/internal/builders/validations" + "github.com/go-openapi/testify/v2/assert" +) + +func TestCoerceConstant(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + value any + schemaType string + expected any + expectedOK bool + }{ + // integer targets + {name: "int64 on integer", value: int64(-1), schemaType: "integer", expected: int64(-1), expectedOK: true}, + {name: "uint64 on integer", value: uint64(math.MaxUint64), schemaType: "integer", expected: uint64(math.MaxUint64), expectedOK: true}, + {name: "integral float on integer", value: 42.0, schemaType: "integer", expected: int64(42), expectedOK: true}, + {name: "negative integral float on integer", value: -42.0, schemaType: "integer", expected: int64(-42), expectedOK: true}, + {name: "fractional float on integer", value: 0.5, schemaType: "integer", expectedOK: false}, + {name: "out-of-range float on integer", value: 1e300, schemaType: "integer", expectedOK: false}, + {name: "MaxInt64+1 as float on integer", value: 9223372036854775808.0, schemaType: "integer", expectedOK: false}, + {name: "NaN on integer", value: math.NaN(), schemaType: "integer", expectedOK: false}, + {name: "string on integer", value: "nope", schemaType: "integer", expectedOK: false}, + + // number targets + {name: "float on number", value: -0.5, schemaType: "number", expected: -0.5, expectedOK: true}, + {name: "int64 on number", value: int64(0), schemaType: "number", expected: float64(0), expectedOK: true}, + {name: "uint64 on number", value: uint64(3), schemaType: "number", expected: float64(3), expectedOK: true}, + {name: "string on number", value: "nope", schemaType: "number", expectedOK: false}, + + // string / boolean targets + {name: "string on string", value: "low", schemaType: "string", expected: "low", expectedOK: true}, + {name: "int64 on string", value: int64(1), schemaType: "string", expectedOK: false}, + {name: "bool on boolean", value: true, schemaType: "boolean", expected: true, expectedOK: true}, + {name: "int64 on boolean", value: int64(1), schemaType: "boolean", expectedOK: false}, + + // unresolved target: pass through untouched rather than guess + {name: "int64 on unresolved type", value: int64(7), schemaType: "", expected: int64(7), expectedOK: true}, + {name: "string on object type", value: "raw", schemaType: "object", expected: "raw", expectedOK: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + value, ok := validations.CoerceConstant(tc.value, tc.schemaType) + assert.Equal(t, tc.expectedOK, ok) + if !tc.expectedOK { + assert.Nil(t, value) + + return + } + assert.Equal(t, tc.expected, value) + }) + } +} diff --git a/internal/integration/coverage_bug_3412_test.go b/internal/integration/coverage_bug_3412_test.go new file mode 100644 index 00000000..dc60976d --- /dev/null +++ b/internal/integration/coverage_bug_3412_test.go @@ -0,0 +1,272 @@ +// 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 covers go-swagger issue #3412 ("negative enum values are dropped"): +// `PanLeft PanDirection = -1` is a unary expression in the Go grammar, not a literal, so the +// scanner's BasicLit-only value extraction skipped it and the emitted enum was [0, 1]. +// +// The enum type is consumed from all three enum-carrying targets — response header, schema +// property and non-body parameter — so the negative value must survive each target's coercion, +// not merely the scan. A float enum covers the FLOAT branch, and a non-decimal enum covers the +// base-detected integer forms. +func TestCoverage_Bug3412(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./bugs/3412/negative/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + signedInts := []any{int64(-1), int64(0), int64(1)} + // TiltFlat is written `= 0`, an INT literal, but a constant's kind follows its declared type — + // float64 here — not the shape of the literal, so the member is 0.0 and the enum stays a + // number enum. + signedFloats := []any{-0.5, float64(0), 0.5} + + t.Run("schema property keeps the negative member", func(t *testing.T) { + props := doc.Definitions["ControlState"].Properties + + assert.Equal(t, signedInts, props["pan"].Enum) + assert.Contains(t, props["pan"].Extensions["x-go-enum-desc"], "-1 PanLeft") + + assert.Equal(t, signedFloats, props["tilt"].Enum) + assert.Contains(t, props["tilt"].Extensions["x-go-enum-desc"], "-0.5 TiltDown") + }) + + t.Run("non-body parameter keeps the negative member", func(t *testing.T) { + op := doc.Paths.Paths["/pan/{pan}"].Get + require.NotNil(t, op, "GET /pan/{pan} operation must be present") + require.Len(t, op.Parameters, 1) + param := op.Parameters[0] + require.Equal(t, "query", param.In) + + assert.Equal(t, signedInts, param.Enum) + assert.Contains(t, param.Description, "-1 PanLeft") + }) + + t.Run("response header keeps the negative member", func(t *testing.T) { + headers := doc.Responses["ControlParams"].Headers + + assert.Equal(t, signedInts, headers["pan"].Enum) + assert.Equal(t, signedFloats, headers["tilt"].Enum) + }) + + t.Run("non-decimal integer literals resolve to their value", func(t *testing.T) { + mask := doc.Definitions["ControlState"].Properties["mask"] + + assert.Equal(t, []any{int64(42), int64(42), int64(42), int64(1000)}, mask.Enum, + "hex, binary and octal forms resolve like the decimal one; digit separators are ignored") + }) + + t.Run("an unsigned member above MaxInt64 survives the signed parse", func(t *testing.T) { + aperture := doc.Definitions["ControlState"].Properties["aperture"] + + assert.Equal(t, []any{int64(0), uint64(18446744073709551615)}, aperture.Enum) + }) + + // The enum's type and format come from the DECLARED Go type. They used to be read off the first + // parsed value, which collapsed every width to int64/double and — worse — let the declaration + // order of the const block decide the type of the whole enum. + t.Run("type and format follow the declared Go type", func(t *testing.T) { + props := doc.Definitions["ControlState"].Properties + + for _, tc := range []struct { + property string + goType string + typ string + format string + }{ + {property: "pan", goType: "int8", typ: "integer", format: "int8"}, + {property: "tilt", goType: "float64", typ: "number", format: "double"}, + {property: "mask", goType: "int64", typ: "integer", format: "int64"}, + {property: "zoom", goType: "float32", typ: "number", format: "float"}, + {property: "aperture", goType: "uint64", typ: "integer", format: "uint64"}, + } { + t.Run(tc.goType, func(t *testing.T) { + assert.Equal(t, []string{tc.typ}, []string(props[tc.property].Type)) + assert.Equal(t, tc.format, props[tc.property].Format) + }) + } + }) + + t.Run("an integer literal in a float enum does not make the enum an integer one", func(t *testing.T) { + zoom := doc.Definitions["ControlState"].Properties["zoom"] + + // ZoomNone is `= 0`, written FIRST in the const block. + assert.Equal(t, []string{"number"}, []string(zoom.Type)) + assert.Equal(t, []any{float64(0), -1.5, 1.5}, zoom.Enum) + }) + + t.Run("the declared type reaches the SimpleSchema targets too", func(t *testing.T) { + header := doc.Responses["ControlParams"].Headers["pan"] + assert.Equal(t, "integer", header.Type) + assert.Equal(t, "int8", header.Format) + + param := doc.Paths.Paths["/pan/{pan}"].Get.Parameters[0] + assert.Equal(t, "integer", param.Type) + assert.Equal(t, "int8", param.Format) + }) + + // The subtests above read the properties the fix is about; the golden pins everything else the + // fixture emits alongside them, so a change nobody asserted on still has to be looked at. + scantest.CompareOrDumpJSON(t, doc, "bugs_3412_negative.json") +} + +// TestCoverage_Bug3412_ConstForms covers the const shapes that carry no readable value in their own +// syntax, and which a literal-syntax reader therefore cannot see at all: `iota` (where the implicit +// specs have neither a type nor a value), constant expressions, references to earlier members, rune +// literals, `true`/`false` (identifiers, not literals — Go has no boolean literal token), and the +// raw / escaped string forms. +// +// Values come from the type-checker, which evaluated all of them exactly; the schema type still +// comes from the declared Go type. +func TestCoverage_Bug3412_ConstForms(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./bugs/3412/constforms/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + props := doc.Definitions["Settings"].Properties + + for _, tc := range []struct { + name string + property string + typ string + format string + enum []any + }{ + { + name: "iota block: the implicit specs carry neither type nor value", + property: "weekday", + typ: "integer", format: "int64", + enum: []any{int64(0), int64(1), int64(2)}, + }, + { + name: "constant expression and reference to an earlier member", + property: "level", + typ: "integer", format: "int64", + enum: []any{int64(8), int64(16)}, + }, + { + name: "rune literals are integer constants", + property: "letter", + typ: "integer", format: "int32", + enum: []any{int64('a'), int64('\t')}, + }, + { + name: "true and false are identifiers, not literals", + property: "toggle", + typ: "boolean", format: "", + enum: []any{true, false}, + }, + { + name: "raw and escaped strings lose their delimiters and resolve escapes", + property: "label", + typ: "string", format: "", + enum: []any{"raw", "a\tb"}, + }, + { + name: "a byte enum mixing an integer and a rune literal", + property: "byte", + typ: "integer", format: "uint8", + enum: []any{int64(0), int64('x')}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, []string{tc.typ}, []string(props[tc.property].Type)) + assert.Equal(t, tc.format, props[tc.property].Format) + assert.Equal(t, tc.enum, props[tc.property].Enum) + }) + } + + t.Run("a const of a same-named imported type is not a member", func(t *testing.T) { + // `const ForeignDay foreign.Weekday = 13` sits in the annotated package but belongs to another + // package's Weekday. Membership is type identity, not name. + assert.NotContains(t, props["weekday"].Enum, int64(13)) + }) + + t.Run("the per-value name mapping follows the evaluated value", func(t *testing.T) { + assert.Equal(t, "0 Sunday\n1 Monday\n2 Tuesday", + props["weekday"].Extensions["x-go-enum-desc"]) + assert.Equal(t, "97 LetterA\n9 LetterTab", + props["letter"].Extensions["x-go-enum-desc"]) + }) + + scantest.CompareOrDumpJSON(t, doc, "bugs_3412_constforms.json") +} + +// TestCoverage_Bug3412_SimpleSchema pins the whole propagation surface of a `swagger:enum` type +// with a whole-spec golden. +// +// The enum reaches the spec through several builder paths: a schema property and its array items, +// but also the SimpleSchema targets — `in: path` / `query` / `header` / `formData`, the `items` of +// an array-typed parameter, and response headers with their own `items` — where OAS v2 forbids the +// `$ref` a definition would use, so the members and the declared type/format must be written +// inline. Each is a distinct path, and a per-property assertion in one of them says nothing about +// the others; the golden is what makes a regression in any single target visible. +func TestCoverage_Bug3412_SimpleSchema(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./bugs/3412/simpleschema/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "bugs_3412_simpleschema.json") +} + +// TestCoverage_Bug3412_StrfmtEnum covers an enum whose declared type is written over another NAMED +// type. `type Kind strfmt.UUID` has `string` as its go/types underlying, so the `uuid` format the +// strfmt declaration carries is absent from the view the enum arm types from — while the very same +// type without the annotation keeps it, since the ordinary path resolves the declaration's +// right-hand side instead. Annotating a type as an enum must not cost the author their format. +func TestCoverage_Bug3412_StrfmtEnum(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./bugs/3412/strfmtenum/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + props := doc.Definitions["Labels"].Properties + + t.Run("the format of the type the enum is written over survives", func(t *testing.T) { + assert.Equal(t, []string{"string"}, []string(props["kind"].Type)) + assert.Equal(t, "uuid", props["kind"].Format) + assert.Equal(t, []any{ + "0a8bcf1e-0000-0000-0000-000000000000", + "0a8bcf1e-1111-1111-1111-111111111111", + }, props["kind"].Enum) + }) + + t.Run("an indirection chain resolves like a single redefinition", func(t *testing.T) { + assert.Equal(t, []string{"string"}, []string(props["contact"].Type)) + assert.Equal(t, "email", props["contact"].Format) + assert.Equal(t, []any{"support@example.com"}, props["contact"].Enum) + }) + + t.Run("an enum straight over a basic type keeps the basic format", func(t *testing.T) { + assert.Equal(t, []string{"string"}, []string(props["plain"].Type)) + assert.Empty(t, props["plain"].Format) + assert.Equal(t, []any{"on"}, props["plain"].Enum) + }) + + scantest.CompareOrDumpJSON(t, doc, "bugs_3412_strfmtenum.json") +} diff --git a/internal/scanner/README.md b/internal/scanner/README.md index be5d34fb..df83149a 100644 --- a/internal/scanner/README.md +++ b/internal/scanner/README.md @@ -28,6 +28,8 @@ parameters, responses) consumed by the builder layer. struct-annotation exclusivity - [§after-decl](#after-decl) — `AfterDeclComments` — reading annotations inside / below a declaration +- [§enum-values](#enum-values) — where `swagger:enum` member values + come from, and what the degraded reading can still see - [§clean-godoc](#clean-godoc) — `CleanGoDoc` — filtering godoc syntax out of carried-over title / description prose - [§quirks-open](#quirks-open) — deferred follow-ups @@ -361,6 +363,81 @@ semantics today. Supporting it would mean new builder behaviour, which this scanner-only feature deliberately avoids. Nested/anonymous inline structs are likewise not enriched (only named struct type decls are walked). +## §enum-values — reading `swagger:enum` members + +`FindEnumValues` walks the const declarations of a package and +emits one row per constant whose type is the annotated enum type. +Two decisions shape it. + +### Membership is decided per name, from the type-checker + +The spec's syntactic type (`vs.Type`) is not usable as the +membership test, because inside an `iota` block only the *first* +spec carries a type at all: + +```go +const ( + Sunday Weekday = iota // Type=Weekday Values=[iota] + Monday // Type= Values= + Tuesday // Type= Values= +) +``` + +Monday and Tuesday inherit both implicitly, so a syntactic reader +sees two specs that declare nothing. Membership therefore comes +from `TypesInfo.Defs[name].(*types.Const).Type()` — the type the +checker assigned — which also covers a constant declared without a +written type (`const Extra = StatusOn`). + +The test is **type identity, not name**: the named type must also +come from the package being walked. A constant declared in the +annotated package can perfectly well have an imported type +(`const ForeignDay foreign.Weekday = 13` next to a local `Weekday` +enum), and it is a member of neither. The syntactic reading ruled +that out structurally — a qualified type is a selector expression, +not the bare ident it required — so the package check is what +keeps the type-checked reading from being *wider* than the one it +replaced. + +An enum cannot be hosted on an **alias to a basic type** +(`type Unsigned = uint64`): the checker erases the alias, so +`const Zero Unsigned = 0` is indistinguishable from any other +`uint64` constant and there is nothing left to match on. The +annotation is a no-op there — as it was before this change, since +the classifier never reaches an alias decl either. An alias to a +*named* enum type (`type Weekday2 = Weekday`) is fine: the +underlying named type survives. + +### Values come from the type-checker, not from the literal + +A const's right-hand side is only incidentally a literal. It can +be `iota`, an expression (`1 << 3`), a reference to an earlier +member (`Prev * 2`), a rune literal (`'a'`, whose constant is the +integer 97), or `true` / `false` — which are predeclared +*identifiers*, since Go has no boolean literal token. Reading the +value out of the syntax means reimplementing Go's constant +evaluator: iota counting, implicit repetition, and constant +folding. + +`go/types` has already done that, exactly and with arbitrary +precision, so `enumConstantValue` converts the resulting +`constant.Value` by kind (`Int` → `int64`, or `uint64` past +`MaxInt64`; `Float` → `float64`; `String`; `Bool`). A constant with +no JSON representation (complex) or one the checker could not +evaluate is dropped rather than emitted as a null member. + +**The degraded reading.** When the package only partially +type-checked (see `ErrDegradedLoad`), a constant may have no value +in `Defs`. Rather than let an annotated enum vanish, +`enumValue` falls back to the literal syntax — a lone literal, +optionally signed, with rune literals and raw/escaped strings +handled. It is a strict subset: `iota`, expressions and references +are invisible to it by construction, and its values keep the kind +their literal implies rather than the kind of their declared type. +The builder's `validations.CoerceConstant` closes that last gap — +see +[§enum-const-values](../builders/validations/README.md#enum-const-values). + ## §clean-godoc — `CleanGoDoc` `Options.CleanGoDoc` (opt-in, default false) rewrites godoc-specific syntax that diff --git a/internal/scanner/enum_value.go b/internal/scanner/enum_value.go index c8373a21..eb30cbaf 100644 --- a/internal/scanner/enum_value.go +++ b/internal/scanner/enum_value.go @@ -5,28 +5,138 @@ package scanner import ( "go/ast" + "go/constant" + "go/token" "strconv" "strings" ) -// 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. +// enumConstantValue converts a type-checked Go constant into its runtime value — int64 / uint64 / +// float64 / string / bool — 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 { - switch basicLit.Kind.String() { - case "INT": - if result, err := strconv.ParseInt(basicLit.Value, 10, 64); err == nil { +// This is the primary reading: go/types has already evaluated the constant exactly, so `iota` +// sequences, expressions (`1 << 3`, `Prev * 2`), references to other constants, rune literals +// (`'a'` → 97), raw/escaped strings and every integer base all arrive resolved. Reading them back +// out of the literal syntax instead would mean reimplementing Go's constant evaluator — see +// [§enum-values](./README.md#enum-values). +// +// Returns ok=false for a constant with no JSON representation (complex) or one the type-checker +// could not evaluate: the caller drops the member rather than emitting a null enum entry. +func enumConstantValue(value constant.Value) (any, bool) { + switch value.Kind() { + case constant.Int: + if i, exact := constant.Int64Val(value); exact { + return i, true + } + // A uint64 above math.MaxInt64 is a legal member of an unsigned enum. + if u, exact := constant.Uint64Val(value); exact { + return u, true + } + + return nil, false + + case constant.Float: + // exact is false whenever the value needs rounding to fit a float64 (1/3, big rationals); + // the rounded value is still the best JSON can carry, so it is kept. + f, _ := constant.Float64Val(value) + + return f, true + + case constant.String: + return constant.StringVal(value), true + + case constant.Bool: + return constant.BoolVal(value), true + + default: // constant.Complex (no JSON representation), constant.Unknown (evaluation failed) + return nil, false + } +} + +// enumValue converts the RHS of a `const Foo Kind = "bar"` declaration into its runtime value by +// reading the literal syntax. +// +// This is the DEGRADED reading, used only when the type-checker has no value for the constant — a +// partially loaded package (see ErrDegradedLoad). It sees a strict subset of what +// [enumConstantValue] sees: a lone literal, optionally signed. `iota`, expressions and references +// to other constants are invisible to it by construction, since their value is not in the syntax. +// +// A signed numeric constant (`-1`, `+2.5`) is not a literal in the Go grammar: it reaches the AST +// as a unary expression wrapping the literal, so the sign is folded back into the literal text +// before parsing. Without this, negative enum members were silently dropped (go-swagger#3412). +// +// Returns nil when the expression is neither a literal nor a signed literal — the caller drops the +// value rather than emitting a null enum member. +func enumValue(expr ast.Expr) any { + switch e := expr.(type) { + case *ast.BasicLit: + return enumLiteralValue(e.Kind, e.Value) + + case *ast.UnaryExpr: + if e.Op != token.SUB && e.Op != token.ADD { + return nil + } + + basicLit, ok := e.X.(*ast.BasicLit) + if !ok || (basicLit.Kind != token.INT && basicLit.Kind != token.FLOAT) { + return nil // a sign only ever applies to a number + } + + return enumLiteralValue(basicLit.Kind, e.Op.String()+basicLit.Value) + + default: + return nil + } +} + +// enumLiteralValue parses the textual form of a literal of the given kind. +// +// Integers are parsed with base detection so the non-decimal Go forms (`0x2a`, `0b101010`, `0o52`) +// and digit separators (`1_000`) resolve to their value like the decimal form does. A value above +// math.MaxInt64 — legal for a `uint64` enum — falls back to an unsigned parse rather than being +// dropped. +// +// A rune literal ('a', '\t') yields its code point, matching the integer type such a constant must +// have. Strings are unquoted rather than trimmed, so escape sequences resolve and the raw +// (backquoted) form loses its delimiters like the interpreted form does. +// +// Returns nil when the textual value fails to parse (rare — Go's own parser would have caught it +// upstream, but the safety net is cheap). +func enumLiteralValue(kind token.Token, value string) any { + switch kind { + case token.INT: + if result, err := strconv.ParseInt(value, 0, 64); err == nil { return result } - case "FLOAT": - if result, err := strconv.ParseFloat(basicLit.Value, 64); err == nil { + if result, err := strconv.ParseUint(value, 0, 64); err == nil { return result } + + case token.FLOAT: + if result, err := strconv.ParseFloat(value, 64); err == nil { + return result + } + + case token.CHAR: + // Drop the opening quote only: UnquoteChar resolves the escape (if any) and stops at the + // closing one, so an escaped quote ('\'') survives where trimming both delimiters would eat + // half of it. + quoted, ok := strings.CutPrefix(value, "'") + if !ok { + return nil + } + if result, _, _, err := strconv.UnquoteChar(quoted, '\''); err == nil { + return int64(result) + } + default: - return strings.Trim(basicLit.Value, "\"") + if result, err := strconv.Unquote(value); err == nil { + return result + } + + return strings.Trim(value, "\"") } + return nil } diff --git a/internal/scanner/enum_value_test.go b/internal/scanner/enum_value_test.go new file mode 100644 index 00000000..ec975fdb --- /dev/null +++ b/internal/scanner/enum_value_test.go @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package scanner + +import ( + "go/constant" + "go/token" + "math" + "testing" + + "github.com/go-openapi/testify/v2/assert" +) + +func TestEnumConstantValue(t *testing.T) { + t.Parallel() + + hugeInt := constant.BinaryOp(constant.MakeUint64(math.MaxUint64), token.MUL, constant.MakeInt64(2)) + + for _, tc := range []struct { + name string + value constant.Value + expected any + expectedOK bool + }{ + {name: "int64", value: constant.MakeInt64(-1), expected: int64(-1), expectedOK: true}, + { + name: "int above MaxInt64 falls back to unsigned", + value: constant.MakeUint64(math.MaxUint64), + expected: uint64(math.MaxUint64), expectedOK: true, + }, + {name: "int beyond uint64 is dropped", value: hugeInt, expectedOK: false}, + {name: "float", value: constant.MakeFloat64(-0.5), expected: -0.5, expectedOK: true}, + { + name: "float needing rounding keeps the rounded value", + value: constant.BinaryOp(constant.MakeInt64(1), token.QUO, constant.MakeInt64(3)), + expected: 1.0 / 3.0, expectedOK: true, + }, + {name: "string", value: constant.MakeString("a\tb"), expected: "a\tb", expectedOK: true}, + {name: "bool", value: constant.MakeBool(true), expected: true, expectedOK: true}, + {name: "complex has no JSON representation", value: constant.ToComplex(constant.MakeInt64(1)), expectedOK: false}, + {name: "unknown (evaluation failed)", value: constant.MakeUnknown(), expectedOK: false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + value, ok := enumConstantValue(tc.value) + assert.Equal(t, tc.expectedOK, ok) + if !tc.expectedOK { + assert.Nil(t, value) + + return + } + assert.Equal(t, tc.expected, value) + }) + } +} + +// TestEnumLiteralValue covers the degraded reading, used only when the type-checker has no value +// for a constant. The literal forms it must still get right are the ones whose delimiters are not +// part of the value. +func TestEnumLiteralValue(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + kind token.Token + value string + expected any + }{ + {name: "decimal int", kind: token.INT, value: "42", expected: int64(42)}, + {name: "hex int", kind: token.INT, value: "0x2a", expected: int64(42)}, + {name: "int above MaxInt64", kind: token.INT, value: "18446744073709551615", expected: uint64(math.MaxUint64)}, + {name: "float", kind: token.FLOAT, value: "-0.5", expected: -0.5}, + {name: "rune", kind: token.CHAR, value: `'a'`, expected: int64('a')}, + {name: "escaped rune", kind: token.CHAR, value: `'\t'`, expected: int64('\t')}, + {name: "quoted rune", kind: token.CHAR, value: `'\''`, expected: int64('\'')}, + {name: "interpreted string", kind: token.STRING, value: `"low"`, expected: "low"}, + {name: "escaped string", kind: token.STRING, value: `"a\tb"`, expected: "a\tb"}, + {name: "raw string", kind: token.STRING, value: "`raw`", expected: "raw"}, + {name: "unparsable int", kind: token.INT, value: "not-a-number", expected: nil}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tc.expected, enumLiteralValue(tc.kind, tc.value)) + }) + } +} diff --git a/internal/scanner/scan_context.go b/internal/scanner/scan_context.go index 0abeb542..e8380923 100644 --- a/internal/scanner/scan_context.go +++ b/internal/scanner/scan_context.go @@ -911,7 +911,7 @@ func (s *ScanCtx) FindEnumValues(pkg *packages.Package, enumName string) (list [ } for _, spec := range gd.Specs { - values, descriptions, positions := s.findEnumValue(spec, enumName) + values, descriptions, positions := s.findEnumValue(pkg, spec, enumName) if len(values) == 0 { continue } @@ -926,46 +926,39 @@ func (s *ScanCtx) FindEnumValues(pkg *packages.Package, enumName string) (list [ return list, descList, posList, true } -// findEnumValue extracts one (value, description) pair per (name, value) position in a const spec. +// findEnumValue extracts one (value, description) row per name declared by a const spec whose type +// is enumName. // -// For a multi-name spec like `const A, B T = "a", "b"` it emits two rows — A↔"a" and B↔"b" -// — 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. -func (s *ScanCtx) findEnumValue(spec ast.Spec, enumName string) (values []any, descriptions []string, positions []token.Pos) { +// For a multi-name spec like `const A, B T = "a", "b"` it emits two rows — A↔"a" and B↔"b" — +// each sharing the spec's doc comment. +// +// Membership is decided per NAME, from the type the type-checker assigned to that constant, not +// from the spec's syntactic type: inside an `iota` block only the first spec carries a type at all, +// and every following one inherits it implicitly. +// +// # Details +// +// See [§enum-values](./README.md#enum-values) — why the values come from go/types and what the +// degraded reading can still see. +func (s *ScanCtx) findEnumValue(pkg *packages.Package, spec ast.Spec, enumName string) (values []any, descriptions []string, positions []token.Pos) { vs, ok := spec.(*ast.ValueSpec) if !ok { return nil, nil, nil } - vsIdent, ok := vs.Type.(*ast.Ident) - if !ok { - return nil, nil, nil - } - - if vsIdent.Name != enumName { - return nil, nil, nil - } - - if len(vs.Values) == 0 || len(vs.Values) != len(vs.Names) { - return nil, nil, nil - } - docSuffix := buildEnumDocSuffix(vs.Doc, vs.Names) for i, nameIdent := range vs.Names { - bl, ok := vs.Values[i].(*ast.BasicLit) + value, ok := s.enumMemberValue(pkg, vs, i, nameIdent, enumName) if !ok { continue } - literalValue := enumBasicLitValue(bl) - var desc strings.Builder - fmt.Fprintf(&desc, "%v %s", literalValue, nameIdent.Name) + fmt.Fprintf(&desc, "%v %s", value, nameIdent.Name) desc.WriteString(docSuffix) - values = append(values, literalValue) + values = append(values, value) descriptions = append(descriptions, desc.String()) positions = append(positions, nameIdent.Pos()) } @@ -973,6 +966,73 @@ func (s *ScanCtx) findEnumValue(spec ast.Spec, enumName string) (values []any, d return values, descriptions, positions } +// enumMemberValue resolves the value of the i-th name declared by a const spec, when that constant +// belongs to the enum type enumName. +// +// The type-checker is the source of truth: it has already evaluated the constant exactly, so the +// value arrives resolved whatever shape the source took (`iota`, `1 << 3`, `'a'`, a reference to +// another constant). Membership is read from the constant's own type, which is what makes the +// implicit specs of an `iota` block visible. +// +// The AST reading below it fires only when the type-checker has no constant for the name — a +// partially loaded package, where an annotated enum should still contribute what can be read +// literally rather than vanish. It keeps the pre-types-info preconditions: an explicit type ident +// on the spec, and one value per name. +func (s *ScanCtx) enumMemberValue(pkg *packages.Package, vs *ast.ValueSpec, i int, nameIdent *ast.Ident, enumName string) (any, bool) { + if cst := constObjectFor(pkg, nameIdent); cst != nil { + if !isNamedType(cst.Type(), pkg.PkgPath, enumName) { + return nil, false + } + + return enumConstantValue(cst.Val()) + } + + vsIdent, ok := vs.Type.(*ast.Ident) + if !ok || vsIdent.Name != enumName { + return nil, false + } + + if len(vs.Values) == 0 || len(vs.Values) != len(vs.Names) { + return nil, false + } + + value := enumValue(vs.Values[i]) + + return value, value != nil +} + +// constObjectFor returns the type-checked constant declared by nameIdent, or nil when the package +// carries no type information for it (nil package, no TypesInfo, or a name the type-checker could +// not resolve in a degraded load). +func constObjectFor(pkg *packages.Package, nameIdent *ast.Ident) *types.Const { + if pkg == nil || pkg.TypesInfo == nil { + return nil + } + + cst, _ := pkg.TypesInfo.Defs[nameIdent].(*types.Const) + + return cst +} + +// isNamedType reports whether tpe is the named (possibly aliased) type called name, declared by the +// package at pkgPath. +// +// The package is part of the test because a constant declared in the scanned package may well have +// an IMPORTED type: `const ForeignOne other.Kind = 901`, sitting next to a local `Kind` enum, is a +// member of neither. The syntactic reading this replaces excluded it structurally — a qualified type +// is a selector expression, not the bare ident it required — so dropping the package check would +// silently widen every enum to its same-named neighbours. +func isNamedType(tpe types.Type, pkgPath, name string) bool { + named, ok := types.Unalias(tpe).(*types.Named) + if !ok { + return false + } + + obj := named.Obj() + + return obj.Name() == name && obj.Pkg() != nil && obj.Pkg().Path() == pkgPath +} + // buildEnumDocSuffix renders the shared doc comment as " ..." (with a leading single // space, keeping the per-line leading whitespace that survives TrimPrefix("//")), or the empty // string if there is no doc. diff --git a/internal/scanner/scan_context_test.go b/internal/scanner/scan_context_test.go index 9d1115ea..0e631d6d 100644 --- a/internal/scanner/scan_context_test.go +++ b/internal/scanner/scan_context_test.go @@ -531,7 +531,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { t.Run("non-ValueSpec returns nil", func(t *testing.T) { spec := &ast.ImportSpec{Path: &ast.BasicLit{Value: `"fmt"`}} - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") assert.Nil(t, values) assert.Nil(t, descs) }) @@ -540,7 +540,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { spec := &ast.ValueSpec{ Names: []*ast.Ident{ast.NewIdent("X")}, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") assert.Nil(t, values) assert.Nil(t, descs) }) @@ -550,7 +550,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { Names: []*ast.Ident{ast.NewIdent("X")}, Type: &ast.SelectorExpr{X: ast.NewIdent("pkg"), Sel: ast.NewIdent("Type")}, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") assert.Nil(t, values) assert.Nil(t, descs) }) @@ -561,7 +561,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { Type: ast.NewIdent("Bar"), Values: []ast.Expr{&ast.BasicLit{Kind: token.INT, Value: "1"}}, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") assert.Nil(t, values) assert.Nil(t, descs) }) @@ -571,7 +571,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { Names: []*ast.Ident{ast.NewIdent("X")}, Type: ast.NewIdent("Foo"), } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") assert.Nil(t, values) assert.Nil(t, descs) }) @@ -582,11 +582,57 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { Type: ast.NewIdent("Foo"), Values: []ast.Expr{ast.NewIdent("someFunc")}, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") assert.Empty(t, values) assert.Empty(t, descs) }) + t.Run("ValueSpec with signed numeric values keeps the sign", func(t *testing.T) { + // go-swagger#3412: `X Foo = -1` is a unary expression, not a BasicLit, and used to be skipped. + spec := &ast.ValueSpec{ + Names: []*ast.Ident{ast.NewIdent("X"), ast.NewIdent("Y"), ast.NewIdent("Z")}, + Type: ast.NewIdent("Foo"), + Values: []ast.Expr{ + &ast.UnaryExpr{Op: token.SUB, X: &ast.BasicLit{Kind: token.INT, Value: "1"}}, + &ast.UnaryExpr{Op: token.ADD, X: &ast.BasicLit{Kind: token.INT, Value: "2"}}, + &ast.UnaryExpr{Op: token.SUB, X: &ast.BasicLit{Kind: token.FLOAT, Value: "0.5"}}, + }, + } + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") + assert.Equal(t, []any{int64(-1), int64(2), -0.5}, values) + assert.Equal(t, []string{"-1 X", "2 Y", "-0.5 Z"}, descs) + }) + + t.Run("ValueSpec with a non-numeric unary value skips that position", func(t *testing.T) { + spec := &ast.ValueSpec{ + Names: []*ast.Ident{ast.NewIdent("X"), ast.NewIdent("Y")}, + Type: ast.NewIdent("Foo"), + Values: []ast.Expr{ + // Neither is legal Go for a constant of a basic type; the guard keeps a malformed AST + // from producing a null enum member. + &ast.UnaryExpr{Op: token.NOT, X: &ast.BasicLit{Kind: token.INT, Value: "1"}}, + &ast.UnaryExpr{Op: token.SUB, X: &ast.BasicLit{Kind: token.STRING, Value: `"a"`}}, + }, + } + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") + assert.Empty(t, values) + assert.Empty(t, descs) + }) + + t.Run("ValueSpec with non-decimal integer values resolves the base", func(t *testing.T) { + spec := &ast.ValueSpec{ + Names: []*ast.Ident{ast.NewIdent("X"), ast.NewIdent("Y"), ast.NewIdent("Z")}, + Type: ast.NewIdent("Foo"), + Values: []ast.Expr{ + &ast.BasicLit{Kind: token.INT, Value: "0x2a"}, + &ast.BasicLit{Kind: token.INT, Value: "0b101010"}, + &ast.BasicLit{Kind: token.INT, Value: "1_000"}, + }, + } + values, _, _ := sctx.findEnumValue(nil, spec, "Foo") + assert.Equal(t, []any{int64(42), int64(42), int64(1000)}, values) + }) + t.Run("ValueSpec with names/values parity mismatch returns nil", func(t *testing.T) { // The Go compiler forbids this, but we guard defensively so a malformed AST (e.g. from tests) // doesn't panic on index access. @@ -595,7 +641,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { Type: ast.NewIdent("Foo"), Values: []ast.Expr{&ast.BasicLit{Kind: token.INT, Value: "42"}}, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") assert.Nil(t, values) assert.Nil(t, descs) }) @@ -612,7 +658,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { List: []*ast.Comment{{Text: "// shared doc"}}, }, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") require.Len(t, values, 2) require.Len(t, descs, 2) assert.EqualT(t, "a", values[0]) @@ -633,7 +679,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { }, }, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") require.Len(t, values, 1) require.Len(t, descs, 1) assert.EqualT(t, "hello", values[0]) @@ -649,7 +695,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { List: []*ast.Comment{{Text: "// PriorityLow is a low-priority level."}}, }, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") require.Len(t, values, 1) assert.EqualT(t, "low PriorityLow is a low-priority level.", descs[0]) }) @@ -666,7 +712,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { List: []*ast.Comment{{Text: "// ChannelEmail and ChannelSMS share a single spec."}}, }, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") require.Len(t, values, 2) // Both rows strip leading "ChannelEmail" because it matches one of the names. assert.EqualT(t, "email ChannelEmail and ChannelSMS share a single spec.", descs[0]) @@ -682,7 +728,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { List: []*ast.Comment{{Text: "// The x value."}}, }, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") require.Len(t, values, 1) assert.EqualT(t, "x X The x value.", descs[0]) }) @@ -693,7 +739,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { Type: ast.NewIdent("Foo"), Values: []ast.Expr{&ast.BasicLit{Kind: token.INT, Value: "7"}}, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") require.Len(t, values, 1) intVal, ok := values[0].(int64) require.True(t, ok)