diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1bd7e09..16f1498 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -36,26 +36,33 @@ to builders without direct coupling. | `scan_context.go` | `ScanCtx` / `NewScanCtx` — loads Go packages via `golang.org/x/tools/go/packages` | | `index.go` | `TypeIndex` — node classification (meta/route/operation/model/parameters/response) | | `declaration.go` | `EntityDecl` — wraps a type/value declaration with its enclosing file/package | +| `classify/` | Classification predicates usable from both scanner and builders (e.g. `IsAllowedExtension`) | -### `internal/parsers/` — comment-block parsing engine +### `internal/parsers/` — scanner classification + helpers + +Post grammar-migration (P6.3), `parsers/` is intentionally scanner-only. The +old regex-based comment-block parsing engine is gone; what remains are +classification helpers used by the scanner and builders, plus subpackages +for the grammar parser and its satellite helpers. + +**Root — scanner classification** | File | Contents | |------|----------| -| `sectioned_parser.go` | The section-driven parser that walks title/description/annotation blocks | -| `parsers.go`, `parsers_helpers.go` | Dispatch + helpers for tag/package filtering, value extraction | -| `tag_parsers.go`, `matchers.go` | Tag recognisers (`TypeName`, `Model`, etc.) | -| `regexprs.go` | Shared regular expressions for annotation parsing | -| `meta.go` | Swagger info-block parsing (title, version, license, contact) | -| `responses.go`, `route_params.go` | Response / route-parameter annotation parsing | -| `validations.go`, `extensions.go` | Validation directives, `x-*` extensions | -| `enum.go`, `security.go` | Enum extraction from Go constants, security-definition blocks | -| `yaml_parser.go`, `yaml_spec_parser.go` | Embedded-YAML parsing for `swagger:operation` bodies | -| `lines.go`, `parsed_path_content.go` | Comment-line and path-content helpers | -| `errors.go` | Sentinel errors | +| `matchers.go` | `ExtractAnnotation`, `ModelOverride`, `ResponseOverride`, `ParametersOverride` — the scanner-level annotation classifiers | +| `regexprs.go` | Regex definitions backing the matchers + `rxRoute` / `rxOperation` for the path-annotation parsers | +| `parsed_path_content.go` | `ParsedPathContent` + `ParseOperationPathAnnotation` / `ParseRoutePathAnnotation` | + +**Subpackages** + +| Package | Role | +|---------|------| +| `grammar/` | The grammar parser — `NewParser`, `Block`, `Property`, keyword tables | +| `yaml/` | YAML sub-parser used by grammar's typed-extensions surface and by operation / meta body unmarshal | ### `internal/builders/` — Swagger object construction -Each sub-package owns one concern and a `taggers.go` file wiring parsers to its targets. +Each sub-package owns one concern; `walker.go` carries the per-block grammar dispatch. | Package | Contents | |---------|----------| @@ -64,9 +71,11 @@ Each sub-package owns one concern and a `taggers.go` file wiring parsers to its | `operations` | Operation (route handler) annotation parsing | | `parameters` | Parameter annotation parsing | | `responses` | Response annotation parsing | -| `routes` | Route/path discovery and matching | -| `items` | Array-item targets (typable + validations, no own annotations) | -| `resolvers` | `SwaggerSchemaForType`, identity/assertion helpers shared by builders | +| `routes` | Route/path discovery + body parsers (`body_params.go`, `body_responses.go`) | +| `common` | `*common.Builder` embedded by every per-decl builder; `SchemesList` + `SecurityRequirements` shared by routes/spec | +| `handlers` | Walker callback factories shared across schema/parameters/responses (`Number`, `Integer`, `UniqueBool`, `PatternString`, …) | +| `resolvers` | `SwaggerSchemaForType`, identity/assertion helpers, items-chain ifaces adapters (`ItemsTypable` / `ItemsValidations`) shared by builders | +| `validations` | Type-aware coercion / shape-check primitives (`CoerceEnum`, `ParseDefault`, `IsLegalForType`) | ### `internal/ifaces/` — cross-package interfaces diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..edb53f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Golden test fixtures must keep LF line endings on every platform +# so byte-level comparison doesn't trip on Windows checkouts that +# default to core.autocrlf=true. +*.json text eol=lf +internal/parsers/grammar/grammar_test/testdata/golden/* text eol=lf +fixtures/integration/golden/* text eol=lf diff --git a/.golangci.yml b/.golangci.yml index d987d15..c66f32a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -5,7 +5,9 @@ linters: - depguard - funlen - godox + - gomodguard_v2 - exhaustruct + - ireturn # not suited to the design of this repo - nlreturn - nonamedreturns - noinlineerr diff --git a/docs/annotation-keywords.md b/docs/annotation-keywords.md new file mode 100644 index 0000000..3008748 --- /dev/null +++ b/docs/annotation-keywords.md @@ -0,0 +1,330 @@ + + +# Annotation keywords + +This document catalogs the `keyword: value` forms recognised by the +codescan annotation surface — the OAS v2 (Swagger 2.0) annotations +the scanner understands. + +## Summary + +| Keyword | Aliases | Value type | Legal in | +|---------|---------|------------|----------| +| `maximum` | `max` | `number` | param, header, schema, items | +| `minimum` | `min` | `number` | param, header, schema, items | +| `multipleOf` | `multiple of`, `multiple-of` | `number` | param, header, schema, items | +| `maxLength` | `max length`, `max-length`, `maxLen`, `max len`, `max-len`, `maximum length`, `maximum-length`, `maximumLength`, `maximum len`, `maximum-len` | `integer` | param, header, schema, items | +| `minLength` | `min length`, `min-length`, `minLen`, `min len`, `min-len`, `minimum length`, `minimum-length`, `minimumLength`, `minimum len`, `minimum-len` | `integer` | param, header, schema, items | +| `pattern` | — | `string` | param, header, schema, items | +| `maxItems` | `max items`, `max-items`, `max.items`, `maximum items`, `maximum-items`, `maximumItems` | `integer` | param, header, schema, items | +| `minItems` | `min items`, `min-items`, `min.items`, `minimum items`, `minimum-items`, `minimumItems` | `integer` | param, header, schema, items | +| `unique` | — | `boolean` | param, header, schema, items | +| `collectionFormat` | `collection format`, `collection-format` | `string-enum` | param, header, items | +| `enum` | — | `comma-list` | param, header, schema, items | +| `default` | — | `raw-value` | param, header, schema, items | +| `example` | — | `raw-value` | param, header, schema, items | +| `required` | — | `boolean` | param, schema | +| `readOnly` | `read only`, `read-only` | `boolean` | schema | +| `discriminator` | — | `boolean` | schema | +| `deprecated` | — | `boolean` | operation, route, schema | +| `in` | — | `string-enum` | param | +| `schemes` | — | `comma-list` | meta, route, operation | +| `version` | — | `string` | meta | +| `host` | — | `string` | meta | +| `basePath` | `base path`, `base-path` | `string` | meta | +| `license` | — | `string` | meta | +| `contact` | `contact info`, `contact-info` | `string` | meta | +| `consumes` | — | `raw-block` | meta, route, operation | +| `produces` | — | `raw-block` | meta, route, operation | +| `security` | — | `raw-block` | meta, route, operation | +| `securityDefinitions` | `security definitions`, `security-definitions` | `raw-block` | meta | +| `responses` | — | `raw-block` | route, operation | +| `parameters` | — | `raw-block` | route, operation | +| `extensions` | — | `raw-block` | meta, route, operation, schema | +| `infoExtensions` | `info extensions`, `info-extensions` | `raw-block` | meta | +| `tos` | `terms of service`, `terms-of-service`, `termsOfService` | `raw-block` | meta | +| `externalDocs` | `external docs`, `external-docs` | `raw-block` | meta, route, operation, schema | + +## Details + +### `maximum` + +- **Aliases:** `max` +- **Value type:** `number` +- **Legal contexts:** + - `param` — Maximum value of the parameter (inclusive by default). + - `header` — Maximum value of the header (inclusive by default). + - `schema` — Maximum value of the property (inclusive by default). + - `items` — Maximum value of each array item (inclusive by default). + +### `minimum` + +- **Aliases:** `min` +- **Value type:** `number` +- **Legal contexts:** + - `param` — Minimum value of the parameter (inclusive by default). + - `header` — Minimum value of the header (inclusive by default). + - `schema` — Minimum value of the property (inclusive by default). + - `items` — Minimum value of each array item (inclusive by default). + +### `multipleOf` + +- **Aliases:** `multiple of`, `multiple-of` +- **Value type:** `number` +- **Legal contexts:** + - `param` — Parameter value must be a multiple of this number. + - `header` — Header value must be a multiple of this number. + - `schema` — Property value must be a multiple of this number. + - `items` — Each array item must be a multiple of this number. + +### `maxLength` + +- **Aliases:** `max length`, `max-length`, `maxLen`, `max len`, `max-len`, `maximum length`, `maximum-length`, `maximumLength`, `maximum len`, `maximum-len` +- **Value type:** `integer` +- **Legal contexts:** + - `param` — Maximum length of the string parameter. + - `header` — Maximum length of the header. + - `schema` — Maximum length of the string property. + - `items` — Maximum length of each string item. + +### `minLength` + +- **Aliases:** `min length`, `min-length`, `minLen`, `min len`, `min-len`, `minimum length`, `minimum-length`, `minimumLength`, `minimum len`, `minimum-len` +- **Value type:** `integer` +- **Legal contexts:** + - `param` — Minimum length of the string parameter. + - `header` — Minimum length of the header. + - `schema` — Minimum length of the string property. + - `items` — Minimum length of each string item. + +### `pattern` + +- **Value type:** `string` +- **Legal contexts:** + - `param` — Regular expression the parameter must match. + - `header` — Regular expression the header must match. + - `schema` — Regular expression the property must match. + - `items` — Regular expression each array item must match. + +### `maxItems` + +- **Aliases:** `max items`, `max-items`, `max.items`, `maximum items`, `maximum-items`, `maximumItems` +- **Value type:** `integer` +- **Legal contexts:** + - `param` — Maximum number of items in the parameter array. + - `header` — Maximum number of items in the header array. + - `schema` — Maximum number of items in the array property. + - `items` — Maximum number of items at this nesting level. + +### `minItems` + +- **Aliases:** `min items`, `min-items`, `min.items`, `minimum items`, `minimum-items`, `minimumItems` +- **Value type:** `integer` +- **Legal contexts:** + - `param` — Minimum number of items in the parameter array. + - `header` — Minimum number of items in the header array. + - `schema` — Minimum number of items in the array property. + - `items` — Minimum number of items at this nesting level. + +### `unique` + +- **Value type:** `boolean` +- **Legal contexts:** + - `param` — Whether items in the parameter array must be unique. + - `header` — Whether items in the header array must be unique. + - `schema` — Whether items in the array property must be unique. + - `items` — Whether items at this level must be unique. + +### `collectionFormat` + +- **Aliases:** `collection format`, `collection-format` +- **Value type:** `string-enum` (one of: `csv`, `ssv`, `tsv`, `pipes`, `multi`) +- **Legal contexts:** + - `param` — Array serialization format (csv, ssv, tsv, pipes, multi). + - `header` — Array serialization in the header (csv, ssv, tsv, pipes). + - `items` — Nested-array serialization format. + +### `enum` + +- **Value type:** `comma-list` +- **Legal contexts:** + - `param` — Allowed values for the parameter (comma-separated). + - `header` — Allowed values for the header (comma-separated). + - `schema` — Allowed values for the property (comma-separated). + - `items` — Allowed values for each array item (comma-separated). + +### `default` + +- **Value type:** `raw-value` +- **Legal contexts:** + - `param` — Default value when the parameter is omitted. + - `header` — Default value when the header is absent. + - `schema` — Default value when the property is absent. + - `items` — Default value for each array item. + +### `example` + +- **Value type:** `raw-value` +- **Legal contexts:** + - `param` — Example value for documentation. + - `header` — Example value for documentation. + - `schema` — Example value for documentation. + - `items` — Example value for documentation. + +### `required` + +- **Value type:** `boolean` +- **Legal contexts:** + - `param` — Whether the parameter is required. + - `schema` — Whether the property is required. + +### `readOnly` + +- **Aliases:** `read only`, `read-only` +- **Value type:** `boolean` +- **Legal contexts:** + - `schema` — Whether the property is read-only (server-set; clients may not write it). + +### `discriminator` + +- **Value type:** `boolean` +- **Legal contexts:** + - `schema` — Marks this property as the polymorphic-schema discriminator. + +### `deprecated` + +- **Value type:** `boolean` +- **Legal contexts:** + - `operation` — Marks this operation as deprecated. + - `route` — Marks this route as deprecated. + - `schema` — Marks this property as deprecated. + +### `in` + +- **Value type:** `string-enum` (one of: `query`, `path`, `header`, `body`, `formData`) +- **Legal contexts:** + - `param` — Parameter location: query, path, header, body, or formData. + +### `schemes` + +- **Value type:** `comma-list` +- **Legal contexts:** + - `meta` — API schemes (http, https, ws, wss). + - `route` — Route-level schemes override. + - `operation` — Operation-level schemes override. + +### `version` + +- **Value type:** `string` +- **Legal contexts:** + - `meta` — API version string. + +### `host` + +- **Value type:** `string` +- **Legal contexts:** + - `meta` — Host (and optional port) serving the API. + +### `basePath` + +- **Aliases:** `base path`, `base-path` +- **Value type:** `string` +- **Legal contexts:** + - `meta` — URL prefix for all API paths. + +### `license` + +- **Value type:** `string` +- **Legal contexts:** + - `meta` — License information (name, optional URL). + +### `contact` + +- **Aliases:** `contact info`, `contact-info` +- **Value type:** `string` +- **Legal contexts:** + - `meta` — Contact information (name, email, URL). + +### `consumes` + +- **Value type:** `raw-block` +- **Legal contexts:** + - `meta` — Default MIME types the API consumes. + - `route` — MIME types this route consumes. + - `operation` — MIME types this operation consumes. + +### `produces` + +- **Value type:** `raw-block` +- **Legal contexts:** + - `meta` — Default MIME types the API produces. + - `route` — MIME types this route produces. + - `operation` — MIME types this operation produces. + +### `security` + +- **Value type:** `raw-block` +- **Legal contexts:** + - `meta` — Default security requirements for the API. + - `route` — Security requirements for this route. + - `operation` — Security requirements for this operation. + +### `securityDefinitions` + +- **Aliases:** `security definitions`, `security-definitions` +- **Value type:** `raw-block` +- **Legal contexts:** + - `meta` — Declared security schemes (apiKey, basic, oauth2). + +### `responses` + +- **Value type:** `raw-block` +- **Legal contexts:** + - `route` — Response mapping: status → response name. + - `operation` — Response mapping: status → response name. + +### `parameters` + +- **Value type:** `raw-block` +- **Legal contexts:** + - `route` — Parameter declarations for this route. + - `operation` — Parameter declarations for this operation. + +### `extensions` + +- **Value type:** `raw-block` +- **Legal contexts:** + - `meta` — Custom x-* vendor extensions at the spec level. + - `route` — Custom x-* vendor extensions on this route. + - `operation` — Custom x-* vendor extensions on this operation. + - `schema` — Custom x-* vendor extensions on this schema. + +### `infoExtensions` + +- **Aliases:** `info extensions`, `info-extensions` +- **Value type:** `raw-block` +- **Legal contexts:** + - `meta` — Custom x-* vendor extensions on the info block. + +### `tos` + +- **Aliases:** `terms of service`, `terms-of-service`, `termsOfService` +- **Value type:** `raw-block` +- **Legal contexts:** + - `meta` — Terms-of-service URL or text. + +### `externalDocs` + +- **Aliases:** `external docs`, `external-docs` +- **Value type:** `raw-block` +- **Legal contexts:** + - `meta` — External documentation reference. + - `route` — External documentation reference. + - `operation` — External documentation reference. + - `schema` — External documentation reference. + diff --git a/fixtures/enhancements/alias-response-shapes/api.go b/fixtures/enhancements/alias-response-shapes/api.go new file mode 100644 index 0000000..37a8a90 --- /dev/null +++ b/fixtures/enhancements/alias-response-shapes/api.go @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package alias_response_shapes captures Q4-exploration goldens +// for the response builder's alias handling on MEMBER fields: +// +// - body field whose Go type is an alias chain +// - header field whose Go type is an alias of a named primitive +// - header field whose Go type is an alias of a named struct +// +// Top-level response-as-alias is intentionally NOT in this package +// because the default (non-Transparent, non-Ref) buildAlias path +// for swagger:response on an alias crashes with "anonymous types +// are currently not supported for responses" (the existing +// alias-response/ fixture documents that gap by running only under +// RefAliases=true). The split lets default mode run on this +// package — the member-alias cases work — and the goldens +// captured side-by-side reveal what's broken vs working. +package alias_response_shapes + +// Envelope is the canonical named struct. +// +// swagger:model Envelope +type Envelope struct { + // required: true + ID int64 `json:"id"` + + Name string `json:"name"` +} + +// EnvelopeAlias is an alias of Envelope. +type EnvelopeAlias = Envelope + +// EnvelopeAlias2 is an alias of EnvelopeAlias (alias-of-alias). +type EnvelopeAlias2 = EnvelopeAlias + +// SessionID is a named string used in alias scenarios. +type SessionID string + +// SessionIDAlias is an alias of SessionID. +type SessionIDAlias = SessionID + +// BodyAliasResponse — body field uses an alias-of-alias chain. +// +// swagger:response bodyAliasResponse +type BodyAliasResponse struct { + // in: body + Body EnvelopeAlias2 `json:"body"` +} + +// HeaderAliasedBasicResponse — header field is an alias of a +// named string. Expected emission: primitive inline +// {string, ""}; no $ref under any mode (headers can't carry +// $ref). +// +// swagger:response headerAliasedBasicResponse +type HeaderAliasedBasicResponse struct { + // in: header + Session SessionIDAlias `json:"X-Session"` +} + +// HeaderAliasedStructResponse — header field is an alias of a +// named struct. Expected emission: NO body schema corruption +// (Q2 fix); header surfaces empty (struct can't reduce to a +// SimpleSchema primitive); CodeUnsupportedInSimpleSchema +// diagnostic fires for the underlying ref attempt. +// +// swagger:response headerAliasedStructResponse +type HeaderAliasedStructResponse struct { + // in: header + Detail EnvelopeAlias `json:"X-Detail"` +} diff --git a/fixtures/enhancements/diagnostics/types.go b/fixtures/enhancements/diagnostics/types.go new file mode 100644 index 0000000..c4bdc03 --- /dev/null +++ b/fixtures/enhancements/diagnostics/types.go @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package diagnostics carries fixtures that exercise the schema +// builder's diagnostic emission path. Each type is annotated with +// at least one deliberately invalid construct that the grammar +// parser rejects with a non-fatal diagnostic; the offending value +// is dropped from the output spec while the diagnostic flows +// through Builder.Diagnostics() / Options.OnDiagnostic. +package diagnostics + +// BadMaximum has an invalid maximum: value. The parser emits a +// CodeInvalidNumber diagnostic and the maximum keyword is dropped +// from the output schema. +// +// swagger:model BadMaximum +type BadMaximum struct { + // Count holds an arbitrary count. + // + // maximum: notanumber + Count int `json:"count"` +} + +// Helper types for the AmbiguousEmbed fixture: two unrelated structs +// that each carry a property whose JSON name is "shared", but whose +// Go field names differ. Embedding both into a single parent triggers +// the same-depth ambiguity case Go itself would refuse to promote. + +// SharedFoo carries a `shared` JSON property under Go name Foo. +// +// swagger:model SharedFoo +type SharedFoo struct { + Foo string `json:"shared"` +} + +// SharedBar carries a `shared` JSON property under Go name Bar. +// +// swagger:model SharedBar +type SharedBar struct { + Bar string `json:"shared"` +} + +// AmbiguousEmbed embeds two unrelated types that both promote the +// `shared` JSON property under different Go names. The schema +// builder emits a CodeAmbiguousEmbed diagnostic; the resulting +// spec is last-write-wins (Bar overrides Foo because struct fields +// iterate in source order — SharedBar is the later embed). +// +// swagger:model AmbiguousEmbed +type AmbiguousEmbed struct { + SharedFoo + SharedBar +} + +// Helper types for the DepthShadowingEmbed fixture: an inner struct +// shadowed by an outer struct's own explicit field of the same JSON +// name. Go's depth rule prefers the shallower field; codescan's +// last-write-wins produces the same outcome (the explicit field +// processes after the embed pass). The diagnostic must NOT fire. + +// DepthInner carries `shared` under Go name Foo at the deeper layer. +// +// swagger:model DepthInner +type DepthInner struct { + Foo string `json:"shared"` +} + +// DepthMiddle re-declares `shared` under Go name Bar at its own +// (shallower) depth, on top of an embed of DepthInner. +// +// swagger:model DepthMiddle +type DepthMiddle struct { + DepthInner + Bar string `json:"shared"` +} + +// DepthShadowingEmbed exercises legitimate Go-depth-rule shadowing +// across an embed chain: DepthMiddle.Bar (depth 1 from the parent +// after embedding) shadows DepthInner.Foo (depth 2). The diagnostic +// must remain silent. +// +// swagger:model DepthShadowingEmbed +type DepthShadowingEmbed struct { + DepthMiddle +} + +// ExplicitOverride exercises the explicit-override case: a top-level +// struct embeds a type carrying `shared`, then re-declares `shared` +// as its own field. The explicit field wins at embedDepth = 0; the +// diagnostic must remain silent. +// +// swagger:model ExplicitOverride +type ExplicitOverride struct { + SharedFoo + Bar string `json:"shared"` +} diff --git a/fixtures/enhancements/enum-overrides/types.go b/fixtures/enhancements/enum-overrides/types.go new file mode 100644 index 0000000..a02e70b --- /dev/null +++ b/fixtures/enhancements/enum-overrides/types.go @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package enum_overrides isolates the v1 behavior of `enum:` when it +// coexists with (or replaces) `swagger:enum TypeName` const-value +// inference. The golden output of TestCoverage_EnumOverrides is the +// factual reference for what the v2 parser migration must preserve +// — or consciously diverge from — under W2's override semantics +// (`.claude/plans/workshops/w2-enum.md` §2.6). +// +// Five cases, one per model in this file: +// +// A. swagger:enum + matching consts, no inline enum on field → consts +// B. inline comma-list on field, no swagger:enum, no consts → inline +// C. inline JSON array on field, no swagger:enum, no consts → inline +// D. swagger:enum but NO matching consts in package → ? +// E. swagger:enum + matching consts + inline enum on field → ? +package enum_overrides + +// --- Case A: swagger:enum + matching consts --- + +// PriorityA is a classic linked-const enum. +// +// swagger:enum PriorityA +type PriorityA string + +const ( + PriorityALow PriorityA = "low" + PriorityAMed PriorityA = "medium" + PriorityAHigh PriorityA = "high" +) + +// NotificationA exercises case A: field uses PriorityA, no inline +// enum override. +// +// swagger:model NotificationA +type NotificationA struct { + // required: true + ID int64 `json:"id"` + + // The priority level. Enum values come from PriorityA's consts. + Priority PriorityA `json:"priority"` +} + +// --- Case B: inline comma-list on field, no swagger:enum --- + +// NotificationB exercises case B: plain string field with inline +// comma-list enum. No swagger:enum on the type, no consts in code. +// +// swagger:model NotificationB +type NotificationB struct { + // The priority level. + // + // enum: low, medium, high + Priority string `json:"priority"` +} + +// --- Case C: inline JSON-array on field, no swagger:enum --- + +// NotificationC exercises case C: inline JSON-array enum. +// +// swagger:model NotificationC +type NotificationC struct { + // The priority level. + // + // enum: ["low","medium","high"] + Priority string `json:"priority"` +} + +// --- Case D: swagger:enum with no matching consts --- + +// PriorityD has a swagger:enum annotation but no corresponding +// const declarations in this package. The builder's FindEnumValues +// call returns an empty slice; the test captures how the spec +// renders in that case. +// +// swagger:enum PriorityD +type PriorityD string + +// NotificationD exercises case D. +// +// swagger:model NotificationD +type NotificationD struct { + // The priority level. + Priority PriorityD `json:"priority"` +} + +// --- Case E: swagger:enum + matching consts + inline override --- + +// PriorityE has both a linked-const set AND fields will provide an +// inline override. +// +// swagger:enum PriorityE +type PriorityE string + +const ( + PriorityELow PriorityE = "low" + PriorityEMed PriorityE = "medium" + PriorityEHigh PriorityE = "high" +) + +// NotificationE exercises case E: the inline enum on the field +// competes with the const-derived enum from PriorityE. The golden +// output captures which one wins in v1. +// +// swagger:model NotificationE +type NotificationE struct { + // Inline enum provides a narrower set than the const block. + // + // enum: urgent, normal + Priority PriorityE `json:"priority"` +} diff --git a/fixtures/enhancements/generic-instantiation/types.go b/fixtures/enhancements/generic-instantiation/types.go new file mode 100644 index 0000000..2fe4632 --- /dev/null +++ b/fixtures/enhancements/generic-instantiation/types.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package generic_instantiation exercises the generic-type +// instantiation short-circuit inside [Builder.buildNamedType]: +// when a named type has TypeArgs (i.e. is an instantiation of a +// generic declaration like `GenericSlice[int]`), we unwrap to its +// parameterised Underlying instead of trying to $ref to it. +// +// Without the short-circuit, an instantiated field type would route +// through resolveRefOr → makeRef → $ref to the generic declaration, +// which itself emits as an empty schema (its type parameters are +// filtered as UnsupportedBuiltinType). With the short-circuit, the +// field correctly reflects the substituted underlying. +package generic_instantiation + +// GenericSlice is the generic declaration. Its own emitted schema +// is essentially empty because the type parameter `T` is filtered +// out by UnsupportedBuiltinType. +type GenericSlice[T any] []T + +// GenericMap is a two-parameter generic declaration. +type GenericMap[K comparable, V any] map[K]V + +// Container holds fields whose types are generic INSTANTIATIONS. +// These should emit with the substituted underlying shape, not as +// $ref to the (empty) generic declaration. +// +// swagger:model generic_container +type Container struct { + // Items is an instantiation of GenericSlice with T=int. + // Expected schema: {array, items:{integer, int64}}. + Items GenericSlice[int] `json:"items"` + + // Names is an instantiation of GenericSlice with T=string. + // Expected schema: {array, items:{string}}. + Names GenericSlice[string] `json:"names"` + + // Counts is an instantiation of GenericMap with K=string, V=int. + // Expected schema: {object, additionalProperties:{integer, int64}}. + Counts GenericMap[string, int] `json:"counts"` +} diff --git a/fixtures/enhancements/header-extensions/api.go b/fixtures/enhancements/header-extensions/api.go new file mode 100644 index 0000000..a04c4e0 --- /dev/null +++ b/fixtures/enhancements/header-extensions/api.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package header_extensions exercises M2's new Walker.Extension wiring +// on the response-header path. Pre-M2, the responses bridge had no +// extension handling on headers at all — a user-authored +// `Extensions:` block on a header field was silently dropped. +// Post-M2, the bridge runs the same Walker.Extension callback the +// parameter bridge uses, so `x-*` entries land on the +// header.Extensions map. +package header_extensions + +// ExtendedResponse demonstrates a response header carrying a +// user-authored vendor extension via the `Extensions:` block. M2's +// bridge dispatches the typed extension value onto the header. +// +// swagger:response extendedResponse +type ExtendedResponse struct { + // RateLimit advertises the remaining quota. + // + // in: header + // Extensions: + // x-rate-window: 60s + // x-burst-allowed: true + RateLimit int `json:"X-Rate-Limit"` + + // Body is the canonical response payload. + // + // in: body + Body struct { + // Message carries the response message. + Message string `json:"message"` + } `json:"body"` +} + +// DoExtended handles the route. +// +// swagger:route GET /extended ext extendedResponse +// +// Responses: +// +// 200: extendedResponse +func DoExtended() {} diff --git a/fixtures/enhancements/header-named-basic/api.go b/fixtures/enhancements/header-named-basic/api.go new file mode 100644 index 0000000..5369f55 --- /dev/null +++ b/fixtures/enhancements/header-named-basic/api.go @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package header_named_basic pins the M1-follow-up fix that brought +// `in: header` parameters into the SimpleSchema-aware primitive- +// inline path. Pre-fix, classifierNamedBasic only short-circuited +// to inline-emission for query / path / formData (the v1 +// `isAliasParam` predicate omitted `header`), so a `type SessionID +// string` field appearing as `in: header` silently fell through to +// FindModel → makeRef and emitted a `$ref`-typed parameter — invalid +// under OAS v2 SimpleSchema. +// +// With the fix, all four non-body locations route through the same +// SimpleSchema-driven primitive-inline arm and the header parameter +// emits `{type: string, format: ""}` inline. +package header_named_basic + +// SessionID is a named string used as a header parameter. The +// schema builder used to emit a $ref to a top-level definition for +// it; under SimpleSchema mode for `in: header` it now inlines as +// `{string, ""}`. +type SessionID string + +// HeaderParams demonstrates a header parameter typed as a named +// string. +// +// swagger:parameters headerNamedBasicOp +type HeaderParams struct { + // Session is the offending parameter — pre-fix it emitted as a + // $ref to definitions/SessionID, post-fix it inlines as a + // primitive string. + // + // in: header + Session SessionID `json:"X-Session"` +} + +// DoHeader handles the route. +// +// swagger:route GET /header-named-basic hdr headerNamedBasicOp +// +// Responses: +// +// 200: description: OK +func DoHeader() {} diff --git a/fixtures/enhancements/parameters-map-postdecl/api.go b/fixtures/enhancements/parameters-map-postdecl/api.go new file mode 100644 index 0000000..1de928e --- /dev/null +++ b/fixtures/enhancements/parameters-map-postdecl/api.go @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package parameters_map_postdecl witnesses a bug in the parameters +// builder's buildFromFieldMap path. +// +// LocalItem below is deliberately NOT annotated with swagger:model. Its +// only reachable site is the map-valued body parameter on MapParams. +// The parameters builder walks into buildFromFieldMap, which spins up a +// fresh schema sub-builder for the map's value type. The sub-builder +// discovers LocalItem and registers it on its own PostDeclarations +// slice. The parameters builder is supposed to propagate that +// registration to its own AppendPostDecl chain — but +// buildFromFieldMap omits that loop (every other buildFromFieldXxx +// method has it). +// +// Without the propagation, spec.Builder.buildDiscovered never sees +// LocalItem; the resulting definitions section is missing the schema, +// and the spec is internally inconsistent (the map's value shape +// vanishes silently). +// +// The pre-fix golden captures this buggy state. The fix commit +// regenerates the golden to show LocalItem appearing in the +// definitions section. +package parameters_map_postdecl + +// LocalItem — NOT annotated; reachable only via the map field on +// MapParams below. +type LocalItem struct { + Name string `json:"name"` + Tag string `json:"tag"` +} + +// MapParams sends a map body parameter keyed by id with LocalItem values. +// +// swagger:parameters mapBody +type MapParams struct { + // Items is a body parameter of type map[string]LocalItem. + // + // in: body + // required: true + Items map[string]LocalItem `json:"items"` +} + +// swagger:operation POST /items mapBody +// +// Send a map body. +// +// --- +// responses: +// "200": +// description: OK +func _() {} diff --git a/fixtures/enhancements/raw-message-override/types.go b/fixtures/enhancements/raw-message-override/types.go new file mode 100644 index 0000000..7d0297f --- /dev/null +++ b/fixtures/enhancements/raw-message-override/types.go @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package raw_message_override exercises user-classifier overrides on +// json.RawMessage. By default a json.RawMessage value emits an empty +// schema (`{}`, "any type") via recognizeRawMessage. This fixture +// captures the three scopes where a user might want to constrain that +// to a typed schema: +// +// A. Plain field — emits `{}` (the recognizer baseline). +// B. Named wrapper type with `swagger:type` on the decl — +// honoured at both field-reference sites (classifierNamedArrayLike) +// AND at the wrapper's own top-level definition (buildFromDecl +// runs classifierNamedTypeOverride before kind-dispatch, falling +// back to Underlying() on unknown-leaf values like "array"). +// C. Field-level `swagger:type` on a json.RawMessage field — +// consumed by scanFieldDoc and applied in applyFieldCarrier +// after buildFromType. The recognizer's empty-schema default +// is replaced by the user-specified type. +package raw_message_override + +import "encoding/json" + +// PlainContainer (case A) — bare json.RawMessage field, no overrides. +// +// swagger:model PlainContainer +type PlainContainer struct { + // Should emit an empty schema (`{}`). + Payload json.RawMessage `json:"payload"` +} + +// AsObject (case B.1) — named wrapper of json.RawMessage with +// `swagger:type object`. The classifier overrides the recognizer +// because IsStdJSONRawMessage checks for encoding/json.RawMessage +// identity; the wrapper has its own package path and does NOT +// match. The classifier on the slice arm fires instead. +// +// swagger:model AsObject +// swagger:type object +type AsObject json.RawMessage + +// AsArray (case B.2) — named wrapper with `swagger:type array`. +// +// swagger:model AsArray +// swagger:type array +type AsArray json.RawMessage + +// TypedContainer (case B field reference) — references the wrappers. +// +// swagger:model TypedContainer +type TypedContainer struct { + // Wrapped via AsObject — expect `{$ref: AsObject}` whose definition + // resolves to `{type: object}` (the classifier wins on the decl). + Obj AsObject `json:"obj"` + + // Wrapped via AsArray — expect `{$ref: AsArray}` whose definition + // resolves to a typed array shape. + Arr AsArray `json:"arr"` +} + +// FieldLevelContainer (case C) — exercises field-level +// `swagger:type` on plain json.RawMessage fields. The field-level +// walker (scanFieldDoc) consumes swagger:type and applyFieldCarrier +// applies it after buildFromType, so the user override beats the +// RawMessage recognizer's empty-schema default. +// +// swagger:model FieldLevelContainer +type FieldLevelContainer struct { + // Overridden to {type: object} via field-level swagger:type. + // + // swagger:type object + Obj json.RawMessage `json:"obj"` + + // Overridden to {type: array, items: {integer, uint8}} via + // field-level swagger:type. "array" isn't a known SwaggerSchemaForType + // value, so the override falls back to building from Underlying() + // (= []byte) which yields the byte-typed array shape. + // + // swagger:type array + Arr json.RawMessage `json:"arr"` +} diff --git a/fixtures/enhancements/response-file-types/api.go b/fixtures/enhancements/response-file-types/api.go new file mode 100644 index 0000000..4bca07c --- /dev/null +++ b/fixtures/enhancements/response-file-types/api.go @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package response_file_types pins Q3's response-side fix: the +// `swagger:file` annotation on a `swagger:response` field is gated +// on `in: body`. Pre-Q3 the file branch fired unconditionally and +// rewrote `resp.Schema` to `{file, ""}` even on header-positioned +// fields — silently corrupting the body schema from a misplaced +// annotation. Per OAS v2 the allowed header types are +// {string, number, integer, boolean, array}; `file` is forbidden +// on headers. +// +// Three response shapes pinned by the golden: +// +// - FileBodyResponse — legitimate file body: `swagger:file` + +// `in: body`. resp.Schema is {file, ""}; no headers. +// - FileOnHeaderResponse — misuse: `swagger:file` + `in: header` +// (or implicit default). Diagnostic emitted; the file branch +// is skipped; the field falls through to the normal header +// build and surfaces as a regular header. +// - FileOnImplicitDefaultResponse — same misuse without the +// `in:` line at all (Q1's implicit-header default applies). +// Diagnostic still fires; field becomes a header. +package response_file_types + +// FileBodyResponse is the legitimate case: the response IS a file. +// `swagger:file` on the Body field with `in: body` rewrites +// resp.Schema to {file, ""} and skips the field build. +// +// swagger:response fileBodyResponse +type FileBodyResponse struct { + // File marks the response body as a file payload. Field shape + // mirrors the canonical v1 file response: a `[]byte` field with + // `in: body` + `swagger:file`. + // + // in: body + // swagger:file + File []byte +} + +// FileOnHeaderResponse exercises the Q3 misuse: `swagger:file` on +// a header-positioned field. Post-fix the diagnostic fires and +// the field falls through to the normal build, surfacing as a +// regular header (typed as string here). +// +// swagger:response fileOnHeaderResponse +type FileOnHeaderResponse struct { + // Misplaced has both `in: header` and `swagger:file`. The + // scanner diagnoses and treats it as a normal header. + // + // in: header + // swagger:file + Misplaced string `json:"X-Misplaced"` +} + +// FileOnImplicitDefaultResponse — `swagger:file` on a field with +// no `in:` line. After Q1 the implicit default is header, so this +// is also a misuse. Same diagnostic fires; field becomes a header. +// +// swagger:response fileOnImplicitDefaultResponse +type FileOnImplicitDefaultResponse struct { + // Implicit has only `swagger:file` — no `in:` line at all. + // Q1's implicit-header default applies; Q3's gate diagnoses. + // + // swagger:file + Implicit string `json:"X-Implicit"` +} diff --git a/fixtures/enhancements/response-header-ref-leak/api.go b/fixtures/enhancements/response-header-ref-leak/api.go new file mode 100644 index 0000000..47c1df8 --- /dev/null +++ b/fixtures/enhancements/response-header-ref-leak/api.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package response_header_ref_leak pins Q2's fix on the response +// side: a response-header field whose Go type triggers the schema +// builder's makeRef path no longer corrupts `response.Schema` with +// a $ref. OAS v2 forbids $ref on response headers entirely +// (headers are SimpleSchema, no model references allowed); the +// pre-Q2 `responseTypable.SetRef` blindly wrote `response.Schema.Ref` +// regardless of the typable's `in`. Q2 changes SetRef to no-op +// under non-body mode and flags the attempt via a `refAttempted` +// state so the SimpleSchema exit validator's HasRef probe catches +// it and emits CodeUnsupportedInSimpleSchema. +// +// Two response shapes captured by the integration test golden: +// +// - LeakResponse — header typed as a named struct, no strfmt. +// Post-fix the response carries only the header entry (still +// empty, since the named struct can't reduce to a SimpleSchema +// primitive); no body Schema; diagnostic fires. +// +// - LeakWithStrfmtResponse — same shape plus `swagger:strfmt +// uuid` on the field. Post-fix the header is {string, uuid} +// (the strfmt override fires after the exit-validator reset); +// no body Schema; the diagnostic still surfaces for the +// underlying ref attempt. +package response_header_ref_leak + +// Tag is a named struct intended to be referenceable as a model +// from body schemas. Using it as the type of a header field is the +// author misuse this fixture captures. +// +// swagger:model +type Tag struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// LeakResponse pins the post-fix shape: the header field is +// surfaced, the body schema is NOT corrupted, and the diagnostic +// signals the misuse. +// +// swagger:response leakResponse +type LeakResponse struct { + // TagHeader is a header field typed as a named struct. Pre-Q2 + // this leaked `$ref: "#/definitions/Tag"` onto resp.Schema and + // left the header empty. Post-fix resp.Schema stays nil; the + // header surfaces empty (no Type — the named struct can't + // reduce to a SimpleSchema primitive); diagnostic fires. + // + // in: header + TagHeader Tag `json:"X-Tag"` +} + +// LeakWithStrfmtResponse pins the post-fix shape under the strfmt +// override: the header gets {string, uuid}, the body schema stays +// nil, and the diagnostic still fires for the underlying ref +// attempt. +// +// swagger:response leakWithStrfmtResponse +type LeakWithStrfmtResponse struct { + // TagID is a header field with strfmt override on a named-struct + // type. The strfmt override runs after the exit-validator's + // reset, so the header surfaces as {string, uuid}. No body-schema + // leak. + // + // in: header + // swagger:strfmt uuid + TagID Tag `json:"X-Tag-ID"` +} diff --git a/fixtures/enhancements/response-implicit-header/api.go b/fixtures/enhancements/response-implicit-header/api.go new file mode 100644 index 0000000..22c82ec --- /dev/null +++ b/fixtures/enhancements/response-implicit-header/api.go @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package response_implicit_header pins Q1's response-side fix: the +// `in:` annotation on a `swagger:response` Go struct's field is a +// scanner-side discriminator (OAS v2's wire format has no `in` on +// response headers) and an omitted line now explicitly defaults to +// "header" rather than falling through an incidental +// `in != "body"` check. +// +// The package exercises four variants captured by the integration +// test golden: +// +// - empty struct → response with description only +// - all-header struct (no `in: body`) → every field a header, +// mixing implicit and explicit +// - mixed struct → body + headers (implicit + explicit) +// - invalid-`in:` struct → diagnostic emitted, field still becomes +// a header (not silently ignored) +package response_implicit_header + +// EmptyResponse — empty Go struct with a swagger:response +// annotation. Produces a response with description only; no Headers +// map, no Schema. +// +// swagger:response emptyResponse +type EmptyResponse struct{} + +// AllHeadersResponse — every field becomes a header. Etag carries +// no `in:` line at all (default); RateLimit carries an explicit +// `in: header` (parity with existing fixtures). +// +// swagger:response allHeadersResponse +type AllHeadersResponse struct { + // Etag carries an HTTP entity tag for cache validation. + // No `in:` line — defaults to header (Q1 fix). + Etag string `json:"ETag"` + + // RateLimit advertises the remaining quota. + // + // in: header + RateLimit int `json:"X-Rate-Limit"` +} + +// MixedResponse — body field plus headers (implicit + explicit). +// +// swagger:response mixedResponse +type MixedResponse struct { + // Tag is a tracing tag. Implicit header (no `in:`). + Tag string `json:"X-Tag"` + + // Limit advertises the remaining quota. + // + // in: header + Limit int `json:"X-Limit"` + + // Body is the response payload. + // + // in: body + Body struct { + Message string `json:"message"` + } `json:"body"` +} + +// InvalidInResponse — non-vocabulary `in:` value. The scanner emits +// a CodeInvalidAnnotation warning naming the bad value, then falls +// back to the default (header). +// +// swagger:response invalidInResponse +type InvalidInResponse struct { + // Cookie carries a session cookie. The `in: cookie` line is + // not in the OAS v2 vocabulary; the scanner warns and treats + // the field as a header anyway. + // + // in: cookie + Cookie string `json:"Cookie"` +} diff --git a/fixtures/enhancements/simple-schema-readonly/api.go b/fixtures/enhancements/simple-schema-readonly/api.go new file mode 100644 index 0000000..6827333 --- /dev/null +++ b/fixtures/enhancements/simple-schema-readonly/api.go @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package simple_schema_readonly exercises the schema builder's +// SimpleSchema-mode gate on the full-Schema-only `readOnly:` +// keyword. The fixture writes `readOnly: true` on a struct property +// reached during a non-body parameter resolution; the schema +// builder's Bool dispatcher emits CodeUnsupportedInSimpleSchema and +// skips the write. +// +// The Filter field must be an **anonymous** struct so the schema +// builder walks it inline rather than emitting a $ref (named +// structs short-circuit via resolveRefOr in buildNamedType and +// never reach walkSchemaLevel under SimpleSchema mode). +package simple_schema_readonly + +// ReadOnlyParams demonstrates a non-body parameter typed as an +// anonymous struct, triggering the SimpleSchema-mode descent +// through the schema builder's struct walker. +// +// swagger:parameters readOnlyOp +type ReadOnlyParams struct { + // Filter is the offending parameter — typed as an anonymous + // struct under `in: query` is itself outside SimpleSchema's + // allowed type set, but the schema builder walks it inline + // (rather than $ref'ing it) and so reaches the inner + // `readOnly:` annotation. The gate emits + // CodeUnsupportedInSimpleSchema and skips the write. + // + // in: query + Filter struct { + // Hidden is meant to be read-only — but `readOnly:` is a + // full-Schema-only keyword and cannot appear under + // SimpleSchema mode. + // + // readOnly: true + Hidden bool `json:"hidden"` + } `json:"filter"` +} + +// DoReadOnly handles the violating route. +// +// swagger:route GET /readonly ro readOnlyOp +// +// Responses: +// +// 200: description: OK +func DoReadOnly() {} diff --git a/fixtures/enhancements/simple-schema-violation/api.go b/fixtures/enhancements/simple-schema-violation/api.go new file mode 100644 index 0000000..0ff8d17 --- /dev/null +++ b/fixtures/enhancements/simple-schema-violation/api.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package simple_schema_violation exercises the M1 exit validator on +// the parameter SimpleSchema path. A non-body parameter typed as a +// named string with a `swagger:type object` decl-level override +// resolves to a schema with `Type == "object"` — invalid under OAS v2 +// SimpleSchema. The exit validator emits +// CodeUnsupportedInSimpleSchema and resets the target to empty `{}`. +package simple_schema_violation + +// ObjectOverride is a named string carrying a decl-level +// `swagger:type object` override. The override is honoured by the +// schema builder (classifierNamedBasic arm); under SimpleSchema mode +// the exit validator catches the resulting `Type == "object"` and +// resets the parameter back to empty `{}`. +// +// swagger:type object +type ObjectOverride string + +// ViolatingParams demonstrates a query parameter whose Go type +// resolves to an object-shaped schema — invalid under SimpleSchema. +// +// swagger:parameters violationOp +type ViolatingParams struct { + // Bad is the offending parameter — its type carries a + // decl-level override that the schema builder honours, producing + // an object-typed SimpleSchema. The M1 exit validator emits a + // CodeUnsupportedInSimpleSchema diagnostic and wipes the target. + // + // in: query + Bad ObjectOverride `json:"bad"` +} + +// DoViolation handles the violating route. +// +// swagger:route GET /violation viol violationOp +// +// Responses: +// +// 200: description: OK +func DoViolation() {} diff --git a/fixtures/enhancements/text-marshal/explicit_override/types.go b/fixtures/enhancements/text-marshal/explicit_override/types.go new file mode 100644 index 0000000..b9503b9 --- /dev/null +++ b/fixtures/enhancements/text-marshal/explicit_override/types.go @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package explicit_override exercises the precedence rule inside +// buildFromTextMarshal: an explicit `swagger:strfmt` annotation on a +// UUID-named TextMarshaler wins over the implicit UUID heuristic. +// +// Under the old order the heuristic ran first; this `UUID` would have +// been emitted as `{string, uuid}` even though the user explicitly +// annotated it as a `date`. The new order runs the classifier first. +package explicit_override + +// UUID is a TextMarshaler whose name matches the case-insensitive +// UUID heuristic but carries an explicit `swagger:strfmt date` +// override. Expected output: `{string, date}` — not `{string, uuid}`. +// +// swagger:strfmt date +type UUID [16]byte + +// MarshalText renders the UUID as text. +func (u UUID) MarshalText() ([]byte, error) { return nil, nil } + +// UnmarshalText parses the UUID from text. +func (u *UUID) UnmarshalText([]byte) error { return nil } + +// OverrideContainer references UUID so the schema builder walks +// the override path. +// +// swagger:model override_container +type OverrideContainer struct { + // required: true + ID UUID `json:"id"` +} diff --git a/fixtures/enhancements/text-marshal/uuid_wrapping_time/types.go b/fixtures/enhancements/text-marshal/uuid_wrapping_time/types.go new file mode 100644 index 0000000..839e9fc --- /dev/null +++ b/fixtures/enhancements/text-marshal/uuid_wrapping_time/types.go @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package uuid_wrapping_time documents the asymmetry between *being* +// `time.Time` and *wrapping* it. A user type named `UUID` that embeds +// time.Time inherits MarshalText via method promotion (it does +// implement TextMarshaler), but the stdlib trio recognizer +// (`IsStdTime`) only matches the literal stdlib `time.Time` TypeName. +// Wrapping does not count. +// +// The UUID heuristic still matches by name though, so this type emits +// as `{string, uuid}` — not as a `date-time` string and not as a +// struct expansion. +package uuid_wrapping_time + +import "time" + +// UUID embeds time.Time and inherits its MarshalText method. The +// stdlib trio doesn't fire (UUID's Obj is in this package, not +// "time"), the user provided no `swagger:strfmt`, so the UUID name +// heuristic wins: emits as `{string, uuid}`. +type UUID struct { + time.Time +} + +// WrappingTimeContainer references UUID so the schema builder walks +// the heuristic path. +// +// swagger:model wrapping_time_container +type WrappingTimeContainer struct { + // required: true + ID UUID `json:"id"` +} diff --git a/fixtures/enhancements/wrapper-decl-type-override/types.go b/fixtures/enhancements/wrapper-decl-type-override/types.go new file mode 100644 index 0000000..974b6b2 --- /dev/null +++ b/fixtures/enhancements/wrapper-decl-type-override/types.go @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package wrapper_decl_type_override isolates Gap B' — the +// wrapper-type's top-level definition emits an empty schema even +// though the same wrapper carries `swagger:type` decoration that +// the classifier honours at field reference sites. +// +// No referencing struct is declared here: the wrapper itself is the +// only top-level model, so the golden makes the gap unambiguous. +// +// Expected (target shape): the top-level `BareWrapperObject` +// definition should be `{type: object}`; `BareWrapperArray` should +// be `{type: array, items: {integer, uint8}}`. Today both come out +// empty (`x-go-package` + description only). +package wrapper_decl_type_override + +import "encoding/json" + +// BareWrapperObject — named wrapper of json.RawMessage with +// `swagger:type object`. No field references it; the only schema +// the scanner emits for this package is the top-level definition. +// +// swagger:model BareWrapperObject +// swagger:type object +type BareWrapperObject json.RawMessage + +// BareWrapperArray — named wrapper with `swagger:type array`. +// No field references. +// +// swagger:model BareWrapperArray +// swagger:type array +type BareWrapperArray json.RawMessage diff --git a/fixtures/goparsing/bookings/api.go b/fixtures/goparsing/bookings/api.go index 8d1522e..0dec00a 100644 --- a/fixtures/goparsing/bookings/api.go +++ b/fixtures/goparsing/bookings/api.go @@ -64,12 +64,12 @@ type BookingResponse struct { // Bookings lists all the appointments that have been made on the site. // // Consumes: -// application/json +// - application/json // // Schemes: http, https // // Produces: -// application/json +// - application/json // // Responses: // 200: BookingResponse diff --git a/fixtures/goparsing/classification/operations/todo_operation.go b/fixtures/goparsing/classification/operations/todo_operation.go index a12c6b5..0b4af29 100644 --- a/fixtures/goparsing/classification/operations/todo_operation.go +++ b/fixtures/goparsing/classification/operations/todo_operation.go @@ -30,12 +30,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // You can get the pets that are out of stock // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -81,12 +81,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // lists orders filtered by some parameters. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -119,12 +119,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // create an order based on the parameters. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -143,12 +143,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // gets the details for an order. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -170,12 +170,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // When the order doesn't exist this will return an error. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -194,12 +194,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // delete a particular order. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // diff --git a/fixtures/goparsing/classification/operations_body/todo_operation_body.go b/fixtures/goparsing/classification/operations_body/todo_operation_body.go index beb9139..a407201 100644 --- a/fixtures/goparsing/classification/operations_body/todo_operation_body.go +++ b/fixtures/goparsing/classification/operations_body/todo_operation_body.go @@ -30,12 +30,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // You can get the pets that are out of stock // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -93,12 +93,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // lists orders filtered by some parameters. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -133,12 +133,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // create an order based on the parameters. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -169,12 +169,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // gets the details for an order. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -195,12 +195,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // When the order doesn't exist this will return an error. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -219,12 +219,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // delete a particular order. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -243,12 +243,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // Allow some params with constraints. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // diff --git a/fixtures/goparsing/spec/api.go b/fixtures/goparsing/spec/api.go index 5234744..abfe8ff 100644 --- a/fixtures/goparsing/spec/api.go +++ b/fixtures/goparsing/spec/api.go @@ -70,14 +70,14 @@ type BookingResponse struct { // Bookings lists all the appointments that have been made on the site. // // Consumes: -// application/json +// - application/json // // Deprecated: true // // Schemes: http, https // // Produces: -// application/json +// - application/json // // Responses: // 200: BookingResponse diff --git a/fixtures/integration/golden/bugs_3125_schema.json b/fixtures/integration/golden/bugs_3125_schema.json index 291ecea..36bf2f3 100644 --- a/fixtures/integration/golden/bugs_3125_schema.json +++ b/fixtures/integration/golden/bugs_3125_schema.json @@ -8,13 +8,25 @@ "properties": { "value1": { "description": "Nullable value", - "x-nullable": true, - "$ref": "#/definitions/ValueStruct" + "allOf": [ + { + "$ref": "#/definitions/ValueStruct" + } + ], + "x-go-name": "Value1", + "x-nullable": true }, "value2": { "description": "Non-nullable value", - "$ref": "#/definitions/ValueStruct", - "example": "{\"value\": 42}" + "allOf": [ + { + "$ref": "#/definitions/ValueStruct" + }, + { + "example": "{\"value\": 42}" + } + ], + "x-go-name": "Value2" } }, "x-go-package": "github.com/go-openapi/codescan/fixtures/bugs/3125/minimal" diff --git a/fixtures/integration/golden/bugs_3125_schema_descwithref.json b/fixtures/integration/golden/bugs_3125_schema_descwithref.json new file mode 100644 index 0000000..36bf2f3 --- /dev/null +++ b/fixtures/integration/golden/bugs_3125_schema_descwithref.json @@ -0,0 +1,34 @@ +{ + "Item": { + "type": "object", + "required": [ + "value1", + "value2" + ], + "properties": { + "value1": { + "description": "Nullable value", + "allOf": [ + { + "$ref": "#/definitions/ValueStruct" + } + ], + "x-go-name": "Value1", + "x-nullable": true + }, + "value2": { + "description": "Non-nullable value", + "allOf": [ + { + "$ref": "#/definitions/ValueStruct" + }, + { + "example": "{\"value\": 42}" + } + ], + "x-go-name": "Value2" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/bugs/3125/minimal" + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/bugs_3125_schema_skipext.json b/fixtures/integration/golden/bugs_3125_schema_skipext.json new file mode 100644 index 0000000..a2d6595 --- /dev/null +++ b/fixtures/integration/golden/bugs_3125_schema_skipext.json @@ -0,0 +1,31 @@ +{ + "Item": { + "type": "object", + "required": [ + "value1", + "value2" + ], + "properties": { + "value1": { + "description": "Nullable value", + "allOf": [ + { + "$ref": "#/definitions/ValueStruct" + } + ], + "x-nullable": true + }, + "value2": { + "description": "Non-nullable value", + "allOf": [ + { + "$ref": "#/definitions/ValueStruct" + }, + { + "example": "{\"value\": 42}" + } + ] + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/bugs_3125_schema_skipext_descwithref.json b/fixtures/integration/golden/bugs_3125_schema_skipext_descwithref.json new file mode 100644 index 0000000..a2d6595 --- /dev/null +++ b/fixtures/integration/golden/bugs_3125_schema_skipext_descwithref.json @@ -0,0 +1,31 @@ +{ + "Item": { + "type": "object", + "required": [ + "value1", + "value2" + ], + "properties": { + "value1": { + "description": "Nullable value", + "allOf": [ + { + "$ref": "#/definitions/ValueStruct" + } + ], + "x-nullable": true + }, + "value2": { + "description": "Non-nullable value", + "allOf": [ + { + "$ref": "#/definitions/ValueStruct" + }, + { + "example": "{\"value\": 42}" + } + ] + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/classification_params_descwithref.json b/fixtures/integration/golden/classification_params_descwithref.json new file mode 100644 index 0000000..7e05ab3 --- /dev/null +++ b/fixtures/integration/golden/classification_params_descwithref.json @@ -0,0 +1,689 @@ +{ + "anotherOperation": { + "operationId": "anotherOperation", + "parameters": [ + { + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "type": "integer", + "format": "int64", + "default": 1, + "x-go-name": "ID", + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "name": "id", + "in": "path", + "required": true + }, + { + "maximum": 45, + "minimum": 3, + "multipleOf": 3, + "type": "integer", + "format": "int32", + "default": 2, + "example": 27, + "x-go-name": "Score", + "description": "The Score of this model", + "name": "score", + "in": "query", + "required": true + }, + { + "maxLength": 50, + "minLength": 4, + "pattern": "[A-Za-z0-9-.]*", + "type": "string", + "x-go-name": "Name", + "description": "Name of this no model instance", + "name": "x-hdr-name", + "in": "header", + "required": true + }, + { + "type": "string", + "format": "date-time", + "x-go-name": "Created", + "description": "Created holds the time when this entry was created", + "name": "created", + "in": "query" + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "x-go-name": "CategoryOld", + "description": "The Category of this model (old enum format)", + "name": "category_old", + "in": "query", + "required": true + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "x-go-name": "Category", + "description": "The Category of this model", + "name": "category", + "in": "query", + "required": true + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "x-go-name": "TypeOld", + "description": "Type of this model (old enum format)", + "name": "type_old", + "in": "query" + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "x-go-name": "Type", + "description": "Type of this model", + "name": "type", + "in": "query" + }, + { + "enum": [ + 1, + "rsq", + "qaz" + ], + "type": "integer", + "format": "int64", + "x-go-name": "BadEnum", + "description": "This is mix in enum. And actually on output should be valid form where int will be int and\nstring will also be presented.", + "name": "bad_enum", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string", + "default": "bar" + }, + "collectionFormat": "pipe", + "x-go-name": "FooSlice", + "description": "a FooSlice has foos which are strings", + "name": "foo_slice", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxItems": 9, + "minItems": 4, + "enum": [ + "bar1", + "bar2", + "bar3" + ], + "type": "array", + "items": { + "maxItems": 8, + "minItems": 5, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string" + } + }, + "default": "bar2" + }, + "collectionFormat": "pipe", + "x-go-name": "BarSlice", + "description": "a BarSlice has bars which are strings", + "name": "bar_slice", + "in": "query" + }, + { + "maxItems": 20, + "minItems": 1, + "type": "array", + "items": { + "maximum": 100, + "minimum": 5, + "uniqueItems": true, + "multipleOf": 5, + "type": "integer", + "format": "int32", + "collectionFormat": "csv" + }, + "x-go-name": "NumSlice", + "description": "a NumSlice has numeric items with item-level validation", + "name": "num_slice", + "in": "query" + }, + { + "x-go-name": "Items", + "description": "the items for this order", + "name": "items", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pet", + "quantity" + ], + "properties": { + "id": { + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "type": "integer", + "format": "int32", + "default": 3, + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "x-go-name": "ID" + }, + "notes": { + "description": "Notes to add to this item.\nThis can be used to add special instructions.", + "type": "string", + "x-go-name": "Notes" + }, + "pet": { + "description": "The Pet to add to this NoModel items bucket.\nPets can appear more than once in the bucket", + "allOf": [ + { + "$ref": "#/definitions/pet" + } + ], + "x-go-name": "Pet" + }, + "quantity": { + "description": "The amount of pets to add to this bucket.", + "type": "integer", + "format": "int16", + "maximum": 10, + "minimum": 1, + "x-go-name": "Quantity" + } + } + } + } + } + ] + }, + "createOrder": { + "operationId": "createOrder", + "parameters": [ + { + "x-go-name": "Order", + "description": "The order to submit.", + "name": "order", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/order" + } + } + ] + }, + "getOrders": { + "operationId": "getOrders", + "parameters": [ + { + "type": "array", + "items": { + "$ref": "#/definitions/OrderBodyParams" + }, + "x-go-name": "Orders", + "description": "The orders", + "name": "orders", + "in": "query", + "required": true + }, + { + "x-go-name": "Another", + "description": "And another thing", + "name": "another", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "that": { + "type": "string", + "x-go-name": "That" + } + } + } + } + } + ] + }, + "myFuncOperation": { + "operationId": "myFuncOperation", + "parameters": [ + { + "type": "file", + "x-go-name": "MyFormFile", + "description": "MyFormFile desc.", + "name": "myFormFile", + "in": "formData" + } + ] + }, + "myOperation": { + "operationId": "myOperation", + "parameters": [ + { + "type": "file", + "x-go-name": "MyFormFile", + "description": "MyFormFile desc.", + "name": "myFormFile", + "in": "formData" + } + ] + }, + "myOtherOperation": { + "operationId": "myOtherOperation", + "parameters": [ + { + "type": "file", + "x-go-name": "MyFormFile", + "description": "MyFormFile desc.", + "name": "myFormFile", + "in": "formData" + }, + { + "type": "integer", + "format": "int64", + "x-go-name": "ExtraParam", + "description": "ExtraParam desc.", + "name": "extraParam", + "in": "formData", + "required": true + } + ] + }, + "someAliasOperation": { + "operationId": "someAliasOperation", + "parameters": [ + { + "maximum": 10, + "minimum": 1, + "type": "integer", + "format": "int64", + "x-go-name": "IntAlias", + "description": "default \"in\" is \"query\" =\u003e this params should be aliased", + "name": "intAlias", + "in": "query", + "required": true + }, + { + "type": "string", + "x-go-name": "StringAlias", + "name": "stringAlias", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "x-go-name": "IntAliasPath", + "name": "intAliasPath", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "x-go-name": "IntAliasForm", + "name": "intAliasForm", + "in": "formData" + } + ] + }, + "someOperation": { + "operationId": "someOperation", + "parameters": [ + { + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "type": "integer", + "format": "int64", + "default": 1, + "x-go-name": "ID", + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "name": "id", + "in": "path", + "required": true + }, + { + "maximum": 45, + "minimum": 3, + "multipleOf": 3, + "type": "integer", + "format": "int32", + "default": 2, + "example": 27, + "x-go-name": "Score", + "description": "The Score of this model", + "name": "score", + "in": "query", + "required": true + }, + { + "maxLength": 50, + "minLength": 4, + "pattern": "[A-Za-z0-9-.]*", + "type": "string", + "x-go-name": "Name", + "description": "Name of this no model instance", + "name": "x-hdr-name", + "in": "header", + "required": true + }, + { + "type": "string", + "format": "date-time", + "x-go-name": "Created", + "description": "Created holds the time when this entry was created", + "name": "created", + "in": "query" + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "x-go-name": "CategoryOld", + "description": "The Category of this model (old enum format)", + "name": "category_old", + "in": "query", + "required": true + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "x-go-name": "Category", + "description": "The Category of this model", + "name": "category", + "in": "query", + "required": true + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "x-go-name": "TypeOld", + "description": "Type of this model (old enum format)", + "name": "type_old", + "in": "query" + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "x-go-name": "Type", + "description": "Type of this model", + "name": "type", + "in": "query" + }, + { + "enum": [ + 1, + "rsq", + "qaz" + ], + "type": "integer", + "format": "int64", + "x-go-name": "BadEnum", + "description": "This is mix in enum. And actually on output should be valid form where int will be int and\nstring will also be presented.", + "name": "bad_enum", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string", + "default": "bar" + }, + "collectionFormat": "pipe", + "x-go-name": "FooSlice", + "description": "a FooSlice has foos which are strings", + "name": "foo_slice", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxItems": 9, + "minItems": 4, + "enum": [ + "bar1", + "bar2", + "bar3" + ], + "type": "array", + "items": { + "maxItems": 8, + "minItems": 5, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string" + } + }, + "default": "bar2" + }, + "collectionFormat": "pipe", + "x-go-name": "BarSlice", + "description": "a BarSlice has bars which are strings", + "name": "bar_slice", + "in": "query" + }, + { + "maxItems": 20, + "minItems": 1, + "type": "array", + "items": { + "maximum": 100, + "minimum": 5, + "uniqueItems": true, + "multipleOf": 5, + "type": "integer", + "format": "int32", + "collectionFormat": "csv" + }, + "x-go-name": "NumSlice", + "description": "a NumSlice has numeric items with item-level validation", + "name": "num_slice", + "in": "query" + }, + { + "x-go-name": "Items", + "description": "the items for this order", + "name": "items", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pet", + "quantity" + ], + "properties": { + "id": { + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "type": "integer", + "format": "int32", + "default": 3, + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "x-go-name": "ID" + }, + "notes": { + "description": "Notes to add to this item.\nThis can be used to add special instructions.", + "type": "string", + "x-go-name": "Notes" + }, + "pet": { + "description": "The Pet to add to this NoModel items bucket.\nPets can appear more than once in the bucket", + "allOf": [ + { + "$ref": "#/definitions/pet" + } + ], + "x-go-name": "Pet" + }, + "quantity": { + "description": "The amount of pets to add to this bucket.", + "type": "integer", + "format": "int16", + "maximum": 10, + "minimum": 1, + "x-go-name": "Quantity" + } + } + } + } + } + ] + }, + "updateOrder": { + "operationId": "updateOrder", + "parameters": [ + { + "x-go-name": "Order", + "description": "The order to submit.", + "name": "order", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/order" + } + } + ] + }, + "yetAnotherOperation": { + "operationId": "yetAnotherOperation", + "parameters": [ + { + "type": "integer", + "format": "int32", + "x-go-name": "Age", + "name": "age", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "x-go-name": "ID", + "description": "ID the id of this not selected model", + "name": "id", + "in": "query" + }, + { + "type": "string", + "x-go-name": "Name", + "description": "Name the name of this not selected model", + "name": "name", + "in": "query" + }, + { + "type": "string", + "x-go-name": "Notes", + "name": "notes", + "in": "query" + }, + { + "type": "string", + "x-go-name": "Extra", + "name": "extra", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "x-go-name": "CreatedAt", + "name": "createdAt", + "in": "query" + }, + { + "type": "string", + "x-go-name": "Informity", + "name": "informity", + "in": "formData" + }, + { + "type": "string", + "name": "NoTagName", + "in": "query" + } + ] + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/classification_params_skipext.json b/fixtures/integration/golden/classification_params_skipext.json new file mode 100644 index 0000000..93ed8be --- /dev/null +++ b/fixtures/integration/golden/classification_params_skipext.json @@ -0,0 +1,625 @@ +{ + "anotherOperation": { + "operationId": "anotherOperation", + "parameters": [ + { + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "type": "integer", + "format": "int64", + "default": 1, + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "name": "id", + "in": "path", + "required": true + }, + { + "maximum": 45, + "minimum": 3, + "multipleOf": 3, + "type": "integer", + "format": "int32", + "default": 2, + "example": 27, + "description": "The Score of this model", + "name": "score", + "in": "query", + "required": true + }, + { + "maxLength": 50, + "minLength": 4, + "pattern": "[A-Za-z0-9-.]*", + "type": "string", + "description": "Name of this no model instance", + "name": "x-hdr-name", + "in": "header", + "required": true + }, + { + "type": "string", + "format": "date-time", + "description": "Created holds the time when this entry was created", + "name": "created", + "in": "query" + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "description": "The Category of this model (old enum format)", + "name": "category_old", + "in": "query", + "required": true + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "description": "The Category of this model", + "name": "category", + "in": "query", + "required": true + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "description": "Type of this model (old enum format)", + "name": "type_old", + "in": "query" + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "description": "Type of this model", + "name": "type", + "in": "query" + }, + { + "enum": [ + 1, + "rsq", + "qaz" + ], + "type": "integer", + "format": "int64", + "description": "This is mix in enum. And actually on output should be valid form where int will be int and\nstring will also be presented.", + "name": "bad_enum", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string", + "default": "bar" + }, + "collectionFormat": "pipe", + "description": "a FooSlice has foos which are strings", + "name": "foo_slice", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxItems": 9, + "minItems": 4, + "enum": [ + "bar1", + "bar2", + "bar3" + ], + "type": "array", + "items": { + "maxItems": 8, + "minItems": 5, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string" + } + }, + "default": "bar2" + }, + "collectionFormat": "pipe", + "description": "a BarSlice has bars which are strings", + "name": "bar_slice", + "in": "query" + }, + { + "maxItems": 20, + "minItems": 1, + "type": "array", + "items": { + "maximum": 100, + "minimum": 5, + "uniqueItems": true, + "multipleOf": 5, + "type": "integer", + "format": "int32", + "collectionFormat": "csv" + }, + "description": "a NumSlice has numeric items with item-level validation", + "name": "num_slice", + "in": "query" + }, + { + "description": "the items for this order", + "name": "items", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pet", + "quantity" + ], + "properties": { + "id": { + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "type": "integer", + "format": "int32", + "default": 3, + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true + }, + "notes": { + "description": "Notes to add to this item.\nThis can be used to add special instructions.", + "type": "string" + }, + "pet": { + "$ref": "#/definitions/pet" + }, + "quantity": { + "description": "The amount of pets to add to this bucket.", + "type": "integer", + "format": "int16", + "maximum": 10, + "minimum": 1 + } + } + } + } + } + ] + }, + "createOrder": { + "operationId": "createOrder", + "parameters": [ + { + "description": "The order to submit.", + "name": "order", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/order" + } + } + ] + }, + "getOrders": { + "operationId": "getOrders", + "parameters": [ + { + "type": "array", + "items": { + "$ref": "#/definitions/OrderBodyParams" + }, + "description": "The orders", + "name": "orders", + "in": "query", + "required": true + }, + { + "description": "And another thing", + "name": "another", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "that": { + "type": "string" + } + } + } + } + } + ] + }, + "myFuncOperation": { + "operationId": "myFuncOperation", + "parameters": [ + { + "type": "file", + "description": "MyFormFile desc.", + "name": "myFormFile", + "in": "formData" + } + ] + }, + "myOperation": { + "operationId": "myOperation", + "parameters": [ + { + "type": "file", + "description": "MyFormFile desc.", + "name": "myFormFile", + "in": "formData" + } + ] + }, + "myOtherOperation": { + "operationId": "myOtherOperation", + "parameters": [ + { + "type": "file", + "description": "MyFormFile desc.", + "name": "myFormFile", + "in": "formData" + }, + { + "type": "integer", + "format": "int64", + "description": "ExtraParam desc.", + "name": "extraParam", + "in": "formData", + "required": true + } + ] + }, + "someAliasOperation": { + "operationId": "someAliasOperation", + "parameters": [ + { + "maximum": 10, + "minimum": 1, + "type": "integer", + "format": "int64", + "description": "default \"in\" is \"query\" =\u003e this params should be aliased", + "name": "intAlias", + "in": "query", + "required": true + }, + { + "type": "string", + "name": "stringAlias", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "name": "intAliasPath", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "name": "intAliasForm", + "in": "formData" + } + ] + }, + "someOperation": { + "operationId": "someOperation", + "parameters": [ + { + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "type": "integer", + "format": "int64", + "default": 1, + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "name": "id", + "in": "path", + "required": true + }, + { + "maximum": 45, + "minimum": 3, + "multipleOf": 3, + "type": "integer", + "format": "int32", + "default": 2, + "example": 27, + "description": "The Score of this model", + "name": "score", + "in": "query", + "required": true + }, + { + "maxLength": 50, + "minLength": 4, + "pattern": "[A-Za-z0-9-.]*", + "type": "string", + "description": "Name of this no model instance", + "name": "x-hdr-name", + "in": "header", + "required": true + }, + { + "type": "string", + "format": "date-time", + "description": "Created holds the time when this entry was created", + "name": "created", + "in": "query" + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "description": "The Category of this model (old enum format)", + "name": "category_old", + "in": "query", + "required": true + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "description": "The Category of this model", + "name": "category", + "in": "query", + "required": true + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "description": "Type of this model (old enum format)", + "name": "type_old", + "in": "query" + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "description": "Type of this model", + "name": "type", + "in": "query" + }, + { + "enum": [ + 1, + "rsq", + "qaz" + ], + "type": "integer", + "format": "int64", + "description": "This is mix in enum. And actually on output should be valid form where int will be int and\nstring will also be presented.", + "name": "bad_enum", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string", + "default": "bar" + }, + "collectionFormat": "pipe", + "description": "a FooSlice has foos which are strings", + "name": "foo_slice", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxItems": 9, + "minItems": 4, + "enum": [ + "bar1", + "bar2", + "bar3" + ], + "type": "array", + "items": { + "maxItems": 8, + "minItems": 5, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string" + } + }, + "default": "bar2" + }, + "collectionFormat": "pipe", + "description": "a BarSlice has bars which are strings", + "name": "bar_slice", + "in": "query" + }, + { + "maxItems": 20, + "minItems": 1, + "type": "array", + "items": { + "maximum": 100, + "minimum": 5, + "uniqueItems": true, + "multipleOf": 5, + "type": "integer", + "format": "int32", + "collectionFormat": "csv" + }, + "description": "a NumSlice has numeric items with item-level validation", + "name": "num_slice", + "in": "query" + }, + { + "description": "the items for this order", + "name": "items", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pet", + "quantity" + ], + "properties": { + "id": { + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "type": "integer", + "format": "int32", + "default": 3, + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true + }, + "notes": { + "description": "Notes to add to this item.\nThis can be used to add special instructions.", + "type": "string" + }, + "pet": { + "$ref": "#/definitions/pet" + }, + "quantity": { + "description": "The amount of pets to add to this bucket.", + "type": "integer", + "format": "int16", + "maximum": 10, + "minimum": 1 + } + } + } + } + } + ] + }, + "updateOrder": { + "operationId": "updateOrder", + "parameters": [ + { + "description": "The order to submit.", + "name": "order", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/order" + } + } + ] + }, + "yetAnotherOperation": { + "operationId": "yetAnotherOperation", + "parameters": [ + { + "type": "integer", + "format": "int32", + "name": "age", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "ID the id of this not selected model", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "Name the name of this not selected model", + "name": "name", + "in": "query" + }, + { + "type": "string", + "name": "notes", + "in": "query" + }, + { + "type": "string", + "name": "extra", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "name": "createdAt", + "in": "query" + }, + { + "type": "string", + "name": "informity", + "in": "formData" + }, + { + "type": "string", + "name": "NoTagName", + "in": "query" + } + ] + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/classification_params_skipext_descwithref.json b/fixtures/integration/golden/classification_params_skipext_descwithref.json new file mode 100644 index 0000000..66734db --- /dev/null +++ b/fixtures/integration/golden/classification_params_skipext_descwithref.json @@ -0,0 +1,635 @@ +{ + "anotherOperation": { + "operationId": "anotherOperation", + "parameters": [ + { + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "type": "integer", + "format": "int64", + "default": 1, + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "name": "id", + "in": "path", + "required": true + }, + { + "maximum": 45, + "minimum": 3, + "multipleOf": 3, + "type": "integer", + "format": "int32", + "default": 2, + "example": 27, + "description": "The Score of this model", + "name": "score", + "in": "query", + "required": true + }, + { + "maxLength": 50, + "minLength": 4, + "pattern": "[A-Za-z0-9-.]*", + "type": "string", + "description": "Name of this no model instance", + "name": "x-hdr-name", + "in": "header", + "required": true + }, + { + "type": "string", + "format": "date-time", + "description": "Created holds the time when this entry was created", + "name": "created", + "in": "query" + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "description": "The Category of this model (old enum format)", + "name": "category_old", + "in": "query", + "required": true + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "description": "The Category of this model", + "name": "category", + "in": "query", + "required": true + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "description": "Type of this model (old enum format)", + "name": "type_old", + "in": "query" + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "description": "Type of this model", + "name": "type", + "in": "query" + }, + { + "enum": [ + 1, + "rsq", + "qaz" + ], + "type": "integer", + "format": "int64", + "description": "This is mix in enum. And actually on output should be valid form where int will be int and\nstring will also be presented.", + "name": "bad_enum", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string", + "default": "bar" + }, + "collectionFormat": "pipe", + "description": "a FooSlice has foos which are strings", + "name": "foo_slice", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxItems": 9, + "minItems": 4, + "enum": [ + "bar1", + "bar2", + "bar3" + ], + "type": "array", + "items": { + "maxItems": 8, + "minItems": 5, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string" + } + }, + "default": "bar2" + }, + "collectionFormat": "pipe", + "description": "a BarSlice has bars which are strings", + "name": "bar_slice", + "in": "query" + }, + { + "maxItems": 20, + "minItems": 1, + "type": "array", + "items": { + "maximum": 100, + "minimum": 5, + "uniqueItems": true, + "multipleOf": 5, + "type": "integer", + "format": "int32", + "collectionFormat": "csv" + }, + "description": "a NumSlice has numeric items with item-level validation", + "name": "num_slice", + "in": "query" + }, + { + "description": "the items for this order", + "name": "items", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pet", + "quantity" + ], + "properties": { + "id": { + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "type": "integer", + "format": "int32", + "default": 3, + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true + }, + "notes": { + "description": "Notes to add to this item.\nThis can be used to add special instructions.", + "type": "string" + }, + "pet": { + "description": "The Pet to add to this NoModel items bucket.\nPets can appear more than once in the bucket", + "allOf": [ + { + "$ref": "#/definitions/pet" + } + ] + }, + "quantity": { + "description": "The amount of pets to add to this bucket.", + "type": "integer", + "format": "int16", + "maximum": 10, + "minimum": 1 + } + } + } + } + } + ] + }, + "createOrder": { + "operationId": "createOrder", + "parameters": [ + { + "description": "The order to submit.", + "name": "order", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/order" + } + } + ] + }, + "getOrders": { + "operationId": "getOrders", + "parameters": [ + { + "type": "array", + "items": { + "$ref": "#/definitions/OrderBodyParams" + }, + "description": "The orders", + "name": "orders", + "in": "query", + "required": true + }, + { + "description": "And another thing", + "name": "another", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "that": { + "type": "string" + } + } + } + } + } + ] + }, + "myFuncOperation": { + "operationId": "myFuncOperation", + "parameters": [ + { + "type": "file", + "description": "MyFormFile desc.", + "name": "myFormFile", + "in": "formData" + } + ] + }, + "myOperation": { + "operationId": "myOperation", + "parameters": [ + { + "type": "file", + "description": "MyFormFile desc.", + "name": "myFormFile", + "in": "formData" + } + ] + }, + "myOtherOperation": { + "operationId": "myOtherOperation", + "parameters": [ + { + "type": "file", + "description": "MyFormFile desc.", + "name": "myFormFile", + "in": "formData" + }, + { + "type": "integer", + "format": "int64", + "description": "ExtraParam desc.", + "name": "extraParam", + "in": "formData", + "required": true + } + ] + }, + "someAliasOperation": { + "operationId": "someAliasOperation", + "parameters": [ + { + "maximum": 10, + "minimum": 1, + "type": "integer", + "format": "int64", + "description": "default \"in\" is \"query\" =\u003e this params should be aliased", + "name": "intAlias", + "in": "query", + "required": true + }, + { + "type": "string", + "name": "stringAlias", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "name": "intAliasPath", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "name": "intAliasForm", + "in": "formData" + } + ] + }, + "someOperation": { + "operationId": "someOperation", + "parameters": [ + { + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "type": "integer", + "format": "int64", + "default": 1, + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "name": "id", + "in": "path", + "required": true + }, + { + "maximum": 45, + "minimum": 3, + "multipleOf": 3, + "type": "integer", + "format": "int32", + "default": 2, + "example": 27, + "description": "The Score of this model", + "name": "score", + "in": "query", + "required": true + }, + { + "maxLength": 50, + "minLength": 4, + "pattern": "[A-Za-z0-9-.]*", + "type": "string", + "description": "Name of this no model instance", + "name": "x-hdr-name", + "in": "header", + "required": true + }, + { + "type": "string", + "format": "date-time", + "description": "Created holds the time when this entry was created", + "name": "created", + "in": "query" + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "description": "The Category of this model (old enum format)", + "name": "category_old", + "in": "query", + "required": true + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "description": "The Category of this model", + "name": "category", + "in": "query", + "required": true + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "description": "Type of this model (old enum format)", + "name": "type_old", + "in": "query" + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "description": "Type of this model", + "name": "type", + "in": "query" + }, + { + "enum": [ + 1, + "rsq", + "qaz" + ], + "type": "integer", + "format": "int64", + "description": "This is mix in enum. And actually on output should be valid form where int will be int and\nstring will also be presented.", + "name": "bad_enum", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string", + "default": "bar" + }, + "collectionFormat": "pipe", + "description": "a FooSlice has foos which are strings", + "name": "foo_slice", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxItems": 9, + "minItems": 4, + "enum": [ + "bar1", + "bar2", + "bar3" + ], + "type": "array", + "items": { + "maxItems": 8, + "minItems": 5, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string" + } + }, + "default": "bar2" + }, + "collectionFormat": "pipe", + "description": "a BarSlice has bars which are strings", + "name": "bar_slice", + "in": "query" + }, + { + "maxItems": 20, + "minItems": 1, + "type": "array", + "items": { + "maximum": 100, + "minimum": 5, + "uniqueItems": true, + "multipleOf": 5, + "type": "integer", + "format": "int32", + "collectionFormat": "csv" + }, + "description": "a NumSlice has numeric items with item-level validation", + "name": "num_slice", + "in": "query" + }, + { + "description": "the items for this order", + "name": "items", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pet", + "quantity" + ], + "properties": { + "id": { + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "type": "integer", + "format": "int32", + "default": 3, + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true + }, + "notes": { + "description": "Notes to add to this item.\nThis can be used to add special instructions.", + "type": "string" + }, + "pet": { + "description": "The Pet to add to this NoModel items bucket.\nPets can appear more than once in the bucket", + "allOf": [ + { + "$ref": "#/definitions/pet" + } + ] + }, + "quantity": { + "description": "The amount of pets to add to this bucket.", + "type": "integer", + "format": "int16", + "maximum": 10, + "minimum": 1 + } + } + } + } + } + ] + }, + "updateOrder": { + "operationId": "updateOrder", + "parameters": [ + { + "description": "The order to submit.", + "name": "order", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/order" + } + } + ] + }, + "yetAnotherOperation": { + "operationId": "yetAnotherOperation", + "parameters": [ + { + "type": "integer", + "format": "int32", + "name": "age", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "ID the id of this not selected model", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "Name the name of this not selected model", + "name": "name", + "in": "query" + }, + { + "type": "string", + "name": "notes", + "in": "query" + }, + { + "type": "string", + "name": "extra", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "name": "createdAt", + "in": "query" + }, + { + "type": "string", + "name": "informity", + "in": "formData" + }, + { + "type": "string", + "name": "NoTagName", + "in": "query" + } + ] + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/classification_responses_descwithref.json b/fixtures/integration/golden/classification_responses_descwithref.json new file mode 100644 index 0000000..e25fb89 --- /dev/null +++ b/fixtures/integration/golden/classification_responses_descwithref.json @@ -0,0 +1,256 @@ +{ + "complexerOne": { + "description": "A ComplexerOne is composed of a SimpleOne and some extra fields.", + "headers": { + "NoTagName": { + "type": "string" + }, + "age": { + "type": "integer", + "format": "int32" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "extra": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64", + "description": "ID the id of this not selected model" + }, + "name": { + "type": "string", + "description": "Name the name of this not selected model" + }, + "notes": { + "type": "string" + } + } + }, + "complexerPointerOne": { + "description": "A ComplexerPointerOne is composed of a *SimpleOne and some extra fields.", + "headers": { + "age": { + "type": "integer", + "format": "int32" + }, + "extra": { + "type": "integer", + "format": "int64" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "fileResponse": { + "description": "File response", + "schema": { + "type": "file" + } + }, + "genericError": { + "description": "A GenericError is an error that is used when no other error is appropriate", + "schema": { + "type": "object", + "properties": { + "Message": { + "type": "string" + } + } + } + }, + "resp": { + "description": "Resp a response for testing", + "schema": { + "$ref": "#/definitions/user" + }, + "headers": { + "UUID": { + "type": "string", + "format": "uuid" + } + } + }, + "simpleOnes": { + "description": "SimpleOnes is a collection of SimpleOne", + "headers": { + "ones": { + "type": "array", + "items": { + "$ref": "#/definitions/SimpleOne" + } + } + } + }, + "simpleOnesFunc": { + "description": "SimpleOnesFunc is a collection of SimpleOne", + "headers": { + "ones": { + "type": "array", + "items": { + "$ref": "#/definitions/SimpleOne" + } + } + } + }, + "someResponse": { + "description": "A SomeResponse is a dummy response object to test parsing.\n\nThe properties are the same as the other structs used to test parsing.", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pet", + "quantity" + ], + "properties": { + "id": { + "description": "ID of this some response instance.\nids in this application start at 11 and are smaller than 1000", + "type": "integer", + "format": "int32", + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "x-go-name": "ID" + }, + "notes": { + "description": "Notes to add to this item.\nThis can be used to add special instructions.", + "type": "string", + "x-go-name": "Notes" + }, + "pet": { + "description": "The Pet to add to this NoModel items bucket.\nPets can appear more than once in the bucket", + "allOf": [ + { + "$ref": "#/definitions/pet" + } + ], + "x-go-name": "Pet" + }, + "quantity": { + "description": "The amount of pets to add to this bucket.", + "type": "integer", + "format": "int16", + "maximum": 10, + "minimum": 1, + "x-go-name": "Quantity" + } + } + } + }, + "headers": { + "active": { + "type": "boolean", + "default": true, + "description": "Active state of the record" + }, + "bar_slice": { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxItems": 9, + "minItems": 4, + "type": "array", + "items": { + "maxItems": 8, + "minItems": 5, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string" + } + } + }, + "collectionFormat": "pipe", + "description": "a BarSlice has bars which are strings" + }, + "created": { + "type": "string", + "format": "date-time", + "description": "Created holds the time when this entry was created" + }, + "foo_slice": { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string", + "example": "foo" + }, + "collectionFormat": "pipe", + "description": "a FooSlice has foos which are strings" + }, + "id": { + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "type": "integer", + "format": "int64", + "default": 11, + "description": "ID of this some response instance.\nids in this application start at 11 and are smaller than 1000" + }, + "score": { + "maximum": 45, + "minimum": 3, + "multipleOf": 3, + "type": "integer", + "format": "int32", + "example": 27, + "description": "The Score of this model" + }, + "x-hdr-name": { + "maxLength": 50, + "minLength": 4, + "pattern": "[A-Za-z0-9-.]*", + "type": "string", + "description": "Name of this some response instance" + } + } + }, + "validationError": { + "description": "A ValidationError is an error that is used when the required input fails validation.", + "schema": { + "type": "object", + "properties": { + "FieldName": { + "description": "An optional field name to which this validation applies", + "type": "string" + }, + "Message": { + "description": "The validation message", + "type": "string" + } + } + }, + "headers": { + "code": { + "enum": [ + "foo", + "bar" + ], + "type": "integer", + "format": "int64", + "default": 400 + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/classification_responses_skipext.json b/fixtures/integration/golden/classification_responses_skipext.json new file mode 100644 index 0000000..bb1f2df --- /dev/null +++ b/fixtures/integration/golden/classification_responses_skipext.json @@ -0,0 +1,247 @@ +{ + "complexerOne": { + "description": "A ComplexerOne is composed of a SimpleOne and some extra fields.", + "headers": { + "NoTagName": { + "type": "string" + }, + "age": { + "type": "integer", + "format": "int32" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "extra": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64", + "description": "ID the id of this not selected model" + }, + "name": { + "type": "string", + "description": "Name the name of this not selected model" + }, + "notes": { + "type": "string" + } + } + }, + "complexerPointerOne": { + "description": "A ComplexerPointerOne is composed of a *SimpleOne and some extra fields.", + "headers": { + "age": { + "type": "integer", + "format": "int32" + }, + "extra": { + "type": "integer", + "format": "int64" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "fileResponse": { + "description": "File response", + "schema": { + "type": "file" + } + }, + "genericError": { + "description": "A GenericError is an error that is used when no other error is appropriate", + "schema": { + "type": "object", + "properties": { + "Message": { + "type": "string" + } + } + } + }, + "resp": { + "description": "Resp a response for testing", + "schema": { + "$ref": "#/definitions/user" + }, + "headers": { + "UUID": { + "type": "string", + "format": "uuid" + } + } + }, + "simpleOnes": { + "description": "SimpleOnes is a collection of SimpleOne", + "headers": { + "ones": { + "type": "array", + "items": { + "$ref": "#/definitions/SimpleOne" + } + } + } + }, + "simpleOnesFunc": { + "description": "SimpleOnesFunc is a collection of SimpleOne", + "headers": { + "ones": { + "type": "array", + "items": { + "$ref": "#/definitions/SimpleOne" + } + } + } + }, + "someResponse": { + "description": "A SomeResponse is a dummy response object to test parsing.\n\nThe properties are the same as the other structs used to test parsing.", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pet", + "quantity" + ], + "properties": { + "id": { + "description": "ID of this some response instance.\nids in this application start at 11 and are smaller than 1000", + "type": "integer", + "format": "int32", + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true + }, + "notes": { + "description": "Notes to add to this item.\nThis can be used to add special instructions.", + "type": "string" + }, + "pet": { + "$ref": "#/definitions/pet" + }, + "quantity": { + "description": "The amount of pets to add to this bucket.", + "type": "integer", + "format": "int16", + "maximum": 10, + "minimum": 1 + } + } + } + }, + "headers": { + "active": { + "type": "boolean", + "default": true, + "description": "Active state of the record" + }, + "bar_slice": { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxItems": 9, + "minItems": 4, + "type": "array", + "items": { + "maxItems": 8, + "minItems": 5, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string" + } + } + }, + "collectionFormat": "pipe", + "description": "a BarSlice has bars which are strings" + }, + "created": { + "type": "string", + "format": "date-time", + "description": "Created holds the time when this entry was created" + }, + "foo_slice": { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string", + "example": "foo" + }, + "collectionFormat": "pipe", + "description": "a FooSlice has foos which are strings" + }, + "id": { + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "type": "integer", + "format": "int64", + "default": 11, + "description": "ID of this some response instance.\nids in this application start at 11 and are smaller than 1000" + }, + "score": { + "maximum": 45, + "minimum": 3, + "multipleOf": 3, + "type": "integer", + "format": "int32", + "example": 27, + "description": "The Score of this model" + }, + "x-hdr-name": { + "maxLength": 50, + "minLength": 4, + "pattern": "[A-Za-z0-9-.]*", + "type": "string", + "description": "Name of this some response instance" + } + } + }, + "validationError": { + "description": "A ValidationError is an error that is used when the required input fails validation.", + "schema": { + "type": "object", + "properties": { + "FieldName": { + "description": "An optional field name to which this validation applies", + "type": "string" + }, + "Message": { + "description": "The validation message", + "type": "string" + } + } + }, + "headers": { + "code": { + "enum": [ + "foo", + "bar" + ], + "type": "integer", + "format": "int64", + "default": 400 + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/classification_responses_skipext_descwithref.json b/fixtures/integration/golden/classification_responses_skipext_descwithref.json new file mode 100644 index 0000000..0f30415 --- /dev/null +++ b/fixtures/integration/golden/classification_responses_skipext_descwithref.json @@ -0,0 +1,252 @@ +{ + "complexerOne": { + "description": "A ComplexerOne is composed of a SimpleOne and some extra fields.", + "headers": { + "NoTagName": { + "type": "string" + }, + "age": { + "type": "integer", + "format": "int32" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "extra": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64", + "description": "ID the id of this not selected model" + }, + "name": { + "type": "string", + "description": "Name the name of this not selected model" + }, + "notes": { + "type": "string" + } + } + }, + "complexerPointerOne": { + "description": "A ComplexerPointerOne is composed of a *SimpleOne and some extra fields.", + "headers": { + "age": { + "type": "integer", + "format": "int32" + }, + "extra": { + "type": "integer", + "format": "int64" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "fileResponse": { + "description": "File response", + "schema": { + "type": "file" + } + }, + "genericError": { + "description": "A GenericError is an error that is used when no other error is appropriate", + "schema": { + "type": "object", + "properties": { + "Message": { + "type": "string" + } + } + } + }, + "resp": { + "description": "Resp a response for testing", + "schema": { + "$ref": "#/definitions/user" + }, + "headers": { + "UUID": { + "type": "string", + "format": "uuid" + } + } + }, + "simpleOnes": { + "description": "SimpleOnes is a collection of SimpleOne", + "headers": { + "ones": { + "type": "array", + "items": { + "$ref": "#/definitions/SimpleOne" + } + } + } + }, + "simpleOnesFunc": { + "description": "SimpleOnesFunc is a collection of SimpleOne", + "headers": { + "ones": { + "type": "array", + "items": { + "$ref": "#/definitions/SimpleOne" + } + } + } + }, + "someResponse": { + "description": "A SomeResponse is a dummy response object to test parsing.\n\nThe properties are the same as the other structs used to test parsing.", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pet", + "quantity" + ], + "properties": { + "id": { + "description": "ID of this some response instance.\nids in this application start at 11 and are smaller than 1000", + "type": "integer", + "format": "int32", + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true + }, + "notes": { + "description": "Notes to add to this item.\nThis can be used to add special instructions.", + "type": "string" + }, + "pet": { + "description": "The Pet to add to this NoModel items bucket.\nPets can appear more than once in the bucket", + "allOf": [ + { + "$ref": "#/definitions/pet" + } + ] + }, + "quantity": { + "description": "The amount of pets to add to this bucket.", + "type": "integer", + "format": "int16", + "maximum": 10, + "minimum": 1 + } + } + } + }, + "headers": { + "active": { + "type": "boolean", + "default": true, + "description": "Active state of the record" + }, + "bar_slice": { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxItems": 9, + "minItems": 4, + "type": "array", + "items": { + "maxItems": 8, + "minItems": 5, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string" + } + } + }, + "collectionFormat": "pipe", + "description": "a BarSlice has bars which are strings" + }, + "created": { + "type": "string", + "format": "date-time", + "description": "Created holds the time when this entry was created" + }, + "foo_slice": { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string", + "example": "foo" + }, + "collectionFormat": "pipe", + "description": "a FooSlice has foos which are strings" + }, + "id": { + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "type": "integer", + "format": "int64", + "default": 11, + "description": "ID of this some response instance.\nids in this application start at 11 and are smaller than 1000" + }, + "score": { + "maximum": 45, + "minimum": 3, + "multipleOf": 3, + "type": "integer", + "format": "int32", + "example": 27, + "description": "The Score of this model" + }, + "x-hdr-name": { + "maxLength": 50, + "minLength": 4, + "pattern": "[A-Za-z0-9-.]*", + "type": "string", + "description": "Name of this some response instance" + } + } + }, + "validationError": { + "description": "A ValidationError is an error that is used when the required input fails validation.", + "schema": { + "type": "object", + "properties": { + "FieldName": { + "description": "An optional field name to which this validation applies", + "type": "string" + }, + "Message": { + "description": "The validation message", + "type": "string" + } + } + }, + "headers": { + "code": { + "enum": [ + "foo", + "bar" + ], + "type": "integer", + "format": "int64", + "default": 400 + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/classification_routes.json b/fixtures/integration/golden/classification_routes.json index 7746e7b..1517db8 100644 --- a/fixtures/integration/golden/classification_routes.json +++ b/fixtures/integration/golden/classification_routes.json @@ -42,7 +42,7 @@ ] } ], - "x-some-flag": "false", + "x-some-flag": false, "x-some-list": [ "item1", "item2", @@ -282,7 +282,7 @@ ] } ], - "x-some-flag": "true" + "x-some-flag": true }, "post": { "consumes": [ diff --git a/fixtures/integration/golden/classification_routes_body.json b/fixtures/integration/golden/classification_routes_body.json index b14aa22..c02cb9e 100644 --- a/fixtures/integration/golden/classification_routes_body.json +++ b/fixtures/integration/golden/classification_routes_body.json @@ -51,7 +51,7 @@ ] } ], - "x-some-flag": "false", + "x-some-flag": false, "x-some-list": [ "item1", "item2", @@ -458,7 +458,7 @@ ] } ], - "x-some-flag": "true" + "x-some-flag": true }, "post": { "consumes": [ diff --git a/fixtures/integration/golden/classification_schema_NoModel.json b/fixtures/integration/golden/classification_schema_NoModel.json index eee3651..b17b0e4 100644 --- a/fixtures/integration/golden/classification_schema_NoModel.json +++ b/fixtures/integration/golden/classification_schema_NoModel.json @@ -251,7 +251,6 @@ "description": "the name for this user", "type": "integer", "format": "int64", - "minLength": 3, "x-go-name": "UserID" } }, diff --git a/fixtures/integration/golden/enhancements_alias_expand.json b/fixtures/integration/golden/enhancements_alias_expand.json index 2fe6aca..5c05c74 100644 --- a/fixtures/integration/golden/enhancements_alias_expand.json +++ b/fixtures/integration/golden/enhancements_alias_expand.json @@ -66,16 +66,6 @@ }, "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/alias-expand" }, - "EnvelopeAlias2": { - "type": "object", - "title": "EnvelopeAlias2 aliases EnvelopeAlias (alias-of-alias).", - "properties": { - "payload": { - "$ref": "#/definitions/PayloadAlias" - } - }, - "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/alias-expand" - }, "Payload": { "type": "object", "title": "Payload is the canonical struct referenced by aliases.", @@ -150,6 +140,7 @@ }, "exportedParams": { "type": "object", + "title": "exportedParams is the backing struct for an aliased swagger:parameters.", "required": [ "data" ], @@ -169,7 +160,7 @@ "aliasedResponse": { "description": "AliasedResponse has a body field whose type is an alias chain.", "schema": { - "$ref": "#/definitions/EnvelopeAlias2" + "$ref": "#/definitions/ResponseEnvelope" } }, "namedTopResponse": { diff --git a/fixtures/integration/golden/enhancements_alias_ref.json b/fixtures/integration/golden/enhancements_alias_ref.json index e92e4ad..eaee663 100644 --- a/fixtures/integration/golden/enhancements_alias_ref.json +++ b/fixtures/integration/golden/enhancements_alias_ref.json @@ -66,6 +66,7 @@ }, "exportedParams": { "type": "object", + "title": "exportedParams is the backing struct for an aliased swagger:parameters.", "required": [ "data" ], diff --git a/fixtures/integration/golden/enhancements_alias_response_shapes_default.json b/fixtures/integration/golden/enhancements_alias_response_shapes_default.json new file mode 100644 index 0000000..b645b10 --- /dev/null +++ b/fixtures/integration/golden/enhancements_alias_response_shapes_default.json @@ -0,0 +1,47 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Envelope": { + "type": "object", + "title": "Envelope is the canonical named struct.", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/alias-response-shapes" + } + }, + "responses": { + "bodyAliasResponse": { + "description": "BodyAliasResponse — body field uses an alias-of-alias chain.", + "schema": { + "$ref": "#/definitions/Envelope" + } + }, + "headerAliasedBasicResponse": { + "description": "HeaderAliasedBasicResponse — header field is an alias of a\nnamed string. Expected emission: primitive inline\n{string, \"\"}; no $ref under any mode (headers can't carry\n$ref).", + "headers": { + "X-Session": { + "type": "string" + } + } + }, + "headerAliasedStructResponse": { + "description": "HeaderAliasedStructResponse — header field is an alias of a\nnamed struct. Expected emission: NO body schema corruption\n(Q2 fix); header surfaces empty (struct can't reduce to a\nSimpleSchema primitive); CodeUnsupportedInSimpleSchema\ndiagnostic fires for the underlying ref attempt.", + "headers": { + "X-Detail": {} + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_alias_response_shapes_ref.json b/fixtures/integration/golden/enhancements_alias_response_shapes_ref.json new file mode 100644 index 0000000..133c139 --- /dev/null +++ b/fixtures/integration/golden/enhancements_alias_response_shapes_ref.json @@ -0,0 +1,55 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Envelope": { + "type": "object", + "title": "Envelope is the canonical named struct.", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/alias-response-shapes" + }, + "EnvelopeAlias": { + "title": "EnvelopeAlias is an alias of Envelope.", + "$ref": "#/definitions/Envelope" + }, + "EnvelopeAlias2": { + "title": "EnvelopeAlias2 is an alias of EnvelopeAlias (alias-of-alias).", + "$ref": "#/definitions/EnvelopeAlias" + } + }, + "responses": { + "bodyAliasResponse": { + "description": "BodyAliasResponse — body field uses an alias-of-alias chain.", + "schema": { + "$ref": "#/definitions/EnvelopeAlias2" + } + }, + "headerAliasedBasicResponse": { + "description": "HeaderAliasedBasicResponse — header field is an alias of a\nnamed string. Expected emission: primitive inline\n{string, \"\"}; no $ref under any mode (headers can't carry\n$ref).", + "headers": { + "X-Session": { + "type": "string" + } + } + }, + "headerAliasedStructResponse": { + "description": "HeaderAliasedStructResponse — header field is an alias of a\nnamed struct. Expected emission: NO body schema corruption\n(Q2 fix); header surfaces empty (struct can't reduce to a\nSimpleSchema primitive); CodeUnsupportedInSimpleSchema\ndiagnostic fires for the underlying ref attempt.", + "headers": { + "X-Detail": {} + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_alias_response_shapes_transparent.json b/fixtures/integration/golden/enhancements_alias_response_shapes_transparent.json new file mode 100644 index 0000000..b645b10 --- /dev/null +++ b/fixtures/integration/golden/enhancements_alias_response_shapes_transparent.json @@ -0,0 +1,47 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Envelope": { + "type": "object", + "title": "Envelope is the canonical named struct.", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/alias-response-shapes" + } + }, + "responses": { + "bodyAliasResponse": { + "description": "BodyAliasResponse — body field uses an alias-of-alias chain.", + "schema": { + "$ref": "#/definitions/Envelope" + } + }, + "headerAliasedBasicResponse": { + "description": "HeaderAliasedBasicResponse — header field is an alias of a\nnamed string. Expected emission: primitive inline\n{string, \"\"}; no $ref under any mode (headers can't carry\n$ref).", + "headers": { + "X-Session": { + "type": "string" + } + } + }, + "headerAliasedStructResponse": { + "description": "HeaderAliasedStructResponse — header field is an alias of a\nnamed struct. Expected emission: NO body schema corruption\n(Q2 fix); header surfaces empty (struct can't reduce to a\nSimpleSchema primitive); CodeUnsupportedInSimpleSchema\ndiagnostic fires for the underlying ref attempt.", + "headers": { + "X-Detail": {} + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_allof_edges.json b/fixtures/integration/golden/enhancements_allof_edges.json index 8f512b0..5f763c6 100644 --- a/fixtures/integration/golden/enhancements_allof_edges.json +++ b/fixtures/integration/golden/enhancements_allof_edges.json @@ -59,6 +59,7 @@ "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/allof-edges" }, "AllOfStrfmt": { + "title": "AllOfStrfmt composes an allOf member that carries a swagger:strfmt tag.", "allOf": [ { "type": "string", diff --git a/fixtures/integration/golden/enhancements_enum_overrides.json b/fixtures/integration/golden/enhancements_enum_overrides.json new file mode 100644 index 0000000..ddd4a68 --- /dev/null +++ b/fixtures/integration/golden/enhancements_enum_overrides.json @@ -0,0 +1,97 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "NotificationA": { + "description": "NotificationA exercises case A: field uses PriorityA, no inline\nenum override.", + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "priority": { + "description": "The priority level. Enum values come from PriorityA's consts.\nlow PriorityALow\nmedium PriorityAMed\nhigh PriorityAHigh", + "type": "string", + "enum": [ + "low", + "medium", + "high" + ], + "x-go-enum-desc": "low PriorityALow\nmedium PriorityAMed\nhigh PriorityAHigh", + "x-go-name": "Priority" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/enum-overrides" + }, + "NotificationB": { + "description": "NotificationB exercises case B: plain string field with inline\ncomma-list enum. No swagger:enum on the type, no consts in code.", + "type": "object", + "properties": { + "priority": { + "description": "The priority level.", + "type": "string", + "enum": [ + "low", + "medium", + "high" + ], + "x-go-name": "Priority" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/enum-overrides" + }, + "NotificationC": { + "type": "object", + "title": "NotificationC exercises case C: inline JSON-array enum.", + "properties": { + "priority": { + "description": "The priority level.", + "type": "string", + "enum": [ + "low", + "medium", + "high" + ], + "x-go-name": "Priority" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/enum-overrides" + }, + "NotificationD": { + "type": "object", + "title": "NotificationD exercises case D.", + "properties": { + "priority": { + "$ref": "#/definitions/PriorityD" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/enum-overrides" + }, + "NotificationE": { + "description": "NotificationE exercises case E: the inline enum on the field\ncompetes with the const-derived enum from PriorityE. The golden\noutput captures which one wins in v1.", + "type": "object", + "properties": { + "priority": { + "description": "Inline enum provides a narrower set than the const block.", + "type": "string", + "enum": [ + "urgent", + "normal" + ], + "x-go-name": "Priority" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/enum-overrides" + }, + "PriorityD": { + "description": "PriorityD has a swagger:enum annotation but no corresponding\nconst declarations in this package. The builder's FindEnumValues\ncall returns an empty slice; the test captures how the spec\nrenders in that case.", + "type": "string", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/enum-overrides" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_generic_instantiation.json b/fixtures/integration/golden/enhancements_generic_instantiation.json new file mode 100644 index 0000000..b303cce --- /dev/null +++ b/fixtures/integration/golden/enhancements_generic_instantiation.json @@ -0,0 +1,41 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "generic_container": { + "description": "These should emit with the substituted underlying shape, not as\n$ref to the (empty) generic declaration.", + "type": "object", + "title": "Container holds fields whose types are generic INSTANTIATIONS.", + "properties": { + "counts": { + "description": "Counts is an instantiation of GenericMap with K=string, V=int.\nExpected schema: {object, additionalProperties:{integer, int64}}.", + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + }, + "x-go-name": "Counts" + }, + "items": { + "description": "Items is an instantiation of GenericSlice with T=int.\nExpected schema: {array, items:{integer, int64}}.", + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "x-go-name": "Items" + }, + "names": { + "description": "Names is an instantiation of GenericSlice with T=string.\nExpected schema: {array, items:{string}}.", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "Names" + } + }, + "x-go-name": "Container", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/generic-instantiation" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_interface_methods.json b/fixtures/integration/golden/enhancements_interface_methods.json index 6ef3996..fa45fb6 100644 --- a/fixtures/integration/golden/enhancements_interface_methods.json +++ b/fixtures/integration/golden/enhancements_interface_methods.json @@ -3,6 +3,7 @@ "paths": {}, "definitions": { "Audited": { + "description": "Audited is a small named interface that is embedded with swagger:allOf\ninto richer interfaces below.", "type": "object", "properties": { "createdAt": { diff --git a/fixtures/integration/golden/enhancements_interface_methods_xnullable.json b/fixtures/integration/golden/enhancements_interface_methods_xnullable.json index 3c677c4..6d6fe00 100644 --- a/fixtures/integration/golden/enhancements_interface_methods_xnullable.json +++ b/fixtures/integration/golden/enhancements_interface_methods_xnullable.json @@ -3,6 +3,7 @@ "paths": {}, "definitions": { "Audited": { + "description": "Audited is a small named interface that is embedded with swagger:allOf\ninto richer interfaces below.", "type": "object", "properties": { "createdAt": { diff --git a/fixtures/integration/golden/enhancements_parameters_map_postdecl.json b/fixtures/integration/golden/enhancements_parameters_map_postdecl.json new file mode 100644 index 0000000..0e335eb --- /dev/null +++ b/fixtures/integration/golden/enhancements_parameters_map_postdecl.json @@ -0,0 +1,48 @@ +{ + "swagger": "2.0", + "paths": { + "/items": { + "post": { + "summary": "Send a map body.", + "operationId": "mapBody", + "parameters": [ + { + "x-go-name": "Items", + "description": "Items is a body parameter of type map[string]LocalItem.", + "name": "items", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/LocalItem" + } + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + } + }, + "definitions": { + "LocalItem": { + "description": "LocalItem — NOT annotated; reachable only via the map field on\nMapParams below.", + "type": "object", + "properties": { + "name": { + "type": "string", + "x-go-name": "Name" + }, + "tag": { + "type": "string", + "x-go-name": "Tag" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/parameters-map-postdecl" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_raw_message_override.json b/fixtures/integration/golden/enhancements_raw_message_override.json new file mode 100644 index 0000000..c0162dc --- /dev/null +++ b/fixtures/integration/golden/enhancements_raw_message_override.json @@ -0,0 +1,73 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "AsArray": { + "type": "array", + "title": "AsArray (case B.2) — named wrapper with `swagger:type array`.", + "items": { + "type": "integer", + "format": "uint8" + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/raw-message-override" + }, + "AsObject": { + "description": "AsObject (case B.1) — named wrapper of json.RawMessage with\n`swagger:type object`. The classifier overrides the recognizer\nbecause IsStdJSONRawMessage checks for encoding/json.RawMessage\nidentity; the wrapper has its own package path and does NOT\nmatch. The classifier on the slice arm fires instead.", + "type": "object", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/raw-message-override" + }, + "FieldLevelContainer": { + "description": "FieldLevelContainer (case C) — exercises field-level\n`swagger:type` on plain json.RawMessage fields. The field-level\nwalker (scanFieldDoc) consumes swagger:type and applyFieldCarrier\napplies it after buildFromType, so the user override beats the\nRawMessage recognizer's empty-schema default.", + "type": "object", + "properties": { + "arr": { + "description": "Overridden to {type: array, items: {integer, uint8}} via\nfield-level swagger:type. \"array\" isn't a known SwaggerSchemaForType\nvalue, so the override falls back to building from Underlying()\n(= []byte) which yields the byte-typed array shape.", + "type": "array", + "items": { + "type": "integer", + "format": "uint8" + }, + "x-go-name": "Arr" + }, + "obj": { + "description": "Overridden to {type: object} via field-level swagger:type.", + "type": "object", + "x-go-name": "Obj" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/raw-message-override" + }, + "PlainContainer": { + "type": "object", + "title": "PlainContainer (case A) — bare json.RawMessage field, no overrides.", + "properties": { + "payload": { + "description": "Should emit an empty schema (`{}`).", + "x-go-name": "Payload" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/raw-message-override" + }, + "TypedContainer": { + "type": "object", + "title": "TypedContainer (case B field reference) — references the wrappers.", + "properties": { + "arr": { + "description": "Wrapped via AsArray — expect `{$ref: AsArray}` whose definition\nresolves to a typed array shape.", + "type": "array", + "items": { + "type": "integer", + "format": "uint8" + }, + "x-go-name": "Arr" + }, + "obj": { + "description": "Wrapped via AsObject — expect `{$ref: AsObject}` whose definition\nresolves to `{type: object}` (the classifier wins on the decl).", + "type": "object", + "x-go-name": "Obj" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/raw-message-override" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_response_file_types.json b/fixtures/integration/golden/enhancements_response_file_types.json new file mode 100644 index 0000000..8de73cd --- /dev/null +++ b/fixtures/integration/golden/enhancements_response_file_types.json @@ -0,0 +1,30 @@ +{ + "swagger": "2.0", + "paths": {}, + "responses": { + "fileBodyResponse": { + "description": "FileBodyResponse is the legitimate case: the response IS a file.\n`swagger:file` on the Body field with `in: body` rewrites\nresp.Schema to {file, \"\"} and skips the field build.", + "schema": { + "type": "file" + } + }, + "fileOnHeaderResponse": { + "description": "FileOnHeaderResponse exercises the Q3 misuse: `swagger:file` on\na header-positioned field. Post-fix the diagnostic fires and\nthe field falls through to the normal build, surfacing as a\nregular header (typed as string here).", + "headers": { + "X-Misplaced": { + "type": "string", + "description": "Misplaced has both `in: header` and `swagger:file`. The\nscanner diagnoses and treats it as a normal header." + } + } + }, + "fileOnImplicitDefaultResponse": { + "description": "FileOnImplicitDefaultResponse — `swagger:file` on a field with\nno `in:` line. After Q1 the implicit default is header, so this\nis also a misuse. Same diagnostic fires; field becomes a header.", + "headers": { + "X-Implicit": { + "type": "string", + "description": "Implicit has only `swagger:file` — no `in:` line at all.\nQ1's implicit-header default applies; Q3's gate diagnoses." + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_response_header_ref_leak.json b/fixtures/integration/golden/enhancements_response_header_ref_leak.json new file mode 100644 index 0000000..20d26ec --- /dev/null +++ b/fixtures/integration/golden/enhancements_response_header_ref_leak.json @@ -0,0 +1,41 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Tag": { + "description": "Tag is a named struct intended to be referenceable as a model\nfrom body schemas. Using it as the type of a header field is the\nauthor misuse this fixture captures.", + "type": "object", + "properties": { + "name": { + "type": "string", + "x-go-name": "Name" + }, + "value": { + "type": "string", + "x-go-name": "Value" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/response-header-ref-leak" + } + }, + "responses": { + "leakResponse": { + "description": "LeakResponse pins the post-fix shape: the header field is\nsurfaced, the body schema is NOT corrupted, and the diagnostic\nsignals the misuse.", + "headers": { + "X-Tag": { + "description": "TagHeader is a header field typed as a named struct. Pre-Q2\nthis leaked `$ref: \"#/definitions/Tag\"` onto resp.Schema and\nleft the header empty. Post-fix resp.Schema stays nil; the\nheader surfaces empty (no Type — the named struct can't\nreduce to a SimpleSchema primitive); diagnostic fires." + } + } + }, + "leakWithStrfmtResponse": { + "description": "LeakWithStrfmtResponse pins the post-fix shape under the strfmt\noverride: the header gets {string, uuid}, the body schema stays\nnil, and the diagnostic still fires for the underlying ref\nattempt.", + "headers": { + "X-Tag-ID": { + "type": "string", + "format": "uuid", + "description": "TagID is a header field with strfmt override on a named-struct\ntype. The strfmt override runs after the exit-validator's\nreset, so the header surfaces as {string, uuid}. No body-schema\nleak." + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_response_implicit_header.json b/fixtures/integration/golden/enhancements_response_implicit_header.json new file mode 100644 index 0000000..6d5f8b4 --- /dev/null +++ b/fixtures/integration/golden/enhancements_response_implicit_header.json @@ -0,0 +1,55 @@ +{ + "swagger": "2.0", + "paths": {}, + "responses": { + "allHeadersResponse": { + "description": "AllHeadersResponse — every field becomes a header. Etag carries\nno `in:` line at all (default); RateLimit carries an explicit\n`in: header` (parity with existing fixtures).", + "headers": { + "ETag": { + "type": "string", + "description": "Etag carries an HTTP entity tag for cache validation.\nNo `in:` line — defaults to header (Q1 fix)." + }, + "X-Rate-Limit": { + "type": "integer", + "format": "int64", + "description": "RateLimit advertises the remaining quota." + } + } + }, + "emptyResponse": { + "description": "EmptyResponse — empty Go struct with a swagger:response\nannotation. Produces a response with description only; no Headers\nmap, no Schema." + }, + "invalidInResponse": { + "description": "InvalidInResponse — non-vocabulary `in:` value. The scanner emits\na CodeInvalidAnnotation warning naming the bad value, then falls\nback to the default (header).", + "headers": { + "Cookie": { + "type": "string", + "description": "Cookie carries a session cookie. The `in: cookie` line is\nnot in the OAS v2 vocabulary; the scanner warns and treats\nthe field as a header anyway." + } + } + }, + "mixedResponse": { + "description": "MixedResponse — body field plus headers (implicit + explicit).", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "x-go-name": "Message" + } + } + }, + "headers": { + "X-Limit": { + "type": "integer", + "format": "int64", + "description": "Limit advertises the remaining quota." + }, + "X-Tag": { + "type": "string", + "description": "Tag is a tracing tag. Implicit header (no `in:`)." + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_swagger_type_array.json b/fixtures/integration/golden/enhancements_swagger_type_array.json index b9d971a..b9d75e2 100644 --- a/fixtures/integration/golden/enhancements_swagger_type_array.json +++ b/fixtures/integration/golden/enhancements_swagger_type_array.json @@ -3,6 +3,7 @@ "paths": {}, "definitions": { "objectStruct": { + "description": "ObjectStruct carries swagger:type object (unsupported by\nswaggerSchemaForType for structs). The fix inlines the struct as\ntype:object rather than producing an empty schema.", "type": "object", "x-go-name": "ObjectStruct", "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/swagger-type-array" @@ -25,6 +26,7 @@ "x-go-name": "Labels" }, "nested": { + "description": "The nested struct with an unsupported swagger:type.", "type": "object", "properties": { "name": { @@ -47,6 +49,7 @@ "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/swagger-type-array" }, "structWithBadType": { + "description": "StructWithBadType is a struct whose swagger:type is set to an\nunrecognised value. The fix ensures buildNamedStruct falls through to\nmakeRef so the property is still serialisable — the key assertion is\nthat the referenced schema is not empty.", "x-go-name": "StructWithBadType", "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/swagger-type-array" } diff --git a/fixtures/integration/golden/enhancements_text_marshal.json b/fixtures/integration/golden/enhancements_text_marshal.json index 1cee1ce..2c82bdf 100644 --- a/fixtures/integration/golden/enhancements_text_marshal.json +++ b/fixtures/integration/golden/enhancements_text_marshal.json @@ -26,6 +26,38 @@ } }, "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/text-marshal" + }, + "override_container": { + "description": "OverrideContainer references UUID so the schema builder walks\nthe override path.", + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "format": "date", + "x-go-name": "ID" + } + }, + "x-go-name": "OverrideContainer", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/text-marshal/explicit_override" + }, + "wrapping_time_container": { + "description": "WrappingTimeContainer references UUID so the schema builder walks\nthe heuristic path.", + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "x-go-name": "ID" + } + }, + "x-go-name": "WrappingTimeContainer", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/text-marshal/uuid_wrapping_time" } } } \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_top_level_kinds.json b/fixtures/integration/golden/enhancements_top_level_kinds.json index 11d6792..c9dca4c 100644 --- a/fixtures/integration/golden/enhancements_top_level_kinds.json +++ b/fixtures/integration/golden/enhancements_top_level_kinds.json @@ -3,7 +3,9 @@ "paths": {}, "definitions": { "IgnoredModel": { + "description": "so the sectionedParser flags it as ignored and buildFromDecl returns\nearly via its `sp.ignored` branch.", "type": "object", + "title": "IgnoredModel is annotated as a model but also carries swagger:ignore,", "properties": { "value": { "type": "integer", diff --git a/fixtures/integration/golden/enhancements_wrapper_decl_type_override.json b/fixtures/integration/golden/enhancements_wrapper_decl_type_override.json new file mode 100644 index 0000000..16dc0e0 --- /dev/null +++ b/fixtures/integration/golden/enhancements_wrapper_decl_type_override.json @@ -0,0 +1,21 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "BareWrapperArray": { + "description": "No field references.", + "type": "array", + "title": "BareWrapperArray — named wrapper with `swagger:type array`.", + "items": { + "type": "integer", + "format": "uint8" + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/wrapper-decl-type-override" + }, + "BareWrapperObject": { + "description": "BareWrapperObject — named wrapper of json.RawMessage with\n`swagger:type object`. No field references it; the only schema\nthe scanner emits for this package is the top-level definition.", + "type": "object", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/wrapper-decl-type-override" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/go123_aliased_spec.json b/fixtures/integration/golden/go123_aliased_spec.json index e6fa7d9..b605b07 100644 --- a/fixtures/integration/golden/go123_aliased_spec.json +++ b/fixtures/integration/golden/go123_aliased_spec.json @@ -236,7 +236,16 @@ "$ref": "#/definitions/ExtendedID" }, "id": { - "$ref": "#/definitions/UUID" + "description": "the id for this order", + "allOf": [ + { + "$ref": "#/definitions/UUID" + }, + { + "minimum": 1 + } + ], + "x-go-name": "ID" }, "items": { "description": "the items for this order", @@ -265,7 +274,6 @@ "description": "the name for this user", "type": "integer", "format": "int64", - "minLength": 3, "x-go-name": "UserID" } }, diff --git a/fixtures/integration/golden/go123_special_spec.json b/fixtures/integration/golden/go123_special_spec.json index 3cce724..4cf947d 100644 --- a/fixtures/integration/golden/go123_special_spec.json +++ b/fixtures/integration/golden/go123_special_spec.json @@ -124,9 +124,7 @@ "type": "string", "x-go-type": "github.com/go-openapi/codescan/fixtures/goparsing/go123/special.IsATextMarshaler" }, - "Message": { - "type": "object" - }, + "Message": {}, "NamedArray": { "$ref": "#/definitions/go_array" }, diff --git a/go.mod b/go.mod index e91176b..1566885 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,9 @@ go 1.25.0 toolchain go1.26.1 require ( - github.com/go-openapi/loads v0.23.3 github.com/go-openapi/spec v0.22.4 github.com/go-openapi/swag/mangling v0.26.0 + github.com/go-openapi/swag/yamlutils v0.26.0 github.com/go-openapi/testify/v2 v2.5.1 go.yaml.in/yaml/v3 v3.0.4 golang.org/x/tools v0.45.0 @@ -16,13 +16,12 @@ require ( require ( github.com/go-openapi/jsonpointer v0.22.5 // indirect github.com/go-openapi/jsonreference v0.21.5 // indirect - github.com/go-openapi/swag/conv v0.25.5 // indirect - github.com/go-openapi/swag/jsonname v0.25.5 // indirect - github.com/go-openapi/swag/jsonutils v0.25.5 // indirect - github.com/go-openapi/swag/loading v0.25.5 // indirect - github.com/go-openapi/swag/stringutils v0.25.5 // indirect - github.com/go-openapi/swag/typeutils v0.25.5 // indirect - github.com/go-openapi/swag/yamlutils v0.25.5 // indirect + github.com/go-openapi/swag/conv v0.26.0 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/go-openapi/swag/jsonutils v0.26.0 // indirect + github.com/go-openapi/swag/loading v0.26.0 // indirect + github.com/go-openapi/swag/stringutils v0.26.0 // indirect + github.com/go-openapi/swag/typeutils v0.26.0 // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect ) diff --git a/go.sum b/go.sum index 7a64be3..b1d78d8 100644 --- a/go.sum +++ b/go.sum @@ -2,30 +2,28 @@ github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+r github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= -github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= -github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= -github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= -github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= -github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= -github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= -github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= -github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= -github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= -github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= +github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= +github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= +github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= +github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= -github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= -github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= -github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= -github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= -github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= -github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.1 h1:NZOrZmIb6PTv5LTFxr5/mKV/FjbUzGE7E6gLz7vFoOQ= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.1/go.mod h1:r7dwsujEHawapMsxA69i+XMGZrQ5tRauhLAjV/sxg3Q= +github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= +github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= +github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= +github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= +github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= +github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= diff --git a/internal/builders/common/builder.go b/internal/builders/common/builder.go new file mode 100644 index 0000000..aca674f --- /dev/null +++ b/internal/builders/common/builder.go @@ -0,0 +1,164 @@ +package common + +import ( + "go/ast" + "log/slog" + + "github.com/go-openapi/codescan/internal/ifaces" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/scanner" + oaispec "github.com/go-openapi/spec" +) + +// Builder holds common characteristics shared by spec builders. +type Builder struct { + Ctx *scanner.ScanCtx + Decl *scanner.EntityDecl + + postDecls []*scanner.EntityDecl + postDeclSet map[*ast.Ident]struct{} // dedup index keyed by EntityDecl.Ident + diagnostics []grammar.Diagnostic + blockCache map[*ast.CommentGroup][]grammar.Block // memoises a parsed comment. + logger *slog.Logger +} + +func New(ctx *scanner.ScanCtx, decl *scanner.EntityDecl) *Builder { + return &Builder{ + Ctx: ctx, + Decl: decl, + + blockCache: make(map[*ast.CommentGroup][]grammar.Block), + logger: slog.Default(), // TODO(fred): accomodate options for level, coloured output, etc + } +} + +func (s *Builder) PostDeclarations() []*scanner.EntityDecl { + return s.postDecls +} + +func (s *Builder) Warn(msg string, args ...any) { + s.logger.Warn(msg, args...) +} + +func (s *Builder) Debug(msg string, args ...any) { + s.logger.Debug(msg, args...) +} + +// Diagnostics returns every grammar.Diagnostic accumulated by this +// Builder during a Build pass. Source order is preserved; no +// deduplication is applied (per plan §11 Q2 — "watch and improve when +// we start doing some serious work with diagnostics"). +// +// The slice is owned by the Builder; callers must not mutate it. +// Returns nil before Build is invoked or when no diagnostics were +// recorded. +// +// Experimental — the diagnostic surface is subject to change while +// LSP integration matures. +func (s *Builder) Diagnostics() []grammar.Diagnostic { + return s.diagnostics +} + +// RecordDiagnostic accumulates one diagnostic on the Builder and, if +// the consumer wired Options.OnDiagnostic, fires the callback in +// source order. Wired into Walker.Diagnostic by every Walker +// constructed in walker.go. +func (s *Builder) RecordDiagnostic(d grammar.Diagnostic) { + s.diagnostics = append(s.diagnostics, d) + if cb := s.Ctx.OnDiagnostic(); cb != nil { + cb(d) + } +} + +// parseBlocks returns the cached grammar.Block slice for cg +// (one entry per annotation per ParseAll's contract), parsing on +// first access and memoising the result. Always returns a non-nil +// slice with at least one Block, so consumers can call +// AnnotationKind() / AnnotationArg() / etc. unconditionally on the +// first element. +func (s *Builder) ParseBlocks(cg *ast.CommentGroup) []grammar.Block { + if cg == nil { + return grammar.NewParser(s.Ctx.FileSet()).ParseAll(nil) + } + + bs, ok := s.blockCache[cg] + if !ok { + bs = grammar.NewParser(s.Ctx.FileSet()).ParseAll(cg) + s.blockCache[cg] = bs + } + + return bs +} + +// parseBlock returns the first Block from parseBlocks(cg) — the +// "primary" annotation for callers that don't need multi- +// annotation visibility (title/description, field-level lookups). +// +// repo +// +//nolint:ireturn // Block interface is the documented return type. // TODO(fred): ireturn should be disabled from this +func (s *Builder) ParseBlock(cg *ast.CommentGroup) grammar.Block { + return s.ParseBlocks(cg)[0] +} + +// AppendPostDecl marks decl for post-processing by the spec +// orchestrator's discovery loop. Idempotent per-Builder: re-appending +// a decl whose Ident was already seen is a no-op. Two consumers may +// still see the same decl across Builders (each Builder has its own +// postDecls), but the spec.Builder.buildDiscovered queue applies a +// second dedup at consumption time so duplicates never reach a second +// Build pass. +// +// Nil and Ident-less decls are silently ignored. +func (s *Builder) AppendPostDecl(decl *scanner.EntityDecl) { + if decl == nil || decl.Ident == nil { + return + } + if s.postDeclSet == nil { + s.postDeclSet = make(map[*ast.Ident]struct{}) + } + if _, dup := s.postDeclSet[decl.Ident]; dup { + return + } + s.postDeclSet[decl.Ident] = struct{}{} + s.postDecls = append(s.postDecls, decl) +} + +// MakeRef writes a `$ref: "#/definitions/"` onto prop and +// registers decl with the discovery loop via AppendPostDecl. The +// name comes from decl.Names() (the first entry — top-level decls +// in this codebase have a single name). Returns an error only if +// `oaispec.NewRef` rejects the JSON pointer. +// +// Same shape as the schema / parameters / responses builders' +// per-package makeRef methods used to have; hoisted to the common +// base so the four (eventually five, once operations / routes +// migrate) builders share one implementation. Adding a side +// effect that should fire on every $ref emission (a diagnostic +// counter, a name-collision check, ...) is now a one-place edit. +func (s *Builder) MakeRef(decl *scanner.EntityDecl, prop ifaces.SwaggerTypable) error { + nm, _ := decl.Names() + ref, err := oaispec.NewRef("#/definitions/" + nm) + if err != nil { + return err + } + + prop.SetRef(ref) + s.AppendPostDecl(decl) + + return nil +} + +// Implementation notes (temporary): +// +// blockCache: +// +// Memoize per CommentGroup pointer so the recursive type descent doesn't re-lex+re-parse +// the same comment for each lookup. +// +// Per-Builder scope (one top-level decl build); not shared across goroutines, so no +// synchronisation needed. ParseAll is preferred over Parse so +// multi-annotation comments (e.g. swagger:type + +// swagger:model on the same decl) keep both annotations +// accessible. See plan §4.1 in +// .claude/plans/grammar/p7.1-schema-walker-driven-lookups.md. diff --git a/internal/builders/handlers/handlers.go b/internal/builders/handlers/handlers.go new file mode 100644 index 0000000..c097a6e --- /dev/null +++ b/internal/builders/handlers/handlers.go @@ -0,0 +1,238 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package handlers ships shared grammar Walker callbacks for the +// SimpleSchema family of OAS v2 dispatchers (parameter level-0 and +// items chain, response-header level-0 and items chain). +// +// The handlers fan a single switch-on-Keyword.Name dispatch out +// across every consumer that writes through an +// `ifaces.ValidationBuilder` or `ifaces.OperationValidationBuilder` +// adapter — paramValidations, headerValidations, items.Validations, +// and any future SimpleSchema-shaped target. +// +// The schema package's full-Schema handlers stay separate: schema +// adds a `checkShape` gate that emits CodeShapeMismatch on +// keyword-vs-resolved-type mismatches, and its Bool handler does +// cross-target writes (required → enclosing.Required keyed by name, +// discriminator likewise). Those concerns are full-Schema-specific +// and don't share the SimpleSchema seam. +// +// Walker payload conventions: +// +// - Number / Integer / Bool callbacks fire with a zero-value +// payload when the lexer rejected the source value; the parser +// has already emitted a CodeInvalid{Number,Integer,Boolean} +// diagnostic. Consumers MUST gate on `pr.IsTyped()` before +// writing — these helpers do that internally. +// - String fires with the raw value alongside `pr.Value`; for +// `pattern:` consumers should read `pr.Value` (the regex source) +// rather than the formatted string. +// - Raw fires for ShapeRawValue keywords (`default:`, `example:`, +// `enum:`) and reads `pr.Value` for the raw text. +package handlers + +import ( + "strings" + + "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/classify" + oaispec "github.com/go-openapi/spec" +) + +// ExtensionTarget is the minimal surface a Walker.Extension consumer +// needs to write a vendor extension. Implemented by every +// oaispec object that embeds VendorExtensible (Schema, Parameter, +// Header, Response, Operation, …) via the AddExtension method +// promoted from the embed. +type ExtensionTarget interface { + AddExtension(key string, value any) +} + +// Extension returns a Walker.Extension callback that filters non-`x-*` +// names via `classify.IsAllowedExtension` and writes the typed +// extension value onto target. +// +// User-authored extensions are NOT gated by `SkipExtensions` — that +// flag suppresses scanner-derived `x-go-*` keys, not author intent. +// Consumers that need additional side effects on a successful write +// (e.g. the schema builder's refOverrideCollector marking the +// collector) wrap this with their own callback rather than reusing +// this helper. +func Extension(target ExtensionTarget) func(grammar.Extension) { + return func(ext grammar.Extension) { + if !classify.IsAllowedExtension(ext.Name) { + return + } + target.AddExtension(ext.Name, ext.Value) + } +} + +// Number returns a Walker.Number callback that routes +// `maximum:` / `minimum:` / `multipleOf:` onto v. +func Number(v ifaces.ValidationBuilder) func(grammar.Property, float64, bool) { + return func(pr grammar.Property, val float64, exclusive bool) { + if !pr.IsTyped() { + return + } + switch pr.Keyword.Name { + case grammar.KwMaximum: + v.SetMaximum(val, exclusive) + case grammar.KwMinimum: + v.SetMinimum(val, exclusive) + case grammar.KwMultipleOf: + v.SetMultipleOf(val) + } + } +} + +// Integer returns a Walker.Integer callback that routes +// `min/maxLength:` and `min/maxItems:` onto v. +func Integer(v ifaces.ValidationBuilder) func(grammar.Property, int64) { + return func(pr grammar.Property, val int64) { + if !pr.IsTyped() { + return + } + switch pr.Keyword.Name { + case grammar.KwMinLength: + v.SetMinLength(val) + case grammar.KwMaxLength: + v.SetMaxLength(val) + case grammar.KwMinItems: + v.SetMinItems(val) + case grammar.KwMaxItems: + v.SetMaxItems(val) + } + } +} + +// UniqueBool returns a Walker.Bool callback that handles only the +// `unique:` keyword. Consumers that also need to dispatch +// `required:` (parameter level) wrap this with a second callback +// via [ComposeBool], or write their own narrow handler that adds the +// parameter-target write next to a call into UniqueBool. +func UniqueBool(v ifaces.ValidationBuilder) func(grammar.Property, bool) { + return func(pr grammar.Property, val bool) { + if !pr.IsTyped() { + return + } + if pr.Keyword.Name == grammar.KwUnique { + v.SetUnique(val) + } + } +} + +// ComposeBool returns a Walker.Bool callback that fans the payload +// out to every non-nil handler in order. Useful when a consumer +// wants UniqueBool plus a context-specific extra (e.g. parameters' +// `required:` writes to param.Required directly). +func ComposeBool(hs ...func(grammar.Property, bool)) func(grammar.Property, bool) { + return func(pr grammar.Property, val bool) { + for _, h := range hs { + if h != nil { + h(pr, val) + } + } + } +} + +// PatternString returns a Walker.String callback for the `pattern:` +// keyword. The pattern is read from `pr.Value` so the regex source +// reaches v.SetPattern verbatim. +func PatternString(v ifaces.ValidationBuilder) func(grammar.Property, string) { + return func(pr grammar.Property, _ string) { + if pr.Keyword.Name == grammar.KwPattern { + v.SetPattern(pr.Value) + } + } +} + +// CollectionFormatString returns a Walker.String callback for the +// `collectionFormat:` keyword. Tries the Walker-supplied typed +// string first; falls back to `strings.TrimSpace(pr.Value)` when +// grammar's closed-vocab string-enum rejected the source (v1 +// accepts any string and stores it verbatim, e.g. `pipe` as a typo +// for `pipes` still round-trips for parity). +// +// SimpleSchema-only — schema-level Validations don't expose +// SetCollectionFormat. +func CollectionFormatString(v ifaces.OperationValidationBuilder) func(grammar.Property, string) { + return func(pr grammar.Property, val string) { + if pr.Keyword.Name != grammar.KwCollectionFormat { + return + } + x := val + if x == "" { + x = strings.TrimSpace(pr.Value) + } + if x != "" { + v.SetCollectionFormat(x) + } + } +} + +// ComposeString returns a Walker.String callback that fans the +// payload out to every non-nil handler in order. The canonical use +// is to combine PatternString + CollectionFormatString in one +// Walker.String slot. +func ComposeString(hs ...func(grammar.Property, string)) func(grammar.Property, string) { + return func(pr grammar.Property, val string) { + for _, h := range hs { + if h != nil { + h(pr, val) + } + } + } +} + +// Raw returns a Walker.Raw callback for `default:` / `example:` / +// `enum:` (Shape=ShapeRawValue per the lexer table). `default` and +// `example` coerce against scheme via `validations.CoerceValue`; +// `enum` is delegated to v.SetEnum which routes through +// `validations.CoerceEnum` inside the adapter. +// +// errSink controls coercion-error semantics: +// +// - errSink == nil → swallow silently (responses' parity — v1 +// never propagated default/example parse errors on the +// response-header path). +// - errSink != nil → invoked with the first ParseValueFromSchema +// error. Returning true short-circuits subsequent Raw +// callbacks within this Walker (the closure's `stopped` flag); +// returning false continues. Parameters use this to bubble the +// error up to dispatchParamLevel0 so the build surfaces a +// malformed default/example as a hard failure (see +// TestMalformed_DefaultInt / TestMalformed_ExampleInt +// integration tests). +func Raw(v ifaces.ValidationBuilder, scheme *oaispec.SimpleSchema, errSink func(error) bool) func(grammar.Property) { + stopped := false + return func(pr grammar.Property) { + if stopped { + return + } + switch pr.Keyword.Name { + case grammar.KwDefault: + val, err := validations.CoerceValue(pr.Value, scheme) + if err != nil { + if errSink != nil && errSink(err) { + stopped = true + } + return + } + v.SetDefault(val) + case grammar.KwExample: + val, err := validations.CoerceValue(pr.Value, scheme) + if err != nil { + if errSink != nil && errSink(err) { + stopped = true + } + return + } + v.SetExample(val) + case grammar.KwEnum: + v.SetEnum(pr.Value) + } + } +} diff --git a/internal/builders/handlers/keywords.go b/internal/builders/handlers/keywords.go new file mode 100644 index 0000000..131b0cd --- /dev/null +++ b/internal/builders/handlers/keywords.go @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +import "github.com/go-openapi/codescan/internal/parsers/grammar" + +// simpleSchemaAllowed enumerates the grammar keyword names that +// can legally appear on an OAS v2 SimpleSchema site (parameter with +// `in != body`, response header, and the items chain within either). +// Source of truth: the OAS v2 Parameter Object and Header Object +// allowed-keyword tables. +// +// Vendor extensions (`x-*`) are NOT listed here — they are gated by +// `classify.IsAllowedExtension`, which runs by name-prefix. +// +//nolint:gochecknoglobals // closed-vocabulary lookup table; one allocation, read-only. +var simpleSchemaAllowed = map[string]struct{}{ + grammar.KwMaximum: {}, + grammar.KwMinimum: {}, + grammar.KwMultipleOf: {}, + grammar.KwMinLength: {}, + grammar.KwMaxLength: {}, + grammar.KwPattern: {}, + grammar.KwMinItems: {}, + grammar.KwMaxItems: {}, + grammar.KwUnique: {}, + grammar.KwCollectionFormat: {}, + grammar.KwDefault: {}, + grammar.KwExample: {}, + grammar.KwEnum: {}, + // `required:` IS valid on the parameter site (parameter-level + // boolean) but NOT on response headers. The parameters walker + // writes it to `param.Required` directly — see + // `parameters/walker.go:paramRequiredBool`. The schema walker + // should skip `required:` under SimpleSchema mode (its + // full-Schema target is `enclosing.Required[name]` — the + // object-level required-array — which doesn't fit the + // SimpleSchema shape). + grammar.KwRequired: {}, +} + +// IsSimpleSchemaKeyword reports whether keyword is legal on an OAS +// v2 SimpleSchema site. Returns false for full-Schema-only keywords +// (`readOnly`, `discriminator`, `$ref`, `allOf`, …) and for unknown +// names. +// +// Consumers wired in SimpleSchema mode (the schema builder under +// `WithSimpleSchema`, the parameters dispatcher, the responses +// dispatcher) use this predicate to gate writes and emit +// `CodeUnsupportedInSimpleSchema` diagnostics on miss. +func IsSimpleSchemaKeyword(keyword string) bool { + _, ok := simpleSchemaAllowed[keyword] + return ok +} diff --git a/internal/builders/handlers/keywords_test.go b/internal/builders/handlers/keywords_test.go new file mode 100644 index 0000000..9491cd5 --- /dev/null +++ b/internal/builders/handlers/keywords_test.go @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +import ( + "testing" + + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/testify/v2/assert" +) + +// TestIsSimpleSchemaKeyword_AllowedSet pins the OAS v2 SimpleSchema +// allowed-keyword vocabulary in code. Any future change to the +// surface (a new SimpleSchema keyword or a removed one) must update +// this test alongside the package-level map and the README +// §simple-schema-mode entry — locking the contract down so it can't +// drift silently. +func TestIsSimpleSchemaKeyword_AllowedSet(t *testing.T) { + want := []string{ + grammar.KwMaximum, + grammar.KwMinimum, + grammar.KwMultipleOf, + grammar.KwMinLength, + grammar.KwMaxLength, + grammar.KwPattern, + grammar.KwMinItems, + grammar.KwMaxItems, + grammar.KwUnique, + grammar.KwCollectionFormat, + grammar.KwDefault, + grammar.KwExample, + grammar.KwEnum, + grammar.KwRequired, + } + for _, kw := range want { + assert.True(t, IsSimpleSchemaKeyword(kw), "keyword %q should be SimpleSchema-legal", kw) + } + assert.Len(t, simpleSchemaAllowed, len(want), "simpleSchemaAllowed must match the documented surface exactly") +} + +// TestIsSimpleSchemaKeyword_FullSchemaOnly pins the keywords that +// MUST be rejected as full-Schema-only. These are the keywords the +// schema Bool handler gates and emits CodeUnsupportedInSimpleSchema +// for under SimpleSchema mode. +func TestIsSimpleSchemaKeyword_FullSchemaOnly(t *testing.T) { + forbidden := []string{ + grammar.KwReadOnly, + grammar.KwDiscriminator, + } + for _, kw := range forbidden { + assert.False(t, IsSimpleSchemaKeyword(kw), "keyword %q must NOT be SimpleSchema-legal", kw) + } +} + +// TestIsSimpleSchemaKeyword_Unknown pins the predicate's behaviour +// on an unknown keyword name — returns false, no panic. +func TestIsSimpleSchemaKeyword_Unknown(t *testing.T) { + assert.False(t, IsSimpleSchemaKeyword("nosuchkeyword")) + assert.False(t, IsSimpleSchemaKeyword("")) +} diff --git a/internal/builders/items/errors.go b/internal/builders/items/errors.go deleted file mode 100644 index 096703e..0000000 --- a/internal/builders/items/errors.go +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package items - -type itemsError string - -func (e itemsError) Error() string { - return string(e) -} - -const ( - // ErrItems is the sentinel error for all errors originating from the items package. - ErrItems itemsError = "builders:items" -) diff --git a/internal/builders/items/taggers.go b/internal/builders/items/taggers.go deleted file mode 100644 index bfb5a1e..0000000 --- a/internal/builders/items/taggers.go +++ /dev/null @@ -1,77 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package items - -import ( - "fmt" - "go/ast" - "slices" - - "github.com/go-openapi/codescan/internal/parsers" - "github.com/go-openapi/spec" -) - -// Taggers builds tag parsers for array items at a given nesting level. -func Taggers(items *spec.Items, level int) []parsers.TagParser { - return itemsTaggers(items, level) -} - -func itemsTaggers(items *spec.Items, level int) []parsers.TagParser { - opts := []parsers.PrefixRxOption{parsers.WithItemsPrefixLevel(level)} - - return []parsers.TagParser{ - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMaximum", level), parsers.NewSetMaximum(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMinimum", level), parsers.NewSetMinimum(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMultipleOf", level), parsers.NewSetMultipleOf(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMinLength", level), parsers.NewSetMinLength(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMaxLength", level), parsers.NewSetMaxLength(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dPattern", level), parsers.NewSetPattern(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dCollectionFormat", level), parsers.NewSetCollectionFormat(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMinItems", level), parsers.NewSetMinItems(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMaxItems", level), parsers.NewSetMaxItems(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dUnique", level), parsers.NewSetUnique(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dEnum", level), parsers.NewSetEnum(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dDefault", level), parsers.NewSetDefault(&items.SimpleSchema, Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dExample", level), parsers.NewSetExample(&items.SimpleSchema, Validations{items}, opts...)), - } -} - -// ParseArrayTypes recursively builds tag parsers for nested array types. -func ParseArrayTypes(taggers []parsers.TagParser, name string, expr ast.Expr, items *spec.Items, level int) ([]parsers.TagParser, error) { - return parseArrayTypes(taggers, name, expr, items, level) -} - -func parseArrayTypes(taggers []parsers.TagParser, name string, expr ast.Expr, items *spec.Items, level int) ([]parsers.TagParser, error) { - if items == nil { - return taggers, nil - } - - switch iftpe := expr.(type) { - case *ast.ArrayType: - eleTaggers := itemsTaggers(items, level) - return parseArrayTypes(slices.Concat(eleTaggers, taggers), name, iftpe.Elt, items.Items, level+1) - - case *ast.SelectorExpr: - return parseArrayTypes(taggers, name, iftpe.Sel, items.Items, level+1) - - case *ast.Ident: - var identTaggers []parsers.TagParser - if iftpe.Obj == nil { - identTaggers = itemsTaggers(items, level) - } - - otherTaggers, err := parseArrayTypes(taggers, name, expr, items.Items, level+1) - if err != nil { - return nil, err - } - - return slices.Concat(identTaggers, otherTaggers), nil - - case *ast.StarExpr: - return parseArrayTypes(taggers, name, iftpe.X, items, level) - - default: - return nil, fmt.Errorf("unknown field type element for %q: %w", name, ErrItems) - } -} diff --git a/internal/builders/items/typable.go b/internal/builders/items/typable.go deleted file mode 100644 index 54b4d57..0000000 --- a/internal/builders/items/typable.go +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package items - -import ( - "github.com/go-openapi/codescan/internal/ifaces" - oaispec "github.com/go-openapi/spec" -) - -type Typable struct { - items *oaispec.Items - level int - in string -} - -func NewTypable(items *oaispec.Items, level int, in string) Typable { - return Typable{ - items: items, - level: level, - in: in, - } -} - -func (pt Typable) In() string { return pt.in } - -func (pt Typable) Level() int { return pt.level } - -func (pt Typable) Typed(tpe, format string) { - pt.items.Typed(tpe, format) -} - -func (pt Typable) SetRef(ref oaispec.Ref) { - pt.items.Ref = ref -} - -func (pt Typable) Schema() *oaispec.Schema { - return nil -} - -func (pt Typable) Items() ifaces.SwaggerTypable { //nolint:ireturn // polymorphic by design - if pt.items.Items == nil { - pt.items.Items = new(oaispec.Items) - } - pt.items.Type = "array" - return Typable{pt.items.Items, pt.level + 1, pt.in} -} - -func (pt Typable) AddExtension(key string, value any) { - pt.items.AddExtension(key, value) -} - -func (pt Typable) WithEnum(values ...any) { - pt.items.WithEnum(values...) -} - -func (pt Typable) WithEnumDescription(_ string) { - // no -} diff --git a/internal/builders/items/validations.go b/internal/builders/items/validations.go deleted file mode 100644 index 515ff34..0000000 --- a/internal/builders/items/validations.go +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package items - -import ( - "github.com/go-openapi/codescan/internal/parsers" - oaispec "github.com/go-openapi/spec" -) - -type Validations struct { - current *oaispec.Items -} - -func (sv Validations) SetMaximum(val float64, exclusive bool) { - sv.current.Maximum = &val - sv.current.ExclusiveMaximum = exclusive -} - -func (sv Validations) SetMinimum(val float64, exclusive bool) { - sv.current.Minimum = &val - sv.current.ExclusiveMinimum = exclusive -} -func (sv Validations) SetMultipleOf(val float64) { sv.current.MultipleOf = &val } -func (sv Validations) SetMinItems(val int64) { sv.current.MinItems = &val } -func (sv Validations) SetMaxItems(val int64) { sv.current.MaxItems = &val } -func (sv Validations) SetMinLength(val int64) { sv.current.MinLength = &val } -func (sv Validations) SetMaxLength(val int64) { sv.current.MaxLength = &val } -func (sv Validations) SetPattern(val string) { sv.current.Pattern = val } -func (sv Validations) SetUnique(val bool) { sv.current.UniqueItems = val } -func (sv Validations) SetCollectionFormat(val string) { sv.current.CollectionFormat = val } -func (sv Validations) SetEnum(val string) { - sv.current.Enum = parsers.ParseEnum(val, &oaispec.SimpleSchema{Type: sv.current.Type, Format: sv.current.Format}) -} -func (sv Validations) SetDefault(val any) { sv.current.Default = val } -func (sv Validations) SetExample(val any) { sv.current.Example = val } diff --git a/internal/builders/operations/operations.go b/internal/builders/operations/operations.go index 44011ac..e42b92c 100644 --- a/internal/builders/operations/operations.go +++ b/internal/builders/operations/operations.go @@ -7,20 +7,27 @@ import ( "fmt" "strings" + "github.com/go-openapi/codescan/internal/builders/common" "github.com/go-openapi/codescan/internal/parsers" "github.com/go-openapi/codescan/internal/scanner" oaispec "github.com/go-openapi/spec" ) +// Builder constructs OAS v2 operation entries for one +// `swagger:operation` annotation. Embeds *common.Builder for shared +// state (Ctx, ParseBlocks cache, diagnostic sink). Decl is unused — +// operations build off a path annotation, not a declaration — and is +// left nil; the MakeRef / Decl-anchored helpers must not be called. type Builder struct { - ctx *scanner.ScanCtx + *common.Builder + path parsers.ParsedPathContent operations map[string]*oaispec.Operation } func NewBuilder(ctx *scanner.ScanCtx, pth parsers.ParsedPathContent, operations map[string]*oaispec.Operation) *Builder { return &Builder{ - ctx: ctx, + Builder: common.New(ctx, nil), path: pth, operations: operations, } @@ -33,15 +40,8 @@ func (o *Builder) Build(tgt *oaispec.Paths) error { o.path.Method, o.path.ID, &pthObj, o.operations[o.path.ID]) op.Tags = o.path.Tags - sp := parsers.NewYAMLSpecScanner( - func(lines []string) { op.Summary = parsers.JoinDropLast(lines) }, // setTitle - func(lines []string) { op.Description = parsers.JoinDropLast(lines) }, // setDescription - ) - if err := sp.Parse(o.path.Remaining); err != nil { - return fmt.Errorf("operation (%s): %w", op.ID, err) - } - if err := sp.UnmarshalSpec(op.UnmarshalJSON); err != nil { + if err := o.applyBlockToOperation(op); err != nil { return fmt.Errorf("operation (%s): %w", op.ID, err) } @@ -54,22 +54,18 @@ func (o *Builder) Build(tgt *oaispec.Paths) error { return nil } -// assignOrReuse either reuses an existing operation (if the ID matches) -// or assigns op to the slot. -// -// TODO(claude): rewrite without double indirection. -func assignOrReuse(slot **oaispec.Operation, op *oaispec.Operation, id string) *oaispec.Operation { - if *slot != nil && id == (*slot).ID { - return *slot - } - *slot = op - return op -} - func SetPathOperation(method, id string, pthObj *oaispec.PathItem, op *oaispec.Operation) *oaispec.Operation { return setPathOperation(method, id, pthObj, op) } +// setPathOperation lands op on the HTTP-verb slot of pthObj. When the +// slot already holds an operation with the same ID, the existing one +// is kept (so a partially-built operation accumulates work across the +// scanner's two passes). Otherwise op replaces what was there. +// +// Returns the operation now occupying the slot — either the reused +// existing one or op itself. Unrecognised methods leave pthObj +// untouched and return op verbatim. func setPathOperation(method, id string, pthObj *oaispec.PathItem, op *oaispec.Operation) *oaispec.Operation { if op == nil { op = new(oaispec.Operation) @@ -78,19 +74,40 @@ func setPathOperation(method, id string, pthObj *oaispec.PathItem, op *oaispec.O switch strings.ToUpper(method) { case "GET": - op = assignOrReuse(&pthObj.Get, op, id) + if pthObj.Get == nil || pthObj.Get.ID != id { + pthObj.Get = op + } + return pthObj.Get case "POST": - op = assignOrReuse(&pthObj.Post, op, id) + if pthObj.Post == nil || pthObj.Post.ID != id { + pthObj.Post = op + } + return pthObj.Post case "PUT": - op = assignOrReuse(&pthObj.Put, op, id) + if pthObj.Put == nil || pthObj.Put.ID != id { + pthObj.Put = op + } + return pthObj.Put case "PATCH": - op = assignOrReuse(&pthObj.Patch, op, id) + if pthObj.Patch == nil || pthObj.Patch.ID != id { + pthObj.Patch = op + } + return pthObj.Patch case "HEAD": - op = assignOrReuse(&pthObj.Head, op, id) + if pthObj.Head == nil || pthObj.Head.ID != id { + pthObj.Head = op + } + return pthObj.Head case "DELETE": - op = assignOrReuse(&pthObj.Delete, op, id) + if pthObj.Delete == nil || pthObj.Delete.ID != id { + pthObj.Delete = op + } + return pthObj.Delete case "OPTIONS": - op = assignOrReuse(&pthObj.Options, op, id) + if pthObj.Options == nil || pthObj.Options.ID != id { + pthObj.Options = op + } + return pthObj.Options } return op diff --git a/internal/builders/operations/operations_go119_test.go b/internal/builders/operations/operations_go119_test.go index 7a932a4..3dfca4b 100644 --- a/internal/builders/operations/operations_go119_test.go +++ b/internal/builders/operations/operations_go119_test.go @@ -25,11 +25,7 @@ func TestIndentedYAMLBlock(t *testing.T) { var ops spec.Paths for apiPath := range sctx.Operations() { - prs := &Builder{ - ctx: sctx, - path: apiPath, - operations: make(map[string]*spec.Operation), - } + prs := NewBuilder(sctx, apiPath, make(map[string]*spec.Operation)) require.NoError(t, prs.Build(&ops)) } diff --git a/internal/builders/operations/operations_test.go b/internal/builders/operations/operations_test.go index d16281e..f3651d9 100644 --- a/internal/builders/operations/operations_test.go +++ b/internal/builders/operations/operations_test.go @@ -28,11 +28,7 @@ func TestOperationsParser(t *testing.T) { require.NoError(t, err) var ops spec.Paths for apiPath := range sctx.Operations() { - prs := &Builder{ - ctx: sctx, - path: apiPath, - operations: make(map[string]*spec.Operation), - } + prs := NewBuilder(sctx, apiPath, make(map[string]*spec.Operation)) require.NoError(t, prs.Build(&ops)) } diff --git a/internal/builders/operations/walker.go b/internal/builders/operations/walker.go new file mode 100644 index 0000000..f39fdee --- /dev/null +++ b/internal/builders/operations/walker.go @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package operations + +import ( + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/parsers/yaml" + oaispec "github.com/go-openapi/spec" +) + +// applyBlockToOperation parses path.Remaining through grammar and +// writes Summary / Description / YAML body content onto op. +// +// Grammar's lexer already classifies prose into TokenTitle / +// TokenDesc and isolates `---` fenced bodies into TokenOpaqueYaml, +// so the bridge collapses to three direct reads: +// +// 1. op.Summary ← block.Title() — first title paragraph. +// 2. op.Description ← block.Description() — remaining prose. +// 3. The first body from block.YAMLBlocks() is fed through +// yaml.UnmarshalBody → op.UnmarshalJSON. v1 only ever +// consumed one fenced body per operation; we preserve that. +// +// path.Remaining is the *ast.CommentGroup AFTER the swagger:operation +// header line has been stripped by parsers.ParseOperationPathAnnotation, +// so grammar sees it as an UnboundBlock whose Title / Description / +// YAMLBlocks all behave identically to a properly-anchored block. +func (o *Builder) applyBlockToOperation(op *oaispec.Operation) error { + block := grammar.NewParser(o.Ctx.FileSet()).Parse(o.path.Remaining) + + op.Summary = block.Title() + op.Description = block.Description() + + for y := range block.YAMLBlocks() { + return yaml.UnmarshalBody(y.Text, op.UnmarshalJSON) + } + return nil +} diff --git a/internal/builders/parameters/README.md b/internal/builders/parameters/README.md new file mode 100644 index 0000000..eac8478 --- /dev/null +++ b/internal/builders/parameters/README.md @@ -0,0 +1,220 @@ +# `internal/builders/parameters` — maintainers' guide + +Builds OAS v2 parameter entries (`swagger:parameters`) and writes them +onto matching operations. One `Builder` per declaration; one +Walk pass per field doc-comment. + +## Sections + +- [§overview](#overview) — package shape and per-file responsibilities +- [§builder](#builder) — `Builder`, `Build`, the build chain +- [§in-discriminator](#in-discriminator) — how `in:` is read and what it gates +- [§dispatch](#dispatch) — Walker handlers wiring at level-0 and items level +- [§typable](#typable) — `paramTypable`, `SimpleSchemaProbe`, body vs non-body +- [§simple-schema-handoff](#simple-schema-handoff) — when the schema builder runs in SimpleSchema mode +- [§quirks-history](#quirks-history) — resolved quirks (Stream M) + +--- + +## §overview — files and responsibilities + +| File | Contents | +|------|----------| +| `parameters.go` | `Builder`, build chain (`Build` → `buildFromType` → `buildNamedType` / `buildAlias` → `buildFromStruct` → `processParamField` → `buildFromField*`), `buildOption` helper | +| `doc_signals.go` | `fieldDocSignals` + `scanFieldDocSignals`: pre-walk extraction of `in:`, `swagger:ignore`, `swagger:file`, `swagger:strfmt` from a field's doc comment | +| `walker.go` | `applyBlockToField` + `walkParamLevel` + `walkItemsLevel`: the grammar Walker dispatch for per-field validations / extensions | +| `typable.go` | `paramTypable` (the `ifaces.SwaggerTypable` adapter) + `paramValidations` (the `ifaces.OperationValidationBuilder` adapter) + `SimpleSchemaProbe` | +| `errors.go` | `ErrParameters` sentinel | + +The builder embeds `*common.Builder` (Ctx, Decl, PostDeclarations, +diagnostic sink, ParseBlocks cache, MakeRef). See +[../common](../common) for the common-builder rationale. + +## §builder — the build chain + +`Build(operations)` iterates over the declaration's `swagger:parameters +` arguments — one struct can attach to many operations — and +calls `buildFromType` for each. The chain unwraps pointers, dispatches +named types and aliases, and ultimately reaches `buildFromStruct` +which walks the struct fields. + +For each non-embedded exported field, `processParamField` runs the +following ordered steps: + +1. Find the AST field via `resolvers.FindASTField` (no AST → skip). +2. Pre-scan the doc-comment signals via `scanFieldDocSignals` (uses + the `common.Builder` parse cache so `applyBlockToField`'s later + walk hits the same parse result). +3. If `swagger:ignore` is present → skip. +4. Resolve the JSON tag name. If ignored (`json:"-"`) → skip. +5. Pick the `in:` location (default `query`; see [§in-discriminator](#in-discriminator)). +6. Build the field's type into the parameter via `buildFromField` — + the schema builder runs in SimpleSchema mode unless `in==body` + (see [§simple-schema-handoff](#simple-schema-handoff)). +7. Apply `swagger:strfmt ` override when set (collapses the + resolved shape to `string + format`). +8. Walk the doc-comment block to apply description, validations, + `required:`, vendor extensions, and items-level validations + (see [§dispatch](#dispatch)). +9. Force `required: true` for `in: path` (OAS-mandated). +10. Append `x-go-name` extension when the JSON tag differs from the + Go field name. + +The order matters: pre-scanning the doc signals BEFORE building lets +the dispatch pick the right `in:` and forces the SimpleSchema mode +correctly; applying the block AFTER the type build lets validations +override the resolved defaults. + +## §in-discriminator — reading `in:` and what it gates + +`in:` is the OAS v2 location discriminator — +`query | path | header | body | formData` (closed vocabulary, see +`validParamIn` in `doc_signals.go`). It drives three downstream +decisions: + +- **Schema vs SimpleSchema mode**: `in==body` ⇒ full Schema build; + any other `in` ⇒ SimpleSchema build (see [§simple-schema-handoff](#simple-schema-handoff)). +- **File handling**: `in==formData` + `swagger:file` ⇒ shape collapses + to `type: file` (no further build). +- **Path required**: `in==path` ⇒ `required: true` forced after + building, regardless of what the block said. + +### Why line-scan instead of property? + +`scanFieldDocSignals` reads `in:` by **scanning the doc text line by +line**, not by reading it as a grammar Property. Reason: grammar +attaches pre-annotation lines (e.g. `in: formData` preceding a +`swagger:file` annotation) to the annotation block's prose, not to +its property list. A direct line scan picks up `in:` regardless of +which side of an annotation it appears on. The line scan mirrors +v1's `rxIn` regex semantics: + +``` +[Ii]n\p{Zs}*:\p{Zs}*(query|path|header|body|formData)(?:\.)?$ +``` + +Default when `in:` is absent: `query` (OAS v2 convention). + +## §dispatch — Walker dispatch at level-0 and items level + +`applyBlockToField` is the per-field entry point. It runs three +phases on the parsed grammar block: + +1. **Prose** → `param.Description` (with `x-go-enum-desc` lift via + `resolvers.GetEnumDesc`). +2. **Level-0 dispatch** → `walkParamLevel` → `dispatchParamLevel0`, + which wires Walker callbacks via the `handlers` package: + - `Number` → `handlers.Number(valid)` (maximum / minimum / multipleOf) + - `Integer` → `handlers.Integer(valid)` (minLength / maxLength / minItems / maxItems) + - `Bool` → `ComposeBool(UniqueBool, paramRequiredBool)` — splits + `uniqueItems` (parameter-side validation) from `required:` + (writes straight onto `param.Required`) + - `String` → `ComposeString(PatternString, CollectionFormatString)` + — pattern + collectionFormat + - `Raw` → `handlers.Raw(valid, scheme, errSink)` — + `default:` / `example:` / `enum:` as raw bodies. `errSink` + captures the first parse error so malformed default/example + surface as a build error (see `TestMalformed_DefaultInt` / + `TestMalformed_ExampleInt` integration tests) + - `Extension` → `handlers.Extension(param)` — pre-typed YAML + extensions land directly on the parameter +3. **Items-level dispatch** → `walkItemsLevel` for each + `(level, items)` pair returned by `collectParamItemsLevels` + (1-indexed depths matching grammar's `Property.ItemsDepth`). + Named/aliased array types opt out — parity with v1. + +`dispatchParamLevel0` is standalone (not a method) so unit tests can +drive it without constructing a full `Builder`. + +### Why `required:` is parameter-specific + +Schema writes `required:` onto the **enclosing schema's** Required +slice keyed by name (because a struct field's required-ness belongs +to the parent type, not the field). Parameters write `required:` +straight onto `param.Required`. Headers don't carry `required:` at +all — the OAS v2 Header object simply doesn't have the field. + +## §typable — `paramTypable`, body vs non-body + +`paramTypable` adapts a `*spec.Parameter` to `ifaces.SwaggerTypable`. +Two shapes share the type: + +- **Body parameter** (`In == "body"`): `Schema()` returns a real + `*spec.Schema`; type writes go to `param.Schema`, not to the + parameter's SimpleSchema. `AddExtension` lands on the schema. +- **Non-body** (path / query / header / formData): `Schema()` + returns nil; type writes go to the parameter's embedded + `SimpleSchema`. `Items()` builds the items chain on the parameter + side (not on a body schema). + +`Items()` switches on `param.In`: under `body`, returns a +`schema.BodyTypable` that walks down `param.Schema.Items`; under +non-body, returns an `items.NewTypable` chain that walks +`param.Items` directly. The body / non-body split is the same +fundamental gate as everywhere else in this package. + +### `SimpleSchemaProbe` implementation + +`paramTypable` implements the `schema.SimpleSchemaProbe` interface so +the schema builder can validate the SimpleSchema outcome after its +internal build: + +- `SimpleSchemaShape() *oaispec.SimpleSchema` — returns the embedded + SimpleSchema so the exit validator can inspect Type / Format +- `HasRef()` — non-empty `Ref` is a violation (SimpleSchema forbids + `$ref`) +- `ResetForViolation()` — wipes SimpleSchema and Ref back to empty + so the resulting spec is honest about the failed resolution + +## §simple-schema-handoff — SimpleSchema mode delegation + +`buildOption(tpe, typable)` returns the `schema.Build` option matching +the typable's `In()`: + +- `In() == body` ⇒ `schema.WithType(tpe, typable)` — full Schema + build +- otherwise ⇒ `schema.WithSimpleSchema(tpe, typable, typable.In())` — + SimpleSchema build with the `in` carried for keyword gating + +Centralised in `buildOption` so every `buildFromFieldXxx` call site +picks the same shape uniformly. The schema builder enforces the +SimpleSchema vocabulary via `handlers.IsSimpleSchemaKeyword` (see +[../schema/README.md#simple-schema-mode](../schema/README.md#simple-schema-mode) +for the keyword surface). + +### Alias handling — when to `$ref` vs expand + +`buildFieldAlias` carries a gate: + +```go +if typable.In() != inBody || !p.Ctx.RefAliases() { + unaliased := types.Unalias(tpe) + return p.buildFromField(fld, unaliased, typable, seen) +} +``` + +Non-body parameters can't carry `$ref` (SimpleSchema forbids it), so +they always expand the alias. Body parameters honour `RefAliases`: +when set, the alias becomes a `$ref` to its target via +`common.Builder.MakeRef`; otherwise the underlying is expanded. + +## §quirks-history — resolved quirks + +The Stream M merge-readiness pass closed a handful of subtle quirks +in this builder. Recorded for archaeology — none should resurface +under the current dispatch. + +- **`x-go-name` parity**: only emit when the JSON tag differs from + the Go field name. Pre-Stream M this was sometimes emitted on + aliases even when names matched. +- **`required:` on path parameters**: forced post-build, after the + block walk. A user-authored `required: false` on a path parameter + is overridden — OAS v2 requires path params to be required. +- **`swagger:strfmt` collapse**: when set, the field's resolved + shape collapses to `string + format`, clearing `Ref` and `Items`. + This is the single point where strfmt overrides the resolved + build. +- **Pre-walk doc signal cache**: `scanFieldDocSignals` calls + `p.ParseBlocks(afld.Doc)` which hits the `common.Builder` cache; + the later `applyBlockToField` reads the same cache entry. One + parse per comment group. diff --git a/internal/builders/parameters/doc_signals.go b/internal/builders/parameters/doc_signals.go new file mode 100644 index 0000000..c87d6ff --- /dev/null +++ b/internal/builders/parameters/doc_signals.go @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package parameters + +import ( + "go/ast" + "strings" + + "github.com/go-openapi/codescan/internal/parsers/grammar" +) + +// fieldDocSignals carries the per-field doc-comment signals the +// parameter dispatcher reads upstream of the schema build: the +// `in:` location, presence of `swagger:ignore`, presence of +// `swagger:file`, and the `swagger:strfmt` argument when set. +// Replaces the four v1 regex helpers (parsers.ParamLocation / +// parsers.FileParam / parsers.StrfmtName / parsers.Ignored) with +// grammar lookups plus a small `in:` line scan. +type fieldDocSignals struct { + in string + inSet bool + ignored bool + file bool + strfmt string + strfmtSet bool +} + +// Parameter-location constants. The set matches v1's `rxIn` regex +// alternation and OAS v2's parameter-location vocabulary. +const ( + inQuery = "query" + inPath = "path" + inHeader = "header" + inFormData = "formData" +) + +// validParamIn enumerates the closed-vocabulary `in:` values the +// scanner accepts. +// +//nolint:gochecknoglobals // closed-vocabulary lookup table; one allocation, read-only. +var validParamIn = map[string]struct{}{ + inQuery: {}, + inPath: {}, + inHeader: {}, + inBody: {}, + inFormData: {}, +} + +// scanFieldDocSignals reads every signal the parameter dispatcher +// needs out of a pre-parsed block slice and the raw doc text. +// Callers should pass `p.ParseBlocks(afld.Doc)` so the +// common.Builder cache absorbs the parse cost. +// +// Returns the zero value when doc is nil. +// +// # Details +// +// See [§in-discriminator](./README.md#in-discriminator) — why `in:` +// is line-scanned rather than read as a grammar Property. +func scanFieldDocSignals(blocks []grammar.Block, doc *ast.CommentGroup) fieldDocSignals { + var pd fieldDocSignals + if doc == nil { + return pd + } + + for _, b := range blocks { + switch b.AnnotationKind() { //nolint:exhaustive // only ignore/file/strfmt are relevant here + case grammar.AnnIgnore: + pd.ignored = true + case grammar.AnnFile: + pd.file = true + case grammar.AnnStrfmt: + if arg, ok := b.AnnotationArg(); ok && !strings.ContainsAny(arg, " \t") { + pd.strfmt = arg + pd.strfmtSet = true + } + } + } + + if v, ok := scanInLocation(doc.Text()); ok { + pd.in = v + pd.inSet = true + } + + return pd +} + +// scanInLocation finds the first `in: X` (case-insensitive on `in`) +// line in text where X is one of the closed-vocabulary parameter +// locations. Mirrors v1's `rxIn` semantics: +// +// regexp: `[Ii]n\p{Zs}*:\p{Zs}*(query|path|header|body|formData)(?:\.)?$` +func scanInLocation(text string) (string, bool) { + for line := range strings.SplitSeq(text, "\n") { + line = strings.TrimSpace(line) + rest, ok := strings.CutPrefix(line, "in:") + if !ok { + rest, ok = strings.CutPrefix(line, "In:") + } + if !ok { + continue + } + v := strings.TrimSpace(rest) + v = strings.TrimSuffix(v, ".") + if _, ok := validParamIn[v]; ok { + return v, true + } + } + return "", false +} + +// strfmtFromDoc returns the argument of a `swagger:strfmt ` +// annotation present in blocks (the pre-parsed common.Builder cache +// slice for some CommentGroup). Single-word filter mirrors the +// schema package's `findAnnotationArg` rule. +func strfmtFromDoc(blocks []grammar.Block) (string, bool) { + for _, b := range blocks { + if b.AnnotationKind() != grammar.AnnStrfmt { + continue + } + if arg, ok := b.AnnotationArg(); ok && !strings.ContainsAny(arg, " \t") { + return arg, true + } + } + return "", false +} diff --git a/internal/builders/parameters/parameters.go b/internal/builders/parameters/parameters.go index 1ee8be6..b345334 100644 --- a/internal/builders/parameters/parameters.go +++ b/internal/builders/parameters/parameters.go @@ -7,43 +7,48 @@ import ( "fmt" "go/types" + "github.com/go-openapi/codescan/internal/builders/common" "github.com/go-openapi/codescan/internal/builders/resolvers" "github.com/go-openapi/codescan/internal/builders/schema" "github.com/go-openapi/codescan/internal/ifaces" "github.com/go-openapi/codescan/internal/logger" - "github.com/go-openapi/codescan/internal/parsers" "github.com/go-openapi/codescan/internal/scanner" oaispec "github.com/go-openapi/spec" ) const inBody = "body" -type ParameterBuilder struct { - ctx *scanner.ScanCtx - decl *scanner.EntityDecl - postDecls []*scanner.EntityDecl +// Builder constructs OAS v2 parameter entries for one +// `swagger:parameters` declaration and writes them onto the matching +// operations. Embeds *common.Builder for shared state (Ctx, Decl, +// PostDeclarations, diagnostics, ParseBlocks cache). +type Builder struct { + *common.Builder } -func NewBuilder(ctx *scanner.ScanCtx, decl *scanner.EntityDecl) *ParameterBuilder { - return &ParameterBuilder{ - ctx: ctx, - decl: decl, +// NewBuilder constructs an initialized [Builder] bound to +// ctx and decl. The embedded common.Builder owns the diagnostic +// sink, the post-declaration list, and the per-comment-group parse +// cache. +func NewBuilder(ctx *scanner.ScanCtx, decl *scanner.EntityDecl) *Builder { + return &Builder{ + Builder: common.New(ctx, decl), } } -func (p *ParameterBuilder) Build(operations map[string]*oaispec.Operation) error { +func (p *Builder) Build(operations map[string]*oaispec.Operation) error { // check if there is a swagger:parameters tag that is followed by one or more words, // these words are the ids of the operations this parameter struct applies to // once type name is found convert it to a schema, by looking up the schema in the // parameters dictionary that got passed into this parse method - for _, opid := range p.decl.OperationIDs() { + for _, opid := range p.Decl.OperationIDs() { operation, ok := operations[opid] if !ok { operation = new(oaispec.Operation) operations[opid] = operation operation.ID = opid } - logger.DebugLogf(p.ctx.Debug(), "building parameters for: %s", opid) + logger.DebugLogf(p.Ctx.Debug(), "building parameters for: %s", opid) // analyze struct body for fields etc // each exported struct field: @@ -52,7 +57,7 @@ func (p *ParameterBuilder) Build(operations map[string]*oaispec.Operation) error // * has to document the validations that apply for the type and the field // * when the struct field points to a model it becomes a ref: #/definitions/ModelName // * comments that aren't tags is used as the description - if err := p.buildFromType(p.decl.ObjType(), operation, make(map[string]oaispec.Parameter)); err != nil { + if err := p.buildFromType(p.Decl.ObjType(), operation, make(map[string]oaispec.Parameter)); err != nil { return err } } @@ -60,25 +65,21 @@ func (p *ParameterBuilder) Build(operations map[string]*oaispec.Operation) error return nil } -func (p *ParameterBuilder) PostDeclarations() []*scanner.EntityDecl { - return p.postDecls -} - -func (p *ParameterBuilder) buildFromType(otpe types.Type, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { +func (p *Builder) buildFromType(otpe types.Type, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { switch tpe := otpe.(type) { case *types.Pointer: return p.buildFromType(tpe.Elem(), op, seen) case *types.Named: return p.buildNamedType(tpe, op, seen) case *types.Alias: - logger.DebugLogf(p.ctx.Debug(), "alias(parameters.buildFromType): got alias %v to %v", tpe, tpe.Rhs()) + logger.DebugLogf(p.Ctx.Debug(), "alias(parameters.buildFromType): got alias %v to %v", tpe, tpe.Rhs()) return p.buildAlias(tpe, op, seen) default: return fmt.Errorf("unhandled type (%T): %s: %w", otpe, tpe.String(), ErrParameters) } } -func (p *ParameterBuilder) buildNamedType(tpe *types.Named, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { +func (p *Builder) buildNamedType(tpe *types.Named, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { o := tpe.Obj() if resolvers.IsAny(o) || resolvers.IsStdError(o) { return fmt.Errorf("%s type not supported in the context of a parameters section definition: %w", o.Name(), ErrParameters) @@ -87,18 +88,18 @@ func (p *ParameterBuilder) buildNamedType(tpe *types.Named, op *oaispec.Operatio switch stpe := o.Type().Underlying().(type) { case *types.Struct: - logger.DebugLogf(p.ctx.Debug(), "build from named type %s: %T", o.Name(), tpe) - if decl, found := p.ctx.DeclForType(o.Type()); found { + logger.DebugLogf(p.Ctx.Debug(), "build from named type %s: %T", o.Name(), tpe) + if decl, found := p.Ctx.DeclForType(o.Type()); found { return p.buildFromStruct(decl, stpe, op, seen) } - return p.buildFromStruct(p.decl, stpe, op, seen) + return p.buildFromStruct(p.Decl, stpe, op, seen) default: return fmt.Errorf("unhandled type (%T): %s: %w", stpe, o.Type().Underlying().String(), ErrParameters) } } -func (p *ParameterBuilder) buildAlias(tpe *types.Alias, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { +func (p *Builder) buildAlias(tpe *types.Alias, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { o := tpe.Obj() if resolvers.IsAny(o) || resolvers.IsStdError(o) { return fmt.Errorf("%s type not supported in the context of a parameters section definition: %w", o.Name(), ErrParameters) @@ -109,15 +110,15 @@ func (p *ParameterBuilder) buildAlias(tpe *types.Alias, op *oaispec.Operation, s rhs := tpe.Rhs() // If transparent aliases are enabled, use the underlying type directly without creating a definition - if p.ctx.TransparentAliases() { + if p.Ctx.TransparentAliases() { return p.buildFromType(rhs, op, seen) } - decl, ok := p.ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, ok := p.Ctx.FindModel(o.Pkg().Path(), o.Name()) if !ok { return fmt.Errorf("can't find source file for aliased type: %v -> %v: %w", tpe, rhs, ErrParameters) } - p.postDecls = append(p.postDecls, decl) // mark the left-hand side as discovered + p.AppendPostDecl(decl) // mark the left-hand side as discovered switch rtpe := rhs.(type) { // load declaration for named unaliased type @@ -126,28 +127,28 @@ func (p *ParameterBuilder) buildAlias(tpe *types.Alias, op *oaispec.Operation, s if o.Pkg() == nil { break // builtin } - decl, found := p.ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, found := p.Ctx.FindModel(o.Pkg().Path(), o.Name()) if !found { return fmt.Errorf("can't find source file for target type of alias: %v -> %v: %w", tpe, rtpe, ErrParameters) } - p.postDecls = append(p.postDecls, decl) + p.AppendPostDecl(decl) case *types.Alias: o := rtpe.Obj() if o.Pkg() == nil { break // builtin } - decl, found := p.ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, found := p.Ctx.FindModel(o.Pkg().Path(), o.Name()) if !found { return fmt.Errorf("can't find source file for target type of alias: %v -> %v: %w", tpe, rtpe, ErrParameters) } - p.postDecls = append(p.postDecls, decl) + p.AppendPostDecl(decl) } return p.buildFromType(rhs, op, seen) } -func (p *ParameterBuilder) buildFromField(fld *types.Var, tpe types.Type, typable ifaces.SwaggerTypable, seen map[string]oaispec.Parameter) error { - logger.DebugLogf(p.ctx.Debug(), "build from field %s: %T", fld.Name(), tpe) +func (p *Builder) buildFromField(fld *types.Var, tpe types.Type, typable ifaces.SwaggerTypable, seen map[string]oaispec.Parameter) error { + logger.DebugLogf(p.Ctx.Debug(), "build from field %s: %T", fld.Name(), tpe) switch ftpe := tpe.(type) { case *types.Basic: @@ -167,49 +168,69 @@ func (p *ParameterBuilder) buildFromField(fld *types.Var, tpe types.Type, typabl case *types.Named: return p.buildNamedField(ftpe, typable) case *types.Alias: - logger.DebugLogf(p.ctx.Debug(), "alias(parameters.buildFromField): got alias %v to %v", ftpe, ftpe.Rhs()) + logger.DebugLogf(p.Ctx.Debug(), "alias(parameters.buildFromField): got alias %v to %v", ftpe, ftpe.Rhs()) return p.buildFieldAlias(ftpe, typable, fld, seen) default: return fmt.Errorf("unknown type for %s: %T: %w", fld.String(), fld.Type(), ErrParameters) } } -func (p *ParameterBuilder) buildFromFieldStruct(tpe *types.Struct, typable ifaces.SwaggerTypable) error { - sb := schema.NewBuilder(p.ctx, p.decl) - if err := sb.BuildFromType(tpe, typable); err != nil { +func (p *Builder) buildFromFieldStruct(tpe *types.Struct, typable ifaces.SwaggerTypable) error { + sb := schema.NewBuilder(p.Ctx, p.Decl) + if err := sb.Build(schema.OptionFor(tpe, typable)); err != nil { return err } - p.postDecls = append(p.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + p.AppendPostDecl(d) + } return nil } -func (p *ParameterBuilder) buildFromFieldMap(ftpe *types.Map, typable ifaces.SwaggerTypable) error { +func (p *Builder) buildFromFieldMap(ftpe *types.Map, typable ifaces.SwaggerTypable) error { + // Map fields are only legal under in=body — paramTypable.Schema() + // returns nil for non-body. No SimpleSchema variant needed. sch := new(oaispec.Schema) typable.Schema().Typed("object", "").AdditionalProperties = &oaispec.SchemaOrBool{ Schema: sch, } - sb := schema.NewBuilder(p.ctx, p.decl) - if err := sb.BuildFromType(ftpe.Elem(), schema.NewTypable(sch, typable.Level()+1, p.ctx.SkipExtensions())); err != nil { + sb := schema.NewBuilder(p.Ctx, p.Decl) + if err := sb.Build(schema.WithType( + ftpe.Elem(), + schema.NewTypable(sch, typable.Level()+1, p.Ctx.SkipExtensions())), + ); err != nil { return err } + // Propagate the sub-builder's PostDeclarations so a model + // discovered only through the map's value type (no + // swagger:model annotation, no other reference site) makes it + // into the spec's definitions section. Every sibling + // buildFromFieldXxx method does the same; this loop went + // missing in M2.5's schema-builder factor-out — see the + // parameters-map-postdecl fixture. + for _, d := range sb.PostDeclarations() { + p.AppendPostDecl(d) + } + return nil } -func (p *ParameterBuilder) buildFromFieldInterface(tpe *types.Interface, typable ifaces.SwaggerTypable) error { - sb := schema.NewBuilder(p.ctx, p.decl) - if err := sb.BuildFromType(tpe, typable); err != nil { +func (p *Builder) buildFromFieldInterface(tpe *types.Interface, typable ifaces.SwaggerTypable) error { + sb := schema.NewBuilder(p.Ctx, p.Decl) + if err := sb.Build(schema.OptionFor(tpe, typable)); err != nil { return err } - p.postDecls = append(p.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + p.AppendPostDecl(d) + } return nil } -func (p *ParameterBuilder) buildNamedField(ftpe *types.Named, typable ifaces.SwaggerTypable) error { +func (p *Builder) buildNamedField(ftpe *types.Named, typable ifaces.SwaggerTypable) error { o := ftpe.Obj() if resolvers.IsAny(o) { // e.g. Field interface{} or Field any @@ -220,7 +241,7 @@ func (p *ParameterBuilder) buildNamedField(ftpe *types.Named, typable ifaces.Swa } resolvers.MustNotBeABuiltinType(o) - decl, found := p.ctx.DeclForType(o.Type()) + decl, found := p.Ctx.DeclForType(o.Type()) if !found { return fmt.Errorf("unable to find package and source file for: %s: %w", ftpe.String(), ErrParameters) } @@ -230,23 +251,25 @@ func (p *ParameterBuilder) buildNamedField(ftpe *types.Named, typable ifaces.Swa return nil } - if sfnm, isf := parsers.StrfmtName(decl.Comments); isf { + if sfnm, isf := strfmtFromDoc(p.ParseBlocks(decl.Comments)); isf { typable.Typed("string", sfnm) return nil } - sb := schema.NewBuilder(p.ctx, decl) + sb := schema.NewBuilder(p.Ctx, decl) sb.InferNames() - if err := sb.BuildFromType(decl.ObjType(), typable); err != nil { + if err := sb.Build(schema.OptionFor(decl.ObjType(), typable)); err != nil { return err } - p.postDecls = append(p.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + p.AppendPostDecl(d) + } return nil } -func (p *ParameterBuilder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypable, fld *types.Var, seen map[string]oaispec.Parameter) error { +func (p *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypable, fld *types.Var, seen map[string]oaispec.Parameter) error { o := tpe.Obj() if resolvers.IsAny(o) { // e.g. Field interface{} or Field any @@ -263,22 +286,24 @@ func (p *ParameterBuilder) buildFieldAlias(tpe *types.Alias, typable ifaces.Swag rhs := tpe.Rhs() // If transparent aliases are enabled, use the underlying type directly without creating a definition - if p.ctx.TransparentAliases() { - sb := schema.NewBuilder(p.ctx, p.decl) - if err := sb.BuildFromType(rhs, typable); err != nil { + if p.Ctx.TransparentAliases() { + sb := schema.NewBuilder(p.Ctx, p.Decl) + if err := sb.Build(schema.OptionFor(rhs, typable)); err != nil { return err } - p.postDecls = append(p.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + p.AppendPostDecl(d) + } return nil } - decl, ok := p.ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, ok := p.Ctx.FindModel(o.Pkg().Path(), o.Name()) if !ok { return fmt.Errorf("can't find source file for aliased type: %v -> %v: %w", tpe, rhs, ErrParameters) } - p.postDecls = append(p.postDecls, decl) // mark the left-hand side as discovered + p.AppendPostDecl(decl) // mark the left-hand side as discovered - if typable.In() != inBody || !p.ctx.RefAliases() { + if typable.In() != inBody || !p.Ctx.RefAliases() { // if ref option is disabled, and always for non-body parameters: just expand the alias unaliased := types.Unalias(tpe) return p.buildFromField(fld, unaliased, typable, seen) @@ -293,31 +318,31 @@ func (p *ParameterBuilder) buildFieldAlias(tpe *types.Alias, typable ifaces.Swag break // builtin } - decl, found := p.ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, found := p.Ctx.FindModel(o.Pkg().Path(), o.Name()) if !found { return fmt.Errorf("can't find source file for target type of alias: %v -> %v: %w", tpe, rtpe, ErrParameters) } - return p.makeRef(decl, typable) + return p.MakeRef(decl, typable) case *types.Alias: o := rtpe.Obj() if o.Pkg() == nil { break // builtin } - decl, found := p.ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, found := p.Ctx.FindModel(o.Pkg().Path(), o.Name()) if !found { return fmt.Errorf("can't find source file for target type of alias: %v -> %v: %w", tpe, rtpe, ErrParameters) } - return p.makeRef(decl, typable) + return p.MakeRef(decl, typable) } // anonymous type: just expand it return p.buildFromField(fld, rhs, typable, seen) } -func (p *ParameterBuilder) buildFromStruct(decl *scanner.EntityDecl, tpe *types.Struct, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { +func (p *Builder) buildFromStruct(decl *scanner.EntityDecl, tpe *types.Struct, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { numFields := tpe.NumFields() if numFields == 0 { @@ -359,19 +384,21 @@ func (p *ParameterBuilder) buildFromStruct(decl *scanner.EntityDecl, tpe *types. // processParamField processes a single non-embedded struct field for parameter building. // Returns the parameter name if the field was processed, or "" if it was skipped. -func (p *ParameterBuilder) processParamField(fld *types.Var, decl *scanner.EntityDecl, seen map[string]oaispec.Parameter) (string, error) { +func (p *Builder) processParamField(fld *types.Var, decl *scanner.EntityDecl, seen map[string]oaispec.Parameter) (string, error) { if !fld.Exported() { - logger.DebugLogf(p.ctx.Debug(), "skipping field %s because it's not exported", fld.Name()) + logger.DebugLogf(p.Ctx.Debug(), "skipping field %s because it's not exported", fld.Name()) return "", nil } afld := resolvers.FindASTField(decl.File, fld.Pos()) if afld == nil { - logger.DebugLogf(p.ctx.Debug(), "can't find source associated with %s", fld.String()) + logger.DebugLogf(p.Ctx.Debug(), "can't find source associated with %s", fld.String()) return "", nil } - if parsers.Ignored(afld.Doc) { + signals := scanFieldDocSignals(p.ParseBlocks(afld.Doc), afld.Doc) + + if signals.ignored { return "", nil } @@ -384,50 +411,30 @@ func (p *ParameterBuilder) processParamField(fld *types.Var, decl *scanner.Entit } in := "query" - // scan for param location first, this changes some behavior down the line - if afld.Doc != nil { - inOverride, ok := parsers.ParamLocation(afld.Doc) - if ok { - in = inOverride - } + if signals.inSet { + in = signals.in } ps := seen[name] ps.In = in - var pty ifaces.SwaggerTypable = paramTypable{&ps, p.ctx.SkipExtensions()} + var pty ifaces.SwaggerTypable = paramTypable{&ps, p.Ctx.SkipExtensions()} if in == inBody { - pty = schema.NewTypable(pty.Schema(), 0, p.ctx.SkipExtensions()) + pty = schema.NewTypable(pty.Schema(), 0, p.Ctx.SkipExtensions()) } - if in == "formData" && afld.Doc != nil && parsers.FileParam(afld.Doc) { + if in == "formData" && signals.file { pty.Typed("file", "") } else if err := p.buildFromField(fld, fld.Type(), pty, seen); err != nil { return "", err } - if strfmtName, ok := parsers.StrfmtName(afld.Doc); ok { - ps.Typed("string", strfmtName) + if signals.strfmtSet { + ps.Typed("string", signals.strfmt) ps.Ref = oaispec.Ref{} ps.Items = nil } - taggers, err := setupParamTaggers(&ps, name, afld, p.ctx.SkipExtensions(), p.ctx.Debug()) - if err != nil { - return "", err - } - - sp := parsers.NewSectionedParser( - parsers.WithSetDescription(func(lines []string) { - ps.Description = parsers.JoinDropLast(lines) - enumDesc := parsers.GetEnumDesc(ps.Extensions) - if enumDesc != "" { - ps.Description += "\n" + enumDesc - } - }), - parsers.WithTaggers(taggers...), - ) - - if err := sp.Parse(afld.Doc); err != nil { + if err := p.applyBlockToField(afld, &ps); err != nil { return "", err } if ps.In == "path" { @@ -439,30 +446,10 @@ func (p *ParameterBuilder) processParamField(fld *types.Var, decl *scanner.Entit } if name != fld.Name() { - resolvers.AddExtension(&ps.VendorExtensible, "x-go-name", fld.Name(), p.ctx.SkipExtensions()) + resolvers.AddExtension(&ps.VendorExtensible, "x-go-name", fld.Name(), p.Ctx.SkipExtensions()) } seen[name] = ps return name, nil } -func (p *ParameterBuilder) makeRef(decl *scanner.EntityDecl, prop ifaces.SwaggerTypable) error { - nm, _ := decl.Names() - ref, err := oaispec.NewRef("#/definitions/" + nm) - if err != nil { - return err - } - - prop.SetRef(ref) - p.postDecls = append(p.postDecls, decl) // mark the $ref target as discovered - - return nil -} - -func spExtensionsSetter(ps *oaispec.Parameter, skipExt bool) func(*oaispec.Extensions) { - return func(exts *oaispec.Extensions) { - for name, value := range *exts { - resolvers.AddExtension(&ps.VendorExtensible, name, value, skipExt) - } - } -} diff --git a/internal/builders/parameters/parameters_test.go b/internal/builders/parameters/parameters_test.go index 1e4fb95..31af113 100644 --- a/internal/builders/parameters/parameters_test.go +++ b/internal/builders/parameters/parameters_test.go @@ -11,7 +11,7 @@ import ( "github.com/go-openapi/testify/v2/assert" "github.com/go-openapi/testify/v2/require" - "github.com/go-openapi/spec" + oaispec "github.com/go-openapi/spec" ) const ( @@ -45,7 +45,7 @@ func getParameter(sctx *scanner.ScanCtx, nm string) *scanner.EntityDecl { func TestScanFileParam(t *testing.T) { sctx := scantest.LoadClassificationPkgsCtx(t) - operations := make(map[string]*spec.Operation) + operations := make(map[string]*oaispec.Operation) paramNames := []string{ "OrderBodyParams", "MultipleOrderParams", "ComplexerOneParams", "NoParams", "NoParamsAlias", "MyFileParams", "MyFuncFileParams", "EmbeddedFileParams", @@ -54,10 +54,7 @@ func TestScanFileParam(t *testing.T) { td := getParameter(sctx, rn) require.NotNil(t, td) - prs := &ParameterBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(operations)) } assert.Len(t, operations, 10) @@ -97,9 +94,65 @@ func TestScanFileParam(t *testing.T) { scantest.CompareOrDumpJSON(t, operations, "classification_params_file.json") } +// TestParamsParser_OptionVariants captures (SkipExtensions, +// DescWithRef) option permutations on the classification operations +// corpus into separately-named goldens. The same set of parameter +// names as TestParamsParser is built per combination, exercising the +// $ref'd-field shape on each option pair: +// +// - Default (false/false): description-only $ref'd fields render +// bare (matches v1 strict). +// - DescWithRef=true: description-only $ref'd fields render as +// single-arm allOf preserving the description. +// - SkipExtensions=true: scanner-derived x-go-name suppressed. +// +// The matching CompareOrDumpJSON files diverge wherever a $ref'd +// field carries field-level decoration. Validation overrides keep +// the allOf wrap regardless. +func TestParamsParser_OptionVariants(t *testing.T) { + cases := []struct { + name string + skipExt bool + descRef bool + goldenFile string + }{ + {"default", false, false, "classification_params.json"}, + {"DescWithRef", false, true, "classification_params_descwithref.json"}, + {"SkipExt", true, false, "classification_params_skipext.json"}, + {"SkipExt+DescWithRef", true, true, "classification_params_skipext_descwithref.json"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sctx, err := scanner.NewScanCtx(&scanner.Options{ + Packages: []string{ + "./goparsing/classification", + "./goparsing/classification/models", + "./goparsing/classification/operations", + }, + WorkDir: scantest.FixturesDir(), + SkipExtensions: tc.skipExt, + DescWithRef: tc.descRef, + }) + require.NoError(t, err) + operations := make(map[string]*oaispec.Operation) + paramNames := []string{ + "OrderBodyParams", "MultipleOrderParams", "ComplexerOneParams", "NoParams", + "NoParamsAlias", "MyFileParams", "MyFuncFileParams", "EmbeddedFileParams", + } + for _, rn := range paramNames { + td := getParameter(sctx, rn) + require.NotNil(t, td) + prs := NewBuilder(sctx, td) + require.NoError(t, prs.Build(operations)) + } + scantest.CompareOrDumpJSON(t, operations, tc.goldenFile) + }) + } +} + func TestParamsParser(t *testing.T) { sctx := scantest.LoadClassificationPkgsCtx(t) - operations := make(map[string]*spec.Operation) + operations := make(map[string]*oaispec.Operation) paramNames := []string{ "OrderBodyParams", "MultipleOrderParams", "ComplexerOneParams", "NoParams", "NoParamsAlias", "MyFileParams", "MyFuncFileParams", "EmbeddedFileParams", @@ -107,10 +160,7 @@ func TestParamsParser(t *testing.T) { for _, rn := range paramNames { td := getParameter(sctx, rn) - prs := &ParameterBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(operations)) } @@ -154,7 +204,7 @@ func TestParamsParser(t *testing.T) { scantest.CompareOrDumpJSON(t, operations, "classification_params.json") } -func assertYetAnotherOperationParams(t *testing.T, operations map[string]*spec.Operation) { +func assertYetAnotherOperationParams(t *testing.T, operations map[string]*oaispec.Operation) { t.Helper() cr, okParam := operations["yetAnotherOperation"] require.TrueT(t, okParam) @@ -191,7 +241,7 @@ func assertYetAnotherOperationParams(t *testing.T, operations map[string]*spec.O } } -func assertSomeOperationParams(t *testing.T, operations map[string]*spec.Operation) { +func assertSomeOperationParams(t *testing.T, operations map[string]*oaispec.Operation) { t.Helper() op, okParam := operations["someOperation"] assert.TrueT(t, okParam) @@ -410,7 +460,7 @@ func assertSomeOperationParams(t *testing.T, operations map[string]*spec.Operati } } -func assertAnotherOperationParamOrder(t *testing.T, operations map[string]*spec.Operation) { +func assertAnotherOperationParamOrder(t *testing.T, operations map[string]*oaispec.Operation) { t.Helper() order, ok := operations["anotherOperation"] @@ -451,7 +501,7 @@ func assertAnotherOperationParamOrder(t *testing.T, operations map[string]*spec. } } -func assertSomeAliasOperationParams(t *testing.T, operations map[string]*spec.Operation) { +func assertSomeAliasOperationParams(t *testing.T, operations map[string]*oaispec.Operation) { t.Helper() aliasOp, ok := operations["someAliasOperation"] @@ -498,18 +548,15 @@ func TestParamsParser_TransparentAliases(t *testing.T) { require.NotNil(t, td) // Build the operation map from the transparent alias fixtures. - operations := make(map[string]*spec.Operation) - prs := &ParameterBuilder{ - ctx: sctx, - decl: td, - } + operations := make(map[string]*oaispec.Operation) + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(operations)) op, ok := operations["transparentAlias"] require.TrueT(t, ok) require.Len(t, op.Parameters, 2) - var bodyParam, queryParam *spec.Parameter + var bodyParam, queryParam *oaispec.Parameter for i := range op.Parameters { p := &op.Parameters[i] switch p.In { @@ -543,12 +590,9 @@ func TestParamsParser_TransparentAliases(t *testing.T) { func TestParameterParser_Issue2007(t *testing.T) { sctx := scantest.LoadClassificationPkgsCtx(t) - operations := make(map[string]*spec.Operation) + operations := make(map[string]*oaispec.Operation) td := getParameter(sctx, "SetConfiguration") - prs := &ParameterBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(operations)) op := operations["getConfiguration"] @@ -567,12 +611,9 @@ func TestParameterParser_Issue2007(t *testing.T) { func TestParameterParser_Issue2011(t *testing.T) { sctx := scantest.LoadClassificationPkgsCtx(t) - operations := make(map[string]*spec.Operation) + operations := make(map[string]*oaispec.Operation) td := getParameter(sctx, "NumPlates") - prs := &ParameterBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(operations)) op := operations["putNumPlate"] @@ -586,12 +627,9 @@ func TestParameterParser_Issue2011(t *testing.T) { func TestGo118ParameterParser_Issue2011(t *testing.T) { sctx := scantest.LoadGo118ClassificationPkgsCtx(t) - operations := make(map[string]*spec.Operation) + operations := make(map[string]*oaispec.Operation) td := getParameter(sctx, "NumPlates") - prs := &ParameterBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(operations)) op := operations["putNumPlate"] diff --git a/internal/builders/parameters/taggers.go b/internal/builders/parameters/taggers.go deleted file mode 100644 index 273daa7..0000000 --- a/internal/builders/parameters/taggers.go +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parameters - -import ( - "go/ast" - "slices" - - "github.com/go-openapi/codescan/internal/builders/items" - "github.com/go-openapi/codescan/internal/parsers" - oaispec "github.com/go-openapi/spec" -) - -func setupParamTaggers(param *oaispec.Parameter, name string, afld *ast.Field, skipExt, debug bool) ([]parsers.TagParser, error) { - // Parameter-level $ref (e.g. {$ref: "#/parameters/X"}) is not emitted by - // the scanner today — named struct fields become body params with a - // schema-level ref (ps.Schema.Ref), never ps.Ref. To support - // operation-level parameter refs, branch here on - // `param.Ref.String() != ""` and dispatch to a narrower tagger set - // (in, required, extensions only). - return setupInlineParamTaggers(param, name, afld, skipExt, debug) -} - -// baseInlineParamTaggers configures taggers for a fully-defined inline parameter. -func baseInlineParamTaggers(param *oaispec.Parameter, skipExt, debug bool) []parsers.TagParser { - return []parsers.TagParser{ - parsers.NewSingleLineTagParser("in", parsers.NewMatchParamIn(param)), - parsers.NewSingleLineTagParser("maximum", parsers.NewSetMaximum(paramValidations{param})), - parsers.NewSingleLineTagParser("minimum", parsers.NewSetMinimum(paramValidations{param})), - parsers.NewSingleLineTagParser("multipleOf", parsers.NewSetMultipleOf(paramValidations{param})), - parsers.NewSingleLineTagParser("minLength", parsers.NewSetMinLength(paramValidations{param})), - parsers.NewSingleLineTagParser("maxLength", parsers.NewSetMaxLength(paramValidations{param})), - parsers.NewSingleLineTagParser("pattern", parsers.NewSetPattern(paramValidations{param})), - parsers.NewSingleLineTagParser("collectionFormat", parsers.NewSetCollectionFormat(paramValidations{param})), - parsers.NewSingleLineTagParser("minItems", parsers.NewSetMinItems(paramValidations{param})), - parsers.NewSingleLineTagParser("maxItems", parsers.NewSetMaxItems(paramValidations{param})), - parsers.NewSingleLineTagParser("unique", parsers.NewSetUnique(paramValidations{param})), - parsers.NewSingleLineTagParser("enum", parsers.NewSetEnum(paramValidations{param})), - parsers.NewSingleLineTagParser("default", parsers.NewSetDefault(¶m.SimpleSchema, paramValidations{param})), - parsers.NewSingleLineTagParser("example", parsers.NewSetExample(¶m.SimpleSchema, paramValidations{param})), - parsers.NewSingleLineTagParser("required", parsers.NewSetRequiredParam(param)), - parsers.NewMultiLineTagParser("Extensions", parsers.NewSetExtensions(spExtensionsSetter(param, skipExt), debug), true), - } -} - -func setupInlineParamTaggers(param *oaispec.Parameter, name string, afld *ast.Field, skipExt, debug bool) ([]parsers.TagParser, error) { - // TODO(claude): don't understand why we need this step. Isn't it handled by the recursion already? - if ftped, ok := afld.Type.(*ast.ArrayType); ok { - taggers, err := items.ParseArrayTypes([]parsers.TagParser{}, name, ftped.Elt, param.Items, 0) - if err != nil { - return nil, err - } - return slices.Concat(taggers, baseInlineParamTaggers(param, skipExt, debug)), nil - } - - return baseInlineParamTaggers(param, skipExt, debug), nil -} diff --git a/internal/builders/parameters/typable.go b/internal/builders/parameters/typable.go index cb27df5..a2ef893 100644 --- a/internal/builders/parameters/typable.go +++ b/internal/builders/parameters/typable.go @@ -4,10 +4,10 @@ package parameters import ( - "github.com/go-openapi/codescan/internal/builders/items" + "github.com/go-openapi/codescan/internal/builders/resolvers" "github.com/go-openapi/codescan/internal/builders/schema" + "github.com/go-openapi/codescan/internal/builders/validations" "github.com/go-openapi/codescan/internal/ifaces" - "github.com/go-openapi/codescan/internal/parsers" oaispec "github.com/go-openapi/spec" ) @@ -41,7 +41,7 @@ func (pt paramTypable) Items() ifaces.SwaggerTypable { //nolint:ireturn // polym pt.param.Items = new(oaispec.Items) } pt.param.Type = "array" - return items.NewTypable(pt.param.Items, 1, pt.param.In) + return resolvers.NewItemsTypable(pt.param.Items, 1, pt.param.In) } func (pt paramTypable) Schema() *oaispec.Schema { @@ -70,7 +70,26 @@ func (pt paramTypable) WithEnumDescription(desc string) { if desc == "" { return } - pt.param.AddExtension(parsers.EnumDescExtension(), desc) + pt.param.AddExtension(resolvers.ExtEnumDesc, desc) +} + +// SimpleSchemaShape satisfies schema.SimpleSchemaProbe. See +// [§typable](./README.md#typable). +func (pt paramTypable) SimpleSchemaShape() *oaispec.SimpleSchema { + return &pt.param.SimpleSchema +} + +// HasRef satisfies schema.SimpleSchemaProbe. SimpleSchema forbids +// $ref; a non-empty Ref signals a violation. +func (pt paramTypable) HasRef() bool { + return pt.param.Ref.String() != "" +} + +// ResetForViolation satisfies schema.SimpleSchemaProbe. Wipes +// SimpleSchema and Ref back to empty. +func (pt paramTypable) ResetForViolation() { + pt.param.SimpleSchema = oaispec.SimpleSchema{} + pt.param.Ref = oaispec.Ref{} } type paramValidations struct { @@ -95,7 +114,7 @@ func (sv paramValidations) SetPattern(val string) { sv.current.Pattern func (sv paramValidations) SetUnique(val bool) { sv.current.UniqueItems = val } func (sv paramValidations) SetCollectionFormat(val string) { sv.current.CollectionFormat = val } func (sv paramValidations) SetEnum(val string) { - sv.current.Enum = parsers.ParseEnum(val, &oaispec.SimpleSchema{Type: sv.current.Type, Format: sv.current.Format}) + sv.current.Enum = validations.ParseEnumValues(val, sv.current.Type, sv.current.Format) } func (sv paramValidations) SetDefault(val any) { sv.current.Default = val } func (sv paramValidations) SetExample(val any) { sv.current.Example = val } diff --git a/internal/builders/parameters/walker.go b/internal/builders/parameters/walker.go new file mode 100644 index 0000000..f73a991 --- /dev/null +++ b/internal/builders/parameters/walker.go @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package parameters + +import ( + "go/ast" + + "github.com/go-openapi/codescan/internal/builders/handlers" + "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/parsers/grammar" + oaispec "github.com/go-openapi/spec" +) + +// paramItemsLevelTarget pairs a 1-indexed nesting depth (matching +// grammar.Property.ItemsDepth) with the *oaispec.Items target into +// which validations at that depth must be written. +type paramItemsLevelTarget struct { + level int + items *oaispec.Items +} + +// collectParamItemsLevels walks the AST array layers of a parameter +// field type and returns the (level, items) pairs reachable from +// the param's items chain. Mirrors items.ParseArrayTypes' shape on +// the grammar path; the schema builder handles the Schema-side +// equivalent internally. +// +// Starting level is 1 — `items.maximum:` has ItemsDepth=1 in the +// grammar lexer. Named/aliased array types opt out (parity with +// v1's tagger pipeline). +func collectParamItemsLevels(expr ast.Expr, it *oaispec.Items, level int) []paramItemsLevelTarget { + if it == nil { + return nil + } + + here := paramItemsLevelTarget{level: level, items: it} + + switch e := expr.(type) { + case *ast.ArrayType: + rest := collectParamItemsLevels(e.Elt, it.Items, level+1) + out := make([]paramItemsLevelTarget, 0, 1+len(rest)) + return append(append(out, here), rest...) + + case *ast.Ident: + rest := collectParamItemsLevels(expr, it.Items, level+1) + if e.Obj == nil { + out := make([]paramItemsLevelTarget, 0, 1+len(rest)) + return append(append(out, here), rest...) + } + return rest + + case *ast.StarExpr: + return collectParamItemsLevels(e.X, it, level) + + case *ast.SelectorExpr: + return []paramItemsLevelTarget{here} + + case *ast.StructType, *ast.InterfaceType, *ast.MapType: + return nil + + default: + return nil + } +} + +// applyBlockToField parses afld.Doc through grammar and dispatches +// description, level-0 validations, required flag, vendor extensions, +// and items-level validations into param. +// +// # Details +// +// See [§dispatch](./README.md#dispatch) — the three-phase Walker +// dispatch, why `in:` is resolved upstream, and the items-level +// chain. +func (p *Builder) applyBlockToField(afld *ast.Field, param *oaispec.Parameter) error { + block := p.ParseBlock(afld.Doc) + + param.Description = block.Prose() + if enumDesc := resolvers.GetEnumDesc(param.Extensions); enumDesc != "" { + if param.Description != "" { + param.Description += "\n" + } + param.Description += enumDesc + } + + if err := p.walkParamLevel(block, param); err != nil { + return err + } + + if arrayType, ok := afld.Type.(*ast.ArrayType); ok { + for _, tgt := range collectParamItemsLevels(arrayType.Elt, param.Items, 1) { + p.walkItemsLevel(block, tgt.items, tgt.level) + } + } + return nil +} + +// walkParamLevel is the builder's per-block entry point. Delegates +// to dispatchParamLevel0 with the builder-bound diagnostic +// forwarder. +func (p *Builder) walkParamLevel(block grammar.Block, param *oaispec.Parameter) error { + return dispatchParamLevel0(block, param, p.RecordDiagnostic) +} + +// dispatchParamLevel0 routes every level-0 Property in block onto +// param via the grammar Walker. Standalone (not a method) so unit +// tests can drive it without building a full Builder. +// +// firstErr captures the first parse error from default/example +// coercion; callers surface it as a build error. diag may be nil +// (parser diagnostics dropped when so). +// +// # Details +// +// See [§dispatch](./README.md#dispatch) — handler wiring per shape, +// the parameter-specific `required:` write, why this dispatcher +// diverges from `schema.walkSchemaLevel`. +func dispatchParamLevel0(block grammar.Block, param *oaispec.Parameter, diag func(grammar.Diagnostic)) error { + valid := paramValidations{param} + scheme := ¶m.SimpleSchema + var firstErr error + + block.Walk(grammar.Walker{ + FilterDepth: 0, + Number: handlers.Number(valid), + Integer: handlers.Integer(valid), + Bool: handlers.ComposeBool( + handlers.UniqueBool(valid), + paramRequiredBool(param), + ), + String: handlers.ComposeString( + handlers.PatternString(valid), + handlers.CollectionFormatString(valid), + ), + Raw: handlers.Raw(valid, scheme, func(err error) bool { + firstErr = err + return true + }), + Extension: handlers.Extension(param), + Diagnostic: diag, + }) + + return firstErr +} + +// paramRequiredBool returns a Walker.Bool callback that writes +// `required:` straight onto the parameter target. Parameter-specific +// — schema writes required onto the enclosing schema keyed by name, +// headers don't carry `required:` at all. +func paramRequiredBool(param *oaispec.Parameter) func(grammar.Property, bool) { + return func(pr grammar.Property, val bool) { + if !pr.IsTyped() { + return + } + if pr.Keyword.Name == grammar.KwRequired { + param.Required = val + } + } +} + +// walkItemsLevel dispatches Property entries at the given items +// depth onto target through the resolvers.ItemsValidations adapter. +func (p *Builder) walkItemsLevel(block grammar.Block, target *oaispec.Items, depth int) { + valid := resolvers.NewItemsValidations(target) + scheme := &target.SimpleSchema + + block.Walk(grammar.Walker{ + FilterDepth: depth, + Number: handlers.Number(valid), + Integer: handlers.Integer(valid), + Bool: handlers.UniqueBool(valid), + String: handlers.ComposeString( + handlers.PatternString(valid), + handlers.CollectionFormatString(valid), + ), + Raw: handlers.Raw(valid, scheme, nil), + Diagnostic: p.RecordDiagnostic, + }) +} diff --git a/internal/builders/parameters/walker_test.go b/internal/builders/parameters/walker_test.go new file mode 100644 index 0000000..86c248d --- /dev/null +++ b/internal/builders/parameters/walker_test.go @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package parameters + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" + + "github.com/go-openapi/codescan/internal/parsers/grammar" + oaispec "github.com/go-openapi/spec" +) + +// ---------- collectParamItemsLevels ---------- + +func fieldType(t *testing.T, expr string) ast.Expr { + t.Helper() + e, err := parser.ParseExpr(expr) + if err != nil { + t.Fatalf("parseExpr %q: %v", expr, err) + } + return e +} + +func arrayTypeElt(t *testing.T, expr string) ast.Expr { + t.Helper() + at, ok := fieldType(t, expr).(*ast.ArrayType) + if !ok { + t.Fatalf("expected ArrayType for %q", expr) + } + return at.Elt +} + +func newItemsChain(depth int) *oaispec.Items { + if depth <= 0 { + return nil + } + root := new(oaispec.Items) + cur := root + for range depth - 1 { + cur.Items = new(oaispec.Items) + cur = cur.Items + } + return root +} + +func TestCollectParamItemsLevelsFlatSlice(t *testing.T) { + it := newItemsChain(1) + got := collectParamItemsLevels(arrayTypeElt(t, "[]string"), it, 1) + if len(got) != 1 || got[0].level != 1 || got[0].items != it { + t.Errorf("[]string: got %+v", got) + } +} + +func TestCollectParamItemsLevelsNestedSlice(t *testing.T) { + it := newItemsChain(2) + got := collectParamItemsLevels(arrayTypeElt(t, "[][]string"), it, 1) + if len(got) != 2 { + t.Fatalf("[][]string: got %d entries", len(got)) + } + if got[0].level != 1 || got[0].items != it { + t.Errorf("level 1: %+v", got[0]) + } + if got[1].level != 2 || got[1].items != it.Items { + t.Errorf("level 2: %+v", got[1]) + } +} + +func TestCollectParamItemsLevelsNilItems(t *testing.T) { + got := collectParamItemsLevels(arrayTypeElt(t, "[]string"), nil, 1) + if len(got) != 0 { + t.Errorf("nil items: got %+v", got) + } +} + +// ---------- dispatchParamLevel0 ---------- + +//nolint:ireturn // grammar.Block is the package's polymorphic return. +func parseParamBody(t *testing.T, body string) grammar.Block { + t.Helper() + p := grammar.NewParser(token.NewFileSet()) + return p.ParseAs(grammar.AnnParameters, body, token.Position{Line: 1}) +} + +func runDispatch(t *testing.T, param *oaispec.Parameter, body string) { + t.Helper() + b := parseParamBody(t, body) + if err := dispatchParamLevel0(b, param, nil); err != nil { + t.Fatalf("dispatchParamLevel0: %v", err) + } +} + +func TestDispatchParamKeywordNumeric(t *testing.T) { + param := &oaispec.Parameter{} + param.Type = "integer" + runDispatch(t, param, "maximum: <10\nminimum: >=0\nmultipleOf: 2") + + if param.Maximum == nil || *param.Maximum != 10 || !param.ExclusiveMaximum { + t.Errorf("maximum: got (%v, %v), want (10, true)", param.Maximum, param.ExclusiveMaximum) + } + if param.Minimum == nil || *param.Minimum != 0 || param.ExclusiveMinimum { + t.Errorf("minimum: got (%v, %v), want (0, false)", param.Minimum, param.ExclusiveMinimum) + } + if param.MultipleOf == nil || *param.MultipleOf != 2 { + t.Errorf("multipleOf: got %v", param.MultipleOf) + } +} + +func TestDispatchParamKeywordIntegerAndFlags(t *testing.T) { + param := &oaispec.Parameter{} + runDispatch(t, param, "minLength: 3\nmaxLength: 10\nminItems: 1\nmaxItems: 100\nunique: true\nrequired: true") + + if param.MinLength == nil || *param.MinLength != 3 { + t.Errorf("minLength: %v", param.MinLength) + } + if param.MaxLength == nil || *param.MaxLength != 10 { + t.Errorf("maxLength: %v", param.MaxLength) + } + if param.MinItems == nil || *param.MinItems != 1 { + t.Errorf("minItems: %v", param.MinItems) + } + if param.MaxItems == nil || *param.MaxItems != 100 { + t.Errorf("maxItems: %v", param.MaxItems) + } + if !param.UniqueItems { + t.Errorf("unique: want true") + } + if !param.Required { + t.Errorf("required: want true") + } +} + +func TestDispatchParamKeywordPatternAndEnum(t *testing.T) { + param := &oaispec.Parameter{} + param.Type = "string" + runDispatch(t, param, "pattern: ^[a-z]+$\nenum: red, green, blue") + + if param.Pattern != "^[a-z]+$" { + t.Errorf("pattern: %q", param.Pattern) + } + if len(param.Enum) != 3 || param.Enum[0] != "red" { + t.Errorf("enum: %v", param.Enum) + } +} + +func TestDispatchParamKeywordDefaultExampleScheme(t *testing.T) { + param := &oaispec.Parameter{} + param.Type = "integer" + runDispatch(t, param, "default: 42\nexample: 7") + + if param.Default != 42 { + t.Errorf("default: got %v (%T), want 42", param.Default, param.Default) + } + if param.Example != 7 { + t.Errorf("example: got %v (%T), want 7", param.Example, param.Example) + } +} + +func TestDispatchParamKeywordCollectionFormat(t *testing.T) { + param := &oaispec.Parameter{} + runDispatch(t, param, "collectionFormat: multi") + + if param.CollectionFormat != "multi" { + t.Errorf("collectionFormat: %q", param.CollectionFormat) + } +} + +func TestDispatchParamKeywordRequiredFalse(t *testing.T) { + param := &oaispec.Parameter{} + param.Required = true // simulate a prior path-param default + runDispatch(t, param, "required: false") + + if param.Required { + t.Errorf("required: want false after explicit override") + } +} diff --git a/internal/builders/resolvers/assertions.go b/internal/builders/resolvers/assertions.go index f588ed7..d637fa3 100644 --- a/internal/builders/resolvers/assertions.go +++ b/internal/builders/resolvers/assertions.go @@ -25,11 +25,18 @@ const ( // code assertions to be explicit about the various expectations when entering a function func MustNotBeABuiltinType(o *types.TypeName) { - if o.Pkg() != nil { + if o != nil && o.Pkg() != nil { return } - panic(fmt.Errorf("type %q expected not to be a builtin: %w", o.Name(), ErrInternal)) + var name string + if o != nil { + name = o.Name() + } else { + name = "" + } + + panic(fmt.Errorf("type %q expected not to be a builtin: %w", name, ErrInternal)) } func MustHaveRightHandSide(a *types.Alias) { @@ -40,6 +47,14 @@ func MustHaveRightHandSide(a *types.Alias) { panic(fmt.Errorf("type alias %q expected to declare a right-hand-side: %w", a.Obj().Name(), ErrInternal)) } +func MustBeAType(tpe types.TypeAndValue) { + if tpe.IsType() { + return + } + + panic(fmt.Errorf("declaration is not a type: %v: %w", tpe, ErrInternal)) +} + // IsFieldStringable check if the field type is a scalar. If the field type is // *ast.StarExpr and is pointer type, check if it refers to a scalar. // Otherwise, the ",string" directive doesn't apply. @@ -60,21 +75,22 @@ func IsFieldStringable(tpe ast.Expr) bool { } func IsTextMarshaler(tpe types.Type) bool { - encodingPkg, err := importer.Default().Import("encoding") + encoding, err := importer.Default().Import("encoding") if err != nil { return false } - // Proposal for enhancement: there should be a better way to check this than hardcoding the TextMarshaler iface. - obj := encodingPkg.Scope().Lookup("TextMarshaler") - if obj == nil { + + iface := encoding.Scope().Lookup("TextMarshaler") + if iface == nil { return false } - ifc, ok := obj.Type().Underlying().(*types.Interface) + + asInterface, ok := iface.Type().Underlying().(*types.Interface) if !ok { return false } - return types.Implements(tpe, ifc) + return types.Implements(tpe, asInterface) } func IsStdTime(o *types.TypeName) bool { @@ -98,5 +114,19 @@ func AddExtension(ve *oaispec.VendorExtensible, key string, value any, skip bool return } + // handle synonyms + if (key != "x-nullable" && key != "x-isnullable") || len(ve.Extensions) == 0 { + ve.AddExtension(key, value) + + return + } + + if _, ok := ve.Extensions["x-nullable"]; ok { + return + } + if _, ok := ve.Extensions["x-isnullable"]; ok { + return + } + ve.AddExtension(key, value) } diff --git a/internal/builders/resolvers/enum_desc.go b/internal/builders/resolvers/enum_desc.go new file mode 100644 index 0000000..12a205d --- /dev/null +++ b/internal/builders/resolvers/enum_desc.go @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package resolvers + +import oaispec "github.com/go-openapi/spec" + +// ExtEnumDesc is the vendor-extension key the scanner uses to expose +// the per-enum-value documentation lines built from `swagger:enum` +// + const-block comments. It's a go-swagger concept (not part of +// the OpenAPI spec); the key lives in `resolvers` so every builder +// (schema, parameters, responses) reads and writes it through the +// same constant. +const ExtEnumDesc = "x-go-enum-desc" + +// GetEnumDesc reads the x-go-enum-desc extension off a Swagger +// extensions map. Empty when absent. +// +// Consumers typically check this after a build pass to know whether +// they should append the per-value docs to a Description (parameters +// + response headers do this for the parameter/header +// description; the schema builder folds it in differently — see +// `schema/extensions.go:clearStaleEnumDesc` for the +// override-cleanup dance). +func GetEnumDesc(extensions oaispec.Extensions) string { + desc, _ := extensions.GetString(ExtEnumDesc) + return desc +} diff --git a/internal/builders/resolvers/items_adapters.go b/internal/builders/resolvers/items_adapters.go new file mode 100644 index 0000000..7a25279 --- /dev/null +++ b/internal/builders/resolvers/items_adapters.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package resolvers + +import ( + "github.com/go-openapi/codescan/internal/builders/validations" + "github.com/go-openapi/codescan/internal/ifaces" + oaispec "github.com/go-openapi/spec" +) + +// ItemsTypable adapts an *oaispec.Items target to +// ifaces.SwaggerTypable so the parameters/responses builders can +// recurse through nested array layers (SimpleSchema's items chain). +// +// One adapter, two callers (parameters, responses); lives here +// because it's pure ifaces-glue with no builder-specific logic. +// The schema builder uses its own *oaispec.Schema.Items chain and +// has no need for this adapter. +type ItemsTypable struct { + items *oaispec.Items + level int + in string +} + +func NewItemsTypable(items *oaispec.Items, level int, in string) ItemsTypable { + return ItemsTypable{items: items, level: level, in: in} +} + +func (pt ItemsTypable) In() string { return pt.in } + +func (pt ItemsTypable) Level() int { return pt.level } + +func (pt ItemsTypable) Typed(tpe, format string) { pt.items.Typed(tpe, format) } + +func (pt ItemsTypable) SetRef(ref oaispec.Ref) { pt.items.Ref = ref } + +func (pt ItemsTypable) Schema() *oaispec.Schema { return nil } + +func (pt ItemsTypable) Items() ifaces.SwaggerTypable { //nolint:ireturn // polymorphic by design + if pt.items.Items == nil { + pt.items.Items = new(oaispec.Items) + } + pt.items.Type = "array" + return ItemsTypable{pt.items.Items, pt.level + 1, pt.in} +} + +func (pt ItemsTypable) AddExtension(key string, value any) { + pt.items.AddExtension(key, value) +} + +func (pt ItemsTypable) WithEnum(values ...any) { + pt.items.WithEnum(values...) +} + +func (pt ItemsTypable) WithEnumDescription(_ string) { + // items levels carry no description channel; the enclosing + // parameter / header description already absorbs the enum doc. +} + +// ItemsValidations wraps an *oaispec.Items as a ValidationBuilder / +// OperationValidationBuilder target. Used by parameters' and +// responses' grammar items-level Walker dispatch. +type ItemsValidations struct { + current *oaispec.Items +} + +func NewItemsValidations(it *oaispec.Items) ItemsValidations { + return ItemsValidations{current: it} +} + +func (sv ItemsValidations) SetMaximum(val float64, exclusive bool) { + sv.current.Maximum = &val + sv.current.ExclusiveMaximum = exclusive +} + +func (sv ItemsValidations) SetMinimum(val float64, exclusive bool) { + sv.current.Minimum = &val + sv.current.ExclusiveMinimum = exclusive +} + +func (sv ItemsValidations) SetMultipleOf(val float64) { sv.current.MultipleOf = &val } +func (sv ItemsValidations) SetMinItems(val int64) { sv.current.MinItems = &val } +func (sv ItemsValidations) SetMaxItems(val int64) { sv.current.MaxItems = &val } +func (sv ItemsValidations) SetMinLength(val int64) { sv.current.MinLength = &val } +func (sv ItemsValidations) SetMaxLength(val int64) { sv.current.MaxLength = &val } +func (sv ItemsValidations) SetPattern(val string) { sv.current.Pattern = val } +func (sv ItemsValidations) SetUnique(val bool) { sv.current.UniqueItems = val } +func (sv ItemsValidations) SetCollectionFormat(val string) { sv.current.CollectionFormat = val } +func (sv ItemsValidations) SetEnum(val string) { + sv.current.Enum = validations.CoerceEnum(val, &oaispec.SimpleSchema{Type: sv.current.Type, Format: sv.current.Format}) +} +func (sv ItemsValidations) SetDefault(val any) { sv.current.Default = val } +func (sv ItemsValidations) SetExample(val any) { sv.current.Example = val } diff --git a/internal/builders/resolvers/resolvers.go b/internal/builders/resolvers/resolvers.go index c0a1e1f..3ffc0d4 100644 --- a/internal/builders/resolvers/resolvers.go +++ b/internal/builders/resolvers/resolvers.go @@ -104,23 +104,24 @@ func UnsupportedBuiltinType(tpe types.Type) bool { } } -func UnsupportedBuiltin(tpe ifaces.Objecter) bool { +// UnsupportedBuiltin returns true when tpe is unsafe.Pointer. +// +// Other "unsupported builtins" (complex64, complex128) cannot reach +// this function: they surface as *types.Basic, which does not satisfy +// [ifaces.Objecter]. Those are caught one layer down by +// [UnsupportedBasic] / [UnsupportedBuiltinType] when the *types.Basic +// surfaces directly. +// +// Supported builtins: +// +// - error +func UnsupportedBuiltin(tpe ifaces.Objecter) (skip bool) { o := tpe.Obj() - if o == nil { + if o == nil || o.Pkg() == nil { return false } - if o.Pkg() != nil { - if o.Pkg().Path() == "unsafe" { - return true - } - - return false // not a builtin type - } - - _, found := unsupportedTypes[o.Name()] - - return found + return o.Pkg().Path() == "unsafe" } func UnsupportedBasic(tpe *types.Basic) bool { diff --git a/internal/builders/resolvers/resolvers_test.go b/internal/builders/resolvers/resolvers_test.go index 5977e14..9aaa63a 100644 --- a/internal/builders/resolvers/resolvers_test.go +++ b/internal/builders/resolvers/resolvers_test.go @@ -134,14 +134,10 @@ func TestUnsupportedBuiltin(t *testing.T) { assert.FalseT(t, UnsupportedBuiltin(m)) }) - t.Run("builtin complex64 returns true", func(t *testing.T) { - tn := types.NewTypeName(token.NoPos, nil, "complex64", nil) - m := &mocks.MockObjecter{ObjFunc: func() *types.TypeName { return tn }} - assert.TrueT(t, UnsupportedBuiltin(m)) - }) - - t.Run("builtin int returns false", func(t *testing.T) { - tn := types.NewTypeName(token.NoPos, nil, "int", nil) + t.Run("universe-scope object returns false", func(t *testing.T) { + // Pkg()==nil objects (predeclared error, any, etc.) never + // represent unsafe.Pointer and must therefore not match. + tn := types.NewTypeName(token.NoPos, nil, "error", nil) m := &mocks.MockObjecter{ObjFunc: func() *types.TypeName { return tn }} assert.FalseT(t, UnsupportedBuiltin(m)) }) diff --git a/internal/builders/responses/README.md b/internal/builders/responses/README.md new file mode 100644 index 0000000..695116e --- /dev/null +++ b/internal/builders/responses/README.md @@ -0,0 +1,271 @@ +# `internal/builders/responses` — maintainers' guide + +Builds OAS v2 response entries (`swagger:response`) including the +response body and any header fields. One `Builder` per +declaration; one Walk pass per field doc-comment. + +## Sections + +- [§overview](#overview) — package shape and per-file responsibilities +- [§builder](#builder) — `Builder`, `Build`, the build chain +- [§in-discriminator](#in-discriminator) — `in:` as body/header annotation switch +- [§file-body](#file-body) — `swagger:file` and the body-only gate +- [§dispatch](#dispatch) — Walker handlers wiring for headers +- [§typable](#typable) — `responseTypable`, the `refAttempted` mechanism, body vs header +- [§alias-handling](#alias-handling) — when to `$ref` vs expand +- [§quirks-history](#quirks-history) — resolved quirks (Stream M) + +--- + +## §overview — files and responsibilities + +| File | Contents | +|------|----------| +| `responses.go` | `Builder`, build chain (`Build` → `buildFromType` → `buildNamedType` / `buildAlias` → `buildFromStruct` → `processResponseField` → `buildFromField*`), `buildOption` helper | +| `doc_signals.go` | `fieldDocSignals` + `scanFieldDocSignals`: pre-walk extraction of `in:`, `swagger:ignore`, `swagger:file`, `swagger:strfmt` from a field's doc comment; closed-vocabulary `in:` validation | +| `walker.go` | `applyBlockToDecl` (top-level decl), `applyBlockToHeader` + `dispatchHeaderLevel0` + `walkHeaderItemsLevel`: the grammar Walker dispatch for per-header validations / extensions | +| `typable.go` | `responseTypable` (the `ifaces.SwaggerTypable` adapter) + `headerValidations` (the `ifaces.ValidationBuilder` adapter) + `SimpleSchemaProbe` | +| `errors.go` | `ErrResponses` sentinel | + +The builder embeds `*common.Builder` (Ctx, Decl, PostDeclarations, +diagnostic sink, ParseBlocks cache, MakeRef). See +[../common](../common) for the common-builder rationale. + +This package's shape closely mirrors +[../parameters](../parameters) — the chain is structurally the +same. Divergences are called out below; the +common-extraction list for Stream M6 lives in +`.claude/plans/stream-M-grammar-merge-readiness.md`. + +## §builder — the build chain + +`Build(responses)` looks up the response by name (from +`r.Decl.ResponseNames()`), runs `applyBlockToDecl` to capture the +top-level description, then calls `buildFromType` on the declared +type. `buildFromType` unwraps pointers, dispatches named types and +aliases. Unlike parameters, **anonymous types are rejected**: +`responses_test.go` documents the rationale — the top-level +response-as-alias case under default mode is deferred to v2. + +For each non-embedded exported field, `processResponseField` runs: + +1. Find the AST field via `resolvers.FindASTField` (no AST → skip). +2. Pre-scan the doc-comment signals via `scanFieldDocSignals` (uses + the `common.Builder` parse cache). +3. If `swagger:ignore` → skip. +4. Resolve JSON tag name. If `json:"-"` → skip. +5. Resolve `in:` (default `header`; see [§in-discriminator](#in-discriminator)). +6. **File-body gate**: if `swagger:file` AND `in==body` → set + `resp.Schema = {type:"file"}` and skip the field build (see + [§file-body](#file-body)). +7. Otherwise build the field's type into either `resp.Schema` + (body) or a header value (non-body), through `responseTypable`. +8. Apply `swagger:strfmt ` override when set. +9. Walk the doc-comment block via `applyBlockToHeader` (description, + validations, items, extensions — no `required:`). +10. If `in != body`, register the header on `resp.Headers[name]`. + +After all fields are processed, `buildFromStruct` deletes header +entries for fields that were skipped (the `seen` map). + +## §in-discriminator — `in:` as body/header annotation switch + +OAS v2 has **no `in` field on the Response Object** — the location +exists at the parameter level only. This package overloads `in:` on +response fields to tell apart "this field is the body" from "this +field is a header" within a single Go struct: + +- `in: body` → field's type populates `resp.Schema` +- `in: header` (or absent → defaults to `header`) → field becomes + one entry in `resp.Headers` +- `in: query | path | formData` → recognised but unusual; not a + response location per OAS v2. Treated as non-body (header-like) + with no special handling. + +### Default — implicit header + +Pre-Stream M the implicit case fell into the `in != "body"` branch +by accident: an empty string is not `"body"` and so behaved +identically to `header`. Q1 made this default **explicit** in code +— `inHeader` is assigned when `!signals.inSet`. Observable behaviour +is unchanged; the implicit fall-through is gone. + +### Invalid `in:` values + +An `in:` line with a non-vocabulary value (e.g. `in: cookie`) emits +a `CodeInvalidAnnotation` warning via `Warnf` and **defaults to +header**. Author misuse surfaces in diagnostics without breaking the +build. + +### Why line-scan instead of property + +Same reason as parameters — `in:` may appear on either side of an +annotation. See +[../parameters/README.md#in-discriminator](../parameters/README.md#in-discriminator). + +## §file-body — `swagger:file` annotation + +`swagger:file` on a response field marks the entire response body as +a file payload (image, PDF, raw bytes). Per OAS v2, the allowed +**header** types are `{string, number, integer, boolean, array}` — +`file` is **forbidden on a header**. The annotation must therefore +land on the body field; on a header it is misuse. + +### The Q3 gate + +Pre-Q3 the file branch fired unconditionally and rewrote +`resp.Schema = {file, ""}` even when `in != body`, silently +corrupting the body schema from a header-positioned annotation. +Q3 gates the branch on `in == inBody`: + +```go +useFileBody := signals.file && in == inBody +``` + +When `signals.file` fires under a non-body `in`, the dispatcher +emits a `CodeUnsupportedInSimpleSchema` warning and falls through +to the regular field build, treating the field like any other +header. The body schema is untouched. + +## §dispatch — Walker dispatch for headers + +`applyBlockToHeader` is the per-field entry point for header +fields. Three phases: + +1. **Prose** → `header.Description`. +2. **Level-0 dispatch** → `dispatchHeaderLevel0` wires Walker + callbacks via the `handlers` package. Shape mirrors parameters' + level-0 dispatcher with one omission: + - `Number`, `Integer`, `String` (Pattern + CollectionFormat), + `Raw` (default/example/enum) → identical to parameters + - `Bool` → `handlers.UniqueBool(valid)` only — **no `required:` + write**. The OAS v2 Header object simply doesn't carry + `required:`. + - `Extension` → `handlers.Extension(header)` — v1 had no + header-side extension support at all; the grammar migration + closes that gap. User-authored `Extensions:` block entries + land on the header. +3. **Items-level dispatch** → `walkHeaderItemsLevel` for each + `(level, items)` pair returned by `collectHeaderItemsLevels` + (1-indexed depths matching grammar's `Property.ItemsDepth`). + +`applyBlockToDecl` is the top-level (response-level) entry point. +It only writes `resp.Description` from prose — the v1 +`SectionedParser` only accepted description at the top level, no +taggers. Property-level keywords on the top-level decl are silently +ignored. + +### Header errSink semantics + +Unlike `dispatchParamLevel0`, the response `Raw` handler is called +with a nil errSink: malformed `default:` / `example:` for a header +produces a parser diagnostic but is **not promoted to a build +error**. Headers are non-critical metadata; failing the build over +a malformed example would be surprising. + +## §typable — `responseTypable`, body vs header + +`responseTypable` adapts a header or body slot to +`ifaces.SwaggerTypable`. Single struct, polymorphic by `ht.in`: + +- **Body** (`in == "body"`): `Schema()` materialises and returns + `resp.Schema`. `Typed()` writes to the header struct, but body + callers use `Schema()` directly. `Items()` walks `resp.Schema.Items`. +- **Header** (`in == "header"` or anything non-body): `Typed()` + writes to the embedded `SimpleSchema` on the header. + `Items()` builds the items chain on the header side. + +### The Q2 `refAttempted` mechanism + +OAS v2 response headers cannot carry `$ref`. Pre-Q2, `SetRef` wrote +the ref onto `response.Schema.Ref` unconditionally — which +corrupted the body schema with a **header field's reference** when a +header field aliased to a named type. The Q2 fix: + +```go +func (ht responseTypable) SetRef(ref oaispec.Ref) { + if ht.in == inBody { + ht.Schema().Ref = ref + return + } + if ht.refAttempted != nil { + *ht.refAttempted = true + } +} +``` + +Non-body `SetRef` calls no-op the ref write and flip a flag on a +caller-owned `bool`. `HasRef()` reads the flag for +`SimpleSchemaProbe` so the schema builder's exit validator can +detect the violation, emit `CodeUnsupportedInSimpleSchema`, and call +`ResetForViolation()` (which wipes the header's SimpleSchema back to +empty). + +The flag is **caller-owned** (passed by pointer) so a single +`responseTypable` value can be shared across recursion levels +without mutation through the `ifaces.SwaggerTypable` value-receiver +methods. + +### `SimpleSchemaProbe` implementation + +- `SimpleSchemaShape()` — returns the header's embedded SimpleSchema +- `HasRef()` — true if a non-body `SetRef` attempt was made +- `ResetForViolation()` — wipes the header's SimpleSchema back to `{}` + +## §alias-handling — when to `$ref` vs expand + +`buildFieldAlias` carries a gate identical to parameters': + +```go +if typable.In() != inBody || !r.Ctx.RefAliases() { + unaliased := types.Unalias(tpe) + return r.buildFromField(fld, unaliased, typable, seen) +} +``` + +Non-body fields can't carry `$ref` (SimpleSchema forbids it), so +they always expand. Body fields honour `RefAliases`: when set, the +alias becomes a `$ref` to its target via +`common.Builder.MakeRef`; otherwise the underlying is expanded. + +The top-level `buildAlias` (declaration-level, not field-level) +behaves slightly differently because the response-as-alias case is +always body-shaped: the gate collapses to `r.Ctx.RefAliases()` alone, +and the unaliased target is materialised via the schema builder. + +## §quirks-history — resolved quirks + +The Stream M merge-readiness pass closed a handful of subtle quirks +in this builder. Recorded for archaeology — none should resurface +under the current dispatch. + +- **Q1: implicit header default** — `in:` absent now resolves + explicitly to `inHeader`. Pre-Q1 the empty string fell through + `in != "body"` by accident. Observable behaviour unchanged. +- **Q2: `$ref` leaking into response body** — non-body `SetRef` + calls no longer write to `response.Schema.Ref`. The + `refAttempted` flag plumbs the attempt to the SimpleSchema exit + validator. See [§typable](#typable). +- **Q3: `swagger:file` on a header** — gated to `in == inBody` only; + misuse emits a diagnostic and falls through to the regular field + build. +- **Q4: alias expansion at field-level** — added the + `In() != inBody || !RefAliases()` gate to `buildFieldAlias` so + non-body header aliases expand instead of leaking a `$ref`. Aligns + with parameters' gate exactly. +- **Q5 (closed, no change)** — the previously-suspected TODO around + alias deduplication turned out to be obsolete; no code change + needed. +- **Q6: alignment with parameters** — `buildFieldAlias` and + `processResponseField` now mirror parameters' shape so the two + builders behave consistently at field-level alias sites and + during dispatch. + +### Deferred to v2 + +- **Top-level response-as-alias under default mode**: + `buildFromType`'s default branch rejects anonymous types with + `"anonymous types are currently not supported for responses"`. A + top-level alias to an anonymous struct under default mode crashes + here. Reproduces in `fixtures/enhancements/alias-response-shapes`. + Out of scope for v1. diff --git a/internal/builders/responses/doc_signals.go b/internal/builders/responses/doc_signals.go new file mode 100644 index 0000000..015ae6c --- /dev/null +++ b/internal/builders/responses/doc_signals.go @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package responses + +import ( + "go/ast" + "strings" + + "github.com/go-openapi/codescan/internal/parsers/grammar" +) + +// fieldDocSignals carries the per-field doc-comment signals the +// response dispatcher reads upstream of the schema build. Shape +// parallels parameters/doc_signals.go's; the two will fold into a +// shared resolvers helper in M6. +// +// # Details +// +// See [§in-discriminator](./README.md#in-discriminator) — the `in:` +// vocabulary, default-to-header behaviour, and the invalid-`in:` +// diagnostic path. +type fieldDocSignals struct { + in string + inSet bool + invalidIn string // raw value when an `in:` line was present but its value isn't in the closed vocabulary; empty otherwise. The caller emits a diagnostic. + ignored bool + file bool + strfmt string + strfmtSet bool +} + +// scanFieldDocSignals reads every signal the response dispatcher +// needs out of a pre-parsed block slice and the raw doc text. +// Callers should pass `r.ParseBlocks(afld.Doc)` so the +// common.Builder cache absorbs the parse cost. +// +// Returns the zero value when doc is nil. +// +// # Details +// +// See [§in-discriminator](./README.md#in-discriminator) — why `in:` +// is line-scanned rather than read as a grammar Property. +func scanFieldDocSignals(blocks []grammar.Block, doc *ast.CommentGroup) fieldDocSignals { + var pd fieldDocSignals + if doc == nil { + return pd + } + + for _, b := range blocks { + switch b.AnnotationKind() { //nolint:exhaustive // only ignore/file/strfmt are relevant here + case grammar.AnnIgnore: + pd.ignored = true + case grammar.AnnFile: + pd.file = true + case grammar.AnnStrfmt: + if arg, ok := b.AnnotationArg(); ok && !strings.ContainsAny(arg, " \t") { + pd.strfmt = arg + pd.strfmtSet = true + } + } + } + + v, ok, invalid := scanInLocation(doc.Text()) + switch { + case ok: + pd.in = v + pd.inSet = true + case invalid != "": + pd.invalidIn = invalid + } + + return pd +} + +// validResponseIn enumerates the closed-vocabulary `in:` values the +// response dispatcher accepts. Same vocabulary as parameters' — the +// in:body distinguishes the response-body / response-header split, +// the others are passed through verbatim. +// +//nolint:gochecknoglobals // closed-vocabulary lookup table; one allocation, read-only. +var validResponseIn = map[string]struct{}{ + "query": {}, + "path": {}, + "header": {}, + "body": {}, + "formData": {}, +} + +// scanInLocation finds the first `in: X` line in text and returns +// the value (when valid) or the raw candidate (when present but +// out-of-vocabulary). See [§in-discriminator](./README.md#in-discriminator). +func scanInLocation(text string) (value string, valid bool, invalid string) { + for line := range strings.SplitSeq(text, "\n") { + line = strings.TrimSpace(line) + rest, ok := strings.CutPrefix(line, "in:") + if !ok { + rest, ok = strings.CutPrefix(line, "In:") + } + if !ok { + continue + } + v := strings.TrimSpace(rest) + v = strings.TrimSuffix(v, ".") + if v == "" { + continue + } + if _, ok := validResponseIn[v]; ok { + return v, true, "" + } + // First `in:` line with a non-vocab value — record so the + // caller can diagnose. Don't keep scanning: a later valid + // `in:` after an invalid one would be a bizarre input we + // don't need to model. + return "", false, v + } + return "", false, "" +} + +// strfmtFromDoc returns the argument of a `swagger:strfmt ` +// annotation present in blocks. Single-word filter mirrors the +// schema package's `findAnnotationArg` rule. +func strfmtFromDoc(blocks []grammar.Block) (string, bool) { + for _, b := range blocks { + if b.AnnotationKind() != grammar.AnnStrfmt { + continue + } + if arg, ok := b.AnnotationArg(); ok && !strings.ContainsAny(arg, " \t") { + return arg, true + } + } + return "", false +} diff --git a/internal/builders/responses/responses.go b/internal/builders/responses/responses.go index e08d9eb..56d113e 100644 --- a/internal/builders/responses/responses.go +++ b/internal/builders/responses/responses.go @@ -7,47 +7,51 @@ import ( "fmt" "go/types" + "github.com/go-openapi/codescan/internal/builders/common" "github.com/go-openapi/codescan/internal/builders/resolvers" "github.com/go-openapi/codescan/internal/builders/schema" "github.com/go-openapi/codescan/internal/ifaces" "github.com/go-openapi/codescan/internal/logger" - "github.com/go-openapi/codescan/internal/parsers" + "github.com/go-openapi/codescan/internal/parsers/grammar" "github.com/go-openapi/codescan/internal/scanner" oaispec "github.com/go-openapi/spec" ) -type ResponseBuilder struct { - ctx *scanner.ScanCtx - decl *scanner.EntityDecl - postDecls []*scanner.EntityDecl +const ( + inBody = "body" + inHeader = "header" +) + +// Builder constructs OAS v2 response entries for one +// `swagger:response` declaration. Embeds *common.Builder for shared +// state (Ctx, Decl, PostDeclarations, diagnostics, ParseBlocks +// cache). +type Builder struct { + *common.Builder } -func NewBuilder(ctx *scanner.ScanCtx, decl *scanner.EntityDecl) *ResponseBuilder { - return &ResponseBuilder{ - ctx: ctx, - decl: decl, +// NewBuilder constructs an initialized [Builder] bound to +// ctx and decl. The embedded common.Builder owns the diagnostic +// sink, the post-declaration list, and the per-comment-group parse +// cache. +func NewBuilder(ctx *scanner.ScanCtx, decl *scanner.EntityDecl) *Builder { + return &Builder{ + Builder: common.New(ctx, decl), } } -func (r *ResponseBuilder) Build(responses map[string]oaispec.Response) error { +func (r *Builder) Build(responses map[string]oaispec.Response) error { // check if there is a swagger:response tag that is followed by one or more words, // these words are the ids of the operations this parameter struct applies to // once type name is found convert it to a schema, by looking up the schema in the // parameters dictionary that got passed into this parse method - name, _ := r.decl.ResponseNames() + name, _ := r.Decl.ResponseNames() response := responses[name] - logger.DebugLogf(r.ctx.Debug(), "building response: %s", name) + logger.DebugLogf(r.Ctx.Debug(), "building response: %s", name) // analyze doc comment for the model - sp := parsers.NewSectionedParser( - parsers.WithSetDescription(func(lines []string) { - response.Description = parsers.JoinDropLast(lines) - }), - ) - if err := sp.Parse(r.decl.Comments); err != nil { - return err - } + r.applyBlockToDecl(&response) // analyze struct body for fields etc // each exported struct field: @@ -56,7 +60,7 @@ func (r *ResponseBuilder) Build(responses map[string]oaispec.Response) error { // * has to document the validations that apply for the type and the field // * when the struct field points to a model it becomes a ref: #/definitions/ModelName // * comments that aren't tags is used as the description - if err := r.buildFromType(r.decl.ObjType(), &response, make(map[string]bool)); err != nil { + if err := r.buildFromType(r.Decl.ObjType(), &response, make(map[string]bool)); err != nil { return err } responses[name] = response @@ -64,12 +68,8 @@ func (r *ResponseBuilder) Build(responses map[string]oaispec.Response) error { return nil } -func (r *ResponseBuilder) PostDeclarations() []*scanner.EntityDecl { - return r.postDecls -} - -func (r *ResponseBuilder) buildFromField(fld *types.Var, tpe types.Type, typable ifaces.SwaggerTypable, seen map[string]bool) error { - logger.DebugLogf(r.ctx.Debug(), "build from field %s: %T", fld.Name(), tpe) +func (r *Builder) buildFromField(fld *types.Var, tpe types.Type, typable ifaces.SwaggerTypable, seen map[string]bool) error { + logger.DebugLogf(r.Ctx.Debug(), "build from field %s: %T", fld.Name(), tpe) switch ftpe := tpe.(type) { case *types.Basic: @@ -89,112 +89,121 @@ func (r *ResponseBuilder) buildFromField(fld *types.Var, tpe types.Type, typable case *types.Named: return r.buildNamedField(ftpe, typable) case *types.Alias: - logger.DebugLogf(r.ctx.Debug(), "alias(responses.buildFromField): got alias %v to %v", ftpe, ftpe.Rhs()) + logger.DebugLogf(r.Ctx.Debug(), "alias(responses.buildFromField): got alias %v to %v", ftpe, ftpe.Rhs()) return r.buildFieldAlias(ftpe, typable, fld, seen) default: return fmt.Errorf("unknown type for %s: %T: %w", fld.String(), fld.Type(), ErrResponses) } } -func (r *ResponseBuilder) buildFromFieldStruct(ftpe *types.Struct, typable ifaces.SwaggerTypable) error { - sb := schema.NewBuilder(r.ctx, r.decl) - if err := sb.BuildFromType(ftpe, typable); err != nil { +func (r *Builder) buildFromFieldStruct(ftpe *types.Struct, typable ifaces.SwaggerTypable) error { + sb := schema.NewBuilder(r.Ctx, r.Decl) + if err := sb.Build(schema.OptionFor(ftpe, typable)); err != nil { return err } - r.postDecls = append(r.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + r.AppendPostDecl(d) + } return nil } -func (r *ResponseBuilder) buildFromFieldMap(ftpe *types.Map, typable ifaces.SwaggerTypable) error { +func (r *Builder) buildFromFieldMap(ftpe *types.Map, typable ifaces.SwaggerTypable) error { sch := new(oaispec.Schema) typable.Schema().Typed("object", "").AdditionalProperties = &oaispec.SchemaOrBool{ Schema: sch, } - sb := schema.NewBuilder(r.ctx, r.decl) - if err := sb.BuildFromType(ftpe.Elem(), schema.NewTypable(sch, typable.Level()+1, r.ctx.SkipExtensions())); err != nil { + sb := schema.NewBuilder(r.Ctx, r.Decl) + if err := sb.Build( + schema.WithType(ftpe.Elem(), + schema.NewTypable(sch, typable.Level()+1, r.Ctx.SkipExtensions())), + ); err != nil { return err } - r.postDecls = append(r.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + r.AppendPostDecl(d) + } return nil } -func (r *ResponseBuilder) buildFromFieldInterface(tpe types.Type, typable ifaces.SwaggerTypable) error { - sb := schema.NewBuilder(r.ctx, r.decl) - if err := sb.BuildFromType(tpe, typable); err != nil { +func (r *Builder) buildFromFieldInterface(tpe *types.Interface, typable ifaces.SwaggerTypable) error { + sb := schema.NewBuilder(r.Ctx, r.Decl) + if err := sb.Build(schema.OptionFor(tpe, typable)); err != nil { return err } - r.postDecls = append(r.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + r.AppendPostDecl(d) + } return nil } -func (r *ResponseBuilder) buildFromType(otpe types.Type, resp *oaispec.Response, seen map[string]bool) error { +func (r *Builder) buildFromType(otpe types.Type, resp *oaispec.Response, seen map[string]bool) error { switch tpe := otpe.(type) { case *types.Pointer: return r.buildFromType(tpe.Elem(), resp, seen) case *types.Named: return r.buildNamedType(tpe, resp, seen) case *types.Alias: - logger.DebugLogf(r.ctx.Debug(), "alias(responses.buildFromType): got alias %v to %v", tpe, tpe.Rhs()) + logger.DebugLogf(r.Ctx.Debug(), "alias(responses.buildFromType): got alias %v to %v", tpe, tpe.Rhs()) return r.buildAlias(tpe, resp, seen) default: return fmt.Errorf("anonymous types are currently not supported for responses: %w", ErrResponses) } } -func (r *ResponseBuilder) buildNamedType(tpe *types.Named, resp *oaispec.Response, seen map[string]bool) error { +func (r *Builder) buildNamedType(tpe *types.Named, resp *oaispec.Response, seen map[string]bool) error { o := tpe.Obj() if resolvers.IsAny(o) || resolvers.IsStdError(o) { return fmt.Errorf("%s type not supported in the context of a responses section definition: %w", o.Name(), ErrResponses) } resolvers.MustNotBeABuiltinType(o) - switch stpe := o.Type().Underlying().(type) { // TODO(fred): this is wrong without checking for aliases? + switch stpe := o.Type().Underlying().(type) { case *types.Struct: - logger.DebugLogf(r.ctx.Debug(), "build from type %s: %T", o.Name(), tpe) - if decl, found := r.ctx.DeclForType(o.Type()); found { + logger.DebugLogf(r.Ctx.Debug(), "build from type %s: %T", o.Name(), tpe) + if decl, found := r.Ctx.DeclForType(o.Type()); found { return r.buildFromStruct(decl, stpe, resp, seen) } - return r.buildFromStruct(r.decl, stpe, resp, seen) + return r.buildFromStruct(r.Decl, stpe, resp, seen) default: - if decl, found := r.ctx.DeclForType(o.Type()); found { + if decl, found := r.Ctx.DeclForType(o.Type()); found { var sch oaispec.Schema - typable := schema.NewTypable(&sch, 0, r.ctx.SkipExtensions()) + typable := schema.NewTypable(&sch, 0, r.Ctx.SkipExtensions()) d := decl.Obj() if resolvers.IsStdTime(d) { typable.Typed("string", "date-time") return nil } - if sfnm, isf := parsers.StrfmtName(decl.Comments); isf { + if sfnm, isf := strfmtFromDoc(r.ParseBlocks(decl.Comments)); isf { typable.Typed("string", sfnm) return nil } - sb := schema.NewBuilder(r.ctx, decl) + sb := schema.NewBuilder(r.Ctx, decl) sb.InferNames() - if err := sb.BuildFromType(tpe.Underlying(), typable); err != nil { + if err := sb.Build(schema.OptionFor(tpe.Underlying(), typable)); err != nil { return err } resp.WithSchema(&sch) - r.postDecls = append(r.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + r.AppendPostDecl(d) + } return nil } return fmt.Errorf("responses can only be structs, did you mean for %s to be the response body?: %w", tpe.String(), ErrResponses) } } -func (r *ResponseBuilder) buildAlias(tpe *types.Alias, resp *oaispec.Response, seen map[string]bool) error { - // panic("yay") +func (r *Builder) buildAlias(tpe *types.Alias, resp *oaispec.Response, seen map[string]bool) error { o := tpe.Obj() if resolvers.IsAny(o) || resolvers.IsStdError(o) { - // wrong: TODO(fred): see what object exactly we want to build here - figure out with specific tests return fmt.Errorf("%s type not supported in the context of a responses section definition: %w", o.Name(), ErrResponses) } resolvers.MustNotBeABuiltinType(o) @@ -203,17 +212,17 @@ func (r *ResponseBuilder) buildAlias(tpe *types.Alias, resp *oaispec.Response, s rhs := tpe.Rhs() // If transparent aliases are enabled, use the underlying type directly without creating a definition - if r.ctx.TransparentAliases() { + if r.Ctx.TransparentAliases() { return r.buildFromType(rhs, resp, seen) } - decl, ok := r.ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, ok := r.Ctx.FindModel(o.Pkg().Path(), o.Name()) if !ok { return fmt.Errorf("can't find source file for aliased type: %v -> %v: %w", tpe, rhs, ErrResponses) } - r.postDecls = append(r.postDecls, decl) // mark the left-hand side as discovered + r.AppendPostDecl(decl) // mark the left-hand side as discovered - if !r.ctx.RefAliases() { + if !r.Ctx.RefAliases() { // expand alias unaliased := types.Unalias(tpe) return r.buildFromType(unaliased.Underlying(), resp, seen) @@ -227,24 +236,24 @@ func (r *ResponseBuilder) buildAlias(tpe *types.Alias, resp *oaispec.Response, s break // builtin } - typable := schema.NewTypable(&oaispec.Schema{}, 0, r.ctx.SkipExtensions()) - return r.makeRef(decl, typable) + typable := schema.NewTypable(&oaispec.Schema{}, 0, r.Ctx.SkipExtensions()) + return r.MakeRef(decl, typable) case *types.Alias: o := rtpe.Obj() if o.Pkg() == nil { break // builtin } - typable := schema.NewTypable(&oaispec.Schema{}, 0, r.ctx.SkipExtensions()) + typable := schema.NewTypable(&oaispec.Schema{}, 0, r.Ctx.SkipExtensions()) - return r.makeRef(decl, typable) + return r.MakeRef(decl, typable) } return r.buildFromType(rhs, resp, seen) } -func (r *ResponseBuilder) buildNamedField(ftpe *types.Named, typable ifaces.SwaggerTypable) error { - decl, found := r.ctx.DeclForType(ftpe.Obj().Type()) +func (r *Builder) buildNamedField(ftpe *types.Named, typable ifaces.SwaggerTypable) error { + decl, found := r.Ctx.DeclForType(ftpe.Obj().Type()) if !found { return fmt.Errorf("unable to find package and source file for: %s: %w", ftpe.String(), ErrResponses) } @@ -255,25 +264,25 @@ func (r *ResponseBuilder) buildNamedField(ftpe *types.Named, typable ifaces.Swag return nil } - if sfnm, isf := parsers.StrfmtName(decl.Comments); isf { + if sfnm, isf := strfmtFromDoc(r.ParseBlocks(decl.Comments)); isf { typable.Typed("string", sfnm) return nil } - sb := schema.NewBuilder(r.ctx, decl) + sb := schema.NewBuilder(r.Ctx, decl) sb.InferNames() - if err := sb.BuildFromType(decl.ObjType(), typable); err != nil { + if err := sb.Build(schema.OptionFor(decl.ObjType(), typable)); err != nil { return err } - r.postDecls = append(r.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + r.AppendPostDecl(d) + } return nil } -func (r *ResponseBuilder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypable, fld *types.Var, seen map[string]bool) error { - _ = fld - _ = seen +func (r *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypable, fld *types.Var, seen map[string]bool) error { o := tpe.Obj() if resolvers.IsAny(o) { // e.g. Field interface{} or Field any @@ -283,25 +292,34 @@ func (r *ResponseBuilder) buildFieldAlias(tpe *types.Alias, typable ifaces.Swagg } // If transparent aliases are enabled, use the underlying type directly without creating a definition - if r.ctx.TransparentAliases() { - sb := schema.NewBuilder(r.ctx, r.decl) - if err := sb.BuildFromType(tpe.Rhs(), typable); err != nil { + if r.Ctx.TransparentAliases() { + sb := schema.NewBuilder(r.Ctx, r.Decl) + if err := sb.Build(schema.OptionFor(tpe.Rhs(), typable)); err != nil { return err } - r.postDecls = append(r.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + r.AppendPostDecl(d) + } return nil } - decl, ok := r.ctx.FindModel(o.Pkg().Path(), o.Name()) + // Non-body or RefAliases-off ⇒ expand. See + // [§alias-handling](./README.md#alias-handling). + if typable.In() != inBody || !r.Ctx.RefAliases() { + unaliased := types.Unalias(tpe) + return r.buildFromField(fld, unaliased, typable, seen) + } + + decl, ok := r.Ctx.FindModel(o.Pkg().Path(), o.Name()) if !ok { return fmt.Errorf("can't find source file for aliased type: %v: %w", tpe, ErrResponses) } - r.postDecls = append(r.postDecls, decl) // mark the left-hand side as discovered + r.AppendPostDecl(decl) // mark the left-hand side as discovered - return r.makeRef(decl, typable) + return r.MakeRef(decl, typable) } -func (r *ResponseBuilder) buildFromStruct(decl *scanner.EntityDecl, tpe *types.Struct, resp *oaispec.Response, seen map[string]bool) error { +func (r *Builder) buildFromStruct(decl *scanner.EntityDecl, tpe *types.Struct, resp *oaispec.Response, seen map[string]bool) error { if tpe.NumFields() == 0 { return nil } @@ -314,7 +332,7 @@ func (r *ResponseBuilder) buildFromStruct(decl *scanner.EntityDecl, tpe *types.S continue } if fld.Anonymous() { - logger.DebugLogf(r.ctx.Debug(), "skipping anonymous field") + logger.DebugLogf(r.Ctx.Debug(), "skipping anonymous field") continue } @@ -331,19 +349,22 @@ func (r *ResponseBuilder) buildFromStruct(decl *scanner.EntityDecl, tpe *types.S return nil } -func (r *ResponseBuilder) processResponseField(fld *types.Var, decl *scanner.EntityDecl, resp *oaispec.Response, seen map[string]bool) error { +func (r *Builder) processResponseField(fld *types.Var, decl *scanner.EntityDecl, resp *oaispec.Response, seen map[string]bool) error { if !fld.Exported() { + logger.DebugLogf(r.Ctx.Debug(), "skipping field %s because it's not exported", fld.Name()) return nil } afld := resolvers.FindASTField(decl.File, fld.Pos()) if afld == nil { - logger.DebugLogf(r.ctx.Debug(), "can't find source associated with %s", fld.String()) + logger.DebugLogf(r.Ctx.Debug(), "can't find source associated with %s", fld.String()) return nil } - if parsers.Ignored(afld.Doc) { - logger.DebugLogf(r.ctx.Debug(), "field %v is deliberately ignored", fld) + signals := scanFieldDocSignals(r.ParseBlocks(afld.Doc), afld.Doc) + + if signals.ignored { + logger.DebugLogf(r.Ctx.Debug(), "field %v is deliberately ignored", fld) return nil } @@ -355,40 +376,56 @@ func (r *ResponseBuilder) processResponseField(fld *types.Var, decl *scanner.Ent return nil } - // scan for param location first, this changes some behavior down the line - in, _ := parsers.ParamLocation(afld.Doc) + // `in:` is the body/header annotation switch (Q1, default header). + // See [§in-discriminator](./README.md#in-discriminator). + in := signals.in + if !signals.inSet { + in = inHeader + } + if signals.invalidIn != "" { + r.RecordDiagnostic(grammar.Warnf( + r.Ctx.PosOf(afld.Pos()), + grammar.CodeInvalidAnnotation, + "unrecognised `in: %s` on response field %q (vocabulary: query/path/header/body/formData); defaulting to header", + signals.invalidIn, name, + )) + } ps := resp.Headers[name] - // support swagger:file for response - // An API operation can return a file, such as an image or PDF. In this case, - // define the response schema with type: file and specify the appropriate MIME types in the produces section. - if afld.Doc != nil && parsers.FileParam(afld.Doc) { + // `swagger:file` is body-only (Q3); on a header it would corrupt + // the body schema. See [§file-body](./README.md#file-body). + useFileBody := signals.file && in == inBody + if signals.file && !useFileBody { + r.RecordDiagnostic(grammar.Warnf( + r.Ctx.PosOf(afld.Pos()), + grammar.CodeUnsupportedInSimpleSchema, + "`swagger:file` is only valid on a body response field (in: body); ignored on response field %q (in=%q). Allowed header types: string/number/integer/boolean/array.", + name, in, + )) + } + + if useFileBody { resp.Schema = &oaispec.Schema{} resp.Schema.Typed("file", "") } else { - logger.DebugLogf(r.ctx.Debug(), "build response %v (%v) (not a file)", fld, fld.Type()) - if err := r.buildFromField(fld, fld.Type(), responseTypable{in, &ps, resp, r.ctx.SkipExtensions()}, seen); err != nil { + logger.DebugLogf(r.Ctx.Debug(), "build response %v (%v) (not a file)", fld, fld.Type()) + var refAttempted bool + if err := r.buildFromField(fld, fld.Type(), responseTypable{ + in: in, + header: &ps, + response: resp, + skipExt: r.Ctx.SkipExtensions(), + refAttempted: &refAttempted, + }, seen); err != nil { return err } } - if strfmtName, ok := parsers.StrfmtName(afld.Doc); ok { - ps.Typed("string", strfmtName) + if signals.strfmtSet { + ps.Typed("string", signals.strfmt) } - taggers, err := setupResponseHeaderTaggers(&ps, name, afld) - if err != nil { - return err - } - - sp := parsers.NewSectionedParser( - parsers.WithSetDescription(func(lines []string) { ps.Description = parsers.JoinDropLast(lines) }), - parsers.WithTaggers(taggers...), - ) - - if err := sp.Parse(afld.Doc); err != nil { - return err - } + r.applyBlockToHeader(afld, &ps) if in != "body" { seen[name] = true @@ -401,15 +438,3 @@ func (r *ResponseBuilder) processResponseField(fld *types.Var, decl *scanner.Ent return nil } -func (r *ResponseBuilder) makeRef(decl *scanner.EntityDecl, prop ifaces.SwaggerTypable) error { - nm, _ := decl.Names() - ref, err := oaispec.NewRef("#/definitions/" + nm) - if err != nil { - return err - } - - prop.SetRef(ref) - r.postDecls = append(r.postDecls, decl) // mark the $ref target as discovered - - return nil -} diff --git a/internal/builders/responses/responses_test.go b/internal/builders/responses/responses_test.go index 2117559..7769ac2 100644 --- a/internal/builders/responses/responses_test.go +++ b/internal/builders/responses/responses_test.go @@ -20,6 +20,54 @@ const ( paramID = "id" ) +// TestParseResponses_OptionVariants captures (SkipExtensions, +// DescWithRef) option permutations on the classification responses +// corpus into separately-named goldens. Same response set as +// TestParseResponses; the matrix verifies $ref'd-field shape under +// each option pair. See parameters/TestParamsParser_OptionVariants +// for the full matrix rationale. +func TestParseResponses_OptionVariants(t *testing.T) { + cases := []struct { + name string + skipExt bool + descRef bool + goldenFile string + }{ + {"default", false, false, "classification_responses.json"}, + {"DescWithRef", false, true, "classification_responses_descwithref.json"}, + {"SkipExt", true, false, "classification_responses_skipext.json"}, + {"SkipExt+DescWithRef", true, true, "classification_responses_skipext_descwithref.json"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sctx, err := scanner.NewScanCtx(&scanner.Options{ + Packages: []string{ + "./goparsing/classification", + "./goparsing/classification/models", + "./goparsing/classification/operations", + }, + WorkDir: scantest.FixturesDir(), + SkipExtensions: tc.skipExt, + DescWithRef: tc.descRef, + }) + require.NoError(t, err) + responses := make(map[string]spec.Response) + responseNames := []string{ + "ComplexerOne", "SimpleOnes", "SimpleOnesFunc", "ComplexerPointerOne", + "SomeResponse", "ValidationError", "Resp", "FileResponse", + "GenericError", "ValidationError", + } + for _, rn := range responseNames { + td := getResponse(sctx, rn) + require.NotNil(t, td) + prs := NewBuilder(sctx, td) + require.NoError(t, prs.Build(responses)) + } + scantest.CompareOrDumpJSON(t, responses, tc.goldenFile) + }) + } +} + func TestParseResponses(t *testing.T) { sctx := scantest.LoadClassificationPkgsCtx(t) responses := make(map[string]spec.Response) @@ -31,10 +79,7 @@ func TestParseResponses(t *testing.T) { for _, rn := range responseNames { td := getResponse(sctx, rn) require.NotNil(t, td) - prs := &ResponseBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(responses)) } @@ -301,10 +346,7 @@ func TestParseResponses_TransparentAliases(t *testing.T) { // Build the response map using the transparent alias fixtures. responses := make(map[string]spec.Response) - prs := &ResponseBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(responses)) resp, ok := responses["transparentAliasResponse"] @@ -327,10 +369,7 @@ func TestParseResponses_Issue2007(t *testing.T) { sctx := scantest.LoadClassificationPkgsCtx(t) responses := make(map[string]spec.Response) td := getResponse(sctx, "GetConfiguration") - prs := &ResponseBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(responses)) resp := responses["GetConfiguration"] @@ -350,10 +389,7 @@ func TestParseResponses_Issue2011(t *testing.T) { responses := make(map[string]spec.Response) td := getResponse(sctx, "NumPlatesResp") require.NotNil(t, td) - prs := &ResponseBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(responses)) resp := responses["NumPlatesResp"] @@ -371,16 +407,13 @@ func TestParseResponses_Issue2145(t *testing.T) { require.NoError(t, err) responses := make(map[string]spec.Response) td := getResponse(sctx, "GetProductsResponse") - prs := &ResponseBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(responses)) resp := responses["GetProductsResponse"] require.Empty(t, resp.Headers) require.NotNil(t, resp.Schema) - assert.NotEmpty(t, prs.postDecls) // should have Product + assert.NotEmpty(t, prs.PostDeclarations()) // should have Product scantest.CompareOrDumpJSON(t, responses, "product_responses.json") } @@ -398,10 +431,7 @@ func TestGo118ParseResponses_Issue2011(t *testing.T) { sctx := scantest.LoadGo118ClassificationPkgsCtx(t) responses := make(map[string]spec.Response) td := getResponse(sctx, "NumPlatesResp") - prs := &ResponseBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(responses)) resp := responses["NumPlatesResp"] diff --git a/internal/builders/responses/taggers.go b/internal/builders/responses/taggers.go deleted file mode 100644 index 79e9b77..0000000 --- a/internal/builders/responses/taggers.go +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package responses - -import ( - "go/ast" - "slices" - - "github.com/go-openapi/codescan/internal/builders/items" - "github.com/go-openapi/codescan/internal/parsers" - oaispec "github.com/go-openapi/spec" -) - -// baseResponseHeaderTaggers configures taggers for a response header field. -func baseResponseHeaderTaggers(header *oaispec.Header) []parsers.TagParser { - return []parsers.TagParser{ - // Match-only: claim `in: header` so it does not leak into the header's description. - parsers.NewSingleLineTagParser("in", parsers.NewMatchIn()), - parsers.NewSingleLineTagParser("maximum", parsers.NewSetMaximum(headerValidations{header})), - parsers.NewSingleLineTagParser("minimum", parsers.NewSetMinimum(headerValidations{header})), - parsers.NewSingleLineTagParser("multipleOf", parsers.NewSetMultipleOf(headerValidations{header})), - parsers.NewSingleLineTagParser("minLength", parsers.NewSetMinLength(headerValidations{header})), - parsers.NewSingleLineTagParser("maxLength", parsers.NewSetMaxLength(headerValidations{header})), - parsers.NewSingleLineTagParser("pattern", parsers.NewSetPattern(headerValidations{header})), - parsers.NewSingleLineTagParser("collectionFormat", parsers.NewSetCollectionFormat(headerValidations{header})), - parsers.NewSingleLineTagParser("minItems", parsers.NewSetMinItems(headerValidations{header})), - parsers.NewSingleLineTagParser("maxItems", parsers.NewSetMaxItems(headerValidations{header})), - parsers.NewSingleLineTagParser("unique", parsers.NewSetUnique(headerValidations{header})), - parsers.NewSingleLineTagParser("enum", parsers.NewSetEnum(headerValidations{header})), - parsers.NewSingleLineTagParser("default", parsers.NewSetDefault(&header.SimpleSchema, headerValidations{header})), - parsers.NewSingleLineTagParser("example", parsers.NewSetExample(&header.SimpleSchema, headerValidations{header})), - } -} - -func setupResponseHeaderTaggers(header *oaispec.Header, name string, afld *ast.Field) ([]parsers.TagParser, error) { - // TODO(claude): don't understand why we need this step. Isn't it handled by the recursion already? - if ftped, ok := afld.Type.(*ast.ArrayType); ok { - taggers, err := items.ParseArrayTypes([]parsers.TagParser{}, name, ftped.Elt, header.Items, 0) - if err != nil { - return nil, err - } - - return slices.Concat(taggers, baseResponseHeaderTaggers(header)), nil - } - - return baseResponseHeaderTaggers(header), nil -} diff --git a/internal/builders/responses/typable.go b/internal/builders/responses/typable.go index f3feae3..cc3cef8 100644 --- a/internal/builders/responses/typable.go +++ b/internal/builders/responses/typable.go @@ -4,10 +4,10 @@ package responses import ( - "github.com/go-openapi/codescan/internal/builders/items" + "github.com/go-openapi/codescan/internal/builders/resolvers" "github.com/go-openapi/codescan/internal/builders/schema" + "github.com/go-openapi/codescan/internal/builders/validations" "github.com/go-openapi/codescan/internal/ifaces" - "github.com/go-openapi/codescan/internal/parsers" oaispec "github.com/go-openapi/spec" ) @@ -18,6 +18,10 @@ type responseTypable struct { header *oaispec.Header response *oaispec.Response skipExt bool + + // refAttempted: caller-owned flag flipped when SetRef is called + // under non-body mode. See [§typable](./README.md#typable). + refAttempted *bool } func (ht responseTypable) In() string { return ht.in } @@ -41,12 +45,20 @@ func (ht responseTypable) Items() ifaces.SwaggerTypable { //nolint:ireturn // po ht.header.Type = "array" - return items.NewTypable(ht.header.Items, 1, "header") + return resolvers.NewItemsTypable(ht.header.Items, 1, "header") } +// SetRef writes the ref onto the body schema in body mode; under +// non-body it no-ops and flips refAttempted (Q2). See +// [§typable](./README.md#typable). func (ht responseTypable) SetRef(ref oaispec.Ref) { - // having trouble seeing the usefulness of this one here - ht.Schema().Ref = ref + if ht.in == inBody { + ht.Schema().Ref = ref + return + } + if ht.refAttempted != nil { + *ht.refAttempted = true + } } func (ht responseTypable) Schema() *oaispec.Schema { @@ -69,6 +81,25 @@ func (ht responseTypable) WithEnumDescription(_ string) { // no } +// SimpleSchemaShape satisfies schema.SimpleSchemaProbe (non-body +// path; body uses WithType). See [§typable](./README.md#typable). +func (ht responseTypable) SimpleSchemaShape() *oaispec.SimpleSchema { + return &ht.header.SimpleSchema +} + +// HasRef satisfies schema.SimpleSchemaProbe. True when a non-body +// SetRef attempt was recorded — the exit validator emits +// CodeUnsupportedInSimpleSchema. See [§typable](./README.md#typable). +func (ht responseTypable) HasRef() bool { + return ht.refAttempted != nil && *ht.refAttempted +} + +// ResetForViolation satisfies schema.SimpleSchemaProbe. Wipes the +// header's SimpleSchema back to `{}`. +func (ht responseTypable) ResetForViolation() { + ht.header.SimpleSchema = oaispec.SimpleSchema{} +} + type headerValidations struct { current *oaispec.Header } @@ -116,7 +147,7 @@ func (sv headerValidations) SetCollectionFormat(val string) { } func (sv headerValidations) SetEnum(val string) { - sv.current.Enum = parsers.ParseEnum(val, &oaispec.SimpleSchema{Type: sv.current.Type, Format: sv.current.Format}) + sv.current.Enum = validations.ParseEnumValues(val, sv.current.Type, sv.current.Format) } func (sv headerValidations) SetDefault(val any) { sv.current.Default = val } diff --git a/internal/builders/responses/walker.go b/internal/builders/responses/walker.go new file mode 100644 index 0000000..dcea55a --- /dev/null +++ b/internal/builders/responses/walker.go @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package responses + +import ( + "go/ast" + + "github.com/go-openapi/codescan/internal/builders/handlers" + "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/parsers/grammar" + oaispec "github.com/go-openapi/spec" +) + +// headerItemsLevelTarget pairs a 1-indexed nesting depth (matching +// grammar.Property.ItemsDepth) with an *oaispec.Items target into +// which items-level validations at that depth must be written. +type headerItemsLevelTarget struct { + level int + items *oaispec.Items +} + +// collectHeaderItemsLevels walks the AST array layers of a response +// header field and returns the (level, items) pairs reachable from +// the header's items chain. Mirrors items.ParseArrayTypes' shape on +// the grammar path; identical recursion shape to parameters' +// equivalent helper. +// +// Starting level is 1 — `items.maximum:` has ItemsDepth=1 in the +// grammar lexer. Named/aliased array types opt out (parity with +// v1's tagger pipeline). +func collectHeaderItemsLevels(expr ast.Expr, it *oaispec.Items, level int) []headerItemsLevelTarget { + if it == nil { + return nil + } + + here := headerItemsLevelTarget{level: level, items: it} + + switch e := expr.(type) { + case *ast.ArrayType: + rest := collectHeaderItemsLevels(e.Elt, it.Items, level+1) + out := make([]headerItemsLevelTarget, 0, 1+len(rest)) + return append(append(out, here), rest...) + + case *ast.Ident: + rest := collectHeaderItemsLevels(expr, it.Items, level+1) + if e.Obj == nil { + out := make([]headerItemsLevelTarget, 0, 1+len(rest)) + return append(append(out, here), rest...) + } + return rest + + case *ast.StarExpr: + return collectHeaderItemsLevels(e.X, it, level) + + case *ast.SelectorExpr: + return []headerItemsLevelTarget{here} + + case *ast.StructType, *ast.InterfaceType, *ast.MapType: + return nil + + default: + return nil + } +} + +// applyBlockToDecl parses the top-level response doc through grammar +// and writes the description to resp.Description via the grammar +// parser's prose accumulator. Does not dispatch any property +// keywords — the v1 SectionedParser only accepted description at the +// top level, no taggers. +func (r *Builder) applyBlockToDecl(resp *oaispec.Response) { + block := r.ParseBlock(r.Decl.Comments) + resp.Description = block.Prose() +} + +// applyBlockToHeader parses afld.Doc through grammar and dispatches +// description, header validations, items-level validations, and +// user-authored vendor extensions into ps. +// +// # Details +// +// See [§dispatch](./README.md#dispatch) — the three-phase Walker +// dispatch for headers, the omitted `required:` write, and how +// items-level validations chain. +func (r *Builder) applyBlockToHeader(afld *ast.Field, header *oaispec.Header) { + block := r.ParseBlock(afld.Doc) + + header.Description = block.Prose() + dispatchHeaderLevel0(block, header, r.RecordDiagnostic) + + if arrayType, ok := afld.Type.(*ast.ArrayType); ok { + for _, tgt := range collectHeaderItemsLevels(arrayType.Elt, header.Items, 1) { + r.walkHeaderItemsLevel(block, tgt.items, tgt.level) + } + } +} + +// dispatchHeaderLevel0 routes every level-0 Property in block onto +// header via the grammar Walker. Standalone (not a method) so unit +// tests can drive it without building a full Builder. +// Mirrors parameters' dispatchParamLevel0 shape minus `required:` +// (no required on headers). +// +// diag may be nil; when nil, parser diagnostics are dropped. +func dispatchHeaderLevel0(block grammar.Block, header *oaispec.Header, diag func(grammar.Diagnostic)) { + valid := headerValidations{header} + scheme := &header.SimpleSchema + + block.Walk(grammar.Walker{ + FilterDepth: 0, + Number: handlers.Number(valid), + Integer: handlers.Integer(valid), + Bool: handlers.UniqueBool(valid), + String: handlers.ComposeString( + handlers.PatternString(valid), + handlers.CollectionFormatString(valid), + ), + Raw: handlers.Raw(valid, scheme, nil), + Extension: handlers.Extension(header), + Diagnostic: diag, + }) +} + +// walkHeaderItemsLevel dispatches Property entries at the given +// items depth onto target via grammar's Walker through the +// resolvers.ItemsValidations adapter. +func (r *Builder) walkHeaderItemsLevel(block grammar.Block, target *oaispec.Items, depth int) { + valid := resolvers.NewItemsValidations(target) + scheme := &target.SimpleSchema + + block.Walk(grammar.Walker{ + FilterDepth: depth, + Number: handlers.Number(valid), + Integer: handlers.Integer(valid), + Bool: handlers.UniqueBool(valid), + String: handlers.ComposeString( + handlers.PatternString(valid), + handlers.CollectionFormatString(valid), + ), + Raw: handlers.Raw(valid, scheme, nil), + Diagnostic: r.RecordDiagnostic, + }) +} diff --git a/internal/builders/responses/walker_test.go b/internal/builders/responses/walker_test.go new file mode 100644 index 0000000..e439c55 --- /dev/null +++ b/internal/builders/responses/walker_test.go @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package responses + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" + + "github.com/go-openapi/codescan/internal/parsers/grammar" + oaispec "github.com/go-openapi/spec" +) + +// ---------- collectHeaderItemsLevels ---------- + +func fieldType(t *testing.T, expr string) ast.Expr { + t.Helper() + e, err := parser.ParseExpr(expr) + if err != nil { + t.Fatalf("parseExpr %q: %v", expr, err) + } + return e +} + +func arrayTypeElt(t *testing.T, expr string) ast.Expr { + t.Helper() + at, ok := fieldType(t, expr).(*ast.ArrayType) + if !ok { + t.Fatalf("expected ArrayType for %q", expr) + } + return at.Elt +} + +func newItemsChain(depth int) *oaispec.Items { + if depth <= 0 { + return nil + } + root := new(oaispec.Items) + cur := root + for range depth - 1 { + cur.Items = new(oaispec.Items) + cur = cur.Items + } + return root +} + +func TestCollectHeaderItemsLevelsFlatSlice(t *testing.T) { + it := newItemsChain(1) + got := collectHeaderItemsLevels(arrayTypeElt(t, "[]string"), it, 1) + if len(got) != 1 || got[0].level != 1 || got[0].items != it { + t.Errorf("[]string: got %+v", got) + } +} + +func TestCollectHeaderItemsLevelsNestedSlice(t *testing.T) { + it := newItemsChain(2) + got := collectHeaderItemsLevels(arrayTypeElt(t, "[][]string"), it, 1) + if len(got) != 2 { + t.Fatalf("[][]string: got %d entries", len(got)) + } + if got[0].level != 1 || got[0].items != it { + t.Errorf("level 1: %+v", got[0]) + } + if got[1].level != 2 || got[1].items != it.Items { + t.Errorf("level 2: %+v", got[1]) + } +} + +func TestCollectHeaderItemsLevelsNilItems(t *testing.T) { + got := collectHeaderItemsLevels(arrayTypeElt(t, "[]string"), nil, 1) + if len(got) != 0 { + t.Errorf("nil items: got %+v", got) + } +} + +// ---------- dispatchHeaderLevel0 ---------- + +//nolint:ireturn // grammar.Block is the package's polymorphic return. +func parseResponseBody(t *testing.T, body string) grammar.Block { + t.Helper() + p := grammar.NewParser(token.NewFileSet()) + return p.ParseAs(grammar.AnnResponse, body, token.Position{Line: 1}) +} + +func runDispatch(t *testing.T, header *oaispec.Header, body string) { + t.Helper() + b := parseResponseBody(t, body) + dispatchHeaderLevel0(b, header, nil) +} + +func TestDispatchHeaderKeywordNumeric(t *testing.T) { + h := &oaispec.Header{} + h.Type = "integer" + runDispatch(t, h, "maximum: <10\nminimum: >=0\nmultipleOf: 2") + + if h.Maximum == nil || *h.Maximum != 10 || !h.ExclusiveMaximum { + t.Errorf("maximum: got (%v, %v)", h.Maximum, h.ExclusiveMaximum) + } + if h.Minimum == nil || *h.Minimum != 0 || h.ExclusiveMinimum { + t.Errorf("minimum: got (%v, %v)", h.Minimum, h.ExclusiveMinimum) + } + if h.MultipleOf == nil || *h.MultipleOf != 2 { + t.Errorf("multipleOf: got %v", h.MultipleOf) + } +} + +func TestDispatchHeaderKeywordIntegerAndUnique(t *testing.T) { + h := &oaispec.Header{} + runDispatch(t, h, "minLength: 3\nmaxLength: 10\nminItems: 1\nmaxItems: 100\nunique: true") + + if h.MinLength == nil || *h.MinLength != 3 { + t.Errorf("minLength: %v", h.MinLength) + } + if h.MaxLength == nil || *h.MaxLength != 10 { + t.Errorf("maxLength: %v", h.MaxLength) + } + if h.MinItems == nil || *h.MinItems != 1 { + t.Errorf("minItems: %v", h.MinItems) + } + if h.MaxItems == nil || *h.MaxItems != 100 { + t.Errorf("maxItems: %v", h.MaxItems) + } + if !h.UniqueItems { + t.Errorf("unique: want true") + } +} + +func TestDispatchHeaderKeywordPatternAndEnum(t *testing.T) { + h := &oaispec.Header{} + h.Type = "string" + runDispatch(t, h, "pattern: ^[a-z]+$\nenum: red, green, blue") + + if h.Pattern != "^[a-z]+$" { + t.Errorf("pattern: %q", h.Pattern) + } + if len(h.Enum) != 3 || h.Enum[0] != "red" { + t.Errorf("enum: %v", h.Enum) + } +} + +func TestDispatchHeaderKeywordDefaultExampleScheme(t *testing.T) { + h := &oaispec.Header{} + h.Type = "integer" + runDispatch(t, h, "default: 42\nexample: 7") + + if h.Default != 42 { + t.Errorf("default: got %v (%T), want 42", h.Default, h.Default) + } + if h.Example != 7 { + t.Errorf("example: got %v (%T), want 7", h.Example, h.Example) + } +} + +func TestDispatchHeaderKeywordCollectionFormat(t *testing.T) { + h := &oaispec.Header{} + runDispatch(t, h, "collectionFormat: csv") + + if h.CollectionFormat != "csv" { + t.Errorf("collectionFormat: %q", h.CollectionFormat) + } +} diff --git a/internal/builders/routes/README.md b/internal/builders/routes/README.md new file mode 100644 index 0000000..c7b7ae4 --- /dev/null +++ b/internal/builders/routes/README.md @@ -0,0 +1,195 @@ +# `internal/builders/routes` — maintainers' guide + +Builds OAS v2 path entries (`swagger:route`) — Summary, Description, +schemes, deprecated, consumes, produces, security, parameters, +responses, extensions. One `Builder` per route annotation; one +grammar parse per route. + +## Sections + +- [§overview](#overview) — package shape and per-file responsibilities +- [§builder](#builder) — `Builder`, `Build`, the dispatch chain +- [§dispatch](#dispatch) — `dispatchRouteKeyword` per-keyword routing +- [§body-parsers](#body-parsers) — `body_params.go` / `body_responses.go` +- [§extensions](#extensions) — typed-extensions path via grammar's lexer +- [§quirks](#quirks) — known behavioural caveats + +--- + +## §overview — files and responsibilities + +| File | Contents | +|------|----------| +| `routes.go` | `Builder` (embeds `*common.Builder`), `Inputs`, `NewBuilder`, top-level `Build` | +| `walker.go` | `applyBlockToRoute` + `dispatchRouteKeyword` — the grammar dispatch | +| `setters.go` | Per-keyword closure factories (`opParamSetter`, `opResponsesSetter`, `opExtensionsSetter`) that bridge the body parsers' write callbacks to the operation target | +| `body_params.go` | `SetOpParams.Parse([]string)` — the `+ name: …` parameter block parser | +| `body_responses.go` | `SetOpResponses.Parse([]string)` — the `200: body Foo description …` status-code → response-ref mapping parser | +| `errors.go` | `ErrRoutes` sentinel (used by every parser in the package) | + +Extensions are NOT parsed here — they ride grammar's typed-extensions +surface directly. See [§extensions](#extensions). + +The builder embeds `*common.Builder` (Ctx, ParseBlocks cache, +diagnostic sink). `Decl` is nil — routes build off a path +annotation, not a declaration; `MakeRef` / Decl-anchored helpers +must not be called. + +## §builder — top-level `Build` + +`Build(tgt *spec.Paths)` looks up the path-item slot on `tgt`, +allocates or reuses the operation for the HTTP verb via +`operations.SetPathOperation` (same helper M4 cleaned up), attaches +the route's `Tags`, then dispatches into `applyBlockToRoute` for the +header content and per-keyword bodies. + +`applyBlockToRoute` parses `route.Remaining` (the `*ast.CommentGroup` +after the `swagger:route` header line has been stripped by +`parsers.ParseRoutePathAnnotation`) into a grammar Block. +`block.Title()` and `block.Description()` give the lexer-classified +prose; `block.Properties()` yields one entry per recognised +route-level keyword. Items-depth entries (`items.maximum:` and +friends) are skipped — they would belong to a nested schema, not +the route header. + +## §dispatch — `dispatchRouteKeyword` + +One switch over `p.Keyword.Name`. Per-shape: + +- **Inline value** (`schemes`, `deprecated`): + - `schemes`: grammar's lexer types `KwSchemes` as `ShapeCommaList` + — the typed form isn't materialised, so we hand the raw + `Property.Value` to `helpers.SchemesList`. + - `deprecated`: typed as `ShapeBool`; read `p.Typed.Boolean` after + an `IsTyped()` guard so malformed inputs (which leave + `ShapeNone`) are skipped silently. +- **Raw block — list of items** (`consumes`, `produces`, `security`): + Split `Property.Body` to `[]string` via `bodyLines` and feed + `helpers.YAMLListBody` (consumes / produces) or + `helpers.SecurityRequirements` (security). +- **Raw block — body parser** (`parameters`, `responses`): Split to + `[]string` and hand off to the per-keyword body parsers + documented below. + +The body lines split is a small helper in `walker.go` (`bodyLines`): +returns `nil` for an empty body so each body parser's "no content" +short-circuit fires correctly, and drops the trailing empty element +when the body ends with `\n`. + +`extensions:` is not on the dispatcher — see +[§extensions](#extensions). + +## §body-parsers — relocated from `internal/parsers/routebody` + +Two body parsers carry shapes too domain-specific to express in +grammar's keyword table: + +- **`SetOpParams`** (`body_params.go`) — the `+ name:` block syntax + used to describe route parameters inline. +- **`SetOpResponses`** (`body_responses.go`) — the + `200: body Foo description text` mini-tag-language for + status-code → response-or-definition mappings. + +Their original home was `internal/parsers/routebody` (the "last +citadel of the pre-grammar pipeline" per the M5 plan). M5 lifted +the files into the routes builder and dropped the dead +matcher/regex surface that the v1 SectionedParser used to discover +section heads (the `Matches()` methods and the `rxParameters` / +`rxResponses` regexes) — grammar's keyword table provides the same +routing via `Property.Keyword.Name`. The body parsers' internal +regex logic survives unchanged; a follow-up may rewrite them on +grammar's lexer if/when the body grammars stabilise into a typed +surface. + +The third v1 body parser — `SetOpExtensions` — was deleted outright +during M5: grammar's lexer already routes `extensions:` raw blocks +through `yaml.TypedExtensions` and exposes typed entries via +`block.Extensions()`. The custom regex+stack walker was duplicating +that work and producing string-only values where every other +builder produces JSON-typed values. See [§extensions](#extensions). + +## §extensions — typed via grammar's lexer + +Routes consumes vendor extensions via the same path schema / +parameters / responses use: + +```go +for ext := range block.Extensions() { + op.AddExtension(ext.Name, ext.Value) +} +``` + +Grammar's lexer recognises `extensions:` (and `infoExtensions:`) +raw blocks and, at lex time, runs the body through +`yaml.TypedExtensions`: + +1. `normaliseExtensionBody` (dedent + tab→space conversion) +2. `yaml.Unmarshal` into `any` +3. `yamlutils.YAMLToJSON` to coerce to JSON-typed values +4. `json.Unmarshal` into `map[string]any` + +The result is exposed on the parsed Block as `[]Extension` with each +entry carrying `Name`, `Pos`, and the JSON-typed `Value` +(`bool` / `float64` / `string` / `[]any` / `map[string]any`). + +### Why not the regex+stack walker + +The v1 `SetOpExtensions` predates round-2 typed-extensions. It +parsed each `x-*` body line-by-line via regex, building either a +`map[string]string`, `map[string]*[]string`, or `map[string]any` +based on shape detection. Every leaf landed as a string — +`x-some-flag: false` became `"false"` (string), not `false` (bool). + +That string-flattening was a regression vs the rest of the +codebase, where extensions were JSON-typed. M5 deleted the +duplicate parser and pointed routes at the shared +`block.Extensions()` surface; the goldens picked up the type +correction (`"x-some-flag": "false"` → `false`). + +## §quirks + +Capture per-quirk as it surfaces. Section grows as the migration +matures. + +### Block-comment prefix on Title / Description + +Route docs are most often `/* ... */` block comments. Each non-first +line of such a comment carries the godoc-style leading tab / +whitespace indent that `//`-style line comments shed naturally +(grammar's preprocessor strips the `// ` prefix per line). Grammar's +lexer classifies the prose correctly (Title vs Desc, markdown ATX +heading stripping included) but preserves the raw source text. + +`walker.go:trimCommentPrefix` shaves the per-line comment-marker +noise (`space`, `tab`, `/`, `*`, `-`, optional `|`) off +`block.Title()` and `block.Description()` before they reach the +operation. The set of stripped characters mirrors v1's +`rxUncommentHeaders` exactly — same recipe, no regex. + +**Why not at the lexer level:** grammar's contract is "preserve +source verbatim, classify into tokens." Stripping comment-marker +noise is a consumer-side concern; doing it at the lexer would +fight the LSP-diagnostics target (per-line file:line:col positions +must survive Preprocess). Operations doesn't need this because its +`//`-style fixtures shed the marker via the AST scanner. + +### Resolved during M5 + +- **Block-comment Title/Desc prefix** (above) — first quirk + surfaced by the routes goldens; `trimCommentPrefix` handles it. +- **Markdown ATX heading promotion** (`# Title`) — already handled + by grammar's lexer (Heuristic 3 in `classifyProseRun`). The + consumer just reads `block.Title()`; the `#` marker is already + stripped. +- **Raw-block absorbs sub-context keywords** — when a + `parameters:` body contains lines whose first word reads as a + keyword from a sub-context (`in:`, `required:`, `default:`), + grammar's lexer absorbs them as body text rather than + terminating the multi-line block. Test pinned in + `walker_test.go:TestRawBlockAbsorbsSubContextKeywords`. +- **String-flattened extensions** — see [§extensions](#extensions). + The custom routes-internal extensions parser stringified every + leaf value; grammar's typed-extensions surface produces + JSON-typed values (bool / float64 / string / list / map). Routes + now reads `block.Extensions()` directly; goldens updated to + reflect typed values (`"x-some-flag": "false"` → `false`). diff --git a/internal/parsers/route_params.go b/internal/builders/routes/body_params.go similarity index 96% rename from internal/parsers/route_params.go rename to internal/builders/routes/body_params.go index 4354ab8..5376e73 100644 --- a/internal/parsers/route_params.go +++ b/internal/builders/routes/body_params.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package parsers +package routes import ( "fmt" @@ -13,6 +13,9 @@ import ( ) const ( + // kvParts is the number of parts when splitting key:value pairs. + kvParts = 2 + // paramDescriptionKey indicates the tag used to define a parameter description in swagger:route. paramDescriptionKey = "description" // paramNameKey indicates the tag used to define a parameter name in swagger:route. @@ -74,10 +77,6 @@ func NewSetParams(params []*oaispec.Parameter, setter func([]*oaispec.Parameter) } } -func (s *SetOpParams) Matches(line string) bool { - return rxParameters.MatchString(line) -} - func (s *SetOpParams) Parse(lines []string) error { if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { return nil @@ -106,7 +105,7 @@ func (s *SetOpParams) Parse(lines []string) error { value := strings.TrimSpace(kv[1]) if current == nil { - return fmt.Errorf("invalid route/operation schema provided: %w", ErrParser) + return fmt.Errorf("invalid route/operation schema provided: %w", ErrRoutes) } applyParamField(current, extraData, key, value) @@ -126,7 +125,7 @@ func applyParamField(current *oaispec.Parameter, extraData map[string]string, ke current.Name = value case paramInKey: v := strings.ToLower(value) - if contains(validIn, v) { + if slices.Contains(validIn, v) { current.In = v } case paramRequiredKey: @@ -137,7 +136,7 @@ func applyParamField(current *oaispec.Parameter, extraData map[string]string, ke if current.Schema == nil { current.Schema = new(oaispec.Schema) } - if contains(basicTypes, value) { + if slices.Contains(basicTypes, value) { current.Type = strings.ToLower(value) if current.Type == typeBool { current.Type = typeBoolean @@ -261,7 +260,3 @@ func getType(schema *oaispec.Schema) string { } return schema.Type[0] } - -func contains(arr []string, obj string) bool { - return slices.Contains(arr, obj) -} diff --git a/internal/parsers/route_params_test.go b/internal/builders/routes/body_params_test.go similarity index 95% rename from internal/parsers/route_params_test.go rename to internal/builders/routes/body_params_test.go index da68209..8bf2f34 100644 --- a/internal/parsers/route_params_test.go +++ b/internal/builders/routes/body_params_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package parsers +package routes import ( "testing" @@ -12,15 +12,6 @@ import ( oaispec "github.com/go-openapi/spec" ) -func TestSetOpParams_Matches(t *testing.T) { - t.Parallel() - - sp := NewSetParams(nil, nil) - assert.TrueT(t, sp.Matches("parameters:")) - assert.TrueT(t, sp.Matches("Parameters:")) - assert.FalseT(t, sp.Matches("something else")) -} - func TestSetOpParams_Parse(t *testing.T) { t.Parallel() @@ -118,7 +109,7 @@ func TestSetOpParams_Parse(t *testing.T) { sp := NewSetParams(nil, func(_ []*oaispec.Parameter) {}) err := sp.Parse([]string{"name: id"}) require.Error(t, err) - assert.ErrorIs(t, err, ErrParser) + assert.ErrorIs(t, err, ErrRoutes) }) t.Run("with schema extras", func(t *testing.T) { diff --git a/internal/parsers/responses.go b/internal/builders/routes/body_responses.go similarity index 95% rename from internal/parsers/responses.go rename to internal/builders/routes/body_responses.go index 53373e4..9779f8b 100644 --- a/internal/parsers/responses.go +++ b/internal/builders/routes/body_responses.go @@ -1,11 +1,10 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package parsers +package routes import ( "fmt" - "regexp" "strconv" "strings" @@ -25,7 +24,6 @@ const ( type SetOpResponses struct { set func(*oaispec.Response, map[int]oaispec.Response) - rx *regexp.Regexp definitions map[string]oaispec.Schema responses map[string]oaispec.Response } @@ -33,16 +31,11 @@ type SetOpResponses struct { func NewSetResponses(definitions map[string]oaispec.Schema, responses map[string]oaispec.Response, setter func(*oaispec.Response, map[int]oaispec.Response)) *SetOpResponses { return &SetOpResponses{ set: setter, - rx: rxResponses, definitions: definitions, responses: responses, } } -func (ss *SetOpResponses) Matches(line string) bool { - return ss.rx.MatchString(line) -} - func (ss *SetOpResponses) Parse(lines []string) error { if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { return nil @@ -191,9 +184,9 @@ func parseTags(line string) (modelOrResponse string, arrays int, isDefinitionRef } if tag == responseTag || tag == bodyTag { - err = fmt.Errorf("valid tag %s, but not in a valid position: %w", tag, ErrParser) + err = fmt.Errorf("valid tag %s, but not in a valid position: %w", tag, ErrRoutes) } else { - err = fmt.Errorf("invalid tag: %s: %w", tag, ErrParser) + err = fmt.Errorf("invalid tag: %s: %w", tag, ErrRoutes) } // Error case diff --git a/internal/parsers/responses_test.go b/internal/builders/routes/body_responses_test.go similarity index 95% rename from internal/parsers/responses_test.go rename to internal/builders/routes/body_responses_test.go index 7fca1f7..5d10ee0 100644 --- a/internal/parsers/responses_test.go +++ b/internal/builders/routes/body_responses_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package parsers +package routes import ( "testing" @@ -12,15 +12,6 @@ import ( oaispec "github.com/go-openapi/spec" ) -func TestSetOpResponses_Matches(t *testing.T) { - t.Parallel() - - sr := NewSetResponses(nil, nil, nil) - assert.TrueT(t, sr.Matches("responses:")) - assert.TrueT(t, sr.Matches("Responses:")) - assert.FalseT(t, sr.Matches("something else")) -} - func TestSetOpResponses_Parse(t *testing.T) { t.Parallel() @@ -224,7 +215,7 @@ func TestSetOpResponses_ParseEdgeCases(t *testing.T) { // "invalid:tag" is not response/body/description → error err := sr.Parse([]string{"200: invalid:tag"}) require.Error(t, err) - assert.ErrorIs(t, err, ErrParser) + assert.ErrorIs(t, err, ErrRoutes) }) t.Run("definition found by fallback lookup", func(t *testing.T) { @@ -259,6 +250,6 @@ func TestParseTags_UntaggedValues(t *testing.T) { // response: after first value already parsed _, _, _, _, err := parseTags("body:Pet response:duplicate") require.Error(t, err) - assert.ErrorIs(t, err, ErrParser) + assert.ErrorIs(t, err, ErrRoutes) }) } diff --git a/internal/builders/routes/routes.go b/internal/builders/routes/routes.go index 5d3687f..35812ce 100644 --- a/internal/builders/routes/routes.go +++ b/internal/builders/routes/routes.go @@ -6,14 +6,21 @@ package routes import ( "fmt" + "github.com/go-openapi/codescan/internal/builders/common" "github.com/go-openapi/codescan/internal/builders/operations" "github.com/go-openapi/codescan/internal/parsers" "github.com/go-openapi/codescan/internal/scanner" oaispec "github.com/go-openapi/spec" ) +// Builder constructs OAS v2 path entries for one `swagger:route` +// annotation. Embeds *common.Builder for shared state (Ctx, +// ParseBlocks cache, diagnostic sink). Decl is unused — routes build +// off a path annotation, not a declaration — and is left nil; the +// MakeRef / Decl-anchored helpers must not be called. type Builder struct { - ctx *scanner.ScanCtx + *common.Builder + route parsers.ParsedPathContent responses map[string]oaispec.Response operations map[string]*oaispec.Operation @@ -29,7 +36,7 @@ type Inputs struct { func NewBuilder(ctx *scanner.ScanCtx, route parsers.ParsedPathContent, inputs Inputs) *Builder { return &Builder{ - ctx: ctx, + Builder: common.New(ctx, nil), route: route, responses: inputs.Responses, operations: inputs.Operations, @@ -45,13 +52,7 @@ func (r *Builder) Build(tgt *oaispec.Paths) error { ) op.Tags = r.route.Tags - sp := parsers.NewSectionedParser( - parsers.WithSetTitle(func(lines []string) { op.Summary = parsers.JoinDropLast(lines) }), - parsers.WithSetDescription(func(lines []string) { op.Description = parsers.JoinDropLast(lines) }), - parsers.WithTaggers(r.routeTaggers(op)...), - ) - - if err := sp.Parse(r.route.Remaining); err != nil { + if err := r.applyBlockToRoute(op); err != nil { return fmt.Errorf("operation (%s): %w", op.ID, err) } diff --git a/internal/builders/routes/routes_test.go b/internal/builders/routes/routes_test.go index 5c99d75..0b335a8 100644 --- a/internal/builders/routes/routes_test.go +++ b/internal/builders/routes/routes_test.go @@ -20,11 +20,9 @@ func TestRoutesParser(t *testing.T) { sctx := scantest.LoadClassificationPkgsCtx(t) var ops oaispec.Paths for apiPath := range sctx.Routes() { - prs := &Builder{ - ctx: sctx, - route: apiPath, - operations: make(map[string]*oaispec.Operation), - } + prs := NewBuilder(sctx, apiPath, Inputs{ + Operations: make(map[string]*oaispec.Operation), + }) require.NoError(t, prs.Build(&ops)) } @@ -32,7 +30,7 @@ func TestRoutesParser(t *testing.T) { po, ok := ops.Paths["/pets"] ext := make(oaispec.Extensions) - ext.Add("x-some-flag", "true") + ext.Add("x-some-flag", true) assert.TrueT(t, ok) assert.NotNil(t, po.Get) assertOperation(t, @@ -56,8 +54,8 @@ func TestRoutesParser(t *testing.T) { po, ok = ops.Paths["/orders"] ext = make(oaispec.Extensions) - ext.Add("x-some-flag", "false") - ext.Add("x-some-list", []string{"item1", "item2", "item3"}) + ext.Add("x-some-flag", false) + ext.Add("x-some-list", []any{"item1", "item2", "item3"}) ext.Add("x-some-object", map[string]any{ "key1": "value1", "key2": "value2", @@ -143,11 +141,9 @@ func TestRoutesParserBody(t *testing.T) { require.NoError(t, err) var ops oaispec.Paths for apiPath := range sctx.Routes() { - prs := &Builder{ - ctx: sctx, - route: apiPath, - operations: make(map[string]*oaispec.Operation), - } + prs := NewBuilder(sctx, apiPath, Inputs{ + Operations: make(map[string]*oaispec.Operation), + }) require.NoError(t, prs.Build(&ops)) } diff --git a/internal/builders/routes/setters.go b/internal/builders/routes/setters.go index ab6b191..c4d7a86 100644 --- a/internal/builders/routes/setters.go +++ b/internal/builders/routes/setters.go @@ -5,22 +5,6 @@ package routes import "github.com/go-openapi/spec" -func opConsumesSetter(op *spec.Operation) func([]string) { - return func(consumes []string) { op.Consumes = consumes } -} - -func opProducesSetter(op *spec.Operation) func([]string) { - return func(produces []string) { op.Produces = produces } -} - -func opSchemeSetter(op *spec.Operation) func([]string) { - return func(schemes []string) { op.Schemes = schemes } -} - -func opSecurityDefsSetter(op *spec.Operation) func([]map[string][]string) { - return func(securityDefs []map[string][]string) { op.Security = securityDefs } -} - func opResponsesSetter(op *spec.Operation) func(*spec.Response, map[int]spec.Response) { return func(def *spec.Response, scr map[int]spec.Response) { if op.Responses == nil { @@ -39,10 +23,3 @@ func opParamSetter(op *spec.Operation) func([]*spec.Parameter) { } } -func opExtensionsSetter(op *spec.Operation) func(*spec.Extensions) { - return func(exts *spec.Extensions) { - for name, value := range *exts { - op.AddExtension(name, value) - } - } -} diff --git a/internal/builders/routes/taggers.go b/internal/builders/routes/taggers.go deleted file mode 100644 index 440f717..0000000 --- a/internal/builders/routes/taggers.go +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package routes - -import ( - "github.com/go-openapi/codescan/internal/parsers" - oaispec "github.com/go-openapi/spec" -) - -func (r *Builder) routeTaggers(op *oaispec.Operation) []parsers.TagParser { - return []parsers.TagParser{ - parsers.NewMultiLineTagParser("Consumes", parsers.NewConsumesDropEmptyParser(opConsumesSetter(op)), false), - parsers.NewMultiLineTagParser("Produces", parsers.NewProducesDropEmptyParser(opProducesSetter(op)), false), - parsers.NewSingleLineTagParser("Schemes", parsers.NewSetSchemes(opSchemeSetter(op))), - parsers.NewMultiLineTagParser("Security", parsers.NewSetSecurityScheme(opSecurityDefsSetter(op)), false), - parsers.NewMultiLineTagParser("Parameters", parsers.NewSetParams(r.parameters, opParamSetter(op)), false), - parsers.NewMultiLineTagParser("Responses", parsers.NewSetResponses(r.definitions, r.responses, opResponsesSetter(op)), false), - parsers.NewSingleLineTagParser("Deprecated", parsers.NewSetDeprecatedOp(op)), - parsers.NewMultiLineTagParser("Extensions", parsers.NewSetExtensions(opExtensionsSetter(op), r.ctx.Debug()), true), - } -} diff --git a/internal/builders/routes/walker.go b/internal/builders/routes/walker.go new file mode 100644 index 0000000..71294e3 --- /dev/null +++ b/internal/builders/routes/walker.go @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package routes + +import ( + "strings" + + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/parsers/yaml" + oaispec "github.com/go-openapi/spec" +) + +// applyBlockToRoute parses route.Remaining through grammar and +// writes Summary / Description / per-keyword content onto op. +// +// Grammar's lexer classifies prose into TokenTitle / TokenDesc +// directly (no CollectScannerTitleDescription dance) and isolates +// every route-level keyword into a Property — `schemes:`, +// `deprecated:`, `consumes:`, `produces:`, `security:`, `parameters:`, +// `responses:`, `extensions:`. Level-0 properties dispatch to +// dispatchRouteKeyword; items-depth properties are skipped (they +// belong to a nested schema, not the route header). +// +// route.Remaining is the *ast.CommentGroup AFTER the swagger:route +// header line has been stripped by parsers.ParseRoutePathAnnotation; +// grammar sees it as an UnboundBlock whose Title / Description / +// Properties behave identically to a properly-anchored block. +func (r *Builder) applyBlockToRoute(op *oaispec.Operation) error { + block := grammar.NewParser(r.Ctx.FileSet()).Parse(r.route.Remaining) + + // Route docs are usually `/* ... */` block comments — every line + // keeps the godoc-style leading tab/whitespace indent that `//` + // comments shed naturally. Grammar's lexer already classifies + // prose into Title vs Desc (the markdown-ATX-heading promotion + // included), but it preserves the raw source text — so we + // shed the per-line comment-marker noise here. Operations doesn't + // need this because its `//`-style fixtures shed the marker + // upstream. See [§quirks](./README.md#quirks). + op.Summary = trimCommentPrefix(block.Title()) + op.Description = trimCommentPrefix(block.Description()) + + for prop := range block.Properties() { + if prop.ItemsDepth != 0 { + continue + } + if err := r.dispatchRouteKeyword(prop, op); err != nil { + return err + } + } + + // Extensions and security are read straight off the block — + // grammar's lexer routes their raw bodies through typed + // sub-parsers (yaml.TypedExtensions, security.Parse) at lex + // time, so the dispatcher above skips them and we read the + // typed surface here. See [§extensions](./README.md#extensions). + for ext := range block.Extensions() { + op.AddExtension(ext.Name, ext.Value) + } + if reqs := block.SecurityRequirements(); reqs != nil { + op.Security = reqs + } + + return nil +} + +// dispatchRouteKeyword routes one grammar Property to the matching +// body parser. Inline-keyword shapes (schemes comma-list, deprecated +// bool) use Property.Value / Property.Typed directly. Raw-block +// shapes (consumes / produces / security / parameters / responses) +// split Property.Body to []string and hand off to the per-keyword +// body parser. +// +// The two domain-heavy body parsers (NewSetParams / NewSetResponses) +// live in this package as body_*.go — relocated from +// internal/parsers/routebody during M5. Their internal regex logic +// survives unchanged; a follow-up may rewrite them on grammar's +// lexer if/when the surface stabilises. Extensions are NOT routed +// here — they ride grammar's typed-extensions surface directly +// (see applyBlockToRoute above). +func (r *Builder) dispatchRouteKeyword(p grammar.Property, op *oaispec.Operation) error { + switch p.Keyword.Name { + case grammar.KwSchemes: + if v := grammar.SplitCommaList(p.Value); v != nil { + op.Schemes = v + } + case grammar.KwDeprecated: + if p.IsTyped() { + op.Deprecated = p.Typed.Boolean + } + case grammar.KwConsumes: + op.Consumes = yaml.ListBody(bodyLines(p.Body)) + case grammar.KwProduces: + op.Produces = yaml.ListBody(bodyLines(p.Body)) + case grammar.KwParameters: + return NewSetParams(r.parameters, opParamSetter(op)).Parse(bodyLines(p.Body)) + case grammar.KwResponses: + return NewSetResponses(r.definitions, r.responses, opResponsesSetter(op)).Parse(bodyLines(p.Body)) + } + return nil +} + +// trimCommentPrefix strips the leading comment-marker noise (space, +// tab, `/`, `*`, `-`, optional `|`) from every line of s. Block-comment +// route docs carry godoc-level indentation that survives grammar's +// verbatim Title/Description capture; `//`-comment docs shed it +// upstream via the AST scanner. +func trimCommentPrefix(s string) string { + if s == "" { + return s + } + lines := strings.Split(s, "\n") + for i, line := range lines { + lines[i] = strings.TrimLeft(line, " \t/*-|") + } + return strings.Join(lines, "\n") +} + +// bodyLines splits a raw-block body into the []string shape the +// body parsers expect. Returns nil for an empty body so the parsers' +// "no content" short-circuits fire correctly; trailing empty element +// from a body that ends with "\n" is dropped. +func bodyLines(body string) []string { + if body == "" { + return nil + } + lines := strings.Split(body, "\n") + if n := len(lines); n > 0 && lines[n-1] == "" { + lines = lines[:n-1] + } + return lines +} diff --git a/internal/builders/routes/walker_test.go b/internal/builders/routes/walker_test.go new file mode 100644 index 0000000..24f7d0d --- /dev/null +++ b/internal/builders/routes/walker_test.go @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package routes + +import ( + "go/token" + "strings" + "testing" + + "github.com/go-openapi/codescan/internal/parsers/grammar" + oaispec "github.com/go-openapi/spec" +) + +// parseRouteBody parses the given body as a swagger:route block using +// grammar's ParseAs hook — the lexer prepends a `swagger:route` +// annotation header so the keywords' CtxRoute context applies. +// +//nolint:ireturn // grammar.Block is the package's polymorphic return. +func parseRouteBody(t *testing.T, body string) grammar.Block { + t.Helper() + p := grammar.NewParser(token.NewFileSet()) + return p.ParseAs(grammar.AnnRoute, body, token.Position{Line: 1}) +} + +func TestDispatchRouteSchemes(t *testing.T) { + var b Builder + op := &oaispec.Operation{} + + block := parseRouteBody(t, "schemes: http, https, ws") + for prop := range block.Properties() { + if err := b.dispatchRouteKeyword(prop, op); err != nil { + t.Fatalf("dispatch: %v", err) + } + } + + want := []string{"http", "https", "ws"} //nolint:goconst // test fixture literals; consts would hurt readability. + if len(op.Schemes) != len(want) { + t.Fatalf("Schemes len: got %d, want %d", len(op.Schemes), len(want)) + } + for i, s := range want { + if op.Schemes[i] != s { + t.Errorf("Schemes[%d]: got %q, want %q", i, op.Schemes[i], s) + } + } +} + +func TestDispatchRouteKeywordDeprecated(t *testing.T) { + var b Builder + op := &oaispec.Operation{} + + block := parseRouteBody(t, "deprecated: true") + for prop := range block.Properties() { + if err := b.dispatchRouteKeyword(prop, op); err != nil { + t.Fatalf("dispatch: %v", err) + } + } + + if !op.Deprecated { + t.Errorf("Deprecated: want true") + } +} + +// TestRawBlockAbsorbsSubContextKeywords verifies the grammar-level +// behaviour that lets a Parameters or Responses body contain lines +// whose first word reads as a keyword from a sub-context +// (Param / Schema / Items): they're absorbed as body text rather +// than terminating the multi-line block. Without this, `default:`, +// `in:`, `required:`, `max:` inside a Parameters body would +// prematurely stop the collection and produce a malformed spec. +func TestRawBlockAbsorbsSubContextKeywords(t *testing.T) { + body := `Parameters: ++ name: someNumber + in: path + required: true + type: number + max: 20 + min: 10 + default: 15 ++ name: flag + in: query + type: boolean +` + block := parseRouteBody(t, body) + + var params grammar.Property + for p := range block.Properties() { + if p.Keyword.Name == grammar.KwParameters { + params = p + break + } + } + if params.Keyword.Name != grammar.KwParameters { + t.Fatalf("parameters property not found") + } + + // Body must retain every source line, absorbed verbatim. We don't + // pin the exact whitespace — the lexer is free to keep or + // normalise inter-token spacing — but every value must survive + // the absorption. + for _, expected := range []string{ + "someNumber", + "path", + "required", + "max", + "default", + "flag", + "query", + typeBoolean, + } { + if !strings.Contains(params.Body, expected) { + t.Errorf("Body missing %q in:\n%s", expected, params.Body) + } + } +} diff --git a/internal/builders/schema/README.md b/internal/builders/schema/README.md new file mode 100644 index 0000000..8f4fdab --- /dev/null +++ b/internal/builders/schema/README.md @@ -0,0 +1,1251 @@ +# schema builder — maintainer notes + +This document is the long-form companion to the schema builder code. + +The source files keep godoc concise; complex invariants, design +trade-offs, and known quirks live here. + +--- + +## Table of contents + +- [§build-entry](#build-entry) — `Build` modes and dispatch entry points +- [§dispatch-table](#dispatch-table) — `buildNamedType`'s underlying-shape table +- [§dissolve-named](#dissolve-named) — when a named type is unwrapped instead of `$ref`'d +- [§special-types](#special-types) — `applyStdlibSpecials`, `applySpecialType`, UUID heuristic +- [§textmarshal-order](#textmarshal-order) — `buildFromTextMarshal` precedence +- [§aliases](#aliases) — `TransparentAliases` vs `RefAliases` vs default-expand +- [§discovery](#discovery) — `Models` / `ExtraModels` / `discovered`, dedup layers +- [§struct](#struct) — `buildFromStruct` two-pass shape +- [§allof](#allof) — `buildAllOf`, `buildNamedAllOf`, `scanEmbeddedFields` +- [§embedded](#embedded) — embed routing, struct/interface specials asymmetry +- [§embed-depth](#embed-depth) — ambiguous-embed diagnostic mechanism +- [§method-mangler](#method-mangler) — interface-method JSON-name derivation +- [§user-overrides](#user-overrides) — explicit user-driven type/format overrides at decl-site and field-site +- [§ref-override](#ref-override) — `applyToRefField`, the allOf-on-$ref shape, `refOverrideCollector`, `applyPattern` +- [§simple-schema-mode](#simple-schema-mode) — the SimpleSchema build mode for OAS v2 non-body params and response headers +- [§classifier-walkers](#classifier-walkers) — per-call-site classifier walkers and `findAnnotationArg`'s single-word filter +- [§quirks](#quirks) — known behavioural caveats + - [§quirks-resolved](#quirks-resolved) — ✅ fixed in this refactor + - [§quirks-open](#quirks-open) — 🟡 deferred / 🟦 documented behaviour + +--- + +## §build-entry — `Build` modes and dispatch entry points + +`Builder.Build` has three modes selected by [`Option`](options.go): + +| Option | Sets | Entry point | Output destination | +|---|---|---|---| +| `WithDefinitions(map)` | `s.definitions` | `buildFromDecl` | `map[s.Name]` | +| `WithType(tpe, tgt)` | `s.inputType`, `s.target` | `buildFromType(inputType, target)` | `tgt` (caller-owned, full Schema) | +| `WithSimpleSchema(tpe, tgt, in)` | `s.inputType`, `s.target`, `s.simpleSchema`, `s.paramIn` | `buildFromType(inputType, target)` + exit validator | `tgt` (caller-owned, SimpleSchema-shaped) | + +`WithDefinitions` and the `WithType`/`WithSimpleSchema` pair are +mutually exclusive; `Build` panics on misuse. `WithDefinitions` is +the spec-orchestrator entry point for top-level type declarations. +`WithType` produces a full OAS v2 Schema for body parameters, +response bodies, and any other site that owns a `Typable` and accepts +the full schema vocabulary. `WithSimpleSchema` produces an OAS v2 +SimpleSchema for non-body parameter sites and response headers — see +[§simple-schema-mode](#simple-schema-mode) for the contract and the +exit-validator's role. + +`buildFromDecl` does three things in order: + +1. Consume the decl's doc-comment block (may short-circuit on `swagger:ignore`). +2. Defer `annotateSchema` (`x-go-name`, `x-go-package` extensions). +3. Intercept stdlib special types ([§special-types](#special-types)) + before kind-dispatch. Necessary because stdlib decls can reach + `buildFromDecl` via the discovery chain (e.g. `type X = time.Time` + pulls `time.Time` itself into `discovered`). +4. Dispatch on `s.Decl.ObjType()`: `*types.Named` → `buildFromType(ti.Type, …)`; + `*types.Alias` → `buildDeclAlias`; otherwise warn-and-skip. + +--- + +## §dispatch-table — `buildNamedType`'s underlying-shape table + +`buildNamedType` handles a `*types.Named` reaching as a field/embed/etc. +type (not a top-level decl). The body is a table indexed by `Underlying()` +kind, with each arm following the same three-step shape: + +1. **shape-local pre-check** (e.g. `UnsupportedBuiltinType` for `*types.Basic`) +2. **classifier walk** — short-circuit on a match, may recurse on Underlying +3. **`FindModel` pivot** via `resolveRefOr` / `resolveRefOrErr` — hit ⇒ `makeRef`, miss ⇒ shape-specific fallback + +The arms: + +| Underlying | Classifier | On-miss fallback | +|---|---|---| +| `*types.Struct` | `classifierNamedStructStrfmt` | silent — `nil` | +| `*types.Interface` | — | `missingSource` error | +| `*types.Basic` | `classifierNamedBasic` (cascade) | `SwaggerSchemaForType(name)` | +| `*types.Array` / `*types.Slice` | `classifierNamedArrayLike(forSlice)` | inline element via `buildFromType` | +| `*types.Map` | — | silent — `nil` | + +`buildNamedArrayLike` is the unified `Array`/`Slice` helper; the slice +arm passes `forSlice=true` so `classifierNamedArrayLike` can honour +the `bsonobjectid` slice-only special case. + +--- + +## §dissolve-named — when a named type is unwrapped instead of `$ref`'d + +After the prelude but before the `Underlying()` switch, +`buildNamedType` has a short-circuit: + +```go +if s.Decl.Spec.Assign.IsValid() || (titpe.TypeArgs() != nil && titpe.TypeArgs().Len() > 0) { + return s.buildFromType(titpe.Underlying(), target) +} +``` + +Two disjuncts, both meaning "this named type has no source-level +`TypeSpec` of its own to `$ref` — emit its structural form inline." + +### Disjunct 1 — `s.Decl.Spec.Assign.IsValid()` + +This is **the TransparentAliases plumbing mechanism**. When the +outer Builder was constructed for an alias-syntax decl (`type X = Y`), +`Spec.Assign` is the position of the `=` token (valid). Every named +type reached during this Build is then inlined rather than `$ref`'d — +which is what `TransparentAliases` semantically means. + +It also covers the gotypesalias=0 legacy mode (`type X = Y` surfacing +as `*types.Named` directly), but that's an incidental side-benefit. + +Example: under `TransparentAliases=true`, a body parameter aliased as +`type AliasBody = Payload` causes `schema.Builder` to dissolve +`AliasBody` → walk `Payload`'s underlying struct → emit `{type:object, properties:{…}}` +inline on the body param. Without the disjunct, `buildNamedType` would +emit `{$ref: "#/definitions/Payload"}` — wrong for the dissolve intent. + +### Disjunct 2 — `titpe.TypeArgs().Len() > 0` + +Generic instantiations like `GenericSlice[int]`. The instantiated +`*types.Named` has no source-level `TypeSpec`; only the generic +declaration (`GenericSlice[T any] []T`) has one. Unwrapping to +`Underlying()` substitutes type params with concrete types +(`[]int`) so the schema reflects the substituted shape. + +Fixture: `fixtures/enhancements/generic-instantiation/`. + +--- + +## §special-types — `applyStdlibSpecials`, `applySpecialType`, UUID heuristic + +Three layers, all in `special_types.go`: + +- **`applySpecialType(obj, target, recognizers...)`** — the engine. + Iterates `recognizers` and applies the first match. Each recognizer + is identity-based (exact `Pkg.Path` + `Name` match) except + `recognizeUUID` which is **fuzzy** (case-insensitive name match). + +- **`applyStdlibSpecials(obj, target)`** — the canonical safe set + `{recognizeAny, recognizeTime, recognizeError, recognizeRawMessage}`. + All four are identity-based and cannot misfire on user types, + so this helper is **called uniformly at every site** that handles + a `*types.TypeName`. + +- **`recognizeUUID`** — opt-in via `applySpecialType`'s variadic. + Currently used **only by `buildFromTextMarshal`** because the + upstream `IsTextMarshaler` gate guarantees the type renders as + text, making the fuzzy name match safe. + +The seven call sites of `applyStdlibSpecials` (`buildFromDecl`, +`buildDeclAlias` RHS, `buildAlias`, `buildNamedType`, +`buildNamedEmbedded` interface arm, `processEmbeddedType` named arm, +`buildNamedAllOf` struct arm) **previously varied their recognizer +subsets**. Unification preserved goldens — the narrow subsets were +historical accumulation, not semantic. Identity checks cannot +misfire, so passing the full safe set everywhere is correct by +construction. + +--- + +## §textmarshal-order — `buildFromTextMarshal` precedence + +The function is entered from `buildFromType`'s shortcut +`hasNamedCore(tpe) && IsTextMarshaler(tpe)`. Pipeline: + +1. peel pointers (self-recurse) +2. route aliases through `buildAlias` (honour `TransparentAliases` / `RefAliases`) +3. type-assert to `*types.Named` (fallback: `{string, ""}`) +4. **classifier (`swagger:strfmt`) — explicit user intent wins** +5. **stdlib trio via `applySpecialType(recognizeError, recognizeTime, recognizeRawMessage, recognizeUUID)`** +6. `PkgForType`-miss bail (gates only the generic fallback below) +7. **generic fallback** — `{string, ""}` + `x-go-type: pkg.Name` + +The user-intent-first rule (step 4 before steps 5–6) is the same +shape applied in `buildNamedAllOf` and elsewhere. The order matters +because, e.g., a UUID-named type carrying `swagger:strfmt date` +should emit `{string, date}` — the classifier wins. + +Fixtures: `fixtures/enhancements/text-marshal/explicit_override/` +demonstrates classifier-beats-heuristic; `text-marshal/uuid_wrapping_time/` +demonstrates heuristic-still-fires when no override exists. + +### Why stdlib recognizers run **before** the `PkgForType` bail + +`PkgForType` looks `tpe` up in `s.app.AllPackages`. Stdlib packages +(`time`, `encoding/json`) often aren't there — the scanner only +registers packages it was asked to scan. Previously the stdlib +recognizers ran **after** the `PkgForType` bail, so stdlib types +reaching here when their package wasn't in `AllPackages` silently +emitted `{}`. The new order recognizes stdlib types via +`tio.Pkg()` alone (no `PkgForType` call needed) before the bail. + +--- + +## §aliases — `TransparentAliases` vs `RefAliases` vs default-expand + +`buildDeclAlias` handles top-level alias declarations. Independent +flags with deterministic precedence: + +| `TransparentAliases` | `RefAliases` | Outcome | +|---|---|---| +| true | (any) | **Dissolve** — `buildFromType(rhs, target)`. No LHS definition. Wins outright. | +| false | true | **`$ref`** — `makeRef` to the RHS's named target. | +| false | false (default) | **Expand** — `buildFromType(Underlying, target)`. LHS gets a structural definition. | + +The dissolve case propagates to nested named types via the +`Spec.Assign.IsValid()` disjunct in `buildNamedType` +([§dissolve-named](#dissolve-named)). + +### `Rhs()` vs `Underlying()` + +- `tpe.Rhs()` — immediate right-hand side of the alias declaration. + For `type X = Y`, `Rhs` is `Y` as-is (may itself be a `*types.Alias` + or `*types.Named`). +- `tpe.Underlying()` — peels through aliases **and** named types to + reach the structural form (`*types.Struct`, `*types.Basic`, …). + +Example: `type X = Y; type Y = Z; type Z = int` gives +`X.Rhs() == Y (*types.Alias)` and `X.Underlying() == int (*types.Basic)`. + +The two branches use them intentionally: + +- **Dissolve** uses `rhs` — dissolves one layer at a time; if the + RHS is itself a named/aliased type, build from it (may recurse). +- **Expand** uses `Underlying()` — fully expand the structural shape + onto the LHS definition. + +--- + +## §discovery — `Models` / `ExtraModels` / `discovered`, dedup layers + +The scanner exposes two model indexes (in `scanner.app`): + +- **`Models`** — decls carrying `swagger:model` (or response/parameter) + annotations. The orchestrator builds these unconditionally. +- **`ExtraModels`** — decls discovered transitively that aren't + annotated but need a top-level definition. Promoted to `Models` + at consumption (`joinExtraModels` calls `MoveExtraToModel`). + +The spec orchestrator also maintains an in-flight queue +`s.discovered` populated via `Builder.AppendPostDecl`: + +- **`makeRef(decl, …)`** appends `decl` to `Builder.postDecls`. +- `spec.Builder.buildDiscoveredSchema` runs schema Builds and harvests + each Builder's `PostDeclarations()` into `s.discovered`. +- `buildDiscovered` then drains `s.discovered` until empty. + +`ScanCtx.AddDiscoveredModel(decl)` is the explicit hook for the +`ExtraModels` side (replacing the now-deprecated `FindModel` +side effect). + +### Dedup layers + +Three layers prevent the same decl from being built twice: + +1. **`AppendPostDecl`** — per-Builder dedup keyed by `EntityDecl.Ident`. +2. **`AddDiscoveredModel`** — no-op when the decl is already in + `Models` (avoids `Models ↔ ExtraModels` bouncing). +3. **`spec.Builder.buildDiscovered`** — per-pass dedup keyed by + `decl.Names()` string. + +Without (3), the `TestCoverage_RefAliasChain` shape regression: the +same alias-target decl, appended via multiple `makeRef` call sites, +got queued twice in one pass, and the second `Build` read the +half-built schema and **appended another set of `allOf` entries** — +doubling them. + +--- + +## §struct — `buildFromStruct` two-pass shape + +`buildFromStruct` emits the schema for a named Go struct in two +passes over the same `*types.Struct`. The split is required because +`allOf` composition (driven by `swagger:allOf` on embedded fields) +can change *which* schema receives the property map. + +1. **Pass 1 — `scanEmbeddedFields`.** Walks every anonymous field. + Each embed is classified: + - **plain embed** (no `swagger:allOf`, embed is not an + `*types.Alias`): its fields are merged into the outer schema + directly via `buildEmbedded`. Returned `target == schema`. + - **allOf embed** (`swagger:allOf` present, or the embed is an + alias): the embedded type becomes an entry in `schema.AllOf`, + and a *fresh* schema is allocated as the target for the + property map. Returned `target == fresh schema`. +2. **Pass 2 — `buildStructFields`.** Iterates non-anonymous + exported fields and emits each via `applyFieldCarrier`. + +If `hasAllOf` is true and the fresh target ended up with +properties, the target itself is appended to `schema.AllOf` so the +inline properties live as their own compound member alongside the +embedded `$ref`s. + +### Why `target.Typed("object", "")` always fires + +The line runs unconditionally after target selection. Reason: a +struct with zero exported fields and zero embeds still emits as +`{type: object, properties: {}}` — distinguishable from a missing +schema. SimpleSchema (forthcoming with M1) introduces the only +path where this line would *not* fire; the current code is +Full-Schema only. + +### User-classifier short-circuit + +`classifierStructPreBuildType` runs at the very top of +`buildFromStruct`. It consumes only `swagger:type` on the decl's +own comment group. On match, the struct walk is skipped entirely +— the schema is whatever `swagger:type X` resolves to via +`SwaggerSchemaForType`. See [§user-overrides](#user-overrides) for +the cascade and the unknown-leaf fallthrough handled by the +caller (`buildFromDecl`), not here. + +--- + +## §allof — `buildAllOf`, `buildNamedAllOf`, `scanEmbeddedFields` + +OAS 2.0 allOf composition surfaces in three places in the schema +builder: `scanEmbeddedFields` (decides which embeds become allOf +members), `buildAllOf` (peels and dispatches an allOf member type), +and `buildNamedAllOf` (resolves a named-type allOf member through the +same user-classifier-first precedence the rest of the builder uses). + +### `scanEmbeddedFields` — embed classification + +Walks `*types.Struct`'s anonymous fields. Three signals decide +classification per embed: + +- `swagger:ignore` — embed skipped entirely. +- `json:"-"` — embed skipped (parity with v1's JSON tag handling). +- `swagger:allOf` (via `fieldDoc.IsAllOfMember`) **or** the + embedded type is `*types.Alias` — embed becomes an allOf member; + remaining properties land on a fresh target schema. +- otherwise — plain embed; properties merge into the outer schema. + +The `swagger:allOf` arg, when present, is recorded as +`x-class: ` on the outer schema (`fd.AllOfClass`). This is the +discriminator hint downstream go-swagger consumes. + +### `buildAllOf` — three-arm peel + +Strips pointers (recurses), routes `*types.Named` through +`buildNamedAllOf`, routes `*types.Alias` through `buildAlias`. Any +other input is dropped silently with a `logger.UnsupportedTypeKind` +warning — parity with v1, which had no surface for non-Named / +non-Alias allOf members. + +### `buildNamedAllOf` — symmetric arm dispatch + +Struct and interface underlyings share the same precedence shape: + +1. **classifier first** — `classifierAliasTargetStrfmt(ftpe, tgt)`. + On match the named type is emitted as `{string, }` and the + walk terminates. Same shape as + [§textmarshal-order](#textmarshal-order)'s step 4. +2. **stdlib specials** — `applyStdlibSpecials(tio, tgt, skipExt)`. + Identity-based, cannot misfire. Catches `time.Time`, `error`, + `json.RawMessage`, `any`/`interface{}` if any of them reach as an + allOf member. +3. **model lookup** — `Ctx.GetModel(pkg, name)`. Missing decl is a + build error (the allOf member must be resolvable). +4. **`HasModelAnnotation()` → `makeRef`** — annotated types become + `$ref` entries. The struct/interface body is built lazily via + `discovered`. +5. **inline build** — `buildFromStruct` / `buildFromInterface` on the + underlying. Used when the type is reachable but not + `swagger:model`-annotated. + +Both arms route through the same `tgt := NewTypable(schema, 0, skipExtensions)` +target, avoiding the v1 asymmetry where the struct arm used +`classifierAliasTargetStrfmt` (decl-fetched internally) and the +interface arm used a comment-group-keyed variant (`classifierAliasOwnDocStrfmt`, +since deleted) that pre-fetched the decl. The unified target lets +both arms run classifier-first without doing the model lookup +upfront — earlier classification means no orphan `ExtraModels` +side effect for strfmt-tagged interface types. + +### Error message on missing decl + +The missing-decl error is now uniform across arms: +`"can't find source for named allOf member %s: %w"`. Previous +phrasing differentiated struct vs interface; no test asserts on +the text, the change is golden-neutral. + +--- + +## §embedded — embed routing, struct/interface specials asymmetry + +`buildEmbedded` is the entry point for a struct's embedded fields +that the embed classifier +([§allof](#allof) §scanEmbeddedFields → plain-embed arm) routed for +inline merge into the outer schema. It splits three ways: pointers +peel (recurse), `*types.Named` descends into `buildNamedEmbedded`, +`*types.Alias` goes through `buildAlias` (so alias-resolution +honours `TransparentAliases` / `RefAliases`). + +### `buildNamedEmbedded` — the two-arm specials asymmetry + +The interface arm runs `applyStdlibSpecials(o, target, skipExt)` +**before** model lookup. The struct arm does *not*. + +The asymmetry is deliberate: + +- **Interface embed** is the common shape that surfaces `error` + promoted into a struct (`type Err struct { error }`). The stdlib + specials catch it and emit `{string}` with the `x-go-type: error` + hint, matching the field-level recognizer behaviour. +- **Struct embed of `time.Time`** is uncommon — the fixture corpus + has none. Adding `applyStdlibSpecials` to the struct arm would + change behaviour only for code paths we don't currently exercise, + and the change would surface as a golden delta on the day someone + adds such a fixture. Until then the conservative choice is to + preserve v1 parity: stdlib structs embedded as `*types.Struct` + reach `GetModel` and build through the normal `buildFromStruct` + path. If `time.Time` ends up embedded and the package isn't in + `AllPackages`, `missingSource` fires — same as v1. + +### `AddDiscoveredModel` pairing + +Both arms call `s.Ctx.AddDiscoveredModel(decl)` before recursing. +Reason: embedded user types appear as their **own** top-level +definition even when not annotated `swagger:model`. The +`discovered` queue picks them up on the next build pass. Parity +with v1. + +### `processEmbeddedType` — interface-side allOf composition + +Called from `buildNamedInterface` when walking the embedded +elements of an interface's underlying. Three shapes: + +- **`*types.Named`** — runs `applyStdlibSpecials` (dummy target + swallows the write so the recognizer can short-circuit without + contaminating `schema`), then routes through + `buildNamedInterface`. +- **`*types.Interface`** — anonymous embedded interface. Builds + into a side schema; only when the result is non-empty + (`Ref || Properties || AllOf`) is it appended to the outer + `schema.AllOf`. +- **`*types.Alias`** — same non-empty guard, builds via + `buildAlias` so alias modes are honoured. + +The non-empty guard is what makes `interface{}` and other +zero-content interfaces invisible at the allOf seam — they don't +contribute an `{}` entry to the outer schema. + +--- + +## §embed-depth — ambiguous-embed diagnostic mechanism + +`Builder.embedDepth` (incremented around `buildNamedEmbedded`'s +recursive descents via `defer s.enterEmbed()()`) tracks the depth +of embedded-type recursion in a single `buildFromStruct` / +`buildFromInterface` pass. + +`applyFieldCarrier` uses it to distinguish: + +- **`embedDepth == 0`** — legitimate explicit override (`S.Foo` + redefining the JSON name of an embedded `E.Foo`). No diagnostic. +- **`embedDepth > 0` + prior at deeper depth** — Go's depth rule + (shallower wins) already disambiguates. No diagnostic. +- **`embedDepth > 0` + prior at same-or-shallower depth + different + Go name** — peer ambiguity Go itself would refuse to promote. + Emits `CodeAmbiguousEmbed` (`SeverityWarning`). Last-write-wins + behaviour is preserved; only the signal is added. + +Fixture: `fixtures/enhancements/diagnostics/types.go` covers all +three cases. + +--- + +## §method-mangler — interface-method JSON-name derivation + +Interface methods can't carry struct tags. To pick a JSON property +name for a method, `Builder.methodMangler` (a +`mangling.NameMangler` from `go-openapi/swag`) applies the same +acronym-aware lower-first transform go-swagger uses for tag-less +struct fields: `CreatedAt → createdAt`, `ID → id`, +`ExternalID → externalId`. + +`swagger:name X` still takes precedence when present +([§dispatch-table](#dispatch-table) field-emission rules). + +The mangler is initialised in `NewBuilder`; `&Builder{…}` literals +that bypass the constructor will nil-panic in `interfaceJSONName`. + +--- + +## §user-overrides — explicit user-driven overrides + +Three classifier annotations let the author bend the type-driven +default. They live at two scopes — the **decl-site** (on the type +declaration's doc comment) and the **field-site** (on a struct +field or interface method's doc comment) — and the schema builder +applies each at a distinct point in the build pipeline. + +### Where each override is consumed + +| Annotation | Scope | Consumed by | Applied at | +|---|---|---|---| +| `swagger:type X` | decl-site | `classifierNamedTypeOverride(s.Decl.Comments, ps)` | `buildFromDecl` — **before** kind-dispatch | +| `swagger:type X` | decl-site, reached via field reference | same classifier, walked from the field's *types.Named decl | `buildNamedType` / `buildNamedArrayLike` / `classifierNamedBasic` arms | +| `swagger:type X` | field-site | `fieldDoc.TypeOverride` populated by `scanFieldDoc` | `applyFieldCarrier` — **after** `buildFromType` | +| `swagger:strfmt X` | decl-site | per-arm classifiers (`classifierTextMarshal`, `classifierNamedStructStrfmt`, …) | dispatch-table arms | +| `swagger:strfmt X` | field-site | `fieldDoc.StrfmtName` | `applyFieldCarrier` | +| json tag `,string` | field-site | `fieldCarrier.isString` | `applyFieldCarrier` | + +### Ordering inside `applyFieldCarrier` (last-write-wins) + +``` +buildFromType(propType, ps) + → isString sets {type: string, format: } + → StrfmtName sets {type: string, format: } + → TypeOverride resets ps; runs SwaggerSchemaForType(X) or falls back to Underlying() + → applyBlockToField consumes everything else (description, validations, extensions) +``` + +A field that picks up multiple overrides resolves by the last write. +Mixing `,string` and `swagger:strfmt` and `swagger:type` is misuse +in source — the precedence simply prevents accidental contradictions +from corrupting the schema mid-build. + +### Decl-site `swagger:type` fallthrough — known leaf vs unknown leaf + +`classifierNamedTypeOverride` tries `SwaggerSchemaForType(name, tgt)`: + +- **known leaf** (`object`, `string`, `integer`, `boolean`, `number`): + classifier returns `(handled=true, recurse=false)`. The override + terminates the dispatch — the schema is left typed as the user + asked. +- **unknown leaf** (`array`, `badvalue`, …): `SwaggerSchemaForType` + errors, classifier returns `(handled=true, recurse=true)`. The + caller falls back to `s.Decl.ObjType().Underlying()` so item + shapes are filled from the Go-level shape. For + `type X json.RawMessage // swagger:type array`, the Underlying is + `[]byte` and the result is `{type: array, items: {integer, uint8}}`. + +The same `(handled, recurse)` contract applies at field-reference +sites via `classifierNamedArrayLike` (the wrapper-decl path that +inlines into the field's schema) and at the decl-site via +`buildFromDecl` (the wrapper's own top-level definition). + +### Two scopes, two effects, independent precedence + +The same `swagger:type` annotation at the **decl-site** decides +what the type's `definitions` entry emits; at the **field-site** it +decides what one specific field emits, regardless of its Go type's +natural shape. + +A field referencing a wrapper-decl honours **both** overrides — the +wrapper's own definition reflects its decl-site override, and the +referring field reflects its own field-site override (which wins +locally by ordering). Both layers compose without mutating each +other. + +### `scanFieldDoc` and the `AnnType` filter + +`scanFieldDoc` is the field-level walker that pre-extracts every +classifier signal in one pass over `ParseBlocks(afld.Doc)`. Most +annotations (`ignore`, `name`, `strfmt`, `allOf`) go through the +lexer's `firstIdent` arg-classifier, which already produces a +single-token arg — no filter needed. + +`AnnType` is the exception: its arg-classifier `TrimSpaces` the +whole rest of the line, so prose noise like +`swagger:type so the scanner emits …` reaches `b.AnnotationArg()` +as a multi-word string. `scanFieldDoc` filters those out with an +inline `strings.ContainsAny(name, " \t")` check, mirroring the +filter inside `findAnnotationArg` (`walker_classifiers.go`). The +`enhancements/named-basic` fixture documents the v1 trap this +filter protects against. + +### Recognizer `skipExt` plumbing + +`applySpecialType` and `applyStdlibSpecials` take a `skipExt bool` +parameter that gates any vendor-extension writes the recognizers +would otherwise emit. Currently only `recognizeError` writes one +(`x-go-type: error`); the other recognizers +(`recognizeTime`, `recognizeAny`, `recognizeRawMessage`, +`recognizeUUID`) are purely type / format mutations and don't +consult `skipExt`. All eight schema-internal call sites pass +`s.skipExtensions` so the recognizer subsystem honours the same +`SkipExtensions` flag as the rest of the builder. + +--- + +## §ref-override — field-level overrides on a `$ref`'d field + +`applyToRefField` handles the case where a struct field's Go type +resolves to a named type whose schema lives in `definitions` +(`ps.Ref` set). Field-level sibling content — description, +`pattern`, `enum`, `example`, `x-*` extensions — cannot ride +alongside `$ref` per JSON-Schema-draft-4: the ref predates and +replaces siblings. The correct shape is an **allOf compound**: + +```json +{ + "description": "...", + "allOf": [ + { "$ref": "#/definitions/Parent" }, + { "...override validations and extensions..." } + ] +} +``` + +### Per-keyword landing rules + +- **`required:`** writes to `enclosing.Required` (it's a + parent-side concern, not a sibling of the `$ref`). +- **Description** rides the outer allOf compound when any + field-level content is present, including just the description + itself. The `DescWithRef` option (below) covers the only-description + edge case. +- **Validations** (`maximum`, `pattern`, `enum`, …) land on `allOf[1]` + — the override schema arm. +- **Vendor extensions** (`x-*` via the `extensions:` raw block) + are **lifted onto the outer compound**, not nested inside + `allOf[1]`. Reason: `x-*` siblings of `$ref` should live at the + same level as scanner-derived metadata (`x-go-name`, + `x-go-package`) for consistency. See `refOverrideCollector`'s + flag explanation below. + +### The `DescWithRef` toggle and the description-only case + +`scanner.Options.DescWithRef` controls how a description-only +override is emitted: + +- **DescWithRef=true**: a $ref'd field whose only field-level + decoration is a description produces a single-arm allOf + compound. The description rides the outer parent so JSON-Schema + consumers see it alongside the `$ref`. +- **DescWithRef=false** (the default — matches v1 strict + behaviour): the description is dropped and a bare `$ref` is + emitted. Users who want the description preserved via the + JSON-Schema-correct compound shape opt in explicitly. + +When validations or user-authored extensions are present, the +allOf wrap is mandatory regardless of the flag — the override +would be lost otherwise. + +### `refOverrideCollector` — accumulate-then-decide + +The collector accumulates field-level overrides into a scratch +schema so `applyToRefField` can pick the final shape after the +Walker has finished firing. Two flags track what was collected: + +- **`collectedValidation`** — a JSON-Schema validation keyword + fired (`maximum`, `pattern`, `enum`, …). When true, the override + arm (`allOf[1]`) is emitted carrying the validation. +- **`collectedExtension`** — a vendor extension fired. When true, + the collected extensions are **lifted onto the outer compound** + (not the override arm) so `x-*` keys live alongside the + scanner-derived `x-go-name` / `x-go-package`. + +Splitting the collector out of `applyToRefField` keeps the +per-shape Walker callbacks short and the orchestrator's cognitive +complexity in check. The Walker fires; the collector records; the +outer function shapes. + +### `applyPattern` — best-effort RE2 hint + +`applyPattern` stores a regex pattern unconditionally — JSON Schema +regex's grammar is broader than Go's RE2 (lookaheads, named +groups, etc.) and a user may rely on a downstream validator that +accepts the wider syntax. A best-effort `regexp.Compile` check +runs alongside: if the pattern is invalid against RE2, a +`SeverityWarning` diagnostic surfaces the issue without dropping +the value. + +The diagnostic rides on `CodeInvalidAnnotation` rather than a +dedicated `CodeInvalidPattern`. Reason: it's the closest existing +class for "the value is grammatically valid but semantically off." +A dedicated code can land alongside a broader pattern-hygiene pass +when one materialises. + +--- + +## §simple-schema-mode — SimpleSchema build mode for non-body params and response headers + +OAS v2 distinguishes a **full Schema** (body parameters, response +bodies, top-level definitions) from a **SimpleSchema** that applies +to non-body parameter sites and response headers: + +- A SimpleSchema is a parameter with `in: {query, path, header, + formData}` (anything except `in: body`) or a response header. +- SimpleSchema vocabulary is a restricted subset of Schema — `$ref`, + `allOf` / `oneOf` / `anyOf` / `not`, `properties` / + `additionalProperties`, object-level `required`, `discriminator`, + `readOnly`, `xml`, `externalDocs` are all forbidden. +- The notion disappears in OAS v3. + +References: +and . + +The schema builder offers `WithSimpleSchema(tpe, tgt, in)` as the +third Build mode (parallel to `WithType` / `WithDefinitions`). The +caller signals SimpleSchema-context explicitly via the option; the +builder no longer infers it from `tgt.In()`. See +[§build-entry](#build-entry) for the option table. + +### Allowed keyword surface + +Common between parameters and headers: + +| Keyword | Notes | +|---|---| +| `type` | `{string, number, integer, boolean, array}`; `file` for params only | +| `format` | same vocabulary as full Schema | +| `items` | required when `type: array`; nested SimpleSchema, recursive | +| `collectionFormat` | `{csv, ssv, tsv, pipes, multi}`, default `csv` — SimpleSchema-only | +| `default` | | +| `maximum`, `exclusiveMaximum`, `minimum`, `exclusiveMinimum`, `multipleOf` | | +| `maxLength`, `minLength`, `pattern` | | +| `maxItems`, `minItems`, `uniqueItems` | | +| `enum` | | +| `x-*` vendor extensions | | + +Parameter-only extras: `allowEmptyValue` (only when +`in ∈ {query, formData}` — forbidden on `path` and `header`); +`type: file` (only when `in: formData`). + +Response headers exclude `file` and `allowEmptyValue` entirely. + +### "Catch at exit" contract + +The schema builder does **not** pre-filter inputs in SimpleSchema +mode. `*types.Struct` and `*types.Interface` are allowed to enter +the resolution pipeline because they can legitimately resolve to a +SimpleSchema-legal primitive: + +- `time.Time` → `{string, date-time}` (stdlib recognizer) +- `TextMarshaler` → `{string, format}` (textmarshal shortcut) +- `json.RawMessage` → empty `{}` (any) +- user-driven overrides (`swagger:strfmt`, `swagger:type`, the + `swagger:alias` per-type opt-in via + [§classifier-walkers](#classifier-walkers)' + `classifierNamedBasic`) win the cascade as they always do + +The exit validator (`validateSimpleSchemaOutcome` in +`simpleschema.go`) inspects the resolved target after +`buildFromType` returns: + +- accept when `shape.Type` is in + `{"", string, number, integer, boolean, array}`, plus `file` if + `in == "formData"` (empty means "any" — `json.RawMessage` ends up + here). +- otherwise emit `CodeUnsupportedInSimpleSchema` (severity + `SeverityWarning`) and **reset** the target. + +The reset wipes the target back to empty `{}` (no `Type`, no +`Format`, no `Ref`) rather than degrading to `{type: string}` — +empty is honest about the failed resolution and avoids silently +mistyping a complex shape as a string. + +### `SimpleSchemaProbe` interface + +The target must expose three methods so the validator can inspect +the post-resolution shape and reset it on violation: + +```go +type SimpleSchemaProbe interface { + SimpleSchemaShape() *oaispec.SimpleSchema + HasRef() bool + ResetForViolation() +} +``` + +Implemented structurally by `paramTypable` in +`internal/builders/parameters` and (forthcoming with M2) +`headerTypable` in `internal/builders/responses`. Consumers don't +need to import the schema package — the interface is satisfied by +method set. + +A target that doesn't implement `SimpleSchemaProbe` is trusted: the +validator no-ops. A `nil` `SimpleSchemaShape` is also trusted — the +caller chose SimpleSchema mode for something that can't surface a +violation, by intent. + +### Knock-on cleanups this contract enables + +Once `WithSimpleSchema` carries the mode flag explicitly, the +builder can stop sniffing `tgt.In()`: + +- `classifierNamedBasic`'s primitive-inline arm (see + [§classifier-walkers](#classifier-walkers)) now keys on + `s.simpleSchema` instead of the v1-era `isAliasParam(tgt)` + predicate. That closes the `in: header` omission documented as a + resolved quirk below ([§quirks-resolved](#quirks-resolved)). +- The `swagger:alias` per-type author override stays orthogonal — + it bypasses the model-ref pipeline regardless of mode. + +### Walker keyword gating under SimpleSchema mode + +The single source of truth for the SimpleSchema vocabulary is +`internal/builders/handlers.IsSimpleSchemaKeyword(name)`. The +package-level `simpleSchemaAllowed` map enumerates every +keyword legal on a non-body parameter, response header, or +items chain within either, per OAS v2's Parameter Object and +Header Object tables. + +`schemaBoolHandler` consults this predicate when +`s.simpleSchema == true`: + +- **Full-Schema-only Bool keywords** (`readOnly`, `discriminator`) + trigger a `CodeUnsupportedInSimpleSchema` `SeverityWarning` + diagnostic and the write is skipped. Even if the path lands on + a throwaway scratch schema (the common case under SimpleSchema + mode — `paramTypable.Schema()` returns nil for non-body), the + diagnostic still surfaces the misuse to the author. +- **`required:`** is accepted by the predicate (it IS + SimpleSchema-legal as a parameter-level boolean) but the + schema's Bool handler skips it silently under SimpleSchema mode + anyway. The parameter-level write — `param.Required = val` — + lives in `parameters/walker.go:paramRequiredBool`. Headers + don't carry `required:` at all. The schema walker's full-Schema + semantics (object-level required-array via + `enclosing.Required[name]`) don't fit the SimpleSchema shape. +- Number / Integer / String / Raw / Extension dispatchers are + unchanged: all the keywords they handle are SimpleSchema-legal. + +The `IsSimpleSchemaKeyword` set is locked down by unit tests in +the handlers package — any future addition (or removal) of a +SimpleSchema keyword has to update the test alongside the map, +so the contract can't drift silently. + +--- + +## §classifier-walkers — per-call-site classifier walkers and `findAnnotationArg`'s single-word filter + +The schema builder dispatches user-classifier annotations +(`swagger:strfmt`, `swagger:type`, `swagger:enum`, +`swagger:default`, `swagger:allOf`, `swagger:alias`) via per-call- +site walker functions in `walker_classifiers.go`. The shape is +deliberate: + +- **One walker per call site.** Each walker is named for the call- + site context (e.g. `classifierTextMarshal`, + `classifierNamedBasic`, `classifierNamedArrayLike`). Its godoc + documents which `swagger:` classifier annotations it + consumes — explicit per-site contract instead of a single + catch-all dispatcher. +- **Reads through the ParseBlocks cache.** Each walker calls + `s.ParseBlocks(cg)` so a single CommentGroup is parsed exactly + once per build regardless of how many walkers inspect it. +- **Site-local writes.** The walker performs the call-site's writes + directly onto the typable target — both lookup and side effect + encapsulated. + +Correctness first; if a future re-read finds genuine redundancy, +the factorisation is mechanical and safer last. + +### `findAnnotationArg` and the single-word filter + +`findAnnotationArg(cg, kind)` returns the first positional argument +of the first `Block` of the given annotation kind, **filtered to +non-empty single-word arguments**: + +```go +if strings.ContainsAny(arg, " \t") { + continue +} +``` + +The single-word filter only matters for annotation kinds whose +lexer arg-classifier doesn't already split on whitespace — namely +`AnnType` (via `argTypeRef`) and `AnnDefaultName` (via +`argDefaultValue`). Kinds that go through `firstIdent` in the lexer +(`AnnStrfmt`, `AnnName`, `AnnAllOf`, `AnnModel`, `AnnResponse`, +`AnnEnum`) already produce single-token args, but the filter is +harmless on those. + +The check matches v1's de-facto `\S+`-anchored capture, which +silently rejected prose lines that happened to open with +`swagger:` followed by a sentence. The +`fixtures/enhancements/named-basic` fixture documents this trap +with a `swagger:type so the scanner emits ...` prose line preceding +the real `swagger:type string` annotation — without the filter, the +prose's "so" would pre-empt the real arg "string". + +`findAnnotationArg` reads through the per-Builder `ParseBlocks` +cache, so the lookup is parse-once per CommentGroup and the +`ParseAll`-aware multi-annotation case still surfaces every +annotation of interest. + +### Walker inventory + +| Walker | Call site | Consumes | +|---|---|---| +| `classifierTextMarshal` | `buildFromTextMarshal` end-of-pipe | `swagger:strfmt` | +| `classifierNamedTypeOverride` | `buildFromType` named fallback, `buildFromStruct` pre-pass | `swagger:type` | +| `classifierNamedBasic` | `buildNamedBasic` | cascade: `swagger:strfmt → swagger:enum → swagger:default → swagger:type → swagger:alias` (the alias arm doubles as the SimpleSchema-mode primitive-inline branch — see [§simple-schema-mode](#simple-schema-mode)) | +| `classifierNamedArrayLike` | `buildNamedArray` / `buildNamedSlice` | `swagger:strfmt`, `swagger:type` | +| `classifierAliasTargetStrfmt` | `buildNamedAllOf` (struct + interface arms) | `swagger:strfmt` | +| `classifierStructPreBuildType` | `buildFromStruct` top | `swagger:type` | +| `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` | + +--- + +## §quirks — known behavioural caveats + +Entries are split into two groups: **[Resolved in this refactor](#quirks-resolved)** lists +quirks fixed in the current pass with a short note on how, so a +future reader can find the change and the rationale. **[Still open](#quirks-open)** lists +quirks the refactor either deliberately did not touch (deferred to +v2 / design call required) or documents as intentional behaviour. + +--- + +## §quirks-resolved — fixed in this refactor + +### ✅ `recognizeRawMessage` now emits an empty schema (was `{type: object}`) + +`json.RawMessage` is `[]byte` underneath but JSON-marshals as +arbitrary JSON. The recognizer now emits an empty schema (`{}`), +which in Swagger 2.0 / JSON Schema means "any type" — the most +faithful representation of `RawMessage`'s contract. Previous +behaviour emitted `{type: object}` as a narrower approximation. + +**Fix:** `recognizeRawMessage` arm in `applySpecialType` rewritten to +call `_ = target.Schema()` (the "any" pattern used by `recognizeAny`) +instead of `target.Typed("object", "")`. Golden delta captured in +`go123_special_spec.json`'s `Message` property. + +### ✅ `recognizeError` x-go-type extension now honours `SkipExtensions` + +The `error` arm of `applySpecialType` writes `x-go-type: error` in +addition to typing the target as `{string, ""}`. This is for +downstream tooling that wants to detect the Go-error origin. The +write is now gated by the `skipExt` argument threaded into +`applySpecialType` / `applyStdlibSpecials` from `s.skipExtensions`, +so `SkipExtensions=true` suppresses it like any other vendor +extension. + +**Fix:** added `skipExt bool` parameter to `applySpecialType` and +`applyStdlibSpecials`; gated the `target.AddExtension(...)` call in +the `recognizeError` arm. Eight schema-internal call sites updated to +pass `s.skipExtensions`. Golden-neutral (no existing fixture combines +the `error` shape with `SkipExtensions=true`). + +### ✅ Field-level `swagger:type` on `json.RawMessage` fields + +A struct field of type `json.RawMessage` with a field-level +`swagger:type object` / `swagger:type array` annotation now produces +the user-specified shape instead of silently emitting the recognizer +default. Pre-fix: `scanFieldDoc` only consumed +`ignore` / `name` / `strfmt` / `allOf`, so field-level `swagger:type` +was dropped before `applyBlockToField` ran. + +**Fix:** added `TypeOverride` to `fieldDoc`; `scanFieldDoc` consumes +`AnnType` with a single-word filter (mirroring `findAnnotationArg`, +since `AnnType` uses TrimSpace and can carry prose on noise lines); +`applyFieldCarrier` applies the override after `buildFromType` — +tries `SwaggerSchemaForType(name, …)` first, falls back to +`buildFromType(c.propType.Underlying(), …)` on unknown leaves like +`"array"` so item shapes are computed from the Go type. Fixture +`fixtures/enhancements/raw-message-override/` (case C); golden +`enhancements_raw_message_override.json`. + +### ✅ Wrapper-decl `swagger:type` honoured at top-level definition + +A named wrapper of `json.RawMessage` (or any other type the recognizer +would otherwise short-circuit) decorated with `swagger:type` on the +decl now emits the user-specified shape at its **own** top-level +definition, not just at field reference sites. Pre-fix: only the +field-reference path consulted the wrapper's `swagger:type`; the +top-level definition emitted an empty schema because `buildFromDecl` +dispatched on `ti.Type` (the RHS) and the RHS recognizer fired +before any wrapper-side classifier could. + +**Fix:** `buildFromDecl` now calls `classifierNamedTypeOverride` on +`s.Decl.Comments` before the kind-dispatch. Known leaves (`object`, +`string`, …) terminate; unknown leaves (`array`) fall back to +`s.Decl.ObjType().Underlying()` so items / properties are filled +from the Go-level shape. Isolation fixture +`fixtures/enhancements/wrapper-decl-type-override/` — +`BareWrapperObject` / `BareWrapperArray`. + +### ✅ `in: header` parameters now inline named-basic types + +Pre-fix, `classifierNamedBasic`'s primitive-inline arm only fired +for `in: query` / `in: path` / `in: formData` — `header` was +silently omitted from the `isAliasParam` predicate (carried over +verbatim from v1's `parsers.IsAliasParam`). So a header parameter +typed as a named string `type SessionID string` resolved through +`FindModel → makeRef` and emitted `{$ref: "#/definitions/SessionID"}` +— invalid under OAS v2 SimpleSchema, which forbids `$ref` on +non-body parameter sites. + +**Fix:** the `isAliasParam(tgt)` `In()`-sniffing predicate replaced +with the M1 `s.simpleSchema` flag (set by `WithSimpleSchema`). The +parameter bridge now wires `WithSimpleSchema` for all four non-body +locations uniformly (`query` / `path` / `header` / `formData`), so +the primitive-inline arm covers `header` automatically. The +predicate function deletes (sole consumer). + +Two paths through the arm remain orthogonal: the SimpleSchema flag +is caller-driven (parameter/header build mode); `swagger:alias` on +the decl is a per-type author override. Either triggers +`SwaggerSchemaForType(underlying basic name)` on the typable. + +Isolation fixture `fixtures/enhancements/header-named-basic/` and +test `internal/integration/coverage_header_named_basic_test.go` +pin the post-fix shape. Responses won't pick up the same fix until +M2 wires `WithSimpleSchema` on header-field builds; the response +edges fixture covers a different (strfmt-tagged) shape already. + +--- + +## §quirks-open — still open + +### 🟡 Named-strfmt + `swagger:model` combo (deferred) + +When the author combines `swagger:strfmt` with `swagger:model` +on the same type, the FIELD reference inlines as `{string, format}` +(via the strfmt classifier) but the TOP-LEVEL definition body is +still emitted from walking the underlying struct. + +**Reproduction.** Fixture `fixtures/enhancements/named-struct-tags-ref/types.go` +declares `PhoneNumber` with both `swagger:strfmt phone` and +`swagger:model`, used by `Contact.Phone`. The golden +`enhancements_named_struct_tags-ref.json` captures the observable +inconsistency: + +- Field site: `{type: "string", format: "phone"}` — strfmt wins. +- Top-level definition: `{type: "object", properties: {CountryCode, Number}}` — + the struct walk wins; the strfmt annotation is ignored at decl time. + +The author asked for "named strfmt" (a reusable `PhoneNumber` +definition rendered as a formatted string) but gets an inconsistent +pair: the field says string, the definition says object. + +**Attempted fix and reasons it reverted.** The first attempt +(referred to as "Option 1") would have: + +1. Detected `swagger:strfmt` on the decl in `buildDeclNamed` and + emitted `{string, fmt}` instead of walking the struct body. +2. In `buildNamedStruct`, when the target also has `swagger:model`, + emitted `$ref` instead of inlining the strfmt. + +This was reverted before merge because: + +- Pre-existing fixtures in + `fixtures/goparsing/classification/transitive/mods/aliases.go` use + the same `swagger:strfmt + swagger:model` combination on + defined-from-`time.Time` types (e.g. `SomeTimeType time.Time`). + The existing tests (`TestAliasedTypes`, `TestAliasedModels`) + assert the *inline* baseline (`scantest.AssertProperty(..., "string", ...)`) + rather than a `$ref`. Option 1 flips these to `$ref`, requiring + coordinated test updates. +- The decl-level `StrfmtName` check also over-fires on slice / array / + map underlyings: `type SomeTimesType []time.Time` with + `swagger:strfmt date-time` should emit + `{array, items: {string, date-time}}`, not flatten to `{string}`. + A correct fix would gate the check on struct-underlying first, + then symmetrically consider whether `buildNamedSlice` / + `buildNamedArray` / `buildNamedMap` should also route through + `$ref` under the `swagger:model` combination. + +The surface area is wider than the Option 1 code change suggested, +and the existing test coverage of the combination is entangled with +the inconsistency itself. + +**Why deferred.** The combination is niche, the footgun is narrow +(you get what you asked for on one side of the indirection, not +both), and v2's annotation redesign can reshape the contract without +carrying this legacy. A focused decision on "named strfmt" semantics +belongs in the v2 design, not a bug-fix pass. + +The `named-struct-tags-ref` fixture and its golden are checked in as +a deliberate marker — the golden captures the observable +inconsistency (inline field + struct-body definition) so future work +on this decision has a failing test to anchor against. + +### 🟦 `interface{}` literals (documented behaviour) + +A bare `interface{}` field hits the `*types.Interface` arm of +`buildFromType` (anonymous, not Named). It produces an empty +schema. The user-named `type X interface{}` is a `*types.Named` +with empty `Underlying()` and emits as `$ref` to a definition +with no `properties` — JSON-equivalent to "any object" in v1. +Behavioural; changing it would break consumers. + +### 🟦 Generic declarations vs instantiations (documented behaviour) + +Generic **declarations** (e.g. `GenericSlice[T any] []T`) are +processed but their schemas are essentially empty — the type +parameter `T` is filtered out by `UnsupportedBuiltinType` as a +`*types.TypeParam`. Generic **instantiations** +(e.g. a field of type `GenericSlice[int]`) emit correctly with +the substituted underlying via the `TypeArgs` short-circuit +([§dissolve-named](#dissolve-named)). No bug — generic decls +without a concrete instantiation simply have no representable +schema. + +### 🟡 Cross-package definition-name collisions silently overwrite + +`buildFromDecl` writes the top-level schema as +`s.definitions[s.Name] = schema`, keyed only by the Go identifier +(`decl.Names()[0]`). When two packages in a single scan declare a type +with the same identifier — `pkg/a.User` and `pkg/b.User` — both map +to `definitions["User"]` and the second build silently overwrites the +first. The output spec carries only one `User`, with no record of the +collision and no signal of which package won. + +The existing `nameByJSON` (`propOwner`) map in field emission is **not** +a defense against this case: it tracks JSON property names within a +single struct's field set plus its embeds (for the ambiguous-embed +diagnostic), not type-level identifier conflicts across packages. + +#### Target shape + +A proper fix needs three pieces: + +1. **Detection** — at write time, recognise the case "definition key + already exists with non-empty schema and originates from a different + package" (use `x-go-package`, or stash origin in the `Builder`). +2. **Diagnostic** — emit `CodeNameConflict` (severity + `SeverityWarning` minimum, possibly `SeverityError` under strict + mode) carrying both `(pkg, name)` pairs. +3. **Policy** — open design call: + - **a. Rename** — prefix loser(s) with a stable short-package + (e.g. `a_User`, `b_User`). Stable but ugly; needs all `$ref`s + to follow the rename — cross-cutting. + - **b. Skip + warn** — keep the first writer, drop subsequent + ones, emit a warning. Predictable but lossy. + - **c. Fail the build** — under strict mode, treat as an error. + Forces the author to rename in source. Cleanest semantics, + most disruptive. + +#### Why deferred + +Each policy choice changes the contract for downstream code generators +(go-swagger, oapi-codegen, …) — they have assumptions about +`definitions` keys matching exported Go names. The "rename" path +additionally requires every `$ref` writer in the builders to consult a +rename map; the surface is wide. + +For multi-package scans where the author controls both packages, the +workaround today is to scope scans to one package per spec, or to +rename one of the colliding types at the source. A future strict-mode +flag (e.g. `Options.StrictNameConflicts`) could enable option (c) +without breaking existing scans. + +### 🟡 Stale `x-go-enum-desc` after a field-level enum override + +When a field uses a type marked `swagger:enum TypeName` **and** carries +its own `enum: …` override, v1 mutates the schema in place: it replaces +`Enum`, strips the inherited `x-go-enum-desc`, and trims the matching +description suffix. This is **lossy** — the per-value docs contributed +by `TypeName` are silently discarded. + +Concretely, given (fixture `fixtures/enhancements/enum-overrides/`, +case E): + +```go +// swagger:enum PriorityE +type PriorityE string + +const ( + PriorityELow PriorityE = "low" // low-priority requests + PriorityEMed PriorityE = "medium" // medium-priority requests + PriorityEHigh PriorityE = "high" // high-priority requests +) + +type NotificationE struct { + // Inline enum provides a narrower set than the const block. + // + // enum: urgent, normal + Priority PriorityE `json:"priority"` +} +``` + +v1 emits: + +```yaml +priority: + type: string + enum: [urgent, normal] # the override wins + description: "Inline enum provides a narrower set than the const block." + # x-go-enum-desc removed by clearStaleEnumDesc + # PriorityE's per-value doc lines silently dropped from description +``` + +The cleanup runs reactively from `schemaValidations.SetEnum` +([typable.go](typable.go#L128)) via `clearStaleEnumDesc` +([extensions.go](extensions.go#L42)). It treats any +`x-go-enum-desc` present at `SetEnum` time as inherited (and therefore +stale once `Enum` is replaced), deletes it, and trims the matching +suffix off `Description`. The `TrimSuffix` dance is fragile — it +relies on the enum-desc pipeline having appended the doc lines as a +literal suffix — but it works under v1's emission discipline. + +#### Target shape (allOf composition) + +OpenAPI 2.0 supports `allOf` for schema composition, so the cleaner +model does not have to wait for OAS 3. The replacement shape is: + +```yaml +# PriorityE promoted to a top-level definition: +definitions: + PriorityE: + type: string + enum: [low, medium, high] + description: | + low: low-priority requests + medium: medium-priority requests + high: high-priority requests + x-go-enum-desc: | + low: low-priority requests + medium: medium-priority requests + high: high-priority requests + + NotificationE: + type: object + properties: + priority: + description: "Inline enum provides a narrower set than the const block." + allOf: + - $ref: '#/definitions/PriorityE' # inherited enum + per-value docs + - enum: [urgent, normal] # the override +``` + +Each branch keeps its own concern: + +- the `$ref` branch carries `PriorityE`'s full schema (values + docs + + `x-go-enum-desc`), untouched and reusable by every field that + references `PriorityE`; +- the inline branch carries the narrowing override only. + +No mutation of the inherited schema, no `TrimSuffix` dance. Validator +semantics for enum-narrowing `allOf` aren't perfectly uniform across +tools, but for the documentation / code-gen use cases codescan feeds +(go-swagger, oapi-codegen, redoc, …) this composition preserves both +layers cleanly. + +#### Prerequisites for the migration (both currently missing) + +1. **Promote unannotated `swagger:enum` types to top-level definitions** + so the `$ref` branch has a target. Today they exist only as inlined + fragments on each referring field. +2. **Move override detection from `SetEnum` (validation hook) to the + field-emission path**, so the override is composed alongside the + inherited schema instead of mutating it after the fact. + +Until both land, `clearStaleEnumDesc` stays in place. The TODO in +`extensions.go` flags it as the replacement target. diff --git a/internal/builders/schema/allof.go b/internal/builders/schema/allof.go new file mode 100644 index 0000000..67c00df --- /dev/null +++ b/internal/builders/schema/allof.go @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "fmt" + "go/types" + + "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/logger" + "github.com/go-openapi/codescan/internal/scanner" + oaispec "github.com/go-openapi/spec" +) + +// scanEmbeddedFields walks st's anonymous fields and decides whether +// each embed contributes properties to the outer schema directly or +// becomes an `allOf` compound member. +// +// # Details +// +// See [§allof](./README.md#allof) — embed classification rules, +// `IsAllOfMember` semantics, and how the returned target ties to +// `buildFromStruct`'s second pass. +// +// Returns: +// - target — the schema receiving properties; nil if no embed contributed, +// `schema` itself for plain embeds, a fresh schema when allOf is in play. +// - hasAllOf — true if at least one allOf member was appended. +func (s *Builder) scanEmbeddedFields( + decl *scanner.EntityDecl, st *types.Struct, schema *oaispec.Schema, nameByJSON map[string]propOwner, +) (target *oaispec.Schema, hasAllOf bool, err error) { + for fld := range st.Fields() { + if !fld.Anonymous() { + continue + } + + afld := resolvers.FindASTField(decl.File, fld.Pos()) + if afld == nil { + continue + } + + fd := s.scanFieldDoc(afld) + if fd.Ignored { + continue + } + + _, ignore, _, _, err := resolvers.ParseJSONTag(afld) + if err != nil { + return nil, false, err + } + if ignore { + continue + } + + _, isAliased := fld.Type().(*types.Alias) + + if !fd.IsAllOfMember && !isAliased { + if target == nil { + target = schema + } + if err := s.buildEmbedded(fld.Type(), target, nameByJSON); err != nil { + return nil, false, err + } + continue + } + + hasAllOf = true + if target == nil { + target = &oaispec.Schema{} + } + var newSch oaispec.Schema + if err := s.buildAllOf(fld.Type(), &newSch); err != nil { + return nil, false, err + } + + if fd.AllOfClass != "" { + schema.AddExtension("x-class", fd.AllOfClass) + } + schema.AllOf = append(schema.AllOf, newSch) + } + + return target, hasAllOf, nil +} + +// buildAllOf builds the schema for one allOf compound member. Peels +// pointers and routes named types and aliases to their dedicated +// helpers. +// +// # Details +// +// See [§allof](./README.md#allof) — the three-arm dispatch and why +// non-Named / non-Alias inputs are dropped silently with a logger +// warning rather than an error (parity with v1). +func (s *Builder) buildAllOf(tpe types.Type, schema *oaispec.Schema) error { + switch ftpe := tpe.(type) { + case *types.Pointer: + return s.buildAllOf(ftpe.Elem(), schema) + case *types.Named: + return s.buildNamedAllOf(ftpe, schema) + case *types.Alias: + tgt := NewTypable(schema, 0, s.skipExtensions) + return s.buildAlias(ftpe, tgt) + default: + logger.UnsupportedTypeKind("buildAllOf", ftpe) + return nil + } +} + +// buildNamedAllOf resolves a named type appearing as an allOf member. +// Struct and interface underlyings share the same precedence shape: +// user-classifier first, then stdlib specials, then model lookup, then +// inline build. +// +// # Details +// +// See [§allof](./README.md#allof) — arm symmetry rationale and why +// `classifierAliasTargetStrfmt` is preferred over a comment-group-keyed +// variant. +func (s *Builder) buildNamedAllOf(ftpe *types.Named, schema *oaispec.Schema) error { + tgt := NewTypable(schema, 0, s.skipExtensions) + tio := ftpe.Obj() + + if s.classifierAliasTargetStrfmt(ftpe, tgt) { + return nil + } + if applyStdlibSpecials(tio, tgt, s.skipExtensions) { + return nil + } + + decl, found := s.Ctx.GetModel(tio.Pkg().Path(), tio.Name()) + if !found { + return fmt.Errorf("can't find source for named allOf member %s: %w", ftpe.String(), ErrSchema) + } + + if decl.HasModelAnnotation() { + return s.MakeRef(decl, tgt) + } + + switch utpe := ftpe.Underlying().(type) { + case *types.Struct: + return s.buildFromStruct(decl, utpe, schema, make(map[string]propOwner)) + case *types.Interface: + return s.buildFromInterface(decl, utpe, schema, make(map[string]propOwner)) + default: + logger.UnsupportedTypeKind("buildNamedAllOf", utpe) + return nil + } +} diff --git a/internal/builders/schema/embedded.go b/internal/builders/schema/embedded.go new file mode 100644 index 0000000..5439075 --- /dev/null +++ b/internal/builders/schema/embedded.go @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/ast" + "go/types" + "log/slog" + + "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/logger" + "github.com/go-openapi/codescan/internal/scanner" + oaispec "github.com/go-openapi/spec" +) + +// enterEmbed bumps embedDepth and returns a func that restores it. +// Use as `defer s.enterEmbed()()` around any recursive build pass +// that descends into an embedded type. Pairs with +// `applyFieldCarrier`'s ambiguity diagnostic. +// +// # Details +// +// See [§embed-depth](./README.md#embed-depth) — depth-rule semantics +// and which writes count as ambiguous embeds. +func (s *Builder) enterEmbed() func() { + s.embedDepth++ + return func() { s.embedDepth-- } +} + +// buildEmbedded routes a struct's embedded-field type to the +// appropriate emitter: pointers are peeled, named types descend into +// `buildNamedEmbedded`, aliases go through `buildAlias`. +// +// # Details +// +// See [§embedded](./README.md#embedded) — the three-arm dispatch and +// the deliberate asymmetry with `buildAllOf` (embeds always inline +// properties, never $ref unless the embed is `swagger:allOf`-tagged). +func (s *Builder) buildEmbedded(tpe types.Type, schema *oaispec.Schema, nameByJSON map[string]propOwner) error { + switch ftpe := tpe.(type) { + case *types.Pointer: + return s.buildEmbedded(ftpe.Elem(), schema, nameByJSON) + case *types.Named: + return s.buildNamedEmbedded(ftpe, schema, nameByJSON) + case *types.Alias: + target := NewTypable(schema, 0, s.skipExtensions) + return s.buildAlias(ftpe, target) + default: + logger.UnsupportedTypeKind("buildEmbedded", ftpe) + return nil + } +} + +// buildNamedEmbedded inlines an embedded named struct or interface +// into the outer schema. The interface arm runs `applyStdlibSpecials` +// so `error` etc. recognize cleanly; the struct arm does not — the +// asymmetry is intentional, see README §embedded. +// +// # Details +// +// See [§embedded](./README.md#embedded) — `AddDiscoveredModel` +// pairing, struct-vs-interface specials asymmetry, and how +// `enterEmbed` interacts with `applyFieldCarrier`'s ambiguity +// diagnostic. +func (s *Builder) buildNamedEmbedded(tpe *types.Named, schema *oaispec.Schema, nameByJSON map[string]propOwner) error { + if resolvers.UnsupportedBuiltin(tpe) { + s.Warn("skipped unsupported builtin", slog.Any("type", tpe)) + + return nil + } + + switch utpe := tpe.Underlying().(type) { + case *types.Struct: + decl, found := s.Ctx.GetModel(tpe.Obj().Pkg().Path(), tpe.Obj().Name()) + if !found { + return missingSource(tpe) + } + s.Ctx.AddDiscoveredModel(decl) + + defer s.enterEmbed()() + return s.buildFromStruct(decl, utpe, schema, nameByJSON) + case *types.Interface: + if utpe.Empty() { + return nil + } + o := tpe.Obj() + target := NewTypable(schema, 0, s.skipExtensions) + if applyStdlibSpecials(o, target, s.skipExtensions) { + return nil + } + + resolvers.MustNotBeABuiltinType(o) + decl, found := s.Ctx.GetModel(o.Pkg().Path(), o.Name()) + if !found { + return missingSource(tpe) + } + s.Ctx.AddDiscoveredModel(decl) + + defer s.enterEmbed()() + return s.buildFromInterface(decl, utpe, schema, nameByJSON) + default: + logger.UnsupportedTypeKind("buildNamedEmbedded", utpe) + return nil + } +} + +// processEmbeddedType handles types reached during an interface's +// `swagger:allOf` walk: named, anonymous interface, and alias +// embeds. Each non-empty sub-schema becomes an `allOf` member on the +// outer schema. +// +// # Details +// +// See [§embedded](./README.md#embedded) — interface-side allOf +// composition rules and the `Ref.String() != "" || Properties >0 || +// AllOf >0` non-empty guard rationale. +func (s *Builder) processEmbeddedType(fld types.Type, flist []*ast.Field, decl *scanner.EntityDecl, schema *oaispec.Schema, + nameByJSON map[string]propOwner, +) (fieldHasAllOf bool, err error) { + switch ftpe := fld.(type) { + case *types.Named: + o := ftpe.Obj() + var dummySchema oaispec.Schema + ps := NewTypable(&dummySchema, 0, s.skipExtensions) + if applyStdlibSpecials(o, ps, s.skipExtensions) { + return false, nil + } + return s.buildNamedInterface(ftpe, flist, decl, schema, nameByJSON) + case *types.Interface: + var aliasedSchema oaispec.Schema + ps := NewTypable(&aliasedSchema, 0, s.skipExtensions) + if err = s.buildAnonymousInterface(ftpe, ps, decl); err != nil { + return false, err + } + if aliasedSchema.Ref.String() != "" || len(aliasedSchema.Properties) > 0 || len(aliasedSchema.AllOf) > 0 { + fieldHasAllOf = true + schema.AddToAllOf(aliasedSchema) + } + case *types.Alias: + var aliasedSchema oaispec.Schema + ps := NewTypable(&aliasedSchema, 0, s.skipExtensions) + if err = s.buildAlias(ftpe, ps); err != nil { + return false, err + } + if aliasedSchema.Ref.String() != "" || len(aliasedSchema.Properties) > 0 || len(aliasedSchema.AllOf) > 0 { + fieldHasAllOf = true + schema.AddToAllOf(aliasedSchema) + } + default: + logger.UnsupportedTypeKind("buildNamedInterface.allOf", ftpe) + } + + return fieldHasAllOf, nil +} diff --git a/internal/builders/schema/errors.go b/internal/builders/schema/errors.go index 2bb3cba..e9a4285 100644 --- a/internal/builders/schema/errors.go +++ b/internal/builders/schema/errors.go @@ -3,7 +3,15 @@ package schema -import "errors" +import ( + "errors" + "fmt" + "go/types" +) // ErrSchema is the sentinel error for all errors originating from the schema builder package. var ErrSchema = errors.New("codescan:builders:schema") + +func missingSource(tpe types.Type) error { + return fmt.Errorf("can't find source file for type: %v: %w", tpe, ErrSchema) +} diff --git a/internal/builders/schema/extensions.go b/internal/builders/schema/extensions.go new file mode 100644 index 0000000..c783f84 --- /dev/null +++ b/internal/builders/schema/extensions.go @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "strings" + + "github.com/go-openapi/codescan/internal/builders/resolvers" + oaispec "github.com/go-openapi/spec" +) + +// clearStaleEnumDesc removes the x-go-enum-desc extension and strips +// the matching suffix from ps.Description. +// +// Called when a field-level enum overrides a type-level swagger:enum +// (the inherited per-value-doc text is now stale because it describes +// values that aren't in the field-level enum any more). +// +// # Details +// +// See [§quirks-open](./README.md#quirks-open) — "Stale x-go-enum-desc +// after a field-level enum override" entry, which carries the +// allOf-compound replacement target shape and the two prerequisites +// (top-level promotion of unannotated swagger:enum types + override +// detection moved out of the SetEnum hook) that gate the principled +// fix. +func clearStaleEnumDesc(ps *oaispec.Schema) { + enumDesc := resolvers.GetEnumDesc(ps.Extensions) + if enumDesc == "" { + return + } + delete(ps.Extensions, resolvers.ExtEnumDesc) + ps.Description = strings.TrimSuffix( + strings.TrimSuffix(ps.Description, enumDesc), + "\n", + ) +} diff --git a/internal/builders/schema/fields.go b/internal/builders/schema/fields.go new file mode 100644 index 0000000..535b38d --- /dev/null +++ b/internal/builders/schema/fields.go @@ -0,0 +1,258 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "fmt" + "go/ast" + "go/token" + "go/types" + + "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/scanner" + oaispec "github.com/go-openapi/spec" +) + +// fieldCarrier holds everything the unified field-emission pipeline needs. +// +// Per-source extractors (struct field, interface method) build it; +// applyFieldCarrier consumes it to emit one Swagger property. +type fieldCarrier struct { + name string // JSON property name (post-tag, post-mangler) + goName string // Go identifier (for x-go-name override) + propType types.Type // type to build the property schema from + afld *ast.Field // AST field, used for block-comment lookup + fd fieldDoc // pre-scanned doc-comment signals + isString bool // JSON tag's ",string" option (struct only) + omitEmpty bool // affects the nullable rule (struct only) +} + +// propOwner records the writer of a property: the Go field name and +// the embed-recursion depth at write time. +// +// Used by: +// - structFieldCarrier — reverse-lookup for `json:"-"` eviction +// (find the JSON name a given Go name was last bound to). +// - applyFieldCarrier — ambiguity detection (a later embed-side +// write at the same or shallower depth than the prior one and +// with a different Go name is the same-depth-ambiguity case Go +// itself would refuse to promote). +type propOwner struct { + goName string + depth int +} + +// nameByJSON maps a JSON property name to its most recent writer. +// The original implementation was map[string]string and drove a +// post-pass GC over target.Properties; that pass was dead under every +// traced path (applyFieldCarrier is the only writer to target.Properties +// and it always writes the matching nameByJSON entry, so +// `Properties.keys() ⊆ nameByJSON.keys()` is an invariant) and has +// been removed. + +// applyFieldCarrier emits one property of target from c. Common +// pipeline shared by processStructField, processInterfaceMethod and +// processAnonInterfaceMethod. `nameByJSON` is optional: nil for +// anonymous interfaces, which don't track duplicates. +// +// # Details +// +// See [§user-overrides](./README.md#user-overrides) — the +// last-write-wins ordering across `isString` / `StrfmtName` / +// `TypeOverride` / `applyBlockToField`, plus the +// `x-go-name` and pointer-nullable rules at the tail. +func (s *Builder) applyFieldCarrier(c fieldCarrier, target *oaispec.Schema, nameByJSON map[string]propOwner) error { + if target.Properties == nil { + target.Properties = make(map[string]oaispec.Schema) + } + + ps := target.Properties[c.name] + if err := s.buildFromType(c.propType, NewTypable(&ps, 0, s.skipExtensions)); err != nil { + return err + } + if c.isString { + ps.Typed("string", ps.Format) + ps.Ref = oaispec.Ref{} + ps.Items = nil + } + if c.fd.StrfmtName != "" { + ps.Typed("string", c.fd.StrfmtName) + ps.Ref = oaispec.Ref{} + ps.Items = nil + } + if c.fd.TypeOverride != "" { + // Field-site swagger:type override. See + // [§user-overrides](./README.md#user-overrides) for ordering + // and the Underlying() fallback rationale. + ps = oaispec.Schema{} + override := NewTypable(&ps, 0, s.skipExtensions) + if err := resolvers.SwaggerSchemaForType(c.fd.TypeOverride, override); err != nil { + if err := s.buildFromType(c.propType.Underlying(), override); err != nil { + return err + } + } + } + + s.applyBlockToField(c.afld, target, &ps, c.name) + + if ps.Ref.String() == "" && c.name != c.goName { + resolvers.AddExtension(&ps.VendorExtensible, "x-go-name", c.goName, s.skipExtensions) + } + if _, isPointer := c.propType.(*types.Pointer); isPointer && !c.omitEmpty { + s.applyNullable(&ps) + } + + if nameByJSON != nil { + s.diagnoseAmbiguousEmbed(c, nameByJSON) + nameByJSON[c.name] = propOwner{goName: c.goName, depth: s.embedDepth} + } + target.Properties[c.name] = ps + return nil +} + +// diagnoseAmbiguousEmbed fires a SeverityWarning Diagnostic on +// embed-side writes that would overwrite a prior entry whose write +// was at the same or shallower depth and bound to a different Go +// name. Last-write-wins is preserved; only the signal is added. +// +// # Details +// +// See [§embed-depth](./README.md#embed-depth) — depth-rule +// disambiguation and the three classification cases. +func (s *Builder) diagnoseAmbiguousEmbed(c fieldCarrier, nameByJSON map[string]propOwner) { + if s.embedDepth == 0 { + return + } + prior, found := nameByJSON[c.name] + if !found || prior.goName == c.goName { + return + } + if prior.depth > s.embedDepth { + // Legitimate depth-rule shadowing: current writer is closer + // to the parent struct than the prior, Go would prefer it. + return + } + var pos token.Position + if c.afld != nil { + pos = s.Ctx.PosOf(c.afld.Pos()) + } + s.RecordDiagnostic(grammar.Diagnostic{ + Pos: pos, + Severity: grammar.SeverityWarning, + Code: grammar.CodeAmbiguousEmbed, + Message: fmt.Sprintf( + "JSON property %q is promoted by Go field %q (depth %d) and Go field %q (depth %d); "+ + "Go would treat this as ambiguous and not promote it. The schema emits the later writer's shape.", + c.name, prior.goName, prior.depth, c.goName, s.embedDepth, + ), + }) +} + +// structFieldCarrier produces the carrier for a struct field, or +// returns ok=false when the field must be skipped silently (embedded, +// unexported, no AST, doc-ignored, JSON-tag-ignored). +// +// The JSON-tag-ignored case carries a side effect: it removes from +// target.Properties any entry whose owner Go name matches the current +// field's Go name — an embed-side property re-declared with +// `json:"-"` wins over the inherited one. +func (s *Builder) structFieldCarrier(fld *types.Var, decl *scanner.EntityDecl, target *oaispec.Schema, nameByJSON map[string]propOwner) (fieldCarrier, bool, error) { + if fld.Embedded() || !fld.Exported() { + return fieldCarrier{}, false, nil + } + + afld := resolvers.FindASTField(decl.File, fld.Pos()) + if afld == nil { + return fieldCarrier{}, false, nil + } + + fd := s.scanFieldDoc(afld) + if fd.Ignored { + return fieldCarrier{}, false, nil + } + + name, ignore, isString, omitEmpty, err := resolvers.ParseJSONTag(afld) + if err != nil { + return fieldCarrier{}, false, err + } + if ignore { + for jsonName, prior := range nameByJSON { + if prior.goName == fld.Name() { + delete(target.Properties, jsonName) + break + } + } + return fieldCarrier{}, false, nil + } + + return fieldCarrier{ + name: name, + goName: fld.Name(), + propType: fld.Type(), + afld: afld, + fd: fd, + isString: isString, + omitEmpty: omitEmpty, + }, true, nil +} + +// methodCarrier produces the carrier for an interface method, or +// returns ok=false when the method must be skipped silently +// (unexported, not a parameterless single-result signature, no AST, +// doc-ignored). +func (s *Builder) methodCarrier(fld *types.Func, decl *scanner.EntityDecl) (fieldCarrier, bool) { + if !fld.Exported() { + return fieldCarrier{}, false + } + + sig, isSignature := fld.Type().(*types.Signature) + if !isSignature { + return fieldCarrier{}, false + } + if sig.Params().Len() > 0 { + return fieldCarrier{}, false + } + if sig.Results() == nil || sig.Results().Len() != 1 { + return fieldCarrier{}, false + } + + afld := resolvers.FindASTField(decl.File, fld.Pos()) + if afld == nil { + return fieldCarrier{}, false + } + + fd := s.scanFieldDoc(afld) + if fd.Ignored { + return fieldCarrier{}, false + } + + name := fd.JSONName + if name == "" { + name = s.interfaceJSONName(fld.Name()) + } + + return fieldCarrier{ + name: name, + goName: fld.Name(), + propType: sig.Results().At(0).Type(), + afld: afld, + fd: fd, + }, true +} + +func (s *Builder) applyNullable(target *oaispec.Schema) { + if !s.Ctx.SetXNullableForPointers() { + return + } + + if target.Extensions == nil || (target.Extensions["x-nullable"] == nil && target.Extensions["x-isnullable"] == nil) { + target.AddExtension("x-nullable", true) + } +} + +// interfaceJSONName maps a Go interface-method name to its JSON property name via the Builder's mangler. +func (s *Builder) interfaceJSONName(goName string) string { + return s.methodMangler.ToJSONName(goName) +} diff --git a/internal/builders/schema/interface.go b/internal/builders/schema/interface.go new file mode 100644 index 0000000..5ecd216 --- /dev/null +++ b/internal/builders/schema/interface.go @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/ast" + "go/types" + + "github.com/go-openapi/codescan/internal/ifaces" + "github.com/go-openapi/codescan/internal/scanner" + oaispec "github.com/go-openapi/spec" +) + +func (s *Builder) buildFromInterface(decl *scanner.EntityDecl, it *types.Interface, schema *oaispec.Schema, nameByJSON map[string]propOwner) error { + if it.Empty() { + // return an empty schema for empty interfaces + return nil + } + + var ( + target *oaispec.Schema + hasAllOf bool + ) + + var flist []*ast.Field + if specType, ok := decl.Spec.Type.(*ast.InterfaceType); ok { + flist = make([]*ast.Field, it.NumEmbeddeds()+it.NumExplicitMethods()) + copy(flist, specType.Methods.List) + } + + // First collect the embedded interfaces + // create refs when: + // + // 1. the embedded interface is decorated with an allOf annotation + // 2. the embedded interface is an alias + for fld := range it.EmbeddedTypes() { + if target == nil { + target = &oaispec.Schema{} + } + + fieldHasAllOf, err := s.processEmbeddedType(fld, flist, decl, schema, nameByJSON) + if err != nil { + return err + } + hasAllOf = hasAllOf || fieldHasAllOf + } + + if target == nil { + target = schema + } + + // We can finally build the actual schema for the struct + if target.Properties == nil { + target.Properties = make(map[string]oaispec.Schema) + } + target.Typed("object", "") + + for fld := range it.ExplicitMethods() { + if err := s.processInterfaceMethod(fld, decl, target, nameByJSON); err != nil { + return err + } + } + + if target == nil { + return nil + } + if hasAllOf && len(target.Properties) > 0 { + schema.AllOf = append(schema.AllOf, *target) + } + + return nil +} + +func (s *Builder) processInterfaceMethod(fld *types.Func, decl *scanner.EntityDecl, target *oaispec.Schema, nameByJSON map[string]propOwner) error { + c, ok := s.methodCarrier(fld, decl) + if !ok { + return nil + } + return s.applyFieldCarrier(c, target, nameByJSON) +} + +func (s *Builder) buildNamedInterface( + ftpe *types.Named, flist []*ast.Field, decl *scanner.EntityDecl, schema *oaispec.Schema, nameByJSON map[string]propOwner, +) (hasAllOf bool, err error) { + o := ftpe.Obj() + var afld *ast.Field + + for _, an := range flist { + if len(an.Names) != 0 { + continue + } + + tpp := decl.Pkg.TypesInfo.Types[an.Type] + if tpp.Type.String() != o.Type().String() { + continue + } + + // decl. + afld = an + break + } + + if afld == nil { + return hasAllOf, nil + } + + fd := s.scanFieldDoc(afld) + if fd.Ignored { + return hasAllOf, nil + } + + if !fd.IsAllOfMember { + var newSch oaispec.Schema + if err = s.buildEmbedded(o.Type(), &newSch, nameByJSON); err != nil { + return hasAllOf, err + } + schema.AllOf = append(schema.AllOf, newSch) + hasAllOf = true + + return hasAllOf, nil + } + + hasAllOf = true + + var newSch oaispec.Schema + // when the embedded struct is annotated with swagger:allOf it will be used as allOf property + // otherwise the fields will just be included as normal properties + if err = s.buildAllOf(o.Type(), &newSch); err != nil { + return hasAllOf, err + } + + if fd.AllOfClass != "" { + schema.AddExtension("x-class", fd.AllOfClass) + } + + schema.AllOf = append(schema.AllOf, newSch) + + return hasAllOf, nil +} + +func (s *Builder) buildAnonymousInterface(it *types.Interface, target ifaces.SwaggerTypable, decl *scanner.EntityDecl) error { + target.Typed("object", "") + + for fld := range it.ExplicitMethods() { + if err := s.processAnonInterfaceMethod(fld, decl, target.Schema()); err != nil { + return err + } + } + + return nil +} + +func (s *Builder) processAnonInterfaceMethod(fld *types.Func, decl *scanner.EntityDecl, schema *oaispec.Schema) error { + c, ok := s.methodCarrier(fld, decl) + if !ok { + return nil + } + return s.applyFieldCarrier(c, schema, nil) +} diff --git a/internal/builders/schema/options.go b/internal/builders/schema/options.go new file mode 100644 index 0000000..1070c7c --- /dev/null +++ b/internal/builders/schema/options.go @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/types" + + "github.com/go-openapi/codescan/internal/ifaces" + oaispec "github.com/go-openapi/spec" +) + +// Option to build a schema. +type Option func(*options) + +type options struct { + definitions map[string]oaispec.Schema + inputType types.Type + target ifaces.SwaggerTypable + simpleSchema bool + paramIn string +} + +// WithDefinitions selects the "definitions" Build mode. The builder +// emits the top-level schema for the bound EntityDecl into the +// supplied map keyed by `s.Name`. +func WithDefinitions(definitions map[string]oaispec.Schema) Option { + return func(o *options) { + o.definitions = definitions + } +} + +// WithType selects the "typed target" Build mode (full Schema). The +// builder writes the schema for tpe into the caller-owned target. +// Used for body parameters, response bodies, and any other site that +// produces a full OAS v2 Schema. +func WithType(tpe types.Type, tgt ifaces.SwaggerTypable) Option { + return func(o *options) { + o.inputType = tpe + o.target = tgt + } +} + +// WithSimpleSchema selects the SimpleSchema Build mode for OAS v2 +// parameter / response-header sites where `in` is not `body`. tpe +// is the Go type; tgt is the caller-owned SimpleSchema-shaped +// target (typically paramTypable or headerTypable); in carries the +// parameter location string ("query" / "path" / "header" / +// "formData", or empty for response headers). +// +// # Details +// +// See [§simple-schema-mode](./README.md#simple-schema-mode) — the +// allowed keyword surface, the catch-at-exit contract, the +// SimpleSchemaProbe interface, and the rules that drive the +// file/allowEmptyValue special cases. +func WithSimpleSchema(tpe types.Type, tgt ifaces.SwaggerTypable, in string) Option { + return func(o *options) { + o.inputType = tpe + o.target = tgt + o.simpleSchema = true + o.paramIn = in + } +} + +// OptionFor picks the right Build mode based on the typable's +// location: WithType when the target is a body schema, WithSimpleSchema +// otherwise. The parameters and responses builders both call this +// at every field-build site; the body / non-body split is the +// single discriminator they rely on. +// +// Centralised here so the dispatch is uniform — adding a third mode +// or refining the gate becomes a one-place edit. +func OptionFor(tpe types.Type, tgt ifaces.SwaggerTypable) Option { + if tgt.In() == "body" { + return WithType(tpe, tgt) + } + return WithSimpleSchema(tpe, tgt, tgt.In()) +} + +func optionsWithDefaults(opts []Option) options { + var o options + + for _, apply := range opts { + apply(&o) + } + + return o +} diff --git a/internal/builders/schema/parsing_stuff.go b/internal/builders/schema/parsing_stuff.go new file mode 100644 index 0000000..81cc1b0 --- /dev/null +++ b/internal/builders/schema/parsing_stuff.go @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/ast" + + "github.com/go-openapi/codescan/internal/parsers/grammar" +) + +func (s *Builder) InferNames() { + s.inferNames() +} + +// findAnnotation returns the first Block in the ParseBlocks slice +// matching kind, or nil. Used by discovery sites (e.g. inferNames) +// where the relevant annotation may not be the first in source +// order — multi-annotation comments like +// `swagger:type … / swagger:model objectStruct` need ParseAll's +// per-annotation Block to surface the model override. +// +//nolint:ireturn // Block interface is the documented return type. +func (s *Builder) findAnnotation(cg *ast.CommentGroup, kind grammar.AnnotationKind) grammar.Block { + for _, b := range s.ParseBlocks(cg) { + if b.AnnotationKind() == kind { + return b + } + } + return nil +} + +func (s *Builder) inferNames() { + if s.GoName != "" { + return + } + + goName := s.Decl.Ident.Name + s.GoName = goName + s.Name = goName + + // Read the model annotation directly off the cached parsed + // Blocks. findAnnotation walks every annotation in the comment + // group, so multi-annotation comments (e.g. `swagger:type` + + // `swagger:model objectStruct`) still surface the model + // override regardless of source order. AnnotationArg() carries + // the IDENT_NAME when one was given; bare `swagger:model` + // keeps the Go identifier as the schema name (parity with v1). + model := s.findAnnotation(s.Decl.Comments, grammar.AnnModel) + if model == nil { + return + } + + s.annotated = true + if override, ok := model.AnnotationArg(); ok { + s.Name = override + } +} diff --git a/internal/builders/schema/ref.go b/internal/builders/schema/ref.go new file mode 100644 index 0000000..828e3a7 --- /dev/null +++ b/internal/builders/schema/ref.go @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/types" + + "github.com/go-openapi/codescan/internal/ifaces" +) + +// resolveRefOr looks tio up in the model index. On hit, emits a +// $ref to it via the inherited MakeRef. On miss, runs orElse (or +// returns nil when orElse is nil). Used by every named-shape leaf +// that follows the "FindModel → MakeRef, else fallback" pattern. +func (s *Builder) resolveRefOr(tio *types.TypeName, tgt ifaces.SwaggerTypable, orElse func() error) error { + if decl, ok := s.Ctx.GetModel(tio.Pkg().Path(), tio.Name()); ok { + return s.MakeRef(decl, tgt) + } + if orElse == nil { + return nil + } + return orElse() +} + +// resolveRefOrErr is the strict counterpart of resolveRefOr: +// the FindModel miss is treated as a missingSource error rather +// than a silent fallback. errTpe is the type to format into the +// error message (typically the underlying, not the *types.Named, +// to mirror v1's diagnostics for that arm). +func (s *Builder) resolveRefOrErr(tio *types.TypeName, tgt ifaces.SwaggerTypable, errTpe types.Type) error { + if decl, ok := s.Ctx.GetModel(tio.Pkg().Path(), tio.Name()); ok { + return s.MakeRef(decl, tgt) + } + return missingSource(errTpe) +} diff --git a/internal/builders/schema/schema.go b/internal/builders/schema/schema.go index 0a5002d..388e966 100644 --- a/internal/builders/schema/schema.go +++ b/internal/builders/schema/schema.go @@ -4,1446 +4,443 @@ package schema import ( - "encoding/json" "fmt" "go/ast" "go/types" - "log" - "reflect" - "strings" + "log/slog" "github.com/go-openapi/swag/mangling" - "golang.org/x/tools/go/packages" + "github.com/go-openapi/codescan/internal/builders/common" "github.com/go-openapi/codescan/internal/builders/resolvers" "github.com/go-openapi/codescan/internal/ifaces" - "github.com/go-openapi/codescan/internal/logger" - "github.com/go-openapi/codescan/internal/parsers" "github.com/go-openapi/codescan/internal/scanner" oaispec "github.com/go-openapi/spec" ) +// Builder knows how to build a spec schema from go source. +// +// # Details +// +// See the maintainer notes in README.md (sibling file). type Builder struct { - ctx *scanner.ScanCtx - decl *scanner.EntityDecl - GoName string - Name string - annotated bool - discovered []*scanner.EntityDecl - postDecls []*scanner.EntityDecl + options + *common.Builder - // interfaceMethodMangler produces JSON-style property names from Go - // interface-method names. Interface methods cannot carry struct tags, so - // codescan can't read a per-field convention — instead it applies the - // same transform go-swagger uses for tag-less struct fields (acronym-aware - // lower-first, e.g. `CreatedAt → createdAt`, `ID → id`, - // `ExternalID → externalId`). `swagger:name` still takes precedence when - // present. NameMangler is thread-safe per its godoc. - // - // Pointer so that the zero value (nil) is safely detected and lazily - // initialized by interfaceJSONName — a zero mangling.NameMangler value - // panics on use, and tests that construct &Builder{…} directly bypass - // NewBuilder. - interfaceMethodMangler *mangling.NameMangler + GoName string + Name string + annotated bool + discovered []*scanner.EntityDecl + methodMangler mangling.NameMangler // see [§method-mangler](./README.md#method-mangler). + skipExtensions bool + + // Embed-recursion depth for ambiguous-embed diagnostics; + // see [§embed-depth](./README.md#embed-depth). + embedDepth int } +// NewBuilder constructs an initialized [Builder]. func NewBuilder(ctx *scanner.ScanCtx, decl *scanner.EntityDecl) *Builder { - m := mangling.NewNameMangler() return &Builder{ - ctx: ctx, - decl: decl, - interfaceMethodMangler: &m, + Builder: common.New(ctx, decl), + methodMangler: mangling.NewNameMangler(), + skipExtensions: ctx.SkipExtensions(), } } -func (s *Builder) Build(definitions map[string]oaispec.Schema) error { - s.inferNames() - - schema := definitions[s.Name] - err := s.buildFromDecl(s.decl, &schema) - if err != nil { - return err +// Build a schema spec. +// +// The output is either stored in the passed definitions map (when used with [WithDefinitions]), +// or in the target renderer (when used with [WithType]). +func (s *Builder) Build(opts ...Option) error { + s.options = optionsWithDefaults(opts) // set options for this build + if s.definitions == nil && s.inputType == nil || s.definitions != nil && s.inputType != nil { + panic("dev error: Build must be called with either a map of definitions or a single type") } - definitions[s.Name] = schema - - return nil -} - -func (s *Builder) SetDiscovered(discovered []*scanner.EntityDecl) { - s.discovered = discovered -} -func (s *Builder) PostDeclarations() []*scanner.EntityDecl { - return s.postDecls -} + if s.definitions != nil { + // top-level declarations + s.inferNames() -func (s *Builder) InferNames() { - s.inferNames() -} + schema := s.definitions[s.Name] // if not named, empty schema + err := s.buildFromDecl(&schema) + if err != nil { + return err + } -func (s *Builder) BuildFromType(tpe types.Type, tgt ifaces.SwaggerTypable) error { - return s.buildFromType(tpe, tgt) -} + s.definitions[s.Name] = schema -func (s *Builder) inferNames() { - if s.GoName != "" { - return + return nil } - goName := s.decl.Ident.Name - s.GoName = goName - s.Name = goName - - override, ok := parsers.ModelOverride(s.decl.Comments) - if !ok { - return + // build from parameter/response schema + if err := s.buildFromType(s.inputType, s.target); err != nil { + return err } - s.annotated = true - // Why: ModelOverride returns ("", true) for a bare `swagger:model` annotation - // without a name — in that case the Go identifier is the model name. - if override != "" { - s.Name = override + if s.simpleSchema { + s.validateSimpleSchemaOutcome() } + return nil } -// interfaceJSONName maps a Go interface-method name to its JSON property -// name via the Builder's mangler, lazily initializing the mangler on first -// use so a zero-value Builder remains usable. -func (s *Builder) interfaceJSONName(goName string) string { - if s.interfaceMethodMangler == nil { - m := mangling.NewNameMangler() - s.interfaceMethodMangler = &m - } - return s.interfaceMethodMangler.ToJSONName(goName) +func (s *Builder) SetDiscovered(discovered []*scanner.EntityDecl) { + s.discovered = discovered } -func (s *Builder) buildFromDecl(_ *scanner.EntityDecl, schema *oaispec.Schema) error { - // analyze doc comment for the model - // This includes parsing "example", "default" and other validation at the top-level declaration. - sp := s.createParser("", schema, schema, nil, - parsers.WithSetTitle(func(lines []string) { schema.Title = parsers.JoinDropLast(lines) }), - parsers.WithSetDescription(func(lines []string) { - schema.Description = parsers.JoinDropLast(lines) - enumDesc := parsers.GetEnumDesc(schema.Extensions) - if enumDesc != "" { - schema.Description += "\n" + enumDesc - } - }), - ) - - if err := sp.Parse(s.decl.Comments); err != nil { - return err +// buildFromDecl emits the schema for a top-level type declaration +// (named or alias). +// +// # Details +// +// See [§build-entry](./README.md#build-entry), [§special-types](./README.md#special-types) +// and [§user-overrides](./README.md#user-overrides). +func (s *Builder) buildFromDecl(schema *oaispec.Schema) error { + if s.applyDeclCommentBlock(schema) { + // swagger:ignore short-circuit. + return nil } + defer s.annotateSchema(schema)() - // if the type is marked to ignore, just return - if sp.Ignored() { + // Intercept stdlib specials before kind-dispatch (catches stdlib + // decls pulled in via the discovery chain). See + // [§special-types](./README.md#special-types). + ps := NewTypable(schema, 0, s.skipExtensions) + if applyStdlibSpecials(s.Decl.Obj(), ps, s.skipExtensions) { return nil } - defer func() { - if schema.Ref.String() == "" { - // unless this is a $ref, we add traceability of the origin of this schema in source - if s.Name != s.GoName { - resolvers.AddExtension(&schema.VendorExtensible, "x-go-name", s.GoName, s.ctx.SkipExtensions()) - } - resolvers.AddExtension(&schema.VendorExtensible, "x-go-package", s.decl.Obj().Pkg().Path(), s.ctx.SkipExtensions()) + // Decl-site swagger:type override wins over type-driven default. + // See [§user-overrides](./README.md#user-overrides) for the + // (handled, recurse) contract and the Underlying() fallback rationale. + if handled, recurse := s.classifierNamedTypeOverride(s.Decl.Comments, ps); handled { + if !recurse { + return nil } - }() + if named, ok := s.Decl.ObjType().(*types.Named); ok { + return s.buildFromType(named.Underlying(), ps) + } + } - switch tpe := s.decl.ObjType().(type) { + switch tpe := s.Decl.ObjType().(type) { case *types.Named: - logger.DebugLogf(s.ctx.Debug(), "named: %v", tpe) - return s.buildDeclNamed(tpe, schema) + if s.guardDecl(tpe) { + return nil + } + ti := s.Decl.Pkg.TypesInfo.Types[s.Decl.Spec.Type] + resolvers.MustBeAType(ti) // invariant + return s.buildFromType(ti.Type, NewTypable(schema, 0, s.skipExtensions)) case *types.Alias: - logger.DebugLogf(s.ctx.Debug(), "alias: %v -> %v", tpe, tpe.Rhs()) - tgt := Typable{schema, 0, s.ctx.SkipExtensions()} - - return s.buildDeclAlias(tpe, tgt) + if s.guardDecl(tpe) { + return nil + } + return s.buildDeclAlias(tpe, NewTypable(schema, 0, s.skipExtensions)) default: - logger.UnsupportedTypeKind("buildFromDecl", tpe) - return nil - } -} - -func (s *Builder) buildDeclNamed(tpe *types.Named, schema *oaispec.Schema) error { - if resolvers.UnsupportedBuiltin(tpe) { - log.Printf("WARNING: skipped unsupported builtin type: %v", tpe) - - return nil - } - o := tpe.Obj() - - resolvers.MustNotBeABuiltinType(o) - - logger.DebugLogf(s.ctx.Debug(), "got the named type object: %s.%s | isAlias: %t | exported: %t", o.Pkg().Path(), o.Name(), o.IsAlias(), o.Exported()) - if resolvers.IsStdTime(o) { - schema.Typed("string", "date-time") + s.Warn("unsupported Go type. Skipping", slog.Any("type", tpe)) return nil } - - ps := Typable{schema, 0, s.ctx.SkipExtensions()} - ti := s.decl.Pkg.TypesInfo.Types[s.decl.Spec.Type] - if !ti.IsType() { - return fmt.Errorf("declaration is not a type: %v: %w", o, ErrSchema) - } - - return s.buildFromType(ti.Type, ps) } -// buildFromTextMarshal renders a type that marshals as text as a string. -func (s *Builder) buildFromTextMarshal(tpe types.Type, tgt ifaces.SwaggerTypable) error { - if typePtr, ok := tpe.(*types.Pointer); ok { - return s.buildFromTextMarshal(typePtr.Elem(), tgt) - } - - // An alias surfaced under a pointer (e.g. *Timestamp where - // Timestamp = time.Time) — route through buildAlias so the alias - // indirection is honored per RefAliases/TransparentAliases, same as - // the non-pointer path in buildFromType. - if typeAlias, ok := tpe.(*types.Alias); ok { - return s.buildAlias(typeAlias, tgt) - } - - typeNamed, ok := tpe.(*types.Named) - if !ok { - tgt.Typed("string", "") - return nil - } - - tio := typeNamed.Obj() - if resolvers.IsStdError(tio) { - tgt.AddExtension("x-go-type", tio.Name()) - return resolvers.SwaggerSchemaForType(tio.Name(), tgt) - } +// buildDeclAlias emits the schema for a top-level alias declaration. +// Three modes — Dissolve / $ref / Expand — selected by TransparentAliases × RefAliases. +// +// # Details +// +// See [§aliases](./README.md#aliases) and [§discovery](./README.md#discovery). +func (s *Builder) buildDeclAlias(tpe *types.Alias, target ifaces.SwaggerTypable) error { + resolvers.MustHaveRightHandSide(tpe) - logger.DebugLogf(s.ctx.Debug(), "named refined type %s.%s", tio.Pkg().Path(), tio.Name()) - pkg, found := s.ctx.PkgForType(tpe) + rhs := tpe.Rhs() - if strings.ToLower(tio.Name()) == "uuid" { - tgt.Typed("string", "uuid") - return nil + // Dissolve: no LHS definition, build directly from the RHS. + if s.Ctx.TransparentAliases() { + return s.buildFromType(rhs, target) } - if !found { - // this must be a builtin - logger.DebugLogf(s.ctx.Debug(), "skipping because package is nil: %v", tpe) - return nil - } + // Register the LHS so the spec's definitions section includes it even when not swagger:model-annotated. + // s.Decl IS the alias's LHS decl by construction (see [§discovery](./README.md#discovery) + // for why we skip the GetModel round-trip here). + s.Ctx.AddDiscoveredModel(s.Decl) + s.AppendPostDecl(s.Decl) - if resolvers.IsStdTime(tio) { - tgt.Typed("string", "date-time") - return nil + // Expand: LHS gets a structural definition mirroring the + // underlying. tpe.Rhs() peels one alias layer; tpe.Underlying() + // peels through aliases AND named types to the structural form. + if !s.Ctx.RefAliases() { + return s.buildFromType(tpe.Underlying(), target) } - if resolvers.IsStdJSONRawMessage(tio) { - tgt.Typed("object", "") // TODO: this should actually be any type - return nil - } + // $ref to the RHS's target. + switch rtpe := rhs.(type) { + case *types.Named: + ro := rtpe.Obj() + rdecl, found := s.Ctx.GetModel(ro.Pkg().Path(), ro.Name()) + if !found { + return missingSource(rtpe) + } - cmt, hasComments := s.ctx.FindComments(pkg, tio.Name()) - if !hasComments { - cmt = new(ast.CommentGroup) - } + return s.MakeRef(rdecl, target) + case *types.Alias: + ro := rtpe.Obj() + if resolvers.UnsupportedBuiltin(rtpe) { + s.Warn("skipped unsupported builtin", slog.Any("type", rtpe)) - if sfnm, isf := parsers.StrfmtName(cmt); isf { - tgt.Typed("string", sfnm) - return nil - } + return nil + } - tgt.Typed("string", "") - tgt.AddExtension("x-go-type", tio.Pkg().Path()+"."+tio.Name()) + if applyStdlibSpecials(ro, target, s.skipExtensions) { + return nil + } - return nil -} + resolvers.MustNotBeABuiltinType(ro) -// hasNamedCore reports whether tpe is a *types.Named, or resolves to one -// by peeling one or more pointer layers. Used to gate content-based -// shortcuts (like the TextMarshaler check) to types whose name can be -// inspected — anonymous structural kinds cannot yield meaningful output -// from those shortcuts and should take the structural dispatch instead. -func hasNamedCore(tpe types.Type) bool { - for { - switch t := tpe.(type) { - case *types.Named: - return true - case *types.Pointer: - tpe = t.Elem() - default: - return false + rdecl, found := s.Ctx.GetModel(ro.Pkg().Path(), ro.Name()) + if !found { + return missingSource(rtpe) } + + return s.MakeRef(rdecl, target) + default: // alias to anonymous type + return s.buildFromType(rhs, target) } } -func (s *Builder) buildFromType(tpe types.Type, tgt ifaces.SwaggerTypable) error { - logger.DebugLogf(s.ctx.Debug(), "schema buildFromType %v (%T)", tpe, tpe) - - // Aliases are dispatched first, before any content-based shortcut, - // so the alias indirection is honored consistently with the caller's - // RefAliases/TransparentAliases intent. Without this, a text- - // marshalable alias (e.g. `type Timestamp = time.Time`) would be - // inlined as a plain string — losing both the alias semantics and - // (because buildFromTextMarshal only unwraps pointers) the target's - // format. +// buildFromType emits the schema for an arbitrary Go type into target. +// +// # Details +// +// See [§dispatch-table](./README.md#dispatch-table) for the named-type pivot; +// [§textmarshal-order](./README.md#textmarshal-order) for the TextMarshaler shortcut. +func (s *Builder) buildFromType(tpe types.Type, target ifaces.SwaggerTypable) error { + // Aliases first — honour RefAliases / TransparentAliases before + // any content-based shortcut (a TextMarshaler-implementing alias + // otherwise gets inlined and loses its alias indirection). if titpe, ok := tpe.(*types.Alias); ok { - logger.DebugLogf(s.ctx.Debug(), "alias(schema.buildFromType): got alias %v to %v", titpe, titpe.Rhs()) - return s.buildAlias(titpe, tgt) + return s.buildAlias(titpe, target) } - // Only shortcut to the TextMarshaler renderer when we can reach a - // *types.Named by peeling pointers — buildFromTextMarshal uses the - // name to map to known formats (time/uuid/json.RawMessage/strfmt) and - // falls back to {string, ""} otherwise. An anonymous struct that only - // satisfies TextMarshaler by embedding time.Time (method promotion) - // would otherwise be flattened to {string}, erasing its body and any - // allOf composition. See Q4 in .claude/plans/observed-quirks.md. + // TextMarshaler shortcut: only when reachable to a *types.Named by peeling pointers. + // Anonymous structs that merely satisfy TextMarshaler via embedded-method promotion don't qualify — + // they need the structural walk to preserve body / allOf shape. if hasNamedCore(tpe) && resolvers.IsTextMarshaler(tpe) { - return s.buildFromTextMarshal(tpe, tgt) + return s.buildFromTextMarshal(tpe, target) } switch titpe := tpe.(type) { case *types.Basic: if resolvers.UnsupportedBuiltinType(titpe) { - log.Printf("WARNING: skipped unsupported builtin type: %v", tpe) + s.Warn("skipped unsupported builtin", slog.Any("type", tpe)) return nil } - return resolvers.SwaggerSchemaForType(titpe.String(), tgt) + return resolvers.SwaggerSchemaForType(titpe.String(), target) case *types.Pointer: - return s.buildFromType(titpe.Elem(), tgt) + return s.buildFromType(titpe.Elem(), target) case *types.Struct: - return s.buildFromStruct(s.decl, titpe, tgt.Schema(), make(map[string]string)) + return s.buildFromStruct(s.Decl, titpe, target.Schema(), make(map[string]propOwner)) case *types.Interface: - return s.buildFromInterface(s.decl, titpe, tgt.Schema(), make(map[string]string)) + return s.buildFromInterface(s.Decl, titpe, target.Schema(), make(map[string]propOwner)) case *types.Slice: - // anonymous slice - return s.buildFromType(titpe.Elem(), tgt.Items()) + return s.buildFromType(titpe.Elem(), target.Items()) case *types.Array: - // anonymous array - return s.buildFromType(titpe.Elem(), tgt.Items()) + return s.buildFromType(titpe.Elem(), target.Items()) case *types.Map: - return s.buildFromMap(titpe, tgt) + return s.buildFromMap(titpe, target) case *types.Named: - // a named type, e.g. type X struct {} - return s.buildNamedType(titpe, tgt) + return s.buildNamedType(titpe, target) default: - // Warn-and-skip for unsupported kinds (TypeParam, Chan, Signature, - // Union, or future go/types additions). The scanner runs on user - // code in uncontrolled environments, so panicking here would be a - // worse experience than producing a partial spec. - logger.UnsupportedTypeKind("buildFromType", titpe) + // Unknown kind (TypeParam, Chan, Signature, Union, future additions). + // Warn-and-skip — panicking would degrade UX on user code we can't inspect. + s.Warn("unsupported Go type. Skipping", slog.Any("type", tpe)) return nil } } -func (s *Builder) buildNamedType(titpe *types.Named, tgt ifaces.SwaggerTypable) error { - tio := titpe.Obj() - if resolvers.UnsupportedBuiltin(titpe) { - log.Printf("WARNING: skipped unsupported builtin type: %v", titpe) +// buildAlias emits a schema for an alias reached as a field/element type (not a top-level decl). +// +// TransparentAliases dissolves the alias indirection; otherwise emit a $ref. +// +// # Details +// +// See [§aliases](./README.md#aliases). +func (s *Builder) buildAlias(tpe *types.Alias, target ifaces.SwaggerTypable) error { + if resolvers.UnsupportedBuiltinType(tpe) { + s.Warn("skipped unsupported builtin", slog.Any("type", tpe)) return nil } - if resolvers.IsAny(tio) { - // e.g type X any or type X interface{} - _ = tgt.Schema() - + o := tpe.Obj() + if applyStdlibSpecials(o, target, s.skipExtensions) { return nil } + resolvers.MustNotBeABuiltinType(o) + + if s.Ctx.TransparentAliases() { + return s.buildFromType(tpe.Rhs(), target) + } - // special case of the "error" interface. - if resolvers.IsStdError(tio) { - tgt.AddExtension("x-go-type", tio.Name()) - return resolvers.SwaggerSchemaForType(tio.Name(), tgt) + decl, ok := s.Ctx.GetModel(o.Pkg().Path(), o.Name()) + if !ok { + return fmt.Errorf("can't find source file for aliased type: %v: %w", tpe, ErrSchema) } + return s.MakeRef(decl, target) +} + +// buildNamedType emits the schema for a *types.Named reached as a field/element/embed type. +// +// # Details +// +// See [§dispatch-table](./README.md#dispatch-table) and [§dissolve-named](./README.md#dissolve-named). +func (s *Builder) buildNamedType(titpe *types.Named, target ifaces.SwaggerTypable) error { + if resolvers.UnsupportedBuiltin(titpe) { + s.Warn("skipped unsupported builtin", slog.Any("type", titpe)) - // special case of the "time.Time" type - if resolvers.IsStdTime(tio) { - tgt.Typed("string", "date-time") return nil } - // special case of the "json.RawMessage" type - if resolvers.IsStdJSONRawMessage(tio) { - tgt.Typed("object", "") // TODO: this should actually be any type + tio := titpe.Obj() + if applyStdlibSpecials(tio, target, s.skipExtensions) { return nil } - pkg, found := s.ctx.PkgForType(titpe) - logger.DebugLogf(s.ctx.Debug(), "named refined type %s.%s", pkg, tio.Name()) + // PkgForType-miss catches types we can't anchor to a scanned package + // (predeclared `comparable`, generic type params, compiler-internal shapes). + // + // Complementary to the UnsupportedBuiltin guard above; also yields `pkg` for the Basic classifier below. + pkg, found := s.Ctx.PkgForType(titpe) if !found { - // this must be a builtin - // - // This could happen for example when using unsupported types such as complex64, complex128, uintptr, - // or type constraints such as comparable. - logger.DebugLogf(s.ctx.Debug(), "skipping because package is nil (builtin type): %v", tio) - return nil } - cmt, hasComments := s.ctx.FindComments(pkg, tio.Name()) - if !hasComments { - cmt = new(ast.CommentGroup) + var cmt *ast.CommentGroup + if decl, ok := s.Ctx.DeclForType(titpe); ok && decl != nil { + cmt = decl.Comments } - if tn, ok := parsers.TypeName(cmt); ok { - if err := resolvers.SwaggerSchemaForType(tn, tgt); err == nil { - return nil + if handled, recurse := s.classifierNamedTypeOverride(cmt, target); handled { + if recurse { + return s.buildFromType(titpe.Underlying(), target) } - // For unsupported swagger:type values (e.g., "array"), fall through - // to underlying type resolution so the full schema (including items - // for slices) is properly built. Build directly from the underlying - // type to bypass the named-type $ref creation. - return s.buildFromType(titpe.Underlying(), tgt) - } - - if s.decl.Spec.Assign.IsValid() { - logger.DebugLogf(s.ctx.Debug(), "found assignment: %s.%s", tio.Pkg().Path(), tio.Name()) - return s.buildFromType(titpe.Underlying(), tgt) + return nil } - if titpe.TypeArgs() != nil && titpe.TypeArgs().Len() > 0 { - return s.buildFromType(titpe.Underlying(), tgt) + // Dissolve when there's no source-level TypeSpec to $ref: + // - outer decl is alias-syntax (TransparentAliases plumbing); + // - generic instantiation (Underlying carries substituted args). + // + // See [§dissolve-named](./README.md#dissolve-named). + if s.Decl.Spec.Assign.IsValid() || (titpe.TypeArgs() != nil && titpe.TypeArgs().Len() > 0) { + return s.buildFromType(titpe.Underlying(), target) } - // invariant: the Underlying cannot be an alias or named type + // Underlying-shape table. See [§dispatch-table](./README.md#dispatch-table). switch utitpe := titpe.Underlying().(type) { case *types.Struct: - return s.buildNamedStruct(tio, cmt, tgt) + if s.classifierNamedStructStrfmt(cmt, target) { + return nil + } + return s.resolveRefOr(tio, target, nil) + case *types.Interface: - logger.DebugLogf(s.ctx.Debug(), "found interface: %s.%s", tio.Pkg().Path(), tio.Name()) + return s.resolveRefOrErr(tio, target, utitpe) - decl, found := s.ctx.FindModel(tio.Pkg().Path(), tio.Name()) - if !found { - return fmt.Errorf("can't find source file for type: %v: %w", utitpe, ErrSchema) + case *types.Basic: + if resolvers.UnsupportedBuiltinType(utitpe) { + s.Warn("skipped unsupported builtin", slog.Any("type", tio)) + return nil + } + if s.classifierNamedBasic(cmt, pkg, utitpe, target) { + return nil } + return s.resolveRefOr(tio, target, func() error { + return resolvers.SwaggerSchemaForType(utitpe.String(), target) + }) - return s.makeRef(decl, tgt) - case *types.Basic: - return s.buildNamedBasic(tio, pkg, cmt, utitpe, tgt) case *types.Array: - return s.buildNamedArray(tio, cmt, utitpe.Elem(), tgt) + return s.buildNamedArrayLike(tio, cmt, utitpe.Elem(), target, false) case *types.Slice: - return s.buildNamedSlice(tio, cmt, utitpe.Elem(), tgt) + return s.buildNamedArrayLike(tio, cmt, utitpe.Elem(), target, true) + case *types.Map: - logger.DebugLogf(s.ctx.Debug(), "found map type: %s.%s", tio.Pkg().Path(), tio.Name()) + return s.resolveRefOr(tio, target, nil) - if decl, ok := s.ctx.FindModel(tio.Pkg().Path(), tio.Name()); ok { - return s.makeRef(decl, tgt) - } - return nil default: - logger.UnsupportedTypeKind("buildNamedType", utitpe) + s.Warn("unsupported Go type. Skipping", slog.Any("type", utitpe)) return nil } } -func (s *Builder) buildNamedBasic(tio *types.TypeName, pkg *packages.Package, cmt *ast.CommentGroup, utitpe *types.Basic, tgt ifaces.SwaggerTypable) error { - if resolvers.UnsupportedBuiltinType(utitpe) { - log.Printf("WARNING: skipped unsupported builtin type: %v", utitpe) - return nil - } - - logger.DebugLogf(s.ctx.Debug(), "found primitive type: %s.%s", tio.Pkg().Path(), tio.Name()) - - if sfnm, isf := parsers.StrfmtName(cmt); isf { - tgt.Typed("string", sfnm) - return nil - } - - if enumName, ok := parsers.EnumName(cmt); ok { - enumValues, enumDesces, _ := 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")) +// buildNamedArrayLike is the unified Array/Slice arm. +// forSlice toggles the slice-only "bsonobjectid" special case in classifierNamedArrayLike. +func (s *Builder) buildNamedArrayLike(tio *types.TypeName, cmt *ast.CommentGroup, elem types.Type, tgt ifaces.SwaggerTypable, forSlice bool) error { + if handled, recurse := s.classifierNamedArrayLike(cmt, tgt, forSlice); handled { + if recurse { + return s.buildFromType(elem, tgt.Items()) } - - return nil - } - - if defaultName, ok := parsers.DefaultName(cmt); ok { - logger.DebugLogf(s.ctx.Debug(), "default name: %s", defaultName) - return nil - } - - if typeName, ok := parsers.TypeName(cmt); ok { - _ = resolvers.SwaggerSchemaForType(typeName, tgt) return nil } - if parsers.IsAliasParam(tgt) || parsers.AliasParam(cmt) { - err := resolvers.SwaggerSchemaForType(utitpe.Name(), tgt) - if err == nil { - return nil - } - } - - if decl, ok := s.ctx.FindModel(tio.Pkg().Path(), tio.Name()); ok { - return s.makeRef(decl, tgt) - } - - return resolvers.SwaggerSchemaForType(utitpe.String(), tgt) + return s.resolveRefOr(tio, tgt, func() error { + return s.buildFromType(elem, tgt.Items()) + }) } -// buildNamedStruct emits a $ref to a named struct definition (or a strfmt -// override when `swagger:strfmt` is set on the type's doc). -// -// Preconditions established by the sole caller buildNamedType and not -// re-checked here: -// - tio is never time.Time — IsStdTime(tio) short-circuits upstream to -// {string, date-time} before the Underlying() switch runs. -// - parsers.TypeName(cmt) is never set — the upstream TypeName branch -// either resolves via SwaggerSchemaForType or delegates to -// buildFromType(Underlying), so neither outcome reaches this function. -// -// Re-adding either check here would be dead code. -func (s *Builder) buildNamedStruct(tio *types.TypeName, cmt *ast.CommentGroup, tgt ifaces.SwaggerTypable) error { - logger.DebugLogf(s.ctx.Debug(), "found struct: %s.%s", tio.Pkg().Path(), tio.Name()) - - // Run strfmt first, before FindModel, so a `swagger:strfmt` type is - // inlined as {string, format} *without* registering the struct in - // ExtraModels — FindModel has a side effect that would otherwise emit - // the struct as an orphan object definition no field references. See - // Q10 in .claude/plans/observed-quirks.md. - // - // A caveat remains: when the author combines `swagger:strfmt` with - // `swagger:model` (a "named strfmt" shape), the field still inlines - // here while the top-level definition body is emitted by walking the - // underlying struct. That inconsistency is documented in - // .claude/plans/deferred-quirks.md and left for v2. - if sfnm, isf := parsers.StrfmtName(cmt); isf { - tgt.Typed("string", sfnm) - return nil +// buildFromMap renders a Go map as a Swagger object with additionalProperties. +// Maps whose key type is neither string nor TextMarshaler are skipped — Swagger object keys are strings. +func (s *Builder) buildFromMap(titpe *types.Map, tgt ifaces.SwaggerTypable) error { + sch := tgt.Schema() + if sch == nil { + return fmt.Errorf("items doesn't support maps: %w", ErrSchema) } - decl, ok := s.ctx.FindModel(tio.Pkg().Path(), tio.Name()) - if !ok { - logger.DebugLogf(s.ctx.Debug(), "could not find model in index: %s.%s", tio.Pkg().Path(), tio.Name()) - return nil + eleProp := NewTypable(sch, tgt.Level(), s.skipExtensions) + key := titpe.Key() + if key.Underlying().String() == "string" || resolvers.IsTextMarshaler(key) { + return s.buildFromType(titpe.Elem(), eleProp.AdditionalProperties()) } - return s.makeRef(decl, tgt) -} - -func (s *Builder) buildNamedArray(tio *types.TypeName, cmt *ast.CommentGroup, elem types.Type, tgt ifaces.SwaggerTypable) error { - logger.DebugLogf(s.ctx.Debug(), "found array type: %s.%s", tio.Pkg().Path(), tio.Name()) - - if sfnm, isf := parsers.StrfmtName(cmt); isf { - if sfnm == "byte" { - tgt.Typed("string", sfnm) - return nil - } - if sfnm == "bsonobjectid" { - tgt.Typed("string", sfnm) - return nil - } - - tgt.Items().Typed("string", sfnm) - return nil - } - // When swagger:type is set to an unsupported value (e.g., "array"), - // skip the $ref and inline the array schema with proper items type. - if tn, ok := parsers.TypeName(cmt); ok { - if err := resolvers.SwaggerSchemaForType(tn, tgt); err != nil { - return s.buildFromType(elem, tgt.Items()) - } - return nil - } - if decl, ok := s.ctx.FindModel(tio.Pkg().Path(), tio.Name()); ok { - return s.makeRef(decl, tgt) - } - return s.buildFromType(elem, tgt.Items()) + return nil } -func (s *Builder) buildNamedSlice(tio *types.TypeName, cmt *ast.CommentGroup, elem types.Type, tgt ifaces.SwaggerTypable) error { - logger.DebugLogf(s.ctx.Debug(), "found slice type: %s.%s", tio.Pkg().Path(), tio.Name()) - - if sfnm, isf := parsers.StrfmtName(cmt); isf { - if sfnm == "byte" { - tgt.Typed("string", sfnm) - return nil +// annotateSchema returns a deferrable that decorates schema with x-go-name / x-go-package traceability extensions, +// unless the schema is just a $ref, in which case the source origin is the target's definition, +// not the reference site. +func (s *Builder) annotateSchema(schema *oaispec.Schema) func() { + return func() { + if schema.Ref.String() != "" { + return } - tgt.Items().Typed("string", sfnm) - return nil - } - // When swagger:type is set to an unsupported value (e.g., "array"), - // skip the $ref and inline the slice schema with proper items type. - // This preserves the field's description that would be lost with $ref. - if tn, ok := parsers.TypeName(cmt); ok { - if err := resolvers.SwaggerSchemaForType(tn, tgt); err != nil { - // Unsupported type name (e.g., "array") — build inline from element type. - return s.buildFromType(elem, tgt.Items()) + if s.Name != s.GoName { + resolvers.AddExtension(&schema.VendorExtensible, "x-go-name", s.GoName, s.skipExtensions) } - return nil + resolvers.AddExtension(&schema.VendorExtensible, "x-go-package", s.Decl.Obj().Pkg().Path(), s.skipExtensions) } - if decl, ok := s.ctx.FindModel(tio.Pkg().Path(), tio.Name()); ok { - return s.makeRef(decl, tgt) - } - return s.buildFromType(elem, tgt.Items()) } -// buildDeclAlias builds a top-level alias declaration. -// -// Note on LHS checks NOT performed here: IsAny(o) / IsStdError(o) / -// IsStdTime(o) on o := tpe.Obj() would all be false. o is the user's -// declared name (e.g. "X" in `type X = any`), so o.Pkg() is always the -// user's package and o.Name() is the user's identifier — neither -// matches the predeclared any/error (Pkg()==nil) nor stdlib time.Time -// (pkg "time", name "Time"). The live equivalents IsAny(ro) / -// IsStdError(ro) inside the `case *types.Alias:` branch of the RHS -// switch below do fire: they inspect the alias target, which for -// `type X = any` resolves to the predeclared any TypeName. -func (s *Builder) buildDeclAlias(tpe *types.Alias, tgt ifaces.SwaggerTypable) error { - if resolvers.UnsupportedBuiltinType(tpe) { - log.Printf("WARNING: skipped unsupported builtin type: %v", tpe) - return nil - } - - o := tpe.Obj() - resolvers.MustNotBeABuiltinType(o) - resolvers.MustHaveRightHandSide(tpe) - rhs := tpe.Rhs() - - // If transparent aliases are enabled, use the underlying type directly without creating a definition - if s.ctx.TransparentAliases() { - return s.buildFromType(rhs, tgt) - } - - decl, ok := s.ctx.FindModel(o.Pkg().Path(), o.Name()) - if !ok { - return fmt.Errorf("can't find source file for aliased type: %v -> %v: %w", tpe, rhs, ErrSchema) - } - - s.postDecls = append(s.postDecls, decl) // mark the left-hand side as discovered - - if !s.ctx.RefAliases() { - // expand alias - return s.buildFromType(tpe.Underlying(), tgt) - } - - // resolve alias to named type as $ref - switch rtpe := rhs.(type) { - // named declarations: we construct a $ref to the right-hand side target of the alias - case *types.Named: - ro := rtpe.Obj() - rdecl, found := s.ctx.FindModel(ro.Pkg().Path(), ro.Name()) - if !found { - return fmt.Errorf("can't find source file for target type of alias: %v -> %v: %w", tpe, rtpe, ErrSchema) - } - - return s.makeRef(rdecl, tgt) - case *types.Alias: - ro := rtpe.Obj() - if resolvers.UnsupportedBuiltin(rtpe) { - log.Printf("WARNING: skipped unsupported builtin type: %v", rtpe) - return nil - } - if resolvers.IsAny(ro) { - // e.g. type X = any - _ = tgt.Schema() // this is mutating tgt to create an empty schema - return nil - } - if resolvers.IsStdError(ro) { - // e.g. type X = error - tgt.AddExtension("x-go-type", o.Name()) - return resolvers.SwaggerSchemaForType(o.Name(), tgt) - } - resolvers.MustNotBeABuiltinType(ro) // TODO(fred): there are a few other cases - - rdecl, found := s.ctx.FindModel(ro.Pkg().Path(), ro.Name()) - if !found { - return fmt.Errorf("can't find source file for target type of alias: %v -> %v: %w", tpe, rtpe, ErrSchema) - } - - return s.makeRef(rdecl, tgt) +// guardDecl filters known-unsupported builtins (unsafe.Pointer) and asserts the scanner-side invariant +// that o.Pkg() is non-nil before downstream code dereferences it. +// Returns true if the caller should skip processing. +func (s *Builder) guardDecl(tpe ifaces.Objecter) (skip bool) { + if resolvers.UnsupportedBuiltin(tpe) { + s.Warn("skipped unsupported builtin", slog.Any("type", tpe)) + return true } - - // alias to anonymous type - return s.buildFromType(rhs, tgt) + resolvers.MustNotBeABuiltinType(tpe.Obj()) // invariant + return false } -func (s *Builder) buildAnonymousInterface(it *types.Interface, tgt ifaces.SwaggerTypable, decl *scanner.EntityDecl) error { - tgt.Typed("object", "") - - for fld := range it.ExplicitMethods() { - if err := s.processAnonInterfaceMethod(fld, it, decl, tgt.Schema()); err != nil { - return err +// hasNamedCore reports whether tpe is, or peels through pointers to reach, +// a *types.Named. Gates content-based checks (notably the TextMarshaler shortcut) +// to types whose name is inspectable. +// Anonymous structural kinds can satisfy TextMarshaler via embedded +// method promotion but must take the structural dispatch instead. +func hasNamedCore(tpe types.Type) bool { + for { + switch t := tpe.(type) { + case *types.Named: + return true + case *types.Pointer: + tpe = t.Elem() + default: + return false } } - - return nil -} - -func (s *Builder) processAnonInterfaceMethod(fld *types.Func, it *types.Interface, decl *scanner.EntityDecl, schema *oaispec.Schema) error { - if !fld.Exported() { - return nil - } - sig, isSignature := fld.Type().(*types.Signature) - if !isSignature { - return nil - } - if sig.Params().Len() > 0 { - return nil - } - if sig.Results() == nil || sig.Results().Len() != 1 { - return nil - } - - afld := resolvers.FindASTField(decl.File, fld.Pos()) - if afld == nil { - logger.DebugLogf(s.ctx.Debug(), "can't find source associated with %s for %s", fld.String(), it.String()) - return nil - } - - if parsers.Ignored(afld.Doc) { - return nil - } - - name, ok := parsers.NameOverride(afld.Doc) - if !ok { - name = s.interfaceJSONName(fld.Name()) - } - - if schema.Properties == nil { - schema.Properties = make(map[string]oaispec.Schema) - } - ps := schema.Properties[name] - if err := s.buildFromType(sig.Results().At(0).Type(), Typable{&ps, 0, s.ctx.SkipExtensions()}); err != nil { - return err - } - if sfName, isStrfmt := parsers.StrfmtName(afld.Doc); isStrfmt { - ps.Typed("string", sfName) - ps.Ref = oaispec.Ref{} - ps.Items = nil - } - - sp := s.createParser(name, schema, &ps, afld) - if err := sp.Parse(afld.Doc); err != nil { - return err - } - - if ps.Ref.String() == "" && name != fld.Name() { - ps.AddExtension("x-go-name", fld.Name()) - } - - if s.ctx.SetXNullableForPointers() { - _, isPointer := fld.Type().(*types.Signature).Results().At(0).Type().(*types.Pointer) - noNullableExt := ps.Extensions == nil || - (ps.Extensions["x-nullable"] == nil && ps.Extensions["x-isnullable"] == nil) - if isPointer && noNullableExt { - ps.AddExtension("x-nullable", true) - } - } - - schema.Properties[name] = ps - return nil -} - -// buildAlias builds a reference to an alias from another type. -func (s *Builder) buildAlias(tpe *types.Alias, tgt ifaces.SwaggerTypable) error { - if resolvers.UnsupportedBuiltinType(tpe) { - log.Printf("WARNING: skipped unsupported builtin type: %v", tpe) - - return nil - } - - o := tpe.Obj() - if resolvers.IsAny(o) { - _ = tgt.Schema() - return nil - } - resolvers.MustNotBeABuiltinType(o) - - // If transparent aliases are enabled, use the underlying type directly - if s.ctx.TransparentAliases() { - return s.buildFromType(tpe.Rhs(), tgt) - } - - decl, ok := s.ctx.FindModel(o.Pkg().Path(), o.Name()) - if !ok { - return fmt.Errorf("can't find source file for aliased type: %v: %w", tpe, ErrSchema) - } - - return s.makeRef(decl, tgt) -} - -func (s *Builder) buildFromMap(titpe *types.Map, tgt ifaces.SwaggerTypable) error { - // check if key is a string type, or knows how to marshall to text. - // If not, print a message and skip the map property. - // - // Only maps with string keys can go into additional properties - - sch := tgt.Schema() - if sch == nil { - return fmt.Errorf("items doesn't support maps: %w", ErrSchema) - } - - eleProp := Typable{sch, tgt.Level(), s.ctx.SkipExtensions()} - key := titpe.Key() - if key.Underlying().String() == "string" || resolvers.IsTextMarshaler(key) { - return s.buildFromType(titpe.Elem(), eleProp.AdditionalProperties()) - } - - return nil -} - -func (s *Builder) buildFromInterface(decl *scanner.EntityDecl, it *types.Interface, schema *oaispec.Schema, seen map[string]string) error { - if it.Empty() { - // return an empty schema for empty interfaces - return nil - } - - var ( - tgt *oaispec.Schema - hasAllOf bool - ) - - var flist []*ast.Field - if specType, ok := decl.Spec.Type.(*ast.InterfaceType); ok { - flist = make([]*ast.Field, it.NumEmbeddeds()+it.NumExplicitMethods()) - copy(flist, specType.Methods.List) - } - - // First collect the embedded interfaces - // create refs when: - // - // 1. the embedded interface is decorated with an allOf annotation - // 2. the embedded interface is an alias - for fld := range it.EmbeddedTypes() { - if tgt == nil { - tgt = &oaispec.Schema{} - } - - fieldHasAllOf, err := s.processEmbeddedType(fld, flist, decl, schema, seen) - if err != nil { - return err - } - hasAllOf = hasAllOf || fieldHasAllOf - } - - if tgt == nil { - tgt = schema - } - - // We can finally build the actual schema for the struct - if tgt.Properties == nil { - tgt.Properties = make(map[string]oaispec.Schema) - } - tgt.Typed("object", "") - - for fld := range it.ExplicitMethods() { - if err := s.processInterfaceMethod(fld, it, decl, tgt, seen); err != nil { - return err - } - } - - if tgt == nil { - return nil - } - if hasAllOf && len(tgt.Properties) > 0 { - schema.AllOf = append(schema.AllOf, *tgt) - } - - for k := range tgt.Properties { - if _, ok := seen[k]; !ok { - delete(tgt.Properties, k) - } - } - - return nil -} - -func (s *Builder) processEmbeddedType( - fld types.Type, - flist []*ast.Field, - decl *scanner.EntityDecl, - schema *oaispec.Schema, - seen map[string]string, -) (fieldHasAllOf bool, err error) { - logger.DebugLogf(s.ctx.Debug(), "inspecting embedded type in interface: %v", fld) - - switch ftpe := fld.(type) { - case *types.Named: - logger.DebugLogf(s.ctx.Debug(), "embedded named type (buildInterface): %v", ftpe) - o := ftpe.Obj() - if resolvers.IsAny(o) || resolvers.IsStdError(o) { - return false, nil - } - return s.buildNamedInterface(ftpe, flist, decl, schema, seen) - case *types.Interface: - logger.DebugLogf(s.ctx.Debug(), "embedded anonymous interface type (buildInterface): %v", ftpe) - var aliasedSchema oaispec.Schema - ps := Typable{schema: &aliasedSchema, skipExt: s.ctx.SkipExtensions()} - if err = s.buildAnonymousInterface(ftpe, ps, decl); err != nil { - return false, err - } - if aliasedSchema.Ref.String() != "" || len(aliasedSchema.Properties) > 0 || len(aliasedSchema.AllOf) > 0 { - fieldHasAllOf = true - schema.AddToAllOf(aliasedSchema) - } - case *types.Alias: - logger.DebugLogf(s.ctx.Debug(), "embedded alias (buildInterface): %v -> %v", ftpe, ftpe.Rhs()) - var aliasedSchema oaispec.Schema - ps := Typable{schema: &aliasedSchema, skipExt: s.ctx.SkipExtensions()} - if err = s.buildAlias(ftpe, ps); err != nil { - return false, err - } - if aliasedSchema.Ref.String() != "" || len(aliasedSchema.Properties) > 0 || len(aliasedSchema.AllOf) > 0 { - fieldHasAllOf = true - schema.AddToAllOf(aliasedSchema) - } - default: - logger.UnsupportedTypeKind("buildNamedInterface.allOf", ftpe) - } - - logger.DebugLogf(s.ctx.Debug(), "got embedded interface: %v {%T}, fieldHasAllOf: %t", fld, fld, fieldHasAllOf) - return fieldHasAllOf, nil -} - -func (s *Builder) processInterfaceMethod(fld *types.Func, it *types.Interface, decl *scanner.EntityDecl, tgt *oaispec.Schema, seen map[string]string) error { - if !fld.Exported() { - return nil - } - - sig, isSignature := fld.Type().(*types.Signature) - if !isSignature { - return nil - } - - if sig.Params().Len() > 0 { - return nil - } - - if sig.Results() == nil || sig.Results().Len() != 1 { - return nil - } - - afld := resolvers.FindASTField(decl.File, fld.Pos()) - if afld == nil { - logger.DebugLogf(s.ctx.Debug(), "can't find source associated with %s for %s", fld.String(), it.String()) - return nil - } - - // if the field is annotated with swagger:ignore, ignore it - if parsers.Ignored(afld.Doc) { - return nil - } - - name, ok := parsers.NameOverride(afld.Doc) - if !ok { - name = s.interfaceJSONName(fld.Name()) - } - - ps := tgt.Properties[name] - if err := s.buildFromType(sig.Results().At(0).Type(), Typable{&ps, 0, s.ctx.SkipExtensions()}); err != nil { - return err - } - - if sfName, isStrfmt := parsers.StrfmtName(afld.Doc); isStrfmt { - ps.Typed("string", sfName) - ps.Ref = oaispec.Ref{} - ps.Items = nil - } - - sp := s.createParser(name, tgt, &ps, afld) - if err := sp.Parse(afld.Doc); err != nil { - return err - } - - if ps.Ref.String() == "" && name != fld.Name() { - ps.AddExtension("x-go-name", fld.Name()) - } - - if s.ctx.SetXNullableForPointers() { - _, isPointer := fld.Type().(*types.Signature).Results().At(0).Type().(*types.Pointer) - noNullableExt := ps.Extensions == nil || - (ps.Extensions["x-nullable"] == nil && ps.Extensions["x-isnullable"] == nil) - if isPointer && noNullableExt { - ps.AddExtension("x-nullable", true) - } - } - - seen[name] = fld.Name() - tgt.Properties[name] = ps - - return nil -} - -func (s *Builder) buildNamedInterface(ftpe *types.Named, flist []*ast.Field, decl *scanner.EntityDecl, schema *oaispec.Schema, seen map[string]string) (hasAllOf bool, err error) { - o := ftpe.Obj() - var afld *ast.Field - - for _, an := range flist { - if len(an.Names) != 0 { - continue - } - - tpp := decl.Pkg.TypesInfo.Types[an.Type] - if tpp.Type.String() != o.Type().String() { - continue - } - - // decl. - logger.DebugLogf(s.ctx.Debug(), "maybe interface field %s: %s(%T)", o.Name(), o.Type().String(), o.Type()) - afld = an - break - } - - if afld == nil { - logger.DebugLogf(s.ctx.Debug(), "can't find source associated with %s", ftpe.String()) - return hasAllOf, nil - } - - // if the field is annotated with swagger:ignore, ignore it - if parsers.Ignored(afld.Doc) { - return hasAllOf, nil - } - - if !parsers.AllOfMember(afld.Doc) { - var newSch oaispec.Schema - if err = s.buildEmbedded(o.Type(), &newSch, seen); err != nil { - return hasAllOf, err - } - schema.AllOf = append(schema.AllOf, newSch) - hasAllOf = true - - return hasAllOf, nil - } - - hasAllOf = true - - var newSch oaispec.Schema - // when the embedded struct is annotated with swagger:allOf it will be used as allOf property - // otherwise the fields will just be included as normal properties - if err = s.buildAllOf(o.Type(), &newSch); err != nil { - return hasAllOf, err - } - - if afld.Doc != nil { - extractAllOfClass(afld.Doc, schema) - } - - schema.AllOf = append(schema.AllOf, newSch) - - return hasAllOf, nil -} - -func (s *Builder) buildFromStruct(decl *scanner.EntityDecl, st *types.Struct, schema *oaispec.Schema, seen map[string]string) error { - cmt, hasComments := s.ctx.FindComments(decl.Pkg, decl.Obj().Name()) - if !hasComments { - cmt = new(ast.CommentGroup) - } - name, ok := parsers.TypeName(cmt) - if ok { - _ = resolvers.SwaggerSchemaForType(name, Typable{schema: schema, skipExt: s.ctx.SkipExtensions()}) - return nil - } - // First pass: scan anonymous/embedded fields for allOf composition. - // Returns the target schema for properties (may differ from schema when allOf is used). - tgt, hasAllOf, err := s.scanEmbeddedFields(decl, st, schema, seen) - if err != nil { - return err - } - - if tgt == nil { - if schema != nil { - tgt = schema - } else { - tgt = &oaispec.Schema{} - } - } - if tgt.Properties == nil { - tgt.Properties = make(map[string]oaispec.Schema) - } - tgt.Typed("object", "") - - // Second pass: build properties from non-embedded exported fields. - if err := s.buildStructFields(decl, st, tgt, seen); err != nil { - return err - } - - if tgt == nil { - return nil - } - if hasAllOf && len(tgt.Properties) > 0 { - schema.AllOf = append(schema.AllOf, *tgt) - } - for k := range tgt.Properties { - if _, ok := seen[k]; !ok { - delete(tgt.Properties, k) - } - } - return nil -} - -// scanEmbeddedFields iterates over anonymous struct fields to detect allOf composition. -// It returns: -// - tgt: the schema that should receive properties (nil if no embedded fields were processed, -// schema itself for plain embeds, or a new schema when allOf is detected) -// - hasAllOf: whether any allOf member was found -func (s *Builder) scanEmbeddedFields(decl *scanner.EntityDecl, st *types.Struct, schema *oaispec.Schema, seen map[string]string) (tgt *oaispec.Schema, hasAllOf bool, err error) { - for i := range st.NumFields() { - fld := st.Field(i) - if !fld.Anonymous() { - logger.DebugLogf(s.ctx.Debug(), "skipping field %q for allOf scan because not anonymous", fld.Name()) - continue - } - tg := st.Tag(i) - - logger.DebugLogf(s.ctx.Debug(), - "maybe allof field(%t) %s: %s (%T) [%q](anon: %t, embedded: %t)", - fld.IsField(), fld.Name(), fld.Type().String(), fld.Type(), tg, fld.Anonymous(), fld.Embedded(), - ) - afld := resolvers.FindASTField(decl.File, fld.Pos()) - if afld == nil { - logger.DebugLogf(s.ctx.Debug(), "can't find source associated with %s for %s", fld.String(), st.String()) - continue - } - - if parsers.Ignored(afld.Doc) { - continue - } - - _, ignore, _, _, err := resolvers.ParseJSONTag(afld) - if err != nil { - return nil, false, err - } - if ignore { - continue - } - - _, isAliased := fld.Type().(*types.Alias) - - if !parsers.AllOfMember(afld.Doc) && !isAliased { - // Plain embed: merge fields into the main schema - if tgt == nil { - tgt = schema - } - if err := s.buildEmbedded(fld.Type(), tgt, seen); err != nil { - return nil, false, err - } - continue - } - - if isAliased { - logger.DebugLogf(s.ctx.Debug(), "alias member in struct: %v", fld) - } - - // allOf member: fields go into a separate schema, embedded struct becomes an allOf entry - hasAllOf = true - if tgt == nil { - tgt = &oaispec.Schema{} - } - var newSch oaispec.Schema - if err := s.buildAllOf(fld.Type(), &newSch); err != nil { - return nil, false, err - } - - extractAllOfClass(afld.Doc, schema) - schema.AllOf = append(schema.AllOf, newSch) - } - - return tgt, hasAllOf, nil -} - -func (s *Builder) buildStructFields(decl *scanner.EntityDecl, st *types.Struct, tgt *oaispec.Schema, seen map[string]string) error { - for fld := range st.Fields() { - if err := s.processStructField(fld, decl, tgt, seen); err != nil { - return err - } - } - return nil -} - -func (s *Builder) processStructField(fld *types.Var, decl *scanner.EntityDecl, tgt *oaispec.Schema, seen map[string]string) error { - if fld.Embedded() || !fld.Exported() { - return nil - } - - afld := resolvers.FindASTField(decl.File, fld.Pos()) - if afld == nil { - logger.DebugLogf(s.ctx.Debug(), "can't find source associated with %s", fld.String()) - return nil - } - - if parsers.Ignored(afld.Doc) { - return nil - } - - name, ignore, isString, omitEmpty, err := resolvers.ParseJSONTag(afld) - if err != nil { - return err - } - - if ignore { - for seenTagName, seenFieldName := range seen { - if seenFieldName == fld.Name() { - delete(tgt.Properties, seenTagName) - break - } - } - return nil - } - - ps := tgt.Properties[name] - if err = s.buildFromType(fld.Type(), Typable{&ps, 0, s.ctx.SkipExtensions()}); err != nil { - return err - } - if isString { - ps.Typed("string", ps.Format) - ps.Ref = oaispec.Ref{} - ps.Items = nil - } - - if sfName, isStrfmt := parsers.StrfmtName(afld.Doc); isStrfmt { - ps.Typed("string", sfName) - ps.Ref = oaispec.Ref{} - ps.Items = nil - } - - sp := s.createParser(name, tgt, &ps, afld) - if err := sp.Parse(afld.Doc); err != nil { - return err - } - - if ps.Ref.String() == "" && name != fld.Name() { - resolvers.AddExtension(&ps.VendorExtensible, "x-go-name", fld.Name(), s.ctx.SkipExtensions()) - } - - if s.ctx.SetXNullableForPointers() { - if _, isPointer := fld.Type().(*types.Pointer); isPointer && !omitEmpty && - (ps.Extensions == nil || (ps.Extensions["x-nullable"] == nil && ps.Extensions["x-isnullable"] == nil)) { - ps.AddExtension("x-nullable", true) - } - } - - // we have 2 cases: - // 1. field with different name override tag - // 2. field with different name removes tag - // so we need to save both tag&name - seen[name] = fld.Name() - tgt.Properties[name] = ps - return nil -} - -func (s *Builder) buildAllOf(tpe types.Type, schema *oaispec.Schema) error { - logger.DebugLogf(s.ctx.Debug(), "allOf %s", tpe.Underlying()) - - switch ftpe := tpe.(type) { - case *types.Pointer: - return s.buildAllOf(ftpe.Elem(), schema) - case *types.Named: - return s.buildNamedAllOf(ftpe, schema) - case *types.Alias: - logger.DebugLogf(s.ctx.Debug(), "allOf member is alias %v => %v", ftpe, ftpe.Rhs()) - tgt := Typable{schema: schema, skipExt: s.ctx.SkipExtensions()} - return s.buildAlias(ftpe, tgt) - default: - logger.UnsupportedTypeKind("buildAllOf", ftpe) - return nil - } -} - -func (s *Builder) buildNamedAllOf(ftpe *types.Named, schema *oaispec.Schema) error { - switch utpe := ftpe.Underlying().(type) { - case *types.Struct: - tio := ftpe.Obj() - - // Run inlining shortcuts (stdlib time, swagger:strfmt) before - // FindModel — FindModel registers the type in ExtraModels as a - // side effect, which would emit an orphan top-level definition - // for a type whose schema we've already inlined. See Q10 in - // .claude/plans/observed-quirks.md. - if resolvers.IsStdTime(tio) { - schema.Typed("string", "date-time") - return nil - } - - if pkg, ok := s.ctx.PkgForType(ftpe); ok { - if cmt, hasComments := s.ctx.FindComments(pkg, tio.Name()); hasComments { - if sfnm, isf := parsers.StrfmtName(cmt); isf { - schema.Typed("string", sfnm) - return nil - } - } - } - - decl, found := s.ctx.FindModel(tio.Pkg().Path(), tio.Name()) - if !found { - return fmt.Errorf("can't find source file for struct: %s: %w", ftpe.String(), ErrSchema) - } - - if decl.HasModelAnnotation() { - return s.makeRef(decl, Typable{schema, 0, s.ctx.SkipExtensions()}) - } - - return s.buildFromStruct(decl, utpe, schema, make(map[string]string)) - case *types.Interface: - decl, found := s.ctx.FindModel(ftpe.Obj().Pkg().Path(), ftpe.Obj().Name()) - if !found { - return fmt.Errorf("can't find source file for interface: %s: %w", ftpe.String(), ErrSchema) - } - - if sfnm, isf := parsers.StrfmtName(decl.Comments); isf { - schema.Typed("string", sfnm) - return nil - } - - if decl.HasModelAnnotation() { - return s.makeRef(decl, Typable{schema, 0, s.ctx.SkipExtensions()}) - } - - return s.buildFromInterface(decl, utpe, schema, make(map[string]string)) - default: - logger.UnsupportedTypeKind("buildNamedAllOf", utpe) - return nil - } -} - -func (s *Builder) buildEmbedded(tpe types.Type, schema *oaispec.Schema, seen map[string]string) error { - logger.DebugLogf(s.ctx.Debug(), "embedded %v", tpe.Underlying()) - - switch ftpe := tpe.(type) { - case *types.Pointer: - return s.buildEmbedded(ftpe.Elem(), schema, seen) - case *types.Named: - return s.buildNamedEmbedded(ftpe, schema, seen) - case *types.Alias: - logger.DebugLogf(s.ctx.Debug(), "embedded alias %v => %v", ftpe, ftpe.Rhs()) - tgt := Typable{schema, 0, s.ctx.SkipExtensions()} - return s.buildAlias(ftpe, tgt) - default: - logger.UnsupportedTypeKind("buildEmbedded", ftpe) - return nil - } -} - -func (s *Builder) buildNamedEmbedded(ftpe *types.Named, schema *oaispec.Schema, seen map[string]string) error { - logger.DebugLogf(s.ctx.Debug(), "embedded named type: %T", ftpe.Underlying()) - if resolvers.UnsupportedBuiltin(ftpe) { - log.Printf("WARNING: skipped unsupported builtin type: %v", ftpe) - - return nil - } - - switch utpe := ftpe.Underlying().(type) { - case *types.Struct: - decl, found := s.ctx.FindModel(ftpe.Obj().Pkg().Path(), ftpe.Obj().Name()) - if !found { - return fmt.Errorf("can't find source file for struct: %s: %w", ftpe.String(), ErrSchema) - } - - return s.buildFromStruct(decl, utpe, schema, seen) - case *types.Interface: - if utpe.Empty() { - return nil - } - o := ftpe.Obj() - if resolvers.IsAny(o) { - return nil - } - if resolvers.IsStdError(o) { - tgt := Typable{schema: schema, skipExt: s.ctx.SkipExtensions()} - tgt.AddExtension("x-go-type", o.Name()) - return resolvers.SwaggerSchemaForType(o.Name(), tgt) - } - resolvers.MustNotBeABuiltinType(o) - - decl, found := s.ctx.FindModel(o.Pkg().Path(), o.Name()) - if !found { - return fmt.Errorf("can't find source file for struct: %s: %w", ftpe.String(), ErrSchema) - } - return s.buildFromInterface(decl, utpe, schema, seen) - default: - logger.UnsupportedTypeKind("buildNamedEmbedded", utpe) - return nil - } -} - -func (s *Builder) makeRef(decl *scanner.EntityDecl, prop ifaces.SwaggerTypable) error { - nm, _ := decl.Names() - ref, err := oaispec.NewRef("#/definitions/" + nm) - if err != nil { - return err - } - - prop.SetRef(ref) - s.postDecls = append(s.postDecls, decl) - - return nil -} - -func (s *Builder) createParser(nm string, schema, ps *oaispec.Schema, fld *ast.Field, opts ...parsers.SectionedParserOption) *parsers.SectionedParser { - if ps.Ref.String() != "" && !s.ctx.DescWithRef() { - // if DescWithRef option is enabled, allow the tagged documentation to flow alongside the $ref - // otherwise behave as expected by jsonschema draft4: $ref predates all sibling keys. - opts = append( - opts, - parsers.WithTaggers(refSchemaTaggers(schema, nm)...), - ) - - return parsers.NewSectionedParser(opts...) - } - - taggers := schemaTaggers(schema, ps, nm) - - // the parser may be called outside the context of struct field. - // In that case, just return the outcome of the parsing now. - - if fld != nil { - // check if this is a primitive, if so parse the validations from the - // doc comments of the slice declaration. - if ftped, ok := fld.Type.(*ast.ArrayType); ok { - var err error - arrayTaggers, err := parseArrayTypes(taggers, ftped.Elt, ps.Items, 0) // NOTE: swallows error silently - if err == nil { - taggers = arrayTaggers - } - } - } - - opts = append( - opts, - parsers.WithSetDescription(func(lines []string) { - ps.Description = parsers.JoinDropLast(lines) - enumDesc := parsers.GetEnumDesc(ps.Extensions) - if enumDesc != "" { - ps.Description += "\n" + enumDesc - } - }), - parsers.WithTaggers(taggers...), - ) - - return parsers.NewSectionedParser(opts...) -} - -func schemaVendorExtensibleSetter(meta *oaispec.Schema) func(json.RawMessage) error { - return func(jsonValue json.RawMessage) error { - var jsonData oaispec.Extensions - err := json.Unmarshal(jsonValue, &jsonData) - if err != nil { - return err - } - - for k := range jsonData { - if !parsers.IsAllowedExtension(k) { - return fmt.Errorf("invalid schema extension name, should start from `x-`: %s: %w", k, ErrSchema) - } - } - - meta.Extensions = jsonData - - return nil - } -} - -func extractAllOfClass(doc *ast.CommentGroup, schema *oaispec.Schema) { - allOfClass, ok := parsers.AllOfName(doc) - if !ok { - return - } - - schema.AddExtension("x-class", allOfClass) } diff --git a/internal/builders/schema/schema_go118_test.go b/internal/builders/schema/schema_go118_test.go index 7b9f1d2..90d3d5c 100644 --- a/internal/builders/schema/schema_go118_test.go +++ b/internal/builders/schema/schema_go118_test.go @@ -26,12 +26,9 @@ func TestGo118SwaggerTypeNamed(t *testing.T) { sctx := scantest.LoadGo118ClassificationPkgsCtx(t) decl := getGo118ClassificationModel(sctx, "NamedWithType") require.NotNil(t, decl) - prs := &Builder{ - ctx: sctx, - decl: decl, - } + prs := NewBuilder(sctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["namedWithType"] scantest.AssertProperty(t, &schema, "object", "some_map", "", "SomeMap") @@ -51,11 +48,8 @@ func TestGo118AliasedModels(t *testing.T) { decl := getGo118ClassificationModel(sctx, nm) require.NotNil(t, decl) - prs := &Builder{ - decl: decl, - ctx: sctx, - } - require.NoError(t, prs.Build(defs)) + prs := NewBuilder(sctx, decl) + require.NoError(t, prs.Build(WithDefinitions(defs))) } for k := range defs { @@ -78,12 +72,9 @@ func TestGo118InterfaceField(t *testing.T) { sctx := scantest.LoadGo118ClassificationPkgsCtx(t) decl := getGo118ClassificationModel(sctx, "Interfaced") require.NotNil(t, decl) - prs := &Builder{ - ctx: sctx, - decl: decl, - } + prs := NewBuilder(sctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["Interfaced"] scantest.AssertProperty(t, &schema, "", "custom_data", "", "CustomData") @@ -95,12 +86,9 @@ func TestGo118_Issue2809(t *testing.T) { sctx := scantest.LoadGo118ClassificationPkgsCtx(t) decl := getGo118ClassificationModel(sctx, "transportErr") require.NotNil(t, decl) - prs := &Builder{ - ctx: sctx, - decl: decl, - } + prs := NewBuilder(sctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["transportErr"] scantest.AssertProperty(t, &schema, "", "data", "", "Data") diff --git a/internal/builders/schema/schema_test.go b/internal/builders/schema/schema_test.go index 8e8778f..9ab310c 100644 --- a/internal/builders/schema/schema_test.go +++ b/internal/builders/schema/schema_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "testing" + "github.com/go-openapi/codescan/internal/parsers/grammar" "github.com/go-openapi/codescan/internal/scanner" "github.com/go-openapi/codescan/internal/scantest" "github.com/go-openapi/testify/v2/assert" @@ -19,7 +20,10 @@ const ( epsilon = 1e-9 // fixturesModule is the module path of the fixtures nested module. - fixturesModule = "github.com/go-openapi/codescan/fixtures" + fixturesModule = "github.com/go-openapi/codescan/fixtures" + fixtureMinimal3125 = "bugs/3125/minimal" + sampleValue1 = "value1" + sampleValue2 = "value2" ) func TestBuilder_Struct_Tag(t *testing.T) { @@ -37,20 +41,14 @@ func TestBuilder_Struct_Tag(t *testing.T) { require.NotNil(t, td) }) - prs := &Builder{ - ctx: ctx, - decl: td, - } + prs := NewBuilder(ctx, td) result := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(result)) + require.NoError(t, prs.Build(WithDefinitions(result))) scantest.CompareOrDumpJSON(t, result, "petstore_schema_Tag.json") } func TestBuilder_Struct_Pet(t *testing.T) { - // Debug = true - // defer func() { Debug = false }() - ctx := scantest.LoadPetstorePkgsCtx(t, false) var td *scanner.EntityDecl for k, v := range ctx.Models() { @@ -62,20 +60,14 @@ func TestBuilder_Struct_Pet(t *testing.T) { } require.NotNil(t, td) - prs := &Builder{ - ctx: ctx, - decl: td, - } + prs := NewBuilder(ctx, td) result := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(result)) + require.NoError(t, prs.Build(WithDefinitions(result))) scantest.CompareOrDumpJSON(t, result, "petstore_schema_Pet.json") } func TestBuilder_Struct_Order(t *testing.T) { - // Debug = true - // defer func() { Debug = false }() - ctx := scantest.LoadPetstorePkgsCtx(t, false) var td *scanner.EntityDecl for k, v := range ctx.Models() { @@ -87,12 +79,9 @@ func TestBuilder_Struct_Order(t *testing.T) { } require.NotNil(t, td) - prs := &Builder{ - ctx: ctx, - decl: td, - } + prs := NewBuilder(ctx, td) result := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(result)) + require.NoError(t, prs.Build(WithDefinitions(result))) scantest.CompareOrDumpJSON(t, result, "petstore_schema_Order.json") } @@ -101,23 +90,26 @@ func TestBuilder(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "NoModel") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["NoModel"] assert.Equal(t, oaispec.StringOrArray([]string{"object"}), schema.Type) assert.EqualT(t, "NoModel is a struct without an annotation.", schema.Title) - assert.EqualT(t, "NoModel exists in a package\nbut is not annotated with the swagger model annotations\nso it should now show up in a test.", schema.Description) + assert.EqualT(t, + "NoModel exists in a package\nbut is not annotated with the swagger model annotations\nso it should now show up in a test.", + schema.Description, + ) assert.Len(t, schema.Required, 3) assert.Len(t, schema.Properties, 12) scantest.AssertProperty(t, &schema, "integer", "id", "int64", "ID") prop, ok := schema.Properties["id"] - assert.EqualT(t, "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", prop.Description) + assert.EqualT(t, + "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + prop.Description, + ) assert.TrueT(t, ok, "should have had an 'id' property") assert.InDeltaT(t, 1000.00, *prop.Maximum, epsilon) assert.TrueT(t, prop.ExclusiveMaximum, "'id' should have had an exclusive maximum") @@ -152,8 +144,8 @@ func TestBuilder(t *testing.T) { expectedNameExtensions := oaispec.Extensions{ "x-go-name": "Name", "x-property-array": []any{ - "value1", - "value2", + sampleValue1, + sampleValue2, }, "x-property-array-obj": []any{ map[string]any{ @@ -269,7 +261,10 @@ func TestBuilder(t *testing.T) { scantest.AssertProperty(t, itprop, "integer", "id", "int32", "ID") iprop, ok := itprop.Properties["id"] assert.TrueT(t, ok) - assert.EqualT(t, "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", iprop.Description) + assert.EqualT(t, + "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + iprop.Description, + ) require.NotNil(t, iprop.Maximum) assert.InDeltaT(t, 1000.00, *iprop.Maximum, epsilon) assert.TrueT(t, iprop.ExclusiveMaximum, "'id' should have had an exclusive maximum") @@ -282,7 +277,9 @@ func TestBuilder(t *testing.T) { iprop, ok = itprop.Properties["pet"] assert.TrueT(t, ok) if itprop.Ref.String() != "" { - assert.EqualT(t, "The Pet to add to this NoModel items bucket.\nPets can appear more than once in the bucket", iprop.Description) + assert.EqualT(t, "The Pet to add to this NoModel items bucket.\nPets can appear more than once in the bucket", + iprop.Description, + ) } scantest.AssertProperty(t, itprop, "integer", "quantity", "int16", "Quantity") @@ -304,7 +301,7 @@ func TestBuilder(t *testing.T) { decl2 := getClassificationModel(ctx, "StoreOrder") require.NotNil(t, decl2) - require.NoError(t, (&Builder{decl: decl2, ctx: ctx}).Build(models)) + require.NoError(t, NewBuilder(ctx, decl2).Build(WithDefinitions(models))) msch, ok := models["order"] pn := fixturesModule + "/goparsing/classification/models" assert.TrueT(t, ok) @@ -319,7 +316,7 @@ func TestBuilder_AddExtensions(t *testing.T) { models := make(map[string]oaispec.Schema) decl := getClassificationModel(ctx, "StoreOrder") require.NotNil(t, decl) - require.NoError(t, (&Builder{decl: decl, ctx: ctx}).Build(models)) + require.NoError(t, NewBuilder(ctx, decl).Build(WithDefinitions(models))) msch, ok := models["order"] pn := fixturesModule + "/goparsing/classification/models" @@ -333,12 +330,9 @@ func TestTextMarhalCustomType(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "TextMarshalModel") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["TextMarshalModel"] scantest.AssertProperty(t, &schema, "string", "id", "uuid", "ID") scantest.AssertArrayProperty(t, &schema, "string", "ids", "uuid", "IDs") @@ -356,12 +350,9 @@ func TestEmbeddedTypes(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "ComplexerOne") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["ComplexerOne"] scantest.AssertProperty(t, &schema, "integer", "age", "int32", "Age") scantest.AssertProperty(t, &schema, "integer", "id", "int64", "ID") @@ -375,12 +366,9 @@ func TestParsePrimitiveSchemaProperty(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "PrimateModel") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["PrimateModel"] scantest.AssertProperty(t, &schema, "boolean", "a", "", "A") @@ -406,12 +394,9 @@ func TestParseStringFormatSchemaProperty(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "FormattedModel") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["FormattedModel"] scantest.AssertProperty(t, &schema, "string", "a", "byte", "A") @@ -441,12 +426,9 @@ func TestStringStructTag(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "JSONString") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) sch := models["jsonString"] scantest.AssertProperty(t, &sch, "string", "someInt", "int64", "SomeInt") @@ -474,12 +456,9 @@ func TestPtrFieldStringStructTag(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "JSONPtrString") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) sch := models["jsonPtrString"] scantest.AssertProperty(t, &sch, "string", "someInt", "int64", "SomeInt") @@ -506,12 +485,9 @@ func TestIgnoredStructField(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "IgnoredFields") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) sch := models["ignoredFields"] scantest.AssertProperty(t, &sch, "string", "someIncludedField", "", "SomeIncludedField") @@ -523,12 +499,9 @@ func TestParseStructFields(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "SimpleComplexModel") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["SimpleComplexModel"] scantest.AssertProperty(t, &schema, "object", "emb", "", "Emb") @@ -544,12 +517,9 @@ func TestParsePointerFields(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "Pointdexter") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["Pointdexter"] @@ -569,12 +539,9 @@ func TestEmbeddedStarExpr(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "EmbeddedStarExpr") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["EmbeddedStarExpr"] @@ -586,12 +553,9 @@ func TestArrayOfPointers(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "Cars") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["cars"] scantest.AssertProperty(t, &schema, "array", "cars", "", "Cars") @@ -601,12 +565,9 @@ func TestOverridingOneIgnore(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "OverridingOneIgnore") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["OverridingOneIgnore"] @@ -630,12 +591,9 @@ func testParseCollectionFields( ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, modelName) require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models[modelName] @@ -682,12 +640,9 @@ func TestInterfaceField(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "Interfaced") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["Interfaced"] scantest.AssertProperty(t, &schema, "", "custom_data", "", "CustomData") @@ -697,12 +652,9 @@ func TestAliasedTypes(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "OtherTypes") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["OtherTypes"] scantest.AssertRef(t, &schema, "named", "Named", "#/definitions/SomeStringType") @@ -783,11 +735,8 @@ func TestAliasedModels(t *testing.T) { decl := getClassificationModel(ctx, nm) require.NotNil(t, decl) - prs := &Builder{ - decl: decl, - ctx: ctx, - } - require.NoError(t, prs.Build(defs)) + prs := NewBuilder(ctx, decl) + require.NoError(t, prs.Build(WithDefinitions(defs))) } for k := range defs { @@ -853,24 +802,21 @@ func TestAliasedTopLevelModels(t *testing.T) { t.Run("with schema builder", func(t *testing.T) { require.NotNil(t, decl) - builder := &Builder{ - ctx: ctx, - decl: decl, - } + builder := NewBuilder(ctx, decl) t.Run("should build model for Customer", func(t *testing.T) { models := make(map[string]oaispec.Schema) - require.NoError(t, builder.Build(models)) + require.NoError(t, builder.Build(WithDefinitions(models))) assertRefDefinition(t, models, "Customer", "#/definitions/User", "") }) t.Run("should have discovered models for User and Customer", func(t *testing.T) { - require.Len(t, builder.postDecls, 2) + require.Len(t, builder.PostDeclarations(), 2) foundUserIndex := -1 foundCustomerIndex := -1 - for i, discoveredDecl := range builder.postDecls { + for i, discoveredDecl := range builder.PostDeclarations() { switch discoveredDecl.Obj().Name() { case "User": foundUserIndex = i @@ -880,15 +826,14 @@ func TestAliasedTopLevelModels(t *testing.T) { } require.GreaterOrEqualT(t, foundUserIndex, 0) require.GreaterOrEqualT(t, foundCustomerIndex, 0) + postDecls := builder.PostDeclarations() + require.GreaterT(t, len(postDecls), foundUserIndex) - userBuilder := &Builder{ - ctx: ctx, - decl: builder.postDecls[foundUserIndex], - } + userBuilder := NewBuilder(ctx, postDecls[foundUserIndex]) t.Run("should build model for User", func(t *testing.T) { models := make(map[string]oaispec.Schema) - require.NoError(t, userBuilder.Build(models)) + require.NoError(t, userBuilder.Build(WithDefinitions(models))) require.MapContainsT(t, models, "User") @@ -929,14 +874,11 @@ func TestAliasedTopLevelModels(t *testing.T) { t.Run("with schema builder", func(t *testing.T) { require.NotNil(t, decl) - builder := &Builder{ - ctx: ctx, - decl: decl, - } + builder := NewBuilder(ctx, decl) t.Run("should build model for Customer", func(t *testing.T) { models := make(map[string]oaispec.Schema) - require.NoError(t, builder.Build(models)) + require.NoError(t, builder.Build(WithDefinitions(models))) require.MapContainsT(t, models, "Customer") customer := models["Customer"] @@ -950,8 +892,9 @@ func TestAliasedTopLevelModels(t *testing.T) { }) t.Run("should have discovered only Customer", func(t *testing.T) { - require.Len(t, builder.postDecls, 1) - discovered := builder.postDecls[0] + postDecls := builder.PostDeclarations() + require.Len(t, postDecls, 1) + discovered := postDecls[0] assert.EqualT(t, "Customer", discovered.Obj().Name()) }) }) @@ -963,12 +906,9 @@ func TestEmbeddedAllOf(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "AllOfModel") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["AllOfModel"] require.Len(t, schema.AllOf, 3) @@ -994,7 +934,7 @@ func TestPointersAreNullableByDefaultWhenSetXNullableForPointersIsSet(t *testing decl, _ := ctx.FindDecl(packagePath, modelName) require.NotNil(t, decl) prs := NewBuilder(ctx, decl) - require.NoError(t, prs.Build(allModels)) + require.NoError(t, prs.Build(WithDefinitions(allModels))) schema := allModels[modelName] require.Len(t, schema.Properties, 5) @@ -1028,11 +968,13 @@ func TestPointersAreNullableByDefaultWhenSetXNullableForPointersIsSet(t *testing } // valueKeys returns the five property keys expected for the fixtures -// Item (struct, Go names verbatim) and ItemInterface (interface methods, -// camelCased per Q9). +// Item (struct, Go names verbatim) and ItemInterface (interface +// methods, JSON-name-derived via the interface-method mangler — see +// [§method-mangler](./README.md#method-mangler) — so the keys are +// camelCased rather than Go-verbatim). func valueKeys(modelName string) (string, string, string, string, string) { if modelName == "ItemInterface" { - return "value1", "value2", "value3", "value4", "value5" + return sampleValue1, sampleValue2, "value3", "value4", "value5" } return "Value1", "Value2", "Value3", "Value4", "Value5" } @@ -1043,7 +985,7 @@ func TestPointersAreNotNullableByDefaultWhenSetXNullableForPointersIsNotSet(t *t decl, _ := ctx.FindDecl(packagePath, modelName) require.NotNil(t, decl) prs := NewBuilder(ctx, decl) - require.NoError(t, prs.Build(allModels)) + require.NoError(t, prs.Build(WithDefinitions(allModels))) schema := allModels[modelName] require.Len(t, schema.Properties, 5) @@ -1080,7 +1022,7 @@ func TestSwaggerTypeNamed(t *testing.T) { require.NotNil(t, decl) prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["namedWithType"] scantest.AssertProperty(t, &schema, "object", "some_map", "", "SomeMap") @@ -1126,7 +1068,7 @@ func TestSwaggerTypeNamedWithGenerics(t *testing.T) { require.NotNil(t, decl) prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) testFunc(t, models) }) } @@ -1138,7 +1080,7 @@ func TestSwaggerTypeStruct(t *testing.T) { require.NotNil(t, decl) prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["NullString"] assert.TrueT(t, schema.Type.Contains("string")) @@ -1154,7 +1096,7 @@ func TestStructDiscriminators(t *testing.T) { decl := getClassificationModel(ctx, tn) require.NotNil(t, decl) prs := NewBuilder(ctx, decl) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) } schema := models["animal"] @@ -1192,7 +1134,7 @@ func TestInterfaceDiscriminators(t *testing.T) { require.NotNil(t, decl) prs := NewBuilder(ctx, decl) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) } schema, ok := models["fish"] @@ -1390,40 +1332,355 @@ func assertMapRef(t *testing.T, schema *oaispec.Schema, jsonName, goName, fragme assert.EqualT(t, fragment, psch.Ref.String()) } +func TestBuilder_DiagnosticsOnInvalidNumeric(t *testing.T) { + packagePattern := "./enhancements/diagnostics" + packagePath := fixturesModule + "/enhancements/diagnostics" + + var collected []grammar.Diagnostic + ctx, err := scanner.NewScanCtx(&scanner.Options{ + Packages: []string{packagePattern}, + WorkDir: scantest.FixturesDir(), + OnDiagnostic: func(d grammar.Diagnostic) { + collected = append(collected, d) + }, + }) + require.NoError(t, err) + + decl, _ := ctx.FindDecl(packagePath, "BadMaximum") + require.NotNil(t, decl) + prs := NewBuilder(ctx, decl) + models := make(map[string]oaispec.Schema) + require.NoError(t, prs.Build(WithDefinitions(models))) + + schema := models["BadMaximum"] + require.Contains(t, schema.Properties, "count") + count := schema.Properties["count"] + // The invalid `maximum: notanumber` is silently dropped — Maximum + // stays nil on the property schema. + assert.Nil(t, count.Maximum, "invalid maximum: should be dropped from spec") + + // Builder.Diagnostics() and the OnDiagnostic callback both surface + // the parser's CodeInvalidNumber error. + bd := prs.Diagnostics() + require.NotEmpty(t, bd) + require.NotEmpty(t, collected) + + foundCallback := false + for _, d := range collected { + if d.Code == grammar.CodeInvalidNumber { + foundCallback = true + break + } + } + assert.True(t, foundCallback, "OnDiagnostic should fire with CodeInvalidNumber") + + foundBuilder := false + for _, d := range bd { + if d.Code == grammar.CodeInvalidNumber { + foundBuilder = true + break + } + } + assert.True(t, foundBuilder, "Builder.Diagnostics should contain CodeInvalidNumber") +} + +// TestBuilder_DiagnosticsOnAmbiguousEmbed exercises the +// embed-ambiguity diagnostic path. The fixture defines three +// shapes that all share a property JSON name across embeds: +// +// - AmbiguousEmbed — two sibling embeds at the same depth +// promote the same JSON name under different Go field names; +// the diagnostic must fire. +// - DepthShadowingEmbed — an inner embed at depth 2 is shadowed +// by a Go field at depth 1; Go's depth rule already disambiguates +// and the diagnostic must remain silent. +// - ExplicitOverride — a top-level field re-declares the +// embedded JSON name; the embed-side override is happening at +// depth 0 and the diagnostic must remain silent. +// +// The diagnostic carries CodeAmbiguousEmbed (SeverityWarning); the +// spec output remains last-write-wins regardless. Behaviour is not +// changed by this signal, only surfaced. +func TestBuilder_DiagnosticsOnAmbiguousEmbed(t *testing.T) { + packagePattern := "./enhancements/diagnostics" + packagePath := fixturesModule + "/enhancements/diagnostics" + + build := func(t *testing.T, name string) *Builder { + t.Helper() + ctx, err := scanner.NewScanCtx(&scanner.Options{ + Packages: []string{packagePattern}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + + decl, _ := ctx.FindDecl(packagePath, name) + require.NotNil(t, decl, "fixture decl %s not found", name) + prs := NewBuilder(ctx, decl) + models := make(map[string]oaispec.Schema) + require.NoError(t, prs.Build(WithDefinitions(models))) + return prs + } + + hasAmbig := func(ds []grammar.Diagnostic) bool { + for _, d := range ds { + if d.Code == grammar.CodeAmbiguousEmbed { + return true + } + } + return false + } + + t.Run("peer embeds at same depth fire the diagnostic", func(t *testing.T) { + prs := build(t, "AmbiguousEmbed") + ds := prs.Diagnostics() + require.NotEmpty(t, ds) + assert.True(t, hasAmbig(ds), "expected CodeAmbiguousEmbed in %+v", ds) + // Verify severity and message shape. + for _, d := range ds { + if d.Code != grammar.CodeAmbiguousEmbed { + continue + } + assert.Equal(t, grammar.SeverityWarning, d.Severity) + assert.Contains(t, d.Message, "shared") + assert.Contains(t, d.Message, "Foo") + assert.Contains(t, d.Message, "Bar") + } + }) + + t.Run("depth-rule shadowing stays silent", func(t *testing.T) { + prs := build(t, "DepthShadowingEmbed") + assert.False(t, hasAmbig(prs.Diagnostics()), + "depth shadowing must not be flagged as ambiguity") + }) + + t.Run("top-level explicit override stays silent", func(t *testing.T) { + prs := build(t, "ExplicitOverride") + assert.False(t, hasAmbig(prs.Diagnostics()), + "top-level explicit override must not be flagged as ambiguity") + }) +} + +// TestEmbeddedDescriptionAndTags verifies the allOf compound shape +// for $ref'd fields with field-level x-extensions and example. v1 +// rode them as siblings of $ref (rejecting JSON Schema draft-4); +// the current builder produces the principled allOf compound where +// the description lives on the outer parent and the override +// decorations live on the override arm — see +// `internal/builders/schema/walker.go#applyToRefField` for the +// shape rules and the DescWithRef toggle's role. func TestEmbeddedDescriptionAndTags(t *testing.T) { - packagePattern := "./bugs/3125/minimal" - packagePath := fixturesModule + "/bugs/3125/minimal" + packagePattern := "./" + fixtureMinimal3125 + packagePath := fixturesModule + "/" + fixtureMinimal3125 ctx, err := scanner.NewScanCtx(&scanner.Options{ - Packages: []string{packagePattern}, - WorkDir: scantest.FixturesDir(), - DescWithRef: true, + Packages: []string{packagePattern}, + WorkDir: scantest.FixturesDir(), }) require.NoError(t, err) decl, _ := ctx.FindDecl(packagePath, "Item") require.NotNil(t, decl) prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["Item"] - assert.Equal(t, []string{"value1", "value2"}, schema.Required) + assert.Equal(t, []string{sampleValue1, sampleValue2}, schema.Required) require.Len(t, schema.Properties, 2) - require.MapContainsT(t, schema.Properties, "value1") - assert.EqualT(t, "Nullable value", schema.Properties["value1"].Description) - assert.Equal(t, true, schema.Properties["value1"].Extensions["x-nullable"]) - - require.MapContainsT(t, schema.Properties, "value2") - assert.EqualT(t, "Non-nullable value", schema.Properties["value2"].Description) - assert.MapNotContainsT(t, schema.Properties["value2"].Extensions, "x-nullable") - assert.Equal(t, `{"value": 42}`, schema.Properties["value2"].Example) + // Both Value1 and Value2 are typed *ValueStruct / ValueStruct + // (named) → $ref. Field-level decorations move to the override + // arm of an allOf compound; description rides the outer parent. + + // Vendor extensions ride the OUTER compound (alongside x-go-name) + // so the field carries all its x-* metadata at one level. + // Validations go on the override arm (AllOf[1]). + + require.MapContainsT(t, schema.Properties, sampleValue1) + v1 := schema.Properties[sampleValue1] + assert.EqualT(t, "Nullable value", v1.Description) + assert.Equal(t, true, v1.Extensions["x-nullable"], "x-nullable should be on the outer compound, not inside AllOf") + require.Len(t, v1.AllOf, 1, "value1 has an extension-only override → single-arm allOf") + assert.Equal(t, "#/definitions/ValueStruct", v1.AllOf[0].Ref.String()) + assert.Empty(t, v1.Ref.String(), "outer schema must NOT carry the ref directly") + + require.MapContainsT(t, schema.Properties, sampleValue2) + v2 := schema.Properties[sampleValue2] + assert.EqualT(t, "Non-nullable value", v2.Description) + assert.MapNotContainsT(t, v2.Extensions, "x-nullable") + require.Len(t, v2.AllOf, 2, "value2 has an example override → two-arm allOf") + assert.Equal(t, "#/definitions/ValueStruct", v2.AllOf[0].Ref.String()) + assert.Equal(t, `{"value": 42}`, v2.AllOf[1].Example) scantest.CompareOrDumpJSON(t, models, "bugs_3125_schema.json") } +// TestEmbeddedDescriptionAndTags_OptionVariants captures the +// (SkipExtensions, DescWithRef) option matrix on the bugs/3125 +// fixture into separately-named goldens. Verifies that: +// +// - Validation/extension overrides on a $ref'd field always wrap +// in allOf (Value1's x-nullable, Value2's example are both +// overrides). DescWithRef toggles description placement on the +// allOf parent vs. dropped in the description-only-no-overrides +// case (which doesn't apply here — both fields have overrides). +// - SkipExtensions suppresses scanner-derived x-go-name / +// x-go-package without affecting user-authored x-nullable or +// the allOf shape. +// +// The four goldens produce a complete trace of (skipExt, descRef) +// permutations and serve as regression locks for the option +// semantics described in scanner.Options. +func TestEmbeddedDescriptionAndTags_OptionVariants(t *testing.T) { + cases := []struct { + name string + skipExt bool + descRef bool + goldenFile string + }{ + {"default", false, false, "bugs_3125_schema.json"}, + {"DescWithRef", false, true, "bugs_3125_schema_descwithref.json"}, + {"SkipExt", true, false, "bugs_3125_schema_skipext.json"}, + {"SkipExt+DescWithRef", true, true, "bugs_3125_schema_skipext_descwithref.json"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, err := scanner.NewScanCtx(&scanner.Options{ + Packages: []string{"./" + fixtureMinimal3125}, + WorkDir: scantest.FixturesDir(), + SkipExtensions: tc.skipExt, + DescWithRef: tc.descRef, + }) + require.NoError(t, err) + decl, _ := ctx.FindDecl(fixturesModule+"/"+fixtureMinimal3125, "Item") + require.NotNil(t, decl) + prs := NewBuilder(ctx, decl) + models := make(map[string]oaispec.Schema) + require.NoError(t, prs.Build(WithDefinitions(models))) + scantest.CompareOrDumpJSON(t, models, tc.goldenFile) + }) + } +} + +// TestEmbeddedDescriptionAndTags_SkipExtensions verifies that with +// SkipExtensions=true, the allOf compound on a $ref'd field is NOT +// polluted by the scanner-derived metadata (x-go-name / x-go-package +// / x-nullable inferred from pointer-ness). User-authored +// `Extensions: x-foo` blocks would still flow (they're explicit), but +// nothing else should land alongside the $ref. +// +// This is a regression guard: in v1, $ref'd fields had ps.Ref non-empty +// throughout, so the schema.go x-go-name / x-go-package guards +// (`if ps.Ref.String() == ""`) silently skipped. Post-S7, the allOf +// rewrite clears ps.Ref — those guards now fire. Without +// SkipExtensions=true, x-go-name lands on the outer compound (visible +// in the regular TestEmbeddedDescriptionAndTags). With +// SkipExtensions=true, the metadata extension writers respect the +// option and the outer compound stays clean. +func TestEmbeddedDescriptionAndTags_SkipExtensions(t *testing.T) { + packagePattern := "./" + fixtureMinimal3125 + packagePath := fixturesModule + "/" + fixtureMinimal3125 + ctx, err := scanner.NewScanCtx(&scanner.Options{ + Packages: []string{packagePattern}, + WorkDir: scantest.FixturesDir(), + SkipExtensions: true, + }) + require.NoError(t, err) + decl, _ := ctx.FindDecl(packagePath, "Item") + require.NotNil(t, decl) + prs := NewBuilder(ctx, decl) + models := make(map[string]oaispec.Schema) + require.NoError(t, prs.Build(WithDefinitions(models))) + schema := models["Item"] + + require.Len(t, schema.Properties, 2) + + // User-authored x-nullable should still be present (`Extensions:` + // raw block in the source). Scanner-derived x-go-name, x-go-package + // should be skipped. + v1 := schema.Properties[sampleValue1] + assert.MapNotContainsT(t, v1.Extensions, "x-go-name", "x-go-name should be skipped under SkipExtensions=true") + assert.MapNotContainsT(t, v1.Extensions, "x-go-package", "x-go-package should be skipped under SkipExtensions=true") + // Note: x-nullable on Value1 is user-authored, not scanner-derived; + // it travels with the user's `Extensions:` block and SHOULD still + // be present even under SkipExtensions=true. + assert.Equal(t, true, v1.Extensions["x-nullable"], "user-authored x-nullable should survive SkipExtensions=true") + + v2 := schema.Properties[sampleValue2] + assert.MapNotContainsT(t, v2.Extensions, "x-go-name") + assert.MapNotContainsT(t, v2.Extensions, "x-go-package") + assert.MapNotContainsT(t, v2.Extensions, "x-nullable", "value2 has no x-nullable in source") +} + +// TestParamsShape_DescWithRef_BothModes covers the description-only +// $ref'd field case where the user toggles DescWithRef: +// +// - DescWithRef=false (default): the description is dropped and the +// field emits as a bare {$ref: ...}. +// - DescWithRef=true: the description rides a single-arm allOf +// compound — {description: ..., allOf: [{$ref}]}. +// +// Fixture: classification operations corpus' `pet` field of +// `items[]` in NoModel carries only a description plus a $ref to the +// pet model — no validations, no user-authored extensions. +// +// When the field carries validation or extension overrides, the +// allOf compound is mandatory regardless of DescWithRef — covered by +// TestEmbeddedDescriptionAndTags / TestEmbeddedDescriptionAndTags_SkipExtensions. +func TestParamsShape_DescWithRef_BothModes(t *testing.T) { + getPetField := func(t *testing.T, descWithRef bool) oaispec.Schema { + t.Helper() + ctx, err := scanner.NewScanCtx(&scanner.Options{ + Packages: []string{ + "./goparsing/classification", + "./goparsing/classification/models", + "./goparsing/classification/operations", + }, + WorkDir: scantest.FixturesDir(), + SkipExtensions: true, + DescWithRef: descWithRef, + }) + require.NoError(t, err) + decl, ok := ctx.FindDecl(fixturesModule+"/goparsing/classification/models", "NoModel") + require.True(t, ok) + require.NotNil(t, decl) + prs := NewBuilder(ctx, decl) + models := make(map[string]oaispec.Schema) + require.NoError(t, prs.Build(WithDefinitions(models))) + noModel := models["NoModel"] + require.Contains(t, noModel.Properties, "items") + itemsProp := noModel.Properties["items"] + require.NotNil(t, itemsProp.Items) + require.NotNil(t, itemsProp.Items.Schema) + itemSchema := itemsProp.Items.Schema + require.Contains(t, itemSchema.Properties, "pet") + return itemSchema.Properties["pet"] + } + + t.Run("DescWithRef=false → bare $ref", func(t *testing.T) { + pet := getPetField(t, false) + assert.Equal(t, "#/definitions/pet", pet.Ref.String()) + assert.Empty(t, pet.AllOf, "no allOf compound expected") + assert.Empty(t, pet.Description, "description dropped under DescWithRef=false") + assert.MapNotContainsT(t, pet.Extensions, "x-go-name") + }) + + t.Run("DescWithRef=true → single-arm allOf with description", func(t *testing.T) { + pet := getPetField(t, true) + assert.Empty(t, pet.Ref.String(), "outer schema must NOT carry the ref directly") + require.Len(t, pet.AllOf, 1, "single-arm allOf for description-only override") + assert.Equal(t, "#/definitions/pet", pet.AllOf[0].Ref.String()) + assert.Contains(t, pet.Description, "The Pet to add") + assert.MapNotContainsT(t, pet.Extensions, "x-go-name") + }) +} + +// TestIssue2540 verifies the JSON Schema draft-4 allOf compound shape +// for a $ref'd field (`Author Author`) carrying its own field-level +// `example:`. The example must travel on the override arm of the +// allOf compound, never as a sibling of $ref. The DescWithRef toggle +// does not change this case — when validations (here, `example`) +// are present, the allOf wrap is mandatory regardless of the flag. func TestIssue2540(t *testing.T) { - t.Run("should produce example and default for top level declaration only", - testIssue2540(false, `{ + const expectedJSON = `{ "Book": { "description": "At this moment, a book is only described by its publishing date\nand author.", "type": "object", @@ -1432,7 +1689,10 @@ func TestIssue2540(t *testing.T) { "default": "{ \"Published\": 1900, \"Author\": \"Unknown\" }", "properties": { "Author": { - "$ref": "#/definitions/Author" + "allOf": [ + {"$ref": "#/definitions/Author"}, + {"example": "{ \"Name\": \"Tolkien\" }"} + ] }, "Published": { "type": "integer", @@ -1442,53 +1702,23 @@ func TestIssue2540(t *testing.T) { } } } - }`), - ) - t.Run("should produce example and default for top level declaration and embedded $ref field", - testIssue2540(true, `{ - "Book": { - "description": "At this moment, a book is only described by its publishing date\nand author.", - "type": "object", - "title": "Book holds all relevant information about a book.", - "example": "{ \"Published\": 2026, \"Author\": \"Fred\" }", - "default": "{ \"Published\": 1900, \"Author\": \"Unknown\" }", - "properties": { - "Author": { - "$ref": "#/definitions/Author", - "example": "{ \"Name\": \"Tolkien\" }" - }, - "Published": { - "type": "integer", - "format": "int64", - "minimum": 0, - "example": 2021 - } - } - } - }`), - ) -} - -func testIssue2540(descWithRef bool, expectedJSON string) func(*testing.T) { - return func(t *testing.T) { - packagePattern := "./bugs/2540/foo" - packagePath := fixturesModule + "/bugs/2540/foo" - ctx, err := scanner.NewScanCtx(&scanner.Options{ - Packages: []string{packagePattern}, - WorkDir: scantest.FixturesDir(), - DescWithRef: descWithRef, - SkipExtensions: true, - }) - require.NoError(t, err) + }` + packagePattern := "./bugs/2540/foo" + packagePath := fixturesModule + "/bugs/2540/foo" + ctx, err := scanner.NewScanCtx(&scanner.Options{ + Packages: []string{packagePattern}, + WorkDir: scantest.FixturesDir(), + SkipExtensions: true, + }) + require.NoError(t, err) - decl, _ := ctx.FindDecl(packagePath, "Book") - require.NotNil(t, decl) - prs := NewBuilder(ctx, decl) - models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + decl, _ := ctx.FindDecl(packagePath, "Book") + require.NotNil(t, decl) + prs := NewBuilder(ctx, decl) + models := make(map[string]oaispec.Schema) + require.NoError(t, prs.Build(WithDefinitions(models))) - b, err := json.Marshal(models) - require.NoError(t, err) - assert.JSONEqT(t, expectedJSON, string(b)) - } + b, err := json.Marshal(models) + require.NoError(t, err) + assert.JSONEqT(t, expectedJSON, string(b)) } diff --git a/internal/builders/schema/simpleschema.go b/internal/builders/schema/simpleschema.go new file mode 100644 index 0000000..3cb140c --- /dev/null +++ b/internal/builders/schema/simpleschema.go @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "github.com/go-openapi/codescan/internal/parsers/grammar" + oaispec "github.com/go-openapi/spec" +) + +// inFormData is the `in:` parameter-location value that enables the +// `file` type under SimpleSchema. Lifted into a constant to satisfy +// goconst across this file and walker_classifiers.go. +const inFormData = "formData" + +// SimpleSchemaProbe is the schema-builder-side contract a +// SimpleSchema target must satisfy. Implemented structurally by +// `paramTypable` and (M2) `headerTypable`. +// +// # Details +// +// See [§simple-schema-mode](./README.md#simple-schema-mode) — full +// interface contract, reset semantics, and the catch-at-exit +// validator's role. +type SimpleSchemaProbe interface { + SimpleSchemaShape() *oaispec.SimpleSchema + HasRef() bool + ResetForViolation() +} + +// validateSimpleSchemaOutcome runs the "catch at exit" contract: +// inspect the resolved target, accept SimpleSchema-legal outcomes, +// emit a diagnostic and reset on violation. +// +// # Details +// +// See [§simple-schema-mode](./README.md#simple-schema-mode) for the +// allowed-type set, the file/formData special case, and the +// honest-over-lossy reset rationale. +func (s *Builder) validateSimpleSchemaOutcome() { + probe, ok := s.target.(SimpleSchemaProbe) + if !ok { + // Target doesn't expose a SimpleSchema shape — caller chose + // the SimpleSchema mode for a target that can't surface a + // violation. Trust the caller; emit no diagnostic. + return + } + + shape := probe.SimpleSchemaShape() + if shape == nil { + return + } + + typeOK := isAllowedSimpleSchemaType(shape.Type, s.paramIn) + refViolation := probe.HasRef() + if typeOK && !refViolation { + return + } + + reason := simpleSchemaViolationReason(shape.Type, refViolation, s.paramIn) + s.RecordDiagnostic(grammar.Warnf( + s.Ctx.PosOf(s.Decl.Spec.Pos()), + grammar.CodeUnsupportedInSimpleSchema, + "non-body parameter / response header (in=%q) cannot be represented as an OAS v2 SimpleSchema: %s; target reset to empty {}", + s.paramIn, reason, + )) + probe.ResetForViolation() +} + +// isAllowedSimpleSchemaType reports whether t is a SimpleSchema-legal +// type given the caller's `in` location. Empty string means "any" and +// is accepted (e.g. json.RawMessage recognizer emits an empty schema). +// `file` is only valid for in == inFormData. +func isAllowedSimpleSchemaType(t, in string) bool { + switch t { + case "", "string", "number", "integer", "boolean", "array": + return true + case "file": + return in == inFormData + } + return false +} + +// simpleSchemaViolationReason produces a short human-readable cause +// for the diagnostic message. +func simpleSchemaViolationReason(t string, refViolation bool, in string) string { + switch { + case refViolation: + return "$ref / model reference is forbidden under SimpleSchema" + case t == "file" && in != inFormData: + return `type "file" is only valid when in: formData` + case t == "object": + return `type "object" is forbidden under SimpleSchema` + case t == "": + // Should never get here — empty type is accepted by + // isAllowedSimpleSchemaType. Defensive only. + return "empty type unexpectedly rejected" + } + return "type " + t + " is not in the allowed SimpleSchema set" +} diff --git a/internal/builders/schema/special_types.go b/internal/builders/schema/special_types.go new file mode 100644 index 0000000..4a742b0 --- /dev/null +++ b/internal/builders/schema/special_types.go @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/types" + "strings" + + "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/ifaces" +) + +// buildFromTextMarshal renders a TextMarshaler-implementing type as a string. +// Six-step pipeline; user-annotation first, implicit recognizers next, generic fallback last. +// +// # Details +// +// See [§textmarshal-order](./README.md#textmarshal-order). +func (s *Builder) buildFromTextMarshal(tpe types.Type, target ifaces.SwaggerTypable) error { + if typePtr, ok := tpe.(*types.Pointer); ok { + return s.buildFromTextMarshal(typePtr.Elem(), target) + } + // Aliases route through buildAlias so RefAliases / TransparentAliases + // stay in charge of the alias indirection. + if typeAlias, ok := tpe.(*types.Alias); ok { + return s.buildAlias(typeAlias, target) + } + + typeNamed, ok := tpe.(*types.Named) + if !ok { + target.Typed("string", "") + return nil + } + tio := typeNamed.Obj() + + // Explicit user annotation wins over implicit recognizers. + if s.classifierTextMarshal(typeNamed, target) { + return nil + } + // Implicit recognizers in priority order. + if applySpecialType(tio, target, s.skipExtensions, recognizeError, recognizeTime, recognizeRawMessage, recognizeUUID) { + return nil + } + // Generic fallback: x-go-type carries pkg.Name, so PkgForType-miss + // must bail (can't produce the extension without the package). + if _, found := s.Ctx.PkgForType(tpe); !found { + return nil + } + target.Typed("string", "") + target.AddExtension("x-go-type", tio.Pkg().Path()+"."+tio.Name()) + return nil +} + +type recognizeType uint8 + +const ( + recognizedNone recognizeType = iota + recognizeTime + recognizeAny + recognizeError + recognizeRawMessage + // recognizeUUID is a fuzzy name-only match (case-insensitive + // "uuid"). Caller-gated — opt in only where the type is + // guaranteed to render as text. See + // [§special-types](./README.md#special-types). + recognizeUUID +) + +// applyStdlibSpecials runs the canonical safe set of identity-based +// recognizers (any / time.Time / error / json.RawMessage). Safe at +// every call site that handles a *types.TypeName. +// +// # Details +// +// See [§special-types](./README.md#special-types). +func applyStdlibSpecials(obj *types.TypeName, target ifaces.SwaggerTypable, skipExt bool) bool { + return applySpecialType(obj, target, skipExt, + recognizeAny, recognizeTime, recognizeError, recognizeRawMessage) +} + +// applySpecialType iterates wanted recognizers in order and applies +// the first match to target, returning resolved=true. Recognizers are +// identity-based except recognizeUUID, which is fuzzy (caller-gated). +// skipExt gates vendor-extension writes. +// +// # Details +// +// See [§special-types](./README.md#special-types), +// [§user-overrides](./README.md#user-overrides) (skipExt plumbing) and +// [§quirks](./README.md#quirks) (per-recognizer rationale). +func applySpecialType(obj *types.TypeName, target ifaces.SwaggerTypable, skipExt bool, wanted ...recognizeType) (resolved bool) { + for _, typeKey := range wanted { + switch typeKey { + case recognizeTime: // special case of the "time.Time" type + if resolvers.IsStdTime(obj) { + target.Typed("string", "date-time") + + return true + } + + case recognizeAny: // e.g type X any or type X interface{} + if resolvers.IsAny(obj) { + _ = target.Schema() + + return true + } + + case recognizeError: // predeclared error; see [§quirks](./README.md#quirks) for x-go-type rationale. + if resolvers.IsStdError(obj) { + if !skipExt { + target.AddExtension("x-go-type", obj.Name()) + } + target.Typed("string", "") + return true + } + + case recognizeRawMessage: // json.RawMessage; see [§quirks](./README.md#quirks) for the "any" rationale. + if resolvers.IsStdJSONRawMessage(obj) { + _ = target.Schema() + return true + } + + case recognizeUUID: // fuzzy — see [§special-types](./README.md#special-types). + if obj != nil && strings.ToLower(obj.Name()) == "uuid" { + target.Typed("string", "uuid") + return true + } + + default: + // ignored + } + } + + return false +} diff --git a/internal/builders/schema/struct.go b/internal/builders/schema/struct.go new file mode 100644 index 0000000..fd3a056 --- /dev/null +++ b/internal/builders/schema/struct.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/types" + + "github.com/go-openapi/codescan/internal/scanner" + oaispec "github.com/go-openapi/spec" +) + +// buildFromStruct emits the schema for a named Go struct. A first +// pass scans anonymous embeds for allOf composition; the second pass +// fills the property map from exported fields. The user-classifier +// short-circuit (`swagger:type`) wins outright. +// +// # Details +// +// See [§struct](./README.md#struct) — two-pass shape, the +// target-vs-schema split when allOf is in play, and why the +// `target.Typed("object", "")` line always fires (no +// SimpleSchema-style early exit yet). +func (s *Builder) buildFromStruct(decl *scanner.EntityDecl, st *types.Struct, schema *oaispec.Schema, nameByJSON map[string]propOwner) error { + if s.classifierStructPreBuildType(decl.Comments, NewTypable(schema, 0, s.skipExtensions)) { + return nil + } + + target, hasAllOf, err := s.scanEmbeddedFields(decl, st, schema, nameByJSON) + if err != nil { + return err + } + + if target == nil { + if schema != nil { + target = schema + } else { + target = &oaispec.Schema{} + } + } + + if target.Properties == nil { + target.Properties = make(map[string]oaispec.Schema) + } + target.Typed("object", "") + + if err := s.buildStructFields(decl, st, target, nameByJSON); err != nil { + return err + } + + if hasAllOf && len(target.Properties) > 0 { + schema.AllOf = append(schema.AllOf, *target) + } + + return nil +} + +func (s *Builder) buildStructFields(decl *scanner.EntityDecl, st *types.Struct, target *oaispec.Schema, nameByJSON map[string]propOwner) error { + for fld := range st.Fields() { + if err := s.processStructField(fld, decl, target, nameByJSON); err != nil { + return err + } + } + + return nil +} + +func (s *Builder) processStructField(fld *types.Var, decl *scanner.EntityDecl, target *oaispec.Schema, nameByJSON map[string]propOwner) error { + c, ok, err := s.structFieldCarrier(fld, decl, target, nameByJSON) + if err != nil || !ok { + return err + } + return s.applyFieldCarrier(c, target, nameByJSON) +} diff --git a/internal/builders/schema/taggers.go b/internal/builders/schema/taggers.go deleted file mode 100644 index 60178d8..0000000 --- a/internal/builders/schema/taggers.go +++ /dev/null @@ -1,125 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package schema - -import ( - "fmt" - "go/ast" - "slices" - - "github.com/go-openapi/codescan/internal/parsers" - oaispec "github.com/go-openapi/spec" -) - -func schemaTaggers(schema, ps *oaispec.Schema, nm string) []parsers.TagParser { - schemeType, err := ps.Type.MarshalJSON() - if err != nil { - return nil - } - scheme := &oaispec.SimpleSchema{Type: string(schemeType)} - - return []parsers.TagParser{ - // Match-only: claim `in: ` lines so they do not leak into the - // schema description. `in:` only matters for parameter/response dispatch; - // if it reaches a schema field (e.g. via the alias-expand path), it is - // still metadata, not prose. - parsers.NewSingleLineTagParser("in", parsers.NewMatchIn()), - parsers.NewSingleLineTagParser("maximum", parsers.NewSetMaximum(schemaValidations{ps})), - parsers.NewSingleLineTagParser("minimum", parsers.NewSetMinimum(schemaValidations{ps})), - parsers.NewSingleLineTagParser("multipleOf", parsers.NewSetMultipleOf(schemaValidations{ps})), - parsers.NewSingleLineTagParser("minLength", parsers.NewSetMinLength(schemaValidations{ps})), - parsers.NewSingleLineTagParser("maxLength", parsers.NewSetMaxLength(schemaValidations{ps})), - parsers.NewSingleLineTagParser("pattern", parsers.NewSetPattern(schemaValidations{ps})), - parsers.NewSingleLineTagParser("minItems", parsers.NewSetMinItems(schemaValidations{ps})), - parsers.NewSingleLineTagParser("maxItems", parsers.NewSetMaxItems(schemaValidations{ps})), - parsers.NewSingleLineTagParser("unique", parsers.NewSetUnique(schemaValidations{ps})), - parsers.NewSingleLineTagParser("enum", parsers.NewSetEnum(schemaValidations{ps})), - parsers.NewSingleLineTagParser("default", parsers.NewSetDefault(scheme, schemaValidations{ps})), - parsers.NewSingleLineTagParser("type", parsers.NewSetDefault(scheme, schemaValidations{ps})), - parsers.NewSingleLineTagParser("example", parsers.NewSetExample(scheme, schemaValidations{ps})), - parsers.NewSingleLineTagParser("required", parsers.NewSetRequiredSchema(schema, nm)), - parsers.NewSingleLineTagParser("readOnly", parsers.NewSetReadOnlySchema(ps)), - parsers.NewSingleLineTagParser("discriminator", parsers.NewSetDiscriminator(schema, nm)), - parsers.NewMultiLineTagParser("YAMLExtensionsBlock", parsers.NewYAMLParser( - parsers.WithExtensionMatcher(), - parsers.WithSetter(schemaVendorExtensibleSetter(ps)), - ), true), - } -} - -func refSchemaTaggers(schema *oaispec.Schema, name string) []parsers.TagParser { - return []parsers.TagParser{ - parsers.NewSingleLineTagParser("required", parsers.NewSetRequiredSchema(schema, name)), - } -} - -func itemsTaggers(items *oaispec.Schema, level int) []parsers.TagParser { - schemeType, err := items.Type.MarshalJSON() - if err != nil { - return nil - } - - scheme := &oaispec.SimpleSchema{Type: string(schemeType)} - opts := []parsers.PrefixRxOption{parsers.WithItemsPrefixLevel(level)} - - return []parsers.TagParser{ - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMaximum", level), parsers.NewSetMaximum(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMinimum", level), parsers.NewSetMinimum(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMultipleOf", level), parsers.NewSetMultipleOf(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMinLength", level), parsers.NewSetMinLength(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMaxLength", level), parsers.NewSetMaxLength(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dPattern", level), parsers.NewSetPattern(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMinItems", level), parsers.NewSetMinItems(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMaxItems", level), parsers.NewSetMaxItems(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dUnique", level), parsers.NewSetUnique(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dEnum", level), parsers.NewSetEnum(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dDefault", level), parsers.NewSetDefault(scheme, schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dExample", level), parsers.NewSetExample(scheme, schemaValidations{items}, opts...)), - } -} - -func parseArrayTypes(taggers []parsers.TagParser, expr ast.Expr, items *oaispec.SchemaOrArray, level int) ([]parsers.TagParser, error) { - if items == nil || items.Schema == nil { - return taggers, nil - } - - switch iftpe := expr.(type) { - case *ast.ArrayType: - eleTaggers := itemsTaggers(items.Schema, level) - otherTaggers, err := parseArrayTypes(slices.Concat(eleTaggers, taggers), iftpe.Elt, items.Schema.Items, level+1) - if err != nil { - return nil, err - } - - return otherTaggers, nil - - case *ast.Ident: - var identTaggers []parsers.TagParser - if iftpe.Obj == nil { - identTaggers = itemsTaggers(items.Schema, level) - } - - otherTaggers, err := parseArrayTypes(taggers, expr, items.Schema.Items, level+1) - if err != nil { - return nil, err - } - - return slices.Concat(identTaggers, otherTaggers), nil - - case *ast.StarExpr: - return parseArrayTypes(taggers, iftpe.X, items, level) - - case *ast.SelectorExpr: - // qualified name (e.g. time.Time): terminal leaf, register items-level validations. - return slices.Concat(itemsTaggers(items.Schema, level), taggers), nil - - case *ast.StructType, *ast.InterfaceType, *ast.MapType: - // anonymous struct / interface / map element: no further items-level - // validations apply; the element type itself carries its schema. - return taggers, nil - - default: - return nil, fmt.Errorf("unknown field type element: %w", ErrSchema) - } -} diff --git a/internal/builders/schema/typable.go b/internal/builders/schema/typable.go index ab49562..a324724 100644 --- a/internal/builders/schema/typable.go +++ b/internal/builders/schema/typable.go @@ -5,8 +5,8 @@ package schema import ( "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" oaispec "github.com/go-openapi/spec" ) @@ -18,8 +18,8 @@ type Typable struct { skipExt bool } -func NewTypable(schema *oaispec.Schema, level int, skipExt bool) Typable { - return Typable{ +func NewTypable(schema *oaispec.Schema, level int, skipExt bool) *Typable { + return &Typable{ schema: schema, level: level, skipExt: skipExt, @@ -32,7 +32,7 @@ func (st Typable) Typed(tpe, format string) { st.schema.Typed(tpe, format) } -func (st Typable) SetRef(ref oaispec.Ref) { // TODO(fred/claude): isn't it a bug? Setter on non-pointer receiver? +func (st *Typable) SetRef(ref oaispec.Ref) { st.schema.Ref = ref } @@ -41,19 +41,19 @@ func (st Typable) Schema() *oaispec.Schema { } //nolint:ireturn // polymorphic by design -func (st Typable) Items() ifaces.SwaggerTypable { +func (st *Typable) Items() ifaces.SwaggerTypable { if st.schema.Items == nil { - st.schema.Items = new(oaispec.SchemaOrArray) // TODO(fred/claude): isn't it a bug? Setter on non-pointer receiver? + st.schema.Items = new(oaispec.SchemaOrArray) } if st.schema.Items.Schema == nil { - st.schema.Items.Schema = new(oaispec.Schema) // TODO(fred/claude): isn't it a bug? Setter on non-pointer receiver? + st.schema.Items.Schema = new(oaispec.Schema) } st.schema.Typed("array", "") - return Typable{st.schema.Items.Schema, st.level + 1, st.skipExt} + return &Typable{st.schema.Items.Schema, st.level + 1, st.skipExt} } -func (st Typable) AdditionalProperties() ifaces.SwaggerTypable { //nolint:ireturn // polymorphic by design +func (st Typable) AdditionalProperties() ifaces.SwaggerTypable { if st.schema.AdditionalProperties == nil { st.schema.AdditionalProperties = new(oaispec.SchemaOrBool) } @@ -62,7 +62,7 @@ func (st Typable) AdditionalProperties() ifaces.SwaggerTypable { //nolint:iretur } st.schema.Typed("object", "") - return Typable{st.schema.AdditionalProperties.Schema, st.level + 1, st.skipExt} + return &Typable{st.schema.AdditionalProperties.Schema, st.level + 1, st.skipExt} } func (st Typable) Level() int { return st.level } @@ -79,26 +79,27 @@ func (st Typable) WithEnumDescription(desc string) { if desc == "" { return } - st.AddExtension(parsers.EnumDescExtension(), desc) + st.AddExtension(resolvers.ExtEnumDesc, desc) } func BodyTypable(in string, schema *oaispec.Schema, skipExt bool) (ifaces.SwaggerTypable, *oaispec.Schema) { //nolint:ireturn // polymorphic by design - if in == "body" { - // get the schema for items on the schema property - if schema == nil { - schema = new(oaispec.Schema) - } - if schema.Items == nil { - schema.Items = new(oaispec.SchemaOrArray) - } - if schema.Items.Schema == nil { - schema.Items.Schema = new(oaispec.Schema) - } - schema.Typed("array", "") - return Typable{schema.Items.Schema, 1, skipExt}, schema + if in != "body" { + return nil, nil // notice that nil,nil does not correspond to a "nil error", but rather to a nil schema. } - return nil, nil + // get the schema for items on the schema property + if schema == nil { + schema = new(oaispec.Schema) + } + if schema.Items == nil { + schema.Items = new(oaispec.SchemaOrArray) + } + if schema.Items.Schema == nil { + schema.Items.Schema = new(oaispec.Schema) + } + schema.Typed("array", "") + + return &Typable{schema.Items.Schema, 1, skipExt}, schema } type schemaValidations struct { @@ -128,5 +129,16 @@ func (sv schemaValidations) SetEnum(val string) { if len(sv.current.Type) > 0 { typ = sv.current.Type[0] } - sv.current.Enum = parsers.ParseEnum(val, &oaispec.SimpleSchema{Format: sv.current.Format, Type: typ}) + sv.current.Enum = validations.ParseEnumValues(val, typ, sv.current.Format) + + // A field-level `enum: ...` overrides const-derived values; the + // inherited x-go-enum-desc (set by the type-level `swagger:enum + // TypeName` pass) is now stale. Strip it and the matching + // description suffix. + // + // See [§quirks-open](./README.md#quirks-open) — "Stale + // x-go-enum-desc after a field-level enum override" — for the + // allOf-compound replacement target shape this cleanup is + // expected to be retired in favour of. + clearStaleEnumDesc(sv.current) } diff --git a/internal/builders/schema/walker.go b/internal/builders/schema/walker.go new file mode 100644 index 0000000..cf57959 --- /dev/null +++ b/internal/builders/schema/walker.go @@ -0,0 +1,598 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/ast" + "regexp" + "strings" + + "github.com/go-openapi/codescan/internal/builders/handlers" + "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/builders/validations" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/scanner/classify" + oaispec "github.com/go-openapi/spec" +) + +// applyBlockToDecl is the grammar entry point for a top-level model +// declaration. Parses the doc, short-circuits on swagger:ignore, writes +// title/description, then dispatches schema-level properties via the +// Walker. +// +// Returns true when the block's primary annotation is swagger:ignore; +// the caller short-circuits further building. +func (s *Builder) applyDeclCommentBlock(schema *oaispec.Schema) (skip bool) { + block := s.ParseBlock(s.Decl.Comments) + // `swagger:ignore` only short-circuits when it is the FIRST + // annotation on the comment group — parity with the original + // grammar Parse() behaviour. Fixture + // fixtures/enhancements/top-level-kinds/IgnoredModel deliberately + // places `swagger:model` first and `swagger:ignore` second to + // document this v1 quirk: the ignore is silently overridden + // because only the source-order-first annotation drives the + // short-circuit. ParseAll widens visibility for inferNames-style + // discovery (which IS source-order independent) but the ignore + // check stays narrow on purpose. + if block.AnnotationKind() == grammar.AnnIgnore { + return true + } + + schema.Title = block.PreambleTitle() + schema.Description = block.PreambleDescription() + if enumDesc := resolvers.GetEnumDesc(schema.Extensions); enumDesc != "" { + if schema.Description != "" { + schema.Description += "\n" + } + schema.Description += enumDesc + } + + s.walkSchemaLevel(block, schema, schema, "") + + return false +} + +// applyBlockToField is the grammar entry point for a struct field / +// interface method doc. Parses, dispatches level-0 properties, and +// recurses into items levels. When the field is a $ref to a named +// type and field-level sibling keywords are present, rewrites ps +// into an allOf compound: `{allOf: [{$ref: X}, {sibling overrides}]}` +// — JSON-Schema-draft-4 semantics, replacing v1's "drop siblings" +// short-circuit. See plan §6.1 (S7 polishing). +func (s *Builder) applyBlockToField(afld *ast.Field, enclosing *oaispec.Schema, ps *oaispec.Schema, name string) { + block := s.ParseBlock(afld.Doc) + + if ps.Ref.String() != "" { + s.applyToRefField(block, enclosing, ps, name) + return + } + + ps.Description = block.Prose() + if enumDesc := resolvers.GetEnumDesc(ps.Extensions); enumDesc != "" { + if ps.Description != "" { + ps.Description += "\n" + } + ps.Description += enumDesc + } + + s.walkSchemaLevel(block, enclosing, ps, name) + + // Items-level dispatch — only when the field type is written as + // an array literal. Named/alias array types opt out (parity with v1). + if arrayType, ok := afld.Type.(*ast.ArrayType); ok { + targets := flattenItemsTargets(arrayType.Elt, ps.Items) + for depth, target := range targets { + s.walkItemsLevel(block, target, depth+1) + } + } +} + +// applyToRefField rewrites a $ref'd field into an allOf compound when +// field-level overrides are present. +// +// # Details +// +// See [§ref-override](./README.md#ref-override) — JSON-Schema-draft-4 +// shape, per-keyword landing rules, the DescWithRef toggle, and the +// description-only edge case. +func (s *Builder) applyToRefField(block grammar.Block, enclosing, ps *oaispec.Schema, name string) { + originalRef := ps.Ref + + c := &refOverrideCollector{builder: s, enclosing: enclosing, name: name} + c.valid = schemaValidations{&c.override} + + block.Walk(grammar.Walker{ + FilterDepth: 0, + Number: c.onNumber, + Integer: c.onInteger, + Bool: c.onBool, + String: c.onString, + Raw: c.onRaw, + Extension: c.onExtension, + Diagnostic: s.RecordDiagnostic, + }) + + description := block.Prose() + + if !c.anyCollected() && description == "" { + return + } + if !c.anyCollected() && !s.Ctx.DescWithRef() { + return + } + + // Lift x-* siblings onto the outer compound (see §ref-override). + liftedExtensions := c.override.Extensions + c.override.Extensions = nil + + allOf := []oaispec.Schema{ + {SchemaProps: oaispec.SchemaProps{Ref: originalRef}}, + } + if c.collectedValidation { + allOf = append(allOf, c.override) + } + *ps = oaispec.Schema{ + VendorExtensible: oaispec.VendorExtensible{Extensions: liftedExtensions}, + SchemaProps: oaispec.SchemaProps{ + Description: description, + AllOf: allOf, + }, + } +} + +// walkSchemaLevel dispatches every level-0 Property in block into the +// schema at ps, with required/discriminator on enclosing keyed by +// name. The dispatch table is the union of the four legacy +// dispatchers (numeric / integer / string-or-enum / flag) plus the +// extensions block, rendered as Walker callbacks. +func (s *Builder) walkSchemaLevel(block grammar.Block, enclosing, ps *oaispec.Schema, name string) { + valid := schemaValidations{ps} + + block.Walk(grammar.Walker{ + FilterDepth: 0, + Number: s.schemaNumberHandler(ps, valid), + Integer: s.schemaIntegerHandler(ps, valid), + Bool: s.schemaBoolHandler(enclosing, ps, name, valid), + String: s.schemaStringHandler(ps, valid), + Raw: s.schemaRawHandler(ps, valid), + Extension: handlers.Extension(ps), + Diagnostic: s.RecordDiagnostic, + }) +} + +// walkItemsLevel dispatches items-depth Property entries onto target +// at the given depth. +func (s *Builder) walkItemsLevel(block grammar.Block, target *oaispec.Schema, depth int) { + valid := schemaValidations{target} + + block.Walk(grammar.Walker{ + FilterDepth: depth, + Number: s.schemaNumberHandler(target, valid), + Integer: s.schemaIntegerHandler(target, valid), + Bool: func(p grammar.Property, val bool) { + if !p.IsTyped() { + return + } + if !s.checkShape(p, target) { + return + } + if p.Keyword.Name == grammar.KwUnique { + valid.SetUnique(val) + } + }, + String: s.schemaStringHandler(target, valid), + Raw: s.schemaRawHandler(target, valid), + Diagnostic: s.RecordDiagnostic, + }) +} + +// schemaNumberHandler returns a Walker.Number callback bound to valid. +// Recognises maximum / minimum / multipleOf. Skips properties where +// typing failed (the parser already emitted a CodeInvalidNumber +// diagnostic; the Walker contract fires the callback with a +// zero-value payload regardless) or where the keyword's domain +// doesn't match the resolved schema type (validations.IsLegalForType +// emits a CodeShapeMismatch and the property is dropped). +func (s *Builder) schemaNumberHandler(ps *oaispec.Schema, valid schemaValidations) func(grammar.Property, float64, bool) { + return func(p grammar.Property, val float64, exclusive bool) { + if !p.IsTyped() { + return + } + if !s.checkShape(p, ps) { + return + } + switch p.Keyword.Name { + case grammar.KwMaximum: + valid.SetMaximum(val, exclusive) + case grammar.KwMinimum: + valid.SetMinimum(val, exclusive) + case grammar.KwMultipleOf: + valid.SetMultipleOf(val) + } + } +} + +// schemaIntegerHandler returns a Walker.Integer callback bound to +// valid. Recognises minLength / maxLength / minItems / maxItems. +// Skips on typing failure or shape mismatch. +func (s *Builder) schemaIntegerHandler(ps *oaispec.Schema, valid schemaValidations) func(grammar.Property, int64) { + return func(p grammar.Property, val int64) { + if !p.IsTyped() { + return + } + if !s.checkShape(p, ps) { + return + } + switch p.Keyword.Name { + case grammar.KwMinLength: + valid.SetMinLength(val) + case grammar.KwMaxLength: + valid.SetMaxLength(val) + case grammar.KwMinItems: + valid.SetMinItems(val) + case grammar.KwMaxItems: + valid.SetMaxItems(val) + } + } +} + +// schemaBoolHandler returns a Walker.Bool callback that routes +// required/discriminator writes to the enclosing schema (keyed by +// name), and unique/readOnly writes to the property schema. +// +// # Details +// +// See [§simple-schema-mode](./README.md#simple-schema-mode) — under +// SimpleSchema mode, `readOnly`/`discriminator` are forbidden +// (diagnostic + skip) and `required:` is silently skipped (handled +// at parameter level, never at header level). +func (s *Builder) schemaBoolHandler(enclosing, ps *oaispec.Schema, name string, valid schemaValidations) func(grammar.Property, bool) { + return func(p grammar.Property, val bool) { + if !p.IsTyped() { + return + } + if !s.checkShape(p, ps) { + return + } + if s.simpleSchema && !handlers.IsSimpleSchemaKeyword(p.Keyword.Name) { + s.RecordDiagnostic(grammar.Warnf( + p.Pos, + grammar.CodeUnsupportedInSimpleSchema, + "%q is a full-Schema-only keyword and is not allowed under SimpleSchema mode; ignored", + p.Keyword.Name, + )) + return + } + if s.simpleSchema && p.Keyword.Name == grammar.KwRequired { + return + } + switch p.Keyword.Name { + case grammar.KwUnique: + valid.SetUnique(val) + case grammar.KwReadOnly: + ps.ReadOnly = val + case grammar.KwRequired: + if name != "" { + s.setRequired(enclosing, name, val) + } + case grammar.KwDiscriminator: + if name != "" { + s.setDiscriminator(enclosing, name, val) + } + } + } +} + +// refOverrideCollector accumulates field-level overrides into a +// scratch schema for the allOf compound rewrite. +// +// # Details +// +// See [§ref-override](./README.md#ref-override) — collector role, +// the two flags (`collectedValidation`, `collectedExtension`) and +// the lift-onto-outer behaviour for vendor extensions. +type refOverrideCollector struct { + builder *Builder + enclosing *oaispec.Schema + name string + override oaispec.Schema + valid schemaValidations + collectedValidation bool + collectedExtension bool +} + +func (c *refOverrideCollector) anyCollected() bool { + return c.collectedValidation || c.collectedExtension +} + +func (c *refOverrideCollector) markValidation() { c.collectedValidation = true } +func (c *refOverrideCollector) markExtension() { c.collectedExtension = true } + +func (c *refOverrideCollector) onNumber(p grammar.Property, val float64, exclusive bool) { + if !p.IsTyped() { + return + } + switch p.Keyword.Name { + case grammar.KwMaximum: + c.valid.SetMaximum(val, exclusive) + c.markValidation() + case grammar.KwMinimum: + c.valid.SetMinimum(val, exclusive) + c.markValidation() + case grammar.KwMultipleOf: + c.valid.SetMultipleOf(val) + c.markValidation() + } +} + +func (c *refOverrideCollector) onInteger(p grammar.Property, val int64) { + if !p.IsTyped() { + return + } + switch p.Keyword.Name { + case grammar.KwMinLength: + c.valid.SetMinLength(val) + c.markValidation() + case grammar.KwMaxLength: + c.valid.SetMaxLength(val) + c.markValidation() + case grammar.KwMinItems: + c.valid.SetMinItems(val) + c.markValidation() + case grammar.KwMaxItems: + c.valid.SetMaxItems(val) + c.markValidation() + } +} + +func (c *refOverrideCollector) onBool(p grammar.Property, val bool) { + if !p.IsTyped() { + return + } + switch p.Keyword.Name { + case grammar.KwRequired: + if c.name != "" { + c.builder.setRequired(c.enclosing, c.name, val) + } + case grammar.KwReadOnly: + c.override.ReadOnly = val + c.markValidation() + case grammar.KwUnique: + c.valid.SetUnique(val) + c.markValidation() + } +} + +func (c *refOverrideCollector) onString(p grammar.Property, val string) { + switch p.Keyword.Name { + case grammar.KwPattern: + c.builder.applyPattern(p, c.valid, val) + c.markValidation() + case grammar.KwDefault: + if v, err := validations.ParseDefault(val, schemaTypeOf(&c.override), c.override.Format); err == nil { + c.valid.SetDefault(v) + c.markValidation() + } + case grammar.KwExample: + if v, err := validations.ParseDefault(val, schemaTypeOf(&c.override), c.override.Format); err == nil { + c.valid.SetExample(v) + c.markValidation() + } + case grammar.KwEnum: + c.valid.SetEnum(val) + c.markValidation() + } +} + +// onExtension applies one YAML-typed Extension entry onto the +// refOverride's compound and marks the collector so the outer caller +// emits an allOf wrap. Allowed-extension filtering matches the +// schema-level handler; user-authored extensions are not gated by +// SkipExtensions (parity with the v1 raw-block path). +func (c *refOverrideCollector) onExtension(ext grammar.Extension) { + if !classify.IsAllowedExtension(ext.Name) { + return + } + c.override.AddExtension(ext.Name, ext.Value) + c.markExtension() +} + +func (c *refOverrideCollector) onRaw(p grammar.Property) { + switch p.Keyword.Name { + case grammar.KwDefault: + if v, err := validations.ParseDefault(p.Value, schemaTypeOf(&c.override), c.override.Format); err == nil { + c.valid.SetDefault(v) + c.markValidation() + } + case grammar.KwExample: + if v, err := validations.ParseDefault(p.Value, schemaTypeOf(&c.override), c.override.Format); err == nil { + c.valid.SetExample(v) + c.markValidation() + } + case grammar.KwEnum: + c.valid.SetEnum(p.Value) + c.markValidation() + } +} + +// applyPattern stores a regex pattern on the property schema after a +// best-effort RE2 hygiene check. +// +// # Details +// +// See [§ref-override](./README.md#ref-override) — RE2 vs JSON Schema +// regex divergence, why we keep the value, and the choice to ride on +// `CodeInvalidAnnotation` rather than a dedicated code. +func (s *Builder) applyPattern(p grammar.Property, valid schemaValidations, val string) { + valid.SetPattern(val) + if val == "" { + return + } + if _, err := regexp.Compile(val); err != nil { + s.RecordDiagnostic(grammar.Diagnostic{ + Pos: p.Pos, + Severity: grammar.SeverityWarning, + Code: grammar.CodeInvalidAnnotation, + Message: strings.TrimSpace( + "pattern: " + val + " is not a valid Go RE2 regex (" + err.Error() + "); " + + "the value is preserved on the schema but downstream RE2 validators will fail", + ), + }) + } +} + +// checkShape gates a Walker callback on +// validations.IsLegalForType(p.Keyword, schema-type). On mismatch, +// records a CodeShapeMismatch diagnostic and returns false so the +// caller drops the property. +func (s *Builder) checkShape(p grammar.Property, ps *oaispec.Schema) bool { + var schemaType string + if ps != nil && len(ps.Type) > 0 { + schemaType = ps.Type[0] + } + ok, hint := validations.IsLegalForType(p.Keyword, schemaType) + if ok { + return true + } + s.RecordDiagnostic(grammar.Diagnostic{ + Pos: p.Pos, + Severity: grammar.SeverityWarning, + Code: grammar.CodeShapeMismatch, + Message: hint, + }) + return false +} + +// schemaStringHandler returns a Walker.String callback. Recognises +// pattern (raw regex) and the enum keyword's pre-typed enum-option +// form (rare; the comma-list / JSON-array forms travel through Raw). +// Shape-checks pattern (string-only); default/example/enum are +// type-independent (ParseDefault handles the type-specific +// coercion). +func (s *Builder) schemaStringHandler(ps *oaispec.Schema, valid schemaValidations) func(grammar.Property, string) { + return func(p grammar.Property, val string) { + if !s.checkShape(p, ps) { + return + } + switch p.Keyword.Name { + case grammar.KwPattern: + s.applyPattern(p, valid, val) + case grammar.KwDefault: + if v, err := validations.ParseDefault(val, schemaTypeOf(ps), ps.Format); err == nil { + valid.SetDefault(v) + } + case grammar.KwExample: + if v, err := validations.ParseDefault(val, schemaTypeOf(ps), ps.Format); err == nil { + valid.SetExample(v) + } + case grammar.KwEnum: + valid.SetEnum(val) + } + } +} + +// schemaRawHandler routes ShapeRawBlock / ShapeRawValue / ShapeNone / +// ShapeCommaList properties — default:, example:, enum: when +// expressed as raw bodies. Extensions travel through Walker.Extension +// with YAML-typed values (round-2 of the typed-extensions plan), so +// the KwExtensions arm retired from this dispatch. +// default/example/enum are type-independent. +func (s *Builder) schemaRawHandler(ps *oaispec.Schema, valid schemaValidations) func(grammar.Property) { + return func(p grammar.Property) { + switch p.Keyword.Name { + case grammar.KwDefault: + if v, err := validations.ParseDefault(p.Value, schemaTypeOf(ps), ps.Format); err == nil { + valid.SetDefault(v) + } + case grammar.KwExample: + if v, err := validations.ParseDefault(p.Value, schemaTypeOf(ps), ps.Format); err == nil { + valid.SetExample(v) + } + case grammar.KwEnum: + valid.SetEnum(p.Value) + } + } +} + +// schemaTypeOf returns the resolved Swagger type of ps, or "" when +// the schema has no Type set (typically a $ref-only schema, where +// type-aware coercion is not possible at this level). +func schemaTypeOf(ps *oaispec.Schema) string { + if ps == nil || len(ps.Type) == 0 { + return "" + } + return ps.Type[0] +} + +// setRequired adds or removes name from the enclosing schema's +// Required slice. +func (s *Builder) setRequired(enclosing *oaispec.Schema, name string, required bool) { + if enclosing == nil { + return + } + midx := -1 + for i, nm := range enclosing.Required { + if nm == name { + midx = i + break + } + } + if required { + if midx < 0 { + enclosing.Required = append(enclosing.Required, name) + } + return + } + if midx >= 0 { + enclosing.Required = append(enclosing.Required[:midx], enclosing.Required[midx+1:]...) + } +} + +// setDiscriminator writes name to enclosing.Discriminator when +// required=true, or clears it when required=false and the current +// value matches. +func (s *Builder) setDiscriminator(enclosing *oaispec.Schema, name string, required bool) { + if enclosing == nil { + return + } + if required { + enclosing.Discriminator = name + return + } + if enclosing.Discriminator == name { + enclosing.Discriminator = "" + } +} + +// flattenItemsTargets walks the array-element AST in parallel with +// the schema's items chain and returns a flat slice of property +// schemas, one per nesting depth, indexed by depth-1 (i.e. depth=1 +// → out[0]). Replaces the legacy collectItemsLevels recursion. +func flattenItemsTargets(elt ast.Expr, schemaItems *oaispec.SchemaOrArray) []*oaispec.Schema { + var out []*oaispec.Schema + for schemaItems != nil && schemaItems.Schema != nil { + out = append(out, schemaItems.Schema) + switch e := elt.(type) { + case *ast.ArrayType: + elt = e.Elt + schemaItems = schemaItems.Schema.Items + case *ast.Ident: + if e.Obj == nil { + schemaItems = schemaItems.Schema.Items + continue + } + return out + case *ast.StarExpr: + elt = e.X + case *ast.SelectorExpr: + return out + case *ast.StructType, *ast.InterfaceType, *ast.MapType: + out = out[:len(out)-1] + return out + default: + return out + } + } + return out +} diff --git a/internal/builders/schema/walker_classifiers.go b/internal/builders/schema/walker_classifiers.go new file mode 100644 index 0000000..d206a97 --- /dev/null +++ b/internal/builders/schema/walker_classifiers.go @@ -0,0 +1,317 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/ast" + "go/types" + "log" + "reflect" + "strings" + + "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/ifaces" + "github.com/go-openapi/codescan/internal/logger" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "golang.org/x/tools/go/packages" +) + +// findAnnotationArg returns the first positional argument of the +// first Block of the given annotation kind in cg, filtered to +// non-empty single-word arguments and read through the ParseBlocks +// cache. +// +// # Details +// +// See [§classifier-walkers](./README.md#classifier-walkers) — the +// single-word filter's rationale and the named-basic prose-trap +// fixture it protects against. +func (s *Builder) findAnnotationArg(cg *ast.CommentGroup, kind grammar.AnnotationKind) (string, bool) { + for _, b := range s.ParseBlocks(cg) { + if b.AnnotationKind() != kind { + continue + } + arg, ok := b.AnnotationArg() + if !ok { + continue + } + if strings.ContainsAny(arg, " \t") { + continue + } + return arg, true + } + return "", false +} + +// Per-call-site classifier walkers. +// +// One walker per call site; each documents in its godoc which +// `swagger:` annotations it consumes. +// +// # Details +// +// See [§classifier-walkers](./README.md#classifier-walkers) — the +// design rationale, the walker inventory table, and the +// `findAnnotationArg` single-word filter contract that backs the +// kind-specific lookups. + +// classifierTextMarshal is the named-type walker fired at the +// text-marshal short-circuit (`buildFromTextMarshal` end-of-pipe). +// Consumes only `swagger:strfmt`. On match writes +// `{string, }` to tgt and returns true. +func (s *Builder) classifierTextMarshal(tpe types.Type, tgt ifaces.SwaggerTypable) (resolved bool) { + decl, ok := s.Ctx.DeclForType(tpe) + if !ok || decl == nil { + return false + } + + if name, ok := s.findAnnotationArg(decl.Comments, grammar.AnnStrfmt); ok { + tgt.Typed("string", name) + return true + } + + return false +} + +// classifierNamedTypeOverride is the named-type walker fired in +// `buildFromType`'s named-fallback and `buildFromStruct`'s pre- +// pass. Consumes only `swagger:type` (the explicit type-override +// annotation). On match attempts SwaggerSchemaForType and +// reports back via the (handled, fallthrough) tuple: +// +// - handled=true, fallthrough=false → caller returns nil +// - handled=false, fallthrough=false → no swagger:type present +// - handled=true, fallthrough=true → unrecognised type-ref +// value (caller should resolve via the underlying type) +func (s *Builder) classifierNamedTypeOverride(cg *ast.CommentGroup, tgt ifaces.SwaggerTypable) (handled, fallthroughUnderlying bool) { + name, ok := s.findAnnotationArg(cg, grammar.AnnType) + if !ok { + return false, false + } + if err := resolvers.SwaggerSchemaForType(name, tgt); err == nil { + return true, false + } + // Unsupported swagger:type value (e.g. "array") — caller falls + // through to the underlying type so the full schema (including + // items for slices) is properly built. + return true, true +} + +// classifierNamedBasic is the named-type walker for +// `buildNamedBasic`. Consumes a cascade of classifier +// annotations in source-priority order: +// +// swagger:strfmt → swagger:enum → swagger:default → +// swagger:type → swagger:alias +// +// The final arm is the "primitive-inline" branch: it fires when +// either (a) the builder is in SimpleSchema mode (the M1 contract +// for non-body parameters and response headers — `$ref` forbidden by +// OAS v2 so the underlying primitive ships inline) or (b) the user +// has explicitly opted in via `swagger:alias` on the decl. These two +// triggers are intentionally orthogonal — the SimpleSchema flag is +// caller-driven and covers query/path/header/formData uniformly, +// while `swagger:alias` is a per-type author override that bypasses +// the model-ref pipeline regardless of mode. +// +// Returns: +// - handled=true → caller returns nil (target written, terminal) +// - handled=false → no classifier matched; caller continues to +// FindModel / SwaggerSchemaForType fallback +func (s *Builder) classifierNamedBasic(cg *ast.CommentGroup, pkg *packages.Package, 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.findAnnotationArg(cg, grammar.AnnEnum); ok { + enumValues, enumDesces, _ := 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")) + } + return true + } + // swagger:enum with no matching const values. Fall through so + // the type-resolution engine can still decide what to do with + // the underlying Go type (it may be a model, an alias, a + // strfmt, …) rather than dropping the field entirely. + log.Printf("WARNING: swagger:enum %s: no matching const values found; dropping enum semantics", enumName) + } + + if defaultName, ok := s.findAnnotationArg(cg, grammar.AnnDefaultName); ok { + logger.DebugLogf(s.Ctx.Debug(), "default name: %s", defaultName) + return true + } + + if typeName, ok := s.findAnnotationArg(cg, grammar.AnnType); ok { + _ = resolvers.SwaggerSchemaForType(typeName, tgt) + return true + } + + if s.simpleSchema || s.findAnnotation(cg, grammar.AnnAlias) != nil { + if err := resolvers.SwaggerSchemaForType(utitpe.Name(), tgt); err == nil { + return true + } + } + + return false +} + +// classifierNamedArrayLike is the named-type walker shared +// between `buildNamedArray` and `buildNamedSlice`. Both have the +// same classifier surface — `swagger:strfmt` and `swagger:type` — +// with subtly different strfmt fall-throughs (array honors a +// "bsonobjectid" special case the slice doesn't). The boolean +// `forSlice` switches that arm; the rest is identical. +// +// Returns: +// - handled=true, err=nil → caller returns nil +// - handled=true, err!=nil → unrecognised swagger:type → caller +// should fall through to inline the element type +// - handled=false, err=nil → no classifier matched +func (s *Builder) classifierNamedArrayLike(cg *ast.CommentGroup, tgt ifaces.SwaggerTypable, forSlice bool) (handled bool, fallthroughElement bool) { + if sfnm, isf := s.findAnnotationArg(cg, grammar.AnnStrfmt); isf { + if sfnm == "byte" { + tgt.Typed("string", sfnm) + return true, false + } + if !forSlice && sfnm == "bsonobjectid" { + tgt.Typed("string", sfnm) + return true, false + } + tgt.Items().Typed("string", sfnm) + return true, false + } + + // When swagger:type is set to an unsupported value (e.g. "array"), + // skip the $ref and inline the array/slice schema with the proper + // items type. + if tn, ok := s.findAnnotationArg(cg, grammar.AnnType); ok { + if err := resolvers.SwaggerSchemaForType(tn, tgt); err != nil { + return true, true + } + return true, false + } + + return false, false +} + +// classifierAliasTargetStrfmt is the named-type walker fired +// from `buildNamedAllOf`'s struct branch — checks the alias's +// target type's docstring for `swagger:strfmt`. On match writes +// `{string, }` to schema and returns true. +func (s *Builder) classifierAliasTargetStrfmt(tpe types.Type, tgt ifaces.SwaggerTypable) bool { + decl, ok := s.Ctx.DeclForType(tpe) + if !ok || decl == nil { + return false + } + if name, ok := s.findAnnotationArg(decl.Comments, grammar.AnnStrfmt); ok { + tgt.Typed("string", name) + return true + } + return false +} + +// fieldDoc is the field-level FieldWalker output: every +// classifier signal a struct field / interface method might +// carry, pre-extracted in a single pass over the field's +// ParseBlocks slice. Consumed by the four field-level call +// patterns documented on scanFieldDoc. +type fieldDoc struct { + // Ignored — bare `swagger:ignore` presence (the field / + // method should be skipped entirely). + Ignored bool + // JSONName — argument of `swagger:name ` (rename the + // JSON property name). Empty when the annotation is absent + // or its arg is empty / multi-word. + JSONName string + // StrfmtName — argument of `swagger:strfmt ` (the + // string format to inline). Empty when the annotation is + // absent or its arg is empty / multi-word. + StrfmtName string + // TypeOverride — argument of `swagger:type ` at the + // field level. Empty when absent or multi-word. See + // [§user-overrides](./README.md#user-overrides). + TypeOverride string + // IsAllOfMember — bare `swagger:allOf` presence (treat + // this embedded type as an allOf compound member). + IsAllOfMember bool + // AllOfClass — argument of `swagger:allOf ` (the + // discriminator class for x-class). Empty for bare + // `swagger:allOf`. + AllOfClass string +} + +// scanFieldDoc inspects afld's docstring through the ParseBlocks +// cache and returns every field-level classifier signal in one pass. +// +// Consumes: swagger:ignore / swagger:name / swagger:strfmt / +// swagger:type / swagger:allOf. The AnnType arm carries an inline +// single-word filter — see [§user-overrides](./README.md#user-overrides) +// for why. +func (s *Builder) scanFieldDoc(afld *ast.Field) fieldDoc { + var fd fieldDoc + if afld == nil { + return fd + } + for _, b := range s.ParseBlocks(afld.Doc) { + switch b.AnnotationKind() { //nolint:exhaustive // field-level walker only consumes these five kinds + case grammar.AnnIgnore: + fd.Ignored = true + case grammar.AnnName: + if name, ok := b.AnnotationArg(); ok { + fd.JSONName = name + } + case grammar.AnnStrfmt: + if name, ok := b.AnnotationArg(); ok { + fd.StrfmtName = name + } + case grammar.AnnType: + if name, ok := b.AnnotationArg(); ok && !strings.ContainsAny(name, " \t") { + fd.TypeOverride = name + } + case grammar.AnnAllOf: + fd.IsAllOfMember = true + if name, ok := b.AnnotationArg(); ok { + fd.AllOfClass = name + } + } + } + return fd +} + +// classifierStructPreBuildType is the named-type walker fired +// at the top of `buildFromStruct`. Consumes only `swagger:type`. +// On match attempts SwaggerSchemaForType (errors are swallowed, +// matching v1 behaviour) and returns true to short-circuit the +// struct-build pipeline. +func (s *Builder) classifierStructPreBuildType(cg *ast.CommentGroup, tgt ifaces.SwaggerTypable) (resolved bool) { + name, ok := s.findAnnotationArg(cg, grammar.AnnType) + if !ok { + return false + } + _ = resolvers.SwaggerSchemaForType(name, tgt) + return true +} + +// classifierNamedStructStrfmt is the named-type walker fired +// from `buildNamedStruct`'s strfmt-first branch. Checks the +// struct's docstring for `swagger:strfmt`; on match writes +// `{string, }` to tgt and returns true. +// +// Pre-FindModel call site: matters because FindModel registers +// the type in ExtraModels as a side effect; running this walker +// first prevents an orphan top-level definition for a strfmt- +// inlined type. +func (s *Builder) classifierNamedStructStrfmt(cg *ast.CommentGroup, tgt ifaces.SwaggerTypable) (resolved bool) { + if name, ok := s.findAnnotationArg(cg, grammar.AnnStrfmt); ok { + tgt.Typed("string", name) + return true + } + return false +} diff --git a/internal/builders/spec/spec.go b/internal/builders/spec/spec.go index 1e49ee8..bee35fa 100644 --- a/internal/builders/spec/spec.go +++ b/internal/builders/spec/spec.go @@ -11,7 +11,7 @@ import ( "github.com/go-openapi/codescan/internal/builders/responses" "github.com/go-openapi/codescan/internal/builders/routes" "github.com/go-openapi/codescan/internal/builders/schema" - "github.com/go-openapi/codescan/internal/parsers" + "github.com/go-openapi/codescan/internal/parsers/grammar" "github.com/go-openapi/codescan/internal/scanner" oaispec "github.com/go-openapi/spec" ) @@ -99,11 +99,22 @@ func (s *Builder) buildDiscovered() error { keepGoing := len(s.discovered) > 0 for keepGoing { var queue []*scanner.EntityDecl + // Dedupe by name within this pass. The same decl can appear + // multiple times in s.discovered (one entry per reference + // site that called AppendPostDecl); without this, both copies + // get queued and Build runs twice, each appending to the + // existing schema's AllOf and producing doubled entries. + queued := make(map[string]struct{}) for _, d := range s.discovered { nm, _ := d.Names() - if _, ok := s.definitions[nm]; !ok { - queue = append(queue, d) + if _, alreadyDone := s.definitions[nm]; alreadyDone { + continue } + if _, dupInPass := queued[nm]; dupInPass { + continue + } + queued[nm] = struct{}{} + queue = append(queue, d) } s.discovered = nil for _, sd := range queue { @@ -120,7 +131,7 @@ func (s *Builder) buildDiscovered() error { func (s *Builder) buildDiscoveredSchema(decl *scanner.EntityDecl) error { sb := schema.NewBuilder(s.ctx, decl) sb.SetDiscovered(s.discovered) - if err := sb.Build(s.definitions); err != nil { + if err := sb.Build(schema.WithDefinitions(s.definitions)); err != nil { return err } @@ -130,9 +141,10 @@ func (s *Builder) buildDiscoveredSchema(decl *scanner.EntityDecl) error { } func (s *Builder) buildMeta() error { - // build swagger object - for decl := range s.ctx.Meta() { - if err := parsers.NewMetaParser(s.input).Parse(decl.Comments); err != nil { + parser := grammar.NewParser(s.ctx.FileSet()) + for cg := range s.ctx.Meta() { + block := parser.Parse(cg) + if err := applyMetaBlock(s.input, block); err != nil { return err } } diff --git a/internal/builders/spec/walker.go b/internal/builders/spec/walker.go new file mode 100644 index 0000000..e7790e9 --- /dev/null +++ b/internal/builders/spec/walker.go @@ -0,0 +1,233 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package spec + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/go-openapi/codescan/internal/parsers/grammar" + yamlparser "github.com/go-openapi/codescan/internal/parsers/yaml" + "github.com/go-openapi/codescan/internal/scanner/classify" + "github.com/go-openapi/spec" +) + +// applyMetaBlock dispatches one parsed swagger:meta block into the +// matching *spec.Swagger fields. Title and Description come from +// grammar's prose classifier; level-0 Property entries are +// routed by keyword name to the appropriate setter. +// +// swspec may have a nil Info field on entry; the helper allocates +// one before writing the first Info.* value. +func applyMetaBlock(swspec *spec.Swagger, block grammar.Block) error { + if swspec.Info == nil { + swspec.Info = new(spec.Info) + } + + swspec.Info.Title = stripPackagePrefix(block.Title()) + swspec.Info.Description = block.Description() + + for p := range block.Properties() { + if p.ItemsDepth != 0 { + continue + } + if err := dispatchMetaKeyword(p, swspec); err != nil { + return err + } + } + + if reqs := block.SecurityRequirements(); reqs != nil { + swspec.Security = reqs + } + c, err := block.Contact() + if err != nil { + return err + } + if c != (grammar.Contact{}) { + swspec.Info.Contact = &spec.ContactInfo{ + ContactInfoProps: spec.ContactInfoProps{Name: c.Name, Email: c.Email, URL: c.URL}, + } + } + if l, ok := block.License(); ok { + swspec.Info.License = &spec.License{ + LicenseProps: spec.LicenseProps{Name: l.Name, URL: l.URL}, + } + } + + return nil +} + +// dispatchMetaKeyword routes one Property to the matching meta-side +// setter. Inline-value keywords (schemes, version, host, basePath, +// license, contact) read Property.Value; raw-block keywords (tos, +// consumes, produces, security, securityDefinitions, infoExtensions, +// extensions) split Property.Body and feed the body parsers. +func dispatchMetaKeyword(p grammar.Property, swspec *spec.Swagger) error { + if dispatchMetaSimple(p, swspec) { + return nil + } + return dispatchMetaYAMLBlock(p, swspec) +} + +// dispatchMetaSimple handles the keywords whose setters cannot fail. +func dispatchMetaSimple(p grammar.Property, swspec *spec.Swagger) bool { + switch p.Keyword.Name { + case grammar.KwTOS: + swspec.Info.TermsOfService = joinNonBlank(bodyLines(p.Body)) + case grammar.KwConsumes: + swspec.Consumes = yamlparser.ListBody(bodyLines(p.Body)) + case grammar.KwProduces: + swspec.Produces = yamlparser.ListBody(bodyLines(p.Body)) + case grammar.KwSchemes: + swspec.Schemes = grammar.SplitCommaList(p.Value) + case grammar.KwVersion: + swspec.Info.Version = strings.TrimSpace(p.Value) + case grammar.KwHost: + host := strings.TrimSpace(p.Value) + if host == "" { + host = "localhost" + } + swspec.Host = host + case grammar.KwBasePath: + swspec.BasePath = strings.TrimSpace(p.Value) + default: + return false + } + return true +} + +// dispatchMetaYAMLBlock handles the keywords whose bodies are YAML +// (securityDefinitions, infoExtensions, extensions). +func dispatchMetaYAMLBlock(p grammar.Property, swspec *spec.Swagger) error { + switch p.Keyword.Name { + case grammar.KwSecurityDefinitions: + return yamlparser.UnmarshalBody(p.Body, func(data []byte) error { + var d spec.SecurityDefinitions + if err := json.Unmarshal(data, &d); err != nil { + return err + } + swspec.SecurityDefinitions = d + return nil + }) + case grammar.KwInfoExtensions: + ext, err := yamlparser.TypedExtensions(p.Body) + if err != nil { + return err + } + if err := validateExtensionNames(ext); err != nil { + return err + } + swspec.Info.Extensions = spec.Extensions(ext) + case grammar.KwExtensions: + ext, err := yamlparser.TypedExtensions(p.Body) + if err != nil { + return err + } + if err := validateExtensionNames(ext); err != nil { + return err + } + swspec.Extensions = spec.Extensions(ext) + } + return nil +} + +// bodyLines splits a grammar raw-block body into the []string shape +// the meta body parsers expect. +func bodyLines(body string) []string { + if body == "" { + return nil + } + lines := strings.Split(body, "\n") + if n := len(lines); n > 0 && lines[n-1] == "" { + lines = lines[:n-1] + } + return lines +} + +// joinNonBlank joins lines with "\n" after dropping whitespace-only +// entries. Used for the `Terms Of Service:` body — author free-form +// prose that should land as a single multi-line string on +// Info.TermsOfService. +func joinNonBlank(lines []string) string { + out := make([]string, 0, len(lines)) + for _, l := range lines { + if strings.TrimSpace(l) != "" { + out = append(out, l) + } + } + return strings.Join(out, "\n") +} + +// stripPackagePrefix shaves a leading `[] Package [ws]` +// prefix off a meta title. Go's `package ` doc-comment +// convention puts the package marker on the first prose line; the +// emitted Info.Title should carry only the rest. Returns the input +// unchanged when the `Package ` pattern is not present. +// +// Restricted to ASCII letter detection and ASCII space/tab — the +// legacy regex used the wider `\p{L}` / `\p{Zs}` classes, but every +// Go source title in the wild starts with an ASCII letter and uses +// ASCII spaces; the byte-loop is both clearer and faster. +func stripPackagePrefix(s string) string { + i := 0 + // Skip leading non-ASCII-letter runs (whitespace, slashes, etc.). + for i < len(s) && !isASCIILetter(s[i]) { + i++ + } + // Match "Package" or "package", case-sensitive on the first char. + rest := s[i:] + switch { + case strings.HasPrefix(rest, "Package"): + i += len("Package") + case strings.HasPrefix(rest, "package"): + i += len("package") + default: + return s + } + // One or more whitespace characters required after the marker. + if i >= len(s) || !isASCIISpace(s[i]) { + return s + } + for i < len(s) && isASCIISpace(s[i]) { + i++ + } + // One or more non-whitespace characters (the package identifier). + if i >= len(s) || isASCIISpace(s[i]) { + return s + } + for i < len(s) && !isASCIISpace(s[i]) { + i++ + } + // Optional trailing whitespace. + for i < len(s) && isASCIISpace(s[i]) { + i++ + } + return s[i:] +} + +func isASCIILetter(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} + +func isASCIISpace(b byte) bool { return b == ' ' || b == '\t' } + +// ErrBadExtensionName is the sentinel used when a meta extension key +// does not start with `x-` or `X-`. +var ErrBadExtensionName = errors.New("invalid schema extension name, should start from `x-`") + +// validateExtensionNames enforces the x-* prefix on every meta +// extension key. Grammar's lexer silently drops non-x-* keys when +// it collects extensions internally; meta rejects them with an +// error so a typo surfaces instead of being absorbed. +func validateExtensionNames(ext map[string]any) error { + for k := range ext { + if !classify.IsAllowedExtension(k) { + return fmt.Errorf("%w: %s", ErrBadExtensionName, k) + } + } + return nil +} + diff --git a/internal/builders/validations/coerce.go b/internal/builders/validations/coerce.go new file mode 100644 index 0000000..e73ef8c --- /dev/null +++ b/internal/builders/validations/coerce.go @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package validations owns cross-builder validation and coercion +// concerns shared by the schema / items / parameters / responses +// builders. +// +// Today (S2) the package contains only value-coercion logic — the +// migration target for `helpers.ParseEnum` and +// `helpers.ParseValueFromSchema`. Subsequent phases add semantic-rule +// helpers under `shape.go` (keyword-shape × schema-type/format +// legality) and `context.go` (keyword × annotation context legality) +// per the P7 plan §5.5. +// +// See `.claude/plans/grammar/p7-schema-builder-redesign.md` §4.3 for +// the rationale: these helpers cannot live in grammar because they +// need the resolved Swagger type/format of the target schema, which +// only the builder layer holds. +package validations + +import ( + "encoding/json" + "strconv" + "strings" + + "github.com/go-openapi/spec" +) + +// CoerceValue converts a raw annotation value to the Go representation +// implied by the target schema's Type/Format. Used by default:/example: +// setters where the annotation body is a primitive literal whose +// meaning depends on the target: `default: 3` becomes int(3) against +// `Type: "integer"`, "3" against `Type: "string"`, and so on. +// JSON-typed targets (`object`, `array`) attempt unmarshal and fall +// back to the raw string on invalid JSON. +// +// A nil schema yields the raw string unchanged. Numeric/boolean +// parsing errors are surfaced to the caller; JSON-parse failures are +// absorbed (v1 quirk; preserved for parity). +// +// This function carries the v1 `SimpleSchema.TypeName()` Format-wins +// behaviour for byte-stable parity through S1–S6. The format-aware +// fix lands in S3 as a separate `parseDefault(type, format, raw)` +// — see plan §6.1. +func CoerceValue(s string, schema *spec.SimpleSchema) (any, error) { + if schema == nil { + return s, nil + } + + switch strings.Trim(schema.TypeName(), "\"") { + case "integer", "int", "int64", "int32", "int16": + return strconv.Atoi(s) + case "bool", "boolean": + return strconv.ParseBool(s) + case "number", "float64", "float32": + return strconv.ParseFloat(s, 64) + case "object": + var obj map[string]any + if err := json.Unmarshal([]byte(s), &obj); err != nil { + return s, nil //nolint:nilerr // fallback: return raw string when JSON is invalid + } + return obj, nil + case "array": + var slice []any + if err := json.Unmarshal([]byte(s), &slice); err != nil { + return s, nil //nolint:nilerr // fallback: return raw string when JSON is invalid + } + return slice, nil + default: + return s, nil + } +} + +// ParseDefault is the format-aware replacement for the v1 +// CoerceValue+schemeFromPS combo used by default/example coercion. +// Dispatches on schemaType (not on SimpleSchema.TypeName, which +// returns Format when set and was the source of the v1 Format-drop +// quirk). Format is currently passed for future refinement +// (per-bit-size integer parsing) but doesn't change the dispatch +// today — the routing matches CoerceValue's switch on the +// Format-stripped type. +// +// Replaces the schema-side `schemeFromPS` quirk during P7/S7. +func ParseDefault(s, schemaType, schemaFormat string) (any, error) { + _ = schemaFormat // reserved for format-specific paths + return CoerceValue(s, &spec.SimpleSchema{Type: schemaType}) +} + +// ParseEnumValues is the format-aware enum coercer. Same shape as +// ParseDefault — dispatches on schemaType, ignoring Format for +// routing purposes. +func ParseEnumValues(val, schemaType, schemaFormat string) []any { + _ = schemaFormat // reserved for format-specific paths + return CoerceEnum(val, &spec.SimpleSchema{Type: schemaType}) +} + +// CoerceEnum turns an `enum: …` annotation value into a typed []any. +// Accepts the JSON-array form (`enum: ["a","b"]`) and the comma-list +// form (`enum: a, b`). Per-value typing is applied via CoerceValue +// against the target's scheme. +func CoerceEnum(val string, s *spec.SimpleSchema) []any { + var rawElements []json.RawMessage + if err := json.Unmarshal([]byte(val), &rawElements); err != nil { + return coerceEnumCommaList(val, s) + } + + out := make([]any, len(rawElements)) + for i, d := range rawElements { + ds, err := strconv.Unquote(string(d)) + if err != nil { + ds = string(d) + } + + v, err := CoerceValue(ds, s) + if err != nil { + out[i] = ds + continue + } + out[i] = v + } + + return out +} + +// coerceEnumCommaList handles the legacy `enum: a, b, c` form. Per- +// value whitespace is trimmed (W2 §2.6 quirk 1 fix). Parse errors on +// individual values fall back to the raw string. +func coerceEnumCommaList(val string, s *spec.SimpleSchema) []any { + list := strings.Split(val, ",") + out := make([]any, len(list)) + + for i, d := range list { + d = strings.TrimSpace(d) + v, err := CoerceValue(d, s) + if err != nil { + out[i] = d + continue + } + out[i] = v + } + + return out +} diff --git a/internal/builders/validations/coerce_test.go b/internal/builders/validations/coerce_test.go new file mode 100644 index 0000000..cdb77c1 --- /dev/null +++ b/internal/builders/validations/coerce_test.go @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package validations_test + +import ( + "testing" + + "github.com/go-openapi/codescan/internal/builders/validations" + "github.com/go-openapi/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestCoerceValue_NilSchemaReturnsRaw(t *testing.T) { + got, err := validations.CoerceValue("hello", nil) + require.NoError(t, err) + assert.Equal(t, "hello", got) +} + +func TestCoerceValue_PrimitiveTypes(t *testing.T) { + cases := []struct { + name string + raw string + schema *spec.SimpleSchema + want any + wantErr bool + }{ + {"integer", "42", &spec.SimpleSchema{Type: "integer"}, 42, false}, + {"int64", "100", &spec.SimpleSchema{Type: "int64"}, 100, false}, + {"int32", "10", &spec.SimpleSchema{Type: "int32"}, 10, false}, + {"boolean true", "true", &spec.SimpleSchema{Type: "boolean"}, true, false}, + {"boolean false", "false", &spec.SimpleSchema{Type: "bool"}, false, false}, + {"number", "1.5", &spec.SimpleSchema{Type: "number"}, 1.5, false}, + {"float64", "3.14", &spec.SimpleSchema{Type: "float64"}, 3.14, false}, + {"unknown type returns raw", "anything", &spec.SimpleSchema{Type: "weird"}, "anything", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := validations.CoerceValue(tc.raw, tc.schema) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestCoerceValue_ObjectAndArrayUnmarshal(t *testing.T) { + obj, err := validations.CoerceValue(`{"k":1}`, &spec.SimpleSchema{Type: "object"}) + require.NoError(t, err) + assert.Equal(t, map[string]any{"k": float64(1)}, obj) + + arr, err := validations.CoerceValue(`[1,2,3]`, &spec.SimpleSchema{Type: "array"}) + require.NoError(t, err) + assert.Equal(t, []any{float64(1), float64(2), float64(3)}, arr) +} + +func TestCoerceValue_InvalidJSONFallsBackToRaw(t *testing.T) { + // v1 quirk preserved: object/array parse failure returns raw, + // not error. + got, err := validations.CoerceValue("not json", &spec.SimpleSchema{Type: "object"}) + require.NoError(t, err) + assert.Equal(t, "not json", got) +} + +func TestCoerceValue_FormatPrecedenceQuirk(t *testing.T) { + // v1 quirk: SimpleSchema.TypeName() returns Format when Format is + // non-empty. With Type="number" and Format="float", TypeName() + // returns "float" — NOT in the switch — so the value falls + // through to default and is returned as raw string. The schema + // builder's schemeFromPS deliberately drops Format to avoid this. + // This test pins the v1-bug behaviour for parity through S1–S6; + // S3 introduces a corrected path. + got, err := validations.CoerceValue("1.5", &spec.SimpleSchema{Type: "number", Format: "float"}) + require.NoError(t, err) + assert.Equal(t, "1.5", got, "v1 Format-wins quirk: raw string returned for unrecognized 'float'") +} + +func TestCoerceEnum_JSONArrayForm(t *testing.T) { + out := validations.CoerceEnum(`["low","medium","high"]`, &spec.SimpleSchema{Type: "string"}) + assert.Equal(t, []any{"low", "medium", "high"}, out) +} + +func TestCoerceEnum_CommaListForm(t *testing.T) { + out := validations.CoerceEnum(`a, b, c`, &spec.SimpleSchema{Type: "string"}) + assert.Equal(t, []any{"a", "b", "c"}, out) +} + +func TestCoerceEnum_PerItemTyping(t *testing.T) { + out := validations.CoerceEnum(`[1, 2, 3]`, &spec.SimpleSchema{Type: "integer"}) + assert.Equal(t, []any{1, 2, 3}, out) +} + +func TestCoerceEnum_CommaListWhitespaceTrimmed(t *testing.T) { + out := validations.CoerceEnum(` a , b ,c `, &spec.SimpleSchema{Type: "string"}) + assert.Equal(t, []any{"a", "b", "c"}, out) +} diff --git a/internal/builders/validations/shape.go b/internal/builders/validations/shape.go new file mode 100644 index 0000000..466ba4a --- /dev/null +++ b/internal/builders/validations/shape.go @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package validations + +import ( + "fmt" + "slices" + + "github.com/go-openapi/codescan/internal/parsers/grammar" +) + +// keywordTypeRules returns the set of Swagger schema types each +// keyword is legal on. Keywords absent from the table are legal on +// any type (or the rule is type-independent — `required`, `readOnly`, +// `deprecated`, `discriminator`). +// +// Sourced from JSON Schema draft-4 (the Swagger 2.0 schema dialect): +// +// - String constraints (pattern, min/maxLength) apply to "string". +// - Numeric constraints (maximum, minimum, multipleOf) apply to +// "integer" and "number". +// - Array constraints (min/maxItems, uniqueItems) apply to "array". +// - Object constraints (min/maxProperties) apply to "object". +// +// `default`, `example`, and `enum` are intentionally absent — their +// values get coerced against the schema's type+format via +// CoerceValue / CoerceEnum, so they're legal on any type. +// +// Returned as a function (not a package var) so the table is +// constructed per call — gochecknoglobals-clean and easily extended +// via a future `WithRules(...)` constructor. +func keywordTypeRules() map[string][]string { + return map[string][]string{ + "pattern": {"string"}, + "minLength": {"string"}, + "maxLength": {"string"}, + "maximum": {"integer", "number"}, + "minimum": {"integer", "number"}, + "multipleOf": {"integer", "number"}, + "minItems": {"array"}, + "maxItems": {"array"}, + "uniqueItems": {"array"}, + "minProperties": {"object"}, + "maxProperties": {"object"}, + } +} + +// IsLegalForType reports whether keyword is legal on a schema with +// the given resolved Swagger type. Returns ok=true with empty hint +// when the keyword has no type constraint or the type matches. +// Returns ok=false with a human-readable hint when the type +// mismatches the keyword's domain. +// +// Empty schemaType is treated as "type unknown" and accepted — the +// caller (typically Walker callbacks) is then responsible for not +// applying the keyword to a typeless schema. This matches v1's +// "best-effort apply" semantics. +// +// Format is intentionally not consulted here. Format is a refinement +// of type, not a separate axis: an int32 field has Type="integer", +// Format="int32", and the keyword constraints apply at the type +// level. A future revision can add format-specific rules (e.g. +// `pattern` only on `format: regex` strings) under a sibling +// IsLegalForFormat helper. +func IsLegalForType(keyword grammar.Keyword, schemaType string) (ok bool, hint string) { + rules, hasRule := keywordTypeRules()[keyword.Name] + if !hasRule { + return true, "" + } + if schemaType == "" { + return true, "" + } + if slices.Contains(rules, schemaType) { + return true, "" + } + return false, fmt.Sprintf( + "keyword %q is only valid on schemas typed %v (got %q)", + keyword.Name, rules, schemaType, + ) +} diff --git a/internal/builders/validations/shape_test.go b/internal/builders/validations/shape_test.go new file mode 100644 index 0000000..b559596 --- /dev/null +++ b/internal/builders/validations/shape_test.go @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package validations_test + +import ( + "testing" + + "github.com/go-openapi/codescan/internal/builders/validations" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/testify/v2/assert" +) + +func TestIsLegalForType_PatternOnlyOnString(t *testing.T) { + kw := grammar.Keyword{Name: "pattern"} + + ok, _ := validations.IsLegalForType(kw, "string") + assert.True(t, ok, "pattern legal on string") + + ok, hint := validations.IsLegalForType(kw, "integer") + assert.False(t, ok) + assert.Contains(t, hint, "string") + assert.Contains(t, hint, "integer") +} + +func TestIsLegalForType_NumericOnNumberOrInteger(t *testing.T) { + for _, kwName := range []string{"maximum", "minimum", "multipleOf"} { + kw := grammar.Keyword{Name: kwName} + + okNum, _ := validations.IsLegalForType(kw, "number") + assert.True(t, okNum, "%s legal on number", kwName) + + okInt, _ := validations.IsLegalForType(kw, "integer") + assert.True(t, okInt, "%s legal on integer", kwName) + + okStr, _ := validations.IsLegalForType(kw, "string") + assert.False(t, okStr, "%s NOT legal on string", kwName) + } +} + +func TestIsLegalForType_StringLengthOnlyOnString(t *testing.T) { + for _, kwName := range []string{"minLength", "maxLength"} { + kw := grammar.Keyword{Name: kwName} + + ok, _ := validations.IsLegalForType(kw, "string") + assert.True(t, ok, "%s legal on string", kwName) + + ok, _ = validations.IsLegalForType(kw, "integer") + assert.False(t, ok, "%s NOT legal on integer", kwName) + } +} + +func TestIsLegalForType_ArrayConstraintsOnlyOnArray(t *testing.T) { + for _, kwName := range []string{"minItems", "maxItems", "uniqueItems"} { + kw := grammar.Keyword{Name: kwName} + + ok, _ := validations.IsLegalForType(kw, "array") + assert.True(t, ok, "%s legal on array", kwName) + + ok, _ = validations.IsLegalForType(kw, "string") + assert.False(t, ok, "%s NOT legal on string", kwName) + + ok, _ = validations.IsLegalForType(kw, "object") + assert.False(t, ok, "%s NOT legal on object", kwName) + } +} + +func TestIsLegalForType_ObjectConstraintsOnlyOnObject(t *testing.T) { + for _, kwName := range []string{"minProperties", "maxProperties"} { + kw := grammar.Keyword{Name: kwName} + + ok, _ := validations.IsLegalForType(kw, "object") + assert.True(t, ok, "%s legal on object", kwName) + + ok, _ = validations.IsLegalForType(kw, "array") + assert.False(t, ok, "%s NOT legal on array", kwName) + } +} + +func TestIsLegalForType_TypelessSchemaIsLenient(t *testing.T) { + // Empty schemaType ("type unknown") is accepted — the caller + // decides whether to apply. + kw := grammar.Keyword{Name: "pattern"} + ok, hint := validations.IsLegalForType(kw, "") + assert.True(t, ok) + assert.Empty(t, hint) +} + +func TestIsLegalForType_KeywordsWithoutRulesAlwaysLegal(t *testing.T) { + // required / readOnly / deprecated / discriminator have no type + // constraint — they apply to any type. + for _, kwName := range []string{"required", "readOnly", "deprecated", "discriminator"} { + kw := grammar.Keyword{Name: kwName} + for _, schemaType := range []string{"string", "integer", "number", "object", "array"} { + ok, _ := validations.IsLegalForType(kw, schemaType) + assert.True(t, ok, "%s should be legal on %s", kwName, schemaType) + } + } +} + +func TestIsLegalForType_DefaultExampleEnumLegalOnAnyType(t *testing.T) { + // default / example / enum coerce against the schema's + // type+format via CoerceValue / CoerceEnum, so they're always + // legal — type validation lives there, not here. + for _, kwName := range []string{"default", "example", "enum"} { + kw := grammar.Keyword{Name: kwName} + for _, schemaType := range []string{"string", "integer", "number", "object", "array"} { + ok, _ := validations.IsLegalForType(kw, schemaType) + assert.True(t, ok, "%s should be legal on %s", kwName, schemaType) + } + } +} diff --git a/internal/integration/coverage_alias_response_shapes_test.go b/internal/integration/coverage_alias_response_shapes_test.go new file mode 100644 index 0000000..b10d547 --- /dev/null +++ b/internal/integration/coverage_alias_response_shapes_test.go @@ -0,0 +1,89 @@ +// 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_AliasResponseShapes_Default — Q4 / Q5 exploration +// goldens. After Q4's fix to responses.buildFieldAlias (parity with +// parameters), default mode dissolves an alias chain on a body +// field down to the canonical decl ($ref → Envelope, no +// EnvelopeAlias{,2} pollution in definitions). Headers typed as an +// alias of a named primitive now also surface correctly as +// SimpleSchema primitives ({string, ""}); pre-Q4 they emitted +// empty headers because the response builder's buildFieldAlias +// fell through to makeRef even on header targets. +func TestCoverage_AliasResponseShapes_Default(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/alias-response-shapes/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + // Header aliased to a named string: surfaces as a primitive. + hdr := doc.Responses["headerAliasedBasicResponse"].Headers["X-Session"] + assert.Equal(t, "string", hdr.Type, "alias-of-named-string header should be {string, ''} post-Q4") + + // Body aliased through a chain: dissolves to canonical Envelope + // under default mode (no per-alias definition pollution). + bodyRef := doc.Responses["bodyAliasResponse"].Schema.Ref.String() + assert.Equal(t, "#/definitions/Envelope", bodyRef, "body alias-chain dissolves to canonical decl") + assert.NotContains(t, doc.Definitions, "EnvelopeAlias", "EnvelopeAlias should not pollute definitions under default mode") + assert.NotContains(t, doc.Definitions, "EnvelopeAlias2", "EnvelopeAlias2 should not pollute definitions under default mode") + assert.NotContains(t, doc.Definitions, "SessionIDAlias", "SessionIDAlias should not pollute definitions under default mode") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_alias_response_shapes_default.json") +} + +// TestCoverage_AliasResponseShapes_Ref — under RefAliases the body +// $ref chains through EnvelopeAlias2 → EnvelopeAlias → Envelope; +// headers STILL expand inline (no $ref) because $ref is not legal +// on a SimpleSchema target. The Q4 gate's +// "in != body OR !RefAliases" branch covers both legs. +func TestCoverage_AliasResponseShapes_Ref(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/alias-response-shapes/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + RefAliases: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + hdr := doc.Responses["headerAliasedBasicResponse"].Headers["X-Session"] + assert.Equal(t, "string", hdr.Type, "header still expands inline under RefAliases") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_alias_response_shapes_ref.json") +} + +// TestCoverage_AliasResponseShapes_Transparent — TransparentAliases +// dissolves every layer up front; the body $ref points at +// Envelope, headers carry their primitive type, and the +// alias-of-struct case on a header surfaces empty (the struct +// can't reduce to a SimpleSchema primitive — same as Q2's +// post-fix behaviour). +func TestCoverage_AliasResponseShapes_Transparent(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/alias-response-shapes/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + TransparentAliases: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + hdr := doc.Responses["headerAliasedBasicResponse"].Headers["X-Session"] + assert.Equal(t, "string", hdr.Type, "header expands inline under Transparent") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_alias_response_shapes_transparent.json") +} diff --git a/internal/integration/coverage_enhancements_test.go b/internal/integration/coverage_enhancements_test.go index 083ae09..e3e677e 100644 --- a/internal/integration/coverage_enhancements_test.go +++ b/internal/integration/coverage_enhancements_test.go @@ -8,6 +8,7 @@ import ( "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" oaispec "github.com/go-openapi/spec" @@ -164,6 +165,121 @@ func TestCoverage_NamedBasic(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_named_basic.json") } +// TestCoverage_WrapperDeclTypeOverride isolates Gap B' — the wrapper's +// own top-level definition does not honour `swagger:type` on the decl, +// even though the same annotation works at field reference sites. +// Pins today's broken behavior so the gap is visible until it's fixed. +func TestCoverage_WrapperDeclTypeOverride(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/wrapper-decl-type-override/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_wrapper_decl_type_override.json") + + defs := doc.Definitions + + t.Run("BareWrapperObject top-level definition is typed object", func(t *testing.T) { + def, ok := defs["BareWrapperObject"] + require.TrueT(t, ok) + assert.TrueT(t, def.Type.Contains("object"), "wrapper-decl swagger:type object should produce a typed object schema") + }) + + t.Run("BareWrapperArray top-level definition is typed array", func(t *testing.T) { + def, ok := defs["BareWrapperArray"] + require.TrueT(t, ok) + assert.TrueT(t, def.Type.Contains("array"), "wrapper-decl swagger:type array should produce a typed array schema") + require.NotNil(t, def.Items) + require.NotNil(t, def.Items.Schema) + assert.TrueT(t, def.Items.Schema.Type.Contains("integer"), "array items reflect []byte → uint8") + }) +} + +// TestCoverage_RawMessageOverride captures the user-classifier-override +// precedence for json.RawMessage. The recognizer emits an empty schema +// (`{}`, "any type") as the baseline. A user-declared wrapping type +// carrying `swagger:type` decoration overrides via the +// classifierNamedArrayLike path (RawMessage underlying is []byte). +// A field-level `swagger:type` is now consumed by scanFieldDoc and +// applied in applyFieldCarrier after buildFromType (Gap C — closed). +func TestCoverage_RawMessageOverride(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/raw-message-override/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_raw_message_override.json") + + defs := doc.Definitions + + t.Run("case A: plain json.RawMessage field emits empty schema", func(t *testing.T) { + def, ok := defs["PlainContainer"] + require.TrueT(t, ok) + payload, ok := def.Properties["payload"] + require.TrueT(t, ok) + assert.EqualT(t, 0, len(payload.Type), "payload should have no type — empty schema") + assert.EqualT(t, "", payload.Format) + }) + + t.Run("case B: wrapper-type swagger:type overrides at field reference sites", func(t *testing.T) { + def, ok := defs["TypedContainer"] + require.TrueT(t, ok) + + obj, ok := def.Properties["obj"] + require.TrueT(t, ok) + assert.TrueT(t, obj.Type.Contains("object"), "obj should be typed object via swagger:type on AsObject") + + arr, ok := def.Properties["arr"] + require.TrueT(t, ok) + assert.TrueT(t, arr.Type.Contains("array"), "arr should be typed array via swagger:type on AsArray") + require.NotNil(t, arr.Items) + require.NotNil(t, arr.Items.Schema) + assert.TrueT(t, arr.Items.Schema.Type.Contains("integer"), "array items reflect []byte → uint8") + }) + + t.Run("case B': wrapper-type top-level definitions honour swagger:type", func(t *testing.T) { + // buildFromDecl now applies classifierNamedTypeOverride from + // s.Decl.Comments before the kind-dispatch, so the wrapper's + // own definition reflects the decl-level swagger:type override + // (Gap B' — closed). + asObject, ok := defs["AsObject"] + require.TrueT(t, ok) + assert.TrueT(t, asObject.Type.Contains("object"), "AsObject def is typed object") + + asArray, ok := defs["AsArray"] + require.TrueT(t, ok) + assert.TrueT(t, asArray.Type.Contains("array"), "AsArray def is typed array") + require.NotNil(t, asArray.Items) + require.NotNil(t, asArray.Items.Schema) + assert.TrueT(t, asArray.Items.Schema.Type.Contains("integer"), "AsArray items reflect underlying []byte") + }) + + t.Run("case C: field-level swagger:type overrides on json.RawMessage", func(t *testing.T) { + // scanFieldDoc now consumes swagger:type at the field level; + // applyFieldCarrier applies it after buildFromType so the + // override beats the RawMessage recognizer's empty-schema default. + def, ok := defs["FieldLevelContainer"] + require.TrueT(t, ok) + + obj, ok := def.Properties["obj"] + require.TrueT(t, ok) + assert.TrueT(t, obj.Type.Contains("object"), "field-level swagger:type object overrides RawMessage default") + + arr, ok := def.Properties["arr"] + require.TrueT(t, ok) + assert.TrueT(t, arr.Type.Contains("array"), "field-level swagger:type array overrides RawMessage default") + require.NotNil(t, arr.Items) + require.NotNil(t, arr.Items.Schema) + assert.TrueT(t, arr.Items.Schema.Type.Contains("integer"), "fallback to Underlying() yields []byte → integer/uint8 items") + }) +} + // TestCoverage_SwaggerTypeArray exercises the fallthrough introduced by // upstream #11: when swagger:type is set to a value not recognised by // SwaggerSchemaForType (e.g. "array"), the builder resolves the underlying @@ -206,6 +322,33 @@ func TestCoverage_EnumDocs(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_enum_docs.json") } +// TestCoverage_EnumOverrides captures the v1 behavior for five +// enum-related cases that W2 needs to pin down before the P5.1 +// schema-builder migration: +// +// A. `swagger:enum` with matching consts — const inference +// B. inline `enum: a,b,c` only — inline only +// C. inline `enum: ["a","b","c"]` JSON form only — JSON inline only +// D. `swagger:enum` with NO matching consts — empty/??? case +// E. `swagger:enum` + matching consts + inline on — override question +// the field +// +// See `.claude/plans/workshops/w2-enum.md` §2.6 and +// `fixtures/enhancements/enum-overrides/types.go` for the fixture. +// The golden snapshot becomes the v1-behavior contract the v2 +// migration either preserves or consciously diverges from. +func TestCoverage_EnumOverrides(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/enum-overrides/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_enum_overrides.json") +} + func TestCoverage_TextMarshal(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/text-marshal/..."}, @@ -218,6 +361,24 @@ func TestCoverage_TextMarshal(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_text_marshal.json") } +// TestCoverage_GenericInstantiation exercises buildNamedType's +// generic-instantiation short-circuit. A field whose type is an +// instantiation (e.g. `GenericSlice[int]`) must emit with the +// substituted underlying shape, not as a $ref to the generic +// declaration (whose own schema is empty because type parameters +// are filtered as UnsupportedBuiltinType). +func TestCoverage_GenericInstantiation(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/generic-instantiation/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_generic_instantiation.json") +} + func TestCoverage_AllHTTPMethods(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/all-http-methods/..."}, @@ -318,3 +479,23 @@ func TestCoverage_InputOverlay(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_input_overlay.json") } + +// TestCoverage_ParametersMapPostDecl scans a fixture that witnesses a bug +// in parameters.buildFromFieldMap: the schema sub-builder's +// PostDeclarations are not propagated to the parent parameters +// builder, so a map's value-type model registered during the build +// never reaches the spec's definitions section. +// +// The pre-fix golden shows the buggy state (LocalItem missing from +// definitions). The fix commit regenerates the golden to show LocalItem +// appearing, witnessing the resolution. +func TestCoverage_ParametersMapPostDecl(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/parameters-map-postdecl/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_parameters_map_postdecl.json") +} diff --git a/internal/integration/coverage_header_extensions_test.go b/internal/integration/coverage_header_extensions_test.go new file mode 100644 index 0000000..673a2ac --- /dev/null +++ b/internal/integration/coverage_header_extensions_test.go @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "testing" + + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + + "github.com/go-openapi/codescan" +) + +// TestCoverage_HeaderExtensions pins M2's Walker.Extension wiring on +// the response-header path. Pre-M2, user-authored `Extensions:` +// blocks on header fields were silently dropped; post-M2 they land +// on the header's Extensions map with grammar-typed values. +func TestCoverage_HeaderExtensions(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/header-extensions/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + + // Response is registered at the doc level and the operation + // references it via $ref — the path-level operation has no + // inline headers, so inspect the top-level definition. + require.Contains(t, doc.Responses, "extendedResponse") + resp := doc.Responses["extendedResponse"] + + require.Contains(t, resp.Headers, "X-Rate-Limit") + hdr := resp.Headers["X-Rate-Limit"] + + // Extensions present on the header. The YAML body of the + // Extensions block was parsed via grammar's typed-extensions + // pipeline, so the values arrive pre-typed (string + bool). + val, ok := hdr.Extensions["x-rate-window"] + require.True(t, ok, "x-rate-window extension should be present on header") + assert.Equal(t, "60s", val) + + val, ok = hdr.Extensions["x-burst-allowed"] + require.True(t, ok, "x-burst-allowed extension should be present on header") + assert.Equal(t, true, val) +} diff --git a/internal/integration/coverage_header_named_basic_test.go b/internal/integration/coverage_header_named_basic_test.go new file mode 100644 index 0000000..29cfd25 --- /dev/null +++ b/internal/integration/coverage_header_named_basic_test.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestCoverage_HeaderNamedBasic pins the M1-follow-up fix that +// brought `in: header` into the SimpleSchema-aware primitive-inline +// arm of classifierNamedBasic. Pre-fix, a header parameter typed as +// a named string emitted a `$ref` (invalid under OAS v2 +// SimpleSchema); post-fix it inlines as `{type: string}`. +func TestCoverage_HeaderNamedBasic(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/header-named-basic/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + + require.Contains(t, doc.Paths.Paths, "/header-named-basic") + op := doc.Paths.Paths["/header-named-basic"].Get + require.NotNil(t, op) + require.Len(t, op.Parameters, 1) + + session := op.Parameters[0] + assert.Equal(t, "X-Session", session.Name) + assert.Equal(t, "header", session.In) + assert.Equal(t, "string", session.Type, "named basic should inline as primitive under SimpleSchema") + assert.Empty(t, session.Ref.String(), "no $ref should be emitted") + + // The SessionID type should NOT appear as a top-level definition + // — the primitive-inline arm bypasses FindModel. + _, defined := doc.Definitions["SessionID"] + assert.False(t, defined, "SessionID should not become a top-level definition") +} diff --git a/internal/integration/coverage_response_file_types_test.go b/internal/integration/coverage_response_file_types_test.go new file mode 100644 index 0000000..0597e4a --- /dev/null +++ b/internal/integration/coverage_response_file_types_test.go @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestCoverage_ResponseFileTypes pins Q3's three variants: +// +// 1. FileBodyResponse — `swagger:file` + `in: body` → +// resp.Schema = {file, ""}; no headers; no diagnostic. +// +// 2. FileOnHeaderResponse — `swagger:file` + explicit +// `in: header` → diagnostic fires; file branch skipped; field +// surfaces as a regular header. +// +// 3. FileOnImplicitDefaultResponse — `swagger:file` with no +// `in:` (Q1 implicit-header default) → same diagnostic; +// field surfaces as a regular header. +// +// Diagnostic capture asserts CodeUnsupportedInSimpleSchema fires +// for variants (2) and (3) but not for (1). +func TestCoverage_ResponseFileTypes(t *testing.T) { + var got []grammar.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/response-file-types/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + OnDiagnostic: func(d grammar.Diagnostic) { + got = append(got, d) + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + // (1) Legitimate file body. + require.Contains(t, doc.Responses, "fileBodyResponse") + body := doc.Responses["fileBodyResponse"] + require.NotNil(t, body.Schema, "FileBodyResponse should produce a body schema") + assert.Equal(t, "file", body.Schema.Type[0], "body schema must be {file, ...}") + assert.Empty(t, body.Headers, "file-body response carries no headers") + + // (2) Misuse with explicit `in: header`. + require.Contains(t, doc.Responses, "fileOnHeaderResponse") + headerMisuse := doc.Responses["fileOnHeaderResponse"] + assert.Nil(t, headerMisuse.Schema, "no body schema corruption from header-positioned swagger:file") + require.Contains(t, headerMisuse.Headers, "X-Misplaced", "field falls through to the normal header build") + assert.Equal(t, "string", headerMisuse.Headers["X-Misplaced"].Type) + + // (3) Misuse with implicit default (no `in:` line). + require.Contains(t, doc.Responses, "fileOnImplicitDefaultResponse") + implicitMisuse := doc.Responses["fileOnImplicitDefaultResponse"] + assert.Nil(t, implicitMisuse.Schema, "no body schema corruption from swagger:file at implicit-header default") + require.Contains(t, implicitMisuse.Headers, "X-Implicit") + assert.Equal(t, "string", implicitMisuse.Headers["X-Implicit"].Type) + + // Diagnostic captures: (2) and (3) each emit one + // CodeUnsupportedInSimpleSchema warning naming `swagger:file`. + var seenExplicit, seenImplicit bool + for _, d := range got { + if d.Code != grammar.CodeUnsupportedInSimpleSchema { + continue + } + if !strings.Contains(d.Message, "swagger:file") { + continue + } + switch { + case strings.Contains(d.Message, `"X-Misplaced"`): + seenExplicit = true + assert.Equal(t, grammar.SeverityWarning, d.Severity) + case strings.Contains(d.Message, `"X-Implicit"`): + seenImplicit = true + assert.Equal(t, grammar.SeverityWarning, d.Severity) + } + } + if !seenExplicit || !seenImplicit { + for i, d := range got { + t.Logf("diag[%d] code=%s severity=%s msg=%q", i, d.Code, d.Severity, d.Message) + } + } + assert.True(t, seenExplicit, "expected diagnostic naming `swagger:file` on field X-Misplaced (explicit in: header)") + assert.True(t, seenImplicit, "expected diagnostic naming `swagger:file` on field X-Implicit (implicit default)") + + // Golden pins the full shape. + scantest.CompareOrDumpJSON(t, doc, "enhancements_response_file_types.json") +} diff --git a/internal/integration/coverage_response_header_ref_leak_test.go b/internal/integration/coverage_response_header_ref_leak_test.go new file mode 100644 index 0000000..49ea71f --- /dev/null +++ b/internal/integration/coverage_response_header_ref_leak_test.go @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestCoverage_ResponseHeaderRefLeak pins the Q2 fix on the +// response side: when a header field's Go type resolves through the +// schema builder's makeRef path (e.g. typed as a named struct), +// `responseTypable.SetRef` no-ops instead of writing `response.Schema.Ref`. +// The body-schema leak that used to land in the pre-fix emission +// is gone; the SimpleSchema exit validator catches the attempt via +// the probe's HasRef and emits CodeUnsupportedInSimpleSchema. +// +// Two variants pinned: +// +// 1. LeakResponse — TagHeader typed as named struct Tag, no +// strfmt override. Post-fix: header stays empty (the named +// struct can't be expressed as OAS v2 SimpleSchema); body +// schema is NOT set; diagnostic fires. +// +// 2. LeakWithStrfmtResponse — same shape plus +// `// swagger:strfmt uuid` on the field. Post-fix: header +// gets {string, uuid} from the strfmt override (which runs +// after the exit validator's reset); body schema is NOT +// set; diagnostic still fires for the underlying ref attempt. +func TestCoverage_ResponseHeaderRefLeak(t *testing.T) { + var got []grammar.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/response-header-ref-leak/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + OnDiagnostic: func(d grammar.Diagnostic) { + got = append(got, d) + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + // Body-schema leak is gone for both responses. + require.Contains(t, doc.Responses, "leakResponse") + leak := doc.Responses["leakResponse"] + assert.Nil(t, leak.Schema, "leakResponse.Schema must be nil — no body leak") + require.Contains(t, leak.Headers, "X-Tag", "the header field must still surface as a header entry") + + require.Contains(t, doc.Responses, "leakWithStrfmtResponse") + leakStrfmt := doc.Responses["leakWithStrfmtResponse"] + assert.Nil(t, leakStrfmt.Schema, "leakWithStrfmtResponse.Schema must be nil — no body leak") + require.Contains(t, leakStrfmt.Headers, "X-Tag-ID") + xTagID := leakStrfmt.Headers["X-Tag-ID"] + assert.Equal(t, "string", xTagID.Type, "strfmt override fires after the exit-validator reset") + assert.Equal(t, "uuid", xTagID.Format) + + // SimpleSchema-mode diagnostic fires at least once. + var seenForbiddenRef bool + for _, d := range got { + if d.Code == grammar.CodeUnsupportedInSimpleSchema && + strings.Contains(d.Message, "$ref") { + seenForbiddenRef = true + assert.Equal(t, grammar.SeverityWarning, d.Severity) + break + } + } + if !seenForbiddenRef { + for i, d := range got { + t.Logf("diag[%d] code=%s severity=%s msg=%q", i, d.Code, d.Severity, d.Message) + } + } + assert.True(t, seenForbiddenRef, "expected CodeUnsupportedInSimpleSchema diagnostic naming `$ref` for the header-as-named-struct attempt") + + // Golden pins the full shape (no body Schema on either response). + scantest.CompareOrDumpJSON(t, doc, "enhancements_response_header_ref_leak.json") +} diff --git a/internal/integration/coverage_response_implicit_header_test.go b/internal/integration/coverage_response_implicit_header_test.go new file mode 100644 index 0000000..c554f54 --- /dev/null +++ b/internal/integration/coverage_response_implicit_header_test.go @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestCoverage_ResponseImplicitHeader exercises the four Q1 variants +// on the response side: +// +// 1. EmptyResponse — empty struct under swagger:response → +// description-only response, no Headers map, no Schema. +// 2. AllHeadersResponse — fields default to header (Etag with no +// `in:` line; RateLimit with explicit `in: header`). The body +// schema stays nil. +// 3. MixedResponse — Tag (implicit header), Limit (explicit +// header), Body (explicit `in: body`). +// 4. InvalidInResponse — `in: cookie` emits a +// CodeInvalidAnnotation warning and defaults to header anyway +// (diagnosed but not silently ignored). +// +// The full document is golden-captured so the shape of each +// response — Headers map presence, Schema presence, primitive types +// — locks down side-by-side. +func TestCoverage_ResponseImplicitHeader(t *testing.T) { + var got []grammar.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/response-implicit-header/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + OnDiagnostic: func(d grammar.Diagnostic) { + got = append(got, d) + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + // --- Variant 1: EmptyResponse --- + require.Contains(t, doc.Responses, "emptyResponse") + empty := doc.Responses["emptyResponse"] + assert.Empty(t, empty.Headers, "empty struct should not emit a Headers map") + assert.Nil(t, empty.Schema, "empty struct should not emit a body Schema") + + // --- Variant 2: AllHeadersResponse --- + require.Contains(t, doc.Responses, "allHeadersResponse") + allHeaders := doc.Responses["allHeadersResponse"] + assert.Contains(t, allHeaders.Headers, "ETag", "Etag (no `in:`) should default to a header") + assert.Contains(t, allHeaders.Headers, "X-Rate-Limit", "RateLimit (explicit `in: header`) should be a header") + assert.Nil(t, allHeaders.Schema, "no `in: body` field → no body Schema") + assert.Equal(t, "string", allHeaders.Headers["ETag"].Type) + assert.Equal(t, "integer", allHeaders.Headers["X-Rate-Limit"].Type) + + // --- Variant 3: MixedResponse --- + require.Contains(t, doc.Responses, "mixedResponse") + mixed := doc.Responses["mixedResponse"] + assert.Contains(t, mixed.Headers, "X-Tag", "Tag (implicit) should be a header") + assert.Contains(t, mixed.Headers, "X-Limit", "Limit (explicit) should be a header") + assert.NotContains(t, mixed.Headers, "body", "Body (explicit `in: body`) should not appear in Headers") + require.NotNil(t, mixed.Schema, "Body field should populate resp.Schema") + + // --- Variant 4: InvalidInResponse --- + require.Contains(t, doc.Responses, "invalidInResponse") + invalidResp := doc.Responses["invalidInResponse"] + assert.Contains(t, invalidResp.Headers, "Cookie", "`in: cookie` → still treated as header (Q1: not silently ignored)") + + var seenInvalid bool + for _, d := range got { + if d.Code == grammar.CodeInvalidAnnotation && + strings.Contains(d.Message, "in: cookie") && + strings.Contains(d.Message, "Cookie") { + seenInvalid = true + assert.Equal(t, grammar.SeverityWarning, d.Severity) + break + } + } + if !seenInvalid { + for i, d := range got { + t.Logf("diag[%d] code=%s severity=%s msg=%q", i, d.Code, d.Severity, d.Message) + } + } + assert.True(t, seenInvalid, "expected a CodeInvalidAnnotation diagnostic naming `in: cookie` on field Cookie") + + // Golden of the full doc — shape-level pin (Headers maps, + // Schema presence, primitive types per response). + scantest.CompareOrDumpJSON(t, doc, "enhancements_response_implicit_header.json") +} diff --git a/internal/integration/coverage_simple_schema_readonly_test.go b/internal/integration/coverage_simple_schema_readonly_test.go new file mode 100644 index 0000000..28b91ad --- /dev/null +++ b/internal/integration/coverage_simple_schema_readonly_test.go @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestCoverage_SimpleSchemaReadOnlyGate pins the schema builder's +// SimpleSchema-mode gate on the full-Schema-only `readOnly:` +// keyword. When the schema builder is invoked via WithSimpleSchema +// and walks an anonymous struct that carries a `readOnly: true` +// sub-field annotation, the Bool handler emits a +// CodeUnsupportedInSimpleSchema diagnostic naming `readOnly` and +// skips the write. +// +// The exit validator does NOT additionally reset the parameter here. +// The struct-walking dance writes through a throwaway scratch schema +// (paramTypable.Schema() returns nil for non-body), so the +// parameter's SimpleSchema stays at Type="" — the validator's "any" +// branch accepts that. The observable signal is the gate diagnostic +// itself. +func TestCoverage_SimpleSchemaReadOnlyGate(t *testing.T) { + var got []grammar.Diagnostic + _, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/simple-schema-readonly/..."}, + WorkDir: scantest.FixturesDir(), + OnDiagnostic: func(d grammar.Diagnostic) { + got = append(got, d) + }, + }) + require.NoError(t, err) + + var seen bool + for _, d := range got { + if d.Code != grammar.CodeUnsupportedInSimpleSchema { + continue + } + if strings.Contains(d.Message, "readOnly") { + seen = true + assert.Equal(t, grammar.SeverityWarning, d.Severity) + assert.Contains(t, d.Message, "full-Schema-only") + break + } + } + if !seen { + for i, d := range got { + t.Logf("diag[%d] code=%s severity=%s msg=%q", i, d.Code, d.Severity, d.Message) + } + } + assert.True(t, seen, "expected a CodeUnsupportedInSimpleSchema diagnostic naming `readOnly`") +} diff --git a/internal/integration/coverage_simple_schema_test.go b/internal/integration/coverage_simple_schema_test.go new file mode 100644 index 0000000..53c6de0 --- /dev/null +++ b/internal/integration/coverage_simple_schema_test.go @@ -0,0 +1,62 @@ +// 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/parsers/grammar" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestCoverage_SimpleSchemaViolation exercises M1's exit validator: +// a query parameter whose Go type resolves to an object-typed +// SimpleSchema fires CodeUnsupportedInSimpleSchema and the target is +// reset to empty `{}`. +// +// Plumbing tested: +// - schema.WithSimpleSchema option carries the `in` value to the builder +// - exit validator detects Type=="object" as a violation +// - paramTypable.ResetForViolation wipes the SimpleSchema-shape +// - OnDiagnostic callback fires with the new code +func TestCoverage_SimpleSchemaViolation(t *testing.T) { + var got []grammar.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/simple-schema-violation/..."}, + WorkDir: scantest.FixturesDir(), + OnDiagnostic: func(d grammar.Diagnostic) { + got = append(got, d) + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + // 1. Diagnostic surface. + var seen bool + for _, d := range got { + if d.Code == grammar.CodeUnsupportedInSimpleSchema { + seen = true + assert.Equal(t, grammar.SeverityWarning, d.Severity, "CodeUnsupportedInSimpleSchema severity") + assert.Contains(t, d.Message, "SimpleSchema", "message should mention SimpleSchema") + break + } + } + assert.True(t, seen, "expected CodeUnsupportedInSimpleSchema diagnostic") + + // 2. Target reset. The offending parameter should have an empty + // SimpleSchema (no Type, no Format, no Ref) — honest over lossy. + require.Contains(t, doc.Paths.Paths, "/violation") + op := doc.Paths.Paths["/violation"].Get + require.NotNil(t, op) + require.Len(t, op.Parameters, 1) + bad := op.Parameters[0] + assert.Equal(t, "bad", bad.Name) + assert.Equal(t, "query", bad.In, "in: query preserved") + assert.Empty(t, bad.Type, "Type should be wiped to empty") + assert.Empty(t, bad.Format, "Format should be wiped to empty") + assert.Empty(t, bad.Ref.String(), "Ref should be wiped to empty") +} diff --git a/internal/integration/schema_special_test.go b/internal/integration/schema_special_test.go index 55303ce..ebd3524 100644 --- a/internal/integration/schema_special_test.go +++ b/internal/integration/schema_special_test.go @@ -255,10 +255,11 @@ func testSpecialTypesStruct(t *testing.T, sp *oaispec.Swagger) { }) }) - t.Run("a json.RawMessage should be recognized and render as an object (yes this is wrong)", func(t *testing.T) { + t.Run("a json.RawMessage should be recognized and render as an empty schema (any type)", func(t *testing.T) { m, ok := props["Message"] require.TrueT(t, ok) - require.TrueT(t, m.Type.Contains("object")) + assert.EqualT(t, 0, len(m.Type), "Message should have no type — empty schema models 'any'") + assert.EqualT(t, "", m.Format) }) t.Run("type time.Duration is not recognized as a special type and should just render as a ref", func(t *testing.T) { diff --git a/internal/parsers/enum.go b/internal/parsers/enum.go deleted file mode 100644 index 0b4e62f..0000000 --- a/internal/parsers/enum.go +++ /dev/null @@ -1,149 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "encoding/json" - "go/ast" - "log" - "regexp" - "strconv" - "strings" - - "github.com/go-openapi/codescan/internal/ifaces" - "github.com/go-openapi/spec" -) - -type SetEnum struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetEnum(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetEnum { - rx := rxEnumValidation - for _, apply := range opts { - rx = apply(rxEnumFmt) - } - - return &SetEnum{ - builder: builder, - rx: rx, - } -} - -func (se *SetEnum) Matches(line string) bool { - return se.rx.MatchString(line) -} - -func (se *SetEnum) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - - matches := se.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - se.builder.SetEnum(matches[1]) - } - - return nil -} - -func parseValueFromSchema(s string, schema *spec.SimpleSchema) (any, error) { - if schema == nil { - return s, nil - } - - switch strings.Trim(schema.TypeName(), "\"") { - case "integer", "int", "int64", "int32", "int16": - return strconv.Atoi(s) - case "bool", "boolean": - return strconv.ParseBool(s) - case "number", "float64", "float32": - return strconv.ParseFloat(s, 64) - case "object": - var obj map[string]any - if err := json.Unmarshal([]byte(s), &obj); err != nil { - return s, nil //nolint:nilerr // fallback: return raw string when JSON is invalid - } - return obj, nil - case "array": - var slice []any - if err := json.Unmarshal([]byte(s), &slice); err != nil { - return s, nil //nolint:nilerr // fallback: return raw string when JSON is invalid - } - return slice, nil - default: - return s, nil - } -} - -func parseEnumOld(val string, s *spec.SimpleSchema) []any { - list := strings.Split(val, ",") - interfaceSlice := make([]any, len(list)) - for i, d := range list { - v, err := parseValueFromSchema(d, s) - if err != nil { - interfaceSlice[i] = d - continue - } - - interfaceSlice[i] = v - } - return interfaceSlice -} - -func ParseEnum(val string, s *spec.SimpleSchema) []any { - // obtain the raw elements of the list to latter process them with the parseValueFromSchema - var rawElements []json.RawMessage - if err := json.Unmarshal([]byte(val), &rawElements); err != nil { - log.Print("WARNING: item list for enum is not a valid JSON array, using the old deprecated format") - return parseEnumOld(val, s) - } - - interfaceSlice := make([]any, len(rawElements)) - - for i, d := range rawElements { - ds, err := strconv.Unquote(string(d)) - if err != nil { - ds = string(d) - } - - v, err := parseValueFromSchema(ds, s) - if err != nil { - interfaceSlice[i] = ds - continue - } - - interfaceSlice[i] = v - } - - return interfaceSlice -} - -func GetEnumBasicLitValue(basicLit *ast.BasicLit) any { - switch basicLit.Kind.String() { - case "INT": - if result, err := strconv.ParseInt(basicLit.Value, 10, 64); err == nil { - return result - } - case "FLOAT": - if result, err := strconv.ParseFloat(basicLit.Value, 64); err == nil { - return result - } - default: - return strings.Trim(basicLit.Value, "\"") - } - return nil -} - -const extEnumDesc = "x-go-enum-desc" - -func GetEnumDesc(extensions spec.Extensions) (desc string) { - desc, _ = extensions.GetString(extEnumDesc) - return desc -} - -func EnumDescExtension() string { - return extEnumDesc -} diff --git a/internal/parsers/enum_test.go b/internal/parsers/enum_test.go deleted file mode 100644 index 14f9288..0000000 --- a/internal/parsers/enum_test.go +++ /dev/null @@ -1,145 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "go/ast" - "go/token" - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" - - "github.com/go-openapi/spec" -) - -func Test_getEnumBasicLitValue(t *testing.T) { - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.INT, Value: "0"}, int64(0)) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.INT, Value: "-1"}, int64(-1)) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.INT, Value: "42"}, int64(42)) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.INT, Value: ""}, nil) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.INT, Value: "word"}, nil) - - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.FLOAT, Value: "0"}, float64(0)) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.FLOAT, Value: "-1"}, float64(-1)) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.FLOAT, Value: "42"}, float64(42)) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.FLOAT, Value: "1.1234"}, float64(1.1234)) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.FLOAT, Value: "1.9876"}, float64(1.9876)) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.FLOAT, Value: ""}, nil) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.FLOAT, Value: "word"}, nil) - - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.STRING, Value: "Foo"}, "Foo") - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.STRING, Value: ""}, "") - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.STRING, Value: "0"}, "0") - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.STRING, Value: "1.1"}, "1.1") -} - -func verifyGetEnumBasicLitValue(t *testing.T, basicLit ast.BasicLit, expected any) { - actual := GetEnumBasicLitValue(&basicLit) - - assert.Equal(t, expected, actual) -} - -func TestParseValueFromSchema(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - schema *spec.SimpleSchema - want any - }{ - {"nil schema", "hello", nil, "hello"}, - {"string", "hello", &spec.SimpleSchema{Type: "string"}, "hello"}, - {"integer", "42", &spec.SimpleSchema{Type: "integer"}, 42}, - {"int64", "100", &spec.SimpleSchema{Type: "int64"}, 100}, - {"bool true", "true", &spec.SimpleSchema{Type: "bool"}, true}, - {"boolean false", "false", &spec.SimpleSchema{Type: "boolean"}, false}, - {"float64", "3.14", &spec.SimpleSchema{Type: "float64"}, float64(3.14)}, - {"number", "2.5", &spec.SimpleSchema{Type: "number"}, float64(2.5)}, - {"object valid", `{"a":"b"}`, &spec.SimpleSchema{Type: "object"}, map[string]any{"a": "b"}}, - {"object invalid json", `not-json`, &spec.SimpleSchema{Type: "object"}, "not-json"}, - {"array valid", `[1,2,3]`, &spec.SimpleSchema{Type: "array"}, []any{float64(1), float64(2), float64(3)}}, - {"array invalid json", `not-json`, &spec.SimpleSchema{Type: "array"}, "not-json"}, - {"unknown type", "raw", &spec.SimpleSchema{Type: "custom"}, "raw"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got, err := parseValueFromSchema(tc.input, tc.schema) - require.NoError(t, err) - assert.Equal(t, tc.want, got) - }) - } - - t.Run("integer parse error", func(t *testing.T) { - _, err := parseValueFromSchema("not-a-number", &spec.SimpleSchema{Type: "integer"}) - require.Error(t, err) - }) - - t.Run("bool parse error", func(t *testing.T) { - _, err := parseValueFromSchema("maybe", &spec.SimpleSchema{Type: "bool"}) - require.Error(t, err) - }) -} - -func TestParseEnum(t *testing.T) { - t.Parallel() - - t.Run("JSON format strings", func(t *testing.T) { - result := ParseEnum(`["a","b","c"]`, &spec.SimpleSchema{Type: "string"}) - assert.Equal(t, []any{"a", "b", "c"}, result) - }) - - t.Run("JSON format integers", func(t *testing.T) { - result := ParseEnum(`[1,2,3]`, &spec.SimpleSchema{Type: "integer"}) - assert.Equal(t, []any{1, 2, 3}, result) - }) - - t.Run("old comma-separated format", func(t *testing.T) { - result := ParseEnum("a,b,c", &spec.SimpleSchema{Type: "string"}) - assert.Equal(t, []any{"a", "b", "c"}, result) - }) - - t.Run("old format integers", func(t *testing.T) { - result := ParseEnum("1,2,3", &spec.SimpleSchema{Type: "integer"}) - assert.Equal(t, []any{1, 2, 3}, result) - }) - - t.Run("old format with parse error fallback", func(t *testing.T) { - // "abc" cannot be parsed as integer → fallback to raw string - result := ParseEnum("abc,2,xyz", &spec.SimpleSchema{Type: "integer"}) - assert.Equal(t, []any{"abc", 2, "xyz"}, result) - }) - - t.Run("JSON format with parse error fallback", func(t *testing.T) { - // JSON array of integers, but "abc" can't parse as integer → fallback - result := ParseEnum(`["abc",2,"xyz"]`, &spec.SimpleSchema{Type: "integer"}) - assert.Equal(t, []any{"abc", 2, "xyz"}, result) - }) -} - -func TestGetEnumDesc(t *testing.T) { - t.Parallel() - - t.Run("with extension", func(t *testing.T) { - ext := spec.Extensions{"x-go-enum-desc": "Active - active state\nInactive - inactive state"} - assert.EqualT(t, "Active - active state\nInactive - inactive state", GetEnumDesc(ext)) - }) - - t.Run("without extension", func(t *testing.T) { - ext := spec.Extensions{} - assert.EqualT(t, "", GetEnumDesc(ext)) - }) - - t.Run("nil extensions", func(t *testing.T) { - assert.EqualT(t, "", GetEnumDesc(nil)) - }) -} - -func TestEnumDescExtension(t *testing.T) { - t.Parallel() - - assert.EqualT(t, "x-go-enum-desc", EnumDescExtension()) -} diff --git a/internal/parsers/errors.go b/internal/parsers/errors.go deleted file mode 100644 index 0fb4e79..0000000 --- a/internal/parsers/errors.go +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import "errors" - -// ErrParser is the sentinel error for all errors originating from the parsers package. -var ErrParser = errors.New("codescan:parsers") diff --git a/internal/parsers/extensions.go b/internal/parsers/extensions.go deleted file mode 100644 index d95445f..0000000 --- a/internal/parsers/extensions.go +++ /dev/null @@ -1,245 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "fmt" - "reflect" - "regexp" - "strings" - - "github.com/go-openapi/codescan/internal/logger" - oaispec "github.com/go-openapi/spec" -) - -// alphaChars used when parsing for Vendor Extensions. -const alphaChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - -type SetOpExtensions struct { - Set func(*oaispec.Extensions) - rx *regexp.Regexp - Debug bool -} - -func NewSetExtensions(setter func(*oaispec.Extensions), debug bool) *SetOpExtensions { - return &SetOpExtensions{ - Set: setter, - rx: rxExtensions, - Debug: debug, - } -} - -func (ss *SetOpExtensions) Matches(line string) bool { - return ss.rx.MatchString(line) -} - -func (ss *SetOpExtensions) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - - cleanLines := cleanupScannerLines(lines, rxUncommentHeaders) - - exts := new(oaispec.VendorExtensible) - extList := make([]extensionObject, 0) - buildExtensionObjects(lines, cleanLines, 0, &extList, nil) - - // Extensions can be one of the following: - // key:value pair - // list/array - // object - for _, ext := range extList { - if m, ok := ext.Root.(map[string]string); ok { - exts.AddExtension(ext.Extension, m[ext.Extension]) - } else if m, ok := ext.Root.(map[string]*[]string); ok { - exts.AddExtension(ext.Extension, *m[ext.Extension]) - } else if m, ok := ext.Root.(map[string]any); ok { - exts.AddExtension(ext.Extension, m[ext.Extension]) - } else { - logger.DebugLogf(ss.Debug, "Unknown Extension type: %s", fmt.Sprint(reflect.TypeOf(ext.Root))) - } - } - - ss.Set(&exts.Extensions) - return nil -} - -type extensionObject struct { - Extension string - Root any -} - -type extensionParsingStack []any - -// Helper function to walk back through extensions until the proper nest level is reached. -func (stack *extensionParsingStack) walkBack(rawLines []string, lineIndex int) { - indent := strings.IndexAny(rawLines[lineIndex], alphaChars) - nextIndent := strings.IndexAny(rawLines[lineIndex+1], alphaChars) - if nextIndent >= indent { - return - } - - // Pop elements off the stack until we're back where we need to be - runbackIndex := 0 - poppedIndent := 1000 - for { - checkIndent := strings.IndexAny(rawLines[lineIndex-runbackIndex], alphaChars) - if nextIndent == checkIndent { - break - } - if checkIndent < poppedIndent { - *stack = (*stack)[:len(*stack)-1] - poppedIndent = checkIndent - } - runbackIndex++ - } -} - -// Recursively parses through the given extension lines, building and adding extension objects as it goes. -// Extensions may be key:value pairs, arrays, or objects. -func buildExtensionObjects(rawLines []string, cleanLines []string, lineIndex int, extObjs *[]extensionObject, stack *extensionParsingStack) { - if lineIndex >= len(rawLines) { - if stack != nil { - if ext, ok := (*stack)[0].(extensionObject); ok { - *extObjs = append(*extObjs, ext) - } - } - return - } - - kv := strings.SplitN(cleanLines[lineIndex], ":", kvParts) - key := strings.TrimSpace(kv[0]) - if key == "" { - // Some odd empty line - return - } - - nextIsList := false - if lineIndex < len(rawLines)-1 { - next := strings.SplitAfterN(cleanLines[lineIndex+1], ":", kvParts) - nextIsList = len(next) == 1 - } - - if len(kv) <= 1 { - // Should be a list item - if stack == nil || len(*stack) == 0 { - return - } - stackIndex := len(*stack) - 1 - list, ok := (*stack)[stackIndex].(*[]string) - if !ok { - panic(fmt.Errorf("internal error: expected *[]string, got %T: %w", (*stack)[stackIndex], ErrParser)) - } - *list = append(*list, key) - (*stack)[stackIndex] = list - if lineIndex < len(rawLines)-1 && !rxAllowedExtensions.MatchString(cleanLines[lineIndex+1]) { - stack.walkBack(rawLines, lineIndex) - } - buildExtensionObjects(rawLines, cleanLines, lineIndex+1, extObjs, stack) - return - } - - // Should be the start of a map or a key:value pair - value := strings.TrimSpace(kv[1]) - - if rxAllowedExtensions.MatchString(key) { - buildNewExtension(key, value, nextIsList, stack, rawLines, cleanLines, lineIndex, extObjs) - return - } - - if stack == nil || len(*stack) == 0 { - return - } - - buildStackEntry(key, value, nextIsList, stack, rawLines, cleanLines, lineIndex) - buildExtensionObjects(rawLines, cleanLines, lineIndex+1, extObjs, stack) -} - -// buildNewExtension handles the start of a new x- extension key. -func buildNewExtension(key, value string, nextIsList bool, stack *extensionParsingStack, rawLines, cleanLines []string, lineIndex int, extObjs *[]extensionObject) { - // Flush any previous extension on the stack - if stack != nil { - if ext, ok := (*stack)[0].(extensionObject); ok { - *extObjs = append(*extObjs, ext) - } - } - - if value != "" { - ext := extensionObject{ - Extension: key, - } - // Extension is simple key:value pair, no stack - rootMap := make(map[string]string) - rootMap[key] = value - ext.Root = rootMap - *extObjs = append(*extObjs, ext) - buildExtensionObjects(rawLines, cleanLines, lineIndex+1, extObjs, nil) - return - } - - ext := extensionObject{ - Extension: key, - } - if nextIsList { - // Extension is an array - rootMap := make(map[string]*[]string) - rootList := make([]string, 0) - rootMap[key] = &rootList - ext.Root = rootMap - stack = &extensionParsingStack{} - *stack = append(*stack, ext) - rootListMap, ok := ext.Root.(map[string]*[]string) - if !ok { - panic(fmt.Errorf("internal error: expected map[string]*[]string, got %T: %w", ext.Root, ErrParser)) - } - *stack = append(*stack, rootListMap[key]) - } else { - // Extension is an object - rootMap := make(map[string]any) - innerMap := make(map[string]any) - rootMap[key] = innerMap - ext.Root = rootMap - stack = &extensionParsingStack{} - *stack = append(*stack, ext) - *stack = append(*stack, innerMap) - } - buildExtensionObjects(rawLines, cleanLines, lineIndex+1, extObjs, stack) -} - -func assertStackMap(stack *extensionParsingStack, index int) map[string]any { - asMap, ok := (*stack)[index].(map[string]any) - if !ok { - panic(fmt.Errorf("internal error: stack index expected to be map[string]any, but got %T: %w", (*stack)[index], ErrParser)) - } - return asMap -} - -// buildStackEntry adds a key/value, nested list, or nested map to the current stack. -func buildStackEntry(key, value string, nextIsList bool, stack *extensionParsingStack, rawLines, cleanLines []string, lineIndex int) { - stackIndex := len(*stack) - 1 - if value == "" { - asMap := assertStackMap(stack, stackIndex) - if nextIsList { - // start of new list - newList := make([]string, 0) - asMap[key] = &newList - *stack = append(*stack, &newList) - } else { - // start of new map - newMap := make(map[string]any) - asMap[key] = newMap - *stack = append(*stack, newMap) - } - return - } - - // key:value - if reflect.TypeOf((*stack)[stackIndex]).Kind() == reflect.Map { - asMap := assertStackMap(stack, stackIndex) - asMap[key] = value - } - if lineIndex < len(rawLines)-1 && !rxAllowedExtensions.MatchString(cleanLines[lineIndex+1]) { - stack.walkBack(rawLines, lineIndex) - } -} diff --git a/internal/parsers/extensions_test.go b/internal/parsers/extensions_test.go deleted file mode 100644 index c55bee4..0000000 --- a/internal/parsers/extensions_test.go +++ /dev/null @@ -1,264 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" - - oaispec "github.com/go-openapi/spec" -) - -func TestSetOpExtensions_Matches(t *testing.T) { - t.Parallel() - - se := NewSetExtensions(nil, false) - assert.TrueT(t, se.Matches("extensions:")) - assert.TrueT(t, se.Matches("Extensions:")) - assert.FalseT(t, se.Matches("something else")) -} - -func TestSetOpExtensions_Parse(t *testing.T) { - t.Parallel() - - t.Run("empty", func(t *testing.T) { - var called bool - se := NewSetExtensions(func(_ *oaispec.Extensions) { called = true }, false) - require.NoError(t, se.Parse(nil)) - assert.FalseT(t, called) - require.NoError(t, se.Parse([]string{})) - require.NoError(t, se.Parse([]string{""})) - }) - - t.Run("simple key-value", func(t *testing.T) { - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{ - "x-custom-value: hello", - } - require.NoError(t, se.Parse(lines)) - val, ok := got.GetString("x-custom-value") - require.TrueT(t, ok) - assert.EqualT(t, "hello", val) - }) - - t.Run("array extension", func(t *testing.T) { - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{ - "x-tags:", - " value1", - " value2", - } - require.NoError(t, se.Parse(lines)) - require.NotNil(t, got) - val, ok := got["x-tags"] - require.TrueT(t, ok) - arr, ok := val.([]string) - require.TrueT(t, ok) - assert.Equal(t, []string{"value1", "value2"}, arr) - }) - - t.Run("object extension", func(t *testing.T) { - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{ - "x-meta:", - " name: obj", - " value: field", - } - require.NoError(t, se.Parse(lines)) - require.NotNil(t, got) - val, ok := got["x-meta"] - require.TrueT(t, ok) - obj, ok := val.(map[string]any) - require.TrueT(t, ok) - assert.EqualT(t, "obj", obj["name"]) - assert.EqualT(t, "field", obj["value"]) - }) - - t.Run("multiple extensions", func(t *testing.T) { - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{ - "x-first: one", - "x-second: two", - } - require.NoError(t, se.Parse(lines)) - v1, ok := got.GetString("x-first") - require.TrueT(t, ok) - assert.EqualT(t, "one", v1) - v2, ok := got.GetString("x-second") - require.TrueT(t, ok) - assert.EqualT(t, "two", v2) - }) - - t.Run("nested object with key-value", func(t *testing.T) { - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{ - "x-nested:", - " outer:", - " inner-key: inner-value", - } - require.NoError(t, se.Parse(lines)) - require.NotNil(t, got) - val, ok := got["x-nested"] - require.TrueT(t, ok) - obj, ok := val.(map[string]any) - require.TrueT(t, ok) - outer, ok := obj["outer"].(map[string]any) - require.TrueT(t, ok) - assert.EqualT(t, "inner-value", outer["inner-key"]) - }) - - t.Run("object then back to simple extension", func(t *testing.T) { - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{ - "x-obj:", - " key1: val1", - " key2: val2", - "x-simple: value", - } - require.NoError(t, se.Parse(lines)) - require.NotNil(t, got) - - obj, ok := got["x-obj"] - require.TrueT(t, ok) - m, ok := obj.(map[string]any) - require.TrueT(t, ok) - assert.EqualT(t, "val1", m["key1"]) - assert.EqualT(t, "val2", m["key2"]) - - simple, ok := got.GetString("x-simple") - require.TrueT(t, ok) - assert.EqualT(t, "value", simple) - }) - - t.Run("nested object with sub-list", func(t *testing.T) { - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{ - "x-deep:", - " sub-list:", - " item1", - " item2", - " sub-key: sub-value", - } - require.NoError(t, se.Parse(lines)) - require.NotNil(t, got) - val, ok := got["x-deep"] - require.TrueT(t, ok) - obj, ok := val.(map[string]any) - require.TrueT(t, ok) - assert.NotNil(t, obj["sub-list"]) - assert.EqualT(t, "sub-value", obj["sub-key"]) - }) - - t.Run("array extension followed by object extension", func(t *testing.T) { - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{ - "x-list:", - " alpha", - " beta", - "x-map:", - " a: 1", - " b: 2", - } - require.NoError(t, se.Parse(lines)) - require.NotNil(t, got) - - list, ok := got["x-list"] - require.TrueT(t, ok) - arr, ok := list.([]string) - require.TrueT(t, ok) - assert.Equal(t, []string{"alpha", "beta"}, arr) - - m, ok := got["x-map"] - require.TrueT(t, ok) - obj, ok := m.(map[string]any) - require.TrueT(t, ok) - assert.EqualT(t, "1", obj["a"]) - }) -} - -// TestSetOpExtensions_Parse_Malformed exercises the defensive guards in -// buildExtensionObjects against malformed extension blocks. Each case -// covers a branch that would otherwise be silently skipped with no test -// witness — losing data without warning would be a nasty silent-bug class. -// The contract these tests pin: malformed lines never panic, never -// corrupt prior extensions, and are simply dropped from the output. -func TestSetOpExtensions_Parse_Malformed(t *testing.T) { - t.Parallel() - - t.Run("line with empty key is skipped", func(t *testing.T) { - // Covers the `if key == "" { return }` guard in buildExtensionObjects: - // a line whose colon-prefix produces an empty trimmed key (e.g. leading - // colon). Must not panic; nothing should land in the output. - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{":just-a-value"} - require.NoError(t, se.Parse(lines)) - assert.Empty(t, got) - }) - - t.Run("list item at top level with no prior extension is dropped", func(t *testing.T) { - // Covers `if stack == nil || len(*stack) == 0 { return }` in the - // `len(kv) <= 1` branch — a bare word (no colon) with no preceding - // x- extension to attach it to. - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{"orphan-item"} - require.NoError(t, se.Parse(lines)) - assert.Empty(t, got) - }) - - t.Run("non-x key:value at top level with no prior extension is dropped", func(t *testing.T) { - // Covers the second `if stack == nil || len(*stack) == 0 { return }` - // guard further down: a plain key:value line whose key isn't an x- - // extension and there's no open extension to nest under. - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{"plain-key: plain-value"} - require.NoError(t, se.Parse(lines)) - assert.Empty(t, got) - }) - - t.Run("valid extension followed by malformed lines keeps valid output", func(t *testing.T) { - // Composition check: a good x- extension followed by each malformed - // shape. The good extension must survive; the junk lines must be - // dropped without disturbing it. - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{ - "x-good: keeper", - ":empty-key", - "orphan-item", - "plain-key: plain-value", - } - require.NoError(t, se.Parse(lines)) - - val, ok := got.GetString("x-good") - require.TrueT(t, ok) - assert.EqualT(t, "keeper", val) - _, hasPlain := got["plain-key"] - assert.FalseT(t, hasPlain, "non-x- key must not leak into output") - }) -} diff --git a/internal/parsers/grammar/annotations.go b/internal/parsers/grammar/annotations.go new file mode 100644 index 0000000..55c4b16 --- /dev/null +++ b/internal/parsers/grammar/annotations.go @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +// AnnotationPrefix is the literal that introduces every codescan +// annotation header. Centralised so callers and tests reference the +// single source of truth rather than the bare literal. +// +// Round-1 stance: the prefix is fixed at "swagger:". A v2 design call +// (per .claude/plans/grammar/open-questions.md / project_v2_vision) +// may promote this to a configurable Option on the parser; making it a +// package-level constant today is the minimal scaffolding for that +// future change. +const AnnotationPrefix = "swagger:" + +// AnnotationKind identifies the top-level swagger: directive. +// Matches the ANN_* terminal vocabulary of 50-full.md §1.1. +type AnnotationKind int + +const ( + AnnUnknown AnnotationKind = iota + + AnnModel // swagger:model + AnnResponse // swagger:response + AnnParameters // swagger:parameters + AnnRoute // swagger:route + AnnOperation // swagger:operation + AnnMeta // swagger:meta + AnnStrfmt // swagger:strfmt + AnnAlias // swagger:alias + AnnName // swagger:name + AnnAllOf // swagger:allOf + AnnEnum // swagger:enum + AnnIgnore // swagger:ignore + AnnDefaultName // swagger:default — value-only classifier annotation + AnnType // swagger:type + AnnFile // swagger:file +) + +const ( + labelModel = "model" + labelResponse = "response" + labelParameters = "parameters" + labelRoute = "route" + labelOperation = "operation" + labelMeta = "meta" + labelStrfmt = "strfmt" + labelAlias = "alias" + labelName = "name" + labelAllOf = "allOf" + labelEnum = "enum" + labelIgnore = "ignore" + labelDefault = "default" + labelType = "type" + labelFile = "file" + labelUnknown = "unknown" +) + +// String renders an AnnotationKind as its source label. +func (a AnnotationKind) String() string { + switch a { + case AnnModel: + return labelModel + case AnnResponse: + return labelResponse + case AnnParameters: + return labelParameters + case AnnRoute: + return labelRoute + case AnnOperation: + return labelOperation + case AnnMeta: + return labelMeta + case AnnStrfmt: + return labelStrfmt + case AnnAlias: + return labelAlias + case AnnName: + return labelName + case AnnAllOf: + return labelAllOf + case AnnEnum: + return labelEnum + case AnnIgnore: + return labelIgnore + case AnnDefaultName: + return labelDefault + case AnnType: + return labelType + case AnnFile: + return labelFile + case AnnUnknown: + fallthrough + default: + return labelUnknown + } +} + +// AnnotationKindFromName resolves the swagger: label to its kind. +// Returns AnnUnknown for labels outside the v1-parity set. +func AnnotationKindFromName(name string) AnnotationKind { + switch name { + case labelModel: + return AnnModel + case labelResponse: + return AnnResponse + case labelParameters: + return AnnParameters + case labelRoute: + return AnnRoute + case labelOperation: + return AnnOperation + case labelMeta: + return AnnMeta + case labelStrfmt: + return AnnStrfmt + case labelAlias: + return AnnAlias + case labelName: + return AnnName + case labelAllOf: + return AnnAllOf + case labelEnum: + return AnnEnum + case labelIgnore: + return AnnIgnore + case labelDefault: + return AnnDefaultName + case labelType: + return AnnType + case labelFile: + return AnnFile + default: + return AnnUnknown + } +} + +// annotationFamily classifies an AnnotationKind into one of the four +// family sub-grammars. Used by the parser dispatcher. +type annotationFamily int + +const ( + familyUnknown annotationFamily = iota + familySchema + familyOperation + familyMeta + familyClassifier +) + +func (a AnnotationKind) family() annotationFamily { + switch a { + case AnnModel, AnnResponse, AnnParameters, + // swagger:name is a field-level rename that accepts the same + // validation-keyword body as a schema field (min length, pattern, + // required, etc. — see fixtures/.../classification/models for the + // canonical interface-method shape). v1 didn't distinguish; in + // grammar it dispatches through the schema parser to surface the + // body keywords as Properties rather than rejecting them as + // context-invalid under a classifier block. + AnnName: + return familySchema + case AnnRoute, AnnOperation: + return familyOperation + case AnnMeta: + return familyMeta + case AnnStrfmt, AnnAlias, AnnAllOf, AnnEnum, + AnnIgnore, AnnDefaultName, AnnType, AnnFile: + return familyClassifier + case AnnUnknown: + fallthrough + default: + return familyUnknown + } +} diff --git a/internal/parsers/grammar/ast.go b/internal/parsers/grammar/ast.go new file mode 100644 index 0000000..c02014e --- /dev/null +++ b/internal/parsers/grammar/ast.go @@ -0,0 +1,538 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "go/token" + "iter" + "strings" + + "github.com/go-openapi/codescan/internal/parsers/security" +) + +// Block is the interface every typed AST node implements. One Block +// corresponds to one Go comment group's parsed content. +// +// Per .claude/plans/grammar/50-full.md, the typed Block hierarchy +// matches the family productions: +// +// - SchemaBlock variants: ModelBlock, ResponseBlock, ParametersBlock +// - OperationFamilyBlock: RouteBlock, InlineOperationBlock +// - MetaBlock +// - ClassifierBlock variants for the nine classifier annotations +// - UnboundBlock for the no-annotation case +// +//nolint:interfacebloat // single consumer contract for builders + LSP. +type Block interface { + Pos() token.Position + Title() string + Description() string + Diagnostics() []Diagnostic + AnnotationKind() AnnotationKind + + Properties() iter.Seq[Property] + YAMLBlocks() iter.Seq[RawYAML] + Extensions() iter.Seq[Extension] + SecurityRequirements() []security.Requirement + Contact() (Contact, error) + License() (License, bool) + + // Walk dispatches properties / prose / extensions / diagnostics + // through the callbacks set on w. See walker.go for the contract. + Walk(w Walker) + + // ProseLines returns the cleaned prose lines (TITLE + DESC) in + // source order, with blank lines preserved as empty strings. + ProseLines() []string + + // PreambleLines returns prose lines that appear BEFORE the block's + // annotation. For UnboundBlock (no annotation), returns the same as + // ProseLines. v1's schema and meta builders consume only pre- + // annotation prose for title/description; routes/operations consult + // ProseLines() and observe post-annotation text too. + PreambleLines() []string + + // PreambleTitle / PreambleDescription return the + // title/description computed from PreambleLines (pre-annotation + // prose only). Schema's top-level model builder uses these to + // match v1's SectionedParser, which treated post-annotation prose + // as body content rather than title/description. + PreambleTitle() string + PreambleDescription() string + + // Prose returns the entire prose surface (TITLE + DESC tokens + // in source order) joined with "\n", with internal blanks + // preserved as paragraph breaks and a single trailing blank + // dropped. Equivalent to v1's JoinDropLast(ProseLines), but + // derived directly from the lexer-classified tokens so callers + // don't re-implement the join externally. + // + // Used by field-level callers (struct field / interface method + // docs) where the entire prose is the description and there's + // no separate title concept. + Prose() string + + Has(name string) bool + GetFloat(name string) (float64, bool) + GetInt(name string) (int64, bool) + GetBool(name string) (bool, bool) + GetString(name string) (string, bool) + GetList(name string) ([]string, bool) + + // AnnotationArg returns the first positional identifier argument + // of the block's primary annotation (e.g. "Pet" for + // `swagger:model Pet`, "date-time" for `swagger:strfmt + // date-time`, the IDENT_NAME for `swagger:name fooBar`). + // Returns ("", false) when the annotation has no arg or carries + // only an empty/whitespace one. Bare annotations (e.g. + // `swagger:model` without a name) return ("", false); callers + // distinguish "annotation present" via AnnotationKind(). + // + // Convergence accessor — replaces type-asserting on each typed + // Block kind to read its Name / Args[0] field. Used by Walker + // callbacks that don't care which classifier flavour they're + // looking at, only what its IDENT_NAME-style argument is. + AnnotationArg() (string, bool) +} + +// Property is one keyword:value (or keyword body) attached to a Block. +// +// For inline-value keywords (NumericValidation, StringValidation, …), +// Value is the raw string and Typed carries the lexically-typed form. +// +// For body keywords (RAW_BLOCK_*, RAW_VALUE_*), Body holds the +// accumulated body content, Raw holds the verbatim source content, +// and Typed.Type indicates the body shape (ShapeRawBlock / ShapeRawValue). +// +// ItemsDepth records the leading items.* depth from the keyword head. +type Property struct { + Keyword Keyword + Pos token.Position + Value string + Body string + Raw string + Typed TypedValue + ItemsDepth int +} + +// IsTyped reports whether the property carries a primitive-typed +// value the caller can consume directly without further coercion. +// +// True when Typed.Type is one of the primitive shapes — Number, +// Integer, Bool, EnumOption — meaning the matching Typed.* field is +// populated and authoritative. False otherwise: +// +// - ShapeNone — typing was not applied (ShapeString keywords like +// `pattern` keep the raw value in Property.Value), or typing +// failed and a diagnostic was emitted. +// - ShapeRawBlock / ShapeRawValue / ShapeCommaList / ShapeString — +// the value's interpretation depends on the resolved spec type +// (e.g. `default: 1.5` against a float-typed schema), so the +// consumer must coerce against schemaType+schemaFormat. +// +// Canonical use site: +// +// if p.IsTyped() { +// // read p.Typed. matching p.Keyword.Shape +// } else { +// // coerce p.Value against the resolved schema type +// } +// +// Replaces the explicit `switch p.Typed.Type` boilerplate consumers +// would otherwise need at every call site. +func (p Property) IsTyped() bool { + switch p.Typed.Type { + case ShapeNumber, ShapeInt, ShapeBool, ShapeEnumOption: + return true + case ShapeNone, ShapeString, ShapeCommaList, ShapeRawBlock, ShapeRawValue: + return false + default: + return false + } +} + +// TypedValue carries the lexer-recognised value shape. +// +// Op is the leading comparison operator stripped from a NumberValue +// ("<", "<=", ">", ">=", "="). Empty for non-Number values or when +// no operator was present. v1 accepts e.g. `maximum: <5`; the analyzer +// interprets Op + Number to decide inclusive vs exclusive semantics. +type TypedValue struct { + Type ValueShape + Op string + Number float64 + Integer int64 + Boolean bool + String string +} + +// RawYAML is one captured `--- … ---` body. The parser does not parse +// the YAML — it isolates the body so the analyzer can hand it to +// internal/parsers/yaml/. +type RawYAML struct { + Pos token.Position + Text string + Truncated bool +} + +// Extension is one x-* vendor entry under an extensions: block. +// +// Value carries the YAML-typed nested value (`bool` / `float64` / +// `string` / `[]any` / `map[string]any`). The parser pipes the body +// through `internal/parsers/yaml.TypedExtensions`, so consumers can +// rely on JSON-normalised types — never `map[any]any`. +type Extension struct { + Name string + Pos token.Position + Value any +} + +// baseBlock provides the fields and accessors common to every typed +// Block. Typed kinds embed *baseBlock and add per-annotation fields. +type baseBlock struct { + pos token.Position + title string + description string + proseLines []string + preambleLines []string + preambleTitle string + preambleDescription string + kind AnnotationKind + + properties []Property + yamlBlocks []RawYAML + extensions []Extension + security []security.Requirement + diagnostics []Diagnostic +} + +func (b *baseBlock) Pos() token.Position { return b.pos } +func (b *baseBlock) Title() string { return b.title } +func (b *baseBlock) Description() string { return b.description } +func (b *baseBlock) ProseLines() []string { return b.proseLines } +func (b *baseBlock) PreambleLines() []string { return b.preambleLines } +func (b *baseBlock) PreambleTitle() string { return b.preambleTitle } +func (b *baseBlock) PreambleDescription() string { return b.preambleDescription } + +// Prose joins the cached proseLines (TITLE + DESC + internal +// blanks, in source order) with "\n" and drops a single +// whitespace-only trailing line — JoinDropLast semantics. The +// trim and join happen on the cached slice; no extra parse work. +func (b *baseBlock) Prose() string { + lines := b.proseLines + if n := len(lines); n > 0 && strings.TrimSpace(lines[n-1]) == "" { + lines = lines[:n-1] + } + return strings.Join(lines, "\n") +} +func (b *baseBlock) Diagnostics() []Diagnostic { return b.diagnostics } +func (b *baseBlock) AnnotationKind() AnnotationKind { return b.kind } + +// AnnotationArg default — Block kinds whose annotation takes no +// identifier argument (UnboundBlock, MetaBlock, RouteBlock, +// InlineOperationBlock, ParametersBlock — the latter has multiple +// args but conceptually it's a list, not a single IDENT_NAME). +// Typed kinds with a single IDENT_NAME override this method. +func (b *baseBlock) AnnotationArg() (string, bool) { return "", false } + +func (b *baseBlock) Properties() iter.Seq[Property] { + return func(yield func(Property) bool) { + for _, p := range b.properties { + if !yield(p) { + return + } + } + } +} + +func (b *baseBlock) YAMLBlocks() iter.Seq[RawYAML] { + return func(yield func(RawYAML) bool) { + for _, y := range b.yamlBlocks { + if !yield(y) { + return + } + } + } +} + +func (b *baseBlock) Extensions() iter.Seq[Extension] { + return func(yield func(Extension) bool) { + for _, e := range b.extensions { + if !yield(e) { + return + } + } + } +} + +// SecurityRequirements returns the typed list of `Security:` +// requirements parsed at lex time from the block's `security:` raw +// body, or nil when no `security:` keyword appeared. Each entry is +// a single-key map from scheme name → scope list, mirroring the +// shape OAS v2 expects on `spec.Operation.Security`. +func (b *baseBlock) SecurityRequirements() []security.Requirement { + return b.security +} + +// Contact returns the typed Contact value parsed from the block's +// `contact:` inline keyword. Returns (Contact{}, nil) when no +// `contact:` appeared, or when its value is empty. A non-nil error +// signals a malformed `Name ` head — the caller decides +// whether to fail or warn. Parses on call. +func (b *baseBlock) Contact() (Contact, error) { + for _, p := range b.properties { + if p.Keyword.Name == KwContact { + return parseContact(p.Value) + } + } + return Contact{}, nil +} + +// License returns the typed License value parsed from the block's +// `license:` inline keyword, or (License{}, false) when no +// `license:` keyword appeared. Parses on call. +func (b *baseBlock) License() (License, bool) { + for _, p := range b.properties { + if p.Keyword.Name == KwLicense { + return parseLicense(p.Value) + } + } + return License{}, false +} + +func (b *baseBlock) Has(name string) bool { + _, ok := b.findProperty(name) + return ok +} + +func (b *baseBlock) GetFloat(name string) (float64, bool) { + p, ok := b.findProperty(name) + if !ok || p.Typed.Type != ShapeNumber { + return 0, false + } + return p.Typed.Number, true +} + +func (b *baseBlock) GetInt(name string) (int64, bool) { + p, ok := b.findProperty(name) + if !ok || p.Typed.Type != ShapeInt { + return 0, false + } + return p.Typed.Integer, true +} + +func (b *baseBlock) GetBool(name string) (bool, bool) { + p, ok := b.findProperty(name) + if !ok || p.Typed.Type != ShapeBool { + return false, false + } + return p.Typed.Boolean, true +} + +func (b *baseBlock) GetString(name string) (string, bool) { + p, ok := b.findProperty(name) + if !ok { + return "", false + } + if p.Typed.Type == ShapeEnumOption { + return p.Typed.String, true + } + return p.Value, true +} + +func (b *baseBlock) GetList(name string) ([]string, bool) { + p, ok := b.findProperty(name) + if !ok { + return nil, false + } + if p.Body != "" { + // Split on \n; drop the final empty element if Body ended with + // a trailing newline. + out := strings.Split(p.Body, "\n") + return out, true + } + if p.Keyword.Shape == ShapeCommaList && p.Value != "" { + return SplitCommaList(p.Value), true + } + return nil, false +} + +func (b *baseBlock) findProperty(name string) (Property, bool) { + for _, p := range b.properties { + if strings.EqualFold(p.Keyword.Name, name) { + return p, true + } + for _, alias := range p.Keyword.Aliases { + if strings.EqualFold(alias, name) { + return p, true + } + } + } + return Property{}, false +} + +// SplitCommaList comma-splits s, trims each entry, and drops empty +// results. The canonical splitter for `ShapeCommaList` keyword +// values — also exported for builder call sites that hold a raw +// Property.Value (e.g. the routes and meta dispatchers reading +// `schemes:`) without going through Block.GetList. +func SplitCommaList(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + t := strings.TrimSpace(p) + if t != "" { + out = append(out, t) + } + } + return out +} + +// --- Typed Block kinds ------------------------------------------------------- + +// ModelBlock is produced by `swagger:model [Name]`. +type ModelBlock struct { + *baseBlock + + Name string +} + +// AnnotationArg returns the IDENT_NAME arg (`Pet` for +// `swagger:model Pet`) or ("", false) for a bare `swagger:model`. +func (b *ModelBlock) AnnotationArg() (string, bool) { + if b.Name == "" { + return "", false + } + return b.Name, true +} + +// ResponseBlock is produced by `swagger:response [Name]`. +type ResponseBlock struct { + *baseBlock + + Name string +} + +// AnnotationArg returns the IDENT_NAME arg or ("", false) for a +// bare `swagger:response`. +func (b *ResponseBlock) AnnotationArg() (string, bool) { + if b.Name == "" { + return "", false + } + return b.Name, true +} + +// NameBlock is produced by `swagger:name `. The +// annotation overrides a struct field's / interface method's +// JSON property name (or schema member name) without mutating +// the surrounding Go identifier. Name carries the IDENT_NAME +// argument. +type NameBlock struct { + *baseBlock + + Name string +} + +// AnnotationArg returns the IDENT_NAME arg. Bare `swagger:name` +// is rejected at parse time (CodeMissingRequiredArg); ("", false) +// only appears on the diagnostic path. +func (b *NameBlock) AnnotationArg() (string, bool) { + if b.Name == "" { + return "", false + } + return b.Name, true +} + +// ParametersBlock is produced by `swagger:parameters T1 T2 …`. +type ParametersBlock struct { + *baseBlock + + OperationIDs []string +} + +// RouteBlock is produced by `swagger:route METHOD /path [tags] opID`. +// Per 21-operation-grammar.md, swagger:route's body is inline keyword +// raw-blocks and **never** an OPAQUE_YAML. +type RouteBlock struct { + *baseBlock + + Method string + Path string + Tags []string + OpID string +} + +// InlineOperationBlock is produced by `swagger:operation METHOD /path +// [tags] opID`. Body may carry an OPAQUE_YAML in addition to inline +// keyword raw-blocks. +type InlineOperationBlock struct { + *baseBlock + + Method string + Path string + Tags []string + OpID string +} + +// MetaBlock is produced by `swagger:meta`. +type MetaBlock struct { + *baseBlock +} + +// ClassifierBlock covers single-line classifier annotations: +// strfmt / name / allOf / ignore / alias / file / type / default. +// The Args slice carries the lexer-tokenised positional arguments. +type ClassifierBlock struct { + *baseBlock + + Args []Token +} + +// AnnotationArg returns the first positional argument's text when +// non-empty, mirroring v1's commentSubMatcher single-word capture. +// Bare classifier annotations (e.g. `swagger:ignore`, +// `swagger:alias`) return ("", false). +func (b *ClassifierBlock) AnnotationArg() (string, bool) { + if len(b.Args) == 0 { + return "", false + } + if b.Args[0].Text == "" { + return "", false + } + return b.Args[0].Text, true +} + +// EnumDeclBlock is produced by `swagger:enum [name] [values…]`. The +// optional multi-line value-list (per Q15) is carried in BodyValues +// when present (lexer accumulates the body as RAW_VALUE_ENUM). +type EnumDeclBlock struct { + *baseBlock + + Name string // "" when absent + InlineForm enumArgsForm + InlineArgs []Token // values fragment as a typed token, when inline values were given + BodyValues string // populated when a multi-line RAW_VALUE_ENUM body was present +} + +// AnnotationArg returns the IDENT_NAME arg (`status` for +// `swagger:enum status`) or ("", false) when the enum was +// declared inline-only (`swagger:enum red green blue`) or as a +// pure body block. +func (b *EnumDeclBlock) AnnotationArg() (string, bool) { + if b.Name == "" { + return "", false + } + return b.Name, true +} + +// UnboundBlock represents a comment group with no annotation line — +// e.g. a struct field's docstring. +type UnboundBlock struct { + *baseBlock +} + +// newBaseBlock initialises a baseBlock for the given annotation kind. +func newBaseBlock(kind AnnotationKind, pos token.Position) *baseBlock { + return &baseBlock{pos: pos, kind: kind} +} diff --git a/internal/parsers/grammar/diagnostic.go b/internal/parsers/grammar/diagnostic.go new file mode 100644 index 0000000..0fda16f --- /dev/null +++ b/internal/parsers/grammar/diagnostic.go @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "fmt" + "go/token" +) + +// Severity classifies a Diagnostic's seriousness. The parser never +// aborts; callers (analyzers, LSP, the CLI) decide policy by severity. +type Severity int + +const ( + SeverityError Severity = iota + SeverityWarning + SeverityHint +) + +// String renders a Severity for logs and CLI output. +func (s Severity) String() string { + switch s { + case SeverityError: + return "error" + case SeverityWarning: + return "warning" + case SeverityHint: + return "hint" + default: + return fmt.Sprintf("severity(%d)", int(s)) + } +} + +// Code is a stable identifier for a class of Diagnostic. +type Code string + +// Diagnostic codes. The `parse.*` prefix marks lexer/parser-level +// observations; `validate.*` marks semantic-validation observations +// emitted by the builder layer (typically through the +// internal/builders/validations package). +const ( + CodeInvalidNumber Code = "parse.invalid-number" + CodeInvalidInteger Code = "parse.invalid-integer" + CodeInvalidBoolean Code = "parse.invalid-boolean" + CodeInvalidEnumOption Code = "parse.invalid-enum-option" + CodeContextInvalid Code = "parse.context-invalid" + CodeInvalidExtension Code = "parse.invalid-extension-name" + // CodeInvalidYAMLExtensions fires when the body of an + // `extensions:` raw block fails YAML parsing. The block is + // skipped (no Extension entries emitted) and a warning is raised. + CodeInvalidYAMLExtensions Code = "parse.invalid-yaml-extensions" + CodeUnterminatedYAML Code = "parse.unterminated-yaml" + CodeInvalidAnnotation Code = "parse.invalid-annotation" + CodeInvalidTypeRef Code = "parse.invalid-type-ref" + CodeUnexpectedToken Code = "parse.unexpected-token" + CodeMalformedOperation Code = "parse.malformed-operation" + CodeMissingRequiredArg Code = "parse.missing-required-arg" + + // CodeShapeMismatch fires when a keyword is applied to a schema + // whose resolved Swagger type doesn't match the keyword's domain + // (e.g. `pattern: ^a$` on an integer field). Emitted by + // internal/builders/validations.IsLegalForType callers. + CodeShapeMismatch Code = "validate.shape-mismatch" + + // CodeAmbiguousEmbed fires when two embedded types of a parent + // struct (or struct embed-chains at the same depth) both promote + // a property with the same JSON name but different Go names. Go's + // own rule is to not promote such ambiguous fields; codescan + // currently emits a last-write-wins schema regardless. The + // diagnostic surfaces the case so authors can disambiguate. + CodeAmbiguousEmbed Code = "validate.ambiguous-embed" + + // CodeUnsupportedInSimpleSchema fires when the schema builder + // running in SimpleSchema mode produces an outcome that OAS v2 + // does not allow on a parameter/header (object type, $ref, allOf, + // properties, …). The diagnostic is emitted at exit and the + // target is reset to empty `{}` — honest over lossy. Reaching + // this code path typically means a non-body parameter or response + // header was typed as a struct or interface that the recognizer + // cascade couldn't reduce to a primitive. + CodeUnsupportedInSimpleSchema Code = "validate.unsupported-in-simple-schema" +) + +// Diagnostic is one observation about a comment block. +type Diagnostic struct { + Pos token.Position + Severity Severity + Code Code + Message string +} + +// String renders a Diagnostic in compiler-style one-line form. +func (d Diagnostic) String() string { + loc := d.Pos.String() + if loc == "-" || loc == "" { + loc = "" + } + return fmt.Sprintf("%s: %s: %s [%s]", loc, d.Severity, d.Message, d.Code) +} + +// Errorf builds a SeverityError Diagnostic with a formatted message. +func Errorf(pos token.Position, code Code, format string, args ...any) Diagnostic { + return Diagnostic{Pos: pos, Severity: SeverityError, Code: code, Message: fmt.Sprintf(format, args...)} +} + +// Warnf builds a SeverityWarning Diagnostic. +func Warnf(pos token.Position, code Code, format string, args ...any) Diagnostic { + return Diagnostic{Pos: pos, Severity: SeverityWarning, Code: code, Message: fmt.Sprintf(format, args...)} +} + +// Hintf builds a SeverityHint Diagnostic. +func Hintf(pos token.Position, code Code, format string, args ...any) Diagnostic { + return Diagnostic{Pos: pos, Severity: SeverityHint, Code: code, Message: fmt.Sprintf(format, args...)} +} diff --git a/internal/parsers/grammar/disambiguate.go b/internal/parsers/grammar/disambiguate.go new file mode 100644 index 0000000..06239bf --- /dev/null +++ b/internal/parsers/grammar/disambiguate.go @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "encoding/json" + "strings" + "unicode" + "unicode/utf8" +) + +// Per .claude/plans/grammar/40-lexer.md §6, value-shape dispatch lives +// in this module so the lexer (and analyzer) share one implementation. +// Productions stay context-free because the lexer emits already- +// disambiguated typed tokens. + +// classifyDefaultValue chooses between JSON_VALUE and RAW_VALUE for +// the argument of swagger:default. Per 23-classifier-grammar.md +// §"Value argument": try JsonValue first, fall back to RawValue. The +// quick check uses the leading character; full JSON validation +// confirms. +// +// Returns either TokenJSONValue or TokenRawValue. The Pos is left to +// the caller to set. +func classifyDefaultValue(s string) TokenKind { + trimmed := strings.TrimSpace(s) + if trimmed == "" { + return TokenRawValue + } + switch trimmed[0] { + case '"', '[', '{', '+', '-': + if isJSONValue(trimmed) { + return TokenJSONValue + } + return TokenRawValue + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': + if isJSONValue(trimmed) { + return TokenJSONValue + } + return TokenRawValue + } + switch trimmed { + case "true", "false", "null": + return TokenJSONValue + } + return TokenRawValue +} + +// isJSONValue reports whether s is a valid JSON literal. Uses the +// stdlib JSON decoder for correctness. +func isJSONValue(s string) bool { + dec := json.NewDecoder(strings.NewReader(s)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return false + } + if dec.More() { + return false + } + return true +} + +// enumArgsForm captures the four-step dispatch outcome from +// 23-classifier-grammar.md §"Disambiguation rule for EnumArgs". +type enumArgsForm int + +const ( + enumFormEmpty enumArgsForm = iota // no inline argument (multi-line body may follow) + enumFormBracketedOnly // EnumValuesOnly = EnumBracketedList + enumFormPlainOnly // EnumValuesOnly = EnumPlainList + enumFormNameOnly // EnumWithName, no values + enumFormNamePlusBracketed // EnumWithName + EnumBracketedList + enumFormNamePlusPlain // EnumWithName + EnumPlainList +) + +// classifyEnumArgs implements the four-step dispatch on the trim-stripped +// argument string after `swagger:enum`. Returns the form plus the name +// (if any) and the verbatim values fragment (if any). The fragment is +// further parsed by classifyEnumValueList. +func classifyEnumArgs(arg string) (form enumArgsForm, name, values string) { + s := strings.TrimSpace(arg) + if s == "" { + return enumFormEmpty, "", "" + } + if s[0] == '[' { + return enumFormBracketedOnly, "", s + } + identEnd := scanEnumIdentifier(s) + if identEnd == 0 { + return enumFormPlainOnly, "", s + } + rest := strings.TrimLeft(s[identEnd:], " \t") + if rest == "" { + return enumFormNameOnly, s[:identEnd], "" + } + if rest[0] == '[' { + return enumFormNamePlusBracketed, s[:identEnd], rest + } + return enumFormNamePlusPlain, s[:identEnd], rest +} + +// scanEnumIdentifier returns the byte length of a leading +// IdentifierName (per 10-shared.md): Letter followed by NameChar*. +// Stops at the first non-NameChar byte. Returns 0 if the leading +// character is not a letter. +func scanEnumIdentifier(s string) int { + r, size := utf8.DecodeRuneInString(s) + if size == 0 || !unicode.IsLetter(r) { + return 0 + } + i := size + for i < len(s) { + r, size = utf8.DecodeRuneInString(s[i:]) + if size == 0 { + break + } + if !isIdentifierContinue(r) { + break + } + i += size + } + return i +} + +// isIdentifierContinue matches NameChar from 10-shared.md: +// Letter | Digit | "_" | "-" | ".". +func isIdentifierContinue(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) || + r == '_' || r == '-' || r == '.' +} + +// enumValueListForm distinguishes the two surface forms. +// +//nolint:unused // full enum list values not wired yet +type enumValueListForm int + +//nolint:unused // full enum list values not wired yet +const ( + enumListPlain enumValueListForm = iota // comma-separated bare items + enumListBracketed // [ … ] +) + +// classifyEnumValueList decides whether values is a bracketed list or +// a plain list. The caller has already trim-stripped values; this +// function only inspects the leading character. +// +//nolint:unused // full enum list values not wired yet +func classifyEnumValueList(values string) enumValueListForm { + trimmed := strings.TrimSpace(values) + if strings.HasPrefix(trimmed, "[") { + return enumListBracketed + } + return enumListPlain +} + +// httpMethods is the closed vocabulary recognised as HTTP_METHOD. +// +//nolint:gochecknoglobals // closed-set lookup table. +var httpMethods = map[string]string{ + "get": "GET", + "post": "POST", + "put": "PUT", + "patch": "PATCH", + "head": "HEAD", + "delete": "DELETE", + "options": "OPTIONS", + "trace": "TRACE", +} + +// classifyHTTPMethod returns the canonical method name and true iff s +// is a recognised HTTP method (case-insensitive). +func classifyHTTPMethod(s string) (string, bool) { + canonical, ok := httpMethods[strings.ToLower(s)] + return canonical, ok +} + +// typeRefVocabulary is the closed type-reference vocabulary per +// 23-classifier-grammar.md §"Closed-vocabulary references". +// +//nolint:gochecknoglobals // closed-set lookup table. +var typeRefVocabulary = map[string]struct{}{ + "string": {}, + "integer": {}, + "number": {}, + "boolean": {}, + "array": {}, + "object": {}, + "file": {}, + "null": {}, +} + +// isTypeRef reports whether s is a recognised TYPE_REF terminal. +func isTypeRef(s string) bool { + _, ok := typeRefVocabulary[s] + return ok +} + +// looksLikeURLPath is a coarse check for the OperationArgs URL path +// position: must start with "/". Full RFC 3986 conformance is left to +// the analyzer / consumers; the lexer only needs to dispatch. +func looksLikeURLPath(s string) bool { + return strings.HasPrefix(s, "/") +} diff --git a/internal/parsers/grammar/doc.go b/internal/parsers/grammar/doc.go new file mode 100644 index 0000000..a4ca34d --- /dev/null +++ b/internal/parsers/grammar/doc.go @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package grammar implements the next-generation annotation parser +// for codescan, built directly against the layered grammar + lexer +// specification under .claude/plans/grammar/. +// +// It is sibling to internal/parsers/grammar so the two implementations +// can run side-by-side during the migration. Once grammar reaches +// parity, internal/parsers/grammar will be retired. +// +// Pipeline: +// +// *ast.CommentGroup +// │ +// ▼ +// Preprocess → []Line (comment-marker stripping) +// │ +// ▼ +// Lex → []Token (line classifier + body accumulator + prose classifier) +// │ +// ▼ +// Parse → Block (recursive-descent over token vocabulary) +// +// The Token vocabulary is defined in token.go and matches the §1 +// inventory of .claude/plans/grammar/50-full.md. See +// .claude/plans/grammar/40-lexer.md for the lexer contract and +// .claude/plans/grammar/00-overview.md for the dispatch table. +package grammar diff --git a/internal/parsers/grammar/fixtures_test.go b/internal/parsers/grammar/fixtures_test.go new file mode 100644 index 0000000..b1f4a9b --- /dev/null +++ b/internal/parsers/grammar/fixtures_test.go @@ -0,0 +1,812 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// fixturesTest_Petstore_PetModel mirrors fixtures/goparsing/petstore/models/pet.go +// — a swagger:model with title/description, multiple validations +// (required, pattern, min/maxLength), and several un-annotated fields. +func TestFixtures_Petstore_PetModel(t *testing.T) { + src := strings.TrimSpace(` +A Pet is the main product in the store. +It is used to describe the animals available in the store. + +swagger:model pet +`) + b := parseString(t, src) + mb, ok := b.(*ModelBlock) + require.True(t, ok) + assert.Equal(t, "pet", mb.Name) + assert.Equal(t, "A Pet is the main product in the store.", mb.Title()) + assert.Equal(t, "It is used to describe the animals available in the store.", mb.Description()) +} + +// TestFixtures_Petstore_PetField_RequiredAndPattern mirrors the Name +// field's docstring: required + pattern + min/maxLength, expressed via +// the alias forms ("minimum length", "maximum length") that the v1 +// keyword aliases support. +func TestFixtures_Petstore_PetField_RequiredAndPattern(t *testing.T) { + src := strings.TrimSpace(` +The name of the pet. + +required: true +pattern: \w[\w-]+ +minimum length: 3 +maximum length: 50 +`) + b := parseString(t, src) + ub, ok := b.(*UnboundBlock) + require.True(t, ok) + + required, ok := ub.GetBool("required") + require.True(t, ok) + assert.True(t, required) + + pat, ok := ub.GetString("pattern") + require.True(t, ok) + assert.Equal(t, `\w[\w-]+`, pat) + + minLen, ok := ub.GetInt("minLength") + require.True(t, ok) + assert.Equal(t, int64(3), minLen) + + maxLen, ok := ub.GetInt("maxLength") + require.True(t, ok) + assert.Equal(t, int64(50), maxLen) + + // Empty diagnostics — every keyword resolves cleanly. + for _, d := range ub.Diagnostics() { + assert.NotEqual(t, SeverityError, d.Severity, "unexpected error diagnostic: %s", d) + } +} + +// TestFixtures_Petstore_ItemsPrefix_NestedArrayValidation mirrors the +// PhotoURLs field in fixtures/goparsing/petstore/models/pet.go which +// uses `items pattern: \.(jpe?g|png)$` for per-item validation. +func TestFixtures_Petstore_ItemsPrefix_NestedArrayValidation(t *testing.T) { + src := strings.TrimSpace(` +The photo urls for the pet. + +items pattern: \.(jpe?g|png)$ +`) + b := parseString(t, src) + ub, ok := b.(*UnboundBlock) + require.True(t, ok) + + var found bool + for p := range ub.Properties() { + if p.Keyword.Name == "pattern" { + found = true + assert.Equal(t, 1, p.ItemsDepth) + assert.Equal(t, `\.(jpe?g|png)$`, p.Value) + } + } + assert.True(t, found) +} + +// TestFixtures_Petstore_ItemsPrefix_DeepNesting covers multiple levels +// of items.* prefix accumulation. +func TestFixtures_Petstore_ItemsPrefix_DeepNesting(t *testing.T) { + src := strings.TrimSpace(` +items.items.items.maxLength: 4 +`) + b := parseString(t, src) + for p := range b.Properties() { + assert.Equal(t, "maxLength", p.Keyword.Name) + assert.Equal(t, 3, p.ItemsDepth) + assert.Equal(t, int64(4), p.Typed.Integer) + } +} + +// TestFixtures_Petstore_ParametersBlock mirrors PetID with +// `swagger:parameters getPetById deletePet updatePet` — three +// operationID references. +func TestFixtures_Petstore_ParametersBlock(t *testing.T) { + src := strings.TrimSpace(` +A PetID parameter model. + +This is used for operations that want the ID of an pet in the path + +swagger:parameters getPetById deletePet updatePet +`) + b := parseString(t, src) + pb, ok := b.(*ParametersBlock) + require.True(t, ok) + assert.Equal(t, []string{"getPetById", "deletePet", "updatePet"}, pb.OperationIDs) +} + +// TestFixtures_Petstore_RouteBlock_GodocPrefixWithDeprecated covers the +// classic `// FooBar swagger:route ...` form plus an inline +// `Deprecated: true` and a `Responses:` raw block. +func TestFixtures_Petstore_RouteBlock_GodocPrefixWithDeprecated(t *testing.T) { + src := strings.TrimSpace(` +GetPets swagger:route GET /pets pets listPets + +Lists the pets known to the store. + +By default it will only lists pets that are available for sale. +This can be changed with the status flag. + +Deprecated: true +Responses: + + default: genericError + 200: []pet +`) + b := parseString(t, src) + rb, ok := b.(*RouteBlock) + require.True(t, ok) + assert.Equal(t, "GET", rb.Method) + assert.Equal(t, "/pets", rb.Path) + assert.Equal(t, []string{"pets"}, rb.Tags) + assert.Equal(t, "listPets", rb.OpID) + assert.Equal(t, "Lists the pets known to the store.", rb.Title()) + assert.Contains(t, rb.Description(), "available for sale") + + dep, ok := rb.GetBool("deprecated") + require.True(t, ok) + assert.True(t, dep) + + respLines, ok := rb.GetList("responses") + require.True(t, ok) + joined := strings.Join(respLines, "\n") + assert.Contains(t, joined, "default: genericError") + assert.Contains(t, joined, "200: []pet") +} + +// TestFixtures_OperationsAnnotation_YAMLBody mirrors +// fixtures/goparsing/classification/operations_annotation/operations.go's +// first operation: a YAML-fenced body holding parameters/responses. +func TestFixtures_OperationsAnnotation_YAMLBody(t *testing.T) { + src := strings.TrimSpace(` +swagger:operation GET /pets pets getPet + +List all pets + +--- +parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + type: integer + format: int32 +consumes: + - "application/json" + - "application/xml" +produces: + - "application/xml" + - "application/json" +responses: + "200": + description: An paged array of pets + default: + description: unexpected error +--- +`) + b := parseString(t, src) + ob, ok := b.(*InlineOperationBlock) + require.True(t, ok) + assert.Equal(t, "GET", ob.Method) + assert.Equal(t, "/pets", ob.Path) + assert.Equal(t, []string{"pets"}, ob.Tags) + assert.Equal(t, "getPet", ob.OpID) + // "List all pets" has no trailing punctuation and no internal + // blank, so heuristics 1/2/3 don't fire — heuristic 4 classifies + // the whole prose as Description. v1's helpers behaves the same + // way on the equivalent ProseLines slice. + assert.Empty(t, ob.Title()) + assert.Equal(t, "List all pets", ob.Description()) + + yamls := []RawYAML{} + for y := range ob.YAMLBlocks() { + yamls = append(yamls, y) + } + require.Len(t, yamls, 1) + assert.Contains(t, yamls[0].Text, "parameters:") + assert.Contains(t, yamls[0].Text, "responses:") + assert.False(t, yamls[0].Truncated) +} + +// TestFixtures_Meta_PetstoreV1 mirrors fixtures/goparsing/meta/v1/doc.go +// — the canonical meta block: prose, single-line keywords, raw blocks, +// extensions, info-extensions, security, security-definitions, with +// `swagger:meta` at the *bottom* (godoc convention). +func TestFixtures_Meta_PetstoreV1(t *testing.T) { + src := `Petstore API. + +the purpose of this application is to provide an application +that is using plain go code to define an API + +This should demonstrate all the possible comment annotations +that are available to turn go code into a fully compliant swagger 2.0 spec + +Terms Of Service: +there are no TOS at this moment, use at your own risk we take no responsibility + + Schemes: http, https + Host: localhost + BasePath: /v2 + Version: 0.0.1 + License: MIT http://opensource.org/licenses/MIT + Contact: John Doe http://john.doe.com + + Consumes: + - application/json + - application/xml + + Produces: + - application/json + - application/xml + + Extensions: + x-meta-value: value + x-meta-array: + - value1 + - value2 + + InfoExtensions: + x-info-value: value + + Security: + - api_key: + + SecurityDefinitions: + api_key: + type: apiKey + name: KEY + in: header + +swagger:meta` + b := parseString(t, src) + mb, ok := b.(*MetaBlock) + require.True(t, ok, "expected *MetaBlock, got %T", b) + assert.Equal(t, "Petstore API.", mb.Title()) + + // Single-line keywords. + host, ok := mb.GetString("host") + require.True(t, ok) + assert.Equal(t, "localhost", host) + + basePath, ok := mb.GetString("basePath") + require.True(t, ok) + assert.Equal(t, "/v2", basePath) + + version, ok := mb.GetString("version") + require.True(t, ok) + assert.Equal(t, "0.0.1", version) + + schemes, ok := mb.GetList("schemes") + require.True(t, ok) + assert.Equal(t, []string{"http", "https"}, schemes) + + // Raw blocks present. + consumes, ok := mb.GetList("consumes") + require.True(t, ok) + joined := strings.Join(consumes, "\n") + assert.Contains(t, joined, "application/json") + assert.Contains(t, joined, "application/xml") + + // Extensions: top-level x-* entries surfaced. + exts := []Extension{} + for e := range mb.Extensions() { + exts = append(exts, e) + } + assert.NotEmpty(t, exts) + hasXMeta := false + for _, e := range exts { + if e.Name == "x-meta-value" { + hasXMeta = true + assert.Equal(t, "value", e.Value) + } + } + assert.True(t, hasXMeta) + + // Security + securityDefinitions raw blocks present as Properties. + sec, ok := mb.GetList("security") + require.True(t, ok) + assert.Contains(t, strings.Join(sec, "\n"), "api_key") + + secDef, ok := mb.GetList("securityDefinitions") + require.True(t, ok) + assert.Contains(t, strings.Join(secDef, "\n"), "type: apiKey") +} + +// TestFixtures_Meta_TosKeywordVariants exercises the trailing-dot, +// alias spelling ("Terms Of Service" / "TermsOfService" / "tos") that +// the meta v3 / v4 fixtures show. +func TestFixtures_Meta_TosKeywordVariants(t *testing.T) { + cases := []string{ + "Terms Of Service:\nuse at your own risk\nswagger:meta", + "TermsOfService:\nuse at your own risk\nswagger:meta", + "tos:\nuse at your own risk\nswagger:meta", + } + for _, src := range cases { + b := parseString(t, src) + _, ok := b.(*MetaBlock) + require.True(t, ok, "expected MetaBlock for %q", src) + tos, ok := b.GetList("tos") + require.True(t, ok, "expected tos block to be present (%q)", src) + assert.Contains(t, strings.Join(tos, "\n"), "use at your own risk") + } +} + +// TestFixtures_Petstore_ResponseBlock with body marker. +func TestFixtures_Petstore_ResponseBlock(t *testing.T) { + src := strings.TrimSpace(` +A GenericError is the default error message that is generated. +For certain status codes there are more appropriate error structures. + +swagger:response genericError +`) + b := parseString(t, src) + rb, ok := b.(*ResponseBlock) + require.True(t, ok) + assert.Equal(t, "genericError", rb.Name) + assert.Equal(t, "A GenericError is the default error message that is generated.", rb.Title()) + assert.Equal(t, "For certain status codes there are more appropriate error structures.", rb.Description()) +} + +// TestFixtures_Petstore_StrfmtAnnotation_FieldLevel mirrors the +// Birthday field's strfmt tag. +func TestFixtures_Petstore_StrfmtAnnotation_FieldLevel(t *testing.T) { + src := strings.TrimSpace(` +The pet's birthday + +swagger:strfmt date +`) + b := parseString(t, src) + cb, ok := b.(*ClassifierBlock) + require.True(t, ok) + assert.Equal(t, AnnStrfmt, cb.AnnotationKind()) + require.Len(t, cb.Args, 1) + assert.Equal(t, "date", cb.Args[0].Text) + assert.Equal(t, TokenIdentName, cb.Args[0].Kind) +} + +// TestFixtures_Petstore_ParameterIn covers the `in:` keyword inside an +// UnboundBlock (a parameter struct field). +func TestFixtures_Petstore_ParameterIn(t *testing.T) { + src := strings.TrimSpace(` +The ID of the pet + +in: path +required: true +`) + b := parseString(t, src) + in, ok := b.GetString("in") + require.True(t, ok) + assert.Equal(t, "path", in) + + required, ok := b.GetBool("required") + require.True(t, ok) + assert.True(t, required) +} + +// TestFixtures_AllowedHTTPMethods covers the closed HTTP method +// vocabulary inspired by fixtures/enhancements/all-http-methods. +func TestFixtures_AllowedHTTPMethods(t *testing.T) { + methods := []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE"} + for _, m := range methods { + src := "swagger:route " + m + " /thing tag op" + m + b := parseString(t, src) + rb, ok := b.(*RouteBlock) + require.True(t, ok, "expected RouteBlock for method %q", m) + assert.Equal(t, m, rb.Method) + assert.Empty(t, b.Diagnostics(), "method %q produced diagnostics: %v", m, b.Diagnostics()) + } +} + +// TestFixtures_AllowedHTTPMethods_LowercaseNormalised checks +// case-insensitive HTTP method matching. +func TestFixtures_AllowedHTTPMethods_LowercaseNormalised(t *testing.T) { + b := parseString(t, "swagger:route get /pets pets listPets") + rb, ok := b.(*RouteBlock) + require.True(t, ok) + assert.Equal(t, "GET", rb.Method, "method canonical form is upper case") +} + +// TestFixtures_GodocLinterTrailingDot covers the trailing-dot elision +// the godot linter triggers across annotation lines. +func TestFixtures_GodocLinterTrailingDot(t *testing.T) { + cases := []struct { + src string + want string + annKind AnnotationKind + argCount int + }{ + {"swagger:strfmt uuid.", "uuid", AnnStrfmt, 1}, + {"swagger:meta.", "", AnnMeta, 0}, + {"swagger:model Pet.", "Pet", AnnModel, 1}, + } + for _, tc := range cases { + b := parseString(t, tc.src) + assert.EqualT(t, tc.annKind, b.AnnotationKind(), "in: %s", tc.src) + switch tc.annKind { + case AnnStrfmt: + cb, ok := b.(*ClassifierBlock) + require.TrueT(t, ok) + require.Len(t, cb.Args, tc.argCount) + assert.Equal(t, tc.want, cb.Args[0].Text) + case AnnModel: + mb, ok := b.(*ModelBlock) + require.TrueT(t, ok) + assert.Equal(t, tc.want, mb.Name) + case AnnMeta: + _, ok := b.(*MetaBlock) + require.True(t, ok) + default: + require.FailNow(t, "test configuration error: missing assertion") + } + } +} + +// TestFixtures_CRLFNormalisation ensures \r\n line endings produce the +// same token stream as \n. +func TestFixtures_CRLFNormalisation(t *testing.T) { + const src = "swagger:model Pet\r\nrequired: true\r\nmaxLength: 5" + + b := parseString(t, src) + mb, ok := b.(*ModelBlock) + require.True(t, ok) + assert.Equal(t, "Pet", mb.Name) + + required, ok := mb.GetBool("required") + require.True(t, ok) + assert.True(t, required) + + maxLen, ok := mb.GetInt("maxLength") + require.True(t, ok) + assert.Equal(t, int64(5), maxLen) +} + +// TestFixtures_ComparisonOperatorOnNumber covers `maximum: <5` v1 form. +func TestFixtures_ComparisonOperatorOnNumber(t *testing.T) { + src := strings.TrimSpace(` +swagger:model Foo + +maximum: <=10 +minimum: >0 +`) + b := parseString(t, src) + for p := range b.Properties() { + switch p.Keyword.Name { + case "maximum": + assert.Equal(t, "<=", p.Typed.Op) + assert.InDelta(t, 10.0, p.Typed.Number, 0) + case "minimum": + assert.Equal(t, ">", p.Typed.Op) + assert.InDelta(t, 0.0, p.Typed.Number, 0) + } + } +} + +// TestFixtures_AllOf_OptionalClassName covers swagger:allOf with and +// without the polymorphic class name (mirrors the docs examples in +// 23-classifier-grammar.md). +func TestFixtures_AllOf_OptionalClassName(t *testing.T) { + bare := parseString(t, "swagger:allOf") + cbBare, ok := bare.(*ClassifierBlock) + require.True(t, ok) + assert.Empty(t, cbBare.Args) + assert.Empty(t, bare.Diagnostics()) + + named := parseString(t, "swagger:allOf Animal") + cbNamed, ok := named.(*ClassifierBlock) + require.True(t, ok) + require.Len(t, cbNamed.Args, 1) + assert.Equal(t, "Animal", cbNamed.Args[0].Text) +} + +// TestFixtures_DefaultAnnotation_JSONForms covers the JsonValue branch +// (objects, arrays, numbers, booleans) and the RawValue fallback. +func TestFixtures_DefaultAnnotation_JSONForms(t *testing.T) { + jsonCases := []string{ + `swagger:default {"limit": 10, "offset": 0}`, + `swagger:default [1,2,3]`, + `swagger:default 42`, + `swagger:default true`, + `swagger:default null`, + `swagger:default "literal-string"`, + } + for _, src := range jsonCases { + b := parseString(t, src) + cb, ok := b.(*ClassifierBlock) + require.True(t, ok, "expected classifier for %q, got %T", src, b) + require.Len(t, cb.Args, 1, src) + assert.Equal(t, TokenJSONValue, cb.Args[0].Kind, "%q", src) + } + + // Bare ident → falls back to RAW_VALUE. + rawSrc := "swagger:default high" + b := parseString(t, rawSrc) + cb, ok := b.(*ClassifierBlock) + require.TrueT(t, ok) + require.Len(t, cb.Args, 1) + assert.Equal(t, TokenRawValue, cb.Args[0].Kind) +} + +// TestFixtures_EnumDecl_BracketedHybrid covers the hybrid-list example +// from 23-classifier-grammar.md ("a, {x:1}, c, [1,2,3], …"). +func TestFixtures_EnumDecl_BracketedHybrid(t *testing.T) { + b := parseString(t, `swagger:enum my_enum [a, {"x":1, "y":[1,2,3]}, c, [1,2,3], ["u","v"]]`) + eb, ok := b.(*EnumDeclBlock) + require.True(t, ok) + assert.Equal(t, "my_enum", eb.Name) + assert.Equal(t, enumFormNamePlusBracketed, eb.InlineForm) + require.Len(t, eb.InlineArgs, 1) + assert.Equal(t, TokenJSONValue, eb.InlineArgs[0].Kind) +} + +// TestFixtures_EnumDecl_MultilineBody backports the Q15 multi-line +// value-list body shape on swagger:enum. +func TestFixtures_EnumDecl_MultilineBody(t *testing.T) { + src := strings.TrimSpace(` +swagger:enum Priority + +enum: + - low + - medium + - high +`) + b := parseString(t, src) + eb, ok := b.(*EnumDeclBlock) + require.True(t, ok) + assert.Equal(t, "Priority", eb.Name) + assert.NotEmpty(t, eb.BodyValues) + assert.Contains(t, eb.BodyValues, "low") + assert.Contains(t, eb.BodyValues, "medium") + assert.Contains(t, eb.BodyValues, "high") +} + +// TestFixtures_BookingMeta_LeadingAnnotation covers the +// `swagger:meta` placed at the top of the comment group. +func TestFixtures_BookingMeta_LeadingAnnotation(t *testing.T) { + src := strings.TrimSpace(` +swagger:meta + +Schemes: http, https +Host: api.example.com +BasePath: /v2 +Version: 1.4.0 +License: MIT https://opensource.org/licenses/MIT +Contact: API Team team@example.com +`) + b := parseString(t, src) + mb, ok := b.(*MetaBlock) + require.True(t, ok) + + assert.Equal(t, AnnMeta, mb.AnnotationKind()) + + v, ok := mb.GetString("version") + require.True(t, ok) + assert.Equal(t, "1.4.0", v) +} + +// TestFixtures_OperationDeprecatedAndExternalDocs covers the cross- +// over deprecated keyword + externalDocs raw block under an inline +// operation block. +func TestFixtures_OperationDeprecatedAndExternalDocs(t *testing.T) { + src := strings.TrimSpace(` +swagger:operation GET /pets pets listPets + +Lists pets. + +deprecated: true +externalDocs: + description: User Guide + url: https://example.com/docs +`) + b := parseString(t, src) + ob, ok := b.(*InlineOperationBlock) + require.True(t, ok) + + dep, ok := ob.GetBool("deprecated") + require.True(t, ok) + assert.True(t, dep) + + docs, ok := ob.GetList("externalDocs") + require.True(t, ok) + joined := strings.Join(docs, "\n") + assert.Contains(t, joined, "description: User Guide") + assert.Contains(t, joined, "url: https://example.com/docs") +} + +// TestFixtures_DecorativeYAMLFenceInExtensions confirms decorative +// `--- … ---` fences around an extensions: body produce byte-identical +// behaviour with and without the fences (the v1 quirk noted in +// 10-shared.md and 40-lexer.md §5). +func TestFixtures_DecorativeYAMLFenceInExtensions(t *testing.T) { + withFence := strings.TrimSpace(` +swagger:meta + +Extensions: +--- +x-foo: bar +x-baz: 1 +--- +`) + withoutFence := strings.TrimSpace(` +swagger:meta + +Extensions: +x-foo: bar +x-baz: 1 +`) + + b1 := parseString(t, withFence) + b2 := parseString(t, withoutFence) + + exts1 := collectExtensionsAsMap(b1) + exts2 := collectExtensionsAsMap(b2) + assert.Equal(t, exts2, exts1, "decorative fence should be transparent") +} + +// TestFixtures_BlockBodyCrossover_KeywordsAcrossSiblings confirms +// `Consumes:` and `Produces:` consecutive raw blocks each terminate at +// the next sibling structural keyword, not at blank lines. +func TestFixtures_BlockBodyCrossover_KeywordsAcrossSiblings(t *testing.T) { + src := strings.TrimSpace(` +swagger:meta + +Consumes: +- application/json + +- application/xml + +Produces: +- application/json +`) + b := parseString(t, src) + cons, ok := b.GetList("consumes") + require.True(t, ok) + consJoined := strings.Join(cons, "\n") + assert.Contains(t, consJoined, "application/json") + assert.Contains(t, consJoined, "application/xml", "blank lines do not terminate a raw block body") + + prod, ok := b.GetList("produces") + require.True(t, ok) + assert.Contains(t, strings.Join(prod, "\n"), "application/json") +} + +// TestFixtures_FullPipeline_FromCommentGroup exercises the public +// Parse(*ast.CommentGroup, *token.FileSet) entry — same as scanner does. +func TestFixtures_FullPipeline_FromCommentGroup(t *testing.T) { + src := `package fake + +// A Pet is the main product in the store. +// +// swagger:model pet +type Pet struct { + // The name of the pet. + // + // required: true + // pattern: \w+ + Name string ` + "`json:\"name\"`" + ` +} +` + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "fake.go", src, parser.ParseComments) + require.NoError(t, err) + + // Find each declared type's documentation comment and parse it. + var got []Block + ast.Inspect(file, func(n ast.Node) bool { + switch d := n.(type) { + case *ast.GenDecl: + if d.Doc != nil { + got = append(got, Parse(d.Doc, fset)) + } + case *ast.Field: + if d.Doc != nil { + got = append(got, Parse(d.Doc, fset)) + } + } + return true + }) + + require.GreaterOrEqual(t, len(got), 2) + mb, ok := got[0].(*ModelBlock) + require.True(t, ok, "expected first decl to be ModelBlock, got %T", got[0]) + assert.Equal(t, "pet", mb.Name) + + ub, ok := got[1].(*UnboundBlock) + require.True(t, ok, "expected field doc to be UnboundBlock, got %T", got[1]) + required, ok := ub.GetBool("required") + require.True(t, ok) + assert.True(t, required) + pat, ok := ub.GetString("pattern") + require.True(t, ok) + assert.Equal(t, `\w+`, pat) +} + +// TestFixtures_CrossSchemeKeyword_Schemes confirms the `schemes:` +// keyword is legal under meta, route, and operation but warns +// elsewhere (e.g. under model). +func TestFixtures_CrossSchemeKeyword_Schemes(t *testing.T) { + cases := []struct { + src string + wantWarn bool + comment string + }{ + {"swagger:meta\n\nSchemes: http", false, "meta"}, + {"swagger:route GET /pets pets listPets\n\nSchemes: http", false, "route"}, + {"swagger:operation GET /pets pets listPets\n\nSchemes: http", false, "operation"}, + {"swagger:model Foo\n\nSchemes: http", true, "model"}, + } + for _, tc := range cases { + b := parseString(t, tc.src) + hasContextWarn := false + for _, d := range b.Diagnostics() { + if d.Code == CodeContextInvalid { + hasContextWarn = true + } + } + if tc.wantWarn { + assert.True(t, hasContextWarn, "%s: expected context-invalid diagnostic", tc.comment) + } else { + assert.False(t, hasContextWarn, "%s: unexpected context-invalid diagnostic", tc.comment) + } + } +} + +// TestFixtures_StrfmtKeywordAlias_MaxLen confirms that the alias +// "max len" / "max-len" / "maxLen" are equivalent to "maxLength". +func TestFixtures_StrfmtKeywordAlias_MaxLen(t *testing.T) { + for _, alias := range []string{"max len", "max-len", "maxLen", "maximum length", "maximumLength"} { + src := alias + ": 42" + b := parseString(t, src) + v, ok := b.GetInt("maxLength") + require.True(t, ok, "alias %q did not resolve to maxLength", alias) + assert.Equal(t, int64(42), v) + } +} + +// TestFixtures_DecimalNumberValue_Roundtrip confirms NUMBER_VALUE +// parses signed/unsigned decimals and fractional values. +func TestFixtures_DecimalNumberValue_Roundtrip(t *testing.T) { + cases := []struct { + src string + op string + want float64 + }{ + {"maximum: 10", "", 10}, + {"maximum: 3.14", "", 3.14}, + {"maximum: <-1", "<", -1}, + {"maximum: >=2.5", ">=", 2.5}, + } + for _, tc := range cases { + b := parseString(t, tc.src) + var found bool + for p := range b.Properties() { + if p.Keyword.Name == "maximum" { + found = true + assert.Equal(t, tc.op, p.Typed.Op) + assert.InDelta(t, tc.want, p.Typed.Number, 1e-9) + } + } + assert.True(t, found, "%q produced no maximum property", tc.src) + } +} + +// collectExtensionsAsMap turns the iter.Seq[Extension] into a map for +// equality comparison. Extension.Value is YAML-typed (`any`); callers +// that only care about presence/equality compare on the typed value. +func collectExtensionsAsMap(b Block) map[string]any { + out := map[string]any{} + for e := range b.Extensions() { + out[e.Name] = e.Value + } + return out +} diff --git a/internal/parsers/grammar/keywords.go b/internal/parsers/grammar/keywords.go new file mode 100644 index 0000000..bfcba4e --- /dev/null +++ b/internal/parsers/grammar/keywords.go @@ -0,0 +1,344 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import "strings" + +// ValueShape names the lexical shape of a keyword's value, mapping +// directly onto the value terminals of 50-full.md §1.4 / §1.5: +// +// - ShapeNumber → NUMBER_VALUE +// - ShapeInt → INT_VALUE +// - ShapeBool → BOOL_VALUE +// - ShapeString → STRING_VALUE +// - ShapeCommaList → COMMA_LIST_VALUE +// - ShapeEnumOption → ENUM_OPTION_VALUE (Values lists the closed set) +// - ShapeRawBlock → RAW_BLOCK_ (multi-line body terminal) +// - ShapeRawValue → RAW_VALUE_ (multi-line OR single-line body terminal) +type ValueShape int + +const ( + ShapeNone ValueShape = iota + ShapeNumber + ShapeInt + ShapeBool + ShapeString + ShapeCommaList + ShapeEnumOption + ShapeRawBlock + ShapeRawValue +) + +// String renders a ValueShape for diagnostics. +func (v ValueShape) String() string { + switch v { + case ShapeNumber: + return "number" + case ShapeInt: + return "integer" + case ShapeBool: + return "boolean" + case ShapeString: + return "string" + case ShapeCommaList: + return "comma-list" + case ShapeEnumOption: + return "enum-option" + case ShapeRawBlock: + return "raw-block" + case ShapeRawValue: + return "raw-value" + case ShapeNone: + fallthrough + default: + return "none" + } +} + +// IsBody reports whether the keyword's value is a multi-line body +// terminal (RAW_BLOCK_* or RAW_VALUE_*). Body keywords trigger the +// lexer's body accumulator. +func (v ValueShape) IsBody() bool { + return v == ShapeRawBlock || v == ShapeRawValue +} + +// Keyword describes one recognisable keyword: form. +type Keyword struct { + Name string + Aliases []string + Shape ValueShape + Values []string // populated when Shape == ShapeEnumOption + // Contexts is the set of family contexts the keyword is legal in. + // Used by the parser layer for non-fatal context-invalid warnings. + Contexts []KeywordContext +} + +// KeywordContext is a family-level context where a keyword is legal. +// Distinct from AnnotationKind because validations are legal across +// several annotation kinds (model + parameters + response, etc.). +type KeywordContext int + +const ( + CtxParam KeywordContext = iota + CtxHeader + CtxSchema + CtxItems + CtxRoute + CtxOperation + CtxMeta + CtxResponse +) + +// String renders a KeywordContext for diagnostics. +func (c KeywordContext) String() string { + switch c { + case CtxParam: + return "param" + case CtxHeader: + return "header" + case CtxSchema: + return "schema" + case CtxItems: + return "items" + case CtxRoute: + return "route" + case CtxOperation: + return "operation" + case CtxMeta: + return "meta" + case CtxResponse: + return "response" + default: + return "?" + } +} + +// keyword constructs a Keyword via functional options. +func keyword(name string, opts ...keywordOpt) Keyword { + kw := Keyword{Name: name} + for _, o := range opts { + o(&kw) + } + return kw +} + +type keywordOpt func(*Keyword) + +func aka(names ...string) keywordOpt { + return func(kw *Keyword) { kw.Aliases = append(kw.Aliases, names...) } +} + +func asNumber() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeNumber } } +func asInt() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeInt } } +func asBool() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeBool } } +func asString() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeString } } +func asCommaList() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeCommaList } } +func asRawBlock() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeRawBlock } } +func asRawValue() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeRawValue } } + +func asEnumOption(values ...string) keywordOpt { + return func(kw *Keyword) { + kw.Shape = ShapeEnumOption + kw.Values = values + } +} + +func ctx(ctxs ...KeywordContext) keywordOpt { + return func(kw *Keyword) { kw.Contexts = append(kw.Contexts, ctxs...) } +} + +// Canonical keyword names. These constants are the single source of +// truth for spelling: every Property's Keyword.Name compares equal to +// exactly one of them. Consumers that switch on Keyword.Name should +// reference these constants rather than re-declaring the strings — +// schema/walker.go and the bridge dispatchers in routes/parameters/ +// responses/operations/items/spec all dispatch on these names. +// +// Sections (numeric validations / length validations / schema +// decorators / boolean markers / param-location / meta single-line / +// raw-block) follow the same order as the keywords table below. +const ( + KwMaximum = "maximum" + KwMinimum = "minimum" + KwMultipleOf = "multipleOf" + KwMaxLength = "maxLength" + KwMinLength = "minLength" + KwPattern = "pattern" + KwMaxItems = "maxItems" + KwMinItems = "minItems" + KwUnique = "unique" + KwCollectionFormat = "collectionFormat" + KwDefault = "default" + KwExample = "example" + KwEnum = "enum" + KwRequired = "required" + KwReadOnly = "readOnly" + KwDiscriminator = "discriminator" + KwDeprecated = "deprecated" + KwIn = "in" + KwSchemes = "schemes" + KwVersion = "version" + KwHost = "host" + KwBasePath = "basePath" + KwLicense = "license" + KwContact = "contact" + KwConsumes = "consumes" + KwProduces = "produces" + KwSecurity = "security" + KwSecurityDefinitions = "securityDefinitions" + KwResponses = "responses" + KwParameters = "parameters" + KwExtensions = "extensions" + KwInfoExtensions = "infoExtensions" + KwTOS = "tos" + KwExternalDocs = "externalDocs" +) + +// keywords is the authoritative table. Additions land here. +// +//nolint:gochecknoglobals // canonical lookup table — see godoc on Lookup. +var keywords = []Keyword{ + // Numeric validations. + keyword(KwMaximum, aka("max"), asNumber(), ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + keyword(KwMinimum, aka("min"), asNumber(), ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + keyword(KwMultipleOf, + aka("multiple of", "multiple-of"), + asNumber(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + + // String-length validations. + keyword(KwMaxLength, + aka("max length", "max-length", "maxLen", "max len", "max-len", + "maximum length", "maximum-length", "maximumLength", + "maximum len", "maximum-len"), + asInt(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + keyword(KwMinLength, + aka("min length", "min-length", "minLen", "min len", "min-len", + "minimum length", "minimum-length", "minimumLength", + "minimum len", "minimum-len"), + asInt(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + keyword(KwPattern, + asString(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + + // Array validations. + keyword(KwMaxItems, + aka("max items", "max-items", "max.items", + "maximum items", "maximum-items", "maximumItems"), + asInt(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + keyword(KwMinItems, + aka("min items", "min-items", "min.items", + "minimum items", "minimum-items", "minimumItems"), + asInt(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + keyword(KwUnique, + asBool(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + keyword(KwCollectionFormat, + aka("collection format", "collection-format"), + asEnumOption("csv", "ssv", "tsv", "pipes", "multi"), + ctx(CtxParam, CtxHeader, CtxItems)), + + // Schema decorators with body-accepting bodies (default/example/enum). + keyword(KwDefault, + asRawValue(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + keyword(KwExample, + asRawValue(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + keyword(KwEnum, + asRawValue(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + + // Field-level boolean markers. + keyword(KwRequired, asBool(), ctx(CtxParam, CtxSchema)), + keyword(KwReadOnly, + aka("read only", "read-only"), + asBool(), + ctx(CtxSchema)), + keyword(KwDiscriminator, asBool(), ctx(CtxSchema)), + keyword(KwDeprecated, asBool(), ctx(CtxOperation, CtxRoute, CtxSchema)), + + // Parameter-location directive. Not part of the formal schema body + // grammar (see 20-schema-grammar.md "in: is not a schema-grammar + // production"). Recognised here so the lexer can hand a typed + // token to the parameters dispatch path; the schema parser treats + // it as a context-invalid warning when seen outside that path. + keyword(KwIn, + asEnumOption("query", "path", "header", "body", "formData"), + ctx(CtxParam)), + + // Meta single-line keywords. + keyword(KwSchemes, + asCommaList(), + ctx(CtxMeta, CtxRoute, CtxOperation)), + keyword(KwVersion, asString(), ctx(CtxMeta)), + keyword(KwHost, asString(), ctx(CtxMeta)), + keyword(KwBasePath, + aka("base path", "base-path"), + asString(), + ctx(CtxMeta)), + keyword(KwLicense, asString(), ctx(CtxMeta)), + keyword(KwContact, + aka("contact info", "contact-info"), + asString(), + ctx(CtxMeta)), + + // Multi-line raw-block keywords. + keyword(KwConsumes, asRawBlock(), ctx(CtxMeta, CtxRoute, CtxOperation)), + keyword(KwProduces, asRawBlock(), ctx(CtxMeta, CtxRoute, CtxOperation)), + keyword(KwSecurity, asRawBlock(), ctx(CtxMeta, CtxRoute, CtxOperation)), + keyword(KwSecurityDefinitions, + aka("security definitions", "security-definitions"), + asRawBlock(), + ctx(CtxMeta)), + keyword(KwResponses, asRawBlock(), ctx(CtxRoute, CtxOperation)), + keyword(KwParameters, asRawBlock(), ctx(CtxRoute, CtxOperation)), + keyword(KwExtensions, + asRawBlock(), + ctx(CtxMeta, CtxRoute, CtxOperation, CtxSchema, CtxParam, CtxHeader)), + keyword(KwInfoExtensions, + aka("info extensions", "info-extensions"), + asRawBlock(), + ctx(CtxMeta)), + keyword(KwTOS, + aka("terms of service", "terms-of-service", "termsOfService"), + asRawBlock(), + ctx(CtxMeta)), + keyword(KwExternalDocs, + aka("external docs", "external-docs"), + asRawBlock(), + ctx(CtxMeta, CtxRoute, CtxOperation, CtxSchema)), +} + +// Lookup returns the Keyword matching name (canonical or alias), +// case-insensitive on the canonical/alias spellings. The second +// return value reports whether a match was found. +func Lookup(name string) (Keyword, bool) { + needle := strings.ToLower(strings.TrimSpace(name)) + if needle == "" { + return Keyword{}, false + } + for _, kw := range keywords { + if strings.EqualFold(kw.Name, needle) { + return kw, true + } + for _, alias := range kw.Aliases { + if strings.EqualFold(alias, needle) { + return kw, true + } + } + } + return Keyword{}, false +} + +// Keywords returns a copy of the authoritative table. +func Keywords() []Keyword { + out := make([]Keyword, len(keywords)) + copy(out, keywords) + return out +} diff --git a/internal/parsers/grammar/lexer.go b/internal/parsers/grammar/lexer.go new file mode 100644 index 0000000..034530c --- /dev/null +++ b/internal/parsers/grammar/lexer.go @@ -0,0 +1,1208 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "go/token" + "slices" + "strconv" + "strings" + "unicode" + "unicode/utf8" +) + +// Lex turns a preprocessed []Line into the token stream consumed by +// the grammar parser. Pipeline: +// +// 1. Line classifier (lexLine): per-line classification into raw +// Tokens (annotation / keyword / fence / blank / text). +// 2. Body accumulator: folds multi-line bodies (OPAQUE_YAML, RAW_BLOCK, +// RAW_VALUE) into single body tokens. +// 3. Prose classifier: re-types surviving text tokens as TITLE / DESC. +// +// The output stream ends in a single TokenEOF. +// +// See .claude/plans/grammar/40-lexer.md for the contract. +func Lex(lines []Line) []Token { + raw := classifyLines(lines) + bodied := accumulateBodies(raw) + return classifyProse(bodied) +} + +// ----- Stage 1 — line classifier -------------------------------------------- + +// classifyLines emits one preliminary token per line. The state it +// carries between lines is whether the cursor sits between matching +// `---` fences (so YAML bodies survive verbatim); body accumulation +// happens later, in stage 2. +func classifyLines(lines []Line) []Token { + out := make([]Token, 0, len(lines)+1) + inFence := false + for _, line := range lines { + tok := lexLine(line, inFence) + out = append(out, tok) + if tok.Kind == tokenYAMLFence { + inFence = !inFence + } + } + return out +} + +// lexLine classifies one line. Returns one of: +// - TokenBlank +// - tokenYAMLFence +// - tokenRawLine (verbatim line inside an active fence) +// - TokenAnnotation (with pre-classified Args) +// - tokenKeywordPre (head + value-string, body accumulator decides next) +// - tokenText (free-form prose; later re-typed as TITLE/DESC) +func lexLine(line Line, inFence bool) Token { + text := strings.TrimRightFunc(line.Text, unicode.IsSpace) + + if strings.TrimSpace(text) == "---" { + return Token{Kind: tokenYAMLFence, Pos: line.Pos} + } + if inFence { + return Token{Kind: tokenRawLine, Pos: line.Pos, Text: line.Raw, Raw: line.Raw} + } + if text == "" { + return Token{Kind: TokenBlank, Pos: line.Pos} + } + + // First-character case insensitivity for swagger: — match v1's + // [Ss]wagger pattern. Only the leading character flips. + if hasSwaggerPrefix(text) { + return lexAnnotation(text, line.Pos) + } + if pfxLen, ok := matchGodocRoutePrefix(text); ok { + pos := line.Pos + pos.Column += pfxLen + pos.Offset += pfxLen + return lexAnnotation(text[pfxLen:], pos) + } + // Go compiler / linter directives (`//go:generate`, `//nolint:foo`, + // `//lint:ignore`, …) — recognise on Raw (which preserves the + // post-`//` spacing) and drop from the prose surface so they never + // land in TITLE / DESC. Must run after the swagger-prefix check so + // `//swagger:model` (legal but non-idiomatic, no leading space) is + // not mistaken for a directive. + if isGoDirective(line.Raw) { + return Token{Kind: tokenDirective, Pos: line.Pos, Raw: line.Raw} + } + if tok, ok := lexKeyword(text, line.Raw, line.Pos); ok { + return tok + } + return Token{Kind: tokenText, Pos: line.Pos, Text: text, Raw: line.Raw} +} + +// isGoDirective reports whether raw is the body of a Go compiler / +// linter directive comment. A directive has the form +// `:` where: +// +// - the leading character is a lowercase ASCII letter (no leading +// whitespace — distinguishes `//nolint:foo` from `// nolint:foo`); +// - the leading word is lowercase ASCII letters only; +// - the word is followed by `:` and **at least one non-whitespace +// character** with no whitespace between the colon and the +// argument. +// +// The "no whitespace after colon" rule separates directives from +// keyword lines: `maximum: 10` (space → keyword), `pattern:` (empty → +// block head), `nolint:foo` (immediate arg → directive). +// +// Note: `swagger:` matches this shape; lexLine runs the swagger +// check before the directive check so swagger annotations are never +// dropped. +func isGoDirective(raw string) bool { + if raw == "" || raw[0] < 'a' || raw[0] > 'z' { + return false + } + i := 1 + for i < len(raw) && raw[i] >= 'a' && raw[i] <= 'z' { + i++ + } + if i >= len(raw) || raw[i] != ':' { + return false + } + after := i + 1 + if after >= len(raw) { + return false + } + if raw[after] == ' ' || raw[after] == '\t' { + return false + } + return true +} + +// hasSwaggerPrefix is the case-insensitive match on the first char of +// AnnotationPrefix — only the first character is permissive (matches v1). +// +// Round-1 stance: AnnotationPrefix is fixed at "swagger:" so the +// case-insensitive fallback is tied to ASCII letter casing of its +// first byte. If v2 promotes the prefix to a configurable Option, +// revisit: a non-letter prefix character would not need this fallback. +func hasSwaggerPrefix(s string) bool { + if len(s) < len(AnnotationPrefix) { + return false + } + first := AnnotationPrefix[0] + if s[0] != first && s[0] != asciiUpper(first) { + return false + } + return s[1:len(AnnotationPrefix)] == AnnotationPrefix[1:] +} + +// asciiUpper returns the uppercase form of an ASCII letter, or the +// byte unchanged otherwise. Used for the first-character case- +// insensitive match on AnnotationPrefix. +func asciiUpper(b byte) byte { + if b >= 'a' && b <= 'z' { + return b - ('a' - 'A') + } + return b +} + +// matchGodocRoutePrefix recognises a leading "GoIdent swagger:route". +// Returns the byte offset where "swagger:route" begins. Only "route" +// gets this exception per 21-operation-grammar.md §"Godoc-prefix +// exception". +func matchGodocRoutePrefix(s string) (int, bool) { + identEnd := scanGoIdentifier(s) + if identEnd == 0 { + return 0, false + } + wsEnd := identEnd + for wsEnd < len(s) && (s[wsEnd] == ' ' || s[wsEnd] == '\t') { + wsEnd++ + } + if wsEnd == identEnd { + return 0, false + } + prefix := AnnotationPrefix + labelRoute + if !strings.HasPrefix(s[wsEnd:], prefix) { + return 0, false + } + after := wsEnd + len(prefix) + if after < len(s) && s[after] != ' ' && s[after] != '\t' { + return 0, false + } + return wsEnd, true +} + +// scanGoIdentifier returns the byte length of a leading Go identifier: +// Letter followed by Letter | Digit | _ | -. Returns 0 if s does not +// start with a letter. +func scanGoIdentifier(s string) int { + if s == "" { + return 0 + } + r, size := utf8.DecodeRuneInString(s) + if !unicode.IsLetter(r) { + return 0 + } + i := size + for i < len(s) { + r, size = utf8.DecodeRuneInString(s[i:]) + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-' { + break + } + i += size + } + return i +} + +// lexAnnotation parses "swagger: [args...]". Empty name falls +// back to a text token so the parser can diagnose. Args are returned +// pre-classified via classifyAnnotationArgs. +func lexAnnotation(text string, pos token.Position) Token { + rest := text[len(AnnotationPrefix):] + rest = strings.TrimRightFunc(stripTrailingDot(rest), unicode.IsSpace) + name, after := splitFirstField(rest) + if name == "" { + return Token{Kind: tokenText, Pos: pos, Text: text} + } + kind := AnnotationKindFromName(name) + args := classifyAnnotationArgs(kind, after, pos, len(text)-len(after)) + return Token{Kind: TokenAnnotation, Pos: pos, Name: name, Args: args} +} + +// stripTrailingDot elides a single trailing "." per 10-shared.md EOL +// rule. Source preservation lives upstream. +func stripTrailingDot(s string) string { + s = strings.TrimRightFunc(s, unicode.IsSpace) + return strings.TrimSuffix(s, ".") +} + +// splitFirstField returns the first whitespace-delimited token and the +// remainder (with leading whitespace stripped). +func splitFirstField(s string) (head, rest string) { + s = strings.TrimLeft(s, " \t") + if s == "" { + return "", "" + } + i := 0 + for i < len(s) && s[i] != ' ' && s[i] != '\t' { + i++ + } + head = s[:i] + rest = strings.TrimLeft(s[i:], " \t") + return head, rest +} + +// classifyAnnotationArgs converts the post-name remainder of an +// annotation line into the typed argument tokens specified by +// 50-full.md §1.2 / §3-§6 per annotation kind. +// +// The byte-offset baseColumn is the column at which `rest` begins +// inside the source line; positions are computed relative to that. +func classifyAnnotationArgs(kind AnnotationKind, rest string, linePos token.Position, baseColumn int) []Token { + rest = strings.TrimLeft(rest, " \t") + if rest == "" { + return nil + } + pos := linePos + pos.Column = linePos.Column + baseColumn + pos.Offset = linePos.Offset + baseColumn + + switch kind { + case AnnRoute, AnnOperation: + return classifyOperationArgs(rest, pos) + case AnnDefaultName: + return []Token{argDefaultValue(rest, pos)} + case AnnType: + return []Token{argTypeRef(rest, pos)} + case AnnEnum: + return classifyEnumAnnotationArgs(rest, pos) + case AnnParameters: + return classifyIdentList(rest, pos) + case AnnAllOf, AnnModel, AnnResponse, AnnStrfmt, AnnName: + return []Token{firstIdent(rest, pos)} + case AnnAlias, AnnIgnore, AnnFile, AnnMeta, AnnUnknown: + // No formal arguments. Capture any trailing tokens as RAW so a + // downstream diagnostic can flag them. + return classifyIdentList(rest, pos) + default: + return classifyIdentList(rest, pos) + } +} + +// classifyOperationArgs extracts METHOD, /path, [tags...], and the +// trailing operationID. Per 50-full.md §4 the trailing IDENT_NAME is +// the OpID; everything between path and the trailing ident is treated +// as a (potentially space-separated) tag list. +func classifyOperationArgs(rest string, basePos token.Position) []Token { + fields := splitFields(rest, basePos) + if len(fields) == 0 { + return nil + } + out := make([]Token, 0, len(fields)) + + // Field 0: HTTP_METHOD (if recognised). + first := fields[0] + if canon, ok := classifyHTTPMethod(first.text); ok { + out = append(out, Token{Kind: TokenHTTPMethod, Pos: first.pos, Text: canon}) + fields = fields[1:] + } + + // Field 0 (now): URL_PATH (if it looks like one). + if len(fields) > 0 && looksLikeURLPath(fields[0].text) { + out = append(out, Token{Kind: TokenURLPath, Pos: fields[0].pos, Text: fields[0].text}) + fields = fields[1:] + } + + // Remaining fields: every IDENT_NAME — analyzer marks the trailing + // one as the OpID per 50-full.md §4 OperationArgs comment. + for _, f := range fields { + out = append(out, Token{Kind: TokenIdentName, Pos: f.pos, Text: f.text}) + } + return out +} + +// argDefaultValue handles the JSON_VALUE | RAW_VALUE alternation for +// swagger:default per 23-classifier-grammar.md §"Value argument". +func argDefaultValue(rest string, pos token.Position) Token { + kind := classifyDefaultValue(rest) + return Token{Kind: kind, Pos: pos, Text: strings.TrimSpace(rest)} +} + +// argTypeRef recognises the closed TYPE_REF vocabulary; non-matches +// fall back to TokenIdentName so the analyzer can diagnose. +func argTypeRef(rest string, pos token.Position) Token { + rest = strings.TrimSpace(rest) + if isTypeRef(rest) { + return Token{Kind: TokenTypeRef, Pos: pos, Text: rest} + } + return Token{Kind: TokenIdentName, Pos: pos, Text: rest} +} + +// classifyEnumAnnotationArgs implements the four-step rule from +// 23-classifier-grammar.md §"Disambiguation rule for EnumArgs". The +// values fragment, when present, is emitted as a single token whose +// kind reflects the bracketed-vs-plain decision; downstream parsing +// of the list items lives in the parser/analyzer. +func classifyEnumAnnotationArgs(rest string, pos token.Position) []Token { + form, name, values := classifyEnumArgs(rest) + switch form { + case enumFormEmpty: + return nil + case enumFormBracketedOnly: + return []Token{{Kind: TokenJSONValue, Pos: pos, Text: values}} + case enumFormPlainOnly: + return []Token{{Kind: TokenCommaListValue, Pos: pos, Text: values}} + case enumFormNameOnly: + return []Token{{Kind: TokenIdentName, Pos: pos, Text: name}} + case enumFormNamePlusBracketed: + valuesPos := pos + valuesPos.Column += len(name) + 1 + valuesPos.Offset += len(name) + 1 + return []Token{ + {Kind: TokenIdentName, Pos: pos, Text: name}, + {Kind: TokenJSONValue, Pos: valuesPos, Text: values}, + } + case enumFormNamePlusPlain: + valuesPos := pos + valuesPos.Column += len(name) + 1 + valuesPos.Offset += len(name) + 1 + return []Token{ + {Kind: TokenIdentName, Pos: pos, Text: name}, + {Kind: TokenCommaListValue, Pos: valuesPos, Text: values}, + } + default: + return nil + } +} + +// classifyIdentList tokenises a whitespace-separated list as IDENT_NAME +// tokens. +func classifyIdentList(rest string, basePos token.Position) []Token { + fields := splitFields(rest, basePos) + out := make([]Token, 0, len(fields)) + for _, f := range fields { + out = append(out, Token{Kind: TokenIdentName, Pos: f.pos, Text: f.text}) + } + return out +} + +// firstIdent emits a single TokenIdentName for the first whitespace- +// separated token in rest. Used for single-arg classifier annotations. +func firstIdent(rest string, basePos token.Position) Token { + fields := splitFields(rest, basePos) + if len(fields) == 0 { + return Token{Kind: TokenIdentName, Pos: basePos} + } + return Token{Kind: TokenIdentName, Pos: fields[0].pos, Text: fields[0].text} +} + +// field is one whitespace-separated token with a position. +type field struct { + text string + pos token.Position +} + +// splitFields breaks s into whitespace-separated fields, advancing the +// position by byte offset for each field. +func splitFields(s string, basePos token.Position) []field { + const sensibleAllocs = 4 + out := make([]field, 0, sensibleAllocs) + i := 0 + for i < len(s) { + // Skip whitespace. + start := i + for i < len(s) && (s[i] == ' ' || s[i] == '\t') { + i++ + } + if i == len(s) { + break + } + offset := i + j := i + for j < len(s) && s[j] != ' ' && s[j] != '\t' { + j++ + } + f := field{ + text: s[i:j], + pos: token.Position{ + Filename: basePos.Filename, + Offset: basePos.Offset + offset, + Line: basePos.Line, + Column: basePos.Column + offset, + }, + } + out = append(out, f) + i = j + _ = start + } + return out +} + +// lexKeyword tries to parse text as a "[items.]*: [value]" +// form. Returns (token, true) on a match. Always returns a +// tokenKeywordPre — head + raw value string. Body accumulation (stage 2) +// decides whether to keep it as inline-value KW or expand into a body. +func lexKeyword(text, raw string, pos token.Position) (Token, bool) { + rest, depth := stripItemsPrefix(text) + + before, after, found := strings.Cut(rest, ":") + if !found { + return Token{}, false + } + + name := strings.TrimSpace(before) + if name == "" { + return Token{}, false + } + // First-character case insensitivity matching v1's [Cc]onsumes + // idiom: lowercase only the first character before lookup. + canonName := lowerFirst(name) + + kw, ok := Lookup(canonName) + if !ok { + return Token{}, false + } + + consumed := len(text) - len(rest) + kwPos := pos + kwPos.Column += consumed + kwPos.Offset += consumed + + value := strings.TrimSpace(after) + value = stripTrailingDot(value) + + return Token{ + Kind: tokenKeywordPre, + Pos: kwPos, + Name: kw.Name, + SourceName: name, + Text: value, // raw post-":" payload + Raw: raw, + ItemsDepth: depth, + // Keyword field reused to carry the table entry shape downstream. + Keyword: kw.Name, + }, true +} + +// lowerFirst applies first-character lowercase; matches v1's regex +// `[Cc]onsumes` idiom (only first char is case-permissive). +func lowerFirst(s string) string { + if s == "" { + return s + } + r, size := utf8.DecodeRuneInString(s) + if unicode.IsUpper(r) { + lower := unicode.ToLower(r) + var buf [4]byte + n := utf8.EncodeRune(buf[:], lower) + return string(buf[:n]) + s[size:] + } + return s +} + +// stripItemsPrefix peels leading "items." (or "items ") segments, +// counting depth. Bare "items:" (no separator) is preserved. +func stripItemsPrefix(s string) (string, int) { + depth := 0 + for { + stripped, ok := stripOneItemsPrefix(s) + if !ok { + return s, depth + } + s = stripped + depth++ + } +} + +func stripOneItemsPrefix(s string) (string, bool) { + const itemsLen = 5 + if len(s) < itemsLen { + return s, false + } + if !strings.EqualFold(s[:itemsLen], "items") { + return s, false + } + rest := s[itemsLen:] + trimmed := strings.TrimLeft(rest, ". \t") + if len(trimmed) == len(rest) { + return s, false + } + return trimmed, true +} + +// ----- Stage 2 — body accumulator ------------------------------------------- + +// accumulateBodies folds multi-line bodies into single body tokens and +// finalises inline-value keywords by typing the value per the keyword's +// declared shape. The output stream contains only tokens the parser +// actually consumes (no internal kinds). +func accumulateBodies(in []Token) []Token { + out := make([]Token, 0, len(in)+1) + i := 0 + for i < len(in) { + t := in[i] + switch t.Kind { + case tokenYAMLFence: + i = collectFencedYAML(in, i, &out) + case tokenKeywordPre: + kw, _ := Lookup(t.Name) + switch kw.Shape { + case ShapeRawBlock: + i = collectRawBlock(in, i, kw, &out) + case ShapeRawValue: + i = collectRawValue(in, i, kw, &out) + case ShapeNone, ShapeNumber, ShapeInt, ShapeBool, + ShapeString, ShapeCommaList, ShapeEnumOption: + out = append(out, finaliseInlineKeyword(t, kw)) + i++ + default: + out = append(out, finaliseInlineKeyword(t, kw)) + i++ + } + case tokenRawLine: + // Stale raw line outside a fence — should not happen given + // classifyLines' state machine. Drop silently. + i++ + case tokenDirective: + // Go directives (//go:, //nolint:, …) are dropped from the + // stream — they have no role in the swagger annotation + // grammar and must not contaminate TITLE / DESC. + i++ + case TokenBlank, tokenText, TokenAnnotation, TokenEOF: + out = append(out, t) + i++ + default: + out = append(out, t) + i++ + } + } + out = append(out, Token{Kind: TokenEOF}) + return out +} + +// collectFencedYAML scans from a `---` opener at index i and emits one +// OPAQUE_YAML token. The body is stored in Body (joined with "\n") and +// in Raw (verbatim, including indentation). Truncated is set on EOF +// without a closer. Returns the index past the closing fence (or the +// EOF position). +func collectFencedYAML(in []Token, i int, out *[]Token) int { + openerPos := in[i].Pos + i++ + var body, raw []string + for i < len(in) { + switch in[i].Kind { + case tokenYAMLFence: + *out = append(*out, Token{ + Kind: TokenOpaqueYaml, + Pos: openerPos, + Body: strings.Join(body, "\n"), + Raw: strings.Join(raw, "\n"), + Keyword: "", + }) + return i + 1 + case tokenRawLine: + body = append(body, in[i].Text) + raw = append(raw, in[i].Raw) + i++ + default: + // Non-raw token shouldn't appear inside a fence — defensive. + i++ + } + } + // EOF before closing fence — truncated body. + *out = append(*out, Token{ + Kind: TokenOpaqueYaml, + Pos: openerPos, + Body: strings.Join(body, "\n"), + Raw: strings.Join(raw, "\n"), + Truncated: true, + }) + return i +} + +// collectRawBlock accumulates the body of a RAW_BLOCK_ keyword +// (consumes / produces / responses / parameters / extensions / …). +// +// Round-1 termination rule (40-lexer.md §7 — A3 pre-flight blocker): +// stop at the next sibling structural item or EOF. Sibling structural +// items in the lexer are: another tokenKeywordPre / TokenAnnotation / +// tokenYAMLFence (when active outside the body). Blank lines do NOT +// terminate. Decorative `---` fences inside extensions bodies are +// dropped silently. +// +// TODO(A3): validate per-body-kind terminators against fixtures. For +// now every RAW_BLOCK uses the same coarse rule above. +func collectRawBlock(in []Token, i int, kw Keyword, out *[]Token) int { + head := in[i] + headPos := head.Pos + i++ + var bodyText, bodyRaw []string + pendingBlanks := 0 + + // extensions / infoExtensions bodies are YAML-parsed downstream + // (yaml.TypedExtensions in parser.go), so every body line MUST + // preserve its original indentation. For flat raw blocks + // (consumes / produces / security / …), v1 parity calls for the + // Text view (leading whitespace dropped, recognised keywords + // reformatted). The branches converge on the same bodyText slice. + yamlBody := kw.Name == "extensions" || kw.Name == "infoExtensions" + bodyLine := func(t Token) string { + if yamlBody { + return strings.TrimRightFunc(t.Raw, unicode.IsSpace) + } + return t.Text + } + + consumed := func() { + // flush any pending blanks into the body so visual separators + // inside list-shaped bodies survive. + for range pendingBlanks { + bodyText = append(bodyText, "") + bodyRaw = append(bodyRaw, "") + } + pendingBlanks = 0 + } + + for i < len(in) { + next := in[i] + switch next.Kind { + case TokenAnnotation: + emitRawBlock(out, headPos, head, kw, bodyText, bodyRaw) + return i + case tokenKeywordPre: + // Sibling structural keyword? — terminate. Keywords that + // could legitimately appear inside the body (e.g. nested + // `default:` under a `Parameters:` block) are absorbed as + // body text. Rule: same family / a sub-context keyword + // is body; another route/operation/meta-context keyword + // is a sibling. + if isSiblingTerminatorFor(kw, next.Name) { + emitRawBlock(out, headPos, head, kw, bodyText, bodyRaw) + return i + } + consumed() + if yamlBody { + bodyText = append(bodyText, strings.TrimRightFunc(next.Raw, unicode.IsSpace)) + } else { + bodyText = append(bodyText, formatKeywordLine(next)) + } + bodyRaw = append(bodyRaw, next.Raw) + i++ + case tokenYAMLFence: + // For extensions blocks the v1 corpus may decorate the body + // with a `---` fence; absorb its contents and drop the + // fences (per 40-lexer.md §5). + if kw.Name == "extensions" { + i = absorbDecorativeFenceInto(in, i+1, &bodyText, &bodyRaw) + continue + } + emitRawBlock(out, headPos, head, kw, bodyText, bodyRaw) + return i + case tokenText: + consumed() + bodyText = append(bodyText, bodyLine(next)) + bodyRaw = append(bodyRaw, next.Raw) + i++ + case tokenRawLine: + consumed() + bodyText = append(bodyText, bodyLine(next)) + bodyRaw = append(bodyRaw, next.Raw) + i++ + case tokenDirective: + // Directives never contribute to body text. + i++ + case TokenBlank: + pendingBlanks++ + i++ + default: + emitRawBlock(out, headPos, head, kw, bodyText, bodyRaw) + return i + } + } + + emitRawBlock(out, headPos, head, kw, bodyText, bodyRaw) + return i +} + +// absorbDecorativeFenceInto consumes raw lines until the matching +// closing fence and appends them into the active body. Fences +// themselves are dropped. Returns the index past the closing fence +// (or len(in) on truncation). +func absorbDecorativeFenceInto(in []Token, i int, bodyText, bodyRaw *[]string) int { + for i < len(in) { + switch in[i].Kind { + case tokenYAMLFence: + return i + 1 + case tokenRawLine: + *bodyText = append(*bodyText, in[i].Text) + *bodyRaw = append(*bodyRaw, in[i].Raw) + default: + // ignored kind + } + i++ + } + return i +} + +// emitRawBlock writes one TokenRawBlockBody to out. headPos/head carry +// items-depth and source-name details forwarded onto the body token. +// A RAW_BLOCK has no closing delimiter — its body ends at the next +// sibling structural keyword or EOF — so there is no truncation +// condition (unlike OPAQUE_YAML, where a missing closing `---` is a +// real failure mode). +func emitRawBlock(out *[]Token, headPos token.Position, head Token, kw Keyword, body, raw []string) { + *out = append(*out, Token{ + Kind: TokenRawBlockBody, + Pos: headPos, + Name: kw.Name, + SourceName: head.SourceName, + Keyword: kw.Name, + Body: strings.Join(body, "\n"), + Raw: strings.Join(raw, "\n"), + ItemsDepth: head.ItemsDepth, + }) +} + +// collectRawValue handles RAW_VALUE_ body keywords (default / +// example / enum). Single-line case (head with non-empty inline value) +// emits one body token immediately; multi-line case scans subsequent +// lines until a sibling terminator. +func collectRawValue(in []Token, i int, kw Keyword, out *[]Token) int { + head := in[i] + headPos := head.Pos + i++ + + // Single-line trivial path. + if head.Text != "" { + *out = append(*out, Token{ + Kind: TokenRawValueBody, + Pos: headPos, + Name: kw.Name, + SourceName: head.SourceName, + Keyword: kw.Name, + Body: head.Text, + Raw: head.Raw, + ItemsDepth: head.ItemsDepth, + }) + return i + } + + // Multi-line block-head path. + var bodyText, bodyRaw []string + pendingBlanks := 0 + consumed := func() { + for range pendingBlanks { + bodyText = append(bodyText, "") + bodyRaw = append(bodyRaw, "") + } + pendingBlanks = 0 + } + + for i < len(in) { + next := in[i] + switch next.Kind { + case TokenAnnotation: + emitRawValue(out, headPos, head, kw, bodyText, bodyRaw) + return i + case tokenKeywordPre: + if isSiblingTerminatorFor(kw, next.Name) { + emitRawValue(out, headPos, head, kw, bodyText, bodyRaw) + return i + } + consumed() + bodyText = append(bodyText, formatKeywordLine(next)) + bodyRaw = append(bodyRaw, next.Raw) + i++ + case tokenYAMLFence: + emitRawValue(out, headPos, head, kw, bodyText, bodyRaw) + return i + case tokenText: + consumed() + bodyText = append(bodyText, next.Text) + bodyRaw = append(bodyRaw, next.Raw) + i++ + case tokenDirective: + // Directives never contribute to body text. + i++ + case TokenBlank: + pendingBlanks++ + i++ + default: + emitRawValue(out, headPos, head, kw, bodyText, bodyRaw) + return i + } + } + + emitRawValue(out, headPos, head, kw, bodyText, bodyRaw) + return i +} + +func emitRawValue(out *[]Token, headPos token.Position, head Token, kw Keyword, body, raw []string) { + *out = append(*out, Token{ + Kind: TokenRawValueBody, + Pos: headPos, + Name: kw.Name, + SourceName: head.SourceName, + Keyword: kw.Name, + Body: strings.Join(body, "\n"), + Raw: strings.Join(raw, "\n"), + ItemsDepth: head.ItemsDepth, + }) +} + +// formatKeywordLine recreates the textual `: ` line for a +// keyword token absorbed into a raw body. Mirrors v1's permissive +// line-preserving behaviour. +func formatKeywordLine(t Token) string { + name := t.SourceName + if name == "" { + name = t.Name + } + if t.Text == "" { + return name + ":" + } + return name + ": " + t.Text +} + +// isSiblingTerminatorFor decides whether a keyword named `next`, +// encountered while accumulating a body opened by `kw`, is a sibling +// structural terminator (true) or a sub-context keyword that should +// be absorbed as body text (false). +// +// Heuristic (round-1, A3-pending fixture validation): +// +// - if kw is a meta/route/operation context block (consumes, produces, +// security, securityDefinitions, responses, parameters, extensions, +// externalDocs, infoExtensions, tos, schemes), terminate on any +// sibling that is also a meta/route/operation-context keyword; +// - if kw is a schema body keyword (default, example, enum), +// terminate on any sibling that is a schema-context keyword. +// +// Look-up uses the keyword table's Contexts. +func isSiblingTerminatorFor(kw Keyword, nextName string) bool { + nextKw, ok := Lookup(nextName) + if !ok { + return false + } + headFamily := familyOf(kw) + nextFamily := familyOf(nextKw) + for _, hf := range headFamily { + if slices.Contains(nextFamily, hf) { + return true + } + } + return false +} + +// familyOf classifies a keyword into one or more "family" buckets per +// its declared contexts. +func familyOf(kw Keyword) []KeywordContext { + out := make([]KeywordContext, 0, len(kw.Contexts)) + for _, c := range kw.Contexts { + switch c { + case CtxMeta, CtxRoute, CtxOperation: + out = append(out, c) + case CtxSchema, CtxItems, CtxParam, CtxHeader, CtxResponse: + out = append(out, c) + default: + // ignored context + } + } + return out +} + +// finaliseInlineKeyword converts a tokenKeywordPre into a TokenKeyword +// followed by a typed value-token pair. Returns the keyword token; the +// value token is appended via the returned pair (caller emits both). +// +// To keep the stream linear and the parser simple, this function emits +// a single TokenKeyword carrying the typed value through the value +// token's payload — the parser reads the (KW, value) pair as two +// adjacent tokens. We achieve that by returning a synthesised KW token +// whose `Args` field carries the value token. The parser unpacks Args +// to satisfy the §3-§5 productions. +// +// Choosing Args (rather than emitting two adjacent tokens) keeps the +// pairing atomic — the body accumulator emits exactly one token per +// keyword regardless of how many sub-tokens the value carries. +func finaliseInlineKeyword(t Token, kw Keyword) Token { + value := t.Text + valuePos := t.Pos + valuePos.Column += len(t.SourceName) + 1 + valuePos.Offset += len(t.SourceName) + 1 + + var argTok Token + switch kw.Shape { + case ShapeNumber: + argTok = Token{Kind: TokenNumberValue, Pos: valuePos, Text: value} + case ShapeInt: + argTok = Token{Kind: TokenIntValue, Pos: valuePos, Text: value} + case ShapeBool: + argTok = Token{Kind: TokenBoolValue, Pos: valuePos, Text: value} + case ShapeString: + argTok = Token{Kind: TokenStringValue, Pos: valuePos, Text: value} + case ShapeCommaList: + argTok = Token{Kind: TokenCommaListValue, Pos: valuePos, Text: value} + case ShapeEnumOption: + argTok = Token{Kind: TokenEnumOption, Pos: valuePos, Text: value} + case ShapeNone, ShapeRawBlock, ShapeRawValue: + // Body keywords reach finaliseInlineKeyword only on the + // pathological "head with no inline value but no following + // body" case. Treat the value, if any, as a string token. + argTok = Token{Kind: TokenStringValue, Pos: valuePos, Text: value} + default: + // ignored shape + } + + return Token{ + Kind: TokenKeyword, + Pos: t.Pos, + Name: kw.Name, + SourceName: t.SourceName, + Text: value, + Raw: t.Raw, + ItemsDepth: t.ItemsDepth, + Args: []Token{argTok}, + } +} + +// ----- Stage 3 — prose classifier ------------------------------------------- + +// classifyProse re-types tokenText tokens as TITLE / DESC per the four +// heuristics in 40-lexer.md §8. +// +// Behaviour: +// - For an annotated input (any TokenAnnotation appears in the +// stream), title/description split applies. Title is determined +// from the prose run that precedes the *first* non-prose, non- +// annotation structural token (or the first run after the +// annotation if it is leading). +// - For an unbound input (no annotation), the entire prose surface +// becomes DESC (no title is emitted). +// +// The function preserves all non-text tokens and the relative order +// of text tokens. Blank tokens within a prose run are preserved so +// downstream consumers can reproduce paragraph structure. +func classifyProse(in []Token) []Token { + hasAnnotation := false + for _, t := range in { + if t.Kind == TokenAnnotation { + hasAnnotation = true + break + } + } + + out := make([]Token, 0, len(in)) + state := proseStart + for _, t := range in { + if t.Kind != tokenText && t.Kind != TokenBlank { + out = append(out, t) + if t.Kind == TokenAnnotation { + state = proseAfterAnnotation + } else { + state = proseInBody + } + continue + } + // Buffer prose runs; the run-classifier runs at run-end. + out = append(out, t) + _ = state + } + + // Always classify — UnboundBlock-style comments (no swagger + // annotation) still need title/desc classification because the + // schema builder consumes their PreambleTitle/PreambleDescription + // when an interface or alias is referenced indirectly. v1's + // helpers.CollectScannerTitleDescription applied the same + // heuristics to those comments. + return classifyProseRunsInPlace(out, hasAnnotation) +} + +type proseState int + +const ( + proseStart proseState = iota + proseAfterAnnotation + proseInBody +) + +// classifyProseRunsInPlace walks `out` and re-types contiguous runs of +// (tokenText / TokenBlank) into TITLE / DESC. The first prose run is +// split into title + description per the four heuristics; later prose +// runs become DESC. The annotation flag is no longer consulted: v1's +// helpers.CollectScannerTitleDescription applied the same heuristics +// to UnboundBlock-style comments (no swagger annotation), so refusing +// to classify when hasAnnotation=false would drop titles on Go-doc +// comments that rendered as schemas through indirect references — +// e.g. a non-annotated interface embedded by a swagger:model parent. +func classifyProseRunsInPlace(out []Token, _ bool) []Token { + firstRun := true + for i := 0; i < len(out); { + if out[i].Kind != tokenText && out[i].Kind != TokenBlank { + i++ + continue + } + j := i + for j < len(out) && (out[j].Kind == tokenText || out[j].Kind == TokenBlank) { + j++ + } + if firstRun { + classifyTitleDescRun(out, i, j) + } else { + retypeRunAs(out, i, j, TokenDesc) + } + firstRun = false + i = j + } + return out +} + +// classifyTitleDescRun applies the four heuristics in 40-lexer.md §8 +// to a single contiguous prose run [start, end). +func classifyTitleDescRun(out []Token, start, end int) { + // Find the first text-line index inside the run. + firstText := -1 + for k := start; k < end; k++ { + if out[k].Kind == tokenText { + firstText = k + break + } + } + if firstText == -1 { + // Run is all blanks. + retypeRunAs(out, start, end, TokenDesc) + return + } + + // Heuristic 1: blank inside the run splits title (before) / desc + // (after). Only fires when the blank has text AFTER it — a + // trailing blank is a separator between the prose run and the + // next non-prose token (annotation / EOF), not an internal + // title/desc divide. v1's helpers.CollectScannerTitleDescription + // matches this rule because it never sees the trailing-blank line + // (the prose lines slice is pre-trimmed). + // + // On a heuristic-1 split, also strip an ATX heading marker from + // the first title line — v1's helpers post-processes title[0] + // the same way (`title[0] = rxTitleStart.ReplaceAllString(...)`). + for k := firstText + 1; k < end; k++ { + if out[k].Kind != TokenBlank { + continue + } + hasTextAfter := false + for m := k + 1; m < end; m++ { + if out[m].Kind == tokenText { + hasTextAfter = true + break + } + } + if !hasTextAfter { + continue + } + if rest, ok := stripATXHeading(out[firstText].Text); ok { + out[firstText].Text = rest + } + retypeRunAs(out, start, k, TokenTitle) + retypeRunAs(out, k, end, TokenDesc) + return + } + + // Heuristic 2: first prose line ends with Unicode punctuation -> title is line 1. + first := out[firstText].Text + if endsWithPunct(first) { + retypeRunAs(out, start, firstText+1, TokenTitle) + retypeRunAs(out, firstText+1, end, TokenDesc) + return + } + + // Heuristic 3: first line matches a markdown ATX heading -> strip + // marker, title is line 1. + if rest, ok := stripATXHeading(first); ok { + out[firstText].Text = rest + retypeRunAs(out, start, firstText+1, TokenTitle) + retypeRunAs(out, firstText+1, end, TokenDesc) + return + } + + // Heuristic 4: entire run becomes description. + retypeRunAs(out, start, end, TokenDesc) +} + +// retypeRunAs re-types the (text, blank) tokens in [start, end) so +// that text becomes `kind`. Blanks are preserved (kept as TokenBlank) +// because consumers may want paragraph breaks intact between TITLE / +// DESC runs. +func retypeRunAs(out []Token, start, end int, kind TokenKind) { + for k := start; k < end; k++ { + if out[k].Kind == tokenText { + out[k].Kind = kind + } + } +} + +// stripATXHeading recognises a markdown ATX-style heading prefix — +// one or more leading `#` followed by at least one whitespace +// character — and returns the trimmed remainder. Reports false +// when the input doesn't open with `#`. Replaces a regexp. +func stripATXHeading(s string) (rest string, ok bool) { + i := 0 + for i < len(s) && s[i] == '#' { + i++ + } + if i == 0 { + return s, false + } + // Need at least one whitespace separator between the # run and + // the heading text. + if i >= len(s) { + return s, false + } + switch s[i] { + case ' ', '\t', '\n', '\f', '\r', '\v': + default: + return s, false + } + return strings.TrimSpace(s[i+1:]), true +} + +// endsWithPunct reports whether s ends in Unicode punctuation other +// than dash/connector — matches v1's `\p{Po}$`. Round-1 implementation +// looks for category Po ("punctuation, other") on the last rune. +func endsWithPunct(s string) bool { + s = strings.TrimRightFunc(s, unicode.IsSpace) + if s == "" { + return false + } + r, _ := utf8.DecodeLastRuneInString(s) + return unicode.Is(unicode.Po, r) +} + +// FormatToken renders a token compactly for diagnostics and tests. +// Avoids leaking internal kinds in production output. +func FormatToken(t Token) string { + switch t.Kind { + case TokenAnnotation: + return "ANN(" + t.Name + argSummary(t.Args) + ")" + case TokenKeyword: + return "KW(" + t.Name + argSummary(t.Args) + ")" + case TokenRawBlockBody: + return "RAW_BLOCK_" + strings.ToUpper(t.Keyword) + case TokenRawValueBody: + return "RAW_VALUE_" + strings.ToUpper(t.Keyword) + case TokenOpaqueYaml: + return "OPAQUE_YAML" + default: + if t.Text != "" { + return t.Kind.String() + "(" + strconv.Quote(t.Text) + ")" + } + return t.Kind.String() + } +} + +func argSummary(args []Token) string { + if len(args) == 0 { + return "" + } + parts := make([]string, len(args)) + for i, a := range args { + parts[i] = a.Kind.String() + } + return ":" + strings.Join(parts, ",") +} diff --git a/internal/parsers/grammar/lexer_test.go b/internal/parsers/grammar/lexer_test.go new file mode 100644 index 0000000..03a5667 --- /dev/null +++ b/internal/parsers/grammar/lexer_test.go @@ -0,0 +1,510 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "go/ast" + goparser "go/parser" + "go/token" + "strings" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// parseGoSource parses a Go source file via go/parser, finds the +// first top-level decl with a doc comment, and runs grammar.Parse on +// it. Used to exercise the public *ast.CommentGroup entry point — +// where Go directives like //nolint: appear with their original +// (no-leading-space) shape. +// +//nolint:ireturn // delegates to Parse which returns Block. +func parseGoSource(t *testing.T, src string) Block { + t.Helper() + fset := token.NewFileSet() + file, err := goparser.ParseFile(fset, "fake.go", src, goparser.ParseComments) + require.NoError(t, err) + for _, d := range file.Decls { + switch decl := d.(type) { + case *ast.GenDecl: + if decl.Doc != nil { + return Parse(decl.Doc, fset) + } + case *ast.FuncDecl: + if decl.Doc != nil { + return Parse(decl.Doc, fset) + } + } + } + t.Fatalf("no doc-bearing decl found in fixture") + return nil +} + +// lexString preprocesses a comment block (without leading // markers) and +// runs the full Lex pipeline. Each input line becomes one Line entry. +func lexString(t *testing.T, src string) []Token { + t.Helper() + const likelyLines = 5 + lines := make([]Line, 0, likelyLines) + pos := token.Position{Filename: "test.go", Line: 1, Column: 1} + for i, raw := range strings.Split(src, "\n") { + p := pos + p.Line = 1 + i + lines = append(lines, Line{Text: trimContentPrefix(raw), Raw: raw, Pos: p}) + } + return Lex(lines) +} + +func tokenKinds(toks []Token) []TokenKind { + out := make([]TokenKind, len(toks)) + for i, t := range toks { + out[i] = t.Kind + } + return out +} + +func TestLexer_AnnotationBare(t *testing.T) { + out := lexString(t, "swagger:meta") + require.GreaterOrEqual(t, len(out), 2) + assert.Equal(t, TokenAnnotation, out[0].Kind) + assert.Equal(t, "meta", out[0].Name) + assert.Empty(t, out[0].Args) + assert.Equal(t, TokenEOF, out[len(out)-1].Kind) +} + +func TestLexer_AnnotationTrailingDot(t *testing.T) { + out := lexString(t, "swagger:strfmt uuid.") + require.NotEmpty(t, out) + require.Equal(t, TokenAnnotation, out[0].Kind) + require.Len(t, out[0].Args, 1) + assert.Equal(t, TokenIdentName, out[0].Args[0].Kind) + assert.Equal(t, "uuid", out[0].Args[0].Text) +} + +func TestLexer_AnnotationCaseInsensitiveFirstChar(t *testing.T) { + out := lexString(t, "Swagger:strfmt uuid") + require.NotEmpty(t, out) + assert.Equal(t, TokenAnnotation, out[0].Kind) + assert.Equal(t, "strfmt", out[0].Name) +} + +func TestLexer_RouteWithGodocPrefix(t *testing.T) { + out := lexString(t, "GetPets swagger:route GET /pets pets listPets") + require.NotEmpty(t, out) + assert.Equal(t, TokenAnnotation, out[0].Kind) + assert.Equal(t, "route", out[0].Name) + require.Len(t, out[0].Args, 4) + assert.Equal(t, TokenHTTPMethod, out[0].Args[0].Kind) + assert.Equal(t, "GET", out[0].Args[0].Text) + assert.Equal(t, TokenURLPath, out[0].Args[1].Kind) + assert.Equal(t, "/pets", out[0].Args[1].Text) + assert.Equal(t, TokenIdentName, out[0].Args[2].Kind) + assert.Equal(t, "pets", out[0].Args[2].Text) + assert.Equal(t, TokenIdentName, out[0].Args[3].Kind) + assert.Equal(t, "listPets", out[0].Args[3].Text) +} + +func TestLexer_RouteOnlyGetsGodocPrefix(t *testing.T) { + // Other annotations must NOT accept a leading godoc identifier. + out := lexString(t, "GetPets swagger:operation GET /pets pets listPets") + // Expect a fallthrough text token, not an annotation. + require.NotEmpty(t, out) + assert.NotEqual(t, TokenAnnotation, out[0].Kind) +} + +func TestLexer_KeywordInlineNumber(t *testing.T) { + out := lexString(t, "maximum: 10") + require.NotEmpty(t, out) + assert.Equal(t, TokenKeyword, out[0].Kind) + assert.Equal(t, "maximum", out[0].Name) + require.Len(t, out[0].Args, 1) + assert.Equal(t, TokenNumberValue, out[0].Args[0].Kind) + assert.Equal(t, "10", out[0].Args[0].Text) +} + +func TestLexer_KeywordCaseInsensitiveFirstChar(t *testing.T) { + out := lexString(t, "Maximum: 10") + require.NotEmpty(t, out) + assert.Equal(t, TokenKeyword, out[0].Kind) + assert.Equal(t, "maximum", out[0].Name) + assert.Equal(t, "Maximum", out[0].SourceName) +} + +func TestLexer_ItemsPrefixDepth(t *testing.T) { + out := lexString(t, "items.items.maxLength: 5") + require.NotEmpty(t, out) + assert.Equal(t, TokenKeyword, out[0].Kind) + assert.Equal(t, "maxLength", out[0].Name) + assert.Equal(t, 2, out[0].ItemsDepth) +} + +func TestLexer_RawBlockConsumes(t *testing.T) { + out := lexString(t, "Consumes:\n - application/json\n - application/xml") + require.NotEmpty(t, out) + assert.Equal(t, TokenRawBlockBody, out[0].Kind) + assert.Equal(t, "consumes", out[0].Keyword) + assert.Contains(t, out[0].Body, "application/json") + assert.Contains(t, out[0].Body, "application/xml") +} + +func TestLexer_RawBlockTerminatedBySibling(t *testing.T) { + src := strings.Join([]string{ + "Consumes:", + " - application/json", + "Produces:", + " - application/json", + }, "\n") + out := lexString(t, src) + require.GreaterOrEqual(t, len(out), 3) + assert.Equal(t, TokenRawBlockBody, out[0].Kind) + assert.Equal(t, "consumes", out[0].Keyword) + assert.Equal(t, TokenRawBlockBody, out[1].Kind) + assert.Equal(t, "produces", out[1].Keyword) +} + +func TestLexer_OpaqueYamlFenced(t *testing.T) { + src := strings.Join([]string{ + "swagger:operation GET /pets pets listPets", + "---", + "parameters:", + " - name: id", + "---", + }, "\n") + out := lexString(t, src) + + // Find the OpaqueYaml token. + var yamlTok *Token + for i := range out { + if out[i].Kind == TokenOpaqueYaml { + yamlTok = &out[i] + break + } + } + require.NotNil(t, yamlTok) + assert.Contains(t, yamlTok.Body, "parameters:") + assert.Contains(t, yamlTok.Body, "name: id") + assert.False(t, yamlTok.Truncated) +} + +func TestLexer_OpaqueYamlTruncated(t *testing.T) { + src := strings.Join([]string{ + "swagger:operation GET /pets pets listPets", + "---", + "parameters:", + }, "\n") + out := lexString(t, src) + + var yamlTok *Token + for i := range out { + if out[i].Kind == TokenOpaqueYaml { + yamlTok = &out[i] + break + } + } + require.NotNil(t, yamlTok) + assert.True(t, yamlTok.Truncated) +} + +func TestLexer_RawValueDefaultInline(t *testing.T) { + out := lexString(t, "default: hello") + require.NotEmpty(t, out) + assert.Equal(t, TokenRawValueBody, out[0].Kind) + assert.Equal(t, "default", out[0].Keyword) + assert.Equal(t, "hello", out[0].Body) +} + +func TestLexer_RawValueDefaultMultiline(t *testing.T) { + src := strings.Join([]string{ + "default:", + " one", + " two", + }, "\n") + out := lexString(t, src) + require.NotEmpty(t, out) + assert.Equal(t, TokenRawValueBody, out[0].Kind) + assert.Equal(t, "default", out[0].Keyword) + assert.Contains(t, out[0].Body, "one") + assert.Contains(t, out[0].Body, "two") +} + +func TestLexer_TitleDescriptionSplit_PunctuationHeuristic(t *testing.T) { + src := strings.Join([]string{ + "A pet in the store.", + "With a longer follow-up paragraph", + "that spans multiple lines.", + "swagger:model Pet", + }, "\n") + out := lexString(t, src) + + titles := collectKind(out, TokenTitle) + descs := collectKind(out, TokenDesc) + require.NotEmpty(t, titles) + require.NotEmpty(t, descs) + assert.Equal(t, "A pet in the store.", titles[0].Text) + assert.Contains(t, descs[0].Text+" "+descs[1].Text, "longer follow-up") +} + +func TestLexer_TitleDescriptionSplit_BlankLineHeuristic(t *testing.T) { + src := strings.Join([]string{ + "A short title", + "", + "And a description following a blank line.", + "swagger:model Pet", + }, "\n") + out := lexString(t, src) + + titles := collectKind(out, TokenTitle) + descs := collectKind(out, TokenDesc) + require.NotEmpty(t, titles) + require.NotEmpty(t, descs) + assert.Equal(t, "A short title", titles[0].Text) +} + +func TestLexer_UnboundBlockClassifiesTitle(t *testing.T) { + // UnboundBlocks (no swagger annotation) still get title/desc + // classification — v1's helpers.CollectScannerTitleDescription + // applied the same heuristics regardless of annotation presence, + // and downstream consumers (e.g. the schema builder when a + // non-annotated interface is referenced via $ref) rely on + // PreambleTitle being populated. + out := lexString(t, "Name of the user.\nrequired: true") + titles := collectKind(out, TokenTitle) + require.NotEmpty(t, titles, "first prose line ending in punct should be TITLE") + assert.Equal(t, "Name of the user.", titles[0].Text) +} + +func TestLexer_DefaultAnnotation_JsonValue(t *testing.T) { + out := lexString(t, `swagger:default {"x": 1}`) + require.NotEmpty(t, out) + require.Equal(t, TokenAnnotation, out[0].Kind) + require.Len(t, out[0].Args, 1) + assert.Equal(t, TokenJSONValue, out[0].Args[0].Kind) +} + +func TestLexer_DefaultAnnotation_RawFallback(t *testing.T) { + out := lexString(t, "swagger:default high") + require.NotEmpty(t, out) + require.Equal(t, TokenAnnotation, out[0].Kind) + require.Len(t, out[0].Args, 1) + assert.Equal(t, TokenRawValue, out[0].Args[0].Kind) +} + +func TestLexer_TypeAnnotation_ClosedVocabulary(t *testing.T) { + out := lexString(t, "swagger:type string") + require.NotEmpty(t, out) + require.Len(t, out[0].Args, 1) + assert.Equal(t, TokenTypeRef, out[0].Args[0].Kind) + assert.Equal(t, "string", out[0].Args[0].Text) + + out2 := lexString(t, "swagger:type custom") + require.NotEmpty(t, out2) + require.Len(t, out2[0].Args, 1) + assert.Equal(t, TokenIdentName, out2[0].Args[0].Kind, "unknown type tokens fall back to IDENT_NAME for analyzer diagnosis") +} + +func TestLexer_EnumAnnotation_NameOnly(t *testing.T) { + out := lexString(t, "swagger:enum Priority") + require.NotEmpty(t, out) + require.Len(t, out[0].Args, 1) + assert.Equal(t, TokenIdentName, out[0].Args[0].Kind) + assert.Equal(t, "Priority", out[0].Args[0].Text) +} + +func TestLexer_EnumAnnotation_PlainListNoName(t *testing.T) { + out := lexString(t, "swagger:enum 1, 2, 3") + require.NotEmpty(t, out) + require.Len(t, out[0].Args, 1) + assert.Equal(t, TokenCommaListValue, out[0].Args[0].Kind) +} + +func TestLexer_EnumAnnotation_NamePlusBracketed(t *testing.T) { + out := lexString(t, "swagger:enum kind [a, b, c]") + require.NotEmpty(t, out) + require.Len(t, out[0].Args, 2) + assert.Equal(t, TokenIdentName, out[0].Args[0].Kind) + assert.Equal(t, "kind", out[0].Args[0].Text) + assert.Equal(t, TokenJSONValue, out[0].Args[1].Kind) +} + +func TestLexer_BlankLinePreserved(t *testing.T) { + out := lexString(t, "swagger:meta\n\nVersion: 1") + // At least one BLANK between annotation and keyword. + hasBlank := false + for _, k := range tokenKinds(out) { + if k == TokenBlank { + hasBlank = true + } + } + assert.True(t, hasBlank) +} + +func TestLexer_DecorativeFenceInExtensions(t *testing.T) { + src := strings.Join([]string{ + "Extensions:", + "---", + "x-foo: bar", + "---", + }, "\n") + out := lexString(t, src) + require.NotEmpty(t, out) + assert.Equal(t, TokenRawBlockBody, out[0].Kind) + assert.Equal(t, "extensions", out[0].Keyword) + assert.Contains(t, out[0].Body, "x-foo: bar") +} + +// collectKind returns the subset of out whose Kind matches. +func collectKind(out []Token, k TokenKind) []Token { + var found []Token + for _, t := range out { + if t.Kind == k { + found = append(found, t) + } + } + return found +} + +func TestLexer_TrailingWhitespaceOnAnnotationLine(t *testing.T) { + out := lexString(t, "swagger:strfmt uuid ") + require.NotEmpty(t, out) + assert.Equal(t, TokenAnnotation, out[0].Kind) + require.Len(t, out[0].Args, 1) + assert.Equal(t, "uuid", out[0].Args[0].Text) +} + +func TestLexer_TrailingWhitespaceOnKeywordLine(t *testing.T) { + out := lexString(t, "maximum: 10 \t") + require.NotEmpty(t, out) + assert.Equal(t, TokenKeyword, out[0].Kind) + require.Len(t, out[0].Args, 1) + assert.Equal(t, "10", out[0].Args[0].Text) +} + +func TestLexer_TrailingNonASCIIWhitespace(t *testing.T) { + // U+00A0 NO-BREAK SPACE, U+2028 LINE SEPARATOR — TrimRightFunc with + // unicode.IsSpace must strip them; TrimRight on " \t" alone would + // leave them attached. + out := lexString(t, "swagger:strfmt uuid 
") + require.NotEmpty(t, out) + assert.Equal(t, TokenAnnotation, out[0].Kind) + require.Len(t, out[0].Args, 1) + assert.Equal(t, "uuid", out[0].Args[0].Text) +} + +func TestLexer_WhitespaceOnlyLineIsBlank(t *testing.T) { + // `// \t ` style line — strips to empty, must surface as BLANK. + out := lexString(t, "swagger:meta\n \t \nVersion: 1") + hasBlank := false + for _, k := range tokenKinds(out) { + if k == TokenBlank { + hasBlank = true + } + } + assert.True(t, hasBlank) +} + +func TestLexer_GoDirectivesDroppedFromProse(t *testing.T) { + cases := []string{ + "nolint:gocritic", + "go:generate stringer -type=Foo", + "lint:ignore U1000 unused field", + "staticcheck:foo", + } + for _, raw := range cases { + assert.True(t, isGoDirective(raw), "expected %q to be a directive", raw) + } + + notDirectives := []string{ + " nolint:gocritic", // leading space (idiomatic prose) + "NoLint:foo", // uppercase first char + "foo bar:baz", // contains space before colon + "description without a colon", + "required: true", // keyword: space after colon + "maximum: 10", // keyword: space after colon + "pattern:", // block head: empty after colon + "version: 1.0.0", // keyword: space after colon + } + for _, raw := range notDirectives { + assert.False(t, isGoDirective(raw), "did not expect %q to be a directive", raw) + } + + // `swagger:model Pet` matches the directive shape per isGoDirective + // in isolation — the swagger annotation check runs first in + // lexLine, so this never reaches the directive filter at runtime. + assert.True(t, isGoDirective("swagger:model"), + "swagger annotations match the directive shape — lexLine special-cases them upstream") +} + +func TestLexer_DirectiveDoesNotPolluteTitle(t *testing.T) { + // Source: a docstring with an embedded //nolint directive. The + // title/description surface must not include the directive. + src := strings.Join([]string{ + "A pet in the store.", + "", + "With a longer description.", + "swagger:model Pet", + }, "\n") + cleanOut := lexString(t, src) + cleanTitles := collectKind(cleanOut, TokenTitle) + cleanDescs := collectKind(cleanOut, TokenDesc) + + srcWithDirective := strings.Join([]string{ + "A pet in the store.", + "", + "With a longer description.", + // Simulate the post-`//` content of `//nolint:revive`. + // preprocessText feeds it as Raw verbatim via our lexString helper: + // trimContentPrefix strips the leading `/` chars only on Text. + "swagger:model Pet", + }, "\n") + out := lexString(t, srcWithDirective) + titles := collectKind(out, TokenTitle) + descs := collectKind(out, TokenDesc) + + // The two streams must classify the same prose surface. + assert.Equal(t, len(cleanTitles), len(titles)) + assert.Equal(t, len(cleanDescs), len(descs)) + + // Direct directive presence test via the public Parse() path: a + // CommentGroup with a //nolint line interleaved into a docstring + // must not surface "nolint:" anywhere in Title / Description. + srcGo := `package fake + +// A pet in the store. +// +//nolint:revive +// +// With a longer description. +// +// swagger:model Pet +type Pet struct{} +` + b := parseGoSource(t, srcGo) + mb, ok := b.(*ModelBlock) + require.True(t, ok, "expected *ModelBlock, got %T", b) + assert.NotContains(t, mb.Title(), "nolint") + assert.NotContains(t, mb.Description(), "nolint") + assert.Equal(t, "A pet in the store.", mb.Title()) + assert.Equal(t, "With a longer description.", mb.Description()) +} + +func TestLexer_DirectiveDroppedInsideRawBlock(t *testing.T) { + src := `package fake + +// swagger:meta +// +// Consumes: +// - application/json +//nolint:gocritic +// - application/xml +type _ struct{} +` + b := parseGoSource(t, src) + cons, ok := b.GetList("consumes") + require.True(t, ok) + joined := strings.Join(cons, "\n") + assert.Contains(t, joined, "application/json") + assert.Contains(t, joined, "application/xml") + assert.NotContains(t, joined, "nolint") +} diff --git a/internal/parsers/grammar/meta_info.go b/internal/parsers/grammar/meta_info.go new file mode 100644 index 0000000..82aa21a --- /dev/null +++ b/internal/parsers/grammar/meta_info.go @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "net/mail" + "strings" +) + +// Contact is the typed shape of a `contact:` inline value on a +// swagger:meta block. The convention is: +// +// contact: +// +// where each part is optional in the order written: the parser +// recognises a `Name ` head (Go's net/mail.ParseAddress form) +// followed by an optional URL. A bare email without a name is also +// accepted. Empty or unrecognised inputs return (Contact{}, false) +// from Block.Contact(). +type Contact struct { + Name, Email, URL string +} + +// License is the typed shape of a `license:` inline value: +// +// license: +// +// where Name is everything before the URL prefix and URL is the +// scheme-anchored remainder. A line without a URL keeps Name and +// leaves URL empty. Empty input returns (License{}, false) from +// Block.License(). +type License struct { + Name, URL string +} + +// parseContact converts the raw contact: value into a typed Contact. +// Returns (Contact{}, nil) on empty input (treated as "no contact"). +// A non-nil error signals a malformed `Name ` head — the +// caller decides whether to fail the build or downgrade to a +// warning. An isolated URL (no name/email) yields (Contact{URL: …}, +// nil). +func parseContact(line string) (Contact, error) { + line = strings.TrimSpace(line) + if line == "" { + return Contact{}, nil + } + nameEmail, url := splitURL(line) + if nameEmail == "" { + return Contact{URL: url}, nil + } + addr, err := mail.ParseAddress(nameEmail) + if err != nil { + return Contact{}, err + } + return Contact{Name: addr.Name, Email: addr.Address, URL: url}, nil +} + +// parseLicense converts the raw license: value into a typed License. +// Returns (License{}, false) only when the input is empty; any +// non-empty input yields a (License, true) with Name and URL split +// on the URL prefix (Name may be empty if the line starts with the +// URL). +func parseLicense(line string) (License, bool) { + line = strings.TrimSpace(line) + if line == "" { + return License{}, false + } + name, url := splitURL(line) + return License{Name: name, URL: url}, true +} + +// urlSchemes lists the leading URL prefixes splitURL recognises. +// The set covers the schemes meta titles realistically carry. +// +//nolint:gochecknoglobals // immutable lookup table; read-only. +var urlSchemes = []string{"https://", "http://", "ftps://", "ftp://", "wss://", "ws://"} + +// splitURL separates the leading non-URL prefix from the trailing +// URL on a single line. Returns ("", url) when the line begins with +// a URL scheme; (line, "") when no scheme is found anywhere. +func splitURL(line string) (notURL, url string) { + str := strings.TrimSpace(line) + idx := -1 + for _, scheme := range urlSchemes { + if i := strings.Index(str, scheme); i >= 0 && (idx < 0 || i < idx) { + idx = i + } + } + if idx < 0 { + if str != "" { + notURL = str + } + return notURL, "" + } + notURL = strings.TrimSpace(str[:idx]) + url = strings.TrimSpace(str[idx:]) + return notURL, url +} diff --git a/internal/parsers/grammar/parser.go b/internal/parsers/grammar/parser.go new file mode 100644 index 0000000..e38ced6 --- /dev/null +++ b/internal/parsers/grammar/parser.go @@ -0,0 +1,931 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "go/ast" + "go/token" + "slices" + "strconv" + "strings" + + "github.com/go-openapi/codescan/internal/parsers/security" + "github.com/go-openapi/codescan/internal/parsers/yaml" +) + +// Parser is the consumer contract for the grammar parser. The package +// ships *DefaultParser; the interface exists so tests can substitute +// a mock that fabricates Block values without running the full lex +// pipeline. +type Parser interface { + Parse(cg *ast.CommentGroup) Block + ParseAll(cg *ast.CommentGroup) []Block + ParseText(text string, pos token.Position) Block + ParseAs(kind AnnotationKind, text string, pos token.Position) Block +} + +// DefaultParser is the concrete Parser implementation. +type DefaultParser struct { + fset *token.FileSet + sink func(Diagnostic) +} + +// NewParser constructs a DefaultParser bound to a FileSet (needed to +// map *ast.CommentGroup positions to absolute source positions). +func NewParser(fset *token.FileSet, opts ...Option) *DefaultParser { + p := &DefaultParser{fset: fset} + for _, o := range opts { + o(p) + } + return p +} + +// Option configures a DefaultParser. +type Option func(*DefaultParser) + +// WithDiagnosticSink streams diagnostics to a callback in addition to +// accumulating them on the returned Block. +func WithDiagnosticSink(sink func(Diagnostic)) Option { + return func(p *DefaultParser) { p.sink = sink } +} + +//nolint:ireturn // stable seam. +func (p *DefaultParser) Parse(cg *ast.CommentGroup) Block { + lines := Preprocess(cg, p.fset) + tokens := Lex(lines) + return p.parseTokens(tokens) +} + +// ParseAll returns one Block per annotation in cg, in source order. +// A comment group with no annotation yields a single-element slice +// holding an UnboundBlock; a single-annotation comment yields the +// same Block as Parse, wrapped in a slice; multi-annotation comments +// yield one Block per annotation. +// +// Token partition: each annotation owns the slice of tokens from +// its index up to (but excluding) the next annotation. The first +// annotation also owns the pre-annotation prose, so its +// PreambleTitle / PreambleDescription match Parse(cg)'s. Body +// tokens between annotations attach to the *preceding* annotation. +// +// In practice, multi-annotation comments like +// +// // swagger:model +// // swagger:strfmt date-time +// +// pair a schema-family annotation with a classifier; the schema +// builder's Walker can dispatch on each Block's AnnotationKind() +// without further partitioning. +func (p *DefaultParser) ParseAll(cg *ast.CommentGroup) []Block { + lines := Preprocess(cg, p.fset) + tokens := Lex(lines) + return p.parseAllTokens(tokens) +} + +//nolint:ireturn // stable seam. +func (p *DefaultParser) ParseText(text string, pos token.Position) Block { + lines := preprocessText(text, pos) + return p.parseTokens(Lex(lines)) +} + +//nolint:ireturn // stable seam. +func (p *DefaultParser) ParseAs(kind AnnotationKind, text string, pos token.Position) Block { + injected := AnnotationPrefix + kind.String() + "\n" + text + return p.ParseText(injected, pos) +} + +// Parse is the convenience wrapper around NewParser(fset).Parse(cg). +// +//nolint:ireturn // stable seam. +func Parse(cg *ast.CommentGroup, fset *token.FileSet) Block { + return NewParser(fset).Parse(cg) +} + +// ParseAll is the convenience wrapper around +// NewParser(fset).ParseAll(cg). +func ParseAll(cg *ast.CommentGroup, fset *token.FileSet) []Block { + return NewParser(fset).ParseAll(cg) +} + +// ParseTokens runs the parser on a pre-lexed token stream. Useful for +// tests and LSP scenarios. +// +//nolint:ireturn // stable seam. +func ParseTokens(tokens []Token) Block { + p := &DefaultParser{} + return p.parseTokens(tokens) +} + +// parseAllTokens implements the multi-annotation slicing rule +// documented on ParseAll. +func (p *DefaultParser) parseAllTokens(tokens []Token) []Block { + var annIndices []int + for i, t := range tokens { + if t.Kind == TokenAnnotation { + annIndices = append(annIndices, i) + } + } + if len(annIndices) <= 1 { + // Zero annotations → UnboundBlock; one annotation → + // equivalent to Parse. Either way, the existing single- + // block path is correct — wrap in a slice. + return []Block{p.parseTokens(tokens)} + } + out := make([]Block, 0, len(annIndices)) + start := 0 + for i := range annIndices { + end := len(tokens) + if i+1 < len(annIndices) { + end = annIndices[i+1] + } + out = append(out, p.parseTokens(tokens[start:end])) + start = end + } + return out +} + +// parseState holds per-block parsing state. +// +// Today's parsers walk s.tokens via range loops (single-pass token +// classifier — order between annotation header and body items is +// flat, so a cursor adds no value). The `pos` field, `peek`, and +// `advance` below are scaffolding for the future order-sensitive +// productions (e.g. strict positional checks on EnumDeclBlock's +// annotation header → RAW_VALUE_ENUM body, or LSP partial-parse +// resumption from a cursor). When those productions land, the +// per-family parsers will switch to calling peek/advance directly +// instead of the range-loop pattern. +// +// To find every unused-on-purpose site: +// +// golangci-lint run --enable-only unused ./internal/parsers/grammar/... +type parseState struct { + tokens []Token + pos int //nolint:unused // see godoc — reserved for recursive-descent cursor + diags []Diagnostic + sink func(Diagnostic) +} + +func (s *parseState) emit(d Diagnostic) { + if s.sink != nil { + s.sink(d) + } + s.diags = append(s.diags, d) +} + +// peek returns the token at s.pos without consuming it. Reserved for +// the recursive-descent rewrite — see parseState godoc. +// +//nolint:unused // prepare full recursive-descent for order-sensitive grammar productions +func (s *parseState) peek() Token { + if s.pos >= len(s.tokens) { + return Token{Kind: TokenEOF} + } + return s.tokens[s.pos] +} + +// advance returns the token at s.pos and moves the cursor one forward. +// Reserved for the recursive-descent rewrite — see parseState godoc. +// +//nolint:unused // prepare full recursive-descent for order-sensitive grammar productions +func (s *parseState) advance() Token { + t := s.peek() + if s.pos < len(s.tokens) { + s.pos++ + } + return t +} + +// parseTokens dispatches the stream to the family-specific parser. +// +//nolint:ireturn // stable seam. +func (p *DefaultParser) parseTokens(tokens []Token) Block { + s := &parseState{tokens: tokens, sink: p.sink} + annIdx := findAnnotation(tokens) + if annIdx < 0 { + return s.parseUnboundBlock() + } + + annTok := tokens[annIdx] + kind := AnnotationKindFromName(annTok.Name) + switch kind.family() { + case familySchema: + return s.parseSchemaBlock(annIdx, annTok, kind) + case familyOperation: + return s.parseOperationBlock(annIdx, annTok, kind) + case familyMeta: + return s.parseMetaBlock(annIdx, annTok) + case familyClassifier: + return s.parseClassifierBlock(annIdx, annTok, kind) + case familyUnknown: + fallthrough + default: + return s.parseUnboundBlock() + } +} + +// findAnnotation returns the index of the first TokenAnnotation, or -1. +func findAnnotation(tokens []Token) int { + for i, t := range tokens { + if t.Kind == TokenAnnotation { + return i + } + } + return -1 +} + +// extractTitleDesc walks the prose tokens of a block (or a sub-slice +// such as the pre-annotation preamble) and returns: +// +// - title — TokenTitle texts joined with "\n" +// - desc — TokenDesc texts joined with "\n", with internal blank +// lines preserved as "" entries (paragraph breaks) +// - lines — interleaved title/desc/blank entries in source order, +// blanks rendered as "" — the legacy ProseLines / PreambleLines +// shape consumers still rely on +// +// Join semantics match helpers.JoinDropLast — single trailing blank is +// dropped from each side, internal blanks are kept verbatim. This +// replaces the earlier `" "` joiner + TrimSpace which collapsed +// paragraph breaks and merged multi-line titles into a single +// flowing line. +func extractTitleDesc(tokens []Token) (title, desc string, lines []string) { + var titleLines, descLines []string + const ( + stateBeforeTitle = iota + stateInTitle + stateInDesc + ) + state := stateBeforeTitle + + for _, t := range tokens { + switch t.Kind { + case TokenTitle: + titleLines = append(titleLines, t.Text) + lines = append(lines, t.Text) + state = stateInTitle + case TokenDesc: + descLines = append(descLines, t.Text) + lines = append(lines, t.Text) + state = stateInDesc + case TokenBlank: + lines = append(lines, "") + switch state { + case stateInTitle: + // Either an internal blank in a multi-paragraph title + // or a separator before the desc run starts. The + // trailing-blank trim below resolves the latter. + titleLines = append(titleLines, "") + case stateInDesc: + descLines = append(descLines, "") + } + default: + // ignored token + } + } + + lines = dropTrailingBlankLines(lines) + titleLines = dropTrailingBlankLines(titleLines) + descLines = dropTrailingBlankLines(descLines) + title = strings.Join(titleLines, "\n") + desc = strings.Join(descLines, "\n") + return title, desc, lines +} + +// dropTrailingBlankLines drops every trailing whitespace-only line +// from ls. Helpers' JoinDropLast only drops one, but its input is +// pre-trimmed by CollectScannerTitleDescription's heuristic-1 split. +// The state-machine here over-appends separator blanks (e.g. a +// TITLE → BLANK+BLANK → DESC sequence pushes both blanks onto +// titleLines), so we trim the whole tail to land on the same shape. +func dropTrailingBlankLines(ls []string) []string { + for len(ls) > 0 && strings.TrimSpace(ls[len(ls)-1]) == "" { + ls = ls[:len(ls)-1] + } + return ls +} + +// finaliseBase populates Title/Description/ProseLines/PreambleLines +// and copies accumulated diagnostics onto the base block. +// +// PreambleLines / PreambleTitle / PreambleDescription hold the subset +// of prose that appears BEFORE the block's annotation. Schema's +// top-level model builder consumes only pre-annotation prose so +// post-annotation text reads as body content (parity with v1's +// SectionedParser). Routes / operations / meta consult Title() and +// Description(), which span the whole block. +func (s *parseState) finaliseBase(base *baseBlock) { + t, d, lines := extractTitleDesc(s.tokens) + base.title = t + base.description = d + base.proseLines = lines + + preTokens := s.tokens + if annIdx := findAnnotation(s.tokens); annIdx >= 0 { + preTokens = s.tokens[:annIdx] + } + pt, pd, preLines := extractTitleDesc(preTokens) + base.preambleTitle = pt + base.preambleDescription = pd + base.preambleLines = preLines + + base.diagnostics = append(base.diagnostics, s.diags...) +} + +// --- UnboundBlock ------------------------------------------------------------ + +//nolint:ireturn // stable seam. +func (s *parseState) parseUnboundBlock() Block { + base := newBaseBlock(AnnUnknown, firstMeaningfulPos(s.tokens)) + for _, t := range s.tokens { + s.consumeBodyToken(base, t, AnnUnknown) + } + s.finaliseBase(base) + return &UnboundBlock{baseBlock: base} +} + +// firstMeaningfulPos picks the first non-blank, non-EOF token's +// position so an UnboundBlock has a sensible Pos(). +func firstMeaningfulPos(tokens []Token) token.Position { + for _, t := range tokens { + switch t.Kind { + case TokenEOF, TokenBlank: + continue + default: + return t.Pos + } + } + return token.Position{} +} + +// --- Schema family ----------------------------------------------------------- + +//nolint:ireturn // stable seam. +func (s *parseState) parseSchemaBlock(annIdx int, annTok Token, kind AnnotationKind) Block { + base := newBaseBlock(kind, annTok.Pos) + + // Validate annotation arguments before walking body tokens so any + // emitted diagnostics land on the block via finaliseBase. + switch kind { + case AnnParameters: + if len(identArgs(annTok)) == 0 { + s.emit(Errorf(annTok.Pos, CodeMissingRequiredArg, + "swagger:parameters requires at least one operation id reference")) + } + case AnnName: + if firstIdentArg(annTok) == "" { + s.emit(Errorf(annTok.Pos, CodeMissingRequiredArg, + "swagger:name requires a member name override argument")) + } + default: + // other schema-family annotations either accept no args (AnnModel) + // or accept an optional name (AnnResponse). + } + + // Walk pre + post tokens; pre-annotation prose contributes to + // Title/Description (already classified by the lexer); pre-annotation + // body tokens (rare, godoc-style "annotation at the bottom") are + // treated the same as post-annotation body tokens. + for i, t := range s.tokens { + if i == annIdx { + continue + } + s.consumeBodyToken(base, t, kind) + } + s.finaliseBase(base) + + switch kind { + case AnnModel: + return &ModelBlock{baseBlock: base, Name: firstIdentArg(annTok)} + case AnnResponse: + return &ResponseBlock{baseBlock: base, Name: firstIdentArg(annTok)} + case AnnParameters: + return &ParametersBlock{baseBlock: base, OperationIDs: identArgs(annTok)} + case AnnName: + return &NameBlock{baseBlock: base, Name: firstIdentArg(annTok)} + default: + return &UnboundBlock{baseBlock: base} + } +} + +// --- Operation family -------------------------------------------------------- + +//nolint:ireturn // stable seam. +func (s *parseState) parseOperationBlock(annIdx int, annTok Token, kind AnnotationKind) Block { + base := newBaseBlock(kind, annTok.Pos) + + method, path, tags, opID := s.parseOperationArgs(annTok) + + // swagger:route does not allow OPAQUE_YAML; flag if seen. + allowYAML := kind == AnnOperation + for i, t := range s.tokens { + if i == annIdx { + continue + } + if t.Kind == TokenOpaqueYaml && !allowYAML { + s.emit(Errorf(t.Pos, CodeUnexpectedToken, + "OPAQUE_YAML body is not legal under swagger:route")) + continue + } + s.consumeBodyToken(base, t, kind) + } + s.finaliseBase(base) + + if kind == AnnRoute { + return &RouteBlock{ + baseBlock: base, + Method: method, + Path: path, + Tags: tags, + OpID: opID, + } + } + return &InlineOperationBlock{ + baseBlock: base, + Method: method, + Path: path, + Tags: tags, + OpID: opID, + } +} + +// parseOperationArgs extracts METHOD, /path, [tags…], OperationID per +// 50-full.md §4 OperationArgs. Trailing IDENT_NAME is the OpID; any +// preceding IDENT_NAMEs are tags. +func (s *parseState) parseOperationArgs(annTok Token) (method, path string, tags []string, opID string) { + args := annTok.Args + // Method. + if len(args) > 0 && args[0].Kind == TokenHTTPMethod { + method = args[0].Text + args = args[1:] + } else { + s.emit(Errorf(annTok.Pos, CodeMalformedOperation, + "swagger:%s: missing or invalid HTTP method", annTok.Name)) + } + // Path. + if len(args) > 0 && args[0].Kind == TokenURLPath { + path = args[0].Text + args = args[1:] + } else { + s.emit(Errorf(annTok.Pos, CodeMalformedOperation, + "swagger:%s: missing or invalid URL path", annTok.Name)) + } + // Trailing ident is the OpID; any preceding idents are tags. + if len(args) == 0 { + s.emit(Errorf(annTok.Pos, CodeMalformedOperation, + "swagger:%s: missing operation id", annTok.Name)) + return method, path, nil, "" + } + last := args[len(args)-1] + if last.Kind != TokenIdentName { + s.emit(Errorf(last.Pos, CodeMalformedOperation, + "swagger:%s: trailing operation id must be an identifier", annTok.Name)) + } + opID = last.Text + args = args[:len(args)-1] + for _, a := range args { + if a.Kind == TokenIdentName { + tags = append(tags, a.Text) + } + } + return method, path, tags, opID +} + +// --- Meta family ------------------------------------------------------------- + +//nolint:ireturn // stable seam. +func (s *parseState) parseMetaBlock(annIdx int, annTok Token) Block { + base := newBaseBlock(AnnMeta, annTok.Pos) + for i, t := range s.tokens { + if i == annIdx { + continue + } + s.consumeBodyToken(base, t, AnnMeta) + } + s.finaliseBase(base) + return &MetaBlock{baseBlock: base} +} + +// --- Classifier family ------------------------------------------------------- + +//nolint:ireturn // stable seam. +func (s *parseState) parseClassifierBlock(annIdx int, annTok Token, kind AnnotationKind) Block { + base := newBaseBlock(kind, annTok.Pos) + + // Classifier bodies are prose-only (per 23-classifier-grammar.md). + // EnumDeclBlock additionally allows a multi-line RAW_VALUE_ENUM + // body. Other body content surfaces as a context-invalid warning. + var enumBody string + for i, t := range s.tokens { + if i == annIdx { + continue + } + switch t.Kind { + case TokenTitle, TokenDesc, TokenBlank, TokenEOF, TokenAnnotation: + // Prose / structural — counted by extractTitleDesc. + case TokenRawValueBody: + if kind == AnnEnum && t.Keyword == "enum" { + enumBody = t.Body + continue + } + s.emit(Warnf(t.Pos, CodeContextInvalid, + "keyword %q not valid under swagger:%s", t.Keyword, kind)) + case TokenRawBlockBody: + s.emit(Warnf(t.Pos, CodeContextInvalid, + "keyword %q not valid under swagger:%s", t.Keyword, kind)) + case TokenKeyword: + s.emit(Warnf(t.Pos, CodeContextInvalid, + "keyword %q not valid under swagger:%s", t.Name, kind)) + case TokenOpaqueYaml: + s.emit(Warnf(t.Pos, CodeUnexpectedToken, + "YAML body not legal under swagger:%s", kind)) + default: + // ignored kind + } + } + + // Argument validation runs before finaliseBase so its diagnostics + // reach the returned Block. + switch kind { + case AnnEnum: + form, name, _, valuesArgs := splitEnumArgs(annTok) + if name == "" && len(valuesArgs) == 0 && enumBody == "" { + s.emit(Errorf(annTok.Pos, CodeMissingRequiredArg, + "swagger:enum requires a name and/or a value list")) + } + s.finaliseBase(base) + return &EnumDeclBlock{ + baseBlock: base, + Name: name, + InlineForm: form, + InlineArgs: valuesArgs, + BodyValues: enumBody, + } + case AnnStrfmt: + if firstIdentArg(annTok) == "" { + s.emit(Errorf(annTok.Pos, CodeMissingRequiredArg, + "swagger:strfmt requires a name argument")) + } + case AnnDefaultName: + if !annTok.HasArg(1) { + s.emit(Errorf(annTok.Pos, CodeMissingRequiredArg, + "swagger:default requires a value argument")) + } + case AnnType: + if len(annTok.Args) == 0 { + s.emit(Errorf(annTok.Pos, CodeMissingRequiredArg, + "swagger:type requires a type-reference argument")) + } else if annTok.Args[0].Kind != TokenTypeRef { + s.emit(Errorf(annTok.Args[0].Pos, CodeInvalidTypeRef, + "swagger:type: %q is not a recognised type reference", annTok.Args[0].Text)) + } + case AnnAllOf, AnnIgnore, AnnAlias, AnnFile: + // Optional / no args. + default: + // ignored annotation + } + s.finaliseBase(base) + return &ClassifierBlock{baseBlock: base, Args: annTok.Args} +} + +// splitEnumArgs reconstructs the (form, name, name-pos, value-tokens) +// from a TokenAnnotation produced for swagger:enum. +func splitEnumArgs(annTok Token) (enumArgsForm, string, token.Position, []Token) { + if len(annTok.Args) == 0 { + return enumFormEmpty, "", annTok.Pos, nil + } + + first := annTok.Args[0] + + // Bracketed-only — single JSON_VALUE arg, no name. + if len(annTok.Args) == 1 && first.Kind == TokenJSONValue { + return enumFormBracketedOnly, "", first.Pos, annTok.Args + } + // Plain-only — single COMMA_LIST_VALUE arg, no name. + if len(annTok.Args) == 1 && first.Kind == TokenCommaListValue { + return enumFormPlainOnly, "", first.Pos, annTok.Args + } + // Name-only — single IDENT_NAME arg. + if len(annTok.Args) == 1 && first.Kind == TokenIdentName { + return enumFormNameOnly, first.Text, first.Pos, nil + } + // Name + bracketed. + if len(annTok.Args) == 2 && first.Kind == TokenIdentName && annTok.Args[1].Kind == TokenJSONValue { + return enumFormNamePlusBracketed, first.Text, first.Pos, annTok.Args[1:] + } + // Name + plain. + if len(annTok.Args) == 2 && first.Kind == TokenIdentName && annTok.Args[1].Kind == TokenCommaListValue { + return enumFormNamePlusPlain, first.Text, first.Pos, annTok.Args[1:] + } + return enumFormEmpty, "", annTok.Pos, annTok.Args +} + +// firstIdentArg returns the Text of the first IDENT_NAME-typed arg, or "". +func firstIdentArg(annTok Token) string { + for _, a := range annTok.Args { + if a.Kind == TokenIdentName { + return a.Text + } + } + return "" +} + +// identArgs returns the Text of every IDENT_NAME-typed arg in source order. +func identArgs(annTok Token) []string { + out := make([]string, 0, len(annTok.Args)) + for _, a := range annTok.Args { + if a.Kind == TokenIdentName { + out = append(out, a.Text) + } + } + return out +} + +// --- Body-token consumption (shared across families) ----------------------- + +// consumeBodyToken folds one token from the stream into the block's +// state. Prose, blank, EOF, and the annotation are no-ops here (they +// are consumed by extractTitleDesc / dispatch). +func (s *parseState) consumeBodyToken(base *baseBlock, t Token, kind AnnotationKind) { + switch t.Kind { + case TokenEOF, TokenBlank, TokenTitle, TokenDesc, TokenAnnotation: + // Handled elsewhere. + case TokenKeyword: + s.emitInlineKeyword(base, t, kind) + case TokenRawBlockBody: + s.emitRawBlock(base, t, kind) + case TokenRawValueBody: + s.emitRawValue(base, t, kind) + case TokenOpaqueYaml: + base.yamlBlocks = append(base.yamlBlocks, RawYAML{ + Pos: t.Pos, + Text: t.Body, + Truncated: t.Truncated, + }) + if t.Truncated { + s.emit(Errorf(t.Pos, CodeUnterminatedYAML, + "YAML body opened with --- but never closed")) + } + default: + // Stray value-only tokens (e.g. trailing IDENT_NAME with no + // owning keyword) are not legal at the body level. + s.emit(Warnf(t.Pos, CodeUnexpectedToken, + "unexpected %s token", t.Kind)) + } +} + +// emitInlineKeyword stores an inline-value keyword as a Property. +func (s *parseState) emitInlineKeyword(base *baseBlock, t Token, kind AnnotationKind) { + kw, ok := Lookup(t.Name) + if !ok { + // Lexer should never emit TokenKeyword for unknown keywords, + // but guard defensively. + return + } + if !contextLegal(kw, kind) { + s.emit(Warnf(t.Pos, CodeContextInvalid, + "keyword %q not valid under swagger:%s (legal in: %s)", + kw.Name, kind, formatContexts(kw))) + } + prop := Property{ + Keyword: kw, + Pos: t.Pos, + Value: t.Text, + ItemsDepth: t.ItemsDepth, + Typed: s.typeInlineValue(kw, t), + } + base.properties = append(base.properties, prop) +} + +// typeInlineValue converts the typed argument token's payload into a +// TypedValue per the keyword's declared shape. Failures emit +// non-fatal diagnostics; on failure Typed.Type stays at ShapeNone so +// consumers can distinguish "no conversion" from "zero converted". +func (s *parseState) typeInlineValue(kw Keyword, t Token) TypedValue { + if len(t.Args) == 0 { + return TypedValue{} + } + arg := t.Args[0] + switch kw.Shape { + case ShapeNumber: + op, rest := splitCmpOperator(arg.Text) + n, err := strconv.ParseFloat(strings.TrimSpace(rest), 64) + if err != nil { + s.emit(Errorf(arg.Pos, CodeInvalidNumber, + "%s: %q is not a valid number", kw.Name, arg.Text)) + return TypedValue{} + } + return TypedValue{Type: ShapeNumber, Op: op, Number: n} + case ShapeInt: + i, err := strconv.ParseInt(strings.TrimSpace(arg.Text), 10, 64) + if err != nil { + s.emit(Errorf(arg.Pos, CodeInvalidInteger, + "%s: %q is not a valid integer", kw.Name, arg.Text)) + return TypedValue{} + } + return TypedValue{Type: ShapeInt, Integer: i} + case ShapeBool: + b, ok := parseBool(arg.Text) + if !ok { + s.emit(Errorf(arg.Pos, CodeInvalidBoolean, + "%s: %q is not a valid boolean (expected true or false)", kw.Name, arg.Text)) + return TypedValue{} + } + return TypedValue{Type: ShapeBool, Boolean: b} + case ShapeEnumOption: + for _, allowed := range kw.Values { + if strings.EqualFold(arg.Text, allowed) { + return TypedValue{Type: ShapeEnumOption, String: allowed} + } + } + s.emit(Errorf(arg.Pos, CodeInvalidEnumOption, + "%s: %q is not one of {%s}", + kw.Name, arg.Text, strings.Join(kw.Values, ", "))) + return TypedValue{} + case ShapeNone, ShapeString, ShapeCommaList, ShapeRawBlock, ShapeRawValue: + return TypedValue{} + default: + // ignored shape + } + return TypedValue{} +} + +// emitRawBlock stores a multi-line raw-block body and, for extensions +// blocks, parses out the x-* entries. +func (s *parseState) emitRawBlock(base *baseBlock, t Token, kind AnnotationKind) { + kw, ok := Lookup(t.Keyword) + if !ok { + return + } + if !contextLegal(kw, kind) { + s.emit(Warnf(t.Pos, CodeContextInvalid, + "keyword %q not valid under swagger:%s (legal in: %s)", + kw.Name, kind, formatContexts(kw))) + } + prop := Property{ + Keyword: kw, + Pos: t.Pos, + Body: t.Body, + Raw: t.Raw, + Typed: TypedValue{Type: ShapeRawBlock}, + ItemsDepth: t.ItemsDepth, + } + base.properties = append(base.properties, prop) + + if kw.Name == "extensions" || kw.Name == "infoExtensions" { + s.collectExtensionsFromBody(base, t) + } + if kw.Name == KwSecurity { + base.security = security.Parse(t.Body) + } +} + +// emitRawValue stores a raw-value body keyword. +func (s *parseState) emitRawValue(base *baseBlock, t Token, kind AnnotationKind) { + kw, ok := Lookup(t.Keyword) + if !ok { + return + } + if !contextLegal(kw, kind) { + s.emit(Warnf(t.Pos, CodeContextInvalid, + "keyword %q not valid under swagger:%s (legal in: %s)", + kw.Name, kind, formatContexts(kw))) + } + prop := Property{ + Keyword: kw, + Pos: t.Pos, + Value: t.Body, + Body: t.Body, + Raw: t.Raw, + Typed: TypedValue{Type: ShapeRawValue}, + ItemsDepth: t.ItemsDepth, + } + base.properties = append(base.properties, prop) +} + +// collectExtensionsFromBody parses the body of an `extensions:` raw +// block via the typed-extensions service and registers one Extension +// per top-level x-* entry on the block, carrying its YAML-typed value +// (`bool` / `float64` / `string` / `[]any` / `map[string]any`). +// +// Non-x-* keys are dropped silently — the same posture the previous +// per-line parser had. A YAML parse failure emits a +// CodeInvalidYAMLExtensions warning and the block is skipped (no +// Extension entries are registered). +// +// Position is currently coarse — every Extension shares t.Pos +// (the `extensions:` keyword's position). Per-entry positions +// require *yaml.Node walking and can be added when LSP-grade +// diagnostics need them; see `.claude/plans/typed-extensions.md`. +func (s *parseState) collectExtensionsFromBody(base *baseBlock, t Token) { + data, err := yaml.TypedExtensions(t.Body) + if err != nil { + s.emit(Warnf(t.Pos, CodeInvalidYAMLExtensions, + "extensions block: %v", err)) + return + } + for name, value := range data { + if !isExtensionName(name) { + continue + } + base.extensions = append(base.extensions, Extension{ + Name: name, + Pos: t.Pos, + Value: value, + }) + } +} + +// isExtensionName reports whether s is a well-formed x-* / X-* name. +func isExtensionName(s string) bool { + const minExtNameLen = 3 + if len(s) < minExtNameLen { + return false + } + if (s[0] != 'x' && s[0] != 'X') || s[1] != '-' { + return false + } + return true +} + +// --- shared helpers --------------------------------------------------------- + +// contextLegal reports whether kw may appear under the given annotation +// kind. Returns true when the keyword's contexts overlap with the +// kind's allowed contexts; returns true unconditionally when the kind +// has no parser-layer policy (e.g. UnboundBlock). +func contextLegal(kw Keyword, kind AnnotationKind) bool { + allowed := allowedContexts(kind) + if allowed == nil { + return true + } + for _, c := range kw.Contexts { + if slices.Contains(allowed, c) { + return true + } + } + return false +} + +// allowedContexts maps an annotation kind to the keyword contexts that +// are legal under it. Mirrors the v1 grammar's `allowedContexts` plus +// the per-family adjustments from the new spec (e.g. classifier kinds +// have no body keywords). +func allowedContexts(kind AnnotationKind) []KeywordContext { + switch kind { + case AnnModel: + return []KeywordContext{CtxSchema, CtxItems} + case AnnParameters: + return []KeywordContext{CtxParam, CtxSchema, CtxItems} + case AnnResponse: + return []KeywordContext{CtxResponse, CtxSchema, CtxHeader, CtxItems} + case AnnOperation: + return []KeywordContext{CtxOperation, CtxParam, CtxSchema, CtxHeader, CtxItems, CtxResponse} + case AnnRoute: + return []KeywordContext{CtxRoute, CtxParam, CtxSchema, CtxHeader, CtxItems, CtxResponse} + case AnnMeta: + return []KeywordContext{CtxMeta, CtxSchema} + case AnnUnknown, + AnnStrfmt, AnnAlias, AnnName, AnnAllOf, AnnEnum, + AnnIgnore, AnnDefaultName, AnnType, AnnFile: + return nil + default: + return nil + } +} + +// formatContexts renders a keyword's legal contexts for diagnostics. +func formatContexts(kw Keyword) string { + parts := make([]string, len(kw.Contexts)) + for i, c := range kw.Contexts { + parts[i] = c.String() + } + return strings.Join(parts, ", ") +} + +// splitCmpOperator strips a leading comparison operator from a number +// value. Supports v1's `maximum: <5` form. +func splitCmpOperator(s string) (op, rest string) { + s = strings.TrimLeft(s, " \t") + for _, c := range []string{"<=", ">=", "<", ">", "="} { + if strings.HasPrefix(s, c) { + return c, s[len(c):] + } + } + return "", s +} + +// parseBool accepts only "true" or "false" (case-insensitive). stdlib +// strconv.ParseBool is too lenient for the swagger grammar. +func parseBool(s string) (bool, bool) { + s = strings.TrimSpace(s) + switch { + case strings.EqualFold(s, "true"): + return true, true + case strings.EqualFold(s, "false"): + return false, true + default: + return false, false + } +} diff --git a/internal/parsers/grammar/parser_test.go b/internal/parsers/grammar/parser_test.go new file mode 100644 index 0000000..a83c283 --- /dev/null +++ b/internal/parsers/grammar/parser_test.go @@ -0,0 +1,483 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "go/token" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// parseString runs the full parser on a // -less comment block. +// +//nolint:ireturn // this test helper is here precisely to mock the interface. +func parseString(t *testing.T, src string) Block { + t.Helper() + pos := token.Position{Filename: "test.go", Line: 1, Column: 1} + return (&DefaultParser{}).ParseText(src, pos) +} + +func TestParser_ModelBlock_NameFromAnnotation(t *testing.T) { + b := parseString(t, "swagger:model Pet") + mb, ok := b.(*ModelBlock) + require.True(t, ok, "expected *ModelBlock, got %T", b) + assert.Equal(t, "Pet", mb.Name) + assert.Equal(t, AnnModel, mb.AnnotationKind()) +} + +func TestParser_ResponseBlock_OptionalName(t *testing.T) { + b1 := parseString(t, "swagger:response petResp") + rb1, ok := b1.(*ResponseBlock) + require.True(t, ok) + assert.Equal(t, "petResp", rb1.Name) + + b2 := parseString(t, "swagger:response") + rb2, ok := b2.(*ResponseBlock) + require.True(t, ok) + assert.Empty(t, rb2.Name) +} + +func TestParser_NameBlock_CapturesIdentArg(t *testing.T) { + b := parseString(t, "swagger:name jsonFieldName") + nb, ok := b.(*NameBlock) + require.True(t, ok, "expected *NameBlock, got %T", b) + assert.Equal(t, "jsonFieldName", nb.Name) + assert.Equal(t, AnnName, nb.AnnotationKind()) + assert.Empty(t, b.Diagnostics()) +} + +func TestParser_NameBlock_MissingArgEmitsDiagnostic(t *testing.T) { + b := parseString(t, "swagger:name") + nb, ok := b.(*NameBlock) + require.True(t, ok, "expected *NameBlock, got %T", b) + assert.Empty(t, nb.Name) + require.NotEmpty(t, b.Diagnostics()) + assert.Equal(t, CodeMissingRequiredArg, b.Diagnostics()[0].Code) +} + +// parseAllString runs ParseAll on a // -less comment block. +func parseAllString(t *testing.T, src string) []Block { + t.Helper() + pos := token.Position{Filename: "test.go", Line: 1, Column: 1} + lines := preprocessText(src, pos) + tokens := Lex(lines) + return (&DefaultParser{}).parseAllTokens(tokens) +} + +func TestParser_ParseAll_SingleAnnotation(t *testing.T) { + blocks := parseAllString(t, "swagger:model Pet") + require.Len(t, blocks, 1) + mb, ok := blocks[0].(*ModelBlock) + require.True(t, ok) + assert.Equal(t, "Pet", mb.Name) +} + +func TestParser_ParseAll_NoAnnotation(t *testing.T) { + blocks := parseAllString(t, "Just a docstring.") + require.Len(t, blocks, 1) + _, ok := blocks[0].(*UnboundBlock) + require.True(t, ok) + assert.Equal(t, "Just a docstring.", blocks[0].Title()) +} + +func TestParser_ParseAll_TwoAnnotations(t *testing.T) { + src := `Pet model documentation. + +swagger:model Pet +swagger:strfmt date-time` + blocks := parseAllString(t, src) + require.Len(t, blocks, 2) + + // First block: ModelBlock owns the pre-annotation prose. + mb, ok := blocks[0].(*ModelBlock) + require.True(t, ok, "expected blocks[0] *ModelBlock, got %T", blocks[0]) + assert.Equal(t, "Pet", mb.Name) + assert.Equal(t, "Pet model documentation.", mb.PreambleTitle()) + + // Second block: ClassifierBlock for swagger:strfmt. + cb, ok := blocks[1].(*ClassifierBlock) + require.True(t, ok, "expected blocks[1] *ClassifierBlock, got %T", blocks[1]) + arg, hasArg := cb.AnnotationArg() + require.True(t, hasArg) + assert.Equal(t, "date-time", arg) +} + +func TestParser_ParseAll_ThreeAnnotations(t *testing.T) { + src := `swagger:model Bag +swagger:strfmt +swagger:ignore` + blocks := parseAllString(t, src) + require.Len(t, blocks, 3) + + _, ok := blocks[0].(*ModelBlock) + require.True(t, ok, "expected blocks[0] *ModelBlock, got %T", blocks[0]) + cb1, ok := blocks[1].(*ClassifierBlock) + require.True(t, ok) + assert.Equal(t, AnnStrfmt, cb1.AnnotationKind()) + cb2, ok := blocks[2].(*ClassifierBlock) + require.True(t, ok) + assert.Equal(t, AnnIgnore, cb2.AnnotationKind()) +} + +func TestBlock_AnnotationArg(t *testing.T) { + cases := []struct { + name string + src string + wantArg string + wantOK bool + }{ + {"model with name", "swagger:model Pet", "Pet", true}, + {"bare model", "swagger:model", "", false}, + {"response with name", "swagger:response petResp", "petResp", true}, + {"bare response", "swagger:response", "", false}, + {"name", "swagger:name jsonField", "jsonField", true}, + {"strfmt", "swagger:strfmt date-time", "date-time", true}, + {"bare strfmt", "swagger:strfmt", "", false}, + {"ignore", "swagger:ignore", "", false}, + {"alias", "swagger:alias", "", false}, + {"unbound prose", "Just a docstring.", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + b := parseString(t, tc.src) + arg, ok := b.AnnotationArg() + assert.Equal(t, tc.wantArg, arg) + assert.Equal(t, tc.wantOK, ok) + }) + } +} + +func TestParser_ParametersBlock_RequiresAtLeastOneArg(t *testing.T) { + b := parseString(t, "swagger:parameters listPets getPet") + pb, ok := b.(*ParametersBlock) + require.True(t, ok) + assert.Equal(t, []string{"listPets", "getPet"}, pb.OperationIDs) + assert.Empty(t, b.Diagnostics()) + + bad := parseString(t, "swagger:parameters") + pbad, ok := bad.(*ParametersBlock) + require.True(t, ok) + assert.Empty(t, pbad.OperationIDs) + require.NotEmpty(t, bad.Diagnostics()) + assert.Equal(t, CodeMissingRequiredArg, bad.Diagnostics()[0].Code) +} + +func TestParser_RouteBlock_BasicArgs(t *testing.T) { + src := `Lists pets. + +swagger:route GET /pets pets listPets + +Consumes: + - application/json + +Produces: + - application/json` + b := parseString(t, src) + rb, ok := b.(*RouteBlock) + require.True(t, ok, "expected *RouteBlock, got %T", b) + assert.Equal(t, "GET", rb.Method) + assert.Equal(t, "/pets", rb.Path) + assert.Equal(t, []string{"pets"}, rb.Tags) + assert.Equal(t, "listPets", rb.OpID) + assert.Equal(t, "Lists pets.", rb.Title()) + + // Body raw blocks present as Properties. + consumes, ok := rb.GetList("consumes") + require.True(t, ok) + assert.Contains(t, consumes[0], "application/json") +} + +func TestParser_RouteBlock_RejectsYAMLBody(t *testing.T) { + src := `swagger:route GET /pets pets listPets + +--- +parameters: + - name: id +---` + b := parseString(t, src) + require.NotEmpty(t, b.Diagnostics()) + found := false + for _, d := range b.Diagnostics() { + if d.Code == CodeUnexpectedToken { + found = true + } + } + assert.True(t, found, "expected an unexpected-token diagnostic for OPAQUE_YAML under swagger:route") +} + +func TestParser_InlineOperation_AllowsYAMLBody(t *testing.T) { + src := `swagger:operation GET /pets pets listPets + +--- +parameters: + - name: id + in: query +---` + b := parseString(t, src) + ob, ok := b.(*InlineOperationBlock) + require.True(t, ok) + assert.Equal(t, "listPets", ob.OpID) + + yamlCount := 0 + for range ob.YAMLBlocks() { + yamlCount++ + } + assert.Equal(t, 1, yamlCount) + + for _, d := range b.Diagnostics() { + assert.NotEqual(t, CodeUnexpectedToken, d.Code) + } +} + +func TestParser_MetaBlock_KeywordsAndYAML(t *testing.T) { + src := `Booking API. + +Version: 1.0.0 +Host: api.example.com + +Consumes: + - application/json + +--- +servers: + - url: https://api.example.com/v1 +--- + +swagger:meta` + b := parseString(t, src) + mb, ok := b.(*MetaBlock) + require.True(t, ok) + assert.Equal(t, "Booking API.", mb.Title()) + + v, ok := mb.GetString("version") + require.True(t, ok) + assert.Equal(t, "1.0.0", v) + + h, ok := mb.GetString("host") + require.True(t, ok) + assert.Equal(t, "api.example.com", h) + + cons, ok := mb.GetList("consumes") + require.True(t, ok) + assert.NotEmpty(t, cons) + + yamlCount := 0 + for range mb.YAMLBlocks() { + yamlCount++ + } + assert.Equal(t, 1, yamlCount) +} + +func TestParser_ClassifierBlock_Strfmt(t *testing.T) { + b := parseString(t, "A MAC address.\n\nswagger:strfmt mac") + cb, ok := b.(*ClassifierBlock) + require.True(t, ok) + assert.Equal(t, AnnStrfmt, cb.AnnotationKind()) + require.Len(t, cb.Args, 1) + assert.Equal(t, "mac", cb.Args[0].Text) + assert.Empty(t, cb.Diagnostics()) +} + +func TestParser_ClassifierBlock_StrfmtMissingArg(t *testing.T) { + b := parseString(t, "swagger:strfmt") + require.NotEmpty(t, b.Diagnostics()) + assert.Equal(t, CodeMissingRequiredArg, b.Diagnostics()[0].Code) +} + +func TestParser_ClassifierBlock_TypeWithVocabulary(t *testing.T) { + b := parseString(t, "swagger:type string") + require.Empty(t, b.Diagnostics()) + + bad := parseString(t, "swagger:type custom") + require.NotEmpty(t, bad.Diagnostics()) + assert.Equal(t, CodeInvalidTypeRef, bad.Diagnostics()[0].Code) +} + +func TestParser_EnumDecl_NameOnly(t *testing.T) { + b := parseString(t, "swagger:enum Priority") + eb, ok := b.(*EnumDeclBlock) + require.True(t, ok) + assert.Equal(t, "Priority", eb.Name) + assert.Equal(t, enumFormNameOnly, eb.InlineForm) +} + +func TestParser_EnumDecl_PlainList(t *testing.T) { + b := parseString(t, "swagger:enum 1, 2, 3") + eb, ok := b.(*EnumDeclBlock) + require.True(t, ok) + assert.Empty(t, eb.Name) + assert.Equal(t, enumFormPlainOnly, eb.InlineForm) +} + +func TestParser_EnumDecl_Empty(t *testing.T) { + b := parseString(t, "swagger:enum") + require.NotEmpty(t, b.Diagnostics()) + assert.Equal(t, CodeMissingRequiredArg, b.Diagnostics()[0].Code) +} + +func TestParser_UnboundBlock(t *testing.T) { + src := `Name of the user. +required: true +maxLength: 64` + b := parseString(t, src) + ub, ok := b.(*UnboundBlock) + require.True(t, ok) + assert.Equal(t, AnnUnknown, ub.AnnotationKind()) + // UnboundBlocks now run title/desc classification too — first line + // ending in punctuation is title (heuristic 2). Required for the + // schema builder's PreambleTitle path on indirectly-referenced + // non-annotated types (interfaces / aliases). + assert.Equal(t, "Name of the user.", ub.Title()) + assert.Empty(t, ub.Description()) + + required, ok := ub.GetBool("required") + require.True(t, ok) + assert.True(t, required) + + maxLen, ok := ub.GetInt("maxLength") + require.True(t, ok) + assert.Equal(t, int64(64), maxLen) +} + +func TestParser_SchemaBody_NumericValidation(t *testing.T) { + src := `swagger:model Foo + +maximum: 100 +minimum: <0` + b := parseString(t, src) + mb, ok := b.(*ModelBlock) + require.True(t, ok) + + maximum, ok := mb.GetFloat("maximum") + require.True(t, ok) + assert.InDelta(t, 100.0, maximum, 0) + + // Operator preserved on Property.Typed. + for p := range mb.Properties() { + if p.Keyword.Name == "minimum" { + assert.Equal(t, "<", p.Typed.Op) + assert.InDelta(t, 0.0, p.Typed.Number, 0) + } + } +} + +func TestParser_SchemaBody_InvalidNumber(t *testing.T) { + b := parseString(t, "swagger:model Foo\n\nmaximum: notanumber") + found := false + for _, d := range b.Diagnostics() { + if d.Code == CodeInvalidNumber { + found = true + } + } + assert.True(t, found) +} + +func TestParser_SchemaBody_ExtensionsBlockExtractsXEntries(t *testing.T) { + src := `swagger:model Foo + +Extensions: + x-flag: true + x-name: hello` + b := parseString(t, src) + + count := 0 + for ext := range b.Extensions() { + count++ + switch ext.Name { + case "x-flag": + // YAML-typed: unquoted `true` is a bool. + assert.Equal(t, true, ext.Value) + case "x-name": + // YAML-typed: unquoted `hello` is a string. + assert.Equal(t, "hello", ext.Value) + } + } + assert.Equal(t, 2, count) +} + +// TestParser_SchemaBody_ExtensionsBlockTypedNested asserts that +// nested YAML mappings surface as typed map[string]any, not as +// yaml.v3's map[any]any or as a flat string. Closes the round-2 +// promise of `.claude/plans/typed-extensions.md`. +func TestParser_SchemaBody_ExtensionsBlockTypedNested(t *testing.T) { + src := `swagger:model Foo + +Extensions: + x-config: + enabled: true + threshold: 0.5 + tags: [a, b, c]` + b := parseString(t, src) + + var found bool + for ext := range b.Extensions() { + if ext.Name != "x-config" { + continue + } + found = true + cfg, ok := ext.Value.(map[string]any) + require.True(t, ok, "x-config: want map[string]any, got %T", ext.Value) + assert.Equal(t, true, cfg["enabled"]) + assert.Equal(t, 0.5, cfg["threshold"]) + tags, ok := cfg["tags"].([]any) + require.True(t, ok, "x-config.tags: want []any, got %T", cfg["tags"]) + assert.Equal(t, []any{"a", "b", "c"}, tags) + } + assert.True(t, found, "x-config Extension should be present") +} + +// TestParser_SchemaBody_ExtensionsBlockMalformedYAMLEmitsDiagnostic +// asserts the new CodeInvalidYAMLExtensions code fires when the body +// fails YAML parsing. +func TestParser_SchemaBody_ExtensionsBlockMalformedYAMLEmitsDiagnostic(t *testing.T) { + src := `swagger:model Foo + +Extensions: + x-broken: [unclosed` + b := parseString(t, src) + + count := 0 + for range b.Extensions() { + count++ + } + assert.Equal(t, 0, count, "malformed YAML: no Extension entries should be emitted") + + var sawDiag bool + for _, d := range b.Diagnostics() { + if d.Code == CodeInvalidYAMLExtensions { + sawDiag = true + break + } + } + assert.True(t, sawDiag, "expected CodeInvalidYAMLExtensions diagnostic") +} + +func TestParser_SchemaBody_DefaultRawValue(t *testing.T) { + b := parseString(t, "swagger:model Foo\n\ndefault: hello") + def, ok := b.GetString("default") + require.True(t, ok) + assert.Equal(t, "hello", def) +} + +func TestParser_SchemaBody_EnumRawValue(t *testing.T) { + src := `swagger:model Foo + +enum: a, b, c` + b := parseString(t, src) + v, ok := b.GetString("enum") + require.True(t, ok) + assert.Equal(t, "a, b, c", v) +} + +func TestParser_RouteBlock_GodocPrefix(t *testing.T) { + src := `GetPets swagger:route GET /pets pets listPets` + b := parseString(t, src) + rb, ok := b.(*RouteBlock) + require.True(t, ok) + assert.Equal(t, "GET", rb.Method) + assert.Equal(t, "/pets", rb.Path) + assert.Equal(t, "listPets", rb.OpID) +} diff --git a/internal/parsers/grammar/preprocess.go b/internal/parsers/grammar/preprocess.go new file mode 100644 index 0000000..29e416d --- /dev/null +++ b/internal/parsers/grammar/preprocess.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "go/ast" + "go/token" + "strings" + "unicode" + "unicode/utf8" +) + +// Line is one preprocessed comment line ready for the lexer. +// +// Text has the Go comment markers (// /* */) stripped along with the +// godoc continuation decoration (leading whitespace, asterisks, +// slashes, optional markdown table pipe). Used for keyword and +// annotation classification. +// +// Raw is the same source line with only the comment marker removed — +// content whitespace, indentation, and list markers are preserved. +// Used by the body accumulator so YAML / nested-map indentation +// survives intact. +// +// Pos points to the first character of Text in the source file. +type Line struct { + Text string + Raw string + Pos token.Position +} + +//nolint:gochecknoglobals // this is fine to declare a replacer as private global +var eolReplacer = strings.NewReplacer("\r\n", "\n", "\r", "\n") + +// Preprocess turns a Go comment group into a position-tagged []Line. +// Returns nil for nil inputs. Pure: no syscalls, no side-effects. +// +// Per 40-lexer.md §5, line endings are normalised before line +// splitting (\r\n → \n, lone \r → \n). +func Preprocess(cg *ast.CommentGroup, fset *token.FileSet) []Line { + if cg == nil || fset == nil { + return nil + } + + var out []Line + for _, c := range cg.List { + out = append(out, stripComment(eolReplacer.Replace(c.Text), fset.Position(c.Slash))...) + } + + return out +} + +// stripComment yields one Line per physical source line of one +// *ast.Comment. Handles both the // and /* */ forms. +func stripComment(raw string, basePos token.Position) []Line { + const markerLen = 2 // "//" / "/*" + switch { + case strings.HasPrefix(raw, "//"): + pos := basePos + pos.Column += markerLen + pos.Offset += markerLen + return []Line{stripLine(raw[markerLen:], pos, stripSingleGodocSpace)} + + case strings.HasPrefix(raw, "/*"): + body := strings.TrimSuffix(raw[markerLen:], "*/") + out := []Line{} + offset := 0 + for idx := 0; ; idx++ { + nl := strings.IndexByte(body[offset:], '\n') + + var seg string + if nl < 0 { + seg = body[offset:] + } else { + seg = body[offset : offset+nl] + } + + pos := basePos + if idx == 0 { + pos.Column += markerLen + pos.Offset += markerLen + } else { + pos.Line += idx + pos.Column = 1 + pos.Offset += markerLen + offset + } + out = append(out, stripLine(seg, pos, stripBlockContinuation)) + + if nl < 0 { + break + } + offset += nl + 1 + } + return out + + default: + return []Line{{Text: raw, Raw: raw, Pos: basePos}} + } +} + +func stripLine(s string, pos token.Position, rawStrip func(string) string) Line { + stripped := trimContentPrefix(s) + consumed := len(s) - len(stripped) + pos.Column += consumed + pos.Offset += consumed + return Line{Text: stripped, Raw: rawStrip(s), Pos: pos} +} + +// stripSingleGodocSpace is intentionally a no-op so Line.Raw preserves +// every character after the comment marker. Kept named so a future +// per-comment-kind raw-stripping strategy can slot in here. +func stripSingleGodocSpace(s string) string { return s } + +// stripBlockContinuation removes the `\s*\*\s?` decoration godoc +// /* */ continuation lines carry, preserving all indentation otherwise. +func stripBlockContinuation(s string) string { + leading := -1 + for i, r := range s { + if !unicode.IsSpace(r) { + leading = i + break + } + } + + if leading < 0 || s[leading] != '*' { // also: empty string, all-blanks string, or no '*' continuation go there + return s + } + + s = s[leading+1:] + r, offset := utf8.DecodeRuneInString(s) + if unicode.IsSpace(r) { + return s[offset:] + } + + return s +} + +// trimContentPrefix strips godoc-style leading decoration. Notably +// preserves leading "-" so the YAML fence "---" survives intact. +func trimContentPrefix(s string) string { + s = strings.TrimLeft(s, " \t*/") + s = strings.TrimPrefix(s, "|") + return strings.TrimLeft(s, " \t") +} + +// preprocessText handles raw text inputs (already stripped of comment +// markers) for ParseText / ParseAs. Per 40-lexer.md §5, line endings +// are normalised (\r\n → \n, lone \r → \n) before splitting. +func preprocessText(text string, basePos token.Position) []Line { + text = normaliseLineEndings(text) + rows := strings.Split(text, "\n") + out := make([]Line, 0, len(rows)) + for i, r := range rows { + pos := basePos + pos.Line += i + if i > 0 { + pos.Column = 1 + } + out = append(out, Line{Text: trimContentPrefix(r), Raw: r, Pos: pos}) + } + return out +} + +// normaliseLineEndings collapses \r\n and lone \r sequences into \n. +func normaliseLineEndings(s string) string { + return eolReplacer.Replace(s) +} diff --git a/internal/parsers/grammar/preprocess_test.go b/internal/parsers/grammar/preprocess_test.go new file mode 100644 index 0000000..54f1eff --- /dev/null +++ b/internal/parsers/grammar/preprocess_test.go @@ -0,0 +1,95 @@ +package grammar + +import ( + "iter" + "slices" + "testing" + + "github.com/go-openapi/testify/v2/assert" +) + +func TestStripBlockContinuation(t *testing.T) { + for testCase := range stripBlockTestCases() { + t.Run(testCase.Name, func(t *testing.T) { + assert.EqualT(t, testCase.Expected, stripBlockContinuation(testCase.Input)) + }) + } +} + +type stripBlockTestCase struct { + Name string + Input string + Expected string +} + +func stripBlockTestCases() iter.Seq[stripBlockTestCase] { + const blanks = " \t \u00a0 \u205f" + + return slices.Values([]stripBlockTestCase{ + { + Name: "empty string", + Input: "", + Expected: "", + }, + { + Name: "blank string with unicode whitespace", + Input: blanks, + Expected: blanks, + }, + { + Name: "all-whitespace returns input verbatim", + Input: " ", + Expected: " ", + }, + { + Name: "blanks with * string", + Input: blanks + "*", + Expected: "", + }, + { + Name: "blanks with * + indented string", + Input: blanks + "*\u2029 ", + Expected: " ", + }, + { + Name: "blanks with * + indented string", + Input: blanks + "*\u2029 indented", + Expected: " indented", + }, + { + Name: "blanks with * + string", + Input: blanks + "*notindented", + Expected: "notindented", + }, + { + Name: "no marker", + Input: "x", + Expected: "x", + }, + { + Name: "indented no marker", + Input: " x", + Expected: " x", + }, + { + Name: "canonical godoc continuation", + Input: " * hello", + Expected: "hello", + }, + { + Name: "Unicode whitespace around the marker", + Input: " * hello", + Expected: "hello", + }, + { + Name: "no marker preserves indentation", + Input: " not_a_continuation", + Expected: " not_a_continuation", + }, + { + Name: "marker without surrounding whitespace", + Input: "*hello", + Expected: "hello", + }, + }) +} diff --git a/internal/parsers/grammar/token.go b/internal/parsers/grammar/token.go new file mode 100644 index 0000000..e151e92 --- /dev/null +++ b/internal/parsers/grammar/token.go @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import "go/token" + +// TokenKind classifies a lexer-emitted token. The kinds map onto the +// terminal vocabulary listed in .claude/plans/grammar/50-full.md §1: +// +// - ANN_* → TokenAnnotation, Name carries the annotation label +// - KW_* → TokenKeyword, Name carries the keyword label +// - IDENT_NAME, JSON_VALUE, RAW_VALUE, TYPE_REF, HTTP_METHOD, +// URL_PATH → distinct kinds, payload in Text +// - NUMBER_VALUE / INT_VALUE / BOOL_VALUE / STRING_VALUE / +// COMMA_LIST_VALUE / ENUM_OPTION_VALUE → distinct kinds +// - RAW_BLOCK_ → TokenRawBlockBody with Keyword = "consumes"/"produces"/… +// - RAW_VALUE_ → TokenRawValueBody with Keyword = "default"/"example"/"enum" +// - OPAQUE_YAML → TokenOpaqueYaml +// - TITLE / DESC / BLANK / EOF → TokenTitle / TokenDesc / TokenBlank / TokenEOF +// +// The lexer also emits a few internal kinds (TokenYAMLFence, TokenText) +// that are consumed by intermediate stages and never appear in the +// final stream the parser consumes. +type TokenKind int + +const ( + TokenEOF TokenKind = iota + + TokenBlank + TokenTitle + TokenDesc + + TokenAnnotation // ANN_* — Name = annotation label + TokenKeyword // KW_* — Name = keyword label + TokenIdentName // IDENT_NAME + TokenJSONValue // JSON_VALUE + TokenRawValue // RAW_VALUE + TokenTypeRef // TYPE_REF + TokenHTTPMethod // HTTP_METHOD + TokenURLPath // URL_PATH + TokenNumberValue // NUMBER_VALUE + TokenIntValue // INT_VALUE + TokenBoolValue // BOOL_VALUE + TokenStringValue // STRING_VALUE + TokenCommaListValue // COMMA_LIST_VALUE + TokenEnumOption // ENUM_OPTION_VALUE + TokenRawBlockBody // RAW_BLOCK_ — Name = parent keyword + TokenRawValueBody // RAW_VALUE_ — Name = parent keyword + TokenOpaqueYaml // OPAQUE_YAML + + // Internal kinds used between pipeline stages; never reach the parser. + tokenYAMLFence // "---" delimiter recognised by the line classifier + tokenText // free-form prose line (re-typed to TITLE/DESC by prose classifier) + tokenKeywordPre // tentative keyword line (head + value-string), pre body-accumulation + tokenRawLine // raw line accumulated inside a YAML fence or raw block + tokenDirective // Go compiler/linter directive (//go:, //nolint:, …) — dropped before parsing +) + +// String renders a TokenKind for diagnostics. +func (k TokenKind) String() string { + switch k { + case TokenEOF: + return "EOF" + case TokenBlank: + return "BLANK" + case TokenTitle: + return "TITLE" + case TokenDesc: + return "DESC" + case TokenAnnotation: + return "ANN" + case TokenKeyword: + return "KW" + case TokenIdentName: + return "IDENT_NAME" + case TokenJSONValue: + return "JSON_VALUE" + case TokenRawValue: + return "RAW_VALUE" + case TokenTypeRef: + return "TYPE_REF" + case TokenHTTPMethod: + return "HTTP_METHOD" + case TokenURLPath: + return "URL_PATH" + case TokenNumberValue: + return "NUMBER_VALUE" + case TokenIntValue: + return "INT_VALUE" + case TokenBoolValue: + return "BOOL_VALUE" + case TokenStringValue: + return "STRING_VALUE" + case TokenCommaListValue: + return "COMMA_LIST_VALUE" + case TokenEnumOption: + return "ENUM_OPTION" + case TokenRawBlockBody: + return "RAW_BLOCK" + case TokenRawValueBody: + return "RAW_VALUE_BODY" + case TokenOpaqueYaml: + return "OPAQUE_YAML" + case tokenYAMLFence: + return "yaml-fence" + case tokenText: + return "text" + case tokenKeywordPre: + return "kw-pre" + case tokenRawLine: + return "raw-line" + case tokenDirective: + return "directive" + default: + return "?" + } +} + +// Token is one lexer-emitted item. Field population varies by kind: +// +// - TokenAnnotation: Name = annotation label, Args are pre-tokenised +// argument tokens already disambiguated per §1.2 of 50-full.md +// (IDENT_NAME, JSON_VALUE, RAW_VALUE, TYPE_REF, HTTP_METHOD, +// URL_PATH, etc.). +// - TokenKeyword: Name = canonical keyword label; SourceName = the +// literal as it appeared (case / alias preserved); ItemsDepth = +// number of leading "items." segments stripped. +// - TokenRawBlockBody / TokenRawValueBody: Keyword = the parent +// keyword; Body = body content joined by "\n"; Raw = verbatim +// source-indented content; ItemsDepth carried from the head if +// applicable. +// - TokenOpaqueYaml: Body = body content (between fences). +// - TokenIdentName / value-typed tokens: Text = payload string. +// - TokenTitle / TokenDesc: Text = prose line content. +// +// Pos is the source position of the first meaningful payload character. +type Token struct { + Kind TokenKind + Pos token.Position + + Name string // for TokenAnnotation / TokenKeyword: canonical label + SourceName string // keyword: literal as written (alias / case preserved) + Text string // payload for value tokens, prose lines, idents + Args []Token // for TokenAnnotation: positional argument tokens + ItemsDepth int // leading "items." depth (keyword tokens only) + Keyword string // for body tokens: parent keyword name + Body string // for body tokens: body content joined by "\n" + Raw string // verbatim source content (indentation preserved) + Truncated bool // body lexed without a closer (e.g. unmatched ---) +} + +// HasArg reports whether the annotation token has at least n positional +// arguments. Convenience used by the parser dispatchers. +func (t Token) HasArg(n int) bool { return len(t.Args) >= n } diff --git a/internal/parsers/grammar/walker.go b/internal/parsers/grammar/walker.go new file mode 100644 index 0000000..5b943fe --- /dev/null +++ b/internal/parsers/grammar/walker.go @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +// Walker is the functional-visitor surface a Block exposes for bulk +// dispatch. Consumers wire only the callbacks they care about — every +// nil field is a silent no-op. See .claude/plans/grammar/p7-schema-builder-redesign.md +// §0 for the design rationale. +// +// Iteration order: properties fire in source order within the Block — +// the order in which they appeared in the original comment group. The +// Walker walks ONE Block per call; ordering across blocks (multiple +// declarations, file order, dependency-discovery order) is the +// builder's concern, not the walker's. +// +// Dispatch order: +// +// 1. Title — fired once if non-empty, before any property. +// +// 2. Description — fired once if non-empty, before any property. +// +// 3. Properties — fired in source order, one callback per Property +// selected by Keyword.Shape (the table-driven shape, not Typed.Type +// — see note below): +// +// Keyword.Shape → callback payload +// ShapeNumber → Number (p, p.Typed.Number, exclusive) +// ShapeInt → Integer (p, p.Typed.Integer) +// ShapeBool → Bool (p, p.Typed.Boolean) +// ShapeString → String (p, p.Value) — string keywords keep raw in p.Value +// ShapeEnumOption → String (p, p.Typed.String) — closed-vocab choice +// ShapeRawBlock → Raw (p) — caller reads p.Body / p.Raw +// ShapeRawValue → Raw (p) +// ShapeCommaList → Raw (p) — caller splits via b.GetList +// ShapeNone → Raw (p) — fallback +// +// For an unknown keyword (Property.Keyword.Name empty, no entry in +// the keyword table) Unknown fires instead of any of the above. +// +// Note on Number/Integer/Bool typing failure: when the lexer rejects an +// invalid value (e.g. `maximum: notanumber`) the parser leaves +// Typed.Type at ShapeNone and emits a CodeInvalidNumber diagnostic. +// Walker still dispatches based on Keyword.Shape — Number/Integer/Bool +// callbacks fire with the zero value of the payload. Consumers should +// treat the Diagnostic callback as authoritative for malformed values +// rather than implementing their own re-validation. +// +// 4. Extensions — fired in source order after every property, once +// per Extension entry. +// 5. Diagnostic — fired interleaved with the steps above whenever the +// parser-attached diagnostics or walker-internal checks produce an +// entry. The order is: per-property diagnostics fire immediately +// before the property's main callback; block-level diagnostics +// (collected during Parse) fire before Title. +// +// FilterDepth gates property callbacks (Number/Integer/Bool/String/Raw/ +// Unknown). When FilterDepth >= 0, only properties whose ItemsDepth +// matches are dispatched. Title/Description/Extension/Diagnostic are +// unaffected. Pass -1 (or leave zero with no items in the block — see +// below) to receive every property; pass 0 for level-0 only; pass N for +// items.items.…N depth. +// +// **FilterDepth zero-value gotcha:** the Go zero value of FilterDepth +// is 0, which means "level-0 only" — the schema-side default. Items +// callers must explicitly set FilterDepth to the wanted depth; they +// cannot leave it at the zero value. The schema-side level-0 walker +// can leave it at zero by accident-and-design. Use AllDepths for +// "every depth" rather than -1 to make the intent explicit. +type Walker struct { + Title func(s string) + Description func(s string) + + Number func(p Property, val float64, exclusive bool) + Integer func(p Property, val int64) + Bool func(p Property, val bool) + String func(p Property, val string) + Raw func(p Property) + Unknown func(p Property) + + Extension func(ext Extension) + Diagnostic func(d Diagnostic) + + FilterDepth int +} + +// AllDepths is the FilterDepth sentinel meaning "fire property +// callbacks regardless of ItemsDepth". Use it explicitly rather than +// -1 so the intent reads at the call site. +const AllDepths = -1 + +// Walk dispatches one Block through w. Nil callbacks are silently +// ignored. See Walker for the dispatch contract. +// +// Walk reads only from b — it never mutates the Block or its properties. +// Walk is safe to call concurrently on the same Block from multiple +// goroutines as long as the Walker callbacks are themselves safe. +func (b *baseBlock) Walk(w Walker) { + // Block-level diagnostics fire first so consumers see them regardless + // of whether they wired any property callbacks. + if w.Diagnostic != nil { + for _, d := range b.diagnostics { + w.Diagnostic(d) + } + } + + if w.Title != nil && b.title != "" { + w.Title(b.title) + } + if w.Description != nil && b.description != "" { + w.Description(b.description) + } + + for _, p := range b.properties { + if !walkerAcceptsDepth(w.FilterDepth, p.ItemsDepth) { + continue + } + walkerDispatchProperty(w, p) + } + + if w.Extension != nil { + for _, e := range b.extensions { + w.Extension(e) + } + } +} + +// walkerAcceptsDepth reports whether a property at itemsDepth should be +// dispatched given the walker's FilterDepth. AllDepths admits everything. +func walkerAcceptsDepth(filter, itemsDepth int) bool { + if filter == AllDepths { + return true + } + return filter == itemsDepth +} + +// walkerDispatchProperty routes one property to the matching callback. +// Dispatch is by Keyword.Shape (the table-declared shape) rather than +// Typed.Type, so failed-typing properties (Typed.Type == ShapeNone with +// a CodeInvalidNumber diagnostic in tow) still reach their shape-typed +// callback — consumers see the zero payload alongside the diagnostic. +// +// Unknown keywords (empty Keyword.Name, no entry in the keyword table) +// take the Unknown path regardless of Shape. +func walkerDispatchProperty(w Walker, p Property) { + if p.Keyword.Name == "" { + if w.Unknown != nil { + w.Unknown(p) + } + return + } + + switch p.Keyword.Shape { + case ShapeNumber: + if w.Number != nil { + w.Number(p, p.Typed.Number, isExclusiveOp(p.Typed.Op)) + } + case ShapeInt: + if w.Integer != nil { + w.Integer(p, p.Typed.Integer) + } + case ShapeBool: + if w.Bool != nil { + w.Bool(p, p.Typed.Boolean) + } + case ShapeString: + // String-shaped keywords keep the raw value in p.Value; + // Typed.String is only set for ShapeEnumOption. + if w.String != nil { + w.String(p, p.Value) + } + case ShapeEnumOption: + if w.String != nil { + w.String(p, p.Typed.String) + } + case ShapeNone, ShapeCommaList, ShapeRawBlock, ShapeRawValue: + if w.Raw != nil { + w.Raw(p) + } + default: + if w.Raw != nil { + w.Raw(p) + } + } +} + +// isExclusiveOp reports whether the leading operator on a Number value +// signals exclusive-bound semantics. v1 grammar accepts `maximum: <5` +// (exclusive max) and `maximum: <=5` (inclusive max); the lexer keeps +// the operator on Typed.Op and Walker collapses it to a bool here. +func isExclusiveOp(op string) bool { + return op == "<" || op == ">" +} diff --git a/internal/parsers/grammar/walker_test.go b/internal/parsers/grammar/walker_test.go new file mode 100644 index 0000000..895450f --- /dev/null +++ b/internal/parsers/grammar/walker_test.go @@ -0,0 +1,218 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestWalker_LevelZeroFiresPerShape(t *testing.T) { + src := `swagger:model Foo + +maximum: 100 +maxLength: 64 +required: true +pattern: ^[a-z]+$` + b := parseString(t, src) + + type seen struct { + number []string + integer []string + boolean []string + stringv []string + } + var got seen + + b.Walk(Walker{ + FilterDepth: 0, + Number: func(p Property, _ float64, _ bool) { got.number = append(got.number, p.Keyword.Name) }, + Integer: func(p Property, _ int64) { got.integer = append(got.integer, p.Keyword.Name) }, + Bool: func(p Property, _ bool) { got.boolean = append(got.boolean, p.Keyword.Name) }, + String: func(p Property, _ string) { got.stringv = append(got.stringv, p.Keyword.Name) }, + }) + + assert.Equal(t, []string{"maximum"}, got.number) + assert.Equal(t, []string{"maxLength"}, got.integer) + assert.Equal(t, []string{"required"}, got.boolean) + assert.Equal(t, []string{"pattern"}, got.stringv) +} + +func TestWalker_NumberOpExclusiveSemantics(t *testing.T) { + src := `swagger:model Foo + +maximum: <100 +minimum: >=0` + b := parseString(t, src) + + got := map[string]bool{} + b.Walk(Walker{ + FilterDepth: 0, + Number: func(p Property, _ float64, exclusive bool) { got[p.Keyword.Name] = exclusive }, + }) + + assert.True(t, got["maximum"], "maximum: <100 should be exclusive") + assert.False(t, got["minimum"], "minimum: >=0 should be inclusive") +} + +func TestWalker_FilterDepthFiltersItems(t *testing.T) { + src := `swagger:model Foo + +maximum: 10 +items.maximum: 20 +items.items.maximum: 30` + b := parseString(t, src) + + collect := func(filter int) []float64 { + var out []float64 + b.Walk(Walker{ + FilterDepth: filter, + Number: func(_ Property, val float64, _ bool) { out = append(out, val) }, + }) + return out + } + + assert.Equal(t, []float64{10}, collect(0)) + assert.Equal(t, []float64{20}, collect(1)) + assert.Equal(t, []float64{30}, collect(2)) + assert.Equal(t, []float64{10, 20, 30}, collect(AllDepths)) +} + +func TestWalker_TitleAndDescription(t *testing.T) { + src := `Pet is a domestic animal. + +It has a name and a tag. + +swagger:model Pet` + b := parseString(t, src) + + var title, desc string + b.Walk(Walker{ + Title: func(s string) { title = s }, + Description: func(s string) { desc = s }, + }) + + assert.Equal(t, "Pet is a domestic animal.", title) + assert.Equal(t, "It has a name and a tag.", desc) +} + +func TestWalker_TitleNotFiredWhenEmpty(t *testing.T) { + // UnboundBlock with description but no title (no first-sentence + // terminator before the blank line — single-line docstrings become + // description per parser logic). + src := `swagger:model Foo + +maximum: 10` + b := parseString(t, src) + + called := false + b.Walk(Walker{ + Title: func(_ string) { called = true }, + }) + + assert.False(t, called, "Title callback should not fire when block has no title") +} + +func TestWalker_ExtensionsFire(t *testing.T) { + src := `swagger:model Foo + +Extensions: + x-flag: true + x-name: hello` + b := parseString(t, src) + + var names []string + b.Walk(Walker{ + Extension: func(ext Extension) { names = append(names, ext.Name) }, + }) + + assert.ElementsMatch(t, []string{"x-flag", "x-name"}, names) +} + +func TestWalker_DiagnosticsFire(t *testing.T) { + // "swagger:parameters" with no operationID emits CodeMissingRequiredArg. + b := parseString(t, "swagger:parameters") + + var codes []Code + b.Walk(Walker{ + Diagnostic: func(d Diagnostic) { codes = append(codes, d.Code) }, + }) + + require.NotEmpty(t, codes) + assert.Contains(t, codes, CodeMissingRequiredArg) +} + +func TestWalker_NilCallbacksAreNoops(t *testing.T) { + src := `swagger:model Foo + +maximum: 100 +required: true +pattern: ^a$` + b := parseString(t, src) + + // All callbacks nil — must not panic. + assert.NotPanics(t, func() { b.Walk(Walker{FilterDepth: 0}) }) +} + +func TestProperty_IsTyped(t *testing.T) { + src := `swagger:model Foo + +maximum: 100 +maxLength: 64 +required: true +pattern: ^[a-z]+$ +default: hello` + b := parseString(t, src) + + got := map[string]bool{} + for p := range b.Properties() { + got[p.Keyword.Name] = p.IsTyped() + } + + // Pre-typed (lexer populated Typed.Number/Integer/Boolean): + assert.True(t, got["maximum"], "maximum has Typed.Number") + assert.True(t, got["maxLength"], "maxLength has Typed.Integer") + assert.True(t, got["required"], "required has Typed.Boolean") + + // Raw — Typed.Type stays ShapeNone: + assert.False(t, got["pattern"], "pattern keeps raw in Value, Typed unused") + assert.False(t, got["default"], "default needs schema-type coercion outside grammar") +} + +func TestProperty_IsTyped_FailedTypingIsFalse(t *testing.T) { + // Failed typing: lexer rejects "notanumber" as ShapeNumber, leaves + // Typed.Type at ShapeNone, emits a CodeInvalidNumber diagnostic. + // IsTyped reports false, matching reality. + const kw = "maximum" + b := parseString(t, "swagger:model Foo\n\n"+kw+": notanumber") + for p := range b.Properties() { + if p.Keyword.Name == kw { + assert.False(t, p.IsTyped(), "failed typing leaves IsTyped false") + } + } +} + +func TestWalker_UnknownFiresForUntabledKeyword(t *testing.T) { + // "fakekeyword:" lands on an UnboundBlock as a Property whose + // Keyword.Name is empty (no entry in the keyword table). + src := `Some prose. +fakekeyword: 42` + b := parseString(t, src) + + var unknown []string + b.Walk(Walker{ + Unknown: func(p Property) { unknown = append(unknown, p.Value) }, + }) + + // The unknown-keyword path is exercised when the lexer emits a + // property with an empty Keyword.Name. If the current pipeline + // classifies "fakekeyword:" as prose rather than a property, + // Unknown stays empty — which is also valid behaviour. We assert + // no panic and consistent typing. + if len(unknown) > 0 { + assert.Equal(t, "42", unknown[0]) + } +} diff --git a/internal/parsers/lines.go b/internal/parsers/lines.go deleted file mode 100644 index e79c041..0000000 --- a/internal/parsers/lines.go +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import "strings" - -func JoinDropLast(lines []string) string { - l := len(lines) - lns := lines - if l > 0 && len(strings.TrimSpace(lines[l-1])) == 0 { - lns = lines[:l-1] - } - return strings.Join(lns, "\n") -} - -// Setter sets a string field from a multi lines comment. -// -// Usage: -// -// Setter(&op.Description) -// Setter(&op.Summary) -// -// Replaces this idiom: -// -// parsers.WithSetDescription(func(lines []string) { op.Description = parsers.JoinDropLast(lines) }), -func Setter(target *string) func([]string) { - return func(lines []string) { - *target = JoinDropLast(lines) - } -} - -func removeEmptyLines(lines []string) []string { - notEmpty := make([]string, 0, len(lines)) - - for _, l := range lines { - if len(strings.TrimSpace(l)) > 0 { - notEmpty = append(notEmpty, l) - } - } - - return notEmpty -} diff --git a/internal/parsers/lines_test.go b/internal/parsers/lines_test.go deleted file mode 100644 index ead2970..0000000 --- a/internal/parsers/lines_test.go +++ /dev/null @@ -1,66 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "testing" - - "github.com/go-openapi/testify/v2/assert" -) - -func TestJoinDropLast(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - lines []string - want string - }{ - {"nil", nil, ""}, - {"empty slice", []string{}, ""}, - {"single line", []string{"hello"}, "hello"}, - {"trailing blank dropped", []string{"hello", "world", " "}, "hello\nworld"}, - {"trailing empty dropped", []string{"hello", "world", ""}, "hello\nworld"}, - {"no trailing blank", []string{"hello", "world"}, "hello\nworld"}, - {"only blank", []string{" "}, ""}, - {"only empty", []string{""}, ""}, - {"middle blank preserved", []string{"hello", "", "world"}, "hello\n\nworld"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - assert.EqualT(t, tc.want, JoinDropLast(tc.lines)) - }) - } -} - -func TestSetter(t *testing.T) { - t.Parallel() - - var target string - set := Setter(&target) - set([]string{"line1", "line2", ""}) - assert.EqualT(t, "line1\nline2", target) -} - -func TestRemoveEmptyLines(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input []string - want []string - }{ - {"nil", nil, []string{}}, - {"all empty", []string{"", " ", "\t"}, []string{}}, - {"mixed", []string{"hello", "", "world", " "}, []string{"hello", "world"}}, - {"no empty", []string{"a", "b"}, []string{"a", "b"}}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, removeEmptyLines(tc.input)) - }) - } -} diff --git a/internal/parsers/matchers.go b/internal/parsers/matchers.go index 79dfc85..3000e31 100644 --- a/internal/parsers/matchers.go +++ b/internal/parsers/matchers.go @@ -6,27 +6,14 @@ package parsers import ( "go/ast" "regexp" - "slices" "strings" - - "github.com/go-openapi/codescan/internal/ifaces" ) const minMatchCount = 2 -func HasAnnotation(line string) bool { - return rxSwaggerAnnotation.MatchString(line) -} - -func IsAliasParam(prop ifaces.SwaggerTypable) bool { - in := prop.In() - return in == "query" || in == "path" || in == "formData" -} - -func IsAllowedExtension(ext string) bool { - return rxAllowedExtensions.MatchString(ext) -} - +// ExtractAnnotation returns the trailing identifier of a `swagger:` +// marker found anywhere on line, or ("", false) if no marker is +// present. Used by the scanner's annotation-classification index. func ExtractAnnotation(line string) (string, bool) { matches := rxSwaggerAnnotation.FindStringSubmatch(line) if len(matches) < minMatchCount { @@ -36,100 +23,32 @@ func ExtractAnnotation(line string) (string, bool) { return matches[1], true } -func AllOfMember(comments *ast.CommentGroup) bool { - return commentMatcher(rxAllOf)(comments) -} - -func FileParam(comments *ast.CommentGroup) bool { - return commentMatcher(rxFileUpload)(comments) -} - -func Ignored(comments *ast.CommentGroup) bool { - return commentMatcher(rxIgnoreOverride)(comments) -} - -func AliasParam(comments *ast.CommentGroup) bool { - return commentMatcher(rxAlias)(comments) -} - -func StrfmtName(comments *ast.CommentGroup) (string, bool) { - return commentSubMatcher(rxStrFmt)(comments) -} - -func ParamLocation(comments *ast.CommentGroup) (string, bool) { - return commentSubMatcher(rxIn)(comments) -} - -func EnumName(comments *ast.CommentGroup) (string, bool) { - return commentSubMatcher(rxEnum)(comments) -} - -func AllOfName(comments *ast.CommentGroup) (string, bool) { - return commentSubMatcher(rxAllOf)(comments) -} - -func NameOverride(comments *ast.CommentGroup) (string, bool) { - return commentSubMatcher(rxName)(comments) -} - -func DefaultName(comments *ast.CommentGroup) (string, bool) { - return commentSubMatcher(rxDefault)(comments) -} - -func TypeName(comments *ast.CommentGroup) (string, bool) { - return commentSubMatcher(rxType)(comments) -} - +// ModelOverride returns the name argument of a `swagger:model ` +// marker found anywhere in comments, or ("", true) when the marker +// is present without an argument (bare `swagger:model`). Returns +// ("", false) when no marker is found. func ModelOverride(comments *ast.CommentGroup) (string, bool) { return commentBlankSubMatcher(rxModelOverride)(comments) } +// ResponseOverride returns the name argument of a `swagger:response +// ` marker, matching the bare-marker semantics of ModelOverride. func ResponseOverride(comments *ast.CommentGroup) (string, bool) { return commentBlankSubMatcher(rxResponseOverride)(comments) } +// ParametersOverride returns every operation-id reference attached to +// a `swagger:parameters` marker. One marker can carry several +// operation ids; multiple markers across comments accumulate. func ParametersOverride(comments *ast.CommentGroup) ([]string, bool) { return commentMultipleSubMatcher(rxParametersOverride)(comments) } -func commentMatcher(rx *regexp.Regexp) func(*ast.CommentGroup) bool { - return func(comments *ast.CommentGroup) bool { - if comments == nil { - return false - } - - return slices.ContainsFunc(comments.List, func(cmt *ast.Comment) bool { - for ln := range strings.SplitSeq(cmt.Text, "\n") { - if rx.MatchString(ln) { - return true - } - } - - return false - }) - } -} - -func commentSubMatcher(rx *regexp.Regexp) func(*ast.CommentGroup) (string, bool) { - return func(comments *ast.CommentGroup) (string, bool) { - if comments == nil { - return "", false - } - - for _, cmt := range comments.List { - for ln := range strings.SplitSeq(cmt.Text, "\n") { - matches := rx.FindStringSubmatch(ln) - if len(matches) > 1 && len(strings.TrimSpace(matches[1])) > 0 { - return strings.TrimSpace(matches[1]), true - } - } - } - - return "", false - } -} - -// same as commentSubMatcher but returns true if a bare annotation is found, even without an empty submatch. +// commentBlankSubMatcher returns a matcher that searches comments for +// any line matching rx and returns the first non-blank submatch. +// When the marker is present but carries no argument, returns +// ("", true) so callers can distinguish "no marker" from "bare +// marker." See ModelOverride / ResponseOverride. func commentBlankSubMatcher(rx *regexp.Regexp) func(*ast.CommentGroup) (string, bool) { return func(comments *ast.CommentGroup) (string, bool) { if comments == nil { @@ -153,6 +72,9 @@ func commentBlankSubMatcher(rx *regexp.Regexp) func(*ast.CommentGroup) (string, } } +// commentMultipleSubMatcher returns a matcher that collects every +// non-blank submatch from comments, splitting whitespace-separated +// arguments into individual entries. See ParametersOverride. func commentMultipleSubMatcher(rx *regexp.Regexp) func(*ast.CommentGroup) ([]string, bool) { return func(comments *ast.CommentGroup) ([]string, bool) { if comments == nil { diff --git a/internal/parsers/matchers_test.go b/internal/parsers/matchers_test.go index 630daf8..36411b3 100644 --- a/internal/parsers/matchers_test.go +++ b/internal/parsers/matchers_test.go @@ -9,98 +9,8 @@ import ( "github.com/go-openapi/testify/v2/assert" "github.com/go-openapi/testify/v2/require" - - "github.com/go-openapi/codescan/internal/ifaces" - oaispec "github.com/go-openapi/spec" ) -// stubTypable is a minimal SwaggerTypable for testing IsAliasParam. -type stubTypable struct { - in string -} - -func (s stubTypable) In() string { return s.in } -func (s stubTypable) Typed(string, string) {} -func (s stubTypable) SetRef(oaispec.Ref) {} - -func (s stubTypable) Items() ifaces.SwaggerTypable { //nolint:ireturn // test stub - return s -} -func (s stubTypable) Schema() *oaispec.Schema { return nil } -func (s stubTypable) Level() int { return 0 } -func (s stubTypable) AddExtension(string, any) {} -func (s stubTypable) WithEnum(...any) {} -func (s stubTypable) WithEnumDescription(string) {} - -func TestHasAnnotation(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - line string - want bool - }{ - {"swagger:model", "// swagger:model Foo", true}, - {"swagger:route", "swagger:route GET /foo tags fooOp", true}, - {"swagger:parameters", "// swagger:parameters addFoo", true}, - {"swagger:response", "swagger:response notFound", true}, - {"swagger:operation", "// swagger:operation POST /bar tags barOp", true}, - {"no annotation", "// this is just a comment", false}, - {"empty", "", false}, - {"partial", "swagger:", false}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - assert.EqualT(t, tc.want, HasAnnotation(tc.line)) - }) - } -} - -func TestIsAliasParam(t *testing.T) { - t.Parallel() - - tests := []struct { - in string - want bool - }{ - {"query", true}, - {"path", true}, - {"formData", true}, - {"body", false}, - {"header", false}, - {"", false}, - } - - for _, tc := range tests { - t.Run(tc.in, func(t *testing.T) { - assert.EqualT(t, tc.want, IsAliasParam(stubTypable{in: tc.in})) - }) - } -} - -func TestIsAllowedExtension(t *testing.T) { - t.Parallel() - - tests := []struct { - ext string - want bool - }{ - {"x-foo", true}, - {"X-bar", true}, - {"x-", true}, - {"y-foo", false}, - {"foo", false}, - {"", false}, - } - - for _, tc := range tests { - t.Run(tc.ext, func(t *testing.T) { - assert.EqualT(t, tc.want, IsAllowedExtension(tc.ext)) - }) - } -} - func TestExtractAnnotation(t *testing.T) { t.Parallel() @@ -138,105 +48,6 @@ func makeCommentGroup(lines ...string) *ast.CommentGroup { return cg } -func TestCommentMatcher(t *testing.T) { - t.Parallel() - - t.Run("AllOfMember", func(t *testing.T) { - assert.TrueT(t, AllOfMember(makeCommentGroup("// swagger:allOf"))) - assert.TrueT(t, AllOfMember(makeCommentGroup("// swagger:allOf MyParent"))) - assert.FalseT(t, AllOfMember(makeCommentGroup("// just a comment"))) - assert.FalseT(t, AllOfMember(nil)) - }) - - t.Run("FileParam", func(t *testing.T) { - assert.TrueT(t, FileParam(makeCommentGroup("// swagger:file"))) - assert.FalseT(t, FileParam(makeCommentGroup("// swagger:model"))) - assert.FalseT(t, FileParam(nil)) - }) - - t.Run("Ignored", func(t *testing.T) { - assert.TrueT(t, Ignored(makeCommentGroup("// swagger:ignore"))) - assert.TrueT(t, Ignored(makeCommentGroup("// swagger:ignore Foo"))) - assert.FalseT(t, Ignored(makeCommentGroup("// swagger:model"))) - assert.FalseT(t, Ignored(nil)) - }) - - t.Run("AliasParam", func(t *testing.T) { - assert.TrueT(t, AliasParam(makeCommentGroup("// swagger:alias"))) - assert.FalseT(t, AliasParam(makeCommentGroup("// swagger:model"))) - assert.FalseT(t, AliasParam(nil)) - }) -} - -func TestCommentSubMatcher(t *testing.T) { - t.Parallel() - - t.Run("StrfmtName", func(t *testing.T) { - name, ok := StrfmtName(makeCommentGroup("// swagger:strfmt date-time")) - require.TrueT(t, ok) - assert.EqualT(t, "date-time", name) - - _, ok = StrfmtName(makeCommentGroup("// swagger:model Foo")) - assert.FalseT(t, ok) - - _, ok = StrfmtName(nil) - assert.FalseT(t, ok) - }) - - t.Run("ParamLocation", func(t *testing.T) { - loc, ok := ParamLocation(makeCommentGroup("// In: query")) - require.TrueT(t, ok) - assert.EqualT(t, "query", loc) - - loc, ok = ParamLocation(makeCommentGroup("// in: body")) - require.TrueT(t, ok) - assert.EqualT(t, "body", loc) - - _, ok = ParamLocation(makeCommentGroup("// no location")) - assert.FalseT(t, ok) - }) - - t.Run("EnumName", func(t *testing.T) { - name, ok := EnumName(makeCommentGroup("// swagger:enum Status")) - require.TrueT(t, ok) - assert.EqualT(t, "Status", name) - - _, ok = EnumName(nil) - assert.FalseT(t, ok) - }) - - t.Run("AllOfName", func(t *testing.T) { - name, ok := AllOfName(makeCommentGroup("// swagger:allOf MyParent")) - require.TrueT(t, ok) - assert.EqualT(t, "MyParent", name) - - // bare annotation: no submatch → returns false - _, ok = AllOfName(makeCommentGroup("// swagger:allOf")) - assert.FalseT(t, ok) - }) - - t.Run("NameOverride", func(t *testing.T) { - name, ok := NameOverride(makeCommentGroup("// swagger:name MyName")) - require.TrueT(t, ok) - assert.EqualT(t, "MyName", name) - - _, ok = NameOverride(nil) - assert.FalseT(t, ok) - }) - - t.Run("DefaultName", func(t *testing.T) { - name, ok := DefaultName(makeCommentGroup("// swagger:default MyDefault")) - require.TrueT(t, ok) - assert.EqualT(t, "MyDefault", name) - }) - - t.Run("TypeName", func(t *testing.T) { - name, ok := TypeName(makeCommentGroup("// swagger:type string")) - require.TrueT(t, ok) - assert.EqualT(t, "string", name) - }) -} - func TestCommentBlankSubMatcher(t *testing.T) { t.Parallel() diff --git a/internal/parsers/meta.go b/internal/parsers/meta.go deleted file mode 100644 index 2d357ca..0000000 --- a/internal/parsers/meta.go +++ /dev/null @@ -1,242 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "encoding/json" - "fmt" - "go/ast" - "net/mail" - "regexp" - "strings" - - "github.com/go-openapi/spec" -) - -type MetaSection struct { - Comments *ast.CommentGroup -} - -func metaTOSSetter(meta *spec.Info) func([]string) { - return func(lines []string) { - meta.TermsOfService = JoinDropLast(lines) - } -} - -func metaConsumesSetter(meta *spec.Swagger) func([]string) { - return func(consumes []string) { meta.Consumes = consumes } -} - -func metaProducesSetter(meta *spec.Swagger) func([]string) { - return func(produces []string) { meta.Produces = produces } -} - -func metaSchemeSetter(meta *spec.Swagger) func([]string) { - return func(schemes []string) { meta.Schemes = schemes } -} - -func metaSecuritySetter(meta *spec.Swagger) func([]map[string][]string) { - return func(secDefs []map[string][]string) { meta.Security = secDefs } -} - -func metaSecurityDefinitionsSetter(meta *spec.Swagger) func(json.RawMessage) error { - return func(jsonValue json.RawMessage) error { - var jsonData spec.SecurityDefinitions - err := json.Unmarshal(jsonValue, &jsonData) - if err != nil { - return err - } - meta.SecurityDefinitions = jsonData - return nil - } -} - -func metaVendorExtensibleSetter(meta *spec.Swagger) func(json.RawMessage) error { - return func(jsonValue json.RawMessage) error { - var jsonData spec.Extensions - err := json.Unmarshal(jsonValue, &jsonData) - if err != nil { - return err - } - for k := range jsonData { - if !rxAllowedExtensions.MatchString(k) { - return fmt.Errorf("invalid schema extension name, should start from `x-`: %s: %w", k, ErrParser) - } - } - meta.Extensions = jsonData - return nil - } -} - -func infoVendorExtensibleSetter(meta *spec.Swagger) func(json.RawMessage) error { - return func(jsonValue json.RawMessage) error { - var jsonData spec.Extensions - err := json.Unmarshal(jsonValue, &jsonData) - if err != nil { - return err - } - for k := range jsonData { - if !rxAllowedExtensions.MatchString(k) { - return fmt.Errorf("invalid schema extension name, should start from `x-`: %s: %w", k, ErrParser) - } - } - meta.Info.Extensions = jsonData - return nil - } -} - -func NewMetaParser(swspec *spec.Swagger) *SectionedParser { - sp := new(SectionedParser) - if swspec.Info == nil { - swspec.Info = new(spec.Info) - } - info := swspec.Info - sp.setTitle = func(lines []string) { - tosave := JoinDropLast(lines) - if len(tosave) > 0 { - tosave = rxStripTitleComments.ReplaceAllString(tosave, "") - } - info.Title = tosave - } - sp.setDescription = func(lines []string) { info.Description = JoinDropLast(lines) } - sp.taggers = []TagParser{ - NewMultiLineTagParser("TOS", newMultilineDropEmptyParser(rxTOS, metaTOSSetter(info)), false), - NewMultiLineTagParser("Consumes", newMultilineDropEmptyParser(rxConsumes, metaConsumesSetter(swspec)), false), - NewMultiLineTagParser("Produces", newMultilineDropEmptyParser(rxProduces, metaProducesSetter(swspec)), false), - NewSingleLineTagParser("Schemes", NewSetSchemes(metaSchemeSetter(swspec))), - NewMultiLineTagParser("Security", newSetSecurity(rxSecuritySchemes, metaSecuritySetter(swspec)), false), - NewMultiLineTagParser("SecurityDefinitions", NewYAMLParser(WithMatcher(rxSecurity), WithSetter(metaSecurityDefinitionsSetter(swspec))), true), - NewSingleLineTagParser("Version", &setMetaSingle{Spec: swspec, Rx: rxVersion, Set: setInfoVersion}), - NewSingleLineTagParser("Host", &setMetaSingle{Spec: swspec, Rx: rxHost, Set: setSwaggerHost}), - NewSingleLineTagParser("BasePath", &setMetaSingle{swspec, rxBasePath, setSwaggerBasePath}), - NewSingleLineTagParser("Contact", &setMetaSingle{Spec: swspec, Rx: rxContact, Set: setInfoContact}), - NewSingleLineTagParser("License", &setMetaSingle{Spec: swspec, Rx: rxLicense, Set: setInfoLicense}), - NewMultiLineTagParser("YAMLInfoExtensionsBlock", NewYAMLParser(WithMatcher(rxInfoExtensions), WithSetter(infoVendorExtensibleSetter(swspec))), true), - NewMultiLineTagParser("YAMLExtensionsBlock", NewYAMLParser(WithExtensionMatcher(), WithSetter(metaVendorExtensibleSetter(swspec))), true), - } - - return sp -} - -type setMetaSingle struct { - Spec *spec.Swagger - Rx *regexp.Regexp - Set func(spec *spec.Swagger, lines []string) error -} - -func (s *setMetaSingle) Matches(line string) bool { - return s.Rx.MatchString(line) -} - -func (s *setMetaSingle) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := s.Rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - return s.Set(s.Spec, []string{matches[1]}) - } - return nil -} - -func setSwaggerHost(swspec *spec.Swagger, lines []string) error { - lns := lines - if len(lns) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - lns = []string{"localhost"} - } - swspec.Host = lns[0] - return nil -} - -func setSwaggerBasePath(swspec *spec.Swagger, lines []string) error { - var ln string - if len(lines) > 0 { - ln = lines[0] - } - swspec.BasePath = ln - return nil -} - -func setInfoVersion(swspec *spec.Swagger, lines []string) error { - if len(lines) == 0 { - return nil - } - info := safeInfo(swspec) - info.Version = strings.TrimSpace(lines[0]) - return nil -} - -func setInfoContact(swspec *spec.Swagger, lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - contact, err := parseContactInfo(lines[0]) - if err != nil { - return err - } - info := safeInfo(swspec) - info.Contact = contact - return nil -} - -func parseContactInfo(line string) (*spec.ContactInfo, error) { - nameEmail, url := splitURL(line) - var name, email string - if len(nameEmail) > 0 { - addr, err := mail.ParseAddress(nameEmail) - if err != nil { - return nil, err - } - name, email = addr.Name, addr.Address - } - return &spec.ContactInfo{ - ContactInfoProps: spec.ContactInfoProps{ - URL: url, - Name: name, - Email: email, - }, - }, nil -} - -func setInfoLicense(swspec *spec.Swagger, lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - info := safeInfo(swspec) - line := lines[0] - name, url := splitURL(line) - info.License = &spec.License{ - LicenseProps: spec.LicenseProps{ - Name: name, - URL: url, - }, - } - return nil -} - -func safeInfo(swspec *spec.Swagger) *spec.Info { - if swspec.Info == nil { - swspec.Info = new(spec.Info) - } - return swspec.Info -} - -// httpFTPScheme matches http://, https://, ws://, wss://. -var httpFTPScheme = regexp.MustCompile("(?:(?:ht|f)tp|ws)s?://") - -func splitURL(line string) (notURL, url string) { - str := strings.TrimSpace(line) - parts := httpFTPScheme.FindStringIndex(str) - if len(parts) == 0 { - if len(str) > 0 { - notURL = str - } - return notURL, "" - } - if len(parts) > 0 { - notURL = strings.TrimSpace(str[:parts[0]]) - url = strings.TrimSpace(str[parts[0]:]) - } - return notURL, url -} diff --git a/internal/parsers/meta_test.go b/internal/parsers/meta_test.go deleted file mode 100644 index 8b8d391..0000000 --- a/internal/parsers/meta_test.go +++ /dev/null @@ -1,270 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - goparser "go/parser" - "go/token" - "testing" - - "github.com/go-openapi/codescan/internal/scantest/classification" - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" - - oaispec "github.com/go-openapi/spec" -) - -func TestSetInfoVersion(t *testing.T) { - info := new(oaispec.Swagger) - err := setInfoVersion(info, []string{"0.0.1"}) - require.NoError(t, err) - assert.EqualT(t, "0.0.1", info.Info.Version) -} - -func TestSetInfoLicense(t *testing.T) { - info := new(oaispec.Swagger) - err := setInfoLicense(info, []string{"MIT http://license.org/MIT"}) - require.NoError(t, err) - assert.EqualT(t, "MIT", info.Info.License.Name) - assert.EqualT(t, "http://license.org/MIT", info.Info.License.URL) -} - -func TestSetInfoContact(t *testing.T) { - info := new(oaispec.Swagger) - err := setInfoContact(info, []string{"Homer J. Simpson http://simpsons.com"}) - require.NoError(t, err) - assert.EqualT(t, "Homer J. Simpson", info.Info.Contact.Name) - assert.EqualT(t, "homer@simpsons.com", info.Info.Contact.Email) - assert.EqualT(t, "http://simpsons.com", info.Info.Contact.URL) -} - -func TestParseInfo(t *testing.T) { - swspec := new(oaispec.Swagger) - parser := NewMetaParser(swspec) - docFile := "../../fixtures/goparsing/classification/doc.go" - fileSet := token.NewFileSet() - fileTree, err := goparser.ParseFile(fileSet, docFile, nil, goparser.ParseComments) - if err != nil { - t.FailNow() - } - - err = parser.Parse(fileTree.Doc) - - require.NoError(t, err) - classification.VerifyInfo(t, swspec.Info) -} - -func TestParseSwagger(t *testing.T) { - swspec := new(oaispec.Swagger) - parser := NewMetaParser(swspec) - docFile := "../../fixtures/goparsing/classification/doc.go" - fileSet := token.NewFileSet() - fileTree, err := goparser.ParseFile(fileSet, docFile, nil, goparser.ParseComments) - if err != nil { - t.FailNow() - } - - err = parser.Parse(fileTree.Doc) - verifyMeta(t, swspec) - - require.NoError(t, err) -} - -func verifyMeta(t *testing.T, doc *oaispec.Swagger) { - assert.NotNil(t, doc) - classification.VerifyInfo(t, doc.Info) - assert.Equal(t, []string{"application/json", "application/xml"}, doc.Consumes) - assert.Equal(t, []string{"application/json", "application/xml"}, doc.Produces) - assert.Equal(t, []string{"http", "https"}, doc.Schemes) - assert.Equal(t, []map[string][]string{{"api_key": {}}}, doc.Security) - expectedSecuritySchemaKey := oaispec.SecurityScheme{ - SecuritySchemeProps: oaispec.SecuritySchemeProps{ - Type: "apiKey", - In: "header", - Name: "KEY", - }, - } - expectedSecuritySchemaOAuth := oaispec.SecurityScheme{ - SecuritySchemeProps: oaispec.SecuritySchemeProps{ //nolint:gosec // G101: false positive, test fixture not real credentials - Type: "oauth2", - In: "header", - AuthorizationURL: "/oauth2/auth", - TokenURL: "/oauth2/token", - Flow: "accessCode", - Scopes: map[string]string{ - "bla1": "foo1", - "bla2": "foo2", - }, - }, - } - expectedExtensions := oaispec.Extensions{ - "x-meta-array": []any{ - "value1", - "value2", - }, - "x-meta-array-obj": []any{ - map[string]any{ - "name": "obj", - "value": "field", - }, - }, - "x-meta-value": "value", - } - expectedInfoExtensions := oaispec.Extensions{ - "x-info-array": []any{ - "value1", - "value2", - }, - "x-info-array-obj": []any{ - map[string]any{ - "name": "obj", - "value": "field", - }, - }, - "x-info-value": "value", - } - assert.NotNil(t, doc.SecurityDefinitions["api_key"]) - assert.NotNil(t, doc.SecurityDefinitions["oauth2"]) - assert.Equal(t, oaispec.SecurityDefinitions{"api_key": &expectedSecuritySchemaKey, "oauth2": &expectedSecuritySchemaOAuth}, doc.SecurityDefinitions) - assert.Equal(t, expectedExtensions, doc.Extensions) - assert.Equal(t, expectedInfoExtensions, doc.Info.Extensions) - assert.EqualT(t, "localhost", doc.Host) - assert.EqualT(t, "/v2", doc.BasePath) -} - -func TestMoreParseMeta(t *testing.T) { - for _, docFile := range []string{ - "../../fixtures/goparsing/meta/v1/doc.go", - "../../fixtures/goparsing/meta/v2/doc.go", - "../../fixtures/goparsing/meta/v3/doc.go", - "../../fixtures/goparsing/meta/v4/doc.go", - } { - swspec := new(oaispec.Swagger) - parser := NewMetaParser(swspec) - fileSet := token.NewFileSet() - fileTree, err := goparser.ParseFile(fileSet, docFile, nil, goparser.ParseComments) - if err != nil { - t.FailNow() - } - - err = parser.Parse(fileTree.Doc) - require.NoError(t, err) - assert.EqualT(t, "there are no TOS at this moment, use at your own risk we take no responsibility", swspec.Info.TermsOfService) - /* - jazon, err := json.MarshalIndent(swoaispec.Info, "", " ") - require.NoError(t, err) - t.Logf("%v", string(jazon)) - */ - } -} - -func TestSetInfoVersion_Empty(t *testing.T) { - swspec := new(oaispec.Swagger) - require.NoError(t, setInfoVersion(swspec, nil)) - assert.Nil(t, swspec.Info) -} - -func TestSetSwaggerHost_Empty(t *testing.T) { - swspec := new(oaispec.Swagger) - require.NoError(t, setSwaggerHost(swspec, nil)) - assert.EqualT(t, "localhost", swspec.Host) // fallback - swspec2 := new(oaispec.Swagger) - require.NoError(t, setSwaggerHost(swspec2, []string{""})) - assert.EqualT(t, "localhost", swspec2.Host) // fallback -} - -func TestSetInfoContact_Empty(t *testing.T) { - swspec := new(oaispec.Swagger) - require.NoError(t, setInfoContact(swspec, nil)) - assert.Nil(t, swspec.Info) - require.NoError(t, setInfoContact(swspec, []string{""})) -} - -func TestSetInfoContact_BadEmail(t *testing.T) { - swspec := new(oaispec.Swagger) - err := setInfoContact(swspec, []string{"not-a-valid-email-address <<<"}) - require.Error(t, err) -} - -func TestSetInfoLicense_Empty(t *testing.T) { - swspec := new(oaispec.Swagger) - require.NoError(t, setInfoLicense(swspec, nil)) - assert.Nil(t, swspec.Info) - require.NoError(t, setInfoLicense(swspec, []string{""})) -} - -func TestSetMetaSingle_Parse_Empty(t *testing.T) { - swspec := new(oaispec.Swagger) - s := &setMetaSingle{Spec: swspec, Rx: rxVersion, Set: setInfoVersion} - require.NoError(t, s.Parse(nil)) - require.NoError(t, s.Parse([]string{""})) - // Line that doesn't match the regex - require.NoError(t, s.Parse([]string{"no match here"})) -} - -func TestSplitURL(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - line string - wantNot string - wantURL string - }{ - {"with http url", "MIT http://example.com", "MIT", "http://example.com"}, - {"with https url", "MIT https://example.com", "MIT", "https://example.com"}, - {"url only", "http://example.com", "", "http://example.com"}, - {"no url", "just text", "just text", ""}, - {"empty", "", "", ""}, - {"ws url", "live ws://example.com/ws", "live", "ws://example.com/ws"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - notURL, url := splitURL(tc.line) - assert.EqualT(t, tc.wantNot, notURL) - assert.EqualT(t, tc.wantURL, url) - }) - } -} - -func TestMetaVendorExtensibleSetter_InvalidKey(t *testing.T) { - swspec := new(oaispec.Swagger) - setter := metaVendorExtensibleSetter(swspec) - // Extension key that doesn't start with x- - err := setter([]byte(`{"not-x-key": "value"}`)) - require.Error(t, err) - require.ErrorIs(t, err, ErrParser) -} - -func TestMetaVendorExtensibleSetter_BadJSON(t *testing.T) { - swspec := new(oaispec.Swagger) - setter := metaVendorExtensibleSetter(swspec) - err := setter([]byte(`{bad json`)) - require.Error(t, err) -} - -func TestInfoVendorExtensibleSetter_InvalidKey(t *testing.T) { - swspec := &oaispec.Swagger{} - swspec.Info = new(oaispec.Info) - setter := infoVendorExtensibleSetter(swspec) - err := setter([]byte(`{"invalid-key": "value"}`)) - require.Error(t, err) - require.ErrorIs(t, err, ErrParser) -} - -func TestInfoVendorExtensibleSetter_BadJSON(t *testing.T) { - swspec := &oaispec.Swagger{} - swspec.Info = new(oaispec.Info) - setter := infoVendorExtensibleSetter(swspec) - err := setter([]byte(`{bad json`)) - require.Error(t, err) -} - -func TestMetaSecurityDefinitionsSetter_BadJSON(t *testing.T) { - swspec := new(oaispec.Swagger) - setter := metaSecurityDefinitionsSetter(swspec) - err := setter([]byte(`{bad json`)) - require.Error(t, err) -} diff --git a/internal/parsers/parsers.go b/internal/parsers/parsers.go deleted file mode 100644 index b7708a5..0000000 --- a/internal/parsers/parsers.go +++ /dev/null @@ -1,142 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "regexp" - "strconv" - - oaispec "github.com/go-openapi/spec" -) - -const ( - // kvParts is the number of parts when splitting key:value pairs. - kvParts = 2 -) - -// Many thanks go to https://github.com/yvasiyarov/swagger -// this is loosely based on that implementation but for swagger 2.0 - -type matchOnlyParam struct { - rx *regexp.Regexp -} - -func (mo *matchOnlyParam) Matches(line string) bool { - return mo.rx.MatchString(line) -} - -func (mo *matchOnlyParam) Parse(_ []string) error { - return nil -} - -type MatchParamIn struct { - *matchOnlyParam -} - -func NewMatchParamIn(_ *oaispec.Parameter) *MatchParamIn { - return NewMatchIn() -} - -// NewMatchIn returns a match-only tagger that claims `in: ` -// lines. The `in:` directive is extracted separately via -// parsers.ParamLocation; this tagger only prevents the line from -// being absorbed into the surrounding description by a SectionedParser. -func NewMatchIn() *MatchParamIn { - return &MatchParamIn{ - matchOnlyParam: &matchOnlyParam{ - rx: rxIn, - }, - } -} - -type MatchParamRequired struct { - *matchOnlyParam -} - -func NewMatchParamRequired(_ *oaispec.Parameter) *MatchParamRequired { - return &MatchParamRequired{ - matchOnlyParam: &matchOnlyParam{ - rx: rxRequired, - }, - } -} - -type SetDeprecatedOp struct { - tgt *oaispec.Operation -} - -func NewSetDeprecatedOp(operation *oaispec.Operation) *SetDeprecatedOp { - return &SetDeprecatedOp{ - tgt: operation, - } -} - -func (su *SetDeprecatedOp) Matches(line string) bool { - return rxDeprecated.MatchString(line) -} - -func (su *SetDeprecatedOp) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - - matches := rxDeprecated.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - req, err := strconv.ParseBool(matches[1]) - if err != nil { - return err - } - su.tgt.Deprecated = req - } - - return nil -} - -type ConsumesDropEmptyParser struct { - *multilineDropEmptyParser -} - -func NewConsumesDropEmptyParser(set func([]string)) *ConsumesDropEmptyParser { - return &ConsumesDropEmptyParser{ - multilineDropEmptyParser: &multilineDropEmptyParser{ - set: set, - rx: rxConsumes, - }, - } -} - -type ProducesDropEmptyParser struct { - *multilineDropEmptyParser -} - -func NewProducesDropEmptyParser(set func([]string)) *ProducesDropEmptyParser { - return &ProducesDropEmptyParser{ - multilineDropEmptyParser: &multilineDropEmptyParser{ - set: set, - rx: rxProduces, - }, - } -} - -type multilineDropEmptyParser struct { - set func([]string) - rx *regexp.Regexp -} - -func newMultilineDropEmptyParser(rx *regexp.Regexp, set func([]string)) *multilineDropEmptyParser { - return &multilineDropEmptyParser{ - set: set, - rx: rx, - } -} - -func (m *multilineDropEmptyParser) Matches(line string) bool { - return m.rx.MatchString(line) -} - -func (m *multilineDropEmptyParser) Parse(lines []string) error { - m.set(removeEmptyLines(lines)) - - return nil -} diff --git a/internal/parsers/parsers_helpers.go b/internal/parsers/parsers_helpers.go deleted file mode 100644 index 3295c41..0000000 --- a/internal/parsers/parsers_helpers.go +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "strings" -) - -// a shared function that can be used to split given headers -// into a title and description. -func collectScannerTitleDescription(headers []string) (title, desc []string) { - hdrs := cleanupScannerLines(headers, rxUncommentHeaders) - - idx := -1 - for i, line := range hdrs { - if strings.TrimSpace(line) == "" { - idx = i - break - } - } - - if idx > -1 { - title = hdrs[:idx] - if len(title) > 0 { - title[0] = rxTitleStart.ReplaceAllString(title[0], "") - } - if len(hdrs) > idx+1 { - desc = hdrs[idx+1:] - } else { - desc = nil - } - return title, desc - } - - if len(hdrs) > 0 { - line := hdrs[0] - switch { - case rxPunctuationEnd.MatchString(line): - title = []string{line} - desc = hdrs[1:] - case rxTitleStart.MatchString(line): - title = []string{rxTitleStart.ReplaceAllString(line, "")} - desc = hdrs[1:] - default: - desc = hdrs - } - } - - return title, desc -} diff --git a/internal/parsers/parsers_helpers_test.go b/internal/parsers/parsers_helpers_test.go deleted file mode 100644 index bc5f5c7..0000000 --- a/internal/parsers/parsers_helpers_test.go +++ /dev/null @@ -1,73 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "testing" - - "github.com/go-openapi/testify/v2/assert" -) - -func TestCollectScannerTitleDescription(t *testing.T) { - t.Parallel() - - t.Run("title and description separated by blank", func(t *testing.T) { - headers := []string{ - "// This is the title.", - "//", - "// This is the description.", - "// More description.", - } - title, desc := collectScannerTitleDescription(headers) - assert.Equal(t, []string{"This is the title."}, title) - assert.Equal(t, []string{"This is the description.", "More description."}, desc) - }) - - t.Run("title only with punctuation", func(t *testing.T) { - headers := []string{ - "// A single title line.", - "// And some description.", - } - title, desc := collectScannerTitleDescription(headers) - assert.Equal(t, []string{"A single title line."}, title) - assert.Equal(t, []string{"And some description."}, desc) - }) - - t.Run("title with markdown header prefix", func(t *testing.T) { - headers := []string{ - "// # My Title", - "// Description here.", - } - title, desc := collectScannerTitleDescription(headers) - assert.Equal(t, []string{"My Title"}, title) - assert.Equal(t, []string{"Description here."}, desc) - }) - - t.Run("no title, all description", func(t *testing.T) { - headers := []string{ - "// no punctuation at end means no title", - "// more text", - } - title, desc := collectScannerTitleDescription(headers) - assert.Empty(t, title) - assert.Equal(t, []string{"no punctuation at end means no title", "more text"}, desc) - }) - - t.Run("empty", func(t *testing.T) { - title, desc := collectScannerTitleDescription(nil) - assert.Empty(t, title) - assert.Empty(t, desc) - }) - - t.Run("blank line only", func(t *testing.T) { - headers := []string{"//"} - title, desc := collectScannerTitleDescription(headers) - assert.Empty(t, title) - assert.Nil(t, desc) - }) - - // Note: the branch at line 31-32 (desc = nil when blank is last line) - // is unreachable because cleanupScannerLines always trims trailing blanks - // before collectScannerTitleDescription processes the slice. -} diff --git a/internal/parsers/parsers_test.go b/internal/parsers/parsers_test.go deleted file mode 100644 index 02841d4..0000000 --- a/internal/parsers/parsers_test.go +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" - - oaispec "github.com/go-openapi/spec" -) - -func TestMatchParamIn(t *testing.T) { - t.Parallel() - - mp := NewMatchParamIn(nil) - assert.TrueT(t, mp.Matches("In: query")) - assert.TrueT(t, mp.Matches("in: body")) - assert.TrueT(t, mp.Matches("in: path")) - assert.TrueT(t, mp.Matches("in: header")) - assert.TrueT(t, mp.Matches("in: formData")) - assert.FalseT(t, mp.Matches("in: cookie")) // not a valid swagger 2.0 location - assert.FalseT(t, mp.Matches("something else")) - - // Parse is a no-op - require.NoError(t, mp.Parse(nil)) -} - -func TestMatchParamRequired(t *testing.T) { - t.Parallel() - - mp := NewMatchParamRequired(nil) - assert.TrueT(t, mp.Matches("required: true")) - assert.TrueT(t, mp.Matches("Required: false")) - assert.FalseT(t, mp.Matches("something else")) - - // Parse is a no-op - require.NoError(t, mp.Parse(nil)) -} - -func TestSetDeprecatedOp(t *testing.T) { - t.Parallel() - - t.Run("true", func(t *testing.T) { - op := new(oaispec.Operation) - sd := NewSetDeprecatedOp(op) - assert.TrueT(t, sd.Matches("deprecated: true")) - require.NoError(t, sd.Parse([]string{"deprecated: true"})) - assert.TrueT(t, op.Deprecated) - }) - - t.Run("false", func(t *testing.T) { - op := new(oaispec.Operation) - sd := NewSetDeprecatedOp(op) - require.NoError(t, sd.Parse([]string{"deprecated: false"})) - assert.FalseT(t, op.Deprecated) - }) - - t.Run("empty", func(t *testing.T) { - op := new(oaispec.Operation) - sd := NewSetDeprecatedOp(op) - require.NoError(t, sd.Parse(nil)) - require.NoError(t, sd.Parse([]string{})) - require.NoError(t, sd.Parse([]string{""})) - assert.FalseT(t, op.Deprecated) - }) - - t.Run("no match", func(t *testing.T) { - sd := NewSetDeprecatedOp(new(oaispec.Operation)) - assert.FalseT(t, sd.Matches("something else")) - }) -} - -func TestConsumesDropEmptyParser(t *testing.T) { - t.Parallel() - - var got []string - cp := NewConsumesDropEmptyParser(func(v []string) { got = v }) - assert.TrueT(t, cp.Matches("consumes:")) - assert.TrueT(t, cp.Matches("Consumes:")) - assert.FalseT(t, cp.Matches("other")) - - require.NoError(t, cp.Parse([]string{"application/json", "", "application/xml", " "})) - assert.Equal(t, []string{"application/json", "application/xml"}, got) -} - -func TestProducesDropEmptyParser(t *testing.T) { - t.Parallel() - - var got []string - pp := NewProducesDropEmptyParser(func(v []string) { got = v }) - assert.TrueT(t, pp.Matches("produces:")) - assert.TrueT(t, pp.Matches("Produces:")) - - require.NoError(t, pp.Parse([]string{"text/plain", "", "text/html"})) - assert.Equal(t, []string{"text/plain", "text/html"}, got) -} diff --git a/internal/parsers/regexprs.go b/internal/parsers/regexprs.go index 1efc912..20dad76 100644 --- a/internal/parsers/regexprs.go +++ b/internal/parsers/regexprs.go @@ -3,82 +3,55 @@ package parsers -import ( - "fmt" - "regexp" -) +import "regexp" const ( // rxCommentPrefix matches the leading comment noise that precedes an // annotation keyword on a raw comment line: whitespace, tabs, slashes, - // asterisks, dashes, optional markdown table pipe, then any trailing - // spaces. Mirrors the prefix class used by rxUncommentHeaders so - // Matches() can still see through the `//` / `*` / ` * ` comment - // prefixes on raw lines. + // asterisks, dashes, optional markdown table pipe, then trailing + // spaces. // // Annotations must START the comment line — any prose before the - // swagger:xxx keyword disqualifies the line: an annotation buried in prose is ignored. - // - // Example: - // `swagger:strfmt` buried inside the sentence - // `// MAC is a text-marshalable ... swagger:strfmt so ...` is ignored and no longer captures - // "so" instead of the intended strfmt name. + // `swagger:xxx` keyword disqualifies the line, so an annotation + // buried in prose is ignored. // - // The sole documented-by-example exception is `swagger:route`, which is - // allowed to follow a single godoc identifier (see rxRoutePrefix). + // The sole documented exception is `swagger:route`, which is allowed + // to follow a single godoc identifier (see rxRoutePrefix). rxCommentPrefix = `^[\p{Zs}\t/\*-]*\|?\p{Zs}*` - // rxRoutePrefix extends rxCommentPrefix with an OPTIONAL single leading - // identifier. Godoc convention places the function/type name before the - // annotation body, e.g. `// DoBad swagger:route GET /path`. Without - // this allowance we would reject every `swagger:route` annotation - // attached to a documented handler. The allowance is intentionally - // narrow — ONE identifier, then whitespace — so multi-word prose - // prefixes still fail. + // rxRoutePrefix extends rxCommentPrefix with an OPTIONAL single + // leading identifier. Godoc convention places the function/type name + // before the annotation body, e.g. `// DoBad swagger:route GET + // /path`. The allowance is intentionally narrow — ONE identifier, + // then whitespace — so multi-word prose prefixes still fail. // - // This exception is reserved for `swagger:route`. All other annotations - // must start the comment line, per rxCommentPrefix. + // This exception is reserved for `swagger:route`. All other + // annotations must start the comment line, per rxCommentPrefix. rxRoutePrefix = rxCommentPrefix + `(?:\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]*\p{Zs}+)?` rxMethod = "(\\p{L}+)" rxPath = "((?:/[\\p{L}\\p{N}\\p{Pd}\\p{Pc}{}\\-\\.\\?_~%!$&'()*+,;=:@/]*)+/?)" rxOpTags = "(\\p{L}[\\p{L}\\p{N}\\p{Pd}\\.\\p{Pc}\\p{Zs}]+)" rxOpID = "((?:\\p{L}[\\p{L}\\p{N}\\p{Pd}\\p{Pc}]+)+)" - - rxMaximumFmt = rxCommentPrefix + "%s[Mm]ax(?:imum)?\\p{Zs}*:\\p{Zs}*([\\<=])?\\p{Zs}*([\\+-]?(?:\\p{N}+\\.)?\\p{N}+)(?:\\.)?$" - rxMinimumFmt = rxCommentPrefix + "%s[Mm]in(?:imum)?\\p{Zs}*:\\p{Zs}*([\\>=])?\\p{Zs}*([\\+-]?(?:\\p{N}+\\.)?\\p{N}+)(?:\\.)?$" - rxMultipleOfFmt = rxCommentPrefix + "%s[Mm]ultiple\\p{Zs}*[Oo]f\\p{Zs}*:\\p{Zs}*([\\+-]?(?:\\p{N}+\\.)?\\p{N}+)(?:\\.)?$" - - rxMaxLengthFmt = rxCommentPrefix + "%s[Mm]ax(?:imum)?(?:\\p{Zs}*[\\p{Pd}\\p{Pc}]?[Ll]en(?:gth)?)\\p{Zs}*:\\p{Zs}*(\\p{N}+)(?:\\.)?$" - rxMinLengthFmt = rxCommentPrefix + "%s[Mm]in(?:imum)?(?:\\p{Zs}*[\\p{Pd}\\p{Pc}]?[Ll]en(?:gth)?)\\p{Zs}*:\\p{Zs}*(\\p{N}+)(?:\\.)?$" - rxPatternFmt = rxCommentPrefix + "%s[Pp]attern\\p{Zs}*:\\p{Zs}*(.*)$" - rxCollectionFormatFmt = rxCommentPrefix + "%s[Cc]ollection(?:\\p{Zs}*[\\p{Pd}\\p{Pc}]?[Ff]ormat)\\p{Zs}*:\\p{Zs}*(.*)$" - rxEnumFmt = rxCommentPrefix + "%s[Ee]num\\p{Zs}*:\\p{Zs}*(.*)$" - rxDefaultFmt = rxCommentPrefix + "%s[Dd]efault\\p{Zs}*:\\p{Zs}*(.*)$" - rxExampleFmt = rxCommentPrefix + "%s[Ee]xample\\p{Zs}*:\\p{Zs}*(.*)$" - - rxMaxItemsFmt = rxCommentPrefix + "%s[Mm]ax(?:imum)?(?:\\p{Zs}*|[\\p{Pd}\\p{Pc}]|\\.)?[Ii]tems\\p{Zs}*:\\p{Zs}*(\\p{N}+)(?:\\.)?$" - rxMinItemsFmt = rxCommentPrefix + "%s[Mm]in(?:imum)?(?:\\p{Zs}*|[\\p{Pd}\\p{Pc}]|\\.)?[Ii]tems\\p{Zs}*:\\p{Zs}*(\\p{N}+)(?:\\.)?$" - rxUniqueFmt = rxCommentPrefix + "%s[Uu]nique\\p{Zs}*:\\p{Zs}*(true|false)(?:\\.)?$" - - rxItemsPrefixFmt = "(?:[Ii]tems[\\.\\p{Zs}]*){%d}" ) +//nolint:gochecknoglobals // compile-once regexes; read-only. var ( - rxSwaggerAnnotation = regexp.MustCompile(`(?:^|[\s/])swagger:([\p{L}\p{N}\p{Pd}\p{Pc}]+)`) - rxFileUpload = regexp.MustCompile(rxCommentPrefix + `swagger:file`) - rxStrFmt = regexp.MustCompile(rxCommentPrefix + `swagger:strfmt\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)(?:\.)?$`) - rxAlias = regexp.MustCompile(rxCommentPrefix + `swagger:alias`) - rxName = regexp.MustCompile(rxCommentPrefix + `swagger:name\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}\.]+)(?:\.)?$`) - rxAllOf = regexp.MustCompile(rxCommentPrefix + `swagger:allOf\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}\.]+)?(?:\.)?$`) + // rxSwaggerAnnotation matches `swagger:` anywhere on a comment + // line where it is preceded by whitespace, `/`, or start-of-line. + // Kept loose because it is the classification regex consumed by + // scanner.index.ExtractAnnotation; `swagger:route` is allowed to + // follow a godoc-style identifier per rxRoutePrefix. + // + // Do NOT use this regex as a block terminator — it triggers on + // mid-prose mentions and would truncate descriptions. + rxSwaggerAnnotation = regexp.MustCompile(`(?:^|[\s/])swagger:([\p{L}\p{N}\p{Pd}\p{Pc}]+)`) + rxModelOverride = regexp.MustCompile(rxCommentPrefix + `swagger:model\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)?(?:\.)?$`) rxResponseOverride = regexp.MustCompile(rxCommentPrefix + `swagger:response\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)?(?:\.)?$`) rxParametersOverride = regexp.MustCompile(rxCommentPrefix + `swagger:parameters\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}\p{Zs}]+)(?:\.)?$`) - rxEnum = regexp.MustCompile(rxCommentPrefix + `swagger:enum\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)(?:\.)?$`) - rxIgnoreOverride = regexp.MustCompile(rxCommentPrefix + `swagger:ignore\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)?(?:\.)?$`) - rxDefault = regexp.MustCompile(rxCommentPrefix + `swagger:default\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)(?:\.)?$`) - rxType = regexp.MustCompile(rxCommentPrefix + `swagger:type\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)(?:\.)?$`) - rxRoute = regexp.MustCompile( + + rxRoute = regexp.MustCompile( rxRoutePrefix + "swagger:route\\p{Zs}*" + rxMethod + @@ -88,10 +61,7 @@ var ( rxOpTags + ")?\\p{Zs}+" + rxOpID + "\\p{Zs}*$") - rxBeginYAMLSpec = regexp.MustCompile(rxCommentPrefix + `---\p{Zs}*$`) - rxUncommentHeaders = regexp.MustCompile(`^[\p{Zs}\t/\*-]*\|?`) - rxUncommentYAML = regexp.MustCompile(`^[\p{Zs}\t]*/*`) - rxOperation = regexp.MustCompile( + rxOperation = regexp.MustCompile( rxCommentPrefix + "swagger:operation\\p{Zs}*" + rxMethod + @@ -101,37 +71,4 @@ var ( rxOpTags + ")?\\p{Zs}+" + rxOpID + "\\p{Zs}*$") - - rxIndent = regexp.MustCompile(`[\p{Zs}\t]*/*[\p{Zs}\t]*[^\p{Zs}\t]`) - rxNotIndent = regexp.MustCompile(`[^\p{Zs}\t]`) - rxPunctuationEnd = regexp.MustCompile(`\p{Po}$`) - rxTitleStart = regexp.MustCompile(`^[#]+\p{Zs}+`) - rxStripTitleComments = regexp.MustCompile(`^[^\p{L}]*[Pp]ackage\p{Zs}+[^\p{Zs}]+\p{Zs}*`) - rxAllowedExtensions = regexp.MustCompile(`^[Xx]-`) - - rxIn = regexp.MustCompile(rxCommentPrefix + `[Ii]n\p{Zs}*:\p{Zs}*(query|path|header|body|formData)(?:\.)?$`) - rxRequired = regexp.MustCompile(rxCommentPrefix + `[Rr]equired\p{Zs}*:\p{Zs}*(true|false)(?:\.)?$`) - rxDiscriminator = regexp.MustCompile(rxCommentPrefix + `[Dd]iscriminator\p{Zs}*:\p{Zs}*(true|false)(?:\.)?$`) - rxReadOnly = regexp.MustCompile(rxCommentPrefix + `[Rr]ead(?:\p{Zs}*|[\p{Pd}\p{Pc}])?[Oo]nly\p{Zs}*:\p{Zs}*(true|false)(?:\.)?$`) - rxConsumes = regexp.MustCompile(rxCommentPrefix + `[Cc]onsumes\p{Zs}*:`) - rxProduces = regexp.MustCompile(rxCommentPrefix + `[Pp]roduces\p{Zs}*:`) - rxSecuritySchemes = regexp.MustCompile(rxCommentPrefix + `[Ss]ecurity\p{Zs}*:`) - rxSecurity = regexp.MustCompile(rxCommentPrefix + `[Ss]ecurity\p{Zs}*[Dd]efinitions:`) - rxResponses = regexp.MustCompile(rxCommentPrefix + `[Rr]esponses\p{Zs}*:`) - rxParameters = regexp.MustCompile(rxCommentPrefix + `[Pp]arameters\p{Zs}*:`) - rxSchemes = regexp.MustCompile(rxCommentPrefix + `[Ss]chemes\p{Zs}*:\p{Zs}*((?:(?:https?|HTTPS?|wss?|WSS?)[\p{Zs},]*)+)(?:\.)?$`) - rxVersion = regexp.MustCompile(rxCommentPrefix + `[Vv]ersion\p{Zs}*:\p{Zs}*(.+)$`) - rxHost = regexp.MustCompile(rxCommentPrefix + `[Hh]ost\p{Zs}*:\p{Zs}*(.+)$`) - rxBasePath = regexp.MustCompile(rxCommentPrefix + `[Bb]ase\p{Zs}*-*[Pp]ath\p{Zs}*:\p{Zs}*` + rxPath + "(?:\\.)?$") - rxLicense = regexp.MustCompile(rxCommentPrefix + `[Ll]icense\p{Zs}*:\p{Zs}*(.+)$`) - rxContact = regexp.MustCompile(rxCommentPrefix + `[Cc]ontact\p{Zs}*-?(?:[Ii]info\p{Zs}*)?:\p{Zs}*(.+)$`) - rxTOS = regexp.MustCompile(rxCommentPrefix + `[Tt](:?erms)?\p{Zs}*-?[Oo]f?\p{Zs}*-?[Ss](?:ervice)?\p{Zs}*:`) - rxExtensions = regexp.MustCompile(rxCommentPrefix + `[Ee]xtensions\p{Zs}*:`) - rxInfoExtensions = regexp.MustCompile(rxCommentPrefix + `[In]nfo\p{Zs}*[Ee]xtensions:`) - rxDeprecated = regexp.MustCompile(rxCommentPrefix + `[Dd]eprecated\p{Zs}*:\p{Zs}*(true|false)(?:\.)?$`) - // currently unused: rxExample = regexp.MustCompile(`[Ex]ample\p{Zs}*:\p{Zs}*(.*)$`). ) - -func Rxf(rxp, ar string) *regexp.Regexp { - return regexp.MustCompile(fmt.Sprintf(rxp, ar)) -} diff --git a/internal/parsers/regexprs_test.go b/internal/parsers/regexprs_test.go index cb5a71c..92b882f 100644 --- a/internal/parsers/regexprs_test.go +++ b/internal/parsers/regexprs_test.go @@ -4,7 +4,6 @@ package parsers import ( - "fmt" "regexp" "strings" "testing" @@ -23,18 +22,6 @@ func TestOperationExpression(t *testing.T) { } func TestSchemaValueExtractors(t *testing.T) { - strfmts := []string{ - "// swagger:strfmt ", - "* swagger:strfmt ", - "* swagger:strfmt ", - " swagger:strfmt ", - "swagger:strfmt ", - "// swagger:strfmt ", - "* swagger:strfmt ", - "* swagger:strfmt ", - " swagger:strfmt ", - "swagger:strfmt ", - } models := []string{ "// swagger:model ", "* swagger:model ", @@ -48,19 +35,6 @@ func TestSchemaValueExtractors(t *testing.T) { "swagger:model ", } - allOf := []string{ - "// swagger:allOf ", - "* swagger:allOf ", - "* swagger:allOf ", - " swagger:allOf ", - "swagger:allOf ", - "// swagger:allOf ", - "* swagger:allOf ", - "* swagger:allOf ", - " swagger:allOf ", - "swagger:allOf ", - } - parameters := []string{ "// swagger:parameters ", "* swagger:parameters ", @@ -80,197 +54,17 @@ func TestSchemaValueExtractors(t *testing.T) { "date-time", "long-combo-1-with-combo-2-and-a-3rd-one-too", } - invalidParams := make([]string, 0, 9) - invalidParams = append(invalidParams, + invalidParams := []string{ "1-yada-3", "1-2-3", "-yada-3", "-2-3", "*blah", "blah*", - ) + } - verifySwaggerOneArgSwaggerTag(t, rxStrFmt, strfmts, validParams, append(invalidParams, "", " ", " ")) verifySwaggerOneArgSwaggerTag(t, rxModelOverride, models, append(validParams, "", " ", " "), invalidParams) - - verifySwaggerOneArgSwaggerTag(t, rxAllOf, allOf, append(validParams, "", " ", " "), invalidParams) - verifySwaggerMultiArgSwaggerTag(t, rxParametersOverride, parameters, validParams, invalidParams) - - verifyMinMax(t, Rxf(rxMinimumFmt, ""), "min", []string{"", ">", "="}) - verifyMinMax(t, Rxf(rxMinimumFmt, fmt.Sprintf(rxItemsPrefixFmt, 1)), "items.min", []string{"", ">", "="}) - verifyMinMax(t, Rxf(rxMaximumFmt, ""), "max", []string{"", "<", "="}) - verifyMinMax(t, Rxf(rxMaximumFmt, fmt.Sprintf(rxItemsPrefixFmt, 1)), "items.max", []string{"", "<", "="}) - verifyNumeric2Words(t, Rxf(rxMultipleOfFmt, ""), "multiple", "of") - verifyNumeric2Words(t, Rxf(rxMultipleOfFmt, fmt.Sprintf(rxItemsPrefixFmt, 1)), "items.multiple", "of") - - verifyIntegerMinMaxManyWords(t, Rxf(rxMinLengthFmt, ""), "min", []string{"len", "length"}) - // pattern - patPrefixes := cartesianJoin( - []string{"//", "*", ""}, - []string{"", " ", " ", " "}, - []string{"pattern", "Pattern"}, - []string{"", " ", " ", " "}, - []string{":"}, - []string{"", " ", " ", " "}, - ) - verifyRegexpArgs(t, Rxf(rxPatternFmt, ""), patPrefixes, []string{"^\\w+$", "[A-Za-z0-9-.]*"}, nil, 2, 1) - - verifyIntegerMinMaxManyWords(t, Rxf(rxMinItemsFmt, ""), "min", []string{"items"}) - verifyBoolean(t, Rxf(rxUniqueFmt, ""), []string{"unique"}, nil) - - verifyBoolean(t, rxReadOnly, []string{"read"}, []string{"only"}) - verifyBoolean(t, rxRequired, []string{"required"}, nil) -} - -func makeMinMax(lower string) (res []string) { - for _, a := range []string{"", "imum"} { - res = append(res, lower+a, strings.Title(lower)+a) //nolint:staticcheck // Title is deprecated, yet still useful here. The replacement is bit heavy for just this test - } - - return res -} - -// cartesianJoin returns all concatenations formed by picking one element from each slot. -func cartesianJoin(slots ...[]string) []string { - result := []string{""} - for _, slot := range slots { - next := make([]string, 0, len(result)*len(slot)) - for _, prefix := range result { - for _, s := range slot { - next = append(next, prefix+s) - } - } - result = next - } - - return result -} - -// titleCaseVariants returns each name paired with its Title-cased form. -func titleCaseVariants(names []string) []string { - result := make([]string, 0, len(names)*2) - for _, nm := range names { - result = append(result, nm, strings.Title(nm)) //nolint:staticcheck // Title is deprecated, yet still useful here - } - - return result -} - -// verifyRegexpArgs tests that matcher matches lines formed by each prefix+validArg -// (expecting expectedMatchLen matches with the value at matchIdx) and rejects prefix+invalidArg. -func verifyRegexpArgs(t *testing.T, matcher *regexp.Regexp, prefixes, validArgs, invalidArgs []string, expectedMatchLen, matchIdx int) int { - t.Helper() - cnt := 0 - for _, prefix := range prefixes { - for _, vv := range validArgs { - matches := matcher.FindStringSubmatch(prefix + vv) - assert.Len(t, matches, expectedMatchLen) - assert.EqualT(t, vv, matches[matchIdx]) - cnt++ - } - - for _, iv := range invalidArgs { - matches := matcher.FindStringSubmatch(prefix + iv) - assert.Empty(t, matches) - cnt++ - } - } - - return cnt -} - -func verifyBoolean(t *testing.T, matcher *regexp.Regexp, names, names2 []string) { - t.Helper() - - extraSpaces := []string{"", " ", " ", " "} - prefixes := []string{"//", "*", ""} - validArgs := []string{"true", "false"} - invalidArgs := []string{"TRUE", "FALSE", "t", "f", "1", "0", "True", "False", "true*", "false*"} - - nms := titleCaseVariants(names) - - var rnms []string - if len(names2) > 0 { - nms2 := titleCaseVariants(names2) - spacesAndDash := []string{"", " ", " ", " ", "-"} - for _, nm := range nms { - for _, sep := range spacesAndDash { - for _, nm2 := range nms2 { - rnms = append(rnms, nm+sep+nm2) - } - } - } - } else { - rnms = nms - } - - linePrefixes := cartesianJoin(prefixes, extraSpaces, rnms, extraSpaces, []string{":"}, extraSpaces) - cnt := verifyRegexpArgs(t, matcher, linePrefixes, validArgs, invalidArgs, 2, 1) - - var nm2 string - if len(names2) > 0 { - nm2 = " " + names2[0] - } - t.Logf("tested %d %s%s combinations\n", cnt, names[0], nm2) -} - -func verifyIntegerMinMaxManyWords(t *testing.T, matcher *regexp.Regexp, name1 string, words []string) { - t.Helper() - - extraSpaces := []string{"", " ", " ", " "} - prefixes := []string{"//", "*", ""} - validArgs := []string{"0", "1234"} - invalidArgs := []string{"1A3F", "2e10", "*12", "12*", "-1235", "0.0", "1234.0394", "-2948.484"} - - wordVariants := titleCaseVariants(words) - spacesAndDash := []string{"", " ", " ", " ", "-"} - linePrefixes := cartesianJoin(prefixes, extraSpaces, makeMinMax(name1), spacesAndDash, wordVariants, extraSpaces, []string{":"}, extraSpaces) - cnt := verifyRegexpArgs(t, matcher, linePrefixes, validArgs, invalidArgs, 2, 1) - - var nm2 string - if len(words) > 0 { - nm2 = " " + words[0] - } - t.Logf("tested %d %s%s combinations\n", cnt, name1, nm2) -} - -func verifyNumeric2Words(t *testing.T, matcher *regexp.Regexp, name1, name2 string) { - t.Helper() - - extraSpaces := []string{"", " ", " ", " "} - prefixes := []string{"//", "*", ""} - validArgs := []string{"0", "1234", "-1235", "0.0", "1234.0394", "-2948.484"} - invalidArgs := []string{"1A3F", "2e10", "*12", "12*"} - - titleName1 := strings.Title(name1) //nolint:staticcheck // Title is deprecated, yet still useful here - titleName2 := strings.Title(name2) //nolint:staticcheck // Title is deprecated, yet still useful here - nameVariants := make([]string, 0, 4*len(extraSpaces)) - for _, es := range extraSpaces { - nameVariants = append(nameVariants, - name1+es+name2, - titleName1+es+titleName2, - titleName1+es+name2, - name1+es+titleName2, - ) - } - - linePrefixes := cartesianJoin(prefixes, extraSpaces, nameVariants, extraSpaces, []string{":"}, extraSpaces) - cnt := verifyRegexpArgs(t, matcher, linePrefixes, validArgs, invalidArgs, 2, 1) - t.Logf("tested %d %s %s combinations\n", cnt, name1, name2) -} - -func verifyMinMax(t *testing.T, matcher *regexp.Regexp, name string, operators []string) { - t.Helper() - - extraSpaces := []string{"", " ", " ", " "} - prefixes := []string{"//", "*", ""} - validArgs := []string{"0", "1234", "-1235", "0.0", "1234.0394", "-2948.484"} - invalidArgs := []string{"1A3F", "2e10", "*12", "12*"} - - linePrefixes := cartesianJoin(prefixes, extraSpaces, makeMinMax(name), extraSpaces, []string{":"}, extraSpaces, operators, extraSpaces) - cnt := verifyRegexpArgs(t, matcher, linePrefixes, validArgs, invalidArgs, 3, 2) - t.Logf("tested %d %s combinations\n", cnt, name) } func verifySwaggerOneArgSwaggerTag(t *testing.T, matcher *regexp.Regexp, prefixes, validParams, invalidParams []string) { diff --git a/internal/parsers/sectioned_parser.go b/internal/parsers/sectioned_parser.go deleted file mode 100644 index c87fb1d..0000000 --- a/internal/parsers/sectioned_parser.go +++ /dev/null @@ -1,289 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "go/ast" - "strings" - - "github.com/go-openapi/codescan/internal/ifaces" -) - -// SectionedParserOption configures a [SectionedParser] via [NewSectionedParser]. -type SectionedParserOption func(*SectionedParser) - -// WithSetTitle provides a callback that receives the extracted title lines -// after parsing completes. If no title callback is set, the parser does not -// attempt to separate the title from the description. -func WithSetTitle(setTitle func([]string)) SectionedParserOption { - return func(p *SectionedParser) { - p.setTitle = setTitle - } -} - -// WithSetDescription provides a callback that receives the extracted -// description lines after parsing completes. -func WithSetDescription(setDescription func([]string)) SectionedParserOption { - return func(p *SectionedParser) { - p.setDescription = setDescription - } -} - -// WithTaggers registers the [TagParser] instances that this SectionedParser -// will try to match against each line after the header section ends. -func WithTaggers(taggers ...TagParser) SectionedParserOption { - return func(p *SectionedParser) { - p.taggers = taggers - } -} - -// SectionedParser is the core comment-block parser for go-swagger annotations. -// It processes an [ast.CommentGroup] and splits its content into three sections: -// -// 1. Header — free-form text at the top of the comment block, later split -// into a title and description. -// 2. Tags — structured key:value lines (e.g. "minimum: 10", "consumes:", -// "schemes: http, https") recognized by registered [TagParser] instances. -// 3. Annotation — an optional swagger:* annotation line (e.g. "swagger:model -// Foo") handled by a dedicated [ifaces.ValueParser]. -// -// # Parsing algorithm -// -// Parse walks each line of the comment block in order. For every line: -// -// 1. If the line contains a swagger:* annotation: -// - "swagger:ignore" → mark as ignored, stop parsing. -// - If an annotation parser is registered and matches → delegate to it. -// - Otherwise → stop parsing (the annotation belongs to a different parser). -// -// 2. If any registered [TagParser] matches the line: -// - For a single-line tagger: collect the line, then reset the current -// tagger so the next line can match a different tag. -// - For a multi-line tagger: the matching (header) line is consumed but NOT -// collected; all subsequent lines are collected into that tagger until a -// different tagger matches or the block ends. -// -// 3. Otherwise, if no tag has been seen yet, the line is appended to the -// header (free-form text). -// -// After the line walk completes, three things happen: -// -// 1. The header is split into title + description (see [collectScannerTitleDescription]). -// 2. For each matched tagger, its collected lines are cleaned up (comment -// prefixes stripped, unless SkipCleanUp is set) and passed to the -// tagger's Parse method, which writes the extracted value into the target -// spec object. -// 3. Title and description callbacks are invoked. -// -// # Example: Swagger meta block -// -// Given the comment block on a package doc.go: -// -// // Petstore API. -// // -// // The purpose of this application is to provide an API for pets. -// // -// // Schemes: http, https -// // Host: petstore.example.com -// // BasePath: /v2 -// // Version: 1.0.0 -// // License: MIT http://opensource.org/licenses/MIT -// // Contact: John Doe http://john.example.com -// // -// // Consumes: -// // - application/json -// // - application/xml -// // -// // swagger:meta -// -// The SectionedParser (configured by [NewMetaParser]) will: -// -// - Collect "Petstore API." as the title, and the next paragraph as the -// description (header section, lines 1-3). -// - Match "Schemes: http, https" via the single-line "Schemes" tagger. -// - Match "Host: ...", "BasePath: ...", etc. via their respective single-line taggers. -// - Match "Consumes:" via the multi-line "Consumes" tagger, collecting -// "- application/json" and "- application/xml" as its body. -// - Stop at "swagger:meta" (an annotation that doesn't match any registered -// annotation parser, so it terminates the block). -type SectionedParser struct { - header []string - matched map[string]TagParser - annotation ifaces.ValueParser - - seenTag bool - skipHeader bool - setTitle func([]string) - setDescription func([]string) - workedOutTitle bool - taggers []TagParser - currentTagger *TagParser - title []string - ignored bool -} - -// NewSectionedParser creates a SectionedParser configured by the given options. -// -// At minimum, callers should provide [WithSetTitle] and [WithTaggers]: -// -// sp := NewSectionedParser( -// WithSetTitle(func(lines []string) { op.Summary = JoinDropLast(lines) }), -// WithSetDescription(func(lines []string) { op.Description = JoinDropLast(lines) }), -// WithTaggers( -// NewSingleLineTagParser("maximum", NewSetMaximum(builder)), -// NewMultiLineTagParser("consumes", NewConsumesDropEmptyParser(setter), false), -// ), -// ) -func NewSectionedParser(opts ...SectionedParserOption) *SectionedParser { - var p SectionedParser - - for _, apply := range opts { - apply(&p) - } - - return &p -} - -// Title returns the title lines extracted from the header. The title is -// separated from the description by the first blank line, or inferred from -// punctuation and markdown heading prefixes when there is no blank line. -// -// Title triggers lazy title/description splitting on first call. -func (st *SectionedParser) Title() []string { - st.collectTitleDescription() - return st.title -} - -// Description returns the description lines extracted from the header (everything -// after the title). Like [SectionedParser.Title], it triggers lazy splitting on first call. -func (st *SectionedParser) Description() []string { - st.collectTitleDescription() - return st.header -} - -// Ignored reports whether a "swagger:ignore" annotation was encountered. -func (st *SectionedParser) Ignored() bool { - return st.ignored -} - -// Parse processes an [ast.CommentGroup] through the sectioned parsing algorithm -// described in the type documentation. Returns an error if any matched tagger's -// Parse method fails. -func (st *SectionedParser) Parse(doc *ast.CommentGroup) error { - if doc == nil { - return nil - } - -COMMENTS: - for _, c := range doc.List { - for line := range strings.SplitSeq(c.Text, "\n") { - if st.parseLine(line) { - break COMMENTS - } - } - } - - if st.setTitle != nil { - st.setTitle(st.Title()) - } - - if st.setDescription != nil { - st.setDescription(st.Description()) - } - - for _, mt := range st.matched { - if !mt.SkipCleanUp { - mt.Lines = cleanupScannerLines(mt.Lines, rxUncommentHeaders) - } - if err := mt.Parse(mt.Lines); err != nil { - return err - } - } - - return nil -} - -// parseLine processes a single comment line. It returns true when the -// caller should stop processing further comments (a swagger: annotation -// that doesn't belong to this parser, or swagger:ignore). -func (st *SectionedParser) parseLine(line string) (stop bool) { - // Step 1: check for swagger:* annotations. - if rxSwaggerAnnotation.MatchString(line) { - if rxIgnoreOverride.MatchString(line) { - st.ignored = true - return true // an explicit ignore terminates this parser - } - if st.annotation == nil || !st.annotation.Matches(line) { - return true // a new swagger: annotation terminates this parser - } - - _ = st.annotation.Parse([]string{line}) - if len(st.header) > 0 { - st.seenTag = true - } - return false - } - - // Step 2: try to match a registered tagger. - var matched bool - for _, tg := range st.taggers { - tagger := tg - if tagger.Matches(line) { - st.seenTag = true - st.currentTagger = &tagger - matched = true - break - } - } - - // Step 3: no tagger active → accumulate as header (free-form text). - if st.currentTagger == nil { - if !st.skipHeader && !st.seenTag { - st.header = append(st.header, line) - } - return false - } - - // For multi-line taggers, the header line (the one that matched) is - // consumed but not collected — only subsequent lines are body. - if st.currentTagger.MultiLine && matched { - return false - } - - // Collect the line into the matched tagger's line buffer. - ts, ok := st.matched[st.currentTagger.Name] - if !ok { - ts = *st.currentTagger - } - ts.Lines = append(ts.Lines, line) - if st.matched == nil { - st.matched = make(map[string]TagParser) - } - st.matched[st.currentTagger.Name] = ts - - // Single-line taggers reset immediately; multi-line taggers stay active. - if !st.currentTagger.MultiLine { - st.currentTagger = nil - } - return false -} - -// collectTitleDescription lazily splits the accumulated header lines into -// title and description. The split is performed at most once. -// -// When setTitle is nil (no title callback registered), the header is only -// cleaned up (comment prefixes removed) but not split — everything stays -// in the description. -func (st *SectionedParser) collectTitleDescription() { - if st.workedOutTitle { - return - } - if st.setTitle == nil { - st.header = cleanupScannerLines(st.header, rxUncommentHeaders) - return - } - - st.workedOutTitle = true - st.title, st.header = collectScannerTitleDescription(st.header) -} diff --git a/internal/parsers/sectioned_parser_go119_test.go b/internal/parsers/sectioned_parser_go119_test.go deleted file mode 100644 index 729e047..0000000 --- a/internal/parsers/sectioned_parser_go119_test.go +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" -) - -func TestSectionedParser_TitleDescriptionGo119(t *testing.T) { - text := `# This has a title that starts with a hash tag - -The punctuation here does indeed matter. But it won't for go. -` - - text2 := `This has a title without whitespace. - -The punctuation here does indeed matter. But it won't for go. - -# There is an inline header here that doesn't count for finding a title - -` - - var err error - - st := &SectionedParser{} - st.setTitle = func(_ []string) {} - err = st.Parse(ascg(text)) - require.NoError(t, err) - - assert.Equal(t, []string{"This has a title that starts with a hash tag"}, st.Title()) - assert.Equal(t, []string{"The punctuation here does indeed matter. But it won't for go."}, st.Description()) - - st = &SectionedParser{} - st.setTitle = func(_ []string) {} - err = st.Parse(ascg(text2)) - require.NoError(t, err) - - assert.Equal(t, []string{"This has a title without whitespace."}, st.Title()) - assert.Equal(t, []string{ - "The punctuation here does indeed matter. But it won't for go.", "", - "# There is an inline header here that doesn't count for finding a title", - }, st.Description()) -} diff --git a/internal/parsers/sectioned_parser_test.go b/internal/parsers/sectioned_parser_test.go deleted file mode 100644 index d28d82c..0000000 --- a/internal/parsers/sectioned_parser_test.go +++ /dev/null @@ -1,382 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "errors" - "fmt" - "go/ast" - "regexp" - "strings" - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" - - "github.com/go-openapi/spec" -) - -// only used within this group of tests but never used within actual code base. -func newSchemaAnnotationParser(goName string) *schemaAnnotationParser { - return &schemaAnnotationParser{GoName: goName, rx: rxModelOverride} -} - -type schemaAnnotationParser struct { - GoName string - Name string - rx *regexp.Regexp -} - -func (sap *schemaAnnotationParser) Matches(line string) bool { - return sap.rx.MatchString(line) -} - -func (sap *schemaAnnotationParser) Parse(lines []string) error { - if sap.Name != "" { - return nil - } - - if len(lines) > 0 { - for _, line := range lines { - matches := sap.rx.FindStringSubmatch(line) - if len(matches) > 1 && len(matches[1]) > 0 { - sap.Name = matches[1] - return nil - } - } - } - return nil -} - -func TestSectionedParser_TitleDescription(t *testing.T) { - const ( - text = `This has a title, separated by a whitespace line - -In this example the punctuation for the title should not matter for swagger. -For go it will still make a difference though. -` - text2 = `This has a title without whitespace. -The punctuation here does indeed matter. But it won't for go. -` - - text3 = `This has a title, and markdown in the description - -See how markdown works now, we can have lists: - -+ first item -+ second item -+ third item - -[Links works too](http://localhost) -` - - text4 = `This has whitespace sensitive markdown in the description - -|+ first item -| + nested item -| + also nested item - -Sample code block: - -| fmt.Println("Hello World!") - -` - ) - - var err error - - st := &SectionedParser{} - st.setTitle = func(_ []string) {} - err = st.Parse(ascg(text)) - require.NoError(t, err) - - assert.Equal(t, []string{"This has a title, separated by a whitespace line"}, st.Title()) - assert.Equal(t, []string{"In this example the punctuation for the title should not matter for swagger.", "For go it will still make a difference though."}, st.Description()) - - st = &SectionedParser{} - st.setTitle = func(_ []string) {} - err = st.Parse(ascg(text2)) - require.NoError(t, err) - - assert.Equal(t, []string{"This has a title without whitespace."}, st.Title()) - assert.Equal(t, []string{"The punctuation here does indeed matter. But it won't for go."}, st.Description()) - - st = &SectionedParser{} - st.setTitle = func(_ []string) {} - err = st.Parse(ascg(text3)) - require.NoError(t, err) - - assert.Equal(t, []string{"This has a title, and markdown in the description"}, st.Title()) - assert.Equal(t, []string{ - "See how markdown works now, we can have lists:", "", - "+ first item", "+ second item", "+ third item", "", - "[Links works too](http://localhost)", - }, st.Description()) - - st = &SectionedParser{} - st.setTitle = func(_ []string) {} - err = st.Parse(ascg(text4)) - require.NoError(t, err) - - assert.Equal(t, []string{"This has whitespace sensitive markdown in the description"}, st.Title()) - assert.Equal(t, []string{"+ first item", " + nested item", " + also nested item", "", "Sample code block:", "", " fmt.Println(\"Hello World!\")"}, st.Description()) -} - -type schemaValidations struct { - current *spec.Schema -} - -func (sv schemaValidations) SetMaximum(val float64, exclusive bool) { - sv.current.Maximum = &val - sv.current.ExclusiveMaximum = exclusive -} - -func (sv schemaValidations) SetMinimum(val float64, exclusive bool) { - sv.current.Minimum = &val - sv.current.ExclusiveMinimum = exclusive -} -func (sv schemaValidations) SetMultipleOf(val float64) { sv.current.MultipleOf = &val } -func (sv schemaValidations) SetMinItems(val int64) { sv.current.MinItems = &val } -func (sv schemaValidations) SetMaxItems(val int64) { sv.current.MaxItems = &val } -func (sv schemaValidations) SetMinLength(val int64) { sv.current.MinLength = &val } -func (sv schemaValidations) SetMaxLength(val int64) { sv.current.MaxLength = &val } -func (sv schemaValidations) SetPattern(val string) { sv.current.Pattern = val } -func (sv schemaValidations) SetUnique(val bool) { sv.current.UniqueItems = val } -func (sv schemaValidations) SetDefault(val any) { sv.current.Default = val } -func (sv schemaValidations) SetExample(val any) { sv.current.Example = val } -func (sv schemaValidations) SetEnum(val string) { - var typ string - if len(sv.current.Type) > 0 { - typ = sv.current.Type[0] - } - sv.current.Enum = ParseEnum(val, &spec.SimpleSchema{Format: sv.current.Format, Type: typ}) -} - -func dummybuilder() schemaValidations { - return schemaValidations{new(spec.Schema)} -} - -func TestSectionedParser_TagsDescription(t *testing.T) { - const ( - block = `This has a title without whitespace. -The punctuation here does indeed matter. But it won't for go. -minimum: 10 -maximum: 20 -` - block2 = `This has a title without whitespace. -The punctuation here does indeed matter. But it won't for go. - -minimum: 10 -maximum: 20 -` - ) - - var err error - - st := &SectionedParser{} - st.setTitle = func(_ []string) {} - st.taggers = []TagParser{ - {"Maximum", false, false, nil, &SetMaximum{builder: dummybuilder(), rx: regexp.MustCompile(fmt.Sprintf(rxMaximumFmt, ""))}}, - {"Minimum", false, false, nil, &SetMinimum{builder: dummybuilder(), rx: regexp.MustCompile(fmt.Sprintf(rxMinimumFmt, ""))}}, - {"MultipleOf", false, false, nil, &SetMultipleOf{builder: dummybuilder(), rx: regexp.MustCompile(fmt.Sprintf(rxMultipleOfFmt, ""))}}, - } - - err = st.Parse(ascg(block)) - require.NoError(t, err) - assert.Equal(t, []string{"This has a title without whitespace."}, st.Title()) - assert.Equal(t, []string{"The punctuation here does indeed matter. But it won't for go."}, st.Description()) - assert.Len(t, st.matched, 2) - _, ok := st.matched["Maximum"] - assert.TrueT(t, ok) - _, ok = st.matched["Minimum"] - assert.TrueT(t, ok) - - st = &SectionedParser{} - st.setTitle = func(_ []string) {} - st.taggers = []TagParser{ - {"Maximum", false, false, nil, &SetMaximum{builder: dummybuilder(), rx: regexp.MustCompile(fmt.Sprintf(rxMaximumFmt, ""))}}, - {"Minimum", false, false, nil, &SetMinimum{builder: dummybuilder(), rx: regexp.MustCompile(fmt.Sprintf(rxMinimumFmt, ""))}}, - {"MultipleOf", false, false, nil, &SetMultipleOf{builder: dummybuilder(), rx: regexp.MustCompile(fmt.Sprintf(rxMultipleOfFmt, ""))}}, - } - - err = st.Parse(ascg(block2)) - require.NoError(t, err) - assert.Equal(t, []string{"This has a title without whitespace."}, st.Title()) - assert.Equal(t, []string{"The punctuation here does indeed matter. But it won't for go."}, st.Description()) - assert.Len(t, st.matched, 2) - _, ok = st.matched["Maximum"] - assert.TrueT(t, ok) - _, ok = st.matched["Minimum"] - assert.TrueT(t, ok) -} - -func TestSectionedParser_Empty(t *testing.T) { - const block = `swagger:response someResponse` - - var err error - - st := &SectionedParser{} - st.setTitle = func(_ []string) {} - ap := newSchemaAnnotationParser("SomeResponse") - ap.rx = rxResponseOverride - st.annotation = ap - - err = st.Parse(ascg(block)) - require.NoError(t, err) - assert.Empty(t, st.Title()) - assert.Empty(t, st.Description()) - assert.Empty(t, st.taggers) - assert.EqualT(t, "SomeResponse", ap.GoName) - assert.EqualT(t, "someResponse", ap.Name) -} - -func testSectionedParserWithBlock( - t *testing.T, - block string, - expectedMatchedCount int, - maximumExpected bool, -) { - t.Helper() - - st := &SectionedParser{} - st.setTitle = func(_ []string) {} - ap := newSchemaAnnotationParser("SomeModel") - st.annotation = ap - st.taggers = []TagParser{ - {"Maximum", false, false, nil, &SetMaximum{builder: dummybuilder(), rx: regexp.MustCompile(fmt.Sprintf(rxMaximumFmt, ""))}}, - {"Minimum", false, false, nil, &SetMinimum{builder: dummybuilder(), rx: regexp.MustCompile(fmt.Sprintf(rxMinimumFmt, ""))}}, - {"MultipleOf", false, false, nil, &SetMultipleOf{builder: dummybuilder(), rx: regexp.MustCompile(fmt.Sprintf(rxMultipleOfFmt, ""))}}, - } - - err := st.Parse(ascg(block)) - require.NoError(t, err) - assert.Equal(t, []string{"This has a title without whitespace."}, st.Title()) - assert.Equal(t, []string{"The punctuation here does indeed matter. But it won't for go."}, st.Description()) - assert.Len(t, st.matched, expectedMatchedCount) - _, ok := st.matched["Maximum"] - assert.EqualT(t, maximumExpected, ok) - _, ok = st.matched["Minimum"] - assert.TrueT(t, ok) - assert.EqualT(t, "SomeModel", ap.GoName) - assert.EqualT(t, "someModel", ap.Name) -} - -func TestSectionedParser_SkipSectionAnnotation(t *testing.T) { - const block = `swagger:model someModel - -This has a title without whitespace. -The punctuation here does indeed matter. But it won't for go. - -minimum: 10 -maximum: 20 -` - testSectionedParserWithBlock(t, block, 2, true) -} - -func TestSectionedParser_TerminateOnNewAnnotation(t *testing.T) { - const block = `swagger:model someModel - -This has a title without whitespace. -The punctuation here does indeed matter. But it won't for go. - -minimum: 10 -swagger:meta -maximum: 20 -` - testSectionedParserWithBlock(t, block, 1, false) -} - -func TestSectionedParser_NilDoc(t *testing.T) { - st := NewSectionedParser( - WithSetTitle(func(_ []string) {}), - WithSetDescription(func(_ []string) {}), - ) - require.NoError(t, st.Parse(nil)) - assert.Empty(t, st.Title()) - assert.Empty(t, st.Description()) - assert.FalseT(t, st.Ignored()) -} - -func TestSectionedParser_IgnoredAnnotation(t *testing.T) { - const block = `swagger:ignore SomeType - -This should not matter. -` - st := NewSectionedParser( - WithSetTitle(func(_ []string) {}), - ) - err := st.Parse(ascg(block)) - require.NoError(t, err) - assert.TrueT(t, st.Ignored()) -} - -func TestSectionedParser_WithoutSetTitle(t *testing.T) { - // When setTitle is nil, collectTitleDescription cleans up headers - // but does not split title from description. - const block = `Just a description line. -Another line. -` - st := &SectionedParser{} - err := st.Parse(ascg(block)) - require.NoError(t, err) - assert.Nil(t, st.Title()) - assert.Equal(t, []string{"Just a description line.", "Another line."}, st.Description()) -} - -func TestSectionedParser_TagParseError(t *testing.T) { - // When a matched tagger's Parse returns an error, SectionedParser.Parse propagates it. - errParser := &failingParser{} - st := NewSectionedParser( - WithSetTitle(func(_ []string) {}), - WithTaggers( - NewSingleLineTagParser("Failing", errParser), - ), - ) - - const block = `Title. - -minimum: 10 -` - err := st.Parse(ascg(block)) - require.Error(t, err) - assert.ErrorIs(t, err, errForced) -} - -type failingParser struct{} - -var errForced = errors.New("forced error") - -func (f *failingParser) Matches(line string) bool { return rxMinimum.MatchString(line) } -func (f *failingParser) Parse(_ []string) error { return errForced } - -func TestSectionedParser_AnnotationMatchWithHeader(t *testing.T) { - // When the annotation matches and headers have been collected, - // seenTag is set to true — further non-tag lines are skipped. - const block = `swagger:model someModel - -Title. -Description. - -swagger:model anotherModel -This line after a re-match should still be part of the description. -` - ap := newSchemaAnnotationParser("SomeModel") - st := &SectionedParser{} - st.setTitle = func(_ []string) {} - st.annotation = ap - - err := st.Parse(ascg(block)) - require.NoError(t, err) - assert.EqualT(t, "someModel", ap.Name) -} - -func ascg(txt string) *ast.CommentGroup { - var cg ast.CommentGroup - for line := range strings.SplitSeq(txt, "\n") { - var cmt ast.Comment - cmt.Text = "// " + line - cg.List = append(cg.List, &cmt) - } - return &cg -} diff --git a/internal/parsers/security.go b/internal/parsers/security.go deleted file mode 100644 index 7d3f434..0000000 --- a/internal/parsers/security.go +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "regexp" - "strings" -) - -type SetSchemes struct { - set func([]string) - rx *regexp.Regexp -} - -func NewSetSchemes(set func([]string)) *SetSchemes { - return &SetSchemes{ - set: set, - rx: rxSchemes, - } -} - -func (ss *SetSchemes) Matches(line string) bool { - return ss.rx.MatchString(line) -} - -func (ss *SetSchemes) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - - matches := ss.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - sch := strings.Split(matches[1], ", ") - - schemes := []string{} - for _, s := range sch { - ts := strings.TrimSpace(s) - if ts != "" { - schemes = append(schemes, ts) - } - } - ss.set(schemes) - } - - return nil -} - -type SetSecurity struct { - set func([]map[string][]string) - rx *regexp.Regexp -} - -func newSetSecurity(rx *regexp.Regexp, setter func([]map[string][]string)) *SetSecurity { - return &SetSecurity{ - set: setter, - rx: rx, - } -} - -func NewSetSecurityScheme(setter func([]map[string][]string)) *SetSecurity { - return &SetSecurity{ - set: setter, - rx: rxSecuritySchemes, - } -} - -func (ss *SetSecurity) Matches(line string) bool { - return ss.rx.MatchString(line) -} - -func (ss *SetSecurity) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - - var result []map[string][]string - const kvParts = 2 - for _, line := range lines { - kv := strings.SplitN(line, ":", kvParts) - scopes := []string{} - var key string - - if len(kv) > 1 { - scs := strings.SplitSeq(kv[1], ",") - for scope := range scs { - tr := strings.TrimSpace(scope) - if tr != "" { - tr = strings.SplitAfter(tr, " ")[0] - scopes = append(scopes, strings.TrimSpace(tr)) - } - } - - key = strings.TrimSpace(kv[0]) - - result = append(result, map[string][]string{key: scopes}) - } - } - - ss.set(result) - - return nil -} diff --git a/internal/parsers/security/security.go b/internal/parsers/security/security.go new file mode 100644 index 0000000..7a2d96c --- /dev/null +++ b/internal/parsers/security/security.go @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package security is a thin sub-parser for the `Security:` block +// body that appears under both `swagger:meta` and `swagger:route`. +// The body shape is one requirement per line: +// +// name: scope1, scope2 +// +// where the scope list may be empty (a bare `name:` is permitted) +// and scopes are whitespace-trimmed. +// +// Sibling of `internal/parsers/yaml/`: imported by +// `internal/parsers/grammar/` from the lexer-time dispatch in +// emitRawBlock (the same seam yaml.TypedExtensions plugs into for +// the `extensions:` keyword). Builders read the typed result via +// `grammar.Block.SecurityRequirements()` rather than re-parsing the +// raw body — same shape extensions already follows. +package security + +import "strings" + +// Requirement is one Security: line's contribution to the spec: +// a single-entry map from name → scope list. Multiple Requirements +// build up across lines. +type Requirement = map[string][]string + +// Parse splits body on newlines and parses each non-blank line as a +// `name: scope1, scope2` security requirement. Empty body returns +// nil. +// +// V1 quirk preserved: a scope that contains whitespace truncates at +// its first word — fixtures today only use single-word scopes, so +// the truncation is invisible, but the regression risk is real +// enough to keep the behaviour locked. +func Parse(body string) []Requirement { + if body == "" { + return nil + } + return parseLines(strings.Split(body, "\n")) +} + +func parseLines(lines []string) []Requirement { + const kvParts = 2 + var result []Requirement + for _, raw := range lines { + kv := strings.SplitN(raw, ":", kvParts) + if len(kv) < kvParts { + continue + } + name := strings.TrimSpace(kv[0]) + if name == "" { + continue + } + scopes := []string{} + for scope := range strings.SplitSeq(kv[1], ",") { + tr := strings.TrimSpace(scope) + if tr == "" { + continue + } + // V1 quirk: scope truncates at first whitespace. + tr = strings.SplitAfter(tr, " ")[0] + scopes = append(scopes, strings.TrimSpace(tr)) + } + result = append(result, Requirement{name: scopes}) + } + return result +} diff --git a/internal/parsers/security_test.go b/internal/parsers/security_test.go deleted file mode 100644 index 6d88115..0000000 --- a/internal/parsers/security_test.go +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" -) - -func TestSetSchemes(t *testing.T) { - t.Parallel() - - t.Run("single scheme", func(t *testing.T) { - var got []string - ss := NewSetSchemes(func(v []string) { got = v }) - assert.TrueT(t, ss.Matches("schemes: http")) - require.NoError(t, ss.Parse([]string{"schemes: http"})) - assert.Equal(t, []string{"http"}, got) - }) - - t.Run("multiple schemes", func(t *testing.T) { - var got []string - ss := NewSetSchemes(func(v []string) { got = v }) - require.NoError(t, ss.Parse([]string{"schemes: http, https"})) - assert.Equal(t, []string{"http", "https"}, got) - }) - - t.Run("wss", func(t *testing.T) { - var got []string - ss := NewSetSchemes(func(v []string) { got = v }) - require.NoError(t, ss.Parse([]string{"Schemes: ws, wss"})) - assert.Equal(t, []string{"ws", "wss"}, got) - }) - - t.Run("empty", func(t *testing.T) { - var got []string - ss := NewSetSchemes(func(v []string) { got = v }) - require.NoError(t, ss.Parse(nil)) - require.NoError(t, ss.Parse([]string{})) - require.NoError(t, ss.Parse([]string{""})) - assert.Nil(t, got) - }) - - t.Run("no match", func(t *testing.T) { - ss := NewSetSchemes(nil) - assert.FalseT(t, ss.Matches("something else")) - }) -} - -func TestSetSecurity(t *testing.T) { - t.Parallel() - - t.Run("with scopes", func(t *testing.T) { - var got []map[string][]string - ss := NewSetSecurityScheme(func(v []map[string][]string) { got = v }) - assert.TrueT(t, ss.Matches("security:")) - require.NoError(t, ss.Parse([]string{ - "api_key:", - "oauth2: read:pets, write:pets", - })) - require.Len(t, got, 2) - assert.Equal(t, map[string][]string{"api_key": {}}, got[0]) - assert.Equal(t, map[string][]string{"oauth2": {"read:pets", "write:pets"}}, got[1]) - }) - - t.Run("empty", func(t *testing.T) { - var got []map[string][]string - ss := NewSetSecurityScheme(func(v []map[string][]string) { got = v }) - require.NoError(t, ss.Parse(nil)) - require.NoError(t, ss.Parse([]string{})) - require.NoError(t, ss.Parse([]string{""})) - assert.Nil(t, got) - }) - - t.Run("no colon in line", func(t *testing.T) { - var got []map[string][]string - ss := NewSetSecurityScheme(func(v []map[string][]string) { got = v }) - require.NoError(t, ss.Parse([]string{"no-colon-here"})) - assert.Nil(t, got) // line without colon is skipped - }) -} diff --git a/internal/parsers/tag_parsers.go b/internal/parsers/tag_parsers.go deleted file mode 100644 index 16d8ad7..0000000 --- a/internal/parsers/tag_parsers.go +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import "github.com/go-openapi/codescan/internal/ifaces" - -// TagParser pairs a named tag with a [ifaces.ValueParser] that recognizes and -// extracts its value from comment lines. -// -// A TagParser operates in one of two modes: -// -// - Single-line: the tag matches exactly one line (e.g. "maximum: 10"). -// The [SectionedParser] resets its current tagger after every single-line -// match, so the next line is free to match a different tagger. -// -// - Multi-line: the tag's first matching line is a header (e.g. "consumes:") -// and all subsequent lines are collected as its body until a different -// tagger matches or the comment block ends. The header line itself is NOT -// included in Lines — only the body lines that follow it. -// -// SkipCleanUp controls whether the [SectionedParser] strips comment prefixes -// (// , *, etc.) from the collected Lines before calling Parse. YAML-based -// taggers set this to true because they need the original indentation intact. -// -// Lines is populated by the [SectionedParser] during its scan; after the scan -// completes, Parse is called with those lines to extract the value. -type TagParser struct { - Name string - MultiLine bool - SkipCleanUp bool - Lines []string - Parser ifaces.ValueParser -} - -// NewMultiLineTagParser creates a TagParser that collects all lines following -// the matching header until a different tag or annotation is encountered. -// -// Example usage (from [NewMetaParser]): -// -// NewMultiLineTagParser("TOS", -// newMultilineDropEmptyParser(rxTOS, metaTOSSetter(info)), -// false, // clean up comment prefixes before parsing -// ) -// -// This creates a tagger that recognizes "Terms of Service:" and collects every -// subsequent line into the TOS field, stripping comment prefixes. -func NewMultiLineTagParser(name string, parser ifaces.ValueParser, skipCleanUp bool) TagParser { - return TagParser{ - Name: name, - MultiLine: true, - SkipCleanUp: skipCleanUp, - Parser: parser, - } -} - -// NewSingleLineTagParser creates a TagParser that matches and parses exactly -// one line. After the match, the [SectionedParser] resets its current tagger -// so subsequent lines can match other taggers. -// -// Example usage (from [NewMetaParser]): -// -// NewSingleLineTagParser("Version", -// &setMetaSingle{Spec: swspec, Rx: rxVersion, Set: setInfoVersion}, -// ) -// -// This creates a tagger that recognizes "Version: 1.0.0" and writes the -// captured value into swspec.Info.Version. -func NewSingleLineTagParser(name string, parser ifaces.ValueParser) TagParser { - return TagParser{ - Name: name, - MultiLine: false, - SkipCleanUp: false, - Parser: parser, - } -} - -// Matches delegates to the underlying Parser. -func (st *TagParser) Matches(line string) bool { - return st.Parser.Matches(line) -} - -// Parse delegates to the underlying Parser. -func (st *TagParser) Parse(lines []string) error { - return st.Parser.Parse(lines) -} diff --git a/internal/parsers/validations.go b/internal/parsers/validations.go deleted file mode 100644 index 1625d9e..0000000 --- a/internal/parsers/validations.go +++ /dev/null @@ -1,610 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "fmt" - "regexp" - "strconv" - - "github.com/go-openapi/codescan/internal/ifaces" - oaispec "github.com/go-openapi/spec" -) - -var ( - rxMaximum = regexp.MustCompile(fmt.Sprintf(rxMaximumFmt, "")) - rxMinimum = regexp.MustCompile(fmt.Sprintf(rxMinimumFmt, "")) - rxMultipleOf = regexp.MustCompile(fmt.Sprintf(rxMultipleOfFmt, "")) - rxMinItems = regexp.MustCompile(fmt.Sprintf(rxMinItemsFmt, "")) - rxMaxItems = regexp.MustCompile(fmt.Sprintf(rxMaxItemsFmt, "")) - rxMaxLength = regexp.MustCompile(fmt.Sprintf(rxMaxLengthFmt, "")) - rxMinLength = regexp.MustCompile(fmt.Sprintf(rxMinLengthFmt, "")) - rxPattern = regexp.MustCompile(fmt.Sprintf(rxPatternFmt, "")) - rxCollectionFormat = regexp.MustCompile(fmt.Sprintf(rxCollectionFormatFmt, "")) - rxUnique = regexp.MustCompile(fmt.Sprintf(rxUniqueFmt, "")) - rxEnumValidation = regexp.MustCompile(fmt.Sprintf(rxEnumFmt, "")) - rxDefaultValidation = regexp.MustCompile(fmt.Sprintf(rxDefaultFmt, "")) - rxExample = regexp.MustCompile(fmt.Sprintf(rxExampleFmt, "")) -) - -type PrefixRxOption func(string) *regexp.Regexp - -func WithItemsPrefixLevel(level int) PrefixRxOption { - // the expression is 1-index based not 0-index - itemsPrefix := fmt.Sprintf(rxItemsPrefixFmt, level+1) - return func(expr string) *regexp.Regexp { - return Rxf(expr, itemsPrefix) // Proposal for enhancement(fred): cache - } -} - -type SetMaximum struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetMaximum(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetMaximum { - rx := rxMaximum - for _, apply := range opts { - rx = apply(rxMaximumFmt) - } - - return &SetMaximum{ - builder: builder, - rx: rx, - } -} - -func (sm *SetMaximum) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := sm.rx.FindStringSubmatch(lines[0]) - if len(matches) > 2 && len(matches[2]) > 0 { - maximum, err := strconv.ParseFloat(matches[2], 64) - if err != nil { - return err - } - sm.builder.SetMaximum(maximum, matches[1] == "<") - } - return nil -} - -func (sm *SetMaximum) Matches(line string) bool { - return sm.rx.MatchString(line) -} - -type SetMinimum struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetMinimum(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetMinimum { - rx := rxMinimum - for _, apply := range opts { - rx = apply(rxMinimumFmt) - } - - return &SetMinimum{ - builder: builder, - rx: rx, - } -} - -func (sm *SetMinimum) Matches(line string) bool { - return sm.rx.MatchString(line) -} - -func (sm *SetMinimum) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := sm.rx.FindStringSubmatch(lines[0]) - if len(matches) > 2 && len(matches[2]) > 0 { - minimum, err := strconv.ParseFloat(matches[2], 64) - if err != nil { - return err - } - sm.builder.SetMinimum(minimum, matches[1] == ">") - } - return nil -} - -type SetMultipleOf struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetMultipleOf(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetMultipleOf { - rx := rxMultipleOf - for _, apply := range opts { - rx = apply(rxMultipleOfFmt) - } - - return &SetMultipleOf{ - builder: builder, - rx: rx, - } -} - -func (sm *SetMultipleOf) Matches(line string) bool { - return sm.rx.MatchString(line) -} - -func (sm *SetMultipleOf) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := sm.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - multipleOf, err := strconv.ParseFloat(matches[1], 64) - if err != nil { - return err - } - sm.builder.SetMultipleOf(multipleOf) - } - return nil -} - -type SetMaxItems struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetMaxItems(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetMaxItems { - rx := rxMaxItems - for _, apply := range opts { - rx = apply(rxMaxItemsFmt) - } - - return &SetMaxItems{ - builder: builder, - rx: rx, - } -} - -func (sm *SetMaxItems) Matches(line string) bool { - return sm.rx.MatchString(line) -} - -func (sm *SetMaxItems) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := sm.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - maxItems, err := strconv.ParseInt(matches[1], 10, 64) - if err != nil { - return err - } - sm.builder.SetMaxItems(maxItems) - } - return nil -} - -type SetMinItems struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetMinItems(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetMinItems { - rx := rxMinItems - for _, apply := range opts { - rx = apply(rxMinItemsFmt) - } - - return &SetMinItems{ - builder: builder, - rx: rx, - } -} - -func (sm *SetMinItems) Matches(line string) bool { - return sm.rx.MatchString(line) -} - -func (sm *SetMinItems) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := sm.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - minItems, err := strconv.ParseInt(matches[1], 10, 64) - if err != nil { - return err - } - sm.builder.SetMinItems(minItems) - } - return nil -} - -type SetMaxLength struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetMaxLength(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetMaxLength { - rx := rxMaxLength - for _, apply := range opts { - rx = apply(rxMaxLengthFmt) - } - - return &SetMaxLength{ - builder: builder, - rx: rx, - } -} - -func (sm *SetMaxLength) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := sm.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - maxLength, err := strconv.ParseInt(matches[1], 10, 64) - if err != nil { - return err - } - sm.builder.SetMaxLength(maxLength) - } - return nil -} - -func (sm *SetMaxLength) Matches(line string) bool { - return sm.rx.MatchString(line) -} - -type SetMinLength struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetMinLength(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetMinLength { - rx := rxMinLength - for _, apply := range opts { - rx = apply(rxMinLengthFmt) - } - - return &SetMinLength{ - builder: builder, - rx: rx, - } -} - -func (sm *SetMinLength) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := sm.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - minLength, err := strconv.ParseInt(matches[1], 10, 64) - if err != nil { - return err - } - sm.builder.SetMinLength(minLength) - } - return nil -} - -func (sm *SetMinLength) Matches(line string) bool { - return sm.rx.MatchString(line) -} - -type SetPattern struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetPattern(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetPattern { - rx := rxPattern - for _, apply := range opts { - rx = apply(rxPatternFmt) - } - - return &SetPattern{ - builder: builder, - rx: rx, - } -} - -func (sm *SetPattern) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := sm.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - sm.builder.SetPattern(matches[1]) - } - return nil -} - -func (sm *SetPattern) Matches(line string) bool { - return sm.rx.MatchString(line) -} - -type SetCollectionFormat struct { - builder ifaces.OperationValidationBuilder - rx *regexp.Regexp -} - -func NewSetCollectionFormat(builder ifaces.OperationValidationBuilder, opts ...PrefixRxOption) *SetCollectionFormat { - rx := rxCollectionFormat - for _, apply := range opts { - rx = apply(rxCollectionFormatFmt) - } - - return &SetCollectionFormat{ - builder: builder, - rx: rx, - } -} - -func (sm *SetCollectionFormat) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := sm.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - sm.builder.SetCollectionFormat(matches[1]) - } - return nil -} - -func (sm *SetCollectionFormat) Matches(line string) bool { - return sm.rx.MatchString(line) -} - -type SetUnique struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetUnique(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetUnique { - rx := rxUnique - for _, apply := range opts { - rx = apply(rxUniqueFmt) - } - - return &SetUnique{ - builder: builder, - rx: rx, - } -} - -func (su *SetUnique) Matches(line string) bool { - return su.rx.MatchString(line) -} - -func (su *SetUnique) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := su.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - req, err := strconv.ParseBool(matches[1]) - if err != nil { - return err - } - su.builder.SetUnique(req) - } - return nil -} - -type SetRequiredParam struct { - tgt *oaispec.Parameter -} - -func NewSetRequiredParam(param *oaispec.Parameter) *SetRequiredParam { - return &SetRequiredParam{ - tgt: param, - } -} - -func (su *SetRequiredParam) Matches(line string) bool { - return rxRequired.MatchString(line) -} - -func (su *SetRequiredParam) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := rxRequired.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - req, err := strconv.ParseBool(matches[1]) - if err != nil { - return err - } - su.tgt.Required = req - } - return nil -} - -type SetReadOnlySchema struct { - tgt *oaispec.Schema -} - -func NewSetReadOnlySchema(schema *oaispec.Schema) *SetReadOnlySchema { - return &SetReadOnlySchema{ - tgt: schema, - } -} - -func (su *SetReadOnlySchema) Matches(line string) bool { - return rxReadOnly.MatchString(line) -} - -func (su *SetReadOnlySchema) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := rxReadOnly.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - req, err := strconv.ParseBool(matches[1]) - if err != nil { - return err - } - su.tgt.ReadOnly = req - } - return nil -} - -type SetRequiredSchema struct { - Schema *oaispec.Schema - Field string -} - -func NewSetRequiredSchema(schema *oaispec.Schema, field string) *SetRequiredSchema { - return &SetRequiredSchema{ - Schema: schema, - Field: field, - } -} - -func (su *SetRequiredSchema) Matches(line string) bool { - return rxRequired.MatchString(line) -} - -func (su *SetRequiredSchema) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := rxRequired.FindStringSubmatch(lines[0]) - if len(matches) <= 1 || len(matches[1]) == 0 { - return nil - } - - req, err := strconv.ParseBool(matches[1]) - if err != nil { - return err - } - midx := -1 - for i, nm := range su.Schema.Required { - if nm == su.Field { - midx = i - break - } - } - if req { - if midx < 0 { - su.Schema.Required = append(su.Schema.Required, su.Field) - } - } else if midx >= 0 { - su.Schema.Required = append(su.Schema.Required[:midx], su.Schema.Required[midx+1:]...) - } - return nil -} - -type SetDefault struct { - scheme *oaispec.SimpleSchema - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetDefault(scheme *oaispec.SimpleSchema, builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetDefault { - rx := rxDefaultValidation - for _, apply := range opts { - rx = apply(rxDefaultFmt) - } - - return &SetDefault{ - scheme: scheme, - builder: builder, - rx: rx, - } -} - -func (sd *SetDefault) Matches(line string) bool { - return sd.rx.MatchString(line) -} - -func (sd *SetDefault) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - - matches := sd.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - d, err := parseValueFromSchema(matches[1], sd.scheme) - if err != nil { - return err - } - sd.builder.SetDefault(d) - } - - return nil -} - -type SetExample struct { - scheme *oaispec.SimpleSchema - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetExample(scheme *oaispec.SimpleSchema, builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetExample { - rx := rxExample - for _, apply := range opts { - rx = apply(rxExampleFmt) - } - - return &SetExample{ - scheme: scheme, - builder: builder, - rx: rx, - } -} - -func (se *SetExample) Matches(line string) bool { - return se.rx.MatchString(line) -} - -func (se *SetExample) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - - matches := se.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - d, err := parseValueFromSchema(matches[1], se.scheme) - if err != nil { - return err - } - se.builder.SetExample(d) - } - - return nil -} - -type SetDiscriminator struct { - Schema *oaispec.Schema - Field string -} - -func NewSetDiscriminator(schema *oaispec.Schema, field string) *SetDiscriminator { - return &SetDiscriminator{ - Schema: schema, - Field: field, - } -} - -func (su *SetDiscriminator) Matches(line string) bool { - return rxDiscriminator.MatchString(line) -} - -func (su *SetDiscriminator) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := rxDiscriminator.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - req, err := strconv.ParseBool(matches[1]) - if err != nil { - return err - } - if req { - su.Schema.Discriminator = su.Field - } else if su.Schema.Discriminator == su.Field { - su.Schema.Discriminator = "" - } - } - return nil -} diff --git a/internal/parsers/validations_test.go b/internal/parsers/validations_test.go deleted file mode 100644 index ed09c9d..0000000 --- a/internal/parsers/validations_test.go +++ /dev/null @@ -1,750 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "strings" - "testing" - - "github.com/go-openapi/codescan/internal/ifaces" - "github.com/go-openapi/codescan/internal/scantest/mocks" - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" - - oaispec "github.com/go-openapi/spec" -) - -// validationRecorder captures all calls made to a ValidationBuilder. -type validationRecorder struct { - maximum *float64 - exclusiveMaximum bool - minimum *float64 - exclusiveMinimum bool - multipleOf *float64 - minItems *int64 - maxItems *int64 - minLength *int64 - maxLength *int64 - pattern string - unique *bool - enum string - defaultVal any - exampleVal any - collectionFormat string -} - -func (r *validationRecorder) SetMaximum(v float64, exclusive bool) { - r.maximum = &v - r.exclusiveMaximum = exclusive -} - -func (r *validationRecorder) SetMinimum(v float64, exclusive bool) { - r.minimum = &v - r.exclusiveMinimum = exclusive -} -func (r *validationRecorder) SetMultipleOf(v float64) { r.multipleOf = &v } -func (r *validationRecorder) SetMinItems(v int64) { r.minItems = &v } -func (r *validationRecorder) SetMaxItems(v int64) { r.maxItems = &v } -func (r *validationRecorder) SetMinLength(v int64) { r.minLength = &v } -func (r *validationRecorder) SetMaxLength(v int64) { r.maxLength = &v } -func (r *validationRecorder) SetPattern(v string) { r.pattern = v } -func (r *validationRecorder) SetUnique(v bool) { r.unique = &v } -func (r *validationRecorder) SetEnum(v string) { r.enum = v } -func (r *validationRecorder) SetDefault(v any) { r.defaultVal = v } -func (r *validationRecorder) SetExample(v any) { r.exampleVal = v } -func (r *validationRecorder) SetCollectionFormat(v string) { r.collectionFormat = v } - -func TestSetMaximum(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - line string - wantMatch bool - wantVal float64 - exclusive bool - }{ - {"inclusive", "maximum: 100", true, 100, false}, - {"exclusive", "maximum: < 100", true, 100, true}, - {"decimal", "maximum: 99.5", true, 99.5, false}, - {"negative", "maximum: -10", true, -10, false}, - {"no match", "something else", false, 0, false}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMaximum(rec) - assert.EqualT(t, tc.wantMatch, sm.Matches(tc.line)) - if tc.wantMatch { - require.NoError(t, sm.Parse([]string{tc.line})) - require.NotNil(t, rec.maximum) - assert.EqualT(t, tc.wantVal, *rec.maximum) - assert.EqualT(t, tc.exclusive, rec.exclusiveMaximum) - } - }) - } - - t.Run("empty lines", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMaximum(rec) - require.NoError(t, sm.Parse(nil)) - require.NoError(t, sm.Parse([]string{})) - require.NoError(t, sm.Parse([]string{""})) - assert.Nil(t, rec.maximum) - }) - - t.Run("parse error", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMaximum(rec) - // Force a match with a non-numeric value via raw regex - require.NoError(t, sm.Parse([]string{"maximum: not-a-number"})) - assert.Nil(t, rec.maximum) // no match because regex won't capture non-numeric - }) -} - -func TestSetMinimum(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - line string - wantMatch bool - wantVal float64 - exclusive bool - }{ - {"inclusive", "minimum: 0", true, 0, false}, - {"exclusive", "minimum: > 0", true, 0, true}, - {"decimal", "min: 1.5", true, 1.5, false}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMinimum(rec) - assert.EqualT(t, tc.wantMatch, sm.Matches(tc.line)) - if tc.wantMatch { - require.NoError(t, sm.Parse([]string{tc.line})) - require.NotNil(t, rec.minimum) - assert.EqualT(t, tc.wantVal, *rec.minimum) - assert.EqualT(t, tc.exclusive, rec.exclusiveMinimum) - } - }) - } -} - -func TestSetMultipleOf(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - line string - wantMatch bool - wantVal float64 - }{ - {"integer", "multiple of: 5", true, 5}, - {"decimal", "Multiple Of: 0.5", true, 0.5}, - {"no match", "something else", false, 0}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMultipleOf(rec) - assert.EqualT(t, tc.wantMatch, sm.Matches(tc.line)) - if tc.wantMatch { - require.NoError(t, sm.Parse([]string{tc.line})) - require.NotNil(t, rec.multipleOf) - assert.EqualT(t, tc.wantVal, *rec.multipleOf) - } - }) - } -} - -func TestSetMaxItems(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - sm := NewSetMaxItems(rec) - assert.TrueT(t, sm.Matches("max items: 10")) - require.NoError(t, sm.Parse([]string{"max items: 10"})) - require.NotNil(t, rec.maxItems) - assert.EqualT(t, int64(10), *rec.maxItems) -} - -func TestSetMinItems(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - sm := NewSetMinItems(rec) - assert.TrueT(t, sm.Matches("min items: 1")) - require.NoError(t, sm.Parse([]string{"min items: 1"})) - require.NotNil(t, rec.minItems) - assert.EqualT(t, int64(1), *rec.minItems) -} - -func TestSetMaxLength(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - sm := NewSetMaxLength(rec) - assert.TrueT(t, sm.Matches("max length: 255")) - require.NoError(t, sm.Parse([]string{"max length: 255"})) - require.NotNil(t, rec.maxLength) - assert.EqualT(t, int64(255), *rec.maxLength) -} - -func TestSetMinLength(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - sm := NewSetMinLength(rec) - assert.TrueT(t, sm.Matches("min length: 1")) - require.NoError(t, sm.Parse([]string{"min length: 1"})) - require.NotNil(t, rec.minLength) - assert.EqualT(t, int64(1), *rec.minLength) -} - -func TestSetPattern(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - sm := NewSetPattern(rec) - assert.TrueT(t, sm.Matches("pattern: ^\\w+$")) - require.NoError(t, sm.Parse([]string{"pattern: ^\\w+$"})) - assert.EqualT(t, "^\\w+$", rec.pattern) -} - -func TestSetCollectionFormat(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - sm := NewSetCollectionFormat(rec) - assert.TrueT(t, sm.Matches("collection format: csv")) - require.NoError(t, sm.Parse([]string{"collection format: csv"})) - assert.EqualT(t, "csv", rec.collectionFormat) -} - -func TestSetUnique(t *testing.T) { - t.Parallel() - - tests := []struct { - line string - want bool - }{ - {"unique: true", true}, - {"unique: false", false}, - } - - for _, tc := range tests { - t.Run(tc.line, func(t *testing.T) { - rec := &validationRecorder{} - su := NewSetUnique(rec) - assert.TrueT(t, su.Matches(tc.line)) - require.NoError(t, su.Parse([]string{tc.line})) - require.NotNil(t, rec.unique) - assert.EqualT(t, tc.want, *rec.unique) - }) - } - - t.Run("parse error", func(t *testing.T) { - rec := &validationRecorder{} - su := NewSetUnique(rec) - // unique: accepts only true/false so non-bool won't match - assert.FalseT(t, su.Matches("unique: maybe")) - }) -} - -func TestSetRequiredParam(t *testing.T) { - t.Parallel() - - tests := []struct { - line string - want bool - }{ - {"required: true", true}, - {"required: false", false}, - } - - for _, tc := range tests { - t.Run(tc.line, func(t *testing.T) { - param := new(oaispec.Parameter) - su := NewSetRequiredParam(param) - assert.TrueT(t, su.Matches(tc.line)) - require.NoError(t, su.Parse([]string{tc.line})) - assert.EqualT(t, tc.want, param.Required) - }) - } - - t.Run("empty", func(t *testing.T) { - param := new(oaispec.Parameter) - su := NewSetRequiredParam(param) - require.NoError(t, su.Parse(nil)) - assert.FalseT(t, param.Required) - }) -} - -func TestSetReadOnlySchema(t *testing.T) { - t.Parallel() - - tests := []struct { - line string - want bool - }{ - {"read only: true", true}, - {"readOnly: true", true}, - {"read-only: false", false}, - } - - for _, tc := range tests { - t.Run(tc.line, func(t *testing.T) { - schema := new(oaispec.Schema) - su := NewSetReadOnlySchema(schema) - assert.TrueT(t, su.Matches(tc.line)) - require.NoError(t, su.Parse([]string{tc.line})) - assert.EqualT(t, tc.want, schema.ReadOnly) - }) - } -} - -func TestSetRequiredSchema(t *testing.T) { - t.Parallel() - - t.Run("set required true", func(t *testing.T) { - schema := new(oaispec.Schema) - su := NewSetRequiredSchema(schema, "name") - require.NoError(t, su.Parse([]string{"required: true"})) - assert.Equal(t, []string{"name"}, schema.Required) - }) - - t.Run("set required false removes", func(t *testing.T) { - schema := &oaispec.Schema{} - schema.Required = []string{"name", "age"} - su := NewSetRequiredSchema(schema, "name") - require.NoError(t, su.Parse([]string{"required: false"})) - assert.Equal(t, []string{"age"}, schema.Required) - }) - - t.Run("set required true idempotent", func(t *testing.T) { - schema := &oaispec.Schema{} - schema.Required = []string{"name"} - su := NewSetRequiredSchema(schema, "name") - require.NoError(t, su.Parse([]string{"required: true"})) - assert.Equal(t, []string{"name"}, schema.Required) - }) - - t.Run("set required false not present", func(t *testing.T) { - schema := new(oaispec.Schema) - su := NewSetRequiredSchema(schema, "name") - require.NoError(t, su.Parse([]string{"required: false"})) - assert.Empty(t, schema.Required) - }) - - t.Run("empty lines", func(t *testing.T) { - schema := new(oaispec.Schema) - su := NewSetRequiredSchema(schema, "name") - require.NoError(t, su.Parse(nil)) - require.NoError(t, su.Parse([]string{""})) - }) - - t.Run("no match in line", func(t *testing.T) { - schema := new(oaispec.Schema) - su := NewSetRequiredSchema(schema, "name") - require.NoError(t, su.Parse([]string{"something else"})) - assert.Empty(t, schema.Required) - }) -} - -func TestSetDefault(t *testing.T) { - t.Parallel() - - t.Run("string type", func(t *testing.T) { - rec := &validationRecorder{} - scheme := &oaispec.SimpleSchema{Type: "string"} - sd := NewSetDefault(scheme, rec) - assert.TrueT(t, sd.Matches("default: hello")) - require.NoError(t, sd.Parse([]string{"default: hello"})) - assert.EqualT(t, "hello", rec.defaultVal) - }) - - t.Run("integer type", func(t *testing.T) { - rec := &validationRecorder{} - scheme := &oaispec.SimpleSchema{Type: "integer"} - sd := NewSetDefault(scheme, rec) - require.NoError(t, sd.Parse([]string{"default: 42"})) - assert.EqualT(t, 42, rec.defaultVal) - }) - - t.Run("empty", func(t *testing.T) { - rec := &validationRecorder{} - scheme := &oaispec.SimpleSchema{Type: "string"} - sd := NewSetDefault(scheme, rec) - require.NoError(t, sd.Parse(nil)) - assert.Nil(t, rec.defaultVal) - }) -} - -func TestSetExample(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - scheme := &oaispec.SimpleSchema{Type: "string"} - se := NewSetExample(scheme, rec) - assert.TrueT(t, se.Matches("example: foobar")) - require.NoError(t, se.Parse([]string{"example: foobar"})) - assert.EqualT(t, "foobar", rec.exampleVal) -} - -func TestSetDiscriminator(t *testing.T) { - t.Parallel() - - t.Run("set true", func(t *testing.T) { - schema := new(oaispec.Schema) - sd := NewSetDiscriminator(schema, "kind") - assert.TrueT(t, sd.Matches("discriminator: true")) - require.NoError(t, sd.Parse([]string{"discriminator: true"})) - assert.EqualT(t, "kind", schema.Discriminator) - }) - - t.Run("set false clears", func(t *testing.T) { - schema := &oaispec.Schema{} - schema.Discriminator = "kind" - sd := NewSetDiscriminator(schema, "kind") - require.NoError(t, sd.Parse([]string{"discriminator: false"})) - assert.EqualT(t, "", schema.Discriminator) - }) - - t.Run("set false different field", func(t *testing.T) { - schema := &oaispec.Schema{} - schema.Discriminator = "type" - sd := NewSetDiscriminator(schema, "kind") - require.NoError(t, sd.Parse([]string{"discriminator: false"})) - assert.EqualT(t, "type", schema.Discriminator) // unchanged - }) -} - -func TestWithItemsPrefixLevel(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - sm := NewSetMaximum(rec, WithItemsPrefixLevel(0)) - line := "items.maximum: 100" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - require.NotNil(t, rec.maximum) - assert.EqualT(t, float64(100), *rec.maximum) - - // Level 1 requires "items.items." - rec2 := &validationRecorder{} - sm2 := NewSetMinimum(rec2, WithItemsPrefixLevel(1)) - line2 := "items.items.minimum: 5" - assert.TrueT(t, sm2.Matches(line2)) - require.NoError(t, sm2.Parse([]string{line2})) - require.NotNil(t, rec2.minimum) - assert.EqualT(t, float64(5), *rec2.minimum) -} - -func TestSetEnum(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - se := NewSetEnum(rec) - line := "enum: " + `["a","b","c"]` - assert.TrueT(t, se.Matches(line)) - require.NoError(t, se.Parse([]string{line})) - assert.EqualT(t, `["a","b","c"]`, rec.enum) - - t.Run("empty", func(t *testing.T) { - rec := &validationRecorder{} - se := NewSetEnum(rec) - require.NoError(t, se.Parse(nil)) - require.NoError(t, se.Parse([]string{""})) - assert.EqualT(t, "", rec.enum) - }) -} - -// TestPrefixRxOption_AllConstructors covers the WithItemsPrefixLevel loop body -// in every validation constructor that accepts PrefixRxOption. -func TestPrefixRxOption_AllConstructors(t *testing.T) { - t.Parallel() - - prefix := WithItemsPrefixLevel(0) - - t.Run("SetMultipleOf", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMultipleOf(rec, prefix) - line := "items.multiple of: 3" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - require.NotNil(t, rec.multipleOf) - assert.EqualT(t, float64(3), *rec.multipleOf) - }) - - t.Run("SetMaxItems", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMaxItems(rec, prefix) - line := "items.max items: 10" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - require.NotNil(t, rec.maxItems) - assert.EqualT(t, int64(10), *rec.maxItems) - }) - - t.Run("SetMinItems", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMinItems(rec, prefix) - line := "items.min items: 1" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - require.NotNil(t, rec.minItems) - assert.EqualT(t, int64(1), *rec.minItems) - }) - - t.Run("SetMaxLength", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMaxLength(rec, prefix) - line := "items.max length: 100" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - require.NotNil(t, rec.maxLength) - assert.EqualT(t, int64(100), *rec.maxLength) - }) - - t.Run("SetMinLength", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMinLength(rec, prefix) - line := "items.min length: 1" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - require.NotNil(t, rec.minLength) - assert.EqualT(t, int64(1), *rec.minLength) - }) - - t.Run("SetPattern", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetPattern(rec, prefix) - line := "items.pattern: ^[a-z]+$" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - assert.EqualT(t, "^[a-z]+$", rec.pattern) - }) - - t.Run("SetCollectionFormat", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetCollectionFormat(rec, prefix) - line := "items.collection format: pipes" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - assert.EqualT(t, "pipes", rec.collectionFormat) - }) - - t.Run("SetUnique", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetUnique(rec, prefix) - line := "items.unique: true" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - require.NotNil(t, rec.unique) - assert.TrueT(t, *rec.unique) - }) - - t.Run("SetDefault", func(t *testing.T) { - rec := &validationRecorder{} - scheme := &oaispec.SimpleSchema{Type: "string"} - sm := NewSetDefault(scheme, rec, prefix) - line := "items.default: hello" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - assert.EqualT(t, "hello", rec.defaultVal) - }) - - t.Run("SetExample", func(t *testing.T) { - rec := &validationRecorder{} - scheme := &oaispec.SimpleSchema{Type: "string"} - sm := NewSetExample(scheme, rec, prefix) - line := "items.example: world" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - assert.EqualT(t, "world", rec.exampleVal) - }) - - t.Run("SetEnum", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetEnum(rec, prefix) - line := `items.enum: ["x","y"]` - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - assert.EqualT(t, `["x","y"]`, rec.enum) - }) -} - -func TestSetDefault_ParseError(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - scheme := &oaispec.SimpleSchema{Type: "integer"} - sd := NewSetDefault(scheme, rec) - err := sd.Parse([]string{"default: not-a-number"}) - require.Error(t, err) - assert.Nil(t, rec.defaultVal) -} - -func TestSetExample_ParseError(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - scheme := &oaispec.SimpleSchema{Type: "integer"} - se := NewSetExample(scheme, rec) - err := se.Parse([]string{"example: not-a-number"}) - require.Error(t, err) - assert.Nil(t, rec.exampleVal) -} - -func TestSetRequiredSchema_Matches(t *testing.T) { - t.Parallel() - - su := NewSetRequiredSchema(new(oaispec.Schema), "name") - assert.TrueT(t, su.Matches("required: true")) - assert.TrueT(t, su.Matches("Required: false")) - assert.FalseT(t, su.Matches("something else")) -} - -// strictMockValidationBuilder returns a MockValidationBuilder whose Set* methods -// fail the test if called. Use this in tests that assert no mutation happened -// (empty-input tolerance, overflow errors, etc.). -func strictMockValidationBuilder(t *testing.T) *mocks.MockValidationBuilder { - t.Helper() - fail := func(name string) func(...any) { - return func(args ...any) { t.Fatalf("%s should not be called (args: %v)", name, args) } - } - m := &mocks.MockValidationBuilder{} - m.SetMaximumFunc = func(v float64, exclusive bool) { fail("SetMaximum")(v, exclusive) } - m.SetMinimumFunc = func(v float64, exclusive bool) { fail("SetMinimum")(v, exclusive) } - m.SetMultipleOfFunc = func(v float64) { fail("SetMultipleOf")(v) } - m.SetMaxItemsFunc = func(v int64) { fail("SetMaxItems")(v) } - m.SetMinItemsFunc = func(v int64) { fail("SetMinItems")(v) } - m.SetMaxLengthFunc = func(v int64) { fail("SetMaxLength")(v) } - m.SetMinLengthFunc = func(v int64) { fail("SetMinLength")(v) } - m.SetPatternFunc = func(v string) { fail("SetPattern")(v) } - m.SetUniqueFunc = func(v bool) { fail("SetUnique")(v) } - m.SetEnumFunc = func(v string) { fail("SetEnum")(v) } - m.SetDefaultFunc = func(v any) { fail("SetDefault")(v) } - m.SetExampleFunc = func(v any) { fail("SetExample")(v) } - return m -} - -// TestValidationParsers_EmptyInputTolerance pins the defensive-guard -// contract documented in the D.5 post-mortem: every Parse(lines) tolerates -// nil / empty-slice / single-empty-string input without panic and without -// mutating its target. Uses MockValidationBuilder (Set* funcs fail on call) -// to prove no side effect. -func TestValidationParsers_EmptyInputTolerance(t *testing.T) { - t.Parallel() - - emptyInputs := [][]string{nil, {}, {""}} - - cases := []struct { - name string - factory func(*testing.T) ifaces.ValueParser - }{ - {"SetMaximum", func(t *testing.T) ifaces.ValueParser { return NewSetMaximum(strictMockValidationBuilder(t)) }}, - {"SetMinimum", func(t *testing.T) ifaces.ValueParser { return NewSetMinimum(strictMockValidationBuilder(t)) }}, - {"SetMultipleOf", func(t *testing.T) ifaces.ValueParser { return NewSetMultipleOf(strictMockValidationBuilder(t)) }}, - {"SetMaxItems", func(t *testing.T) ifaces.ValueParser { return NewSetMaxItems(strictMockValidationBuilder(t)) }}, - {"SetMinItems", func(t *testing.T) ifaces.ValueParser { return NewSetMinItems(strictMockValidationBuilder(t)) }}, - {"SetMaxLength", func(t *testing.T) ifaces.ValueParser { return NewSetMaxLength(strictMockValidationBuilder(t)) }}, - {"SetMinLength", func(t *testing.T) ifaces.ValueParser { return NewSetMinLength(strictMockValidationBuilder(t)) }}, - {"SetPattern", func(t *testing.T) ifaces.ValueParser { return NewSetPattern(strictMockValidationBuilder(t)) }}, - {"SetUnique", func(t *testing.T) ifaces.ValueParser { return NewSetUnique(strictMockValidationBuilder(t)) }}, - {"SetExample", func(t *testing.T) ifaces.ValueParser { - scheme := &oaispec.SimpleSchema{Type: "string"} - return NewSetExample(scheme, strictMockValidationBuilder(t)) - }}, - {"SetCollectionFormat", func(t *testing.T) ifaces.ValueParser { - // OperationValidationBuilder — use the op-variant mock, fail-all. - m := &mocks.MockOperationValidationBuilder{ - SetCollectionFormatFunc: func(v string) { t.Fatalf("SetCollectionFormat should not be called (arg: %s)", v) }, - } - return NewSetCollectionFormat(m) - }}, - {"SetReadOnlySchema", func(_ *testing.T) ifaces.ValueParser { return NewSetReadOnlySchema(new(oaispec.Schema)) }}, - {"SetDiscriminator", func(_ *testing.T) ifaces.ValueParser { return NewSetDiscriminator(new(oaispec.Schema), "kind") }}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - p := tc.factory(t) - for _, in := range emptyInputs { - require.NoError(t, p.Parse(in)) - } - }) - } -} - -// TestValidationParsers_NumericOverflow pins the overflow defence we kept -// in D.5: the regex captures \p{N}+ (any-length digit string), which matches -// values beyond int64 / float64 range. strconv.ParseInt / ParseFloat returns -// ErrRange in those cases, and the parser must propagate the error without -// invoking the target builder. See .claude/plans/dead-code-cleanup.md D.5 -// post-mortem for the rationale. -func TestValidationParsers_NumericOverflow(t *testing.T) { - t.Parallel() - - // int64 max is 9223372036854775807 (19 digits); 20+ 9's overflows. - intOverflow := strings.Repeat("9", 25) - // float64 max is ~1.8e308 in magnitude; 400 9's in decimal notation - // overflows ParseFloat (returns +Inf, ErrRange). - floatOverflow := strings.Repeat("9", 400) - - cases := []struct { - name string - line string - newP func(*testing.T) ifaces.ValueParser - }{ - { - name: "SetMaximum float overflow", - line: "maximum: " + floatOverflow, - newP: func(t *testing.T) ifaces.ValueParser { return NewSetMaximum(strictMockValidationBuilder(t)) }, - }, - { - name: "SetMinimum float overflow", - line: "minimum: " + floatOverflow, - newP: func(t *testing.T) ifaces.ValueParser { return NewSetMinimum(strictMockValidationBuilder(t)) }, - }, - { - name: "SetMultipleOf float overflow", - line: "multiple of: " + floatOverflow, - newP: func(t *testing.T) ifaces.ValueParser { return NewSetMultipleOf(strictMockValidationBuilder(t)) }, - }, - { - name: "SetMaxItems int overflow", - line: "max items: " + intOverflow, - newP: func(t *testing.T) ifaces.ValueParser { return NewSetMaxItems(strictMockValidationBuilder(t)) }, - }, - { - name: "SetMinItems int overflow", - line: "min items: " + intOverflow, - newP: func(t *testing.T) ifaces.ValueParser { return NewSetMinItems(strictMockValidationBuilder(t)) }, - }, - { - name: "SetMaxLength int overflow", - line: "max length: " + intOverflow, - newP: func(t *testing.T) ifaces.ValueParser { return NewSetMaxLength(strictMockValidationBuilder(t)) }, - }, - { - name: "SetMinLength int overflow", - line: "min length: " + intOverflow, - newP: func(t *testing.T) ifaces.ValueParser { return NewSetMinLength(strictMockValidationBuilder(t)) }, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - p := tc.newP(t) - require.TrueT(t, p.Matches(tc.line), "regex must match overflow input; otherwise the guard we're testing is dead") - err := p.Parse([]string{tc.line}) - require.Error(t, err, "expected ParseInt/ParseFloat ErrRange") - }) - } -} diff --git a/internal/parsers/yaml/dedent.go b/internal/parsers/yaml/dedent.go new file mode 100644 index 0000000..93063e8 --- /dev/null +++ b/internal/parsers/yaml/dedent.go @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package yaml + +import "strings" + +// RemoveIndent normalises the common leading indentation of a YAML +// body lifted from a godoc comment block: the first line's indent +// length is treated as the canonical strip width and applied to every +// subsequent line. Any tabs in the stripped lines' leading-whitespace +// run are then expanded to two spaces, because YAML refuses tab +// indentation. +// +// The first-line dedent (vs "shortest leading-whitespace run across +// every non-blank line") is preserved verbatim from the legacy +// operations bridge — the existing operation goldens depend on it. +// +// Whitespace tokens recognised here are space (' '), tab ('\t'), and +// the leading `/` characters that survive when the lexer hasn't +// stripped a godoc comment marker yet. Unicode space separators +// (\p{Zs}) are NOT recognised: real Go source code uses ASCII +// whitespace, and the legacy regex's \p{Zs} support was carried over +// from a more permissive era. If a corpus surfaces that depends on +// it, reintroduce the Unicode branch here. +func RemoveIndent(lines []string) []string { + if len(lines) == 0 { + return lines + } + + indent := leadingIndent(lines[0]) + if indent == 0 { + return lines + } + + out := make([]string, len(lines)) + for i, line := range lines { + if len(line) < indent { + out[i] = line + continue + } + out[i] = retabLeading(line[indent:]) + } + return out +} + +// leadingIndent returns the byte position of the first non-indent +// character on line — i.e., the number of bytes the line should be +// stripped by to remove its leading indent. +// +// An "indent" here is the maximal prefix matching the pattern +// (ws* / ws*)+ followed by ws*, where ws is space or tab. The +// optional `/` run absorbs godoc comment markers (`//`, `///`) when +// they slipped through preprocessing. +// +// Lines that are pure indent (empty or all-whitespace) return 0 — +// there's no meaningful strip count for them. +func leadingIndent(line string) int { + i := 0 + for i < len(line) && isIndentSpace(line[i]) { + i++ + } + for i < len(line) && line[i] == '/' { + i++ + } + for i < len(line) && isIndentSpace(line[i]) { + i++ + } + if i >= len(line) { + return 0 + } + return i +} + +// retabLeading replaces every tab in the leading-whitespace run of +// line with two spaces. Tabs past the first non-whitespace character +// are left untouched. +func retabLeading(line string) string { + n := 0 + for n < len(line) && isIndentSpace(line[n]) { + n++ + } + if n == 0 { + return line + } + return strings.ReplaceAll(line[:n], "\t", " ") + line[n:] +} + +func isIndentSpace(b byte) bool { return b == ' ' || b == '\t' } diff --git a/internal/parsers/yaml/list.go b/internal/parsers/yaml/list.go new file mode 100644 index 0000000..bc680d2 --- /dev/null +++ b/internal/parsers/yaml/list.go @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package yaml + +import ( + "fmt" + "strings" +) + +// ListBody parses a meta / route block body as a strict YAML list +// and returns its stringified items. Used by the `consumes:` and +// `produces:` keyword bodies on both the meta and route paths. +// +// Per-line cleanup strips comment-marker noise (space, tab, `/`, +// `*`, optional trailing `|`) — the `-` marker survives so YAML +// list items keep their dash. Blank lines after strip are dropped. +// A non-list body (scalar, map, parse error) silently returns nil; +// callers can choose to log or warn upstream. Empty bodies return +// nil. +func ListBody(body []string) []string { + cleaned := make([]string, 0, len(body)) + for _, line := range body { + stripped := stripListLeader(line) + if strings.TrimSpace(stripped) == "" { + continue + } + cleaned = append(cleaned, stripped) + } + if len(cleaned) == 0 { + return nil + } + parsed, err := Parse(strings.Join(cleaned, "\n")) + if err != nil { + return nil + } + list, ok := parsed.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(list)) + for _, item := range list { + out = append(out, fmt.Sprintf("%v", item)) + } + return out +} + +// stripListLeader removes leading comment-marker noise from line: +// runs of space, tab, `/` or `*`, followed by an optional single `|`. +// Mirrors the legacy `^[\p{Zs}\t/\*]*\|?` regex — restricted to ASCII +// whitespace, which is what real godoc-style sources carry. +func stripListLeader(line string) string { + i := 0 + for i < len(line) { + c := line[i] + if c == ' ' || c == '\t' || c == '/' || c == '*' { + i++ + continue + } + break + } + if i < len(line) && line[i] == '|' { + i++ + } + return line[i:] +} diff --git a/internal/parsers/yaml/operation.go b/internal/parsers/yaml/operation.go new file mode 100644 index 0000000..1bb6e83 --- /dev/null +++ b/internal/parsers/yaml/operation.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package yaml + +import ( + "fmt" + "strings" + + "github.com/go-openapi/swag/yamlutils" + "go.yaml.in/yaml/v3" +) + +// UnmarshalBody runs a raw godoc-comment YAML body through the +// standard godoc → YAML → JSON pipeline expected by every Swagger +// target that consumes JSON-shape input: +// +// 1. RemoveIndent — strip the common indent godoc adds to every +// line and turn leading tabs into two-space sequences (YAML +// refuses tab indentation). +// 2. yaml.Unmarshal into a generic map[any]any. +// 3. yamlutils.YAMLToJSON — coerce the map[any]any soup into +// JSON-shaped values with concrete leaf types. +// 4. Hand the resulting JSON bytes to the caller's callback, +// typically a *spec..UnmarshalJSON or a json.Unmarshal +// into a caller-provided struct. +// +// Empty body returns nil — the caller's target is left untouched. +// +// Used by the operations bridge (swagger:operation YAML body), +// the meta bridge (securityDefinitions, infoExtensions, +// extensions raw blocks), and any future target that needs the +// same shape. +func UnmarshalBody(body string, unmarshal func([]byte) error) error { + if body == "" { + return nil + } + + lines := strings.Split(body, "\n") + lines = RemoveIndent(lines) + normalised := strings.Join(lines, "\n") + + yamlValue := make(map[any]any) + if err := yaml.Unmarshal([]byte(normalised), &yamlValue); err != nil { + return fmt.Errorf("yaml body: %w", err) + } + + jsonValue, err := yamlutils.YAMLToJSON(yamlValue) + if err != nil { + return fmt.Errorf("yaml→json: %w", err) + } + + return unmarshal(jsonValue) +} diff --git a/internal/parsers/yaml/operation_test.go b/internal/parsers/yaml/operation_test.go new file mode 100644 index 0000000..8d0174d --- /dev/null +++ b/internal/parsers/yaml/operation_test.go @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package yaml_test + +import ( + "testing" + + "github.com/go-openapi/codescan/internal/parsers/yaml" + oaispec "github.com/go-openapi/spec" +) + +// TestUnmarshalBody_RoundTrip checks the YAML → JSON → +// UnmarshalJSON pipeline used by the swagger:operation grammar bridge. +// The raw body here matches what grammar's TokenOpaqueYaml emits for +// a `---` fenced block (contents only, no fences, no `//` markers). +func TestUnmarshalBody_RoundTrip(t *testing.T) { + body := `parameters: + - name: limit + in: query + type: integer + format: int32 +responses: + "200": + description: OK +` + op := new(oaispec.Operation) + if err := yaml.UnmarshalBody(body, op.UnmarshalJSON); err != nil { + t.Fatalf("UnmarshalBody: %v", err) + } + + if len(op.Parameters) != 1 { + t.Fatalf("parameters: got %d, want 1", len(op.Parameters)) + } + p := op.Parameters[0] + if p.Name != "limit" || p.In != "query" || p.Type != "integer" || p.Format != "int32" { + t.Errorf("parameter fields: %+v", p) + } + if op.Responses == nil || op.Responses.StatusCodeResponses[200].Description != "OK" { + t.Errorf("responses: %+v", op.Responses) + } +} + +func TestUnmarshalBody_InvalidYAML(t *testing.T) { + // Unbalanced brackets — yaml.Unmarshal will error. + body := "parameters: [\n - name: x" + op := new(oaispec.Operation) + if err := yaml.UnmarshalBody(body, op.UnmarshalJSON); err == nil { + t.Error("expected error on malformed YAML, got nil") + } +} + +func TestUnmarshalBody_EmptyBody(t *testing.T) { + // Empty body short-circuits before unmarshal — caller's target + // stays untouched. + op := new(oaispec.Operation) + if err := yaml.UnmarshalBody("", op.UnmarshalJSON); err != nil { + t.Errorf("empty body should not error: %v", err) + } +} + +// TestUnmarshalBody_TabIndent verifies the dedent step +// handles tab-indented godoc-style bodies (the go119 fixture style). +func TestUnmarshalBody_TabIndent(t *testing.T) { + body := "\tparameters:\n\t - name: limit\n\t in: query\n\t type: integer\n" + op := new(oaispec.Operation) + if err := yaml.UnmarshalBody(body, op.UnmarshalJSON); err != nil { + t.Fatalf("UnmarshalBody: %v", err) + } + if len(op.Parameters) != 1 || op.Parameters[0].Name != "limit" { + t.Fatalf("parameters: %+v", op.Parameters) + } +} diff --git a/internal/parsers/yaml/yaml.go b/internal/parsers/yaml/yaml.go new file mode 100644 index 0000000..a2412f9 --- /dev/null +++ b/internal/parsers/yaml/yaml.go @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package yaml is a thin wrapper around go.yaml.in/yaml/v3 for +// consuming the RawYAML bodies that internal/parsers/grammar/ +// isolates between `---` fences, plus the typed-extensions service +// the lexer calls for `extensions:` raw blocks. +// +// Importers: +// +// - The analyzer / builder layer — bridge-taggers that decide when +// to parse a given RawYAML body. +// - `internal/parsers/grammar` — calls `TypedExtensions` from its +// extensions raw-block lexer so `Extension.Value` ships typed. +// This is the one carve-out from the "grammar stays YAML-free" +// architecture rule; see `.claude/plans/typed-extensions.md`. +// +// `internal/parsers/grammar/` (v1) does NOT import this package — it +// retains the per-line flat-string Extensions handling. +// +// This subpackage also establishes the sibling-sub-parser pattern: +// any future sub-language (enum-variant forms per W2, richer +// example syntax per W3, private-comment bodies per W4, …) gets its +// own `internal/parsers//` subpackage following the same seam. +package yaml + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/go-openapi/swag/yamlutils" + "go.yaml.in/yaml/v3" +) + +// Parse unmarshals the given raw YAML body into a generic value +// (typically a map[string]interface{} or []interface{}). The pos +// parameter is passed through any wrapping error so downstream +// diagnostics can point at the original source location — YAML +// library errors carry their own line/column numbers relative to the +// body, not to the Go source. +// +// Returns (nil, nil) for an empty body so callers can handle +// "annotation had a fence but no content" without branching on +// error-vs-nil. +// +//nolint:nilnil // (nil, nil) is the documented "empty body" return — the caller distinguishes via len(body) if needed. +func Parse(body string) (any, error) { + if body == "" { + return nil, nil + } + var v any + if err := yaml.Unmarshal([]byte(body), &v); err != nil { + return nil, fmt.Errorf("yaml: %w", err) + } + return v, nil +} + +// ParseInto unmarshals body into the given destination, typically a +// pointer to a struct the caller defined to match an expected YAML +// shape (e.g., operation-body or extension-value). Wraps the +// underlying error for uniform error reporting. +func ParseInto(body string, dst any) error { + if body == "" { + return nil + } + if err := yaml.Unmarshal([]byte(body), dst); err != nil { + return fmt.Errorf("yaml: %w", err) + } + return nil +} + +// TypedExtensions parses the body of an `extensions:` raw block and +// returns its top-level entries as JSON-typed values +// (`bool` / `float64` / `string` / `[]any` / `map[string]any`). +// +// The body is dedented before parsing — the lexer preserves the +// godoc-level indentation prefix on each line, but YAML refuses +// tab indentation and treats leading whitespace as structural. +// Stripping the common leading-whitespace prefix (and substituting +// any residual tabs for two spaces) normalises both godoc-style +// inputs. +// +// The YAML→JSON normalisation is necessary because yaml.v3 yields +// `map[any]any` for nested mappings, which downstream consumers +// (vendor-extension targets, code generators) expect as +// `map[string]any` with concrete leaf types — JSON unmarshalling is +// the cheapest way to enforce that shape. +// +// No name filtering is applied here: the caller decides whether +// to accept only `x-*` keys (via `classify.IsAllowedExtension`) +// or to consume the full mapping. Empty body returns `(nil, nil)`. +// +// Round-1 of the typed-extensions plan +// (`.claude/plans/typed-extensions.md`): the schema builder calls this +// directly. Round-2 moves the call into grammar's lexer so +// `Extension.Value` ships pre-typed. +func TypedExtensions(body string) (map[string]any, error) { + if body == "" { + return nil, nil //nolint:nilnil // documented "empty body" return + } + normalised := normaliseExtensionBody(body) + var yamlValue any + if err := yaml.Unmarshal([]byte(normalised), &yamlValue); err != nil { + return nil, fmt.Errorf("yaml: %w", err) + } + jsonValue, err := yamlutils.YAMLToJSON(yamlValue) + if err != nil { + return nil, fmt.Errorf("yaml→json: %w", err) + } + var data map[string]any + if err := json.Unmarshal(jsonValue, &data); err != nil { + return nil, fmt.Errorf("json: %w", err) + } + return data, nil +} + +// normaliseExtensionBody dedents an extensions-block body: strips the +// common leading-whitespace prefix shared by every non-blank line and +// substitutes any residual leading tabs with two spaces (YAML refuses +// tab indentation). +// +// The lexer preserves each line's original whitespace (round-2 wants +// indentation to survive for nested YAML), so the dedent has to live +// downstream of it. Tab-and-space mixes in godoc-style sources work +// after this pass — both petstore's `\t`-indented Extensions block and +// the typed-nested test case using ` ` indentation parse identically. +func normaliseExtensionBody(body string) string { + lines := strings.Split(body, "\n") + + prefix := commonLeadingWhitespace(lines) + if prefix > 0 { + for i, line := range lines { + if len(line) >= prefix { + lines[i] = line[prefix:] + } + } + } + + for i, line := range lines { + lines[i] = replaceLeadingTabs(line) + } + return strings.Join(lines, "\n") +} + +// commonLeadingWhitespace returns the length of the longest leading +// whitespace run shared by every non-blank line. Returns 0 when any +// non-blank line starts at column 0 (nothing to dedent). +func commonLeadingWhitespace(lines []string) int { + prefix := -1 + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + n := 0 + for n < len(line) && (line[n] == ' ' || line[n] == '\t') { + n++ + } + if prefix < 0 || n < prefix { + prefix = n + } + if prefix == 0 { + return 0 + } + } + if prefix < 0 { + return 0 + } + return prefix +} + +// replaceLeadingTabs converts any tab characters in the leading +// whitespace run of a line into two spaces, leaving the rest of the +// line untouched. +func replaceLeadingTabs(line string) string { + n := 0 + for n < len(line) && (line[n] == ' ' || line[n] == '\t') { + n++ + } + if n == 0 { + return line + } + return strings.ReplaceAll(line[:n], "\t", " ") + line[n:] +} diff --git a/internal/parsers/yaml/yaml_test.go b/internal/parsers/yaml/yaml_test.go new file mode 100644 index 0000000..c76c7af --- /dev/null +++ b/internal/parsers/yaml/yaml_test.go @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package yaml_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/go-openapi/codescan/internal/parsers/yaml" +) + +func TestParseEmpty(t *testing.T) { + v, err := yaml.Parse("") + if err != nil { + t.Fatalf("empty body: unexpected error: %v", err) + } + if v != nil { + t.Errorf("empty body: want nil, got %v", v) + } +} + +func TestParseFlatMap(t *testing.T) { + // Note: go.yaml.in/yaml/v3 returns map[string]any for + // string-keyed maps and auto-types scalars (unquoted "1.0" + // becomes float64). Quote the value to keep it as a string. + body := "name: Foo\nversion: \"1.0\"\n" + v, err := yaml.Parse(body) + if err != nil { + t.Fatalf("parse: %v", err) + } + m, ok := v.(map[string]any) + if !ok { + t.Fatalf("want map[string]any, got %T: %v", v, v) + } + if m["name"] != "Foo" { + t.Errorf("name: got %v want Foo", m["name"]) + } + if m["version"] != "1.0" { + t.Errorf("version: got %v", m["version"]) + } +} + +func TestParseNestedStructure(t *testing.T) { + // Representative of an operation body's responses mapping. + // Numeric keys like `200` arrive as int keys; the outer map + // becomes map[any]any because not all keys are strings. + body := "responses:\n 200:\n description: ok\n 404:\n description: not found\n" + v, err := yaml.Parse(body) + if err != nil { + t.Fatalf("parse: %v", err) + } + top, ok := v.(map[string]any) + if !ok { + t.Fatalf("want top-level map[string]any, got %T", v) + } + // The responses map has integer keys (200, 404), so the + // YAML library returns map[any]any (keys include non-strings). + resp, ok := top["responses"].(map[any]any) + if !ok { + t.Fatalf("responses: want map[any]any (int keys), got %T", top["responses"]) + } + if len(resp) != 2 { + t.Errorf("responses: want 2 entries, got %d", len(resp)) + } +} + +func TestParseInvalidYAML(t *testing.T) { + // Bad indentation / stray colon. + body := "key: [unclosed\n" + _, err := yaml.Parse(body) + if err == nil { + t.Fatal("expected error for invalid YAML") + } + if !strings.HasPrefix(err.Error(), "yaml:") { + t.Errorf("error should be wrapped with 'yaml:' prefix: got %q", err.Error()) + } +} + +func TestParseIntoStruct(t *testing.T) { + type operation struct { + Method string `yaml:"method"` + Path string `yaml:"path"` + } + body := "method: GET\npath: /pets\n" + var op operation + if err := yaml.ParseInto(body, &op); err != nil { + t.Fatalf("parse: %v", err) + } + if op.Method != http.MethodGet || op.Path != "/pets" { + t.Errorf("unmarshalled struct: %+v", op) + } +} + +func TestParseIntoEmpty(t *testing.T) { + // Empty body is a no-op (dst left at zero value). + type op struct{ Method string } + var v op + if err := yaml.ParseInto("", &v); err != nil { + t.Errorf("empty body: unexpected error: %v", err) + } + if v.Method != "" { + t.Errorf("dst should be untouched, got %+v", v) + } +} + +func TestTypedExtensionsEmpty(t *testing.T) { + m, err := yaml.TypedExtensions("") + if err != nil { + t.Fatalf("empty body: unexpected error: %v", err) + } + if m != nil { + t.Errorf("empty body: want nil map, got %v", m) + } +} + +func TestTypedExtensionsFlatScalars(t *testing.T) { + body := "x-tag: foo\nx-priority: 5\nx-enabled: true\n" + m, err := yaml.TypedExtensions(body) + if err != nil { + t.Fatalf("parse: %v", err) + } + if m["x-tag"] != "foo" { + t.Errorf("x-tag: got %v want foo", m["x-tag"]) + } + // JSON normalisation yields float64 for numeric scalars. + if got, ok := m["x-priority"].(float64); !ok || got != 5 { + t.Errorf("x-priority: got %v (%T) want float64(5)", m["x-priority"], m["x-priority"]) + } + if m["x-enabled"] != true { + t.Errorf("x-enabled: got %v want true", m["x-enabled"]) + } +} + +func TestTypedExtensionsNestedYAML(t *testing.T) { + // The case the schema builder's prior applyExtensionsRawBlock + // existed for: nested map / list values must arrive as typed + // map[string]any and []any, not yaml.v3's map[any]any. + body := "x-config:\n enabled: true\n threshold: 0.5\n tags: [a, b, c]\n" + m, err := yaml.TypedExtensions(body) + if err != nil { + t.Fatalf("parse: %v", err) + } + cfg, ok := m["x-config"].(map[string]any) + if !ok { + t.Fatalf("x-config: want map[string]any, got %T", m["x-config"]) + } + if cfg["enabled"] != true { + t.Errorf("x-config.enabled: got %v want true", cfg["enabled"]) + } + if cfg["threshold"] != 0.5 { + t.Errorf("x-config.threshold: got %v want 0.5", cfg["threshold"]) + } + tags, ok := cfg["tags"].([]any) + if !ok || len(tags) != 3 { + t.Fatalf("x-config.tags: want []any{a,b,c}, got %v (%T)", cfg["tags"], cfg["tags"]) + } +} + +func TestTypedExtensionsNoFilter(t *testing.T) { + // The service does NOT drop non-x-* keys — the caller decides. + body := "x-good: 1\nnot-an-extension: 2\n" + m, err := yaml.TypedExtensions(body) + if err != nil { + t.Fatalf("parse: %v", err) + } + if _, ok := m["not-an-extension"]; !ok { + t.Errorf("non-x-* key should be present (caller filters); got map %v", m) + } +} + +func TestTypedExtensionsInvalidYAML(t *testing.T) { + body := "x-broken: [unclosed\n" + _, err := yaml.TypedExtensions(body) + if err == nil { + t.Fatal("expected error for invalid YAML") + } +} diff --git a/internal/parsers/yaml_parser.go b/internal/parsers/yaml_parser.go deleted file mode 100644 index 8643921..0000000 --- a/internal/parsers/yaml_parser.go +++ /dev/null @@ -1,106 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "encoding/json" - "regexp" - "strings" - - "github.com/go-openapi/loads/fmts" - "go.yaml.in/yaml/v3" -) - -type YAMLParserOption func(*YAMLParser) - -func WithSetter(set func(json.RawMessage) error) YAMLParserOption { - return func(p *YAMLParser) { - p.set = set - } -} - -func WithMatcher(rx *regexp.Regexp) YAMLParserOption { - return func(p *YAMLParser) { - p.rx = rx - } -} - -func WithExtensionMatcher() YAMLParserOption { - return func(p *YAMLParser) { - p.rx = rxExtensions - } -} - -type YAMLParser struct { - set func(json.RawMessage) error - rx *regexp.Regexp -} - -func NewYAMLParser(opts ...YAMLParserOption) *YAMLParser { - var y YAMLParser - for _, apply := range opts { - apply(&y) - } - - return &y -} - -func (y *YAMLParser) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - - uncommented := make([]string, 0, len(lines)) - uncommented = append(uncommented, removeYamlIndent(lines)...) - - yamlContent := strings.Join(uncommented, "\n") - var yamlValue any - err := yaml.Unmarshal([]byte(yamlContent), &yamlValue) - if err != nil { - return err - } - - var jsonValue json.RawMessage - jsonValue, err = fmts.YAMLToJSON(yamlValue) - if err != nil { - return err - } - - if y.set == nil { - return nil - } - - return y.set(jsonValue) -} - -func (y *YAMLParser) Matches(line string) bool { - if y.rx == nil { - return false - } - - return y.rx.MatchString(line) -} - -// removes indent base on the first line. -// -// The difference with removeIndent is that lines shorter than the indentation are elided. -func removeYamlIndent(spec []string) []string { - if len(spec) == 0 { - return spec - } - - loc := rxIndent.FindStringIndex(spec[0]) - if len(loc) < 2 || loc[1] <= 1 { - return spec - } - - s := make([]string, 0, len(spec)) - for i := range spec { - if len(spec[i]) >= loc[1] { - s = append(s, spec[i][loc[1]-1:]) - } - } - - return s -} diff --git a/internal/parsers/yaml_parser_test.go b/internal/parsers/yaml_parser_test.go deleted file mode 100644 index d4cb200..0000000 --- a/internal/parsers/yaml_parser_test.go +++ /dev/null @@ -1,141 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "encoding/json" - "errors" - "testing" - - "github.com/go-openapi/testify/v2/require" -) - -var errSetterFailed = errors.New("setter failed") - -func TestYamlParser(t *testing.T) { - t.Parallel() - - setter := func(out *string, called *int) func(json.RawMessage) error { - return func(in json.RawMessage) error { - *called++ - *out = string(in) - - return nil - } - } - - t.Run("with happy path", func(t *testing.T) { - t.Run("should parse security definitions object as YAML", func(t *testing.T) { - setterCalled := 0 - var actualJSON string - parser := NewYAMLParser(WithMatcher(rxSecurity), WithSetter(setter(&actualJSON, &setterCalled))) - - lines := []string{ - "SecurityDefinitions:", - " api_key:", - " type: apiKey", - " name: X-API-KEY", - " petstore_auth:", - " type: oauth2", - " scopes:", - " 'write:pets': modify pets in your account", - " 'read:pets': read your pets", - } - - require.TrueT(t, parser.Matches(lines[0])) - require.NoError(t, parser.Parse(lines)) - require.EqualT(t, 1, setterCalled) - - const expectedJSON = `{"SecurityDefinitions":{"api_key":{"name":"X-API-KEY","type":"apiKey"},` + - `"petstore_auth":{"scopes":{"read:pets":"read your pets","write:pets":"modify pets in your account"},"type":"oauth2"}}}` - - require.JSONEqT(t, expectedJSON, actualJSON) - }) - }) - - t.Run("with edge cases", func(t *testing.T) { - t.Run("should handle empty input", func(t *testing.T) { - setterCalled := 0 - var actualJSON string - parser := NewYAMLParser(WithMatcher(rxSecurity), WithSetter(setter(&actualJSON, &setterCalled))) - - require.FalseT(t, parser.Matches("")) - require.NoError(t, parser.Parse([]string{})) - require.Zero(t, setterCalled) - }) - - t.Run("should handle nil input", func(t *testing.T) { - setterCalled := 0 - var actualJSON string - parser := NewYAMLParser(WithMatcher(rxSecurity), WithSetter(setter(&actualJSON, &setterCalled))) - - require.NoError(t, parser.Parse(nil)) - require.Zero(t, setterCalled) - }) - - t.Run("should handle bad indentation", func(t *testing.T) { - setterCalled := 0 - var actualJSON string - parser := NewYAMLParser(WithMatcher(rxSecurity), WithSetter(setter(&actualJSON, &setterCalled))) - lines := []string{ - "SecurityDefinitions:", - "\t\tapi_key:", - " type: apiKey", - } - - require.TrueT(t, parser.Matches(lines[0])) - err := parser.Parse(lines) - require.Error(t, err) - require.StringContainsT(t, err.Error(), "yaml: line 2:") - require.Zero(t, setterCalled) - }) - - t.Run("should catch YAML errors", func(t *testing.T) { - setterCalled := 0 - var actualJSON string - parser := NewYAMLParser(WithMatcher(rxSecurity), WithSetter(setter(&actualJSON, &setterCalled))) - lines := []string{ - "SecurityDefinitions:", - " api_key", - " type: apiKey", - } - - require.TrueT(t, parser.Matches(lines[0])) - err := parser.Parse(lines) - require.Error(t, err) - require.StringContainsT(t, err.Error(), "yaml: line 3: mapping value") - require.Zero(t, setterCalled) - }) - - t.Run("should handle nil rx in Matches", func(t *testing.T) { - parser := NewYAMLParser(WithSetter(func(_ json.RawMessage) error { return nil })) - require.FalseT(t, parser.Matches("anything")) - }) - - t.Run("should handle nil setter", func(t *testing.T) { - parser := NewYAMLParser(WithMatcher(rxSecurity)) - lines := []string{ - "SecurityDefinitions:", - " api_key:", - " type: apiKey", - } - require.NoError(t, parser.Parse(lines)) - }) - - t.Run("should propagate setter error", func(t *testing.T) { - parser := NewYAMLParser( - WithMatcher(rxSecurity), - WithSetter(func(_ json.RawMessage) error { return errSetterFailed }), - ) - lines := []string{ - "SecurityDefinitions:", - " api_key:", - " type: apiKey", - } - err := parser.Parse(lines) - require.Error(t, err) - require.ErrorIs(t, err, errSetterFailed) - }) - }) -} diff --git a/internal/parsers/yaml_spec_parser.go b/internal/parsers/yaml_spec_parser.go deleted file mode 100644 index 1ebe8c7..0000000 --- a/internal/parsers/yaml_spec_parser.go +++ /dev/null @@ -1,202 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "encoding/json" - "fmt" - "go/ast" - "regexp" - "strings" - - "github.com/go-openapi/loads/fmts" - "go.yaml.in/yaml/v3" -) - -// YAMLSpecScanner aggregates lines in header until it sees `---`, -// the beginning of a YAML spec. -type YAMLSpecScanner struct { - header []string - yamlSpec []string - setTitle func([]string) - setDescription func([]string) - workedOutTitle bool - title []string - skipHeader bool -} - -func NewYAMLSpecScanner(setTitle func([]string), setDescription func([]string)) *YAMLSpecScanner { - return &YAMLSpecScanner{ - setTitle: setTitle, - setDescription: setDescription, - } -} - -func (sp *YAMLSpecScanner) Title() []string { - sp.collectTitleDescription() - return sp.title -} - -func (sp *YAMLSpecScanner) Description() []string { - sp.collectTitleDescription() - return sp.header -} - -func (sp *YAMLSpecScanner) Parse(doc *ast.CommentGroup) error { - if doc == nil { - return nil - } - var startedYAMLSpec bool -COMMENTS: - for _, c := range doc.List { - for line := range strings.SplitSeq(c.Text, "\n") { - if HasAnnotation(line) { - break COMMENTS // a new swagger: annotation terminates this parser - } - - if !startedYAMLSpec { - if rxBeginYAMLSpec.MatchString(line) { - startedYAMLSpec = true - sp.yamlSpec = append(sp.yamlSpec, line) - continue - } - - if !sp.skipHeader { - sp.header = append(sp.header, line) - } - - // no YAML spec yet, moving on - continue - } - - sp.yamlSpec = append(sp.yamlSpec, line) - } - } - if sp.setTitle != nil { - sp.setTitle(sp.Title()) - } - if sp.setDescription != nil { - sp.setDescription(sp.Description()) - } - return nil -} - -func (sp *YAMLSpecScanner) UnmarshalSpec(u func([]byte) error) (err error) { - specYaml := cleanupScannerLines(sp.yamlSpec, rxUncommentYAML) - if len(specYaml) == 0 { - return fmt.Errorf("no spec available to unmarshal: %w", ErrParser) - } - - if !strings.Contains(specYaml[0], "---") { - return fmt.Errorf("yaml spec has to start with `---`: %w", ErrParser) - } - - // remove indentation - specYaml = removeIndent(specYaml) - - // 1. parse yaml lines - yamlValue := make(map[any]any) - - yamlContent := strings.Join(specYaml, "\n") - err = yaml.Unmarshal([]byte(yamlContent), &yamlValue) - if err != nil { - return err - } - - // 2. convert to json - var jsonValue json.RawMessage - jsonValue, err = fmts.YAMLToJSON(yamlValue) - if err != nil { - return err - } - - // 3. unmarshal the json into an interface - var data []byte - data, err = jsonValue.MarshalJSON() - if err != nil { - return err - } - err = u(data) - if err != nil { - return err - } - - // all parsed, returning... - sp.yamlSpec = nil // spec is now consumed, so let's erase the parsed lines - - return nil -} - -func (sp *YAMLSpecScanner) collectTitleDescription() { - if sp.workedOutTitle { - return - } - if sp.setTitle == nil { - sp.header = cleanupScannerLines(sp.header, rxUncommentHeaders) - return - } - - sp.workedOutTitle = true - sp.title, sp.header = collectScannerTitleDescription(sp.header) -} - -// removes indent based on the first line. -func removeIndent(spec []string) []string { - if len(spec) == 0 { - return spec - } - - loc := rxIndent.FindStringIndex(spec[0]) - if len(loc) < 2 || loc[1] <= 1 { - return spec - } - - s := make([]string, len(spec)) - copy(s, spec) - - for i := range s { - if len(s[i]) < loc[1] { - continue - } - - s[i] = spec[i][loc[1]-1:] //nolint:gosec // G602: bounds already checked on line 445 - start := rxNotIndent.FindStringIndex(s[i]) - if len(start) < 2 || start[1] == 0 { - continue - } - - s[i] = strings.Replace(s[i], "\t", " ", start[1]) - } - - return s -} - -func cleanupScannerLines(lines []string, ur *regexp.Regexp) []string { - // bail early when there is nothing to parse - if len(lines) == 0 { - return lines - } - - seenLine := -1 - var lastContent int - - uncommented := make([]string, 0, len(lines)) - for i, v := range lines { - str := ur.ReplaceAllString(v, "") - uncommented = append(uncommented, str) - if str != "" { - if seenLine < 0 { - seenLine = i - } - lastContent = i - } - } - - // fixes issue #50 - if seenLine == -1 { - return nil - } - - return uncommented[seenLine : lastContent+1] -} diff --git a/internal/parsers/yaml_spec_parser_test.go b/internal/parsers/yaml_spec_parser_test.go deleted file mode 100644 index d870bd8..0000000 --- a/internal/parsers/yaml_spec_parser_test.go +++ /dev/null @@ -1,402 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "errors" - "go/ast" - "testing" - - "github.com/go-openapi/testify/v2/require" -) - -var errCallback = errors.New("callback error") - -func TestYamlSpecScanner(t *testing.T) { - t.Parallel() - - t.Run("with happy path", func(t *testing.T) { - t.Run("should parse operation definition object as YAML", func(t *testing.T) { - parser := new(YAMLSpecScanner) - var title, description []string - parser.setTitle = func(lines []string) { title = lines } - parser.setDescription = func(lines []string) { description = lines } - - lines := []string{ - // from issue #3225, reindented - // `swagger:operation POST /v1/example-endpoint addExampleConfig`, - `title for this operation`, - ``, // blank line elided - `description of this operation`, - ``, // blank line preserved - `continuation of the description`, - `---`, // YAML block - `summary: Adds a new configuration entry`, - `description: |-`, - ` Creates and validates a new configuration request.`, - ``, - `security:`, - `- AuthToken: []`, - `consumes:`, - `- application/json`, - `tags:`, - `- Example|Configuration`, - `responses:`, - ` 201:`, - ` $ref: "#/responses/createdResponse"`, - ` 400:`, - ` $ref: "#/responses/badRequestResponse"`, - ` 412:`, - ` $ref: "#/responses/preconditionFailedResponse"`, - ` 500:`, - ` $ref: "#/responses/internalServerErrorResponse"`, - } - - doc := buildRawTestComments(lines) - require.NoError(t, parser.Parse(doc)) - require.Equal(t, title, parser.Title()) - require.Equal(t, []string{"title for this operation"}, parser.Title()) - require.Equal(t, description, parser.Description()) - require.Equal(t, []string{"description of this operation", "", "continuation of the description"}, parser.Description()) - - var receivedJSON string - yamlReceiver := func(b []byte) error { - receivedJSON = string(b) - return nil - } - - require.NoError(t, parser.UnmarshalSpec(yamlReceiver)) - - const expectedJSON = `{ - "summary":"Adds a new configuration entry", - "description":"Creates and validates a new configuration request.", - "security":[ - {"AuthToken":[]} - ], - "consumes":["application/json"], - "tags":["Example|Configuration"], - "responses":{ - "201":{"$ref":"#/responses/createdResponse"}, - "400":{"$ref":"#/responses/badRequestResponse"}, - "412":{"$ref":"#/responses/preconditionFailedResponse"}, - "500":{"$ref":"#/responses/internalServerErrorResponse"} - } - }` - - require.JSONEqT(t, expectedJSON, receivedJSON) - }) - - t.Run("should stop yaml operation block when new tag is found", func(t *testing.T) { - parser := new(YAMLSpecScanner) - var title, description []string - parser.setTitle = func(lines []string) { title = lines } - parser.setDescription = func(lines []string) { description = lines } - - lines := []string{ - `title for this operation`, - ``, // blank line elided - `description of this operation`, - `---`, // YAML block - `summary: Adds a new configuration entry`, - ``, - `swagger:enum`, // yaml block ended at this tag. Rest is ignored - `security:`, - `- AuthToken: []`, - } - - doc := buildRawTestComments(lines) - require.NoError(t, parser.Parse(doc)) - require.Equal(t, title, parser.Title()) - require.Equal(t, []string{"title for this operation"}, parser.Title()) - require.Equal(t, description, parser.Description()) - require.Equal(t, []string{"description of this operation"}, parser.Description()) - - var receivedJSON string - yamlReceiver := func(b []byte) error { - receivedJSON = string(b) - return nil - } - - require.NoError(t, parser.UnmarshalSpec(yamlReceiver)) - - const expectedJSON = `{ - "summary":"Adds a new configuration entry" - }` - - require.JSONEqT(t, expectedJSON, receivedJSON) - }) - - t.Run("should stop yaml operation block when new yaml document separator is found", func(t *testing.T) { - parser := new(YAMLSpecScanner) - var title, description []string - parser.setTitle = func(lines []string) { title = lines } - parser.setDescription = func(lines []string) { description = lines } - - lines := []string{ - `title for this operation`, - ``, // blank line elided - `description of this operation`, - `---`, // YAML block - `summary: Adds a new configuration entry`, - ``, - `---`, // yaml block ended at mark. Rest is ignored - `security:`, - `- AuthToken: []`, - } - - doc := buildRawTestComments(lines) - require.NoError(t, parser.Parse(doc)) - require.Equal(t, title, parser.Title()) - require.Equal(t, []string{"title for this operation"}, parser.Title()) - require.Equal(t, description, parser.Description()) - require.Equal(t, []string{"description of this operation"}, parser.Description()) - - var receivedJSON string - yamlReceiver := func(b []byte) error { - receivedJSON = string(b) - return nil - } - - require.NoError(t, parser.UnmarshalSpec(yamlReceiver)) - - const expectedJSON = `{ - "summary":"Adds a new configuration entry" - }` - - require.JSONEqT(t, expectedJSON, receivedJSON) - }) - }) - - t.Run("with edge cases", func(t *testing.T) { - t.Run("with empty comment block", func(t *testing.T) { - parser := new(YAMLSpecScanner) - var title, description []string - parser.setTitle = func(lines []string) { title = lines } - parser.setDescription = func(lines []string) { description = lines } - doc := buildRawTestComments(nil) - require.NoError(t, parser.Parse(doc)) - require.Empty(t, title) - require.Empty(t, description) - }) - - t.Run("with nil comment block", func(t *testing.T) { - parser := new(YAMLSpecScanner) - var title, description []string - parser.setTitle = func(lines []string) { title = lines } - parser.setDescription = func(lines []string) { description = lines } - require.NoError(t, parser.Parse(nil)) - require.Empty(t, title) - require.Empty(t, description) - }) - - t.Run("without setTitle", func(t *testing.T) { - parser := new(YAMLSpecScanner) - var description []string - parser.setDescription = func(lines []string) { description = lines } - - lines := []string{ - `title for this operation`, - ``, // blank line preserved - `description of this operation`, - `---`, // YAML block - } - - doc := buildRawTestComments(lines) - require.NoError(t, parser.Parse(doc)) - require.Nil(t, parser.Title()) - require.Equal(t, description, parser.Description()) - require.Equal(t, []string{"title for this operation", "", "description of this operation"}, parser.Description()) - - var receivedJSON string - yamlReceiver := func(b []byte) error { - receivedJSON = string(b) - return nil - } - require.NoError(t, parser.UnmarshalSpec(yamlReceiver)) - require.JSONEqT(t, `{}`, receivedJSON) - }) - }) -} - -func TestYAMLSpecScanner_UnmarshalSpec_Errors(t *testing.T) { - t.Parallel() - - t.Run("no spec available", func(t *testing.T) { - parser := new(YAMLSpecScanner) - parser.setTitle = func(_ []string) {} - parser.setDescription = func(_ []string) {} - // Parse with no --- marker → no yamlSpec collected - doc := buildRawTestComments([]string{"just text, no yaml"}) - require.NoError(t, parser.Parse(doc)) - - err := parser.UnmarshalSpec(func(_ []byte) error { return nil }) - require.Error(t, err) - require.ErrorIs(t, err, ErrParser) - }) - - t.Run("spec doesnt start with ---", func(t *testing.T) { - parser := new(YAMLSpecScanner) - // Manually inject yamlSpec without the --- marker - parser.yamlSpec = []string{"summary: test"} - - err := parser.UnmarshalSpec(func(_ []byte) error { return nil }) - require.Error(t, err) - require.ErrorIs(t, err, ErrParser) - }) - - t.Run("invalid yaml", func(t *testing.T) { - parser := new(YAMLSpecScanner) - parser.yamlSpec = []string{"// ---", "// \tbad:", "// yaml"} - - err := parser.UnmarshalSpec(func(_ []byte) error { return nil }) - require.Error(t, err) - }) - - t.Run("unmarshal callback error", func(t *testing.T) { - parser := new(YAMLSpecScanner) - parser.setTitle = func(_ []string) {} - parser.setDescription = func(_ []string) {} - - lines := []string{ - "title", - "---", - "summary: test", - } - doc := buildRawTestComments(lines) - require.NoError(t, parser.Parse(doc)) - - err := parser.UnmarshalSpec(func(_ []byte) error { return errCallback }) - require.Error(t, err) - require.ErrorIs(t, err, errCallback) - }) -} - -func TestNewYAMLSpecScanner(t *testing.T) { - t.Parallel() - - var title, desc []string - scanner := NewYAMLSpecScanner( - func(lines []string) { title = lines }, - func(lines []string) { desc = lines }, - ) - - lines := []string{ - "My Title.", - "", - "My description.", - "---", - "summary: test", - } - doc := buildRawTestComments(lines) - require.NoError(t, scanner.Parse(doc)) - require.Equal(t, []string{"My Title."}, title) - require.Equal(t, []string{"My description."}, desc) -} - -func TestRemoveIndent(t *testing.T) { - t.Parallel() - - t.Run("with removeIndent", func(t *testing.T) { - t.Run("should tolerate empty input", func(t *testing.T) { - res := removeIndent([]string{}) - require.Empty(t, res) - require.NotNil(t, res) - }) - - t.Run("should tolerate nil input", func(t *testing.T) { - res := removeIndent(nil) - require.Empty(t, res) - require.Nil(t, res) - }) - - t.Run("should support headline without indentation", func(t *testing.T) { - lines := []string{ - "xyz", - " abc", - } - res := removeIndent(lines) - require.Equal(t, lines, res) - }) - - t.Run("should tolerate lines with only indents", func(t *testing.T) { - lines := []string{ - " xyz", - "", - " ", - " ", - } - res := removeIndent(lines) - - expected := []string{ - "xyz", - "", // empty line preserved - " ", // blank lines unindented - " ", - } - require.Equal(t, expected, res) - }) - - t.Run("should replace tabs with spaces in indentation", func(t *testing.T) { - lines := []string{ - "\t\txyz", - "", - " ", - "\t \t", - } - res := removeIndent(lines) - - expected := []string{ - "xyz", - "", // empty line preserved - " ", // blank lines unindented - " \t", - } - require.Equal(t, expected, res) - }) - }) - - t.Run("with removeYamlIndent", func(t *testing.T) { - t.Run("should tolerate empty input", func(t *testing.T) { - res := removeYamlIndent([]string{}) - require.Empty(t, res) - require.NotNil(t, res) - }) - - t.Run("should tolerate nil input", func(t *testing.T) { - res := removeYamlIndent(nil) - require.Empty(t, res) - require.Nil(t, res) - }) - - t.Run("should support headline without indentation", func(t *testing.T) { - lines := []string{ - "xyz", - " abc", - } - res := removeYamlIndent(lines) - require.Equal(t, lines, res) - }) - - t.Run("should support headline without indentation", func(t *testing.T) { - lines := []string{ - "xyz", - " abc", - } - res := removeYamlIndent(lines) - require.Equal(t, lines, res) - }) - }) -} - -func buildRawTestComments(lines []string) *ast.CommentGroup { - // build raw doc comments like ast provides - doc := &ast.CommentGroup{ - List: make([]*ast.Comment, 0, len(lines)), - } - for _, line := range lines { - doc.List = append(doc.List, &ast.Comment{Text: "// " + line}) - } - - return doc -} diff --git a/internal/scanner/classify/extension.go b/internal/scanner/classify/extension.go new file mode 100644 index 0000000..5ae7a61 --- /dev/null +++ b/internal/scanner/classify/extension.go @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package classify provides small classification predicates used by +// the scanner and by builders to decide whether a given name or +// comment line belongs to a particular Swagger-annotation family. +// +// The package lives beneath internal/scanner/ because classification +// is fundamentally a scanner concern: "does this string denote a +// swagger:xxx construct?" is the same kind of question the scanner +// asks when indexing packages. Builders that need the same predicate +// (vendor-extension key filtering, for instance) import from here +// rather than reaching back into internal/parsers/. +package classify + +// IsAllowedExtension reports whether key is a valid Swagger +// vendor-extension key — opens with `x-` or `X-`. Mirrors the +// previous regex `^[Xx]-`: the suffix may be empty (the spec +// itself rejects empty-suffix keys at a higher level). +func IsAllowedExtension(key string) bool { + return len(key) >= 2 && (key[0] == 'x' || key[0] == 'X') && key[1] == '-' +} diff --git a/internal/scanner/classify/extension_test.go b/internal/scanner/classify/extension_test.go new file mode 100644 index 0000000..b669b84 --- /dev/null +++ b/internal/scanner/classify/extension_test.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package classify + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" +) + +func TestIsAllowedExtension(t *testing.T) { + t.Parallel() + + tests := []struct { + ext string + want bool + }{ + {"x-foo", true}, + {"X-bar", true}, + {"x-", true}, + {"y-foo", false}, + {"foo", false}, + {"", false}, + } + + for _, tc := range tests { + t.Run(tc.ext, func(t *testing.T) { + assert.EqualT(t, tc.want, IsAllowedExtension(tc.ext)) + }) + } +} diff --git a/internal/scanner/enum_value.go b/internal/scanner/enum_value.go new file mode 100644 index 0000000..9975d67 --- /dev/null +++ b/internal/scanner/enum_value.go @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package scanner + +import ( + "go/ast" + "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. +// +// 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 { + return result + } + case "FLOAT": + if result, err := strconv.ParseFloat(basicLit.Value, 64); err == nil { + return result + } + default: + return strings.Trim(basicLit.Value, "\"") + } + return nil +} diff --git a/internal/scanner/index.go b/internal/scanner/index.go index 4a68828..05f03a8 100644 --- a/internal/scanner/index.go +++ b/internal/scanner/index.go @@ -74,7 +74,7 @@ type TypeIndex struct { AllPackages map[string]*packages.Package Models map[*ast.Ident]*EntityDecl ExtraModels map[*ast.Ident]*EntityDecl - Meta []parsers.MetaSection + Meta []*ast.CommentGroup Routes []parsers.ParsedPathContent Operations []parsers.ParsedPathContent Parameters []*EntityDecl @@ -145,7 +145,7 @@ func (a *TypeIndex) processFile(pkg *packages.Package, file *ast.File) error { } if n&metaNode != 0 { - a.Meta = append(a.Meta, parsers.MetaSection{Comments: file.Doc}) + a.Meta = append(a.Meta, file.Doc) } if n&operationNode != 0 { diff --git a/internal/scanner/options.go b/internal/scanner/options.go index 987b18a..9ea5b83 100644 --- a/internal/scanner/options.go +++ b/internal/scanner/options.go @@ -3,7 +3,10 @@ package scanner -import "github.com/go-openapi/spec" +import ( + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/spec" +) type Options struct { Packages []string @@ -19,7 +22,35 @@ type Options struct { SetXNullableForPointers bool RefAliases bool // aliases result in $ref, otherwise aliases are expanded TransparentAliases bool // aliases are completely transparent, never creating definitions - DescWithRef bool // allow overloaded descriptions together with $ref, otherwise jsonschema draft4 $ref predates everything - SkipExtensions bool // skip generating x-go-* vendor extensions in the spec - Debug bool // enable verbose debug logging during scanning + // DescWithRef controls description preservation on $ref'd fields + // in the description-only-decoration case: when a struct field's + // Go type resolves to a named type ($ref) and its only + // field-level decoration is a description (no validations, no + // user-authored extensions). + // + // - false (default): the description is dropped and the field + // emits as a bare `{$ref: ...}`. Matches v1's strict default. + // - true: the description is preserved by wrapping the $ref in + // a single-arm `allOf` compound — `{description: "...", + // allOf: [{$ref}]}` — which is the JSON-Schema-draft-4 + // correct shape for sibling description. + // + // When the field also carries validation overrides (pattern, + // enum, example, etc.) or user-authored vendor extensions, the + // allOf compound is mandatory regardless of this flag — the + // override would be lost otherwise. + DescWithRef bool + SkipExtensions bool // skip generating x-go-* vendor extensions in the spec + Debug bool // enable verbose debug logging during scanning + + // OnDiagnostic, when non-nil, is invoked for every diagnostic the + // builder layer records (lexer/parser warnings, semantic-validation + // failures from the validations package, etc.). The callback fires + // once per diagnostic in source order; diagnostics never block the + // build — invalid constructs are silently dropped from the output + // spec while their explanation flows through this channel. + // + // Experimental: the public API surface for diagnostics is subject + // to change while LSP integration matures. + OnDiagnostic func(grammar.Diagnostic) } diff --git a/internal/scanner/scan_context.go b/internal/scanner/scan_context.go index 24eec84..c2d9094 100644 --- a/internal/scanner/scan_context.go +++ b/internal/scanner/scan_context.go @@ -16,6 +16,7 @@ import ( "github.com/go-openapi/codescan/internal/logger" "github.com/go-openapi/codescan/internal/parsers" + "github.com/go-openapi/codescan/internal/parsers/grammar" "golang.org/x/tools/go/packages" ) @@ -98,11 +99,47 @@ func (s *ScanCtx) RefAliases() bool { return s.opts.RefAliases } +// FileSet returns the shared *token.FileSet used by the scan's +// loaded packages. Needed by callers that construct a +// grammar.Parser for comment groups that don't live under a single +// EntityDecl's *packages.Package — notably operation and route +// path-level annotations whose source is aggregated from multiple +// packages. +func (s *ScanCtx) FileSet() *token.FileSet { + if len(s.pkgs) == 0 { + return nil + } + return s.pkgs[0].Fset +} + +// PosOf resolves p to a token.Position via the active FileSet. Returns +// the zero token.Position when p is invalid or no FileSet is available. +// Useful for attaching a source location to a Diagnostic without each +// caller re-deriving the FileSet. +func (s *ScanCtx) PosOf(p token.Pos) token.Position { + if !p.IsValid() { + return token.Position{} + } + fset := s.FileSet() + if fset == nil { + return token.Position{} + } + return fset.Position(p) +} + func (s *ScanCtx) Debug() bool { return s.debug } -func (s *ScanCtx) Meta() iter.Seq[parsers.MetaSection] { +// OnDiagnostic returns the user-supplied diagnostic sink, or nil when +// the consumer has not opted into diagnostic delivery. Builders pipe +// every grammar.Diagnostic they record through this callback in +// source order. Experimental — see Options.OnDiagnostic. +func (s *ScanCtx) OnDiagnostic() func(grammar.Diagnostic) { + return s.opts.OnDiagnostic +} + +func (s *ScanCtx) Meta() iter.Seq[*ast.CommentGroup] { if s.app == nil { return nil } @@ -229,6 +266,59 @@ func (s *ScanCtx) FindDecl(pkgPath, name string) (*EntityDecl, bool) { return nil, false } +// GetModel is a pure read: it returns the model decl for (pkgPath, +// name) without any side effect. The lookup considers both +// swagger:model-annotated decls (Models) and previously-registered +// discovered decls (ExtraModels), then falls back to FindDecl over +// the loaded packages. Returns (nil, false) when no matching decl +// exists in any of those. +// +// Callers that want the lookup hit registered as a discovered model +// must follow up with AddDiscoveredModel explicitly. +func (s *ScanCtx) GetModel(pkgPath, name string) (*EntityDecl, bool) { + for _, cand := range s.app.Models { + ct := cand.Obj() + if ct.Name() == name && ct.Pkg().Path() == pkgPath { + return cand, true + } + } + + for _, cand := range s.app.ExtraModels { + ct := cand.Obj() + if ct.Name() == name && ct.Pkg().Path() == pkgPath { + return cand, true + } + } + + return s.FindDecl(pkgPath, name) +} + +// AddDiscoveredModel registers decl in the ExtraModels index so the +// spec orchestrator emits a top-level definition for it. No-op when +// decl is already an annotated swagger:model (in Models) — those are +// emitted unconditionally, registering them as "discovered" would +// just create a Models↔ExtraModels bouncing loop in joinExtraModels. +// Use only at sites that explicitly intend the registration — +// pure-read lookups should use GetModel. +func (s *ScanCtx) AddDiscoveredModel(decl *EntityDecl) { + if decl == nil || decl.Ident == nil { + return + } + if _, alreadyModel := s.app.Models[decl.Ident]; alreadyModel { + return + } + s.app.ExtraModels[decl.Ident] = decl +} + +// FindModel returns the model decl for (pkgPath, name) and, when the +// hit comes from FindDecl fallback, registers it in ExtraModels as a +// side effect. +// +// Deprecated: prefer the explicit pair GetModel (pure read) and +// AddDiscoveredModel (explicit registration). The implicit +// registration side effect surprises readers and pulls stdlib types +// (notably time.Time, json.RawMessage) into the spec's top-level +// definitions when they should be inlined where referenced. func (s *ScanCtx) FindModel(pkgPath, name string) (*EntityDecl, bool) { for _, cand := range s.app.Models { ct := cand.Obj() @@ -361,7 +451,7 @@ func (s *ScanCtx) findEnumValue(spec ast.Spec, enumName string) (values []any, d continue } - literalValue := parsers.GetEnumBasicLitValue(bl) + literalValue := enumBasicLitValue(bl) var desc strings.Builder fmt.Fprintf(&desc, "%v %s", literalValue, nameIdent.Name) diff --git a/internal/scantest/property.go b/internal/scantest/property.go index f761901..6e89201 100644 --- a/internal/scantest/property.go +++ b/internal/scantest/property.go @@ -59,10 +59,19 @@ func AssertArrayRef(t *testing.T, schema *oaispec.Schema, jsonName, goName, frag assert.EqualT(t, fragment, psch.Ref.String()) } +// AssertRef checks that the named property is a $ref to fragment. +// Accepts both shapes: a bare $ref schema, and the P7/S7 allOf +// compound where the $ref rides arm[0] (with description and +// optional override siblings on the parent / arm[1]). func AssertRef(t *testing.T, schema *oaispec.Schema, jsonName, _, fragment string) { t.Helper() - assert.Empty(t, schema.Properties[jsonName].Type) psch := schema.Properties[jsonName] - assert.EqualT(t, fragment, psch.Ref.String()) + assert.Empty(t, psch.Type) + if psch.Ref.String() != "" { + assert.EqualT(t, fragment, psch.Ref.String()) + return + } + require.NotEmpty(t, psch.AllOf, "property %q has neither $ref nor allOf", jsonName) + assert.EqualT(t, fragment, psch.AllOf[0].Ref.String()) }