diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 52865031..90cae8d7 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -128,7 +128,24 @@ malformed input, the petstore, aliased schemas, go123-specific forms, and cross- - `SkipAllOfCompounding` — never emit an `allOf` compound; validations/externalDocs dropped (description/extensions too, unless `EmitRefSiblings` keeps them as siblings), each with a diagnostic. For consumers (e.g. go-swagger) wanting bare refs. + - `DefaultAllOfForEmbeds` — opt-in (default false): render a plain + (non-`swagger:allOf`) struct embed as allOf composition instead of inlining + its properties — a `$ref` allOf member for a model embed, an inline member + otherwise, with the embedding struct's own fields in a sibling member. + Json-named embeds (go-swagger#2038) and interface embeds are unaffected; + `swagger:allOf` already wins. See `internal/builders/schema/README.md#allof`. - `SetXNullableForPointers` — emit `x-nullable: true` on pointer fields. + - `NameFromTags` — ordered list of struct-tag types a field's emitted name is + derived from (schema properties, parameters, response headers). First listed + tag that supplies a usable name wins. nil/unset ⇒ `["json"]` (historic); + explicit empty slice ⇒ Go field name. Only the name; encoding/json directives + (`-`, `,omitempty`, `,string`) always come from the `json` tag. e.g. + `["form","json"]` for gin (go-swagger#2912/#1391). + - `SkipJSONifyInterfaceMethods` — opt out (default false) of the auto-jsonify + mangler on interface-method property names (`ID`→`id`, `CreatedAt`→`createdAt`). + When true the Go method name is emitted verbatim; `swagger:name` still wins + verbatim regardless. Does not affect struct fields. See + `internal/builders/schema/README.md#interface-naming`. - `SkipExtensions` — suppress `x-go-*` vendor extensions. - `OnDiagnostic` — callback sink for all scan-time observations (the only output channel; codescan never writes to stdout/stderr). @@ -148,6 +165,10 @@ malformed input, the petstore, aliased schemas, go123-specific forms, and cross- - Uses `golang.org/x/tools/go/packages` for module-aware package loading. - Comment annotations follow the go-swagger convention (`swagger:route`, `swagger:operation`, `swagger:parameters`, `swagger:response`, `swagger:model`, etc.). +- `swagger:description |` (YAML literal block-scalar marker) captures a verbatim + markdown body — blank lines, indentation, table pipes preserved — until the next + line-leading annotation or EOF; reframes go-swagger#3211. Plain `swagger:description` + stays blank-terminated. See `internal/parsers/grammar/README.md#literal-description`. - The scanner works at the AST / `go/types` level — it never executes or compiles scanned code. - Parsers never import builders; they write through the interfaces in `internal/ifaces`. When adding a new annotation, extend the relevant builder's `taggers.go` rather than reaching diff --git a/docs/doc-site/about.md b/docs/doc-site/about.md index e00096f2..c9c86591 100644 --- a/docs/doc-site/about.md +++ b/docs/doc-site/about.md @@ -56,7 +56,7 @@ your own program (see [Getting started]({{% relref "/getting-started" %}})). When document-level metadata (info, security, servers) is more naturally hand-authored, you do not have to push it into the code: scan the code for the operations and models, and **overlay** the result onto a hand-written base -document (see [Shaping the output → Overlaying a spec]({{% relref "/shaping-the-output/overlaying-a-spec" %}})). +document (see [Shaping the output → Overlaying a spec]({{% relref "overlaying-a-spec" %}})). ## A community toolkit diff --git a/docs/doc-site/annotation-index/_index.md b/docs/doc-site/annotation-index/_index.md index b4e76222..b7616b70 100644 --- a/docs/doc-site/annotation-index/_index.md +++ b/docs/doc-site/annotation-index/_index.md @@ -17,7 +17,7 @@ tutorial that shows the annotation as runnable Go next to the spec it produces; | `swagger:strfmt` | type declaration | `{type: string, format: …}` at every use | [example]({{% relref "/tutorials/model-definitions#swaggerstrfmt" %}}) | [reference]({{% relref "/maintainers/annotations#swaggerstrfmt" %}}) | | `swagger:enum` | named type | an `enum` array (+ `x-go-enum-desc`) | [example]({{% relref "/tutorials/model-definitions#swaggerenum" %}}) | [reference]({{% relref "/maintainers/annotations#swaggerenum" %}}) | | `swagger:allOf` | embedded field / struct | an `allOf` composition | [example]({{% relref "/tutorials/model-definitions#swaggerallof" %}}) | [reference]({{% relref "/maintainers/annotations#swaggerallof" %}}) | -| `swagger:alias` *(deprecated)* | type alias | **no effect** — alias rendering is controlled by Go aliases + options | [how-to]({{% relref "/shaping-the-output/alias-rendering" %}}) | [reference]({{% relref "/maintainers/annotations#swaggeralias--deprecated" %}}) | +| `swagger:alias` *(deprecated)* | type alias | **no effect** — alias rendering is controlled by Go aliases + options | [how-to]({{% relref "alias-rendering" %}}) | [reference]({{% relref "/maintainers/annotations#swaggeralias--deprecated" %}}) | | `swagger:route` | func / var doc | a `paths` entry + operation | [example]({{% relref "/tutorials/routes-and-operations#swaggerroute" %}}) | [reference]({{% relref "/maintainers/annotations#swaggerroute" %}}) | | `swagger:operation` | func / var doc | a `paths` entry (YAML body) | [example]({{% relref "/tutorials/routes-and-operations#swaggeroperation" %}}) | [reference]({{% relref "/maintainers/annotations#swaggeroperation" %}}) | | `swagger:parameters` | struct declaration | parameters on the named operation(s) | [example]({{% relref "/tutorials/routes-and-operations#swaggerparameters" %}}) | [reference]({{% relref "/maintainers/annotations#swaggerparameters" %}}) | diff --git a/docs/doc-site/getting-started/usage-as-a-library.md b/docs/doc-site/getting-started/usage-as-a-library.md index b47d7d80..3257a3b5 100644 --- a/docs/doc-site/getting-started/usage-as-a-library.md +++ b/docs/doc-site/getting-started/usage-as-a-library.md @@ -48,13 +48,13 @@ an existing spec via `Options.InputSpec`. | `InputSpec` | Overlay: merge discoveries on top of an existing spec. | | `BuildTags`, `Include`/`Exclude` | Scope control over what gets scanned. | | `RefAliases`, `TransparentAliases` | Alias-handling knobs. | -| `EmitRefSiblings` | Emit a `$ref`'d field's description and extensions as direct `$ref` siblings instead of an `allOf` wrap — see [Descriptions beside a $ref]({{% relref "/shaping-the-output/descriptions-beside-a-ref" %}}). | -| `SkipAllOfCompounding` | Never wrap a `$ref`'d field in an `allOf`; emit a bare `$ref` and drop the decorations that need a compound — see [Descriptions beside a $ref]({{% relref "/shaping-the-output/descriptions-beside-a-ref" %}}). | +| `EmitRefSiblings` | Emit a `$ref`'d field's description and extensions as direct `$ref` siblings instead of an `allOf` wrap — see [Descriptions beside a $ref]({{% relref "descriptions-beside-a-ref" %}}). | +| `SkipAllOfCompounding` | Never wrap a `$ref`'d field in an `allOf`; emit a bare `$ref` and drop the decorations that need a compound — see [Descriptions beside a $ref]({{% relref "descriptions-beside-a-ref" %}}). | | `DescWithRef` | _Deprecated_ — preserve a description-only `$ref` field via a single-arm `allOf`; prefer `EmitRefSiblings`. | | `SkipExtensions` | Suppress `x-go-*` vendor extensions. | | `SkipEnumDescriptions` | Keep the `swagger:enum` const→value mapping out of property/parameter descriptions (it still rides `x-go-enum-desc`). | -| `EmitXGoType` | Stamp `x-go-type` (the fully-qualified Go type) on every definition — see [Vendor extensions]({{% relref "/shaping-the-output/vendor-extensions#stamping-x-go-type" %}}). | -| `SingleLineCommentAsDescription` | Route single-line comments to `description` instead of `title`/`summary` — see [Single-line comments]({{% relref "/shaping-the-output/single-line-comments" %}}). | +| `EmitXGoType` | Stamp `x-go-type` (the fully-qualified Go type) on every definition — see [Vendor extensions]({{% relref "vendor-extensions#stamping-x-go-type" %}}). | +| `SingleLineCommentAsDescription` | Route single-line comments to `description` instead of `title`/`summary` — see [Single-line comments]({{% relref "single-line-comments" %}}). | See the [godoc][godoc] for the full list. diff --git a/docs/doc-site/maintainers/_index.md b/docs/doc-site/maintainers/_index.md index 893200f1..c00bfe4d 100644 --- a/docs/doc-site/maintainers/_index.md +++ b/docs/doc-site/maintainers/_index.md @@ -8,9 +8,10 @@ description: | --- This section is the **reference compendium**: the precise, exhaustive -description of the language codescan parses. It is written for people who want -the full contract — annotation authors looking up an exact rule, and -contributors porting, extending, or debugging the parser. +description of the language codescan parses and the options that drive it. It is +written for people who want the full contract — annotation authors looking up an +exact rule, library callers looking up an option, and contributors porting, +extending, or debugging the parser. If you are learning codescan by example, start with the [Tutorials]({{% relref "/tutorials" %}}) instead — they show the same concepts @@ -18,7 +19,7 @@ as runnable Go with the spec they produce, side by side. The [Annotation index]({{% relref "/annotation-index" %}}) cross-references every annotation to both its tutorial and its entry here. -## The four documents +## The reference documents {{< children type="card" description="true" >}} @@ -28,6 +29,9 @@ annotation to both its tutorial and its entry here. - **[Keywords]({{% relref "keywords" %}})** — the per-keyword reference card: every `keyword: value` form, its value shape, and the contexts where it is legal. +- **[Options]({{% relref "options" %}})** — every field of `codescan.Options`: + its type, default, and effect, cross-linked to the how-to that shows it in + action. The library-caller reference. - **[Sub-languages]({{% relref "sub-languages" %}})** — the smaller languages embedded inside annotation bodies (`Parameters:` / `Responses:` grammars, YAML surfaces, prose classification). diff --git a/docs/doc-site/maintainers/annotations.md b/docs/doc-site/maintainers/annotations.md index 4618a8fc..e6f5d70c 100644 --- a/docs/doc-site/maintainers/annotations.md +++ b/docs/doc-site/maintainers/annotations.md @@ -393,7 +393,7 @@ By default the const→value mapping is folded into the property's `description` (as above) **and** duplicated in `x-go-enum-desc`. Set the scanner option `SkipEnumDescriptions: true` to keep the authored prose as the description; the mapping then rides `x-go-enum-desc` only. See -[Vendor extensions]({{% relref "/shaping-the-output/vendor-extensions" %}}). +[Vendor extensions]({{% relref "vendor-extensions" %}}). **Legal keywords.** Schema-context keywords. The `enum:` keyword can ALSO be used inline on the type doc to force a value set; when present, @@ -682,6 +682,22 @@ packages** and matched to operations by operation ID — so a shared parameter set can live in its own package, as long as that package is in the scan set. +**Shared & path-item targets.** The first argument need not be an +operation ID. A `*` wildcard or a `/path` switches the annotation to the +spec-level **shared namespace**: + +- `swagger:parameters *` registers the struct's fields at the spec top + level (`#/parameters/{name}`) for reuse; `swagger:parameters * …` + also `$ref`s them into the listed operations. +- `swagger:parameters /path` inlines the fields into that **exact** + path-item (OAS2 has no path hierarchy). +- A standalone `swagger:parameters …` (or `/path …`) + marker on a **function** `$ref`s an already-registered shared parameter + into that operation / path-item. + +See [Sharing parameters & responses]({{% relref "/tutorials/sharing-parameters-and-responses" %}}) +for the walkthrough and the keep-first / dangling-ref diagnostics. + **Sample.** ```go @@ -755,7 +771,10 @@ intentional, not a missing type. **Where it goes.** On a struct declaration. **Argument shape.** Optional IDENT — the published response name. -Default: the Go type's name. +Default: the Go type's name. A `*` wildcard (`swagger:response *`) is a +synonym for the bare form that explicitly marks the response as a +**shared** one, registered at `#/responses/{name}` for operations to +`$ref` by name — see [Sharing parameters & responses]({{% relref "/tutorials/sharing-parameters-and-responses" %}}). **Sample.** diff --git a/docs/doc-site/maintainers/keywords.md b/docs/doc-site/maintainers/keywords.md index 9b540d0d..59cccf18 100644 --- a/docs/doc-site/maintainers/keywords.md +++ b/docs/doc-site/maintainers/keywords.md @@ -36,7 +36,7 @@ formal productions should read [grammar.md]({{% relref "grammar" %}}). - [Schema decorators](#schema-decorators) — `default`, `example`, `enum`, `required`, `readOnly`, `discriminator`, `deprecated` - [Parameter location](#parameter-location) — `in` - [Meta single-line keywords](#meta-single-line-keywords) — `schemes`, `version`, `host`, `basePath`, `license`, `contact` -- [Body keywords](#body-keywords) — `consumes`, `produces`, `security`, `securityDefinitions`, `responses`, `parameters`, `extensions`, `infoExtensions`, `tos`, `externalDocs`, `tags` +- [Body keywords](#body-keywords) — `consumes`, `produces`, `security`, `securityDefinitions`, `responses`, `parameters`, `examples`, `extensions`, `infoExtensions`, `tos`, `externalDocs`, `tags` --- @@ -141,6 +141,7 @@ them. Detailed entries follow this table. | `securityDefinitions` | `security definitions`, `security-definitions` | raw-block (YAML map) | meta | | `responses` | — | raw-block (response sub-language) | route, operation | | `parameters` | — | raw-block (parameter chunk sub-language) | route, operation | +| `examples` | — | raw-block (YAML map keyed by mime type) | response | | `extensions` | — | raw-block (YAML map of `x-*` entries) | meta, route, operation, schema, param, header | | `infoExtensions` | `info extensions`, `info-extensions` | raw-block (YAML map of `x-*` entries) | meta | | `tos` | `terms of service`, `terms-of-service`, `termsOfService` | raw-block (prose paragraph) | meta | @@ -416,7 +417,9 @@ type PageParam int An example value for the schema, surfaced in tooling. Same raw-value shape as `default`. Maps to `schema.example` (or `parameter.example` -for SimpleSchema parameters). +for SimpleSchema parameters). This is the **singular**, schema-scoped +keyword; for the **plural** response-scoped [`examples`](#examples) (a +map keyed by mime type) see the Body keywords section. ### `enum` @@ -710,6 +713,32 @@ Responses: default: response:genericError ``` +### `examples` + +Response-level examples on a `swagger:response` struct — a YAML map +whose first-level keys are **mime types** and whose values are the +example payloads, populating the OAS2 `Response.examples` field. Note +the OAS2 keyword split: the **plural** `examples` is the *response* +keyword (this one); the **singular** [`example`](#example) is the +schema / param / header / items decorator. The `swagger:operation` YAML +body carries `examples` natively (it is unmarshalled straight into the +spec types); this keyword is the struct-`swagger:response` counterpart. + +```go +// swagger:response widgetResponse +// +// examples: +// +// application/json: +// name: alice +// count: 3 +// application/xml: "alice" +type WidgetResponse struct { + // in: body + Body Widget `json:"body"` +} +``` + ### `parameters` Per-route / per-operation parameter declarations. Body is a sequence diff --git a/docs/doc-site/maintainers/options.md b/docs/doc-site/maintainers/options.md new file mode 100644 index 00000000..6282c47c --- /dev/null +++ b/docs/doc-site/maintainers/options.md @@ -0,0 +1,105 @@ +--- +title: "Options reference" +weight: 25 +description: | + Every field of codescan.Options — its type, default, and effect — grouped by + concern and cross-linked to the how-to that shows it in action. +--- + +[`codescan.Options`](https://pkg.go.dev/github.com/go-openapi/codescan#Options) +is the single configuration struct passed to +[`codescan.Run`](https://pkg.go.dev/github.com/go-openapi/codescan#Run). **The +zero value is a valid configuration** — every flag defaults to `false`, every +slice/map to `nil`, every numeric tunable to its built-in default. You set only +what you need. + +This page is the field-by-field catalogue. The +[godoc](https://pkg.go.dev/github.com/go-openapi/codescan#Options) is the +normative source; each field here links to the +[how-to guide]({{% relref "/shaping-the-output" %}}) that shows it on real +input where one exists. + +{{% notice style="note" %}} +codescan never writes to stdout or stderr. Every scan-time observation — a +dropped construct, a rename, a prune — flows through the `OnDiagnostic` +callback. See [Diagnostics & observability](#diagnostics--observability) below. +{{% /notice %}} + +## Inputs & scope + +What gets loaded and which packages and types are in play. See +[Scope & discovery]({{% relref "/shaping-the-output/scope-and-discovery" %}}). + +| Option | Type | Default | Effect | +|--------|------|---------|--------| +| `Packages` | `[]string` | `nil` | Package patterns to scan (e.g. `./...`), resolved relative to `WorkDir`. | +| `WorkDir` | `string` | `""` (cwd) | Working directory the package patterns and module resolution are rooted at. | +| `BuildTags` | `string` | `""` | Go build tags to activate while loading, so tag-guarded source is scanned. See [Build tags]({{% relref "build-tags" %}}). | +| `Include` | `[]string` | `nil` | Allow-list of package path patterns; when non-empty only matching packages are scanned. See [Scoping the scan]({{% relref "scoping-the-scan" %}}). | +| `Exclude` | `[]string` | `nil` | Deny-list of package path patterns, applied after `Include`. See [Scoping the scan]({{% relref "scoping-the-scan" %}}). | +| `IncludeTags` | `[]string` | `nil` | Allow-list filtering routes/operations by their swagger `tags`. | +| `ExcludeTags` | `[]string` | `nil` | Deny-list filtering routes/operations by their swagger `tags`. | +| `ExcludeDeps` | `bool` | `false` | Skip types reached through module dependencies, keeping the scan to first-party packages. | +| `ScanModels` | `bool` | `false` | Also emit a definition for every `swagger:model` type, not just route-reachable ones. See [When the scanner emits a type]({{% relref "type-discovery" %}}). | +| `PruneUnusedModels` | `bool` | `false` | With `ScanModels`, drop discovered definitions not transitively reachable from a path, shared response/parameter, or `InputSpec` root. Runs before name reduction; `InputSpec` definitions are pinned. No-op without `ScanModels`. See [Pruning unused models]({{% relref "pruning-unused-models" %}}). | +| `InputSpec` | `*spec.Swagger` | `nil` | Base document to overlay scanned discoveries onto; its definitions are pinned and seed pruning roots. See [Overlaying a spec]({{% relref "overlaying-a-spec" %}}). | + +## Names & references + +How definitions are named and how `$ref`s render. See +[Names & `$ref`s]({{% relref "/shaping-the-output/names-and-refs" %}}). + +| Option | Type | Default | Effect | +|--------|------|---------|--------| +| `NameFromTags` | `[]string` | `nil` (⇒ `["json"]`) | Ordered struct-tag types a property/parameter/header name is derived from; first that supplies a name wins. Explicit empty slice ⇒ Go field name. Only the name — `json` encoding directives (`-`, `,omitempty`, `,string`) always come from `json`. See [Naming from struct tags]({{% relref "naming-from-tags" %}}). | +| `SkipJSONifyInterfaceMethods` | `bool` | `false` | Emit interface-method property names verbatim (`ID`, `CreatedAt`) instead of auto-jsonifying them (`id`, `createdAt`). Only affects interface methods; struct fields and `swagger:name` overrides are unchanged. See [Interface-method property names]({{% relref "interface-method-names" %}}). | +| `RefAliases` | `bool` | `false` | Render Go type aliases as a first-class `$ref` (via `swagger:model`) instead of expanding them inline. See [Alias rendering]({{% relref "alias-rendering" %}}). | +| `TransparentAliases` | `bool` | `false` | Make aliases fully transparent — never creating a definition. See [Alias rendering]({{% relref "alias-rendering" %}}). | +| `DefaultAllOfForEmbeds` | `bool` | `false` | Render a plain (untagged, unnamed) struct embed as an `allOf` member — a `$ref` for a model embed, an inline member otherwise — with the embedding struct's own fields in a sibling member, instead of inlining promoted properties. json-named embeds, `swagger:allOf` embeds, and interface embeds are unaffected. See [Composing embeds with allOf]({{% relref "composing-embeds-with-allof" %}}). | +| `NameConcatBudget` | `float64` | `0` (⇒ `0.65`) | Readability cutoff `[0,1]` for the package-segment concatenation that deconflicts colliding definition names; lower scores are more readable. A group whose best concat scores above the budget is a candidate for the hierarchical fallback. See [Resolving `$ref` name conflicts]({{% relref "resolving-name-conflicts" %}}). | +| `EmitHierarchicalNames` | `bool` | `false` | For the rare collision group whose best flat concat exceeds `NameConcatBudget`, emit nested container definitions (`#/definitions//`) instead of a long flat concat, with an explanatory diagnostic. The always-correct flat concat is the default. See [Resolving `$ref` name conflicts]({{% relref "resolving-name-conflicts" %}}). | +| `EmitRefSiblings` | `bool` | `false` | Emit a `$ref`'d field's description and vendor extensions as direct `$ref` siblings (`{$ref, description, x-*}`) instead of an `allOf` wrap. Validations/externalDocs still force a compound. See [Descriptions beside a `$ref`]({{% relref "descriptions-beside-a-ref" %}}). | +| `SkipAllOfCompounding` | `bool` | `false` | Never emit an `allOf` compound for a `$ref`'d field. Validations/externalDocs are dropped (description/extensions too, unless `EmitRefSiblings` keeps them as siblings); each drop raises a diagnostic. `required` is unaffected. See [Descriptions beside a `$ref`]({{% relref "descriptions-beside-a-ref" %}}). | +| `DescWithRef` | `bool` | `false` | **Deprecated** — prefer `EmitRefSiblings`. In the description-only case, wrap the `$ref` in a single-arm `allOf` to preserve the description (strict draft-4 shape). No-op when `EmitRefSiblings` is set. See [Descriptions beside a `$ref`]({{% relref "descriptions-beside-a-ref" %}}). | + +## Titles & descriptions + +The human-readable text the spec carries. See +[Titles & descriptions]({{% relref "/shaping-the-output/titles-and-descriptions" %}}). + +| Option | Type | Default | Effect | +|--------|------|---------|--------| +| `SingleLineCommentAsDescription` | `bool` | `false` | Route every single-line doc comment to `description`, never to `title`/`summary` (the first-sentence convention otherwise applies). Multi-line comments keep the title/description split. See [Single-line comments as descriptions]({{% relref "single-line-comments" %}}). | +| `AfterDeclComments` | `bool` | `false` | Let swagger annotations live inside a struct body or as a trailing comment, in addition to the doc comment above the declaration, so the godoc stays clean. v0.36 scope: type declarations (struct inside-body + alias trailing comment). See [Keeping annotations out of the godoc]({{% relref "keeping-annotations-out-of-the-godoc" %}}). | +| `CleanGoDoc` | `bool` | `false` | Strip godoc doc-link brackets from generated `title`/`description` (humanizing unresolved ones, dropping reference-definition lines, recomposing resolved links to each schema's exposed name). Applies only to godoc-derived prose; overrides are untouched. See [Cleaning godoc doc-links]({{% relref "cleaning-godoc-doc-links" %}}). | + +## Field types, formats & extensions + +How an individual property renders. See +[Field types & formats]({{% relref "/shaping-the-output/field-types-and-formats" %}}). + +| Option | Type | Default | Effect | +|--------|------|---------|--------| +| `SetXNullableForPointers` | `bool` | `false` | Emit `x-nullable: true` on pointer-typed fields. See [Nullable pointers]({{% relref "nullable-pointers" %}}). | +| `SkipExtensions` | `bool` | `false` | Suppress all `x-go-*` vendor extensions in the output. See [Vendor extensions]({{% relref "vendor-extensions" %}}). | +| `EmitXGoType` | `bool` | `false` | Stamp an `x-go-type` extension (fully-qualified originating Go type) on every emitted definition, for round-tripping a spec back to its Go types. Suppressed under `SkipExtensions`. See [Vendor extensions]({{% relref "vendor-extensions" %}}). | +| `SkipEnumDescriptions` | `bool` | `false` | Keep the per-enum-value const-name mapping (from `swagger:enum`) out of the `description`, exposing it only via the `x-go-enum-desc` extension. Suppressed entirely under `SkipExtensions`. | + +## Diagnostics & observability + +Channels for what the scan observed; these do not change the output spec. + +| Option | Type | Default | Effect | +|--------|------|---------|--------| +| `OnDiagnostic` | `func(Diagnostic)` | `nil` | Invoked once per diagnostic in source order (parser warnings, validation failures, prunes, renames). Diagnostics never block the build — invalid constructs are dropped from the spec while their explanation flows here. The only output channel. **Experimental** while LSP integration matures. | +| `OnProvenance` | `func(Provenance)` | `nil` | Invoked once per anchor node in the produced spec, carrying its JSON pointer and the source position of the Go construct that produced it. Never blocks the build. **Experimental** while LSP/TUI integration matures. | +| `Debug` | `bool` | `false` | **Deprecated, ignored.** The legacy stderr debug logger was retired; wire `OnDiagnostic` instead. Retained for API compatibility. | + +## See also + +- [Annotations]({{% relref "annotations" %}}) — the `swagger:*` vocabulary the + scanner reads from comments. +- [Keyword reference]({{% relref "keywords" %}}) — the `keyword: value` forms + inside annotation bodies. +- [Shaping the output]({{% relref "/shaping-the-output" %}}) — task-oriented + how-tos that put these options to work on real input. diff --git a/docs/doc-site/shaping-the-output/_index.md b/docs/doc-site/shaping-the-output/_index.md index 158a5b32..a0da9022 100644 --- a/docs/doc-site/shaping-the-output/_index.md +++ b/docs/doc-site/shaping-the-output/_index.md @@ -3,21 +3,28 @@ title: Shaping the output weight: 30 description: | How-to guides for the knobs that change how the same Go source renders into - the spec — $ref vs inline, alias handling, descriptions beside a $ref, - nullable pointers, vendor extensions, and spec overlays. + the spec — grouped by what they shape: scope & discovery, names & $refs, + titles & descriptions, field types & formats, and response bodies. --- -The same annotated Go can render into the spec in more than one shape. A handful -of [`codescan.Options`](https://pkg.go.dev/github.com/go-openapi/codescan#Options) -let you choose: should an alias become a `$ref` or expand inline? Should a -field's description survive next to a `$ref`? Should pointer fields be marked -nullable? +The same annotated Go can render into the spec in more than one shape, and a +handful of [`codescan.Options`](https://pkg.go.dev/github.com/go-openapi/codescan#Options) +(plus a few field-level annotations) let you choose. The guides are grouped by +*what* they shape: -Each guide here is task-oriented — *"I want the output to look like this"* — and -shows the **same input rendered both ways**, as before/after golden output the -example tests verify. +- **Scope & discovery** — which packages are read and which types become + definitions. +- **Names & `$ref`s** — the names definitions are published under and how + references render. +- **Titles & descriptions** — the human-readable text the spec carries. +- **Field types & formats** — how an individual property renders. +- **Response bodies** — describing a payload without a dedicated + `swagger:response` struct. {{< children type="card" description="true" >}} -For the field-by-field meaning of each option, see the +Each guide is task-oriented — *"I want the output to look like this"* — and shows +the **same input rendered both ways**, as before/after golden output the example +tests verify. For the field-by-field meaning of every option, see the +[Options reference]({{% relref "options" %}}) or the [`Options` godoc](https://pkg.go.dev/github.com/go-openapi/codescan#Options). diff --git a/docs/doc-site/shaping-the-output/field-types-and-formats/_index.md b/docs/doc-site/shaping-the-output/field-types-and-formats/_index.md new file mode 100644 index 00000000..a03a8783 --- /dev/null +++ b/docs/doc-site/shaping-the-output/field-types-and-formats/_index.md @@ -0,0 +1,14 @@ +--- +title: Field types & formats +weight: 40 +description: | + Tune how an individual field renders — force a conformant format, mark + pointer fields nullable, and control the x-go-* vendor extensions codescan + emits. +--- + +These knobs act at the level of a single property: the `format` it carries, +whether a pointer is advertised as nullable, and the vendor extensions that +record its Go provenance. + +{{< children type="card" description="true" >}} diff --git a/docs/doc-site/shaping-the-output/forcing-a-format.md b/docs/doc-site/shaping-the-output/field-types-and-formats/forcing-a-format.md similarity index 95% rename from docs/doc-site/shaping-the-output/forcing-a-format.md rename to docs/doc-site/shaping-the-output/field-types-and-formats/forcing-a-format.md index 6fb6d54a..011c275a 100644 --- a/docs/doc-site/shaping-the-output/forcing-a-format.md +++ b/docs/doc-site/shaping-the-output/field-types-and-formats/forcing-a-format.md @@ -1,6 +1,6 @@ --- title: Forcing a conformant format -weight: 25 +weight: 10 description: | Override a Go-derived format (e.g. the vendor uint64/uint32 formats) with an official, JSON-conformant one using a field-level swagger:strfmt. @@ -30,6 +30,6 @@ a `UUID` type → `{string, format: uuid}`); see [Model definitions → swagger:strfmt]({{% relref "/tutorials/model-definitions" %}}). The `swagger:type` annotation is the related tool when you want to override the whole **type**, not just its format — see -[Type discovery]({{% relref "/shaping-the-output/type-discovery" %}}) and the +[Type discovery]({{% relref "type-discovery" %}}) and the [`swagger:type` reference]({{% relref "/maintainers/annotations#swaggertype" %}}). {{% /notice %}} diff --git a/docs/doc-site/shaping-the-output/nullable-pointers.md b/docs/doc-site/shaping-the-output/field-types-and-formats/nullable-pointers.md similarity index 99% rename from docs/doc-site/shaping-the-output/nullable-pointers.md rename to docs/doc-site/shaping-the-output/field-types-and-formats/nullable-pointers.md index 43986261..feb3ee70 100644 --- a/docs/doc-site/shaping-the-output/nullable-pointers.md +++ b/docs/doc-site/shaping-the-output/field-types-and-formats/nullable-pointers.md @@ -1,6 +1,6 @@ --- title: Nullable pointers -weight: 30 +weight: 20 description: | Mark pointer-typed fields as nullable with x-nullable, via SetXNullableForPointers. diff --git a/docs/doc-site/shaping-the-output/vendor-extensions.md b/docs/doc-site/shaping-the-output/field-types-and-formats/vendor-extensions.md similarity index 99% rename from docs/doc-site/shaping-the-output/vendor-extensions.md rename to docs/doc-site/shaping-the-output/field-types-and-formats/vendor-extensions.md index 406c363b..0627137a 100644 --- a/docs/doc-site/shaping-the-output/vendor-extensions.md +++ b/docs/doc-site/shaping-the-output/field-types-and-formats/vendor-extensions.md @@ -1,6 +1,6 @@ --- title: Vendor extensions -weight: 40 +weight: 30 description: | Control the x-go-* vendor extensions codescan emits, or suppress them with SkipExtensions. diff --git a/docs/doc-site/shaping-the-output/names-and-refs/_index.md b/docs/doc-site/shaping-the-output/names-and-refs/_index.md new file mode 100644 index 00000000..529f177a --- /dev/null +++ b/docs/doc-site/shaping-the-output/names-and-refs/_index.md @@ -0,0 +1,15 @@ +--- +title: Names & $refs +weight: 20 +description: | + Control the names definitions are published under and how references render — + deconflicting collisions, deriving member names from struct tags, alias + rendering, and a description sitting beside a $ref. +--- + +Once codescan knows *which* definitions to emit, these knobs govern how they are +**named and referenced**: the definition names that form your published `$ref` +contract, where member names come from, and the shape a reference takes in the +output. + +{{< children type="card" description="true" >}} diff --git a/docs/doc-site/shaping-the-output/alias-rendering.md b/docs/doc-site/shaping-the-output/names-and-refs/alias-rendering.md similarity index 99% rename from docs/doc-site/shaping-the-output/alias-rendering.md rename to docs/doc-site/shaping-the-output/names-and-refs/alias-rendering.md index 44381397..88e29de6 100644 --- a/docs/doc-site/shaping-the-output/alias-rendering.md +++ b/docs/doc-site/shaping-the-output/names-and-refs/alias-rendering.md @@ -1,6 +1,6 @@ --- title: Alias rendering -weight: 60 +weight: 30 description: | Choose how Go type aliases render — dissolved to their target, or exposed as a first-class $ref via swagger:model, with RefAliases / TransparentAliases. diff --git a/docs/doc-site/shaping-the-output/names-and-refs/composing-embeds-with-allof.md b/docs/doc-site/shaping-the-output/names-and-refs/composing-embeds-with-allof.md new file mode 100644 index 00000000..12316b17 --- /dev/null +++ b/docs/doc-site/shaping-the-output/names-and-refs/composing-embeds-with-allof.md @@ -0,0 +1,81 @@ +--- +title: Composing embeds with allOf +weight: 35 +description: | + Render a plain struct embed as an allOf composition — a $ref to the embedded + model plus a sibling member for the embedding struct's own fields — instead of + inlining the promoted properties, with DefaultAllOfForEmbeds. +--- + +When a struct embeds another struct, Go *promotes* the embedded fields, and by +default codescan mirrors that: the embedded type's properties are inlined flat +into the embedding schema. That is faithful to the Go value, but it loses the +*"this composes `Base`"* relationship — every embedding model emits its own flat +copy of the embedded fields, and a client generator can't recover the shared +base type. + +`DefaultAllOfForEmbeds` changes that. With the option on, a **plain** embed (one +with no explicit name and no `swagger:allOf` tag) is rendered as an `allOf` +member — exactly as if it carried +[`swagger:allOf`]({{% relref "/tutorials/polymorphic-models" %}}) — so the +composition relationship survives in the spec. It is opt-in and defaults to off; +with it off, output is byte-identical to before. + +## What composes + +This model embeds a `swagger:model` type (`Base`), a non-model type (`Mixin`), +and adds an own field: + +{{< code file="shaping/embedallof/embedallof.go" lang="go" region="base" >}} + +{{< code file="shaping/embedallof/embedallof.go" lang="go" region="plain" >}} + +Scanned with the flag off the embedded properties inline flat; on, the embed +becomes an `allOf` composition: + +{{< compare left="shaping/embedallof/testdata/plainembed_off.json" leftlabel="Default — inlined" + right="shaping/embedallof/testdata/plainembed_on.json" rightlabel="DefaultAllOfForEmbeds — composed" >}} + +Reading the composed pane, each embed takes the path its kind dictates: + +- **A model embed becomes a `$ref` member.** `Base` is a `swagger:model`, so it + has its own definition and composes as `{$ref: "#/definitions/Base"}` — no + copy of `id` / `name`. +- **A non-model embed becomes an inline member.** `Mixin` carries no + `swagger:model`, so it has no definition to point at; its `note` property + rides an inline `allOf` member instead. +- **The embedding struct's own fields move to a sibling member.** `color` is no + longer a top-level property — it lands in its own `allOf` arm alongside the + composed embeds. + +## What's left alone + +The flag only changes the *untagged, unnamed* embed — every other embed shape is +unaffected: + +{{< code file="shaping/embedallof/embedallof.go" lang="go" region="edges" >}} + +- **Pointer embeds** are peeled first, so `*Base` composes to the same + `$ref` member as a value embed. +- **A json-named embed is not a promotion.** Giving the embed a json tag + (`Base \`json:"base"\``) makes it a single nested property named `base`, on or + off — Go doesn't promote a named embed (go-swagger#2038). +- **An explicit `swagger:allOf` embed already composes**, so the flag is a no-op + for it; it only makes `allOf` the *default* for untagged embeds. +- **Interface embeds** compose via `allOf` regardless of this flag. + +{{% notice style="note" %}} +`DefaultAllOfForEmbeds` is the global default-on switch for the same shape +`swagger:allOf` produces per-embed. Reach for the annotation when only some +embeds should compose; reach for the option when composition is your house style +for every plain embed. +{{% /notice %}} + +## What's next + +- [Polymorphic models]({{% relref "/tutorials/polymorphic-models" %}}) — the + `swagger:allOf` annotation and discriminator hints this option generalises. +- [Descriptions beside a `$ref`]({{% relref "descriptions-beside-a-ref" %}}) — + how a description and validations render on an `allOf` member. +- [Resolving `$ref` name conflicts]({{% relref "resolving-name-conflicts" %}}) — + the definition names the composed `$ref`s point at. diff --git a/docs/doc-site/shaping-the-output/descriptions-beside-a-ref.md b/docs/doc-site/shaping-the-output/names-and-refs/descriptions-beside-a-ref.md similarity index 99% rename from docs/doc-site/shaping-the-output/descriptions-beside-a-ref.md rename to docs/doc-site/shaping-the-output/names-and-refs/descriptions-beside-a-ref.md index a7efdc92..c69bc439 100644 --- a/docs/doc-site/shaping-the-output/descriptions-beside-a-ref.md +++ b/docs/doc-site/shaping-the-output/names-and-refs/descriptions-beside-a-ref.md @@ -1,6 +1,6 @@ --- title: Descriptions beside a $ref -weight: 50 +weight: 40 description: | Control how a field's description and extensions are rendered when its type resolves to a $ref — wrapped in an allOf, emitted as direct siblings diff --git a/docs/doc-site/shaping-the-output/names-and-refs/interface-method-names.md b/docs/doc-site/shaping-the-output/names-and-refs/interface-method-names.md new file mode 100644 index 00000000..2d039c9e --- /dev/null +++ b/docs/doc-site/shaping-the-output/names-and-refs/interface-method-names.md @@ -0,0 +1,59 @@ +--- +title: Interface-method property names +weight: 25 +description: | + Emit interface-method property names verbatim (ID, CreatedAt) instead of the + auto-jsonified spelling (id, createdAt), with SkipJSONifyInterfaceMethods. +--- + +When a model's shape is described by an **interface**, its methods have no +natural JSON serialization — Go's `encoding/json` can't marshal interface +methods, so there's no struct tag to read a name from. codescan invents one by +running its jsonify transform on the Go method name: `ID` → `id`, `CreatedAt` +→ `createdAt`. That "one size fits all" convention isn't always what you want — +an interface already named for its JSON shape, or a codebase with its own +canonical-name discipline, wants the Go name kept as-is. + +`SkipJSONifyInterfaceMethods` opts out of the mangler. With it set, an +interface-method property is emitted under the Go method name verbatim. It is an +opt-out and defaults to off; with it off, output is unchanged. + +## What changes + +This model is an interface with two default-path methods and one carrying a +`swagger:name` override: + +{{< code file="shaping/interfacenames/interfacenames.go" lang="go" region="account" >}} + +Scanned with the flag off the method names auto-jsonify; on, they ride through +verbatim: + +{{< compare left="shaping/interfacenames/testdata/account_off.json" leftlabel="Default — jsonified" + right="shaping/interfacenames/testdata/account_on.json" rightlabel="SkipJSONifyInterfaceMethods — verbatim" >}} + +Reading the two panes: + +- **Default-path methods are jsonified.** `ID()` → `id`, `CreatedAt()` → + `createdAt`; the original Go name is preserved as the `x-go-name` extension. +- **With the opt-out, the Go name is the property name.** `ID` and `CreatedAt` + appear verbatim — and `x-go-name` drops, since it would now just repeat the + property name. +- **A `swagger:name` override is verbatim either way.** `OverriddenField` is + published as `explicit_name` in both panes — the override already bypasses the + mangler, so the flag never touches it (and never re-mangles it to + `explicitName`). + +{{% notice style="note" %}} +This flag only affects **interface methods**, which have no JSON serialization to +mirror. Struct-field property names are untouched — they always reflect what +`encoding/json` actually produces (see +[Naming from struct tags]({{% relref "naming-from-tags" %}}) to source those from +a different tag). +{{% /notice %}} + +## What's next + +- [Naming from struct tags]({{% relref "naming-from-tags" %}}) — choose which + struct tag a *field* name comes from (the struct-field analogue of this knob). +- [Resolving `$ref` name conflicts]({{% relref "resolving-name-conflicts" %}}) — + how the definition names themselves are kept distinct. diff --git a/docs/doc-site/shaping-the-output/names-and-refs/naming-from-tags.md b/docs/doc-site/shaping-the-output/names-and-refs/naming-from-tags.md new file mode 100644 index 00000000..858da5d7 --- /dev/null +++ b/docs/doc-site/shaping-the-output/names-and-refs/naming-from-tags.md @@ -0,0 +1,43 @@ +--- +title: Naming from struct tags +weight: 20 +description: | + Derive property, parameter and header names from a struct tag other than + json (form, xml, …) via NameFromTags. +--- + +By default codescan derives a field's spec name from its `json:` tag (then the +Go field name). `Options.NameFromTags` lets you choose which struct-tag types +supply the name, in precedence order — handy when your structs are tagged for +another binding library (for example gin's `form:`). It applies everywhere a +name is derived from a field: schema properties, parameters, and response +headers. The model below tags every field with both `json:` and `form:`: + +{{< code file="shaping/naming-from-tags/naming.go" lang="go" region="model" >}} + +Scanned with the default (`["json"]`) and with `["form","json"]`, the property +names differ — `form:` wins because it is listed first: + +{{< compare left="shaping/naming-from-tags/testdata/default.json" leftlabel="Default (json)" + right="shaping/naming-from-tags/testdata/form.json" rightlabel="NameFromTags: [form, json]" >}} + +```go +codescan.Run(&codescan.Options{ + Packages: []string{"./..."}, + ScanModels: true, + NameFromTags: []string{"form", "json"}, +}) +``` + +The first listed tag that supplies a usable name wins; a tag that is absent or +carries only options (e.g. `,omitempty`) is skipped and the next is tried. An +explicit empty list (`NameFromTags: []string{}`) consults no tag and falls back +to the Go field name. + +{{% notice style="info" %}} +**Name only.** `NameFromTags` changes only the *name*. The encoding/json +directives — `json:"-"` (exclude), `,omitempty`, `,string` — are always read +from the `json` tag, whatever names the field. Targeted renames (the `name:` +keyword, `swagger:name`, and `swagger:model {name}`) still take precedence over +any tag-derived name. +{{% /notice %}} diff --git a/docs/doc-site/shaping-the-output/resolving-name-conflicts.md b/docs/doc-site/shaping-the-output/names-and-refs/resolving-name-conflicts.md similarity index 97% rename from docs/doc-site/shaping-the-output/resolving-name-conflicts.md rename to docs/doc-site/shaping-the-output/names-and-refs/resolving-name-conflicts.md index afbf25a9..d0f5d44e 100644 --- a/docs/doc-site/shaping-the-output/resolving-name-conflicts.md +++ b/docs/doc-site/shaping-the-output/names-and-refs/resolving-name-conflicts.md @@ -1,6 +1,6 @@ --- title: Resolving $ref name conflicts -weight: 18 +weight: 10 description: | When two Go types want the same definition name, codescan keeps them distinct with deterministic, package-qualified names — and you stay in control of the @@ -137,9 +137,9 @@ the nested shape only when you prefer it for the over-budget tail. ## What's next -- [Type discovery]({{% relref "/shaping-the-output/type-discovery" %}}) — which +- [Type discovery]({{% relref "type-discovery" %}}) — which types become definitions in the first place. -- [Pruning unused models]({{% relref "/shaping-the-output/pruning-unused-models" %}}) — +- [Pruning unused models]({{% relref "pruning-unused-models" %}}) — drops unreferenced models *before* this name stage, so collisions caused only by models you do not use never arise. - [Maps & free-form objects]({{% relref "/tutorials/maps-and-free-form-objects" %}}) — diff --git a/docs/doc-site/shaping-the-output/response-bodies/_index.md b/docs/doc-site/shaping-the-output/response-bodies/_index.md new file mode 100644 index 00000000..80e6e5ad --- /dev/null +++ b/docs/doc-site/shaping-the-output/response-bodies/_index.md @@ -0,0 +1,15 @@ +--- +title: Response bodies +weight: 50 +description: | + Describe a concrete response payload without a dedicated swagger:response + struct — declare the body inline on the route, or shadow a generic envelope's + payload with a doc-only struct. +--- + +When a handler's actual Go return type doesn't map cleanly to the payload you +want documented, these knobs let you pin the response body the spec describes — +inline on the route, or via a doc-only struct that stands in for a generic +envelope. + +{{< children type="card" description="true" >}} diff --git a/docs/doc-site/shaping-the-output/documenting-generic-responses.md b/docs/doc-site/shaping-the-output/response-bodies/documenting-generic-responses.md similarity index 98% rename from docs/doc-site/shaping-the-output/documenting-generic-responses.md rename to docs/doc-site/shaping-the-output/response-bodies/documenting-generic-responses.md index 6d5c5be0..8072b2cc 100644 --- a/docs/doc-site/shaping-the-output/documenting-generic-responses.md +++ b/docs/doc-site/shaping-the-output/response-bodies/documenting-generic-responses.md @@ -1,6 +1,6 @@ --- title: Documenting generic responses -weight: 22 +weight: 20 description: | Your handlers return one generic envelope with an interface{} payload, but you want the spec to describe a concrete type per operation. Doc-only structs that @@ -40,7 +40,7 @@ carried over from the embed: right="shaping/genericenvelopes/testdata/statusenvelope.json" rightlabel="Doc-only — data is concrete" >}} You restate **one** field, not the whole envelope. Point the route's response at -the doc-only struct with the [`body:` sub-language]({{% relref "/shaping-the-output/inline-response-bodies" %}}), +the doc-only struct with the [`body:` sub-language]({{% relref "inline-response-bodies" %}}), and specialise the same envelope per operation with a different payload each time: diff --git a/docs/doc-site/shaping-the-output/inline-response-bodies.md b/docs/doc-site/shaping-the-output/response-bodies/inline-response-bodies.md similarity index 99% rename from docs/doc-site/shaping-the-output/inline-response-bodies.md rename to docs/doc-site/shaping-the-output/response-bodies/inline-response-bodies.md index fa77835b..d6308440 100644 --- a/docs/doc-site/shaping-the-output/inline-response-bodies.md +++ b/docs/doc-site/shaping-the-output/response-bodies/inline-response-bodies.md @@ -1,6 +1,6 @@ --- title: Inline response bodies -weight: 20 +weight: 10 description: | Declare a route's responses inline with the body: sub-language — a primitive, an array, or a model $ref — without writing a swagger:response struct. diff --git a/docs/doc-site/shaping-the-output/scope-and-discovery/_index.md b/docs/doc-site/shaping-the-output/scope-and-discovery/_index.md new file mode 100644 index 00000000..4d1a9180 --- /dev/null +++ b/docs/doc-site/shaping-the-output/scope-and-discovery/_index.md @@ -0,0 +1,14 @@ +--- +title: Scope & discovery +weight: 10 +description: | + Choose what gets scanned and which definitions land in the spec — package + patterns and filters, when a type is emitted, pruning unreferenced models, + overlaying an existing document, and build constraints. +--- + +These knobs decide the *inputs and the surface* of the scan: which packages +codescan reads, which types become definitions, and how that set is trimmed or +merged before anything is rendered. + +{{< children type="card" description="true" >}} diff --git a/docs/doc-site/shaping-the-output/build-tags.md b/docs/doc-site/shaping-the-output/scope-and-discovery/build-tags.md similarity index 99% rename from docs/doc-site/shaping-the-output/build-tags.md rename to docs/doc-site/shaping-the-output/scope-and-discovery/build-tags.md index 6a2a24a6..f6cfc070 100644 --- a/docs/doc-site/shaping-the-output/build-tags.md +++ b/docs/doc-site/shaping-the-output/scope-and-discovery/build-tags.md @@ -1,6 +1,6 @@ --- title: Build tags -weight: 70 +weight: 50 description: | Scan source guarded by Go build constraints by passing build tags to the scanner. diff --git a/docs/doc-site/shaping-the-output/overlaying-a-spec.md b/docs/doc-site/shaping-the-output/scope-and-discovery/overlaying-a-spec.md similarity index 99% rename from docs/doc-site/shaping-the-output/overlaying-a-spec.md rename to docs/doc-site/shaping-the-output/scope-and-discovery/overlaying-a-spec.md index 3ab02b14..69073ddd 100644 --- a/docs/doc-site/shaping-the-output/overlaying-a-spec.md +++ b/docs/doc-site/shaping-the-output/scope-and-discovery/overlaying-a-spec.md @@ -1,6 +1,6 @@ --- title: Overlaying a spec -weight: 60 +weight: 40 description: | Merge scanned discoveries on top of an existing Swagger document with InputSpec. diff --git a/docs/doc-site/shaping-the-output/pruning-unused-models.md b/docs/doc-site/shaping-the-output/scope-and-discovery/pruning-unused-models.md similarity index 83% rename from docs/doc-site/shaping-the-output/pruning-unused-models.md rename to docs/doc-site/shaping-the-output/scope-and-discovery/pruning-unused-models.md index 2c0cecc5..64f85f43 100644 --- a/docs/doc-site/shaping-the-output/pruning-unused-models.md +++ b/docs/doc-site/shaping-the-output/scope-and-discovery/pruning-unused-models.md @@ -1,6 +1,6 @@ --- title: Pruning unused models -weight: 16 +weight: 30 description: | Scan a shared library with swagger:model discovery, then keep only the definitions actually reachable from your API — the middle ground between @@ -9,7 +9,7 @@ description: | `Options.ScanModels` (the `-m` flag) publishes **every** `swagger:model` type it finds, whether or not anything references it — see -[When the scanner emits a type]({{% relref "/shaping-the-output/type-discovery" %}}). +[When the scanner emits a type]({{% relref "type-discovery" %}}). That is exactly what you want when the annotated package *is* the contract. It is the wrong default when you point codescan at a large **shared model library** and only care about the slice your API actually exposes: the spec fills up with @@ -40,7 +40,7 @@ through any `$ref` — from one of these **roots**: - an operation's body parameters and response schemas; - a top-level shared `response` or `parameter`; -- a definition supplied via [`InputSpec`]({{% relref "/shaping-the-output/overlaying-a-spec" %}}). +- a definition supplied via [`InputSpec`]({{% relref "overlaying-a-spec" %}}). The walk follows references through every schema shape — properties, `allOf` / `anyOf` / `oneOf`, array items, `additionalProperties`, and so on — and @@ -48,6 +48,15 @@ terminates cleanly on recursive or cyclic models. A model referenced **only by another unreferenced model** is itself unreachable, so the whole dead subtree is removed, not just its entry point. +Those shared `response` / `parameter` roots are pruned too. A +[shared parameter or response]({{% relref "/tutorials/sharing-parameters-and-responses" %}}) +that **no operation and no path-item references** is itself dropped (with a +`scan.pruned-unused` Hint) — and because that happens *before* the definition +walk reads its roots from the same `#/parameters` / `#/responses` maps, a model +kept alive only by a now-pruned shared object becomes prunable in turn. +Shared objects supplied through `InputSpec` are pinned, exactly like +definitions. + {{% notice style="info" %}} Definitions you supply through `InputSpec` are **pinned**: they are never pruned, and they seed the reachability roots, so anything they `$ref` survives too. The @@ -62,7 +71,7 @@ definition by a compiler-unique identity while it builds, then a final stage projects each one back to the shortest unique name — deconflicting cross-package collisions along the way (`billing.Account` / `identity.Account` → `BillingAccount` / `IdentityAccount`; see -[Resolving $ref name conflicts]({{% relref "/shaping-the-output/resolving-name-conflicts" %}})). +[Resolving $ref name conflicts]({{% relref "resolving-name-conflicts" %}})). `PruneUnusedModels` runs **before** that name-resolution stage. So when one half of a colliding pair is unused, it is pruned *first* — and the collision never @@ -97,9 +106,9 @@ codescan reports: ## What's next -- [When the scanner emits a type]({{% relref "/shaping-the-output/type-discovery" %}}) — +- [When the scanner emits a type]({{% relref "type-discovery" %}}) — reachability and `swagger:model`, the rules pruning builds on. -- [Resolving $ref name conflicts]({{% relref "/shaping-the-output/resolving-name-conflicts" %}}) — +- [Resolving $ref name conflicts]({{% relref "resolving-name-conflicts" %}}) — the name-resolution stage pruning runs ahead of. -- [Overlaying a spec]({{% relref "/shaping-the-output/overlaying-a-spec" %}}) — +- [Overlaying a spec]({{% relref "overlaying-a-spec" %}}) — `InputSpec`, whose definitions are pinned against the prune. diff --git a/docs/doc-site/shaping-the-output/scoping-the-scan.md b/docs/doc-site/shaping-the-output/scope-and-discovery/scoping-the-scan.md similarity index 97% rename from docs/doc-site/shaping-the-output/scoping-the-scan.md rename to docs/doc-site/shaping-the-output/scope-and-discovery/scoping-the-scan.md index 61a8e25a..598b0da1 100644 --- a/docs/doc-site/shaping-the-output/scoping-the-scan.md +++ b/docs/doc-site/shaping-the-output/scope-and-discovery/scoping-the-scan.md @@ -64,4 +64,4 @@ referenced models. `Options.ExcludeDeps` keeps the scan within your own module, leaving out types pulled in from dependencies. Build constraints get their own guide — see -[Build tags]({{% relref "/shaping-the-output/build-tags" %}}). +[Build tags]({{% relref "build-tags" %}}). diff --git a/docs/doc-site/shaping-the-output/type-discovery.md b/docs/doc-site/shaping-the-output/scope-and-discovery/type-discovery.md similarity index 96% rename from docs/doc-site/shaping-the-output/type-discovery.md rename to docs/doc-site/shaping-the-output/scope-and-discovery/type-discovery.md index 9e5d7d80..6d2e76d0 100644 --- a/docs/doc-site/shaping-the-output/type-discovery.md +++ b/docs/doc-site/shaping-the-output/scope-and-discovery/type-discovery.md @@ -1,6 +1,6 @@ --- title: When the scanner emits a type -weight: 15 +weight: 20 description: | codescan never invents definitions — a type appears only when it is reachable or registered. Understand reachability and swagger:model so nothing goes @@ -37,7 +37,7 @@ If a model is missing from your spec, it is almost always **unreachable**: no operation/parameter/response/model leads to it. Either reference it, or annotate it `swagger:model` and scan with `ScanModels`. For the opposite problem — a `ScanModels` scan that pulls in models you do *not* want, like `Standalone` — -see [Pruning unused models]({{% relref "/shaping-the-output/pruning-unused-models" %}}). +see [Pruning unused models]({{% relref "pruning-unused-models" %}}). {{% /notice %}} ## Generic and embedded types diff --git a/docs/doc-site/shaping-the-output/titles-and-descriptions/_index.md b/docs/doc-site/shaping-the-output/titles-and-descriptions/_index.md new file mode 100644 index 00000000..20754855 --- /dev/null +++ b/docs/doc-site/shaping-the-output/titles-and-descriptions/_index.md @@ -0,0 +1,14 @@ +--- +title: Titles & descriptions +weight: 30 +description: | + Shape the human-readable text — override godoc with API-facing title and + description, route single-line comments to the description, keep annotations + out of the godoc, and clean godoc doc-links out of generated prose. +--- + +The same Go doc comments feed both `pkg.go.dev` and your API documentation, and +the two audiences rarely want the exact same words. These knobs let you keep a +concise godoc while curating the `title` / `description` text the spec carries. + +{{< children type="card" description="true" >}} diff --git a/docs/doc-site/shaping-the-output/titles-and-descriptions/cleaning-godoc-doc-links.md b/docs/doc-site/shaping-the-output/titles-and-descriptions/cleaning-godoc-doc-links.md new file mode 100644 index 00000000..7c2c4b27 --- /dev/null +++ b/docs/doc-site/shaping-the-output/titles-and-descriptions/cleaning-godoc-doc-links.md @@ -0,0 +1,78 @@ +--- +title: Cleaning godoc doc-links +weight: 40 +description: | + Strip godoc doc-link brackets from generated descriptions and recompose + resolvable links to each schema's exposed name — the CleanGoDoc opt-in. +--- + +A Go doc comment can use godoc's [doc-link](https://go.dev/doc/comment#doclinks) +syntax — `[Gadget]`, `[Order.CustName]`, reference-style `[text]: url` lines. +Those render as live links in `pkg.go.dev`, but carried verbatim into a spec +`title` / `description` they read as bracket noise, and the bracketed Go +identifier is rarely the name the schema is actually exposed under. + +`CleanGoDoc` tidies that up. With the option on, godoc doc-link brackets are +removed and — when a link resolves to a scanned schema — the span is recomposed +to the name that schema is **exposed under**, so the prose stays true to the +generated definitions. It applies **only to godoc-derived prose**; an +author-written +[`swagger:title` / `swagger:description`]({{% relref "overriding-titles-and-descriptions" %}}) +override is deliberate text and is never touched. + +Each pane below pairs the annotated Go (left) with the exact fragment the scanner +emits (right), from the test-covered +[`docs/examples/shaping/godoclinks`](https://github.com/go-openapi/codescan/tree/master/docs/examples/shaping/godoclinks) +package. + +## What gets cleaned + +This model — its doc comment and its fields — is dense with doc-link syntax: a +self-reference, links to other models, a pointer, a cross-package link, an +unknown identifier, ordinary brackets, and a reference-definition line: + +{{< code file="shaping/godoclinks/godoclinks.go" lang="go" region="widget" >}} + +Scanned with `CleanGoDoc` off the godoc is emitted verbatim; on, every doc-link +is resolved or humanized and the reference-definition line is dropped: + +{{< compare left="shaping/godoclinks/testdata/gizmo_off.json" leftlabel="Default — verbatim" + right="shaping/godoclinks/testdata/gizmo_on.json" rightlabel="CleanGoDoc — cleaned" >}} + +Reading the cleaned pane: + +- **Links recompose to the exposed name.** `[Gadget]` → `Gadget` (no override, so + its Go name); the `[Order.CustName]` member link → `Order.customer_name` (the + model name plus the field's `json` name); and the leading self-name `Widget` + → `Gizmo`, because the model is published as `swagger:model gizmo` (restored to + sentence case). A cross-package `[inventory.Ledger]` resolves through the + file's imports to `Ledger`. +- **Unresolved links are humanized.** `[Sprocket]` names no scanned model, so it + becomes the plain word `sprocket` rather than a dangling bracket. +- **Reference-definition lines are dropped.** The `[the spec]: https://…` line on + the `spec` field is link plumbing carrying no prose, so the whole line is + removed. + +{{% notice style="info" %}} +**It recomposes to the *final* exposed name.** The substitution runs after +codescan resolves definition names, so a link to a model that gets +[renamed to deconflict a collision]({{% relref "resolving-name-conflicts" %}}) +points at the renamed definition, not the original Go identifier. +{{% /notice %}} + +## Conservative by design + +Only a genuine doc-link is rewritten — a dotted chain (`[pkg.Type]`) or an +uppercase-led identifier (`[Widget]`). Ordinary prose brackets are left exactly +as written, as the `index` field above shows: `[0]`, `[see notes]` and the +bare-lowercase `[id]` all survive untouched. + +`CleanGoDoc` is opt-in and defaults to off — with it off, output is +byte-identical to before, so existing specs never shift under you. + +## What's next + +- [Overriding titles & descriptions]({{% relref "overriding-titles-and-descriptions" %}}) + — replace the godoc text outright (overrides are never cleaned). +- [Resolving `$ref` name conflicts]({{% relref "resolving-name-conflicts" %}}) + — the exposed-name resolution that doc-links recompose to. diff --git a/docs/doc-site/shaping-the-output/titles-and-descriptions/keeping-annotations-out-of-the-godoc.md b/docs/doc-site/shaping-the-output/titles-and-descriptions/keeping-annotations-out-of-the-godoc.md new file mode 100644 index 00000000..59e62814 --- /dev/null +++ b/docs/doc-site/shaping-the-output/titles-and-descriptions/keeping-annotations-out-of-the-godoc.md @@ -0,0 +1,82 @@ +--- +title: Keeping annotations out of the godoc +weight: 30 +description: | + Let swagger annotations live inside a struct body or as trailing comments so + the godoc above each declaration stays clean — the AfterDeclComments opt-in. +--- + +A godoc comment and an API description pursue different goals. The godoc is for +the Go developers reading the package; the API text is for the consumers of the +generated spec. By default codescan reads its annotations from the doc comment +**above** a declaration, which mixes the two concerns — a `swagger:model`, +`maxProperties:` or `swagger:strfmt` line sits right in the middle of the prose a +Go reader sees. + +`AfterDeclComments` separates them. With the option on, codescan also reads +annotations placed **inside a struct body** (its leading comment) or **inlined as +a trailing comment** on the same line as the declaration. The godoc above stays +concise and human-facing while the swagger machinery lives out of it — same +annotation grammar, no new syntax. It is the placement counterpart to +[overriding titles & descriptions]({{% relref "overriding-titles-and-descriptions" %}}), +which separates the same two concerns at the *text* level. + +Each pane below pairs the annotated Go (left) with the exact fragment the scanner +emits (right), from the test-covered +[`docs/examples/shaping/afterdecl`](https://github.com/go-openapi/codescan/tree/master/docs/examples/shaping/afterdecl) +package. + +## Inside a struct body, or trailing on a field + +The `swagger:model` annotation (and any decl-level keyword such as +`maxProperties:`) can live as the **leading comment inside the struct body**, +above the first field. A field-level annotation like `swagger:strfmt` can ride a +**trailing comment** on the field line. The godoc above `Widget` says nothing +about swagger: + +{{< code file="shaping/afterdecl/afterdecl.go" lang="go" region="struct" >}} + +## Inlined on a defined type or alias + +For a non-struct type — a defined type or a type alias — the annotation rides a +trailing comment after the declaration: + +{{< code file="shaping/afterdecl/afterdecl.go" lang="go" region="aliases" >}} + +## Turning it on + +`AfterDeclComments` is opt-in and defaults to off — so existing code, where a +clean comment that happens to look like an annotation is just prose, is never +reinterpreted: + +```go +codescan.Run(&codescan.Options{ + Packages: []string{"./..."}, + ScanModels: true, + AfterDeclComments: true, +}) +``` + +With the option **off**, the inside-body and trailing annotations above are inert +— the clean godoc carries no annotation, so nothing is discovered. With it +**on**, the same source yields the three definitions, each with its keywords +applied (and the clean godoc still supplies the human-facing `title`): + +{{< compare left="shaping/afterdecl/testdata/off.json" leftlabel="Default — annotations inert" + right="shaping/afterdecl/testdata/on.json" rightlabel="AfterDeclComments — discovered" >}} + +{{% notice style="info" %}} +**Scope (v0.36).** The opt-in covers **type declarations** — a struct's +inside-body leading comment, a struct field's trailing comment, and the trailing +comment of a defined type or alias. **Routes and operations are already +position-agnostic**: a `swagger:route` / `swagger:operation` block inside a +function body is discovered with or without this option. Const-based enums are a +planned follow-up. +{{% /notice %}} + +## What's next + +- [Overriding titles & descriptions]({{% relref "overriding-titles-and-descriptions" %}}) + — separate the godoc and the API text at the content level. +- [Single-line comments]({{% relref "single-line-comments" %}}) + — how a plain comment is routed to `title` vs `description`. diff --git a/docs/doc-site/shaping-the-output/titles-and-descriptions/markdown-descriptions.md b/docs/doc-site/shaping-the-output/titles-and-descriptions/markdown-descriptions.md new file mode 100644 index 00000000..40c701d7 --- /dev/null +++ b/docs/doc-site/shaping-the-output/titles-and-descriptions/markdown-descriptions.md @@ -0,0 +1,76 @@ +--- +title: Markdown descriptions +weight: 15 +description: | + Carry a verbatim markdown body — tables, blank lines, indentation and all — + into a description with the swagger:description | literal block-scalar marker, + instead of letting Option B fold it. +--- + +A multi-line `swagger:description` normally folds its body with the **Option B** +rule: contiguous prose lines up to the first blank line, each trimmed. That's +right for a paragraph of prose, but it destroys markdown — a blank line ends the +description, and leading indentation and table pipes are stripped. So a table or +a multi-paragraph body never survives the trip into the spec. + +Ending the annotation line with a lone `|` — the YAML **literal block-scalar +marker** — opts the body into *verbatim* capture instead. Everything below is +taken exactly as written — blank lines, indentation, table pipes and `---` all +preserved — until the next annotation or the end of the comment. It is opt-in +per annotation; a plain `swagger:description` (no `|`) keeps the Option B +behaviour unchanged. + +## Plain prose vs a verbatim body + +These two models carry the *same* markdown body. The first uses an ordinary +annotation; the second adds the `|` marker: + +{{< code file="shaping/markdowndesc/markdowndesc.go" lang="go" region="plain" >}} + +{{< code file="shaping/markdowndesc/markdowndesc.go" lang="go" region="markdown" >}} + +The emitted descriptions diverge sharply: + +{{< compare left="shaping/markdowndesc/testdata/plain.json" leftlabel="Plain — Option B folds" + right="shaping/markdowndesc/testdata/markdown.json" rightlabel="swagger:description | — verbatim" >}} + +- **Option B stops at the first blank line.** The plain model's description is + just the opening sentence — the table that follows the blank line is dropped + entirely (the original go-swagger#3211 grievance). +- **The `|` body is captured whole.** Table leading pipes, the significant blank + line, and the bullet list after it all ride through verbatim. +- **The marker never leaks.** The trailing `|`, the `swagger:description` line + itself, and the single godoc `// ` convention space per line are all stripped; + interior indentation and trailing whitespace (markdown hard breaks) are kept. +- **The title is unaffected.** It still comes from the godoc preamble above the + annotation — only the description body becomes verbatim. + +It works on a **field** description just the same — the `name` property above +keeps the indentation of its ordered list (` 1. unique`). + +## Where the block ends + +The literal block runs until the **next annotation at the start of a line**, or +the end of the doc comment. In the examples above, the trailing +`swagger:model Widget` line closes the block. + +A `swagger:` token *mid-line* is ordinary prose and stays in the body — only a +line that *begins* with an annotation terminates. Indentation doesn't shield +such a line, though: the comment prefix and leading whitespace are stripped +before the check, so a line-leading `swagger:` inside an indented markdown code +block still ends the block. Keep annotation-looking lines out of the verbatim +body, or place them before the `|` annotation. + +{{% notice style="note" %}} +This reframes [go-swagger#3211](https://github.com/go-swagger/go-swagger/issues/3211): +markdown is authored **explicitly** via `swagger:description |`, never recovered +from ambient godoc prose. A plain doc comment stays plain — godoc and the spec +keep their separate conventions. +{{% /notice %}} + +## What's next + +- [Overriding titles & descriptions]({{% relref "overriding-titles-and-descriptions" %}}) + — the `swagger:title` / `swagger:description` overrides this builds on. +- [Single-line comments as descriptions]({{% relref "single-line-comments" %}}) + — route short comments to the description without a marker. diff --git a/docs/doc-site/shaping-the-output/titles-and-descriptions/overriding-titles-and-descriptions.md b/docs/doc-site/shaping-the-output/titles-and-descriptions/overriding-titles-and-descriptions.md new file mode 100644 index 00000000..6e47ffa3 --- /dev/null +++ b/docs/doc-site/shaping-the-output/titles-and-descriptions/overriding-titles-and-descriptions.md @@ -0,0 +1,105 @@ +--- +title: Overriding titles & descriptions +weight: 10 +description: | + Replace the godoc-derived title and description with API-facing text using + swagger:title and swagger:description — on models, fields, $ref'd fields and + responses. +--- + +A Go doc comment is written for Go readers. The same prose is not always what +you want in the published API — a comment may explain internal usage, reference +Go types, or simply read awkwardly to an API consumer. `swagger:title` and +`swagger:description` let the spec text **diverge** from the godoc: the +annotation replaces the prose-derived value, leaving the Go comment free to say +whatever Go developers need. + +This is the *explicit* counterpart to +[Single-line comments]({{% relref "single-line-comments" %}}), +which controls how a plain comment is *implicitly* routed to `title` vs +`description`. Each pane below pairs the annotated Go (left) with the exact +fragment the scanner emits (right), from the test-covered +[`docs/examples/shaping/overrides`](https://github.com/go-openapi/codescan/tree/master/docs/examples/shaping/overrides) +package. + +## Overriding a model and its fields + +`swagger:title ` sets the `title`; `swagger:description ` sets the +`description`. Both sit in the comment block beside `swagger:model` (on a type) +or beside a field's other keywords. The model's Go-facing godoc here is replaced +wholesale by the two overrides: + +{{< code file="shaping/overrides/overrides.go" lang="go" region="model" >}} + +{{< code file="shaping/overrides/testdata/widget.json" lang="json" >}} + +A few things to read out of that pane: + +- **`title` on a property comes *only* from an override.** A field's godoc + becomes its `description`; codescan never derives a property `title` from + prose, so `swagger:title` (as on `label`) is the only way to set one. +- **`plain` keeps its godoc** — no override means no change. Overrides are + strictly opt-in; un-annotated declarations behave exactly as before. + +### Multi-line descriptions + +`swagger:description` may span several lines. The lines immediately following +the annotation fold into one description (joined with newlines) and the body +**terminates at the first blank line, keyword, annotation, or end of comment**. +The `notes` field above shows this: its two prose lines fold together, and the +ordinary godoc paragraph after the blank line is discarded. + +To carry a body *past* a blank line — a markdown table, a multi-paragraph +description — end the annotation line with a `|` literal block marker; see +[Markdown descriptions]({{% relref "markdown-descriptions" %}}). + +### Keeping a co-located validation keyword + +Because the override annotations dispatch through the schema family, a +validation keyword on the *same* field still applies — they co-exist rather +than one shadowing the other. The `capacity` field carries both +`swagger:description` and `maximum: 1000`, and the output keeps both. + +### Suppressing a godoc comment + +A **bare** `swagger:description` (no text, empty body) applies the *empty* value +— a deliberate way to drop a godoc comment from the spec without deleting it +from the source. Because a stray bare marker could also be an accident, +codescan raises a `scan.empty-override` warning through `OnDiagnostic`. The +`suppressed` field above emits no `description` at all. + +## Overrides beside a `$ref` + +`title` and `description` are symmetric `$ref` siblings: on a field whose Go +type is a referenced model, they follow the **same preservation rule a prose +description does**. Under the default flags they drop to a bare `$ref`; with +[`EmitRefSiblings`]({{% relref "descriptions-beside-a-ref" %}}) +they ride alongside the `$ref` as direct siblings. + +{{< compare left="shaping/overrides/testdata/gadget_bare.json" leftlabel="Default — dropped to a bare $ref" + right="shaping/overrides/testdata/gadget_siblings.json" rightlabel="EmitRefSiblings — kept as siblings" >}} + +## Responses and headers + +`swagger:description` also overrides the description of a `swagger:response` and +of its response headers. OpenAPI 2.0 Response and Header objects have **no +`title` field**, so a `swagger:title` on a response or header is rejected with a +`parse.context-invalid` diagnostic — the description override still applies. + +{{< code file="shaping/overrides/overrides.go" lang="go" region="response" >}} + +{{< code file="shaping/overrides/testdata/errorresponse.json" lang="json" >}} + +{{% notice style="info" %}} +**Precedence.** An override always wins over the godoc-derived value. Absent → +the godoc is used unchanged. Empty (bare marker) → the empty value is applied +*and* `scan.empty-override` is raised. `swagger:title` is schema-only; on a +response/header it is dropped with `parse.context-invalid`. +{{% /notice %}} + +## What's next + +- [Single-line comments]({{% relref "single-line-comments" %}}) + — the implicit `title` vs `description` routing this overrides. +- [Descriptions beside a `$ref`]({{% relref "descriptions-beside-a-ref" %}}) + — the `EmitRefSiblings` rule that title/description ride. diff --git a/docs/doc-site/shaping-the-output/single-line-comments.md b/docs/doc-site/shaping-the-output/titles-and-descriptions/single-line-comments.md similarity index 96% rename from docs/doc-site/shaping-the-output/single-line-comments.md rename to docs/doc-site/shaping-the-output/titles-and-descriptions/single-line-comments.md index a0f41c61..71d35891 100644 --- a/docs/doc-site/shaping-the-output/single-line-comments.md +++ b/docs/doc-site/shaping-the-output/titles-and-descriptions/single-line-comments.md @@ -1,6 +1,6 @@ --- title: Single-line comments as descriptions -weight: 42 +weight: 20 description: | Route every single-line doc comment to description instead of title/summary with the SingleLineCommentAsDescription option. @@ -51,5 +51,5 @@ two-line comment, or drop the trailing period, when you want a description. `info` title/description split this option also governs. - [Model definitions]({{% relref "/tutorials/model-definitions" %}}) — the title/description convention on a `swagger:model`. -- [Vendor extensions]({{% relref "/shaping-the-output/vendor-extensions" %}}) — +- [Vendor extensions]({{% relref "vendor-extensions" %}}) — other `Options` knobs that reshape the emitted spec. diff --git a/docs/doc-site/tutorials/document-metadata.md b/docs/doc-site/tutorials/document-metadata.md index a10dce1f..8a80e83f 100644 --- a/docs/doc-site/tutorials/document-metadata.md +++ b/docs/doc-site/tutorials/document-metadata.md @@ -58,7 +58,7 @@ doc.Info.Version = buildVersion // e.g. injected via -ldflags "-X main.buildVers Alternatively, overlay a base document that already carries the version with `Options.InputSpec` (see -[Overlaying a spec]({{% relref "/shaping-the-output/overlaying-a-spec" %}})). +[Overlaying a spec]({{% relref "overlaying-a-spec" %}})). ## What's next diff --git a/docs/doc-site/tutorials/examples-and-defaults.md b/docs/doc-site/tutorials/examples-and-defaults.md index 1c690ecb..d2762d16 100644 --- a/docs/doc-site/tutorials/examples-and-defaults.md +++ b/docs/doc-site/tutorials/examples-and-defaults.md @@ -23,7 +23,8 @@ For the exact value shapes these keywords accept, see type — `Hello, world!` stays a string, `3` becomes a number. {{< example go="concepts/examples/examples.go" goregion="example" - json="concepts/examples/testdata/example.json" jsonlabel="#/definitions/Greeting" >}} + json="concepts/examples/testdata/example.json" + full="concepts/examples/testdata/full.json" >}} The value is not limited to scalars. A **JSON literal** is parsed into a structured example — a `{ … }` object on a map field, a `[ … ]` array on a slice @@ -85,8 +86,11 @@ body schema: ## Response examples by media type -A `swagger:operation` YAML body can give a response an `examples:` map keyed by -media type — these populate the OpenAPI response `examples` object: +A response can carry an `examples:` map keyed by media type — these populate the +OpenAPI response `examples` object, one example payload per content type. Both +annotation styles support it. + +In a `swagger:operation` YAML body, `examples:` sits under the response code: ```go // swagger:operation GET /status status getStatus @@ -100,13 +104,18 @@ media type — these populate the OpenAPI response `examples` object: // hello: world ``` -This per-media-type form is available in the `swagger:operation` YAML body; the -struct-based `swagger:response` does not yet emit per-media-type examples. +On a struct-based `swagger:response`, the same `examples:` block lives in the +declaration comment (the plural `examples:` is the response keyword; the +singular `example:` above is the schema decorator) and produces the same +response `examples` object: + +{{< example go="concepts/examples/examples.go" goregion="responseexamplesbymime" + json="concepts/examples/testdata/responseexamplesbymime.json" jsonlabel="responses[petResponse]" >}} -Because the example lives on the operation's response, **one shared model can -carry a different example per response code and per operation** — a `200` and a -`404` that both return the same error model each show their own illustrative -payload, with no need for a distinct struct per case. +Because the example lives on the response, **one shared model can carry a +different example per response code and per operation** — a `200` and a `404` +that both return the same error model each show their own illustrative payload, +with no need for a distinct struct per case. ## What's next diff --git a/docs/doc-site/tutorials/maps-and-free-form-objects.md b/docs/doc-site/tutorials/maps-and-free-form-objects.md index 973b526a..9ce3e8c7 100644 --- a/docs/doc-site/tutorials/maps-and-free-form-objects.md +++ b/docs/doc-site/tutorials/maps-and-free-form-objects.md @@ -74,7 +74,7 @@ own package first, then uniquely across the scanned model set, so a value type declared in another package resolves to a `$ref` by name. A leaf that matches a model in several packages is ambiguous — it is dropped with a `validate.ambiguous-type-name` diagnostic. See -[Resolving $ref name conflicts]({{% relref "/shaping-the-output/resolving-name-conflicts#referencing-a-model-by-leaf-across-packages" %}}). +[Resolving $ref name conflicts]({{% relref "resolving-name-conflicts#referencing-a-model-by-leaf-across-packages" %}}). {{% /notice %}} ## Per-field control diff --git a/docs/doc-site/tutorials/model-definitions.md b/docs/doc-site/tutorials/model-definitions.md index 2b17f06b..d66554a3 100644 --- a/docs/doc-site/tutorials/model-definitions.md +++ b/docs/doc-site/tutorials/model-definitions.md @@ -29,7 +29,7 @@ automatically — a `time.Time` field, for instance, is published as A `swagger:model` that nothing else references appears in `definitions` only when you scan with `Options.ScanModels` (the `-m` flag). See -[When the scanner emits a type]({{% relref "/shaping-the-output/type-discovery" %}}). +[When the scanner emits a type]({{% relref "type-discovery" %}}). ## swagger:strfmt @@ -160,7 +160,7 @@ alongside the reference: Nothing is dropped. The description-only case is the exception — it is governed by the `DescWithRef` option (see -[Descriptions beside a $ref]({{% relref "/shaping-the-output/descriptions-beside-a-ref" %}})) — +[Descriptions beside a $ref]({{% relref "descriptions-beside-a-ref" %}})) — and a `default`/`example` on a `$ref`'d field is shown in [Examples & defaults]({{% relref "/tutorials/examples-and-defaults" %}}). @@ -171,7 +171,7 @@ identity and qualifies the colliding ones with a package segment (`billing.Account` / `identity.Account` → `BillingAccount` / `IdentityAccount`), deterministically. Pin the names in your public contract with an explicit `swagger:model `, and let auto-resolution handle the rest — see -[Resolving $ref name conflicts]({{% relref "/shaping-the-output/resolving-name-conflicts" %}}). +[Resolving $ref name conflicts]({{% relref "resolving-name-conflicts" %}}). {{% /notice %}} ## What's next diff --git a/docs/doc-site/tutorials/other-type-decorators.md b/docs/doc-site/tutorials/other-type-decorators.md index dddd838c..d0173443 100644 --- a/docs/doc-site/tutorials/other-type-decorators.md +++ b/docs/doc-site/tutorials/other-type-decorators.md @@ -20,7 +20,8 @@ For the value shapes and legal contexts of each, see the sets it, clients must not. {{< example go="concepts/decorators/decorators.go" goregion="readonly" - json="concepts/decorators/testdata/readonly.json" jsonlabel="#/definitions/Token" >}} + json="concepts/decorators/testdata/readonly.json" + full="concepts/decorators/testdata/full.json" >}} This is the idiomatic way to model server-set fields (an `id`, a `createdAt`) that appear in responses but should not be supplied on create — one model, diff --git a/docs/doc-site/tutorials/polymorphic-models.md b/docs/doc-site/tutorials/polymorphic-models.md index c91477ca..3f7a21c2 100644 --- a/docs/doc-site/tutorials/polymorphic-models.md +++ b/docs/doc-site/tutorials/polymorphic-models.md @@ -41,7 +41,7 @@ now given meaning by the base's discriminator. If the subtypes are missing from your spec, they are **unreachable**: a subtype appears only when something references it or you scan with `ScanModels` (the -`-m` flag), the same [reachability rule]({{% relref "/shaping-the-output/type-discovery" %}}) +`-m` flag), the same [reachability rule]({{% relref "type-discovery" %}}) as any model — codescan does not auto-discover subtypes from the base alone. {{% notice style="info" %}} diff --git a/docs/doc-site/tutorials/putting-it-together.md b/docs/doc-site/tutorials/putting-it-together.md index 56d10557..c8bbc0b6 100644 --- a/docs/doc-site/tutorials/putting-it-together.md +++ b/docs/doc-site/tutorials/putting-it-together.md @@ -45,3 +45,13 @@ regenerates and compares on every run (`UPDATE_GOLDEN=1 go test ./...`). Because the example is ordinary, test-covered Go, `go test ./docs/examples/...` keeps the page honest — if the scanner's output changes, CI fails before the documentation can go stale. + +## Seeing it rendered + +The same golden spec, rendered as live API documentation by Swagger UI — what a +consumer of the generated document sees. This closes the loop the capstone is +about: annotated Go → the Swagger 2.0 JSON above → the API docs those +annotations produce. The widget reads the very same golden file, so the rendered +view can't drift from the JSON either. + +{{< openapi src="examples/basic/testdata/swagger.json" >}} diff --git a/docs/doc-site/tutorials/routes-and-operations.md b/docs/doc-site/tutorials/routes-and-operations.md index 28649012..dcee9bc2 100644 --- a/docs/doc-site/tutorials/routes-and-operations.md +++ b/docs/doc-site/tutorials/routes-and-operations.md @@ -27,7 +27,8 @@ named responses (`$ref` into the spec's `responses`). It lives in a plain comment block — no Go declaration required. {{< example go="concepts/routes/routes.go" goregion="route" - json="concepts/routes/testdata/route.json" jsonlabel="paths[/pets]" >}} + json="concepts/routes/testdata/route.json" + full="concepts/routes/testdata/full.json" >}} The body can also carry an indented `Parameters:` block to declare simple parameters (path / query / header) inline — no `swagger:parameters` struct @@ -42,7 +43,8 @@ YAML document after a `---` fence — useful when you want to author the operati object directly (here a path parameter and an inline `$ref` response schema). {{< example go="concepts/routes/routes.go" goregion="operation" - json="concepts/routes/testdata/operation.json" jsonlabel="paths[/pets/{id}]" >}} + json="concepts/routes/testdata/operation.json" + full="concepts/routes/testdata/full.json" >}} ## swagger:parameters @@ -113,6 +115,8 @@ reads better on a single-line doc comment: ## What's next +- [Sharing parameters & responses]({{% relref "/tutorials/sharing-parameters-and-responses" %}}) + — declare a header or error response once and `$ref` it from many operations. - [Validations]({{% relref "/tutorials/validations" %}}) — constrain parameter and field values. - [Model definitions]({{% relref "/tutorials/model-definitions" %}}) — the diff --git a/docs/doc-site/tutorials/security.md b/docs/doc-site/tutorials/security.md index 914fcbc3..27e9443f 100644 --- a/docs/doc-site/tutorials/security.md +++ b/docs/doc-site/tutorials/security.md @@ -35,7 +35,8 @@ A route with no `Security:` keyword inherits the document-wide default and `write` scopes, overriding the default: {{< example go="concepts/security/routes.go" goregion="routes" golabel="swagger:route" - json="concepts/security/testdata/route.json" jsonlabel="security on createReport" >}} + json="concepts/security/testdata/route.json" + full="concepts/security/testdata/full.json" >}} A `Security:` block is plain **YAML** — a sequence of requirement objects. Scopes are a flow list (`[read, write]`) or a block list; an empty list @@ -88,7 +89,7 @@ come entirely from the base: {{< code file="concepts/security/testdata/overlay.json" lang="json" >}} -See [Overlaying a spec]({{% relref "/shaping-the-output/overlaying-a-spec" %}}) +See [Overlaying a spec]({{% relref "overlaying-a-spec" %}}) for the full `InputSpec` merge semantics. ## What's next diff --git a/docs/doc-site/tutorials/sharing-parameters-and-responses.md b/docs/doc-site/tutorials/sharing-parameters-and-responses.md new file mode 100644 index 00000000..f4fd0ca4 --- /dev/null +++ b/docs/doc-site/tutorials/sharing-parameters-and-responses.md @@ -0,0 +1,143 @@ +--- +title: Sharing parameters & responses +weight: 25 +description: | + Declare a parameter or response once and reuse it across operations through + the spec-level shared namespace, with the wildcard swagger:parameters and + swagger:response forms. +--- + +When the same header, query parameter or error response appears on many +operations, you don't have to repeat it. codescan can publish a parameter or +response **once** into the spec's top-level `parameters` / `responses` maps, then +reference it from each operation as a `$ref`. This is the OpenAPI 2.0 shared +namespace (`#/parameters/{name}`, `#/responses/{name}`). + +Each pane below pairs the annotated Go (left) with the exact fragment the scanner +emits (right), from the test-covered +[`docs/examples/concepts/sharedparams`](https://github.com/go-openapi/codescan/tree/master/docs/examples/concepts/sharedparams) +package. For the per-operation basics this builds on — the plain +`swagger:parameters ` and `swagger:response ` forms — see +[Routes & operations]({{% relref "/tutorials/routes-and-operations" %}}). + +## Declaring a shared parameter + +`swagger:parameters *` declares a struct whose fields are registered at the spec +top level, `#/parameters/{name}`, keyed by each parameter's resolved name. The +bare `*` is **register-only**: it publishes the parameter but does not, by +itself, attach it to any operation. + +The convenience form `swagger:parameters * …` does both at once — it +registers the parameter **and** `$ref`s it into the listed operations, which is +handy for a small spec. + +{{< code file="concepts/sharedparams/sharedparams.go" lang="go" region="shared" >}} + +Both structs land in the top-level `parameters` map, each keyed by its parameter +name (the `json:` tag, or a `name:` / `swagger:name` override): + +{{< code file="concepts/sharedparams/testdata/parameters.json" lang="json" >}} + +## Referencing a shared parameter + +Once a parameter is registered, an operation can pull it in by name. There are +two reference channels: + +- **`swagger:parameters * `** on the declaring struct — the + convenience form above. `AuthHeader` uses it to inject `X-API-Key` into + `createPet`. +- **`swagger:parameters …`** as a standalone marker on the + operation's function — the *scaling* channel: the shared struct need not + enumerate every operation that wants the parameter; instead each operation + opts in next to its own `swagger:route`. + +{{< code file="concepts/sharedparams/sharedparams.go" lang="go" region="routes" >}} + +`listPets` opts in through the standalone marker, so its only parameter is a +`$ref` to the shared `X-Request-ID`: + +{{< code file="concepts/sharedparams/testdata/listpets.json" lang="json" >}} + +`createPet` receives the `$ref`'d `X-API-Key` (from `* createPet`) **alongside** +its own inlined body parameter — references and inline parameters coexist: + +{{< code file="concepts/sharedparams/testdata/createpet.json" lang="json" >}} + +## Path-item parameters + +A parameter can also attach to a whole **path** rather than a single operation. +`swagger:parameters /path` inlines a struct's fields into the path-item's +`parameters` array, so every operation under that path inherits them. + +{{< code file="concepts/sharedparams/sharedparams.go" lang="go" region="pathitem" >}} + +`X-Tenant` now rides the `/pets/{id}` path-item; `getPet` inherits it without +declaring a header of its own: + +{{< code file="concepts/sharedparams/testdata/pathitem.json" lang="json" >}} + +{{% notice style="warning" %}} +**Exact path, no hierarchy.** OpenAPI 2.0 has no path nesting, so the target is +matched literally: `swagger:parameters /pets/{id}` applies to `/pets/{id}` only — +**not** to `/pets`. Path-item parameters also *co-exist* with operation-level +ones rather than replacing them; if an operation declares a parameter with the +same `(name, in)`, the operation's wins at resolution time per the OAS2 rule. +{{% /notice %}} + +To `$ref` an already-registered shared parameter into a path-item (instead of +inlining a new one), use the reference form with a path target: +`swagger:parameters /path …` — the path-item analogue of the per-operation +marker above. + +## Shared responses + +Responses share the same way. `swagger:response *` registers a struct at +`#/responses/{name}` (keyed by the Go type name). The `*` is a synonym for the +bare/named `swagger:response` form — its job is to mark the response as a +*shared* one. Operations then name it in their `Responses:` block and it is +emitted as a `$ref`. + +{{< code file="concepts/sharedparams/sharedparams.go" lang="go" region="sharedresponse" >}} + +The shared `ErrorResponse` lands in the top-level `responses` map: + +{{< code file="concepts/sharedparams/testdata/responses.json" lang="json" >}} + +Both routes write `default: ErrorResponse`, which resolves to a single shared +`$ref` (visible as `responses.default.$ref` in the operation panes above) — +one error envelope, defined once, referenced everywhere. + +## Conflicts, duplicates & dangling references + +The shared namespace is referenced **only by short name**, so codescan cannot +silently rename a collision the way it +[deconflicts model definitions]({{% relref "resolving-name-conflicts" %}}). +Instead it applies a deterministic, observable policy and reports every +adjustment through `Options.OnDiagnostic` (the scan never fails on these — it +keeps a valid spec and warns): + +| Situation | Policy | Diagnostic | +|---|---|---| +| Two `swagger:parameters *` register the same name | **keep-first** (sorted by package path then position; never renamed) — later one dropped | `scan.shared-parameter-conflict` | +| Two `swagger:response *` register the same name | keep-first; later one dropped | `scan.shared-response-conflict` | +| A reference names a parameter no `*` registered | reference dropped (no dangling `$ref` emitted) | `scan.dangling-parameter-ref` | +| An operation names an unregistered shared response | reference dropped | `scan.dangling-response-ref` | +| A `* …` marker repeats an operation id | duplicate dropped | `scan.duplicate-target` | +| A reference repeats a parameter name | collapses to a single `$ref` | `scan.duplicate-ref` | + +{{% notice style="note" %}} +The shared **parameters**, **responses** and **definitions** namespaces are +independent: `#/parameters/Status`, `#/responses/Status` and +`#/definitions/Status` can all coexist. The resolved (`name:`-overridden) name is +the key, so references must use that name — not the Go field name. An `InputSpec` +overlay entry seeds the namespace and wins any keep-first conflict. +{{% /notice %}} + +## What's next + +- [Routes & operations]({{% relref "/tutorials/routes-and-operations" %}}) — the + per-operation `swagger:parameters` / `swagger:response` basics. +- [Pruning unused models]({{% relref "pruning-unused-models" %}}) + — shared parameters and responses count as reachability roots when pruning. +- [Keyword reference]({{% relref "/maintainers/keywords" %}}) — the exhaustive + `parameters` / `responses` body grammars. diff --git a/docs/doc-site/tutorials/validations.md b/docs/doc-site/tutorials/validations.md index 0da81abc..77546b55 100644 --- a/docs/doc-site/tutorials/validations.md +++ b/docs/doc-site/tutorials/validations.md @@ -30,7 +30,8 @@ A `swagger:model` field accepts the full JSON-schema validation vocabulary: object-level `required` array (`sku`). {{< example go="concepts/validations/validations.go" goregion="field" - json="concepts/validations/testdata/field.json" jsonlabel="#/definitions/Product" >}} + json="concepts/validations/testdata/field.json" + full="concepts/validations/testdata/full.json" >}} ## On parameters — the simple-schema surface diff --git a/docs/examples/concepts/decorators/decorators_test.go b/docs/examples/concepts/decorators/decorators_test.go index 00763933..8fded88b 100644 --- a/docs/examples/concepts/decorators/decorators_test.go +++ b/docs/examples/concepts/decorators/decorators_test.go @@ -73,4 +73,6 @@ func TestDecoratorFragments(t *testing.T) { goldenJSON(t, "readonly", token) // read only: true → readOnly on the property goldenJSON(t, "deprecated", ping) // deprecated: true on the operation goldenJSON(t, "deprecated_model", gadget) // model/field deprecation → x-deprecated + + goldenJSON(t, "full", doc) // whole spec for the tutorial's live "SwaggerUI" tab } diff --git a/docs/examples/concepts/decorators/testdata/full.json b/docs/examples/concepts/decorators/testdata/full.json new file mode 100644 index 00000000..32a3f74d --- /dev/null +++ b/docs/examples/concepts/decorators/testdata/full.json @@ -0,0 +1,75 @@ +{ + "swagger": "2.0", + "paths": { + "/legacy/ping": { + "get": { + "tags": [ + "legacy" + ], + "summary": "Ping is the legacy health check.", + "operationId": "ping", + "deprecated": true, + "responses": { + "200": { + "$ref": "#/responses/pingResponse" + } + } + } + } + }, + "definitions": { + "Gadget": { + "description": "Deprecated: superseded by the v2 widget API.", + "type": "object", + "title": "Gadget is a deprecated model. OpenAPI 2.0 has no native `deprecated` on a\nschema, so codescan emits `x-deprecated: true` — here triggered by the\ngodoc-style \"Deprecated:\" paragraph, which is recognised on its own without a\nseparate annotation. (The explicit `deprecated: true` annotation, shown on the\noperation above, has the same effect on a model or field.)", + "properties": { + "name": { + "description": "Name is the current display name.", + "type": "string", + "x-go-name": "Name" + }, + "serialNo": { + "description": "SerialNo is the legacy identifier.\n\nDeprecated: use the v2 identifier instead.", + "type": "string", + "x-deprecated": true, + "x-go-name": "SerialNo" + } + }, + "x-deprecated": true, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/decorators" + }, + "Token": { + "type": "object", + "title": "Token is issued by the server.", + "properties": { + "id": { + "description": "ID is assigned by the server and cannot be set by clients.", + "type": "string", + "x-go-name": "ID", + "readOnly": true + }, + "value": { + "description": "Value is the token value.", + "type": "string", + "x-go-name": "Value" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/decorators" + } + }, + "responses": { + "pingResponse": { + "description": "PingResponse is the body returned by ping.", + "schema": { + "type": "object", + "properties": { + "ok": { + "description": "OK is true when healthy.", + "type": "boolean", + "x-go-name": "OK" + } + } + } + } + } +} diff --git a/docs/examples/concepts/examples/examples.go b/docs/examples/concepts/examples/examples.go index f7cf7c5d..5948e6c6 100644 --- a/docs/examples/concepts/examples/examples.go +++ b/docs/examples/concepts/examples/examples.go @@ -149,3 +149,37 @@ type Profile struct { } // endsnippet:complexexample + +// snippet:responseexamplesbymime + +// Pet is the response payload. +// +// swagger:model Pet +type Pet struct { + Name string `json:"name"` +} + +// PetResponse returns a pet, with one example payload per media type. +// +// The plural `examples:` keyword on a struct swagger:response is a YAML map +// keyed by media type, populating the OpenAPI response `examples` object. +// +// swagger:response petResponse +// +// examples: +// +// application/json: +// name: Fluffy +// application/xml: "Fluffy" +type PetResponse struct { + // in: body + Body Pet `json:"body"` +} + +// swagger:route GET /pets pets listPets +// +// responses: +// +// 200: petResponse + +// endsnippet:responseexamplesbymime diff --git a/docs/examples/concepts/examples/examples_test.go b/docs/examples/concepts/examples/examples_test.go index e86615a0..ceddc5c5 100644 --- a/docs/examples/concepts/examples/examples_test.go +++ b/docs/examples/concepts/examples/examples_test.go @@ -79,6 +79,15 @@ func TestExampleFragments(t *testing.T) { goldenJSON(t, doc, "complexexample", "Profile") // structured (object/array) example values goldenJSON(t, doc, "refstructured", "Place") // JSON-object example coerced on a $ref'd field + pet, ok := doc.Responses["petResponse"] + require.True(t, ok, "petResponse response missing") + goldenRaw(t, "responseexamplesbymime", pet) // response examples keyed by media type + + // The struct-based swagger:response carries a per-media-type examples map. + require.NotNil(t, pet.Examples, "petResponse must carry an examples map") + assert.Equal(t, map[string]any{"name": "Fluffy"}, pet.Examples["application/json"]) + assert.Equal(t, "Fluffy", pet.Examples["application/xml"]) + // Type coercion: a numeric default on an int field is a JSON number, a // boolean default a JSON bool — not strings. port := doc.Definitions["Settings"].Properties["port"] @@ -92,4 +101,6 @@ func TestExampleFragments(t *testing.T) { require.Len(t, at.AllOf, 2, "expected a $ref arm and an override arm") assert.IsType(t, map[string]any{}, at.AllOf[1].Example, "object-literal example coerces to a structured value, not a string") + + goldenRaw(t, "full", doc) // whole spec for the tutorial's live "SwaggerUI" tab } diff --git a/docs/examples/concepts/examples/testdata/full.json b/docs/examples/concepts/examples/testdata/full.json new file mode 100644 index 00000000..1fa3eadb --- /dev/null +++ b/docs/examples/concepts/examples/testdata/full.json @@ -0,0 +1,214 @@ +{ + "swagger": "2.0", + "paths": { + "/ntp": { + "get": { + "tags": [ + "ntp" + ], + "operationId": "listNTP", + "responses": { + "200": { + "$ref": "#/responses/ntpServers" + } + } + } + }, + "/pets": { + "get": { + "tags": [ + "pets" + ], + "operationId": "listPets", + "responses": { + "200": { + "$ref": "#/responses/petResponse" + } + } + } + } + }, + "definitions": { + "Coordinates": { + "description": "Coordinates is a defined struct, so a field typed Coordinates renders as a\n$ref.", + "type": "object", + "properties": { + "lat": { + "description": "Lat is the latitude.", + "type": "number", + "format": "double", + "x-go-name": "Lat" + }, + "lng": { + "description": "Lng is the longitude.", + "type": "number", + "format": "double", + "x-go-name": "Lng" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/examples" + }, + "Currency": { + "description": "Currency is a named (defined) string type, so it earns its own definition and\na field typed Currency renders as a $ref. A $ref cannot carry sibling\nkeywords, so an example or default on such a field rides the override arm of\nan allOf compound — the value still reaches the spec.", + "type": "string", + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/examples" + }, + "Greeting": { + "type": "object", + "title": "Greeting carries an example value for documentation.", + "properties": { + "count": { + "description": "Count is how many times to repeat it.", + "type": "integer", + "format": "int32", + "x-go-name": "Count", + "example": 3 + }, + "message": { + "description": "Message is the greeting text.", + "type": "string", + "x-go-name": "Message", + "example": "Hello, world!" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/examples" + }, + "Pet": { + "type": "object", + "title": "Pet is the response payload.", + "properties": { + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/examples" + }, + "Place": { + "description": "Place shows a JSON-object example on a $ref'd field. Because the field is a\n$ref, the example rides the override arm of the allOf — and a JSON object (or\narray) literal is coerced into a structured value there, exactly as it is on a\nplain field. A bare scalar would instead stay a string, since the referenced\ntype is not known on the override arm.", + "type": "object", + "properties": { + "at": { + "description": "At is the location.", + "allOf": [ + { + "$ref": "#/definitions/Coordinates" + }, + { + "example": { + "lat": 48.85, + "lng": 2.35 + } + } + ], + "x-go-name": "At" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/examples" + }, + "Price": { + "type": "object", + "title": "Price shows example + default on a defined-type field.", + "properties": { + "unit": { + "description": "Unit is the ISO currency code.", + "allOf": [ + { + "$ref": "#/definitions/Currency" + }, + { + "default": "USD", + "example": "EUR" + } + ], + "x-go-name": "Unit" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/examples" + }, + "Profile": { + "description": "Profile carries structured (non-scalar) example values. A JSON object literal\non a map field and a JSON array literal on a slice field are parsed into\nstructured examples — a bare comma-separated list would instead be kept\nverbatim as a string.", + "type": "object", + "properties": { + "labels": { + "description": "Labels is a set of key/value labels.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "x-go-name": "Labels", + "example": { + "env": "prod", + "tier": "gold" + } + }, + "roles": { + "description": "Roles is the list of assigned roles.", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "Roles", + "example": [ + "admin", + "auditor" + ] + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/examples" + }, + "Settings": { + "type": "object", + "title": "Settings carries default values applied when a field is omitted.", + "properties": { + "mode": { + "description": "Mode is the run mode.", + "type": "string", + "default": "auto", + "x-go-name": "Mode" + }, + "port": { + "description": "Port is the listen port.", + "type": "integer", + "format": "int32", + "default": 8080, + "x-go-name": "Port" + }, + "verbose": { + "description": "Verbose toggles verbose logging.", + "type": "boolean", + "default": false, + "x-go-name": "Verbose" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/examples" + } + }, + "responses": { + "ntpServers": { + "description": "NTPServers is a top-level array response carrying an example. The example\nlands on the response body schema rather than being dropped.", + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "10.0.0.1", + "10.0.0.2" + ] + } + }, + "petResponse": { + "description": "PetResponse returns a pet, with one example payload per media type.\n\nThe plural `examples:` keyword on a struct swagger:response is a YAML map\nkeyed by media type, populating the OpenAPI response `examples` object.", + "schema": { + "$ref": "#/definitions/Pet" + }, + "examples": { + "application/json": { + "name": "Fluffy" + }, + "application/xml": "\u003cpet\u003e\u003cname\u003eFluffy\u003c/name\u003e\u003c/pet\u003e" + } + } + } +} diff --git a/docs/examples/concepts/examples/testdata/responseexamplesbymime.json b/docs/examples/concepts/examples/testdata/responseexamplesbymime.json new file mode 100644 index 00000000..80d4ef17 --- /dev/null +++ b/docs/examples/concepts/examples/testdata/responseexamplesbymime.json @@ -0,0 +1,12 @@ +{ + "description": "PetResponse returns a pet, with one example payload per media type.\n\nThe plural `examples:` keyword on a struct swagger:response is a YAML map\nkeyed by media type, populating the OpenAPI response `examples` object.", + "schema": { + "$ref": "#/definitions/Pet" + }, + "examples": { + "application/json": { + "name": "Fluffy" + }, + "application/xml": "\u003cpet\u003e\u003cname\u003eFluffy\u003c/name\u003e\u003c/pet\u003e" + } +} diff --git a/docs/examples/concepts/routes/routes_test.go b/docs/examples/concepts/routes/routes_test.go index e76d0211..89ec0f51 100644 --- a/docs/examples/concepts/routes/routes_test.go +++ b/docs/examples/concepts/routes/routes_test.go @@ -104,4 +104,8 @@ func TestRouteFragments(t *testing.T) { goldenJSON(t, "externaldocs_schema", catalog) // externalDocs on a full schema goldenJSON(t, "bodyparam", create.Post.Parameters) // in: body request parameter goldenJSON(t, "paramtype", filter.Get.Parameters) // swagger:type override on a parameter field + + // The whole-spec golden the tutorial's "SwaggerUI" tab renders live (the + // per-pane goldens above are focused fragments of this same document). + goldenJSON(t, "full", doc) } diff --git a/docs/examples/concepts/routes/testdata/full.json b/docs/examples/concepts/routes/testdata/full.json new file mode 100644 index 00000000..7439029b --- /dev/null +++ b/docs/examples/concepts/routes/testdata/full.json @@ -0,0 +1,262 @@ +{ + "swagger": "2.0", + "paths": { + "/pets": { + "get": { + "tags": [ + "pets" + ], + "summary": "Lists pets in the store, optionally filtered by tag.", + "operationId": "listPets", + "parameters": [ + { + "type": "string", + "x-go-name": "Tag", + "description": "Tag filters pets by tag.", + "name": "tag", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "format": "int32", + "x-go-name": "Limit", + "description": "Limit caps the number of results.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/petsResponse" + }, + "default": { + "$ref": "#/responses/errorResponse" + } + } + } + }, + "/pets/filter": { + "get": { + "tags": [ + "pets" + ], + "summary": "Filter pets with cursor pagination.", + "operationId": "filterPets", + "parameters": [ + { + "type": "string", + "x-go-name": "After", + "description": "After is an opaque cursor carried as a plain string query parameter.", + "name": "after", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "Sort", + "description": "Sort is a list of sort keys carried as an array-of-string query parameter.", + "name": "sort", + "in": "query" + } + ], + "responses": { + "200": { + "description": "matched pets" + } + } + } + }, + "/pets/import": { + "post": { + "tags": [ + "pets" + ], + "operationId": "createPet", + "parameters": [ + { + "x-go-name": "Body", + "description": "Body is the pet to create.", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Pet" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/petsResponse" + } + } + } + }, + "/pets/search": { + "get": { + "tags": [ + "pets" + ], + "summary": "Searches pets. The operation links out to external documentation.", + "externalDocs": { + "description": "Search guide", + "url": "https://example.com/docs/search" + }, + "operationId": "searchPets", + "responses": { + "200": { + "$ref": "#/responses/petsResponse" + } + } + } + }, + "/pets/{id}": { + "get": { + "tags": [ + "pets" + ], + "summary": "Get a pet by ID.", + "operationId": "getPet", + "parameters": [ + { + "type": "integer", + "format": "int64", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "the requested pet", + "schema": { + "$ref": "#/definitions/Pet" + } + }, + "default": { + "$ref": "#/responses/errorResponse" + } + } + } + }, + "/pets/{id}/photo": { + "post": { + "tags": [ + "pets" + ], + "operationId": "uploadPetPhoto", + "parameters": [ + { + "type": "file", + "x-go-name": "Photo", + "description": "Photo is the image to upload.", + "name": "photo", + "in": "formData" + } + ], + "responses": { + "200": { + "$ref": "#/responses/petsResponse" + } + } + } + } + }, + "definitions": { + "CatalogEntry": { + "description": "CatalogEntry carries externalDocs at the schema level (the link rides the\ndefinition) and on its fields. (On a simple-schema parameter externalDocs is\ndropped with a diagnostic: it is a full-Schema-only keyword.)", + "type": "object", + "properties": { + "sku": { + "description": "SKU is the catalog identifier.", + "type": "string", + "x-go-name": "SKU" + }, + "supplier": { + "description": "Supplier is a $ref'd field: its sibling externalDocs lifts onto the\nfield's allOf compound (a bare $ref cannot carry sibling keywords).", + "allOf": [ + { + "$ref": "#/definitions/Supplier" + } + ], + "x-go-name": "Supplier", + "externalDocs": { + "description": "Supplier docs", + "url": "https://example.com/docs/supplier" + } + }, + "vendor": { + "description": "Vendor is a plain field: externalDocs attaches directly to the property.", + "type": "string", + "x-go-name": "Vendor", + "externalDocs": { + "description": "Vendor field docs", + "url": "https://example.com/docs/vendor" + } + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/routes", + "externalDocs": { + "description": "Catalog schema reference", + "url": "https://example.com/docs/catalog" + } + }, + "Pet": { + "type": "object", + "title": "Pet is the model the operations below produce and consume.", + "properties": { + "id": { + "description": "ID is the unique identifier.", + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "description": "Name is the pet's display name.", + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/routes" + }, + "Supplier": { + "type": "object", + "title": "Supplier is referenced by CatalogEntry.Supplier.", + "properties": { + "name": { + "description": "Name is the supplier name.", + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/routes" + } + }, + "responses": { + "errorResponse": { + "description": "ErrorResponse is the default error payload.", + "schema": { + "type": "object", + "properties": { + "message": { + "description": "Message is a human-readable error message.", + "type": "string", + "x-go-name": "Message" + } + } + } + }, + "petsResponse": { + "description": "PetsResponse is the list returned by listPets.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Pet" + } + } + } + } +} diff --git a/docs/examples/concepts/security/security_test.go b/docs/examples/concepts/security/security_test.go index 3c42cfb1..2e7fb33b 100644 --- a/docs/examples/concepts/security/security_test.go +++ b/docs/examples/concepts/security/security_test.go @@ -76,6 +76,10 @@ func TestSecurityInCode(t *testing.T) { goldenRaw(t, "route", create.Security) goldenRaw(t, "and", archive.Security) goldenRaw(t, "public", map[string]any{"security": public.Security}) + + // Whole-spec golden the tutorial's "SwaggerUI" tab renders live (the + // fragments above are focused slices of this same document). + goldenRaw(t, "full", doc) } // TestSecurityByOverlay shows the "keep security out of app code" path: the diff --git a/docs/examples/concepts/security/testdata/full.json b/docs/examples/concepts/security/testdata/full.json new file mode 100644 index 00000000..2ac7cbc9 --- /dev/null +++ b/docs/examples/concepts/security/testdata/full.json @@ -0,0 +1,99 @@ +{ + "swagger": "2.0", + "info": { + "description": "The swagger:meta block declares the security schemes once and sets the\ndocument-wide default requirement.", + "title": "Reports API.", + "version": "1.0.0" + }, + "paths": { + "/reports": { + "get": { + "tags": [ + "reports" + ], + "operationId": "listReports", + "responses": { + "200": { + "description": "the reports" + } + } + }, + "post": { + "tags": [ + "reports" + ], + "operationId": "createReport", + "responses": { + "201": { + "description": "created" + } + }, + "security": [ + { + "oauth2": [ + "read", + "write" + ] + } + ] + } + }, + "/reports/archive": { + "post": { + "tags": [ + "reports" + ], + "operationId": "archiveReport", + "responses": { + "200": { + "description": "archived" + } + }, + "security": [ + { + "api_key": [], + "oauth2": [ + "write" + ] + } + ] + } + }, + "/reports/public": { + "get": { + "tags": [ + "reports" + ], + "operationId": "publicReport", + "responses": { + "200": { + "description": "the public reports" + } + }, + "security": [] + } + } + }, + "securityDefinitions": { + "api_key": { + "type": "apiKey", + "name": "X-API-Key", + "in": "header" + }, + "oauth2": { + "type": "oauth2", + "flow": "accessCode", + "authorizationUrl": "https://example.com/auth", + "tokenUrl": "https://example.com/token", + "scopes": { + "read": "read reports", + "write": "write reports" + } + } + }, + "security": [ + { + "api_key": [] + } + ] +} diff --git a/docs/examples/concepts/sharedparams/sharedparams.go b/docs/examples/concepts/sharedparams/sharedparams.go new file mode 100644 index 00000000..d36a2936 --- /dev/null +++ b/docs/examples/concepts/sharedparams/sharedparams.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package sharedparams is the test-backed witness for the "Sharing +// parameters & responses" tutorial (go-swagger#2632). It declares a small +// pet API that reuses a header parameter and an error response across +// several operations through the spec-level shared namespace +// (#/parameters and #/responses). +package sharedparams + +// snippet:shared + +// CommonHeaders registers a reusable header parameter at the spec top +// level, #/parameters/X-Request-ID. The bare `*` target is register-only: +// it publishes the parameter but does not, by itself, attach it to any +// operation. +// +// swagger:parameters * +type CommonHeaders struct { + // RequestID correlates a request across services. + // + // in: header + RequestID string `json:"X-Request-ID"` //nolint:tagliatelle // canonical HTTP header name +} + +// AuthHeader registers #/parameters/X-API-Key and, in the same breath, +// $ref's it into the createPet operation — the convenient `* ` +// form for a small spec. +// +// swagger:parameters * createPet +type AuthHeader struct { + // APIKey authorises access. + // + // in: header + // required: true + APIKey string `json:"X-API-Key"` //nolint:tagliatelle // canonical HTTP header name +} + +// endsnippet:shared + +// snippet:sharedresponse + +// ErrorResponse is the common error envelope returned by every operation. +// +// swagger:response * +type ErrorResponse struct { + // in: body + Body struct { + // Code is a machine-readable error code. + Code int `json:"code"` + // Message is a human-readable error message. + Message string `json:"message"` + } `json:"body"` +} + +// endsnippet:sharedresponse + +// CreatePetParams carries the request body for createPet; it is inlined +// into the operation the normal way and sits alongside the $ref'd +// X-API-Key header. +// +// swagger:parameters createPet +type CreatePetParams struct { + // in: body + // required: true + Body Pet `json:"body"` +} + +// Pet is the body model. +// +// swagger:model +type Pet struct { + // Name of the pet. + Name string `json:"name"` +} + +// snippet:routes + +// ListPets lists pets. +// +// The standalone reference marker on the next line pulls the shared +// X-Request-ID parameter into this operation as a $ref. This is the +// scaling channel: the shared struct need not enumerate every operation +// that wants the parameter. +// +// swagger:route GET /pets pets listPets +// swagger:parameters listPets X-Request-ID +// Responses: +// +// default: ErrorResponse +func ListPets() {} + +// CreatePet creates a pet. Its X-API-Key comes from AuthHeader +// (#/parameters/X-API-Key, $ref'd via `* createPet`); its body comes from +// the inlined CreatePetParams. +// +// swagger:route POST /pets pets createPet +// Responses: +// +// default: ErrorResponse +func CreatePet() {} + +// endsnippet:routes + +// snippet:pathitem + +// TenantHeader inlines a required header into the /pets/{id} path-item +// itself, so every operation under that exact path inherits it. The +// target is a literal path, and matching is exact — OAS2 has no path +// hierarchy, so this does NOT apply to /pets. +// +// swagger:parameters /pets/{id} +type TenantHeader struct { + // Tenant scopes the request to a customer. + // + // in: header + // required: true + Tenant string `json:"X-Tenant"` //nolint:tagliatelle // canonical HTTP header name +} + +// GetPet fetches one pet. It declares no header of its own; X-Tenant +// reaches it through the /pets/{id} path-item parameter above. +// +// swagger:route GET /pets/{id} pets getPet +// Responses: +// +// default: ErrorResponse +func GetPet() {} + +// endsnippet:pathitem diff --git a/docs/examples/concepts/sharedparams/sharedparams_test.go b/docs/examples/concepts/sharedparams/sharedparams_test.go new file mode 100644 index 00000000..cc6fe627 --- /dev/null +++ b/docs/examples/concepts/sharedparams/sharedparams_test.go @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sharedparams + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func examplesRoot(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok) + return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..")) +} + +func scanSharedParams(t *testing.T) *spec.Swagger { + t.Helper() + doc, err := codescan.Run(&codescan.Options{ + WorkDir: examplesRoot(t), + Packages: []string{"./concepts/sharedparams"}, + }) + require.NoError(t, err) + require.NotNil(t, doc) + return doc +} + +// goldenRaw marshals an arbitrary value into testdata/.json, +// honouring UPDATE_GOLDEN. +// +// Regenerate with: UPDATE_GOLDEN=1 go test ./... +func goldenRaw(t *testing.T, feature string, v any) { + t.Helper() + got, err := json.MarshalIndent(v, "", " ") + require.NoError(t, err) + got = append(got, '\n') + + golden := filepath.Join("testdata", feature+".json") + if os.Getenv("UPDATE_GOLDEN") != "" { + require.NoError(t, os.WriteFile(golden, got, 0o600)) + } + want, err := os.ReadFile(golden) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(got)) +} + +// paramRefs collects the #/parameters/{name} $ref targets on a parameter slice. +func paramRefs(params []spec.Parameter) []string { + var refs []string + for i := range params { + if r := params[i].Ref.String(); r != "" { + refs = append(refs, r) + } + } + return refs +} + +// TestSharedParameters emits and verifies the golden fragments the tutorial +// pairs with each source region, and pins the behaviours the prose claims. +func TestSharedParameters(t *testing.T) { + doc := scanSharedParams(t) + require.NotNil(t, doc.Paths) + + // --- The shared namespace (#/parameters, #/responses) ----------------- + goldenRaw(t, "parameters", doc.Parameters) + goldenRaw(t, "responses", doc.Responses) + + // `swagger:parameters *` registers a top-level header parameter; the + // `* createPet` form registers AND is wired below. + reqID, ok := doc.Parameters["X-Request-ID"] + require.True(t, ok, "expected #/parameters/X-Request-ID") + assert.Equal(t, "header", reqID.In) + apiKey, ok := doc.Parameters["X-API-Key"] + require.True(t, ok, "expected #/parameters/X-API-Key") + assert.True(t, apiKey.Required) + // Inline-only params (the createPet body) do not leak into #/parameters. + _, hasBody := doc.Parameters["body"] + assert.False(t, hasBody, "inline operation params must not leak into #/parameters") + + // `swagger:response *` registers a shared response. + _, ok = doc.Responses["ErrorResponse"] + require.True(t, ok, "expected #/responses/ErrorResponse from swagger:response *") + + // --- Operations: both reference channels ------------------------------ + pets, ok := doc.Paths.Paths["/pets"] + require.True(t, ok, "expected a /pets path") + goldenRaw(t, "listpets", pets.Get) + goldenRaw(t, "createpet", pets.Post) + + // listPets: standalone `swagger:parameters listPets X-Request-ID` marker. + require.NotNil(t, pets.Get) + assert.Contains(t, paramRefs(pets.Get.Parameters), "#/parameters/X-Request-ID", + "listPets should $ref the shared X-Request-ID (standalone marker)") + // createPet: `* createPet` convenience form. + require.NotNil(t, pets.Post) + assert.Contains(t, paramRefs(pets.Post.Parameters), "#/parameters/X-API-Key", + "createPet should $ref the shared X-API-Key (via `* createPet`)") + + // Both routes resolve `default: ErrorResponse` to a $ref. + for _, op := range []*spec.Operation{pets.Get, pets.Post} { + require.NotNil(t, op.Responses) + require.NotNil(t, op.Responses.Default) + assert.Equal(t, "#/responses/ErrorResponse", op.Responses.Default.Ref.String()) + } + + // --- Path-item parameters (exact path, no hierarchy) ------------------ + petByID, ok := doc.Paths.Paths["/pets/{id}"] + require.True(t, ok, "expected a /pets/{id} path") + goldenRaw(t, "pathitem", petByID) + + var hasTenant bool + for i := range petByID.Parameters { + if petByID.Parameters[i].Name == "X-Tenant" && petByID.Parameters[i].In == "header" { + hasTenant = true + } + } + assert.True(t, hasTenant, "expected the inline X-Tenant header on the /pets/{id} path-item") + + // Exact path: the /pets/{id} path-item header must NOT leak onto /pets. + for i := range pets.Parameters { + assert.NotEqual(t, "X-Tenant", pets.Parameters[i].Name, + "/pets must not inherit /pets/{id} path-item parameters (no hierarchy)") + } +} diff --git a/docs/examples/concepts/sharedparams/testdata/createpet.json b/docs/examples/concepts/sharedparams/testdata/createpet.json new file mode 100644 index 00000000..a8a6a3a7 --- /dev/null +++ b/docs/examples/concepts/sharedparams/testdata/createpet.json @@ -0,0 +1,25 @@ +{ + "tags": [ + "pets" + ], + "operationId": "createPet", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Pet" + } + }, + { + "$ref": "#/parameters/X-API-Key" + } + ], + "responses": { + "default": { + "$ref": "#/responses/ErrorResponse" + } + } +} diff --git a/docs/examples/concepts/sharedparams/testdata/listpets.json b/docs/examples/concepts/sharedparams/testdata/listpets.json new file mode 100644 index 00000000..a159e3c9 --- /dev/null +++ b/docs/examples/concepts/sharedparams/testdata/listpets.json @@ -0,0 +1,16 @@ +{ + "tags": [ + "pets" + ], + "operationId": "listPets", + "parameters": [ + { + "$ref": "#/parameters/X-Request-ID" + } + ], + "responses": { + "default": { + "$ref": "#/responses/ErrorResponse" + } + } +} diff --git a/docs/examples/concepts/sharedparams/testdata/parameters.json b/docs/examples/concepts/sharedparams/testdata/parameters.json new file mode 100644 index 00000000..b91e673f --- /dev/null +++ b/docs/examples/concepts/sharedparams/testdata/parameters.json @@ -0,0 +1,17 @@ +{ + "X-API-Key": { + "type": "string", + "x-go-name": "APIKey", + "description": "APIKey authorises access.", + "name": "X-API-Key", + "in": "header", + "required": true + }, + "X-Request-ID": { + "type": "string", + "x-go-name": "RequestID", + "description": "RequestID correlates a request across services.", + "name": "X-Request-ID", + "in": "header" + } +} diff --git a/docs/examples/concepts/sharedparams/testdata/pathitem.json b/docs/examples/concepts/sharedparams/testdata/pathitem.json new file mode 100644 index 00000000..4aa419f8 --- /dev/null +++ b/docs/examples/concepts/sharedparams/testdata/pathitem.json @@ -0,0 +1,23 @@ +{ + "get": { + "tags": [ + "pets" + ], + "operationId": "getPet", + "responses": { + "default": { + "$ref": "#/responses/ErrorResponse" + } + } + }, + "parameters": [ + { + "type": "string", + "x-go-name": "Tenant", + "description": "Tenant scopes the request to a customer.", + "name": "X-Tenant", + "in": "header", + "required": true + } + ] +} diff --git a/docs/examples/concepts/sharedparams/testdata/responses.json b/docs/examples/concepts/sharedparams/testdata/responses.json new file mode 100644 index 00000000..a0e8c6eb --- /dev/null +++ b/docs/examples/concepts/sharedparams/testdata/responses.json @@ -0,0 +1,21 @@ +{ + "ErrorResponse": { + "description": "ErrorResponse is the common error envelope returned by every operation.", + "schema": { + "type": "object", + "properties": { + "code": { + "description": "Code is a machine-readable error code.", + "type": "integer", + "format": "int64", + "x-go-name": "Code" + }, + "message": { + "description": "Message is a human-readable error message.", + "type": "string", + "x-go-name": "Message" + } + } + } + } +} diff --git a/docs/examples/concepts/validations/testdata/full.json b/docs/examples/concepts/validations/testdata/full.json new file mode 100644 index 00000000..095e3932 --- /dev/null +++ b/docs/examples/concepts/validations/testdata/full.json @@ -0,0 +1,142 @@ +{ + "swagger": "2.0", + "paths": { + "/products/search": { + "get": { + "tags": [ + "search" + ], + "operationId": "searchProducts", + "parameters": [ + { + "maxLength": 50, + "minLength": 3, + "type": "string", + "x-go-name": "Q", + "description": "Q is the search text.", + "name": "q", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "format": "int32", + "x-go-name": "Limit", + "description": "Limit caps the number of results.", + "name": "limit", + "in": "query" + }, + { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "x-go-name": "Sort", + "description": "Sort lists the sort fields.", + "name": "sort", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/productList" + }, + "429": { + "$ref": "#/responses/rateLimited" + } + } + } + } + }, + "definitions": { + "Attributes": { + "description": "Attributes is a free-form object constrained by the object-validation\nkeywords: it must carry between 1 and 10 properties, and any property whose\nname matches the regex is permitted. Object validations constrain the map of\n(additional) properties rather than named struct fields.", + "type": "object", + "maxProperties": 10, + "minProperties": 1, + "additionalProperties": {}, + "patternProperties": { + "^x-": {} + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/validations" + }, + "Product": { + "type": "object", + "title": "Product is a model whose fields carry the full JSON-schema validation surface.", + "required": [ + "sku" + ], + "properties": { + "grade": { + "description": "Grade is a quality band.", + "type": "string", + "enum": [ + "A", + "B", + "C" + ], + "x-go-name": "Grade" + }, + "name": { + "description": "Name is the display name.", + "type": "string", + "maxLength": 120, + "minLength": 1, + "x-go-name": "Name" + }, + "price": { + "description": "Price is the price in cents.", + "type": "integer", + "format": "int64", + "maximum": 1000000, + "minimum": 1, + "multipleOf": 1, + "x-go-name": "Price" + }, + "sku": { + "description": "SKU is the stock code.", + "type": "string", + "pattern": "^[A-Z]{3}-[0-9]{4}$", + "x-go-name": "SKU" + }, + "tags": { + "description": "Tags label the product.", + "type": "array", + "maxItems": 10, + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + }, + "x-go-name": "Tags" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/validations" + } + }, + "responses": { + "productList": { + "description": "ProductList is the 200 response body for searchProducts.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Product" + } + } + }, + "rateLimited": { + "description": "RateLimited is a response carrying a validated header (a simple schema).", + "headers": { + "X-Rate-Remaining": { + "minimum": 0, + "type": "integer", + "format": "int32", + "description": "XRateRemaining is the remaining request budget." + } + } + } + } +} diff --git a/docs/examples/concepts/validations/validations_test.go b/docs/examples/concepts/validations/validations_test.go index 61681a3c..f67492dd 100644 --- a/docs/examples/concepts/validations/validations_test.go +++ b/docs/examples/concepts/validations/validations_test.go @@ -78,4 +78,6 @@ func TestValidationFragments(t *testing.T) { goldenJSON(t, "param", search.Get.Parameters) // reduced simple-schema surface goldenJSON(t, "header", rate) // validated response header goldenJSON(t, "object", attrs) // object-validation keywords + + goldenJSON(t, "full", doc) // whole spec for the tutorial's live "SwaggerUI" tab } diff --git a/docs/examples/shaping/afterdecl/afterdecl.go b/docs/examples/shaping/afterdecl/afterdecl.go new file mode 100644 index 00000000..df8808dc --- /dev/null +++ b/docs/examples/shaping/afterdecl/afterdecl.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package afterdecl is the test-backed witness for the "Keeping annotations +// out of the godoc" how-to. It shows the AfterDeclComments opt-in: swagger +// annotations live inside a struct body or inlined as a trailing comment, so +// the godoc above each declaration stays clean and human-facing. +package afterdecl + +// snippet:struct + +// Widget is a widget. This godoc stays clean — no swagger machinery here. +type Widget struct { + // Widget is exposed to API consumers. + // + // swagger:model widgetModel + // maxProperties: 5 + + Name string `json:"name"` + + // Created is documented with a clean godoc; the format annotation is + // inlined as a trailing comment. + Created string `json:"created"` // swagger:strfmt date +} + +// endsnippet:struct + +// snippet:aliases + +// Count is a plain count. Clean godoc above; annotation inlined below. +type Count int // swagger:model countType + +// Stamp is a string alias. Clean godoc above; annotation inlined trailing. +type Stamp = string // swagger:model stampType + +// endsnippet:aliases + +// snippet:route + +// ListWidgets is an ordinary Go handler with a clean godoc. +func ListWidgets() { + // swagger:route GET /widgets widgets listWidgets + // + // Lists the widgets. + // + // Responses: + // 200: widgetModel +} + +// endsnippet:route diff --git a/docs/examples/shaping/afterdecl/afterdecl_test.go b/docs/examples/shaping/afterdecl/afterdecl_test.go new file mode 100644 index 00000000..6a65ec83 --- /dev/null +++ b/docs/examples/shaping/afterdecl/afterdecl_test.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 + +package afterdecl + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func examplesRoot(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok) + return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..")) +} + +// scanAfterDecl scans the witness package with the AfterDeclComments opt-in +// either off (the control) or on. +func scanAfterDecl(t *testing.T, on bool) *spec.Swagger { + t.Helper() + doc, err := codescan.Run(&codescan.Options{ + WorkDir: examplesRoot(t), + Packages: []string{"./shaping/afterdecl"}, + ScanModels: true, + AfterDeclComments: on, + }) + require.NoError(t, err) + require.NotNil(t, doc) + return doc +} + +// goldenRaw marshals an arbitrary value into testdata/.json, +// honouring UPDATE_GOLDEN. +// +// Regenerate with: UPDATE_GOLDEN=1 go test ./... +func goldenRaw(t *testing.T, feature string, v any) { + t.Helper() + got, err := json.MarshalIndent(v, "", " ") + require.NoError(t, err) + got = append(got, '\n') + + golden := filepath.Join("testdata", feature+".json") + if os.Getenv("UPDATE_GOLDEN") != "" { + require.NoError(t, os.WriteFile(golden, got, 0o600)) + } + want, err := os.ReadFile(golden) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(got)) +} + +// TestAfterDecl pins the opt-in's effect: off, the inside-body / inlined +// annotations are inert and nothing is discovered; on, the same source yields +// the three definitions, with their keywords applied. +func TestAfterDecl(t *testing.T) { + // Off (control): the godoc above each decl is clean, so nothing is found. + off := scanAfterDecl(t, false) + goldenRaw(t, "off", off.Definitions) + assert.Empty(t, off.Definitions, "with the option off, the annotations are inert") + + // On: the inside-body and inlined trailing annotations are discovered. + on := scanAfterDecl(t, true) + goldenRaw(t, "on", on.Definitions) + + // struct inside-body: swagger:model widgetModel + maxProperties: 5. + widget, ok := on.Definitions["widgetModel"] + require.True(t, ok, "widgetModel definition missing") + require.NotNil(t, widget.MaxProperties) + assert.Equal(t, int64(5), *widget.MaxProperties) + + // field trailing comment: swagger:strfmt date. + created, ok := widget.Properties["created"] + require.True(t, ok, "created property missing") + assert.Equal(t, "date", created.Format) + + // defined type trailing: swagger:model countType → integer. + count, ok := on.Definitions["countType"] + require.True(t, ok, "countType definition missing") + assert.Contains(t, count.Type, "integer") + + // type alias trailing: swagger:model stampType → string. + stamp, ok := on.Definitions["stampType"] + require.True(t, ok, "stampType definition missing") + assert.Contains(t, stamp.Type, "string") + + // The route in the func body is discovered either way (location-agnostic). + require.NotNil(t, on.Paths) + _, ok = on.Paths.Paths["/widgets"] + assert.True(t, ok, "the /widgets route is discovered") +} diff --git a/docs/examples/shaping/afterdecl/testdata/off.json b/docs/examples/shaping/afterdecl/testdata/off.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/docs/examples/shaping/afterdecl/testdata/off.json @@ -0,0 +1 @@ +{} diff --git a/docs/examples/shaping/afterdecl/testdata/on.json b/docs/examples/shaping/afterdecl/testdata/on.json new file mode 100644 index 00000000..6636d087 --- /dev/null +++ b/docs/examples/shaping/afterdecl/testdata/on.json @@ -0,0 +1,35 @@ +{ + "countType": { + "type": "integer", + "format": "int64", + "title": "Count is a plain count. Clean godoc above; annotation inlined below.", + "x-go-name": "Count", + "x-go-package": "github.com/go-openapi/codescan/docs/examples/shaping/afterdecl" + }, + "stampType": { + "type": "string", + "title": "Stamp is a string alias. Clean godoc above; annotation inlined trailing.", + "x-go-name": "Stamp", + "x-go-package": "github.com/go-openapi/codescan/docs/examples/shaping/afterdecl" + }, + "widgetModel": { + "description": "Widget is exposed to API consumers.", + "type": "object", + "title": "Widget is a widget. This godoc stays clean — no swagger machinery here.", + "maxProperties": 5, + "properties": { + "created": { + "description": "Created is documented with a clean godoc; the format annotation is\ninlined as a trailing comment.", + "type": "string", + "format": "date", + "x-go-name": "Created" + }, + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-name": "Widget", + "x-go-package": "github.com/go-openapi/codescan/docs/examples/shaping/afterdecl" + } +} diff --git a/docs/examples/shaping/embedallof/embedallof.go b/docs/examples/shaping/embedallof/embedallof.go new file mode 100644 index 00000000..3157b0da --- /dev/null +++ b/docs/examples/shaping/embedallof/embedallof.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package embedallof is the test-backed witness for the "Composing embeds with +// allOf" how-to. It is scanned with DefaultAllOfForEmbeds off (an embedded +// struct's properties inline into the embedding schema) and on (a plain embed +// becomes an allOf member — a $ref when the embedded type is a model, an inline +// member otherwise — with the embedding struct's own fields in a sibling +// member). A json-named embed and an explicit swagger:allOf embed are +// unaffected by the flag. +package embedallof + +// snippet:base + +// Base is a reusable base model. +// +// swagger:model Base +type Base struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// Mixin is a non-model embedded type (no swagger:model), reachable only through +// embedding. Under the flag it composes as an inline allOf member, since it has +// no definition of its own to $ref. +type Mixin struct { + Note string `json:"note"` +} + +// endsnippet:base + +// snippet:plain + +// PlainEmbed embeds a model and a non-model plainly, plus an own field. +// +// swagger:model PlainEmbed +type PlainEmbed struct { + Base + Mixin + + Color string `json:"color"` +} + +// endsnippet:plain + +// snippet:edges + +// PointerEmbed embeds a model through a pointer; the pointer is peeled and +// takes the same $ref path as a value embed. +// +// swagger:model PointerEmbed +type PointerEmbed struct { + *Base + + Tag string `json:"tag"` +} + +// NamedEmbed embeds Base under an explicit json name, so Go does not promote +// it: it stays a single nested property, identical on or off (go-swagger#2038). +// +// swagger:model NamedEmbed +type NamedEmbed struct { + Base `json:"base"` + + Extra string `json:"extra"` +} + +// TaggedEmbed already composes Base via an explicit swagger:allOf tag, so the +// flag does not change its shape. +// +// swagger:model TaggedEmbed +type TaggedEmbed struct { + // swagger:allOf + Base + + Field string `json:"field"` +} + +// endsnippet:edges diff --git a/docs/examples/shaping/embedallof/embedallof_test.go b/docs/examples/shaping/embedallof/embedallof_test.go new file mode 100644 index 00000000..d0af90ab --- /dev/null +++ b/docs/examples/shaping/embedallof/embedallof_test.go @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 + +package embedallof + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func examplesRoot(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok) + return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..")) +} + +// scanEmbedAllOf scans the witness tree with DefaultAllOfForEmbeds off or on. +func scanEmbedAllOf(t *testing.T, on bool) *spec.Swagger { + t.Helper() + doc, err := codescan.Run(&codescan.Options{ + WorkDir: examplesRoot(t), + Packages: []string{"./shaping/embedallof/..."}, + ScanModels: true, + DefaultAllOfForEmbeds: on, + }) + require.NoError(t, err) + require.NotNil(t, doc) + return doc +} + +// goldenRaw marshals an arbitrary value into testdata/.json, +// honouring UPDATE_GOLDEN. +// +// Regenerate with: UPDATE_GOLDEN=1 go test ./... +func goldenRaw(t *testing.T, feature string, v any) { + t.Helper() + got, err := json.MarshalIndent(v, "", " ") + require.NoError(t, err) + got = append(got, '\n') + + golden := filepath.Join("testdata", feature+".json") + if os.Getenv("UPDATE_GOLDEN") != "" { + require.NoError(t, os.WriteFile(golden, got, 0o600)) + } + want, err := os.ReadFile(golden) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(got)) +} + +// allOfHasRef reports whether any allOf member of sch is a $ref to ref. +func allOfHasRef(sch spec.Schema, ref string) bool { + for _, m := range sch.AllOf { + if m.Ref.String() == ref { + return true + } + } + return false +} + +// allOfHasProp reports whether any allOf member of sch carries property name. +func allOfHasProp(sch spec.Schema, name string) bool { + for _, m := range sch.AllOf { + if _, ok := m.Properties[name]; ok { + return true + } + } + return false +} + +// TestEmbedAllOf_Off is the control: with the flag off a plain embed inlines +// the embedded type's properties (byte-identical to the historic behaviour). +func TestEmbedAllOf_Off(t *testing.T) { + doc := scanEmbedAllOf(t, false) + pe, ok := doc.Definitions["PlainEmbed"] + require.True(t, ok, "PlainEmbed definition missing") + goldenRaw(t, "plainembed_off", pe) + + // Inlined flat: every embedded + own field lands as a top-level property. + assert.Empty(t, pe.AllOf, "plain embed must not compose when off") + for _, p := range []string{"id", "name", "note", "color"} { + _, has := pe.Properties[p] + assert.True(t, has, "field %q inlined when off", p) + } +} + +// TestEmbedAllOf_On exercises the feature: a plain model embed becomes an allOf +// $ref member, a plain non-model embed an inline allOf member, and the embedding +// struct's own fields a sibling allOf member. A json-named embed and an explicit +// swagger:allOf embed are unaffected. +func TestEmbedAllOf_On(t *testing.T) { + doc := scanEmbedAllOf(t, true) + pe, ok := doc.Definitions["PlainEmbed"] + require.True(t, ok, "PlainEmbed definition missing") + goldenRaw(t, "plainembed_on", pe) + + // PlainEmbed composes; no embedded/own field is inlined at the top level. + require.NotEmpty(t, pe.AllOf, "plain embed must compose into allOf when on") + _, hasID := pe.Properties["id"] + assert.False(t, hasID, "model embed must not inline when composing") + assert.True(t, allOfHasRef(pe, "#/definitions/Base"), "model embed → $ref allOf member") + assert.True(t, allOfHasProp(pe, "note"), "non-model embed → inline allOf member") + assert.True(t, allOfHasProp(pe, "color"), "own field → sibling allOf member") + + // PointerEmbed: *Base is peeled to the same $ref composition. + ptr := doc.Definitions["PointerEmbed"] + require.NotEmpty(t, ptr.AllOf) + assert.True(t, allOfHasRef(ptr, "#/definitions/Base"), "pointer embed peels to a $ref member") + assert.True(t, allOfHasProp(ptr, "tag"), "own field in sibling member") + + // NamedEmbed: a json-named embed stays a single nested property, unaffected. + named := doc.Definitions["NamedEmbed"] + assert.Empty(t, named.AllOf, "a json-named embed must not compose") + _, hasBase := named.Properties["base"] + assert.True(t, hasBase, "named embed nests under its json name") + + // TaggedEmbed: an explicit swagger:allOf embed is already composition. + tagged := doc.Definitions["TaggedEmbed"] + assert.True(t, allOfHasRef(tagged, "#/definitions/Base"), "swagger:allOf embed already composes") +} diff --git a/docs/examples/shaping/embedallof/testdata/plainembed_off.json b/docs/examples/shaping/embedallof/testdata/plainembed_off.json new file mode 100644 index 00000000..9788d844 --- /dev/null +++ b/docs/examples/shaping/embedallof/testdata/plainembed_off.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "title": "PlainEmbed embeds a model and a non-model plainly, plus an own field.", + "properties": { + "color": { + "type": "string", + "x-go-name": "Color" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "type": "string", + "x-go-name": "Name" + }, + "note": { + "type": "string", + "x-go-name": "Note" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/shaping/embedallof" +} diff --git a/docs/examples/shaping/embedallof/testdata/plainembed_on.json b/docs/examples/shaping/embedallof/testdata/plainembed_on.json new file mode 100644 index 00000000..0c8dc6c9 --- /dev/null +++ b/docs/examples/shaping/embedallof/testdata/plainembed_on.json @@ -0,0 +1,27 @@ +{ + "title": "PlainEmbed embeds a model and a non-model plainly, plus an own field.", + "allOf": [ + { + "$ref": "#/definitions/Base" + }, + { + "type": "object", + "properties": { + "note": { + "type": "string", + "x-go-name": "Note" + } + } + }, + { + "type": "object", + "properties": { + "color": { + "type": "string", + "x-go-name": "Color" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/docs/examples/shaping/embedallof" +} diff --git a/docs/examples/shaping/godoclinks/godoclinks.go b/docs/examples/shaping/godoclinks/godoclinks.go new file mode 100644 index 00000000..f694e5df --- /dev/null +++ b/docs/examples/shaping/godoclinks/godoclinks.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package godoclinks is the test-backed witness for the "Cleaning godoc +// doc-links" how-to. It is scanned with CleanGoDoc off (godoc emitted +// verbatim) and on (doc-links recomposed to each schema's exposed name, +// unresolved links humanized, reference-definition lines dropped, ordinary +// brackets left intact). +package godoclinks + +import "github.com/go-openapi/codescan/docs/examples/shaping/godoclinks/inventory" + +// snippet:widget + +// Widget is the primary [Gadget] holder and references [Order.CustName]. +// +// More detail mentions a [*Gadget] pointer, a [inventory.Ledger], and an +// unknown [Sprocket]. +// +// swagger:model gizmo +type Widget struct { + // Holder points at the [Gadget] that owns this widget. + Holder string `json:"holder"` + + // Ledger is the cross-package [inventory.Ledger] reference. + Ledger *inventory.Ledger `json:"ledger"` + + // Index is element [0] in the [see notes] list; the [id] stays bare. + Index int `json:"index"` + + // Spec points at [Gadget]; the reference-definition line below is godoc + // link plumbing that carries no prose. + // + // [the spec]: https://example.com/spec + Spec string `json:"spec"` +} + +// endsnippet:widget + +// Gadget is a small component. +// +// swagger:model +type Gadget struct { + Name string `json:"name"` +} + +// Order captures a purchase. +// +// swagger:model +type Order struct { + // CustName is the buyer. + CustName string `json:"customer_name"` //nolint:tagliatelle // snake_case demonstrates exposed-name recomposition +} diff --git a/docs/examples/shaping/godoclinks/godoclinks_test.go b/docs/examples/shaping/godoclinks/godoclinks_test.go new file mode 100644 index 00000000..3317a474 --- /dev/null +++ b/docs/examples/shaping/godoclinks/godoclinks_test.go @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 + +package godoclinks + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func examplesRoot(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok) + return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..")) +} + +// scanGodocLinks scans the witness tree (the package + its inventory +// subpackage) with CleanGoDoc off or on. +func scanGodocLinks(t *testing.T, on bool) *spec.Swagger { + t.Helper() + doc, err := codescan.Run(&codescan.Options{ + WorkDir: examplesRoot(t), + Packages: []string{"./shaping/godoclinks/..."}, + ScanModels: true, + CleanGoDoc: on, + }) + require.NoError(t, err) + require.NotNil(t, doc) + return doc +} + +// goldenRaw marshals an arbitrary value into testdata/.json, +// honouring UPDATE_GOLDEN. +// +// Regenerate with: UPDATE_GOLDEN=1 go test ./... +func goldenRaw(t *testing.T, feature string, v any) { + t.Helper() + got, err := json.MarshalIndent(v, "", " ") + require.NoError(t, err) + got = append(got, '\n') + + golden := filepath.Join("testdata", feature+".json") + if os.Getenv("UPDATE_GOLDEN") != "" { + require.NoError(t, os.WriteFile(golden, got, 0o600)) + } + want, err := os.ReadFile(golden) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(got)) +} + +// TestGodocLinks_Off is the control: with CleanGoDoc off the godoc doc-link +// syntax is carried into the spec verbatim (byte-identical to the historic +// behaviour). +func TestGodocLinks_Off(t *testing.T) { + doc := scanGodocLinks(t, false) + gizmo, ok := doc.Definitions["gizmo"] + require.True(t, ok, "gizmo definition missing") + goldenRaw(t, "gizmo_off", gizmo) + + // Brackets and the reference-definition line are preserved when off. + assert.Contains(t, gizmo.Title, "[Gadget]") + assert.Contains(t, gizmo.Title, "[Order.CustName]") + assert.Contains(t, gizmo.Properties["spec"].Description, "[the spec]: https://example.com/spec") +} + +// TestGodocLinks_On exercises the full feature: resolvable doc-links are +// recomposed to the referenced schema's EXPOSED name, unresolved links are +// humanized, reference-definition lines are dropped, and non-doc-link brackets +// are left intact. +func TestGodocLinks_On(t *testing.T) { + doc := scanGodocLinks(t, true) + gizmo, ok := doc.Definitions["gizmo"] + require.True(t, ok, "gizmo definition missing") + goldenRaw(t, "gizmo_on", gizmo) + + // Leading self-name recomposed to the exposed model name (swagger:model + // gizmo), restored to sentence case: "Widget …" → "Gizmo …". + assert.Contains(t, gizmo.Title, "Gizmo is the primary") + assert.NotContains(t, gizmo.Title, "Widget") + // Doc-link to another model → its exposed name (no override → Go name). + assert.Contains(t, gizmo.Title, "Gadget") + assert.NotContains(t, gizmo.Title, "[Gadget]") + // type.field doc-link → exposed model name + exposed (json) property name. + assert.Contains(t, gizmo.Title, "Order.customer_name") + assert.NotContains(t, gizmo.Title, "[Order.CustName]") + + // Description: pointer link resolved; unknown link humanized; cross-package + // link resolved via imports. + assert.Contains(t, gizmo.Description, "Gadget pointer") + assert.Contains(t, gizmo.Description, "unknown sprocket") + assert.Contains(t, gizmo.Description, "Ledger") + assert.NotContains(t, gizmo.Description, "[") + _, ok = doc.Definitions["Ledger"] + assert.True(t, ok, "cross-package Ledger definition missing") + + // Field doc-link recomposed. + assert.Contains(t, gizmo.Properties["holder"].Description, "Gadget that owns") + assert.NotContains(t, gizmo.Properties["holder"].Description, "[Gadget]") + + // Reference-definition line dropped on the spec field; the [Gadget] link + // there recomposes to its exposed name. + spec := gizmo.Properties["spec"].Description + assert.Contains(t, spec, "Spec points at Gadget") + assert.NotContains(t, spec, "https://example.com/spec") + + // Conservative recognizer: ordinary (non-doc-link) brackets are left intact. + idx := gizmo.Properties["index"].Description + assert.Contains(t, idx, "[0]") + assert.Contains(t, idx, "[see notes]") + assert.Contains(t, idx, "[id]") +} diff --git a/docs/examples/shaping/godoclinks/inventory/ledger.go b/docs/examples/shaping/godoclinks/inventory/ledger.go new file mode 100644 index 00000000..213e0cd3 --- /dev/null +++ b/docs/examples/shaping/godoclinks/inventory/ledger.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package inventory provides a model referenced cross-package by a doc-link. +package inventory + +// Ledger records stock movements. +// +// swagger:model +type Ledger struct { + Entries int `json:"entries"` +} diff --git a/docs/examples/shaping/godoclinks/testdata/gizmo_off.json b/docs/examples/shaping/godoclinks/testdata/gizmo_off.json new file mode 100644 index 00000000..95b1ea09 --- /dev/null +++ b/docs/examples/shaping/godoclinks/testdata/gizmo_off.json @@ -0,0 +1,28 @@ +{ + "description": "More detail mentions a [*Gadget] pointer, a [inventory.Ledger], and an\nunknown [Sprocket].", + "type": "object", + "title": "Widget is the primary [Gadget] holder and references [Order.CustName].", + "properties": { + "holder": { + "description": "Holder points at the [Gadget] that owns this widget.", + "type": "string", + "x-go-name": "Holder" + }, + "index": { + "description": "Index is element [0] in the [see notes] list; the [id] stays bare.", + "type": "integer", + "format": "int64", + "x-go-name": "Index" + }, + "ledger": { + "$ref": "#/definitions/Ledger" + }, + "spec": { + "description": "Spec points at [Gadget]; the reference-definition line below is godoc\nlink plumbing that carries no prose.\n\n[the spec]: https://example.com/spec", + "type": "string", + "x-go-name": "Spec" + } + }, + "x-go-name": "Widget", + "x-go-package": "github.com/go-openapi/codescan/docs/examples/shaping/godoclinks" +} diff --git a/docs/examples/shaping/godoclinks/testdata/gizmo_on.json b/docs/examples/shaping/godoclinks/testdata/gizmo_on.json new file mode 100644 index 00000000..2fd95e2d --- /dev/null +++ b/docs/examples/shaping/godoclinks/testdata/gizmo_on.json @@ -0,0 +1,28 @@ +{ + "description": "More detail mentions a Gadget pointer, a Ledger, and an\nunknown sprocket.", + "type": "object", + "title": "Gizmo is the primary Gadget holder and references Order.customer_name.", + "properties": { + "holder": { + "description": "Holder points at the Gadget that owns this widget.", + "type": "string", + "x-go-name": "Holder" + }, + "index": { + "description": "Index is element [0] in the [see notes] list; the [id] stays bare.", + "type": "integer", + "format": "int64", + "x-go-name": "Index" + }, + "ledger": { + "$ref": "#/definitions/Ledger" + }, + "spec": { + "description": "Spec points at Gadget; the reference-definition line below is godoc\nlink plumbing that carries no prose.", + "type": "string", + "x-go-name": "Spec" + } + }, + "x-go-name": "Widget", + "x-go-package": "github.com/go-openapi/codescan/docs/examples/shaping/godoclinks" +} diff --git a/docs/examples/shaping/interfacenames/interfacenames.go b/docs/examples/shaping/interfacenames/interfacenames.go new file mode 100644 index 00000000..a4c7a9e1 --- /dev/null +++ b/docs/examples/shaping/interfacenames/interfacenames.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package interfacenames is the test-backed witness for the "Interface-method +// property names" how-to. An interface method has no natural JSON +// serialization, so codescan invents a property name by running the +// swag/mangling transform on the Go method name (ID → id, CreatedAt → +// createdAt). It is scanned with SkipJSONifyInterfaceMethods off (the mangler +// runs) and on (the Go method name is emitted verbatim). A swagger:name +// override is taken verbatim either way. +package interfacenames + +// snippet:account + +// Account is a read model whose shape is described by interface methods. +// +// swagger:model Account +type Account interface { + // ID is emitted as "id" by default; verbatim "ID" with the opt-out set. + ID() string + + // CreatedAt is emitted as "createdAt" by default; verbatim "CreatedAt" with + // the opt-out set. + CreatedAt() string + + // swagger:name explicit_name + // + // A swagger:name override is taken verbatim either way — re-mangling would + // camelCase it to "explicitName". + OverriddenField() string +} + +// endsnippet:account diff --git a/docs/examples/shaping/interfacenames/interfacenames_test.go b/docs/examples/shaping/interfacenames/interfacenames_test.go new file mode 100644 index 00000000..b4cc0350 --- /dev/null +++ b/docs/examples/shaping/interfacenames/interfacenames_test.go @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 + +package interfacenames + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func examplesRoot(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok) + return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..")) +} + +// scanInterfaceNames scans the witness with SkipJSONifyInterfaceMethods off or on. +func scanInterfaceNames(t *testing.T, skip bool) *spec.Swagger { + t.Helper() + doc, err := codescan.Run(&codescan.Options{ + WorkDir: examplesRoot(t), + Packages: []string{"./shaping/interfacenames/..."}, + ScanModels: true, + SkipJSONifyInterfaceMethods: skip, + }) + require.NoError(t, err) + require.NotNil(t, doc) + return doc +} + +// goldenRaw marshals an arbitrary value into testdata/.json, +// honouring UPDATE_GOLDEN. +// +// Regenerate with: UPDATE_GOLDEN=1 go test ./... +func goldenRaw(t *testing.T, feature string, v any) { + t.Helper() + got, err := json.MarshalIndent(v, "", " ") + require.NoError(t, err) + got = append(got, '\n') + + golden := filepath.Join("testdata", feature+".json") + if os.Getenv("UPDATE_GOLDEN") != "" { + require.NoError(t, os.WriteFile(golden, got, 0o600)) + } + want, err := os.ReadFile(golden) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(got)) +} + +// TestInterfaceNames_Off is the control: with the flag off interface-method +// names auto-jsonify (ID → id), matching the historic behaviour. +func TestInterfaceNames_Off(t *testing.T) { + doc := scanInterfaceNames(t, false) + acct, ok := doc.Definitions["Account"] + require.True(t, ok, "Account definition missing") + goldenRaw(t, "account_off", acct) + + props := acct.Properties + for _, name := range []string{"id", "createdAt"} { + _, has := props[name] + assert.True(t, has, "default path must auto-jsonify to %q when off", name) + } + for _, name := range []string{"ID", "CreatedAt"} { + _, has := props[name] + assert.False(t, has, "verbatim Go name %q must not appear when off", name) + } + _, has := props["explicit_name"] + assert.True(t, has, "swagger:name override must stay verbatim") +} + +// TestInterfaceNames_On exercises SkipJSONifyInterfaceMethods: default-path +// methods are emitted verbatim while the swagger:name override is unchanged. +func TestInterfaceNames_On(t *testing.T) { + doc := scanInterfaceNames(t, true) + acct, ok := doc.Definitions["Account"] + require.True(t, ok, "Account definition missing") + goldenRaw(t, "account_on", acct) + + props := acct.Properties + for _, name := range []string{"ID", "CreatedAt"} { + _, has := props[name] + assert.True(t, has, "opt-out must emit the Go name %q verbatim", name) + } + for _, name := range []string{"id", "createdAt"} { + _, has := props[name] + assert.False(t, has, "mangled spelling %q must not appear when opted out", name) + } + _, has := props["explicit_name"] + assert.True(t, has, "swagger:name override must stay verbatim") + _, remangled := props["explicitName"] + assert.False(t, remangled, "the override must not be re-mangled") +} diff --git a/docs/examples/shaping/interfacenames/testdata/account_off.json b/docs/examples/shaping/interfacenames/testdata/account_off.json new file mode 100644 index 00000000..9fe93d30 --- /dev/null +++ b/docs/examples/shaping/interfacenames/testdata/account_off.json @@ -0,0 +1,22 @@ +{ + "type": "object", + "title": "Account is a read model whose shape is described by interface methods.", + "properties": { + "createdAt": { + "description": "CreatedAt is emitted as \"createdAt\" by default; verbatim \"CreatedAt\" with\nthe opt-out set.", + "type": "string", + "x-go-name": "CreatedAt" + }, + "explicit_name": { + "description": "\nA swagger:name override is taken verbatim either way — re-mangling would\ncamelCase it to \"explicitName\".", + "type": "string", + "x-go-name": "OverriddenField" + }, + "id": { + "description": "ID is emitted as \"id\" by default; verbatim \"ID\" with the opt-out set.", + "type": "string", + "x-go-name": "ID" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/shaping/interfacenames" +} diff --git a/docs/examples/shaping/interfacenames/testdata/account_on.json b/docs/examples/shaping/interfacenames/testdata/account_on.json new file mode 100644 index 00000000..1e5e3c9b --- /dev/null +++ b/docs/examples/shaping/interfacenames/testdata/account_on.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "title": "Account is a read model whose shape is described by interface methods.", + "properties": { + "CreatedAt": { + "description": "CreatedAt is emitted as \"createdAt\" by default; verbatim \"CreatedAt\" with\nthe opt-out set.", + "type": "string" + }, + "ID": { + "description": "ID is emitted as \"id\" by default; verbatim \"ID\" with the opt-out set.", + "type": "string" + }, + "explicit_name": { + "description": "\nA swagger:name override is taken verbatim either way — re-mangling would\ncamelCase it to \"explicitName\".", + "type": "string", + "x-go-name": "OverriddenField" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/shaping/interfacenames" +} diff --git a/docs/examples/shaping/markdowndesc/markdowndesc.go b/docs/examples/shaping/markdowndesc/markdowndesc.go new file mode 100644 index 00000000..ec8054f5 --- /dev/null +++ b/docs/examples/shaping/markdowndesc/markdowndesc.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package markdowndesc is the test-backed witness for the "Markdown +// descriptions" how-to. It contrasts an ordinary description annotation (folded +// with the Option B rule, which stops at the first blank line) with the +// swagger:description | literal block-scalar marker, which captures the body +// verbatim — blank lines, indentation and table pipes preserved — until the +// next annotation or end of comment. Reframes go-swagger#3211. +package markdowndesc + +// snippet:plain + +// Plain uses an ordinary description annotation. +// +// swagger:description +// Option B folds prose up to the first blank line, so only this sentence +// survives — the markdown table below never reaches the spec. +// +// | name | purpose | +// |------|---------| +// | foo | bars | +// +// swagger:model Plain +type Plain struct { + Name string `json:"name"` +} + +// endsnippet:plain + +// snippet:markdown + +// Markdown opts into a verbatim body with the literal block marker. +// +// swagger:description | +// The body is captured **verbatim** — pipes, blank lines and all: +// +// | name | purpose | +// |------|---------| +// | foo | bars | +// +// - point one +// - point two +// +// swagger:model Markdown +type Markdown struct { + // Name of the widget. + // + // swagger:description | + // The name must be: + // + // 1. unique + // 2. lowercase + Name string `json:"name"` +} + +// endsnippet:markdown diff --git a/docs/examples/shaping/markdowndesc/markdowndesc_test.go b/docs/examples/shaping/markdowndesc/markdowndesc_test.go new file mode 100644 index 00000000..5e4b78c8 --- /dev/null +++ b/docs/examples/shaping/markdowndesc/markdowndesc_test.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 + +package markdowndesc + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func examplesRoot(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok) + return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..")) +} + +// scanMarkdownDesc scans the witness tree (no option needed — the literal block +// marker is annotation syntax, opt-in per annotation). +func scanMarkdownDesc(t *testing.T) *spec.Swagger { + t.Helper() + doc, err := codescan.Run(&codescan.Options{ + WorkDir: examplesRoot(t), + Packages: []string{"./shaping/markdowndesc/..."}, + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + return doc +} + +// goldenRaw marshals an arbitrary value into testdata/.json, +// honouring UPDATE_GOLDEN. +// +// Regenerate with: UPDATE_GOLDEN=1 go test ./... +func goldenRaw(t *testing.T, feature string, v any) { + t.Helper() + got, err := json.MarshalIndent(v, "", " ") + require.NoError(t, err) + got = append(got, '\n') + + golden := filepath.Join("testdata", feature+".json") + if os.Getenv("UPDATE_GOLDEN") != "" { + require.NoError(t, os.WriteFile(golden, got, 0o600)) + } + want, err := os.ReadFile(golden) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(got)) +} + +// TestMarkdownDesc contrasts the two forms end to end: Option B drops the table +// (truncates at the first blank line), while swagger:description | carries the +// whole markdown body — table pipes, blank lines, bullets and indentation — +// verbatim, with the | marker never leaking. +func TestMarkdownDesc(t *testing.T) { + doc := scanMarkdownDesc(t) + + plain, ok := doc.Definitions["Plain"] + require.True(t, ok, "Plain definition missing") + goldenRaw(t, "plain", plain) + + md, ok := doc.Definitions["Markdown"] + require.True(t, ok, "Markdown definition missing") + goldenRaw(t, "markdown", md) + + // Plain: Option B folds up to the first blank line — the table is dropped. + assert.Contains(t, plain.Description, "only this sentence") + assert.NotContains(t, plain.Description, "| name | purpose |", "Option B drops the post-blank-line table") + + // Markdown: the body is captured verbatim. + assert.Contains(t, md.Description, "**verbatim**") + assert.Contains(t, md.Description, "| name | purpose |", "table leading pipes survive") + assert.Contains(t, md.Description, "\n\n", "significant blank line preserved") + assert.Contains(t, md.Description, "- point one", "bullets after a blank line are kept") + assert.NotContains(t, md.Description, "swagger:description", "the marker line never leaks") + assert.False(t, strings.HasPrefix(md.Description, "|"), "the | marker must not leak into the body") + + // Title still comes from the godoc preamble, not the literal body. + assert.Equal(t, "Markdown opts into a verbatim body with the literal block marker.", md.Title) + + // Field description keeps the indentation of the ordered list. + name := md.Properties["name"] + assert.Contains(t, name.Description, " 1. unique") + assert.Contains(t, name.Description, " 2. lowercase") +} diff --git a/docs/examples/shaping/markdowndesc/testdata/markdown.json b/docs/examples/shaping/markdowndesc/testdata/markdown.json new file mode 100644 index 00000000..42ac83c9 --- /dev/null +++ b/docs/examples/shaping/markdowndesc/testdata/markdown.json @@ -0,0 +1,13 @@ +{ + "description": "The body is captured **verbatim** — pipes, blank lines and all:\n\n| name | purpose |\n|------|---------|\n| foo | bars |\n\n- point one\n- point two", + "type": "object", + "title": "Markdown opts into a verbatim body with the literal block marker.", + "properties": { + "name": { + "description": "The name must be:\n\n 1. unique\n 2. lowercase", + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/shaping/markdowndesc" +} diff --git a/docs/examples/shaping/markdowndesc/testdata/plain.json b/docs/examples/shaping/markdowndesc/testdata/plain.json new file mode 100644 index 00000000..3d18d625 --- /dev/null +++ b/docs/examples/shaping/markdowndesc/testdata/plain.json @@ -0,0 +1,12 @@ +{ + "description": "Option B folds prose up to the first blank line, so only this sentence\nsurvives — the markdown table below never reaches the spec.", + "type": "object", + "title": "Plain uses an ordinary description annotation.", + "properties": { + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/shaping/markdowndesc" +} diff --git a/docs/examples/shaping/naming-from-tags/naming.go b/docs/examples/shaping/naming-from-tags/naming.go new file mode 100644 index 00000000..f7603cfb --- /dev/null +++ b/docs/examples/shaping/naming-from-tags/naming.go @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package namingfromtags holds the annotated declaration used by the +// "Naming from struct tags" how-to. naming_test.go scans it twice — with +// the default NameFromTags (["json"]) and with ["form","json"] — and writes +// the before/after golden fragments the guide renders. +package namingfromtags + +// snippet:model + +// Filter is a query model whose fields carry both json: and form: tags. +// +// swagger:model +type Filter struct { + // SortKey selects the sort column. + SortKey string `json:"sortKey" form:"sort_key"` + + // PageSize bounds the page length. + PageSize int `json:"pageSize" form:"page_size"` +} + +// endsnippet:model diff --git a/docs/examples/shaping/naming-from-tags/naming_test.go b/docs/examples/shaping/naming-from-tags/naming_test.go new file mode 100644 index 00000000..89a8db53 --- /dev/null +++ b/docs/examples/shaping/naming-from-tags/naming_test.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 + +package namingfromtags + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func examplesRoot(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok) + return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..")) +} + +func scan(t *testing.T, nameTags []string) *spec.Swagger { + t.Helper() + doc, err := codescan.Run(&codescan.Options{ + WorkDir: examplesRoot(t), + Packages: []string{"./shaping/naming-from-tags"}, + ScanModels: true, + NameFromTags: nameTags, + }) + require.NoError(t, err) + require.NotNil(t, doc) + return doc +} + +// goldenDef marshals the Filter definition and compares it to (or, under +// UPDATE_GOLDEN, rewrites) testdata/.json. +// +// Regenerate with: UPDATE_GOLDEN=1 go test ./... +func goldenDef(t *testing.T, doc *spec.Swagger, feature string) { + t.Helper() + schema, ok := doc.Definitions["Filter"] + require.True(t, ok, "Filter definition missing") + got, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + got = append(got, '\n') + + golden := filepath.Join("testdata", feature+".json") + if os.Getenv("UPDATE_GOLDEN") != "" { + require.NoError(t, os.WriteFile(golden, got, 0o600)) + } + want, err := os.ReadFile(golden) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(got)) +} + +// TestNamingFromTags emits the before/after fragments and asserts the toggle: +// property names come from json by default, and from form when NameFromTags +// lists it first. +func TestNamingFromTags(t *testing.T) { + def := scan(t, nil) // default → ["json"] + form := scan(t, []string{"form", "json"}) // prefer form, fall back to json + + goldenDef(t, def, "default") + goldenDef(t, form, "form") + + _, hasJSON := def.Definitions["Filter"].Properties["sortKey"] + assert.True(t, hasJSON, "default: name comes from the json tag") + + _, hasForm := form.Definitions["Filter"].Properties["sort_key"] + assert.True(t, hasForm, "form-first: name comes from the form tag") +} diff --git a/docs/examples/shaping/naming-from-tags/testdata/default.json b/docs/examples/shaping/naming-from-tags/testdata/default.json new file mode 100644 index 00000000..743b9f26 --- /dev/null +++ b/docs/examples/shaping/naming-from-tags/testdata/default.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "title": "Filter is a query model whose fields carry both json: and form: tags.", + "properties": { + "pageSize": { + "description": "PageSize bounds the page length.", + "type": "integer", + "format": "int64", + "x-go-name": "PageSize" + }, + "sortKey": { + "description": "SortKey selects the sort column.", + "type": "string", + "x-go-name": "SortKey" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/shaping/naming-from-tags" +} diff --git a/docs/examples/shaping/naming-from-tags/testdata/form.json b/docs/examples/shaping/naming-from-tags/testdata/form.json new file mode 100644 index 00000000..dd52e900 --- /dev/null +++ b/docs/examples/shaping/naming-from-tags/testdata/form.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "title": "Filter is a query model whose fields carry both json: and form: tags.", + "properties": { + "page_size": { + "description": "PageSize bounds the page length.", + "type": "integer", + "format": "int64", + "x-go-name": "PageSize" + }, + "sort_key": { + "description": "SortKey selects the sort column.", + "type": "string", + "x-go-name": "SortKey" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/shaping/naming-from-tags" +} diff --git a/docs/examples/shaping/overrides/overrides.go b/docs/examples/shaping/overrides/overrides.go new file mode 100644 index 00000000..57c6f4be --- /dev/null +++ b/docs/examples/shaping/overrides/overrides.go @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package overrides is the test-backed witness for the "Overriding titles & +// descriptions" how-to. It shows swagger:title / swagger:description replacing +// the godoc-derived text so the Go-facing comment can diverge from the +// API-facing spec, across models, fields, $ref'd fields, and responses. +package overrides + +// snippet:model + +// Widget is the Go-facing widget doc, written for Go readers. +// +// It explains internal Go usage that should not leak into the API spec. +// +// swagger:model +// swagger:title A Public Widget +// swagger:description A widget exposed via the public API. +type Widget struct { + // ID explains the Go field for Go readers. + // + // swagger:description The unique widget identifier. + ID string `json:"id"` + + // Label is the Go-facing field doc. Fields carry no title by default; + // the override is the only way a property gets one. + // + // swagger:title Display Label + // swagger:description Human-readable label shown to API consumers. + Label string `json:"label"` + + // Plain keeps its godoc description because it carries no override. + Plain string `json:"plain"` + + // Capacity combines a description override with an inline validation + // keyword on the same field: the override applies AND maximum is kept, + // because the override annotations dispatch through the schema family. + // + // swagger:description The maximum capacity, in liters. + // maximum: 1000 + Capacity int64 `json:"capacity"` + + // Suppressed has a godoc that a bare swagger:description suppresses: the + // empty value is applied (description omitted) and scan.empty-override is + // raised, in case the bare marker was left behind by mistake. + // + // swagger:description + Suppressed string `json:"suppressed"` + + // Notes carries a multi-line description override: the lines following the + // annotation fold into the description until the blank line, joined with + // newlines. + // + // swagger:description Free-form notes about the widget. + // They may span several lines, all folded into one description. + // + // The blank line above terminates the override body; this paragraph is + // ordinary godoc and is discarded (the override won). + Notes string `json:"notes"` + + // Gadget is a $ref field carrying title + description overrides. They are + // symmetric $ref siblings: kept under EmitRefSiblings, dropped to a bare + // $ref under the default flags — the same rule a prose description follows. + // + // swagger:title Gadget Ref + // swagger:description The attached gadget, described for API consumers. + Gadget Gadget `json:"gadget"` +} + +// Gadget is a plain referenced model. +// +// swagger:model +type Gadget struct { + Serial string `json:"serial"` +} + +// endsnippet:model + +// snippet:response + +// ErrorResponse is the Go-facing response doc, written for Go readers — it +// should not leak into the API spec. +// +// swagger:response errorResponse +// swagger:description The error payload returned to API consumers. +// swagger:title Ignored — responses have no title +type ErrorResponse struct { + // XErrorCode is the Go-facing header doc. + // + // swagger:description The machine-readable error code. + XErrorCode string `json:"X-Error-Code"` //nolint:tagliatelle // canonical HTTP header name + + // ErrorBody carries the structured error. + // + // in: body + Body ErrorBody `json:"body"` +} + +// ErrorBody is the error payload returned in the response body. +// +// swagger:model +type ErrorBody struct { + Message string `json:"message"` +} + +// endsnippet:response diff --git a/docs/examples/shaping/overrides/overrides_test.go b/docs/examples/shaping/overrides/overrides_test.go new file mode 100644 index 00000000..3afc5ada --- /dev/null +++ b/docs/examples/shaping/overrides/overrides_test.go @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 + +package overrides + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// Stable, public diagnostic-code strings (codescan.Code is a string alias). +const ( + codeEmptyOverride codescan.Code = "scan.empty-override" + codeContextInvalid codescan.Code = "parse.context-invalid" +) + +func examplesRoot(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok) + return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..")) +} + +// scanOverrides scans the witness package. emitRefSiblings selects whether a +// $ref'd field keeps its override siblings or drops to a bare $ref; diags +// collects the scan diagnostics. +func scanOverrides(t *testing.T, emitRefSiblings bool, diags *[]codescan.Diagnostic) *spec.Swagger { + t.Helper() + doc, err := codescan.Run(&codescan.Options{ + WorkDir: examplesRoot(t), + Packages: []string{"./shaping/overrides"}, + ScanModels: true, + EmitRefSiblings: emitRefSiblings, + OnDiagnostic: func(d codescan.Diagnostic) { + if diags != nil { + *diags = append(*diags, d) + } + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + return doc +} + +// goldenRaw marshals an arbitrary value into testdata/.json, +// honouring UPDATE_GOLDEN. +// +// Regenerate with: UPDATE_GOLDEN=1 go test ./... +func goldenRaw(t *testing.T, feature string, v any) { + t.Helper() + got, err := json.MarshalIndent(v, "", " ") + require.NoError(t, err) + got = append(got, '\n') + + golden := filepath.Join("testdata", feature+".json") + if os.Getenv("UPDATE_GOLDEN") != "" { + require.NoError(t, os.WriteFile(golden, got, 0o600)) + } + want, err := os.ReadFile(golden) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(got)) +} + +func hasCode(diags []codescan.Diagnostic, code codescan.Code) bool { + for _, d := range diags { + if d.Code == code { + return true + } + } + return false +} + +// TestOverrides emits and verifies the golden fragments the how-to pairs with +// each source region, and pins the behaviours the prose claims. +func TestOverrides(t *testing.T) { + var diags []codescan.Diagnostic + doc := scanOverrides(t, false, &diags) + + widget, ok := doc.Definitions["Widget"] + require.True(t, ok, "Widget definition missing") + goldenRaw(t, "widget", widget) + + // Model: both title and description overridden — the Go-facing godoc is gone. + assert.Equal(t, "A Public Widget", widget.Title) + assert.Equal(t, "A widget exposed via the public API.", widget.Description) + + // Field: description override; a property's title comes ONLY from an override. + assert.Equal(t, "The unique widget identifier.", widget.Properties["id"].Description) + assert.Equal(t, "", widget.Properties["id"].Title) + assert.Equal(t, "Display Label", widget.Properties["label"].Title) + + // Regression: no override → godoc description retained. + assert.NotEqual(t, "", widget.Properties["plain"].Description) + + // Co-location: the description override applies AND maximum survives. + capacity := widget.Properties["capacity"] + assert.Equal(t, "The maximum capacity, in liters.", capacity.Description) + require.NotNil(t, capacity.Maximum) + assert.Equal(t, float64(1000), *capacity.Maximum) + + // Multi-line (Option B): body lines fold into one description, \n-joined. + assert.Equal(t, + "Free-form notes about the widget.\nThey may span several lines, all folded into one description.", + widget.Properties["notes"].Description) + + // Empty override: bare swagger:description suppresses the godoc and warns. + assert.Equal(t, "", widget.Properties["suppressed"].Description) + assert.True(t, hasCode(diags, codeEmptyOverride), + "expected scan.empty-override for the bare swagger:description") + + // $ref field, default flags: title/description drop to a bare {$ref}. + gadget := widget.Properties["gadget"] + goldenRaw(t, "gadget_bare", gadget) + assert.NotEqual(t, "", gadget.Ref.String()) + assert.Equal(t, "", gadget.Title) + assert.Equal(t, "", gadget.Description) + + // $ref field, EmitRefSiblings: the overrides are preserved beside the $ref. + sib := scanOverrides(t, true, nil).Definitions["Widget"].Properties["gadget"] + goldenRaw(t, "gadget_siblings", sib) + assert.Equal(t, "Gadget Ref", sib.Title) + assert.Equal(t, "The attached gadget, described for API consumers.", sib.Description) + assert.NotEqual(t, "", sib.Ref.String()) +} + +// TestOverrides_Responses pins the response / header description override and +// the swagger:title context error (OAS2 responses and headers have no title). +func TestOverrides_Responses(t *testing.T) { + var diags []codescan.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + WorkDir: examplesRoot(t), + Packages: []string{"./shaping/overrides"}, + OnDiagnostic: func(d codescan.Diagnostic) { + diags = append(diags, d) + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + resp, ok := doc.Responses["errorResponse"] + require.True(t, ok, "errorResponse missing") + goldenRaw(t, "errorresponse", resp) + + // Response + header descriptions overridden. + assert.Equal(t, "The error payload returned to API consumers.", resp.Description) + hdr, ok := resp.Headers["X-Error-Code"] + require.True(t, ok, "X-Error-Code header missing") + assert.Equal(t, "The machine-readable error code.", hdr.Description) + + // swagger:title on a response is rejected as context-invalid. + assert.True(t, hasCode(diags, codeContextInvalid), + "expected parse.context-invalid for swagger:title on a response") +} diff --git a/docs/examples/shaping/overrides/testdata/errorresponse.json b/docs/examples/shaping/overrides/testdata/errorresponse.json new file mode 100644 index 00000000..4d259c92 --- /dev/null +++ b/docs/examples/shaping/overrides/testdata/errorresponse.json @@ -0,0 +1,12 @@ +{ + "description": "The error payload returned to API consumers.", + "schema": { + "$ref": "#/definitions/ErrorBody" + }, + "headers": { + "X-Error-Code": { + "type": "string", + "description": "The machine-readable error code." + } + } +} diff --git a/docs/examples/shaping/overrides/testdata/gadget_bare.json b/docs/examples/shaping/overrides/testdata/gadget_bare.json new file mode 100644 index 00000000..6ec013e4 --- /dev/null +++ b/docs/examples/shaping/overrides/testdata/gadget_bare.json @@ -0,0 +1,3 @@ +{ + "$ref": "#/definitions/Gadget" +} diff --git a/docs/examples/shaping/overrides/testdata/gadget_siblings.json b/docs/examples/shaping/overrides/testdata/gadget_siblings.json new file mode 100644 index 00000000..332ffc30 --- /dev/null +++ b/docs/examples/shaping/overrides/testdata/gadget_siblings.json @@ -0,0 +1,5 @@ +{ + "description": "The attached gadget, described for API consumers.", + "title": "Gadget Ref", + "$ref": "#/definitions/Gadget" +} diff --git a/docs/examples/shaping/overrides/testdata/widget.json b/docs/examples/shaping/overrides/testdata/widget.json new file mode 100644 index 00000000..f280fc1c --- /dev/null +++ b/docs/examples/shaping/overrides/testdata/widget.json @@ -0,0 +1,43 @@ +{ + "description": "A widget exposed via the public API.", + "type": "object", + "title": "A Public Widget", + "properties": { + "capacity": { + "description": "The maximum capacity, in liters.", + "type": "integer", + "format": "int64", + "maximum": 1000, + "x-go-name": "Capacity" + }, + "gadget": { + "$ref": "#/definitions/Gadget" + }, + "id": { + "description": "The unique widget identifier.", + "type": "string", + "x-go-name": "ID" + }, + "label": { + "description": "Human-readable label shown to API consumers.", + "type": "string", + "title": "Display Label", + "x-go-name": "Label" + }, + "notes": { + "description": "Free-form notes about the widget.\nThey may span several lines, all folded into one description.", + "type": "string", + "x-go-name": "Notes" + }, + "plain": { + "description": "Plain keeps its godoc description because it carries no override.", + "type": "string", + "x-go-name": "Plain" + }, + "suppressed": { + "type": "string", + "x-go-name": "Suppressed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/shaping/overrides" +} diff --git a/fixtures/enhancements/after-decl-comments/api.go b/fixtures/enhancements/after-decl-comments/api.go new file mode 100644 index 00000000..71ed0849 --- /dev/null +++ b/fixtures/enhancements/after-decl-comments/api.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package after_decl_comments witnesses the AfterDeclComments opt-in: swagger +// annotations live INSIDE a declaration (a struct's leading body comment) or +// INLINED as a trailing comment, so the godoc above the declaration stays +// clean. With the option off, those annotations are inert (nothing discovered). +// +// See .claude/plans/features/comment-source-filtering-design.md. +package after_decl_comments + +// Widget is a widget. This godoc stays clean — no swagger machinery here. +type Widget struct { + // Widget is exposed to API consumers. + // + // swagger:model widgetModel + // maxProperties: 5 + + Name string `json:"name"` + + // Created is documented with a clean godoc; the format annotation is + // inlined as a trailing comment (Phase B). + Created string `json:"created"` // swagger:strfmt date +} + +// Count is a plain count. Clean godoc above; annotation inlined below. +type Count int // swagger:model countType + +// Stamp is a string alias. Clean godoc above; annotation inlined trailing. +type Stamp = string // swagger:model stampType + +// ListWidgets is an ordinary Go handler with a clean godoc. +func ListWidgets() { + // swagger:route GET /widgets widgets listWidgets + // + // Lists the widgets. + // + // Responses: + // 200: widgetModel +} diff --git a/fixtures/enhancements/default-allof-embeds/types.go b/fixtures/enhancements/default-allof-embeds/types.go new file mode 100644 index 00000000..5d69ed1a --- /dev/null +++ b/fixtures/enhancements/default-allof-embeds/types.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package default_allof_embeds exercises Options.DefaultAllOfForEmbeds — the +// opt-in that renders a plain struct embed as allOf composition instead of +// inlining the embedded type's properties. +// +// Each embedding model below covers one interaction: +// +// - PlainEmbed — a model embed and a non-model embed, both untagged, plus +// an own field. ON: model embed becomes an allOf $ref member, the non-model +// embed an inline allOf member, and the own field a sibling member. OFF: +// every embedded field is inlined flat. +// - PointerEmbed — a plain *Base embed; the pointer is peeled and takes the +// same path as PlainEmbed's model embed. +// - NamedEmbed — an embed carrying a json tag name. It is a single nested +// property (Go does not promote it), so it is identical ON and OFF +// (go-swagger#2038). +// - TaggedEmbed — an explicit allOf-tagged embed. Already composition, so it +// is identical ON and OFF (the flag only changes the untagged default). +// +// See internal/builders/schema/README.md §allof. +package default_allof_embeds + +// Base is a reusable base model. +// +// swagger:model Base +type Base struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// Mixin is a non-model embedded type (no swagger:model), reachable only through +// embedding. Under the flag it composes as an inline allOf member rather than a +// $ref, since it has no definition of its own. +type Mixin struct { + Note string `json:"note"` +} + +// PlainEmbed embeds a model and a non-model plainly, plus an own field. +// +// swagger:model PlainEmbed +type PlainEmbed struct { + Base + Mixin + Color string `json:"color"` +} + +// PointerEmbed embeds a model through a pointer. +// +// swagger:model PointerEmbed +type PointerEmbed struct { + *Base + Tag string `json:"tag"` +} + +// NamedEmbed embeds Base under an explicit json name; it nests as a single +// property and is unaffected by the flag. +// +// swagger:model NamedEmbed +type NamedEmbed struct { + Base `json:"base"` + Extra string `json:"extra"` +} + +// TaggedEmbed composes Base through an explicit allOf tag; the flag does not +// change its shape. +// +// swagger:model TaggedEmbed +type TaggedEmbed struct { + // swagger:allOf + Base + Field string `json:"field"` +} diff --git a/fixtures/enhancements/description-title-override-responses/api.go b/fixtures/enhancements/description-title-override-responses/api.go new file mode 100644 index 00000000..476bdca7 --- /dev/null +++ b/fixtures/enhancements/description-title-override-responses/api.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package description_title_override_responses witnesses the swagger:description +// override on responses and response headers (P3 of the override feature, Q30 +// close-out), plus the swagger:title context error: OpenAPI 2.0 Response and +// Header objects have no `title` field, so a swagger:title on a response / +// header is rejected with scan.context-invalid and the description override is +// still applied. +// +// See .claude/plans/features/swagger-description-override-design.md. +package description_title_override_responses + +// ErrorBody is the error payload returned in the response body. +// +// swagger:model +type ErrorBody struct { + Message string `json:"message"` +} + +// ErrorResponse is the Go-facing response doc, written for Go readers — it +// should not leak into the API spec. +// +// swagger:response errorResponse +// swagger:description The error payload returned to API consumers. +// swagger:title Ignored — responses have no title +type ErrorResponse struct { + // XErrorCode is the Go-facing header doc. + // + // swagger:description The machine-readable error code. + XErrorCode string `json:"X-Error-Code"` + + // ErrorBody carries the structured error. + // + // in: body + Body ErrorBody `json:"body"` +} diff --git a/fixtures/enhancements/description-title-override/api.go b/fixtures/enhancements/description-title-override/api.go new file mode 100644 index 00000000..90952db8 --- /dev/null +++ b/fixtures/enhancements/description-title-override/api.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package description_title_override witnesses the swagger:title / +// swagger:description override annotations (Q30 close-out): an explicit +// annotation replaces the godoc-derived title / description so the Go-facing +// doc comment can diverge from the API-facing spec text. +// +// Scope exercised here (P2): model schemas + struct fields. The overrides +// replace the prose-derived value; absence leaves the godoc untouched +// (regression guard); a bare swagger:description suppresses the godoc (empty +// value applied) and raises scan.empty-override. +// +// See .claude/plans/features/swagger-description-override-design.md. +package description_title_override + +// Widget is the Go-facing widget doc, written for Go readers. +// +// It explains internal Go usage that should not leak into the API spec. +// +// swagger:model +// swagger:title A Public Widget +// swagger:description A widget exposed via the public API. +type Widget struct { + // ID explains the Go field for Go readers. + // + // swagger:description The unique widget identifier. + ID string `json:"id"` + + // Label is the Go-facing field doc. Fields carry no title by default; + // the override is the only way a property gets one. + // + // swagger:title Display Label + // swagger:description Human-readable label shown to API consumers. + Label string `json:"label"` + + // Plain keeps its godoc description because it carries no override + Plain string `json:"plain"` + + // Capacity combines a description override with an inline validation + // keyword on the same field: the override applies AND maximum is kept + // (override annotations dispatch through the schema family, so co-located + // keywords survive). + // + // swagger:description The maximum capacity, in liters. + // maximum: 1000 + Capacity int64 `json:"capacity"` + + // Suppressed has a godoc that is suppressed by a bare swagger:description: + // the empty value is applied (description omitted) and scan.empty-override + // is raised. + // + // swagger:description + Suppressed string `json:"suppressed"` + + // Notes carries a multi-line description override (Option B): the lines + // following the annotation fold into the description until the blank line, + // joined with newlines. + // + // swagger:description Free-form notes about the widget. + // They may span several lines, all folded into one description. + // + // The blank line above terminates the override body; this paragraph is + // ordinary godoc and is discarded (the override won). + Notes string `json:"notes"` + + // Gadget is a $ref field carrying title + description overrides. Title and + // description are symmetric $ref siblings: they ride the same preservation + // rule (kept under EmitRefSiblings / a forced compound, dropped to a bare + // $ref under the default flags) — no title-specific compounding. + // + // swagger:title Gadget Ref + // swagger:description The attached gadget, described for API consumers. + Gadget Gadget `json:"gadget"` +} + +// Gadget is a plain referenced model. +// +// swagger:model +type Gadget struct { + Serial string `json:"serial"` +} diff --git a/fixtures/enhancements/godoc-links/api.go b/fixtures/enhancements/godoc-links/api.go new file mode 100644 index 00000000..df1fc1f8 --- /dev/null +++ b/fixtures/enhancements/godoc-links/api.go @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package godoclinks exercises the CleanGoDoc godoc-syntax filter. It is scanned +// both with the option off (godoc emitted verbatim) and on. With the option on, +// doc-links that resolve to a scanned model are recomposed to that model's +// EXPOSED name (Widget -> "gizmo" via swagger:model, Order.CustName -> +// "Order.customer_name" via the json tag); unresolved links are humanized; +// reference-definition lines are dropped; and non-doc-link brackets are left +// intact. +package godoclinks + +import "github.com/go-openapi/codescan/fixtures/enhancements/godoc-links/inventory" + +// Widget is the primary [Gadget] holder and references [Order.CustName]. +// +// More detail mentions a [*Gadget] pointer, a [inventory.Ledger], and an +// unknown [Sprocket]. +// +// [the spec]: https://example.com/spec +// +// swagger:model gizmo +type Widget struct { + // Holder points at the [Gadget] that owns this widget. + Holder string `json:"holder"` + + // Ledger is the cross-package [inventory.Ledger] reference. + Ledger *inventory.Ledger `json:"ledger"` + + // Index is element [0] in the [see notes] list; the [id] stays bare. + Index int `json:"index"` +} + +// Gadget is a small component. +// +// swagger:model +type Gadget struct { + Name string `json:"name"` +} + +// Order captures a purchase. +// +// swagger:model +type Order struct { + // CustName is the buyer. + CustName string `json:"customer_name"` +} diff --git a/fixtures/enhancements/godoc-links/inventory/ledger.go b/fixtures/enhancements/godoc-links/inventory/ledger.go new file mode 100644 index 00000000..89070442 --- /dev/null +++ b/fixtures/enhancements/godoc-links/inventory/ledger.go @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package inventory provides a model referenced cross-package by a doc-link. +package inventory + +// Ledger records stock movements. +// +// swagger:model +type Ledger struct { + Entries int `json:"entries"` +} diff --git a/fixtures/enhancements/inner-markdown/types.go b/fixtures/enhancements/inner-markdown/types.go new file mode 100644 index 00000000..7a29e2d5 --- /dev/null +++ b/fixtures/enhancements/inner-markdown/types.go @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package inner_markdown exercises the swagger:description | literal block +// scalar (reframes go-swagger#3211): an author opts a description into verbatim +// markdown by ending the annotation line with the YAML literal-block marker `|`. +// The body below is captured exactly — blank lines, indentation, and table +// pipes preserved — until the next swagger annotation or end of comment. +package inner_markdown + +// Widget is a thing. +// +// swagger:description | +// Widgets support **markdown** in their description: +// +// | name | purpose | +// |------|---------| +// | foo | bars | +// +// - point one +// - point two +// +// swagger:model Widget +type Widget struct { + // Name of the widget. + // + // swagger:description | + // The name must be: + // + // 1. unique + // 2. lowercase + Name string `json:"name"` +} diff --git a/fixtures/enhancements/interface-no-mangle/types.go b/fixtures/enhancements/interface-no-mangle/types.go new file mode 100644 index 00000000..43d782da --- /dev/null +++ b/fixtures/enhancements/interface-no-mangle/types.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package interface_no_mangle exercises Options.SkipJSONifyInterfaceMethods — +// the opt-out of the interface-method auto-jsonify mangler. +// +// An interface method has no natural JSON serialization, so by default codescan +// runs the swag/mangling ToJSONName transform on the Go method name to invent a +// property name (ID -> id, CreatedAt -> createdAt). With the opt-out set the Go +// method name is emitted verbatim instead. A swagger:name override is always +// taken verbatim, independent of the flag. +// +// The default-path methods deliberately mangle to a different spelling than +// their Go name so the on/off difference is observable; the override method +// pins the contract that an explicit name always wins, regardless of the flag. +// +// See internal/builders/schema/README.md §interface-naming. +package interface_no_mangle + +// Account is a no-mangle witness interface. +// +// swagger:model Account +type Account interface { + // Default path, no override. The mangler produces "id"; with the opt-out + // the verbatim "ID" is emitted. + ID() string + + // Default path, no override. The mangler produces "createdAt"; with the + // opt-out the verbatim "CreatedAt" is emitted. + CreatedAt() string + + // swagger:name explicit_name + // + // A swagger:name override is taken verbatim whether or not the mangler is + // skipped — re-mangling would camelCase this to "explicitName". + OverriddenField() string +} diff --git a/fixtures/enhancements/name-from-tags/api.go b/fixtures/enhancements/name-from-tags/api.go new file mode 100644 index 00000000..8842a8ae --- /dev/null +++ b/fixtures/enhancements/name-from-tags/api.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package name_from_tags witnesses the NameFromTags option: the emitted +// name of a schema property, query parameter, or response header is +// sourced from the first struct-tag type listed in Options.NameFromTags. +// +// Every field below carries BOTH a json: and a form: tag whose names +// differ. Under the default (nil → ["json"]) the json name is used; +// under ["form","json"] the form name wins; under an explicit empty +// slice the Go field name is used. The encoding/json directives +// (`,omitempty`, `json:"-"`) are always read from the json tag, +// independent of the setting. +package name_from_tags + +// Filter is a model whose fields carry differing json: and form: names. +// +// swagger:model Filter +type Filter struct { + // SortKey is named "sortKey" by default, "sort_key" under form-first. + SortKey string `json:"sortKey" form:"sort_key"` + + // PageSize keeps its json omitempty directive whichever tag names it. + PageSize int `json:"pageSize,omitempty" form:"page_size"` + + // Label has only a json tag — same name under every setting. + Label string `json:"label" form:"-"` + + // Internal is excluded by json:"-" regardless of NameFromTags, even + // though the form tag would name it. + Internal string `json:"-" form:"internal"` +} + +// ListParams are the query parameters for the list operation. +// +// swagger:parameters listItems +type ListParams struct { + // SortKey selects the sort column. + // + // in: query + SortKey string `json:"sortKey" form:"sort_key"` + + // PageSize bounds the page length. + // + // in: query + PageSize int `json:"pageSize" form:"page_size"` +} + +// ListResponse carries response headers with differing json: and form: names. +// +// swagger:response listResponse +type ListResponse struct { + // RequestID is a correlation header. + // + // in: header + RequestID string `json:"X-Request-Id" form:"x_request_id"` + + // Body is the payload. + // + // in: body + Body struct { + // Items is the page of matching filters. + Items []Filter `json:"items"` + } `json:"body"` +} + +// ListItems swagger:route GET /items items listItems +// +// List items. +// +// Responses: +// +// 200: listResponse +func ListItems() {} diff --git a/fixtures/enhancements/response-examples-by-mime/api.go b/fixtures/enhancements/response-examples-by-mime/api.go new file mode 100644 index 00000000..670a975f --- /dev/null +++ b/fixtures/enhancements/response-examples-by-mime/api.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package response_examples_by_mime witnesses response-level `examples` +// (a map keyed by mime type) on a struct-based `swagger:response` +// (go-swagger#2871). +// +// OAS2 reserves the plural `examples` keyword for the Response object; +// the value is a `{mimeType: example}` map. The `swagger:operation` YAML +// path already carries this for free (it unmarshals straight into the +// spec types — see fixtures/bugs/1713 and fixtures/bugs/2871); this +// fixture exercises the Go-struct `swagger:response` path, where the +// `examples:` block in the decl comment is parsed into Response.examples. +package response_examples_by_mime + +// Widget is the response payload. +// +// swagger:model Widget +type Widget struct { + Name string `json:"name"` +} + +// WidgetResponse returns a widget, with response-level examples per mime type. +// +// swagger:response widgetResponse +// +// examples: +// +// application/json: +// name: alice +// count: 3 +// application/xml: "alice" +type WidgetResponse struct { + // in: body + Body Widget `json:"body"` +} + +// GetWidgets swagger:route GET /widgets widgets getWidgets +// +// Get a widget. +// +// Responses: +// +// 200: widgetResponse +func GetWidgets() {} diff --git a/fixtures/enhancements/shared-parameters-conflict/pkga/api.go b/fixtures/enhancements/shared-parameters-conflict/pkga/api.go new file mode 100644 index 00000000..61481f53 --- /dev/null +++ b/fixtures/enhancements/shared-parameters-conflict/pkga/api.go @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package pkga is the FIRST package of Fixture 3 (cross-package +// conflicts). By import-path order it is the survivor of the duplicate +// shared short names also declared in pkgb. +// +// See .claude/plans/features/shared-parameters-fixtures.md. +package pkga + +// TokenA registers #/parameters/X-Token (header). pkgb declares the same +// short name with a different `in:` — pkga wins, pkgb is dropped + warned. +// +// swagger:parameters * +type TokenA struct { + // in: header + Token string `json:"X-Token"` +} + +// StatusParam registers #/parameters/Status. The model Status below +// registers #/definitions/Status — independent namespaces, NO conflict. +// +// swagger:parameters * +type StatusParam struct { + // in: header + Status string `json:"Status"` +} + +// Status is a model at #/definitions/Status, coexisting with the shared +// parameter #/parameters/Status (cross-namespace non-conflict witness). +// +// swagger:model +type Status struct { + // State of the resource. + State string `json:"state"` +} + +// ErrorResponse force-registers #/responses/ErrorResponse. pkgb declares +// the same response short name with a different body — pkga wins. +// +// swagger:response * +type ErrorResponse struct { + // in: body + Body struct { + // Code is a machine-readable error code. + Code int `json:"code"` + } `json:"body"` +} + +// ListA gives the scan an operation in pkga. +// +// swagger:route GET /a alpha listA +// Responses: +// +// default: ErrorResponse +func ListA() {} diff --git a/fixtures/enhancements/shared-parameters-conflict/pkgb/api.go b/fixtures/enhancements/shared-parameters-conflict/pkgb/api.go new file mode 100644 index 00000000..1d6ddfac --- /dev/null +++ b/fixtures/enhancements/shared-parameters-conflict/pkgb/api.go @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package pkgb is the SECOND package of Fixture 3 (cross-package +// conflicts). Its shared parameter X-Token and shared response +// ErrorResponse collide on short name with pkga's; both are dropped with +// a keep-first warning (no rename — short-name refs must stay valid). +// +// See .claude/plans/features/shared-parameters-fixtures.md. +package pkgb + +// TokenB collides with pkga's #/parameters/X-Token on short name, with a +// DIFFERENT `in:` (query vs header). Expected: dropped + warning. +// +// swagger:parameters * +type TokenB struct { + // in: query + Token string `json:"X-Token"` +} + +// ErrorResponse collides with pkga's #/responses/ErrorResponse on short +// name, with a different body shape. Expected: dropped + warning. +// +// swagger:response * +type ErrorResponse struct { + // in: body + Body struct { + // Message is a human-readable error message. + Message string `json:"message"` + } `json:"body"` +} + +// ListB gives the scan an operation in pkgb. +// +// swagger:route GET /b beta listB +// Responses: +// +// default: ErrorResponse +func ListB() {} diff --git a/fixtures/enhancements/shared-parameters-overrides/api.go b/fixtures/enhancements/shared-parameters-overrides/api.go new file mode 100644 index 00000000..029e4dad --- /dev/null +++ b/fixtures/enhancements/shared-parameters-overrides/api.go @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package shared_parameters_overrides (Fixture 5) witnesses two caveats +// of the shared-parameters feature (go-swagger#2632): +// +// - C3: the shared key and every reference use the FINAL, overridden +// parameter name (the `name:` keyword wins over the json tag), not +// the Go field name. +// - C1/C2: duplicate op-id targets and duplicate reference names are +// deduplicated, each surplus raising a warning. +// +// See .claude/plans/features/shared-parameters-fixtures.md. +package shared_parameters_overrides + +// CommonHeaders registers a shared header whose spec name is OVERRIDDEN +// by the `name:` keyword: it lands at #/parameters/X-Correlation-ID, not +// #/parameters/X-Request-ID. References must use the overridden name. +// +// swagger:parameters * +type CommonHeaders struct { + // RequestID correlates a request across services. + // + // in: header + // name: X-Correlation-ID + RequestID string `json:"X-Request-ID"` +} + +// AuthHeader registers #/parameters/X-API-Key and $ref's it into +// createThing. The op-id list repeats createThing: the duplicate is +// dropped (C1) and a scan.duplicate-target warning is raised. +// +// swagger:parameters * createThing createThing +type AuthHeader struct { + // APIKey authorises access. + // + // in: header + APIKey string `json:"X-API-Key"` +} + +// ListThings lists things. The standalone reference marker repeats the +// shared name X-Correlation-ID: the duplicate is dropped (C2) with a +// scan.duplicate-ref warning, leaving a single $ref. It also proves a +// reference resolves by the OVERRIDDEN name. +// +// swagger:route GET /things things listThings +// swagger:parameters listThings X-Correlation-ID X-Correlation-ID +// Responses: +// +// 200: description: OK +func ListThings() {} + +// CreateThing creates a thing; it gets the $ref'd X-API-Key (once). +// +// swagger:route POST /things things createThing +// Responses: +// +// 201: description: Created +func CreateThing() {} + +// danglingRef references a shared parameter that no `swagger:parameters *` +// declaration registers. The reference is dropped with a +// scan.dangling-parameter-ref warning rather than emitting a dangling +// $ref. +// +// swagger:parameters listThings NoSuchParam +func danglingRef() {} diff --git a/fixtures/enhancements/shared-parameters-pathitem/api.go b/fixtures/enhancements/shared-parameters-pathitem/api.go new file mode 100644 index 00000000..e04aa518 --- /dev/null +++ b/fixtures/enhancements/shared-parameters-pathitem/api.go @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package shared_parameters_pathitem (Fixture 2) witnesses path-item +// level parameters for the shared-parameters feature (go-swagger#2632): +// path-item inline and reference forms, an operation override on +// (name, in), and exact-path application (no path hierarchy). Each form is +// documented on the declaration that exercises it. +// +// See .claude/plans/features/shared-parameters-fixtures.md for the grammar +// and the expected spec. +package shared_parameters_pathitem + +// CommonHeaders registers #/parameters/X-Request-ID so the path-item +// reference marker below can $ref it. +// +// swagger:parameters * +type CommonHeaders struct { + // RequestID correlates a request across services. + // + // in: header + RequestID string `json:"X-Request-ID"` +} + +// PetPathParams are inlined into the /pets path-item parameters and thus +// inherited by every operation under the EXACT path /pets (no hierarchy: +// /pets/{id} does not inherit them). +// +// swagger:parameters /pets +type PetPathParams struct { + // APIKey authorises access to the pet store. + // + // in: header + // required: true + APIKey string `json:"X-API-Key"` +} + +// ListPetsParams override the path-item X-API-Key on (name="X-API-Key", +// in="header"): per OAS2 the operation-level parameter wins, so listPets +// sees X-API-Key as optional even though the path-item marks it required. +// Both appear in the spec (co-presence); the operation one wins. +// +// swagger:parameters listPets +type ListPetsParams struct { + // APIKey overrides the path-item parameter to make it optional here. + // + // in: header + // required: false + APIKey string `json:"X-API-Key"` +} + +// GetPetParams supply the path parameter for /pets/{id}. +// +// swagger:parameters getPet +type GetPetParams struct { + // in: path + // required: true + ID string `json:"id"` +} + +// pathItemRefs is the standalone anchor for the path-item reference +// marker: it adds the shared X-Request-ID parameter to the /pets +// path-item as a $ref. (Not a struct → reference mode.) +// +// swagger:parameters /pets X-Request-ID +func pathItemRefs() {} + +// ListPets lists pets, under the exact path /pets. +// +// swagger:route GET /pets pets listPets +// Responses: +// +// 200: description: OK +func ListPets() {} + +// CreatePet creates a pet, under the exact path /pets. It declares no +// parameters of its own, so it inherits the /pets path-item params +// (X-API-Key required + the X-Request-ID $ref) unchanged. +// +// swagger:route POST /pets pets createPet +// Responses: +// +// 201: description: Created +func CreatePet() {} + +// GetPet reads one pet, under the EXACT path /pets/{id}. It must NOT +// inherit the /pets path-item parameters (OAS2 has no path hierarchy). +// +// swagger:route GET /pets/{id} pets getPet +// Responses: +// +// 200: description: OK +func GetPet() {} diff --git a/fixtures/enhancements/shared-parameters-prune-cascade/api.go b/fixtures/enhancements/shared-parameters-prune-cascade/api.go new file mode 100644 index 00000000..32ca19b1 --- /dev/null +++ b/fixtures/enhancements/shared-parameters-prune-cascade/api.go @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package shared_parameters_prune_cascade witnesses the second step of the +// shared-parameters prune extension (C4 / P7, go-swagger#2632): pruning an +// unreferenced shared response also prunes the definitions reachable ONLY +// through it (the cascade), while definitions still reachable via a surviving +// shared object are kept. +// +// The shared-object prune runs BEFORE the definition reachability walk, so a +// model whose last keeper was a now-pruned shared response loses its root and +// is pruned in turn — but a model shared with a surviving response stays. +// +// Layout (scanned under ScanModels, toggling PruneUnusedModels): +// +// UsedResponse (swagger:response *, referenced by listC) → body Survivor +// UnusedResponse(swagger:response *, referenced by nobody) → body Orphan +// Survivor → field Shared (kept: UsedResponse survives) +// Orphan → field Shared (pruned: only UnusedResponse reached it) +// Shared (kept: still reachable via Survivor) +// +// Expected with PruneUnusedModels: responses {UsedResponse}; definitions +// {Survivor, Shared}; UnusedResponse + Orphan pruned (two scan.pruned-unused +// Hints); Shared NOT pruned (the prune is reachability-correct, not a naive +// "drop everything the pruned response touched"). +// +// See .claude/plans/features/shared-parameters-fixtures.md (§6b, step 2). +package shared_parameters_prune_cascade + +// Survivor is reached from UsedResponse's body and survives the prune. +// +// swagger:model +type Survivor struct { + Name string `json:"name"` + Shared Shared `json:"shared"` +} + +// Orphan is reached ONLY from UnusedResponse's body. When UnusedResponse is +// pruned (no operation references it), Orphan loses its only keeper and is +// pruned in turn — the cascade. +// +// swagger:model +type Orphan struct { + Detail string `json:"detail"` + Shared Shared `json:"shared"` +} + +// Shared is reached from BOTH Survivor (kept) and Orphan (pruned). It must +// survive, because the surviving Survivor still references it — the cascade +// prunes only what nothing reachable keeps alive. +// +// swagger:model +type Shared struct { + Code int64 `json:"code"` +} + +// UsedResponse is referenced by listC's Responses block → survives, keeping +// Survivor (and transitively Shared). +// +// swagger:response * +type UsedResponse struct { + // in: body + Body Survivor `json:"body"` +} + +// UnusedResponse is referenced by no operation → pruned, cascading to Orphan. +// +// swagger:response * +type UnusedResponse struct { + // in: body + Body Orphan `json:"body"` +} + +// ListC references only the used shared response; UnusedResponse is left +// dangling for the prune pass. +// +// swagger:route GET /c cascade listC +// Responses: +// +// default: UsedResponse +func ListC() {} diff --git a/fixtures/enhancements/shared-parameters-prune/api.go b/fixtures/enhancements/shared-parameters-prune/api.go new file mode 100644 index 00000000..bdf30858 --- /dev/null +++ b/fixtures/enhancements/shared-parameters-prune/api.go @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package shared_parameters_prune (Fixture 6) witnesses the prune +// extension (C4) of the shared-parameters feature (go-swagger#2632): +// shared parameters/responses referenced by no operation are pruned +// under PruneUnusedModels, and retained without it. +// +// The integration test scans this package twice — with ScanModels only +// (all four shared objects present) and with ScanModels + +// PruneUnusedModels (the unused pair pruned) — mirroring +// coverage_prune_unused_test.go. +// +// See .claude/plans/features/shared-parameters-fixtures.md (§6b). +package shared_parameters_prune + +// UsedHeader registers #/parameters/X-Used and is referenced by listP → +// it survives pruning. +// +// swagger:parameters * +type UsedHeader struct { + // in: header + Used string `json:"X-Used"` +} + +// UnusedHeader registers #/parameters/X-Unused and is referenced by no +// operation → pruned under PruneUnusedModels. +// +// swagger:parameters * +type UnusedHeader struct { + // in: header + Unused string `json:"X-Unused"` +} + +// UsedResponse is referenced by listP's Responses block → survives. +// +// swagger:response * +type UsedResponse struct { + // in: body + Body struct { + // OK message. + Message string `json:"message"` + } `json:"body"` +} + +// UnusedResponse is referenced by no operation → pruned under +// PruneUnusedModels. +// +// swagger:response * +type UnusedResponse struct { + // in: body + Body struct { + // unused detail. + Detail string `json:"detail"` + } `json:"body"` +} + +// ListP references the used shared parameter and the used shared +// response; the unused pair is left dangling for the prune pass. +// +// swagger:route GET /p prune listP +// swagger:parameters listP X-Used +// Responses: +// +// default: UsedResponse +func ListP() {} diff --git a/fixtures/enhancements/shared-parameters-yaml/api.go b/fixtures/enhancements/shared-parameters-yaml/api.go new file mode 100644 index 00000000..512ab398 --- /dev/null +++ b/fixtures/enhancements/shared-parameters-yaml/api.go @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package shared_parameters_yaml (Fixture 4) witnesses that the +// swagger:operation wholesale-YAML path runs the same shared-namespace +// checks: a $ref into #/parameters or #/responses is validated, and a +// dangling ref raises a diagnostic. +// +// See .claude/plans/features/shared-parameters-fixtures.md. +package shared_parameters_yaml + +// CommonHeaders registers #/parameters/X-Request-ID. +// +// swagger:parameters * +type CommonHeaders struct { + // in: header + RequestID string `json:"X-Request-ID"` +} + +// ErrorResponse registers #/responses/ErrorResponse. +// +// swagger:response * +type ErrorResponse struct { + // in: body + Body struct { + // Message is a human-readable error message. + Message string `json:"message"` + } `json:"body"` +} + +// OpA references the shared namespace from wholesale YAML; both refs +// resolve and are kept. +// +// swagger:operation GET /a opA +// +// --- +// summary: resolving refs +// parameters: +// - $ref: '#/parameters/X-Request-ID' +// responses: +// default: +// $ref: '#/responses/ErrorResponse' +func OpA() {} + +// OpB references names that do not exist in the shared namespace; each +// raises a dangling-ref diagnostic. +// +// swagger:operation GET /b opB +// +// --- +// summary: dangling refs +// parameters: +// - $ref: '#/parameters/DoesNotExist' +// responses: +// default: +// $ref: '#/responses/Missing' +func OpB() {} diff --git a/fixtures/enhancements/shared-parameters/api.go b/fixtures/enhancements/shared-parameters/api.go new file mode 100644 index 00000000..4df554bd --- /dev/null +++ b/fixtures/enhancements/shared-parameters/api.go @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package shared_parameters (Fixture 1) witnesses the spec-level shared +// namespace for the shared-parameters feature (go-swagger#2632): the +// wildcard target, both parameter reference channels, and a shared +// response. Each form is documented on the declaration that exercises it. +// +// See .claude/plans/features/shared-parameters-fixtures.md for the grammar +// and the expected spec this fixture should produce. +package shared_parameters + +// CommonHeaders registers a reusable header parameter at the spec top +// level (#/parameters/X-Request-ID). Register-only: no operation list, +// so nothing references it directly here — it is pulled in below via the +// standalone reference channel. +// +// swagger:parameters * +type CommonHeaders struct { + // RequestID correlates a request across services. + // + // in: header + RequestID string `json:"X-Request-ID"` +} + +// AuthHeader registers #/parameters/X-API-Key AND $ref's it into the +// createPet operation (the small-spec "* opID" convenience). +// +// swagger:parameters * createPet +type AuthHeader struct { + // APIKey authorises access. + // + // in: header + // required: true + APIKey string `json:"X-API-Key"` +} + +// ListPetsParams are inlined into listPets (existing per-operation form). +// +// swagger:parameters listPets +type ListPetsParams struct { + // Limit caps the number of pets returned. + // + // in: query + Limit int `json:"limit"` +} + +// CreatePetParams are inlined into createPet; createPet therefore mixes +// an inlined body parameter with the $ref'd X-API-Key above. +// +// swagger:parameters createPet +type CreatePetParams struct { + // in: body + // required: true + Body Pet `json:"body"` +} + +// ErrorResponse is force-registered at #/responses/ErrorResponse and +// referenced by both routes' Responses blocks (emitted as a $ref). +// +// swagger:response * +type ErrorResponse struct { + // in: body + Body struct { + // Code is a machine-readable error code. + Code int `json:"code"` + // Message is a human-readable error message. + Message string `json:"message"` + } `json:"body"` +} + +// Pet is the body model. +// +// swagger:model +type Pet struct { + // Name of the pet. + Name string `json:"name"` +} + +// ListPets lists pets. The standalone reference marker below pulls the +// shared X-Request-ID parameter into this operation as a $ref (the +// scaling channel: the shared struct need not list every operation). +// +// swagger:route GET /pets pets listPets +// swagger:parameters listPets X-Request-ID +// Responses: +// +// default: ErrorResponse +func ListPets() {} + +// CreatePet creates a pet. Its parameters come from AuthHeader +// (#/parameters/X-API-Key, $ref'd via "* createPet") and CreatePetParams +// (inlined body). +// +// swagger:route POST /pets pets createPet +// Responses: +// +// default: ErrorResponse +func CreatePet() {} diff --git a/fixtures/integration/golden/enhancements_after_decl_comments.json b/fixtures/integration/golden/enhancements_after_decl_comments.json new file mode 100644 index 00000000..f0b39cbc --- /dev/null +++ b/fixtures/integration/golden/enhancements_after_decl_comments.json @@ -0,0 +1,57 @@ +{ + "swagger": "2.0", + "paths": { + "/widgets": { + "get": { + "tags": [ + "widgets" + ], + "summary": "Lists the widgets.", + "operationId": "listWidgets", + "responses": { + "200": { + "description": "Widget is a widget. This godoc stays clean — no swagger machinery here.", + "schema": { + "$ref": "#/definitions/widgetModel" + } + } + } + } + } + }, + "definitions": { + "countType": { + "type": "integer", + "format": "int64", + "title": "Count is a plain count. Clean godoc above; annotation inlined below.", + "x-go-name": "Count", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/after-decl-comments" + }, + "stampType": { + "type": "string", + "title": "Stamp is a string alias. Clean godoc above; annotation inlined trailing.", + "x-go-name": "Stamp", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/after-decl-comments" + }, + "widgetModel": { + "description": "Widget is exposed to API consumers.", + "type": "object", + "title": "Widget is a widget. This godoc stays clean — no swagger machinery here.", + "maxProperties": 5, + "properties": { + "created": { + "description": "Created is documented with a clean godoc; the format annotation is\ninlined as a trailing comment (Phase B).", + "type": "string", + "format": "date", + "x-go-name": "Created" + }, + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-name": "Widget", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/after-decl-comments" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_after_decl_comments_off.json b/fixtures/integration/golden/enhancements_after_decl_comments_off.json new file mode 100644 index 00000000..29ceb8b7 --- /dev/null +++ b/fixtures/integration/golden/enhancements_after_decl_comments_off.json @@ -0,0 +1,15 @@ +{ + "swagger": "2.0", + "paths": { + "/widgets": { + "get": { + "tags": [ + "widgets" + ], + "summary": "Lists the widgets.", + "operationId": "listWidgets", + "responses": {} + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_default_allof_embeds_off.json b/fixtures/integration/golden/enhancements_default_allof_embeds_off.json new file mode 100644 index 00000000..f9875d95 --- /dev/null +++ b/fixtures/integration/golden/enhancements_default_allof_embeds_off.json @@ -0,0 +1,109 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Base": { + "type": "object", + "title": "Base is a reusable base model.", + "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/default-allof-embeds" + }, + "Mixin": { + "description": "Mixin is a non-model embedded type (no swagger:model), reachable only through\nembedding. Under the flag it composes as an inline allOf member rather than a\n$ref, since it has no definition of its own.", + "type": "object", + "properties": { + "note": { + "type": "string", + "x-go-name": "Note" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-allof-embeds" + }, + "NamedEmbed": { + "description": "NamedEmbed embeds Base under an explicit json name; it nests as a single\nproperty and is unaffected by the flag.", + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/Base" + }, + "extra": { + "type": "string", + "x-go-name": "Extra" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-allof-embeds" + }, + "PlainEmbed": { + "type": "object", + "title": "PlainEmbed embeds a model and a non-model plainly, plus an own field.", + "properties": { + "color": { + "type": "string", + "x-go-name": "Color" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "type": "string", + "x-go-name": "Name" + }, + "note": { + "type": "string", + "x-go-name": "Note" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-allof-embeds" + }, + "PointerEmbed": { + "type": "object", + "title": "PointerEmbed embeds a model through a pointer.", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "type": "string", + "x-go-name": "Name" + }, + "tag": { + "type": "string", + "x-go-name": "Tag" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-allof-embeds" + }, + "TaggedEmbed": { + "description": "TaggedEmbed composes Base through an explicit allOf tag; the flag does not\nchange its shape.", + "allOf": [ + { + "$ref": "#/definitions/Base" + }, + { + "type": "object", + "properties": { + "field": { + "type": "string", + "x-go-name": "Field" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-allof-embeds" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_default_allof_embeds_on.json b/fixtures/integration/golden/enhancements_default_allof_embeds_on.json new file mode 100644 index 00000000..9ed9b2fd --- /dev/null +++ b/fixtures/integration/golden/enhancements_default_allof_embeds_on.json @@ -0,0 +1,99 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Base": { + "type": "object", + "title": "Base is a reusable base model.", + "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/default-allof-embeds" + }, + "NamedEmbed": { + "description": "NamedEmbed embeds Base under an explicit json name; it nests as a single\nproperty and is unaffected by the flag.", + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/Base" + }, + "extra": { + "type": "string", + "x-go-name": "Extra" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-allof-embeds" + }, + "PlainEmbed": { + "title": "PlainEmbed embeds a model and a non-model plainly, plus an own field.", + "allOf": [ + { + "$ref": "#/definitions/Base" + }, + { + "type": "object", + "properties": { + "note": { + "type": "string", + "x-go-name": "Note" + } + } + }, + { + "type": "object", + "properties": { + "color": { + "type": "string", + "x-go-name": "Color" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-allof-embeds" + }, + "PointerEmbed": { + "title": "PointerEmbed embeds a model through a pointer.", + "allOf": [ + { + "$ref": "#/definitions/Base" + }, + { + "type": "object", + "properties": { + "tag": { + "type": "string", + "x-go-name": "Tag" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-allof-embeds" + }, + "TaggedEmbed": { + "description": "TaggedEmbed composes Base through an explicit allOf tag; the flag does not\nchange its shape.", + "allOf": [ + { + "$ref": "#/definitions/Base" + }, + { + "type": "object", + "properties": { + "field": { + "type": "string", + "x-go-name": "Field" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-allof-embeds" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_description_title_override.json b/fixtures/integration/golden/enhancements_description_title_override.json new file mode 100644 index 00000000..40e0d440 --- /dev/null +++ b/fixtures/integration/golden/enhancements_description_title_override.json @@ -0,0 +1,60 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Gadget": { + "type": "object", + "title": "Gadget is a plain referenced model.", + "properties": { + "serial": { + "type": "string", + "x-go-name": "Serial" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/description-title-override" + }, + "Widget": { + "description": "A widget exposed via the public API.", + "type": "object", + "title": "A Public Widget", + "properties": { + "capacity": { + "description": "The maximum capacity, in liters.", + "type": "integer", + "format": "int64", + "maximum": 1000, + "x-go-name": "Capacity" + }, + "gadget": { + "$ref": "#/definitions/Gadget" + }, + "id": { + "description": "The unique widget identifier.", + "type": "string", + "x-go-name": "ID" + }, + "label": { + "description": "Human-readable label shown to API consumers.", + "type": "string", + "title": "Display Label", + "x-go-name": "Label" + }, + "notes": { + "description": "Free-form notes about the widget.\nThey may span several lines, all folded into one description.", + "type": "string", + "x-go-name": "Notes" + }, + "plain": { + "description": "Plain keeps its godoc description because it carries no override", + "type": "string", + "x-go-name": "Plain" + }, + "suppressed": { + "type": "string", + "x-go-name": "Suppressed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/description-title-override" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_description_title_override_ref_siblings.json b/fixtures/integration/golden/enhancements_description_title_override_ref_siblings.json new file mode 100644 index 00000000..541e4688 --- /dev/null +++ b/fixtures/integration/golden/enhancements_description_title_override_ref_siblings.json @@ -0,0 +1,62 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Gadget": { + "type": "object", + "title": "Gadget is a plain referenced model.", + "properties": { + "serial": { + "type": "string", + "x-go-name": "Serial" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/description-title-override" + }, + "Widget": { + "description": "A widget exposed via the public API.", + "type": "object", + "title": "A Public Widget", + "properties": { + "capacity": { + "description": "The maximum capacity, in liters.", + "type": "integer", + "format": "int64", + "maximum": 1000, + "x-go-name": "Capacity" + }, + "gadget": { + "description": "The attached gadget, described for API consumers.", + "title": "Gadget Ref", + "$ref": "#/definitions/Gadget" + }, + "id": { + "description": "The unique widget identifier.", + "type": "string", + "x-go-name": "ID" + }, + "label": { + "description": "Human-readable label shown to API consumers.", + "type": "string", + "title": "Display Label", + "x-go-name": "Label" + }, + "notes": { + "description": "Free-form notes about the widget.\nThey may span several lines, all folded into one description.", + "type": "string", + "x-go-name": "Notes" + }, + "plain": { + "description": "Plain keeps its godoc description because it carries no override", + "type": "string", + "x-go-name": "Plain" + }, + "suppressed": { + "type": "string", + "x-go-name": "Suppressed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/description-title-override" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_description_title_override_responses.json b/fixtures/integration/golden/enhancements_description_title_override_responses.json new file mode 100644 index 00000000..fd3ccd46 --- /dev/null +++ b/fixtures/integration/golden/enhancements_description_title_override_responses.json @@ -0,0 +1,31 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "ErrorBody": { + "type": "object", + "title": "ErrorBody is the error payload returned in the response body.", + "properties": { + "message": { + "type": "string", + "x-go-name": "Message" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/description-title-override-responses" + } + }, + "responses": { + "errorResponse": { + "description": "The error payload returned to API consumers.", + "schema": { + "$ref": "#/definitions/ErrorBody" + }, + "headers": { + "X-Error-Code": { + "type": "string", + "description": "The machine-readable error code." + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_godoc_links.json b/fixtures/integration/golden/enhancements_godoc_links.json new file mode 100644 index 00000000..6dfe7f5d --- /dev/null +++ b/fixtures/integration/golden/enhancements_godoc_links.json @@ -0,0 +1,64 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Gadget": { + "type": "object", + "title": "Gadget is a small component.", + "properties": { + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/godoc-links" + }, + "Ledger": { + "type": "object", + "title": "Ledger records stock movements.", + "properties": { + "entries": { + "type": "integer", + "format": "int64", + "x-go-name": "Entries" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/godoc-links/inventory" + }, + "Order": { + "type": "object", + "title": "Order captures a purchase.", + "properties": { + "customer_name": { + "description": "CustName is the buyer.", + "type": "string", + "x-go-name": "CustName" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/godoc-links" + }, + "gizmo": { + "description": "More detail mentions a Gadget pointer, a Ledger, and an\nunknown sprocket.", + "type": "object", + "title": "Gizmo is the primary Gadget holder and references Order.customer_name.", + "properties": { + "holder": { + "description": "Holder points at the Gadget that owns this widget.", + "type": "string", + "x-go-name": "Holder" + }, + "index": { + "description": "Index is element [0] in the [see notes] list; the [id] stays bare.", + "type": "integer", + "format": "int64", + "x-go-name": "Index" + }, + "ledger": { + "$ref": "#/definitions/Ledger" + } + }, + "x-go-name": "Widget", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/godoc-links" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_godoc_links_off.json b/fixtures/integration/golden/enhancements_godoc_links_off.json new file mode 100644 index 00000000..d7daaa5d --- /dev/null +++ b/fixtures/integration/golden/enhancements_godoc_links_off.json @@ -0,0 +1,64 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Gadget": { + "type": "object", + "title": "Gadget is a small component.", + "properties": { + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/godoc-links" + }, + "Ledger": { + "type": "object", + "title": "Ledger records stock movements.", + "properties": { + "entries": { + "type": "integer", + "format": "int64", + "x-go-name": "Entries" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/godoc-links/inventory" + }, + "Order": { + "type": "object", + "title": "Order captures a purchase.", + "properties": { + "customer_name": { + "description": "CustName is the buyer.", + "type": "string", + "x-go-name": "CustName" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/godoc-links" + }, + "gizmo": { + "description": "More detail mentions a [*Gadget] pointer, a [inventory.Ledger], and an\nunknown [Sprocket].\n\n[the spec]: https://example.com/spec", + "type": "object", + "title": "Widget is the primary [Gadget] holder and references [Order.CustName].", + "properties": { + "holder": { + "description": "Holder points at the [Gadget] that owns this widget.", + "type": "string", + "x-go-name": "Holder" + }, + "index": { + "description": "Index is element [0] in the [see notes] list; the [id] stays bare.", + "type": "integer", + "format": "int64", + "x-go-name": "Index" + }, + "ledger": { + "$ref": "#/definitions/Ledger" + } + }, + "x-go-name": "Widget", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/godoc-links" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_inner_markdown.json b/fixtures/integration/golden/enhancements_inner_markdown.json new file mode 100644 index 00000000..521ff261 --- /dev/null +++ b/fixtures/integration/golden/enhancements_inner_markdown.json @@ -0,0 +1,19 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Widget": { + "description": "Widgets support **markdown** in their description:\n\n| name | purpose |\n|------|---------|\n| foo | bars |\n\n- point one\n- point two", + "type": "object", + "title": "Widget is a thing.", + "properties": { + "name": { + "description": "The name must be:\n\n 1. unique\n 2. lowercase", + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/inner-markdown" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_interface_no_mangle_on.json b/fixtures/integration/golden/enhancements_interface_no_mangle_on.json new file mode 100644 index 00000000..897a2d4c --- /dev/null +++ b/fixtures/integration/golden/enhancements_interface_no_mangle_on.json @@ -0,0 +1,26 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Account": { + "type": "object", + "title": "Account is a no-mangle witness interface.", + "properties": { + "CreatedAt": { + "description": "Default path, no override. The mangler produces \"createdAt\"; with the\nopt-out the verbatim \"CreatedAt\" is emitted.", + "type": "string" + }, + "ID": { + "description": "Default path, no override. The mangler produces \"id\"; with the opt-out\nthe verbatim \"ID\" is emitted.", + "type": "string" + }, + "explicit_name": { + "description": "\nA swagger:name override is taken verbatim whether or not the mangler is\nskipped — re-mangling would camelCase this to \"explicitName\".", + "type": "string", + "x-go-name": "OverriddenField" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/interface-no-mangle" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_name_from_tags_form_first.json b/fixtures/integration/golden/enhancements_name_from_tags_form_first.json new file mode 100644 index 00000000..27e92acc --- /dev/null +++ b/fixtures/integration/golden/enhancements_name_from_tags_form_first.json @@ -0,0 +1,85 @@ +{ + "swagger": "2.0", + "paths": { + "/items": { + "get": { + "tags": [ + "items" + ], + "summary": "List items.", + "operationId": "listItems", + "parameters": [ + { + "type": "string", + "x-go-name": "SortKey", + "description": "SortKey selects the sort column.", + "name": "sort_key", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "x-go-name": "PageSize", + "description": "PageSize bounds the page length.", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/listResponse" + } + } + } + } + }, + "definitions": { + "Filter": { + "type": "object", + "title": "Filter is a model whose fields carry differing json: and form: names.", + "properties": { + "label": { + "description": "Label has only a json tag — same name under every setting.", + "type": "string", + "x-go-name": "Label" + }, + "page_size": { + "description": "PageSize keeps its json omitempty directive whichever tag names it.", + "type": "integer", + "format": "int64", + "x-go-name": "PageSize" + }, + "sort_key": { + "description": "SortKey is named \"sortKey\" by default, \"sort_key\" under form-first.", + "type": "string", + "x-go-name": "SortKey" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/name-from-tags" + } + }, + "responses": { + "listResponse": { + "description": "ListResponse carries response headers with differing json: and form: names.", + "schema": { + "type": "object", + "properties": { + "items": { + "description": "Items is the page of matching filters.", + "type": "array", + "items": { + "$ref": "#/definitions/Filter" + }, + "x-go-name": "Items" + } + } + }, + "headers": { + "x_request_id": { + "type": "string", + "description": "RequestID is a correlation header." + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_response_examples_by_mime.json b/fixtures/integration/golden/enhancements_response_examples_by_mime.json new file mode 100644 index 00000000..94ce22ba --- /dev/null +++ b/fixtures/integration/golden/enhancements_response_examples_by_mime.json @@ -0,0 +1,47 @@ +{ + "swagger": "2.0", + "paths": { + "/widgets": { + "get": { + "tags": [ + "widgets" + ], + "summary": "Get a widget.", + "operationId": "getWidgets", + "responses": { + "200": { + "$ref": "#/responses/widgetResponse" + } + } + } + } + }, + "definitions": { + "Widget": { + "type": "object", + "title": "Widget is the response payload.", + "properties": { + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/response-examples-by-mime" + } + }, + "responses": { + "widgetResponse": { + "description": "WidgetResponse returns a widget, with response-level examples per mime type.", + "schema": { + "$ref": "#/definitions/Widget" + }, + "examples": { + "application/json": { + "count": 3, + "name": "alice" + }, + "application/xml": "\u003cwidget\u003e\u003cname\u003ealice\u003c/name\u003e\u003c/widget\u003e" + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_shared_parameters.json b/fixtures/integration/golden/enhancements_shared_parameters.json new file mode 100644 index 00000000..2ff6d8b5 --- /dev/null +++ b/fixtures/integration/golden/enhancements_shared_parameters.json @@ -0,0 +1,108 @@ +{ + "swagger": "2.0", + "paths": { + "/pets": { + "get": { + "tags": [ + "pets" + ], + "operationId": "listPets", + "parameters": [ + { + "type": "integer", + "format": "int64", + "x-go-name": "Limit", + "description": "Limit caps the number of pets returned.", + "name": "limit", + "in": "query" + }, + { + "$ref": "#/parameters/X-Request-ID" + } + ], + "responses": { + "default": { + "$ref": "#/responses/ErrorResponse" + } + } + }, + "post": { + "tags": [ + "pets" + ], + "operationId": "createPet", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Pet" + } + }, + { + "$ref": "#/parameters/X-API-Key" + } + ], + "responses": { + "default": { + "$ref": "#/responses/ErrorResponse" + } + } + } + } + }, + "definitions": { + "Pet": { + "type": "object", + "title": "Pet is the body model.", + "properties": { + "name": { + "description": "Name of the pet.", + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/shared-parameters" + } + }, + "parameters": { + "X-API-Key": { + "type": "string", + "x-go-name": "APIKey", + "description": "APIKey authorises access.", + "name": "X-API-Key", + "in": "header", + "required": true + }, + "X-Request-ID": { + "type": "string", + "x-go-name": "RequestID", + "description": "RequestID correlates a request across services.", + "name": "X-Request-ID", + "in": "header" + } + }, + "responses": { + "ErrorResponse": { + "description": "ErrorResponse is force-registered at #/responses/ErrorResponse and\nreferenced by both routes' Responses blocks (emitted as a $ref).", + "schema": { + "type": "object", + "properties": { + "code": { + "description": "Code is a machine-readable error code.", + "type": "integer", + "format": "int64", + "x-go-name": "Code" + }, + "message": { + "description": "Message is a human-readable error message.", + "type": "string", + "x-go-name": "Message" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_shared_parameters_conflict.json b/fixtures/integration/golden/enhancements_shared_parameters_conflict.json new file mode 100644 index 00000000..7708d655 --- /dev/null +++ b/fixtures/integration/golden/enhancements_shared_parameters_conflict.json @@ -0,0 +1,74 @@ +{ + "swagger": "2.0", + "paths": { + "/a": { + "get": { + "tags": [ + "alpha" + ], + "operationId": "listA", + "responses": { + "default": { + "$ref": "#/responses/ErrorResponse" + } + } + } + }, + "/b": { + "get": { + "tags": [ + "beta" + ], + "operationId": "listB", + "responses": { + "default": { + "$ref": "#/responses/ErrorResponse" + } + } + } + } + }, + "definitions": { + "Status": { + "description": "Status is a model at #/definitions/Status, coexisting with the shared\nparameter #/parameters/Status (cross-namespace non-conflict witness).", + "type": "object", + "properties": { + "state": { + "description": "State of the resource.", + "type": "string", + "x-go-name": "State" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/shared-parameters-conflict/pkga" + } + }, + "parameters": { + "Status": { + "type": "string", + "name": "Status", + "in": "header" + }, + "X-Token": { + "type": "string", + "x-go-name": "Token", + "name": "X-Token", + "in": "header" + } + }, + "responses": { + "ErrorResponse": { + "description": "ErrorResponse force-registers #/responses/ErrorResponse. pkgb declares\nthe same response short name with a different body — pkga wins.", + "schema": { + "type": "object", + "properties": { + "code": { + "description": "Code is a machine-readable error code.", + "type": "integer", + "format": "int64", + "x-go-name": "Code" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_shared_parameters_overrides.json b/fixtures/integration/golden/enhancements_shared_parameters_overrides.json new file mode 100644 index 00000000..389e14fb --- /dev/null +++ b/fixtures/integration/golden/enhancements_shared_parameters_overrides.json @@ -0,0 +1,55 @@ +{ + "swagger": "2.0", + "paths": { + "/things": { + "get": { + "tags": [ + "things" + ], + "operationId": "listThings", + "parameters": [ + { + "$ref": "#/parameters/X-Correlation-ID" + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "post": { + "tags": [ + "things" + ], + "operationId": "createThing", + "parameters": [ + { + "$ref": "#/parameters/X-API-Key" + } + ], + "responses": { + "201": { + "description": "Created" + } + } + } + } + }, + "parameters": { + "X-API-Key": { + "type": "string", + "x-go-name": "APIKey", + "description": "APIKey authorises access.", + "name": "X-API-Key", + "in": "header" + }, + "X-Correlation-ID": { + "type": "string", + "x-go-name": "RequestID", + "description": "RequestID correlates a request across services.", + "name": "X-Correlation-ID", + "in": "header" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_shared_parameters_pathitem.json b/fixtures/integration/golden/enhancements_shared_parameters_pathitem.json new file mode 100644 index 00000000..59685c4d --- /dev/null +++ b/fixtures/integration/golden/enhancements_shared_parameters_pathitem.json @@ -0,0 +1,82 @@ +{ + "swagger": "2.0", + "paths": { + "/pets": { + "get": { + "tags": [ + "pets" + ], + "operationId": "listPets", + "parameters": [ + { + "type": "string", + "x-go-name": "APIKey", + "description": "APIKey overrides the path-item parameter to make it optional here.", + "name": "X-API-Key", + "in": "header" + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "post": { + "tags": [ + "pets" + ], + "operationId": "createPet", + "responses": { + "201": { + "description": "Created" + } + } + }, + "parameters": [ + { + "type": "string", + "x-go-name": "APIKey", + "description": "APIKey authorises access to the pet store.", + "name": "X-API-Key", + "in": "header", + "required": true + }, + { + "$ref": "#/parameters/X-Request-ID" + } + ] + }, + "/pets/{id}": { + "get": { + "tags": [ + "pets" + ], + "operationId": "getPet", + "parameters": [ + { + "type": "string", + "x-go-name": "ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + } + }, + "parameters": { + "X-Request-ID": { + "type": "string", + "x-go-name": "RequestID", + "description": "RequestID correlates a request across services.", + "name": "X-Request-ID", + "in": "header" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_shared_parameters_prune.json b/fixtures/integration/golden/enhancements_shared_parameters_prune.json new file mode 100644 index 00000000..879b7345 --- /dev/null +++ b/fixtures/integration/golden/enhancements_shared_parameters_prune.json @@ -0,0 +1,46 @@ +{ + "swagger": "2.0", + "paths": { + "/p": { + "get": { + "tags": [ + "prune" + ], + "operationId": "listP", + "parameters": [ + { + "$ref": "#/parameters/X-Used" + } + ], + "responses": { + "default": { + "$ref": "#/responses/UsedResponse" + } + } + } + } + }, + "parameters": { + "X-Used": { + "type": "string", + "x-go-name": "Used", + "name": "X-Used", + "in": "header" + } + }, + "responses": { + "UsedResponse": { + "description": "UsedResponse is referenced by listP's Responses block → survives.", + "schema": { + "type": "object", + "properties": { + "message": { + "description": "OK message.", + "type": "string", + "x-go-name": "Message" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_shared_parameters_prune_cascade.json b/fixtures/integration/golden/enhancements_shared_parameters_prune_cascade.json new file mode 100644 index 00000000..6c7eabd4 --- /dev/null +++ b/fixtures/integration/golden/enhancements_shared_parameters_prune_cascade.json @@ -0,0 +1,54 @@ +{ + "swagger": "2.0", + "paths": { + "/c": { + "get": { + "tags": [ + "cascade" + ], + "operationId": "listC", + "responses": { + "default": { + "$ref": "#/responses/UsedResponse" + } + } + } + } + }, + "definitions": { + "Shared": { + "description": "Shared is reached from BOTH Survivor (kept) and Orphan (pruned). It must\nsurvive, because the surviving Survivor still references it — the cascade\nprunes only what nothing reachable keeps alive.", + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int64", + "x-go-name": "Code" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/shared-parameters-prune-cascade" + }, + "Survivor": { + "type": "object", + "title": "Survivor is reached from UsedResponse's body and survives the prune.", + "properties": { + "name": { + "type": "string", + "x-go-name": "Name" + }, + "shared": { + "$ref": "#/definitions/Shared" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/shared-parameters-prune-cascade" + } + }, + "responses": { + "UsedResponse": { + "description": "UsedResponse is referenced by listC's Responses block → survives, keeping\nSurvivor (and transitively Shared).", + "schema": { + "$ref": "#/definitions/Survivor" + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_shared_parameters_yaml.json b/fixtures/integration/golden/enhancements_shared_parameters_yaml.json new file mode 100644 index 00000000..2fd956a2 --- /dev/null +++ b/fixtures/integration/golden/enhancements_shared_parameters_yaml.json @@ -0,0 +1,51 @@ +{ + "swagger": "2.0", + "paths": { + "/a": { + "get": { + "summary": "resolving refs", + "operationId": "opA", + "parameters": [ + { + "$ref": "#/parameters/X-Request-ID" + } + ], + "responses": { + "default": { + "$ref": "#/responses/ErrorResponse" + } + } + } + }, + "/b": { + "get": { + "summary": "dangling refs", + "operationId": "opB", + "responses": {} + } + } + }, + "parameters": { + "X-Request-ID": { + "type": "string", + "x-go-name": "RequestID", + "name": "X-Request-ID", + "in": "header" + } + }, + "responses": { + "ErrorResponse": { + "description": "ErrorResponse registers #/responses/ErrorResponse.", + "schema": { + "type": "object", + "properties": { + "message": { + "description": "Message is a human-readable error message.", + "type": "string", + "x-go-name": "Message" + } + } + } + } + } +} \ No newline at end of file diff --git a/hack/doc-site/hugo/layouts/partials/custom-header.html b/hack/doc-site/hugo/layouts/partials/custom-header.html index d6507539..7aef11f9 100644 --- a/hack/doc-site/hugo/layouts/partials/custom-header.html +++ b/hack/doc-site/hugo/layouts/partials/custom-header.html @@ -57,4 +57,129 @@ margin-top: 0.25rem; font-size: 0.8rem; } +/* examplelive: the right card's Spec | SwaggerUI tabs (tabbed-example pilot). */ +.examplelive .el-right { min-width: 0; } +.examplelive .el-tabs { + display: flex; + gap: 0.25rem; + margin-bottom: 0.5rem; +} +.examplelive .el-tab { + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.25rem 0.85rem; + border: 1px solid rgba(127, 127, 127, 0.4); + border-radius: 4px 4px 0 0; + background: transparent; + color: inherit; + opacity: 0.55; + cursor: pointer; +} +.examplelive .el-tab.active { + opacity: 1; + border-bottom-color: transparent; + background: rgba(127, 127, 127, 0.12); +} +.examplelive .el-panel { display: none; } +.examplelive .el-panel.active { display: block; } + diff --git a/hack/doc-site/hugo/layouts/shortcodes/example.html b/hack/doc-site/hugo/layouts/shortcodes/example.html index ec9d2c7b..9f77bc3a 100644 --- a/hack/doc-site/hugo/layouts/shortcodes/example.html +++ b/hack/doc-site/hugo/layouts/shortcodes/example.html @@ -5,7 +5,7 @@ code-block partial, so neither can drift from real scanner output. The grid collapses to stacked panes on narrow screens (see the - .example-grid rule in themes/codescan-assets/css). + .example-grid rule in custom-header.html). Parameters: go (required) Go source path relative to docs/examples. @@ -20,10 +20,18 @@ jsonlang Chroma lexer for the right pane. Default "json". jsonlabel Caption above the right pane. Default "Generated spec". - nolink "true" to suppress the "Full source" links on both panes. + full Optional whole-spec golden (path relative to docs/examples). When + set, the RIGHT pane becomes a tabbed card — "Spec" (the fragment + above, default) and "SwaggerUI" (this whole spec rendered live). + A script in custom-header.html lazily renders Swagger UI on first + reveal. Only valid for whole Swagger 2.0 documents; each live + example renders independently (unique container id). + + nolink "true" to suppress the "Full source" links on the panes. */ -}} {{- $nolink := eq (.Get "nolink") "true" -}} -
+{{- $full := .Get "full" -}} +
{{ .Get "golabel" | default "Annotated Go" }}
{{- partial "code-block.html" (dict @@ -35,15 +43,39 @@ "position" .Position ) -}}
-
-
{{ .Get "jsonlabel" | default "Generated spec" }}
- {{- partial "code-block.html" (dict - "file" (.Get "json") - "lang" (.Get "jsonlang" | default "json") - "region" (.Get "jsonregion") - "lines" (.Get "jsonlines") - "nolink" $nolink - "position" .Position - ) -}} +
+ {{- if $full -}} + {{- $id := substr (md5 (printf "%s-%s-%s-%s" (.Get "go") (.Get "goregion") (.Get "json") $full)) 0 10 -}} + {{- $rsc := resources.Get (printf "examples/%s" $full) -}} + {{- if not $rsc }}{{ errorf "example at %s: full spec %q not found under assets/examples" .Position $full }}{{ end -}} + {{- .Page.Store.Set "hasOpenApi" true -}} +
+ + +
+
+ {{- partial "code-block.html" (dict + "file" (.Get "json") + "lang" (.Get "jsonlang" | default "json") + "region" (.Get "jsonregion") + "lines" (.Get "jsonlines") + "nolink" $nolink + "position" .Position + ) -}} +
+
+
+
+ {{- else -}} +
{{ .Get "jsonlabel" | default "Generated spec" }}
+ {{- partial "code-block.html" (dict + "file" (.Get "json") + "lang" (.Get "jsonlang" | default "json") + "region" (.Get "jsonregion") + "lines" (.Get "jsonlines") + "nolink" $nolink + "position" .Position + ) -}} + {{- end -}}
diff --git a/internal/builders/common/builder.go b/internal/builders/common/builder.go index afd665c2..0bd4e303 100644 --- a/internal/builders/common/builder.go +++ b/internal/builders/common/builder.go @@ -1,17 +1,18 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -// Package common holds shared per-Builder state every concrete -// per-decl builder (schema, parameters, responses, routes, -// operations, spec) embeds. See [./README.md](./README.md) for the -// long-form maintainer notes on cache scope, diagnostic posture, and -// the post-decl queue's double-dedup design. +// Package common holds shared per-Builder state every concrete per-decl builder (schema, +// parameters, responses, routes, operations, spec) embeds. +// +// See [./README.md](./README.md) for the long-form maintainer notes on cache scope, diagnostic +// posture, and the post-decl queue's double-dedup design. package common import ( "go/ast" "go/token" + "github.com/go-openapi/codescan/internal/builders/godoclink" "github.com/go-openapi/codescan/internal/ifaces" "github.com/go-openapi/codescan/internal/parsers/grammar" "github.com/go-openapi/codescan/internal/scanner" @@ -20,8 +21,8 @@ import ( // Builder holds the per-decl state shared across every concrete builder via embedding. // -// See [§blockcache], [§diagnostics], and [§postdecls] for the cache scope, accumulator -// posture, and discovery queue's dedup design. +// See [§blockcache], [§diagnostics], and [§postdecls] for the cache scope, accumulator posture, +// and discovery queue's dedup design. // // [§blockcache]: https://github.com/go-openapi/codescan/blob/master/internal/common/README.md#blockcache // [§diagnostics]: https://github.com/go-openapi/codescan/blob/master/internal/common/README.md#diagnostics @@ -47,8 +48,8 @@ func New(ctx *scanner.ScanCtx, decl *scanner.EntityDecl) *Builder { } } -// PostDeclarations returns the post-decl queue accumulated by this -// Builder during a Build pass, in source order. +// PostDeclarations returns the post-decl queue accumulated by this Builder during a Build pass, in +// source order. // // See [§postdecls](./README.md#postdecls). func (s *Builder) PostDeclarations() []*scanner.EntityDecl { @@ -63,30 +64,31 @@ func (s *Builder) PostDeclarations() []*scanner.EntityDecl { // // # Details // -// See [§diagnostics](./README.md#diagnostics) — accumulator ordering, -// dedup posture, and the LSP-evolution caveat (the diagnostic surface -// is expected to widen once IDE integration matures). +// See [§diagnostics](./README.md#diagnostics) — accumulator ordering, dedup posture, and the +// LSP-evolution caveat (the diagnostic surface is expected to widen once IDE integration matures). func (s *Builder) Diagnostics() []grammar.Diagnostic { return s.diagnostics } -// RecordDiagnostic accumulates one diagnostic on the Builder and -// fires the consumer's [Options.OnDiagnostic] callback when wired. +// RecordDiagnostic accumulates one diagnostic on the Builder and fires the consumer's +// [Options.OnDiagnostic] callback when wired. // -// Walker.Diagnostic is bound to this method, so grammar-level -// warnings flow through the same accumulator as builder-level ones. +// Walker.Diagnostic is bound to this method, so grammar-level warnings flow through the same +// accumulator as builder-level ones. func (s *Builder) RecordDiagnostic(d grammar.Diagnostic) { s.diagnostics = append(s.diagnostics, d) s.Ctx.EmitDiagnostic(d) } -// WarnStrippedPathRegex records a warning that one or more inline -// regex path-parameter constraints (`{id:[0-9]+}`) were stripped to the -// bare `{id}` template form. OpenAPI 2.0 path templating follows -// RFC 6570 URI Template Level-1 expansion (simple `{name}` -// substitution) only — it cannot express regex/operator constraints — -// so the route is still emitted, with the constraint dropped. No-op -// when params is empty. Shared by the routes and operations builders. +// WarnStrippedPathRegex records a warning that one or more inline regex path-parameter constraints +// (`{id:[0-9]+}`) were stripped to the bare `{id}` template form. +// +// OpenAPI 2.0 path templating follows RFC 6570 URI Template Level-1 expansion (simple `{name}` +// substitution) only — it cannot express regex/operator constraints — so the route is still +// emitted, with the constraint dropped. +// No-op when params is empty. +// +// Shared by the routes and operations builders. func (s *Builder) WarnStrippedPathRegex(pos token.Pos, params []string) { if len(params) == 0 { return @@ -101,19 +103,16 @@ func (s *Builder) WarnStrippedPathRegex(pos token.Pos, params []string) { )) } -// ParseBlocks returns the cached grammar.Block slice for cg (one -// entry per annotation), parsing on first access and memoising the -// result. +// ParseBlocks returns the cached grammar.Block slice for cg (one entry per annotation), parsing on +// first access and memoising the result. // -// Always returns a non-nil slice with at least one Block, so -// consumers can call [Block.AnnotationKind], [Block.AnnotationArg] / etc. -// unconditionally on the first element. +// Always returns a non-nil slice with at least one Block, so consumers can call +// [Block.AnnotationKind], [Block.AnnotationArg] / etc. unconditionally on the first element. // // # Details // -// See [§blockcache](./README.md#blockcache) — memoisation scope, -// why ParseAll is preferred over Parse, and the per-Builder -// (single-goroutine) lifetime that obviates synchronisation. +// See [§blockcache](./README.md#blockcache) — memoisation scope, why ParseAll is preferred over +// Parse, and the per-Builder (single-goroutine) lifetime that obviates synchronisation. func (s *Builder) ParseBlocks(cg *ast.CommentGroup) []grammar.Block { parser := grammar.NewParser(s.Ctx.FileSet(), grammar.WithSingleLineCommentAsDescription(s.Ctx.SingleLineCommentAsDescription())) @@ -140,16 +139,99 @@ 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. Nil and Ident-less -// decls are silently ignored. +// OverrideValue is an optional swagger:title / swagger:description override harvested from a +// comment group. +// +// Present=false → annotation absent (fall back to the godoc-derived value); Present=true with +// Value=="" → explicit empty, the deliberate godoc-suppression affordance (design D7). +// See .claude/plans/features/swagger-description-override-design.md. +type OverrideValue struct { + Value string + Present bool + Pos token.Position +} + +// HarvestOverrides scans a comment group's sibling classifier blocks for the swagger:title / +// swagger:description override annotations. +// +// Last occurrence wins. +// This is a pure harvest: the diagnostic policy — the empty-override warning, and the +// context-invalid rejection of swagger:title where a target has no title (responses / headers) — +// is left to each consumer, since it differs per builder. +func (s *Builder) HarvestOverrides(cg *ast.CommentGroup) (title, desc OverrideValue) { + for _, b := range s.ParseBlocks(cg) { + switch b.AnnotationKind() { //nolint:exhaustive // only the two override kinds are relevant here + case grammar.AnnTitle: + arg, _ := b.AnnotationArg() + title = OverrideValue{Value: arg, Present: true, Pos: b.Pos()} + case grammar.AnnDescription: + arg, _ := b.AnnotationArg() + desc = OverrideValue{Value: arg, Present: true, Pos: b.Pos()} + } + } + return title, desc +} + +// WarnEmptyOverride raises scan.empty-override when an override is present with an empty value. +// +// The empty value is still applied by the caller — empty is the deliberate godoc-suppression +// affordance — but the case is flagged in case the marker was left bare by mistake (design D7). +// Emitted at the consumption point rather than in the parser: sibling classifier blocks are not +// Walk-ed, so a grammar-stored diagnostic would not reach OnDiagnostic. +func (s *Builder) WarnEmptyOverride(kind grammar.AnnotationKind, ov OverrideValue) { + if !ov.Present || ov.Value != "" { + return + } + s.RecordDiagnostic(grammar.Warnf(ov.Pos, grammar.CodeEmptyOverride, + "swagger:%s override is empty: the godoc-derived value is suppressed", kind)) +} + +// CleanGoDoc applies godoc-syntax filtering (Options.CleanGoDoc) to godoc- derived prose, returning +// text unchanged when the option is off. +// +// It MUST be applied only to godoc-derived title / description text — never to author- written +// swagger:title / swagger:description override values, which are deliberate and harvested through +// HarvestOverrides. +// +// Resolvable doc-links are recomposed (via markers; see godoclink) to the referenced schema's +// exposed name; the rest are humanized. +func (s *Builder) CleanGoDoc(text string) string { + if !s.Ctx.CleanGoDoc() { + return text + } + + return godoclink.Clean(text, godoclink.Options{ + Mangler: s.Ctx.Mangler(), + Resolver: s.godocResolver(), + }) +} + +// CleanGoDocSelf is CleanGoDoc plus leading self-name recomposition: the decl's own +// godoc-convention leading name ("Widget" in "Widget does things") is recomposed to its exposed +// definition name. +// +// Use it for a declaration's title / description; use CleanGoDoc for field / member prose. +func (s *Builder) CleanGoDocSelf(text string) string { + if !s.Ctx.CleanGoDoc() { + return text + } + + return godoclink.Clean(text, godoclink.Options{ + Mangler: s.Ctx.Mangler(), + Resolver: s.godocResolver(), + Self: s.godocSelf(), + }) +} + +// 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. +// Nil and Ident-less decls are silently ignored. // // # Details // -// See [§postdecls](./README.md#postdecls) — per-Builder dedup index -// and the second dedup applied at consumption time by -// spec.Builder.buildDiscovered. +// See [§postdecls](./README.md#postdecls) — per-Builder dedup index and the second dedup applied +// at consumption time by spec.Builder.buildDiscovered. func (s *Builder) AppendPostDecl(decl *scanner.EntityDecl) { if decl == nil || decl.Ident == nil { return @@ -164,16 +246,17 @@ func (s *Builder) AppendPostDecl(decl *scanner.EntityDecl) { s.postDecls = append(s.postDecls, decl) } -// ResetPostDeclarations drops every decl this Builder enqueued during -// the current Build pass. Used by the SimpleSchema catch-at-exit -// validator: when a non-body parameter / response-header element -// dissolves an illegal $ref, the decl that MakeRef discovered for that -// ref is a byproduct of the now-removed reference and would otherwise -// linger as an orphan definition (go-swagger#1088). A single-type Build -// renders exactly one target, so every queued decl is reachable only -// through it; clearing the whole queue is correct. A decl genuinely -// referenced elsewhere is re-discovered by that other site's Builder -// and deduplicated by the orchestrator. +// ResetPostDeclarations drops every decl this Builder enqueued during the current Build pass. +// +// Used by the SimpleSchema catch-at-exit validator: when a non-body parameter / response-header +// element dissolves an illegal $ref, the decl that MakeRef discovered for that ref is a byproduct +// of the now-removed reference and would otherwise linger as an orphan definition +// (go-swagger#1088). +// +// A single-type Build renders exactly one target, so every queued decl is reachable only through +// it; clearing the whole queue is correct. +// A decl genuinely referenced elsewhere is re-discovered by that other site's Builder and +// deduplicated by the orchestrator. // // # Details // @@ -183,22 +266,21 @@ func (s *Builder) ResetPostDeclarations() { s.postDeclSet = nil } -// 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. +// 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. // // # Details // -// See [§makeref](./README.md#makeref) — why the operation lives on -// the common base and what kinds of cross-cutting refinements that -// shape enables. +// See [§makeref](./README.md#makeref) — why the operation lives on the common base and what +// kinds of cross-cutting refinements that shape enables. func (s *Builder) MakeRef(decl *scanner.EntityDecl, prop ifaces.SwaggerTypable) error { - // Emit the fully-qualified identity key (pkgpath/name), not the bare - // short name: this keeps distinct Go types from colliding before the - // spec.Builder's reduce stage shortens names back. See - // .claude/plans/name-identity-cyclic-ref.md §9.1/§12.1. + // Emit the fully-qualified identity key (pkgpath/name), not the bare short name: this keeps + // distinct Go types from colliding before the spec.Builder's reduce stage shortens names back. + // See .claude/plans/name-identity-cyclic-ref.md §9.1/§12.1. ref, err := oaispec.NewRef("#/definitions/" + decl.DefKey()) if err != nil { return err diff --git a/internal/builders/common/embed.go b/internal/builders/common/embed.go index 69483ced..812aff79 100644 --- a/internal/builders/common/embed.go +++ b/internal/builders/common/embed.go @@ -10,12 +10,12 @@ import ( "github.com/go-openapi/codescan/internal/parsers/grammar" ) -// EmbedInheritance carries the doc-comment directives an embedded -// (anonymous) struct field passes down to the members it promotes: the -// `in:` location and the `required:` flag. It is the shared kernel of the -// "annotations on an embed apply to its promoted members" rule, used by -// the schema, parameters, and responses builders so the behaviour is -// identical everywhere. See go-swagger#2701. +// EmbedInheritance carries the doc-comment directives an embedded (anonymous) struct field passes +// down to the members it promotes: the `in:` location and the `required:` flag. +// +// It is the shared kernel of the "annotations on an embed apply to its promoted members" rule, used +// by the schema, parameters, and responses builders so the behaviour is identical everywhere. +// See go-swagger#2701. // // Semantics: // - A member's own `in:`/`required:` always wins; the inherited value is @@ -34,10 +34,10 @@ type EmbedInheritance struct { RequiredSet bool } -// ReadEmbedInheritance merges the `in:`/`required:` directives on an -// embedded field's doc comment into current, returning the context to -// pass down to the fields that embed promotes. The embed's own directives -// override the inherited ones; absent directives carry current through +// ReadEmbedInheritance merges the `in:`/`required:` directives on an embedded field's doc comment +// into current, returning the context to pass down to the fields that embed promotes. +// +// The embed's own directives override the inherited ones; absent directives carry current through // unchanged. // // doc is the embed field's *ast.CommentGroup (nil → current unchanged). @@ -59,16 +59,16 @@ func (s *Builder) ReadEmbedInheritance(doc *ast.CommentGroup, current EmbedInher return next } -// ScanInLocation finds the first `in: X` line in text and returns the -// canonical OAS v2 form of X (when recognised, case-insensitive via -// [grammar.NormalizeIn]) or the raw candidate (when present but -// out-of-vocabulary). The `form` alias is NOT accepted here — it is -// contained to the routes inline-param path. +// ScanInLocation finds the first `in: X` line in text and returns the canonical OAS v2 form of X +// (when recognised, case-insensitive via [grammar.NormalizeIn]) or the raw candidate (when present +// but out-of-vocabulary). +// +// The `form` alias is NOT accepted here — it is contained to the routes inline-param path. // // Shared by the parameters and responses field-signal scanners and by -// [Builder.ReadEmbedInheritance]; `in:` is line-scanned rather than read -// as a grammar Property because grammar attaches pre-annotation lines to -// the following annotation's prose, not its property list. +// [Builder.ReadEmbedInheritance]; `in:` is line-scanned rather than read as a grammar Property +// because grammar attaches pre-annotation lines to the following annotation's prose, not its +// property list. func ScanInLocation(text string) (value string, valid bool, invalid string) { for line := range strings.SplitSeq(text, "\n") { line = strings.TrimSpace(line) @@ -87,9 +87,9 @@ func ScanInLocation(text string) (value string, valid bool, invalid string) { if canonical, ok := grammar.NormalizeIn(v, false); ok { return canonical, 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. + // 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, "" diff --git a/internal/builders/common/godoc.go b/internal/builders/common/godoc.go new file mode 100644 index 00000000..05e1f23c --- /dev/null +++ b/internal/builders/common/godoc.go @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package common + +import ( + "go/ast" + "strconv" + "strings" + + "github.com/go-openapi/codescan/internal/builders/godoclink" + "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/scanner" +) + +// godocResolver builds the doc-link resolver for the declaration currently being built. +// +// It maps a doc-link reference — the bracket content with any leading `*` stripped, e.g. +// "Order.CustName" or "inventory.Ledger" — to the referenced schema's fully-qualified definition +// key plus an exposed field-chain suffix. +// +// A reference resolves only when its leading segment(s) name a scanned model (same-package, or via +// a file import for a `pkg.Type` qualifier); a member segment then resolves to its exposed property +// name. +// +// References that name a non-model (a func registered as an operation, an unknown identifier, a +// non-struct field path) return ok=false, so the caller humanizes the leaf instead. +// Returns nil when there is no usable decl context. +func (s *Builder) godocResolver() godoclink.Resolver { + decl := s.Decl + if decl == nil || decl.File == nil { + return nil + } + obj := decl.Obj() + if obj == nil || obj.Pkg() == nil { + return nil + } + currentPkg := obj.Pkg().Path() + imports := fileImports(decl) + + return func(ref string) (godoclink.Resolution, bool) { + parts := strings.Split(ref, ".") + pkgPath, typeName, fields := currentPkg, parts[0], parts[1:] + if len(parts) > 1 { + if p, ok := imports[parts[0]]; ok { + pkgPath, typeName, fields = p, parts[1], parts[2:] + } + } + + target, ok := s.Ctx.GetModel(pkgPath, typeName) + if !ok { + return godoclink.Resolution{}, false + } + suffix, ok := s.resolveFieldChain(target, fields) + if !ok { + return godoclink.Resolution{}, false + } + + return godoclink.Resolution{DefKey: target.DefKey(), Suffix: suffix}, true + } +} + +// godocSelf describes the declaration being built so godoclink can recompose its leading +// godoc-convention self-name to its own exposed name. +// +// Nil when there is no usable decl. +func (s *Builder) godocSelf() *godoclink.SelfRef { + decl := s.Decl + if decl == nil || decl.Ident == nil || decl.Obj() == nil || decl.Obj().Pkg() == nil { + return nil + } + _, goName := decl.Names() + + return &godoclink.SelfRef{Name: goName, DefKey: decl.DefKey()} +} + +// resolveFieldChain maps a member chain on decl's struct to its exposed property suffix (e.g. +// [".customer_name"]). +// +// An empty chain is the bare-type case (suffix ""). +// Only a single member level is resolved in this phase; deeper chains, non-struct targets, ignored +// / un-named fields return ok=false so the caller humanizes the leaf. +func (s *Builder) resolveFieldChain(decl *scanner.EntityDecl, fields []string) (string, bool) { + if len(fields) == 0 { + return "", true + } + if len(fields) > 1 { + return "", false + } + + st, ok := structAST(decl) + if !ok { + return "", false + } + for _, f := range st.Fields.List { + for _, n := range f.Names { + if n.Name != fields[0] { + continue + } + name, ignore, _, _, err := resolvers.ParseFieldTag(f, n.Name, s.Ctx.NameFromTags()) + if err != nil || ignore || name == "" { + return "", false + } + + return "." + name, true + } + } + + return "", false +} + +// structAST returns decl's struct AST when it is a struct type declaration. +func structAST(decl *scanner.EntityDecl) (*ast.StructType, bool) { + if decl.Spec == nil { + return nil, false + } + st, ok := decl.Spec.Type.(*ast.StructType) + + return st, ok +} + +// fileImports maps each usable import's local name to its package path for the file enclosing decl, +// so a `pkg.Type` doc-link can resolve `pkg` to a path. +// +// Blank, dot and unresolvable imports are skipped. +func fileImports(decl *scanner.EntityDecl) map[string]string { + out := make(map[string]string) + for _, imp := range decl.File.Imports { + path, err := strconv.Unquote(imp.Path.Value) + if err != nil { + continue + } + + name := "" + switch { + case imp.Name != nil: + name = imp.Name.Name + case decl.Pkg != nil: + if p, ok := decl.Pkg.Imports[path]; ok { + name = p.Name + } + } + if name == "" || name == "_" || name == "." { + continue + } + out[name] = path + } + + return out +} diff --git a/internal/builders/godoclink/README.md b/internal/builders/godoclink/README.md new file mode 100644 index 00000000..17a17fc2 --- /dev/null +++ b/internal/builders/godoclink/README.md @@ -0,0 +1,136 @@ +# godoclink — maintainer notes + +This document is the long-form companion to the `godoclink` package. + +`godoclink` implements the **`Options.CleanGoDoc`** feature: when a title / +description is carried **from a Go doc comment** into the emitted spec, it cleans +godoc-specific syntax that reads as bracket noise, and recomposes resolvable +doc-links to the name the referenced schema is actually **exposed under**. + +It is applied **only to godoc-derived prose** — never to author-written +`swagger:title` / `swagger:description` overrides, which flow through a separate +path (`common.Builder.HarvestOverrides`) and are deliberate. + +The recognizer regexes are adapted from +[`github.com/fredbi/go-fred-mcp/pkg/doc-filters/godoc-filter`]; the key +difference is that this package **rewrites** the prose, whereas that tool +**redacts** (length-preserving blanking) for masking. + +--- + +## Table of contents + +- [§transforms](#transforms) — what is cleaned, and the recognizer +- [§seam](#seam) — why recomposition is split across two build phases +- [§markers](#markers) — the marker format and the round-trip contract +- [§wiring](#wiring) — how builders call in, and the consumption sites +- [§deferred](#deferred) — intentionally-deferred follow-ups + +--- + +## §transforms — what is cleaned + +With `CleanGoDoc` on, godoc-derived prose is run through `Clean`: + +1. **Reference-definition lines dropped** — a line like `[text]: https://…` + (optionally indented) is godoc/markdown link plumbing carrying no prose; the + whole line is removed and the blank run it leaves is folded. +2. **Doc-link spans rewritten** — `[Widget]`, `[pkg.Type]`, `[Order.Field]` + (a leading `*` tolerated). The brackets are stripped and the span replaced by + either the referenced schema's exposed name (see [§seam](#seam)) or, when it + does not resolve, the **humanized** leaf identifier + (`mangling.NameMangler.ToHumanNameLower`, e.g. `[CustName]` → "cust name"). +3. **Leading self-name recomposed** — a declaration's godoc conventionally opens + with its own name (`// Widget does things`). With a `SelfRef`, that leading + word is recomposed to the decl's own exposed name. +4. **Sentence-initial titleizing** — the first identifier of the prose is + restored to sentence case (first rune upper), whatever the exposed name's + actual case (`Widget`+`swagger:model gizmo` → "Gizmo …"). + +**Conservative recognizer.** `docLinkRE` matches only a dotted chain +(`[pkg.Type]`) or an uppercase-led single (`[Widget]`). Ordinary prose brackets +are left intact by construction: `[]byte` (empty), `[0]` (digit-led), +`[see notes]` (spaces), bare-lowercase `[id]` (a lowercase single never names an +exported schema in this phase). + +## §seam — the two-phase split + +Recomposition has two halves that want **opposite** timing: + +- **Which prose is godoc-derived** is known only **here**, at the consumption + seam — `block.PreambleTitle/PreambleDescription/Prose()` are godoc, while + overrides arrive via `HarvestOverrides`. After the build, a `description` is + just a string; provenance is gone. So cleaning must happen at consumption. +- **A referenced schema's final exposed name** is fixed only by the spec + builder's `reduceDefinitionNames()`, which runs **last** (collision renames + shorten the fully-qualified discovery keys). So the final name is unknown at + consumption. + +The bridge: at consumption a resolvable doc-link is replaced by a **marker** +carrying the referenced type's fully-qualified definition key; a post-reduce +pass (`spec.Builder.substituteGodocMarkers` → `SubstituteMarkers`) rewrites each +marker to the final name. This mirrors the existing +`defOrigins → FlushDefOrigins(finalName)` pattern in the scanner: buffer keyed +by fq-key during the build, re-point to final names after reduce. + +## §markers — format and round-trip + +A marker is NUL / Unit-Separator delimited — neither rune can occur in a Go +source comment, so a marker never collides with real prose: + +``` +\x00gl\x1f\x1f\x1f\x1f<0|1>\x00 +``` + +- `defKey` — the referenced type's fully-qualified definition key (the same key + `EntityDecl.DefKey()` produces, so `swagger:model` overrides are honored). +- `suffix` — the exposed field-chain for a member reference (`.customer_name`), + or empty for a bare type. +- `fallback` — the humanized leaf, used when the key turns out **not** to be an + emitted definition (pruned / unresolved). +- titleize bit — sentence-initial position. + +`SubstituteMarkers(text, finalName)` resolves each marker: `finalName+suffix` +when `finalName(defKey)` succeeds, else `fallback`; the titleize bit upper-cases +the first rune. It **guarantees no marker survives** — an unmatched marker +collapses to its fallback. With `CleanGoDoc` off no marker is ever produced, and +`SubstituteMarkers` short-circuits on marker-free text (`HasMarkers`). + +The round-trip (emit via `Clean` with a `Resolver` → `SubstituteMarkers`, +including the pruned-key fallback and a collision rename) is unit-tested in +`markers_test.go`. + +## §wiring — how builders call in + +`Clean(text, Options{Mangler, Resolver, Self})`. The two callers are on the +builder side, gated by `Ctx.CleanGoDoc()`: + +- `common.Builder.CleanGoDoc` / `CleanGoDocSelf` (the latter passes a `Self`, + used for a declaration's title/description; the former for field / member + prose). `common.Builder.godocResolver` builds the `Resolver` from the active + `EntityDecl` (reusing `ScanCtx.GetModel`; same-package + imported lookup; + field → exposed property name via `resolvers.ParseFieldTag` + `NameFromTags`). +- `spec.Builder.cleanGoDoc` — a sibling for the `swagger:meta` Info site (the + spec builder does not embed `common.Builder`); resolution-free there (info + prose rarely names models). + +The nine godoc-prose consumption sites it is wired at: `swagger:meta` Info +title/description; route + inline operation summary/description; response and +response-header description; parameter description; model title/description; +field description (plain and `$ref`-override paths). + +A nil `Resolver` (or nil `Self`) selects resolution-free cleanup — the behavior +for sites without a usable decl context. + +## §deferred — follow-ups + +- **Field-level leading self-name.** Only a declaration's own leading name is + recomposed; a field's leading Go name (`// Holder …`) is left as-is. +- **Nested member chains.** `[Type.A.B]` resolves only the first member level; + deeper chains fall back to humanizing the leaf. +- **Dot-imports.** A `.`-imported package is skipped in import resolution, so a + `[Type]` actually referring to a dot-imported schema is humanized. + +None of these is wrong today — each falls back to the humanized leaf. + +[`github.com/fredbi/go-fred-mcp/pkg/doc-filters/godoc-filter`]: https://github.com/fredbi/go-fred-mcp diff --git a/internal/builders/godoclink/godoclink.go b/internal/builders/godoclink/godoclink.go new file mode 100644 index 00000000..7bba3d76 --- /dev/null +++ b/internal/builders/godoclink/godoclink.go @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package godoclink rewrites godoc-specific syntax that reads as noise when a Go doc comment is +// carried into a Swagger title / description (the Options.CleanGoDoc feature). +// +// Two transforms apply to godoc-derived prose: +// +// - resolution-free cleanup: reference-style link definition lines +// (`[text]: url`) are dropped; godoc doc-link spans (`[Widget]`, +// `[pkg.Type]`, `[Order.Field]`) have their brackets removed and the (leaf) +// identifier humanized via the swag name mangler — e.g. `[CustName]` → +// "cust name"; the first identifier of the prose is restored to sentence +// case; +// - idiom recomposition: when a [Resolver] maps a doc-link (or the leading +// godoc-convention self-name) to an emitted schema, the span is replaced by +// a [marker] carrying that schema's fully-qualified definition key. Markers +// are resolved to the schema's final exposed name by [SubstituteMarkers], +// run after the spec builder has reduced definition names. This two-step +// dance is needed because the final name is only known at the very end of +// the build, whereas which prose is godoc-derived is only known here, at the +// consumption seam. +// +// With a nil [Options.Resolver] (and nil Self), only the resolution-free cleanup runs and no marker +// is produced. +// +// The recognizer regexes are adapted from the battle-tested +// github.com/fredbi/go-fred-mcp/pkg/doc-filters/godoc-filter; the key difference is that this +// package *rewrites* the prose whereas that tool *redacts* (length-preserving blanking) for +// masking. +// +// See .claude/plans/features/godoc-filter-design.md. +package godoclink + +import ( + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "github.com/go-openapi/swag/mangling" +) + +// docLinkRE matches a godoc doc-link span: a dotted identifier chain (`[pkg.Type]`, +// `[Order.Field]`) OR an uppercase-led single (`[Widget]`), tolerating a leading `*` +// (`[*time.Time]`). +// +// Bare-lowercase singles (`[id]`), empty (`[]byte`), digit-led (`[0]`) and multi-word (`[see +// notes]`) spans are deliberately not matched, so ordinary prose brackets are left intact. +var docLinkRE = regexp.MustCompile(`\[\*?\w+(?:\.\w+)+\]|\[\*?[A-Z]\w*\]`) + +// refDefLineRE matches a reference-style link definition line — `[text]: url` on its own line +// (optionally indented). +// +// Such a line is godoc/markdown link plumbing and carries no prose, so the whole line is dropped. +var refDefLineRE = regexp.MustCompile(`^[ \t]*\[[^\]]+\]:[ \t]+\S.*$`) + +// multiNewlineRE collapses a 3+ newline run (left behind by dropping an interior +// reference-definition line) back to a single paragraph break. +var multiNewlineRE = regexp.MustCompile(`\n{3,}`) + +// identRE matches a leading Go-identifier chain (the godoc-convention self-name at the start of a +// doc comment, e.g. "Widget" in "Widget does things"). +var identRE = regexp.MustCompile(`^[\p{L}_][\p{L}\p{N}_]*`) + +// Resolution is the outcome of resolving a doc-link reference to an emitted schema. +// +// DefKey is the referenced type's fully-qualified definition key (whose final exposed name is +// substituted later); Suffix is the already-exposed field chain for a dotted member reference (e.g. +// ".customer_name"), or "" for a bare type reference. +type Resolution struct { + DefKey string + Suffix string +} + +// Resolver maps a doc-link reference — the bracket content with any leading `*` stripped, e.g. +// "Order.CustName" or "pkg.Type" — to a [Resolution]. +// +// It returns ok=false when the reference does not resolve to an emitted schema, in which case the +// caller humanizes the leaf identifier instead. +type Resolver func(ref string) (Resolution, bool) + +// SelfRef describes the declaration whose godoc is being cleaned, so the leading godoc-convention +// self-name ("Widget" in "Widget does things") can be recomposed to the declaration's own exposed +// name. +// +// Name is the Go identifier; DefKey is its fully-qualified definition key. +type SelfRef struct { + Name string + DefKey string +} + +// Options configures Clean. +type Options struct { + // Mangler humanizes leaf identifiers (and marker fallbacks). + // + // Required. + Mangler *mangling.NameMangler + // Resolver, when non-nil, recomposes resolvable doc-links into markers; nil selects + // resolution-free cleanup only. + Resolver Resolver + // Self, when non-nil, enables leading self-name recomposition. + // + // It has effect only together with a non-nil Resolver. + Self *SelfRef +} + +// Clean applies godoc filtering to text. +// +// It is the caller's responsibility to apply Clean ONLY to godoc-derived prose — never to +// author-written swagger:title / swagger:description override text. +// Clean never mutates o. +func Clean(text string, o Options) string { + if text == "" { + return text + } + + text = dropRefDefLines(text) + text = rewrite(text, o) + + return text +} + +// dropRefDefLines removes reference-style link definition lines, collapsing the blank runs a +// mid-text drop would otherwise leave and trimming trailing space. +func dropRefDefLines(text string) string { + if !strings.Contains(text, "]:") { + return text + } + + lines := strings.Split(text, "\n") + kept := make([]string, 0, len(lines)) + dropped := false + for _, ln := range lines { + if refDefLineRE.MatchString(ln) { + dropped = true + continue + } + kept = append(kept, ln) + } + if !dropped { + return text + } + + out := strings.Join(kept, "\n") + // A dropped interior line can leave a 3+ newline run; fold to a paragraph break. + // Trailing whitespace/newlines from a dropped final line are trimmed. + out = multiNewlineRE.ReplaceAllString(out, "\n\n") + + return strings.TrimRight(out, " \t\n") +} + +// span is one stretch of text to replace: a doc-link bracket span or the leading self-name. +type span struct { + lo, hi int + ref string // resolver reference (full inner chain, `*` stripped); self-name = Self.Name + leaf string // trailing identifier, for the humanize fallback + self bool +} + +// rewrite replaces every doc-link span (and, when enabled, the leading self-name) left to right, +// emitting a marker for resolvable references and the humanized leaf otherwise. +func rewrite(text string, o Options) string { + spans := collectSpans(text, o) + if len(spans) == 0 { + return text + } + + var b strings.Builder + b.Grow(len(text)) + last := 0 + for _, s := range spans { + if s.lo < last { + continue // overlap guard (defensive; spans are non-overlapping) + } + b.WriteString(text[last:s.lo]) + b.WriteString(replacement(s, text, o)) + last = s.hi + } + b.WriteString(text[last:]) + + return b.String() +} + +// collectSpans gathers the leading self-name (when it matches Self.Name) and every doc-link bracket +// span, in ascending position order. +func collectSpans(text string, o Options) []span { + var spans []span + + if o.Self != nil && o.Self.Name != "" { + if loc := identRE.FindStringIndex(strings.TrimLeft(text, " \t")); loc != nil { + offset := len(text) - len(strings.TrimLeft(text, " \t")) + lo, hi := offset+loc[0], offset+loc[1] + if text[lo:hi] == o.Self.Name { + spans = append(spans, span{lo: lo, hi: hi, ref: o.Self.Name, leaf: o.Self.Name, self: true}) + } + } + } + + for _, loc := range docLinkRE.FindAllStringIndex(text, -1) { + inner := strings.TrimPrefix(text[loc[0]+1:loc[1]-1], "*") + spans = append(spans, span{lo: loc[0], hi: loc[1], ref: inner, leaf: leafOf(inner)}) + } + + return spans +} + +// replacement computes the substitution text for one span: a marker when the reference resolves to +// a schema, otherwise the humanized leaf (sentence-cased at the start of the prose). +// +// A self-name without a Resolver is left verbatim (its exposed name is unknowable without +// resolution). +func replacement(s span, text string, o Options) string { + titleize := isSentenceStart(text, s.lo) + fallback := o.humanize(s.leaf) + + if o.Resolver != nil { + if s.self { + return encodeMarker(o.Self.DefKey, "", fallback, titleize) + } + if res, ok := o.Resolver(s.ref); ok { + return encodeMarker(res.DefKey, res.Suffix, fallback, titleize) + } + } else if s.self { + return text[s.lo:s.hi] + } + + if titleize { + return capitalizeFirst(fallback) + } + + return fallback +} + +// humanize renders an identifier as lower-case words; a nil mangler is treated as the identity +// (defensive — callers always supply one). +func (o Options) humanize(ident string) string { + if o.Mangler == nil { + return ident + } + + return o.Mangler.ToHumanNameLower(ident) +} + +// leafOf returns the trailing dot-separated segment of a reference chain (`inventory.Ledger` → +// "Ledger"). +func leafOf(ref string) string { + if i := strings.LastIndex(ref, "."); i >= 0 { + return ref[i+1:] + } + + return ref +} + +// isSentenceStart reports whether pos is the first non-space byte of text — the sentence-initial +// position where a substituted name is restored to title case. +func isSentenceStart(text string, pos int) bool { + for i := range pos { + if !unicode.IsSpace(rune(text[i])) { + return false + } + } + + return true +} + +// capitalizeFirst upper-cases the first rune of s, leaving the rest untouched. +func capitalizeFirst(s string) string { + if s == "" { + return s + } + r, size := utf8.DecodeRuneInString(s) + if r == utf8.RuneError { + return s + } + + return string(unicode.ToUpper(r)) + s[size:] +} diff --git a/internal/builders/godoclink/godoclink_test.go b/internal/builders/godoclink/godoclink_test.go new file mode 100644 index 00000000..dfa18591 --- /dev/null +++ b/internal/builders/godoclink/godoclink_test.go @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package godoclink + +import ( + "testing" + + "github.com/go-openapi/swag/mangling" + "github.com/go-openapi/testify/v2/assert" +) + +func TestClean(t *testing.T) { + m := mangling.NewNameMangler() + + cases := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"no brackets", "A plain sentence with no links.", "A plain sentence with no links."}, + + // doc-link spans: brackets stripped + leaf humanized. + {"single uppercase mid-sentence", "Owned by a [Person] here.", "Owned by a person here."}, + {"dotted leaf", "Tracked in [inventory.Ledger] now.", "Tracked in ledger now."}, + {"type.field leaf", "See [Order.CustName] for it.", "See cust name for it."}, + {"pointer span", "Wraps a [*time.Time] value.", "Wraps a time value."}, + + // sentence-initial substitution is restored to sentence case. + {"leading link titleized", "[Widget] does things.", "Widget does things."}, + {"leading dotted titleized", "[inventory.Ledger] holds rows.", "Ledger holds rows."}, + {"leading after spaces", " [Person] owns it.", " Person owns it."}, + + // reference-style link definition lines are dropped. + { + "ref def line dropped", + "A widget.\n\n[the spec]: https://example.com/spec", + "A widget.", + }, + { + "ref def interior line folded", + "Intro line.\n\n[ref]: https://x.test\n\nOutro line.", + "Intro line.\n\nOutro line.", + }, + + // conservative recognizer: ordinary prose brackets are left intact. + {"empty brackets byte", "A [] field of type []byte.", "A [] field of type []byte."}, + {"digit index", "Element [0] of the slice.", "Element [0] of the slice."}, + {"multiword brackets", "As noted [see notes] above.", "As noted [see notes] above."}, + {"bare lowercase", "The [id] field is required.", "The [id] field is required."}, + + // multiple links in one line. + { + "multiple links", + "[Widget] links to a [Person] and an [Order].", + "Widget links to a person and an order.", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, Clean(tc.in, Options{Mangler: &m})) + }) + } +} + +func TestCleanLeavesInitialismCasing(t *testing.T) { + m := mangling.NewNameMangler() + // ToHumanNameLower keeps recognized initialisms cased; CleanGoDoc must not re-case them + // mid-sentence. + assert.Equal(t, "Serves over an HTTP server.", Clean("Serves over an [HTTPServer].", Options{Mangler: &m})) +} diff --git a/internal/builders/godoclink/markers.go b/internal/builders/godoclink/markers.go new file mode 100644 index 00000000..38e3addd --- /dev/null +++ b/internal/builders/godoclink/markers.go @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package godoclink + +import ( + "regexp" + "strings" +) + +// Markers bridge the two halves of idiom recomposition. +// +// At the consumption seam a resolvable doc-link is encoded as a marker carrying the referenced +// schema's fully-qualified definition key (plus an exposed field-chain suffix, a humanized +// fallback, and a sentence-initial titleize bit). +// +// After the spec builder reduces definition names, SubstituteMarkers rewrites each marker to the +// schema's final exposed name. +// +// The delimiters are NUL (\x00) and Unit Separator (\x1f): neither can appear in a Go source +// comment, so a marker never collides with real prose. +// The format is. +// +// \x00gl\x1f\x1f\x1f\x1f<0|1>\x00 +const ( + markerOpen = "\x00gl\x1f" + markerClose = "\x00" + markerSep = "\x1f" +) + +// markerRE matches one encoded marker, capturing defKey, suffix, fallback and the titleize bit. +// +// The field bodies exclude the two delimiter runes so a marker can never swallow an adjacent one. +var markerRE = regexp.MustCompile("\x00gl\x1f([^\x1f\x00]*)\x1f([^\x1f\x00]*)\x1f([^\x1f\x00]*)\x1f([01])\x00") + +// encodeMarker builds a marker for a resolvable doc-link. defKey is the referenced type's +// definition key; suffix is the exposed field chain (already resolved, e.g. ".customer_name") or +// ""; fallback is the humanized leaf used when the key turns out not to be an emitted definition; +// titleize records sentence-initial position. +func encodeMarker(defKey, suffix, fallback string, titleize bool) string { + bit := "0" + if titleize { + bit = "1" + } + + return markerOpen + defKey + markerSep + suffix + markerSep + fallback + markerSep + bit + markerClose +} + +// HasMarkers reports whether s contains any godoclink marker — a cheap guard so callers can skip +// the substitution walk for marker-free prose. +func HasMarkers(s string) bool { + return strings.Contains(s, markerOpen) +} + +// SubstituteMarkers rewrites every marker in text to its final exposed name. finalName maps a +// definition key to the name it is ultimately emitted under, returning ok=false when the key is not +// an emitted definition (pruned or unresolved); in that case the marker collapses to its humanized +// fallback. +// +// A resolved marker yields finalName+suffix. +// The sentence-initial bit, when set, upper-cases the first rune of the result. +// No marker ever survives this pass. +func SubstituteMarkers(text string, finalName func(defKey string) (string, bool)) string { + if !HasMarkers(text) { + return text + } + + return markerRE.ReplaceAllStringFunc(text, func(m string) string { + groups := markerRE.FindStringSubmatch(m) + defKey, suffix, fallback, bit := groups[1], groups[2], groups[3], groups[4] + + var out string + if name, ok := finalName(defKey); ok { + out = name + suffix + } else { + out = fallback + } + if bit == "1" { + out = capitalizeFirst(out) + } + + return out + }) +} diff --git a/internal/builders/godoclink/markers_test.go b/internal/builders/godoclink/markers_test.go new file mode 100644 index 00000000..efdec185 --- /dev/null +++ b/internal/builders/godoclink/markers_test.go @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package godoclink + +import ( + "testing" + + "github.com/go-openapi/swag/mangling" + "github.com/go-openapi/testify/v2/assert" +) + +// fakeResolver maps a doc-link reference to a Resolution from a fixed table — standing in for the +// real go/types resolver (wired in a later phase). +func fakeResolver(table map[string]Resolution) Resolver { + return func(ref string) (Resolution, bool) { + r, ok := table[ref] + return r, ok + } +} + +func TestClean_EmitsMarkersForResolvableLinks(t *testing.T) { + m := mangling.NewNameMangler() + resolve := fakeResolver(map[string]Resolution{ + "Person": {DefKey: "x/models/Person"}, + "Order.CustName": {DefKey: "x/models/Order", Suffix: ".customer_name"}, + }) + + out := Clean("Owned by a [Person]; see [Order.CustName] and [Unknown].", + Options{Mangler: &m, Resolver: resolve}) + + // resolvable links became markers carrying the definition key. + assert.True(t, HasMarkers(out)) + assert.Contains(t, out, encodeMarker("x/models/Person", "", "person", false)) + assert.Contains(t, out, encodeMarker("x/models/Order", ".customer_name", "cust name", false)) + // the unresolved link is humanized in place (no marker). + assert.Contains(t, out, "unknown") + assert.NotContains(t, out, "[Unknown]") +} + +func TestClean_LeadingSelfNameMarker(t *testing.T) { + m := mangling.NewNameMangler() + out := Clean("Widget is owned by a [Person].", + Options{ + Mangler: &m, + Resolver: fakeResolver(map[string]Resolution{"Person": {DefKey: "x/Person"}}), + Self: &SelfRef{Name: "Widget", DefKey: "x/Widget"}, + }) + + // the leading self-name became a titleizing marker for its own def key. + assert.Contains(t, out, encodeMarker("x/Widget", "", "widget", true)) +} + +func TestClean_NoSelfMarkerWithoutResolver(t *testing.T) { + m := mangling.NewNameMangler() + // With no Resolver, the leading self-name is left verbatim (its exposed name is unknowable) and + // links are humanized — the P1 / live behaviour. + out := Clean("Widget is owned by a [Person].", + Options{Mangler: &m, Self: &SelfRef{Name: "Widget", DefKey: "x/Widget"}}) + + assert.False(t, HasMarkers(out)) + assert.Equal(t, "Widget is owned by a person.", out) +} + +func TestSubstituteMarkers_RoundTrip(t *testing.T) { + m := mangling.NewNameMangler() + resolve := fakeResolver(map[string]Resolution{ + "Person": {DefKey: "x/Person"}, + "Order.CustName": {DefKey: "x/Order", Suffix: ".customer_name"}, + "Gone": {DefKey: "x/Gone"}, + }) + emitted := Clean("Widget owns a [Person], a [Order.CustName] and a [Gone] thing.", + Options{ + Mangler: &m, + Resolver: resolve, + Self: &SelfRef{Name: "Widget", DefKey: "x/Widget"}, + }) + + // final names: Widget renamed (gizmo), Person/Order kept exposed lowercase, Gone pruned (not an + // emitted definition) → falls back to its humanized leaf. + final := map[string]string{ + "x/Widget": "gizmo", + "x/Person": "person", + "x/Order": "order", + } + got := SubstituteMarkers(emitted, func(k string) (string, bool) { + n, ok := final[k] + return n, ok + }) + + assert.False(t, HasMarkers(got)) + // self-name titleized to the renamed exposed name; field chain joined; pruned key collapsed to + // fallback. + assert.Equal(t, "Gizmo owns a person, a order.customer_name and a gone thing.", got) +} + +func TestSubstituteMarkers_NoMarkersIsIdentity(t *testing.T) { + in := "Plain prose with no markers at all." + assert.Equal(t, in, SubstituteMarkers(in, func(string) (string, bool) { return "", false })) +} diff --git a/internal/builders/handlers/dispatch_schema.go b/internal/builders/handlers/dispatch_schema.go index 417485af..da0aeffe 100644 --- a/internal/builders/handlers/dispatch_schema.go +++ b/internal/builders/handlers/dispatch_schema.go @@ -21,23 +21,20 @@ var _ ifaces.ValidationBuilder = &SchemaValidations{} // SchemaOptions configures the full-Schema dispatch. // -// SimpleSchemaMode enables the SimpleSchema gating used by the -// parameter/header drivers that pass through the schema builder: -// full-Schema-only keywords (readOnly, discriminator, ...) emit -// CodeUnsupportedInSimpleSchema and skip the write; `required:` is -// silently skipped. +// SimpleSchemaMode enables the SimpleSchema gating used by the parameter/header drivers that pass +// through the schema builder: full-Schema-only keywords (readOnly, discriminator, ...) emit +// CodeUnsupportedInSimpleSchema and skip the write; `required:` is silently skipped. // // See [§simple-schema-keywords](./README.md#simple-schema-keywords). type SchemaOptions struct { SimpleSchemaMode bool } -// SchemaValidations adapts *oaispec.Schema to ifaces.ValidationBuilder -// so the full-Schema handler family can write level-0 and items-level -// validations through a uniform target. +// SchemaValidations adapts *oaispec.Schema to ifaces.ValidationBuilder so the full-Schema handler +// family can write level-0 and items-level validations through a uniform target. // -// Exported because the schema package's refOverrideCollector also uses -// it directly when building $ref-override allOf compounds. +// Exported because the schema package's refOverrideCollector also uses it directly when building +// $ref-override allOf compounds. type SchemaValidations struct { current *oaispec.Schema } @@ -65,12 +62,11 @@ func (sv SchemaValidations) SetMinLength(val int64) { sv.current.MinLength = func (sv SchemaValidations) SetMaxLength(val int64) { sv.current.MaxLength = &val } func (sv SchemaValidations) SetPattern(val string) { sv.current.Pattern = val } -// SetPatternProperties adds one regex → empty-schema entry to the -// schema's patternProperties map. The empty value schema (`{}`) means -// "properties whose name matches pattern are allowed (any value)" — -// the annotation surface carries only the regex, not a per-pattern -// value schema. Repeated patternProperties lines accumulate distinct -// entries. +// SetPatternProperties adds one regex → empty-schema entry to the schema's patternProperties map. +// +// The empty value schema (`{}`) means "properties whose name matches pattern are allowed (any +// value)" — the annotation surface carries only the regex, not a per-pattern value schema. +// Repeated patternProperties lines accumulate distinct entries. func (sv SchemaValidations) SetPatternProperties(pattern string) { if sv.current.PatternProperties == nil { sv.current.PatternProperties = make(oaispec.SchemaProperties) @@ -81,9 +77,8 @@ 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 } -// SetEnum writes the parsed enum onto the schema and strips any -// inherited x-go-enum-desc that the field-level override has made -// stale — see [§stale-enum-desc](./README.md#stale-enum-desc). +// SetEnum writes the parsed enum onto the schema and strips any inherited x-go-enum-desc that the +// field-level override has made stale — see [§stale-enum-desc](./README.md#stale-enum-desc). func (sv SchemaValidations) SetEnum(val string) { var typ string if len(sv.current.Type) > 0 { @@ -93,14 +88,13 @@ func (sv SchemaValidations) SetEnum(val string) { clearStaleEnumDesc(sv.current) } -// DispatchSchemaLevel0 routes every level-0 Property in block into -// ps. The enclosing schema receives required/discriminator writes -// keyed by name (name == "" silently skips those cross-target writes, -// for the top-level model case where there is no enclosing). +// DispatchSchemaLevel0 routes every level-0 Property in block into ps. +// +// The enclosing schema receives required/discriminator writes keyed by name (name == "" silently +// skips those cross-target writes, for the top-level model case where there is no enclosing). // -// opts.SimpleSchemaMode gates full-Schema-only keywords with -// CodeUnsupportedInSimpleSchema (for the parameters/headers paths -// that drive the schema builder under SimpleSchema constraints). +// opts.SimpleSchemaMode gates full-Schema-only keywords with CodeUnsupportedInSimpleSchema (for the +// parameters/headers paths that drive the schema builder under SimpleSchema constraints). // // diag may be nil; when nil, all diagnostics are dropped. func DispatchSchemaLevel0(block grammar.Block, enclosing, ps *oaispec.Schema, name string, @@ -120,15 +114,14 @@ func DispatchSchemaLevel0(block grammar.Block, enclosing, ps *oaispec.Schema, na }) } -// DispatchSchemaItemsLevel dispatches items-depth Property entries -// onto target. Items elements don't carry vendor extensions and don't -// participate in cross-target writes (required/discriminator), so the -// dispatcher is narrower than the level-0 entry — only number/ +// DispatchSchemaItemsLevel dispatches items-depth Property entries onto target. +// +// Items elements don't carry vendor extensions and don't participate in cross-target writes +// (required/discriminator), so the dispatcher is narrower than the level-0 entry — only number/ // integer/bool(unique)/string/raw handlers fire. // -// opts.SimpleSchemaMode is threaded to the raw handler so the -// full-Schema-only externalDocs keyword is gated consistently; the -// other items-level handlers are unaffected by it. +// opts.SimpleSchemaMode is threaded to the raw handler so the full-Schema-only externalDocs keyword +// is gated consistently; the other items-level handlers are unaffected by it. // // diag may be nil; when nil, all diagnostics are dropped. func DispatchSchemaItemsLevel(block grammar.Block, target *oaispec.Schema, depth int, @@ -157,15 +150,14 @@ func DispatchSchemaItemsLevel(block grammar.Block, target *oaispec.Schema, depth }) } -// ApplyPattern stores a regex pattern on valid and runs a best-effort -// RE2 hygiene check, emitting CodeInvalidAnnotation when the regex -// does not compile in Go's RE2 engine. The pattern is kept on the -// schema regardless — downstream tools may use JSON Schema's wider +// ApplyPattern stores a regex pattern on valid and runs a best-effort RE2 hygiene check, emitting +// CodeInvalidAnnotation when the regex does not compile in Go's RE2 engine. +// +// The pattern is kept on the schema regardless — downstream tools may use JSON Schema's wider // regex dialect. // -// Exported because the schema package's refOverrideCollector applies -// the same pattern semantics when building $ref-override allOf -// compounds. +// Exported because the schema package's refOverrideCollector applies the same pattern semantics +// when building $ref-override allOf compounds. // // diag may be nil; when nil, the RE2 mismatch warning is dropped. func ApplyPattern(p grammar.Property, valid SchemaValidations, val string, diag func(grammar.Diagnostic)) { @@ -173,14 +165,13 @@ func ApplyPattern(p grammar.Property, valid SchemaValidations, val string, diag warnInvalidRE2(p, grammar.KwPattern, val, diag) } -// ApplyPatternProperties adds a regex → empty-schema entry to the -// schema's patternProperties map and runs the same best-effort RE2 -// hygiene check as ApplyPattern: a regex that does not compile in Go's -// RE2 engine emits CodeInvalidAnnotation but is preserved on the schema -// (downstream tools may use JSON Schema's wider regex dialect). +// ApplyPatternProperties adds a regex → empty-schema entry to the schema's patternProperties map +// and runs the same best-effort RE2 hygiene check as ApplyPattern: a regex that does not compile in +// Go's RE2 engine emits CodeInvalidAnnotation but is preserved on the schema (downstream tools may +// use JSON Schema's wider regex dialect). // -// Exported for symmetry with ApplyPattern; the schema package's -// refOverrideCollector applies the same semantics on $ref overrides. +// Exported for symmetry with ApplyPattern; the schema package's refOverrideCollector applies the +// same semantics on $ref overrides. // // diag may be nil; when nil, the RE2 mismatch warning is dropped. func ApplyPatternProperties(p grammar.Property, valid SchemaValidations, val string, diag func(grammar.Diagnostic)) { @@ -188,12 +179,13 @@ func ApplyPatternProperties(p grammar.Property, valid SchemaValidations, val str warnInvalidRE2(p, grammar.KwPatternProperties, val, diag) } -// warnInvalidRE2 emits a CodeInvalidAnnotation warning when val is a -// non-empty string that does not compile under Go's RE2 engine. The -// keyword name is used to prefix the message so pattern and -// patternProperties produce parallel diagnostics. No-op on empty val -// or nil diag — the value is always preserved on the schema by the -// caller regardless. +// warnInvalidRE2 emits a CodeInvalidAnnotation warning when val is a non-empty string that does not +// compile under Go's RE2 engine. +// +// The keyword name is used to prefix the message so pattern and patternProperties produce parallel +// diagnostics. +// No-op on empty val or nil diag — the value is always preserved on the schema by the caller +// regardless. func warnInvalidRE2(p grammar.Property, keyword, val string, diag func(grammar.Diagnostic)) { if val == "" || diag == nil { return @@ -237,9 +229,8 @@ func SetRequired(enclosing *oaispec.Schema, name string, required bool) { } } -// SetDiscriminator writes name to enclosing.Discriminator when -// required=true, or clears it when required=false and the current -// value matches. +// SetDiscriminator writes name to enclosing.Discriminator when required=true, or clears it when +// required=false and the current value matches. func SetDiscriminator(enclosing *oaispec.Schema, name string, required bool) { if enclosing == nil { return @@ -253,9 +244,8 @@ func SetDiscriminator(enclosing *oaispec.Schema, name string, required bool) { } } -// 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). +// 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 "" @@ -266,8 +256,9 @@ func SchemaTypeOf(ps *oaispec.Schema) string { // --- internal per-shape handler factories --- // schemaNumberHandler returns a Walker.Number callback bound to valid. -// Recognises maximum / minimum / multipleOf. Skips on typing failure -// (parser already emitted a CodeInvalidNumber) or shape mismatch +// +// Recognises maximum / minimum / multipleOf. +// Skips on typing failure (parser already emitted a CodeInvalidNumber) or shape mismatch // (checkShape emits CodeShapeMismatch and the property is dropped). func schemaNumberHandler(ps *oaispec.Schema, valid SchemaValidations, diag func(grammar.Diagnostic), @@ -290,10 +281,10 @@ func schemaNumberHandler(ps *oaispec.Schema, valid SchemaValidations, } } -// schemaIntegerHandler returns a Walker.Integer callback bound to -// valid. Recognises minLength / maxLength / minItems / maxItems / -// minProperties / maxProperties. Skips on typing failure or shape -// mismatch. +// schemaIntegerHandler returns a Walker.Integer callback bound to valid. +// +// Recognises minLength / maxLength / minItems / maxItems / minProperties / maxProperties. +// Skips on typing failure or shape mismatch. func schemaIntegerHandler(ps *oaispec.Schema, valid SchemaValidations, diag func(grammar.Diagnostic), ) func(grammar.Property, int64) { @@ -321,13 +312,13 @@ func schemaIntegerHandler(ps *oaispec.Schema, valid SchemaValidations, } } -// schemaBoolHandler returns a Walker.Bool callback. Required and -// discriminator route to the enclosing schema keyed by name; -// unique/readOnly route to the property schema. +// schemaBoolHandler returns a Walker.Bool callback. +// +// Required and discriminator route to the enclosing schema keyed by name; unique/readOnly route to +// the property schema. // -// Under SimpleSchemaMode, full-Schema-only keywords (readOnly, -// discriminator) emit CodeUnsupportedInSimpleSchema and skip; -// `required:` is silently skipped — see +// Under SimpleSchemaMode, full-Schema-only keywords (readOnly, discriminator) emit +// CodeUnsupportedInSimpleSchema and skip; `required:` is silently skipped — see // [§simple-schema-keywords](./README.md#simple-schema-keywords). func schemaBoolHandler(enclosing, ps *oaispec.Schema, name string, valid SchemaValidations, diag func(grammar.Diagnostic), opts SchemaOptions, @@ -372,13 +363,11 @@ func schemaBoolHandler(enclosing, ps *oaispec.Schema, name string, valid SchemaV // schemaStringHandler returns a Walker.String callback. // -// Recognises pattern (raw regex), patternProperties (object regex key) -// and the enum keyword's pre-typed enum-option form (rare; comma-list / -// JSON-array forms travel through Raw). +// Recognises pattern (raw regex), patternProperties (object regex key) and the enum keyword's +// pre-typed enum-option form (rare; comma-list / JSON-array forms travel through Raw). // -// Shape-checks pattern (string-only) and patternProperties (object-only); -// default/example/enum are type-independent (ParseDefault handles the -// type-specific coercion). +// Shape-checks pattern (string-only) and patternProperties (object-only); default/example/enum are +// type-independent (ParseDefault handles the type-specific coercion). func schemaStringHandler(ps *oaispec.Schema, valid SchemaValidations, diag func(grammar.Diagnostic), ) func(grammar.Property, string) { @@ -405,16 +394,16 @@ func schemaStringHandler(ps *oaispec.Schema, valid SchemaValidations, } } -// schemaRawHandler routes ShapeRawBlock / ShapeRawValue / ShapeNone / -// ShapeCommaList properties — default:, example:, enum: when -// expressed as raw bodies, plus externalDocs: (a YAML map body). -// Extensions travel through Walker.Extension with YAML-typed values, so -// the KwExtensions arm is absent here. +// schemaRawHandler routes ShapeRawBlock / ShapeRawValue / ShapeNone / ShapeCommaList properties — +// default:, example:, enum: when expressed as raw bodies, plus externalDocs: (a YAML map body). +// +// Extensions travel through Walker.Extension with YAML-typed values, so the KwExtensions arm is +// absent here. // -// externalDocs is full-Schema-only: under opts.SimpleSchemaMode it -// emits CodeUnsupportedInSimpleSchema and is dropped (mirroring the -// Bool handler's readOnly/discriminator gating). A malformed body emits -// CodeInvalidAnnotation. diag may be nil. +// externalDocs is full-Schema-only: under opts.SimpleSchemaMode it emits +// CodeUnsupportedInSimpleSchema and is dropped (mirroring the Bool handler's readOnly/discriminator +// gating). +// A malformed body emits CodeInvalidAnnotation. diag may be nil. func schemaRawHandler(ps *oaispec.Schema, valid SchemaValidations, diag func(grammar.Diagnostic), opts SchemaOptions, ) func(grammar.Property) { @@ -456,11 +445,12 @@ func schemaRawHandler(ps *oaispec.Schema, valid SchemaValidations, } } -// ParseExternalDocs unmarshals an `externalDocs:` block body (a YAML -// map with description/url keys) into a *spec.ExternalDocumentation. -// Returns (nil, nil) for an empty/blank block so callers don't emit a -// useless `externalDocs: {}` (the OAS object requires url). Shared by -// the schema raw handler and the routes builder. +// ParseExternalDocs unmarshals an `externalDocs:` block body (a YAML map with description/url keys) +// into a *spec.ExternalDocumentation. +// +// Returns (nil, nil) for an empty/blank block so callers don't emit a useless `externalDocs: {}` +// (the OAS object requires url). +// Shared by the schema raw handler and the routes builder. func ParseExternalDocs(body string) (*oaispec.ExternalDocumentation, error) { var out *oaispec.ExternalDocumentation err := yamlparser.UnmarshalBody(body, func(data []byte) error { @@ -476,10 +466,10 @@ func ParseExternalDocs(body string) (*oaispec.ExternalDocumentation, error) { return out, err } -// checkShape gates a Walker callback on -// validations.IsLegalForType(p.Keyword, schema-type). On mismatch, -// emits a CodeShapeMismatch diagnostic and returns false so the -// caller drops the property. +// checkShape gates a Walker callback on validations.IsLegalForType(p.Keyword, schema-type). +// +// On mismatch, emits a CodeShapeMismatch diagnostic and returns false so the caller drops the +// property. func checkShape(p grammar.Property, ps *oaispec.Schema, diag func(grammar.Diagnostic)) bool { var schemaType string if ps != nil && len(ps.Type) > 0 { @@ -500,20 +490,19 @@ func checkShape(p grammar.Property, ps *oaispec.Schema, diag func(grammar.Diagno return false } -// RecheckSchemaShape re-validates the shape-constrained validations -// already written onto sch against sch's now-resolved type, stripping -// any that are illegal for that type and emitting CodeShapeMismatch for -// each (at pos). +// RecheckSchemaShape re-validates the shape-constrained validations already written onto sch +// against sch's now-resolved type, stripping any that are illegal for that type and emitting +// CodeShapeMismatch for each (at pos). +// +// This exists for the top-level model decl path only: there the doc-comment block is dispatched +// (via DispatchSchemaLevel0) before the Go type has been built onto the schema, so the inline +// checkShape sees an empty type and cannot gate. // -// This exists for the top-level model decl path only: there the -// doc-comment block is dispatched (via DispatchSchemaLevel0) before the -// Go type has been built onto the schema, so the inline checkShape sees -// an empty type and cannot gate. Field- and items-level dispatch -// already run after their target's type is set, so they don't need it. +// Field- and items-level dispatch already run after their target's type is set, so they don't need +// it. // -// uniqueItems is intentionally not rechecked: its grammar keyword -// (`unique`) carries no type-domain rule, matching the field/items -// paths which likewise never shape-gate it. diag may be nil. +// uniqueItems is intentionally not rechecked: its grammar keyword (`unique`) carries no type-domain +// rule, matching the field/items paths which likewise never shape-gate it. diag may be nil. func RecheckSchemaShape(sch *oaispec.Schema, pos token.Position, diag func(grammar.Diagnostic)) { if sch == nil || len(sch.Type) == 0 { return @@ -543,11 +532,10 @@ func RecheckSchemaShape(sch *oaispec.Schema, pos token.Position, diag func(gramm drop(grammar.KwPatternProperties, len(sch.PatternProperties) > 0, func() { sch.PatternProperties = nil }) } -// clearStaleEnumDesc removes the x-go-enum-desc extension and strips -// the matching suffix from ps.Description. +// clearStaleEnumDesc removes the x-go-enum-desc extension and strips the matching suffix from +// ps.Description. // -// Called from SetEnum when a field-level `enum:` overrides a -// type-level `swagger:enum` — see +// Called from SetEnum when a field-level `enum:` overrides a type-level `swagger:enum` — see // [§stale-enum-desc](./README.md#stale-enum-desc). func clearStaleEnumDesc(ps *oaispec.Schema) { enumDesc := resolvers.GetEnumDesc(ps.Extensions) diff --git a/internal/builders/handlers/dispatch_simple.go b/internal/builders/handlers/dispatch_simple.go index 4ee68cb2..a56ea158 100644 --- a/internal/builders/handlers/dispatch_simple.go +++ b/internal/builders/handlers/dispatch_simple.go @@ -16,9 +16,8 @@ var ( _ ifaces.ValidationBuilder = &headerValidations{} ) -// paramValidations adapts [oaispec.Parameter] to -// [ifaces.OperationValidationBuilder] so the SimpleSchema handler family -// can write level-0 parameter validations through a uniform target. +// paramValidations adapts [oaispec.Parameter] to [ifaces.OperationValidationBuilder] so the +// SimpleSchema handler family can write level-0 parameter validations through a uniform target. // // The adapter is unexported — DispatchParamLevel0 is the entry point. type paramValidations struct { @@ -48,8 +47,8 @@ func (sv paramValidations) SetEnum(val string) { func (sv paramValidations) SetDefault(val any) { sv.current.Default = val } func (sv paramValidations) SetExample(val any) { sv.current.Example = val } -// headerValidations adapts *oaispec.Header to ifaces.ValidationBuilder -// for the response-header SimpleSchema dispatch. +// headerValidations adapts *oaispec.Header to ifaces.ValidationBuilder for the response-header +// SimpleSchema dispatch. type headerValidations struct { current *oaispec.Header } @@ -77,10 +76,11 @@ func (sv headerValidations) SetEnum(val string) { func (sv headerValidations) SetDefault(val any) { sv.current.Default = val } func (sv headerValidations) SetExample(val any) { sv.current.Example = val } -// paramRequiredBool returns a Walker.Bool callback that writes the -// `required:` keyword straight onto a parameter target. Parameter- -// specific — schema writes required onto the enclosing schema keyed -// by name, headers don't carry `required:` at all. +// paramRequiredBool returns a Walker.Bool callback that writes the `required:` keyword straight +// onto a 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() { @@ -92,14 +92,13 @@ func paramRequiredBool(param *oaispec.Parameter) func(grammar.Property, bool) { } } -// DispatchParamLevel0 routes every level-0 Property in block onto -// param via the grammar Walker. Handler wiring is the SimpleSchema -// surface: Number/Integer/UniqueBool+RequiredBool/Pattern+CollectionFmt/ -// Raw-with-errSink/Extension. +// DispatchParamLevel0 routes every level-0 Property in block onto param via the grammar Walker. +// +// Handler wiring is the SimpleSchema surface: +// Number/Integer/UniqueBool+RequiredBool/Pattern+CollectionFmt/ Raw-with-errSink/Extension. // -// 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). +// 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). func DispatchParamLevel0(block grammar.Block, param *oaispec.Parameter, diag func(grammar.Diagnostic)) error { valid := paramValidations{param} scheme := ¶m.SimpleSchema @@ -129,11 +128,11 @@ func DispatchParamLevel0(block grammar.Block, param *oaispec.Parameter, diag fun return firstErr } -// DispatchHeaderLevel0 routes every level-0 Property in block onto -// header via the grammar Walker. Mirrors DispatchParamLevel0 minus -// `required:` (headers don't carry it) and wires errSink=nil so -// malformed default/example values on a header don't fail the -// build — see [§raw-errsink](./README.md#raw-errsink). +// DispatchHeaderLevel0 routes every level-0 Property in block onto header via the grammar Walker. +// +// Mirrors DispatchParamLevel0 minus `required:` (headers don't carry it) and wires errSink=nil so +// malformed default/example values on a header don't fail the build — see +// [§raw-errsink](./README.md#raw-errsink). // // diag may be nil; when nil, parser diagnostics are dropped. func DispatchHeaderLevel0(block grammar.Block, header *oaispec.Header, diag func(grammar.Diagnostic)) { @@ -156,11 +155,11 @@ func DispatchHeaderLevel0(block grammar.Block, header *oaispec.Header, diag func }) } -// DispatchItemsLevel dispatches Property entries at the given items -// depth onto target via the resolvers.ItemsValidations adapter. The -// shape is identical between parameter and response-header items -// chains — both write to *oaispec.Items via the items adapter — so a -// single function serves both consumers. +// DispatchItemsLevel dispatches Property entries at the given items depth onto target via the +// resolvers.ItemsValidations adapter. +// +// The shape is identical between parameter and response-header items chains — both write to +// *oaispec.Items via the items adapter — so a single function serves both consumers. func DispatchItemsLevel(block grammar.Block, target *oaispec.Items, depth int, diag func(grammar.Diagnostic)) { valid := resolvers.NewItemsValidations(target) scheme := &target.SimpleSchema diff --git a/internal/builders/handlers/handlers.go b/internal/builders/handlers/handlers.go index 2b364070..91344a74 100644 --- a/internal/builders/handlers/handlers.go +++ b/internal/builders/handlers/handlers.go @@ -1,14 +1,13 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -// Package handlers ships shared grammar Walker callbacks for the -// SimpleSchema and full-Schema families of OAS v2 dispatchers. +// Package handlers ships shared grammar Walker callbacks for the SimpleSchema and full-Schema +// families of OAS v2 dispatchers. // // # Details // -// See [§dispatch-surface](./README.md#dispatch-surface) for the -// split between SimpleSchema and full-Schema dispatch and -// [§walker-payloads](./README.md#walker-payloads) for the payload +// See [§dispatch-surface](./README.md#dispatch-surface) for the split between SimpleSchema and +// full-Schema dispatch and [§walker-payloads](./README.md#walker-payloads) for the payload // conventions on each Walker callback. package handlers @@ -22,23 +21,22 @@ import ( 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. +// 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. +// Extension returns a Walker.Extension callback that filters non-`x-*` names via +// classify.IsAllowedExtension and writes the typed extension value onto target. // // # Details // -// See [§extensions](./README.md#extensions) for the SkipExtensions -// interaction and the wrap-for-side-effects pattern. +// See [§extensions](./README.md#extensions) for the SkipExtensions interaction and the +// wrap-for-side-effects pattern. func Extension(target ExtensionTarget) func(grammar.Extension) { return func(ext grammar.Extension) { if !classify.IsAllowedExtension(ext.Name) { @@ -48,8 +46,8 @@ func Extension(target ExtensionTarget) func(grammar.Extension) { } } -// Number returns a Walker.Number callback that routes -// `maximum:` / `minimum:` / `multipleOf:` onto v. +// 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() { @@ -66,11 +64,11 @@ func Number(v ifaces.ValidationBuilder) func(grammar.Property, float64, bool) { } } -// Integer returns a Walker.Integer callback for the SimpleSchema -// surface. It routes `min/maxLength:` and `min/maxItems:` onto v and, -// when diag is non-nil, emits CodeUnsupportedInSimpleSchema for any -// other integer keyword — the full-Schema-only object keywords -// `minProperties:` / `maxProperties:`, which have no SimpleSchema form. +// Integer returns a Walker.Integer callback for the SimpleSchema surface. +// +// It routes `min/maxLength:` and `min/maxItems:` onto v and, when diag is non-nil, emits +// CodeUnsupportedInSimpleSchema for any other integer keyword — the full-Schema-only object +// keywords `minProperties:` / `maxProperties:`, which have no SimpleSchema form. // // diag may be nil; when nil, the unsupported-keyword warning is dropped. func Integer(v ifaces.ValidationBuilder, diag func(grammar.Diagnostic)) func(grammar.Property, int64) { @@ -95,32 +93,32 @@ func Integer(v ifaces.ValidationBuilder, diag func(grammar.Diagnostic)) func(gra } } -// UnsupportedSimpleSchemaString returns a Walker.String callback that -// emits CodeUnsupportedInSimpleSchema for full-Schema-only string -// keywords (`patternProperties:`) that reach a SimpleSchema site. The -// SimpleSchema-legal string keywords (`pattern:`, `collectionFormat:`) -// are handled by their own callbacks and ignored here. +// UnsupportedSimpleSchemaString returns a Walker.String callback that emits +// CodeUnsupportedInSimpleSchema for full-Schema-only string keywords (`patternProperties:`) that +// reach a SimpleSchema site. +// +// The SimpleSchema-legal string keywords (`pattern:`, `collectionFormat:`) are handled by their own +// callbacks and ignored here. // -// Composed alongside PatternString / CollectionFormatString in the -// parameter / header / items dispatchers. diag may be nil. +// Composed alongside PatternString / CollectionFormatString in the parameter / header / items +// dispatchers. diag may be nil. func UnsupportedSimpleSchemaString(diag func(grammar.Diagnostic)) func(grammar.Property, string) { return func(pr grammar.Property, _ string) { - // No IsTyped() gate: ShapeString keywords (the slot this fires - // in) report IsTyped()==false by construction, so guarding on it - // would skip every candidate. PatternString likewise gates only - // on the keyword name. + // No IsTyped() gate: ShapeString keywords (the slot this fires in) report IsTyped()==false by + // construction, so guarding on it would skip every candidate. + // PatternString likewise gates only on the keyword name. if isFullSchemaOnly(pr.Keyword) { warnUnsupportedInSimpleSchema(pr, diag) } } } -// isFullSchemaOnly reports whether kw is legal only on full-Schema -// sites: it lists CtxSchema and none of the SimpleSchema contexts -// (CtxParam / CtxHeader / CtxItems). The SimpleSchema dispatchers use -// this to single out the object validation keywords (minProperties / -// maxProperties / patternProperties) from the location/structural -// keywords (e.g. `in:`) that legitimately travel the same Walker slots. +// isFullSchemaOnly reports whether kw is legal only on full-Schema sites: it lists CtxSchema and +// none of the SimpleSchema contexts (CtxParam / CtxHeader / CtxItems). +// +// The SimpleSchema dispatchers use this to single out the object validation keywords (minProperties +// / maxProperties / patternProperties) from the location/structural keywords (e.g. `in:`) that +// legitimately travel the same Walker slots. func isFullSchemaOnly(kw grammar.Keyword) bool { onSchema := false for _, c := range kw.Contexts { @@ -130,18 +128,19 @@ func isFullSchemaOnly(kw grammar.Keyword) bool { case grammar.CtxSchema: onSchema = true default: - // Other/future contexts (meta, route, operation, response): - // not SimpleSchema sites, so they don't affect the verdict. + // Other/future contexts (meta, route, operation, response): not SimpleSchema sites, so they + // don't affect the verdict. } } return onSchema } -// warnUnsupportedInSimpleSchema emits the canonical -// CodeUnsupportedInSimpleSchema warning for a full-Schema-only keyword -// encountered on a SimpleSchema site. No-op when diag is nil. The -// keyword is dropped (the caller writes nothing) — mirroring the schema -// Bool handler's treatment of readOnly / discriminator. +// warnUnsupportedInSimpleSchema emits the canonical CodeUnsupportedInSimpleSchema warning for a +// full-Schema-only keyword encountered on a SimpleSchema site. +// +// No-op when diag is nil. +// The keyword is dropped (the caller writes nothing) — mirroring the schema Bool handler's +// treatment of readOnly / discriminator. func warnUnsupportedInSimpleSchema(pr grammar.Property, diag func(grammar.Diagnostic)) { if diag == nil { return @@ -154,11 +153,11 @@ func warnUnsupportedInSimpleSchema(pr grammar.Property, diag func(grammar.Diagno )) } -// 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. +// 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() { @@ -170,9 +169,10 @@ func UniqueBool(v ifaces.ValidationBuilder) func(grammar.Property, bool) { } } -// 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' +// 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) { @@ -184,9 +184,9 @@ func ComposeBool(hs ...func(grammar.Property, bool)) func(grammar.Property, bool } } -// 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. +// 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 { @@ -195,19 +195,18 @@ func PatternString(v ifaces.ValidationBuilder) func(grammar.Property, string) { } } -// CollectionFormatString returns a Walker.String callback for the -// `collectionFormat:` keyword. Tries the Walker-supplied typed -// string first and falls back to strings.TrimSpace(pr.Value) when -// the grammar's closed-vocab string-enum rejected the source, so -// values outside the OAS v2 vocabulary round-trip verbatim. +// CollectionFormatString returns a Walker.String callback for the `collectionFormat:` keyword. +// +// Tries the Walker-supplied typed string first and falls back to strings.TrimSpace(pr.Value) when +// the grammar's closed-vocab string-enum rejected the source, so values outside the OAS v2 +// vocabulary round-trip verbatim. // -// SimpleSchema-only — schema-level Validations don't expose -// SetCollectionFormat. +// SimpleSchema-only — schema-level Validations don't expose SetCollectionFormat. // // # Details // -// See [§collection-format-fallback](./README.md#collection-format-fallback) -// for the rationale behind the lax fallback. +// See [§collection-format-fallback](./README.md#collection-format-fallback) for the rationale +// behind the lax fallback. func CollectionFormatString(v ifaces.OperationValidationBuilder) func(grammar.Property, string) { return func(pr grammar.Property, val string) { if pr.Keyword.Name != grammar.KwCollectionFormat { @@ -223,10 +222,10 @@ func CollectionFormatString(v ifaces.OperationValidationBuilder) func(grammar.Pr } } -// 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. +// 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 { @@ -237,11 +236,11 @@ func ComposeString(hs ...func(grammar.Property, string)) func(grammar.Property, } } -// 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. +// 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: // @@ -255,15 +254,13 @@ func ComposeString(hs ...func(grammar.Property, string)) func(grammar.Property, // error up so the build surfaces a malformed default/example // as a hard failure. // -// diag receives a CodeUnsupportedInSimpleSchema warning when a -// full-Schema-only raw keyword (e.g. externalDocs:) appears on this -// SimpleSchema site; the keyword is dropped. diag may be nil. +// diag receives a CodeUnsupportedInSimpleSchema warning when a full-Schema-only raw keyword (e.g. +// externalDocs:) appears on this SimpleSchema site; the keyword is dropped. diag may be nil. // // # Details // -// See [§raw-errsink](./README.md#raw-errsink) for the per-dispatcher -// wiring and the integration tests that exercise the parameter-path -// hard-failure behaviour. +// See [§raw-errsink](./README.md#raw-errsink) for the per-dispatcher wiring and the integration +// tests that exercise the parameter-path hard-failure behaviour. func Raw(v ifaces.ValidationBuilder, scheme *oaispec.SimpleSchema, errSink func(error) bool, diag func(grammar.Diagnostic), ) func(grammar.Property) { @@ -294,8 +291,8 @@ func Raw(v ifaces.ValidationBuilder, scheme *oaispec.SimpleSchema, errSink func( case grammar.KwEnum: v.SetEnum(pr.Value) default: - // Full-Schema-only raw keyword (externalDocs:) on a - // SimpleSchema site — drop with a diagnostic. + // Full-Schema-only raw keyword (externalDocs:) on a SimpleSchema site — drop with a + // diagnostic. if diag != nil && !IsSimpleSchemaKeyword(pr.Keyword.Name) { diag(grammar.Warnf( pr.Pos, diff --git a/internal/builders/handlers/keywords.go b/internal/builders/handlers/keywords.go index bc525606..c52bd9a3 100644 --- a/internal/builders/handlers/keywords.go +++ b/internal/builders/handlers/keywords.go @@ -37,14 +37,13 @@ var simpleSchemaAllowed = map[string]struct{}{ 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. +// IsSimpleSchemaKeyword reports whether keyword is legal on an OAS v2 SimpleSchema site. // -// Consumers wired in SimpleSchema mode (the schema builder under -// WithSimpleSchema, the parameters dispatcher, the responses -// dispatcher) use this predicate to gate writes and emit +// 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] diff --git a/internal/builders/handlers/keywords_test.go b/internal/builders/handlers/keywords_test.go index 9491cd53..47ceff76 100644 --- a/internal/builders/handlers/keywords_test.go +++ b/internal/builders/handlers/keywords_test.go @@ -10,12 +10,12 @@ import ( "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. +// 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, @@ -39,10 +39,11 @@ func TestIsSimpleSchemaKeyword_AllowedSet(t *testing.T) { 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. +// 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, @@ -53,8 +54,8 @@ func TestIsSimpleSchemaKeyword_FullSchemaOnly(t *testing.T) { } } -// TestIsSimpleSchemaKeyword_Unknown pins the predicate's behaviour -// on an unknown keyword name — returns false, no panic. +// 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/operations/errors.go b/internal/builders/operations/errors.go index 2f8fdf28..0ec313c4 100644 --- a/internal/builders/operations/errors.go +++ b/internal/builders/operations/errors.go @@ -5,5 +5,6 @@ package operations import "errors" -// ErrOperations is the sentinel error for all errors originating from the operations builder package. +// ErrOperations is the sentinel error for all errors originating from the operations builder +// package. var ErrOperations = errors.New("codescan:builders:operations") diff --git a/internal/builders/operations/operations.go b/internal/builders/operations/operations.go index 0e28e8ae..39c2977b 100644 --- a/internal/builders/operations/operations.go +++ b/internal/builders/operations/operations.go @@ -13,16 +13,16 @@ import ( 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 -// because operations build off a path annotation, not a declaration; -// the MakeRef / Decl-anchored helpers must not be called. +// 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 because operations build off a path annotation, not a declaration; the MakeRef / +// Decl-anchored helpers must not be called. // // # Details // -// See [§builder](./README.md#builder) for the Build orchestration -// and the path-item-slot reuse semantics. +// See [§builder](./README.md#builder) for the Build orchestration and the path-item-slot reuse +// semantics. type Builder struct { *common.Builder @@ -58,8 +58,8 @@ func (o *Builder) Build(tgt *oaispec.Paths) error { tgt.Paths[o.path.Path] = pthObj - // Cross-ref linkage: anchor the operation node to its swagger:operation - // annotation; parameters/responses under it resolve to this node. + // Cross-ref linkage: anchor the operation node to its swagger:operation annotation; + // parameters/responses under it resolve to this node. if o.Ctx.OriginEnabled() && o.path.Pos.IsValid() { o.Ctx.RecordOrigin( scanner.JSONPointer("paths", o.path.Path, strings.ToLower(o.path.Method)), @@ -74,16 +74,16 @@ func SetPathOperation(method, id string, pthObj *oaispec.PathItem, op *oaispec.O return setPathOperation(method, id, pthObj, op) } -// setPathOperation lands op on the HTTP-verb slot of pthObj. Returns -// the operation now occupying the slot — either the reused existing -// one or op itself. Unrecognised methods leave pthObj untouched and -// return op verbatim. +// setPathOperation lands op on the HTTP-verb slot of pthObj. +// +// Returns the operation now occupying the slot — either the reused existing one or op itself. +// Unrecognised methods leave pthObj untouched and return op verbatim. // // # Details // -// See [§path-operation-slot](./README.md#path-operation-slot) for the -// slot-reuse contract, the nil-op allocation behaviour, and the -// public `SetPathOperation` re-export used by sibling packages. +// See [§path-operation-slot](./README.md#path-operation-slot) for the slot-reuse contract, the +// nil-op allocation behaviour, and the public `SetPathOperation` re-export used by sibling +// packages. func setPathOperation(method, id string, pthObj *oaispec.PathItem, op *oaispec.Operation) *oaispec.Operation { if op == nil { op = new(oaispec.Operation) diff --git a/internal/builders/operations/walker.go b/internal/builders/operations/walker.go index c421df66..4796abb3 100644 --- a/internal/builders/operations/walker.go +++ b/internal/builders/operations/walker.go @@ -9,29 +9,27 @@ import ( oaispec "github.com/go-openapi/spec" ) -// applyBlockToOperation parses path.Remaining through grammar and -// writes Summary / Description / YAML body content onto op. +// applyBlockToOperation parses path.Remaining through grammar and writes Summary / Description / +// YAML body content onto op. // // # Details // -// See [§walker](./README.md#walker) for the three-step bridge from -// the lexer-classified Block onto op and the single-fenced-body -// contract. +// See [§walker](./README.md#walker) for the three-step bridge from the lexer-classified Block onto +// op and the single-fenced-body contract. func (o *Builder) applyBlockToOperation(op *oaispec.Operation) error { block := grammar.NewParser(o.Ctx.FileSet(), grammar.WithSingleLineCommentAsDescription(o.Ctx.SingleLineCommentAsDescription())).Parse(o.path.Remaining) - op.Summary = block.Title() - op.Description = block.Description() + op.Summary = o.CleanGoDoc(block.Title()) + op.Description = o.CleanGoDoc(block.Description()) for y := range block.YAMLBlocks() { - // Parameters already bound to this operation by a - // swagger:parameters struct must survive the inline YAML - // unmarshal as distinct entries. encoding/json reuses the - // existing slice elements when decoding the inline `parameters:` - // array, which would weld a bound body's $ref schema onto an - // inline parameter (go-swagger#2651). Decode the inline body into - // a fresh slice, then merge the bound parameters back. + // Parameters already bound to this operation by a swagger:parameters struct must survive the + // inline YAML unmarshal as distinct entries. encoding/json reuses the existing slice elements + // when decoding the inline `parameters:` array, which would weld a bound body's $ref schema onto + // an inline parameter (go-swagger#2651). + // + // Decode the inline body into a fresh slice, then merge the bound parameters back. bound := op.Parameters op.Parameters = nil if err := yaml.UnmarshalBody(y.Text, op.UnmarshalJSON); err != nil { @@ -43,11 +41,12 @@ func (o *Builder) applyBlockToOperation(op *oaispec.Operation) error { return nil } -// mergeBoundParameters appends parameters bound by a swagger:parameters -// struct to the inline swagger:operation parameters, skipping any bound -// parameter already declared inline. Collisions are matched on -// (name, location); body parameters collide on location alone (an -// operation has at most one body). Inline declarations win on conflict. +// mergeBoundParameters appends parameters bound by a swagger:parameters struct to the inline +// swagger:operation parameters, skipping any bound parameter already declared inline. +// +// Collisions are matched on (name, location); body parameters collide on location alone (an +// operation has at most one body). +// Inline declarations win on conflict. func mergeBoundParameters(inline, bound []oaispec.Parameter) []oaispec.Parameter { if len(bound) == 0 { return inline @@ -69,8 +68,8 @@ func mergeBoundParameters(inline, bound []oaispec.Parameter) []oaispec.Parameter return out } -// parametersCollide reports whether two parameters occupy the same OAS v2 -// slot: same name and location, or both the (singleton) body parameter. +// parametersCollide reports whether two parameters occupy the same OAS v2 slot: same name and +// location, or both the (singleton) body parameter. func parametersCollide(a, b oaispec.Parameter) bool { if a.In == "body" || b.In == "body" { return a.In == "body" && b.In == "body" diff --git a/internal/builders/parameters/README.md b/internal/builders/parameters/README.md index d1d1572e..d01c8d25 100644 --- a/internal/builders/parameters/README.md +++ b/internal/builders/parameters/README.md @@ -32,11 +32,47 @@ diagnostic sink, ParseBlocks cache, MakeRef). See ## §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. +`Build(operations)` iterates the declaration's `swagger:parameters` +markers (parsed by the grammar into `grammar.ParametersBlock`s) and +dispatches per target: + +- **operations** (`swagger:parameters opid …`) — `buildIntoOperations` + inlines the struct's fields into each named operation (one struct can + attach to many). The historical behaviour. +- **shared** (`swagger:parameters *`) — `buildShared` builds the fields as + a free-standing set and harvests them into `shared`, keyed by the + resolved parameter name (so `name:` / `NameFromTags` overrides are the + key — C3). Exposed via `SharedParameters()`; the spec builder + (`registerSharedParameters`) merges them into the top-level + `#/parameters` map with **keep-first** conflict handling (shared + parameters are never renamed — they are referenced only by short name — + so a duplicate short name keeps the first and drops the later with a + `scan.shared-parameter-conflict` warning; resolution order is + package-path then position, so it is load-order independent). +- **path** (`swagger:parameters /path`) — `buildPathItem` inlines the + struct's fields into the named path-item, harvested into `pathItems` + (keyed by exact path) and exposed via `PathItemParameters()`. The spec + builder (`applyPathItemParameters`) appends them to `PathItem.Parameters` + after all paths exist, alongside any `swagger:parameters /path name` + $refs. Application is exact-path (OAS2 has no path hierarchy); a target + naming a path with no operations is dropped with a warning. Path-item and + operation parameters co-exist — the operation one wins at resolution + (co-presence, not removal). + +A `swagger:parameters * opid …` marker also *references* the struct's +shared parameters into the listed operations; the target ops are exposed +via `SharedRefOperations()` and the spec builder +(`applyParameterRefs`) emits the `#/parameters/{name}` $refs once the +shared map is complete (alongside the standalone reference markers the +scanner collects into `ScanCtx.ParameterRefs`). A reference to an +unregistered shared parameter is dropped with a +`scan.dangling-parameter-ref` warning. Duplicate target / reference tokens +the grammar drops raise `scan.duplicate-target` (C1) / `scan.duplicate-ref` +(C2) warnings. + +Each target ultimately reaches `buildFromType` → `buildFromStruct`, which +walks the struct fields (unwrapping pointers, dispatching named types and +aliases). For each non-embedded exported field, `processParamField` runs the following ordered steps: diff --git a/internal/builders/parameters/doc_signals.go b/internal/builders/parameters/doc_signals.go index a0ec0c9e..026a0f1c 100644 --- a/internal/builders/parameters/doc_signals.go +++ b/internal/builders/parameters/doc_signals.go @@ -11,14 +11,12 @@ import ( "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`, the `swagger:strfmt` argument, and the -// `swagger:type` override 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. +// 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`, the `swagger:strfmt` argument, and the `swagger:type` override 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 @@ -30,17 +28,17 @@ type fieldDocSignals struct { swTypeSet bool } -// 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. +// 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. +// 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 { @@ -59,9 +57,8 @@ func scanFieldDocSignals(blocks []grammar.Block, doc *ast.CommentGroup) fieldDoc pd.strfmtSet = true } case grammar.AnnType: - // A field-level swagger:type overrides the parameter type - // (go-swagger#1499). Single-word filter mirrors strfmt and the - // schema builder's findAnnotationArg rule. + // A field-level swagger:type overrides the parameter type (go-swagger#1499). + // Single-word filter mirrors strfmt and the schema builder's findAnnotationArg rule. if arg, ok := b.AnnotationArg(); ok && !strings.ContainsAny(arg, " \t") { pd.swaggerType = arg pd.swTypeSet = true @@ -77,10 +74,10 @@ func scanFieldDocSignals(blocks []grammar.Block, doc *ast.CommentGroup) fieldDoc return pd } -// 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. +// 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 { diff --git a/internal/builders/parameters/errors.go b/internal/builders/parameters/errors.go index a04735f9..14f55495 100644 --- a/internal/builders/parameters/errors.go +++ b/internal/builders/parameters/errors.go @@ -8,10 +8,11 @@ import "errors" // ErrParameters is the sentinel error for all errors originating from the parameters package. var ErrParameters = errors.New("codescan:builders:parameters") -// errUnrepresentableParam is an internal sentinel signalling that a -// struct field has no OAS v2 SimpleSchema representation in a non-body -// parameter context (query/formData/path/header) — e.g. a Go map. The -// field-level caller (processParamField) recognizes it, records a -// diagnostic, and skips the field instead of failing the whole scan. +// errUnrepresentableParam is an internal sentinel signalling that a struct field has no OAS v2 +// SimpleSchema representation in a non-body parameter context (query/formData/path/header) — e.g. +// a Go map. +// +// The field-level caller (processParamField) recognizes it, records a diagnostic, and skips the +// field instead of failing the whole scan. // See go-swagger/go-swagger#2804. var errUnrepresentableParam = errors.New("codescan:builders:parameters:unrepresentable") diff --git a/internal/builders/parameters/parameters.go b/internal/builders/parameters/parameters.go index 17659c79..e2666a39 100644 --- a/internal/builders/parameters/parameters.go +++ b/internal/builders/parameters/parameters.go @@ -21,32 +21,57 @@ import ( const inBody = "body" -// 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). +// 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 - // inherited carries an embedded field's in:/required: annotation down - // into the parameters it promotes (go-swagger#2701). The zero value - // means no inheritance (top-level / non-embedded path). Set with - // save/restore around the embedded-field recursion in buildFromStruct. - // The mechanism is shared with the schema and responses builders via - // common.EmbedInheritance. + // inherited carries an embedded field's in:/required: annotation down into the parameters it + // promotes (go-swagger#2701). + // + // The zero value means no inheritance (top-level / non-embedded path). + // Set with save/restore around the embedded-field recursion in buildFromStruct. + // The mechanism is shared with the schema and responses builders via common.EmbedInheritance. inherited common.EmbedInheritance - // currentOpID is the operation id whose parameter set is being built. Set - // per-iteration in Build and read by processParamField to key the deferred - // cross-ref anchor capture. The same swagger:parameters struct may apply to - // several operations, so the capture runs once per op id. + // currentOpID is the operation id whose parameter set is being built. + // + // Set per-iteration in Build and read by processParamField to key the deferred cross-ref anchor + // capture. + // The same swagger:parameters struct may apply to several operations, so the capture runs once per + // op id. + // + // Empty while building a shared (`*`) parameter set — there is no operation context. currentOpID string + + // shared accumulates the parameters this struct registers at the spec top level + // (`swagger:parameters *`), keyed by resolved parameter name. nil unless a shared marker was + // built. + // + // Exposed via SharedParameters. + shared map[string]oaispec.Parameter + + // sharedRefOps are the operation ids a `swagger:parameters * opid …` marker references the + // struct's shared parameters into. + // + // The $ref wiring is applied by the spec builder. + // Exposed via SharedRefOperations. + sharedRefOps []string + + // pathItems holds the parameters a `swagger:parameters /path` marker inlines into a path-item, + // keyed by exact path. nil unless a path marker was built. + // + // Exposed via PathItemParameters. + pathItems map[string][]oaispec.Parameter } -// 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. +// 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), @@ -54,11 +79,100 @@ func NewBuilder(ctx *scanner.ScanCtx, decl *scanner.EntityDecl) *Builder { } 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() { + // A swagger:parameters struct may carry several markers, each parsed by + // the grammar into a ParametersBlock with a target (grammar owns the + // targeting parse). Dispatch per target: + // - operations: inline the struct's fields into each named operation + // (the historical behaviour). + // - shared (`*`): register the fields at the spec top level + // (#/parameters/{name}); harvested here, merged + conflict-checked by + // the spec builder via SharedParameters(). + // - path (`/path`): path-item parameters — handled in a later phase. + for _, pb := range p.parametersBlocks() { + p.warnDuplicateTargets(pb) + switch pb.Target { + case grammar.ParamTargetOperations: + if err := p.buildIntoOperations(pb.OperationIDs(), operations); err != nil { + return err + } + case grammar.ParamTargetShared: + if err := p.buildShared(); err != nil { + return err + } + // A `swagger:parameters * opid …` marker also references the struct's shared parameters into + // the listed operations; the $ref wiring is applied by the spec builder after the shared map is + // complete (see SharedRefOperations). + p.sharedRefOps = append(p.sharedRefOps, pb.Args...) + case grammar.ParamTargetPath: + // `swagger:parameters /path` on a struct inlines the struct's fields into the path-item. + // Harvested here; the spec builder applies them once paths are built (PathItemParameters). + if err := p.buildPathItem(pb.Path); err != nil { + return err + } + default: + // ParametersTarget is a closed set produced by the grammar; every member is handled above. + // A new member must add its case. + } + } + + return nil +} + +// SharedParameters returns the parameters this struct registers at the spec top level +// (`swagger:parameters *`), keyed by resolved parameter name. +// +// Empty unless the struct carried a shared (`*`) marker. +// The spec builder merges these into the single #/parameters map with keep-first conflict handling. +// Valid after Build. +func (p *Builder) SharedParameters() map[string]oaispec.Parameter { + return p.shared +} + +// SharedRefOperations returns the operation ids that a `swagger:parameters * opid …` marker +// references this struct's shared parameters into (as #/parameters/{name} $refs). +// +// Empty unless such a marker was present. +// The spec builder applies the $ref wiring once the shared map is complete. +// Valid after Build. +func (p *Builder) SharedRefOperations() []string { + return p.sharedRefOps +} + +// PathItemParameters returns the parameters this struct inlines into path-items via +// `swagger:parameters /path`, keyed by exact path, in field order. +// +// Empty unless a path marker was present. +// The spec builder applies them once paths are built. +// Valid after Build. +func (p *Builder) PathItemParameters() map[string][]oaispec.Parameter { + return p.pathItems +} + +// warnDuplicateTargets emits a duplicate-target warning (C1) for each argument token the grammar +// dropped as a duplicate on a definition marker (operations / shared targets). +func (p *Builder) warnDuplicateTargets(pb *grammar.ParametersBlock) { + for _, dup := range pb.Dups { + p.RecordDiagnostic(grammar.Warnf(pb.Pos(), grammar.CodeDuplicateTarget, + "swagger:parameters: duplicate target %q dropped", dup)) + } +} + +// parametersBlocks returns every grammar.ParametersBlock attached to the decl, in source order (a +// struct may carry several swagger:parameters lines). +func (p *Builder) parametersBlocks() []*grammar.ParametersBlock { + var out []*grammar.ParametersBlock + for _, b := range p.ParseBlocks(p.Decl.Comments) { + if pb, ok := b.(*grammar.ParametersBlock); ok { + out = append(out, pb) + } + } + return out +} + +// buildIntoOperations inlines the struct's fields into each named operation, creating the operation +// entry when absent. +func (p *Builder) buildIntoOperations(opids []string, operations map[string]*oaispec.Operation) error { + for _, opid := range opids { operation, ok := operations[opid] if !ok { operation = new(oaispec.Operation) @@ -82,6 +196,49 @@ func (p *Builder) Build(operations map[string]*oaispec.Operation) error { return nil } +// buildShared builds the struct's fields as a free-standing parameter set and harvests them, keyed +// by the resolved parameter name (the overridden name when `name:` / NameFromTags applies — C3), +// for top-level registration. +// +// Reuses the full field-building path by building into a throwaway operation. currentOpID is left +// empty so no operation-path cross-ref anchor is recorded (there is no operation here). +func (p *Builder) buildShared() error { + p.currentOpID = "" + tmp := new(oaispec.Operation) + if err := p.buildFromType(p.Decl.ObjType(), tmp, make(map[string]oaispec.Parameter)); err != nil { + return err + } + + if p.shared == nil { + p.shared = make(map[string]oaispec.Parameter, len(tmp.Parameters)) + } + for _, prm := range tmp.Parameters { + p.shared[prm.Name] = prm + } + + return nil +} + +// buildPathItem builds the struct's fields as an ordered parameter set for inlining into the given +// path-item. +// +// Reuses the full field-building path by building into a throwaway operation; currentOpID is left +// empty (no operation-path cross-ref anchor — path-item parameters are not under an operation). +func (p *Builder) buildPathItem(path string) error { + p.currentOpID = "" + tmp := new(oaispec.Operation) + if err := p.buildFromType(p.Decl.ObjType(), tmp, make(map[string]oaispec.Parameter)); err != nil { + return err + } + + if p.pathItems == nil { + p.pathItems = make(map[string][]oaispec.Parameter) + } + p.pathItems[path] = append(p.pathItems[path], tmp.Parameters...) + + return nil +} + func (p *Builder) buildFromType(otpe types.Type, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { switch tpe := otpe.(type) { case *types.Pointer: @@ -122,16 +279,17 @@ func (p *Builder) buildAlias(tpe *types.Alias, op *oaispec.Operation, seen map[s resolvers.MustNotBeABuiltinType(o) resolvers.MustHaveRightHandSide(tpe) - // `swagger:parameters` declares a parameter SET, not a model. Neither - // the alias decl nor any chain link of its target surfaces as a - // `definitions` entry — the fields of the unaliased target become the - // operation's parameters. There is no mode-specific behaviour for this - // case: TransparentAliases takes the same path as Default and - // RefAliases. The mode flags only affect alias *use* sites (field / - // element), not the top-level parameter-set declaration. + // `swagger:parameters` declares a parameter SET, not a model. + // Neither the alias decl nor any chain link of its target surfaces as a `definitions` entry — + // the fields of the unaliased target become the operation's parameters. // - // Recursion handles alias chains naturally: buildFromType dispatches - // back here for any chain link whose RHS is itself an alias. + // There is no mode-specific behaviour for this case: TransparentAliases takes the same path as + // Default and RefAliases. + // The mode flags only affect alias *use* sites (field / element), not the top-level parameter-set + // declaration. + // + // Recursion handles alias chains naturally: buildFromType dispatches back here for any chain link + // whose RHS is itself an alias. return p.buildFromType(tpe.Rhs(), op, seen) } @@ -173,13 +331,13 @@ func (p *Builder) buildFromFieldStruct(tpe *types.Struct, typable ifaces.Swagger } func (p *Builder) buildFromFieldMap(ftpe *types.Map, typable ifaces.SwaggerTypable) error { - // A Go map is only representable under in=body (object + - // additionalProperties). In any OAS v2 SimpleSchema location - // (query/formData/path/header) it has no representation: paramTypable - // (and ItemsTypable) return a nil schema there, so dereferencing it - // would panic (go-swagger#2804). Signal the field-level caller to skip - // the field with a diagnostic instead. Same rule as - // responses.buildFromFieldMap for SimpleSchema response headers. + // A Go map is only representable under in=body (object + additionalProperties). + // In any OAS v2 SimpleSchema location (query/formData/path/header) it has no representation: + // paramTypable (and ItemsTypable) return a nil schema there, so dereferencing it would panic + // (go-swagger#2804). + // + // Signal the field-level caller to skip the field with a diagnostic instead. + // Same rule as responses.buildFromFieldMap for SimpleSchema response headers. if typable.In() != inBody { return errUnrepresentableParam } @@ -197,13 +355,12 @@ func (p *Builder) buildFromFieldMap(ftpe *types.Map, typable ifaces.SwaggerTypab 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. + // 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) } @@ -277,8 +434,8 @@ func (p *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl resolvers.MustNotBeABuiltinType(o) resolvers.MustHaveRightHandSide(tpe) - // TransparentAliases supersedes annotation at use sites — dissolve - // to the unaliased target via the schema sub-builder. + // TransparentAliases supersedes annotation at use sites — dissolve to the unaliased target via + // the schema sub-builder. if p.Ctx.TransparentAliases() { sb := schema.NewBuilder(p.Ctx, p.Decl) if err := sb.Build(schema.OptionFor(tpe.Rhs(), typable)); err != nil { @@ -295,17 +452,15 @@ func (p *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl return fmt.Errorf("can't find source file for aliased type: %v -> %v: %w", tpe, tpe.Rhs(), ErrParameters) } - // Non-body parameters are SimpleSchema targets and cannot carry $ref — - // always expand the alias to its unaliased target regardless of - // annotation. Walking through every alias layer (types.Unalias) - // dissolves chains fully in one step. + // Non-body parameters are SimpleSchema targets and cannot carry $ref — always expand the alias + // to its unaliased target regardless of annotation. + // Walking through every alias layer (types.Unalias) dissolves chains fully in one step. if typable.In() != inBody { return p.buildFromField(fld, types.Unalias(tpe), typable, seen) } - // Body field: annotation gates first-class identity at the use - // site. See [§alias-handling](./README.md#alias-handling) for - // the cross-builder rule. + // Body field: annotation gates first-class identity at the use site. + // See [§alias-handling](./README.md#alias-handling) for the cross-builder rule. // // - annotated alias → $ref preserves the alias name; the alias // gets its own definition via MakeRef's AppendPostDecl side effect. @@ -313,9 +468,9 @@ func (p *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl // chain collapse via types.Unalias); the alias produces no // definition entry. // - // The mode flag (RefAliases vs Default) only affects the shape of the - // alias decl's OWN definition downstream — it does not change the - // field-site $ref target, which is gated entirely by annotation. + // The mode flag (RefAliases vs Default) only affects the shape of the alias decl's OWN definition + // downstream — it does not change the field-site $ref target, which is gated entirely by + // annotation. if decl.HasModelAnnotation() { return p.MakeRef(decl, typable) } @@ -367,20 +522,20 @@ func (p *Builder) buildFromStruct(decl *scanner.EntityDecl, tpe *types.Struct, o } func (p *Builder) buildEmbeddedField(fld *types.Var, decl *scanner.EntityDecl, op *oaispec.Operation, sequence []string, seen map[string]oaispec.Parameter) ([]string, error) { - // An in:/required: annotation on the embed itself applies to the - // parameters it promotes (go-swagger#2701). Thread it through the - // recursion as inherited context, restoring afterwards so sibling - // fields are unaffected. + // An in:/required: annotation on the embed itself applies to the parameters it promotes + // (go-swagger#2701). + // Thread it through the recursion as inherited context, restoring afterwards so sibling fields are + // unaffected. saved := p.inherited if afld := resolvers.FindASTField(decl.File, fld.Pos()); afld != nil { p.inherited = p.ReadEmbedInheritance(afld.Doc, saved) } - // An embed marked `in: body` IS the body parameter — the embedded - // struct becomes one body param's schema, exactly like a named - // `Body Foo` field, rather than promoting its members as N separate - // body params (an operation allows at most one body parameter, so - // per-field promotion produces an invalid spec). go-swagger#1635; - // the parameters counterpart of the responses in: body embed. + // An embed marked `in: body` IS the body parameter — the embedded struct becomes one body + // param's schema, exactly like a named `Body Foo` field, rather than promoting its members as N + // separate body params (an operation allows at most one body parameter, so per-field promotion + // produces an invalid spec). go-swagger#1635; the parameters counterpart of the responses in: body + // embed. + // // Other in: values still promote the embed's fields (#2701). if p.inherited.InSet && p.inherited.In == inBody { name, err := p.processParamField(fld, decl, seen) @@ -405,17 +560,19 @@ func (p *Builder) buildEmbeddedField(fld *types.Var, decl *scanner.EntityDecl, o return sequence, nil } -// applyTypeOverride honours a field-level `swagger:type` on a parameter -// (go-swagger#1499). The override always produces an inline SimpleSchema and -// wins outright over the field's Go type. Only what a parameter can represent -// is accepted: a scalar / Go-builtin base, optionally wrapped in `[]` array -// layers. `inline`, `file`, and type-name references have no SimpleSchema -// representation — they (and any unknown token) are rejected with a located -// diagnostic and the caller falls back to Go-type resolution. +// applyTypeOverride honours a field-level `swagger:type` on a parameter (go-swagger#1499). +// +// The override always produces an inline SimpleSchema and wins outright over the field's Go type. +// Only what a parameter can represent is accepted: a scalar / Go-builtin base, optionally wrapped +// in `[]` array layers. +// +// `inline`, `file`, and type-name references have no SimpleSchema representation — they (and any +// unknown token) are rejected with a located diagnostic and the caller falls back to Go-type +// resolution. // -// Unlike the schema builder's resolveTypeOverride, this never recurses into a -// Go struct (which would dereference the nil SimpleSchema schema of a non-body -// param), so it is panic-safe for every parameter location. +// Unlike the schema builder's resolveTypeOverride, this never recurses into a Go struct (which +// would dereference the nil SimpleSchema schema of a non-body param), so it is panic-safe for every +// parameter location. func (p *Builder) applyTypeOverride(arg string, typable ifaces.SwaggerTypable, fld *types.Var) bool { base, depth := stripArrayPrefixes(arg) @@ -438,10 +595,12 @@ func (p *Builder) applyTypeOverride(arg string, typable ifaces.SwaggerTypable, f return true } -// stripArrayPrefixes counts leading `[]` prefixes on a swagger:type argument -// and returns the bare base plus the array depth. `[]string` → ("string", 1), -// `int64` → ("int64", 0). Mirrors the schema builder's identically named -// unexported helper; kept local to avoid widening the schema package surface. +// stripArrayPrefixes counts leading `[]` prefixes on a swagger:type argument and returns the bare +// base plus the array depth. +// +// `[]string` → ("string", 1), `int64` → ("int64", 0). +// Mirrors the schema builder's identically named unexported helper; kept local to avoid widening +// the schema package surface. func stripArrayPrefixes(arg string) (base string, depth int) { base = strings.TrimSpace(arg) for strings.HasPrefix(base, "[]") { @@ -451,28 +610,29 @@ func stripArrayPrefixes(arg string) (base string, depth int) { return base, depth } -// resolveParamType resolves the parameter's type onto pty in precedence -// order: a formData file field, then a field-level swagger:type override -// (go-swagger#1499), then the field's own Go type. Returns skip=true (with a -// recorded diagnostic) when the Go type has no OAS v2 SimpleSchema +// resolveParamType resolves the parameter's type onto pty in precedence order: a formData file +// field, then a field-level swagger:type override (go-swagger#1499), then the field's own Go type. +// +// Returns skip=true (with a recorded diagnostic) when the Go type has no OAS v2 SimpleSchema // representation in this location and the field should be dropped. func (p *Builder) resolveParamType(signals fieldDocSignals, fld *types.Var, name, in string, pty ifaces.SwaggerTypable, seen map[string]oaispec.Parameter) (skip bool, err error) { switch { case in == "formData" && signals.file: pty.Typed("file", "") case signals.swTypeSet && p.applyTypeOverride(signals.swaggerType, pty, fld): - // A field-level swagger:type overrides the Go type for the parameter - // (go-swagger#1499) — the SimpleSchema analogue of the schema - // builder's field-level override. The override wins outright; the Go - // type is not consulted. A compatible swagger:strfmt then rides as a - // supplementary format back in processParamField. + // A field-level swagger:type overrides the Go type for the parameter (go-swagger#1499) — the + // SimpleSchema analogue of the schema builder's field-level override. + // The override wins outright; the Go type is not consulted. + // A compatible swagger:strfmt then rides as a supplementary format back in processParamField. default: if err := p.buildFromField(fld, fld.Type(), pty, seen); err != nil { if errors.Is(err, errUnrepresentableParam) { - // The field type has no OAS v2 SimpleSchema representation in - // this non-body location (e.g. a map under in=query). Record a - // located diagnostic and skip the field instead of panicking or - // failing the whole scan. See go-swagger/go-swagger#2804. + // The field type has no OAS v2 SimpleSchema representation in this non-body location (e.g. a + // map under in=query). + // Record a located diagnostic and skip the field instead of panicking or failing the whole + // scan. + // + // See go-swagger/go-swagger#2804. p.RecordDiagnostic(grammar.Warnf( p.Ctx.PosOf(fld.Pos()), grammar.CodeUnsupportedInSimpleSchema, @@ -489,6 +649,7 @@ func (p *Builder) resolveParamType(signals fieldDocSignals, fld *types.Var, name } // 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 *Builder) processParamField(fld *types.Var, decl *scanner.EntityDecl, seen map[string]oaispec.Parameter) (string, error) { if !fld.Exported() { @@ -506,7 +667,7 @@ func (p *Builder) processParamField(fld *types.Var, decl *scanner.EntityDecl, se return "", nil } - name, ignore, _, _, err := resolvers.ParseJSONTag(afld, fld.Name()) + name, ignore, _, _, err := resolvers.ParseFieldTag(afld, fld.Name(), p.Ctx.NameFromTags()) if err != nil { return "", err } @@ -514,22 +675,22 @@ func (p *Builder) processParamField(fld *types.Var, decl *scanner.EntityDecl, se return "", nil } - // A `name:` keyword on the field renames the JSON parameter name, - // overriding the json-tag / Go-field derivation (the parameter-side - // analogue of swagger:name on a schema field). Read it before `name` - // flows into the `seen` key, ps.Name, the sequence and the dedup so - // the rename is applied consistently. applyFieldCarrier-style - // x-go-name tracking below records the Go field name when it differs. + // A `name:` keyword on the field renames the JSON parameter name, overriding the json-tag / + // Go-field derivation (the parameter-side analogue of swagger:name on a schema field). + // + // Read it before `name` flows into the `seen` key, ps.Name, the sequence and the dedup so the + // rename is applied consistently. applyFieldCarrier-style x-go-name tracking below records the Go + // field name when it differs. if kwName, ok := p.ParseBlock(afld.Doc).GetString(grammar.KwName); ok { if kwName = strings.TrimSpace(kwName); kwName != "" { name = kwName } } - // A swagger:name annotation is inert in a parameter context — the - // canonical rename keyword here is `name:` (doc-quirk G2). It is dropped - // rather than applied, so warn in case the author reached for the schema - // annotation when they meant the keyword. + // A swagger:name annotation is inert in a parameter context — the canonical rename keyword here + // is `name:` (doc-quirk G2). + // It is dropped rather than applied, so warn in case the author reached for the schema annotation + // when they meant the keyword. for _, b := range p.ParseBlocks(afld.Doc) { if b.AnnotationKind() == grammar.AnnName { p.RecordDiagnostic(grammar.Warnf( @@ -542,9 +703,10 @@ func (p *Builder) processParamField(fld *types.Var, decl *scanner.EntityDecl, se } } - // Cross-ref linkage: capture the field's position keyed by (opid, name) for - // the spec builder's deferred /paths/.../parameters/{i} anchor pass. - if p.Ctx.OriginEnabled() { + // Cross-ref linkage: capture the field's position keyed by (opid, name) for the spec builder's + // deferred /paths/.../parameters/{i} anchor pass. + // Skipped for shared (`*`) parameters (empty opID) — they have no operation path. + if p.Ctx.OriginEnabled() && p.currentOpID != "" { p.Ctx.RecordParamOrigin(p.currentOpID, name, p.Ctx.PosOf(afld.Pos())) } @@ -572,11 +734,10 @@ func (p *Builder) processParamField(fld *types.Var, decl *scanner.EntityDecl, se if signals.strfmtSet { if signals.swTypeSet { - // swagger:type already fixed the type axis (go-swagger#1499); - // swagger:strfmt is supplementary and applies as a format only - // when compatible with the resolved type, mirroring the schema - // builder's swagger:type + swagger:strfmt precedence. An - // incompatible format is dropped rather than overriding the type. + // swagger:type already fixed the type axis (go-swagger#1499); swagger:strfmt is supplementary + // and applies as a format only when compatible with the resolved type, mirroring the schema + // builder's swagger:type + swagger:strfmt precedence. + // An incompatible format is dropped rather than overriding the type. if ok, _ := validations.IsFormatCompatible(ps.Type, signals.strfmt); ok { ps.Format = signals.strfmt } @@ -594,8 +755,8 @@ func (p *Builder) processParamField(fld *types.Var, decl *scanner.EntityDecl, se if ps.In == "path" { ps.Required = true } - // required: from an embedding field (go-swagger#2701), unless the - // promoted field set its own required: explicitly. + // required: from an embedding field (go-swagger#2701), unless the promoted field set its own + // required: explicitly. if !fieldSetRequired && p.inherited.RequiredSet && p.inherited.Required { ps.Required = true } diff --git a/internal/builders/parameters/parameters_test.go b/internal/builders/parameters/parameters_test.go index adf5bc80..6aa819a3 100644 --- a/internal/builders/parameters/parameters_test.go +++ b/internal/builders/parameters/parameters_test.go @@ -27,7 +27,9 @@ const ( paramFooSlice = "foo_slice" paramBarSlice = "bar_slice" - // paramNameKey / paramTypeKey mirror swagger:route param tag keys defined in parsers/route_params.go. + // paramNameKey / paramTypeKey mirror swagger:route param tag keys defined in + // parsers/route_params.go. + // // Duplicated here because the production constants are unexported and these swagger tag names are // part of a stable external contract. paramNameKey = "name" @@ -92,11 +94,11 @@ func TestScanFileParam(t *testing.T) { assert.FalseT(t, fileParam.Required) } -// TestParamsParser_OptionVariants exercises the (SkipExtensions, -// DescWithRef) option permutations on the classification operations -// corpus. The same set of parameter names as TestParamsParser is -// built per combination, asserting the $ref'd-field shape on each -// option pair: +// TestParamsParser_OptionVariants exercises the (SkipExtensions, DescWithRef) option permutations +// on the classification operations corpus. +// +// The same set of parameter names as TestParamsParser is built per combination, asserting the +// $ref'd-field shape on each option pair: // // - Default (false/false): description-only $ref'd fields render // as a bare $ref (matches v1 strict); scanner-derived x-go-name @@ -105,9 +107,9 @@ func TestScanFileParam(t *testing.T) { // single-arm allOf preserving the description. // - SkipExtensions=true: scanner-derived x-go-name is suppressed. // -// The witness field is the nested `pet` property of the array-item -// schema produced for someOperation's body `items` parameter, which -// carries both a description and an x-go-name on top of its $ref. +// The witness field is the nested `pet` property of the array-item schema produced for +// someOperation's body `items` parameter, which carries both a description and an x-go-name on top +// of its $ref. func TestParamsParser_OptionVariants(t *testing.T) { cases := []struct { name string @@ -144,9 +146,8 @@ func TestParamsParser_OptionVariants(t *testing.T) { require.NoError(t, prs.Build(operations)) } - // Locate someOperation's body `items` parameter and dig - // down to the array-item `pet` property, which is the - // field whose rendering diverges by option. + // Locate someOperation's body `items` parameter and dig down to the array-item `pet` property, + // which is the field whose rendering diverges by option. op, ok := operations["someOperation"] require.TrueT(t, ok) var itemsParam *oaispec.Parameter @@ -169,8 +170,8 @@ func TestParamsParser_OptionVariants(t *testing.T) { const petRef = "#/definitions/github.com/go-openapi/codescan/fixtures/goparsing/classification/transitive/mods/pet" if tc.descRef { - // description-only $ref renders as a single-arm allOf - // preserving the description; the bare $ref is empty. + // description-only $ref renders as a single-arm allOf preserving the description; the bare $ref + // is empty. assert.Empty(t, pet.Ref.String()) require.Len(t, pet.AllOf, 1) assert.EqualT(t, petRef, pet.AllOf[0].Ref.String()) @@ -181,8 +182,7 @@ func TestParamsParser_OptionVariants(t *testing.T) { assert.Empty(t, pet.AllOf) } - // x-go-name is scanner-derived: present by default, - // suppressed under SkipExtensions. + // x-go-name is scanner-derived: present by default, suppressed under SkipExtensions. require.NotNil(t, idParam) if tc.skipExt { assert.NotContains(t, idParam.Extensions, "x-go-name") diff --git a/internal/builders/parameters/typable.go b/internal/builders/parameters/typable.go index d5983698..a1e3669b 100644 --- a/internal/builders/parameters/typable.go +++ b/internal/builders/parameters/typable.go @@ -70,20 +70,23 @@ func (pt paramTypable) WithEnumDescription(desc string) { pt.param.AddExtension(resolvers.ExtEnumDesc, desc) } -// SimpleSchemaShape satisfies schema.SimpleSchemaProbe. See -// [§typable](./README.md#typable). +// 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. +// 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. +// 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{} diff --git a/internal/builders/parameters/walker.go b/internal/builders/parameters/walker.go index 7bf5bae8..6476a52b 100644 --- a/internal/builders/parameters/walker.go +++ b/internal/builders/parameters/walker.go @@ -11,23 +11,21 @@ import ( 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. +// 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. +// collectParamItemsLevels walks the AST array layers of a parameter field type and returns the +// (level, items) pairs reachable from the param's items chain. // -// 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). +// 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 @@ -63,19 +61,17 @@ func collectParamItemsLevels(expr ast.Expr, it *oaispec.Items, level int) []para } } -// applyBlockToField parses afld.Doc through grammar and dispatches -// description, level-0 validations, required flag, vendor extensions, -// and items-level validations into param. +// 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. +// 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() + param.Description = p.CleanGoDoc(block.Prose()) param.Description = resolvers.AppendEnumDesc(param.Description, param.Extensions, p.Ctx.SkipEnumDescriptions()) if err := handlers.DispatchParamLevel0(block, param, p.RecordDiagnostic); err != nil { diff --git a/internal/builders/resolvers/assertions.go b/internal/builders/resolvers/assertions.go index 282cabb5..3dfc8d5e 100644 --- a/internal/builders/resolvers/assertions.go +++ b/internal/builders/resolvers/assertions.go @@ -55,8 +55,9 @@ func MustBeAType(tpe types.TypeAndValue) { 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. +// 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. func IsFieldStringable(tpe ast.Expr) bool { if ident, ok := tpe.(*ast.Ident); ok { @@ -93,15 +94,16 @@ func IsTextMarshaler(tpe types.Type) bool { return types.Implements(tpe, asInterface) } -// IsJSONMapKey reports whether a Go map with this key type marshals to a JSON -// object under encoding/json — i.e. whether the map is representable as -// {type: object, additionalProperties: V}. +// IsJSONMapKey reports whether a Go map with this key type marshals to a JSON object under +// encoding/json — i.e. whether the map is representable as {type: object, additionalProperties: +// V}. +// +// The rule mirrors encoding/json's newMapEncoder: the key kind is string or any integer / +// unsigned-integer kind (int, int8…int64, uint, uint8…uint64, uintptr — all stringified), or +// the key implements encoding.TextMarshaler. // -// The rule mirrors encoding/json's newMapEncoder: the key kind is string or any -// integer / unsigned-integer kind (int, int8…int64, uint, uint8…uint64, -// uintptr — all stringified), or the key implements encoding.TextMarshaler. -// Everything else (float, bool, struct without TextMarshaler, interface, func) -// makes json.Marshal fail; json.Marshaler is never consulted for keys. +// Everything else (float, bool, struct without TextMarshaler, interface, func) makes json.Marshal +// fail; json.Marshaler is never consulted for keys. func IsJSONMapKey(key types.Type) bool { if b, ok := key.Underlying().(*types.Basic); ok && b.Info()&(types.IsString|types.IsInteger) != 0 { return true diff --git a/internal/builders/resolvers/deprecated.go b/internal/builders/resolvers/deprecated.go index 8dd17913..8da11ca3 100644 --- a/internal/builders/resolvers/deprecated.go +++ b/internal/builders/resolvers/deprecated.go @@ -5,18 +5,21 @@ package resolvers import oaispec "github.com/go-openapi/spec" -// ExtDeprecated is the vendor-extension key codescan uses to flag a -// deprecated model or field. OAS v2 has a native `deprecated` field only -// on operations; for schemas (models and their fields) the widely-used -// convention is the `x-deprecated: true` vendor extension. It is NOT an -// `x-go-*` reflection-metadata extension, so it is emitted regardless of +// ExtDeprecated is the vendor-extension key codescan uses to flag a deprecated model or field. +// +// OAS v2 has a native `deprecated` field only on operations; for schemas (models and their fields) +// the widely-used convention is the `x-deprecated: true` vendor extension. +// It is NOT an `x-go-*` reflection-metadata extension, so it is emitted regardless of // Options.SkipExtensions — it carries author intent, not Go internals. -// Detection of the deprecation (explicit `deprecated:` keyword or a godoc -// "Deprecated:" paragraph) is done in the grammar; builders consume the -// single grammar.Block.IsDeprecated() signal. See go-swagger/go-swagger#3138. +// +// Detection of the deprecation (explicit `deprecated:` keyword or a godoc "Deprecated:" paragraph) +// is done in the grammar; builders consume the single grammar.Block.IsDeprecated() signal. +// See go-swagger/go-swagger#3138. const ExtDeprecated = "x-deprecated" -// MarkDeprecated sets `x-deprecated: true` on the schema. Idempotent. +// MarkDeprecated sets `x-deprecated: true` on the schema. +// +// Idempotent. func MarkDeprecated(ps *oaispec.Schema) { if ps == nil { return diff --git a/internal/builders/resolvers/enum_desc.go b/internal/builders/resolvers/enum_desc.go index 8e430015..7eefc134 100644 --- a/internal/builders/resolvers/enum_desc.go +++ b/internal/builders/resolvers/enum_desc.go @@ -13,32 +13,38 @@ import oaispec "github.com/go-openapi/spec" // same constant. const ExtEnumDesc = "x-go-enum-desc" -// GetEnumDesc reads the x-go-enum-desc extension off a Swagger -// extensions map. Empty when absent. +// GetEnumDesc reads the x-go-enum-desc extension off a Swagger extensions map. // -// Consumers typically check this after a build pass to know whether -// they should append the per-value docs to a Description (parameters -// do this for the parameter description; the schema builder folds it -// in differently — see `handlers/dispatch_schema.go:clearStaleEnumDesc` -// for the override-cleanup dance). Response headers carry the mapping -// on x-go-enum-desc but never fold it into the header description. +// 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 do this for the parameter description; the schema +// builder folds it in differently — see `handlers/dispatch_schema.go:clearStaleEnumDesc` for the +// override-cleanup dance). +// +// Response headers carry the mapping on x-go-enum-desc but never fold it into the header +// description. func GetEnumDesc(extensions oaispec.Extensions) string { desc, _ := extensions.GetString(ExtEnumDesc) return desc } -// AppendEnumDesc folds the x-go-enum-desc const-name mapping (if any) -// into description, returning the resulting description. A newline -// separates the authored prose from the appended mapping. +// AppendEnumDesc folds the x-go-enum-desc const-name mapping (if any) into description, returning +// the resulting description. +// +// A newline separates the authored prose from the appended mapping. +// +// When skip is true the description is returned unchanged — the mapping then rides x-go-enum-desc +// only. +// +// This is the single gate shared by the schema (model decl + struct field) and parameter builders +// so the SkipEnumDescriptions option behaves identically across every target that folds the mapping +// in. // -// When skip is true the description is returned unchanged — the mapping -// then rides x-go-enum-desc only. This is the single gate shared by the -// schema (model decl + struct field) and parameter builders so the -// SkipEnumDescriptions option behaves identically across every target -// that folds the mapping in. Response headers never fold the mapping into -// their description, so they don't call this — but they do carry it on -// x-go-enum-desc in both modes (spec >= v0.22.6 marshals header vendor -// extensions). See go-swagger/go-swagger#2922. +// Response headers never fold the mapping into their description, so they don't call this — but +// they do carry it on x-go-enum-desc in both modes (spec >= v0.22.6 marshals header vendor +// extensions). +// See go-swagger/go-swagger#2922. func AppendEnumDesc(description string, extensions oaispec.Extensions, skip bool) string { if skip { return description diff --git a/internal/builders/resolvers/items_adapters.go b/internal/builders/resolvers/items_adapters.go index 1c3c6e7b..2f2e8431 100644 --- a/internal/builders/resolvers/items_adapters.go +++ b/internal/builders/resolvers/items_adapters.go @@ -9,14 +9,12 @@ import ( 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). +// 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. +// 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 @@ -54,38 +52,41 @@ func (pt ItemsTypable) WithEnum(values ...any) { } func (pt ItemsTypable) WithEnumDescription(_ string) { - // items levels carry no description channel; the enclosing - // parameter / header description already absorbs the enum doc. + // items levels carry no description channel; the enclosing parameter / header description already + // absorbs the enum doc. } -// SimpleSchemaShape satisfies schema.SimpleSchemaProbe: an items level -// in a non-body parameter / response header is itself a Swagger 2.0 -// SimpleSchema. Exposing it lets the schema builder's catch-at-exit -// validator inspect array-element shapes, not just the top-level -// target (go-swagger#1088). +// SimpleSchemaShape satisfies schema.SimpleSchemaProbe: an items level in a non-body parameter / +// response header is itself a Swagger 2.0 SimpleSchema. +// +// Exposing it lets the schema builder's catch-at-exit validator inspect array-element shapes, not +// just the top-level target (go-swagger#1088). func (pt ItemsTypable) SimpleSchemaShape() *oaispec.SimpleSchema { return &pt.items.SimpleSchema } -// HasRef satisfies schema.SimpleSchemaProbe. SimpleSchema forbids a -// $ref, including on array items: a named object element ([]Ele under -// in: query) otherwise resolves to `items: {$ref}`, which the Swagger -// 2.0 editor rejects (go-swagger#1088). +// HasRef satisfies schema.SimpleSchemaProbe. +// +// SimpleSchema forbids a $ref, including on array items: a named object element ([]Ele under in: +// query) otherwise resolves to `items: {$ref}`, which the Swagger 2.0 editor rejects +// (go-swagger#1088). func (pt ItemsTypable) HasRef() bool { return pt.items.Ref.String() != "" } -// ResetForViolation satisfies schema.SimpleSchemaProbe. Dissolves an -// illegal $ref / shape on this items level back to empty, mirroring +// ResetForViolation satisfies schema.SimpleSchemaProbe. +// +// Dissolves an illegal $ref / shape on this items level back to empty, mirroring // paramTypable.ResetForViolation one level down. func (pt ItemsTypable) ResetForViolation() { pt.items.SimpleSchema = oaispec.SimpleSchema{} pt.items.Ref = oaispec.Ref{} } -// ItemsValidations wraps an *oaispec.Items as a ValidationBuilder / -// OperationValidationBuilder target. Used by parameters' and -// responses' grammar items-level Walker dispatch. +// 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 } diff --git a/internal/builders/resolvers/resolvers.go b/internal/builders/resolvers/resolvers.go index 77048356..13a02829 100644 --- a/internal/builders/resolvers/resolvers.go +++ b/internal/builders/resolvers/resolvers.go @@ -26,9 +26,10 @@ const ( goTypeInt64 = "int64" ) -// SwaggerSchemaForType maps all Go builtin types that have Json representation to Swagger/Json types. +// SwaggerSchemaForType maps all Go builtin types that have Json representation to Swagger/Json +// types. // -// See https://golang.org/pkg/builtin/ and http://swagger.io/specification/ +// See https://golang.org/pkg/builtin/ and http://swagger.io/specification/. func SwaggerSchemaForType(typeName string, prop ifaces.SwaggerTypable) error { switch typeName { case "bool": @@ -38,9 +39,9 @@ func SwaggerSchemaForType(typeName string, prop ifaces.SwaggerTypable) error { case "complex128", "complex64": return fmt.Errorf("unsupported builtin %q (no JSON marshaller): %w", typeName, ErrResolver) case "error": - // Proposal for enhancement: error is often marshalled into a string but not always (e.g. errors package creates - // errors that are marshalled into an empty object), this could be handled the same way - // custom JSON marshallers are handled (future) + // Proposal for enhancement: error is often marshalled into a string but not always (e.g. errors + // package creates errors that are marshalled into an empty object), this could be handled the + // same way custom JSON marshallers are handled (future) prop.Typed("string", "") case "float32": prop.Typed("number", "float") @@ -74,14 +75,15 @@ func SwaggerSchemaForType(typeName string, prop ifaces.SwaggerTypable) error { prop.Typed("integer", "uint64") case "object": prop.Typed("object", "") - // Canonical OAS-2 scalar type names, accepted as `swagger:type` - // arguments alongside the Go-builtin spellings above (quirk F3). - // No implied format — `string`/`integer`/`number`/`boolean` carry - // only their type; a format may still be supplied via swagger:strfmt - // (applied when format-compatible — see validations.IsFormatCompatible). - // The Go-basic resolution path never passes these names (a - // *types.Basic stringifies as `int64`/`string`/…), so this only - // widens the swagger:type surface. + // Canonical OAS-2 scalar type names, accepted as `swagger:type` arguments alongside the Go-builtin + // spellings above (quirk F3). + // + // No implied format — `string`/`integer`/`number`/`boolean` carry only their type; a format may + // still be supplied via swagger:strfmt (applied when format-compatible — see + // validations.IsFormatCompatible). + // + // The Go-basic resolution path never passes these names (a *types.Basic stringifies as + // `int64`/`string`/…), so this only widens the swagger:type surface. case "integer": prop.Typed("integer", "") case "number": @@ -120,11 +122,10 @@ func UnsupportedBuiltinType(tpe types.Type) 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. +// 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: // @@ -173,17 +174,27 @@ func (t tagOptions) Name() string { return t[0] } -// ParseJSONTag derives the JSON property name and tag options for a struct -// field. goName is the field's Go identifier as reported by go/types; it is -// authoritative for the default name because a single AST field group may -// declare several names (`R, G, B, A uint8`), each a distinct go/types field -// promoted to its own property (go-swagger#2638). When goName is empty the -// first AST name is used as a fallback. +// ParseFieldTag derives the emitted name and the encoding/json directives for a struct field. // -// A json rename (`json:"foo"`) can only name a single field, so it is ignored -// for a multi-name group — each member keeps its own Go name — while the `-`, -// `,omitempty` and `,string` options still apply to every member. -func ParseJSONTag(field *ast.Field, goName string) (name string, ignore, isString, omitEmpty bool, err error) { +// The name is sourced from the first struct-tag type in nameTags that supplies a usable name — a +// non-empty name-part that isn't "-"; a tag type that is absent or carries only options (e.g. +// `,omitempty`) is skipped and the next type is tried. nameTags is typically Options.NameFromTags, +// defaulting to ["json"]. +// +// When nameTags is empty, or none of the listed tags name the field, the name falls back to goName +// — the field's Go identifier as reported by go/types, authoritative because a single AST field +// group may declare several names (`R, G, B, A uint8`), each a distinct go/types field promoted to +// its own property (go-swagger#2638). +// +// When goName is empty the first AST name is used. +// +// The `-` (ignore), `,omitempty` and `,string` directives are ALWAYS read from the `json` tag, +// independent of nameTags — they describe the encoding/json wire shape, not the Swagger name. +// `json:"-"` ignores the field. +// +// A rename can only name a single field, so it is dropped for a multi-name group — each member +// keeps its own Go name — while the directives still apply to every member. +func ParseFieldTag(field *ast.Field, goName string, nameTags []string) (name string, ignore, isString, omitEmpty bool, err error) { name = goName if name == "" && len(field.Names) > 0 { name = field.Names[0].Name @@ -196,45 +207,45 @@ func ParseJSONTag(field *ast.Field, goName string) (name string, ignore, isStrin if err != nil { return name, false, false, false, err } + if strings.TrimSpace(tv) == "" { + return name, false, false, false, nil + } - if strings.TrimSpace(tv) != "" { - st := reflect.StructTag(tv) - jsonParts := tagOptions(strings.Split(st.Get("json"), ",")) + st := reflect.StructTag(tv) - if jsonParts.Contain("string") { - // Need to check if the field type is a scalar. Otherwise, the - // ",string" directive doesn't apply. - isString = IsFieldStringable(field.Type) - } + // Directives are encoding/json-specific: always read them from the json tag, whatever nameTags + // selects for the name. + jsonParts := tagOptions(strings.Split(st.Get("json"), ",")) + if jsonParts.Contain("string") { + // The ",string" directive only applies to scalar field types. + isString = IsFieldStringable(field.Type) + } + omitEmpty = jsonParts.Contain("omitempty") + if jsonParts.Name() == "-" { + return name, true, isString, omitEmpty, nil + } - omitEmpty = jsonParts.Contain("omitempty") - - switch jsonParts.Name() { - case "-": - return name, true, isString, omitEmpty, nil - case "": - return name, false, isString, omitEmpty, nil - default: - if len(field.Names) > 1 { - // A rename names a single field; with a multi-name group it - // can't name N members, so each keeps its own Go name. - return name, false, isString, omitEmpty, nil + // The name comes from the first tag type that yields a usable name. + // A rename can't name N members of a multi-name group, so each keeps its own Go name. + if len(field.Names) <= 1 { + for _, tagType := range nameTags { + if candidate := tagOptions(strings.Split(st.Get(tagType), ",")).Name(); candidate != "" && candidate != "-" { + name = candidate + break } - return jsonParts.Name(), false, isString, omitEmpty, nil } } - return name, false, false, false, nil + + return name, false, isString, omitEmpty, nil } -// ExplicitJSONName returns the name set in a field's json struct tag — -// the part before the first comma — or "" when the field has no json -// tag, the tag sets no name (`json:",omitempty"`), or the tag skips the -// field (`json:"-"`). +// ExplicitJSONName returns the name set in a field's json struct tag — the part before the first +// comma — or "" when the field has no json tag, the tag sets no name (`json:",omitempty"`), or +// the tag skips the field (`json:"-"`). // -// Go's encoding/json treats an *embedded* struct field carrying an -// explicit json name as a regular named field: the embedded value nests -// under that name instead of being promoted. Callers use a non-empty -// result to distinguish a nesting embed from a flattening one +// Go's encoding/json treats an *embedded* struct field carrying an explicit json name as a regular +// named field: the embedded value nests under that name instead of being promoted. +// Callers use a non-empty result to distinguish a nesting embed from a flattening one // (go-swagger#2038). func ExplicitJSONName(field *ast.Field) string { if field == nil || field.Tag == nil || len(strings.TrimSpace(field.Tag.Value)) == 0 { diff --git a/internal/builders/resolvers/resolvers_test.go b/internal/builders/resolvers/resolvers_test.go index bef223ff..83227ac0 100644 --- a/internal/builders/resolvers/resolvers_test.go +++ b/internal/builders/resolvers/resolvers_test.go @@ -42,8 +42,8 @@ func TestAddExtension(t *testing.T) { assert.Nil(t, ve.Extensions[key3]) } -// typableCapture builds a MockSwaggerTypable whose Typed() records -// the (swaggerType, format) pair it receives. +// typableCapture builds a MockSwaggerTypable whose Typed() records the (swaggerType, format) pair +// it receives. func typableCapture() (*mocks.MockSwaggerTypable, *[2]string) { got := new([2]string) m := &mocks.MockSwaggerTypable{ @@ -135,8 +135,8 @@ func TestUnsupportedBuiltin(t *testing.T) { }) 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. + // 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)) @@ -220,7 +220,10 @@ func TestIsFieldStringable(t *testing.T) { }) } -func TestParseJSONTag(t *testing.T) { +func TestParseFieldTag(t *testing.T) { + // jsonTags is the default NameFromTags — name derived from the json tag. + jsonTags := []string{"json"} + ident := func(name string) *ast.Field { return &ast.Field{ Names: []*ast.Ident{ast.NewIdent(name)}, @@ -238,7 +241,7 @@ func TestParseJSONTag(t *testing.T) { t.Run("no tag uses goName", func(t *testing.T) { f := ident("Foo") - name, ignore, isString, omitEmpty, err := ParseJSONTag(f, "Foo") + name, ignore, isString, omitEmpty, err := ParseFieldTag(f, "Foo", jsonTags) require.NoError(t, err) assert.EqualT(t, "Foo", name) assert.FalseT(t, ignore) @@ -248,7 +251,7 @@ func TestParseJSONTag(t *testing.T) { t.Run("empty goName falls back to first AST name", func(t *testing.T) { f := ident("Foo") - name, _, _, _, err := ParseJSONTag(f, "") + name, _, _, _, err := ParseFieldTag(f, "", jsonTags) require.NoError(t, err) assert.EqualT(t, "Foo", name) }) @@ -256,7 +259,7 @@ func TestParseJSONTag(t *testing.T) { t.Run("json tag renames", func(t *testing.T) { f := ident("Foo") f.Tag = &ast.BasicLit{Value: "`json:\"foo,omitempty\"`"} - name, ignore, isString, omitEmpty, err := ParseJSONTag(f, "Foo") + name, ignore, isString, omitEmpty, err := ParseFieldTag(f, "Foo", jsonTags) require.NoError(t, err) assert.EqualT(t, "foo", name) assert.FalseT(t, ignore) @@ -267,7 +270,7 @@ func TestParseJSONTag(t *testing.T) { t.Run("json:\"-\" marks ignored", func(t *testing.T) { f := ident("Foo") f.Tag = &ast.BasicLit{Value: "`json:\"-\"`"} - name, ignore, _, _, err := ParseJSONTag(f, "Foo") + name, ignore, _, _, err := ParseFieldTag(f, "Foo", jsonTags) require.NoError(t, err) assert.EqualT(t, "Foo", name) assert.TrueT(t, ignore) @@ -276,7 +279,7 @@ func TestParseJSONTag(t *testing.T) { t.Run("json:\",string\" on scalar sets isString", func(t *testing.T) { f := ident("Foo") f.Tag = &ast.BasicLit{Value: "`json:\",string\"`"} - name, _, isString, _, err := ParseJSONTag(f, "Foo") + name, _, isString, _, err := ParseFieldTag(f, "Foo", jsonTags) require.NoError(t, err) assert.EqualT(t, "Foo", name) assert.TrueT(t, isString) @@ -284,11 +287,10 @@ func TestParseJSONTag(t *testing.T) { t.Run("whitespace-only tag value falls through", func(t *testing.T) { f := ident("Foo") - // Backticks wrap a single space — TrimSpace of `" "` != empty (outer check - // passes), but Unquote yields " " which does TrimSpace to empty — hits - // the final fallthrough. + // Backticks wrap a single space — TrimSpace of `" "` != empty (outer check passes), but Unquote + // yields " " which does TrimSpace to empty — hits the final fallthrough. f.Tag = &ast.BasicLit{Value: "` `"} - name, ignore, isString, omitEmpty, err := ParseJSONTag(f, "Foo") + name, ignore, isString, omitEmpty, err := ParseFieldTag(f, "Foo", jsonTags) require.NoError(t, err) assert.EqualT(t, "Foo", name) assert.FalseT(t, ignore) @@ -298,18 +300,19 @@ func TestParseJSONTag(t *testing.T) { t.Run("malformed tag returns Unquote error", func(t *testing.T) { f := ident("Foo") - // Unquote requires surrounding backticks/quotes. Bare word is invalid. + // Unquote requires surrounding backticks/quotes. + // Bare word is invalid. f.Tag = &ast.BasicLit{Value: "not-a-quoted-tag"} - _, _, _, _, err := ParseJSONTag(f, "Foo") + _, _, _, _, err := ParseFieldTag(f, "Foo", jsonTags) require.Error(t, err) }) t.Run("multi-name group keeps each goName (go-swagger#2638)", func(t *testing.T) { - // `R, G, B, A uint8` — go/types yields one var per name sharing the - // same AST field; each member must keep its own name. + // `R, G, B, A uint8` — go/types yields one var per name sharing the same AST field; each member + // must keep its own name. f := multi("R", "G", "B", "A") for _, n := range []string{"R", "G", "B", "A"} { - name, _, _, _, err := ParseJSONTag(f, n) + name, _, _, _, err := ParseFieldTag(f, n, jsonTags) require.NoError(t, err) assert.EqualT(t, n, name) } @@ -318,7 +321,7 @@ func TestParseJSONTag(t *testing.T) { t.Run("rename ignored on multi-name group, options still apply", func(t *testing.T) { f := multi("R", "G") f.Tag = &ast.BasicLit{Value: "`json:\"renamed,omitempty\"`"} - name, ignore, _, omitEmpty, err := ParseJSONTag(f, "G") + name, ignore, _, omitEmpty, err := ParseFieldTag(f, "G", jsonTags) require.NoError(t, err) assert.EqualT(t, "G", name, "a single rename cannot name N members") assert.FalseT(t, ignore) @@ -328,10 +331,83 @@ func TestParseJSONTag(t *testing.T) { t.Run("json:\"-\" on multi-name group ignores all members", func(t *testing.T) { f := multi("R", "G") f.Tag = &ast.BasicLit{Value: "`json:\"-\"`"} - _, ignore, _, _, err := ParseJSONTag(f, "R") + _, ignore, _, _, err := ParseFieldTag(f, "R", jsonTags) require.NoError(t, err) assert.TrueT(t, ignore) }) + + // NameFromTags — the name is sourced from the first tag type that yields a usable name; + // encoding/json directives always come from the json tag. + + t.Run("form tag names the field (go-swagger#2912)", func(t *testing.T) { + f := ident("SortKey") + f.Tag = &ast.BasicLit{Value: "`form:\"sort_key\"`"} + name, _, _, _, err := ParseFieldTag(f, "SortKey", []string{"form", "json"}) + require.NoError(t, err) + assert.EqualT(t, "sort_key", name, "form: wins when it is first in the list") + }) + + t.Run("first usable tag in the list wins", func(t *testing.T) { + f := ident("X") + f.Tag = &ast.BasicLit{Value: "`json:\"x\" form:\"y\"`"} + name, _, _, _, err := ParseFieldTag(f, "X", []string{"form", "json"}) + require.NoError(t, err) + assert.EqualT(t, "y", name, "form precedes json in the list") + }) + + t.Run("falls through a present-but-nameless tag", func(t *testing.T) { + f := ident("X") + // form is present but supplies no name (options only) — fall through to json. + f.Tag = &ast.BasicLit{Value: "`json:\"x\" form:\",omitempty\"`"} + name, _, _, _, err := ParseFieldTag(f, "X", []string{"form", "json"}) + require.NoError(t, err) + assert.EqualT(t, "x", name) + }) + + t.Run("no listed tag present falls back to goName", func(t *testing.T) { + f := ident("Foo") + f.Tag = &ast.BasicLit{Value: "`xml:\"foo\"`"} + name, _, _, _, err := ParseFieldTag(f, "Foo", []string{"form", "json"}) + require.NoError(t, err) + assert.EqualT(t, "Foo", name) + }) + + t.Run("empty nameTags ignores tags for naming, directives still apply", func(t *testing.T) { + f := ident("Foo") + f.Tag = &ast.BasicLit{Value: "`json:\"foo,omitempty\"`"} + name, ignore, _, omitEmpty, err := ParseFieldTag(f, "Foo", []string{}) + require.NoError(t, err) + assert.EqualT(t, "Foo", name, "no tag consulted for the name") + assert.FalseT(t, ignore) + assert.TrueT(t, omitEmpty, "omitempty still read from the json tag") + }) + + t.Run("json:\"-\" still excludes regardless of nameTags", func(t *testing.T) { + f := ident("Secret") + f.Tag = &ast.BasicLit{Value: "`json:\"-\" form:\"secret\"`"} + _, ignore, _, _, err := ParseFieldTag(f, "Secret", []string{"form", "json"}) + require.NoError(t, err) + assert.TrueT(t, ignore, "the json `-` directive is independent of name selection") + }) + + t.Run("directives read from json even when name comes from form", func(t *testing.T) { + f := ident("Count") + f.Tag = &ast.BasicLit{Value: "`json:\",omitempty,string\" form:\"count\"`"} + name, ignore, isString, omitEmpty, err := ParseFieldTag(f, "Count", []string{"form", "json"}) + require.NoError(t, err) + assert.EqualT(t, "count", name) + assert.FalseT(t, ignore) + assert.TrueT(t, isString) + assert.TrueT(t, omitEmpty) + }) + + t.Run("form name dropped on multi-name group", func(t *testing.T) { + f := multi("R", "G") + f.Tag = &ast.BasicLit{Value: "`form:\"renamed\"`"} + name, _, _, _, err := ParseFieldTag(f, "G", []string{"form", "json"}) + require.NoError(t, err) + assert.EqualT(t, "G", name, "a single rename cannot name N members, whatever the tag type") + }) } func TestMustNotBeABuiltinType(t *testing.T) { @@ -355,8 +431,8 @@ func TestMustNotBeABuiltinType(t *testing.T) { } func TestInternalError_Error(t *testing.T) { - // Exercises the internalError.Error method that satisfies the `error` - // interface used in fmt.Errorf("...%w", ErrInternal). + // Exercises the internalError.Error method that satisfies the `error` interface used in + // fmt.Errorf("...%w", ErrInternal). assert.EqualT(t, string(ErrInternal), ErrInternal.Error()) } diff --git a/internal/builders/responses/README.md b/internal/builders/responses/README.md index 3c3e0cd3..30149693 100644 --- a/internal/builders/responses/README.md +++ b/internal/builders/responses/README.md @@ -38,12 +38,41 @@ same. Divergences are called out below. ## §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. +`ResponseName()`), runs `applyBlockToDecl` to capture the +top-level description **and the response-level `examples:` block**, 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. + +`ResponseName()` resolves the spec name from the **grammar** +(`grammar.ResponseBlock.Name`) — the explicit `swagger:response {name}` +argument, else the Go type name. The bare `swagger:response` and the +`swagger:response *` synonym both key by the type name (`*` is a shared +marker mirroring `swagger:parameters *`; responses are always top-level and +referenceable, so it carries no op-ids and no `/path` form). The targeting +parse lives in the grammar, not the scanner — `rxResponseOverride` is only a +classification gate (it accepts `*` but still rejects a malformed +package-qualified name so `MalformedResponseName` can flag it). + +Every `swagger:response` registers a shared `#/responses/{name}`; the spec +builder (`buildResponses`) merges them in deterministic order (package path, +then position) with **keep-first** conflict handling — a duplicate short +name keeps the first and drops the later with a +`scan.shared-response-conflict` warning, never renaming (responses are +referenced only by short name). An InputSpec (overlay) response of the same +name is not a conflict: a scanned struct extends it. + +`examples:` is the OAS2 **response-scoped** keyword (plural): a YAML map +keyed by mime type (`examples:` then an indented `application/json: {…}`) +parsed into `Response.examples` (go-swagger#2871). It is the +`CtxResponse`-only `examples` grammar keyword, joined to the lexer's +YAML-body set so the mime→object nesting survives. The singular +`example:` is a schema/param/header decorator, handled per-field / +per-body, not here. The `swagger:operation` YAML path carries examples +for free via the spec unmarshal; this is the struct-`swagger:response` +counterpart. For each non-embedded exported field, `processResponseField` runs: diff --git a/internal/builders/responses/doc_signals.go b/internal/builders/responses/doc_signals.go index cb6ed0a7..cb7f5d58 100644 --- a/internal/builders/responses/doc_signals.go +++ b/internal/builders/responses/doc_signals.go @@ -11,16 +11,16 @@ import ( "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. +// 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. +// 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 @@ -31,17 +31,17 @@ type fieldDocSignals struct { 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. +// 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. +// 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 { @@ -74,9 +74,9 @@ func scanFieldDocSignals(blocks []grammar.Block, doc *ast.CommentGroup) fieldDoc return pd } -// strfmtFromDoc returns the argument of a `swagger:strfmt ` -// annotation present in blocks. Single-word filter mirrors the -// schema package's `findAnnotationArg` rule. +// 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 { diff --git a/internal/builders/responses/errors.go b/internal/builders/responses/errors.go index bfe3953f..f5ebd266 100644 --- a/internal/builders/responses/errors.go +++ b/internal/builders/responses/errors.go @@ -8,10 +8,11 @@ import "errors" // ErrResponses is the sentinel error for all errors originating from the responses package. var ErrResponses = errors.New("codescan:builders:responses") -// errUnrepresentableHeader is an internal sentinel signalling that a -// response field has no OAS v2 SimpleSchema representation in a header -// (non-body) context — e.g. a Go map. The field-level caller -// (processResponseField) recognizes it, records a diagnostic, and skips -// the header instead of corrupting the response body schema. Mirrors -// parameters.errUnrepresentableParam. See go-swagger/go-swagger#2804. +// errUnrepresentableHeader is an internal sentinel signalling that a response field has no OAS v2 +// SimpleSchema representation in a header (non-body) context — e.g. a Go map. +// +// The field-level caller (processResponseField) recognizes it, records a diagnostic, and skips the +// header instead of corrupting the response body schema. +// Mirrors parameters.errUnrepresentableParam. +// See go-swagger/go-swagger#2804. var errUnrepresentableHeader = errors.New("codescan:builders:responses:unrepresentable") diff --git a/internal/builders/responses/responses.go b/internal/builders/responses/responses.go index 02520306..504c414f 100644 --- a/internal/builders/responses/responses.go +++ b/internal/builders/responses/responses.go @@ -24,53 +24,72 @@ const ( inHeader = "header" ) -// Builder constructs OAS v2 response entries for one -// `swagger:response` declaration. Embeds *common.Builder for shared -// state (Ctx, Decl, PostDeclarations, diagnostics, ParseBlocks +// 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 - // inherited carries an embedded field's in: annotation down to the - // response fields it promotes (go-swagger#2701) — the body/header - // routing discriminator. Set with save/restore around the embedded- - // field recursion in buildFromStruct. The mechanism is shared with the - // schema and parameters builders via common.EmbedInheritance; responses - // consume only In (OAS2 response headers carry no required). + // inherited carries an embedded field's in: annotation down to the response fields it promotes + // (go-swagger#2701) — the body/header routing discriminator. + // + // Set with save/restore around the embedded- field recursion in buildFromStruct. + // The mechanism is shared with the schema and parameters builders via common.EmbedInheritance; + // responses consume only In (OAS2 response headers carry no required). inherited common.EmbedInheritance - // respBase is the cross-ref base pointer for this response — - // /responses/{name} — set per Build when a provenance sink is wired ("" when - // off). Header anchors hang at respBase/headers/{h}; the in:body schema under - // respBase/schema. bodyPath is the live cursor into the body-schema subtree, - // advanced by descendBody as the responses builder peels its OWN array/map - // layers (delegated struct/named builds are pathed by the schema builder). + // respBase is the cross-ref base pointer for this response — /responses/{name} — set per Build + // when a provenance sink is wired ("" when off). + // + // Header anchors hang at respBase/headers/{h}; the in:body schema under respBase/schema. bodyPath + // is the live cursor into the body-schema subtree, advanced by descendBody as the responses + // builder peels its OWN array/map layers (delegated struct/named builds are pathed by the schema + // builder). respBase string bodyPath string } -// 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. +// 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), } } +// ResponseName resolves the spec name of this response declaration from the grammar +// (grammar.ResponseBlock): the explicit `swagger:response {name}` argument when present, else the +// Go type name (covering the bare `swagger:response` and the `swagger:response *` synonym, which +// both key the response by its type name). +// +// The targeting parse lives in the grammar, not the scanner. +func (r *Builder) ResponseName() string { + for _, b := range r.ParseBlocks(r.Decl.Comments) { + if rb, ok := b.(*grammar.ResponseBlock); ok { + if rb.Name != "" { + return rb.Name + } + break + } + } + return r.Decl.Ident.Name +} + 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 + // 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.ResponseName() response := responses[name] - // Cross-ref linkage: anchor this response's headers and in:body schema under - // /responses/{name}. The response name is known here (no deferral, unlike a - // parameter's array index), so the base path is fixed for the whole build. + // Cross-ref linkage: anchor this response's headers and in:body schema under /responses/{name}. + // The response name is known here (no deferral, unlike a parameter's array index), so the base + // path is fixed for the whole build. if r.Ctx.OriginEnabled() { r.respBase = scanner.JSONPointer("responses", name) r.bodyPath = r.respBase + scanner.JSONPointer("schema") @@ -90,12 +109,13 @@ func (r *Builder) Build(responses map[string]oaispec.Response) error { return err } - // Carry decl-comment schema keywords (example:, default:, validations) - // onto a top-level non-struct response body schema. applyBlockToDecl - // only takes the prose/description; without this, an `example:` on a - // `swagger:response` whose body is a bare array/scalar type is dropped - // (go-swagger#3013). Struct responses carry these on their fields, not - // the decl, and a $ref body must not gain sibling keywords — both skipped. + // Carry decl-comment schema keywords (example:, default:, validations) onto a top-level non-struct + // response body schema. applyBlockToDecl only takes the prose/description; without this, an + // `example:` on a `swagger:response` whose body is a bare array/scalar type is dropped + // (go-swagger#3013). + // + // Struct responses carry these on their fields, not the decl, and a $ref body must not gain + // sibling keywords — both skipped. if response.Schema != nil && response.Schema.Ref.String() == "" && !underlyingIsStruct(r.Decl.ObjType()) { handlers.DispatchSchemaLevel0( r.ParseBlock(r.Decl.Comments), nil, response.Schema, "", @@ -108,9 +128,8 @@ func (r *Builder) Build(responses map[string]oaispec.Response) error { return nil } -// underlyingIsStruct reports whether t resolves (through named/alias/ -// pointer layers) to a struct — i.e. a struct-bodied response whose -// fields, not the decl comment, carry schema keywords. +// underlyingIsStruct reports whether t resolves (through named/alias/ pointer layers) to a struct +// — i.e. a struct-bodied response whose fields, not the decl comment, carry schema keywords. func underlyingIsStruct(t types.Type) bool { for { switch tt := t.(type) { @@ -128,10 +147,11 @@ func underlyingIsStruct(t types.Type) bool { } } -// descendBody advances the in:body schema cursor by segs for the duration of a -// child build, mirroring the schema builder's descend. It keeps bodyPath aligned -// with the node being filled when the responses builder peels its OWN array/map -// layers; types delegated to the schema sub-builder are pathed there instead. +// descendBody advances the in:body schema cursor by segs for the duration of a child build, +// mirroring the schema builder's descend. +// +// It keeps bodyPath aligned with the node being filled when the responses builder peels its OWN +// array/map layers; types delegated to the schema sub-builder are pathed there instead. // No-op (and no restore cost) when provenance is off (bodyPath == ""). func (r *Builder) descendBody(segs ...string) func() { if r.bodyPath == "" { @@ -142,10 +162,10 @@ func (r *Builder) descendBody(segs ...string) func() { return func() { r.bodyPath = saved } } -// bodyPathFor returns the cross-ref base path to hand a schema sub-build: the -// live body cursor when the build targets the in:body schema, else "" — a header -// schema anchors at respBase/headers/{h}, not under /schema, so its finer nodes -// resolve to the header anchor rather than emitting a wrong /schema/... pointer. +// bodyPathFor returns the cross-ref base path to hand a schema sub-build: the live body cursor when +// the build targets the in:body schema, else "" — a header schema anchors at +// respBase/headers/{h}, not under /schema, so its finer nodes resolve to the header anchor rather +// than emitting a wrong /schema/... pointer. func (r *Builder) bodyPathFor(typable ifaces.SwaggerTypable) string { if typable != nil && typable.In() == inBody { return r.bodyPath @@ -194,14 +214,14 @@ func (r *Builder) buildFromFieldStruct(ftpe *types.Struct, typable ifaces.Swagge } func (r *Builder) buildFromFieldMap(ftpe *types.Map, typable ifaces.SwaggerTypable) error { - // A Go map is only representable under in=body (object + - // additionalProperties). A response header is an OAS v2 SimpleSchema - // target with no map representation. Unlike paramTypable, - // responseTypable.Schema() always returns the *body* schema, so the - // non-body path would not panic but silently corrupt the response body - // and leave the header untyped. Signal the field-level caller to skip - // the header with a diagnostic instead. Same rule as - // parameters.buildFromFieldMap. See go-swagger/go-swagger#2804. + // A Go map is only representable under in=body (object + additionalProperties). + // A response header is an OAS v2 SimpleSchema target with no map representation. + // Unlike paramTypable, responseTypable.Schema() always returns the *body* schema, so the non-body + // path would not panic but silently corrupt the response body and leave the header untyped. + // + // Signal the field-level caller to skip the header with a diagnostic instead. + // Same rule as parameters.buildFromFieldMap. + // See go-swagger/go-swagger#2804. if typable.In() != inBody { return errUnrepresentableHeader } @@ -211,8 +231,8 @@ func (r *Builder) buildFromFieldMap(ftpe *types.Map, typable ifaces.SwaggerTypab Schema: sch, } - // The map value renders at respBase/schema/additionalProperties; advance the - // body cursor so the value's inline props (if any) anchor there. + // The map value renders at respBase/schema/additionalProperties; advance the body cursor so the + // value's inline props (if any) anchor there. defer r.descendBody("additionalProperties")() valTypable := schema.NewTypable(sch, typable.Level()+1, r.Ctx.SkipExtensions()) sb := schema.NewBuilder(r.Ctx, r.Decl) @@ -307,19 +327,17 @@ func (r *Builder) buildAlias(tpe *types.Alias, resp *oaispec.Response, seen map[ resolvers.MustNotBeABuiltinType(o) resolvers.MustHaveRightHandSide(tpe) - // `swagger:response` declares a response, not a model. Neither the - // alias decl nor any chain link of its backing struct surfaces as a - // `definitions` entry — the fields of the unaliased target become - // the response's body / headers. The mode flags only affect alias - // *use* sites (field / element), not the top-level response-set - // declaration; TransparentAliases, RefAliases and Default share - // the same path here. + // `swagger:response` declares a response, not a model. + // Neither the alias decl nor any chain link of its backing struct surfaces as a `definitions` + // entry — the fields of the unaliased target become the response's body / headers. // - // Recursion handles alias chains naturally: buildFromType - // dispatches back here for any chain link whose RHS is itself an - // alias. The named-struct target is reached via buildNamedType -> - // buildFromStruct, the same path a directly-declared - // swagger:response struct takes. + // The mode flags only affect alias *use* sites (field / element), not the top-level response-set + // declaration; TransparentAliases, RefAliases and Default share the same path here. + // + // Recursion handles alias chains naturally: buildFromType dispatches back here for any chain link + // whose RHS is itself an alias. + // The named-struct target is reached via buildNamedType -> buildFromStruct, the same path a + // directly-declared swagger:response struct takes. return r.buildFromType(tpe.Rhs(), resp, seen) } @@ -362,8 +380,8 @@ func (r *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl return nil // just leave an empty schema } - // TransparentAliases supersedes annotation at use sites — dissolve - // to the unaliased target via the schema sub-builder. + // TransparentAliases supersedes annotation at use sites — dissolve to the unaliased target via + // the schema sub-builder. if r.Ctx.TransparentAliases() { sb := schema.NewBuilder(r.Ctx, r.Decl) if err := sb.Build(schema.OptionFor(tpe.Rhs(), typable), schema.WithPath(r.bodyPathFor(typable))); err != nil { @@ -375,9 +393,8 @@ func (r *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl return nil } - // Non-body fields are SimpleSchema targets and cannot carry $ref — - // always expand the alias to its unaliased target regardless of - // annotation. types.Unalias collapses chains in one step. + // Non-body fields are SimpleSchema targets and cannot carry $ref — always expand the alias to + // its unaliased target regardless of annotation. types.Unalias collapses chains in one step. if typable.In() != inBody { return r.buildFromField(fld, types.Unalias(tpe), typable, seen) } @@ -387,9 +404,8 @@ func (r *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl return fmt.Errorf("can't find source file for aliased type: %v: %w", tpe, ErrResponses) } - // Body field: annotation gates first-class identity at the use - // site. See [§alias-handling](./README.md#alias-handling) for - // the cross-builder rule. + // Body field: annotation gates first-class identity at the use site. + // See [§alias-handling](./README.md#alias-handling) for the cross-builder rule. // // - annotated alias → $ref preserves the alias name; the alias // gets its own definition via MakeRef's AppendPostDecl side @@ -397,9 +413,8 @@ func (r *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl // - unannotated alias → dissolve fully to the unaliased target; // the alias produces no definition entry. // - // The mode flag (RefAliases vs Default) only affects the shape of - // the alias decl's OWN definition downstream — it does not change - // the field-site $ref target, which is gated entirely by + // The mode flag (RefAliases vs Default) only affects the shape of the alias decl's OWN definition + // downstream — it does not change the field-site $ref target, which is gated entirely by // annotation. if decl.HasModelAnnotation() { return r.MakeRef(decl, typable) @@ -442,19 +457,17 @@ func (r *Builder) buildFromStruct(decl *scanner.EntityDecl, tpe *types.Struct, r } func (r *Builder) buildEmbeddedField(fld *types.Var, decl *scanner.EntityDecl, resp *oaispec.Response, seen map[string]bool) error { - // An in: annotation on the embed applies to the response fields - // it promotes (go-swagger#2701) — body/header routing. Thread it - // through the recursion, restoring afterwards so siblings are - // unaffected. + // An in: annotation on the embed applies to the response fields it promotes (go-swagger#2701) — + // body/header routing. + // Thread it through the recursion, restoring afterwards so siblings are unaffected. saved := r.inherited if afld := resolvers.FindASTField(decl.File, fld.Pos()); afld != nil { r.inherited = r.ReadEmbedInheritance(afld.Doc, saved) } - // An embed marked `in: body` IS the response body — the embedded - // struct becomes the body schema, exactly like a named `Body Foo` - // field, rather than promoting its members (a response has a single - // body, so per-field promotion is meaningless). go-swagger#1635. - // Other in: values still promote the embed's fields (#2701). + // An embed marked `in: body` IS the response body — the embedded struct becomes the body schema, + // exactly like a named `Body Foo` field, rather than promoting its members (a response has a + // single body, so per-field promotion is meaningless). go-swagger#1635. Other in: values still + // promote the embed's fields (#2701). if r.inherited.InSet && r.inherited.In == inBody { err := r.buildBodyEmbed(fld, resp, seen) r.inherited = saved @@ -474,10 +487,9 @@ func (r *Builder) buildEmbeddedField(fld *types.Var, decl *scanner.EntityDecl, r return nil } -// buildBodyEmbed renders an anonymously-embedded field marked `in: body` -// as the response body, exactly like a named `Body Foo` field: the -// embedded type drives the body schema (a $ref to a model, or its inline -// shape) instead of its members becoming response headers (go-swagger#1635). +// buildBodyEmbed renders an anonymously-embedded field marked `in: body` as the response body, +// exactly like a named `Body Foo` field: the embedded type drives the body schema (a $ref to a +// model, or its inline shape) instead of its members becoming response headers (go-swagger#1635). func (r *Builder) buildBodyEmbed(fld *types.Var, resp *oaispec.Response, seen map[string]bool) error { var refAttempted bool header := oaispec.Header{} @@ -506,7 +518,7 @@ func (r *Builder) processResponseField(fld *types.Var, decl *scanner.EntityDecl, return nil } - name, ignore, _, _, err := resolvers.ParseJSONTag(afld, fld.Name()) + name, ignore, _, _, err := resolvers.ParseFieldTag(afld, fld.Name(), r.Ctx.NameFromTags()) if err != nil { return err } @@ -514,11 +526,12 @@ func (r *Builder) processResponseField(fld *types.Var, decl *scanner.EntityDecl, return nil } - // A `name:` keyword renames the response header (the Headers map key), - // overriding the json-tag / Go-field derivation — the response-side - // analogue of the same keyword on a swagger:parameters field. Read it - // before `name` flows into the Headers key / seen set. (Harmless on a - // body field: the body path below never consults `name`.) + // A `name:` keyword renames the response header (the Headers map key), overriding the json-tag / + // Go-field derivation — the response-side analogue of the same keyword on a swagger:parameters + // field. + // + // Read it before `name` flows into the Headers key / seen set. (Harmless on a body field: the body + // path below never consults `name`.) if kwName, ok := r.ParseBlock(afld.Doc).GetString(grammar.KwName); ok { if kwName = strings.TrimSpace(kwName); kwName != "" { name = kwName @@ -526,8 +539,9 @@ func (r *Builder) processResponseField(fld *types.Var, decl *scanner.EntityDecl, } // `in:` is the body/header annotation switch (Q1, default header). - // A field's own in: wins; otherwise an enclosing embed's inherited in: - // applies (go-swagger#2701); otherwise default header. + // + // A field's own in: wins; otherwise an enclosing embed's inherited in: applies (go-swagger#2701); + // otherwise default header. // See [§in-discriminator](./README.md#in-discriminator). var in string switch { @@ -547,10 +561,10 @@ func (r *Builder) processResponseField(fld *types.Var, decl *scanner.EntityDecl, )) } - // A swagger:name annotation is inert on a response header — the canonical - // rename keyword is `name:` (doc-quirk G2). Only the header path consults - // `name` (a body field becomes resp.Schema), so warn there in case the - // author meant the keyword; the annotation is dropped either way. + // A swagger:name annotation is inert on a response header — the canonical rename keyword is + // `name:` (doc-quirk G2). + // Only the header path consults `name` (a body field becomes resp.Schema), so warn there in case + // the author meant the keyword; the annotation is dropped either way. if in == inHeader { for _, b := range r.ParseBlocks(afld.Doc) { if b.AnnotationKind() == grammar.AnnName { @@ -566,8 +580,8 @@ func (r *Builder) processResponseField(fld *types.Var, decl *scanner.EntityDecl, } ps := resp.Headers[name] - // `swagger:file` is body-only (Q3); on a header it would corrupt - // the body schema. See [§file-body](./README.md#file-body). + // `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( @@ -591,10 +605,12 @@ func (r *Builder) processResponseField(fld *types.Var, decl *scanner.EntityDecl, refAttempted: &refAttempted, }, seen); err != nil { if errors.Is(err, errUnrepresentableHeader) { - // The field type has no OAS v2 SimpleSchema representation in - // this header (non-body) location (e.g. a map). Record a - // located diagnostic and skip the header instead of corrupting - // the response body schema. See go-swagger/go-swagger#2804. + // The field type has no OAS v2 SimpleSchema representation in this header (non-body) location + // (e.g. a map). + // Record a located diagnostic and skip the header instead of corrupting the response body + // schema. + // + // See go-swagger/go-swagger#2804. r.RecordDiagnostic(grammar.Warnf( r.Ctx.PosOf(afld.Pos()), grammar.CodeUnsupportedInSimpleSchema, @@ -608,12 +624,12 @@ func (r *Builder) processResponseField(fld *types.Var, decl *scanner.EntityDecl, } if in == inBody { - // Body field: schema-level keywords (example/default/validations, - // strfmt) belong on the body schema. Non-body fields route them - // through the header, but body responses discard the header, so a - // body field's `example:` would be lost (go-swagger#3013, same - // family as #2942). Skip a $ref body — siblings on a $ref are - // invalid. + // Body field: schema-level keywords (example/default/validations, strfmt) belong on the body + // schema. + // Non-body fields route them through the header, but body responses discard the header, so a body + // field's `example:` would be lost (go-swagger#3013, same family as #2942). + // + // Skip a $ref body — siblings on a $ref are invalid. if resp.Schema != nil && resp.Schema.Ref.String() == "" { if signals.strfmtSet { resp.Schema.Typed("string", signals.strfmt) @@ -638,9 +654,9 @@ func (r *Builder) processResponseField(fld *types.Var, decl *scanner.EntityDecl, } resp.Headers[name] = ps - // Cross-ref linkage: anchor the header to its struct field. The response - // name is known (respBase set), so this is direct — no deferral. Finer - // header nodes (validations) resolve to this anchor. + // Cross-ref linkage: anchor the header to its struct field. + // The response name is known (respBase set), so this is direct — no deferral. + // Finer header nodes (validations) resolve to this anchor. if r.respBase != "" { r.Ctx.RecordOrigin(r.respBase+scanner.JSONPointer("headers", name), r.Ctx.PosOf(afld.Pos())) } diff --git a/internal/builders/responses/responses_test.go b/internal/builders/responses/responses_test.go index 6d9a2019..520b4725 100644 --- a/internal/builders/responses/responses_test.go +++ b/internal/builders/responses/responses_test.go @@ -20,12 +20,12 @@ 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. +// 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 @@ -64,9 +64,8 @@ func TestParseResponses_OptionVariants(t *testing.T) { require.NoError(t, prs.Build(responses)) } - // The someResponse schema carries the $ref'd "pet" field plus - // x-go-name'd scalar fields; these are the surfaces the option - // matrix permutes, so assert their shape directly per-option. + // The someResponse schema carries the $ref'd "pet" field plus x-go-name'd scalar fields; these + // are the surfaces the option matrix permutes, so assert their shape directly per-option. some, ok := responses["someResponse"] require.TrueT(t, ok) require.NotNil(t, some.Schema) @@ -77,8 +76,8 @@ func TestParseResponses_OptionVariants(t *testing.T) { pet, ok := itprop.Properties["pet"] require.TrueT(t, ok) if tc.descRef { - // DescWithRef: the bare $ref is wrapped in a single-arm allOf - // so a description can sit alongside the reference. + // DescWithRef: the bare $ref is wrapped in a single-arm allOf so a description can sit + // alongside the reference. assert.Empty(t, pet.Ref.String()) require.Len(t, pet.AllOf, 1) assert.EqualT(t, petRef, pet.AllOf[0].Ref.String()) @@ -392,9 +391,9 @@ func TestParseResponses_TransparentAliases(t *testing.T) { assert.TrueT(t, payload.Type.Contains("object")) assert.Empty(t, payload.Ref.String()) assert.Equal(t, "Payload", payload.Extensions["x-go-name"]) - // The inlined struct lives in a different source file than the response - // decl; its fields must still be promoted (go-swagger#2417 — same - // cross-source-file root cause as the cross-package embed). + // The inlined struct lives in a different source file than the response decl; its fields must + // still be promoted (go-swagger#2417 — same cross-source-file root cause as the cross-package + // embed). assert.Contains(t, payload.Properties, "id") assert.Contains(t, payload.Properties, "name") } @@ -428,8 +427,8 @@ func TestParseResponses_Issue2011(t *testing.T) { require.Empty(t, resp.Headers) require.NotNil(t, resp.Schema) - // go-swagger#2011: an in:body field typed `interface{}` yields an - // open (empty) schema — no type, no $ref — rather than erroring. + // go-swagger#2011: an in:body field typed `interface{}` yields an open (empty) schema — no type, + // no $ref — rather than erroring. assert.Empty(t, resp.Schema.Type) assert.Empty(t, resp.Schema.Ref.String()) assert.Empty(t, resp.Schema.Properties) @@ -449,8 +448,8 @@ func TestParseResponses_Issue2145(t *testing.T) { require.Empty(t, resp.Headers) require.NotNil(t, resp.Schema) - // go-swagger#2145: a map[...]Product body becomes an object schema - // whose additionalProperties is a $ref to the (post-declared) Product. + // go-swagger#2145: a map[...]Product body becomes an object schema whose additionalProperties is a + // $ref to the (post-declared) Product. assert.TrueT(t, resp.Schema.Type.Contains("object")) require.NotNil(t, resp.Schema.AdditionalProperties) require.NotNil(t, resp.Schema.AdditionalProperties.Schema) @@ -482,8 +481,8 @@ func TestGo118ParseResponses_Issue2011(t *testing.T) { require.Empty(t, resp.Headers) require.NotNil(t, resp.Schema) - // go-swagger#2011 (go1.18 generics corpus): an in:body `interface{}` - // field yields an open (empty) schema, same as the non-generic corpus. + // go-swagger#2011 (go1.18 generics corpus): an in:body `interface{}` field yields an open (empty) + // schema, same as the non-generic corpus. assert.Empty(t, resp.Schema.Type) assert.Empty(t, resp.Schema.Ref.String()) assert.Empty(t, resp.Schema.Properties) diff --git a/internal/builders/responses/typable.go b/internal/builders/responses/typable.go index 49d55674..48080759 100644 --- a/internal/builders/responses/typable.go +++ b/internal/builders/responses/typable.go @@ -16,8 +16,9 @@ type responseTypable struct { response *oaispec.Response skipExt bool - // refAttempted: caller-owned flag flipped when SetRef is called - // under non-body mode. See [§typable](./README.md#typable). + // refAttempted: caller-owned flag flipped when SetRef is called under non-body mode. + // + // See [§typable](./README.md#typable). refAttempted *bool } @@ -25,12 +26,13 @@ func (ht responseTypable) In() string { return ht.in } func (ht responseTypable) Level() int { return 0 } -// Typed writes the primitive type onto the body schema in body mode, -// or onto the header in SimpleSchema mode. Without the body branch a -// primitive `Body` field (e.g. `Body string`) lands its type on the -// header, which body responses discard — leaving the response with no -// schema at all (go-swagger#2942). Mirrors SetRef's body/non-body -// split. +// Typed writes the primitive type onto the body schema in body mode, or onto the header in +// SimpleSchema mode. +// +// Without the body branch a primitive `Body` field (e.g. `Body string`) lands its type on the +// header, which body responses discard — leaving the response with no schema at all +// (go-swagger#2942). +// Mirrors SetRef's body/non-body split. func (ht responseTypable) Typed(tpe, format string) { if ht.in == inBody { ht.Schema().Typed(tpe, format) @@ -55,9 +57,10 @@ func (ht responseTypable) Items() ifaces.SwaggerTypable { //nolint:ireturn // po 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). +// 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) { if ht.in == inBody { ht.Schema().Ref = ref @@ -81,46 +84,47 @@ func (ht responseTypable) AddExtension(key string, value any) { } func (ht responseTypable) WithEnum(values ...any) { - // Spread the variadic through: passing the slice itself would nest it - // one level deep (enum: [[FIRST, SECOND]]), producing malformed OAS2. - // Mirrors paramTypable / schema.Typable / ItemsTypable. + // Spread the variadic through: passing the slice itself would nest it one level deep (enum: + // [[FIRST, SECOND]]), producing malformed OAS2. Mirrors paramTypable / schema.Typable / + // ItemsTypable. ht.header.WithEnum(values...) } -// WithEnumDescription rides the enum const-name mapping on the -// header's x-go-enum-desc vendor extension, mirroring -// paramTypable.WithEnumDescription. +// WithEnumDescription rides the enum const-name mapping on the header's x-go-enum-desc vendor +// extension, mirroring paramTypable.WithEnumDescription. // -// This is wired against go-openapi/spec >= v0.22.6, where -// Header.MarshalJSON emits the embedded VendorExtensible (go-openapi/spec#277). -// Earlier versions dropped header extensions at marshal, so this was a -// documented no-op. The enum *values* themselves ship via WithEnum and -// were never affected. +// This is wired against go-openapi/spec >= v0.22.6, where Header.MarshalJSON emits the embedded +// VendorExtensible (go-openapi/spec#277). +// Earlier versions dropped header extensions at marshal, so this was a documented no-op. +// The enum *values* themselves ship via WithEnum and were never affected. func (ht responseTypable) WithEnumDescription(desc string) { if desc == "" { return } - // Gated on SkipExtensions (mirrors schema.Typable): the contract is that - // x-go-* vendor extensions are suppressed everywhere when SkipExtensions - // is set. + // Gated on SkipExtensions (mirrors schema.Typable): the contract is that x-go-* vendor extensions + // are suppressed everywhere when SkipExtensions is set. resolvers.AddExtension(&ht.header.VendorExtensible, resolvers.ExtEnumDesc, desc, ht.skipExt) } -// SimpleSchemaShape satisfies schema.SimpleSchemaProbe (non-body -// path; body uses WithType). See [§typable](./README.md#typable). +// 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). +// 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 `{}`. +// ResetForViolation satisfies schema.SimpleSchemaProbe. +// +// Wipes the header's SimpleSchema back to `{}`. func (ht responseTypable) ResetForViolation() { ht.header.SimpleSchema = oaispec.SimpleSchema{} } diff --git a/internal/builders/responses/walker.go b/internal/builders/responses/walker.go index f48e0deb..415de704 100644 --- a/internal/builders/responses/walker.go +++ b/internal/builders/responses/walker.go @@ -4,29 +4,30 @@ package responses import ( + "encoding/json" "go/ast" "github.com/go-openapi/codescan/internal/builders/handlers" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/parsers/yaml" 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. +// 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. +// collectHeaderItemsLevels walks the AST array layers of a response header field and returns the +// (level, items) pairs reachable from the header's items chain. // -// 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). +// 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 @@ -62,29 +63,82 @@ func collectHeaderItemsLevels(expr ast.Expr, it *oaispec.Items, level int) []hea } } -// 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. +// applyBlockToDecl parses the top-level response doc through grammar and writes the description to +// resp.Description via the grammar parser's prose accumulator, plus the response-level `examples:` +// block (the only keyword the response decl level accepts). func (r *Builder) applyBlockToDecl(resp *oaispec.Response) { block := r.ParseBlock(r.Decl.Comments) - resp.Description = block.Prose() + resp.Description = r.overriddenDescription(r.CleanGoDoc(block.Prose()), r.Decl.Comments) + r.applyResponseExamples(block, resp) +} + +// overriddenDescription returns the swagger:description override for cg when present (empty +// included — godoc suppression, D7), else the godoc-derived fallback. +// +// A swagger:title on a response / header target is rejected with a context-invalid diagnostic: +// OpenAPI 2.0 Response and Header objects have no `title` field. +// See .claude/plans/features/swagger-description-override-design.md. +func (r *Builder) overriddenDescription(fallback string, cg *ast.CommentGroup) string { + titleOv, descOv := r.HarvestOverrides(cg) + if titleOv.Present { + r.RecordDiagnostic(grammar.Warnf(titleOv.Pos, grammar.CodeContextInvalid, + "swagger:title is not applicable here: OpenAPI 2.0 responses and headers have no title; ignored")) + } + r.WarnEmptyOverride(grammar.AnnDescription, descOv) + if descOv.Present { + return descOv.Value + } + return fallback +} + +// applyResponseExamples parses a response-level `examples:` block — a YAML map keyed by mime type +// (`examples:` then `application/json: {…}`) — onto resp.Examples (the OAS2 Response.examples +// field). +// +// This is the plural, response-scoped keyword (go-swagger#2871); the singular schema `example:` is +// handled per-field / per-body elsewhere. +// The `swagger:operation` YAML path already carries examples for free via the spec unmarshal — +// this covers only the struct-based `swagger:response`. +func (r *Builder) applyResponseExamples(block grammar.Block, resp *oaispec.Response) { + var prop grammar.Property + var found bool + for p := range block.Properties() { + if p.Keyword.Name == grammar.KwExamples { + prop, found = p, true + break + } + } + if !found || prop.Body == "" { + return + } + + examples := make(map[string]any) + if err := yaml.UnmarshalBody(prop.Body, func(b []byte) error { + return json.Unmarshal(b, &examples) + }); err != nil { + r.RecordDiagnostic(grammar.Warnf( + prop.Pos, grammar.CodeInvalidAnnotation, + "could not parse response examples block as a mime-keyed YAML map: %v", err, + )) + return + } + + if len(examples) > 0 { + resp.Examples = examples + } } -// applyBlockToHeader parses afld.Doc through grammar and dispatches -// description, header validations, items-level validations, and -// user-authored vendor extensions into ps. +// 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. +// 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() + header.Description = r.overriddenDescription(r.CleanGoDoc(block.Prose()), afld.Doc) handlers.DispatchHeaderLevel0(block, header, r.RecordDiagnostic) if arrayType, ok := afld.Type.(*ast.ArrayType); ok { diff --git a/internal/builders/routes/routes.go b/internal/builders/routes/routes.go index e19af11e..f6df87a9 100644 --- a/internal/builders/routes/routes.go +++ b/internal/builders/routes/routes.go @@ -14,11 +14,11 @@ import ( oaispec "github.com/go-openapi/spec" ) -// Builder constructs OAS v2 path entries for one `swagger:route` -// annotation. It embeds *common.Builder for shared state (Ctx, -// ParseBlocks cache, diagnostic sink). The embedded Decl is left -// nil — routes build off a path annotation, not a declaration — -// so MakeRef and other Decl-anchored helpers must not be called. +// Builder constructs OAS v2 path entries for one `swagger:route` annotation. +// +// It embeds *common.Builder for shared state (Ctx, ParseBlocks cache, diagnostic sink). +// The embedded Decl is left nil — routes build off a path annotation, not a declaration — so +// MakeRef and other Decl-anchored helpers must not be called. type Builder struct { *common.Builder @@ -64,8 +64,8 @@ func (r *Builder) Build(tgt *oaispec.Paths) error { } tgt.Paths[r.route.Path] = pthObj - // Cross-ref linkage: anchor the operation node to its swagger:route - // annotation; parameters/responses under it resolve to this node. + // Cross-ref linkage: anchor the operation node to its swagger:route annotation; + // parameters/responses under it resolve to this node. if r.Ctx.OriginEnabled() && r.route.Pos.IsValid() { r.Ctx.RecordOrigin( scanner.JSONPointer("paths", r.route.Path, strings.ToLower(r.route.Method)), diff --git a/internal/builders/routes/routes_test.go b/internal/builders/routes/routes_test.go index adc173a2..8e88bc4b 100644 --- a/internal/builders/routes/routes_test.go +++ b/internal/builders/routes/routes_test.go @@ -18,11 +18,10 @@ const epsilon = 1e-9 func TestRoutesParser(t *testing.T) { sctx := scantest.LoadClassificationPkgsCtx(t) - // M6.5-C requires ref resolution to succeed (Q22 strictness): - // untagged response names must be present in either the responses - // map or the definitions map, otherwise the dangling ref is - // diagnosed and dropped. Seed the responses map with the names - // the classification fixture's routes reference inline. + // M6.5-C requires ref resolution to succeed (Q22 strictness): untagged response names must be + // present in either the responses map or the definitions map, otherwise the dangling ref is + // diagnosed and dropped. + // Seed the responses map with the names the classification fixture's routes reference inline. responses := map[string]oaispec.Response{ "genericError": {}, "someResponse": {}, @@ -287,12 +286,13 @@ func validateRoutesParameters(t *testing.T, ops oaispec.Paths) { assert.InDelta(t, def, p.Default, epsilon) assert.Nil(t, p.Schema) - // someQuery — array param. M6.5-C type-gates SimpleSchema - // validations: minLength/maxLength apply only to string-typed - // schemas (the OAS v2 contract). The legacy routes parser - // mis-applied them to arrays; M6.5-C drops them with a - // CodeShapeMismatch diagnostic instead. Use `minItems`/`maxItems` - // to constrain array length. + // someQuery — array param. + // M6.5-C type-gates SimpleSchema validations: minLength/maxLength apply only to string-typed + // schemas (the OAS v2 contract). + // + // The legacy routes parser mis-applied them to arrays; M6.5-C drops them with a CodeShapeMismatch + // diagnostic instead. + // Use `minItems`/`maxItems` to constrain array length. p = po.Post.Parameters[1] assert.EqualT(t, "someQuery", p.Name) assert.EqualT(t, "some query values", p.Description) @@ -315,7 +315,8 @@ func validateRoutesParameters(t *testing.T, ops oaispec.Paths) { assert.TrueT(t, someBoolean) assert.Nil(t, p.Schema) - // Testing that "min", "max", "minLength" and "maxLength" constraints will only be considered if the right type is provided + // Testing that "min", "max", "minLength" and "maxLength" constraints will only be considered if + // the right type is provided. p = po.Post.Parameters[3] assert.EqualT(t, "constraintsOnInvalidType", p.Name) assert.EqualT(t, "test constraints on invalid types", p.Description) diff --git a/internal/builders/routes/walker.go b/internal/builders/routes/walker.go index c848224a..02e07d66 100644 --- a/internal/builders/routes/walker.go +++ b/internal/builders/routes/walker.go @@ -62,26 +62,25 @@ var responseBodyReservedTypes = map[string]struct{}{ "null": {}, } -// applyBlockToRoute parses route.Remaining through grammar and -// writes Summary / Description / per-keyword content onto op. +// applyBlockToRoute parses route.Remaining through grammar and writes Summary / Description / +// per-keyword content onto op. // -// Grammar's lexer classifies prose into TokenTitle / TokenDesc -// directly 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). +// Grammar's lexer classifies prose into TokenTitle / TokenDesc directly and isolates every +// route-level keyword into a Property — `schemes:`, `deprecated:`, `consumes:`, `produces:`, +// `security:`, `parameters:`, `responses:`, `extensions:`. // -// 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 / +// 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(), grammar.WithSingleLineCommentAsDescription(r.Ctx.SingleLineCommentAsDescription())).Parse(r.route.Remaining) - op.Summary = block.Title() - op.Description = block.Description() + op.Summary = r.CleanGoDoc(block.Title()) + op.Description = r.CleanGoDoc(block.Description()) for prop := range block.Properties() { if prop.ItemsDepth != 0 { @@ -93,11 +92,10 @@ func (r *Builder) applyBlockToRoute(op *oaispec.Operation) error { r.recordRouteKeywordOrigin(prop) } - // 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). + // 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) } @@ -108,11 +106,12 @@ func (r *Builder) applyBlockToRoute(op *oaispec.Operation) error { return nil } -// recordRouteKeywordOrigin anchors one route-level keyword to its source line -// under /paths/{path}/{method}/{seg}, when a provenance sink is wired. The -// keyword→segment knowledge lives in the grammar ([grammar.PointerPath]); here -// we prepend the operation base. parameters/responses (containers) and security -// (consumed at lex time) are absent and resolve to the operation anchor. +// recordRouteKeywordOrigin anchors one route-level keyword to its source line under +// /paths/{path}/{method}/{seg}, when a provenance sink is wired. +// +// The keyword→segment knowledge lives in the grammar ([grammar.PointerPath]); here we prepend the +// operation base. parameters/responses (containers) and security (consumed at lex time) are absent +// and resolve to the operation anchor. func (r *Builder) recordRouteKeywordOrigin(p grammar.Property) { if !r.Ctx.OriginEnabled() { return @@ -125,14 +124,14 @@ func (r *Builder) recordRouteKeywordOrigin(p grammar.Property) { r.Ctx.RecordOrigin(base+scanner.JSONPointer(segs...), p.Pos) } -// dispatchRouteKeyword routes one grammar Property to the matching -// body parser. List-shaped keywords (schemes / consumes / produces) -// flow through Property.AsList, which unifies inline comma-lists, -// multi-line bare-line bodies, and YAML-style `- ` markers. Inline- -// keyword shapes (deprecated bool) read Property.Typed directly. -// Routebody body parsers (parameters / responses) own their own -// orchestration; extensions ride grammar's typed-extensions surface -// (see applyBlockToRoute above). +// dispatchRouteKeyword routes one grammar Property to the matching body parser. +// +// List-shaped keywords (schemes / consumes / produces) flow through Property.AsList, which unifies +// inline comma-lists, multi-line bare-line bodies, and YAML-style `- ` markers. +// Inline- keyword shapes (deprecated bool) read Property.Typed directly. +// +// Routebody body parsers (parameters / responses) own their own orchestration; extensions ride +// grammar's typed-extensions surface (see applyBlockToRoute above). func (r *Builder) dispatchRouteKeyword(p grammar.Property, op *oaispec.Operation) error { switch p.Keyword.Name { case grammar.KwSchemes: @@ -148,11 +147,10 @@ func (r *Builder) dispatchRouteKeyword(p grammar.Property, op *oaispec.Operation case grammar.KwProduces: op.Produces = p.AsList() case grammar.KwTags: - // `Tags:` on a route is a plain string list of tag names, - // unioned onto any tags already parsed off the swagger:route - // header line (go-swagger#2655). Duplicates are dropped, - // source order preserved. The meta `Tags:` object shape is - // handled by a different builder (the spec/meta walker). + // `Tags:` on a route is a plain string list of tag names, unioned onto any tags already parsed + // off the swagger:route header line (go-swagger#2655). + // Duplicates are dropped, source order preserved. + // The meta `Tags:` object shape is handled by a different builder (the spec/meta walker). op.Tags = unionTags(op.Tags, p.AsList()) case grammar.KwParameters: return r.dispatchParameters(p, op) @@ -171,10 +169,12 @@ func (r *Builder) dispatchRouteKeyword(p grammar.Property, op *oaispec.Operation return nil } -// unionTags appends src tag names to dst, dropping any already -// present, and returns the merged slice. Source order is preserved: -// header-line tags (in dst) come first, then any new `Tags:`-keyword -// names. Used to merge route-header and route-body tag sources. +// unionTags appends src tag names to dst, dropping any already present, and returns the merged +// slice. +// +// Source order is preserved: header-line tags (in dst) come first, then any new `Tags:`-keyword +// names. +// Used to merge route-header and route-body tag sources. func unionTags(dst, src []string) []string { if len(src) == 0 { return dst @@ -193,11 +193,11 @@ func unionTags(dst, src []string) []string { return dst } -// dispatchParameters lowers a `Parameters:` raw body via routebody -// then dispatches each ParamDecl through the standard handlers seam. -// Non-body params route through handlers.DispatchParamLevel0 -// (SimpleSchema); body params route through -// handlers.DispatchSchemaLevel0 onto a freshly-allocated param.Schema. +// dispatchParameters lowers a `Parameters:` raw body via routebody then dispatches each ParamDecl +// through the standard handlers seam. +// +// Non-body params route through handlers.DispatchParamLevel0 (SimpleSchema); body params route +// through handlers.DispatchSchemaLevel0 onto a freshly-allocated param.Schema. func (r *Builder) dispatchParameters(p grammar.Property, op *oaispec.Operation) error { decls := routebody.ParseParameters(p.Body, p.Pos, r.RecordDiagnostic) for i := range decls { @@ -211,11 +211,11 @@ func (r *Builder) dispatchParameters(p grammar.Property, op *oaispec.Operation) return nil } -// buildRouteParam materialises one ParamDecl into a *spec.Parameter -// and dispatches its validation Block through the handlers seam. -// Returns nil when the decl is too thin to form a valid parameter -// (no name and no in — likely a fixture quirk routebody could not -// fully diagnose). +// buildRouteParam materialises one ParamDecl into a *spec.Parameter and dispatches its validation +// Block through the handlers seam. +// +// Returns nil when the decl is too thin to form a valid parameter (no name and no in — likely a +// fixture quirk routebody could not fully diagnose). func (r *Builder) buildRouteParam(decl *routebody.ParamDecl) *oaispec.Parameter { if decl.Name == "" && decl.In == "" { return nil @@ -232,29 +232,27 @@ func (r *Builder) buildRouteParam(decl *routebody.ParamDecl) *oaispec.Parameter if decl.In == "body" { param.Schema = r.buildBodySchema(decl) - // Body parameters' Description lives only on the parameter — - // the referenced model owns the schema-level description. - // Format is the only inline schema-level override routebody - // preserves on body. + // Body parameters' Description lives only on the parameter — the referenced model owns the + // schema-level description. + // Format is the only inline schema-level override routebody preserves on body. handlers.DispatchSchemaLevel0(decl.Block, nil, param.Schema, "", r.RecordDiagnostic, handlers.SchemaOptions{}) return param } - // SimpleSchema (path/query/header/formData) — populate type from - // head fields, then dispatch validations through a type-gated - // Block. Format is applied AFTER dispatch on purpose: go-openapi's - // SimpleSchema.TypeName() returns Format when non-empty (so - // validations.CoerceValue would key default/example coercion off - // the format string instead of the type), which conflicts with the - // author's clear intent. Setting Format post-dispatch keeps the - // scheme.Type stable through coercion. + // SimpleSchema (path/query/header/formData) — populate type from head fields, then dispatch + // validations through a type-gated Block. + // + // Format is applied AFTER dispatch on purpose: go-openapi's SimpleSchema.TypeName() returns Format + // when non-empty (so validations.CoerceValue would key default/example coercion off the format + // string instead of the type), which conflicts with the author's clear intent. + // Setting Format post-dispatch keeps the scheme.Type stable through coercion. if decl.TypeRef != "" { param.Type = normaliseSimpleType(decl.TypeRef) } gated := r.typeGateBlock(decl.Block, param.Type, decl.Pos) if err := handlers.DispatchParamLevel0(gated, param, r.RecordDiagnostic); err != nil { - // Surface the first coercion error as a diagnostic so the - // author sees the loss rather than dropping it silently. + // Surface the first coercion error as a diagnostic so the author sees the loss rather than + // dropping it silently. r.RecordDiagnostic(grammar.Diagnostic{ Pos: decl.Pos, Severity: grammar.SeverityWarning, @@ -266,10 +264,10 @@ func (r *Builder) buildRouteParam(decl *routebody.ParamDecl) *oaispec.Parameter return param } -// buildBodySchema materialises a body parameter's Schema from a -// ParamDecl. Primitive types (string/integer/number/boolean/array/ -// object) become a typed schema; anything else is treated as a model -// reference and resolved via $ref (with optional `[]` array layer +// buildBodySchema materialises a body parameter's Schema from a ParamDecl. +// +// Primitive types (string/integer/number/boolean/array/ object) become a typed schema; anything +// else is treated as a model reference and resolved via $ref (with optional `[]` array layer // wrapping). func (r *Builder) buildBodySchema(decl *routebody.ParamDecl) *oaispec.Schema { if decl.TypeRef == "" { @@ -296,23 +294,20 @@ func (r *Builder) buildBodySchema(decl *routebody.ParamDecl) *oaispec.Schema { return schema } -// typeGateBlock returns a Block containing only the Properties from -// in whose Keyword applies to schemaType per -// validations.IsLegalForType. Dropped properties emit -// CodeShapeMismatch diagnostics so the author sees the loss -// (incompatible validations are dropped rather than left to produce -// a malformed spec). +// typeGateBlock returns a Block containing only the Properties from in whose Keyword applies to +// schemaType per validations.IsLegalForType. +// +// Dropped properties emit CodeShapeMismatch diagnostics so the author sees the loss (incompatible +// validations are dropped rather than left to produce a malformed spec). // -// schemaType may be empty for params with no explicit `type:` head; -// IsLegalForType admits every keyword on the empty-type sentinel so -// nothing is gated. +// schemaType may be empty for params with no explicit `type:` head; IsLegalForType admits every +// keyword on the empty-type sentinel so nothing is gated. func (r *Builder) typeGateBlock(in grammar.Block, schemaType string, pos token.Position) grammar.Block { if in == nil { return in } - // No declared type means no meaningful validation surface: drop - // every property on a type-less SimpleSchema param and surface a - // diagnostic per dropped keyword. + // No declared type means no meaningful validation surface: drop every property on a type-less + // SimpleSchema param and surface a diagnostic per dropped keyword. if schemaType == "" { had := false for p := range in.Properties() { @@ -349,10 +344,10 @@ func (r *Builder) typeGateBlock(in grammar.Block, schemaType string, pos token.P return grammar.NewSyntheticBlock(pos, in.Title(), in.Description(), filtered) } -// normaliseSimpleType maps short type spellings to their OAS v2 -// canonical forms. Only `bool` → `boolean` is meaningful at present; -// the other primitive names (`string`, `integer`, `number`, -// `boolean`, `array`) pass through unchanged. +// normaliseSimpleType maps short type spellings to their OAS v2 canonical forms. +// +// Only `bool` → `boolean` is meaningful at present; the other primitive names (`string`, +// `integer`, `number`, `boolean`, `array`) pass through unchanged. func normaliseSimpleType(t string) string { if t == "bool" { return "boolean" @@ -360,18 +355,18 @@ func normaliseSimpleType(t string) string { return t } -// primitiveBodySchema returns a typed primitive Schema when name is an -// OAS v2 scalar primitive type spelling (per responseBodyPrimitives), -// wrapped in `arrays` nested array layers; it returns nil otherwise so -// the caller can diagnose a reserved keyword or fall back to model -// reference resolution. +// primitiveBodySchema returns a typed primitive Schema when name is an OAS v2 scalar primitive type +// spelling (per responseBodyPrimitives), wrapped in `arrays` nested array layers; it returns nil +// otherwise so the caller can diagnose a reserved keyword or fall back to model reference +// resolution. +// +// This gives swagger:route responses a path to a primitive body via the unambiguous `body:` tag — +// `200: body:string` → {schema: {type: string}}, `200: body:[]integer` → {schema: {type: array, +// items: {type: integer}}} — mirroring buildBodySchema's primitive handling for body parameters +// (go-swagger#2942). // -// This gives swagger:route responses a path to a primitive body via the -// unambiguous `body:` tag — `200: body:string` → {schema: {type: string}}, -// `200: body:[]integer` → {schema: {type: array, items: {type: integer}}} -// — mirroring buildBodySchema's primitive handling for body parameters -// (go-swagger#2942). The bare/untagged `200: string` form is NOT promoted -// (an untagged token is a response name, not a type). +// The bare/untagged `200: string` form is NOT promoted (an untagged token is a response name, not a +// type). func primitiveBodySchema(name string, arrays int) *oaispec.Schema { if _, ok := responseBodyPrimitives[name]; !ok { return nil @@ -386,18 +381,17 @@ func primitiveBodySchema(name string, arrays int) *oaispec.Schema { return leaf } -// resolveBodySchema builds a Schema for a body param/response's -// type reference. arrayLayer is the number of `[]` array wrappers -// already stripped from the ref; the function applies them as nested +// resolveBodySchema builds a Schema for a body param/response's type reference. arrayLayer is the +// number of `[]` array wrappers already stripped from the ref; the function applies them as nested // array Schemas around the final $ref. // -// The resulting Schema is best-effort: the orchestrator does NOT -// gate on existence in r.definitions because the swagger:model pass -// may emit the definition independently. A dangling $ref preserves -// the author's spec-first intent (the "force-the-spec" reading) -// without silent loss. Response-side callers additionally check for -// unresolvable refs and emit CodeInvalidAnnotation diagnostics; on -// the parameter side we trust the author. +// The resulting Schema is best-effort: the orchestrator does NOT gate on existence in r.definitions +// because the swagger:model pass may emit the definition independently. +// A dangling $ref preserves the author's spec-first intent (the "force-the-spec" reading) without +// silent loss. +// +// Response-side callers additionally check for unresolvable refs and emit CodeInvalidAnnotation +// diagnostics; on the parameter side we trust the author. func (r *Builder) resolveBodySchema(ref string, arrayLayer int) *oaispec.Schema { if ref == "" { return nil @@ -414,8 +408,7 @@ func (r *Builder) resolveBodySchema(ref string, arrayLayer int) *oaispec.Schema if err != nil { return nil } - // Innermost schema carries the $ref; nested arrays wrap from - // outside in. + // Innermost schema carries the $ref; nested arrays wrap from outside in. leaf := &oaispec.Schema{SchemaProps: oaispec.SchemaProps{Ref: target}} for range arrayLayer { leaf = &oaispec.Schema{ @@ -430,17 +423,16 @@ func (r *Builder) resolveBodySchema(ref string, arrayLayer int) *oaispec.Schema return leaf } -// resolveDefinitionByLeaf reports whether the definitions map holds a -// definition whose leaf name (the segment after the last '/') equals -// short, and whether more than one does. +// resolveDefinitionByLeaf reports whether the definitions map holds a definition whose leaf name +// (the segment after the last '/') equals short, and whether more than one does. // -// During build the definitions map is keyed by the fully-qualified -// identity (pkgpath/name — see scanner.EntityDecl.DefKey), while author -// annotations reference models by their short name; a leaf lookup -// bridges the two until the spec.Builder's reduce stage shortens unique -// leaves back to bare names. `ambiguous` means several cross-package -// definitions share the leaf — a real collision the short name cannot -// resolve (name-identity design §12.1). +// During build the definitions map is keyed by the fully-qualified identity (pkgpath/name — see +// scanner.EntityDecl.DefKey), while author annotations reference models by their short name; a leaf +// lookup bridges the two until the spec.Builder's reduce stage shortens unique leaves back to bare +// names. +// +// `ambiguous` means several cross-package definitions share the leaf — a real collision the short +// name cannot resolve (name-identity design §12.1). func resolveDefinitionByLeaf(defs map[string]oaispec.Schema, short string) (key string, found, ambiguous bool) { for k := range defs { if leafOfKey(k) != short { @@ -454,8 +446,8 @@ func resolveDefinitionByLeaf(defs map[string]oaispec.Schema, short string) (key return key, found, false } -// leafOfKey returns the segment of a definition key after the last '/', -// or the whole key when there is none. +// leafOfKey returns the segment of a definition key after the last '/', or the whole key when there +// is none. func leafOfKey(key string) string { if i := strings.LastIndex(key, "/"); i >= 0 { return key[i+1:] @@ -463,14 +455,15 @@ func leafOfKey(key string) string { return key } -// dispatchResponses lowers a `Responses:` raw body via routebody -// then assembles each ResponseDecl into op.Responses. References to -// known swagger:response objects produce a `$ref: #/responses/` -// directly on the Response; body refs produce a Schema with optional -// array wrapping. Untagged refs follow the definition-fallback rule: -// a name found in r.definitions but not in r.responses is silently -// promoted to a body ref. Unresolvable refs emit -// CodeInvalidAnnotation and the response is dropped. +// dispatchResponses lowers a `Responses:` raw body via routebody then assembles each ResponseDecl +// into op.Responses. +// +// References to known swagger:response objects produce a `$ref: #/responses/` directly on the +// Response; body refs produce a Schema with optional array wrapping. +// Untagged refs follow the definition-fallback rule: a name found in r.definitions but not in +// r.responses is silently promoted to a body ref. +// +// Unresolvable refs emit CodeInvalidAnnotation and the response is dropped. func (r *Builder) dispatchResponses(p grammar.Property, op *oaispec.Operation) error { decls := routebody.ParseResponses(p.Body, p.Pos, r.RecordDiagnostic) if len(decls) == 0 { @@ -511,20 +504,22 @@ func (r *Builder) dispatchResponses(p grammar.Property, op *oaispec.Operation) e return nil } -// defaultResponseDescription is the last-resort description for a `body:` -// response whose code has no standard HTTP reason phrase (the `default` -// catch-all, or a non-standard numeric code) and whose body type carries -// no godoc. OAS2 `default` covers any undeclared code — not necessarily an -// error — so the placeholder stays neutral rather than asserting "error". -// Capitalised to read as prose alongside the HTTP reason phrases ("Not -// Found", "Internal Server Error") it sits next to in a responses table. +// defaultResponseDescription is the last-resort description for a `body:` response whose code has +// no standard HTTP reason phrase (the `default` catch-all, or a non-standard numeric code) and +// whose body type carries no godoc. +// +// OAS2 `default` covers any undeclared code — not necessarily an error — so the placeholder +// stays neutral rather than asserting "error". +// Capitalised to read as prose alongside the HTTP reason phrases ("Not Found", "Internal Server +// Error") it sits next to in a responses table. const defaultResponseDescription = "Default response" -// bodyResponseDescription chooses a non-empty, human-meaningful description -// for a `body:`-form response that carries no trailing description text. -// OAS2 requires a non-empty description, but the bare Go type token (e.g. -// "Pet" or "string") leaks an implementation detail into the contract -// (doc-quirk G1). Preference, most to least specific: +// bodyResponseDescription chooses a non-empty, human-meaningful description for a `body:`-form +// response that carries no trailing description text. +// +// OAS2 requires a non-empty description, but the bare Go type token (e.g. "Pet" or "string") leaks +// an implementation detail into the contract (doc-quirk G1). +// Preference, most to least specific: // // 1. the referenced model's own godoc (its Title, then Description) — this // mirrors how a named swagger:response derives its description from @@ -533,9 +528,9 @@ const defaultResponseDescription = "Default response" // 404 → "Not Found"); // 3. a neutral placeholder for `default` / non-standard codes. func (r *Builder) bodyResponseDescription(code, ref string) string { - // The definitions map is keyed by the fully-qualified identity during - // build; the author wrote a short model name, so resolve by leaf (a - // unique match only — an ambiguous one cannot pick a godoc source). + // The definitions map is keyed by the fully-qualified identity during build; the author wrote a + // short model name, so resolve by leaf (a unique match only — an ambiguous one cannot pick a + // godoc source). if key, ok, ambiguous := resolveDefinitionByLeaf(r.definitions, ref); ok && !ambiguous { def := r.definitions[key] if t := strings.TrimSpace(def.Title); t != "" { @@ -553,12 +548,12 @@ func (r *Builder) bodyResponseDescription(code, ref string) string { return defaultResponseDescription } -// buildRouteResponse materialises one ResponseDecl into a -// spec.Response. Resolves the ref by consulting r.responses (named -// swagger:response objects) first, then r.definitions: untagged -// response names that happen to be model definitions are silently -// promoted to body refs. Unresolvable refs return -// (Response{}, false) with a CodeInvalidAnnotation diagnostic. +// buildRouteResponse materialises one ResponseDecl into a spec.Response. +// +// Resolves the ref by consulting r.responses (named swagger:response objects) first, then +// r.definitions: untagged response names that happen to be model definitions are silently promoted +// to body refs. +// Unresolvable refs return (Response{}, false) with a CodeInvalidAnnotation diagnostic. func (r *Builder) buildRouteResponse(decl *routebody.ResponseDecl) (oaispec.Response, bool) { switch { case decl.BodyTypeRef != "": @@ -598,17 +593,16 @@ func (r *Builder) buildRouteResponse(decl *routebody.ResponseDecl) (oaispec.Resp }, true case decl.ResponseRef != "": - // Definition-fallback: if the ref name is NOT in r.responses - // but IS in r.definitions, silently promote it to a body ref - // (intentional kindness for the common case where the author - // referenced a model by name rather than a response). + // Definition-fallback: if the ref name is NOT in r.responses but IS in r.definitions, silently + // promote it to a body ref (intentional kindness for the common case where the author referenced + // a model by name rather than a response). if _, ok := r.responses[decl.ResponseRef]; !ok { - // The definitions map is keyed by the fully-qualified - // identity during build (pkgpath/name); the author wrote a - // short model name, so resolve by leaf. Exactly one match → - // promote; several → ambiguous cross-package collision the - // short name cannot disambiguate (drop + diagnose, per - // name-identity D-8). See §12.1. + // The definitions map is keyed by the fully-qualified identity during build (pkgpath/name); the + // author wrote a short model name, so resolve by leaf. + // Exactly one match → promote; several → ambiguous cross-package collision the short name + // cannot disambiguate (drop + diagnose, per name-identity D-8). + // + // See §12.1. _, found, ambiguous := resolveDefinitionByLeaf(r.definitions, decl.ResponseRef) switch { case ambiguous: @@ -634,14 +628,15 @@ func (r *Builder) buildRouteResponse(decl *routebody.ResponseDecl) (oaispec.Resp }, }, true } - // Dangling refs (not in responses, not in definitions) - // emit a diagnostic and are dropped rather than silently - // emitting an invalid $ref. A bare primitive type spelling - // (`200: string`) is intentionally NOT promoted to a typed - // schema — an untagged token is a response/model NAME, and - // reading it as a type would make the syntax ambiguous. The - // unambiguous form is `body:` (no Go type name carries - // a `:`), so point the author there. + // Dangling refs (not in responses, not in definitions) emit a diagnostic and are dropped rather + // than silently emitting an invalid $ref. + // + // A bare primitive type spelling (`200: string`) is intentionally NOT promoted to a typed schema + // — an untagged token is a response/model NAME, and reading it as a type would make the syntax + // ambiguous. + // + // The unambiguous form is `body:` (no Go type name carries a `:`), so point the author + // there. msg := "response ref " + decl.ResponseRef + " not found in responses or definitions; dropped" if _, isPrim := responseBodyPrimitives[decl.ResponseRef]; isPrim { msg = "response " + decl.Code + ": " + decl.ResponseRef + @@ -663,9 +658,9 @@ func (r *Builder) buildRouteResponse(decl *routebody.ResponseDecl) (oaispec.Resp return oaispec.Response{Refable: oaispec.Refable{Ref: ref}}, true default: - // Description-only or empty-value response. An empty - // description carries through as "" — callers see exactly - // what the author wrote, with no implicit unset semantics. + // Description-only or empty-value response. + // An empty description carries through as "" — callers see exactly what the author wrote, with + // no implicit unset semantics. return oaispec.Response{ ResponseProps: oaispec.ResponseProps{Description: decl.Description}, }, true diff --git a/internal/builders/routes/walker_test.go b/internal/builders/routes/walker_test.go index 590d8ff7..1fbd6281 100644 --- a/internal/builders/routes/walker_test.go +++ b/internal/builders/routes/walker_test.go @@ -61,13 +61,12 @@ func TestDispatchRouteKeywordDeprecated(t *testing.T) { } } -// 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. +// 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 @@ -94,10 +93,9 @@ func TestRawBlockAbsorbsSubContextKeywords(t *testing.T) { 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. + // 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", diff --git a/internal/builders/schema/README.md b/internal/builders/schema/README.md index 6d8a2d1e..b3d0f888 100644 --- a/internal/builders/schema/README.md +++ b/internal/builders/schema/README.md @@ -428,6 +428,19 @@ classification per embed: ordinary field rather than promoting it (go-swagger#2038). - no explicit name — properties merge (promote) into the outer schema. +**`DefaultAllOfForEmbeds` (opt-in).** When `Options.DefaultAllOfForEmbeds` +is set, a plain embed with **no explicit name** is reclassified as an +allOf member — exactly as if it carried `swagger:allOf` — so it takes the +`buildAllOf` path ($ref when the embedded type is a model, inline member +otherwise) and the embedding struct's own fields move into a sibling allOf +member. The decision lives in `scanEmbeddedFields` (`isAllOf` is forced +true via `embedNestName(afld, fd) == ""`); a json-named embed keeps its +nested-property shape (go-swagger#2038), an explicit `swagger:allOf` is +already in this shape, and interface embeds are out of scope (they compose +via allOf regardless — see [§embedded](#embedded)). Default off ⇒ output +unchanged. Pinned by `fixtures/enhancements/default-allof-embeds/` + +`integration/coverage_default_allof_embeds_test.go`. + 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. @@ -638,8 +651,16 @@ This asymmetry is intentional, not a quirk: serialization mirror. The "one size fits all" mangler on interfaces will not always be -what the author wanted — a future global opt-out -(`skip-jsonify-interfaces` or similar) is on the roadmap. +what the author wanted, so it can be turned off globally with +**`Options.SkipJSONifyInterfaceMethods`** (opt-out, default `false`). +When set, the carrier emits the Go method name verbatim instead of +calling `s.interfaceJSONName(fld.Name())` — useful for an interface +already named for its JSON shape, or a codebase with its own +canonical-name discipline. A `swagger:name X` override still wins +verbatim regardless (see below); the flag only changes the +no-override fallback. The on/off contract is pinned by +`fixtures/enhancements/interface-no-mangle/` + +`integration/coverage_skip_jsonify_interface_test.go`. ### `swagger:name X` is verbatim @@ -760,6 +781,46 @@ 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. +### `swagger:title` / `swagger:description` overrides + +Two override annotations let the author replace the **godoc-derived** +title / description with curated API-facing text (Q30 close-out: a +Go doc comment written for Go readers — e.g. `time.Time`'s monotonic- +clock prose — should not have to leak into the spec). + +- **`swagger:title `** — single line; sets the schema/property + `title`. Schema-only. +- **`swagger:description `** — replaces the `description`. May span + multiple lines: the prose lines following the annotation fold into the + description (joined with `\n`), terminated by the first blank line, + keyword, or annotation (Option B). On responses/headers only the + description override applies; `swagger:title` there raises + `parse.context-invalid` (OpenAPI 2.0 has no Response/Header title). + +Semantics: + +- **Precedence.** Present ⇒ replaces the godoc value; absent ⇒ godoc is + used unchanged (no behaviour change for un-annotated decls). +- **Empty ⇒ suppress + warn.** A bare `swagger:description` (or a + whitespace/blank-only body) applies the empty value — the deliberate + godoc-suppression affordance — and raises `scan.empty-override`. +- **Harvest.** `common.Builder.HarvestOverrides` collects the two + annotations from a comment group's sibling blocks; `WarnEmptyOverride` + raises the empty warning at the consumption point (sibling classifier + blocks are not `Walk`-ed, so a grammar-stored diagnostic would not reach + `OnDiagnostic`). The schema builder wraps both in `overridesFor`. +- **Family.** Unlike the classifier annotations above, `swagger:title` / + `swagger:description` dispatch through the **schema** family (like + `swagger:name`), so a co-located validation keyword (`maximum:`, + `pattern:`, …) on the same field surfaces as a Property and is applied + rather than rejected as context-invalid. +- **`$ref` fields.** title and description are symmetric `$ref` siblings: + they ride description's existing preservation rule — kept under + `EmitRefSiblings` / a forced `allOf` compound, dropped to a bare `$ref` + under the default flags (see [§ref-override](#ref-override)). + +See `.claude/plans/features/swagger-description-override-design.md`. + --- ## §traceability — `x-go-*` origin extensions and `EmitXGoType` diff --git a/internal/builders/schema/additional_properties.go b/internal/builders/schema/additional_properties.go index 00eb348e..9e8d95fa 100644 --- a/internal/builders/schema/additional_properties.go +++ b/internal/builders/schema/additional_properties.go @@ -12,22 +12,22 @@ import ( oaispec "github.com/go-openapi/spec" ) -// classifierAdditionalProperties applies a decl-level -// `swagger:additionalProperties ` marker onto a top-level model schema. +// classifierAdditionalProperties applies a decl-level `swagger:additionalProperties ` marker +// onto a top-level model schema. // -// is `true` | `false` | a swagger:type-style spec (primitive / `[]T` / -// type-name → `$ref`). Semantics depend on the schema the Go type produced: +// is `true` | `false` | a swagger:type-style spec (primitive / `[]T` / type-name → +// `$ref`). +// Semantics depend on the schema the Go type produced: // // - struct → COMPLEMENT: keep the named properties, add additionalProperties; // - map → OVERRIDE the element-derived additionalProperties; // - a bare `$ref` (a map/wrapper type) → DEFINE a clean object schema (the // marker beats the Go type; a `$ref` cannot carry sibling keywords). // -// additionalProperties is the lowest-priority annotation: if a prior rule has -// already resolved a non-object type (swagger:type on a non-object, -// swagger:strfmt, a special/known type), the marker is dropped with a -// diagnostic — it only ever rides on top of an object. See -// [§additional-properties](./README.md#additional-properties). +// additionalProperties is the lowest-priority annotation: if a prior rule has already resolved a +// non-object type (swagger:type on a non-object, swagger:strfmt, a special/known type), the marker +// is dropped with a diagnostic — it only ever rides on top of an object. +// See [§additional-properties](./README.md#additional-properties). func (s *Builder) classifierAdditionalProperties(schema *oaispec.Schema, pos token.Position) { arg, ok := s.findAnnotationArg(s.Decl.Comments, grammar.AnnAdditionalProperties) if !ok { @@ -36,13 +36,14 @@ func (s *Builder) classifierAdditionalProperties(schema *oaispec.Schema, pos tok s.applyAdditionalPropertiesSpec(schema, arg, pos) } -// applyAdditionalPropertiesSpec sets additionalProperties on an object schema -// from a arg. Shared by the swagger:additionalProperties marker and the -// additionalProperties: field keyword (non-$ref path). +// applyAdditionalPropertiesSpec sets additionalProperties on an object schema from a arg. // -// Lowest-priority precedence: a schema already typed non-object is left alone -// with a diagnostic. A bare $ref is replaced by a clean object (a $ref ignores -// sibling keywords, so additionalProperties could not ride alongside it). +// Shared by the swagger:additionalProperties marker and the additionalProperties: field keyword +// (non-$ref path). +// +// Lowest-priority precedence: a schema already typed non-object is left alone with a diagnostic. +// A bare $ref is replaced by a clean object (a $ref ignores sibling keywords, so +// additionalProperties could not ride alongside it). func (s *Builder) applyAdditionalPropertiesSpec(schema *oaispec.Schema, arg string, pos token.Position) { if len(schema.Type) > 0 && !schema.Type.Contains("object") { s.RecordDiagnostic(grammar.Warnf(pos, grammar.CodeShapeMismatch, @@ -51,12 +52,12 @@ func (s *Builder) applyAdditionalPropertiesSpec(schema *oaispec.Schema, arg stri return } - // Cross-ref linkage: the value schema lands at /additionalProperties, - // so advance the base path for its build (an inlined element's properties / - // enum values resolve under it). Brackets only the value resolve; the - // $ref-sibling path (refOverrideCollector) calls - // resolveAdditionalPropertiesValue directly and is intentionally not - // bracketed here — its value lives in an untracked allOf member. + // Cross-ref linkage: the value schema lands at /additionalProperties, so advance the base + // path for its build (an inlined element's properties / enum values resolve under it). + // + // Brackets only the value resolve; the $ref-sibling path (refOverrideCollector) calls + // resolveAdditionalPropertiesValue directly and is intentionally not bracketed here — its value + // lives in an untracked allOf member. restore := s.descend("additionalProperties") sob, ok := s.resolveAdditionalPropertiesValue(arg, pos) restore() @@ -71,11 +72,11 @@ func (s *Builder) applyAdditionalPropertiesSpec(schema *oaispec.Schema, arg stri schema.AdditionalProperties = sob } -// resolveAdditionalPropertiesValue turns a arg (true | false | a -// swagger:type-style spec) into a SchemaOrBool, without mutating any parent -// schema — so it serves both the type-forcing paths (marker / field keyword) -// and the allOf-sibling path on a $ref'd field. Returns ok=false (diagnostic -// recorded) when a TypeSpec cannot be resolved. +// resolveAdditionalPropertiesValue turns a arg (true | false | a swagger:type-style spec) +// into a SchemaOrBool, without mutating any parent schema — so it serves both the type-forcing +// paths (marker / field keyword) and the allOf-sibling path on a $ref'd field. +// +// Returns ok=false (diagnostic recorded) when a TypeSpec cannot be resolved. func (s *Builder) resolveAdditionalPropertiesValue(arg string, pos token.Position) (*oaispec.SchemaOrBool, bool) { switch arg { case "true": @@ -91,11 +92,13 @@ func (s *Builder) resolveAdditionalPropertiesValue(arg string, pos token.Positio } } -// resolveAdditionalPropertiesType resolves a swagger:type-style spec onto the -// additionalProperties value schema. Unlike swagger:type's resolveTypeOverride -// (which inlines named references), a type-name here resolves to a `$ref` — an -// additionalProperties value naturally references a model, matching how a -// `map[string]Model` field renders. Leading `[]` build array layers. +// resolveAdditionalPropertiesType resolves a swagger:type-style spec onto the additionalProperties +// value schema. +// +// Unlike swagger:type's resolveTypeOverride (which inlines named references), a type-name here +// resolves to a `$ref` — an additionalProperties value naturally references a model, matching how +// a `map[string]Model` field renders. +// Leading `[]` build array layers. func (s *Builder) resolveAdditionalPropertiesType(arg string, target *oaispec.Schema, pos token.Position) bool { base, depth := stripArrayPrefixes(arg) @@ -104,9 +107,9 @@ func (s *Builder) resolveAdditionalPropertiesType(arg string, target *oaispec.Sc inner.Typed("array", "") inner = inner.Items() } - // Cross-ref linkage: the base resolves into the innermost items node; keep - // the path aligned for anchors emitted there (caller has already descended - // the additionalProperties / patternProperties segment). + // Cross-ref linkage: the base resolves into the innermost items node; keep the path aligned for + // anchors emitted there (caller has already descended the additionalProperties / patternProperties + // segment). defer s.descendItems(depth)() // Primitive / OAS-2 scalar / Go-builtin spelling — inline. @@ -114,11 +117,10 @@ func (s *Builder) resolveAdditionalPropertiesType(arg string, target *oaispec.Sc return true } - // Type-name reference → $ref. Resolve the leaf in the builder's own - // package first, then uniquely across the scanned packages' models - // (name-identity leaf resolution). buildFromType uses the NAMED type - // (not its Underlying) so buildNamedType emits the $ref and registers it - // for discovery. + // Type-name reference → $ref. + // Resolve the leaf in the builder's own package first, then uniquely across the scanned packages' + // models (name-identity leaf resolution). buildFromType uses the NAMED type (not its Underlying) + // so buildNamedType emits the $ref and registers it for discovery. decl, found, ambiguous := s.resolveNamedTypeLeaf(base, pos) if ambiguous { return false // diagnostic already recorded diff --git a/internal/builders/schema/allof.go b/internal/builders/schema/allof.go index 58012e9d..a85ceea8 100644 --- a/internal/builders/schema/allof.go +++ b/internal/builders/schema/allof.go @@ -13,15 +13,13 @@ import ( 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. +// 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. +// 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, @@ -45,7 +43,7 @@ func (s *Builder) scanEmbeddedFields( continue } - _, ignore, isString, omitEmpty, err := resolvers.ParseJSONTag(afld, fld.Name()) + _, ignore, isString, omitEmpty, err := resolvers.ParseFieldTag(afld, fld.Name(), s.Ctx.NameFromTags()) if err != nil { return nil, false, err } @@ -53,7 +51,16 @@ func (s *Builder) scanEmbeddedFields( continue } - if !fd.IsAllOfMember { + // DefaultAllOfForEmbeds promotes a plain (untagged) embed to allOf composition, exactly as a + // `swagger:allOf` tag would. + // A json-named embed is a single named property, not a promotion, so it stays inline + // (go-swagger#2038); interface embeds are out of scope here. + isAllOf := fd.IsAllOfMember + if !isAllOf && s.Ctx.DefaultAllOfForEmbeds() && embedNestName(afld, fd) == "" { + isAllOf = true + } + + if !isAllOf { target, err = s.buildPlainEmbed(fld, afld, fd, isString, omitEmpty, schema, target, nameByJSON) if err != nil { return nil, false, err @@ -79,9 +86,8 @@ func (s *Builder) scanEmbeddedFields( return target, hasAllOf, nil } -// buildPlainEmbed handles an anonymous embed that carries no -// `swagger:allOf` annotation, returning the (possibly newly-assigned) -// property target. +// buildPlainEmbed handles an anonymous embed that carries no `swagger:allOf` annotation, returning +// the (possibly newly-assigned) property target. // // Two shapes, mirroring Go's encoding/json: // @@ -103,10 +109,7 @@ func (s *Builder) buildPlainEmbed( target = schema } - nestName := resolvers.ExplicitJSONName(afld) - if fd.JSONName != "" { - nestName = fd.JSONName - } + nestName := embedNestName(afld, fd) if nestName != "" { err := s.applyFieldCarrier(fieldCarrier{ name: nestName, @@ -120,9 +123,8 @@ func (s *Builder) buildPlainEmbed( return target, err } - // A `required:` annotation on the embed applies to the properties it - // promotes (go-swagger#2701). Thread it through the recursion, - // restoring afterwards so sibling fields are unaffected. + // A `required:` annotation on the embed applies to the properties it promotes (go-swagger#2701). + // Thread it through the recursion, restoring afterwards so sibling fields are unaffected. saved := s.embedInherited s.embedInherited = s.ReadEmbedInheritance(afld.Doc, saved) err := s.buildEmbedded(fld.Type(), target, nameByJSON) @@ -131,19 +133,30 @@ func (s *Builder) buildPlainEmbed( return target, err } -// buildAllOf builds the schema for one allOf compound member. Peels -// pointers and routes named types and aliases to their dedicated -// helpers. +// embedNestName returns the explicit name an embed nests under — the json tag name, overridden by +// `swagger:name` — or "" when the embed promotes its properties (Go field promotion). +// +// An embed with a nest name is a single named property, never a promotion or allOf composition +// (go-swagger#2038). +func embedNestName(afld *ast.Field, fd fieldDoc) string { + if fd.JSONName != "" { + return fd.JSONName + } + return resolvers.ExplicitJSONName(afld) +} + +// 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. +// 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. func (s *Builder) buildAllOf(tpe types.Type, schema *oaispec.Schema) error { - // Cross-ref linkage: an allOf member is an untracked subtree - // (/allOf/{k}/…); clear the base path so nothing inside emits a wrong - // anchor. Members that are $refs anchor via their own definition. + // Cross-ref linkage: an allOf member is an untracked subtree (/allOf/{k}/…); clear the base path + // so nothing inside emits a wrong anchor. + // Members that are $refs anchor via their own definition. defer s.repath("")() switch ftpe := tpe.(type) { @@ -161,15 +174,14 @@ func (s *Builder) buildAllOf(tpe types.Type, schema *oaispec.Schema) error { } // 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. +// +// 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. +// 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() diff --git a/internal/builders/schema/embedded.go b/internal/builders/schema/embedded.go index 6302e2a2..a735eb0e 100644 --- a/internal/builders/schema/embedded.go +++ b/internal/builders/schema/embedded.go @@ -13,31 +13,30 @@ import ( ) // 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. +// +// 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. +// 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 resolve transparently and dispatch as -// the unaliased type would (so that `type BaseAlias = Base` embedded -// in a struct produces the same inline shape as `Base` embedded -// directly — both Go types are identical to the type system). +// buildEmbedded routes a struct's embedded-field type to the appropriate emitter: pointers are +// peeled, named types descend into `buildNamedEmbedded`, aliases resolve transparently and dispatch +// as the unaliased type would (so that `type BaseAlias = Base` embedded in a struct produces the +// same inline shape as `Base` embedded directly — both Go types are identical to the type +// system). // // # Details // -// See [§embedded](./README.md#embedded) — the three-arm dispatch and -// the contract that embeds always inline properties, never $ref unless -// the embed is `swagger:allOf`-tagged. +// See [§embedded](./README.md#embedded) — the three-arm dispatch and the contract that 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: @@ -63,16 +62,15 @@ func (s *Builder) buildEmbedded(tpe types.Type, schema *oaispec.Schema, nameByJS } } -// 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. +// 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 +// 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) { @@ -116,22 +114,20 @@ func (s *Builder) buildNamedEmbedded(tpe *types.Named, schema *oaispec.Schema, n } } -// 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. +// 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. +// 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) { - // Cross-ref linkage: interface-side embeds compose into allOf members - // (/allOf/{k}/…), an untracked subtree; clear the base path so nothing - // inside emits a wrong anchor. + // Cross-ref linkage: interface-side embeds compose into allOf members (/allOf/{k}/…), an + // untracked subtree; clear the base path so nothing inside emits a wrong anchor. defer s.repath("")() switch ftpe := fld.(type) { diff --git a/internal/builders/schema/fields.go b/internal/builders/schema/fields.go index cbaefc18..687404cf 100644 --- a/internal/builders/schema/fields.go +++ b/internal/builders/schema/fields.go @@ -18,8 +18,8 @@ import ( // 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. +// 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) @@ -30,8 +30,8 @@ type fieldCarrier struct { 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. +// 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 @@ -46,33 +46,30 @@ type propOwner struct { } // 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. +// +// 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. +// 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) } - // Cross-ref linkage: advance the base path to this property's pointer for - // the duration of its value build, so anchors emitted within (enum values, - // nested-object properties, slice/map element fields) carry the correct - // pointer. parentPath is restored on exit; the property's own anchor is + // Cross-ref linkage: advance the base path to this property's pointer for the duration of its + // value build, so anchors emitted within (enum values, nested-object properties, slice/map element + // fields) carry the correct pointer. parentPath is restored on exit; the property's own anchor is // recorded at fieldPath below. parentPath := s.path fieldPath := "" @@ -91,11 +88,10 @@ func (s *Builder) applyFieldCarrier(c fieldCarrier, target *oaispec.Schema, name ps.Ref = oaispec.Ref{} ps.Items = nil } - // swagger:type + swagger:strfmt precedence (F3). swagger:type wins on - // the type axis and always inlines; swagger:strfmt then applies as a - // supplementary format only when compatible with the resolved type. - // strfmt ALONE keeps forcing the string-encoded representation - // (go-swagger#1512). See [§user-overrides](./README.md#user-overrides). + // swagger:type + swagger:strfmt precedence (F3). swagger:type wins on the type axis and always + // inlines; swagger:strfmt then applies as a supplementary format only when compatible with the + // resolved type. strfmt ALONE keeps forcing the string-encoded representation (go-swagger#1512). + // See [§user-overrides](./README.md#user-overrides). pos := s.Ctx.PosOf(c.afld.Pos()) switch { case c.fd.TypeOverride != "": @@ -106,8 +102,7 @@ func (s *Builder) applyFieldCarrier(c fieldCarrier, target *oaispec.Schema, name s.applyStrfmtFormat(&ps, c.fd.StrfmtName, pos) } } else { - // Unresolved (file / unknown): fall through to the field's Go - // type, inlined as before. + // Unresolved (file / unknown): fall through to the field's Go type, inlined as before. ps = oaispec.Schema{} if err := s.buildFromType(c.propType.Underlying(), NewTypable(&ps, 0, s.skipExtensions)); err != nil { return err @@ -121,9 +116,9 @@ func (s *Builder) applyFieldCarrier(c fieldCarrier, target *oaispec.Schema, name s.applyBlockToField(c.afld, target, &ps, c.name) - // required: inherited from an embedding field (go-swagger#2701), unless - // the promoted property set its own required: explicitly. The flag lives - // on the enclosing object's Required list, so it is written to target. + // required: inherited from an embedding field (go-swagger#2701), unless the promoted property set + // its own required: explicitly. + // The flag lives on the enclosing object's Required list, so it is written to target. if s.embedInherited.RequiredSet && s.embedInherited.Required { if _, ownRequired := s.ParseBlock(c.afld.Doc).GetBool(grammar.KwRequired); !ownRequired { handlers.SetRequired(target, c.name, true) @@ -143,10 +138,10 @@ func (s *Builder) applyFieldCarrier(c fieldCarrier, target *oaispec.Schema, name } target.Properties[c.name] = ps - // Cross-ref linkage: anchor the property to its struct field. Only when a - // base path was initiated (WithPath) for the parent node and a sink is - // wired. fieldPath is this property's pointer (s.path was advanced to it - // above and is restored to parentPath on exit). + // Cross-ref linkage: anchor the property to its struct field. + // Only when a base path was initiated (WithPath) for the parent node and a sink is wired. + // fieldPath is this property's pointer (s.path was advanced to it above and is restored to + // parentPath on exit). if fieldPath != "" && c.afld != nil && s.Ctx.OriginEnabled() { s.Ctx.RecordOrigin(fieldPath, s.Ctx.PosOf(c.afld.Pos())) } @@ -154,23 +149,24 @@ func (s *Builder) applyFieldCarrier(c fieldCarrier, target *oaispec.Schema, name return nil } -// repath swaps the cross-ref base path (s.path) for the duration of a child -// build and returns a restore func, mirroring enterEmbed's idiom. Use -// `defer s.repath("")()` to clear the path when descending into a subtree whose -// node pointer isn't tracked (an allOf member, an aliased compound), so no -// wrong anchor is emitted there; finer nodes resolve to the nearest anchored -// ancestor instead. +// repath swaps the cross-ref base path (s.path) for the duration of a child build and returns a +// restore func, mirroring enterEmbed's idiom. +// +// Use `defer s.repath("")()` to clear the path when descending into a subtree whose node pointer +// isn't tracked (an allOf member, an aliased compound), so no wrong anchor is emitted there; finer +// nodes resolve to the nearest anchored ancestor instead. func (s *Builder) repath(base string) func() { saved := s.path s.path = base return func() { s.path = saved } } -// descend path-joins segments onto the cross-ref base path for the duration of -// a child build (items, additionalProperties, …), keeping s.path aligned with -// the schema node currently being filled so anchors emitted within — enum -// values, nested-object properties — carry the correct pointer. No-op (and no -// restore cost) when provenance is off (s.path == ""). +// descend path-joins segments onto the cross-ref base path for the duration of a child build +// (items, additionalProperties, …), keeping s.path aligned with the schema node currently being +// filled so anchors emitted within — enum values, nested-object properties — carry the correct +// pointer. +// +// No-op (and no restore cost) when provenance is off (s.path == ""). func (s *Builder) descend(segments ...string) func() { if s.path == "" { return func() {} @@ -178,14 +174,14 @@ func (s *Builder) descend(segments ...string) func() { return s.repath(s.path + scanner.JSONPointer(segments...)) } -// descendItems path-joins `depth` "items" segments onto the cross-ref base path -// for the duration of an array-element build, so anchors emitted inside the -// innermost element (an inlined struct's properties, enum values, validations) -// carry the …/items[/items…] pointer that matches where the element renders. -// Used by the `[]T` array-layer arms of swagger:type and -// swagger:additionalProperties, which build their layers via Items() rather -// than the structural slice/array path. No-op (and no restore cost) when -// provenance is off (s.path == "") or depth == 0. +// descendItems path-joins `depth` "items" segments onto the cross-ref base path for the duration of +// an array-element build, so anchors emitted inside the innermost element (an inlined struct's +// properties, enum values, validations) carry the …/items[/items…] pointer that matches where +// the element renders. +// +// Used by the `[]T` array-layer arms of swagger:type and swagger:additionalProperties, which build +// their layers via Items() rather than the structural slice/array path. +// No-op (and no restore cost) when provenance is off (s.path == "") or depth == 0. func (s *Builder) descendItems(depth int) func() { if s.path == "" || depth == 0 { return func() {} @@ -197,15 +193,16 @@ func (s *Builder) descendItems(depth int) func() { return s.descend(segments...) } -// 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. +// 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. +// 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 @@ -215,8 +212,8 @@ func (s *Builder) diagnoseAmbiguousEmbed(c fieldCarrier, nameByJSON map[string]p 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. + // Legitimate depth-rule shadowing: current writer is closer to the parent struct than the prior, + // Go would prefer it. return } var pos token.Position @@ -235,14 +232,12 @@ func (s *Builder) diagnoseAmbiguousEmbed(c fieldCarrier, nameByJSON map[string]p }) } -// 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). +// 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. +// 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 @@ -250,13 +245,13 @@ func (s *Builder) structFieldCarrier(fld *types.Var, decl *scanner.EntityDecl, t afld := resolvers.FindASTField(decl.File, fld.Pos()) if afld == nil && fld.Pkg() != nil { - // The field is not in the embedding decl's file. This happens when an - // embedded named type promotes fields whose source lives elsewhere — - // e.g. embedding a cross-package defined type - // (`type AnotherPackageAlias color.Color`), where `decl` is the alias - // in package `a` but the fields belong to `color.Color` in package - // `color`. Resolve the field's AST against its own source file so its - // json tag and doc are read correctly. See go-swagger#2417. + // The field is not in the embedding decl's file. + // This happens when an embedded named type promotes fields whose source lives elsewhere — e.g. + // embedding a cross-package defined type (`type AnotherPackageAlias color.Color`), where `decl` + // is the alias in package `a` but the fields belong to `color.Color` in package `color`. + // + // Resolve the field's AST against its own source file so its json tag and doc are read correctly. + // See go-swagger#2417. if file, ok := s.Ctx.FileForPos(fld.Pkg().Path(), fld.Pos()); ok { afld = resolvers.FindASTField(file, fld.Pos()) } @@ -270,7 +265,7 @@ func (s *Builder) structFieldCarrier(fld *types.Var, decl *scanner.EntityDecl, t return fieldCarrier{}, false, nil } - name, ignore, isString, omitEmpty, err := resolvers.ParseJSONTag(afld, fld.Name()) + name, ignore, isString, omitEmpty, err := resolvers.ParseFieldTag(afld, fld.Name(), s.Ctx.NameFromTags()) if err != nil { return fieldCarrier{}, false, err } @@ -284,11 +279,10 @@ func (s *Builder) structFieldCarrier(fld *types.Var, decl *scanner.EntityDecl, t return fieldCarrier{}, false, nil } - // swagger:name overrides the json-tag / field-name derivation on a - // struct field — matching its documented "field OR method" scope and - // the behaviour already honoured on interface methods (F5, - // doc-site-quirks.md). applyFieldCarrier still records the Go name as - // x-go-name when it differs from the emitted JSON name. + // swagger:name overrides the json-tag / field-name derivation on a struct field — matching its + // documented "field OR method" scope and the behaviour already honoured on interface methods (F5, + // doc-site-quirks.md). applyFieldCarrier still records the Go name as x-go-name when it differs + // from the emitted JSON name. if fd.JSONName != "" { name = fd.JSONName } @@ -304,9 +298,8 @@ func (s *Builder) structFieldCarrier(fld *types.Var, decl *scanner.EntityDecl, t }, 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, +// 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() { @@ -336,7 +329,13 @@ func (s *Builder) methodCarrier(fld *types.Func, decl *scanner.EntityDecl) (fiel name := fd.JSONName if name == "" { - name = s.interfaceJSONName(fld.Name()) + // No swagger:name override: auto-jsonify the Go method name unless the author opted out, in which + // case the Go name is emitted verbatim. + if s.Ctx.SkipJSONifyInterfaceMethods() { + name = fld.Name() + } else { + name = s.interfaceJSONName(fld.Name()) + } } return fieldCarrier{ @@ -358,7 +357,8 @@ func (s *Builder) applyNullable(target *oaispec.Schema) { } } -// interfaceJSONName maps a Go interface-method name to its JSON property name via the Builder's mangler. +// 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 index 13ee711c..89ddc6a6 100644 --- a/internal/builders/schema/interface.go +++ b/internal/builders/schema/interface.go @@ -29,8 +29,7 @@ func (s *Builder) buildFromInterface(decl *scanner.EntityDecl, it *types.Interfa copy(flist, specType.Methods.List) } - // First collect the embedded interfaces - // create refs when: + // 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 @@ -56,8 +55,8 @@ func (s *Builder) buildFromInterface(decl *scanner.EntityDecl, it *types.Interfa } target.Typed("object", "") - // Cross-ref linkage: same divergence guard as buildFromStruct — methods - // landing in a fresh allOf member resolve to schema's anchor. + // Cross-ref linkage: same divergence guard as buildFromStruct — methods landing in a fresh allOf + // member resolve to schema's anchor. if target != schema { defer s.repath("")() } @@ -131,7 +130,7 @@ func (s *Builder) buildNamedInterface( 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 + // otherwise the fields will just be included as normal properties. if err = s.buildAllOf(o.Type(), &newSch); err != nil { return hasAllOf, err } diff --git a/internal/builders/schema/options.go b/internal/builders/schema/options.go index 6dbb0b48..2baddfbd 100644 --- a/internal/builders/schema/options.go +++ b/internal/builders/schema/options.go @@ -22,30 +22,31 @@ type options struct { path string // base JSON pointer for cross-ref provenance (empty = off) } -// WithPath sets the base RFC 6901 pointer this build emits provenance under -// (cross-ref linkage). The caller initiates it for the placement context -// (e.g. "/definitions/User" or "/paths/~1pets/get/responses/200/schema"); the -// builder path-joins each member it produces. Empty (the default) records -// nothing. +// WithPath sets the base RFC 6901 pointer this build emits provenance under (cross-ref linkage). +// +// The caller initiates it for the placement context (e.g. "/definitions/User" or +// "/paths/~1pets/get/responses/200/schema"); the builder path-joins each member it produces. +// Empty (the default) records nothing. func WithPath(base string) Option { return func(o *options) { o.path = base } } -// 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`. +// 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. +// 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 @@ -53,18 +54,15 @@ func WithType(tpe types.Type, tgt ifaces.SwaggerTypable) Option { } } -// 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). +// 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 +// 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) { @@ -75,14 +73,14 @@ func WithSimpleSchema(tpe types.Type, tgt ifaces.SwaggerTypable, in string) Opti } } -// 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. +// 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. +// 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) diff --git a/internal/builders/schema/parsing_stuff.go b/internal/builders/schema/parsing_stuff.go index 24c03881..0b5723ac 100644 --- a/internal/builders/schema/parsing_stuff.go +++ b/internal/builders/schema/parsing_stuff.go @@ -39,22 +39,20 @@ func (s *Builder) inferNames() { 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. + // 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. model := s.findAnnotation(s.Decl.Comments, grammar.AnnModel) if model == nil { return } s.annotated = true - // A same-package duplicate has its override suppressed (D-4): keep - // the Go name so the x-go-name extension and definition key stay - // consistent with EntityDecl.Names/DefKey. + // A same-package duplicate has its override suppressed (D-4): keep the Go name so the x-go-name + // extension and definition key stay consistent with EntityDecl.Names/DefKey. if override, ok := model.AnnotationArg(); ok && !s.Decl.ModelOverrideSuppressed() { s.Name = override } diff --git a/internal/builders/schema/pattern_properties.go b/internal/builders/schema/pattern_properties.go index e4449545..86267080 100644 --- a/internal/builders/schema/pattern_properties.go +++ b/internal/builders/schema/pattern_properties.go @@ -13,18 +13,19 @@ import ( oaispec "github.com/go-openapi/spec" ) -// classifierPatternProperties applies a decl-level -// `swagger:patternProperties "": , …` marker onto a top-level model -// schema. Each pair maps a quoted property-name regex to a typed value schema -// (`` uses the swagger:type-style grammar, type-name → $ref) — the typed -// counterpart of the regex-only `patternProperties:` field keyword, which sets -// an empty value schema. +// classifierPatternProperties applies a decl-level `swagger:patternProperties "": , …` +// marker onto a top-level model schema. // -// Like additionalProperties, it only rides on an object (lowest-priority -// precedence) and replaces a bare $ref with a clean object. Each regex is -// RE2-hygiene-checked: an invalid regex is preserved on the schema but raises a -// CodeInvalidAnnotation warning (never dropped silently). See -// [§pattern-properties](./README.md#pattern-properties). +// Each pair maps a quoted property-name regex to a typed value schema (`` uses the +// swagger:type-style grammar, type-name → $ref) — the typed counterpart of the regex-only +// `patternProperties:` field keyword, which sets an empty value schema. +// +// Like additionalProperties, it only rides on an object (lowest-priority precedence) and replaces a +// bare $ref with a clean object. +// Each regex is RE2-hygiene-checked: an invalid regex is preserved on the schema but raises a +// CodeInvalidAnnotation warning (never dropped silently). +// +// See [§pattern-properties](./README.md#pattern-properties). func (s *Builder) classifierPatternProperties(schema *oaispec.Schema, pos token.Position) { arg, ok := s.findRawAnnotationArg(s.Decl.Comments, grammar.AnnPatternProperties) if !ok { @@ -55,9 +56,9 @@ func (s *Builder) classifierPatternProperties(schema *oaispec.Schema, pos token. for _, pr := range pairs { valSchema := new(oaispec.Schema) - // Cross-ref linkage: each value lands at /patternProperties/ - // (per-pair, not a shared additionalProperties node), so anchors emitted - // while resolving an inlined value resolve under the right pointer. + // Cross-ref linkage: each value lands at /patternProperties/ (per-pair, not a shared + // additionalProperties node), so anchors emitted while resolving an inlined value resolve under + // the right pointer. restore := s.descend("patternProperties", pr.regex) resolved := s.resolveAdditionalPropertiesType(pr.spec, valSchema, pos) restore() @@ -76,17 +77,17 @@ func (s *Builder) classifierPatternProperties(schema *oaispec.Schema, pos token. } } -// patternPropPair is one `"": ` entry of a swagger:patternProperties -// marker. +// patternPropPair is one `"": ` entry of a swagger:patternProperties marker. type patternPropPair struct { regex string spec string } // parsePatternPropertyPairs parses `"": , "": ` into pairs. -// The regex is double-quoted so it may contain commas/colons/spaces; only `\"` -// is an escape inside it (every other backslash is preserved verbatim — `\d` -// stays `\d`). The spec runs from the colon to the next top-level comma. +// +// The regex is double-quoted so it may contain commas/colons/spaces; only `\"` is an escape inside +// it (every other backslash is preserved verbatim — `\d` stays `\d`). +// The spec runs from the colon to the next top-level comma. // Returns ok=false on a structural error. func parsePatternPropertyPairs(arg string) (pairs []patternPropPair, ok bool) { i := 0 @@ -138,10 +139,11 @@ func parsePatternPropertyPairs(arg string) (pairs []patternPropPair, ok bool) { return pairs, true } -// findRawAnnotationArg is the unfiltered sibling of findAnnotationArg: it -// returns the first argument of the first block of kind verbatim, including -// whitespace-bearing args (the swagger:patternProperties pair list). The -// single-word filter is intentionally not applied. +// findRawAnnotationArg is the unfiltered sibling of findAnnotationArg: it returns the first +// argument of the first block of kind verbatim, including whitespace-bearing args (the +// swagger:patternProperties pair list). +// +// The single-word filter is intentionally not applied. func (s *Builder) findRawAnnotationArg(cg *ast.CommentGroup, kind grammar.AnnotationKind) (string, bool) { for _, b := range s.ParseBlocks(cg) { if b.AnnotationKind() != kind { diff --git a/internal/builders/schema/ref.go b/internal/builders/schema/ref.go index 77d75ed1..4a7cd5f2 100644 --- a/internal/builders/schema/ref.go +++ b/internal/builders/schema/ref.go @@ -9,10 +9,11 @@ import ( "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. +// 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) @@ -23,12 +24,10 @@ func (s *Builder) resolveRefOr(tio *types.TypeName, tgt ifaces.SwaggerTypable, o 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 rather than the -// *types.Named, so the diagnostic points at the structural shape -// the caller actually expected to resolve). +// 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 rather than the *types.Named, so the diagnostic points at the +// structural shape the caller actually expected to resolve). 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) diff --git a/internal/builders/schema/schema.go b/internal/builders/schema/schema.go index 5c317386..67a37ebf 100644 --- a/internal/builders/schema/schema.go +++ b/internal/builders/schema/schema.go @@ -37,16 +37,16 @@ type Builder struct { skipExtensions bool emitXGoType bool // stamp x-go-type on definitions; see [§traceability](./README.md#traceability). - // Embed-recursion depth for ambiguous-embed diagnostics; - // see [§embed-depth](./README.md#embed-depth). + // Embed-recursion depth for ambiguous-embed diagnostics; see + // [§embed-depth](./README.md#embed-depth). embedDepth int - // embedInherited carries a `required:` annotation on an embedded field - // down to the properties it promotes (go-swagger#2701). Set with - // save/restore around the embed recursion in scanEmbeddedFields. The - // mechanism is shared with the parameters and responses builders via - // common.EmbedInheritance; the schema builder consumes only Required - // (it has no `in:` location concept). + // embedInherited carries a `required:` annotation on an embedded field down to the properties it + // promotes (go-swagger#2701). + // + // Set with save/restore around the embed recursion in scanEmbeddedFields. + // The mechanism is shared with the parameters and responses builders via common.EmbedInheritance; + // the schema builder consumes only Required (it has no `in:` location concept). embedInherited common.EmbedInheritance } @@ -62,8 +62,8 @@ func NewBuilder(ctx *scanner.ScanCtx, decl *scanner.EntityDecl) *Builder { // 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]). +// 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 { @@ -74,11 +74,11 @@ func (s *Builder) Build(opts ...Option) error { // top-level declarations s.inferNames() - // Key the definitions map by the fully-qualified identity key - // (pkgpath/name), not the bare s.Name: distinct Go types that - // share a short name must not collide here. s.Name stays the - // leaf so annotateSchema's x-go-name / x-go-package emission is - // unchanged. The reduce stage shortens keys back afterwards. + // Key the definitions map by the fully-qualified identity key (pkgpath/name), not the bare + // s.Name: distinct Go types that share a short name must not collide here. s.Name stays the leaf + // so annotateSchema's x-go-name / x-go-package emission is unchanged. + // The reduce stage shortens keys back afterwards. + // // See .claude/plans/name-identity-cyclic-ref.md §9.1/§12.1. defKey := s.Decl.DefKey() @@ -88,19 +88,19 @@ func (s *Builder) Build(opts ...Option) error { return err } - // Decl-level `swagger:additionalProperties` rides on top of the - // type-derived schema (lowest priority; object-only). Applied after the - // Go type is resolved so it can complement a struct, override a map's - // element schema, or warn-and-drop on a non-object. See - // classifierAdditionalProperties. + // Decl-level `swagger:additionalProperties` rides on top of the type-derived schema (lowest + // priority; object-only). + // Applied after the Go type is resolved so it can complement a struct, override a map's element + // schema, or warn-and-drop on a non-object. + // + // See classifierAdditionalProperties. s.classifierAdditionalProperties(&schema, s.Ctx.PosOf(s.Decl.Ident.Pos())) s.classifierPatternProperties(&schema, s.Ctx.PosOf(s.Decl.Ident.Pos())) - // The decl-comment block is dispatched before the Go type is - // resolved onto the schema (see buildFromDecl), so the inline - // checkShape ran against an empty type. Re-gate now that the - // type is known: strip validations illegal for the resolved - // type and warn. See [§decl-shape-recheck](./README.md#decl-shape-recheck). + // The decl-comment block is dispatched before the Go type is resolved onto the schema (see + // buildFromDecl), so the inline checkShape ran against an empty type. + // Re-gate now that the type is known: strip validations illegal for the resolved type and warn. + // See [§decl-shape-recheck](./README.md#decl-shape-recheck). handlers.RecheckSchemaShape(&schema, s.Ctx.PosOf(s.Decl.Spec.Pos()), s.RecordDiagnostic) s.definitions[defKey] = schema @@ -122,9 +122,10 @@ func (s *Builder) SetDiscovered(discovered []*scanner.EntityDecl) { s.discovered = discovered } -// declPos returns the position of the declaration being built, used as a -// coarse fallback for diagnostics raised deep in the type dispatch where no -// finer AST node is in scope. Returns the zero Position when unavailable. +// declPos returns the position of the declaration being built, used as a coarse fallback for +// diagnostics raised deep in the type dispatch where no finer AST node is in scope. +// +// Returns the zero Position when unavailable. func (s *Builder) declPos() token.Position { if s.Decl == nil || s.Decl.Ident == nil { return token.Position{} @@ -133,11 +134,10 @@ func (s *Builder) declPos() token.Position { return s.Ctx.PosOf(s.Decl.Ident.Pos()) } -// warnUnsupportedGoType records a [grammar.CodeUnsupportedGoType] Warning that -// tpe could not be translated to a Swagger 2.0 construct and was dropped from -// the spec. where names the dispatch site (function / switch arm) for triage. -// tpe is any (a types.Type, *types.TypeName, ifaces.Objecter, …); the message -// renders its dynamic type and value. +// warnUnsupportedGoType records a [grammar.CodeUnsupportedGoType] Warning that tpe could not be +// translated to a Swagger 2.0 construct and was dropped from the spec. where names the dispatch +// site (function / switch arm) for triage. tpe is any (a types.Type, *types.TypeName, +// ifaces.Objecter, …); the message renders its dynamic type and value. func (s *Builder) warnUnsupportedGoType(where string, tpe any) { s.RecordDiagnostic(grammar.Warnf( s.declPos(), @@ -146,13 +146,12 @@ func (s *Builder) warnUnsupportedGoType(where string, tpe any) { )) } -// buildFromDecl emits the schema for a top-level type declaration -// (named or alias). +// 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). +// 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. @@ -160,17 +159,17 @@ func (s *Builder) buildFromDecl(schema *oaispec.Schema) error { } defer s.annotateSchema(schema)() - // Intercept stdlib specials before kind-dispatch (catches stdlib - // decls pulled in via the discovery chain). See - // [§special-types](./README.md#special-types). + // 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 } // 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. + // 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, s.Decl.ObjType(), s.Ctx.PosOf(s.Decl.Ident.Pos())); handled { if !recurse { return nil @@ -185,14 +184,13 @@ func (s *Builder) buildFromDecl(schema *oaispec.Schema) error { if s.guardDecl(tpe) { return nil } - // F1/F4: a swagger:model type carrying a swagger:strfmt or - // swagger:enum override publishes the FULL override schema on its - // own definition, not the bare-underlying orphan - // ({type:string} with the format dropped / no enum values). The - // override classifiers — the same ones the field site uses — - // inline that schema onto the definition target here; field sites - // then $ref the definition (see buildNamedType's swagger:model - // gate). swagger:type is already applied above (decl-site + // F1/F4: a swagger:model type carrying a swagger:strfmt or swagger:enum override publishes the + // FULL override schema on its own definition, not the bare-underlying orphan ({type:string} with + // the format dropped / no enum values). + // + // The override classifiers — the same ones the field site uses — inline that schema onto the + // definition target here; field sites then $ref the definition (see buildNamedType's + // swagger:model gate). swagger:type is already applied above (decl-site // classifierNamedTypeOverride). defTgt := NewTypable(schema, 0, s.skipExtensions) switch ut := tpe.Underlying().(type) { @@ -220,6 +218,7 @@ func (s *Builder) buildFromDecl(schema *oaispec.Schema) error { } // buildDeclAlias emits the schema for a top-level alias declaration. +// // Three modes — Dissolve / $ref / Expand — selected by TransparentAliases × RefAliases. // // # Details @@ -228,19 +227,17 @@ func (s *Builder) buildFromDecl(schema *oaispec.Schema) error { func (s *Builder) buildDeclAlias(tpe *types.Alias, target ifaces.SwaggerTypable) error { resolvers.MustHaveRightHandSide(tpe) - // `swagger:strfmt` user-override on the alias decl. Without this - // check, an alias decl decorated with `// swagger:strfmt ` - // would fall through to the recognizer (`type X = any` → empty - // body) or to the underlying primitive (`type X = int64` → - // `{integer, int64}`), silently dropping the user's annotation. - // Honouring it here mirrors `classifierNamedTypeOverride`'s - // decl-entry treatment of `swagger:type`, and matches the - // strfmt-first cascade order from `classifierNamedBasic`. + // `swagger:strfmt` user-override on the alias decl. + // Without this check, an alias decl decorated with `// swagger:strfmt ` would fall through + // to the recognizer (`type X = any` → empty body) or to the underlying primitive (`type X = + // int64` → `{integer, int64}`), silently dropping the user's annotation. // - // `swagger:strfmt` on a Named decl is unaffected — that path is - // covered by `classifierNamedStructStrfmt` (struct underlying) - // and `classifierNamedBasic` (primitive underlying), both fired - // from `buildNamedType` after the underlying-kind switch. + // Honouring it here mirrors `classifierNamedTypeOverride`'s decl-entry treatment of + // `swagger:type`, and matches the strfmt-first cascade order from `classifierNamedBasic`. + // + // `swagger:strfmt` on a Named decl is unaffected — that path is covered by + // `classifierNamedStructStrfmt` (struct underlying) and `classifierNamedBasic` (primitive + // underlying), both fired from `buildNamedType` after the underlying-kind switch. if name, ok := s.findAnnotationArg(s.Decl.Comments, grammar.AnnStrfmt); ok { target.Typed("string", name) return nil @@ -253,26 +250,24 @@ func (s *Builder) buildDeclAlias(tpe *types.Alias, target ifaces.SwaggerTypable) return s.buildFromType(rhs, target) } - // 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). + // 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) - // 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. + // 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() { // Consult the stdlib recognizers before walking Underlying(). - // Without this, aliases of stdlib-special types (time.Time, - // error, json.RawMessage, any) lose their canonical shape: - // the structural walk produces {type:object} for time.Time - // and {type:array, items:{integer,uint8}} for json.RawMessage - // instead of {type:string, format:date-time} and the open - // "any JSON" idiom respectively. The TransparentAliases path - // at line 156 already gets this right via buildFromType(rhs); - // Expand needs the same recognizer call before its Underlying - // fallthrough. + // + // Without this, aliases of stdlib-special types (time.Time, error, json.RawMessage, any) lose + // their canonical shape: the structural walk produces {type:object} for time.Time and + // {type:array, items:{integer,uint8}} for json.RawMessage instead of {type:string, + // format:date-time} and the open "any JSON" idiom respectively. + // + // The TransparentAliases path at line 156 already gets this right via buildFromType(rhs); Expand + // needs the same recognizer call before its Underlying fallthrough. if obj := rhsTypeName(rhs); obj != nil && applyStdlibSpecials(obj, target, s.skipExtensions) { return nil @@ -284,27 +279,25 @@ func (s *Builder) buildDeclAlias(tpe *types.Alias, target ifaces.SwaggerTypable) switch rtpe := rhs.(type) { case *types.Named: ro := rtpe.Obj() - // Consult the stdlib recognizers first. When the RHS is one of - // time.Time / error / json.RawMessage / any, emit the canonical - // inline shape on the alias's OWN definition rather than chaining - // a $ref to a separately-built definition. This matches what the - // sibling *types.Alias branch already does, what TransparentAliases - // mode already does for stdlib decls, and eliminates the Q30 noise - // from stdlib godocs landing on the chain targets (Time / - // RawMessage) — those targets no longer exist in the spec when - // the alias inlines the canonical shape directly. + // Consult the stdlib recognizers first. + // When the RHS is one of time.Time / error / json.RawMessage / any, emit the canonical inline + // shape on the alias's OWN definition rather than chaining a $ref to a separately-built + // definition. + // + // This matches what the sibling *types.Alias branch already does, what TransparentAliases mode + // already does for stdlib decls, and eliminates the Q30 noise from stdlib godocs landing on the + // chain targets (Time / RawMessage) — those targets no longer exist in the spec when the alias + // inlines the canonical shape directly. // - // For predeclared `error`, this is also the only safe path: it has - // no package, so the GetModel lookup below would nil-panic on - // Pkg().Path(). User-defined named types (non-stdlib) fall through - // to the GetModel + MakeRef chain as before. + // For predeclared `error`, this is also the only safe path: it has no package, so the GetModel + // lookup below would nil-panic on Pkg().Path(). + // User-defined named types (non-stdlib) fall through to the GetModel + MakeRef chain as before. if applyStdlibSpecials(ro, target, s.skipExtensions) { return nil } if ro.Pkg() == nil { - // Not a recognised special AND no package — predeclared but - // unsupported; degrade to missingSource so the build fails - // cleanly. + // Not a recognised special AND no package — predeclared but unsupported; degrade to + // missingSource so the build fails cleanly. return missingSource(rtpe) } rdecl, found := s.Ctx.GetModel(ro.Pkg().Path(), ro.Name()) @@ -345,16 +338,15 @@ func (s *Builder) buildDeclAlias(tpe *types.Alias, target ifaces.SwaggerTypable) // 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). + // 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 { return s.buildAlias(titpe, target) } // 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. + // 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, target) } @@ -418,11 +410,11 @@ func (s *Builder) buildAlias(tpe *types.Alias, target ifaces.SwaggerTypable) err return fmt.Errorf("can't find source file for aliased type: %v: %w", tpe, ErrSchema) } // Unannotated alias at a use site: dissolve to the unaliased target. - // The annotation is the user's opt-in to exposing the alias as a - // first-class spec entity; without it the aliasing is a Go - // implementation detail and the spec carries the underlying name. - // Matches the dissolve already performed under TransparentAliases at - // line 313, but applied per-decl based on intent instead of per-mode. + // The annotation is the user's opt-in to exposing the alias as a first-class spec entity; without + // it the aliasing is a Go implementation detail and the spec carries the underlying name. + // + // Matches the dissolve already performed under TransparentAliases at line 313, but applied + // per-decl based on intent instead of per-mode. // See [§aliases](./README.md#aliases). if !decl.HasModelAnnotation() { return s.buildFromType(tpe.Rhs(), target) @@ -434,7 +426,8 @@ func (s *Builder) buildAlias(tpe *types.Alias, target ifaces.SwaggerTypable) err // // # Details // -// See [§dispatch-table](./README.md#dispatch-table) and [§dissolve-named](./README.md#dissolve-named). +// 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.warnUnsupportedGoType("buildNamedType", titpe) @@ -447,10 +440,11 @@ func (s *Builder) buildNamedType(titpe *types.Named, target ifaces.SwaggerTypabl return nil } - // PkgForType-miss catches types we can't anchor to a scanned package - // (predeclared `comparable`, generic type params, compiler-internal shapes). + // 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. + // Complementary to the UnsupportedBuiltin guard above; also yields `pkg` for the Basic classifier + // below. pkg, found := s.Ctx.PkgForType(titpe) if !found { return nil @@ -464,13 +458,13 @@ func (s *Builder) buildNamedType(titpe *types.Named, target ifaces.SwaggerTypabl } // refModel: reference this type by $ref instead of inlining the override. - // A swagger:model type carrying an override (strfmt/type/enum) publishes - // its override schema on its OWN definition (applied at buildFromDecl) and - // is referenced here by $ref — the inline override classifiers below are - // skipped (F1/F2/F4). BUT only in full-schema mode: under SimpleSchema - // (non-body params / response headers) a $ref is illegal in OAS v2, so the - // override must still inline even for a swagger:model type. Without - // swagger:model an override type inlines everywhere, as before. + // A swagger:model type carrying an override (strfmt/type/enum) publishes its override schema on + // its OWN definition (applied at buildFromDecl) and is referenced here by $ref — the inline + // override classifiers below are skipped (F1/F2/F4). + // + // BUT only in full-schema mode: under SimpleSchema (non-body params / response headers) a $ref is + // illegal in OAS v2, so the override must still inline even for a swagger:model type. + // Without swagger:model an override type inlines everywhere, as before. refModel := isModel && !s.simpleSchema if !refModel { @@ -491,7 +485,8 @@ func (s *Builder) buildNamedType(titpe *types.Named, target ifaces.SwaggerTypabl return s.buildFromType(titpe.Underlying(), target) } - // Underlying-shape table. See [§dispatch-table](./README.md#dispatch-table). + // Underlying-shape table. + // See [§dispatch-table](./README.md#dispatch-table). switch utitpe := titpe.Underlying().(type) { case *types.Struct: if !refModel && s.classifierNamedStructStrfmt(cmt, target) { @@ -528,10 +523,10 @@ func (s *Builder) buildNamedType(titpe *types.Named, target ifaces.SwaggerTypabl } } -// buildNamedArrayLike is the unified Array/Slice arm. -// forSlice toggles the slice-only "bsonobjectid" special case in classifierNamedArrayLike. -// isModel skips the inline override classifier so a swagger:model array/slice -// type is referenced by $ref (its override schema lives on its own definition). +// buildNamedArrayLike is the unified Array/Slice arm. forSlice toggles the slice-only +// "bsonobjectid" special case in classifierNamedArrayLike. isModel skips the inline override +// classifier so a swagger:model array/slice type is referenced by $ref (its override schema lives +// on its own definition). func (s *Builder) buildNamedArrayLike(tio *types.TypeName, cmt *ast.CommentGroup, elem types.Type, tgt ifaces.SwaggerTypable, forSlice, isModel bool) error { if !isModel { if handled, recurse := s.classifierNamedArrayLike(cmt, tgt, forSlice); handled { @@ -551,12 +546,13 @@ func (s *Builder) buildNamedArrayLike(tio *types.TypeName, cmt *ast.CommentGroup // buildFromMap renders a Go map as a Swagger object with additionalProperties. // -// A map is representable as {type: object, additionalProperties: V} only when -// its key marshals to a JSON string — string kinds, integer/uint kinds, or an -// encoding.TextMarshaler key (resolvers.IsJSONMapKey, mirroring encoding/json). -// A key that json.Marshal itself rejects (float, bool, struct without -// TextMarshaler, interface, …) is not silently dropped to a typeless property: -// it raises a warning and emits no additionalProperties (go-swagger#2251, §18). +// A map is representable as {type: object, additionalProperties: V} only when its key marshals to a +// JSON string — string kinds, integer/uint kinds, or an encoding.TextMarshaler key +// (resolvers.IsJSONMapKey, mirroring encoding/json). +// +// A key that json.Marshal itself rejects (float, bool, struct without TextMarshaler, interface, +// …) is not silently dropped to a typeless property: it raises a warning and emits no +// additionalProperties (go-swagger#2251, §18). func (s *Builder) buildFromMap(titpe *types.Map, tgt ifaces.SwaggerTypable) error { sch := tgt.Schema() if sch == nil { @@ -582,15 +578,14 @@ func (s *Builder) buildFromMap(titpe *types.Map, tgt ifaces.SwaggerTypable) erro return s.buildFromType(titpe.Elem(), eleProp.AdditionalProperties()) } -// 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. +// 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. // -// When Options.EmitXGoType is set it also stamps x-go-type with the -// fully-qualified Go type — but only if a recognizer hasn't already set it -// (the special-type cases for `error` / the generic fallback carry their own -// deliberate value, which we must not clobber). See -// [§traceability](./README.md#traceability). +// When Options.EmitXGoType is set it also stamps x-go-type with the fully-qualified Go type — but +// only if a recognizer hasn't already set it (the special-type cases for `error` / the generic +// fallback carry their own deliberate value, which we must not clobber). +// See [§traceability](./README.md#traceability). func (s *Builder) annotateSchema(schema *oaispec.Schema) func() { return func() { if schema.Ref.String() != "" { @@ -608,8 +603,9 @@ func (s *Builder) annotateSchema(schema *oaispec.Schema) func() { } } -// guardDecl filters known-unsupported builtins (unsafe.Pointer) and asserts the scanner-side invariant -// that o.Pkg() is non-nil before downstream code dereferences it. +// 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) { @@ -620,11 +616,12 @@ func (s *Builder) guardDecl(tpe ifaces.Objecter) (skip bool) { return false } -// 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. +// 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) { @@ -638,15 +635,15 @@ func hasNamedCore(tpe types.Type) bool { } } -// rhsTypeName extracts the *types.TypeName from an alias RHS when it -// is one of the two kinds applyStdlibSpecials accepts: *types.Named -// (the direct stdlib reference, e.g. `Timestamp = time.Time`) or -// *types.Alias (the chained reference, e.g. `Wrap = Timestamp`). -// Returns nil for anonymous and other RHS kinds — applyStdlibSpecials -// has nothing to recognize on those. +// rhsTypeName extracts the *types.TypeName from an alias RHS when it is one of the two kinds +// applyStdlibSpecials accepts: *types.Named (the direct stdlib reference, e.g. `Timestamp = +// time.Time`) or *types.Alias (the chained reference, e.g. `Wrap = Timestamp`). +// +// Returns nil for anonymous and other RHS kinds — applyStdlibSpecials has nothing to recognize on +// those. // -// Used by buildDeclAlias's Expand branch to consult the stdlib -// recognizers before walking Underlying. +// Used by buildDeclAlias's Expand branch to consult the stdlib recognizers before walking +// Underlying. func rhsTypeName(rhs types.Type) *types.TypeName { switch t := rhs.(type) { case *types.Named: diff --git a/internal/builders/schema/schema_go118_test.go b/internal/builders/schema/schema_go118_test.go index d883820a..b19369f8 100644 --- a/internal/builders/schema/schema_go118_test.go +++ b/internal/builders/schema/schema_go118_test.go @@ -54,8 +54,8 @@ func TestGo118AliasedModels(t *testing.T) { for k := range defs { for i, b := range names { - // defs is keyed by the fully-qualified identity; match on the - // leaf via ResolveTestKey rather than the bare name. + // defs is keyed by the fully-qualified identity; match on the leaf via ResolveTestKey rather + // than the bare name. if scantest.ResolveTestKey(t, defs, b) == k { // remove the entry from the collection names = append(names[:i], names[i+1:]...) diff --git a/internal/builders/schema/schema_test.go b/internal/builders/schema/schema_test.go index 97c371b4..66cf101e 100644 --- a/internal/builders/schema/schema_test.go +++ b/internal/builders/schema/schema_test.go @@ -22,19 +22,19 @@ const ( // fixturesModule is the module path of the fixtures nested module. fixturesModule = "github.com/go-openapi/codescan/fixtures" - // classificationOrderRef is the fully-qualified $ref of the classification - // `order` model — emitted by the schema builder before the spec reduce - // stage shortens it (see scanner.EntityDecl.DefKey). + // classificationOrderRef is the fully-qualified $ref of the classification `order` model — + // emitted by the schema builder before the spec reduce stage shortens it (see + // scanner.EntityDecl.DefKey). classificationOrderRef = "#/definitions/" + fixturesModule + "/goparsing/classification/models/order" fixtureMinimal3125 = "bugs/3125/minimal" sampleValue1 = "value1" sampleValue2 = "value2" ) -// NOTE: the per-type petstore schema snapshots (Tag / Pet / Order) moved to -// the full-pipeline integration golden petstore_spec.json, which captures the -// same models in their reduced form. Builder-unit tests assert properties; they -// no longer dump whole-spec goldens. See .claude/plans/golden-unit-to-integration.md. +// NOTE: the per-type petstore schema snapshots (Tag / Pet / Order) moved to the full-pipeline +// integration golden petstore_spec.json, which captures the same models in their reduced form. +// Builder-unit tests assert properties; they no longer dump whole-spec goldens. +// See .claude/plans/golden-unit-to-integration.md. func TestBuilder(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) @@ -606,10 +606,9 @@ func TestAliasedTypes(t *testing.T) { require.NoError(t, prs.Build(WithDefinitions(models))) schema := models[scantest.ResolveTestKey(t, models, "OtherTypes")] - // Sub-builder unit tests run without the spec reduce stage, so $refs - // stay fully-qualified. The alias targets are not built into the local - // map (only OtherTypes is), so ResolveTestKey cannot shorten them; - // hardcode their package-qualified keys. + // Sub-builder unit tests run without the spec reduce stage, so $refs stay fully-qualified. + // The alias targets are not built into the local map (only OtherTypes is), so ResolveTestKey + // cannot shorten them; hardcode their package-qualified keys. mp := "#/definitions/" + fixturesModule + "/goparsing/classification/models/" tp := "#/definitions/" + fixturesModule + "/goparsing/classification/transitive/mods/" scantest.AssertRef(t, &schema, "named", "Named", mp+"SomeStringType") @@ -637,9 +636,8 @@ func TestAliasedTypes(t *testing.T) { scantest.AssertRef(t, &schema, "modsNamed", "ModsNamed", tp+"modsSomeStringType") scantest.AssertRef(t, &schema, "modsNumbered", "ModsNumbered", tp+"modsSomeIntType") - // F1: modsSomeTimeType is swagger:model + swagger:strfmt date-time, so it - // now $refs its definition (which carries the format) like its siblings, - // rather than inlining {string,date-time}. + // F1: modsSomeTimeType is swagger:model + swagger:strfmt date-time, so it now $refs its definition + // (which carries the format) like its siblings, rather than inlining {string,date-time}. scantest.AssertRef(t, &schema, "modsDated", "ModsDated", tp+"modsSomeTimeType") scantest.AssertRef(t, &schema, "modsTimed", "ModsTimed", tp+"modsSomeTimedType") scantest.AssertRef(t, &schema, "modsPetted", "ModsPetted", tp+"modsSomePettedType") @@ -658,9 +656,9 @@ func TestAliasedTypes(t *testing.T) { scantest.AssertRef(t, &schema, "manyModsPetted", "ManyModsPetted", tp+"modsSomePettedsType") scantest.AssertRef(t, &schema, "manyModsPettedPtr", "ManyModsPettedPtr", tp+"modsSomePettedsPtrType") - // swagger:alias is deprecated (F8): it no longer force-inlines the - // primitive, so these now $ref their definitions like any other named - // type (consistent with the `named`/`numbered` assertions above). + // swagger:alias is deprecated (F8): it no longer force-inlines the primitive, so these now $ref + // their definitions like any other named type (consistent with the `named`/`numbered` assertions + // above). scantest.AssertRef(t, &schema, "namedAlias", "NamedAlias", mp+"SomeStringTypeAlias") scantest.AssertRef(t, &schema, "numberedAlias", "NumberedAlias", mp+"SomeIntTypeAlias") assertArrayRef(t, &schema, "namedsAlias", "NamedsAlias", mp+"SomeStringTypeAlias") @@ -703,17 +701,16 @@ func TestAliasedModels(t *testing.T) { for k := range defs { for i, b := range names { - // defs is keyed by the fully-qualified identity; match on the - // leaf via ResolveTestKey rather than the bare name. + // defs is keyed by the fully-qualified identity; match on the leaf via ResolveTestKey rather + // than the bare name. if scantest.ResolveTestKey(t, defs, b) == k { // remove the entry from the collection names = append(names[:i], names[i+1:]...) } } } - // Sub-builder unit tests run without the spec reduce stage; the pet / - // Something $ref targets are not in the local map, so their keys are - // hardcoded with their package paths. + // Sub-builder unit tests run without the spec reduce stage; the pet / Something $ref targets are + // not in the local map, so their keys are hardcoded with their package paths. petRef := "#/definitions/" + fixturesModule + "/goparsing/classification/transitive/mods/pet" somethingRef := "#/definitions/" + fixturesModule + "/goparsing/classification/models/Something" if assert.Empty(t, names) { @@ -907,8 +904,8 @@ func TestPointersAreNullableByDefaultWhenSetXNullableForPointersIsSet(t *testing schema := allModels[scantest.ResolveTestKey(t, allModels, modelName)] require.Len(t, schema.Properties, 5) - // Interface-method properties are camelCased; struct fields - // without json tags keep the Go identifier verbatim. + // Interface-method properties are camelCased; struct fields without json tags keep the Go + // identifier verbatim. v1, v2, v3, v4, v5 := valueKeys(modelName) require.MapContainsT(t, schema.Properties, v1) @@ -933,11 +930,10 @@ func TestPointersAreNullableByDefaultWhenSetXNullableForPointersIsSet(t *testing assertModel(ctx, packagePath, "ItemInterface") } -// valueKeys returns the five property keys expected for the fixtures -// 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). +// valueKeys returns the five property keys expected for the fixtures 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 sampleValue1, sampleValue2, "value3", "value4", "value5" @@ -1080,8 +1076,7 @@ func TestStructDiscriminators(t *testing.T) { // sch = noModelDefs["lion"] - // b, _ := json.MarshalIndent(sch, "", " ") - // fmt.Println(string(b)) + // b, _ := json.MarshalIndent(sch, "", " ") fmt.Println(string(b)) } func TestInterfaceDiscriminators(t *testing.T) { @@ -1311,12 +1306,12 @@ func TestBuilder_DiagnosticsOnInvalidNumeric(t *testing.T) { schema := models[scantest.ResolveTestKey(t, 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. + // 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. + // Builder.Diagnostics() and the OnDiagnostic callback both surface the parser's CodeInvalidNumber + // error. bd := prs.Diagnostics() require.NotEmpty(t, bd) require.NotEmpty(t, collected) @@ -1340,9 +1335,9 @@ func TestBuilder_DiagnosticsOnInvalidNumeric(t *testing.T) { 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: +// 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; @@ -1354,9 +1349,9 @@ func TestBuilder_DiagnosticsOnInvalidNumeric(t *testing.T) { // 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. +// 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" @@ -1416,14 +1411,12 @@ func TestBuilder_DiagnosticsOnAmbiguousEmbed(t *testing.T) { }) } -// 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. +// 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 := "./" + fixtureMinimal3125 packagePath := fixturesModule + "/" + fixtureMinimal3125 @@ -1442,12 +1435,12 @@ func TestEmbeddedDescriptionAndTags(t *testing.T) { assert.Equal(t, []string{sampleValue1, sampleValue2}, schema.Required) require.Len(t, schema.Properties, 2) - // 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. + // 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. + // 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) @@ -1464,15 +1457,15 @@ func TestEmbeddedDescriptionAndTags(t *testing.T) { 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/"+fixturesModule+"/"+fixtureMinimal3125+"/ValueStruct", v2.AllOf[0].Ref.String()) - // The JSON-object example coerces structurally on the $ref override - // arm, matching the direct-field path (quirk G3) — it was previously - // carried as the raw string `{"value": 42}`. + // The JSON-object example coerces structurally on the $ref override arm, matching the direct-field + // path (quirk G3) — it was previously carried as the raw string `{"value": 42}`. assert.Equal(t, map[string]any{"value": float64(42)}, v2.AllOf[1].Example) } -// TestEmbeddedDescriptionAndTags_OptionVariants captures the -// (SkipExtensions, DescWithRef) option matrix on the bugs/3125 -// fixture into separately-named goldens. Verifies that: +// 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 @@ -1483,9 +1476,8 @@ func TestEmbeddedDescriptionAndTags(t *testing.T) { // 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. +// 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 @@ -1516,8 +1508,7 @@ func TestEmbeddedDescriptionAndTags_OptionVariants(t *testing.T) { require.MapContainsT(t, schema.Properties, sampleValue1) v1 := schema.Properties[sampleValue1] - // Both fields have overrides → the allOf compound persists in - // every option combination. + // Both fields have overrides → the allOf compound persists in every option combination. require.NotEmpty(t, v1.AllOf, "overrides always wrap in allOf") assert.Empty(t, v1.Ref.String(), "outer schema must not carry the ref directly") @@ -1532,28 +1523,28 @@ func TestEmbeddedDescriptionAndTags_OptionVariants(t *testing.T) { assert.Equal(t, "Value1", v1.Extensions["x-go-name"]) } - // DescWithRef only governs the description-only-no-override case; - // here both fields keep their descriptions on the outer compound. + // DescWithRef only governs the description-only-no-override case; here both fields keep their + // descriptions on the outer compound. assert.EqualT(t, "Nullable value", v1.Description) }) } } -// 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. +// 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. // -// 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. +// 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 @@ -1572,15 +1563,13 @@ func TestEmbeddedDescriptionAndTags_SkipExtensions(t *testing.T) { 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. + // 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. + // 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] @@ -1589,12 +1578,12 @@ func TestEmbeddedDescriptionAndTags_SkipExtensions(t *testing.T) { assert.MapNotContainsT(t, v2.Extensions, "x-nullable", "value2 has no x-nullable in source") } -// TestEmbeddedDescriptionAndTags_SkipAllOfCompounding is the A/B -// witness for the SkipAllOfCompounding option on the bugs/3125 -// fixture. Both Value1 (*ValueStruct, user-authored x-nullable + -// description) and Value2 (ValueStruct, example + description) are -// $ref'd fields whose siblings normally wrap into an allOf compound -// (see TestEmbeddedDescriptionAndTags). +// TestEmbeddedDescriptionAndTags_SkipAllOfCompounding is the A/B witness for the +// SkipAllOfCompounding option on the bugs/3125 fixture. +// +// Both Value1 (*ValueStruct, user-authored x-nullable + description) and Value2 (ValueStruct, +// example + description) are $ref'd fields whose siblings normally wrap into an allOf compound (see +// TestEmbeddedDescriptionAndTags). // // With SkipAllOfCompounding=true: // @@ -1644,8 +1633,10 @@ func TestEmbeddedDescriptionAndTags_SkipAllOfCompounding(t *testing.T) { assert.Empty(t, v2.Description, "description dropped on a bare $ref") assert.Nil(t, v2.Example, "override example dropped on a bare $ref") - // Every dropped sibling is reported. Value1 → x-nullable + description; - // Value2 → example + description. All carry CodeDroppedRefSibling. + // Every dropped sibling is reported. + // + // Value1 → x-nullable + description; Value2 → example + description. + // All carry CodeDroppedRefSibling. var keywords, descDrops int for _, d := range diags { if d.Code != grammar.CodeDroppedRefSibling { @@ -1678,8 +1669,8 @@ func sliceContainsSubstr(ss []string, sub string) bool { return false } -// TestEmbeddedDescriptionAndTags_EmitRefSiblings exercises the -// EmitRefSiblings lenient mode (no SkipAllOfCompounding) on bugs/3125. +// TestEmbeddedDescriptionAndTags_EmitRefSiblings exercises the EmitRefSiblings lenient mode (no +// SkipAllOfCompounding) on bugs/3125. // // - Value1 (*ValueStruct, user x-nullable + description): no // validation forces a compound, so description and x-nullable ride @@ -1734,9 +1725,10 @@ func TestEmbeddedDescriptionAndTags_EmitRefSiblings(t *testing.T) { } } -// TestEmbeddedDescriptionAndTags_EmitRefSiblings_Skip covers the -// EmitRefSiblings + SkipAllOfCompounding combination on bugs/3125 -// (the "both on" quadrant). No allOf compound is ever produced: +// TestEmbeddedDescriptionAndTags_EmitRefSiblings_Skip covers the EmitRefSiblings + +// SkipAllOfCompounding combination on bugs/3125 (the "both on" quadrant). +// +// No allOf compound is ever produced: // // - Value1: description + x-nullable survive as direct $ref siblings. // - Value2: description survives as a sibling, but `example` (a @@ -1789,21 +1781,20 @@ func TestEmbeddedDescriptionAndTags_EmitRefSiblings_Skip(t *testing.T) { assert.Equal(t, 1, drops, "only the example (a validation) is dropped") } -// TestParamsShape_DescWithRef_BothModes covers the description-only -// $ref'd field case where the user toggles DescWithRef: +// 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. +// 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. +// 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() @@ -1852,15 +1843,15 @@ func TestParamsShape_DescWithRef_BothModes(t *testing.T) { }) } -// 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. +// 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) { - // Sub-builder unit tests run without the spec reduce stage, so the - // definitions key and the $ref stay fully-qualified. + // Sub-builder unit tests run without the spec reduce stage, so the definitions key and the $ref + // stay fully-qualified. const expectedJSON = `{ "github.com/go-openapi/codescan/fixtures/bugs/2540/foo/Book": { "description": "At this moment, a book is only described by its publishing date\nand author.", diff --git a/internal/builders/schema/simpleschema.go b/internal/builders/schema/simpleschema.go index c1696c20..d7d6983d 100644 --- a/internal/builders/schema/simpleschema.go +++ b/internal/builders/schema/simpleschema.go @@ -8,41 +8,38 @@ import ( 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. +// 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`. +// 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. +// 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. +// 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. +// 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. + // 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 } @@ -66,15 +63,16 @@ func (s *Builder) validateSimpleSchemaOutcome() { )) probe.ResetForViolation() if refViolation { - // MakeRef discovered the now-dissolved target's decl; drop it so - // it doesn't linger as an orphan definition (go-swagger#1088). + // MakeRef discovered the now-dissolved target's decl; drop it so it doesn't linger as an orphan + // definition (go-swagger#1088). s.ResetPostDeclarations() } } -// 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). +// 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 { @@ -86,8 +84,7 @@ func isAllowedSimpleSchemaType(t, in string) bool { return false } -// simpleSchemaViolationReason produces a short human-readable cause -// for the diagnostic message. +// simpleSchemaViolationReason produces a short human-readable cause for the diagnostic message. func simpleSchemaViolationReason(t string, refViolation bool, in string) string { switch { case refViolation: @@ -97,8 +94,8 @@ func simpleSchemaViolationReason(t string, refViolation bool, in string) string case t == "object": return `type "object" is forbidden under SimpleSchema` case t == "": - // Should never get here — empty type is accepted by - // isAllowedSimpleSchemaType. Defensive only. + // 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 index 4a742b00..11d5b0aa 100644 --- a/internal/builders/schema/special_types.go +++ b/internal/builders/schema/special_types.go @@ -12,6 +12,7 @@ import ( ) // buildFromTextMarshal renders a TextMarshaler-implementing type as a string. +// // Six-step pipeline; user-annotation first, implicit recognizers next, generic fallback last. // // # Details @@ -21,8 +22,8 @@ func (s *Builder) buildFromTextMarshal(tpe types.Type, target ifaces.SwaggerTypa 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. + // 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) } @@ -42,8 +43,8 @@ func (s *Builder) buildFromTextMarshal(tpe types.Type, target ifaces.SwaggerTypa 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). + // 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 } @@ -60,16 +61,17 @@ const ( 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 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. +// 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 // @@ -79,16 +81,16 @@ func applyStdlibSpecials(obj *types.TypeName, target ifaces.SwaggerTypable, skip 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. +// 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). +// 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 { diff --git a/internal/builders/schema/struct.go b/internal/builders/schema/struct.go index 94da3349..aac5257a 100644 --- a/internal/builders/schema/struct.go +++ b/internal/builders/schema/struct.go @@ -10,17 +10,17 @@ import ( 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. +// 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). +// 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 @@ -44,9 +44,9 @@ func (s *Builder) buildFromStruct(decl *scanner.EntityDecl, st *types.Struct, sc } target.Typed("object", "") - // Cross-ref linkage: when own fields land in a fresh allOf member (target - // diverges from schema), their pointer is /allOf/{k}/properties/… — not the - // tracked base — so clear the path and let them resolve to schema's anchor. + // Cross-ref linkage: when own fields land in a fresh allOf member (target diverges from schema), + // their pointer is /allOf/{k}/properties/… — not the tracked base — so clear the path and + // let them resolve to schema's anchor. // Plain structs and plain embeds keep target == schema and anchor directly. if target != schema { defer s.repath("")() diff --git a/internal/builders/schema/type_override.go b/internal/builders/schema/type_override.go index 33bafd8d..140be036 100644 --- a/internal/builders/schema/type_override.go +++ b/internal/builders/schema/type_override.go @@ -16,9 +16,10 @@ import ( oaispec "github.com/go-openapi/spec" ) -// resolveTypeOverride applies a `swagger:type` argument onto tgt, ALWAYS -// producing an inline schema (never a $ref). It is the single resolution -// point for the keyword consumed at every swagger:type site (the F3 +// resolveTypeOverride applies a `swagger:type` argument onto tgt, ALWAYS producing an inline schema +// (never a $ref). +// +// It is the single resolution point for the keyword consumed at every swagger:type site (the F3 // reconciliation — see .claude/plans/quirks-F-series-fix.md). // // - ownType is the annotated field/decl's Go type, consumed by the @@ -26,13 +27,12 @@ import ( // nil when the site has no Go type to expand. // - pos drives diagnostics. // -// Returns applied=true when the override was honoured (the caller -// short-circuits). applied=false means the caller should fall through to -// default Go-type resolution; a diagnostic explaining why has already been -// recorded (unknown type, `file`, or an invalid array element). +// Returns applied=true when the override was honoured (the caller short-circuits). applied=false +// means the caller should fall through to default Go-type resolution; a diagnostic explaining why +// has already been recorded (unknown type, `file`, or an invalid array element). // -// Argument grammar (after stripping N leading `[]` → an N-deep array whose -// innermost items are the resolved base): +// Argument grammar (after stripping N leading `[]` → an N-deep array whose innermost items are +// the resolved base): // // - keyword scalars `string`/`integer`/`number`/`boolean`/`object` and the // Go-builtin spellings (`int64`, `uint32`, `float64`, …) — via @@ -50,24 +50,24 @@ func (s *Builder) resolveTypeOverride(arg string, tgt ifaces.SwaggerTypable, own return s.resolveTypeBase(base, tgt, ownType, pos, false) } - // `[]T …` — build N array layers, then resolve the base into the - // innermost items. The items form an inline schema like any other. + // `[]T …` — build N array layers, then resolve the base into the innermost items. + // The items form an inline schema like any other. tgt.Typed("array", "") items := tgt.Items() for range depth - 1 { items.Typed("array", "") items = items.Items() } - // Cross-ref linkage: the base resolves into the innermost items node, so - // any anchors it emits (an inlined named struct's properties, enum values) - // must carry the …/items[/items…] pointer, not the parent's. + // Cross-ref linkage: the base resolves into the innermost items node, so any anchors it emits (an + // inlined named struct's properties, enum values) must carry the …/items[/items…] pointer, not + // the parent's. defer s.descendItems(depth)() return s.resolveTypeBase(base, items, ownType, pos, true) } -// resolveTypeBase resolves a single (array-stripped) swagger:type base onto -// target. isElem reports whether target is an array-element position, where -// the own-type keywords (`inline`/`array`) and `file` are not meaningful. +// resolveTypeBase resolves a single (array-stripped) swagger:type base onto target. isElem reports +// whether target is an array-element position, where the own-type keywords (`inline`/`array`) and +// `file` are not meaningful. func (s *Builder) resolveTypeBase(base string, target ifaces.SwaggerTypable, ownType types.Type, pos token.Position, isElem bool) (applied bool) { switch base { case keywordInline, keywordArray: @@ -96,8 +96,8 @@ func (s *Builder) resolveTypeBase(base string, target ifaces.SwaggerTypable, own } // Otherwise a type-name reference: inline a known definition in place. - // Resolve the leaf in the builder's own package first, then uniquely - // across the scanned packages' models (name-identity leaf resolution). + // Resolve the leaf in the builder's own package first, then uniquely across the scanned packages' + // models (name-identity leaf resolution). decl, found, ambiguous := s.resolveNamedTypeLeaf(base, pos) if ambiguous { return false // diagnostic already recorded @@ -113,15 +113,19 @@ func (s *Builder) resolveTypeBase(base string, target ifaces.SwaggerTypable, own return false } -// applyStrfmtFormat applies a swagger:strfmt format onto a schema whose type -// has already been fixed by swagger:type. The format rides as a supplementary -// hint ONLY when it is consistent with that type (string accepts any; integer -// / number accept the numeric width formats — see -// validations.IsFormatCompatible). An incompatible format is dropped with a -// shape-mismatch diagnostic rather than silently overriding the type. This is -// the swagger:type + swagger:strfmt precedence: type wins, format is advisory. -// It does NOT apply to the strfmt-alone path, where strfmt still forces -// {type: string, format: X} (go-swagger#1512). +// applyStrfmtFormat applies a swagger:strfmt format onto a schema whose type has already been fixed +// by swagger:type. +// +// The format rides as a supplementary hint ONLY when it is consistent with that type (string +// accepts any; integer / number accept the numeric width formats — see +// validations.IsFormatCompatible). +// +// An incompatible format is dropped with a shape-mismatch diagnostic rather than silently +// overriding the type. +// This is the swagger:type + swagger:strfmt precedence: type wins, format is advisory. +// +// It does NOT apply to the strfmt-alone path, where strfmt still forces {type: string, format: X} +// (go-swagger#1512). func (s *Builder) applyStrfmtFormat(ps *oaispec.Schema, format string, pos token.Position) { var schemaType string if len(ps.Type) > 0 { @@ -136,10 +140,11 @@ func (s *Builder) applyStrfmtFormat(ps *oaispec.Schema, format string, pos token "swagger:strfmt with swagger:type: %s; format ignored", hint)) } -// inlineGoType expands a Go type onto target as an inline schema, never a -// $ref: pointers are peeled and a named/alias type is reduced to its -// underlying shape (so buildFromType emits the structure rather than a -// reference). Returns true on success. +// inlineGoType expands a Go type onto target as an inline schema, never a $ref: pointers are peeled +// and a named/alias type is reduced to its underlying shape (so buildFromType emits the structure +// rather than a reference). +// +// Returns true on success. func (s *Builder) inlineGoType(t types.Type, target ifaces.SwaggerTypable) bool { base := t for { @@ -149,18 +154,17 @@ func (s *Builder) inlineGoType(t types.Type, target ifaces.SwaggerTypable) bool } base = ptr.Elem() } - // Underlying() peels a Named/Alias to its structural type and is a - // no-op for already-structural types — so buildFromType inlines - // uniformly without taking the $ref branch in buildNamedType. + // Underlying() peels a Named/Alias to its structural type and is a no-op for already-structural + // types — so buildFromType inlines uniformly without taking the $ref branch in buildNamedType. return s.buildFromType(base.Underlying(), target) == nil } -// resolveNamedTypeLeaf resolves a bare type name written in a type-name -// keyword (swagger:type / swagger:additionalProperties / -// swagger:patternProperties) to its declaration. It looks in the builder's -// own package first — a local type wins (intent) — then, failing that, -// resolves the leaf across the scanned packages' annotated model set -// (name-identity leaf resolution, mirroring routes' resolveDefinitionByLeaf): +// resolveNamedTypeLeaf resolves a bare type name written in a type-name keyword (swagger:type / +// swagger:additionalProperties / swagger:patternProperties) to its declaration. +// +// It looks in the builder's own package first — a local type wins (intent) — then, failing +// that, resolves the leaf across the scanned packages' annotated model set (name-identity leaf +// resolution, mirroring routes' resolveDefinitionByLeaf): // // - exactly one model with that leaf -> (decl, true, false); // - several -> records an ambiguity diagnostic and returns (nil, false, true); @@ -191,9 +195,9 @@ func (s *Builder) resolveNamedTypeLeaf(name string, pos token.Position) (decl *s } } -// declNamedType returns the named or alias type a decl carries (nil if -// neither). The caller decides whether to emit it as a $ref (the named type) -// or inline it (its Underlying). +// declNamedType returns the named or alias type a decl carries (nil if neither). +// +// The caller decides whether to emit it as a $ref (the named type) or inline it (its Underlying). func declNamedType(decl *scanner.EntityDecl) types.Type { switch { case decl.Type != nil: @@ -205,18 +209,19 @@ func declNamedType(decl *scanner.EntityDecl) types.Type { } } -// swagger:type keyword values that are not resolved as scalar/builtin type -// names (lowercase, case-sensitive — a capitalised spelling is a type-name -// reference instead, e.g. `Inline` vs the keyword `inline`). +// swagger:type keyword values that are not resolved as scalar/builtin type names (lowercase, +// case-sensitive — a capitalised spelling is a type-name reference instead, e.g. `Inline` vs the +// keyword `inline`). const ( keywordInline = "inline" keywordArray = "array" keywordFile = "file" ) -// stripArrayPrefixes counts leading `[]` prefixes on a swagger:type argument -// and returns the bare base plus the array depth. `[][]string` → ("string", 2), -// `int64` → ("int64", 0). +// stripArrayPrefixes counts leading `[]` prefixes on a swagger:type argument and returns the bare +// base plus the array depth. +// +// `[][]string` → ("string", 2), `int64` → ("int64", 0). func stripArrayPrefixes(arg string) (base string, depth int) { base = strings.TrimSpace(arg) for strings.HasPrefix(base, "[]") { diff --git a/internal/builders/schema/walker.go b/internal/builders/schema/walker.go index e0fc3cae..08897bc4 100644 --- a/internal/builders/schema/walker.go +++ b/internal/builders/schema/walker.go @@ -8,6 +8,7 @@ import ( "go/token" "strings" + "github.com/go-openapi/codescan/internal/builders/common" "github.com/go-openapi/codescan/internal/builders/handlers" "github.com/go-openapi/codescan/internal/builders/resolvers" "github.com/go-openapi/codescan/internal/builders/validations" @@ -17,15 +18,18 @@ import ( oaispec "github.com/go-openapi/spec" ) -// recordValidationOrigins anchors each scalar validation keyword in block to its -// source comment line, so following e.g. a `maximum` node in the spec jumps to -// its `// maximum: 100` line rather than the struct field. Only when a base path -// was initiated (WithPath) and a sink is wired. The keyword→segment knowledge -// lives in the grammar ([grammar.PointerPath]); here we prepend the field base -// and any items depth (base + (/items)×ItemsDepth + /segment, mirroring where -// the value renders). Runs only on the non-$ref field path (a $ref field with -// siblings is rewritten to an allOf compound elsewhere, so its validations are -// not children of base — they resolve to the field anchor). +// recordValidationOrigins anchors each scalar validation keyword in block to its source comment +// line, so following e.g. a `maximum` node in the spec jumps to its `// maximum: 100` line rather +// than the struct field. +// +// Only when a base path was initiated (WithPath) and a sink is wired. +// The keyword→segment knowledge lives in the grammar ([grammar.PointerPath]); here we prepend the +// field base and any items depth (base + (/items)×ItemsDepth + /segment, mirroring where the value +// renders). +// +// Runs only on the non-$ref field path (a $ref field with siblings is rewritten to an allOf +// compound elsewhere, so its validations are not children of base — they resolve to the field +// anchor). func (s *Builder) recordValidationOrigins(block grammar.Block) { if s.path == "" || !s.Ctx.OriginEnabled() { return @@ -52,35 +56,55 @@ func (s *Builder) recordValidationOrigins(block grammar.Block) { }) } -// 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. +// overridesFor harvests the swagger:title / swagger:description overrides for a comment group and +// raises scan.empty-override for an empty value. +// +// Thin wrapper over the shared common.Builder primitive so the schema sites read cleanly. +func (s *Builder) overridesFor(cg *ast.CommentGroup) (title, desc common.OverrideValue) { + title, desc = s.HarvestOverrides(cg) + s.WarnEmptyOverride(grammar.AnnTitle, title) + s.WarnEmptyOverride(grammar.AnnDescription, desc) + return title, desc +} + +// 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. +// 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. Fixture - // fixtures/enhancements/top-level-kinds/IgnoredModel deliberately - // places `swagger:model` first and `swagger:ignore` second to - // pin this behaviour: 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. + // `swagger:ignore` only short-circuits when it is the FIRST annotation on the comment group. + // Fixture fixtures/enhancements/top-level-kinds/IgnoredModel deliberately places `swagger:model` + // first and `swagger:ignore` second to pin this behaviour: 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() - schema.Description = resolvers.AppendEnumDesc(schema.Description, schema.Extensions, s.Ctx.SkipEnumDescriptions()) + schema.Title = s.CleanGoDocSelf(block.PreambleTitle()) + description := s.CleanGoDocSelf(block.PreambleDescription()) + // swagger:title / swagger:description overrides replace the godoc-derived title / description + // (enum value docs are still appended below). + // Overrides are author-written and never passed through CleanGoDoc. + titleOv, descOv := s.overridesFor(s.Decl.Comments) + if titleOv.Present { + schema.Title = titleOv.Value + } + if descOv.Present { + description = descOv.Value + } + schema.Description = resolvers.AppendEnumDesc(description, schema.Extensions, s.Ctx.SkipEnumDescriptions()) - // `deprecated: true` or a godoc-style "Deprecated:" paragraph marks the - // model deprecated (go-swagger#3138). The grammar block unifies both - // triggers; OAS2 has no native schema `deprecated`, so emit x-deprecated. + // `deprecated: true` or a godoc-style "Deprecated:" paragraph marks the model deprecated + // (go-swagger#3138). + // The grammar block unifies both triggers; OAS2 has no native schema `deprecated`, so emit + // x-deprecated. if block.IsDeprecated() { resolvers.MarkDeprecated(schema) } @@ -93,45 +117,49 @@ func (s *Builder) applyDeclCommentBlock(schema *oaispec.Schema) (skip bool) { 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 so the override is preserved -// without dropping siblings of the $ref. +// 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 so the override is preserved without dropping siblings of the $ref. func (s *Builder) applyBlockToField(afld *ast.Field, enclosing *oaispec.Schema, ps *oaispec.Schema, name string) { block := s.ParseBlock(afld.Doc) + titleOv, descOv := s.overridesFor(afld.Doc) if ps.Ref.String() != "" { - s.applyToRefField(block, enclosing, ps, name) + s.applyToRefField(block, enclosing, ps, name, titleOv, descOv) return } - ps.Description = block.Prose() - ps.Description = resolvers.AppendEnumDesc(ps.Description, ps.Extensions, s.Ctx.SkipEnumDescriptions()) + description := s.CleanGoDoc(block.Prose()) + if descOv.Present { + description = descOv.Value + } + ps.Description = resolvers.AppendEnumDesc(description, ps.Extensions, s.Ctx.SkipEnumDescriptions()) + if titleOv.Present { + ps.Title = titleOv.Value + } - // `deprecated: true` or a godoc-style "Deprecated:" paragraph marks the - // field deprecated (go-swagger#3138) — see the model-level note above. + // `deprecated: true` or a godoc-style "Deprecated:" paragraph marks the field deprecated + // (go-swagger#3138) — see the model-level note above. if block.IsDeprecated() { resolvers.MarkDeprecated(ps) } handlers.DispatchSchemaLevel0(block, enclosing, ps, name, s.RecordDiagnostic, s.schemaOpts()) - // additionalProperties: field keyword. Applied after the type-derived - // dispatch so it complements an inline object, overrides a map's element - // schema, or warn-drops on a non-object — the same precedence as the - // type-level marker. ($ref'd fields are handled in applyToRefField via an - // allOf sibling, above.) + // additionalProperties: field keyword. + // Applied after the type-derived dispatch so it complements an inline object, overrides a map's + // element schema, or warn-drops on a non-object — the same precedence as the type-level marker. + // ($ref'd fields are handled in applyToRefField via an allOf sibling, above.) if apSpec, ok := block.GetString(grammar.KwAdditionalProperties); ok { s.applyAdditionalPropertiesSpec(ps, strings.TrimSpace(apSpec), s.Ctx.PosOf(afld.Pos())) } - // Items-level dispatch — only when the field type is written as - // an array literal. Named/alias array types opt out: their items - // chain belongs to the referenced/aliased definition, not to the - // referring field's block. + // Items-level dispatch — only when the field type is written as an array literal. + // Named/alias array types opt out: their items chain belongs to the referenced/aliased definition, + // not to the referring field's block. if arrayType, ok := afld.Type.(*ast.ArrayType); ok { targets := flattenItemsTargets(arrayType.Elt, ps.Items) for depth, target := range targets { @@ -143,21 +171,20 @@ func (s *Builder) applyBlockToField(afld *ast.Field, enclosing *oaispec.Schema, s.recordValidationOrigins(block) } -// schemaOpts packages the Builder's dispatch options into the value -// the handlers entry points consume. +// schemaOpts packages the Builder's dispatch options into the value the handlers entry points +// consume. func (s *Builder) schemaOpts() handlers.SchemaOptions { return handlers.SchemaOptions{SimpleSchemaMode: s.simpleSchema} } -// applyToRefField rewrites a $ref'd field into an allOf compound when -// field-level overrides are present. +// 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) { +// 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, titleOv, descOv common.OverrideValue) { originalRef := ps.Ref c := &refOverrideCollector{builder: s, enclosing: enclosing, name: name} @@ -174,30 +201,41 @@ func (s *Builder) applyToRefField(block grammar.Block, enclosing, ps *oaispec.Sc Diagnostic: s.RecordDiagnostic, }) - description := block.Prose() + // A swagger:description override replaces the godoc prose; title is a symmetric $ref sibling that + // rides description's fate (preserved when description would be, dropped when it would be) — no + // title-specific compounding rule. + // Both are absent (present=false) ⇒ godoc behaviour. + description := s.CleanGoDoc(block.Prose()) + if descOv.Present { + description = descOv.Value + } + var title string + if titleOv.Present { + title = titleOv.Value + } - if !c.anyCollected() && description == "" { + if !c.anyCollected() && description == "" && title == "" { return // bare {$ref}: nothing to attach } - // SkipAllOfCompounding: never emit an allOf compound. Validations and - // externalDocs can only ride a compound, so they are dropped; - // description and extensions are dropped too UNLESS EmitRefSiblings - // keeps them as direct $ref siblings. `required` already landed on the - // enclosing schema during the Walk (a parent-side concern, not a $ref - // sibling) and is unaffected. + // SkipAllOfCompounding: never emit an allOf compound. + // Validations and externalDocs can only ride a compound, so they are dropped; description and + // extensions are dropped too UNLESS EmitRefSiblings keeps them as direct $ref siblings. + // + // `required` already landed on the enclosing schema during the Walk (a parent-side concern, not a + // $ref sibling) and is unaffected. if s.Ctx.SkipAllOfCompounding() { - s.applyRefSiblingDrop(c, ps, description, name, block.Pos()) + s.applyRefSiblingDrop(c, ps, description, title, name, block.Pos()) return } - // EmitRefSiblings: when nothing forces a compound (no validations, no - // externalDocs), description and extensions ride directly beside the - // $ref rather than in a single-arm allOf wrap. A forced compound falls - // through to the wrap path below, where they ride the outer compound. + // EmitRefSiblings: when nothing forces a compound (no validations, no externalDocs), description + // and extensions ride directly beside the $ref rather than in a single-arm allOf wrap. + // A forced compound falls through to the wrap path below, where they ride the outer compound. forcedCompound := c.collectedValidation || c.collectedExternalDoc if s.Ctx.EmitRefSiblings() && !forcedCompound { ps.Description = description + ps.Title = title for k, v := range c.override.Extensions { ps.AddExtension(k, v) } @@ -205,7 +243,7 @@ func (s *Builder) applyToRefField(block grammar.Block, enclosing, ps *oaispec.Sc } if !c.anyCollected() && !s.Ctx.DescWithRef() { - return // description-only, not preserved → bare {$ref} + return // description/title-only, not preserved → bare {$ref} } // Lift x-* siblings onto the outer compound (see §ref-override). @@ -221,25 +259,26 @@ func (s *Builder) applyToRefField(block grammar.Block, enclosing, ps *oaispec.Sc *ps = oaispec.Schema{ VendorExtensible: oaispec.VendorExtensible{Extensions: liftedExtensions}, SchemaProps: oaispec.SchemaProps{ + Title: title, Description: description, AllOf: allOf, }, - // externalDocs is an annotation sibling of the $ref, like - // description and x-* — it lifts onto the outer compound - // rather than into the allOf override (go-swagger#2655). + // externalDocs is an annotation sibling of the $ref, like description and x-* — it lifts onto + // the outer compound rather than into the allOf override (go-swagger#2655). SwaggerSchemaProps: oaispec.SwaggerSchemaProps{ ExternalDocs: c.externalDocs, }, } } -// applyRefSiblingDrop handles the SkipAllOfCompounding case: no allOf -// compound is produced, so the field keeps its bare {$ref}. Extensions -// survive as direct siblings when EmitRefSiblings is set; description -// likewise. Everything else (validations, externalDocs) — and, without -// EmitRefSiblings, description / extensions too — is dropped, each with -// one CodeDroppedRefSibling diagnostic so the loss is never silent. -func (s *Builder) applyRefSiblingDrop(c *refOverrideCollector, ps *oaispec.Schema, description, name string, blockPos token.Position) { +// applyRefSiblingDrop handles the SkipAllOfCompounding case: no allOf compound is produced, so the +// field keeps its bare {$ref}. +// +// Extensions survive as direct siblings when EmitRefSiblings is set; description likewise. +// Everything else (validations, externalDocs) — and, without EmitRefSiblings, description / +// extensions too — is dropped, each with one CodeDroppedRefSibling diagnostic so the loss is +// never silent. +func (s *Builder) applyRefSiblingDrop(c *refOverrideCollector, ps *oaispec.Schema, description, title, name string, blockPos token.Position) { keepSiblings := s.Ctx.EmitRefSiblings() for _, d := range c.collected { @@ -252,27 +291,36 @@ func (s *Builder) applyRefSiblingDrop(c *refOverrideCollector, ps *oaispec.Schem name, d.keyword)) } - if description == "" { - return - } - if keepSiblings { - ps.Description = description - return + // description and title are symmetric $ref siblings: kept directly when EmitRefSiblings is set, + // otherwise dropped with a diagnostic. + for _, sib := range []struct { + kw, val string + set func(string) + }{ + {"description", description, func(v string) { ps.Description = v }}, + {"title", title, func(v string) { ps.Title = v }}, + } { + if sib.val == "" { + continue + } + if keepSiblings { + sib.set(sib.val) + continue + } + s.RecordDiagnostic(grammar.Warnf(blockPos, grammar.CodeDroppedRefSibling, + "field %q: %s dropped — not representable on a bare $ref (SkipAllOfCompounding)", + name, sib.kw)) } - s.RecordDiagnostic(grammar.Warnf(blockPos, grammar.CodeDroppedRefSibling, - "field %q: description dropped — not representable on a bare $ref (SkipAllOfCompounding)", - name)) } -// refOverrideCollector accumulates field-level overrides into a -// scratch schema for the allOf compound rewrite. +// 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 flags (`collectedValidation`, `collectedExtension`, -// `collectedExternalDoc`) and the lift-onto-outer behaviour for -// vendor extensions and externalDocs. +// See [§ref-override](./README.md#ref-override) — collector role, the flags +// (`collectedValidation`, `collectedExtension`, `collectedExternalDoc`) and the lift-onto-outer +// behaviour for vendor extensions and externalDocs. type refOverrideCollector struct { builder *Builder enclosing *oaispec.Schema @@ -283,16 +331,18 @@ type refOverrideCollector struct { collectedValidation bool collectedExtension bool collectedExternalDoc bool - // collected records each collected sibling (keyword + source - // position + class) so applyToRefField can decide, per category, - // what to drop under SkipAllOfCompounding and raise a per-keyword - // diagnostic. See [§ref-override]. + // collected records each collected sibling (keyword + source position + class) so applyToRefField + // can decide, per category, what to drop under SkipAllOfCompounding and raise a per-keyword + // diagnostic. + // + // See [§ref-override]. collected []collectedSibling } // siblingKind classifies a $ref sibling by how it can be emitted. -// Extensions can ride directly beside a $ref (EmitRefSiblings); -// validations and externalDocs can only ride an allOf compound. +// +// Extensions can ride directly beside a $ref (EmitRefSiblings); validations and externalDocs can +// only ride an allOf compound. type siblingKind int const ( @@ -301,8 +351,8 @@ const ( siblingExternalDoc ) -// collectedSibling names one $ref-sibling keyword, where it was written, -// and its class — for the drop diagnostics and category-aware handling. +// collectedSibling names one $ref-sibling keyword, where it was written, and its class — for the +// drop diagnostics and category-aware handling. type collectedSibling struct { keyword string pos token.Position @@ -393,17 +443,16 @@ func (c *refOverrideCollector) onString(p grammar.Property, val string) { handlers.ApplyPatternProperties(p, c.valid, val, c.builder.RecordDiagnostic) c.markValidation(p) case grammar.KwAdditionalProperties: - // On a $ref'd field, additionalProperties rides as an allOf sibling - // (`{allOf: [{$ref}, {additionalProperties: …}]}`) so the reference is - // preserved — JSON-Schema-draft-4 semantics, like the other siblings. + // On a $ref'd field, additionalProperties rides as an allOf sibling (`{allOf: [{$ref}, + // {additionalProperties: …}]}`) so the reference is preserved — JSON-Schema-draft-4 + // semantics, like the other siblings. if sob, ok := c.builder.resolveAdditionalPropertiesValue(strings.TrimSpace(val), p.Pos); ok { c.override.AdditionalProperties = sob c.markValidation(p) } case grammar.KwDefault: - // The $ref override arm carries no Type of its own, so a JSON - // object/array literal is coerced structurally here rather than - // type-driven via ParseDefault (quirk G3). + // The $ref override arm carries no Type of its own, so a JSON object/array literal is coerced + // structurally here rather than type-driven via ParseDefault (quirk G3). c.valid.SetDefault(validations.CoerceJSONOrString(val)) c.markValidation(p) case grammar.KwExample: @@ -415,12 +464,12 @@ func (c *refOverrideCollector) onString(p grammar.Property, val string) { } } -// 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 — SkipExtensions targets scanner-derived vendor -// extensions (`x-go-*`), not author-written ones. +// 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 — SkipExtensions targets scanner-derived vendor extensions (`x-go-*`), +// not author-written ones. func (c *refOverrideCollector) onExtension(ext grammar.Extension) { if !classify.IsAllowedExtension(ext.Name) { return @@ -442,9 +491,8 @@ func (c *refOverrideCollector) onRaw(p grammar.Property) { c.valid.SetEnum(p.Value) c.markValidation(p) case grammar.KwExternalDocs: - // externalDocs on a $ref'd field lifts onto the outer allOf - // compound (see applyToRefField). A non-ref field handles it - // via handlers.schemaRawHandler instead (go-swagger#2655). + // externalDocs on a $ref'd field lifts onto the outer allOf compound (see applyToRefField). + // A non-ref field handles it via handlers.schemaRawHandler instead (go-swagger#2655). ed, err := handlers.ParseExternalDocs(p.Body) if err != nil { c.builder.RecordDiagnostic(grammar.Warnf(p.Pos, grammar.CodeInvalidAnnotation, "externalDocs: %v", err)) @@ -458,9 +506,8 @@ func (c *refOverrideCollector) onRaw(p grammar.Property) { } } -// 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 +// 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]). func flattenItemsTargets(elt ast.Expr, schemaItems *oaispec.SchemaOrArray) []*oaispec.Schema { var out []*oaispec.Schema diff --git a/internal/builders/schema/walker_classifiers.go b/internal/builders/schema/walker_classifiers.go index 44039b93..40bd547a 100644 --- a/internal/builders/schema/walker_classifiers.go +++ b/internal/builders/schema/walker_classifiers.go @@ -18,16 +18,14 @@ import ( "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. +// 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. +// 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 { @@ -47,20 +45,20 @@ func (s *Builder) findAnnotationArg(cg *ast.CommentGroup, kind grammar.Annotatio // Per-call-site classifier walkers. // -// One walker per call site; each documents in its godoc which -// `swagger:` annotations it consumes. +// 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 +// 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. +// 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 { @@ -75,16 +73,16 @@ func (s *Builder) classifierTextMarshal(tpe types.Type, tgt ifaces.SwaggerTypabl 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). It is the single resolution point for `swagger:type` on a -// named type: it routes the argument through resolveTypeOverride (always -// inlining — keyword scalars / Go builtins / `[]T` / `inline` / `array` / -// type-name refs), and applies a co-present `swagger:strfmt` as a -// supplementary format only when compatible with the resolved type (F3 — -// see .claude/plans/quirks-F-series-fix.md). ownType is the named Go type -// (consumed by the `inline`/`array` keywords); pos drives diagnostics. +// 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). +// +// It is the single resolution point for `swagger:type` on a named type: it routes the argument +// through resolveTypeOverride (always inlining — keyword scalars / Go builtins / `[]T` / `inline` +// / `array` / type-name refs), and applies a co-present `swagger:strfmt` as a supplementary format +// only when compatible with the resolved type (F3 — see .claude/plans/quirks-F-series-fix.md). +// ownType is the named Go type (consumed by the `inline`/`array` keywords); pos drives diagnostics. // // Reports back via the (handled, fallthrough) tuple: // @@ -106,11 +104,12 @@ func (s *Builder) classifierNamedTypeOverride(cg *ast.CommentGroup, tgt ifaces.S return true, true } -// enumName resolves the enum type name for a swagger:enum annotation: the -// explicit `swagger:enum ` argument, or — for the BARE `swagger:enum` -// form on a type declaration — the declared type's own name (F4b, the name is -// redundant on a type decl). Returns ("", false) when no swagger:enum is -// present, or when the bare form has no declared type to infer from. +// enumName resolves the enum type name for a swagger:enum annotation: the explicit `swagger:enum +// ` argument, or — for the BARE `swagger:enum` form on a type declaration — the declared +// type's own name (F4b, the name is redundant on a type decl). +// +// Returns ("", false) when no swagger:enum is present, or when the bare form has no declared type +// to infer from. func (s *Builder) enumName(cg *ast.CommentGroup, declTypeName string) (string, bool) { if name, ok := s.findAnnotationArg(cg, grammar.AnnEnum); ok { return name, true @@ -121,18 +120,18 @@ func (s *Builder) enumName(cg *ast.CommentGroup, declTypeName string) (string, b return "", false } -// classifierNamedBasic is the named-type walker for -// `buildNamedBasic`. Consumes a cascade of classifier -// annotations in source-priority order: +// 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 // -// The final arm is the "primitive-inline" branch, which now fires only -// in SimpleSchema mode (the M1 contract for non-body parameters and -// response headers — `$ref` forbidden by OAS v2 so the underlying -// primitive ships inline). `swagger:alias` USED to be a second trigger -// here (a per-type force-inline override); it is deprecated (F8) and no -// longer affects output — its presence only emits a deprecation +// The final arm is the "primitive-inline" branch, which now fires only in SimpleSchema mode (the M1 +// contract for non-body parameters and response headers — `$ref` forbidden by OAS v2 so the +// underlying primitive ships inline). +// +// `swagger:alias` USED to be a second trigger here (a per-type force-inline override); it is +// deprecated (F8) and no longer affects output — its presence only emits a deprecation // diagnostic. // // Returns: @@ -140,10 +139,12 @@ func (s *Builder) enumName(cg *ast.CommentGroup, declTypeName string) (string, b // - handled=false → no classifier matched; caller continues to // FindModel / SwaggerSchemaForType fallback // -// recordEnumOrigins anchors each enum value to its const source position, but -// only when this is the canonical definition node (s.path set; cleared in field -// context, where the enum is a $ref to that definition instead). No-op when no -// provenance sink is wired. Cross-ref linkage only. +// recordEnumOrigins anchors each enum value to its const source position, but only when this is the +// canonical definition node (s.path set; cleared in field context, where the enum is a $ref to that +// definition instead). +// No-op when no provenance sink is wired. +// +// Cross-ref linkage only. func (s *Builder) recordEnumOrigins(enumPos []token.Pos) { if s.path == "" || !s.Ctx.OriginEnabled() { return @@ -171,10 +172,9 @@ func (s *Builder) classifierNamedBasic(cg *ast.CommentGroup, pkg *packages.Packa s.recordEnumOrigins(enumPos) 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. + // 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. s.RecordDiagnostic(grammar.Warnf(s.declPos(), grammar.CodeInvalidEnumOption, "swagger:enum %s: no matching const values found; enum semantics dropped", enumName)) } @@ -188,20 +188,19 @@ func (s *Builder) classifierNamedBasic(cg *ast.CommentGroup, pkg *packages.Packa return true } - // swagger:alias is DEPRECATED (F8): it is now an empty sink. It used to - // force a named primitive to inline ({type:string}) instead of the $ref - // a swagger:model primitive gets; that special behaviour is removed — - // the type now follows default handling. Emit a deprecation diagnostic - // and fall through. (Detection lives here because this is the only site - // that ever consulted swagger:alias; on non-primitive types it was - // already inert.) + // swagger:alias is DEPRECATED (F8): it is now an empty sink. + // It used to force a named primitive to inline ({type:string}) instead of the $ref a swagger:model + // primitive gets; that special behaviour is removed — the type now follows default handling. + // + // Emit a deprecation diagnostic and fall through. (Detection lives here because this is the only + // site that ever consulted swagger:alias; on non-primitive types it was already inert.) if alias := s.findAnnotation(cg, grammar.AnnAlias); alias != nil { s.RecordDiagnostic(grammar.Warnf(alias.Pos(), grammar.CodeDeprecated, `swagger:alias is deprecated and no longer affects output; use "swagger:type inline" to inline a type, or swagger:model for a first-class definition`)) } - // SimpleSchema mode (non-body params / response headers) still inlines - // the underlying primitive — $ref is forbidden there by OAS v2. + // SimpleSchema mode (non-body params / response headers) still inlines the underlying primitive + // — $ref is forbidden there by OAS v2. if s.simpleSchema { if err := resolvers.SwaggerSchemaForType(utitpe.Name(), tgt); err == nil { return true @@ -211,12 +210,12 @@ func (s *Builder) classifierNamedBasic(cg *ast.CommentGroup, pkg *packages.Packa 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. +// 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 @@ -237,9 +236,8 @@ func (s *Builder) classifierNamedArrayLike(cg *ast.CommentGroup, tgt ifaces.Swag 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. + // 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 @@ -250,10 +248,10 @@ func (s *Builder) classifierNamedArrayLike(cg *ast.CommentGroup, tgt ifaces.Swag 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. +// 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 { @@ -266,43 +264,41 @@ func (s *Builder) classifierAliasTargetStrfmt(tpe types.Type, tgt ifaces.Swagger 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. +// 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 — 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 — 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 — 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 — 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 — 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 — 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. +// 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. +// 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 { @@ -331,13 +327,13 @@ func (s *Builder) scanFieldDoc(afld *ast.Field) fieldDoc { fd.AllOfClass = name } } - // The `name:` keyword is the canonical field-naming keyword, - // honoured uniformly across schema / param / header (doc-quirk - // G2). On a model property or interface method it renames the - // emitted JSON name just as it already does on a parameter / - // header field. It takes precedence over the legacy swagger:name - // annotation when both are present, so capture it across all - // blocks and apply it last. + // The `name:` keyword is the canonical field-naming keyword, honoured uniformly across schema / + // param / header (doc-quirk G2). + // On a model property or interface method it renames the emitted JSON name just as it already + // does on a parameter / header field. + // + // It takes precedence over the legacy swagger:name annotation when both are present, so capture + // it across all blocks and apply it last. if v, ok := b.GetString(grammar.KwName); ok { if v = strings.TrimSpace(v); v != "" { nameKeyword = v @@ -350,11 +346,11 @@ func (s *Builder) scanFieldDoc(afld *ast.Field) fieldDoc { 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 — -// the caller's fallback handles unknown leaves) and returns true to -// short-circuit the struct-build pipeline. +// classifierStructPreBuildType is the named-type walker fired at the top of `buildFromStruct`. +// +// Consumes only `swagger:type`. +// On match attempts SwaggerSchemaForType (errors are swallowed — the caller's fallback handles +// unknown leaves) 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 { @@ -364,15 +360,15 @@ func (s *Builder) classifierStructPreBuildType(cg *ast.CommentGroup, tgt ifaces. 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. +// 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. +// 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) diff --git a/internal/builders/spec/concat_score_test.go b/internal/builders/spec/concat_score_test.go index 6423e505..3de5dada 100644 --- a/internal/builders/spec/concat_score_test.go +++ b/internal/builders/spec/concat_score_test.go @@ -3,19 +3,19 @@ package spec -// concat_score_test.go is the K2 exploration harness for the readability -// score that gates name-identity concat names (see reduce.go concatScore and -// Options.NameConcatBudget). It is NOT a regression test and is DISABLED by -// default (t.SkipNow): it logs a ranked table for human judgement and exists -// so we can re-challenge the production score function against alternatives in -// the future. +// concat_score_test.go is the K2 exploration harness for the readability score that gates +// name-identity concat names (see reduce.go concatScore and Options.NameConcatBudget). +// +// It is NOT a regression test and is DISABLED by default (t.SkipNow): it logs a ranked table for +// human judgement and exists so we can re-challenge the production score function against +// alternatives in the future. // // To re-run it, comment out the SkipNow and: // -// go test ./internal/builders/spec/ -run TestConcatScoreExperiment -v +// go test ./internal/builders/spec/ -run TestConcatScoreExperiment -v // -// The corpus, the combinations, and the row order are all deterministic, so -// the output is stable across runs. +// The corpus, the combinations, and the row order are all deterministic, so the output is stable +// across runs. import ( "fmt" @@ -26,10 +26,11 @@ import ( "github.com/go-openapi/swag/mangling" ) -// scoreFunc is a named candidate readability score. Add more here to compare -// them side by side in the table — every function gets its own column. The -// "weighted" candidate is the production concatScore (reduce.go), so this -// harness always re-challenges whatever ships. +// scoreFunc is a named candidate readability score. +// +// Add more here to compare them side by side in the table — every function gets its own column. +// The "weighted" candidate is the production concatScore (reduce.go), so this harness always +// re-challenges whatever ships. type scoreFunc struct { name string fn func(concat string, idents []string) float64 @@ -44,8 +45,8 @@ type scoreFunc struct { // CEILING, not a floor (max would pin everything to ≥1.0). // - added the missing closing paren / func header. // -// It is kept only as the cautionary baseline: its /maxWord^2 term rewards a -// single dominating long word, so it anti-correlates with intuition. +// It is kept only as the cautionary baseline: its /maxWord^2 term rewards a single dominating long +// word, so it anti-correlates with intuition. func scoreSeed(concat string, idents []string) float64 { const maxParts = 3.00 @@ -99,8 +100,7 @@ func maxIdentLen(idents []string) int { return m } -// combinations returns all k-subsets of [0,n) in lexicographic index order -// (deterministic). +// combinations returns all k-subsets of [0,n) in lexicographic index order (deterministic). func combinations(n, k int) [][]int { if k <= 0 || k > n { return nil @@ -137,8 +137,8 @@ func combinations(n, k int) [][]int { func TestConcatScoreExperiment(t *testing.T) { t.SkipNow() // exploration harness, not a regression test — see file header. - // concatScoreCorpus: five common English words spanning 3..12 characters, - // so a 2/3-word concat exercises a wide spread of part lengths. + // concatScoreCorpus: five common English words spanning 3..12 characters, so a 2/3-word concat + // exercises a wide spread of part lengths. concatScoreCorpus := []string{ "cat", // 3 "house", // 5 @@ -182,8 +182,8 @@ func TestConcatScoreExperiment(t *testing.T) { t.Fatal("no rows generated") } - // Rank by the weighted (production) score, ascending = most readable - // first; tie-break by concat for stability. + // Rank by the weighted (production) score, ascending = most readable first; tie-break by concat + // for stability. sort.SliceStable(rows, func(i, j int) bool { si, sj := concatScore(rows[i].concat, rows[i].idents), concatScore(rows[j].concat, rows[j].idents) if si != sj { diff --git a/internal/builders/spec/godoc_markers.go b/internal/builders/spec/godoc_markers.go new file mode 100644 index 00000000..ed9a76eb --- /dev/null +++ b/internal/builders/spec/godoc_markers.go @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package spec + +import ( + "strings" + + "github.com/go-openapi/codescan/internal/builders/godoclink" + oaispec "github.com/go-openapi/spec" +) + +// substituteGodocMarkers is the post-reduce half of CleanGoDoc idiom recomposition: it walks every +// godoc-derived title / description in the finished document and rewrites each godoclink marker to +// the exposed name of the schema it references. renames maps a pre-reduce definition key to its +// final user-facing name; a marker whose key is not an emitted definition (pruned / unresolved) +// collapses to its humanized fallback. +// +// See .claude/plans/features/godoc-filter-design.md §3. +func (s *Builder) substituteGodocMarkers(renames map[string]string) { + finalName := func(defKey string) (string, bool) { + if n, ok := renames[defKey]; ok { + return n, true + } + // No rename entry: the definition kept its default short name (the leaf of the key). + // Confirm it survived to the final document; otherwise the marker falls back. + leaf := defKey[strings.LastIndex(defKey, "/")+1:] + if _, ok := s.input.Definitions[leaf]; ok { + return leaf, true + } + + return "", false + } + + subst := func(in string) string { return godoclink.SubstituteMarkers(in, finalName) } + walkSpecProse(s.input, subst) +} + +// walkSpecProse applies subst to every prose field (title / description / summary) reachable in sw +// — the info block, definitions, shared parameters and responses, and every operation under +// paths. +// +// It mirrors reduce.go's rewriteAllRefs traversal. +func walkSpecProse(sw *oaispec.Swagger, subst func(string) string) { + if sw.Info != nil { + sw.Info.Title = subst(sw.Info.Title) + sw.Info.Description = subst(sw.Info.Description) + } + + for k, v := range sw.Definitions { + walkSchemaProse(&v, subst) + sw.Definitions[k] = v + } + for k, p := range sw.Parameters { + p.Description = subst(p.Description) + walkSchemaProse(p.Schema, subst) + sw.Parameters[k] = p + } + for k, r := range sw.Responses { + walkResponseProse(&r, subst) + sw.Responses[k] = r + } + + if sw.Paths == nil { + return + } + for k, pi := range sw.Paths.Paths { + walkPathItemProse(&pi, subst) + sw.Paths.Paths[k] = pi + } +} + +// walkPathItemProse applies subst to a path item's shared parameters and every operation's summary +// / description, parameters and responses. +func walkPathItemProse(pi *oaispec.PathItem, subst func(string) string) { + for i := range pi.Parameters { + pi.Parameters[i].Description = subst(pi.Parameters[i].Description) + walkSchemaProse(pi.Parameters[i].Schema, subst) + } + + for _, op := range operationsByMethod(pi) { + if op == nil { + continue + } + op.Summary = subst(op.Summary) + op.Description = subst(op.Description) + for i := range op.Parameters { + op.Parameters[i].Description = subst(op.Parameters[i].Description) + walkSchemaProse(op.Parameters[i].Schema, subst) + } + if op.Responses == nil { + continue + } + if op.Responses.Default != nil { + walkResponseProse(op.Responses.Default, subst) + } + for code, r := range op.Responses.StatusCodeResponses { + walkResponseProse(&r, subst) + op.Responses.StatusCodeResponses[code] = r + } + } +} + +// walkResponseProse applies subst to a response's description, headers and schema. +func walkResponseProse(r *oaispec.Response, subst func(string) string) { + r.Description = subst(r.Description) + for k, h := range r.Headers { + h.Description = subst(h.Description) + r.Headers[k] = h + } + walkSchemaProse(r.Schema, subst) +} + +// walkSchemaProse applies subst to a schema's title / description and recurses through every nested +// schema (properties, composition arms, items, maps). +func walkSchemaProse(sch *oaispec.Schema, subst func(string) string) { + if sch == nil { + return + } + + sch.Title = subst(sch.Title) + sch.Description = subst(sch.Description) + + for k, p := range sch.Properties { + walkSchemaProse(&p, subst) + sch.Properties[k] = p + } + for k, p := range sch.PatternProperties { + walkSchemaProse(&p, subst) + sch.PatternProperties[k] = p + } + for i := range sch.AllOf { + walkSchemaProse(&sch.AllOf[i], subst) + } + for i := range sch.AnyOf { + walkSchemaProse(&sch.AnyOf[i], subst) + } + for i := range sch.OneOf { + walkSchemaProse(&sch.OneOf[i], subst) + } + if sch.Items != nil { + walkSchemaProse(sch.Items.Schema, subst) + for i := range sch.Items.Schemas { + walkSchemaProse(&sch.Items.Schemas[i], subst) + } + } + if sch.AdditionalProperties != nil { + walkSchemaProse(sch.AdditionalProperties.Schema, subst) + } + for k, d := range sch.Definitions { + walkSchemaProse(&d, subst) + sch.Definitions[k] = d + } +} diff --git a/internal/builders/spec/panic_guard_test.go b/internal/builders/spec/panic_guard_test.go index 6b206549..267f7c91 100644 --- a/internal/builders/spec/panic_guard_test.go +++ b/internal/builders/spec/panic_guard_test.go @@ -13,13 +13,13 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestBuilder_guard_RecoversPanicWithLocatedDiagnostic locks the §8.1 -// behaviour (go-swagger#2886): a panic in a per-declaration build step is -// recovered, surfaced as a located scan.internal-panic diagnostic naming the -// offending source (file:line + what), and converted into an aborting error -// wrapping ErrInternalPanic — never a raw Go stack trace. A non-panicking -// step is transparent. The spec.Builder build loops wrap every per-decl step -// in this guard. +// TestBuilder_guard_RecoversPanicWithLocatedDiagnostic locks the §8.1 behaviour (go-swagger#2886): +// a panic in a per-declaration build step is recovered, surfaced as a located scan.internal-panic +// diagnostic naming the offending source (file:line + what), and converted into an aborting error +// wrapping ErrInternalPanic — never a raw Go stack trace. +// +// A non-panicking step is transparent. +// The spec.Builder build loops wrap every per-decl step in this guard. func TestBuilder_guard_RecoversPanicWithLocatedDiagnostic(t *testing.T) { var diags []grammar.Diagnostic ctx, err := scanner.NewScanCtx(&scanner.Options{ diff --git a/internal/builders/spec/prune.go b/internal/builders/spec/prune.go index cf8616d2..54dddea9 100644 --- a/internal/builders/spec/prune.go +++ b/internal/builders/spec/prune.go @@ -14,23 +14,26 @@ import ( const defRefPrefix = "#/definitions/" -// pruneUnusedModels drops every discovered definition not transitively -// referenced from a reachability root, when the caller set PruneUnusedModels. -// It is the middle ground between the default route-reachable-only mode and the -// emit-everything ScanModels mode: run ScanModels discovery, then prune the -// unreachable tail (go-swagger/go-swagger#2639). +// pruneUnusedModels drops every discovered definition not transitively referenced from a +// reachability root, when the caller set PruneUnusedModels. // -// It runs BEFORE reduceDefinitionNames, in the fully-qualified definition-key -// namespace, so a pruned (unused) model can no longer force a spurious -// cross-package name collision on a model that IS used — the survivor keeps its -// clean short name. Buffered provenance for each pruned definition is dropped so -// no anchor dangles, and each prune raises a scan.pruned-unused Hint. +// It is the middle ground between the default route-reachable-only mode and the emit-everything +// ScanModels mode: run ScanModels discovery, then prune the unreachable tail +// (go-swagger/go-swagger#2639). // -// Reachability roots are the paths (operation parameters + responses), the -// shared responses and parameters, and every definition supplied via InputSpec -// (pinned: never pruned, and seeded as roots so their $ref targets survive). -// Definitions themselves are not roots — a model referenced only by another -// unreferenced model is pruned too. +// It runs BEFORE reduceDefinitionNames, in the fully-qualified definition-key namespace, so a +// pruned (unused) model can no longer force a spurious cross-package name collision on a model that +// IS used — the survivor keeps its clean short name. +// +// Buffered provenance for each pruned definition is dropped so no anchor dangles, and each prune +// raises a scan.pruned-unused Hint. +// +// Reachability roots are the paths (operation parameters + responses), the shared responses and +// parameters, and every definition supplied via InputSpec (pinned: never pruned, and seeded as +// roots so their $ref targets survive). +// +// Definitions themselves are not roots — a model referenced only by another unreferenced model is +// pruned too. func (s *Builder) pruneUnusedModels() { if !s.ctx.PruneUnusedModels() { return @@ -39,9 +42,9 @@ func (s *Builder) pruneUnusedModels() { onDiag := s.ctx.OnDiagnostic() if !s.scanModels { - // Without ScanModels the emitted set is already reference-reachable, so - // there is nothing to prune. Surface one Hint so a caller who set the - // flag alone learns it did nothing. + // Without ScanModels the emitted set is already reference-reachable, so there is nothing to + // prune. + // Surface one Hint so a caller who set the flag alone learns it did nothing. if onDiag != nil { onDiag(grammar.Hintf(token.Position{}, grammar.CodePrunedUnused, "PruneUnusedModels has no effect without ScanModels: "+ @@ -50,6 +53,11 @@ func (s *Builder) pruneUnusedModels() { return } + // C4: prune unreferenced shared parameters / responses FIRST, so a definition kept alive only by a + // now-pruned shared object becomes prunable in the definition pass below. + // See .claude/plans/features/shared-parameters-fixtures.md §6b. + s.pruneUnusedSharedObjects(onDiag) + if len(s.input.Definitions) == 0 { return } @@ -74,8 +82,8 @@ func (s *Builder) pruneUnusedModels() { for _, key := range s.rootRefs() { mark(key) } - // Pin overlay-supplied definitions (those built from no source declaration) - // as roots: the caller put them there deliberately. + // Pin overlay-supplied definitions (those built from no source declaration) as roots: the caller + // put them there deliberately. for key := range s.input.Definitions { if _, built := s.declPos[key]; !built { mark(key) @@ -110,10 +118,10 @@ func (s *Builder) pruneUnusedModels() { } } -// rootRefs returns every definition key referenced from a reachability root: -// path operations (body parameters + response schemas), shared responses and -// shared parameters. Definitions are deliberately excluded — they stay only if -// reached transitively from here. +// rootRefs returns every definition key referenced from a reachability root: path operations (body +// parameters + response schemas), shared responses and shared parameters. +// +// Definitions are deliberately excluded — they stay only if reached transitively from here. func (s *Builder) rootRefs() []string { var out []string collect := func(sch *oaispec.Schema) { @@ -151,10 +159,115 @@ func (s *Builder) rootRefs() []string { return out } -// collectDefRefs walks sch and every sub-schema, invoking mark for each -// "#/definitions/" reference it carries. It is the read-only mirror of -// rewriteSchemaRefs and must cover the same container set; a missed container -// would make a referenced model look unreachable and wrongly prune it. +// pruneUnusedSharedObjects drops shared parameters (#/parameters/*) and shared responses +// (#/responses/*) that no operation or path-item references (C4). +// +// It runs BEFORE the definition reachability walk so the surviving shared objects (which are +// reachability roots) seed that walk, and a definition kept alive only by a now-pruned shared +// object becomes prunable in turn. +// +// InputSpec-supplied shared objects are pinned (never pruned), mirroring the definitions rule. +// Each drop raises a located scan.pruned-unused Hint. +func (s *Builder) pruneUnusedSharedObjects(onDiag func(grammar.Diagnostic)) { + refParams, refResponses := s.collectSharedRefs() + + // Shared parameters. + prunedParams := make([]string, 0, len(s.parameters)) + for name := range s.parameters { + if _, pinned := s.pinnedParams[name]; pinned { + continue + } + if _, used := refParams[name]; used { + continue + } + prunedParams = append(prunedParams, name) + } + sort.Strings(prunedParams) + for _, name := range prunedParams { + delete(s.parameters, name) + if onDiag != nil { + onDiag(grammar.Hintf(s.sharedParamPos[name], grammar.CodePrunedUnused, + "shared parameter %q pruned: not referenced by any operation or path-item", name)) + } + } + + // Shared responses. + prunedResponses := make([]string, 0, len(s.responses)) + for name := range s.responses { + if _, pinned := s.pinnedResponses[name]; pinned { + continue + } + if _, used := refResponses[name]; used { + continue + } + prunedResponses = append(prunedResponses, name) + } + sort.Strings(prunedResponses) + for _, name := range prunedResponses { + delete(s.responses, name) + s.ctx.DropDeferredOrigins(responseOriginKey(name)) // no orphan provenance + if onDiag != nil { + onDiag(grammar.Hintf(s.sharedRespPos[name], grammar.CodePrunedUnused, + "shared response %q pruned: not referenced by any operation or path-item", name)) + } + } +} + +// collectSharedRefs scans every path-item and operation for references into the shared namespaces, +// returning the set of shared-parameter names reached by a #/parameters/{name} $ref and the set of +// shared-response names reached by a #/responses/{name} $ref. +// +// It is the read-only "is referenced" mirror of the ref-application passes (applyParameterRefs / +// applyPathItemParameters / route response binding) and the validateSharedRefs safety net. +func (s *Builder) collectSharedRefs() (params, responses map[string]struct{}) { + params = map[string]struct{}{} + responses = map[string]struct{}{} + if s.input.Paths == nil { + return params, responses + } + + markParam := func(ref oaispec.Ref) { + if name, ok := sharedRefName(ref, "#/parameters/"); ok { + params[name] = struct{}{} + } + } + markResp := func(ref oaispec.Ref) { + if name, ok := sharedRefName(ref, "#/responses/"); ok { + responses[name] = struct{}{} + } + } + + for _, pi := range s.input.Paths.Paths { + for i := range pi.Parameters { + markParam(pi.Parameters[i].Ref) + } + for _, op := range operationsOf(pi) { + if op == nil { + continue + } + for i := range op.Parameters { + markParam(op.Parameters[i].Ref) + } + if op.Responses == nil { + continue + } + if op.Responses.Default != nil { + markResp(op.Responses.Default.Ref) + } + for _, r := range op.Responses.StatusCodeResponses { + markResp(r.Ref) + } + } + } + + return params, responses +} + +// collectDefRefs walks sch and every sub-schema, invoking mark for each "#/definitions/" +// reference it carries. +// +// It is the read-only mirror of rewriteSchemaRefs and must cover the same container set; a missed +// container would make a referenced model look unreachable and wrongly prune it. func collectDefRefs(sch *oaispec.Schema, mark func(key string)) { if sch == nil { return diff --git a/internal/builders/spec/reduce.go b/internal/builders/spec/reduce.go index be12ca1d..60b8c91e 100644 --- a/internal/builders/spec/reduce.go +++ b/internal/builders/spec/reduce.go @@ -15,10 +15,9 @@ import ( "github.com/go-openapi/swag/mangling" ) -// reduceDefinitionNames is the final stage of Build: it shortens the -// fully-qualified, compiler-unique definition keys produced during -// discovery ("/", see EntityDecl.DefKey) back to -// user-facing names, then re-points every $ref accordingly. +// reduceDefinitionNames is the final stage of Build: it shortens the fully-qualified, +// compiler-unique definition keys produced during discovery ("/", see +// EntityDecl.DefKey) back to user-facing names, then re-points every $ref accordingly. // // The name ladder (see .claude/plans/name-identity-cyclic-ref.md §9.2/§12): // @@ -37,13 +36,13 @@ import ( // (placeHierarchical); by default the always-correct flat concat is // kept (the concat deepens to the full path if needed). // -// The whole pass is a pure function of the definition key set, so the -// output is deterministic regardless of discovery / map-iteration order -// (G4): the previous silent merge was the only source of non-determinism -// and it is gone now that distinct types get distinct keys. -// reduceDefinitionNames returns the old->new rename map it applied, so the -// caller can re-point buffered provenance anchors to the final names -// (FlushDefOrigins). A nil/empty map means no definition was renamed. +// The whole pass is a pure function of the definition key set, so the output is deterministic +// regardless of discovery / map-iteration order (G4): the previous silent merge was the only source +// of non-determinism and it is gone now that distinct types get distinct keys. +// reduceDefinitionNames returns the old->new rename map it applied, so the caller can re-point +// buffered provenance anchors to the final names (FlushDefOrigins). +// +// A nil/empty map means no definition was renamed. func (s *Builder) reduceDefinitionNames() map[string]string { if len(s.input.Definitions) == 0 { return nil @@ -62,15 +61,18 @@ func (s *Builder) reduceDefinitionNames() map[string]string { return renames } -// diagnoseRenames emits a scan.renamed-definition Hint for every definition the -// reduce stage renamed to deconflict a cross-package collision. The trivial -// lift of a globally-unique leaf to its bare name is not a rename and is -// skipped (final == leaf). Unlike the positionless group Warning -// (diagnoseCollision), each Hint carries the source position of the originating -// Go type — captured during the build in declPos — so a source<->spec consumer -// (the genspec TUI) can follow a renamed type back to its declaration. No -// provenance is emitted here: the renamed node's anchor is the normal flushed -// one under its final name (see FlushDefOrigins). +// diagnoseRenames emits a scan.renamed-definition Hint for every definition the reduce stage +// renamed to deconflict a cross-package collision. +// +// The trivial lift of a globally-unique leaf to its bare name is not a rename and is skipped (final +// == leaf). +// +// Unlike the positionless group Warning (diagnoseCollision), each Hint carries the source position +// of the originating Go type — captured during the build in declPos — so a source<->spec +// consumer (the genspec TUI) can follow a renamed type back to its declaration. +// +// No provenance is emitted here: the renamed node's anchor is the normal flushed one under its +// final name (see FlushDefOrigins). func (s *Builder) diagnoseRenames(renames map[string]string) { onDiag := s.ctx.OnDiagnostic() if onDiag == nil { @@ -96,16 +98,16 @@ func (s *Builder) diagnoseRenames(renames map[string]string) { } } -// computeNameReductions groups definition keys by their leaf name (the -// segment after the last '/') and returns the rename map old->new: +// computeNameReductions groups definition keys by their leaf name (the segment after the last '/') +// and returns the rename map old->new: // // - a unique leaf is lifted to the bare leaf; // - a colliding leaf is resolved by resolveCollisionGroup (concat). // -// Names are reserved in a global "taken" set so a concat can never shadow -// a bare name or another group's chosen name (W4). Collision groups are -// processed in sorted order so the result is a pure function of the key -// set (G4). +// Names are reserved in a global "taken" set so a concat can never shadow a bare name or another +// group's chosen name (W4). +// Collision groups are processed in sorted order so the result is a pure function of the key set +// (G4). func (s *Builder) computeNameReductions() map[string]string { groups := make(map[string][]string, len(s.input.Definitions)) for key := range s.input.Definitions { @@ -116,8 +118,8 @@ func (s *Builder) computeNameReductions() map[string]string { renames := make(map[string]string, len(groups)) taken := make(map[string]struct{}, len(groups)) - // Pass 1: unique leaves -> bare; reserve every bare name first so the - // concat pass (which must avoid them) sees the full reserved set. + // Pass 1: unique leaves -> bare; reserve every bare name first so the concat pass (which must + // avoid them) sees the full reserved set. collisions := make([]string, 0) for leaf, keys := range groups { if len(keys) == 1 { @@ -131,9 +133,8 @@ func (s *Builder) computeNameReductions() map[string]string { } sort.Strings(collisions) - // Pass 2: colliding leaves -> minimal-depth concat, or the - // hierarchical fail-safe when the best flat concat is over budget and - // the caller opted in. + // Pass 2: colliding leaves -> minimal-depth concat, or the hierarchical fail-safe when the best + // flat concat is over budget and the caller opted in. mangler := mangling.NewNameMangler() budget := s.concatBudget() hierarchical := s.ctx.EmitHierarchicalNames() @@ -164,9 +165,8 @@ func (s *Builder) computeNameReductions() map[string]string { return renames } -// concatBudget resolves the effective readability budget: the -// caller-supplied Options.NameConcatBudget, or the built-in default when -// the caller left it at its zero value. +// concatBudget resolves the effective readability budget: the caller-supplied +// Options.NameConcatBudget, or the built-in default when the caller left it at its zero value. func (s *Builder) concatBudget() float64 { budget := s.ctx.NameConcatBudget() if budget <= 0 { @@ -175,17 +175,16 @@ func (s *Builder) concatBudget() float64 { return budget } -// resolveCollisionGroup picks a globally-unique PascalCase concat name -// for every key sharing a leaf. It tries increasing parent depth (leaf, -// leaf+1 parent, leaf+2 parents, …) and accepts the shallowest depth at -// which every name in the group is mutually distinct AND free of the -// taken set. If even the full path is not enough (ToGoName is not -// injective on arbitrary paths), a deterministic numeric suffix breaks -// the remaining tie. +// resolveCollisionGroup picks a globally-unique PascalCase concat name for every key sharing a +// leaf. +// +// It tries increasing parent depth (leaf, leaf+1 parent, leaf+2 parents, …) and accepts the +// shallowest depth at which every name in the group is mutually distinct AND free of the taken set. +// If even the full path is not enough (ToGoName is not injective on arbitrary paths), a +// deterministic numeric suffix breaks the remaining tie. // -// It also returns the group's worst (least-readable) concatScore, which -// the caller checks against the configured budget — the seam for the -// hierarchical fallback (Stage 3 / K3). +// It also returns the group's worst (least-readable) concatScore, which the caller checks against +// the configured budget — the seam for the hierarchical fallback (Stage 3 / K3). func resolveCollisionGroup(keys []string, taken map[string]struct{}, m *mangling.NameMangler) (map[string]string, float64) { segs := make(map[string][]string, len(keys)) maxDepth := 0 @@ -239,33 +238,35 @@ func resolveCollisionGroup(keys []string, taken map[string]struct{}, m *mangling return cand, groupWorstScore(keys, cand, segs, maxDepth) } -// concatName PascalCase-concatenates the leaf with its d nearest parent -// segments (clamped to the available depth). +// concatName PascalCase-concatenates the leaf with its d nearest parent segments (clamped to the +// available depth). func concatName(segs []string, d int, m *mangling.NameMangler) string { return m.ToGoName(strings.Join(concatParts(segs, d), " ")) } -// concatParts returns the leaf plus its d nearest parent segments -// (clamped to the available depth) — the raw words concatName joins. +// concatParts returns the leaf plus its d nearest parent segments (clamped to the available depth) +// — the raw words concatName joins. func concatParts(segs []string, d int) []string { start := max(len(segs)-d-1, 0) return segs[start:] } -// defaultNameConcatBudget is the readability cutoff used when the caller -// leaves Options.NameConcatBudget at its zero value. Empirically chosen -// (see concat_score_test.go): comfortably accepts the common 2-segment -// concats while fencing off long 3-segment ones. +// defaultNameConcatBudget is the readability cutoff used when the caller leaves +// Options.NameConcatBudget at its zero value. +// +// Empirically chosen (see concat_score_test.go): comfortably accepts the common 2-segment concats +// while fencing off long 3-segment ones. const defaultNameConcatBudget = 0.65 -// concatScore rates a deconflicted concat name in [0,1]: lower is more -// readable. It blends the three criteria — total length, part count and -// longest segment — each normalized to [0,1] against a budget, with -// weights summing to 1, so every criterion pushes the score UP as it -// grows. A 4+-part concat is always 1.0 (ruled out). +// concatScore rates a deconflicted concat name in [0,1]: lower is more readable. +// +// It blends the three criteria — total length, part count and longest segment — each normalized +// to [0,1] against a budget, with weights summing to 1, so every criterion pushes the score UP as +// it grows. +// A 4+-part concat is always 1.0 (ruled out). // -// The weights and budgets are the tuning knobs; the function is exercised -// against alternatives in the (disabled) concat_score_test.go harness. +// The weights and budgets are the tuning knobs; the function is exercised against alternatives in +// the (disabled) concat_score_test.go harness. func concatScore(concat string, parts []string) float64 { const ( maxParts = 3.00 @@ -301,8 +302,8 @@ func concatScore(concat string, parts []string) float64 { return min(scoreCeil, wLen*lenTerm+wParts*partsTerm+wWord*wordTerm) } -// groupWorstScore returns the least-readable (highest) concatScore across -// a resolved collision group, the value the budget is checked against. +// groupWorstScore returns the least-readable (highest) concatScore across a resolved collision +// group, the value the budget is checked against. func groupWorstScore(keys []string, cand map[string]string, segs map[string][]string, d int) float64 { worst := 0.0 for _, k := range keys { @@ -313,16 +314,15 @@ func groupWorstScore(keys []string, cand map[string]string, segs map[string][]st return worst } -// resolveHierarchicalGroup picks, per key, the nested container path -// [parent…, leaf] used for the hierarchical fail-safe — the same nearest -// package segments the flat concat would have joined, kept as raw nesting -// levels instead. It uses the shallowest depth at which every path is -// mutually distinct AND no root container clashes with an already-taken -// flat name; the full key path is the always-unique backstop. +// resolveHierarchicalGroup picks, per key, the nested container path [parent…, leaf] used for the +// hierarchical fail-safe — the same nearest package segments the flat concat would have joined, +// kept as raw nesting levels instead. // -// Unlike the flat rung this never needs ToGoName (the segments stay raw -// JSON keys), so distinct segment sequences always yield distinct -// pointers — no numeric-suffix fallback is required. +// It uses the shallowest depth at which every path is mutually distinct AND no root container +// clashes with an already-taken flat name; the full key path is the always-unique backstop. +// +// Unlike the flat rung this never needs ToGoName (the segments stay raw JSON keys), so distinct +// segment sequences always yield distinct pointers — no numeric-suffix fallback is required. func resolveHierarchicalGroup(keys []string, taken, containerRoots map[string]struct{}) map[string][]string { segs := make(map[string][]string, len(keys)) maxDepth := 0 @@ -359,8 +359,8 @@ func resolveHierarchicalGroup(keys []string, taken, containerRoots map[string]st } } - // Backstop: the full key path (pkg path + leaf) is unique by - // construction (DefKey is compiler-unique). + // Backstop: the full key path (pkg path + leaf) is unique by construction (DefKey is + // compiler-unique). cand := make(map[string][]string, len(keys)) for _, k := range keys { cand[k] = segs[k] @@ -368,11 +368,11 @@ func resolveHierarchicalGroup(keys []string, taken, containerRoots map[string]st return cand } -// diagnoseCollision reports a cross-package definition-name collision and -// the distinct names the reduce stage minted for it. Positionless: by -// reduce time the per-declaration source positions are gone (the spec is -// the only memory), and the collision is a property of the global name -// set, not a single source line. +// diagnoseCollision reports a cross-package definition-name collision and the distinct names the +// reduce stage minted for it. +// +// Positionless: by reduce time the per-declaration source positions are gone (the spec is the only +// memory), and the collision is a property of the global name set, not a single source line. func (s *Builder) diagnoseCollision(leaf string, keys []string, chosen map[string]string) { onDiag := s.ctx.OnDiagnostic() if onDiag == nil { @@ -392,10 +392,10 @@ func (s *Builder) diagnoseCollision(leaf string, keys []string, chosen map[strin leaf, len(keys), strings.Join(mappings, ", "))) } -// rekeyDefinitions moves each definition from its old key to its new -// name. A clash on the new name cannot occur for unique leaves; it is -// guarded defensively, skipping the rekey rather than silently -// overwriting. +// rekeyDefinitions moves each definition from its old key to its new name. +// +// A clash on the new name cannot occur for unique leaves; it is guarded defensively, skipping the +// rekey rather than silently overwriting. func (s *Builder) rekeyDefinitions(renames map[string]string) { for old, nw := range renames { if strings.Contains(nw, "/") { @@ -415,10 +415,10 @@ func (s *Builder) rekeyDefinitions(renames map[string]string) { const extGoPackage = "x-go-package" -// diagnoseHierarchical reports that a colliding name was emitted as nested -// container definitions because its flat concat was over budget, listing -// the nested pointer minted for each package. Positionless, like -// diagnoseCollision. +// diagnoseHierarchical reports that a colliding name was emitted as nested container definitions +// because its flat concat was over budget, listing the nested pointer minted for each package. +// +// Positionless, like diagnoseCollision. func (s *Builder) diagnoseHierarchical(leaf string, keys []string, paths map[string][]string) { onDiag := s.ctx.OnDiagnostic() if onDiag == nil { @@ -439,14 +439,15 @@ func (s *Builder) diagnoseHierarchical(leaf string, keys []string, paths map[str leaf, len(keys), strings.Join(mappings, ", "))) } -// placeHierarchical realises the nested-container definitions for the -// renames whose target is a slash path (produced by -// resolveHierarchicalGroup). For each, it moves the model schema from its -// flat key into definitions[]…ExtraProps[], creating the -// container chain (additionalProperties:true, plus x-go-package on the -// innermost container) and merging into any container shared with another -// model. The matching $refs were already pointed at the slash path by the -// shared ref rewrite. +// placeHierarchical realises the nested-container definitions for the renames whose target is a +// slash path (produced by resolveHierarchicalGroup). +// +// For each, it moves the model schema from its flat key into +// definitions[]…ExtraProps[], creating the container chain +// (additionalProperties:true, plus x-go-package on the innermost container) and merging into any +// container shared with another model. +// +// The matching $refs were already pointed at the slash path by the shared ref rewrite. func (s *Builder) placeHierarchical(renames map[string]string) { skipExt := s.ctx.SkipExtensions() for old, nw := range renames { @@ -466,11 +467,11 @@ func (s *Builder) placeHierarchical(renames map[string]string) { } } -// placeNested inserts model at definitions[path[0]]…[path[n-1]] where the -// last segment is the model's leaf key and the earlier segments are -// container schemas; pkgPath is stamped (unless skipExt) on the innermost -// container. Existing containers are merged into, not overwritten, so two -// models sharing a container coexist. +// placeNested inserts model at definitions[path[0]]…[path[n-1]] where the last segment is the +// model's leaf key and the earlier segments are container schemas; pkgPath is stamped (unless +// skipExt) on the innermost container. +// +// Existing containers are merged into, not overwritten, so two models sharing a container coexist. func placeNested(defs oaispec.Definitions, path []string, model oaispec.Schema, pkgPath string, skipExt bool) { root := path[0] container := defs[root] // zero Schema when absent @@ -484,8 +485,8 @@ func placeNested(defs oaispec.Definitions, path []string, model oaispec.Schema, defs[root] = container } -// placeNestedExtra recurses into parent's ExtraProps to realise the -// remaining container chain. path is [container…, leaf] with len >= 2. +// placeNestedExtra recurses into parent's ExtraProps to realise the remaining container chain. path +// is [container…, leaf] with len >= 2. func placeNestedExtra(parent *oaispec.Schema, path []string, model oaispec.Schema, pkgPath string, skipExt bool) { head := path[0] child := childSchema(parent, head) @@ -499,8 +500,8 @@ func placeNestedExtra(parent *oaispec.Schema, path []string, model oaispec.Schem setContainerChild(parent, head, child) } -// ensureContainer marks a schema as a lenient container (W2: additional -// properties cover the nested model so validation stays permissive). +// ensureContainer marks a schema as a lenient container (W2: additional properties cover the nested +// model so validation stays permissive). func ensureContainer(s *oaispec.Schema) { if s.AdditionalProperties == nil { s.AdditionalProperties = &oaispec.SchemaOrBool{Allows: true} @@ -514,8 +515,8 @@ func stampGoPackage(s *oaispec.Schema, pkgPath string, skipExt bool) { s.AddExtension(extGoPackage, pkgPath) } -// setContainerChild stores child under key in parent's ExtraProps — the -// carrier go-openapi/spec uses for nested-definition members (see W2). +// setContainerChild stores child under key in parent's ExtraProps — the carrier go-openapi/spec +// uses for nested-definition members (see W2). func setContainerChild(parent *oaispec.Schema, key string, child oaispec.Schema) { if parent.ExtraProps == nil { parent.ExtraProps = map[string]any{} @@ -523,8 +524,8 @@ func setContainerChild(parent *oaispec.Schema, key string, child oaispec.Schema) parent.ExtraProps[key] = child } -// childSchema returns the existing container stored under key in parent's -// ExtraProps, or a zero Schema when absent / of another type. +// childSchema returns the existing container stored under key in parent's ExtraProps, or a zero +// Schema when absent / of another type. func childSchema(parent *oaispec.Schema, key string) oaispec.Schema { if parent.ExtraProps != nil { if existing, ok := parent.ExtraProps[key].(oaispec.Schema); ok { @@ -534,8 +535,8 @@ func childSchema(parent *oaispec.Schema, key string) oaispec.Schema { return oaispec.Schema{} } -// leafName returns the segment of a definition key after the last '/', -// or the whole key when there is none. +// leafName returns the segment of a definition key after the last '/', or the whole key when there +// is none. func leafName(key string) string { if i := strings.LastIndex(key, "/"); i >= 0 { return key[i+1:] @@ -543,9 +544,8 @@ func leafName(key string) string { return key } -// repointer returns a function that rewrites a "#/definitions/" -// reference to "#/definitions/" when is in the rename map, -// and reports whether it changed. +// repointer returns a function that rewrites a "#/definitions/" reference to +// "#/definitions/" when is in the rename map, and reports whether it changed. func repointer(renames map[string]string) func(oaispec.Ref) (oaispec.Ref, bool) { const prefix = "#/definitions/" return func(ref oaispec.Ref) (oaispec.Ref, bool) { @@ -565,20 +565,21 @@ func repointer(renames map[string]string) func(oaispec.Ref) (oaispec.Ref, bool) } } -// rewriteAllRefs walks every schema-bearing node of the document and -// repoints its $ref in place. It covers the locations codescan actually -// emits definition refs: definitions, shared parameters/responses -// schemas, and per-operation parameter / response schemas. Parameter, -// response and path-item self-$refs are skipped — codescan never points -// those at #/definitions. (Walk structure mirrors go-openapi/analysis's -// analyzer, minus the parts we never emit.) +// rewriteAllRefs walks every schema-bearing node of the document and repoints its $ref in place. +// +// It covers the locations codescan actually emits definition refs: definitions, shared +// parameters/responses schemas, and per-operation parameter / response schemas. +// +// Parameter, response and path-item self-$refs are skipped — codescan never points those at +// #/definitions. (Walk structure mirrors go-openapi/analysis's analyzer, minus the parts we never +// emit.) func rewriteAllRefs(sw *oaispec.Swagger, repoint func(oaispec.Ref) (oaispec.Ref, bool)) { for k, v := range sw.Definitions { rewriteSchemaRefs(&v, repoint) sw.Definitions[k] = v } - // Shared parameters / responses carry their schema by pointer, so - // mutating through it needs no write-back to the map. + // Shared parameters / responses carry their schema by pointer, so mutating through it needs no + // write-back to the map. for _, p := range sw.Parameters { rewriteSchemaRefs(p.Schema, repoint) } @@ -612,22 +613,20 @@ func rewriteAllRefs(sw *oaispec.Swagger, repoint func(oaispec.Ref) (oaispec.Ref, } } -// operationsOf returns the (possibly nil) operation pointers of a path -// item, in a fixed order. +// operationsOf returns the (possibly nil) operation pointers of a path item, in a fixed order. func operationsOf(pi oaispec.PathItem) []*oaispec.Operation { return []*oaispec.Operation{ pi.Get, pi.Put, pi.Post, pi.Delete, pi.Options, pi.Head, pi.Patch, } } -// rewriteSchemaRefs repoints the $ref on sch and recurses into every -// sub-schema. Schema-by-value containers (Properties, nested -// Definitions, …) are written back after mutation; pointer- and -// slice-addressable containers are mutated in place. +// rewriteSchemaRefs repoints the $ref on sch and recurses into every sub-schema. +// +// Schema-by-value containers (Properties, nested Definitions, …) are written back after mutation; +// pointer- and slice-addressable containers are mutated in place. // -// nil-guards and the SchemaOrArray (single vs tuple) / SchemaOrBool -// (.Schema may be nil) handling follow the quirks documented in -// go-openapi/analysis. +// nil-guards and the SchemaOrArray (single vs tuple) / SchemaOrBool (.Schema may be nil) handling +// follow the quirks documented in go-openapi/analysis. func rewriteSchemaRefs(sch *oaispec.Schema, repoint func(oaispec.Ref) (oaispec.Ref, bool)) { if sch == nil { return diff --git a/internal/builders/spec/reduce_test.go b/internal/builders/spec/reduce_test.go index 964e2830..b0397985 100644 --- a/internal/builders/spec/reduce_test.go +++ b/internal/builders/spec/reduce_test.go @@ -9,9 +9,10 @@ import ( "github.com/go-openapi/testify/v2/assert" ) -// TestConcatScore locks the production readability score (reduce.go) that the -// name-identity concat budget is checked against. The exploration harness in -// concat_score_test.go (disabled) re-challenges it; this test pins the +// TestConcatScore locks the production readability score (reduce.go) that the name-identity concat +// budget is checked against. +// +// The exploration harness in concat_score_test.go (disabled) re-challenges it; this test pins the // contract K3's hierarchical fallback will rely on. func TestConcatScore(t *testing.T) { t.Run("empty is perfectly readable", func(t *testing.T) { @@ -40,9 +41,8 @@ func TestConcatScore(t *testing.T) { }) } -// TestResolveHierarchicalGroup locks the nested-path resolution logic the -// integration fixture doesn't reach: depth-deepening on a shared parent, and -// the taken-vs-shareable root distinction. +// TestResolveHierarchicalGroup locks the nested-path resolution logic the integration fixture +// doesn't reach: depth-deepening on a shared parent, and the taken-vs-shareable root distinction. func TestResolveHierarchicalGroup(t *testing.T) { none := func() map[string]struct{} { return map[string]struct{}{} } @@ -74,8 +74,8 @@ func TestResolveHierarchicalGroup(t *testing.T) { }) } -// TestConcatScoreVersusDefaultBudget shows the default budget cleanly separates -// the common short concats (accepted) from the long ones (Stage-3 candidates). +// TestConcatScoreVersusDefaultBudget shows the default budget cleanly separates the common short +// concats (accepted) from the long ones (Stage-3 candidates). func TestConcatScoreVersusDefaultBudget(t *testing.T) { accepted := concatScore("BWidget", []string{"b", "widget"}) assert.Less(t, accepted, defaultNameConcatBudget, diff --git a/internal/builders/spec/spec.go b/internal/builders/spec/spec.go index 3a6f9a8c..babf5893 100644 --- a/internal/builders/spec/spec.go +++ b/internal/builders/spec/spec.go @@ -11,7 +11,9 @@ import ( "iter" "sort" "strconv" + "strings" + "github.com/go-openapi/codescan/internal/builders/godoclink" "github.com/go-openapi/codescan/internal/builders/operations" "github.com/go-openapi/codescan/internal/builders/parameters" "github.com/go-openapi/codescan/internal/builders/responses" @@ -22,10 +24,12 @@ import ( oaispec "github.com/go-openapi/spec" ) -// ErrInternalPanic is the base error for a builder panic recovered while -// processing a single declaration. Rather than surfacing a raw Go stack -// trace, the scan names the offending source declaration (file:line) and -// aborts with this error. See go-swagger/go-swagger#2886. +// ErrInternalPanic is the base error for a builder panic recovered while processing a single +// declaration. +// +// Rather than surfacing a raw Go stack trace, the scan names the offending source declaration +// (file:line) and aborts with this error. +// See go-swagger/go-swagger#2886. var ErrInternalPanic = errors.New("internal panic during build") type Builder struct { @@ -35,12 +39,33 @@ type Builder struct { discovered []*scanner.EntityDecl definitions map[string]oaispec.Schema responses map[string]oaispec.Response + parameters map[string]oaispec.Parameter operations map[string]*oaispec.Operation - // declPos records the source position of each discovered definition, keyed - // by its fully-qualified DefKey, so the prune (scan.pruned-unused) and - // rename (scan.renamed-definition) Hints can be located at the originating - // Go type even though the spec node may by then be gone or renamed. + // paramRefIntents are `swagger:parameters * opid …` references collected during buildParameters + // and applied (as #/parameters/{name} $refs into operations) once the shared map is complete. + // + // See applyParameterRefs. + paramRefIntents []paramRefIntent + // pathItemInlines are `swagger:parameters /path` inline registrations collected during + // buildParameters and applied to PathItem.Parameters once all paths exist. + // + // See applyPathItemParameters. + pathItemInlines []pathItemInlineIntent + // declPos records the source position of each discovered definition, keyed by its fully-qualified + // DefKey, so the prune (scan.pruned-unused) and rename (scan.renamed-definition) Hints can be + // located at the originating Go type even though the spec node may by then be gone or renamed. declPos map[string]token.Position + // sharedParamPos / sharedRespPos record the source position of each scanned shared parameter / + // response, keyed by its registered name, so the shared prune (scan.pruned-unused, C4) can locate + // its Hint at the originating Go declaration. + sharedParamPos map[string]token.Position + sharedRespPos map[string]token.Position + // pinnedParams / pinnedResponses are the shared parameters / responses supplied via InputSpec + // (present before the scan). + // + // They are pinned: never pruned, mirroring the definitions rule. + pinnedParams map[string]struct{} + pinnedResponses map[string]struct{} } func NewBuilder(input *oaispec.Swagger, sc *scanner.ScanCtx, scanModels bool) *Builder { @@ -58,25 +83,44 @@ func NewBuilder(input *oaispec.Swagger, sc *scanner.ScanCtx, scanModels bool) *B if input.Responses == nil { input.Responses = make(map[string]oaispec.Response) } + if input.Parameters == nil { + input.Parameters = make(map[string]oaispec.Parameter) + } if input.Extensions == nil { input.Extensions = make(oaispec.Extensions) } + // Snapshot the InputSpec-supplied shared parameters / responses before the scan adds any: these + // are pinned (never pruned by C4). + pinnedParams := make(map[string]struct{}, len(input.Parameters)) + for name := range input.Parameters { + pinnedParams[name] = struct{}{} + } + pinnedResponses := make(map[string]struct{}, len(input.Responses)) + for name := range input.Responses { + pinnedResponses[name] = struct{}{} + } + return &Builder{ - ctx: sc, - input: input, - scanModels: scanModels, - operations: collectOperationsFromInput(input), - definitions: input.Definitions, - responses: input.Responses, - declPos: make(map[string]token.Position), + ctx: sc, + input: input, + scanModels: scanModels, + operations: collectOperationsFromInput(input), + definitions: input.Definitions, + responses: input.Responses, + parameters: input.Parameters, + declPos: make(map[string]token.Position), + sharedParamPos: make(map[string]token.Position), + sharedRespPos: make(map[string]token.Position), + pinnedParams: pinnedParams, + pinnedResponses: pinnedResponses, } } func (s *Builder) Build() (*oaispec.Swagger, error) { - // Resolve same-package duplicate swagger:model names up front so that - // every later DefKey() / MakeRef() observes a consistent, conflict-free - // key for each declaration (D-4). Must run before anything emits a ref. + // Resolve same-package duplicate swagger:model names up front so that every later DefKey() / + // MakeRef() observes a consistent, conflict-free key for each declaration (D-4). + // Must run before anything emits a ref. s.resolveSamePackageDuplicates() // this initial scan step is skipped if !scanModels. @@ -89,6 +133,10 @@ func (s *Builder) Build() (*oaispec.Swagger, error) { return nil, err } + // Wire shared-parameter references (#/parameters/{name} $refs) into operations now that the + // top-level #/parameters map is complete, and before buildRoutes attaches operations to paths. + s.applyParameterRefs() + if err := s.buildResponses(); err != nil { return nil, err } @@ -106,34 +154,54 @@ func (s *Builder) Build() (*oaispec.Swagger, error) { return nil, err } + // Apply path-item parameters now that all paths exist (they are created by buildRoutes / + // buildOperations). + // `swagger:parameters /path` inlines fields into the path-item; `swagger:parameters /path name` + // adds a #/parameters/{name} $ref. + s.applyPathItemParameters() + + // Validate shared-namespace references across all paths now that the #/parameters and #/responses + // maps are complete. + // Catches dangling refs from swagger:operation wholesale-YAML bodies (which unmarshal verbatim) + // and acts as a uniform safety net; a dangling ref is dropped, never emitted. + s.validateSharedRefs() + if err := s.buildMeta(); err != nil { return nil, err } - // Cross-ref linkage: parameter anchors are resolved here, once paths, - // methods and final array indices are all known (see emitParameterAnchors). - // Param anchors live under /paths/.../parameters/{i}, independent of the - // definition-name reduction below. + // Cross-ref linkage: parameter anchors are resolved here, once paths, methods and final array + // indices are all known (see emitParameterAnchors). + // Param anchors live under /paths/.../parameters/{i}, independent of the definition-name reduction + // below. s.emitParameterAnchors() - // Prune discovered definitions not reachable from a root, when the caller - // opted in (PruneUnusedModels). Runs before name reduction so an unused - // model cannot force a spurious collision rename on a used one. See - // .claude/plans/prune-unused-models.md. + // Prune discovered definitions not reachable from a root, when the caller opted in + // (PruneUnusedModels). + // Runs before name reduction so an unused model cannot force a spurious collision rename on a used + // one. + // + // See .claude/plans/prune-unused-models.md. s.pruneUnusedModels() - // Final stage: shorten the fully-qualified, collision-proof - // definition keys produced during discovery back to user-facing - // names and re-point every $ref. Runs last because buildRoutes / - // buildOperations also emit definition refs. See - // .claude/plans/name-identity-cyclic-ref.md §9/§12. + // Fire the deferred shared-response provenance anchors buffered during buildResponses (only when + // PruneUnusedModels + OnProvenance are both on); anchors of responses dropped by the prune above + // were already discarded, so none dangle. + // A no-op otherwise. + s.ctx.FlushDeferredOrigins() + + // Final stage: shorten the fully-qualified, collision-proof definition keys produced during + // discovery back to user-facing names and re-point every $ref. + // Runs last because buildRoutes / buildOperations also emit definition refs. + // See .claude/plans/name-identity-cyclic-ref.md §9/§12. renames := s.reduceDefinitionNames() - // Definition provenance was buffered under each definition's fully-qualified - // key while building (BeginDefOrigins); now that names are final, re-point - // every buffered anchor to its final name and emit it. Anchors for pruned - // definitions were already dropped, so none dangle. See - // .claude/plans/prune-unused-models.md. + // Definition provenance was buffered under each definition's fully-qualified key while building + // (BeginDefOrigins); now that names are final, re-point every buffered anchor to its final name + // and emit it. + // Anchors for pruned definitions were already dropped, so none dangle. + // + // See .claude/plans/prune-unused-models.md. s.ctx.FlushDefOrigins(func(defKey string) string { if final, ok := renames[defKey]; ok { return final @@ -141,6 +209,16 @@ func (s *Builder) Build() (*oaispec.Swagger, error) { return defKey }) + // CleanGoDoc idiom recomposition: doc-links resolved at the consumption seam were encoded as + // markers carrying a definition key; now that names are final (post-reduce), substitute each + // marker for its schema's exposed name. + // A no-op when CleanGoDoc is off (no markers were emitted). + // + // See .claude/plans/features/godoc-filter-design.md §3. + if s.ctx.CleanGoDoc() { + s.substituteGodocMarkers(renames) + } + if s.input.Swagger == "" { s.input.Swagger = "2.0" } @@ -148,12 +226,12 @@ func (s *Builder) Build() (*oaispec.Swagger, error) { return s.input, nil } -// emitParameterAnchors fires the deferred /paths/{path}/{method}/parameters/{i} -// anchors. Parameters are built before their operation is bound to a path and -// before the parameter array order is final, so the parameters builder only -// captured (opid → name → position) via ScanCtx.RecordParamOrigin; here the -// absolute pointer is assembled from the finished paths tree. Finer parameter -// sub-nodes resolve to the parameter (or the operation) anchor. +// emitParameterAnchors fires the deferred /paths/{path}/{method}/parameters/{i} anchors. +// +// Parameters are built before their operation is bound to a path and before the parameter array +// order is final, so the parameters builder only captured (opid → name → position) via +// ScanCtx.RecordParamOrigin; here the absolute pointer is assembled from the finished paths tree. +// Finer parameter sub-nodes resolve to the parameter (or the operation) anchor. func (s *Builder) emitParameterAnchors() { if !s.ctx.OriginEnabled() || s.input.Paths == nil { return @@ -175,10 +253,10 @@ func (s *Builder) emitParameterAnchors() { } } -// operationsByMethod yields each populated (lowercase method → operation) slot -// of a path item, in a stable order. (Distinct from reduce.go's operationsOf, -// which returns the bare operation pointers for ref rewriting; here the method -// name is needed to build the /paths/{path}/{method}/... anchor pointer.) +// operationsByMethod yields each populated (lowercase method → operation) slot of a path item, in +// a stable order. (Distinct from reduce.go's operationsOf, which returns the bare operation +// pointers for ref rewriting; here the method name is needed to build the +// /paths/{path}/{method}/... anchor pointer.) func operationsByMethod(item *oaispec.PathItem) iter.Seq2[string, *oaispec.Operation] { return func(yield func(string, *oaispec.Operation) bool) { slots := []struct { @@ -204,13 +282,15 @@ func operationsByMethod(item *oaispec.PathItem) iter.Seq2[string, *oaispec.Opera } } -// guard runs a per-declaration build step under panic recovery. On panic it -// emits a located scan.internal-panic diagnostic naming the offending source -// (pos + what), then returns an aborting error wrapping ErrInternalPanic — so -// a builder bug surfaces as a clean, located failure instead of a raw stack -// trace (#2886). A non-panicking run is transparent. pos/what identify the -// declaration being built; the spec.Builder build loops wrap each per-decl -// step. Panics outside any decl loop are caught by the Run backstop. +// guard runs a per-declaration build step under panic recovery. +// +// On panic it emits a located scan.internal-panic diagnostic naming the offending source (pos + +// what), then returns an aborting error wrapping ErrInternalPanic — so a builder bug surfaces as +// a clean, located failure instead of a raw stack trace (#2886). +// +// A non-panicking run is transparent. pos/what identify the declaration being built; the +// spec.Builder build loops wrap each per-decl step. +// Panics outside any decl loop are caught by the Run backstop. func (s *Builder) guard(pos token.Position, what string, run func() error) (err error) { defer func() { if r := recover(); r != nil { @@ -225,8 +305,8 @@ func (s *Builder) guard(pos token.Position, what string, run func() error) (err return run() } -// declLabel names an EntityDecl for a diagnostic — the fully-qualified Go -// type when the package path is known, else the bare identifier. +// declLabel names an EntityDecl for a diagnostic — the fully-qualified Go type when the package +// path is known, else the bare identifier. func declLabel(d *scanner.EntityDecl) string { if d.Pkg != nil && d.Pkg.PkgPath != "" { return d.Pkg.PkgPath + "." + d.Ident.Name @@ -240,19 +320,18 @@ 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. + // 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 { - // Dedup by the fully-qualified identity key (pkgpath/name), - // matching the definitions-map key written by the schema - // builder. A cycle / re-discovery of the SAME decl resolves - // to the same key and is skipped (reuse); two distinct decls - // that merely share a short name now get distinct keys and - // both build. See name-identity design §9.1/§12.1. + // Dedup by the fully-qualified identity key (pkgpath/name), matching the definitions-map key + // written by the schema builder. + // A cycle / re-discovery of the SAME decl resolves to the same key and is skipped (reuse); two + // distinct decls that merely share a short name now get distinct keys and both build. + // + // See name-identity design §9.1/§12.1. key := d.DefKey() if _, alreadyDone := s.definitions[key]; alreadyDone { continue @@ -281,20 +360,21 @@ func (s *Builder) buildDiscoveredSchema(decl *scanner.EntityDecl) error { sb := schema.NewBuilder(s.ctx, decl) sb.SetDiscovered(s.discovered) - // Stash the definition's source position for the prune / rename Hints, which - // fire after the spec node may have been dropped or renamed. + // Stash the definition's source position for the prune / rename Hints, which fire after the spec + // node may have been dropped or renamed. s.declPos[decl.DefKey()] = s.ctx.PosOf(decl.Ident.Pos()) - // Cross-ref linkage: initiate the base pointer for this definition so the - // schema builder path-joins its members (properties, …) under it, and anchor - // the definition node itself to its type declaration. + // Cross-ref linkage: initiate the base pointer for this definition so the schema builder + // path-joins its members (properties, …) under it, and anchor the definition node itself to its + // type declaration. + // + // The base uses the fully-qualified DefKey, not the user-facing name: the definition is keyed by + // DefKey throughout discovery and only renamed to its final name at the end of the build + // (reduceDefinitionNames), after a possible prune. // - // The base uses the fully-qualified DefKey, not the user-facing name: the - // definition is keyed by DefKey throughout discovery and only renamed to its - // final name at the end of the build (reduceDefinitionNames), after a - // possible prune. Anchors are buffered under DefKey (BeginDefOrigins) and - // re-pointed to the final name by FlushDefOrigins once names are settled, so - // every emitted pointer resolves against the final document. + // Anchors are buffered under DefKey (BeginDefOrigins) and re-pointed to the final name by + // FlushDefOrigins once names are settled, so every emitted pointer resolves against the final + // document. var defPtr string opts := []schema.Option{schema.WithDefinitions(s.definitions)} if s.ctx.OriginEnabled() { @@ -317,19 +397,31 @@ func (s *Builder) buildDiscoveredSchema(decl *scanner.EntityDecl) error { return nil } +// cleanGoDoc applies godoc-syntax filtering (Options.CleanGoDoc) to godoc- derived prose, returning +// text unchanged when the option is off. +// +// The spec builder owns its own ScanCtx (it does not embed common.Builder), so it carries a sibling +// of common.Builder.CleanGoDoc for the swagger:meta site. +func (s *Builder) cleanGoDoc(text string) string { + if !s.ctx.CleanGoDoc() { + return text + } + + return godoclink.Clean(text, godoclink.Options{Mangler: s.ctx.Mangler()}) +} + func (s *Builder) buildMeta() error { parser := grammar.NewParser(s.ctx.FileSet(), grammar.WithSingleLineCommentAsDescription(s.ctx.SingleLineCommentAsDescription())) for cg := range s.ctx.Meta() { block := parser.Parse(cg) - if err := applyMetaBlock(s.input, block); err != nil { + if err := applyMetaBlock(s.input, block, s.cleanGoDoc); err != nil { return err } - // Cross-ref linkage: anchor the info node to its swagger:meta block, - // then each meta keyword to its own line (the top-level fields — - // host/basePath/consumes/… — have no ancestor anchor otherwise, since - // /info is their sibling, not parent). + // Cross-ref linkage: anchor the info node to its swagger:meta block, then each meta keyword to + // its own line (the top-level fields — host/basePath/consumes/… — have no ancestor anchor + // otherwise, since /info is their sibling, not parent). if s.ctx.OriginEnabled() { s.ctx.RecordOrigin(scanner.JSONPointer("info"), s.ctx.PosOf(cg.Pos())) s.recordMetaOrigins(block) @@ -339,10 +431,10 @@ func (s *Builder) buildMeta() error { return nil } -// recordMetaOrigins anchors each meta keyword in block to its source line. The -// keyword→pointer knowledge lives in the grammar ([grammar.PointerPath]); meta -// pointers are absolute (Info.* under /info, the rest at the document root), so -// there is no base to prepend. +// recordMetaOrigins anchors each meta keyword in block to its source line. +// +// The keyword→pointer knowledge lives in the grammar ([grammar.PointerPath]); meta pointers are +// absolute (Info.* under /info, the rest at the document root), so there is no base to prepend. func (s *Builder) recordMetaOrigins(block grammar.Block) { for p := range block.Properties() { if p.ItemsDepth != 0 { @@ -392,21 +484,68 @@ func (s *Builder) buildRoutes() error { } func (s *Builder) buildResponses() error { - // build responses dictionary + // Order response declarations deterministically (package import path, then position) so the + // keep-first conflict winner does not depend on package-load order. + // + // Distinct names are independent, so the order does not otherwise affect output. + var decls []*scanner.EntityDecl for decl := range s.ctx.Responses() { + decls = append(decls, decl) + } + sort.Slice(decls, func(i, j int) bool { + pi, pj := declPkgPath(decls[i]), declPkgPath(decls[j]) + if pi != pj { + return pi < pj + } + a, b := s.ctx.PosOf(decls[i].Ident.Pos()), s.ctx.PosOf(decls[j].Ident.Pos()) + if a.Filename != b.Filename { + return a.Filename < b.Filename + } + return a.Offset < b.Offset + }) + + // scanned tracks the response short names already registered by a scanned struct in this pass. + // A later scanned struct with the same name is a keep-first conflict; an InputSpec (overlay) + // response of that name is NOT in the set, so a scanned struct still legitimately extends it. + scanned := make(map[string]string) + + // Under PruneUnusedModels a shared response may be pruned after the build (C4). + // Buffer its provenance anchors (top-level node + headers + inline body sub-anchors) so a pruned + // response leaves none dangling; survivors are flushed verbatim by FlushDeferredOrigins after the + // prune. + deferOrigins := s.ctx.OriginEnabled() && s.ctx.PruneUnusedModels() + + // build responses dictionary + for _, decl := range decls { if err := s.guard(s.ctx.PosOf(decl.Ident.Pos()), declLabel(decl), func() error { rb := responses.NewBuilder(s.ctx, decl) + name := rb.ResponseName() + + if kept, dup := scanned[name]; dup { + if onDiag := s.ctx.OnDiagnostic(); onDiag != nil { + onDiag(grammar.Warnf(s.ctx.PosOf(decl.Ident.Pos()), grammar.CodeSharedResponseConflict, + "shared response %q is already registered by %s; this declaration (%s) is dropped (keep-first)", + name, kept, declLabel(decl))) + } + return nil + } + + if deferOrigins && name != "" { + s.ctx.BeginDeferredOrigins(responseOriginKey(name)) + defer s.ctx.EndDeferredOrigins() + } + if err := rb.Build(s.responses); err != nil { return err } s.discovered = append(s.discovered, rb.PostDeclarations()...) + scanned[name] = declLabel(decl) + s.sharedRespPos[name] = s.ctx.PosOf(decl.Ident.Pos()) - // Cross-ref linkage: anchor the top-level response node to its - // swagger:response declaration; headers/body resolve to it. - if s.ctx.OriginEnabled() { - if name, _ := decl.ResponseNames(); name != "" { - s.ctx.RecordOrigin(scanner.JSONPointer("responses", name), s.ctx.PosOf(decl.Ident.Pos())) - } + // Cross-ref linkage: anchor the top-level response node to its swagger:response declaration; + // headers/body resolve to it. + if s.ctx.OriginEnabled() && name != "" { + s.ctx.RecordOrigin(scanner.JSONPointer("responses", name), s.ctx.PosOf(decl.Ident.Pos())) } return nil @@ -418,7 +557,28 @@ func (s *Builder) buildResponses() error { return nil } +// responseOriginKey is the deferred-provenance buffer key for a shared response (see +// ScanCtx.BeginDeferredOrigins). +// +// Keyed by the registered response name. +func responseOriginKey(name string) string { return "responses/" + name } + +// sharedParamCandidate is one `swagger:parameters *` registration awaiting merge into the top-level +// #/parameters map. +// +// Candidates are collected from every declaration first, then resolved in a deterministic order so +// the keep-first conflict winner does not depend on package-load order. +type sharedParamCandidate struct { + name string + param oaispec.Parameter + pkg string + pos token.Position + label string +} + func (s *Builder) buildParameters() error { + var sharedCandidates []sharedParamCandidate + // build parameters dictionary for decl := range s.ctx.Parameters() { if err := s.guard(s.ctx.PosOf(decl.Ident.Pos()), declLabel(decl), func() error { @@ -428,30 +588,485 @@ func (s *Builder) buildParameters() error { } s.discovered = append(s.discovered, pb.PostDeclarations()...) + pkg := declPkgPath(decl) + pos := s.ctx.PosOf(decl.Ident.Pos()) + for name, prm := range pb.SharedParameters() { + sharedCandidates = append(sharedCandidates, sharedParamCandidate{ + name: name, param: prm, pkg: pkg, pos: pos, label: declLabel(decl), + }) + } + + // `swagger:parameters * opid …` also references the struct's shared parameters into the listed + // operations. + // Collect the intents now; they are applied once the shared map is complete. + for _, op := range pb.SharedRefOperations() { + for name := range pb.SharedParameters() { + s.paramRefIntents = append(s.paramRefIntents, paramRefIntent{ + op: op, name: name, pos: pos, label: declLabel(decl), + }) + } + } + + // `swagger:parameters /path` inlines the struct's fields into a path-item. + // Collect now; applied after paths are built. + for path, params := range pb.PathItemParameters() { + s.pathItemInlines = append(s.pathItemInlines, pathItemInlineIntent{ + path: path, params: params, pkg: pkg, pos: pos, label: declLabel(decl), + }) + } + return nil }); err != nil { return err } } + s.registerSharedParameters(sharedCandidates) + return nil } -// resolveSamePackageDuplicates detects two distinct annotated models in -// the SAME package that claim the same definition name — necessarily via -// a `swagger:model ` override, since Go type names are unique per -// package. The first (in a deterministic order) keeps the name; later -// ones have their override suppressed (reverting to the Go type name) and -// get a diagnostic. This is the build-side half of D-4; cross-package -// same-name collisions are handled later by the reduce stage. See -// .claude/plans/name-identity-cyclic-ref.md §9.1/§12.1. +// registerSharedParameters merges the collected `swagger:parameters *` registrations into the +// spec's top-level #/parameters map. +// +// Shared parameters are referenced only by short name and are therefore never renamed (unlike +// definitions): on a duplicate short name the first registration is kept and the later one dropped +// with a CodeSharedParameterConflict warning (keep-first). +// InputSpec-supplied entries seed the map before the scan, so they win any collision. +// +// Candidates are resolved in a deterministic order — package import path, then source position, +// then parameter name — so the kept/dropped choice and the emitted diagnostic are stable +// regardless of package-load order. +func (s *Builder) registerSharedParameters(candidates []sharedParamCandidate) { + if len(candidates) == 0 { + return + } + + sort.Slice(candidates, func(i, j int) bool { + a, b := candidates[i], candidates[j] + if a.pkg != b.pkg { + return a.pkg < b.pkg + } + if a.pos.Filename != b.pos.Filename { + return a.pos.Filename < b.pos.Filename + } + if a.pos.Offset != b.pos.Offset { + return a.pos.Offset < b.pos.Offset + } + return a.name < b.name + }) + + owners := make(map[string]string, len(candidates)) + for _, c := range candidates { + if _, exists := s.parameters[c.name]; exists { + if onDiag := s.ctx.OnDiagnostic(); onDiag != nil { + kept := owners[c.name] + if kept == "" { + kept = "an overlay (InputSpec) definition" + } + onDiag(grammar.Warnf(c.pos, grammar.CodeSharedParameterConflict, + "shared parameter %q is already registered by %s; this declaration (%s) is dropped (keep-first)", + c.name, kept, c.label)) + } + continue + } + s.parameters[c.name] = c.param + owners[c.name] = c.label + s.sharedParamPos[c.name] = c.pos + } +} + +// declPkgPath returns the import path of the declaration's package, or "" when unknown. +func declPkgPath(d *scanner.EntityDecl) string { + if d.Pkg != nil { + return d.Pkg.PkgPath + } + return "" +} + +// paramRefIntent is one shared-parameter reference to apply as a #/parameters/{name} $ref on an +// operation. +type paramRefIntent struct { + op string + name string + pos token.Position + label string +} + +// applyParameterRefs wires shared-parameter references into operations as #/parameters/{name} +// $refs. +// +// Two sources feed it: `swagger:parameters * opid …` markers (collected in paramRefIntents during +// buildParameters) and standalone `swagger:parameters opid name …` reference markers on func +// declarations (ScanCtx.ParameterRefs, discovered by the scanner). +// +// A reference to an unregistered shared parameter is dropped with a scan.dangling-parameter-ref +// warning rather than emitting a dangling $ref. +// +// Runs after the top-level #/parameters map is complete and before buildRoutes attaches operations +// to paths. +// Intents are applied in a deterministic order (operation id, then parameter name). +func (s *Builder) applyParameterRefs() { + intents := append([]paramRefIntent(nil), s.paramRefIntents...) + intents = append(intents, s.collectStandaloneParameterRefs()...) + if len(intents) == 0 { + return + } + + sort.Slice(intents, func(i, j int) bool { + if intents[i].op != intents[j].op { + return intents[i].op < intents[j].op + } + return intents[i].name < intents[j].name + }) + + for _, in := range intents { + s.addParameterRef(in) + } +} + +// collectStandaloneParameterRefs turns each standalone `swagger:parameters` reference marker on a +// func (ScanCtx.ParameterRefs) into per-name ref intents. +// +// The marker's first token is the target operation id and the remaining tokens are shared-parameter +// names. +// Path-item references (`/path` target) are handled in a later phase. +// Duplicate names dropped by the grammar raise a duplicate-ref warning (C2). +func (s *Builder) collectStandaloneParameterRefs() []paramRefIntent { + var out []paramRefIntent + for ref := range s.ctx.ParameterRefs() { + pb := s.parametersBlockOf(ref.Comments) + if pb == nil || pb.Target != grammar.ParamTargetOperations { + continue + } + label := "a swagger:parameters reference" + if ref.Pkg != nil { + label = "a swagger:parameters reference in " + ref.Pkg.PkgPath + } + for _, dup := range pb.Dups { + if onDiag := s.ctx.OnDiagnostic(); onDiag != nil { + onDiag(grammar.Warnf(pb.Pos(), grammar.CodeDuplicateRef, + "swagger:parameters: duplicate reference %q dropped", dup)) + } + } + // Args[0] is the target operation id; Args[1:] are shared names. + // + // A reference needs at least a target plus one name. + const minReferenceTokens = 2 + if len(pb.Args) < minReferenceTokens { + continue + } + op := pb.Args[0] + for _, name := range pb.Args[1:] { + out = append(out, paramRefIntent{op: op, name: name, pos: pb.Pos(), label: label}) + } + } + return out +} + +// parametersBlockOf returns the first ParametersBlock parsed from cg, or nil. +// +// References are parsed straight from the grammar (the targeting parse lives there); the spec +// builder owns no parameters.Builder for a func-hosted marker. +func (s *Builder) parametersBlockOf(cg *ast.CommentGroup) *grammar.ParametersBlock { + for _, b := range grammar.NewParser(s.ctx.FileSet()).ParseAll(cg) { + if pb, ok := b.(*grammar.ParametersBlock); ok { + return pb + } + } + return nil +} + +// addParameterRef appends a #/parameters/{name} $ref to the target operation (creating the +// operation entry when absent), or drops the reference with a scan.dangling-parameter-ref warning +// when no such shared parameter is registered. +// +// The same $ref is never added twice to one operation. +func (s *Builder) addParameterRef(in paramRefIntent) { + if _, ok := s.parameters[in.name]; !ok { + if onDiag := s.ctx.OnDiagnostic(); onDiag != nil { + onDiag(grammar.Warnf(in.pos, grammar.CodeDanglingParameterRef, + "%s references shared parameter %q, but no #/parameters/%s is registered; dropped", + in.label, in.name, in.name)) + } + return + } + + op, ok := s.operations[in.op] + if !ok { + op = new(oaispec.Operation) + op.ID = in.op + s.operations[in.op] = op + } + + ref, err := oaispec.NewRef("#/parameters/" + in.name) + if err != nil { + return + } + for _, p := range op.Parameters { + if p.Ref.String() == ref.String() { + return + } + } + op.Parameters = append(op.Parameters, oaispec.Parameter{Refable: oaispec.Refable{Ref: ref}}) +} + +// pathItemInlineIntent is one `swagger:parameters /path` registration: the struct's fields, inlined +// into the named path-item. +type pathItemInlineIntent struct { + path string + params []oaispec.Parameter + pkg string + pos token.Position + label string +} + +// pathItemRefIntent is one `swagger:parameters /path name` reference: a #/parameters/{name} $ref +// added to the named path-item. +type pathItemRefIntent struct { + path string + name string + pos token.Position + label string +} + +// applyPathItemParameters applies path-item parameters once all paths exist. +// +// `swagger:parameters /path` inlines a struct's fields into the path-item; `swagger:parameters +// /path name` adds a #/parameters/{name} $ref. +// Per-path the inline parameters come first (ordered by package path then position), then the $refs +// (ordered by name). +// +// The path must already exist as a route/operation target (OAS2 has no path hierarchy — the match +// is exact); a reference to an unknown path or unregistered shared parameter is dropped with a +// warning. +// +// Operation-level parameters are left untouched: path-item and operation parameters co-exist (the +// operation one wins at resolution per OAS2). +func (s *Builder) applyPathItemParameters() { + if s.input.Paths == nil { + return + } + + // Accumulate the ordered parameter list to append per path. + perPath := map[string][]oaispec.Parameter{} + + inlines := append([]pathItemInlineIntent(nil), s.pathItemInlines...) + sort.Slice(inlines, func(i, j int) bool { + a, b := inlines[i], inlines[j] + if a.path != b.path { + return a.path < b.path + } + if a.pkg != b.pkg { + return a.pkg < b.pkg + } + if a.pos.Filename != b.pos.Filename { + return a.pos.Filename < b.pos.Filename + } + return a.pos.Offset < b.pos.Offset + }) + for _, in := range inlines { + perPath[in.path] = append(perPath[in.path], in.params...) + } + + refs := s.collectPathItemRefs() + sort.Slice(refs, func(i, j int) bool { + if refs[i].path != refs[j].path { + return refs[i].path < refs[j].path + } + return refs[i].name < refs[j].name + }) + onDiag := s.ctx.OnDiagnostic() + for _, r := range refs { + if _, ok := s.parameters[r.name]; !ok { + if onDiag != nil { + onDiag(grammar.Warnf(r.pos, grammar.CodeDanglingParameterRef, + "%s references shared parameter %q, but no #/parameters/%s is registered; dropped", + r.label, r.name, r.name)) + } + continue + } + ref, err := oaispec.NewRef("#/parameters/" + r.name) + if err != nil { + continue + } + if !containsParamRef(perPath[r.path], ref) { + perPath[r.path] = append(perPath[r.path], oaispec.Parameter{Refable: oaispec.Refable{Ref: ref}}) + } + } + + for path, params := range perPath { + pi, ok := s.input.Paths.Paths[path] + if !ok { + if onDiag != nil { + onDiag(grammar.Warnf(token.Position{}, grammar.CodeInvalidAnnotation, + "swagger:parameters %s names a path with no operations; path-item parameters dropped", path)) + } + continue + } + pi.Parameters = append(pi.Parameters, params...) + s.input.Paths.Paths[path] = pi + } +} + +// collectPathItemRefs turns each standalone `swagger:parameters /path name …` reference marker +// (ScanCtx.ParameterRefs with a path target) into per-name path-item ref intents. +// +// Duplicate names dropped by the grammar raise a duplicate-ref warning (C2). +func (s *Builder) collectPathItemRefs() []pathItemRefIntent { + var out []pathItemRefIntent + for ref := range s.ctx.ParameterRefs() { + pb := s.parametersBlockOf(ref.Comments) + if pb == nil || pb.Target != grammar.ParamTargetPath { + continue + } + label := "a swagger:parameters path reference" + if ref.Pkg != nil { + label = "a swagger:parameters path reference in " + ref.Pkg.PkgPath + } + for _, dup := range pb.Dups { + if onDiag := s.ctx.OnDiagnostic(); onDiag != nil { + onDiag(grammar.Warnf(pb.Pos(), grammar.CodeDuplicateRef, + "swagger:parameters: duplicate reference %q dropped", dup)) + } + } + // For a path target the path is pb.Path; every Arg is a shared name. + for _, name := range pb.Args { + out = append(out, pathItemRefIntent{path: pb.Path, name: name, pos: pb.Pos(), label: label}) + } + } + return out +} + +// containsParamRef reports whether params already holds a $ref to the same target. +func containsParamRef(params []oaispec.Parameter, ref oaispec.Ref) bool { + for _, p := range params { + if p.Ref.String() == ref.String() { + return true + } + } + return false +} + +// validateSharedRefs drops shared-namespace references that resolve to nothing — a +// #/parameters/{name} or #/responses/{name} $ref whose target is not registered. +// +// The main source is a swagger:operation wholesale-YAML body, which unmarshals its +// parameters/responses (and their $refs) verbatim; this pass validates them against the completed +// #/parameters and #/responses maps and drops danglers with a warning rather than leaving an +// invalid reference in the output. +// +// Valid refs (and non-shared refs such as #/definitions/…) are left untouched. +func (s *Builder) validateSharedRefs() { + if s.input.Paths == nil { + return + } + onDiag := s.ctx.OnDiagnostic() + + for path, pi := range s.input.Paths.Paths { + pi.Parameters = s.keepResolvableParamRefs(path, pi.Parameters, onDiag) + for _, op := range pathItemOperations(&pi) { + if op == nil { + continue + } + op.Parameters = s.keepResolvableParamRefs(path, op.Parameters, onDiag) + s.dropDanglingResponseRefs(path, op.Responses, onDiag) + } + s.input.Paths.Paths[path] = pi + } +} + +// pathItemOperations returns the operation pointers of a path-item (nil where a method is absent). +func pathItemOperations(pi *oaispec.PathItem) []*oaispec.Operation { + return []*oaispec.Operation{pi.Get, pi.Put, pi.Post, pi.Delete, pi.Options, pi.Head, pi.Patch} +} + +// keepResolvableParamRefs returns params with any dangling #/parameters/{name} $ref dropped (and +// warned). +// +// Non-ref and resolvable-ref parameters are preserved in order. +func (s *Builder) keepResolvableParamRefs(path string, params []oaispec.Parameter, onDiag func(grammar.Diagnostic)) []oaispec.Parameter { + kept := params[:0:0] + for _, p := range params { + if name, ok := sharedRefName(p.Ref, "#/parameters/"); ok { + if _, exists := s.parameters[name]; !exists { + if onDiag != nil { + onDiag(grammar.Warnf(token.Position{}, grammar.CodeDanglingParameterRef, + "path %s references #/parameters/%s, which is not registered; dropped", path, name)) + } + continue + } + } + kept = append(kept, p) + } + return kept +} + +// dropDanglingResponseRefs removes any dangling #/responses/{name} $ref from an operation's +// responses (default + per-status), warning on each. +func (s *Builder) dropDanglingResponseRefs(path string, resp *oaispec.Responses, onDiag func(grammar.Diagnostic)) { + if resp == nil { + return + } + if resp.Default != nil { + if name, dangling := s.danglingResponseRef(*resp.Default); dangling { + if onDiag != nil { + onDiag(grammar.Warnf(token.Position{}, grammar.CodeDanglingResponseRef, + "path %s default response references #/responses/%s, which is not registered; dropped", path, name)) + } + resp.Default = nil + } + } + for code, r := range resp.StatusCodeResponses { + if name, dangling := s.danglingResponseRef(r); dangling { + if onDiag != nil { + onDiag(grammar.Warnf(token.Position{}, grammar.CodeDanglingResponseRef, + "path %s response %d references #/responses/%s, which is not registered; dropped", path, code, name)) + } + delete(resp.StatusCodeResponses, code) + } + } +} + +// danglingResponseRef reports whether r is a #/responses/{name} $ref whose target is not +// registered, returning the unresolved name. +func (s *Builder) danglingResponseRef(r oaispec.Response) (string, bool) { + name, ok := sharedRefName(r.Ref, "#/responses/") + if !ok { + return "", false + } + if _, exists := s.responses[name]; exists { + return "", false + } + return name, true +} + +// sharedRefName returns the {name} suffix of a $ref of the form "{prefix}{name}" (e.g. prefix +// "#/parameters/"), and whether ref matched. +func sharedRefName(ref oaispec.Ref, prefix string) (string, bool) { + if after, ok := strings.CutPrefix(ref.String(), prefix); ok && after != "" { + return after, true + } + return "", false +} + +// resolveSamePackageDuplicates detects two distinct annotated models in the SAME package that claim +// the same definition name — necessarily via a `swagger:model ` override, since Go type +// names are unique per package. +// +// The first (in a deterministic order) keeps the name; later ones have their override suppressed +// (reverting to the Go type name) and get a diagnostic. +// This is the build-side half of D-4; cross-package same-name collisions are handled later by the +// reduce stage. +// +// See .claude/plans/name-identity-cyclic-ref.md §9.1/§12.1. func (s *Builder) resolveSamePackageDuplicates() { models := make([]*scanner.EntityDecl, 0) for _, d := range s.ctx.Models() { models = append(models, d) } - // Deterministic order so "first wins" is stable across runs: by key, - // then by Go name within a colliding group. + // Deterministic order so "first wins" is stable across runs: by key, then by Go name within a + // colliding group. sort.Slice(models, func(i, j int) bool { ki, kj := models[i].DefKey(), models[j].DefKey() if ki != kj { diff --git a/internal/builders/spec/walker.go b/internal/builders/spec/walker.go index cfe63869..0a51fac6 100644 --- a/internal/builders/spec/walker.go +++ b/internal/builders/spec/walker.go @@ -12,20 +12,23 @@ import ( "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. +// applyMetaBlock dispatches one parsed swagger:meta block into the matching *spec.Swagger fields. // -// 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 { +// 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, clean func(string) string) error { if swspec.Info == nil { swspec.Info = new(spec.Info) } - swspec.Info.Title = stripPackagePrefix(block.Title()) - swspec.Info.Description = block.Description() + // Title / Description are godoc-derived (swagger:meta has no override path), so they pass through + // the CleanGoDoc filter; the remaining meta fields are structured values, not prose, and are left + // untouched. + swspec.Info.Title = clean(stripPackagePrefix(block.Title())) + swspec.Info.Description = clean(block.Description()) for p := range block.Properties() { if p.ItemsDepth != 0 { @@ -71,10 +74,10 @@ func applyMetaBlock(swspec *spec.Swagger, block grammar.Block) error { 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, +// 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) { @@ -110,16 +113,14 @@ func dispatchMetaSimple(p grammar.Property, swspec *spec.Swagger) bool { return true } -// dispatchMetaYAMLBlock handles the keywords whose bodies are -// structurally YAML and not amenable to the flex-list union: -// securityDefinitions and externalDocs. extensions / infoExtensions -// ride grammar's typed Extensions surface (see applyMetaBlock — -// the block.Extensions() loop routes each entry by ext.Source). +// dispatchMetaYAMLBlock handles the keywords whose bodies are structurally YAML and not amenable to +// the flex-list union: securityDefinitions and externalDocs. extensions / infoExtensions ride +// grammar's typed Extensions surface (see applyMetaBlock — the block.Extensions() loop routes +// each entry by ext.Source). // -// The KwExternalDocs arm here sets the top-level spec.ExternalDocs for -// swagger:meta. The same keyword on route/operation/schema is emitted -// by their own builders (routes/walker.go, handlers.schemaRawHandler), -// and per-tag externalDocs rides the KwTags []spec.Tag unmarshal below. +// The KwExternalDocs arm here sets the top-level spec.ExternalDocs for swagger:meta. +// The same keyword on route/operation/schema is emitted by their own builders (routes/walker.go, +// handlers.schemaRawHandler), and per-tag externalDocs rides the KwTags []spec.Tag unmarshal below. func dispatchMetaYAMLBlock(p grammar.Property, swspec *spec.Swagger) error { switch p.Keyword.Name { case grammar.KwSecurityDefinitions: @@ -137,16 +138,16 @@ func dispatchMetaYAMLBlock(p grammar.Property, swspec *spec.Swagger) error { if err := json.Unmarshal(data, &d); err != nil { return err } - // Skip an empty/blank block so we don't emit a useless - // `externalDocs: {}` (the OAS object requires `url`). + // Skip an empty/blank block so we don't emit a useless `externalDocs: {}` (the OAS object + // requires `url`). if d != (spec.ExternalDocumentation{}) { swspec.ExternalDocs = &d } return nil }) case grammar.KwTags: - // `Tags:` is a YAML list of tag objects ({name, description, - // externalDocs, x-*}) → spec.Swagger.Tags (go-swagger#2655). + // `Tags:` is a YAML list of tag objects ({name, description, externalDocs, x-*}) → + // spec.Swagger.Tags (go-swagger#2655). return yamlparser.UnmarshalListBody(p.Body, func(data []byte) error { var tags []spec.Tag if err := json.Unmarshal(data, &tags); err != nil { @@ -159,8 +160,7 @@ func dispatchMetaYAMLBlock(p grammar.Property, swspec *spec.Swagger) error { return nil } -// bodyLines splits a grammar raw-block body into the []string shape -// the meta body parsers expect. +// 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 @@ -172,10 +172,10 @@ func bodyLines(body string) []string { 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. +// 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 { @@ -186,17 +186,16 @@ func joinNonBlank(lines []string) string { return strings.Join(out, "\n") } -// stripPackagePrefix shaves a leading `Package ` 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 pattern is not present. +// stripPackagePrefix shaves a leading `Package ` 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 pattern is not present. // -// Match shape: optional leading whitespace, then `Package` (capital -// P, the canonical godoc spelling — `package` lowercase rejected so -// authors writing prose like "package this carefully" don't get -// silently chopped), one or more spaces, the package identifier -// (any non-space run), then optional trailing whitespace. +// Match shape: optional leading whitespace, then `Package` (capital P, the canonical godoc spelling +// — `package` lowercase rejected so authors writing prose like "package this carefully" don't get +// silently chopped), one or more spaces, the package identifier (any non-space run), then optional +// trailing whitespace. func stripPackagePrefix(s string) string { rest, ok := strings.CutPrefix(strings.TrimLeft(s, " \t"), "Package ") if !ok { @@ -208,9 +207,8 @@ func stripPackagePrefix(s string) string { } idx := strings.IndexAny(rest, " \t") if idx < 0 { - // Title is exactly `Package ` with nothing after — - // preserve the original so the spec doesn't end up with an - // empty Title. + // Title is exactly `Package ` with nothing after — preserve the original so the spec + // doesn't end up with an empty Title. return s } return strings.TrimLeft(rest[idx:], " \t") diff --git a/internal/builders/validations/coerce.go b/internal/builders/validations/coerce.go index ba9bba14..ce363bf5 100644 --- a/internal/builders/validations/coerce.go +++ b/internal/builders/validations/coerce.go @@ -1,9 +1,8 @@ // 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, parameters, responses, and -// items/headers code paths. +// Package validations owns cross-builder validation and coercion concerns shared by the schema, +// parameters, responses, and items/headers code paths. // // The package has two halves: // @@ -16,8 +15,8 @@ // // # Details // -// See [§contract](./README.md#contract) — why these helpers live -// in the builder layer rather than in the grammar parser. +// See [§contract](./README.md#contract) — why these helpers live in the builder layer rather +// than in the grammar parser. package validations import ( @@ -28,24 +27,24 @@ import ( "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. +// CoerceValue converts a raw annotation value to the Go representation implied by the target +// schema's Type/Format. // -// A nil schema yields the raw string unchanged. Numeric and boolean -// parsing errors are surfaced to the caller; JSON parse failures on +// 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. +// +// A nil schema yields the raw string unchanged. +// Numeric and boolean parsing errors are surfaced to the caller; JSON parse failures on // object/array targets are absorbed and the raw string is returned. // -// Dispatch is on [spec.SimpleSchema.TypeName] — Format wins when set, -// otherwise Type is consulted. +// Dispatch is on [spec.SimpleSchema.TypeName] — Format wins when set, otherwise Type is +// consulted. // // # Details // -// See [§coercion-dispatch](./README.md#coercion-dispatch) — the -// per-type dispatch table, and the rationale for absorbing -// object/array JSON parse failures. +// See [§coercion-dispatch](./README.md#coercion-dispatch) — the per-type dispatch table, and the +// rationale for absorbing object/array JSON parse failures. func CoerceValue(s string, schema *spec.SimpleSchema) (any, error) { if schema == nil { return s, nil @@ -59,10 +58,9 @@ func CoerceValue(s string, schema *spec.SimpleSchema) (any, error) { case "number", "float64", "float32": return strconv.ParseFloat(s, 64) case "string": - // Surrounding quotes are delimiters, not part of the value: - // `example: ""` is the empty string, `example: "Foo"` is Foo - // (go-swagger#2547 / #2899, quirk F8). An unquoted value (`Foo`) - // is returned unchanged. + // Surrounding quotes are delimiters, not part of the value: `example: ""` is the empty string, + // `example: "Foo"` is Foo (go-swagger#2547 / #2899, quirk F8). + // An unquoted value (`Foo`) is returned unchanged. return unquoteIfQuoted(s), nil case "object": var obj map[string]any @@ -81,19 +79,21 @@ func CoerceValue(s string, schema *spec.SimpleSchema) (any, error) { } } -// CoerceJSONOrString coerces a default:/example: value whose target type -// is unknown — the case of a $ref override arm, which carries no Type of -// its own (the type lives on the referenced definition, not on the allOf -// sibling). A JSON object- or array-literal body is parsed structurally so -// it matches the direct-field path (quirk G3): `example: {"k":"v"}` yields -// an object on a $ref'd field just as it does on a plain map field. Any -// other value is treated as a string scalar, with surrounding double -// quotes stripped (mirroring [CoerceValue]'s "string" arm). +// CoerceJSONOrString coerces a default:/example: value whose target type is unknown — the case of +// a $ref override arm, which carries no Type of its own (the type lives on the referenced +// definition, not on the allOf sibling). +// +// A JSON object- or array-literal body is parsed structurally so it matches the direct-field path +// (quirk G3): `example: {"k":"v"}` yields an object on a $ref'd field just as it does on a plain +// map field. +// +// Any other value is treated as a string scalar, with surrounding double quotes stripped (mirroring +// [CoerceValue]'s "string" arm). // // Never errors: a malformed JSON literal falls back to the unquoted string. -// Scalar literals (numbers, booleans) are intentionally NOT coerced here — -// the referenced type is unknown, so a bare `example: 42` against a -// string-format $ref must not be silently turned into a number. +// Scalar literals (numbers, booleans) are intentionally NOT coerced here — the referenced type is +// unknown, so a bare `example: 42` against a string-format $ref must not be silently turned into a +// number. func CoerceJSONOrString(s string) any { if t := strings.TrimSpace(s); len(t) > 0 && (t[0] == '{' || t[0] == '[') { var v any @@ -104,11 +104,12 @@ func CoerceJSONOrString(s string) any { return unquoteIfQuoted(s) } -// unquoteIfQuoted strips a single pair of surrounding double quotes from -// a string annotation value, honouring Go/JSON escape sequences. A value -// that is not a double-quoted literal is returned unchanged, so a bare -// `example: Foo` stays Foo while `example: "Foo"` becomes Foo and -// `example: ""` becomes the empty string (go-swagger#2547 / #2899, F8). +// unquoteIfQuoted strips a single pair of surrounding double quotes from a string annotation value, +// honouring Go/JSON escape sequences. +// +// A value that is not a double-quoted literal is returned unchanged, so a bare `example: Foo` stays +// Foo while `example: "Foo"` becomes Foo and `example: ""` becomes the empty string +// (go-swagger#2547 / #2899, F8). func unquoteIfQuoted(s string) string { if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { if unq, err := strconv.Unquote(s); err == nil { @@ -118,39 +119,39 @@ func unquoteIfQuoted(s string) string { return s } -// ParseDefault is the two-axis entry point for default:/example: -// coercion. It dispatches on schemaType alone — schemaFormat is -// accepted for surface stability but not consulted today. +// ParseDefault is the two-axis entry point for default:/example: coercion. +// +// It dispatches on schemaType alone — schemaFormat is accepted for surface stability but not +// consulted today. // // # Details // // See [§coercion-dispatch](./README.md#coercion-dispatch) and -// [§format-axis](./README.md#format-axis) — why the two-axis -// surface exists alongside [CoerceValue] and which refinements -// the schemaFormat argument is reserved for. +// [§format-axis](./README.md#format-axis) — why the two-axis surface exists alongside +// [CoerceValue] and which refinements the schemaFormat argument is reserved for. func ParseDefault(s, schemaType, schemaFormat string) (any, error) { _ = schemaFormat // reserved for format-specific paths return CoerceValue(s, &spec.SimpleSchema{Type: schemaType}) } // ParseEnumValues is the two-axis entry point for enum: coercion. -// Mirrors [ParseDefault] — dispatches on schemaType, with -// schemaFormat reserved for future per-format paths. +// +// Mirrors [ParseDefault] — dispatches on schemaType, with schemaFormat reserved for future +// per-format paths. 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 schema. +// +// 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 schema. // // # Details // -// See [§enum-shapes](./README.md#enum-shapes) — how the two input -// forms are detected and how each element is normalised before -// per-value coercion. +// See [§enum-shapes](./README.md#enum-shapes) — how the two input forms are detected and how +// each element is normalised before per-value coercion. func CoerceEnum(val string, s *spec.SimpleSchema) []any { var rawElements []json.RawMessage if err := json.Unmarshal([]byte(val), &rawElements); err != nil { @@ -176,14 +177,16 @@ func CoerceEnum(val string, s *spec.SimpleSchema) []any { } // coerceEnumCommaList handles the comma-list `enum: a, b, c` form. -// Per-value whitespace is trimmed before coercion; per-value parse -// errors fall back to the raw string. -// -// The bracketed `enum: [a, b, c]` form also lands here when its values -// are unquoted (so [CoerceEnum]'s JSON-array parse fails — only the -// quoted `["a","b"]` / numeric `[1,2]` variants are valid JSON). The -// surrounding `[`/`]` are delimiters and must be stripped, else they -// glue onto the first/last value (go-swagger#2396). +// +// Per-value whitespace is trimmed before coercion; per-value parse errors fall back to the raw +// string. +// +// The bracketed `enum: [a, b, c]` form also lands here when its values are unquoted (so +// [CoerceEnum]'s JSON-array parse fails — only the quoted `["a","b"]` / numeric `[1,2]` variants +// are valid JSON). +// +// The surrounding `[`/`]` are delimiters and must be stripped, else they glue onto the first/last +// value (go-swagger#2396). func coerceEnumCommaList(val string, s *spec.SimpleSchema) []any { if v := strings.TrimSpace(val); len(v) >= 2 && v[0] == '[' && v[len(v)-1] == ']' { val = v[1 : len(v)-1] diff --git a/internal/builders/validations/coerce_test.go b/internal/builders/validations/coerce_test.go index 23bd9c67..4839bf4c 100644 --- a/internal/builders/validations/coerce_test.go +++ b/internal/builders/validations/coerce_test.go @@ -49,8 +49,8 @@ func TestCoerceValue_PrimitiveTypes(t *testing.T) { } func TestCoerceValue_StringStripsSurroundingQuotes(t *testing.T) { - // quirk F8 (go-swagger#2547 / #2899): surrounding quotes on a - // string-typed example/default are delimiters and must be stripped. + // quirk F8 (go-swagger#2547 / #2899): surrounding quotes on a string-typed example/default are + // delimiters and must be stripped. cases := []struct { name string raw string @@ -84,29 +84,27 @@ func TestCoerceValue_ObjectAndArrayUnmarshal(t *testing.T) { } func TestCoerceValue_InvalidJSONFallsBackToRaw(t *testing.T) { - // v1 quirk preserved: object/array parse failure returns raw, - // not error. + // 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. + // 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 TestCoerceJSONOrString(t *testing.T) { - // JSON object/array literals parse structurally (quirk G3: the - // $ref override arm has no Type to dispatch on). + // JSON object/array literals parse structurally (quirk G3: the $ref override arm has no Type to + // dispatch on). assert.Equal(t, map[string]any{"k": "v"}, validations.CoerceJSONOrString(`{"k":"v"}`)) assert.Equal(t, map[string]any{"name": "Rex"}, validations.CoerceJSONOrString(` {"name":"Rex"} `)) assert.Equal(t, []any{float64(1), float64(2)}, validations.CoerceJSONOrString(`[1,2]`)) @@ -115,14 +113,14 @@ func TestCoerceJSONOrString(t *testing.T) { // A malformed JSON literal falls back to the (unquoted) string. assert.Equal(t, "{not json", validations.CoerceJSONOrString("{not json")) - // Non-JSON scalars are left as plain strings; surrounding double - // quotes are stripped (mirroring CoerceValue's string arm). + // Non-JSON scalars are left as plain strings; surrounding double quotes are stripped (mirroring + // CoerceValue's string arm). assert.Equal(t, "plain-scalar", validations.CoerceJSONOrString("plain-scalar")) assert.Equal(t, "quoted", validations.CoerceJSONOrString(`"quoted"`)) assert.Equal(t, "", validations.CoerceJSONOrString(`""`)) - // Bare scalars are NOT coerced to numbers/booleans — the referenced - // type is unknown on the override arm. + // Bare scalars are NOT coerced to numbers/booleans — the referenced type is unknown on the + // override arm. assert.Equal(t, "42", validations.CoerceJSONOrString("42")) assert.Equal(t, "true", validations.CoerceJSONOrString("true")) } @@ -147,9 +145,10 @@ func TestCoerceEnum_CommaListWhitespaceTrimmed(t *testing.T) { assert.Equal(t, []any{"a", "b", "c"}, out) } -// go-swagger#2396: the bracketed comma-list form has unquoted values, so it -// is not valid JSON and falls to the comma-list path. The surrounding -// brackets are delimiters and must be stripped, matching the unbracketed form. +// go-swagger#2396: the bracketed comma-list form has unquoted values, so it is not valid JSON and +// falls to the comma-list path. +// +// The surrounding brackets are delimiters and must be stripped, matching the unbracketed form. func TestCoerceEnum_BracketedCommaList(t *testing.T) { want := []any{"issues", "pulls", "projects"} bracketed := validations.CoerceEnum(`[issues, pulls, projects]`, &spec.SimpleSchema{Type: "string"}) diff --git a/internal/builders/validations/format.go b/internal/builders/validations/format.go index 5afee5c6..2c33cb36 100644 --- a/internal/builders/validations/format.go +++ b/internal/builders/validations/format.go @@ -5,12 +5,12 @@ package validations import "fmt" -// IsFormatCompatible reports whether a `format` is meaningful on a schema -// whose resolved Swagger `type` is already fixed (e.g. by a `swagger:type` -// override). It answers the question "may this strfmt format ride on top of -// this type?" — the supplementary-format rule for the swagger:type + -// swagger:strfmt combination, where swagger:type wins on the type axis and -// the format applies only when it is consistent with that type. +// IsFormatCompatible reports whether a `format` is meaningful on a schema whose resolved Swagger +// `type` is already fixed (e.g. by a `swagger:type` override). +// +// It answers the question "may this strfmt format ride on top of this type?" — the +// supplementary-format rule for the swagger:type + swagger:strfmt combination, where swagger:type +// wins on the type axis and the format applies only when it is consistent with that type. // // Rules (see README §format-compat): // @@ -21,10 +21,10 @@ import "fmt" // float widths apply. // - any other type (boolean / object / array / file): no format applies. // -// An empty format is trivially compatible (there is nothing to apply). An -// empty/unknown schemaType is accepted best-effort, mirroring -// [IsLegalForType] — the caller has no type to check against. A false result -// carries a human-readable hint suitable for a diagnostic message. +// An empty format is trivially compatible (there is nothing to apply). +// An empty/unknown schemaType is accepted best-effort, mirroring [IsLegalForType] — the caller +// has no type to check against. +// A false result carries a human-readable hint suitable for a diagnostic message. func IsFormatCompatible(schemaType, format string) (ok bool, hint string) { if format == "" { return true, "" @@ -44,10 +44,9 @@ func IsFormatCompatible(schemaType, format string) (ok bool, hint string) { return false, fmt.Sprintf("format %q is not compatible with type %q", format, schemaType) } -// isIntegerFormat reports whether format is one of the integer-width -// spellings: the Go int widths (int, int8…int64) and the swagger-extension -// unsigned widths (uint, uint8…uint64). int32 / int64 are the only -// OAS-2-official integer formats; the rest round-trip back to Go. +// isIntegerFormat reports whether format is one of the integer-width spellings: the Go int widths +// (int, int8…int64) and the swagger-extension unsigned widths (uint, uint8…uint64). int32 / +// int64 are the only OAS-2-official integer formats; the rest round-trip back to Go. func isIntegerFormat(format string) bool { switch format { case "int", "int8", "int16", "int32", "int64", @@ -58,8 +57,8 @@ func isIntegerFormat(format string) bool { } } -// isFloatFormat reports whether format is one of the float-width spellings: -// the OAS-2-official float / double, plus the Go-spelled float32 / float64. +// isFloatFormat reports whether format is one of the float-width spellings: the OAS-2-official +// float / double, plus the Go-spelled float32 / float64. func isFloatFormat(format string) bool { switch format { case "float", "double", "float32", "float64": diff --git a/internal/builders/validations/shape.go b/internal/builders/validations/shape.go index 16b39571..4b950256 100644 --- a/internal/builders/validations/shape.go +++ b/internal/builders/validations/shape.go @@ -10,16 +10,15 @@ import ( "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`). +// 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`). // // # Details // -// See [§type-domain-table](./README.md#type-domain-table) — the -// source dialect, the keywords intentionally absent from the -// table, and the rationale for returning a fresh map per call. +// See [§type-domain-table](./README.md#type-domain-table) — the source dialect, the keywords +// intentionally absent from the table, and the rationale for returning a fresh map per call. func keywordTypeRules() map[string][]string { return map[string][]string{ "pattern": {"string"}, @@ -37,21 +36,19 @@ func keywordTypeRules() map[string][]string { } } -// 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. +// 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 decides whether to apply the keyword to a typeless schema. +// Empty schemaType is treated as "type unknown" and accepted; the caller decides whether to apply +// the keyword to a typeless schema. // Format is not consulted — the domain rules apply at the type level. // // # Details // -// See [§empty-type](./README.md#empty-type) — the best-effort-apply -// rule for unknown schema types, and why Format is intentionally -// kept off this axis. +// See [§empty-type](./README.md#empty-type) — the best-effort-apply rule for unknown schema +// types, and why Format is intentionally kept off this axis. func IsLegalForType(keyword grammar.Keyword, schemaType string) (ok bool, hint string) { rules, hasRule := keywordTypeRules()[keyword.Name] if !hasRule { diff --git a/internal/builders/validations/shape_test.go b/internal/builders/validations/shape_test.go index b5595960..7103281a 100644 --- a/internal/builders/validations/shape_test.go +++ b/internal/builders/validations/shape_test.go @@ -78,8 +78,7 @@ func TestIsLegalForType_ObjectConstraintsOnlyOnObject(t *testing.T) { } func TestIsLegalForType_TypelessSchemaIsLenient(t *testing.T) { - // Empty schemaType ("type unknown") is accepted — the caller - // decides whether to apply. + // 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) @@ -87,8 +86,8 @@ func TestIsLegalForType_TypelessSchemaIsLenient(t *testing.T) { } func TestIsLegalForType_KeywordsWithoutRulesAlwaysLegal(t *testing.T) { - // required / readOnly / deprecated / discriminator have no type - // constraint — they apply to any type. + // 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"} { @@ -99,9 +98,8 @@ func TestIsLegalForType_KeywordsWithoutRulesAlwaysLegal(t *testing.T) { } 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. + // 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"} { diff --git a/internal/ifaces/ifaces.go b/internal/ifaces/ifaces.go index 049feca6..9199bf97 100644 --- a/internal/ifaces/ifaces.go +++ b/internal/ifaces/ifaces.go @@ -1,13 +1,12 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -// Package ifaces defines the internal interfaces that decouple the -// comment-parsing pipeline from the concrete Swagger spec builders. +// Package ifaces defines the internal interfaces that decouple the comment-parsing pipeline from +// the concrete Swagger spec builders. // -// These interfaces allow the parsers (in internal/parsers) to remain -// independent of the specific spec objects they write into (Schema, -// Parameter, Header, Items), and let the builders (in internal/builders) -// provide type-specific implementations. +// These interfaces allow the parsers (in internal/parsers) to remain independent of the specific +// spec objects they write into (Schema, Parameter, Header, Items), and let the builders (in +// internal/builders) provide type-specific implementations. package ifaces import ( @@ -18,17 +17,16 @@ import ( // SwaggerTypable is a write target for Swagger type assignments. // -// When the scanner resolves a Go type to its Swagger representation -// (e.g. int64 -> "integer"/"int64", or a named struct -> a $ref), -// it writes the result through this interface. The four production -// implementations adapt SwaggerTypable to the Swagger spec object -// shapes: [spec.Schema], [spec.Parameter], [spec.Response]/[spec.Header], -// and [spec.Items]. +// When the scanner resolves a Go type to its Swagger representation (e.g. int64 -> +// "integer"/"int64", or a named struct -> a $ref), it writes the result through this interface. +// The four production implementations adapt SwaggerTypable to the Swagger spec object shapes: +// [spec.Schema], [spec.Parameter], [spec.Response]/[spec.Header], and [spec.Items]. +// +// Items returns a nested SwaggerTypable for the element type of arrays, enabling recursive descent +// into multi-level array types. +// In reports the parameter location (body, query, path, header, formData) so the pipeline can +// branch: body parameters use schemas, while others use simple types. // -// Items returns a nested SwaggerTypable for the element type of arrays, -// enabling recursive descent into multi-level array types. In reports the -// parameter location (body, query, path, header, formData) so the pipeline -// can branch: body parameters use schemas, while others use simple types. // Level reports the current nesting depth for array items. type SwaggerTypable interface { Typed(swaggerType string, format string) @@ -44,13 +42,12 @@ type SwaggerTypable interface { // ValidationBuilder is a write target for Swagger validation constraints. // -// When the comment parser encounters a validation directive such as -// "Maximum: 100" or "Pattern: ^[a-z]+$", it extracts the value and -// calls the corresponding setter on this interface. This decouples -// the parsing logic (in internal/parsers) from the spec-object -// structure: each builder implementation (for Schema, Parameter, -// Header, or Items) knows how to write the constraint onto its -// specific spec type. +// When the comment parser encounters a validation directive such as "Maximum: 100" or "Pattern: +// ^[a-z]+$", it extracts the value and calls the corresponding setter on this interface. +// +// This decouples the parsing logic (in internal/parsers) from the spec-object structure: each +// builder implementation (for Schema, Parameter, Header, or Items) knows how to write the +// constraint onto its specific spec type. type ValidationBuilder interface { //nolint:interfacebloat // mirrors the full set of Swagger validation properties SetMaximum(maxium float64, isExclusive bool) SetMinimum(minimum float64, isExclusive bool) @@ -71,23 +68,21 @@ type ValidationBuilder interface { //nolint:interfacebloat // mirrors the full s // ValueParser is the fundamental unit of comment-line parsing. // -// Each implementation recognizes (via Matches) and extracts data from -// (via Parse) one kind of swagger annotation or validation directive -// in a Go comment block. ValueParsers are composed into TagParser -// wrappers and fed to the SectionedParser, which iterates over -// comment lines and dispatches each line to the first matching parser. +// Each implementation recognizes (via Matches) and extracts data from (via Parse) one kind of +// swagger annotation or validation directive in a Go comment block. +// ValueParsers are composed into TagParser wrappers and fed to the SectionedParser, which iterates +// over comment lines and dispatches each line to the first matching parser. type ValueParser interface { Parse(commentlines []string) error Matches(commentLine string) bool } -// OperationValidationBuilder extends [ValidationBuilder] with -// SetCollectionFormat, which applies only to operation parameters, -// response headers, and array items — not to schema definitions. +// OperationValidationBuilder extends [ValidationBuilder] with SetCollectionFormat, which applies +// only to operation parameters, response headers, and array items — not to schema definitions. // -// The narrower interface enforces at the type level that collection -// format (csv, ssv, tsv, pipes, multi) cannot be accidentally set on -// a schema. Schema validations implement only [ValidationBuilder]. +// The narrower interface enforces at the type level that collection format (csv, ssv, tsv, pipes, +// multi) cannot be accidentally set on a schema. +// Schema validations implement only [ValidationBuilder]. type OperationValidationBuilder interface { ValidationBuilder SetCollectionFormat(collectionFormat string) @@ -95,11 +90,10 @@ type OperationValidationBuilder interface { // Objecter abstracts over Go type objects that carry a [types.TypeName]. // -// It is used during type resolution to detect unsupported builtin types -// (complex64, complex128, unsafe.Pointer) that have no JSON/Swagger -// representation. Both [*types.Named] and [scanner.EntityDecl] satisfy -// this interface, giving the resolver a uniform way to extract the -// underlying type name without a type switch on every concrete type. +// It is used during type resolution to detect unsupported builtin types (complex64, complex128, +// unsafe.Pointer) that have no JSON/Swagger representation. +// Both [*types.Named] and [scanner.EntityDecl] satisfy this interface, giving the resolver a +// uniform way to extract the underlying type name without a type switch on every concrete type. type Objecter interface { Obj() *types.TypeName } diff --git a/internal/integration/classification_test.go b/internal/integration/classification_test.go index b9a67774..c8d2d026 100644 --- a/internal/integration/classification_test.go +++ b/internal/integration/classification_test.go @@ -11,11 +11,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestClassification_Spec is the full-pipeline snapshot of the canonical -// classification fixture (schemas + paths with their parameters, responses and -// operations), captured across the (SkipExtensions, DescWithRef) option matrix. -// It is the integration home for the snapshots that used to live as per-builder -// unit goldens (classification_{schema,params,responses,routes,operations}_*). +// TestClassification_Spec is the full-pipeline snapshot of the canonical classification fixture +// (schemas + paths with their parameters, responses and operations), captured across the +// (SkipExtensions, DescWithRef) option matrix. +// +// It is the integration home for the snapshots that used to live as per-builder unit goldens +// (classification_{schema,params,responses,routes,operations}_*). func TestClassification_Spec(t *testing.T) { cases := []struct { name string diff --git a/internal/integration/coverage_additional_properties_field_test.go b/internal/integration/coverage_additional_properties_field_test.go index cd6a227c..1d718736 100644 --- a/internal/integration/coverage_additional_properties_field_test.go +++ b/internal/integration/coverage_additional_properties_field_test.go @@ -13,10 +13,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_AdditionalProperties_Field locks the field-level -// additionalProperties: keyword (Phase 2b): override a map element schema, -// true / typed / model-reference values, the allOf-sibling form on a -// referenced-model field, and the lowest-priority contradiction. +// TestCoverage_AdditionalProperties_Field locks the field-level additionalProperties: keyword +// (Phase 2b): override a map element schema, true / typed / model-reference values, the +// allOf-sibling form on a referenced-model field, and the lowest-priority contradiction. func TestCoverage_AdditionalProperties_Field(t *testing.T) { var diags []grammar.Diagnostic doc, err := codescan.Run(&codescan.Options{ @@ -50,7 +49,8 @@ func TestCoverage_AdditionalProperties_Field(t *testing.T) { require.NotNil(t, ref.Schema) assert.Equal(t, "#/definitions/Inner", ref.Schema.Ref.String()) - // referenced-model field closed via an allOf sibling: {allOf:[{$ref},{additionalProperties:false}]}. + // referenced-model field closed via an allOf sibling: + // {allOf:[{$ref},{additionalProperties:false}]}. closed := props["closedInner"] require.Len(t, closed.AllOf, 2) assert.Equal(t, "#/definitions/Inner", closed.AllOf[0].Ref.String()) diff --git a/internal/integration/coverage_additional_properties_simpleschema_test.go b/internal/integration/coverage_additional_properties_simpleschema_test.go index 4ad169bf..960633c8 100644 --- a/internal/integration/coverage_additional_properties_simpleschema_test.go +++ b/internal/integration/coverage_additional_properties_simpleschema_test.go @@ -14,10 +14,11 @@ import ( ) // TestCoverage_AdditionalProperties_SimpleSchemaSafeguard proves the safeguard: -// additionalProperties and patternProperties (object-schema keywords) cannot -// land on an OAS2 SimpleSchema produced by parameters.Builder (a non-body -// parameter) or responses.Builder (a response header). Each occurrence is -// dropped with a CodeUnsupportedInSimpleSchema diagnostic — both keywords are +// additionalProperties and patternProperties (object-schema keywords) cannot land on an OAS2 +// SimpleSchema produced by parameters.Builder (a non-body parameter) or responses.Builder (a +// response header). +// +// Each occurrence is dropped with a CodeUnsupportedInSimpleSchema diagnostic — both keywords are // declared CtxSchema-only, so handlers.isFullSchemaOnly routes them to // UnsupportedSimpleSchemaString in the param/header/items dispatchers. func TestCoverage_AdditionalProperties_SimpleSchemaSafeguard(t *testing.T) { @@ -33,24 +34,24 @@ func TestCoverage_AdditionalProperties_SimpleSchemaSafeguard(t *testing.T) { require.NoError(t, err) require.NotNil(t, doc) - // The query parameter stays a clean string (the object keywords have no - // SimpleSchema slot to land in — Parameter embeds SimpleSchema only). + // The query parameter stays a clean string (the object keywords have no SimpleSchema slot to land + // in — Parameter embeds SimpleSchema only). op := doc.Paths.Paths["/safeguard"].Get require.NotNil(t, op) require.Len(t, op.Parameters, 1) assert.Equal(t, "query", op.Parameters[0].In) assert.Equal(t, "string", op.Parameters[0].Type) - // The response header likewise stays a clean string (the response is a - // shared definition referenced by $ref from the operation). + // The response header likewise stays a clean string (the response is a shared definition + // referenced by $ref from the operation). resp, ok := doc.Responses["safeguardResponse"] require.True(t, ok) hdr, ok := resp.Headers["X-Thing"] require.True(t, ok) assert.Equal(t, "string", hdr.Type) - // Two diagnostics per site (additionalProperties + patternProperties) × two - // sites (param + header) = four; all CodeUnsupportedInSimpleSchema. + // Two diagnostics per site (additionalProperties + patternProperties) × two sites (param + + // header) = four; all CodeUnsupportedInSimpleSchema. var simple int for _, d := range diags { if d.Code == grammar.CodeUnsupportedInSimpleSchema { diff --git a/internal/integration/coverage_additional_properties_test.go b/internal/integration/coverage_additional_properties_test.go index c753b67e..91067e0d 100644 --- a/internal/integration/coverage_additional_properties_test.go +++ b/internal/integration/coverage_additional_properties_test.go @@ -13,10 +13,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_AdditionalProperties locks the type-level -// swagger:additionalProperties marker (go-swagger#2539 / #3005, forthcoming §17): -// true / false / typed value / model $ref, the map-override case, coexistence -// with maxProperties, and the lowest-priority precedence rule. +// TestCoverage_AdditionalProperties locks the type-level swagger:additionalProperties marker +// (go-swagger#2539 / #3005, forthcoming §17): true / false / typed value / model $ref, the +// map-override case, coexistence with maxProperties, and the lowest-priority precedence rule. func TestCoverage_AdditionalProperties(t *testing.T) { var diags []grammar.Diagnostic doc, err := codescan.Run(&codescan.Options{ diff --git a/internal/integration/coverage_after_decl_comments_test.go b/internal/integration/coverage_after_decl_comments_test.go new file mode 100644 index 00000000..64347789 --- /dev/null +++ b/internal/integration/coverage_after_decl_comments_test.go @@ -0,0 +1,72 @@ +// 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/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func runAfterDeclComments(t *testing.T, on bool) *spec.Swagger { + t.Helper() + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/after-decl-comments/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + AfterDeclComments: on, + }) + require.NoError(t, err) + require.NotNil(t, doc) + return doc +} + +// TestCoverage_AfterDeclComments_Off is the control: with the option off, the inside-body / inlined +// annotations are inert — the carriers carry no annotation in their (clean) godoc, so nothing is +// discovered. +func TestCoverage_AfterDeclComments_Off(t *testing.T) { + doc := runAfterDeclComments(t, false) + assert.MapNotContainsT(t, doc.Definitions, "widgetModel") + assert.MapNotContainsT(t, doc.Definitions, "countType") + assert.MapNotContainsT(t, doc.Definitions, "stampType") + // Golden pins the FULL inert output: the inside-body / inlined annotations contribute nothing with + // the option off (the only discovery is the location-agnostic route). + scantest.CompareOrDumpJSON(t, doc, "enhancements_after_decl_comments_off.json") +} + +// TestCoverage_AfterDeclComments_On exercises Phase A: with the option on, the struct's inside-body +// annotation and the defined type's inlined trailing annotation are discovered, named, and +// validated; the route inside a func body is discovered (already location-agnostic). +func TestCoverage_AfterDeclComments_On(t *testing.T) { + doc := runAfterDeclComments(t, true) + + // struct inside-body: swagger:model widgetModel + maxProperties: 5 + require.MapContainsT(t, doc.Definitions, "widgetModel") + widget := doc.Definitions["widgetModel"] + require.NotNil(t, widget.MaxProperties) + assert.EqualT(t, int64(5), *widget.MaxProperties) + + // field-level inlined trailing comment (Phase B): swagger:strfmt date + require.MapContainsT(t, widget.Properties, "created") + assert.EqualT(t, "date", widget.Properties["created"].Format) + + // defined type inlined trailing: swagger:model countType + require.MapContainsT(t, doc.Definitions, "countType") + assert.SliceContainsT(t, doc.Definitions["countType"].Type, "integer") + + // type alias inlined trailing: swagger:model stampType + require.MapContainsT(t, doc.Definitions, "stampType") + assert.SliceContainsT(t, doc.Definitions["stampType"].Type, "string") + + // route inside the func body is discovered (location-agnostic today). + require.NotNil(t, doc.Paths) + require.MapContainsT(t, doc.Paths.Paths, "/widgets") + require.NotNil(t, doc.Paths.Paths["/widgets"].Get) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_after_decl_comments.json") +} diff --git a/internal/integration/coverage_alias_embed_inline_test.go b/internal/integration/coverage_alias_embed_inline_test.go index 7e104488..8a2d73de 100644 --- a/internal/integration/coverage_alias_embed_inline_test.go +++ b/internal/integration/coverage_alias_embed_inline_test.go @@ -12,15 +12,13 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_AliasEmbedInline pins the embed-handling contract: -// a struct that embeds an alias of a named type produces the same -// flat inline shape as a struct that embeds the named type -// directly. Embeds always inline properties, never `$ref` unless -// the embed carries `swagger:allOf` -// (internal/builders/schema/embedded.go). The fixture exercises -// the same Base struct embedded direct, via alias, via pointer, -// and via interface — all four produce structurally equivalent -// inline shapes. +// TestCoverage_AliasEmbedInline pins the embed-handling contract: a struct that embeds an alias of +// a named type produces the same flat inline shape as a struct that embeds the named type directly. +// +// Embeds always inline properties, never `$ref` unless the embed carries `swagger:allOf` +// (internal/builders/schema/embedded.go). +// The fixture exercises the same Base struct embedded direct, via alias, via pointer, and via +// interface — all four produce structurally equivalent inline shapes. func TestCoverage_AliasEmbedInline(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/alias-calibration-embed/..."}, @@ -56,8 +54,8 @@ func TestCoverage_AliasEmbedInline(t *testing.T) { assert.Contains(t, ptr.Properties, "name") assert.Contains(t, ptr.Properties, "extra") - // EmbedsInterface (named interface embed) — flat with method - // promoted as property + the outer's own field. + // EmbedsInterface (named interface embed) — flat with method promoted as property + the outer's + // own field. require.Contains(t, doc.Definitions, "EmbedsInterface") iface := doc.Definitions["EmbedsInterface"] assert.Empty(t, iface.AllOf) @@ -65,12 +63,12 @@ func TestCoverage_AliasEmbedInline(t *testing.T) { assert.Contains(t, iface.Properties, "tag") } -// TestCoverage_AliasEmbedAllOfOptIn pins the other half of the -// embed contract: when the user EXPLICITLY annotates an aliased -// embed with `swagger:allOf`, the spec produces allOf composition -// with a `$ref` to the embedded type. Together with -// TestCoverage_AliasEmbedInline this asserts the bidirectional -// contract: annotation is the sole gate of allOf composition. +// TestCoverage_AliasEmbedAllOfOptIn pins the other half of the embed contract: when the user +// EXPLICITLY annotates an aliased embed with `swagger:allOf`, the spec produces allOf composition +// with a `$ref` to the embedded type. +// +// Together with TestCoverage_AliasEmbedInline this asserts the bidirectional contract: annotation +// is the sole gate of allOf composition. func TestCoverage_AliasEmbedAllOfOptIn(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/alias-calibration-embed/..."}, @@ -80,21 +78,18 @@ func TestCoverage_AliasEmbedAllOfOptIn(t *testing.T) { require.NoError(t, err) require.NotNil(t, doc) - // EmbedsAliasOptIn (BaseAlias embed with swagger:allOf) → - // allOf: [{$ref: Base}, {properties: {extra inline}}]. + // EmbedsAliasOptIn (BaseAlias embed with swagger:allOf) → allOf: [{$ref: Base}, {properties: + // {extra inline}}]. // - // The `swagger:allOf` annotation governs COMPOSITION SHAPE - // (allOf vs flat inline). It does not preserve alias identity: - // the $ref target is the unaliased Base because the alias - // BaseAlias itself is unannotated (no `swagger:model`). Only - // `swagger:model` makes an alias a first-class spec entity; - // without it, the aliasing is a Go implementation detail and - // the spec carries the underlying name even inside an allOf - // composition. + // The `swagger:allOf` annotation governs COMPOSITION SHAPE (allOf vs flat inline). + // It does not preserve alias identity: the $ref target is the unaliased Base because the alias + // BaseAlias itself is unannotated (no `swagger:model`). // - // To preserve the alias name in allOf composition, the alias - // would need its own `swagger:model` annotation (see - // EmbedsAliasModeledOptIn below for the witness). + // Only `swagger:model` makes an alias a first-class spec entity; without it, the aliasing is a Go + // implementation detail and the spec carries the underlying name even inside an allOf composition. + // + // To preserve the alias name in allOf composition, the alias would need its own `swagger:model` + // annotation (see EmbedsAliasModeledOptIn below for the witness). require.Contains(t, doc.Definitions, "EmbedsAliasOptIn") optInAlias := doc.Definitions["EmbedsAliasOptIn"] require.Len(t, optInAlias.AllOf, 2, @@ -117,9 +112,8 @@ func TestCoverage_AliasEmbedAllOfOptIn(t *testing.T) { assert.True(t, foundRefToBase, "must find $ref member pointing at Base") assert.True(t, foundInlineExtra, "must find inline-properties member") - // EmbedsDirectStructOptIn (Base direct embed with swagger:allOf) - // → allOf with $ref to Base. Same annotation, same composition - // shape, regardless of whether the embed was aliased — alias + // EmbedsDirectStructOptIn (Base direct embed with swagger:allOf) → allOf with $ref to Base. + // Same annotation, same composition shape, regardless of whether the embed was aliased — alias // and direct paths agree everywhere. require.Contains(t, doc.Definitions, "EmbedsDirectStructOptIn") optInDirect := doc.Definitions["EmbedsDirectStructOptIn"] @@ -140,14 +134,13 @@ func TestCoverage_AliasEmbedAllOfOptIn(t *testing.T) { assert.True(t, foundRefToBaseDirect) assert.True(t, foundInlineExtraDirect) - // EmbedsAliasModeledOptIn (BaseAliasModeled embed with swagger:allOf) - // → allOf: [{$ref: BaseAliasModeled}, {properties: {extra inline}}]. + // EmbedsAliasModeledOptIn (BaseAliasModeled embed with swagger:allOf) → allOf: [{$ref: + // BaseAliasModeled}, {properties: {extra inline}}]. // - // Bidirectional witness against EmbedsAliasOptIn above: same - // allOf composition shape, same outer struct, but the embedded - // alias is ANNOTATED. The annotation surfaces the alias name - // in the $ref target — `swagger:model` is the SOLE knob that - // preserves alias identity inside an allOf member. + // Bidirectional witness against EmbedsAliasOptIn above: same allOf composition shape, same outer + // struct, but the embedded alias is ANNOTATED. + // The annotation surfaces the alias name in the $ref target — `swagger:model` is the SOLE knob + // that preserves alias identity inside an allOf member. require.Contains(t, doc.Definitions, "EmbedsAliasModeledOptIn") optInAliasModeled := doc.Definitions["EmbedsAliasModeledOptIn"] require.Len(t, optInAliasModeled.AllOf, 2, diff --git a/internal/integration/coverage_alias_field_test.go b/internal/integration/coverage_alias_field_test.go index 67ce830b..07ab19e7 100644 --- a/internal/integration/coverage_alias_field_test.go +++ b/internal/integration/coverage_alias_field_test.go @@ -12,27 +12,23 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// Field-reach witnesses for the schema builder's alias-handling -// contract. The `alias-calibration-embed` fixture pairs two -// envelopes: +// Field-reach witnesses for the schema builder's alias-handling contract. +// The `alias-calibration-embed` fixture pairs two envelopes: // // - Envelope → uses the UNANNOTATED `BaseAlias` // - EnvelopeAnnotatedAlias → uses the ANNOTATED `BaseAliasModeled` // -// Same Go type behind both aliases (`types.Unalias` collapses -// them to `Base`); the ONLY difference is the `swagger:model` -// annotation. The annotation gates whether the alias is a -// first-class spec entity at use sites: +// Same Go type behind both aliases (`types.Unalias` collapses them to `Base`); the ONLY difference +// is the `swagger:model` annotation. +// The annotation gates whether the alias is a first-class spec entity at use sites: // // - annotated → preserves `$ref: ` + own definition // - unannotated → dissolves to `$ref: `; no definition // -// `TransparentAliases=true` overrides the gate (always dissolve at -// use sites). See [§aliases](../builders/schema/README.md#aliases) -// for the rule. +// `TransparentAliases=true` overrides the gate (always dissolve at use sites). +// See [§aliases](../builders/schema/README.md#aliases) for the rule. -// TestCoverage_AliasField_Default captures the Default-mode -// (Expand) shape: +// TestCoverage_AliasField_Default captures the Default-mode (Expand) shape: // // - Envelope.viaAlias (UNANNOTATED BaseAlias) → $ref: Base // (alias dissolves at the use site); BaseAlias has no own def @@ -66,10 +62,10 @@ func TestCoverage_AliasField_Default(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_alias_field_default.json") } -// TestCoverage_AliasField_Ref captures the RefAliases shape. The -// annotation gate fires identically to Default at use sites — -// mode only affects the alias decl's own definition shape (chain -// `$ref` instead of structural copy), not the field-site `$ref` +// TestCoverage_AliasField_Ref captures the RefAliases shape. +// +// The annotation gate fires identically to Default at use sites — mode only affects the alias +// decl's own definition shape (chain `$ref` instead of structural copy), not the field-site `$ref` // target. func TestCoverage_AliasField_Ref(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ @@ -97,10 +93,10 @@ func TestCoverage_AliasField_Ref(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_alias_field_ref.json") } -// TestCoverage_AliasField_Transparent captures the dissolve-all -// shape. Both Envelope.viaAlias and -// EnvelopeAnnotatedAlias.viaAliasModeled resolve to {$ref: Base} -// because Transparent supersedes the annotation gate at use sites. +// TestCoverage_AliasField_Transparent captures the dissolve-all shape. +// +// Both Envelope.viaAlias and EnvelopeAnnotatedAlias.viaAliasModeled resolve to {$ref: Base} because +// Transparent supersedes the annotation gate at use sites. func TestCoverage_AliasField_Transparent(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/alias-calibration-embed/..."}, @@ -111,9 +107,8 @@ func TestCoverage_AliasField_Transparent(t *testing.T) { require.NoError(t, err) require.NotNil(t, doc) - // Transparent dissolves regardless of annotation: both field - // types resolve to Base at the use site, and the unannotated - // alias does not appear in definitions. + // Transparent dissolves regardless of annotation: both field types resolve to Base at the use + // site, and the unannotated alias does not appear in definitions. require.Contains(t, doc.Definitions, "Envelope") viaAlias := doc.Definitions["Envelope"].Properties["viaAlias"] assert.Equal(t, "#/definitions/Base", viaAlias.Ref.String(), @@ -126,10 +121,9 @@ func TestCoverage_AliasField_Transparent(t *testing.T) { assert.NotContains(t, doc.Definitions, "BaseAlias", "Transparent dissolves the unannotated alias decl") - // `swagger:model` forces decl-level registration regardless of - // mode. Under Transparent the alias dissolves at USE sites - // (the viaAliasModeled $ref above) but the decl entry - // survives — the dangling annotated def trade-off. + // `swagger:model` forces decl-level registration regardless of mode. + // Under Transparent the alias dissolves at USE sites (the viaAliasModeled $ref above) but the decl + // entry survives — the dangling annotated def trade-off. assert.Contains(t, doc.Definitions, "BaseAliasModeled", "annotated decl entry survives Transparent (swagger:model forces registration)") diff --git a/internal/integration/coverage_alias_findmodel_witness_test.go b/internal/integration/coverage_alias_findmodel_witness_test.go index 4b390b92..9e31c511 100644 --- a/internal/integration/coverage_alias_findmodel_witness_test.go +++ b/internal/integration/coverage_alias_findmodel_witness_test.go @@ -12,14 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_AliasFindModelWitness — locks the post-migration -// behaviour of GetModel + AppendPostDecl on alias targets that are -// not annotated swagger:model. +// TestCoverage_AliasFindModelWitness — locks the post-migration behaviour of GetModel + +// AppendPostDecl on alias targets that are not annotated swagger:model. // -// PlainTarget (unannotated) must reach spec.definitions via the -// orchestrator's discovery loop after the alias's RHS lookup -// queues it; under the pre-migration FindModel, the same decl -// reached definitions via the implicit ExtraModels side effect. +// PlainTarget (unannotated) must reach spec.definitions via the orchestrator's discovery loop after +// the alias's RHS lookup queues it; under the pre-migration FindModel, the same decl reached +// definitions via the implicit ExtraModels side effect. // Both paths produce identical output; the golden is the witness. func TestCoverage_AliasFindModelWitness(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ @@ -34,11 +32,10 @@ func TestCoverage_AliasFindModelWitness(t *testing.T) { assert.Contains(t, doc.Definitions, "PlainTarget", "unannotated alias target must reach definitions via the orchestrator's discovery path") - // Bidirectional witness — unannotated alias dissolves to its - // target at every use site; annotated alias preserves the - // alias name. AliasOfPlain (unannotated) and AliasOfPlainModeled - // (annotated) sit on the same fixture so both halves are - // visible side by side in this golden. + // Bidirectional witness — unannotated alias dissolves to its target at every use site; annotated + // alias preserves the alias name. + // AliasOfPlain (unannotated) and AliasOfPlainModeled (annotated) sit on the same fixture so both + // halves are visible side by side in this golden. assert.NotContains(t, doc.Definitions, "AliasOfPlain", "unannotated alias must not produce a definition") require.Contains(t, doc.Definitions, "AliasOfPlainModeled", @@ -52,14 +49,11 @@ func TestCoverage_AliasFindModelWitness(t *testing.T) { doc.Responses["witnessModeledResponse"].Schema.Ref.String(), "annotated body response alias preserves the alias name") - // The parameters-side bidirectional contract is observable in - // the golden but not in doc.Parameters here: this fixture has no - // swagger:route, so the swagger:parameters declarations don't - // bind to an operation and never reach spec.Parameters. - // witnessRequest / witnessModeledRequest live only in the - // scanner's internal parameter-set map; they show up in the - // golden via the WitnessParams / WitnessParamsModeled struct - // surfaces (or their absence under the parameters builder's + // The parameters-side bidirectional contract is observable in the golden but not in doc.Parameters + // here: this fixture has no swagger:route, so the swagger:parameters declarations don't bind to an + // operation and never reach spec.Parameters. witnessRequest / witnessModeledRequest live only in + // the scanner's internal parameter-set map; they show up in the golden via the WitnessParams / + // WitnessParamsModeled struct surfaces (or their absence under the parameters builder's // no-definition rule for top-level alias parameters). scantest.CompareOrDumpJSON(t, doc, "enhancements_alias_findmodel_witness.json") } diff --git a/internal/integration/coverage_alias_parameters_calibration_test.go b/internal/integration/coverage_alias_parameters_calibration_test.go index d3222aa2..182c35b1 100644 --- a/internal/integration/coverage_alias_parameters_calibration_test.go +++ b/internal/integration/coverage_alias_parameters_calibration_test.go @@ -13,9 +13,8 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// findBodyParam locates the body parameter named `name` on the -// operation under (path, verb) in the spec — small helper to keep -// the parameters-builder alias-handling assertions readable. +// findBodyParam locates the body parameter named `name` on the operation under (path, verb) in the +// spec — small helper to keep the parameters-builder alias-handling assertions readable. func findBodyParam(t *testing.T, doc *oaispec.Swagger, path, verb, name string) oaispec.Parameter { t.Helper() op := doc.Paths.Paths[path].PathItemProps @@ -38,10 +37,9 @@ func findBodyParam(t *testing.T, doc *oaispec.Swagger, path, verb, name string) return oaispec.Parameter{} } -// Calibration coverage for the parameters builder's alias-handling -// contract. The three tests below scan the calibration fixture -// under all three alias modes (Default, RefAliases, -// TransparentAliases) and pin both inline assertions and goldens. +// Calibration coverage for the parameters builder's alias-handling contract. +// The three tests below scan the calibration fixture under all three alias modes (Default, +// RefAliases, TransparentAliases) and pin both inline assertions and goldens. // // The fixture deliberately includes: // @@ -52,8 +50,7 @@ func findBodyParam(t *testing.T, doc *oaispec.Swagger, path, verb, name string) // - a non-body field typed as an unannotated alias of a named // primitive (SimpleSchema target — always inline). // -// See [§alias-handling](../builders/parameters/README.md#alias-handling) -// for the contract. +// See [§alias-handling](../builders/parameters/README.md#alias-handling) for the contract. func TestCoverage_AliasParametersCalibration_Default(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ @@ -64,11 +61,10 @@ func TestCoverage_AliasParametersCalibration_Default(t *testing.T) { require.NoError(t, err) require.NotNil(t, doc) - // Top-level `swagger:parameters` alias — neither the alias nor - // its unexported backing struct surface as model definitions. - // The /aliased-top operation still gets its parameters built - // correctly: the fields of the unaliased backing struct become - // parameters, and `body` resolves to the canonical Payload model. + // Top-level `swagger:parameters` alias — neither the alias nor its unexported backing struct + // surface as model definitions. + // The /aliased-top operation still gets its parameters built correctly: the fields of the + // unaliased backing struct become parameters, and `body` resolves to the canonical Payload model. assert.NotContains(t, doc.Definitions, "AliasedTopParams", "top-level swagger:parameters alias must not produce a definition") assert.NotContains(t, doc.Definitions, "internalParams", @@ -115,11 +111,10 @@ func TestCoverage_AliasParametersCalibration_Ref(t *testing.T) { require.NoError(t, err) require.NotNil(t, doc) - // Behaviour at field sites is mode-agnostic: the annotation - // gate fires the same way under RefAliases as under Default. - // The mode only affects the alias decl's OWN definition shape - // (PayloadAliasModeled's downstream representation), not the - // field $ref target. + // Behaviour at field sites is mode-agnostic: the annotation gate fires the same way under + // RefAliases as under Default. + // The mode only affects the alias decl's OWN definition shape (PayloadAliasModeled's downstream + // representation), not the field $ref target. assert.NotContains(t, doc.Definitions, "AliasedTopParams") assert.NotContains(t, doc.Definitions, "internalParams") assert.NotContains(t, doc.Definitions, "PayloadAlias") @@ -150,10 +145,9 @@ func TestCoverage_AliasParametersCalibration_Transparent(t *testing.T) { require.NoError(t, err) require.NotNil(t, doc) - // TransparentAliases supersedes the annotation gate at use - // sites (every body alias dissolves to its unaliased target), - // but `swagger:model` still forces decl-level registration — - // annotated aliases keep their definition entry. + // TransparentAliases supersedes the annotation gate at use sites (every body alias dissolves to + // its unaliased target), but `swagger:model` still forces decl-level registration — annotated + // aliases keep their definition entry. assert.NotContains(t, doc.Definitions, "AliasedTopParams") assert.NotContains(t, doc.Definitions, "internalParams") assert.NotContains(t, doc.Definitions, "PayloadAlias") diff --git a/internal/integration/coverage_alias_predeclared_ref_test.go b/internal/integration/coverage_alias_predeclared_ref_test.go index 54b0e5ec..2377d1cd 100644 --- a/internal/integration/coverage_alias_predeclared_ref_test.go +++ b/internal/integration/coverage_alias_predeclared_ref_test.go @@ -12,17 +12,16 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_AliasPredeclaredRef regression-tests the nil-pkg -// panic that fires in `buildDeclAlias`'s $ref branch when the -// alias's RHS is a predeclared type — concretely `type Err = error`. -// The predeclared `error` interface has no package (universe scope); -// `ro.Pkg()` returns nil, and the GetModel lookup -// `ro.Pkg().Path()` nil-panics. +// TestCoverage_AliasPredeclaredRef regression-tests the nil-pkg panic that fires in +// `buildDeclAlias`'s $ref branch when the alias's RHS is a predeclared type — concretely `type +// Err = error`. // -// The fix in `buildDeclAlias`'s `case *types.Named` branch guards -// the nil package case and routes through `applyStdlibSpecials`, -// so `error` produces its canonical `{type: string, x-go-type: error}` -// shape inline instead of crashing the scan. +// The predeclared `error` interface has no package (universe scope); `ro.Pkg()` returns nil, and +// the GetModel lookup `ro.Pkg().Path()` nil-panics. +// +// The fix in `buildDeclAlias`'s `case *types.Named` branch guards the nil package case and routes +// through `applyStdlibSpecials`, so `error` produces its canonical `{type: string, x-go-type: +// error}` shape inline instead of crashing the scan. func TestCoverage_AliasPredeclaredRef(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/alias-calibration-stdlib/..."}, @@ -33,11 +32,10 @@ func TestCoverage_AliasPredeclaredRef(t *testing.T) { require.NoError(t, err, "no panic; no error; spec built") require.NotNil(t, doc) - // All four stdlib aliases must produce their canonical inline - // shape directly on their own definition — no chain hop through a - // separately-built target. Lifting applyStdlibSpecials above the - // nil-pkg conditional unified what was a within-mode asymmetry - // (predeclared `error` inline; packaged stdlib types chained). + // All four stdlib aliases must produce their canonical inline shape directly on their own + // definition — no chain hop through a separately-built target. + // Lifting applyStdlibSpecials above the nil-pkg conditional unified what was a within-mode + // asymmetry (predeclared `error` inline; packaged stdlib types chained). require.Contains(t, doc.Definitions, "Err", "swagger:model Err = error must produce an Err definition") @@ -63,10 +61,9 @@ func TestCoverage_AliasPredeclaredRef(t *testing.T) { assert.Empty(t, raw.Ref.String(), "Raw must NOT chain to a separate RawMessage definition under Ref") - // Unannotated aliases are a Go implementation detail and do not - // surface as standalone definitions. SilentTime dissolves at - // its use site (Envelope.silent); the recognizer still wins for - // the inline shape. + // Unannotated aliases are a Go implementation detail and do not surface as standalone definitions. + // SilentTime dissolves at its use site (Envelope.silent); the recognizer still wins for the inline + // shape. assert.NotContains(t, doc.Definitions, "SilentTime", "unannotated alias of time.Time must not produce a standalone definition") require.Contains(t, doc.Definitions, "Envelope") @@ -85,12 +82,10 @@ func TestCoverage_AliasPredeclaredRef(t *testing.T) { "no RawMessage def — Raw inlines the open shape, no chain target") } -// TestCoverage_AliasStdlibDefault pins the stdlib-recognizer -// behaviour on `buildDeclAlias`'s Expand branch: aliases of -// stdlib-special types (time.Time, error, json.RawMessage, any) -// produce their canonical shape directly in Default mode instead -// of the structural-walk wrong shape that buildFromType(Underlying) -// would otherwise emit. +// TestCoverage_AliasStdlibDefault pins the stdlib-recognizer behaviour on `buildDeclAlias`'s Expand +// branch: aliases of stdlib-special types (time.Time, error, json.RawMessage, any) produce their +// canonical shape directly in Default mode instead of the structural-walk wrong shape that +// buildFromType(Underlying) would otherwise emit. // // Without the recognizer call before the Underlying fallthrough: // - Timestamp = time.Time → {type: object} (walked the empty struct) @@ -98,8 +93,8 @@ func TestCoverage_AliasPredeclaredRef(t *testing.T) { // - Raw = json.RawMessage → {type: array, items: {integer, uint8}} // - SilentTime = time.Time → {type: object} // -// With the recognizer call, all four produce their canonical shape -// inline, matching TransparentAliases mode. +// With the recognizer call, all four produce their canonical shape inline, matching +// TransparentAliases mode. func TestCoverage_AliasStdlibDefault(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/alias-calibration-stdlib/..."}, @@ -125,11 +120,10 @@ func TestCoverage_AliasStdlibDefault(t *testing.T) { assert.Equal(t, "error", errDef.Extensions["x-go-type"], "Err must carry the x-go-type:error extension") - // SilentTime = time.Time (UNANNOTATED, reachable via field) — - // unannotated aliases do not produce standalone definitions. - // The recognizer's canonical shape ({string, date-time}) lands - // inline at the use site (Envelope.silent) instead of on a - // SilentTime def. + // SilentTime = time.Time (UNANNOTATED, reachable via field) — unannotated aliases do not produce + // standalone definitions. + // The recognizer's canonical shape ({string, date-time}) lands inline at the use site + // (Envelope.silent) instead of on a SilentTime def. assert.NotContains(t, doc.Definitions, "SilentTime", "unannotated alias of time.Time must not produce a standalone definition under Default") require.Contains(t, doc.Definitions, "Envelope") @@ -140,9 +134,9 @@ func TestCoverage_AliasStdlibDefault(t *testing.T) { assert.Empty(t, silentField.Ref.String(), "Envelope.silent must be inline; no $ref now that SilentTime is dissolved") - // Raw = json.RawMessage → recognizeRawMessage produces the open - // "any JSON" shape (target.Schema() with no Typed() call). The - // emitted Raw definition has no `type` keyword — same as `any`. + // Raw = json.RawMessage → recognizeRawMessage produces the open "any JSON" shape + // (target.Schema() with no Typed() call). + // The emitted Raw definition has no `type` keyword — same as `any`. // The shape is recognizer-canonical even if visually ambiguous. require.Contains(t, doc.Definitions, "Raw") raw := doc.Definitions["Raw"] diff --git a/internal/integration/coverage_alias_response_shapes_test.go b/internal/integration/coverage_alias_response_shapes_test.go index d4ff081a..34e5aa97 100644 --- a/internal/integration/coverage_alias_response_shapes_test.go +++ b/internal/integration/coverage_alias_response_shapes_test.go @@ -12,15 +12,15 @@ import ( "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. +// 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/..."}, @@ -34,17 +34,16 @@ func TestCoverage_AliasResponseShapes_Default(t *testing.T) { 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). + // 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") - // Bidirectional companion — the annotated alias preserves its - // identity at the body response site; the unannotated alias - // above dissolves to canonical. + // Bidirectional companion — the annotated alias preserves its identity at the body response + // site; the unannotated alias above dissolves to canonical. modeledRef := doc.Responses["bodyAliasModeledResponse"].Schema.Ref.String() assert.Equal(t, "#/definitions/EnvelopeAliasModeled", modeledRef, "annotated body response alias keeps the alias name in the $ref") @@ -54,11 +53,11 @@ func TestCoverage_AliasResponseShapes_Default(t *testing.T) { 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. +// 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/..."}, @@ -75,12 +74,10 @@ func TestCoverage_AliasResponseShapes_Ref(t *testing.T) { 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). +// 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/..."}, diff --git a/internal/integration/coverage_alias_responses_calibration_test.go b/internal/integration/coverage_alias_responses_calibration_test.go index d53243ca..739c381a 100644 --- a/internal/integration/coverage_alias_responses_calibration_test.go +++ b/internal/integration/coverage_alias_responses_calibration_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// Calibration coverage for the responses builder's alias-handling -// contract. The three tests below scan the calibration fixture -// under all three alias modes (Default, RefAliases, -// TransparentAliases) and pin both inline assertions and goldens. +// Calibration coverage for the responses builder's alias-handling contract. +// The three tests below scan the calibration fixture under all three alias modes (Default, +// RefAliases, TransparentAliases) and pin both inline assertions and goldens. // // The fixture deliberately includes: // @@ -26,8 +25,7 @@ import ( // - a non-body (header) field typed as an unannotated alias of a // named primitive (SimpleSchema target — always inline). // -// See [§alias-handling](../builders/responses/README.md#alias-handling) -// for the contract. +// See [§alias-handling](../builders/responses/README.md#alias-handling) for the contract. func TestCoverage_AliasResponsesCalibration_Default(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ @@ -38,11 +36,11 @@ func TestCoverage_AliasResponsesCalibration_Default(t *testing.T) { require.NoError(t, err) require.NotNil(t, doc) - // Top-level `swagger:response` alias — neither the alias nor - // its unexported backing struct surface as model definitions. - // The aliasedTopResponse still gets a valid body schema (the - // canonical Envelope) — earlier states crashed in Default with - // "anonymous types are currently not supported" or attached an + // Top-level `swagger:response` alias — neither the alias nor its unexported backing struct + // surface as model definitions. + // + // The aliasedTopResponse still gets a valid body schema (the canonical Envelope) — earlier + // states crashed in Default with "anonymous types are currently not supported" or attached an // empty schema; both gone. assert.NotContains(t, doc.Definitions, "AliasedTopResponse", "top-level swagger:response alias must not produce a definition") @@ -90,10 +88,9 @@ func TestCoverage_AliasResponsesCalibration_Ref(t *testing.T) { require.NoError(t, err) require.NotNil(t, doc) - // Behaviour at use sites is mode-agnostic: the annotation gate - // fires the same way under RefAliases as under Default. The - // mode only affects the alias decl's OWN definition shape, not - // the field $ref target. + // Behaviour at use sites is mode-agnostic: the annotation gate fires the same way under RefAliases + // as under Default. + // The mode only affects the alias decl's OWN definition shape, not the field $ref target. assert.NotContains(t, doc.Definitions, "AliasedTopResponse") assert.NotContains(t, doc.Definitions, "internalResponse") assert.NotContains(t, doc.Definitions, "EnvelopeAlias") @@ -128,10 +125,9 @@ func TestCoverage_AliasResponsesCalibration_Transparent(t *testing.T) { require.NoError(t, err) require.NotNil(t, doc) - // TransparentAliases supersedes the annotation gate at use - // sites (every body alias dissolves to its unaliased target), - // but `swagger:model` still forces decl-level registration — - // annotated aliases keep their definition entry. + // TransparentAliases supersedes the annotation gate at use sites (every body alias dissolves to + // its unaliased target), but `swagger:model` still forces decl-level registration — annotated + // aliases keep their definition entry. assert.NotContains(t, doc.Definitions, "AliasedTopResponse") assert.NotContains(t, doc.Definitions, "internalResponse") assert.NotContains(t, doc.Definitions, "EnvelopeAlias") diff --git a/internal/integration/coverage_bug_1026_test.go b/internal/integration/coverage_bug_1026_test.go index c53bc8eb..8d2e8d1e 100644 --- a/internal/integration/coverage_bug_1026_test.go +++ b/internal/integration/coverage_bug_1026_test.go @@ -12,13 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1026 locks the resolution to go-swagger issue #1026 -// ("suggestion: x-logo feature"): no dedicated knob is needed — a custom vendor -// extension (x-logo) is declared in the swagger:meta block via InfoExtensions -// and lands on the spec's info object. +// TestCoverage_Bug1026 locks the resolution to go-swagger issue #1026 ("suggestion: x-logo +// feature"): no dedicated knob is needed — a custom vendor extension (x-logo) is declared in the +// swagger:meta block via InfoExtensions and lands on the spec's info object. // -// 📖 Need doc: document InfoExtensions / Extensions for arbitrary x-* vendor -// extensions on info / the spec root. +// 📖 Need doc: document InfoExtensions / Extensions for arbitrary x-* vendor extensions on info / +// the spec root. func TestCoverage_Bug1026(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1026/..."}, diff --git a/internal/integration/coverage_bug_1063_test.go b/internal/integration/coverage_bug_1063_test.go index 2261c265..f966b0c9 100644 --- a/internal/integration/coverage_bug_1063_test.go +++ b/internal/integration/coverage_bug_1063_test.go @@ -12,13 +12,11 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1063 locks the resolution to go-swagger issue #1063 ("making -// a model readOnly"): a `// read only: true` annotation emits `readOnly: true` -// on the property. +// TestCoverage_Bug1063 locks the resolution to go-swagger issue #1063 ("making a model readOnly"): +// a `// read only: true` annotation emits `readOnly: true` on the property. // -// 📖 Need doc: readOnly is a spec-level marker; excluding the field from request -// bodies is the responsibility of downstream code generation / validation, not -// the spec scanner. +// 📖 Need doc: readOnly is a spec-level marker; excluding the field from request bodies is the +// responsibility of downstream code generation / validation, not the spec scanner. func TestCoverage_Bug1063(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1063/..."}, diff --git a/internal/integration/coverage_bug_1078_test.go b/internal/integration/coverage_bug_1078_test.go index 157fa0f4..ef2cf679 100644 --- a/internal/integration/coverage_bug_1078_test.go +++ b/internal/integration/coverage_bug_1078_test.go @@ -12,9 +12,8 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1078 locks the fix for go-swagger issue #1078 ("fails to -// generate JSON schema for time fields"): a time.Time field is documented as -// {type: string, format: date-time}. +// TestCoverage_Bug1078 locks the fix for go-swagger issue #1078 ("fails to generate JSON schema for +// time fields"): a time.Time field is documented as {type: string, format: date-time}. func TestCoverage_Bug1078(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1078/..."}, diff --git a/internal/integration/coverage_bug_1079_test.go b/internal/integration/coverage_bug_1079_test.go index a763846e..b3aa76c9 100644 --- a/internal/integration/coverage_bug_1079_test.go +++ b/internal/integration/coverage_bug_1079_test.go @@ -12,14 +12,14 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1079 documents the resolution to go-swagger issue #1079 ("don't -// generate schemas for models without swagger:model"): under -m, codescan emits -// the annotated model plus the types it transitively references — but NOT -// unreferenced non-annotated types. So no "junk schemas" appear; a referenced -// type must still be emitted to keep the $ref resolvable. +// TestCoverage_Bug1079 documents the resolution to go-swagger issue #1079 ("don't generate schemas +// for models without swagger:model"): under -m, codescan emits the annotated model plus the types +// it transitively references — but NOT unreferenced non-annotated types. // -// 📖 Need doc: clarify -m semantics (annotated models + referenced types, not -// every struct in the package). +// So no "junk schemas" appear; a referenced type must still be emitted to keep the $ref resolvable. +// +// 📖 Need doc: clarify -m semantics (annotated models + referenced types, not every struct in the +// package). func TestCoverage_Bug1079(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1079/..."}, diff --git a/internal/integration/coverage_bug_1088_test.go b/internal/integration/coverage_bug_1088_test.go index 0ac5c642..25c54df6 100644 --- a/internal/integration/coverage_bug_1088_test.go +++ b/internal/integration/coverage_bug_1088_test.go @@ -14,8 +14,8 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1088 locks the fix for go-swagger issue #1088 ("editor error -// for array parameters"). +// TestCoverage_Bug1088 locks the fix for go-swagger issue #1088 ("editor error for array +// parameters"). // // An array IS valid in a query parameter / response header, but its `items` // form a Swagger 2.0 *simple schema*, which may not contain a $ref. The scanner @@ -26,9 +26,8 @@ import ( // dissolves to an empty items schema with a diagnostic, and must NOT leave // an orphan definition behind. // -// The fix lives in the shared resolvers.ItemsTypable (now a -// schema.SimpleSchemaProbe), so the identical constraint on response headers is -// fixed by the same change. +// The fix lives in the shared resolvers.ItemsTypable (now a schema.SimpleSchemaProbe), so the +// identical constraint on response headers is fixed by the same change. func TestCoverage_Bug1088(t *testing.T) { var diagnostics []grammar.Diagnostic @@ -79,14 +78,12 @@ func TestCoverage_Bug1088(t *testing.T) { assert.Empty(t, xObjs.Items.Ref.String(), "an object response-header array item must not carry a $ref (go-swagger#1088)") - // --- no orphan definition --- - // The dissolved $ref must not leave `Ele` lingering in definitions: nothing - // references it once the array items are emptied. + // --- no orphan definition --- The dissolved $ref must not leave `Ele` lingering in definitions: + // nothing references it once the array items are emptied. assert.NotContains(t, doc.Definitions, "Ele", "a dissolved simple-schema $ref must not leave an orphan definition") - // --- diagnostics --- - // Each unrepresentable object element emits a located diagnostic. + // --- diagnostics --- Each unrepresentable object element emits a located diagnostic. var objectViolations int for _, d := range diagnostics { if d.Code != grammar.CodeUnsupportedInSimpleSchema { diff --git a/internal/integration/coverage_bug_1092_test.go b/internal/integration/coverage_bug_1092_test.go index 781be2f9..bdf5fc7e 100644 --- a/internal/integration/coverage_bug_1092_test.go +++ b/internal/integration/coverage_bug_1092_test.go @@ -13,10 +13,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1092 locks go-swagger issue #1092 ("mapping values are not -// allowed in this context"): a swagger:meta block with colon-bearing prose -// (host:port, ratios, URLs) parses cleanly — the prose stays in the description -// and the real Version field is read; no YAML "mapping values" error. +// TestCoverage_Bug1092 locks go-swagger issue #1092 ("mapping values are not allowed in this +// context"): a swagger:meta block with colon-bearing prose (host:port, ratios, URLs) parses cleanly +// — the prose stays in the description and the real Version field is read; no YAML "mapping +// values" error. func TestCoverage_Bug1092(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1092/..."}, WorkDir: scantest.FixturesDir(), diff --git a/internal/integration/coverage_bug_1096_test.go b/internal/integration/coverage_bug_1096_test.go index 626f19ec..9f9d9032 100644 --- a/internal/integration/coverage_bug_1096_test.go +++ b/internal/integration/coverage_bug_1096_test.go @@ -14,11 +14,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1096 locks the fix for go-swagger issue #1096 ("annotated go -// code fails to generate spec when using cgo"): a package that imports "C" used -// to yield an empty spec; it now scans normally via go/packages. Guarded by the -// cgo build tag so it is skipped where cgo is unavailable (the fixture is -// likewise cgo-tagged). +// TestCoverage_Bug1096 locks the fix for go-swagger issue #1096 ("annotated go code fails to +// generate spec when using cgo"): a package that imports "C" used to yield an empty spec; it now +// scans normally via go/packages. +// +// Guarded by the cgo build tag so it is skipped where cgo is unavailable (the fixture is likewise +// cgo-tagged). func TestCoverage_Bug1096(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1096/..."}, diff --git a/internal/integration/coverage_bug_1109_test.go b/internal/integration/coverage_bug_1109_test.go index 8ece9a28..78cb9149 100644 --- a/internal/integration/coverage_bug_1109_test.go +++ b/internal/integration/coverage_bug_1109_test.go @@ -14,13 +14,13 @@ import ( oaispec "github.com/go-openapi/spec" ) -// TestCoverage_Bug1109 documents the behaviour behind go-swagger issue -// #1109 ("generates invalid swagger.json when returning response is -// model"). Per @casualjim this is expected behaviour, not a codegen bug: -// `200: ` resolves a swagger:response by name, and a route that -// references a swagger:model by that name needs -m (ScanModels) for the -// model to be discovered. The original invalid-spec symptom (a dangling -// `$ref: #/responses/order`) no longer occurs. +// TestCoverage_Bug1109 documents the behaviour behind go-swagger issue #1109 ("generates invalid +// swagger.json when returning response is model"). +// +// Per @casualjim this is expected behaviour, not a codegen bug: `200: ` resolves a +// swagger:response by name, and a route that references a swagger:model by that name needs -m +// (ScanModels) for the model to be discovered. +// The original invalid-spec symptom (a dangling `$ref: #/responses/order`) no longer occurs. // // This test pins the two working forms (both with -m): // @@ -28,8 +28,8 @@ import ( // definition-fallback (model name promoted to a body $ref), and // - casualjim's explicit `200: body:order` form. // -// It is flagged "Need doc" in the backlog: the doc site should explain the -// `-m` requirement and the swagger:response-vs-model name resolution. +// It is flagged "Need doc" in the backlog: the doc site should explain the `-m` requirement and the +// swagger:response-vs-model name resolution. func TestCoverage_Bug1109(t *testing.T) { t.Run("OP form `200: order` resolves via definition-fallback with -m", func(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ @@ -58,9 +58,9 @@ func TestCoverage_Bug1109(t *testing.T) { }) } -// assertOrderBodyResponse checks the 200 response carries a body schema -// $ref to the order definition, and that the definition is emitted — i.e. -// no dangling #/responses/order and no missing definitions block. +// assertOrderBodyResponse checks the 200 response carries a body schema $ref to the order +// definition, and that the definition is emitted — i.e. no dangling #/responses/order and no +// missing definitions block. func assertOrderBodyResponse(t *testing.T, doc *oaispec.Swagger) { t.Helper() diff --git a/internal/integration/coverage_bug_1115_test.go b/internal/integration/coverage_bug_1115_test.go index b6881baa..e1e9f315 100644 --- a/internal/integration/coverage_bug_1115_test.go +++ b/internal/integration/coverage_bug_1115_test.go @@ -12,10 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1115 locks the fix for go-swagger issue #1115 ("Missing parser -// for a *ast.StarExpr"): a type aliased to a POINTER (type BarResponse *Thing) -// used to warn and be omitted. The grammar2 scanner handles the StarExpr — the -// pointer is dereferenced and the alias emits a $ref like the non-pointer form. +// TestCoverage_Bug1115 locks the fix for go-swagger issue #1115 ("Missing parser for a +// *ast.StarExpr"): a type aliased to a POINTER (type BarResponse *Thing) used to warn and be +// omitted. +// +// The grammar2 scanner handles the StarExpr — the pointer is dereferenced and the alias emits a +// $ref like the non-pointer form. func TestCoverage_Bug1115(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1115/..."}, diff --git a/internal/integration/coverage_bug_1117_test.go b/internal/integration/coverage_bug_1117_test.go index e9252eef..f884dd02 100644 --- a/internal/integration/coverage_bug_1117_test.go +++ b/internal/integration/coverage_bug_1117_test.go @@ -12,9 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1117 documents the answer to go-swagger issue #1117 ("how to -// define response as array type"): give the swagger:response an in:body field of -// a slice type; the response schema becomes {type: array, items: {$ref}}. +// TestCoverage_Bug1117 documents the answer to go-swagger issue #1117 ("how to define response as +// array type"): give the swagger:response an in:body field of a slice type; the response schema +// becomes {type: array, items: {$ref}}. // // 📖 Need doc: array-body response recipe (in:body Body []T). func TestCoverage_Bug1117(t *testing.T) { diff --git a/internal/integration/coverage_bug_1118_test.go b/internal/integration/coverage_bug_1118_test.go index 97a93608..dd501eaa 100644 --- a/internal/integration/coverage_bug_1118_test.go +++ b/internal/integration/coverage_bug_1118_test.go @@ -12,12 +12,15 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1118 documents (current behaviour) go-swagger issue #1118 -// ("sometimes title, sometimes description"). This is the same title/description -// heuristic as #2626: a single-line model comment ending in a period becomes the -// title; without a trailing period it becomes the description; a multi-line -// comment splits into title (first line) + description (rest). Kept as-is — an -// opt-in knob to force description-only is tracked as forthcoming-features.md §13. +// TestCoverage_Bug1118 documents (current behaviour) go-swagger issue #1118 ("sometimes title, +// sometimes description"). +// +// This is the same title/description heuristic as #2626: a single-line model comment ending in a +// period becomes the title; without a trailing period it becomes the description; a multi-line +// comment splits into title (first line) + description (rest). +// +// Kept as-is — an opt-in knob to force description-only is tracked as forthcoming-features.md +// §13. // // 📖 Need doc: document the title/description heuristic (see #2626). func TestCoverage_Bug1118(t *testing.T) { diff --git a/internal/integration/coverage_bug_1121_test.go b/internal/integration/coverage_bug_1121_test.go index 50c6f5f2..940ce712 100644 --- a/internal/integration/coverage_bug_1121_test.go +++ b/internal/integration/coverage_bug_1121_test.go @@ -12,9 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1121 locks the answer to go-swagger issue #1121 ("how to add -// description to tags"): a `Tags:` block in the swagger:meta annotation emits the -// OAI root-level tags section with per-tag description (and externalDocs). +// TestCoverage_Bug1121 locks the answer to go-swagger issue #1121 ("how to add description to +// tags"): a `Tags:` block in the swagger:meta annotation emits the OAI root-level tags section with +// per-tag description (and externalDocs). // // 📖 Need doc: document the swagger:meta Tags: block for tag descriptions. func TestCoverage_Bug1121(t *testing.T) { diff --git a/internal/integration/coverage_bug_1133_test.go b/internal/integration/coverage_bug_1133_test.go index ab57d7cf..4fbb8f36 100644 --- a/internal/integration/coverage_bug_1133_test.go +++ b/internal/integration/coverage_bug_1133_test.go @@ -12,10 +12,11 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1133 locks the fix for go-swagger issues #1133 and #1174 ("scan -// models chokes on unsupported type"): an unsupported (function) type in the -// scanned package — even one used by a non-annotated struct — no longer halts the -// whole scan. It is warned and skipped; the annotated model is still emitted. +// TestCoverage_Bug1133 locks the fix for go-swagger issues #1133 and #1174 ("scan models chokes on +// unsupported type"): an unsupported (function) type in the scanned package — even one used by a +// non-annotated struct — no longer halts the whole scan. +// +// It is warned and skipped; the annotated model is still emitted. func TestCoverage_Bug1133(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1133/..."}, diff --git a/internal/integration/coverage_bug_1267_test.go b/internal/integration/coverage_bug_1267_test.go index dbf019b5..480b26d2 100644 --- a/internal/integration/coverage_bug_1267_test.go +++ b/internal/integration/coverage_bug_1267_test.go @@ -12,9 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1267 locks go-swagger issue #1267 ("response files?"): a -// file-download response is described with produces: application/octet-stream and -// a body field marked swagger:strfmt binary -> {type:string, format:binary}. +// TestCoverage_Bug1267 locks go-swagger issue #1267 ("response files?"): a file-download response +// is described with produces: application/octet-stream and a body field marked swagger:strfmt +// binary -> {type:string, format:binary}. func TestCoverage_Bug1267(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1267/..."}, WorkDir: scantest.FixturesDir(), diff --git a/internal/integration/coverage_bug_1279_test.go b/internal/integration/coverage_bug_1279_test.go index 86dd3f4e..3bcdd670 100644 --- a/internal/integration/coverage_bug_1279_test.go +++ b/internal/integration/coverage_bug_1279_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1279 locks the answer to go-swagger issue #1279 ("add path -// parameters to swagger:route without using structs"): an inline `Parameters:` -// block inside the swagger:route comment declares parameters directly — no -// wrapper swagger:parameters struct required. +// TestCoverage_Bug1279 locks the answer to go-swagger issue #1279 ("add path parameters to +// swagger:route without using structs"): an inline `Parameters:` block inside the swagger:route +// comment declares parameters directly — no wrapper swagger:parameters struct required. // // 📖 Need doc: document the inline Parameters: block in swagger:route. func TestCoverage_Bug1279(t *testing.T) { diff --git a/internal/integration/coverage_bug_1395_test.go b/internal/integration/coverage_bug_1395_test.go index 0a131e58..ce18d04e 100644 --- a/internal/integration/coverage_bug_1395_test.go +++ b/internal/integration/coverage_bug_1395_test.go @@ -12,12 +12,13 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1395 locks go-swagger issue #1395: the OP's tab-indented -// swagger:meta with a Security requirement + SecurityDefinitions now parses -// cleanly into a correct security requirement and apiKey definition. The -// original report (corrupted definitions) was a misclassified security entry, -// resolved by the meta-Security parsing fixes (cf. #2403). The OP's -// "SecurityDefinition" (singular) was a keyword typo. +// TestCoverage_Bug1395 locks go-swagger issue #1395: the OP's tab-indented swagger:meta with a +// Security requirement + SecurityDefinitions now parses cleanly into a correct security requirement +// and apiKey definition. +// +// The original report (corrupted definitions) was a misclassified security entry, resolved by the +// meta-Security parsing fixes (cf. #2403). +// The OP's "SecurityDefinition" (singular) was a keyword typo. func TestCoverage_Bug1395(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1395/..."}, WorkDir: scantest.FixturesDir(), diff --git a/internal/integration/coverage_bug_1402_test.go b/internal/integration/coverage_bug_1402_test.go index d496ad26..dbe88418 100644 --- a/internal/integration/coverage_bug_1402_test.go +++ b/internal/integration/coverage_bug_1402_test.go @@ -13,9 +13,8 @@ import ( ) // TestCoverage_Bug1402 locks go-swagger issue #1402 ("additionalProperties breaks -// map[string]interface{}"): the field renders as {type:object, -// additionalProperties:{}} — an OPEN value schema (any), not one wrongly -// constrained to object. +// map[string]interface{}"): the field renders as {type:object, additionalProperties:{}} — an OPEN +// value schema (any), not one wrongly constrained to object. func TestCoverage_Bug1402(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1402/..."}, WorkDir: scantest.FixturesDir(), ScanModels: true, diff --git a/internal/integration/coverage_bug_1416_test.go b/internal/integration/coverage_bug_1416_test.go index 31c8cd73..815a182a 100644 --- a/internal/integration/coverage_bug_1416_test.go +++ b/internal/integration/coverage_bug_1416_test.go @@ -12,11 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1416 locks the fix for go-swagger issue #1416 ("parameters do -// not get properly linked to operations"): the reporter saw a spurious top-level -// $ref emitted alongside the schema on a body parameter (malformed). The body -// param is now clean — {in: body, schema: {$ref}}, no top-level $ref — and links -// to the operation. +// TestCoverage_Bug1416 locks the fix for go-swagger issue #1416 ("parameters do not get properly +// linked to operations"): the reporter saw a spurious top-level $ref emitted alongside the schema +// on a body parameter (malformed). +// +// The body param is now clean — {in: body, schema: {$ref}}, no top-level $ref — and links to +// the operation. func TestCoverage_Bug1416(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1416/..."}, diff --git a/internal/integration/coverage_bug_1436_test.go b/internal/integration/coverage_bug_1436_test.go index e0698338..33a22f5e 100644 --- a/internal/integration/coverage_bug_1436_test.go +++ b/internal/integration/coverage_bug_1436_test.go @@ -12,10 +12,11 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1436 locks go-swagger issue #1436 (the -m semantics): without -// --scan-models, only route-reachable models are emitted; with -m, every -// swagger:model is emitted (including standalone ones). Works-as-designed; the -// "prune unreferenced under -m" middle ground is forthcoming-features §12. +// TestCoverage_Bug1436 locks go-swagger issue #1436 (the -m semantics): without --scan-models, only +// route-reachable models are emitted; with -m, every swagger:model is emitted (including standalone +// ones). +// +// Works-as-designed; the "prune unreferenced under -m" middle ground is forthcoming-features §12. func TestCoverage_Bug1436(t *testing.T) { run := func(m bool) map[string]struct{} { doc, err := codescan.Run(&codescan.Options{ diff --git a/internal/integration/coverage_bug_1459_test.go b/internal/integration/coverage_bug_1459_test.go index 119d5d9d..a17bbff3 100644 --- a/internal/integration/coverage_bug_1459_test.go +++ b/internal/integration/coverage_bug_1459_test.go @@ -12,11 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1459 locks go-swagger issue #1459 ("example values on map keys -// instead of AdditionalProp"): a map field accepts an `example` object with -// meaningful keys, which the spec carries on the additionalProperties schema. -// The misleading additionalProp1/2/3 keys are only the Swagger UI's fallback -// when no example is provided. +// TestCoverage_Bug1459 locks go-swagger issue #1459 ("example values on map keys instead of +// AdditionalProp"): a map field accepts an `example` object with meaningful keys, which the spec +// carries on the additionalProperties schema. +// +// The misleading additionalProp1/2/3 keys are only the Swagger UI's fallback when no example is +// provided. func TestCoverage_Bug1459(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1459/..."}, WorkDir: scantest.FixturesDir(), ScanModels: true, diff --git a/internal/integration/coverage_bug_1483_test.go b/internal/integration/coverage_bug_1483_test.go index 2dbcd047..16c2d2bf 100644 --- a/internal/integration/coverage_bug_1483_test.go +++ b/internal/integration/coverage_bug_1483_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1483 locks go-swagger issue #1483 ("items doesn't support maps -// whilst using map[string]string"): a map[string]string field in a body -// parameter renders as {type:object, additionalProperties:{type:string}} — no -// "items doesn't support maps" error. +// TestCoverage_Bug1483 locks go-swagger issue #1483 ("items doesn't support maps whilst using +// map[string]string"): a map[string]string field in a body parameter renders as {type:object, +// additionalProperties:{type:string}} — no "items doesn't support maps" error. func TestCoverage_Bug1483(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1483/..."}, WorkDir: scantest.FixturesDir(), diff --git a/internal/integration/coverage_bug_1499_test.go b/internal/integration/coverage_bug_1499_test.go index 66ab7b1c..57d96726 100644 --- a/internal/integration/coverage_bug_1499_test.go +++ b/internal/integration/coverage_bug_1499_test.go @@ -13,16 +13,16 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1499 locks the fix for go-swagger issue #1499 ("not possible -// to override parameter schema type"). +// TestCoverage_Bug1499 locks the fix for go-swagger issue #1499 ("not possible to override +// parameter schema type"). // -// A `swagger:type` override on a swagger:parameters field whose Go type is a -// struct must make the parameter that simple type (here `string`). The schema -// (model) builder already honours this (#2419/#2184), but the PARAMETERS builder -// used to ignore it — the param came out typeless, which is invalid Swagger 2.0. +// A `swagger:type` override on a swagger:parameters field whose Go type is a struct must make the +// parameter that simple type (here `string`). +// The schema (model) builder already honours this (#2419/#2184), but the PARAMETERS builder used to +// ignore it — the param came out typeless, which is invalid Swagger 2.0. // -// The `[]`-prefixed form collapses a Go struct slice to a simple -// array-of-scalar query parameter, mirroring the schema builder's array layers. +// The `[]`-prefixed form collapses a Go struct slice to a simple array-of-scalar query parameter, +// mirroring the schema builder's array layers. func TestCoverage_Bug1499(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1499/..."}, diff --git a/internal/integration/coverage_bug_1512_test.go b/internal/integration/coverage_bug_1512_test.go index 78ed3764..06c5d9dd 100644 --- a/internal/integration/coverage_bug_1512_test.go +++ b/internal/integration/coverage_bug_1512_test.go @@ -12,15 +12,16 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1512 documents the resolution to go-swagger issue #1512 -// ("invalid integer formats (uint64)"): codescan emits the Go-specific uint -// formats (uint64 / uint32) by design — a vendor convention that round-trips -// back to Go. Works as designed. For OAI-conformant / precision-safe output, a -// field is overridden with `swagger:strfmt int64`, which yields the -// string-encoded {type: string, format: int64} representation. +// TestCoverage_Bug1512 documents the resolution to go-swagger issue #1512 ("invalid integer formats +// (uint64)"): codescan emits the Go-specific uint formats (uint64 / uint32) by design — a vendor +// convention that round-trips back to Go. // -// 📖 Need doc: the uint* formats are intentional; use swagger:strfmt int64 to -// emit a conformant string-encoded int64. +// Works as designed. +// For OAI-conformant / precision-safe output, a field is overridden with `swagger:strfmt int64`, +// which yields the string-encoded {type: string, format: int64} representation. +// +// 📖 Need doc: the uint* formats are intentional; use swagger:strfmt int64 to emit a conformant +// string-encoded int64. func TestCoverage_Bug1512(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1512/..."}, diff --git a/internal/integration/coverage_bug_1542_test.go b/internal/integration/coverage_bug_1542_test.go index 5228b04d..85963c90 100644 --- a/internal/integration/coverage_bug_1542_test.go +++ b/internal/integration/coverage_bug_1542_test.go @@ -12,13 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1542 locks the resolution to go-swagger issue #1542 ("inserting -// examples in response schema"): an example on a scalar field is applied, and an -// example on a map field is applied as an object — provided the map example is -// written as valid JSON (`{"k":"v"}`). +// TestCoverage_Bug1542 locks the resolution to go-swagger issue #1542 ("inserting examples in +// response schema"): an example on a scalar field is applied, and an example on a map field is +// applied as an object — provided the map example is written as valid JSON (`{"k":"v"}`). // -// 📖 Need doc: complex (map/object) examples must be written as valid JSON on the -// example: line. (Comma-list / array coercion remains the §2.1 enhancement.) +// 📖 Need doc: complex (map/object) examples must be written as valid JSON on the example: line. +// (Comma-list / array coercion remains the §2.1 enhancement.) func TestCoverage_Bug1542(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1542/..."}, diff --git a/internal/integration/coverage_bug_1587_test.go b/internal/integration/coverage_bug_1587_test.go index 92241af2..cc45e9ab 100644 --- a/internal/integration/coverage_bug_1587_test.go +++ b/internal/integration/coverage_bug_1587_test.go @@ -12,11 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1587 locks the fix for go-swagger issue #1587 ("import detection -// fails if package path does not match package name"): a field referencing a type -// from a package whose import-path tail (josev2) differs from its package name -// (jose) used to fail with "no import found for jose". go/packages resolves it by -// the real package name, so the $ref and its definition are emitted. +// TestCoverage_Bug1587 locks the fix for go-swagger issue #1587 ("import detection fails if package +// path does not match package name"): a field referencing a type from a package whose import-path +// tail (josev2) differs from its package name (jose) used to fail with "no import found for jose". +// go/packages resolves it by the real package name, so the $ref and its definition are emitted. func TestCoverage_Bug1587(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1587/..."}, diff --git a/internal/integration/coverage_bug_1595_test.go b/internal/integration/coverage_bug_1595_test.go index 39f86c47..95890a51 100644 --- a/internal/integration/coverage_bug_1595_test.go +++ b/internal/integration/coverage_bug_1595_test.go @@ -12,16 +12,16 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1595 locks the fix for go-swagger issue #1595 -// ("multi-line/block instead of single-line comments"). +// TestCoverage_Bug1595 locks the fix for go-swagger issue #1595 ("multi-line/block instead of +// single-line comments"). // -// A swagger:operation written in a /* */ block comment (a legal Go doc comment) -// used to fail the whole scan with a YAML parse error ("did not find expected -// alphabetic or numeric character"): the path-annotation parser reshaped the -// block comment line-by-line and let the closing */ leak into the YAML body, -// where it reads as a YAML alias indicator. Both the flush-left block style and -// the idiomatic `*`-decorated style must parse like their // equivalent — the -// path with a 200 response — without erroring. +// A swagger:operation written in a /* */ block comment (a legal Go doc comment) used to fail the +// whole scan with a YAML parse error ("did not find expected alphabetic or numeric character"): the +// path-annotation parser reshaped the block comment line-by-line and let the closing */ leak into +// the YAML body, where it reads as a YAML alias indicator. +// +// Both the flush-left block style and the idiomatic `*`-decorated style must parse like their // +// equivalent — the path with a 200 response — without erroring. func TestCoverage_Bug1595(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1595/..."}, diff --git a/internal/integration/coverage_bug_1609_test.go b/internal/integration/coverage_bug_1609_test.go index 6ff10629..0a9015f5 100644 --- a/internal/integration/coverage_bug_1609_test.go +++ b/internal/integration/coverage_bug_1609_test.go @@ -12,14 +12,14 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1609 locks the resolution to go-swagger issue #1609 ("vendor -// extension x-example"): x-* vendor extensions are emitted on both parameters and -// response headers when declared via an Extensions: block (mirroring -// swagger:meta's InfoExtensions / Extensions). Both round-trip into the marshaled -// spec. +// TestCoverage_Bug1609 locks the resolution to go-swagger issue #1609 ("vendor extension +// x-example"): x-* vendor extensions are emitted on both parameters and response headers when +// declared via an Extensions: block (mirroring swagger:meta's InfoExtensions / Extensions). // -// 📖 Need doc: a bare `// x-example: …` line is swallowed as the description; the -// supported form is an Extensions: block on the parameter / header. +// Both round-trip into the marshaled spec. +// +// 📖 Need doc: a bare `// x-example: …` line is swallowed as the description; the supported +// form is an Extensions: block on the parameter / header. func TestCoverage_Bug1609(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1609/..."}, diff --git a/internal/integration/coverage_bug_1613_test.go b/internal/integration/coverage_bug_1613_test.go index 78adb7cc..17fea385 100644 --- a/internal/integration/coverage_bug_1613_test.go +++ b/internal/integration/coverage_bug_1613_test.go @@ -12,10 +12,11 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1613 locks the fix for go-swagger issue #1613 ("generate spec -// for response with string content type"): a swagger:response with a plain string -// in:body field used to emit a response with no schema (description only). It now -// emits schema {type: string} (the primitive-body work, #2942). +// TestCoverage_Bug1613 locks the fix for go-swagger issue #1613 ("generate spec for response with +// string content type"): a swagger:response with a plain string in:body field used to emit a +// response with no schema (description only). +// +// It now emits schema {type: string} (the primitive-body work, #2942). func TestCoverage_Bug1613(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1613/..."}, diff --git a/internal/integration/coverage_bug_1635_test.go b/internal/integration/coverage_bug_1635_test.go index 2950ea29..88aa5f52 100644 --- a/internal/integration/coverage_bug_1635_test.go +++ b/internal/integration/coverage_bug_1635_test.go @@ -12,15 +12,16 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1635 asserts the resolution of go-swagger issue #1635 ("spec -// response generation without inner struct"). +// TestCoverage_Bug1635 asserts the resolution of go-swagger issue #1635 ("spec response generation +// without inner struct"). // -// A swagger:response (or swagger:parameters) that ANONYMOUSLY embeds a named -// struct marked in:body must treat the embed AS the body — exactly like a named -// `Body Foo` field — instead of promoting the embedded struct's fields. For -// responses the buggy form put the promoted fields under `headers`; for -// parameters it emitted one body parameter per promoted field (invalid OAS2, -// which allows at most one body parameter per operation). +// A swagger:response (or swagger:parameters) that ANONYMOUSLY embeds a named struct marked in:body +// must treat the embed AS the body — exactly like a named `Body Foo` field — instead of +// promoting the embedded struct's fields. +// +// For responses the buggy form put the promoted fields under `headers`; for parameters it emitted +// one body parameter per promoted field (invalid OAS2, which allows at most one body parameter per +// operation). func TestCoverage_Bug1635(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1635/..."}, @@ -31,31 +32,28 @@ func TestCoverage_Bug1635(t *testing.T) { const bodyRef = "#/definitions/PositionResponseBody" - // --- responses --- - // Guard rail: the named Body field form already yields a $ref body schema. + // --- responses --- Guard rail: the named Body field form already yields a $ref body schema. named := doc.Responses["namedBodyResponse"].Schema require.NotNil(t, named) assert.Equal(t, bodyRef, named.Ref.String()) - // The #1635 defect: the anonymous-embed body must become a body schema, not - // response headers. + // The #1635 defect: the anonymous-embed body must become a body schema, not response headers. embed := doc.Responses["positionResponse"] assert.Empty(t, embed.Headers, "the embedded body fields must not become response headers (go-swagger#1635)") require.NotNil(t, embed.Schema, "the anonymous-embed body must produce a schema (go-swagger#1635)") assert.Equal(t, bodyRef, embed.Schema.Ref.String(), "the anonymous-embed body should $ref the embedded struct, like the named form") - // --- parameters (same rule) --- - // Guard rail: the named Body field form yields a single body parameter. + // --- parameters (same rule) --- Guard rail: the named Body field form yields a single body + // parameter. namedParams := doc.Paths.Paths["/position-named"].Post.Parameters require.Len(t, namedParams, 1) require.NotNil(t, namedParams[0].Schema) assert.Equal(t, "body", namedParams[0].In) assert.Equal(t, bodyRef, namedParams[0].Schema.Ref.String()) - // The #1635 defect for parameters: the anonymous-embed body must become a - // SINGLE body parameter $ref'ing the embedded struct, not one body param per - // promoted field. + // The #1635 defect for parameters: the anonymous-embed body must become a SINGLE body parameter + // $ref'ing the embedded struct, not one body param per promoted field. embedParams := doc.Paths.Paths["/position"].Post.Parameters require.Len(t, embedParams, 1, "an in: body embed must produce exactly one body parameter (go-swagger#1635)") assert.Equal(t, "body", embedParams[0].In) diff --git a/internal/integration/coverage_bug_1665_test.go b/internal/integration/coverage_bug_1665_test.go index e5fe332b..39f88796 100644 --- a/internal/integration/coverage_bug_1665_test.go +++ b/internal/integration/coverage_bug_1665_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1665 locks the resolution to go-swagger issue #1665 ("can't -// combine a same annotation across multiple lines"): multiple swagger:parameters -// lines on one type are all honored — the shared param binds to operations listed -// across every line. +// TestCoverage_Bug1665 locks the resolution to go-swagger issue #1665 ("can't combine a same +// annotation across multiple lines"): multiple swagger:parameters lines on one type are all honored +// — the shared param binds to operations listed across every line. func TestCoverage_Bug1665(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1665/..."}, diff --git a/internal/integration/coverage_bug_1708_test.go b/internal/integration/coverage_bug_1708_test.go index abd0032c..b5b1fe66 100644 --- a/internal/integration/coverage_bug_1708_test.go +++ b/internal/integration/coverage_bug_1708_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1708 locks the fix for go-swagger issue #1708 ("response -// annotation missing property type", follow-up of #619): a body response whose -// fields are pointers to other models no longer errors with "missing property -// type" — the fields resolve to $refs in an object schema. +// TestCoverage_Bug1708 locks the fix for go-swagger issue #1708 ("response annotation missing +// property type", follow-up of #619): a body response whose fields are pointers to other models no +// longer errors with "missing property type" — the fields resolve to $refs in an object schema. func TestCoverage_Bug1708(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1708/..."}, diff --git a/internal/integration/coverage_bug_1711_test.go b/internal/integration/coverage_bug_1711_test.go index 5dee9d64..4dfeae68 100644 --- a/internal/integration/coverage_bug_1711_test.go +++ b/internal/integration/coverage_bug_1711_test.go @@ -12,13 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1711 documents the resolution to go-swagger issue #1711 ("how -// to add a different description to parameters"): a parameter's description is the -// comment text, kept distinct from the Go field name (carried separately as the -// x-go-name vendor extension). +// TestCoverage_Bug1711 documents the resolution to go-swagger issue #1711 ("how to add a different +// description to parameters"): a parameter's description is the comment text, kept distinct from +// the Go field name (carried separately as the x-go-name vendor extension). // -// 📖 Need doc: the param description comes from the field comment; x-go-name is a -// separate vendor extension, not concatenated into the description. +// 📖 Need doc: the param description comes from the field comment; x-go-name is a separate vendor +// extension, not concatenated into the description. func TestCoverage_Bug1711(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1711/..."}, diff --git a/internal/integration/coverage_bug_1713_test.go b/internal/integration/coverage_bug_1713_test.go index 2999b837..890db760 100644 --- a/internal/integration/coverage_bug_1713_test.go +++ b/internal/integration/coverage_bug_1713_test.go @@ -12,12 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1713 locks the answer to go-swagger issue #1713 ("how to label -// the example Response & Request"): response `examples:` keyed by mime type are -// supported in the swagger:operation YAML body. +// TestCoverage_Bug1713 locks the answer to go-swagger issue #1713 ("how to label the example +// Response & Request"): response `examples:` keyed by mime type are supported in the +// swagger:operation YAML body. // -// (The struct-based swagger:response example-by-mime form is the separate -// forthcoming feature §10 / #2871.) +// (The struct-based swagger:response example-by-mime form is the separate forthcoming feature §10 +// / #2871.) func TestCoverage_Bug1713(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1713/..."}, diff --git a/internal/integration/coverage_bug_1726_test.go b/internal/integration/coverage_bug_1726_test.go index 243b7640..60e6df5e 100644 --- a/internal/integration/coverage_bug_1726_test.go +++ b/internal/integration/coverage_bug_1726_test.go @@ -12,14 +12,15 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1726 locks the fix for go-swagger#1726 ("How to annotate -// lists?"): markdown-style bullet lists must be identified like the YAML-style -// dash form. The lexer normalises `*`/`+` bullets to the canonical `- `, so the -// relaxation propagates uniformly — both prose descriptions and value lists -// (consumes/produces, via Property.AsList) recognise them. +// TestCoverage_Bug1726 locks the fix for go-swagger#1726 ("How to annotate lists?"): markdown-style +// bullet lists must be identified like the YAML-style dash form. // -// `* item` / `+ item` are normalised to `- item` (matching gofmt's own rewrite -// of doc-comment bullets), so the asserted descriptions carry the dash form. +// The lexer normalises `*`/`+` bullets to the canonical `- `, so the relaxation propagates +// uniformly — both prose descriptions and value lists (consumes/produces, via Property.AsList) +// recognise them. +// +// `* item` / `+ item` are normalised to `- item` (matching gofmt's own rewrite of doc-comment +// bullets), so the asserted descriptions carry the dash form. func TestCoverage_Bug1726(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1726/..."}, @@ -33,8 +34,8 @@ func TestCoverage_Bug1726(t *testing.T) { assert.Contains(t, widget.Description, "- red") assert.Contains(t, widget.Description, "- green") - // Asterisk bullets: identified as a list, normalised to the dash form - // (not stripped to a run-on line, and not left as a stray `*`). + // Asterisk bullets: identified as a list, normalised to the dash form (not stripped to a run-on + // line, and not left as a stray `*`). gadget := doc.Definitions["Gadget"] assert.Contains(t, gadget.Description, "- fast", "asterisk bullets must be identified as a list (go-swagger#1726)") @@ -48,8 +49,8 @@ func TestCoverage_Bug1726(t *testing.T) { "plus bullets must be identified as a list (go-swagger#1726)") assert.Contains(t, gizmo.Description, "- sturdy") - // Wide propagation: the value-list path (Produces/Consumes via AsList) - // recognises markdown bullets too, not just prose descriptions. + // Wide propagation: the value-list path (Produces/Consumes via AsList) recognises markdown bullets + // too, not just prose descriptions. op := doc.Paths.Paths["/widgets"].Get require.NotNil(t, op) assert.ElementsMatch(t, []string{"application/json", "application/xml"}, op.Produces, diff --git a/internal/integration/coverage_bug_1737_test.go b/internal/integration/coverage_bug_1737_test.go index dd150ec5..3b67995c 100644 --- a/internal/integration/coverage_bug_1737_test.go +++ b/internal/integration/coverage_bug_1737_test.go @@ -12,13 +12,13 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1737 locks the answer to go-swagger issue #1737 ("bson.ObjectId -// in swagger:response generates $ref"): a field whose type resolves to a $ref -// (an external named type such as bson.ObjectId) cannot carry a sibling -// description in Swagger 2.0, so by default the field-level description is -// dropped — a $ref node has no room for it. The `DescWithRef` option is the -// supported answer: it wraps the field in `allOf: [{$ref}]` so the description -// survives alongside the reference. +// TestCoverage_Bug1737 locks the answer to go-swagger issue #1737 ("bson.ObjectId in +// swagger:response generates $ref"): a field whose type resolves to a $ref (an external named type +// such as bson.ObjectId) cannot carry a sibling description in Swagger 2.0, so by default the +// field-level description is dropped — a $ref node has no room for it. +// +// The `DescWithRef` option is the supported answer: it wraps the field in `allOf: [{$ref}]` so the +// description survives alongside the reference. func TestCoverage_Bug1737(t *testing.T) { t.Run("default drops the description (bare $ref)", func(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ diff --git a/internal/integration/coverage_bug_1742_test.go b/internal/integration/coverage_bug_1742_test.go index dfc2aa73..9a7c091c 100644 --- a/internal/integration/coverage_bug_1742_test.go +++ b/internal/integration/coverage_bug_1742_test.go @@ -12,15 +12,14 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1742 locks go-swagger issue #1742 ("Parameters"): a -// swagger:parameters struct may live in a DIFFERENT package from the -// swagger:route that references it — as long as both packages are scanned, the -// parameter is bound to the operation by its operation id. +// TestCoverage_Bug1742 locks go-swagger issue #1742 ("Parameters"): a swagger:parameters struct may +// live in a DIFFERENT package from the swagger:route that references it — as long as both +// packages are scanned, the parameter is bound to the operation by its operation id. // -// This fixture also stands as the regression witness for go-swagger #1735: the -// legacy `scan/route_params.go` engine panicked ("assignment to entry in nil -// map") while parsing a route's params. That engine is gone; grammar2 scans a -// route-with-params (here, cross-package) without panicking. +// This fixture also stands as the regression witness for go-swagger #1735: the legacy +// `scan/route_params.go` engine panicked ("assignment to entry in nil map") while parsing a route's +// params. +// That engine is gone; grammar2 scans a route-with-params (here, cross-package) without panicking. func TestCoverage_Bug1742(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1742/..."}, diff --git a/internal/integration/coverage_bug_1828_test.go b/internal/integration/coverage_bug_1828_test.go index d848afd7..a2f8b621 100644 --- a/internal/integration/coverage_bug_1828_test.go +++ b/internal/integration/coverage_bug_1828_test.go @@ -12,11 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1828 locks go-swagger issue #1828 ("route responses tag -// description"): the inline `NNN: description:` form inside a -// swagger:route Responses block must produce a valid response with that -// description. The legacy engine emitted an invalid `$ref: '#/responses/'` -// (empty name); grammar2 parses the inline description correctly. +// TestCoverage_Bug1828 locks go-swagger issue #1828 ("route responses tag description"): the inline +// `NNN: description:` form inside a swagger:route Responses block must produce a valid +// response with that description. +// +// The legacy engine emitted an invalid `$ref: '#/responses/'` (empty name); grammar2 parses the +// inline description correctly. func TestCoverage_Bug1828(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1828/..."}, diff --git a/internal/integration/coverage_bug_1852_test.go b/internal/integration/coverage_bug_1852_test.go index 7ced29cd..d029d71e 100644 --- a/internal/integration/coverage_bug_1852_test.go +++ b/internal/integration/coverage_bug_1852_test.go @@ -14,11 +14,11 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1852 locks go-swagger issue #1852 ("Schema error for delete -// operation"): a 204 response with no description comment must still carry the -// OAS2-required `description` key. The legacy engine omitted it entirely, so -// editor.swagger.io rejected the spec ("missingProperty: description"); codescan -// now always emits the key (empty string when no doc comment is present). +// TestCoverage_Bug1852 locks go-swagger issue #1852 ("Schema error for delete operation"): a 204 +// response with no description comment must still carry the OAS2-required `description` key. +// +// The legacy engine omitted it entirely, so editor.swagger.io rejected the spec ("missingProperty: +// description"); codescan now always emits the key (empty string when no doc comment is present). func TestCoverage_Bug1852(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1852/..."}, @@ -29,8 +29,8 @@ func TestCoverage_Bug1852(t *testing.T) { resp, ok := doc.Responses["noContentResponse"] require.True(t, ok) - // The description key must be emitted even though the response type has no - // doc comment (presence is what OAS2 validation requires). + // The description key must be emitted even though the response type has no doc comment (presence + // is what OAS2 validation requires). b, err := json.Marshal(resp) require.NoError(t, err) assert.True(t, strings.Contains(string(b), `"description"`), diff --git a/internal/integration/coverage_bug_1867_test.go b/internal/integration/coverage_bug_1867_test.go index 55ed4392..752815a9 100644 --- a/internal/integration/coverage_bug_1867_test.go +++ b/internal/integration/coverage_bug_1867_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1867 locks go-swagger issue #1867 ("PATCH operation"): PATCH -// works through BOTH swagger:route (with a body parameter) and swagger:operation -// — the reporter found swagger:operation silently failed and swagger:route could -// not specify the JSON body. +// TestCoverage_Bug1867 locks go-swagger issue #1867 ("PATCH operation"): PATCH works through BOTH +// swagger:route (with a body parameter) and swagger:operation — the reporter found +// swagger:operation silently failed and swagger:route could not specify the JSON body. func TestCoverage_Bug1867(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1867/..."}, diff --git a/internal/integration/coverage_bug_1881_test.go b/internal/integration/coverage_bug_1881_test.go index 01f65e18..0a8e3637 100644 --- a/internal/integration/coverage_bug_1881_test.go +++ b/internal/integration/coverage_bug_1881_test.go @@ -12,9 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1881 locks go-swagger issue #1881 (the array-of-object -// response part): a swagger:response whose body is a slice of a model produces -// a valid `{type: array, items: {$ref}}` schema. +// TestCoverage_Bug1881 locks go-swagger issue #1881 (the array-of-object response part): a +// swagger:response whose body is a slice of a model produces a valid `{type: array, items: {$ref}}` +// schema. func TestCoverage_Bug1881(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1881/..."}, diff --git a/internal/integration/coverage_bug_1887_test.go b/internal/integration/coverage_bug_1887_test.go index baa2abbf..e90ac0bb 100644 --- a/internal/integration/coverage_bug_1887_test.go +++ b/internal/integration/coverage_bug_1887_test.go @@ -12,9 +12,8 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1887 locks go-swagger issue #1887 ("file type support"): the -// `swagger:file` marker on a formData parameter field emits the Swagger 2.0 -// `type: file` parameter. +// TestCoverage_Bug1887 locks go-swagger issue #1887 ("file type support"): the `swagger:file` +// marker on a formData parameter field emits the Swagger 2.0 `type: file` parameter. func TestCoverage_Bug1887(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1887/..."}, diff --git a/internal/integration/coverage_bug_1891_test.go b/internal/integration/coverage_bug_1891_test.go index 96f4c768..0c2f6de2 100644 --- a/internal/integration/coverage_bug_1891_test.go +++ b/internal/integration/coverage_bug_1891_test.go @@ -12,9 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1891 locks go-swagger issue #1891 ("group type"): a model -// declared inside a grouped `type ( ... )` block, plus a swagger:route declared -// inside a function body, are both discovered and produce a valid spec. +// TestCoverage_Bug1891 locks go-swagger issue #1891 ("group type"): a model declared inside a +// grouped `type ( ... )` block, plus a swagger:route declared inside a function body, are both +// discovered and produce a valid spec. func TestCoverage_Bug1891(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1891/..."}, diff --git a/internal/integration/coverage_bug_1913_test.go b/internal/integration/coverage_bug_1913_test.go index 21cd3437..746bcddf 100644 --- a/internal/integration/coverage_bug_1913_test.go +++ b/internal/integration/coverage_bug_1913_test.go @@ -12,14 +12,14 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1913 locks go-swagger issue #1913 ("sub-types not generated -// from go discriminated type"): an interface model with `discriminator: true` -// emits a base definition carrying `discriminator`, and the struct subtypes -// that embed it via `swagger:allOf` emit `allOf: [{$ref base}, {own props}]`. +// TestCoverage_Bug1913 locks go-swagger issue #1913 ("sub-types not generated from go discriminated +// type"): an interface model with `discriminator: true` emits a base definition carrying +// `discriminator`, and the struct subtypes that embed it via `swagger:allOf` emit `allOf: [{$ref +// base}, {own props}]`. // -// (The reporter's residual concern — subtypes require -m, which over-generates -// — is tracked as forthcoming-features §12 (prune under -m) and §15 -// (auto-discover discriminator subtypes of a referenced base).) +// (The reporter's residual concern — subtypes require -m, which over-generates — is tracked as +// forthcoming-features §12 (prune under -m) and §15 (auto-discover discriminator subtypes of a +// referenced base).) func TestCoverage_Bug1913(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1913/..."}, diff --git a/internal/integration/coverage_bug_1925_test.go b/internal/integration/coverage_bug_1925_test.go index 4824e6ea..7309b250 100644 --- a/internal/integration/coverage_bug_1925_test.go +++ b/internal/integration/coverage_bug_1925_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1925 locks go-swagger issue #1925 ("parameters do not support -// interface"): a body parameter typed `[]map[string]interface{}` no longer -// aborts spec generation — it produces a valid array-of-object schema with open -// additionalProperties. +// TestCoverage_Bug1925 locks go-swagger issue #1925 ("parameters do not support interface"): a body +// parameter typed `[]map[string]interface{}` no longer aborts spec generation — it produces a +// valid array-of-object schema with open additionalProperties. func TestCoverage_Bug1925(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1925/..."}, diff --git a/internal/integration/coverage_bug_1934_test.go b/internal/integration/coverage_bug_1934_test.go index dd6b535f..adf81796 100644 --- a/internal/integration/coverage_bug_1934_test.go +++ b/internal/integration/coverage_bug_1934_test.go @@ -12,10 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1934 locks go-swagger issue #1934 ("model declared in function -// not picked up"): a swagger:model and a swagger:parameters declared on types -// INSIDE a function body are both discovered — the model resolves as the route's -// response body $ref, and the params type contributes the query parameter. +// TestCoverage_Bug1934 locks go-swagger issue #1934 ("model declared in function not picked up"): a +// swagger:model and a swagger:parameters declared on types INSIDE a function body are both +// discovered — the model resolves as the route's response body $ref, and the params type +// contributes the query parameter. func TestCoverage_Bug1934(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1934/..."}, diff --git a/internal/integration/coverage_bug_1955_test.go b/internal/integration/coverage_bug_1955_test.go index e5fef4dd..f3629b2a 100644 --- a/internal/integration/coverage_bug_1955_test.go +++ b/internal/integration/coverage_bug_1955_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1955 locks go-swagger issue #1955: a swagger:operation can use -// a swagger:parameters struct defined in a DIFFERENT package — they are matched -// by operation id across all scanned packages (the swagger:operation analog of -// #1742). +// TestCoverage_Bug1955 locks go-swagger issue #1955: a swagger:operation can use a +// swagger:parameters struct defined in a DIFFERENT package — they are matched by operation id +// across all scanned packages (the swagger:operation analog of #1742). func TestCoverage_Bug1955(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1955/..."}, diff --git a/internal/integration/coverage_bug_1958_test.go b/internal/integration/coverage_bug_1958_test.go index d1f825b1..934b3d5d 100644 --- a/internal/integration/coverage_bug_1958_test.go +++ b/internal/integration/coverage_bug_1958_test.go @@ -12,11 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1958 locks go-swagger issue #1958 ("Ability to skip embedded -// tags"): a vendor extension in the swagger:operation YAML body is preserved in -// full — including nested keys that share a name with a known keyword (here, -// `responses:` inside x-amazon-apigateway-integration), which must NOT be -// confused with the operation's own responses. +// TestCoverage_Bug1958 locks go-swagger issue #1958 ("Ability to skip embedded tags"): a vendor +// extension in the swagger:operation YAML body is preserved in full — including nested keys that +// share a name with a known keyword (here, `responses:` inside x-amazon-apigateway-integration), +// which must NOT be confused with the operation's own responses. func TestCoverage_Bug1958(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1958/..."}, diff --git a/internal/integration/coverage_bug_1992_test.go b/internal/integration/coverage_bug_1992_test.go index 676390b3..fa59a88b 100644 --- a/internal/integration/coverage_bug_1992_test.go +++ b/internal/integration/coverage_bug_1992_test.go @@ -12,11 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug1992 documents the answer to go-swagger issue #1992 ("hide -// parts of composition for a struct"): codescan emits one schema per Go type, so -// per-operation field hiding is out of scope, but the OAS2 idiom for -// server-assigned fields — `read only: true` → `readOnly: true` — is supported -// and is the recommended mechanism. +// TestCoverage_Bug1992 documents the answer to go-swagger issue #1992 ("hide parts of composition +// for a struct"): codescan emits one schema per Go type, so per-operation field hiding is out of +// scope, but the OAS2 idiom for server-assigned fields — `read only: true` → `readOnly: true` +// — is supported and is the recommended mechanism. func TestCoverage_Bug1992(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/1992/..."}, diff --git a/internal/integration/coverage_bug_2002_test.go b/internal/integration/coverage_bug_2002_test.go index 1ecd4f78..d8babea6 100644 --- a/internal/integration/coverage_bug_2002_test.go +++ b/internal/integration/coverage_bug_2002_test.go @@ -12,10 +12,11 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2002 locks go-swagger issue #2002 ("generate spec fails with -// invalid type error"): a swagger:response body field whose type lives in -// another package resolves to a $ref definition. The reporter's "unsupported -// type 'invalid type'" came from the old GO111MODULE=off go/loader; the +// TestCoverage_Bug2002 locks go-swagger issue #2002 ("generate spec fails with invalid type +// error"): a swagger:response body field whose type lives in another package resolves to a $ref +// definition. +// +// The reporter's "unsupported type 'invalid type'" came from the old GO111MODULE=off go/loader; the // module-aware go/packages loader resolves the cross-package type. func TestCoverage_Bug2002(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ diff --git a/internal/integration/coverage_bug_2013_test.go b/internal/integration/coverage_bug_2013_test.go index ba0ae8b8..9f979307 100644 --- a/internal/integration/coverage_bug_2013_test.go +++ b/internal/integration/coverage_bug_2013_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2013 locks go-swagger issue #2013 ("panic: index out of range" -// in SetEnum during buildEmbedded): an enum on a promoted (embedded) field is -// parsed without panicking and the enum values are carried onto the composed -// model. +// TestCoverage_Bug2013 locks go-swagger issue #2013 ("panic: index out of range" in SetEnum during +// buildEmbedded): an enum on a promoted (embedded) field is parsed without panicking and the enum +// values are carried onto the composed model. func TestCoverage_Bug2013(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2013/..."}, diff --git a/internal/integration/coverage_bug_2020_test.go b/internal/integration/coverage_bug_2020_test.go index a59d0d5b..595e0eae 100644 --- a/internal/integration/coverage_bug_2020_test.go +++ b/internal/integration/coverage_bug_2020_test.go @@ -12,14 +12,13 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2020 locks go-swagger issue #2020 ("parameters not detected -// with multiple structs in one statement"): two swagger:parameters structs -// declared in a single grouped `type ( ... )` block are each detected and bound -// to their operation by id. +// TestCoverage_Bug2020 locks go-swagger issue #2020 ("parameters not detected with multiple structs +// in one statement"): two swagger:parameters structs declared in a single grouped `type ( ... )` +// block are each detected and bound to their operation by id. // -// (The reporter's exotic anonymous-inline-body field marked `swagger:name -` -// remains mis-handled — it is dropped to a query param — but that narrow edge is -// independent of the grouped-declaration detection this locks.) +// (The reporter's exotic anonymous-inline-body field marked `swagger:name -` remains mis-handled +// — it is dropped to a query param — but that narrow edge is independent of the +// grouped-declaration detection this locks.) func TestCoverage_Bug2020(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2020/..."}, diff --git a/internal/integration/coverage_bug_2038_test.go b/internal/integration/coverage_bug_2038_test.go index 6b62a499..a3a2b632 100644 --- a/internal/integration/coverage_bug_2038_test.go +++ b/internal/integration/coverage_bug_2038_test.go @@ -12,14 +12,15 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2038 asserts the EXPECTED resolution of go-swagger issue #2038 -// ("swagger ignore json tags for embedded structures"). It is currently RED. +// TestCoverage_Bug2038 asserts the EXPECTED resolution of go-swagger issue #2038 ("swagger ignore +// json tags for embedded structures"). // -// Go's encoding/json treats an embedded struct WITH an explicit json tag as a -// regular named field: the embedded value NESTS under the tag rather than being -// promoted. codescan currently ignores the tag and flattens both forms, so the -// generated spec for Tagged does not match the actual JSON. The untagged form -// (correct promotion) is the green guard rail. +// It is currently RED. +// +// Go's encoding/json treats an embedded struct WITH an explicit json tag as a regular named field: +// the embedded value NESTS under the tag rather than being promoted. codescan currently ignores the +// tag and flattens both forms, so the generated spec for Tagged does not match the actual JSON. +// The untagged form (correct promotion) is the green guard rail. func TestCoverage_Bug2038(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2038/..."}, @@ -44,17 +45,17 @@ func TestCoverage_Bug2038(t *testing.T) { assert.Equal(t, "#/definitions/Inner", inner.Ref.String(), "the nested embed should reference the embedded type") - // `json:"-"` on an embed squashes it: Go drops it entirely, so neither the - // embed nor its fields appear (not promoted, not nested). + // `json:"-"` on an embed squashes it: Go drops it entirely, so neither the embed nor its fields + // appear (not promoted, not nested). squashed := doc.Definitions["Squashed"].Properties assert.Contains(t, squashed, "outer_field") assert.NotContains(t, squashed, "inner_field", "a squashed embed promotes nothing") assert.NotContains(t, squashed, "inner", "a squashed embed nests nothing") assert.Len(t, squashed, 1) - // swagger:allOf on a json-tagged embed: the explicit composition annotation - // wins over the tag — the embed becomes an allOf $ref member, NOT a nested - // property. (Nesting fires only for plain, non-allOf embeds.) + // swagger:allOf on a json-tagged embed: the explicit composition annotation wins over the tag — + // the embed becomes an allOf $ref member, NOT a nested property. (Nesting fires only for plain, + // non-allOf embeds.) taggedAllOf := doc.Definitions["TaggedAllOf"] assert.NotContains(t, taggedAllOf.Properties, "inner", "swagger:allOf diverts the embed away from the json-tag nesting path") diff --git a/internal/integration/coverage_bug_2062_test.go b/internal/integration/coverage_bug_2062_test.go index 18576ed1..2cd217b4 100644 --- a/internal/integration/coverage_bug_2062_test.go +++ b/internal/integration/coverage_bug_2062_test.go @@ -12,10 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2062 locks go-swagger issue #2062 (the security half): a -// `security:` requirement in the swagger:operation YAML body is emitted on the -// operation. (SecurityDefinitions is a global swagger:meta concept in OpenAPI -// 2.0, not a per-operation field — that part of the ask is N/A by spec.) +// TestCoverage_Bug2062 locks go-swagger issue #2062 (the security half): a `security:` requirement +// in the swagger:operation YAML body is emitted on the operation. (SecurityDefinitions is a global +// swagger:meta concept in OpenAPI 2.0, not a per-operation field — that part of the ask is N/A by +// spec.) func TestCoverage_Bug2062(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2062/..."}, diff --git a/internal/integration/coverage_bug_2064_test.go b/internal/integration/coverage_bug_2064_test.go index 8e8de593..13202cd3 100644 --- a/internal/integration/coverage_bug_2064_test.go +++ b/internal/integration/coverage_bug_2064_test.go @@ -12,10 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2064 locks go-swagger issue #2064 ("add example to string -// parameter in request body"): the example and default of a body parameter are -// emitted (they were previously missing). They are carried on the parameter -// object alongside the body schema. +// TestCoverage_Bug2064 locks go-swagger issue #2064 ("add example to string parameter in request +// body"): the example and default of a body parameter are emitted (they were previously missing). +// +// They are carried on the parameter object alongside the body schema. func TestCoverage_Bug2064(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2064/..."}, diff --git a/internal/integration/coverage_bug_2106_test.go b/internal/integration/coverage_bug_2106_test.go index add211f9..4178ed50 100644 --- a/internal/integration/coverage_bug_2106_test.go +++ b/internal/integration/coverage_bug_2106_test.go @@ -12,9 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2106 locks go-swagger issue #2106 ("Extensions ignored on -// models when type is not an array"): a field-level `Extensions:` block emits its -// x-* vendor extensions on BOTH scalar and array fields. +// TestCoverage_Bug2106 locks go-swagger issue #2106 ("Extensions ignored on models when type is not +// an array"): a field-level `Extensions:` block emits its x-* vendor extensions on BOTH scalar and +// array fields. func TestCoverage_Bug2106(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2106/..."}, diff --git a/internal/integration/coverage_bug_2119_test.go b/internal/integration/coverage_bug_2119_test.go index a7d7ddb5..3298752e 100644 --- a/internal/integration/coverage_bug_2119_test.go +++ b/internal/integration/coverage_bug_2119_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2119 locks the answer to go-swagger issue #2119 ("add flag to -// skip generation of x-go-name"): the `SkipExtensions` option suppresses the -// scanner-derived x-go-name / x-go-package vendor extensions (which clash across -// same-named types). +// TestCoverage_Bug2119 locks the answer to go-swagger issue #2119 ("add flag to skip generation of +// x-go-name"): the `SkipExtensions` option suppresses the scanner-derived x-go-name / x-go-package +// vendor extensions (which clash across same-named types). func TestCoverage_Bug2119(t *testing.T) { // Default: x-go-name / x-go-package are present. on, err := codescan.Run(&codescan.Options{ diff --git a/internal/integration/coverage_bug_2125_test.go b/internal/integration/coverage_bug_2125_test.go index f6787b9c..24109487 100644 --- a/internal/integration/coverage_bug_2125_test.go +++ b/internal/integration/coverage_bug_2125_test.go @@ -13,10 +13,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2125 locks go-swagger issue #2125 ("parsing meta info comments -// can parse fields wrong"): markdown content in a swagger:meta block (headings, -// prose mentioning "Api-Version") is kept in the info description and does NOT -// derail field parsing — the real `Version:` field is read correctly. +// TestCoverage_Bug2125 locks go-swagger issue #2125 ("parsing meta info comments can parse fields +// wrong"): markdown content in a swagger:meta block (headings, prose mentioning "Api-Version") is +// kept in the info description and does NOT derail field parsing — the real `Version:` field is +// read correctly. func TestCoverage_Bug2125(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2125/..."}, WorkDir: scantest.FixturesDir(), diff --git a/internal/integration/coverage_bug_2133_test.go b/internal/integration/coverage_bug_2133_test.go index ecbccc6b..57e21726 100644 --- a/internal/integration/coverage_bug_2133_test.go +++ b/internal/integration/coverage_bug_2133_test.go @@ -12,9 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2133 locks go-swagger issue #2133 ("spec generating schema and -// type"): a path parameter is emitted as a simple {type:string, in:path} with NO -// sibling `schema` (the double-emission the reporter saw would fail validation). +// TestCoverage_Bug2133 locks go-swagger issue #2133 ("spec generating schema and type"): a path +// parameter is emitted as a simple {type:string, in:path} with NO sibling `schema` (the +// double-emission the reporter saw would fail validation). func TestCoverage_Bug2133(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2133/..."}, diff --git a/internal/integration/coverage_bug_2160_test.go b/internal/integration/coverage_bug_2160_test.go index c29806f0..bc047d7d 100644 --- a/internal/integration/coverage_bug_2160_test.go +++ b/internal/integration/coverage_bug_2160_test.go @@ -12,11 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2160 locks go-swagger issue #2160 ("example of array of structs -// is 0 valued"): an array-of-structs field accepts an inline JSON-array example -// and emits it as an array of objects (not zero-valued). (The multi-line -// YAML-list example syntax is kept as a raw string — the example-coercion gap -// tracked in forthcoming-features §2.1.) +// TestCoverage_Bug2160 locks go-swagger issue #2160 ("example of array of structs is 0 valued"): an +// array-of-structs field accepts an inline JSON-array example and emits it as an array of objects +// (not zero-valued). (The multi-line YAML-list example syntax is kept as a raw string — the +// example-coercion gap tracked in forthcoming-features §2.1.) func TestCoverage_Bug2160(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2160/..."}, diff --git a/internal/integration/coverage_bug_2184_test.go b/internal/integration/coverage_bug_2184_test.go index b53565f3..e9450839 100644 --- a/internal/integration/coverage_bug_2184_test.go +++ b/internal/integration/coverage_bug_2184_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2184 locks go-swagger issue #2184 ("spec generation fails to -// replace $ref with indicated type when using swagger:type on struct"): a struct -// type carrying `swagger:type int64`, used as a parameter field, makes the -// parameter an integer — not a $ref to the struct. +// TestCoverage_Bug2184 locks go-swagger issue #2184 ("spec generation fails to replace $ref with +// indicated type when using swagger:type on struct"): a struct type carrying `swagger:type int64`, +// used as a parameter field, makes the parameter an integer — not a $ref to the struct. func TestCoverage_Bug2184(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2184/..."}, diff --git a/internal/integration/coverage_bug_2218_test.go b/internal/integration/coverage_bug_2218_test.go index 452003c9..625f45d8 100644 --- a/internal/integration/coverage_bug_2218_test.go +++ b/internal/integration/coverage_bug_2218_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2218 locks go-swagger issue #2218 ("unable to connect a -// parameter to a route"): a swagger:parameters struct is bound to its route when -// the struct's operation id matches the route's operation id — the query -// parameter appears on the operation. +// TestCoverage_Bug2218 locks go-swagger issue #2218 ("unable to connect a parameter to a route"): a +// swagger:parameters struct is bound to its route when the struct's operation id matches the +// route's operation id — the query parameter appears on the operation. func TestCoverage_Bug2218(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2218/..."}, diff --git a/internal/integration/coverage_bug_2228_test.go b/internal/integration/coverage_bug_2228_test.go index 9da99a65..ced5a8e0 100644 --- a/internal/integration/coverage_bug_2228_test.go +++ b/internal/integration/coverage_bug_2228_test.go @@ -12,9 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2228 locks go-swagger issue #2228 ("how to give empty summary -// in swagger:route"): the auto-derived summary (first prose line) is suppressed -// by simply omitting that line — a route with only `responses:` has no summary. +// TestCoverage_Bug2228 locks go-swagger issue #2228 ("how to give empty summary in swagger:route"): +// the auto-derived summary (first prose line) is suppressed by simply omitting that line — a +// route with only `responses:` has no summary. func TestCoverage_Bug2228(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2228/..."}, diff --git a/internal/integration/coverage_bug_2230_test.go b/internal/integration/coverage_bug_2230_test.go index 2d7a423f..ee1ac9e4 100644 --- a/internal/integration/coverage_bug_2230_test.go +++ b/internal/integration/coverage_bug_2230_test.go @@ -12,11 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2230 locks go-swagger issue #2230 ("example in json.RawMessage -// field"): a json.RawMessage field renders as an open (typeless) schema — i.e. -// "any JSON", which matches RawMessage's meaning — while a typed sibling field -// carries its example. To constrain or example the RawMessage, annotate it -// (swagger:type / swagger:strfmt); the raw bytes are not forced to an int array. +// TestCoverage_Bug2230 locks go-swagger issue #2230 ("example in json.RawMessage field"): a +// json.RawMessage field renders as an open (typeless) schema — i.e. "any JSON", which matches +// RawMessage's meaning — while a typed sibling field carries its example. +// +// To constrain or example the RawMessage, annotate it (swagger:type / swagger:strfmt); the raw +// bytes are not forced to an int array. func TestCoverage_Bug2230(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2230/..."}, diff --git a/internal/integration/coverage_bug_2232_test.go b/internal/integration/coverage_bug_2232_test.go index c2592076..aa2218ab 100644 --- a/internal/integration/coverage_bug_2232_test.go +++ b/internal/integration/coverage_bug_2232_test.go @@ -12,10 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2232 locks go-swagger issue #2232 ("tags with spaces"): a -// multi-word tag is expressed via the swagger:operation YAML body `tags:` list. -// (The swagger:route line cannot carry spaced tags — its tags are -// space-delimited tokens — so the YAML form is the supported route to it.) +// TestCoverage_Bug2232 locks go-swagger issue #2232 ("tags with spaces"): a multi-word tag is +// expressed via the swagger:operation YAML body `tags:` list. (The swagger:route line cannot carry +// spaced tags — its tags are space-delimited tokens — so the YAML form is the supported route +// to it.) func TestCoverage_Bug2232(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2232/..."}, diff --git a/internal/integration/coverage_bug_2233_test.go b/internal/integration/coverage_bug_2233_test.go index b65ae57b..2ab713fa 100644 --- a/internal/integration/coverage_bug_2233_test.go +++ b/internal/integration/coverage_bug_2233_test.go @@ -12,11 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2233 locks go-swagger issue #2233 ("unable to find responses -// defined in other package using swagger:operation"): a swagger:operation YAML -// body that `$ref`s a swagger:response defined in ANOTHER package resolves — the -// response and its body model are emitted (no "$refs must reference a valid -// location"). +// TestCoverage_Bug2233 locks go-swagger issue #2233 ("unable to find responses defined in other +// package using swagger:operation"): a swagger:operation YAML body that `$ref`s a swagger:response +// defined in ANOTHER package resolves — the response and its body model are emitted (no "$refs +// must reference a valid location"). func TestCoverage_Bug2233(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2233/..."}, diff --git a/internal/integration/coverage_bug_2245_test.go b/internal/integration/coverage_bug_2245_test.go index 7dc021ea..a00b2999 100644 --- a/internal/integration/coverage_bug_2245_test.go +++ b/internal/integration/coverage_bug_2245_test.go @@ -12,9 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2245 locks go-swagger issue #2245 ("swagger:response that -// produces application/xml"): a `produces: [application/xml]` in the -// swagger:operation YAML body sets the operation's produced media type. +// TestCoverage_Bug2245 locks go-swagger issue #2245 ("swagger:response that produces +// application/xml"): a `produces: [application/xml]` in the swagger:operation YAML body sets the +// operation's produced media type. func TestCoverage_Bug2245(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2245/..."}, diff --git a/internal/integration/coverage_bug_2248_test.go b/internal/integration/coverage_bug_2248_test.go index 84a2667f..bc37d9d1 100644 --- a/internal/integration/coverage_bug_2248_test.go +++ b/internal/integration/coverage_bug_2248_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2248 locks go-swagger issue #2248 ("time.Duration in response's -// header"): a response header typed time.Duration resolves to {type:integer, -// format:int64} — it carries a type, so the spec is valid (the reporter saw -// "headers..type in body is required"). +// TestCoverage_Bug2248 locks go-swagger issue #2248 ("time.Duration in response's header"): a +// response header typed time.Duration resolves to {type:integer, format:int64} — it carries a +// type, so the spec is valid (the reporter saw "headers..type in body is required"). func TestCoverage_Bug2248(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2248/..."}, diff --git a/internal/integration/coverage_bug_2251_test.go b/internal/integration/coverage_bug_2251_test.go index aefbe13f..99bd3bd4 100644 --- a/internal/integration/coverage_bug_2251_test.go +++ b/internal/integration/coverage_bug_2251_test.go @@ -13,14 +13,17 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2251 asserts the EXPECTED resolution of go-swagger issue #2251 -// ("map with non-string keys"). Currently RED for integer-kind keys. +// TestCoverage_Bug2251 asserts the EXPECTED resolution of go-swagger issue #2251 ("map with +// non-string keys"). // -// encoding/json marshals integer-kind map keys (map[int]V, map[int64]V) as JSON -// string keys, so they are representable as {type:object, additionalProperties:V} -// — but buildFromMap only emits additionalProperties for string / TextMarshaler -// keys and silently drops the rest. The string-key form is the green guard rail. -// (Part of the additionalProperties work on this branch; see forthcoming §18.) +// Currently RED for integer-kind keys. +// +// encoding/json marshals integer-kind map keys (map[int]V, map[int64]V) as JSON string keys, so +// they are representable as {type:object, additionalProperties:V} — but buildFromMap only emits +// additionalProperties for string / TextMarshaler keys and silently drops the rest. +// +// The string-key form is the green guard rail. (Part of the additionalProperties work on this +// branch; see forthcoming §18.) func TestCoverage_Bug2251(t *testing.T) { var diags []grammar.Diagnostic doc, err := codescan.Run(&codescan.Options{ @@ -46,8 +49,8 @@ func TestCoverage_Bug2251(t *testing.T) { assert.True(t, hasAP("intKey"), "map[int]V must emit additionalProperties (go-swagger#2251)") assert.True(t, hasAP("i64Key"), "map[int64]V must emit additionalProperties (go-swagger#2251)") - // Fail-loud half (§18): a json-illegal key (float) emits no additionalProperties - // AND raises a CodeUnsupportedType diagnostic instead of dropping silently. + // Fail-loud half (§18): a json-illegal key (float) emits no additionalProperties AND raises a + // CodeUnsupportedType diagnostic instead of dropping silently. assert.False(t, hasAP("badKey"), "map[float64]V is not json-representable; no additionalProperties") var unsupported []grammar.Diagnostic diff --git a/internal/integration/coverage_bug_2286_test.go b/internal/integration/coverage_bug_2286_test.go index 8c5381aa..6ccafca1 100644 --- a/internal/integration/coverage_bug_2286_test.go +++ b/internal/integration/coverage_bug_2286_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2286 locks go-swagger issue #2286 ("model accepted as -// response"): naming a swagger:model directly as a route response (`200: model`) -// is accepted and yields a valid response with a $ref to that model — the lenient -// shorthand for `200: body:model`. +// TestCoverage_Bug2286 locks go-swagger issue #2286 ("model accepted as response"): naming a +// swagger:model directly as a route response (`200: model`) is accepted and yields a valid response +// with a $ref to that model — the lenient shorthand for `200: body:model`. func TestCoverage_Bug2286(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2286/..."}, diff --git a/internal/integration/coverage_bug_2294_test.go b/internal/integration/coverage_bug_2294_test.go index 45eca703..448f7880 100644 --- a/internal/integration/coverage_bug_2294_test.go +++ b/internal/integration/coverage_bug_2294_test.go @@ -12,14 +12,15 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2294 locks the resolution of go-swagger issue #2294 -// ("more than one security header using AND logic"). +// TestCoverage_Bug2294 locks the resolution of go-swagger issue #2294 ("more than one security +// header using AND logic"). // -// Two schemes combined in ONE security requirement (a sequence item with two -// mapping keys) means AND — both must be satisfied — and must produce a single -// requirement object with both keys. Before the fix the scanner split it into -// two separate requirements (OR logic). The fix lives in the meta-Security -// parser (internal/parsers/security), the same parser as #2403/#2479. +// Two schemes combined in ONE security requirement (a sequence item with two mapping keys) means +// AND — both must be satisfied — and must produce a single requirement object with both keys. +// Before the fix the scanner split it into two separate requirements (OR logic). +// +// The fix lives in the meta-Security parser (internal/parsers/security), the same parser as +// #2403/#2479. func TestCoverage_Bug2294(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2294/..."}, diff --git a/internal/integration/coverage_bug_2296_test.go b/internal/integration/coverage_bug_2296_test.go index 28184d0f..005b4cfb 100644 --- a/internal/integration/coverage_bug_2296_test.go +++ b/internal/integration/coverage_bug_2296_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2296 locks go-swagger issue #2296 ("panic, embedded meet -// anonymous"): embedding a struct that itself has an anonymous-struct field no -// longer panics during schema build; the promoted anonymous-struct property is -// emitted as a nested object. +// TestCoverage_Bug2296 locks go-swagger issue #2296 ("panic, embedded meet anonymous"): embedding a +// struct that itself has an anonymous-struct field no longer panics during schema build; the +// promoted anonymous-struct property is emitted as a nested object. func TestCoverage_Bug2296(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2296/..."}, diff --git a/internal/integration/coverage_bug_2305_test.go b/internal/integration/coverage_bug_2305_test.go index 819736c4..98a13af9 100644 --- a/internal/integration/coverage_bug_2305_test.go +++ b/internal/integration/coverage_bug_2305_test.go @@ -12,9 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2305 locks go-swagger issue #2305 ("query params, enum -// dropdown"): a query parameter with an enum produces a clean, valid parameter — -// the enum is present, with no illegal `schema` and no duplicated description. +// TestCoverage_Bug2305 locks go-swagger issue #2305 ("query params, enum dropdown"): a query +// parameter with an enum produces a clean, valid parameter — the enum is present, with no illegal +// `schema` and no duplicated description. func TestCoverage_Bug2305(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2305/..."}, diff --git a/internal/integration/coverage_bug_2311_test.go b/internal/integration/coverage_bug_2311_test.go index 89dfe89d..75ac6885 100644 --- a/internal/integration/coverage_bug_2311_test.go +++ b/internal/integration/coverage_bug_2311_test.go @@ -12,9 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2311 locks go-swagger issue #2311 (the behaviour behind the -// doc gap): `swagger:ignore` on a struct FIELD drops that property from the -// model (the docs only mentioned types/whole declarations). +// TestCoverage_Bug2311 locks go-swagger issue #2311 (the behaviour behind the doc gap): +// `swagger:ignore` on a struct FIELD drops that property from the model (the docs only mentioned +// types/whole declarations). func TestCoverage_Bug2311(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2311/..."}, diff --git a/internal/integration/coverage_bug_2317_test.go b/internal/integration/coverage_bug_2317_test.go index ade20b70..b3bbd056 100644 --- a/internal/integration/coverage_bug_2317_test.go +++ b/internal/integration/coverage_bug_2317_test.go @@ -12,9 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2317 locks go-swagger issue #2317 ("x-nullable on a pointer has -// no effect"): an `x-nullable: true` Extensions block on a pointer field is -// applied — the field becomes allOf[$ref] carrying x-nullable. +// TestCoverage_Bug2317 locks go-swagger issue #2317 ("x-nullable on a pointer has no effect"): an +// `x-nullable: true` Extensions block on a pointer field is applied — the field becomes +// allOf[$ref] carrying x-nullable. func TestCoverage_Bug2317(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2317/..."}, diff --git a/internal/integration/coverage_bug_2353_test.go b/internal/integration/coverage_bug_2353_test.go index 4bc04c1a..e22f86f1 100644 --- a/internal/integration/coverage_bug_2353_test.go +++ b/internal/integration/coverage_bug_2353_test.go @@ -12,11 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2353 locks go-swagger issue #2353 ("valid spec with body and -// request param"): an operation that mixes a path parameter and a body parameter -// produces a valid parameter set — the body param is a clean {in:body, schema: -// {$ref}} with no forbidden sibling `type`, and the path param is a simple -// {in:path, type:string}. +// TestCoverage_Bug2353 locks go-swagger issue #2353 ("valid spec with body and request param"): an +// operation that mixes a path parameter and a body parameter produces a valid parameter set — the +// body param is a clean {in:body, schema: {$ref}} with no forbidden sibling `type`, and the path +// param is a simple {in:path, type:string}. func TestCoverage_Bug2353(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2353/..."}, diff --git a/internal/integration/coverage_bug_2384_test.go b/internal/integration/coverage_bug_2384_test.go index b2666158..8b98f15d 100644 --- a/internal/integration/coverage_bug_2384_test.go +++ b/internal/integration/coverage_bug_2384_test.go @@ -12,11 +12,11 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2384 locks go-swagger issue #2384 ("pattern containing '\n' is -// interpreted"): codescan reads a `pattern:` value VERBATIM — the backslash -// escapes `\t` / `\n` are preserved as the two characters they are, not turned -// into literal tab/newline. (The reporter's broken multi-line output came from -// go-swagger's spec->code generator, not from codescan's code->spec scan.) +// TestCoverage_Bug2384 locks go-swagger issue #2384 ("pattern containing '\n' is interpreted"): +// codescan reads a `pattern:` value VERBATIM — the backslash escapes `\t` / `\n` are preserved as +// the two characters they are, not turned into literal tab/newline. (The reporter's broken +// multi-line output came from go-swagger's spec->code generator, not from codescan's code->spec +// scan.) func TestCoverage_Bug2384(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2384/..."}, diff --git a/internal/integration/coverage_bug_2396_test.go b/internal/integration/coverage_bug_2396_test.go index 54bc8616..d87a0f8f 100644 --- a/internal/integration/coverage_bug_2396_test.go +++ b/internal/integration/coverage_bug_2396_test.go @@ -12,14 +12,15 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2396 asserts the EXPECTED resolution of go-swagger issue #2396 -// ("model enum recognition and handling spaces"). Currently RED. +// TestCoverage_Bug2396 asserts the EXPECTED resolution of go-swagger issue #2396 ("model enum +// recognition and handling spaces"). // -// The bracketed `enum: [a, b, c]` form must produce the same enum as the -// unbracketed `enum: a, b, c` form — the surrounding `[`/`]` are delimiters and -// must be stripped. The scanner currently glues them onto the first/last value -// (["[issues", "pulls", "projects]"]). The unbracketed form (which already trims -// correctly) is the green guard rail. +// Currently RED. +// +// The bracketed `enum: [a, b, c]` form must produce the same enum as the unbracketed `enum: a, b, +// c` form — the surrounding `[`/`]` are delimiters and must be stripped. +// The scanner currently glues them onto the first/last value (["[issues", "pulls", "projects]"]). +// The unbracketed form (which already trims correctly) is the green guard rail. func TestCoverage_Bug2396(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2396/..."}, diff --git a/internal/integration/coverage_bug_2403_test.go b/internal/integration/coverage_bug_2403_test.go index ca6133cf..a5f7a0a3 100644 --- a/internal/integration/coverage_bug_2403_test.go +++ b/internal/integration/coverage_bug_2403_test.go @@ -12,8 +12,8 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2403 locks the resolution of go-swagger issue #2403 -// ("go swagger security auth0"). +// TestCoverage_Bug2403 locks the resolution of go-swagger issue #2403 ("go swagger security +// auth0"). // // A global `Security:` requirement in swagger:meta written as a YAML sequence // (`- auth0: []`) must parse into a requirement naming `auth0` with empty scopes. diff --git a/internal/integration/coverage_bug_2407_test.go b/internal/integration/coverage_bug_2407_test.go index 57e30f2e..b4bb66db 100644 --- a/internal/integration/coverage_bug_2407_test.go +++ b/internal/integration/coverage_bug_2407_test.go @@ -12,9 +12,8 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2407 locks go-swagger issue #2407 ("how add example to yml"): -// an `example: [1, 2, 3]` on an array-typed body response is emitted on the -// response schema (previously absent). +// TestCoverage_Bug2407 locks go-swagger issue #2407 ("how add example to yml"): an `example: [1, 2, +// 3]` on an array-typed body response is emitted on the response schema (previously absent). func TestCoverage_Bug2407(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2407/..."}, diff --git a/internal/integration/coverage_bug_2409_test.go b/internal/integration/coverage_bug_2409_test.go index 321ecfca..719eea60 100644 --- a/internal/integration/coverage_bug_2409_test.go +++ b/internal/integration/coverage_bug_2409_test.go @@ -12,11 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2409 locks go-swagger issue #2409 ("annotate structures with -// extensions"): a TYPE-level Extensions block emits its x-* extensions at the -// definition level. (The reporter's specific x-go-type IMPORT semantics is a -// separate design question — poison-queue #2924 — but the extension mechanism -// itself works for any x-* key.) +// TestCoverage_Bug2409 locks go-swagger issue #2409 ("annotate structures with extensions"): a +// TYPE-level Extensions block emits its x-* extensions at the definition level. (The reporter's +// specific x-go-type IMPORT semantics is a separate design question — poison-queue #2924 — but +// the extension mechanism itself works for any x-* key.) func TestCoverage_Bug2409(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2409/..."}, diff --git a/internal/integration/coverage_bug_2417_test.go b/internal/integration/coverage_bug_2417_test.go index 1da7bdc7..7cb2390d 100644 --- a/internal/integration/coverage_bug_2417_test.go +++ b/internal/integration/coverage_bug_2417_test.go @@ -12,13 +12,15 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2417 covers go-swagger issue #2417 ("embedding of aliased -// type"). Embedding an anonymous defined-type whose underlying struct lives in -// a *different* package than the type itself (a.AnotherPackageAlias, underlying -// color.Color) used to promote no fields — the definition came out a bare empty -// object — because field-AST resolution searched only the embedding decl's -// source file. The field-promotion path now falls back to the field's own -// source file (ScanCtx.FileForPos), so the fields are promoted. +// TestCoverage_Bug2417 covers go-swagger issue #2417 ("embedding of aliased type"). +// +// Embedding an anonymous defined-type whose underlying struct lives in a *different* package than +// the type itself (a.AnotherPackageAlias, underlying color.Color) used to promote no fields — the +// definition came out a bare empty object — because field-AST resolution searched only the +// embedding decl's source file. +// +// The field-promotion path now falls back to the field's own source file (ScanCtx.FileForPos), so +// the fields are promoted. // // The other three matrix cells were already correct and are asserted here as // guard rails against regression: @@ -43,9 +45,8 @@ func TestCoverage_Bug2417(t *testing.T) { assert.Equal(t, "#/definitions/AnotherPackageAlias", named.Ref.String(), "a named cross-package alias field is referenced") - // The #2417 defect: embedding an alias-of-cross-package-type promotes - // nothing. Expected behaviour is that "hue" is promoted, like every other - // embed form above. + // The #2417 defect: embedding an alias-of-cross-package-type promotes nothing. + // Expected behaviour is that "hue" is promoted, like every other embed form above. assert.Contains(t, doc.Definitions["CrossAliasEmbed"].Properties, "hue", "embedding an alias whose underlying type is in another package must "+ "promote its fields (go-swagger#2417)") diff --git a/internal/integration/coverage_bug_2419_test.go b/internal/integration/coverage_bug_2419_test.go index 36491d6b..9a328ff1 100644 --- a/internal/integration/coverage_bug_2419_test.go +++ b/internal/integration/coverage_bug_2419_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2419 locks go-swagger issue #2419 ("support custom swagger type -// for struct field"): a `swagger:type string` on a field whose type is an -// external/imported struct overrides it to a plain string (instead of a $ref to -// the external type's definition). +// TestCoverage_Bug2419 locks go-swagger issue #2419 ("support custom swagger type for struct +// field"): a `swagger:type string` on a field whose type is an external/imported struct overrides +// it to a plain string (instead of a $ref to the external type's definition). func TestCoverage_Bug2419(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2419/..."}, diff --git a/internal/integration/coverage_bug_2441_test.go b/internal/integration/coverage_bug_2441_test.go index ffd360d9..56e7ced4 100644 --- a/internal/integration/coverage_bug_2441_test.go +++ b/internal/integration/coverage_bug_2441_test.go @@ -12,10 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2441 locks go-swagger issue #2441 ("file upload how to describe -// in annotations"): a raw (non-multipart) binary body is described with a string -// field marked `swagger:strfmt binary` → format binary. (OpenAPI 2.0 `type: file` -// is formData-only; a raw body uses `{type: string, format: binary}`.) +// TestCoverage_Bug2441 locks go-swagger issue #2441 ("file upload how to describe in annotations"): +// a raw (non-multipart) binary body is described with a string field marked `swagger:strfmt binary` +// → format binary. (OpenAPI 2.0 `type: file` is formData-only; a raw body uses `{type: string, +// format: binary}`.) func TestCoverage_Bug2441(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2441/..."}, diff --git a/internal/integration/coverage_bug_2479_test.go b/internal/integration/coverage_bug_2479_test.go index f75fc2fb..2ecde161 100644 --- a/internal/integration/coverage_bug_2479_test.go +++ b/internal/integration/coverage_bug_2479_test.go @@ -14,14 +14,15 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2479 locks the resolution of go-swagger issue #2479 -// ("how to disable security on a route but keep on all endpoints"). +// TestCoverage_Bug2479 locks the resolution of go-swagger issue #2479 ("how to disable security on +// a route but keep on all endpoints"). // -// A route with `Security: []` must emit an explicit empty `security: []` on the -// operation — the OpenAPI 2.0 idiom for opting OUT of global security. Before the -// fix the scanner dropped it entirely (no `security` key), so the operation -// silently inherited global security instead of overriding it. The fix makes -// security.Parse return a non-nil empty list for the explicit `[]` form, which +// A route with `Security: []` must emit an explicit empty `security: []` on the operation — the +// OpenAPI 2.0 idiom for opting OUT of global security. +// Before the fix the scanner dropped it entirely (no `security` key), so the operation silently +// inherited global security instead of overriding it. +// +// The fix makes security.Parse return a non-nil empty list for the explicit `[]` form, which // go-openapi/spec marshals as `"security": []` rather than omitting it. func TestCoverage_Bug2479(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ diff --git a/internal/integration/coverage_bug_2483_test.go b/internal/integration/coverage_bug_2483_test.go index 93e50f79..de5a5038 100644 --- a/internal/integration/coverage_bug_2483_test.go +++ b/internal/integration/coverage_bug_2483_test.go @@ -12,11 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2483 locks the fix for go-swagger issue #2483 ("generation -// from code not working with allOf"): a model embedding a swagger:allOf base -// plus a plain inline struct used to duplicate the allOf arms (the $ref and the -// inline object each appeared twice), which tripped a "circular ancestry" -// validation error. The arms are now emitted exactly once. +// TestCoverage_Bug2483 locks the fix for go-swagger issue #2483 ("generation from code not working +// with allOf"): a model embedding a swagger:allOf base plus a plain inline struct used to duplicate +// the allOf arms (the $ref and the inline object each appeared twice), which tripped a "circular +// ancestry" validation error. +// +// The arms are now emitted exactly once. func TestCoverage_Bug2483(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2483/..."}, diff --git a/internal/integration/coverage_bug_2520_test.go b/internal/integration/coverage_bug_2520_test.go index b549e604..14ffa293 100644 --- a/internal/integration/coverage_bug_2520_test.go +++ b/internal/integration/coverage_bug_2520_test.go @@ -12,11 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2520 locks go-swagger issue #2520 ("generate spec fails with -// unsupported type 'invalid type'"): a struct with a custom MarshalJSON and only -// unexported fields no longer aborts the scan — it emits an (empty) object and a -// $ref from the using field. codescan works at the type level and cannot infer -// the custom-marshaled wire shape; declaring it requires swagger:type / +// TestCoverage_Bug2520 locks go-swagger issue #2520 ("generate spec fails with unsupported type +// 'invalid type'"): a struct with a custom MarshalJSON and only unexported fields no longer aborts +// the scan — it emits an (empty) object and a $ref from the using field. codescan works at the +// type level and cannot infer the custom-marshaled wire shape; declaring it requires swagger:type / // swagger:strfmt on the wrapper type. func TestCoverage_Bug2520(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ diff --git a/internal/integration/coverage_bug_2547_test.go b/internal/integration/coverage_bug_2547_test.go index ba1c7ce2..ef675a33 100644 --- a/internal/integration/coverage_bug_2547_test.go +++ b/internal/integration/coverage_bug_2547_test.go @@ -12,11 +12,11 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2547 asserts the EXPECTED resolution of go-swagger issue #2547 -// (quirk F8): a string example/default written with surrounding quotes must have -// those delimiter quotes stripped. `example: ""` is the empty string; the -// scanner currently keeps the quotes verbatim (`""` -> the 2-char string `""`, -// `"Foo"` -> `"Foo"` with quotes), so this is currently RED. +// TestCoverage_Bug2547 asserts the EXPECTED resolution of go-swagger issue #2547 (quirk F8): a +// string example/default written with surrounding quotes must have those delimiter quotes stripped. +// +// `example: ""` is the empty string; the scanner currently keeps the quotes verbatim (`""` -> the +// 2-char string `""`, `"Foo"` -> `"Foo"` with quotes), so this is currently RED. func TestCoverage_Bug2547(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2547/..."}, diff --git a/internal/integration/coverage_bug_2549_test.go b/internal/integration/coverage_bug_2549_test.go index af5e4e2b..c1ccf4a9 100644 --- a/internal/integration/coverage_bug_2549_test.go +++ b/internal/integration/coverage_bug_2549_test.go @@ -12,12 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2549 locks go-swagger issue #2549 ("example not working for -// imported Types"): an example on a field whose type is an imported named type -// (a $ref) is applied via allOf — the example is no longer dropped ("shows 0"). +// TestCoverage_Bug2549 locks go-swagger issue #2549 ("example not working for imported Types"): an +// example on a field whose type is an imported named type (a $ref) is applied via allOf — the +// example is no longer dropped ("shows 0"). // -// (The example value is carried as the string "210000"; numeric coercion of -// example values is the separate forthcoming-features §2.1 / #1268 concern.) +// (The example value is carried as the string "210000"; numeric coercion of example values is the +// separate forthcoming-features §2.1 / #1268 concern.) func TestCoverage_Bug2549(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2549/..."}, diff --git a/internal/integration/coverage_bug_2588_test.go b/internal/integration/coverage_bug_2588_test.go index d61c5e6e..e3255dbd 100644 --- a/internal/integration/coverage_bug_2588_test.go +++ b/internal/integration/coverage_bug_2588_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2588 locks go-swagger issue #2588 ("panic on parsing interface -// type definition"): a struct embedding a named type whose underlying type is an -// interface (type B A; type C struct{ B }) no longer panics with -// "interface conversion: ast.Expr is *ast.Ident, not *ast.InterfaceType". +// TestCoverage_Bug2588 locks go-swagger issue #2588 ("panic on parsing interface type definition"): +// a struct embedding a named type whose underlying type is an interface (type B A; type C struct{ B +// }) no longer panics with "interface conversion: ast.Expr is *ast.Ident, not *ast.InterfaceType". func TestCoverage_Bug2588(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2588/..."}, diff --git a/internal/integration/coverage_bug_2592_test.go b/internal/integration/coverage_bug_2592_test.go index 6607b493..e6e14e0d 100644 --- a/internal/integration/coverage_bug_2592_test.go +++ b/internal/integration/coverage_bug_2592_test.go @@ -12,13 +12,13 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2592 locks the in-scope answer to go-swagger issue #2592 -// ("wrap/compose a response from two types"): `// swagger:allOf` on the embedded -// components of a body type composes them into an `allOf` schema. A named -// Combined{User,Token} yields allOf:[{$ref:User},{$ref:Token}]; an inline body -// with the same embeds yields that allOf directly on the response schema. -// (Embedding in the response WRAPPER instead turns the field into a header — the -// embeds must live in the body.) +// TestCoverage_Bug2592 locks the in-scope answer to go-swagger issue #2592 ("wrap/compose a +// response from two types"): `// swagger:allOf` on the embedded components of a body type composes +// them into an `allOf` schema. +// +// A named Combined{User,Token} yields allOf:[{$ref:User},{$ref:Token}]; an inline body with the +// same embeds yields that allOf directly on the response schema. (Embedding in the response WRAPPER +// instead turns the field into a header — the embeds must live in the body.) func TestCoverage_Bug2592(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2592/..."}, WorkDir: scantest.FixturesDir(), ScanModels: true, diff --git a/internal/integration/coverage_bug_2599_test.go b/internal/integration/coverage_bug_2599_test.go index c5cd1850..ceb6aaeb 100644 --- a/internal/integration/coverage_bug_2599_test.go +++ b/internal/integration/coverage_bug_2599_test.go @@ -12,10 +12,11 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2599 locks the resolution to go-swagger issue #2599 ("custom -// type on model fields"): a field-level `// swagger:type string` override makes -// a custom Go type (here UUID, an array under the hood) render as a bare string -// in the spec, without needing a strfmt type. Same mechanism answers #2404/#2419. +// TestCoverage_Bug2599 locks the resolution to go-swagger issue #2599 ("custom type on model +// fields"): a field-level `// swagger:type string` override makes a custom Go type (here UUID, an +// array under the hood) render as a bare string in the spec, without needing a strfmt type. +// +// Same mechanism answers #2404/#2419. func TestCoverage_Bug2599(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2599/..."}, diff --git a/internal/integration/coverage_bug_2637_test.go b/internal/integration/coverage_bug_2637_test.go index 8712352c..c8e61ac0 100644 --- a/internal/integration/coverage_bug_2637_test.go +++ b/internal/integration/coverage_bug_2637_test.go @@ -12,21 +12,21 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2637 locks the fix for go-swagger issue #2637: a local type -// defined from a same-named type in another package -// (`type CreateDomainRequest mongo.CreateDomainRequest`) used to collide on the -// short key and emit a definition whose body was a `$ref` TO ITSELF — invalid -// OAS that hangs downstream codegen. Now the local type and mongo's get distinct -// concat-qualified names, so the local definition's body is a `$ref` to the -// MONGO definition — a valid cross-type reference, no self-`$ref`. Same family -// as #2783. See .claude/plans/name-identity-cyclic-ref.md. +// TestCoverage_Bug2637 locks the fix for go-swagger issue #2637: a local type defined from a +// same-named type in another package (`type CreateDomainRequest mongo.CreateDomainRequest`) used to +// collide on the short key and emit a definition whose body was a `$ref` TO ITSELF — invalid OAS +// that hangs downstream codegen. +// +// Now the local type and mongo's get distinct concat-qualified names, so the local definition's +// body is a `$ref` to the MONGO definition — a valid cross-type reference, no self-`$ref`. +// Same family as #2783. See .claude/plans/name-identity-cyclic-ref.md. func TestCoverage_Bug2637(t *testing.T) { doc, diags := nameIdentityDocDiags(t, "./bugs/2637/...") require.Len(t, doc.Definitions, 2, "the local and the mongo CreateDomainRequest are distinct") - // The local defined type (package bug2637, dir "2637") qualifies to - // X2637CreateDomainRequest; the mongo one to MongoCreateDomainRequest. + // The local defined type (package bug2637, dir "2637") qualifies to X2637CreateDomainRequest; the + // mongo one to MongoCreateDomainRequest. local, ok := doc.Definitions["X2637CreateDomainRequest"] require.True(t, ok, "the local defined type keeps its own (qualified) definition") require.Contains(t, doc.Definitions, "MongoCreateDomainRequest") diff --git a/internal/integration/coverage_bug_2638_test.go b/internal/integration/coverage_bug_2638_test.go index cac7f4cc..edd0d563 100644 --- a/internal/integration/coverage_bug_2638_test.go +++ b/internal/integration/coverage_bug_2638_test.go @@ -12,9 +12,8 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2638 locks the fix for go-swagger issue #2638: a field group -// with multiple names on one line (`R, G, B, A uint8`) emits one property per -// name, not a single mislabeled property. +// TestCoverage_Bug2638 locks the fix for go-swagger issue #2638: a field group with multiple names +// on one line (`R, G, B, A uint8`) emits one property per name, not a single mislabeled property. func TestCoverage_Bug2638(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2638/..."}, @@ -34,9 +33,9 @@ func TestCoverage_Bug2638(t *testing.T) { assert.NotContains(t, ps.Extensions, "x-go-name", "property %q must not carry a stray x-go-name", name) } - // Mixed: the multi-name group keeps each member; the json rename is dropped - // (a single rename cannot name X/Y/Z) while omitempty still applied during - // parsing. Single-name fields are unaffected. + // Mixed: the multi-name group keeps each member; the json rename is dropped (a single rename + // cannot name X/Y/Z) while omitempty still applied during parsing. + // Single-name fields are unaffected. mixed := doc.Definitions["Mixed"].Properties require.Len(t, mixed, 4, "X, Y, Z each emit, plus Label") for _, name := range []string{"X", "Y", "Z", "Label"} { diff --git a/internal/integration/coverage_bug_2651_test.go b/internal/integration/coverage_bug_2651_test.go index a45aa701..629bcb36 100644 --- a/internal/integration/coverage_bug_2651_test.go +++ b/internal/integration/coverage_bug_2651_test.go @@ -13,14 +13,13 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2651 locks the fix for go-swagger issue #2651: an operation -// mixing inline `swagger:operation` parameters with a `swagger:parameters`-bound -// struct used to mis-bind — the bound body's `$ref` schema was welded onto the -// inline `id` PATH parameter (invalid: a path param cannot carry a body schema), -// instead of being emitted as a separate body parameter. +// TestCoverage_Bug2651 locks the fix for go-swagger issue #2651: an operation mixing inline +// `swagger:operation` parameters with a `swagger:parameters`-bound struct used to mis-bind — the +// bound body's `$ref` schema was welded onto the inline `id` PATH parameter (invalid: a path param +// cannot carry a body schema), instead of being emitted as a separate body parameter. // -// After the fix the bound body is its own `in: body` parameter and the inline -// path param carries only its simple schema (no Schema/$ref). +// After the fix the bound body is its own `in: body` parameter and the inline path param carries +// only its simple schema (no Schema/$ref). func TestCoverage_Bug2651(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2651/..."}, diff --git a/internal/integration/coverage_bug_2652_test.go b/internal/integration/coverage_bug_2652_test.go index 0c6a33d0..3d3981f2 100644 --- a/internal/integration/coverage_bug_2652_test.go +++ b/internal/integration/coverage_bug_2652_test.go @@ -12,16 +12,17 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2652 locks the fix for go-swagger issue #2652 ("how to add -// complex example for a swagger:model"): an `example:` on a field whose type is -// a named model (rendered as a $ref) used to be dropped. It is now preserved on -// the override arm of an allOf compound (the #3125 shape), so the $ref and the -// example coexist. +// TestCoverage_Bug2652 locks the fix for go-swagger issue #2652 ("how to add complex example for a +// swagger:model"): an `example:` on a field whose type is a named model (rendered as a $ref) used +// to be dropped. // -// A complex JSON example on a $ref'd field is coerced into a structured -// object on the override arm — matching the direct-field path (quirk G3, -// fixed). Previously it was carried as a raw string ("{...}") because the -// override arm has no type to coerce against. +// It is now preserved on the override arm of an allOf compound (the #3125 shape), so the $ref and +// the example coexist. +// +// A complex JSON example on a $ref'd field is coerced into a structured object on the override arm +// — matching the direct-field path (quirk G3, fixed). +// Previously it was carried as a raw string ("{...}") because the override arm has no type to +// coerce against. func TestCoverage_Bug2652(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2652/..."}, diff --git a/internal/integration/coverage_bug_2655_test.go b/internal/integration/coverage_bug_2655_test.go index 3f7670ec..391481bf 100644 --- a/internal/integration/coverage_bug_2655_test.go +++ b/internal/integration/coverage_bug_2655_test.go @@ -13,12 +13,13 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2655 verifies the fix for go-swagger issue #2655: a `Tags:` -// block in swagger:meta now populates the top-level `tags` section -// (spec.Swagger.Tags) instead of being swallowed into info.description. Each -// tag carries its name, description, optional externalDocs and vendor -// extensions — the YAML list nesting survives the raw-block lexer because -// `Tags:` preserves per-line indentation (like extensions/securityDefinitions). +// TestCoverage_Bug2655 verifies the fix for go-swagger issue #2655: a `Tags:` block in swagger:meta +// now populates the top-level `tags` section (spec.Swagger.Tags) instead of being swallowed into +// info.description. +// +// Each tag carries its name, description, optional externalDocs and vendor extensions — the YAML +// list nesting survives the raw-block lexer because `Tags:` preserves per-line indentation (like +// extensions/securityDefinitions). func TestCoverage_Bug2655(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2655/..."}, @@ -27,8 +28,7 @@ func TestCoverage_Bug2655(t *testing.T) { require.NoError(t, err) require.NotNil(t, doc) - // The Tags block now lands on doc.Tags, no longer leaking its content - // into the info.description. + // The Tags block now lands on doc.Tags, no longer leaking its content into the info.description. require.NotNil(t, doc.Info) assert.False(t, strings.Contains(doc.Info.Description, "Everything about your Pets"), "the Tags block content must not leak into info.description") @@ -48,11 +48,10 @@ func TestCoverage_Bug2655(t *testing.T) { assert.Equal(t, "Store", store.Extensions["x-display-name"], "per-tag vendor extension must survive") - // Operation-level tags are a plain string list (not objects). A - // swagger:route unions its header-line tag (`pets`) with the body - // `Tags:` keyword (`pets`, `store`), deduping to [pets, store]. A - // swagger:operation gets the same list straight from its wholesale - // YAML body. + // Operation-level tags are a plain string list (not objects). + // A swagger:route unions its header-line tag (`pets`) with the body `Tags:` keyword (`pets`, + // `store`), deduping to [pets, store]. + // A swagger:operation gets the same list straight from its wholesale YAML body. require.NotNil(t, doc.Paths) listPets := doc.Paths.Paths["/pets"].Get require.NotNil(t, listPets, "GET /pets must be present") diff --git a/internal/integration/coverage_bug_2663_test.go b/internal/integration/coverage_bug_2663_test.go index 5fdc5948..5d126718 100644 --- a/internal/integration/coverage_bug_2663_test.go +++ b/internal/integration/coverage_bug_2663_test.go @@ -12,11 +12,13 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2663 documents the recommended idiom for go-swagger issue -// #2663 ("How to document a body parameter in a POST request?"). The answer is -// a doc-only one: a body parameter is a SINGLE parameter whose Go type is the -// body schema. Declare exactly ONE field carrying `in: body`; give it a named -// type so the schema is emitted as a reusable $ref. +// TestCoverage_Bug2663 documents the recommended idiom for go-swagger issue #2663 ("How to document +// a body parameter in a POST request?"). +// +// The answer is a doc-only one: a body parameter is a SINGLE parameter whose Go type is the body +// schema. +// Declare exactly ONE field carrying `in: body`; give it a named type so the schema is emitted as a +// reusable $ref. // // This is also the contrast against the proposals floated on the issue: // - marking each scalar field `in: body` emits a SEPARATE body parameter per diff --git a/internal/integration/coverage_bug_2687_test.go b/internal/integration/coverage_bug_2687_test.go index c091a015..6ad1c441 100644 --- a/internal/integration/coverage_bug_2687_test.go +++ b/internal/integration/coverage_bug_2687_test.go @@ -12,14 +12,15 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2687 covers go-swagger issue #2687 ("Ignore kubebuilder -// annotations"): tool directive markers (`// +kubebuilder:...`, `// +genclient`, -// `// +k8s:...`) used to leak into the generated model and property -// descriptions. This was also the residual flagged by #3007. +// TestCoverage_Bug2687 covers go-swagger issue #2687 ("Ignore kubebuilder annotations"): tool +// directive markers (`// +kubebuilder:...`, `// +genclient`, `// +k8s:...`) used to leak into the +// generated model and property descriptions. // -// FIXED (fix/go-swagger-2687): the lexer classifies `// +marker`-style lines as -// directives (alongside Go `//go:`/`//nolint:` directives) and drops them from -// the prose surface, so the descriptions stay clean. +// This was also the residual flagged by #3007. +// +// FIXED (fix/go-swagger-2687): the lexer classifies `// +marker`-style lines as directives +// (alongside Go `//go:`/`//nolint:` directives) and drops them from the prose surface, so the +// descriptions stay clean. func TestCoverage_Bug2687(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2687/..."}, @@ -37,7 +38,7 @@ func TestCoverage_Bug2687(t *testing.T) { assert.NotContains(t, app.Description, "+genclient") assert.NotContains(t, app.Properties["name"].Description, "+kubebuilder") - // The +kubebuilder:validation:Required marker is dropped, but the - // genuine `required: true` keyword on the same field is still honoured. + // The +kubebuilder:validation:Required marker is dropped, but the genuine `required: true` keyword + // on the same field is still honoured. assert.Contains(t, app.Required, "name") } diff --git a/internal/integration/coverage_bug_2701_test.go b/internal/integration/coverage_bug_2701_test.go index 4685483d..51272e9a 100644 --- a/internal/integration/coverage_bug_2701_test.go +++ b/internal/integration/coverage_bug_2701_test.go @@ -12,14 +12,13 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2701 covers go-swagger issue #2701: an `in: path` (and -// `required: true`) annotation on an EMBEDDED struct field used to be ignored — -// the promoted fields (vendorID/urcapID/version) defaulted to `in: query`, -// invalid for the route's path templates. +// TestCoverage_Bug2701 covers go-swagger issue #2701: an `in: path` (and `required: true`) +// annotation on an EMBEDDED struct field used to be ignored — the promoted fields +// (vendorID/urcapID/version) defaulted to `in: query`, invalid for the route's path templates. // -// FIXED (fix/go-swagger-2701): the embed's in:/required: annotation propagates -// to the parameters it promotes, recursively. The test also guards the -// exportedness contract: only exported fields are promoted (including ones +// FIXED (fix/go-swagger-2701): the embed's in:/required: annotation propagates to the parameters it +// promotes, recursively. +// The test also guards the exportedness contract: only exported fields are promoted (including ones // reached through an unexported embed), and unexported fields never surface. func TestCoverage_Bug2701(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ @@ -36,8 +35,8 @@ func TestCoverage_Bug2701(t *testing.T) { names := make([]string, 0, len(pi.Delete.Parameters)) for _, p := range pi.Delete.Parameters { - // FIXED: the embed's in:path propagates to every promoted field, - // including "trace" reached through the unexported auditInfo embed. + // FIXED: the embed's in:path propagates to every promoted field, including "trace" reached + // through the unexported auditInfo embed. assert.Equal(t, "path", p.In, "in:path on the embedded struct must make %q a path param (go-swagger#2701)", p.Name) assert.True(t, p.Required, @@ -45,8 +44,8 @@ func TestCoverage_Bug2701(t *testing.T) { names = append(names, p.Name) } - // Exported fields promote (recursively); unexported "secret" and "token" - // must never surface — the product documents only the public API surface. + // Exported fields promote (recursively); unexported "secret" and "token" must never surface — + // the product documents only the public API surface. assert.ElementsMatch(t, []string{"vendorID", "urcapID", "version", "trace"}, names) assert.NotContains(t, names, "secret") assert.NotContains(t, names, "token") diff --git a/internal/integration/coverage_bug_2746_test.go b/internal/integration/coverage_bug_2746_test.go index 5f143269..fa2a78f5 100644 --- a/internal/integration/coverage_bug_2746_test.go +++ b/internal/integration/coverage_bug_2746_test.go @@ -12,10 +12,11 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2746 locks the fix for go-swagger issue #2746 ("Strfmt: -// array of UUID"): a slice of a swagger:strfmt-uuid type used to lose the -// format on its items (strfmt only applied to a single value). The format now -// applies to the array's items. +// TestCoverage_Bug2746 locks the fix for go-swagger issue #2746 ("Strfmt: array of UUID"): a slice +// of a swagger:strfmt-uuid type used to lose the format on its items (strfmt only applied to a +// single value). +// +// The format now applies to the array's items. func TestCoverage_Bug2746(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2746/..."}, diff --git a/internal/integration/coverage_bug_2761_test.go b/internal/integration/coverage_bug_2761_test.go index c4f8dbb2..0fed78c2 100644 --- a/internal/integration/coverage_bug_2761_test.go +++ b/internal/integration/coverage_bug_2761_test.go @@ -12,15 +12,16 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2761 documents the resolution to go-swagger issue #2761 -// ("allOf in response doesn't use $ref"): a response body with an embedded -// `swagger:allOf` member emits a proper `allOf: [{$ref}, …]` — provided the -// embedded base is a swagger:model (a definition to reference). The reporter -// embedded a swagger:*response* (which has no definition), so its fields were -// inlined; using a swagger:model yields the $ref, as locked here. +// TestCoverage_Bug2761 documents the resolution to go-swagger issue #2761 ("allOf in response +// doesn't use $ref"): a response body with an embedded `swagger:allOf` member emits a proper +// `allOf: [{$ref}, …]` — provided the embedded base is a swagger:model (a definition to +// reference). // -// 📖 Need doc: an allOf $ref requires the embedded base to be a swagger:model, -// not a swagger:response. +// The reporter embedded a swagger:*response* (which has no definition), so its fields were inlined; +// using a swagger:model yields the $ref, as locked here. +// +// 📖 Need doc: an allOf $ref requires the embedded base to be a swagger:model, not a +// swagger:response. func TestCoverage_Bug2761(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2761/..."}, diff --git a/internal/integration/coverage_bug_2783_test.go b/internal/integration/coverage_bug_2783_test.go index ac26a082..37891dc7 100644 --- a/internal/integration/coverage_bug_2783_test.go +++ b/internal/integration/coverage_bug_2783_test.go @@ -12,13 +12,15 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2783 locks the fix for go-swagger issue #2783: two packages -// each declare `swagger:model Test`. They used to collide on the short key -// "Test" and SILENTLY MERGE (union of both structs' fields, last-package-wins, -// non-deterministic). Now each keeps its own identity: two distinct Test -// definitions (deep-keyed by package while the leaf collides) plus -// TestResponseBody — three definitions, deterministic. See the name-identity / -// cyclic-$ref design (.claude/plans/name-identity-cyclic-ref.md). +// TestCoverage_Bug2783 locks the fix for go-swagger issue #2783: two packages each declare +// `swagger:model Test`. +// +// They used to collide on the short key "Test" and SILENTLY MERGE (union of both structs' fields, +// last-package-wins, non-deterministic). +// Now each keeps its own identity: two distinct Test definitions (deep-keyed by package while the +// leaf collides) plus TestResponseBody — three definitions, deterministic. +// +// See the name-identity / cyclic-$ref design (.claude/plans/name-identity-cyclic-ref.md). func TestCoverage_Bug2783(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2783/..."}, @@ -32,8 +34,8 @@ func TestCoverage_Bug2783(t *testing.T) { "TestResponseBody + two distinct Tests (b.Test and c.Test no longer merge)") assert.NotContains(t, doc.Definitions, "Test", "no merged bare Test key") - // The two Tests are distinct, qualified by their package leaf: BTest - // carries only b's field, CTest only c's. + // The two Tests are distinct, qualified by their package leaf: BTest carries only b's field, CTest + // only c's. bTest, okB := doc.Definitions["BTest"] cTest, okC := doc.Definitions["CTest"] require.True(t, okB, "b.Test -> BTest") diff --git a/internal/integration/coverage_bug_2799_test.go b/internal/integration/coverage_bug_2799_test.go index e69de798..16ceb3d0 100644 --- a/internal/integration/coverage_bug_2799_test.go +++ b/internal/integration/coverage_bug_2799_test.go @@ -12,14 +12,15 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2799 locks the fix for go-swagger issue #2799 ("swagger -// generator should keep the format of the API description"): a markdown bullet -// list in the description used to lose its leading `-` (silent strip). The -// grammar2 parser preserves it (forthcoming-features §4.2), so the meta -// description round-trips the list verbatim. +// TestCoverage_Bug2799 locks the fix for go-swagger issue #2799 ("swagger generator should keep the +// format of the API description"): a markdown bullet list in the description used to lose its +// leading `-` (silent strip). // -// (Distinct from #3211, which is about markdown *table* rows losing their -// leading pipe — still open.) +// The grammar2 parser preserves it (forthcoming-features §4.2), so the meta description +// round-trips the list verbatim. +// +// (Distinct from #3211, which is about markdown *table* rows losing their leading pipe — still +// open.) func TestCoverage_Bug2799(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2799/..."}, diff --git a/internal/integration/coverage_bug_2801_test.go b/internal/integration/coverage_bug_2801_test.go index ed082784..26bd95c4 100644 --- a/internal/integration/coverage_bug_2801_test.go +++ b/internal/integration/coverage_bug_2801_test.go @@ -12,11 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2801 locks the fix for go-swagger issue #2801 ("Spec is not -// generated if generic struct declaration is not in the same file as -// swagger:parameters"): the generic struct (WrappedRequest[T], in types.go) is -// resolved even though its swagger:parameters instantiation lives in another -// file of the same package (params.go). The v0.30 empty-spec is gone. +// TestCoverage_Bug2801 locks the fix for go-swagger issue #2801 ("Spec is not generated if generic +// struct declaration is not in the same file as swagger:parameters"): the generic struct +// (WrappedRequest[T], in types.go) is resolved even though its swagger:parameters instantiation +// lives in another file of the same package (params.go). +// +// The v0.30 empty-spec is gone. func TestCoverage_Bug2801(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2801/..."}, diff --git a/internal/integration/coverage_bug_2802_test.go b/internal/integration/coverage_bug_2802_test.go index 11753896..1909a7f3 100644 --- a/internal/integration/coverage_bug_2802_test.go +++ b/internal/integration/coverage_bug_2802_test.go @@ -12,15 +12,14 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2802 locks the fix for go-swagger issue #2802 ("Error -// generating spec if swagger:model is a generic struct"): an instantiated -// generic (`WrappedRequest[Request]`) used to panic with "can't determine -// refined type T (*types.TypeParam)". +// TestCoverage_Bug2802 locks the fix for go-swagger issue #2802 ("Error generating spec if +// swagger:model is a generic struct"): an instantiated generic (`WrappedRequest[Request]`) used to +// panic with "can't determine refined type T (*types.TypeParam)". // -// It no longer panics: the instantiated type resolves its type argument -// (`Body.Body -> $ref: Request`). The uninstantiated generic's free type -// parameter `T` is skipped with a warning (it has no spec representation), -// which is acceptable — the point is the scan completes. +// It no longer panics: the instantiated type resolves its type argument (`Body.Body -> $ref: +// Request`). +// The uninstantiated generic's free type parameter `T` is skipped with a warning (it has no spec +// representation), which is acceptable — the point is the scan completes. func TestCoverage_Bug2802(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2802/..."}, diff --git a/internal/integration/coverage_bug_2804_test.go b/internal/integration/coverage_bug_2804_test.go index 6fdeaa28..f04740e1 100644 --- a/internal/integration/coverage_bug_2804_test.go +++ b/internal/integration/coverage_bug_2804_test.go @@ -14,9 +14,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2804 verifies the fix for go-swagger issue #2804: a -// map[string][]string field used to crash (parameters) or silently corrupt the -// output (response headers) — a Go map has no OAS2 SimpleSchema representation. +// TestCoverage_Bug2804 verifies the fix for go-swagger issue #2804: a map[string][]string field +// used to crash (parameters) or silently corrupt the output (response headers) — a Go map has no +// OAS2 SimpleSchema representation. +// // The same guard now covers both SimpleSchema targets: // // - a map swagger:parameters field (in=query) used to panic with a nil-ptr @@ -24,10 +25,12 @@ import ( // - a map response header field (in=header) used to write `object` onto the // response *body* schema and leave the header untyped. // -// Fixed behaviour: each unrepresentable map field is guarded — the scan emits a -// located diagnostic (validate.unsupported-in-simple-schema) and skips the -// field instead of panicking or corrupting the output. This is also a concrete -// witness for the §8.1 scanner panic-recovery feature (forthcoming-features.md). +// Fixed behaviour: each unrepresentable map field is guarded — the scan emits a located +// diagnostic (validate.unsupported-in-simple-schema) and skips the field instead of panicking or +// corrupting the output. +// +// This is also a concrete witness for the §8.1 scanner panic-recovery feature +// (forthcoming-features.md). func TestCoverage_Bug2804(t *testing.T) { var diagnostics []grammar.Diagnostic @@ -54,8 +57,8 @@ func TestCoverage_Bug2804(t *testing.T) { "the unrepresentable map param must be skipped, not emitted") } - // The map response-header field is skipped, and the response body schema - // was NOT corrupted by the map (the bug wrote `object` onto resp.Schema). + // The map response-header field is skipped, and the response body schema was NOT corrupted by the + // map (the bug wrote `object` onto resp.Schema). resp := op.Responses.StatusCodeResponses[200] assert.NotContains(t, resp.Headers, "X-Filters", "the unrepresentable map header must be skipped, not emitted") diff --git a/internal/integration/coverage_bug_2837_test.go b/internal/integration/coverage_bug_2837_test.go index 8a1c08bc..1f1f6f24 100644 --- a/internal/integration/coverage_bug_2837_test.go +++ b/internal/integration/coverage_bug_2837_test.go @@ -14,11 +14,11 @@ import ( oaispec "github.com/go-openapi/spec" ) -// TestCoverage_Bug2837 locks the fix for go-swagger issue #2837 ("Responses -// defined in routes break with go 1.19 formatting"): gofmt rewrites a route's -// `+ name:` parameter bullet to `- name:`, which used to break parsing. Both -// the authored `+` form and the gofmt-canonical `-` form now parse to the same -// parameter. +// TestCoverage_Bug2837 locks the fix for go-swagger issue #2837 ("Responses defined in routes break +// with go 1.19 formatting"): gofmt rewrites a route's `+ name:` parameter bullet to `- name:`, +// which used to break parsing. +// +// Both the authored `+` form and the gofmt-canonical `-` form now parse to the same parameter. func TestCoverage_Bug2837(t *testing.T) { scan := func(pkg string) *oaispec.Swagger { doc, err := codescan.Run(&codescan.Options{Packages: []string{pkg}, WorkDir: scantest.FixturesDir()}) diff --git a/internal/integration/coverage_bug_2871_test.go b/internal/integration/coverage_bug_2871_test.go index 56fe8829..cc10dbb9 100644 --- a/internal/integration/coverage_bug_2871_test.go +++ b/internal/integration/coverage_bug_2871_test.go @@ -12,11 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2871 locks the reasonable answer to go-swagger issue #2871: the -// literal "dynamic examples" framing is out of scope, but distinct per-operation, -// per-response examples (by mime type) ARE supported — multiple operations may -// reuse one example-bearing model while each response code carries its own -// example, different from the model's. +// TestCoverage_Bug2871 locks the reasonable answer to go-swagger issue #2871: the literal "dynamic +// examples" framing is out of scope, but distinct per-operation, per-response examples (by mime +// type) ARE supported — multiple operations may reuse one example-bearing model while each +// response code carries its own example, different from the model's. func TestCoverage_Bug2871(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2871/..."}, WorkDir: scantest.FixturesDir(), ScanModels: true, diff --git a/internal/integration/coverage_bug_2872_test.go b/internal/integration/coverage_bug_2872_test.go index ccc90f57..54da5a88 100644 --- a/internal/integration/coverage_bug_2872_test.go +++ b/internal/integration/coverage_bug_2872_test.go @@ -12,14 +12,13 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2872 verifies the fix for go-swagger issue #2872: an -// ExternalDocs block in swagger:meta now emits the top-level -// `externalDocs: {description, url}` (it was previously dropped — the keyword -// existed in the grammar but was not wired into the meta builder). +// TestCoverage_Bug2872 verifies the fix for go-swagger issue #2872: an ExternalDocs block in +// swagger:meta now emits the top-level `externalDocs: {description, url}` (it was previously +// dropped — the keyword existed in the grammar but was not wired into the meta builder). // -// Companion feature (forthcoming-features.md §9): ExternalDocs is also valid on -// other OAIv2 objects (schema, operation, tag); supporting those is a separate -// feature beyond this meta-focused fix. +// Companion feature (forthcoming-features.md §9): ExternalDocs is also valid on other OAIv2 +// objects (schema, operation, tag); supporting those is a separate feature beyond this meta-focused +// fix. func TestCoverage_Bug2872(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2872/..."}, diff --git a/internal/integration/coverage_bug_2875_test.go b/internal/integration/coverage_bug_2875_test.go index ba18536f..5640bc29 100644 --- a/internal/integration/coverage_bug_2875_test.go +++ b/internal/integration/coverage_bug_2875_test.go @@ -12,10 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2875 locks the fix for go-swagger issue #2875 ("AllOf member -// does not generate an external $ref object"): an embedded struct annotated -// `swagger:allOf` used to inline the embedded type's properties; it now emits -// a proper `allOf: [{$ref: …}]` to the embedded model's definition. +// TestCoverage_Bug2875 locks the fix for go-swagger issue #2875 ("AllOf member does not generate an +// external $ref object"): an embedded struct annotated `swagger:allOf` used to inline the embedded +// type's properties; it now emits a proper `allOf: [{$ref: …}]` to the embedded model's +// definition. func TestCoverage_Bug2875(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2875/..."}, diff --git a/internal/integration/coverage_bug_2899_test.go b/internal/integration/coverage_bug_2899_test.go index 28ec9145..a3266882 100644 --- a/internal/integration/coverage_bug_2899_test.go +++ b/internal/integration/coverage_bug_2899_test.go @@ -12,13 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2899 locks the fix for go-swagger issue #2899 ("Example not -// being added to schema for body string params"): an `example:` on an inline -// body parameter of primitive type is now carried onto the parameter's schema. +// TestCoverage_Bug2899 locks the fix for go-swagger issue #2899 ("Example not being added to schema +// for body string params"): an `example:` on an inline body parameter of primitive type is now +// carried onto the parameter's schema. // -// The quoted source example (`example: "123456"`) emits the quote-stripped -// value `123456`: surrounding quotes are delimiters (quirk F8, fixed alongside -// go-swagger#2547). +// The quoted source example (`example: "123456"`) emits the quote-stripped value `123456`: +// surrounding quotes are delimiters (quirk F8, fixed alongside go-swagger#2547). func TestCoverage_Bug2899(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2899/..."}, diff --git a/internal/integration/coverage_bug_2907_test.go b/internal/integration/coverage_bug_2907_test.go index 77be72ec..2d292f45 100644 --- a/internal/integration/coverage_bug_2907_test.go +++ b/internal/integration/coverage_bug_2907_test.go @@ -12,11 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2907 locks the fix for go-swagger issue #2907 ("Go-Swagger -// not generating properties in yaml file"): a response whose body is a slice -// of a cross-package model (`Body []data.Movie`) used to emit `items: {}` — -// the model's properties were lost. The cross-package model is now resolved, -// so the response schema is an array of `$ref` to the emitted definition. +// TestCoverage_Bug2907 locks the fix for go-swagger issue #2907 ("Go-Swagger not generating +// properties in yaml file"): a response whose body is a slice of a cross-package model (`Body +// []data.Movie`) used to emit `items: {}` — the model's properties were lost. +// +// The cross-package model is now resolved, so the response schema is an array of `$ref` to the +// emitted definition. func TestCoverage_Bug2907(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2907/..."}, diff --git a/internal/integration/coverage_bug_2909_test.go b/internal/integration/coverage_bug_2909_test.go index 95fc590a..882f667c 100644 --- a/internal/integration/coverage_bug_2909_test.go +++ b/internal/integration/coverage_bug_2909_test.go @@ -13,12 +13,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2909 verifies the fix for go-swagger issue #2909: a route -// path with an inline regex segment ({id:[0-9]+}) was silently dropped because -// rxPath's alphabet lacks `[`/`]`. The fix strips the regex constraint to the -// RFC 6570 URI Template Level-1 form ({id}) before matching, so the route is -// emitted as /items/{id}, and warns that the constraint was dropped (OpenAPI -// 2.0 path templating cannot express it). +// TestCoverage_Bug2909 verifies the fix for go-swagger issue #2909: a route path with an inline +// regex segment ({id:[0-9]+}) was silently dropped because rxPath's alphabet lacks `[`/`]`. +// +// The fix strips the regex constraint to the RFC 6570 URI Template Level-1 form ({id}) before +// matching, so the route is emitted as /items/{id}, and warns that the constraint was dropped +// (OpenAPI 2.0 path templating cannot express it). func TestCoverage_Bug2909(t *testing.T) { var diags []grammar.Diagnostic doc, err := codescan.Run(&codescan.Options{ diff --git a/internal/integration/coverage_bug_2917_test.go b/internal/integration/coverage_bug_2917_test.go index c05087b5..5adcfc5f 100644 --- a/internal/integration/coverage_bug_2917_test.go +++ b/internal/integration/coverage_bug_2917_test.go @@ -11,11 +11,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2917 locks the fix for go-swagger issue #2917 ("classifier: -// unknown swagger annotation \"extendee\""): a grpc-gateway generated comment -// containing `…openapiv2_swagger:extendee…` used to be matched by the -// over-broad annotation regex and aborted the scan. The current regex only -// matches `swagger:` at start-of-line or after whitespace/slash, so the +// TestCoverage_Bug2917 locks the fix for go-swagger issue #2917 ("classifier: unknown swagger +// annotation \"extendee\""): a grpc-gateway generated comment containing +// `…openapiv2_swagger:extendee…` used to be matched by the over-broad annotation regex and +// aborted the scan. +// +// The current regex only matches `swagger:` at start-of-line or after whitespace/slash, so the // embedded `_swagger:extendee` is not treated as an annotation. func TestCoverage_Bug2917(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ diff --git a/internal/integration/coverage_bug_2922_test.go b/internal/integration/coverage_bug_2922_test.go index fde54c47..f1aa9b4c 100644 --- a/internal/integration/coverage_bug_2922_test.go +++ b/internal/integration/coverage_bug_2922_test.go @@ -14,11 +14,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2922 covers go-swagger issue #2922 ("enum description: -// superfluous name&values"): the enum const-name mapping (e.g. "FIRST -// TestEnumFirst") was unconditionally appended to the property description AND -// duplicated in x-go-enum-desc — the reporter finds the description pollution -// superfluous. +// TestCoverage_Bug2922 covers go-swagger issue #2922 ("enum description: superfluous name&values"): +// the enum const-name mapping (e.g. "FIRST TestEnumFirst") was unconditionally appended to the +// property description AND duplicated in x-go-enum-desc — the reporter finds the description +// pollution superfluous. // // Fix (decided with the maintainer): the Options knob SkipEnumDescriptions. // - default (false): mapping appended to the description AND exposed via @@ -26,14 +25,15 @@ import ( // - true: the description is left as the authored prose; the mapping rides // x-go-enum-desc only. // -// Response headers are the third OAS2 SimpleSchema target. They now carry -// x-go-enum-desc like the other two: go-openapi/spec >= v0.22.6 emits the -// embedded VendorExtensible from Header.MarshalJSON (go-openapi/spec#277), -// where earlier versions dropped it. Headers never folded the mapping into -// their description, so SkipEnumDescriptions does not change them — the -// extension is present in both modes and the description stays the authored -// prose. A latent double-nesting bug (enum: [[FIRST, SECOND]]) in -// responseTypable.WithEnum was fixed alongside, so the values emit correctly. +// Response headers are the third OAS2 SimpleSchema target. +// They now carry x-go-enum-desc like the other two: go-openapi/spec >= v0.22.6 emits the embedded +// VendorExtensible from Header.MarshalJSON (go-openapi/spec#277), where earlier versions dropped +// it. +// +// Headers never folded the mapping into their description, so SkipEnumDescriptions does not change +// them — the extension is present in both modes and the description stays the authored prose. +// A latent double-nesting bug (enum: [[FIRST, SECOND]]) in responseTypable.WithEnum was fixed +// alongside, so the values emit correctly. func TestCoverage_Bug2922(t *testing.T) { const enumMapping = "FIRST TestEnumFirst" @@ -58,8 +58,8 @@ func TestCoverage_Bug2922(t *testing.T) { "default behaviour: the const mapping is folded into the parameter description") assert.Contains(t, param.Extensions, "x-go-enum-desc") - // Response header (in: header): flat enum, prose description, and the - // const mapping rides x-go-enum-desc (spec >= v0.22.6 marshals it). + // Response header (in: header): flat enum, prose description, and the const mapping rides + // x-go-enum-desc (spec >= v0.22.6 marshals it). assertEnumHeader(t, doc) scantest.CompareOrDumpJSON(t, doc, "bugs_2922_schema.json") @@ -93,17 +93,16 @@ func TestCoverage_Bug2922(t *testing.T) { assert.True(t, ok, "the mapping must still ride x-go-enum-desc on the parameter") assert.True(t, strings.Contains(paramEnumDesc, enumMapping)) - // Response header (in: header): identical to default mode — the knob - // does not touch headers (they never folded into the description), so - // x-go-enum-desc is present here too. + // Response header (in: header): identical to default mode — the knob does not touch headers + // (they never folded into the description), so x-go-enum-desc is present here too. assertEnumHeader(t, doc) scantest.CompareOrDumpJSON(t, doc, "bugs_2922_skip_enum_desc_schema.json") }) } -// queryParam returns the named in=query parameter of the GET /enum/test -// operation, failing the test if it's absent. +// queryParam returns the named in=query parameter of the GET /enum/test operation, failing the test +// if it's absent. func queryParam(t *testing.T, doc *spec.Swagger, name string) spec.Parameter { t.Helper() op := doc.Paths.Paths["/enum/test"].Get @@ -117,12 +116,12 @@ func queryParam(t *testing.T, doc *spec.Swagger, name string) spec.Parameter { return spec.Parameter{} } -// assertEnumHeader checks the X-Test-Enum header of the enumHeaderResponse -// swagger:response: the enum values are a flat list (not double-nested), the -// description is the authored prose, and the const mapping rides along on -// x-go-enum-desc (spec >= v0.22.6 marshals header vendor extensions). The -// behaviour is identical regardless of SkipEnumDescriptions, because headers -// never fold the mapping into the description. +// assertEnumHeader checks the X-Test-Enum header of the enumHeaderResponse swagger:response: the +// enum values are a flat list (not double-nested), the description is the authored prose, and the +// const mapping rides along on x-go-enum-desc (spec >= v0.22.6 marshals header vendor extensions). +// +// The behaviour is identical regardless of SkipEnumDescriptions, because headers never fold the +// mapping into the description. func assertEnumHeader(t *testing.T, doc *spec.Swagger) { t.Helper() resp, ok := doc.Responses["enumHeaderResponse"] diff --git a/internal/integration/coverage_bug_2961_test.go b/internal/integration/coverage_bug_2961_test.go index c1b3084a..044f2d11 100644 --- a/internal/integration/coverage_bug_2961_test.go +++ b/internal/integration/coverage_bug_2961_test.go @@ -12,10 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2961 locks the fix for go-swagger issue #2961 ("Improper -// parsing of uint enums"): an `enum:` on a uint / uint64 field used to be -// emitted as strings (["1","2","3"]). The values are now coerced to numbers -// against the integer schema type. +// TestCoverage_Bug2961 locks the fix for go-swagger issue #2961 ("Improper parsing of uint enums"): +// an `enum:` on a uint / uint64 field used to be emitted as strings (["1","2","3"]). +// +// The values are now coerced to numbers against the integer schema type. func TestCoverage_Bug2961(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2961/..."}, diff --git a/internal/integration/coverage_bug_2985_test.go b/internal/integration/coverage_bug_2985_test.go index f4863f84..abe0b563 100644 --- a/internal/integration/coverage_bug_2985_test.go +++ b/internal/integration/coverage_bug_2985_test.go @@ -12,11 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug2985 verifies the fix for go-swagger issue #2985: -// minProperties / maxProperties (the reporter's "minAttributes/maxAttributes") -// are now grammar keywords (mirroring minItems / maxItems), so on an object -// swagger:model they emit property-count validation instead of leaking into -// the model description. +// TestCoverage_Bug2985 verifies the fix for go-swagger issue #2985: minProperties / maxProperties +// (the reporter's "minAttributes/maxAttributes") are now grammar keywords (mirroring minItems / +// maxItems), so on an object swagger:model they emit property-count validation instead of leaking +// into the model description. func TestCoverage_Bug2985(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/2985/..."}, diff --git a/internal/integration/coverage_bug_3005_test.go b/internal/integration/coverage_bug_3005_test.go index e68d051f..4ad8c5cc 100644 --- a/internal/integration/coverage_bug_3005_test.go +++ b/internal/integration/coverage_bug_3005_test.go @@ -12,12 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug3005 locks the resolution of go-swagger issue #3005 -// ("additionalProperties are lost when generating spec from code"): a model -// with named properties AND free-form values. The json:"-" map the reporter -// used is muted; the type-level `swagger:additionalProperties number` marker -// supplies the additionalProperties value schema. field1 stays a named -// property alongside it. +// TestCoverage_Bug3005 locks the resolution of go-swagger issue #3005 ("additionalProperties are +// lost when generating spec from code"): a model with named properties AND free-form values. +// +// The json:"-" map the reporter used is muted; the type-level `swagger:additionalProperties number` +// marker supplies the additionalProperties value schema. field1 stays a named property alongside +// it. func TestCoverage_Bug3005(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/3005/..."}, diff --git a/internal/integration/coverage_bug_3007_test.go b/internal/integration/coverage_bug_3007_test.go index 06d49ed3..3f1e0238 100644 --- a/internal/integration/coverage_bug_3007_test.go +++ b/internal/integration/coverage_bug_3007_test.go @@ -12,14 +12,15 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug3007 locks the fix for go-swagger issue #3007 -// ("[Bug]generate spec error"): a non-swagger kubebuilder marker -// (`+kubebuilder:default:=false`) used to abort the scan with -// `strconv.ParseBool parsing "=false"`. The scan now succeeds. +// TestCoverage_Bug3007 locks the fix for go-swagger issue #3007 ("[Bug]generate spec error"): a +// non-swagger kubebuilder marker (`+kubebuilder:default:=false`) used to abort the scan with +// `strconv.ParseBool parsing "=false"`. // -// The residual (the marker leaking into the field description) is now closed -// alongside go-swagger#2687: the lexer drops `+marker`-style directive lines -// from prose, so the description is clean. +// The scan now succeeds. +// +// The residual (the marker leaking into the field description) is now closed alongside +// go-swagger#2687: the lexer drops `+marker`-style directive lines from prose, so the description +// is clean. func TestCoverage_Bug3007(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/3007/..."}, diff --git a/internal/integration/coverage_bug_3013_test.go b/internal/integration/coverage_bug_3013_test.go index 3664ea2d..8f837d1f 100644 --- a/internal/integration/coverage_bug_3013_test.go +++ b/internal/integration/coverage_bug_3013_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug3013 verifies the fix for go-swagger issue #3013: an -// `example:` on a swagger:response whose body is a top-level array type now -// lands on the response body schema (it was previously dropped — the response -// decl comment only contributed the description). +// TestCoverage_Bug3013 verifies the fix for go-swagger issue #3013: an `example:` on a +// swagger:response whose body is a top-level array type now lands on the response body schema (it +// was previously dropped — the response decl comment only contributed the description). func TestCoverage_Bug3013(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/3013/..."}, diff --git a/internal/integration/coverage_bug_301_test.go b/internal/integration/coverage_bug_301_test.go index d8976631..cc1ad12f 100644 --- a/internal/integration/coverage_bug_301_test.go +++ b/internal/integration/coverage_bug_301_test.go @@ -12,11 +12,11 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug301 locks the resolution to go-swagger issue #301 -// ("swagger:model huh?"): a swagger:model is emitted with its full title / -// description split and field validations. The reporter's "model not appearing" -// was the -m (ScanModels) requirement — an unreferenced model only shows up -// under -m, which is asserted here as the documented behaviour. +// TestCoverage_Bug301 locks the resolution to go-swagger issue #301 ("swagger:model huh?"): a +// swagger:model is emitted with its full title / description split and field validations. +// +// The reporter's "model not appearing" was the -m (ScanModels) requirement — an unreferenced +// model only shows up under -m, which is asserted here as the documented behaviour. func TestCoverage_Bug301(t *testing.T) { withModels, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/301/..."}, diff --git a/internal/integration/coverage_bug_3035_test.go b/internal/integration/coverage_bug_3035_test.go index 25d99cd0..1a8b065f 100644 --- a/internal/integration/coverage_bug_3035_test.go +++ b/internal/integration/coverage_bug_3035_test.go @@ -12,22 +12,22 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug3035 locks the fix for go-swagger issue #3035 ("Example -// spec for swagger:response does not produce example output"). A -// swagger:response whose body is an anonymous inline struct used to emit -// only the response description with no schema, dropping the field-level -// Example/Required and property descriptions entirely. +// TestCoverage_Bug3035 locks the fix for go-swagger issue #3035 ("Example spec for swagger:response +// does not produce example output"). // -// The fix produces a full object schema. This test asserts the schema and, -// crucially, the per-field example — the subject of the issue. +// A swagger:response whose body is an anonymous inline struct used to emit only the response +// description with no schema, dropping the field-level Example/Required and property descriptions +// entirely. // -// Known, intentional delta vs the reporter's hand-written expected YAML: -// the body field's leading prose ("The error message") is NOT propagated -// to the schema-level description. No code path in codescan surfaces a body -// field's prose as the schema description (verified against all response -// goldens), and an added blank line after that prose does not change it. -// We lock the current behaviour; surfacing it would be a separate -// enhancement. +// The fix produces a full object schema. +// This test asserts the schema and, crucially, the per-field example — the subject of the issue. +// +// Known, intentional delta vs the reporter's hand-written expected YAML: the body field's leading +// prose ("The error message") is NOT propagated to the schema-level description. +// No code path in codescan surfaces a body field's prose as the schema description (verified +// against all response goldens), and an added blank line after that prose does not change it. +// +// We lock the current behaviour; surfacing it would be a separate enhancement. func TestCoverage_Bug3035(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/3035/..."}, diff --git a/internal/integration/coverage_bug_3069_test.go b/internal/integration/coverage_bug_3069_test.go index 929405b3..b5ea2688 100644 --- a/internal/integration/coverage_bug_3069_test.go +++ b/internal/integration/coverage_bug_3069_test.go @@ -12,12 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug3069 demonstrates the resolution to go-swagger issue #3069 -// ("change the representation of one parameter"): a custom-marshalled struct -// cannot be inferred by codescan, but a `swagger:type` override states its -// representation explicitly. ComplexType -> string makes a []ComplexType field -// render as an array of strings, matching the actual wire format. (📖 Need doc: -// document this override for custom-marshalled types.) +// TestCoverage_Bug3069 demonstrates the resolution to go-swagger issue #3069 ("change the +// representation of one parameter"): a custom-marshalled struct cannot be inferred by codescan, but +// a `swagger:type` override states its representation explicitly. +// +// ComplexType -> string makes a []ComplexType field render as an array of strings, matching the +// actual wire format. (📖 Need doc: document this override for custom-marshalled types.) func TestCoverage_Bug3069(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/3069/..."}, diff --git a/internal/integration/coverage_bug_3100_test.go b/internal/integration/coverage_bug_3100_test.go index cb422c85..d4a9d61d 100644 --- a/internal/integration/coverage_bug_3100_test.go +++ b/internal/integration/coverage_bug_3100_test.go @@ -12,11 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug3100 locks the fix for go-swagger issue #3100 -// ("`in: formData` in `swagger:route` annotation translates to nothing"). -// A parameter declared with `in: formData` inside an inline swagger:route -// annotation must round-trip its `in` field verbatim; the scanner used to -// only recognise `form` and dropped `in` entirely from the spec. +// TestCoverage_Bug3100 locks the fix for go-swagger issue #3100 ("`in: formData` in `swagger:route` +// annotation translates to nothing"). +// +// A parameter declared with `in: formData` inside an inline swagger:route annotation must +// round-trip its `in` field verbatim; the scanner used to only recognise `form` and dropped `in` +// entirely from the spec. func TestCoverage_Bug3100(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/3100/..."}, diff --git a/internal/integration/coverage_bug_3107_test.go b/internal/integration/coverage_bug_3107_test.go index a214b0f9..5d143c9f 100644 --- a/internal/integration/coverage_bug_3107_test.go +++ b/internal/integration/coverage_bug_3107_test.go @@ -12,16 +12,16 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug3107 locks the fix for go-swagger issue #3107 ("No -// struct definition in swagger generate"). Under `generate spec -m` -// (ScanModels), a swagger:model struct must be emitted with its full -// shape — `type: object` plus its properties — rather than a bare -// definition carrying only `x-go-package` with the fields dropped. +// TestCoverage_Bug3107 locks the fix for go-swagger issue #3107 ("No struct definition in swagger +// generate"). // -// The fixture places the model in its own package, referenced -// cross-package by a route's response body, so the test also exercises -// type resolution through the import graph (the v0.30.5 trigger: the -// model package was resolved by reference, not directly scanned). +// Under `generate spec -m` (ScanModels), a swagger:model struct must be emitted with its full shape +// — `type: object` plus its properties — rather than a bare definition carrying only +// `x-go-package` with the fields dropped. +// +// The fixture places the model in its own package, referenced cross-package by a route's response +// body, so the test also exercises type resolution through the import graph (the v0.30.5 trigger: +// the model package was resolved by reference, not directly scanned). func TestCoverage_Bug3107(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/3107/..."}, diff --git a/internal/integration/coverage_bug_3117_test.go b/internal/integration/coverage_bug_3117_test.go index 56b596a4..93723fe3 100644 --- a/internal/integration/coverage_bug_3117_test.go +++ b/internal/integration/coverage_bug_3117_test.go @@ -12,10 +12,11 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug3117 locks go-swagger issue #3117 ("spec generating type -// property along with schema references"): a body parameter whose schema is a -// $ref must NOT also carry a sibling `type: object` (which is invalid Swagger -// 2.0). The body param schema is a clean $ref. +// TestCoverage_Bug3117 locks go-swagger issue #3117 ("spec generating type property along with +// schema references"): a body parameter whose schema is a $ref must NOT also carry a sibling `type: +// object` (which is invalid Swagger 2.0). +// +// The body param schema is a clean $ref. func TestCoverage_Bug3117(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/3117/..."}, diff --git a/internal/integration/coverage_bug_3138_test.go b/internal/integration/coverage_bug_3138_test.go index 7cf74bea..f74c7158 100644 --- a/internal/integration/coverage_bug_3138_test.go +++ b/internal/integration/coverage_bug_3138_test.go @@ -13,11 +13,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug3138 verifies the fix for go-swagger issue #3138 ("How To -// mark a field as deprecated?"). OAS2 spec.Schema has no native `deprecated` -// field, so model- and field-level deprecation is emitted as the -// `x-deprecated: true` vendor extension. Two triggers are supported on both -// models and fields, unified by grammar.Block.IsDeprecated(): +// TestCoverage_Bug3138 verifies the fix for go-swagger issue #3138 ("How To mark a field as +// deprecated?"). +// +// OAS2 spec.Schema has no native `deprecated` field, so model- and field-level deprecation is +// emitted as the `x-deprecated: true` vendor extension. +// Two triggers are supported on both models and fields, unified by grammar.Block.IsDeprecated(): // // - the explicit `deprecated: true` annotation (consumed as a keyword), and // - a godoc-style "Deprecated:" paragraph, which stays in the description @@ -45,14 +46,14 @@ func TestCoverage_Bug3138(t *testing.T) { widget := doc.Definitions["Widget"] - // Field, explicit `deprecated: true`: x-deprecated set, annotation - // consumed (not left in the description). + // Field, explicit `deprecated: true`: x-deprecated set, annotation consumed (not left in the + // description). oldName := widget.Properties["oldName"] assert.Equal(t, true, oldName.Extensions["x-deprecated"]) assert.Equal(t, "The legacy name.", oldName.Description) - // Field, godoc "Deprecated:" paragraph: x-deprecated set AND the reason - // text is preserved in the description (no spurious diagnostic). + // Field, godoc "Deprecated:" paragraph: x-deprecated set AND the reason text is preserved in the + // description (no spurious diagnostic). legacyID := widget.Properties["legacyID"] assert.Equal(t, true, legacyID.Extensions["x-deprecated"]) assert.Contains(t, legacyID.Description, "Deprecated: use Name instead.", diff --git a/internal/integration/coverage_bug_3213_test.go b/internal/integration/coverage_bug_3213_test.go index 5c8d3e17..541cd894 100644 --- a/internal/integration/coverage_bug_3213_test.go +++ b/internal/integration/coverage_bug_3213_test.go @@ -12,15 +12,13 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug3213 locks the behaviour behind go-swagger issue #3213 -// ("Consider TypeSpec comments"): doc comments and swagger annotations that -// attach to an individual TypeSpec inside a grouped `type ( ... )` -// declaration — not to the enclosing GenDecl — are parsed. +// TestCoverage_Bug3213 locks the behaviour behind go-swagger issue #3213 ("Consider TypeSpec +// comments"): doc comments and swagger annotations that attach to an individual TypeSpec inside a +// grouped `type ( ... )` declaration — not to the enclosing GenDecl — are parsed. // -// The proof is two models in ONE type group, each carrying its OWN doc -// comment: both surface their distinct titles, which a GenDecl-only reader -// could not produce. A grouped `swagger:enum` referenced by one of them also -// has its values parsed. +// The proof is two models in ONE type group, each carrying its OWN doc comment: both surface their +// distinct titles, which a GenDecl-only reader could not produce. +// A grouped `swagger:enum` referenced by one of them also has its values parsed. func TestCoverage_Bug3213(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/3213/..."}, diff --git a/internal/integration/coverage_bug_3214_test.go b/internal/integration/coverage_bug_3214_test.go index d3fe6f37..c313a38f 100644 --- a/internal/integration/coverage_bug_3214_test.go +++ b/internal/integration/coverage_bug_3214_test.go @@ -13,19 +13,20 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug3214 locks the fix for go-swagger issue #3214 -// ("Incomplete parsing of referenced typed primitives"). A struct field -// that references a named primitive type whose doc comment carries both -// prose and an inline `enum:` annotation must: +// TestCoverage_Bug3214 locks the fix for go-swagger issue #3214 ("Incomplete parsing of referenced +// typed primitives"). +// +// A struct field that references a named primitive type whose doc comment carries both prose and an +// inline `enum:` annotation must: // // - parse the `enum:` declaration into enum values on the referenced // type's definition, and // - preserve the prose untouched, never folding the `enum:` line into // the title or description. // -// The historical bug folded the `enum:` declaration line into the -// description. This test asserts the parsed shape directly and captures -// the full output in a golden as a regression lock. +// The historical bug folded the `enum:` declaration line into the description. +// This test asserts the parsed shape directly and captures the full output in a golden as a +// regression lock. func TestCoverage_Bug3214(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/3214/..."}, @@ -44,8 +45,8 @@ func TestCoverage_Bug3214(t *testing.T) { assert.NotContains(t, state.Title, "enum:", "the enum line must not leak into the title") assert.NotContains(t, state.Description, "enum:", "the enum line must not leak into the description") - // Currency: multi-paragraph prose → title + description, enum parsed - // and the description paragraph survives intact. + // Currency: multi-paragraph prose → title + description, enum parsed and the description + // paragraph survives intact. currency, ok := doc.Definitions["Currency"] require.True(t, ok, "Currency must be emitted as its own definition") assert.Equal(t, "string", currency.Type[0]) diff --git a/internal/integration/coverage_bug_334_test.go b/internal/integration/coverage_bug_334_test.go index 836a6515..e6e784d5 100644 --- a/internal/integration/coverage_bug_334_test.go +++ b/internal/integration/coverage_bug_334_test.go @@ -12,15 +12,13 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug334 locks the resolution to go-swagger issue #334 ("not -// generating model info / empty definitions, Swagger UI error"): a swagger:route -// annotation placed INSIDE a function body is now detected, and the model is -// emitted — so definitions and paths are no longer empty. +// TestCoverage_Bug334 locks the resolution to go-swagger issue #334 ("not generating model info / +// empty definitions, Swagger UI error"): a swagger:route annotation placed INSIDE a function body +// is now detected, and the model is emitted — so definitions and paths are no longer empty. // -// 📖 Need doc: when `// swagger:model` is placed BEFORE the doc text -// (annotation-first), the title/description are dropped — they are only captured -// when the annotation comes last. Asserted explicitly below so a future change -// is conscious. +// 📖 Need doc: when `// swagger:model` is placed BEFORE the doc text (annotation-first), the +// title/description are dropped — they are only captured when the annotation comes last. +// Asserted explicitly below so a future change is conscious. func TestCoverage_Bug334(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/334/..."}, diff --git a/internal/integration/coverage_bug_413_test.go b/internal/integration/coverage_bug_413_test.go index 6fe652f2..1bad2daa 100644 --- a/internal/integration/coverage_bug_413_test.go +++ b/internal/integration/coverage_bug_413_test.go @@ -12,11 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug413 locks the fix for go-swagger issue #413 ("error reading -// embedded struct"): embedding an external-package struct that itself carries a -// custom-typed field (maps.NearbySearchRequest with a RankBy field) used to fail -// the scan with "unable to resolve embedded struct for: RankBy". It now resolves -// cleanly — the embedded fields are promoted as parameters. +// TestCoverage_Bug413 locks the fix for go-swagger issue #413 ("error reading embedded struct"): +// embedding an external-package struct that itself carries a custom-typed field +// (maps.NearbySearchRequest with a RankBy field) used to fail the scan with "unable to resolve +// embedded struct for: RankBy". +// +// It now resolves cleanly — the embedded fields are promoted as parameters. func TestCoverage_Bug413(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/413/..."}, diff --git a/internal/integration/coverage_bug_439_test.go b/internal/integration/coverage_bug_439_test.go index 9fd39448..be949e56 100644 --- a/internal/integration/coverage_bug_439_test.go +++ b/internal/integration/coverage_bug_439_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug439 locks go-swagger issue #439 ("too deep scans / Expr -// unsupported for a schema"): an unrelated, unannotated type with fields the -// scanner can't model (chan, time.Time) is left untouched — only the discovered -// body type is emitted, with no "Expr unsupported" error. +// TestCoverage_Bug439 locks go-swagger issue #439 ("too deep scans / Expr unsupported for a +// schema"): an unrelated, unannotated type with fields the scanner can't model (chan, time.Time) is +// left untouched — only the discovered body type is emitted, with no "Expr unsupported" error. func TestCoverage_Bug439(t *testing.T) { doc, err := codescan.Run(&codescan.Options{Packages: []string{"./bugs/439/..."}, WorkDir: scantest.FixturesDir(), ScanModels: true}) require.NoError(t, err, "unrelated declarations must not break the scan (go-swagger#439)") diff --git a/internal/integration/coverage_bug_613_test.go b/internal/integration/coverage_bug_613_test.go index 2e96b50a..8c869a63 100644 --- a/internal/integration/coverage_bug_613_test.go +++ b/internal/integration/coverage_bug_613_test.go @@ -12,9 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug613 locks go-swagger issue #613 ("generating spec of external -// structs"): a referenced struct (here via a []*Ulimit field) is implicitly -// converted into its own definition and referenced by $ref — no unhelpful error. +// TestCoverage_Bug613 locks go-swagger issue #613 ("generating spec of external structs"): a +// referenced struct (here via a []*Ulimit field) is implicitly converted into its own definition +// and referenced by $ref — no unhelpful error. func TestCoverage_Bug613(t *testing.T) { doc, err := codescan.Run(&codescan.Options{Packages: []string{"./bugs/613/..."}, WorkDir: scantest.FixturesDir(), ScanModels: true}) require.NoError(t, err) diff --git a/internal/integration/coverage_bug_622_test.go b/internal/integration/coverage_bug_622_test.go index 5a6ebf94..26333f23 100644 --- a/internal/integration/coverage_bug_622_test.go +++ b/internal/integration/coverage_bug_622_test.go @@ -12,9 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug622 locks go-swagger issue #622 ("response example objects"): an -// `example:` on a response body field is emitted on the response schema. (The -// reporter's `exampleValue:` was non-standard; `example:` is the keyword.) +// TestCoverage_Bug622 locks go-swagger issue #622 ("response example objects"): an `example:` on a +// response body field is emitted on the response schema. (The reporter's `exampleValue:` was +// non-standard; `example:` is the keyword.) func TestCoverage_Bug622(t *testing.T) { doc, err := codescan.Run(&codescan.Options{Packages: []string{"./bugs/622/..."}, WorkDir: scantest.FixturesDir()}) require.NoError(t, err) diff --git a/internal/integration/coverage_bug_796_test.go b/internal/integration/coverage_bug_796_test.go index ede311a7..7d9335cc 100644 --- a/internal/integration/coverage_bug_796_test.go +++ b/internal/integration/coverage_bug_796_test.go @@ -12,18 +12,17 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug796 locks the fix for go-swagger issue #796 -// ("Definitions are generated without use of `swagger:model`"). Under -// `generate spec -m` (ScanModels), the scanner used to emit definitions -// for types that carry no `swagger:model`: a bare interface (Handler) and -// a `swagger:parameters` struct (pingParams) both leaked into -// `definitions`. The parameter struct also leaked its `in: path` line into -// the property description. +// TestCoverage_Bug796 locks the fix for go-swagger issue #796 ("Definitions are generated without +// use of `swagger:model`"). // -// The expected behaviour — locked by the golden — is that only the -// `swagger:model` type (pingResponse) is emitted; the parameter struct is -// rendered as an operation parameter (with a clean description and a real -// `in` field) and the interface is dropped entirely. +// Under `generate spec -m` (ScanModels), the scanner used to emit definitions for types that carry +// no `swagger:model`: a bare interface (Handler) and a `swagger:parameters` struct (pingParams) +// both leaked into `definitions`. +// The parameter struct also leaked its `in: path` line into the property description. +// +// The expected behaviour — locked by the golden — is that only the `swagger:model` type +// (pingResponse) is emitted; the parameter struct is rendered as an operation parameter (with a +// clean description and a real `in` field) and the interface is dropped entirely. func TestCoverage_Bug796(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/796/..."}, @@ -40,8 +39,8 @@ func TestCoverage_Bug796(t *testing.T) { assert.NotContains(t, doc.Definitions, "pingParams", "a swagger:parameters struct must not become a definition") assert.Len(t, doc.Definitions, 1, "no extra definitions beyond the swagger:model") - // The parameter struct renders as an operation parameter with a clean - // description (no `in:` leakage) and a real `in` field. + // The parameter struct renders as an operation parameter with a clean description (no `in:` + // leakage) and a real `in` field. pi, ok := doc.Paths.Paths["/ping/{who}"] require.True(t, ok) require.NotNil(t, pi.Get) diff --git a/internal/integration/coverage_bug_834_test.go b/internal/integration/coverage_bug_834_test.go index 2bd6a494..7d671ddd 100644 --- a/internal/integration/coverage_bug_834_test.go +++ b/internal/integration/coverage_bug_834_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug834 locks go-swagger issue #834 (the field-annotation half): -// pattern / max length / example on a field are applied as schema fields, not -// concatenated into the property description. (The non-json naming-tag ask is the -// separate forthcoming-features §7 / #1391 item.) +// TestCoverage_Bug834 locks go-swagger issue #834 (the field-annotation half): pattern / max length +// / example on a field are applied as schema fields, not concatenated into the property +// description. (The non-json naming-tag ask is the separate forthcoming-features §7 / #1391 item.) func TestCoverage_Bug834(t *testing.T) { doc, err := codescan.Run(&codescan.Options{Packages: []string{"./bugs/834/..."}, WorkDir: scantest.FixturesDir(), ScanModels: true}) require.NoError(t, err) diff --git a/internal/integration/coverage_bug_91_test.go b/internal/integration/coverage_bug_91_test.go index 1e0ab678..97139b32 100644 --- a/internal/integration/coverage_bug_91_test.go +++ b/internal/integration/coverage_bug_91_test.go @@ -12,9 +12,8 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug91 locks go-swagger issue #91 ("decouple from source"): a query -// parameter can be declared INLINE in the swagger:operation YAML body — no Go -// wrapper struct required. +// TestCoverage_Bug91 locks go-swagger issue #91 ("decouple from source"): a query parameter can be +// declared INLINE in the swagger:operation YAML body — no Go wrapper struct required. func TestCoverage_Bug91(t *testing.T) { doc, err := codescan.Run(&codescan.Options{Packages: []string{"./bugs/91/..."}, WorkDir: scantest.FixturesDir()}) require.NoError(t, err) diff --git a/internal/integration/coverage_bug_958_test.go b/internal/integration/coverage_bug_958_test.go index e014746b..74b58575 100644 --- a/internal/integration/coverage_bug_958_test.go +++ b/internal/integration/coverage_bug_958_test.go @@ -12,12 +12,13 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug958 locks the fix for go-swagger issue #958 ("how to -// specify sample values in user structs"). A field-level `default:` / -// `example:` was carried for a plain builtin field but dropped when the -// field used a named (defined) type. The fix carries both for defined-type -// fields too; since a $ref cannot carry sibling keywords, they ride the -// override arm of an allOf compound. +// TestCoverage_Bug958 locks the fix for go-swagger issue #958 ("how to specify sample values in +// user structs"). +// +// A field-level `default:` / `example:` was carried for a plain builtin field but dropped when the +// field used a named (defined) type. +// The fix carries both for defined-type fields too; since a $ref cannot carry sibling keywords, +// they ride the override arm of an allOf compound. func TestCoverage_Bug958(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/958/..."}, @@ -35,8 +36,7 @@ func TestCoverage_Bug958(t *testing.T) { assert.Equal(t, "samplesample", pv.Default) assert.Equal(t, "examplevalue", pv.Example) - // Defined-type field: the regression. $ref + override arm carrying - // both default and example. + // Defined-type field: the regression. $ref + override arm carrying both default and example. defined := doc.Definitions["DefinedStruct"] require.Contains(t, defined.Properties, "value") dv := defined.Properties["value"] diff --git a/internal/integration/coverage_bug_977_test.go b/internal/integration/coverage_bug_977_test.go index bb296504..9dbdf1a3 100644 --- a/internal/integration/coverage_bug_977_test.go +++ b/internal/integration/coverage_bug_977_test.go @@ -12,10 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug977 locks go-swagger issue #977 ("aliased string type as map -// key"): a map keyed by a named string type (type Role string) renders as -// {type:object, additionalProperties:} — its underlying string kind makes -// it a valid object-key. +// TestCoverage_Bug977 locks go-swagger issue #977 ("aliased string type as map key"): a map keyed +// by a named string type (type Role string) renders as {type:object, additionalProperties:} +// — its underlying string kind makes it a valid object-key. func TestCoverage_Bug977(t *testing.T) { doc, err := codescan.Run(&codescan.Options{Packages: []string{"./bugs/977/..."}, WorkDir: scantest.FixturesDir(), ScanModels: true}) require.NoError(t, err) diff --git a/internal/integration/coverage_bug_989_test.go b/internal/integration/coverage_bug_989_test.go index 29c26112..f9609b12 100644 --- a/internal/integration/coverage_bug_989_test.go +++ b/internal/integration/coverage_bug_989_test.go @@ -12,15 +12,14 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_Bug989 locks the resolution to go-swagger issue #989 ("add -// description to response"): the inline description text is no longer -// misinterpreted as a model $ref, and an inline response description is fully -// captured via the swagger:operation YAML body (403 -> "Unauthorized"). +// TestCoverage_Bug989 locks the resolution to go-swagger issue #989 ("add description to +// response"): the inline description text is no longer misinterpreted as a model $ref, and an +// inline response description is fully captured via the swagger:operation YAML body (403 -> +// "Unauthorized"). // -// 📖 Need doc: in the legacy swagger:route Responses block a status code maps to -// a response name; an inline description value there is NOT captured (the 403 -// gets an empty description). To add a description, use swagger:operation or a -// dedicated swagger:response object. +// 📖 Need doc: in the legacy swagger:route Responses block a status code maps to a response name; +// an inline description value there is NOT captured (the 403 gets an empty description). +// To add a description, use swagger:operation or a dedicated swagger:response object. func TestCoverage_Bug989(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./bugs/989/..."}, @@ -33,8 +32,8 @@ func TestCoverage_Bug989(t *testing.T) { require.NotNil(t, adm) assert.Equal(t, "Unauthorized", adm.Responses.StatusCodeResponses[403].Description) - // swagger:route maps codes to response names; 403 has no model and the - // inline description value is dropped (documented limitation). + // swagger:route maps codes to response names; 403 has no model and the inline description value is + // dropped (documented limitation). usr := doc.Paths.Paths["/users"].Get require.NotNil(t, usr) r200 := usr.Responses.StatusCodeResponses[200] diff --git a/internal/integration/coverage_default_allof_embeds_test.go b/internal/integration/coverage_default_allof_embeds_test.go new file mode 100644 index 00000000..8358d886 --- /dev/null +++ b/internal/integration/coverage_default_allof_embeds_test.go @@ -0,0 +1,95 @@ +// 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/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func runDefaultAllOfEmbeds(t *testing.T, on bool) *spec.Swagger { + t.Helper() + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/default-allof-embeds/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + DefaultAllOfForEmbeds: on, + }) + require.NoError(t, err) + require.NotNil(t, doc) + return doc +} + +// TestCoverage_DefaultAllOfEmbeds_Off is the control: with the flag off a plain struct embed +// inlines the embedded type's properties (the historic behaviour). +func TestCoverage_DefaultAllOfEmbeds_Off(t *testing.T) { + doc := runDefaultAllOfEmbeds(t, false) + + pe := doc.Definitions["PlainEmbed"] + // Inlined flat: embedded fields land directly as properties, no allOf. + assert.Empty(t, pe.AllOf, "plain embed must not compose when the flag is off") + assert.Contains(t, pe.Properties, "id", "model embed inlined") + assert.Contains(t, pe.Properties, "name", "model embed inlined") + assert.Contains(t, pe.Properties, "note", "non-model embed inlined") + assert.Contains(t, pe.Properties, "color", "own field present") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_default_allof_embeds_off.json") +} + +// TestCoverage_DefaultAllOfEmbeds_On exercises the feature: a plain model embed becomes an allOf +// $ref member, a plain non-model embed an inline allOf member, and the embedding struct's own +// fields a sibling allOf member. +// +// A json-named embed and an explicit swagger:allOf embed are unaffected. +func TestCoverage_DefaultAllOfEmbeds_On(t *testing.T) { + doc := runDefaultAllOfEmbeds(t, true) + + // PlainEmbed composes; no field is inlined at the top level. + pe := doc.Definitions["PlainEmbed"] + require.NotEmpty(t, pe.AllOf, "plain embed must compose into allOf when the flag is on") + assert.NotContains(t, pe.Properties, "id", "model embed must not inline when composing") + assert.NotContains(t, pe.Properties, "color", "own fields move into a sibling allOf member") + assert.True(t, allOfHasRef(pe, "#/definitions/Base"), "model embed becomes a $ref allOf member") + assert.True(t, allOfHasProp(pe, "note"), "non-model embed becomes an inline allOf member") + assert.True(t, allOfHasProp(pe, "color"), "own field lands in a sibling allOf member") + + // PointerEmbed: *Base peeled, same $ref composition. + ptr := doc.Definitions["PointerEmbed"] + require.NotEmpty(t, ptr.AllOf) + assert.True(t, allOfHasRef(ptr, "#/definitions/Base"), "pointer embed peels to a $ref member") + assert.True(t, allOfHasProp(ptr, "tag"), "own field in sibling member") + + // NamedEmbed: a json-named embed stays a single nested property, on or off. + named := doc.Definitions["NamedEmbed"] + assert.Empty(t, named.AllOf, "a json-named embed must not compose") + assert.Contains(t, named.Properties, "base", "named embed nests under its json name") + assert.Contains(t, named.Properties, "extra") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_default_allof_embeds_on.json") +} + +// allOfHasRef reports whether any allOf member of sch is a $ref to ref. +func allOfHasRef(sch spec.Schema, ref string) bool { + for _, m := range sch.AllOf { + if m.Ref.String() == ref { + return true + } + } + return false +} + +// allOfHasProp reports whether any allOf member of sch carries property name. +func allOfHasProp(sch spec.Schema, name string) bool { + for _, m := range sch.AllOf { + if _, ok := m.Properties[name]; ok { + return true + } + } + return false +} diff --git a/internal/integration/coverage_description_title_override_test.go b/internal/integration/coverage_description_title_override_test.go new file mode 100644 index 00000000..a9ea6e39 --- /dev/null +++ b/internal/integration/coverage_description_title_override_test.go @@ -0,0 +1,159 @@ +// 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_DescriptionTitleOverride_Schema exercises P2 of the swagger:title / +// swagger:description override feature (Q30 close-out): on a model and on struct fields, an +// explicit override replaces the godoc-derived title / description; absence leaves the godoc +// untouched; a bare swagger:description suppresses the godoc (empty applied) and raises +// scan.empty-override. +func TestCoverage_DescriptionTitleOverride_Schema(t *testing.T) { + var diags []grammar.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/description-title-override/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + OnDiagnostic: func(d grammar.Diagnostic) { + diags = append(diags, d) + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + require.MapContainsT(t, doc.Definitions, "Widget") + widget := doc.Definitions["Widget"] + + // Model: both title and description overridden (godoc text gone). + assert.EqualT(t, "A Public Widget", widget.Title) + assert.EqualT(t, "A widget exposed via the public API.", widget.Description) + assert.NotEqualT(t, "Widget is the Go-facing widget doc, written for Go readers.", widget.Title) + + require.MapContainsT(t, widget.Properties, "id") + require.MapContainsT(t, widget.Properties, "label") + require.MapContainsT(t, widget.Properties, "plain") + require.MapContainsT(t, widget.Properties, "suppressed") + + // Field: description override; fields carry no title by default. + id := widget.Properties["id"] + assert.EqualT(t, "The unique widget identifier.", id.Description) + assert.EqualT(t, "", id.Title) + + // Field: title override (the only way a property gets a title) + description. + label := widget.Properties["label"] + assert.EqualT(t, "Display Label", label.Title) + assert.EqualT(t, "Human-readable label shown to API consumers.", label.Description) + + // Regression: no override → godoc description retained. + plain := widget.Properties["plain"] + assert.NotEqualT(t, "", plain.Description) + + // Override + inline validation keyword coexist on the same field: the description override applies + // AND maximum survives (schema-family dispatch). + capacity := widget.Properties["capacity"] + assert.EqualT(t, "The maximum capacity, in liters.", capacity.Description) + require.NotNil(t, capacity.Maximum) + assert.EqualT(t, float64(1000), *capacity.Maximum) + + // Multi-line description override (Option B): the body lines fold into one description, joined + // with newlines, terminated by the blank line. + notes := widget.Properties["notes"] + assert.EqualT(t, + "Free-form notes about the widget.\nThey may span several lines, all folded into one description.", + notes.Description) + + // Empty override: bare swagger:description suppresses the godoc. + suppressed := widget.Properties["suppressed"] + assert.EqualT(t, "", suppressed.Description) + + // $ref field: under the default flags (no EmitRefSiblings, no DescWithRef, no validation forcing a + // compound), title/description ride description's fate and are dropped to a bare {$ref} — same + // rule as a prose description. + require.MapContainsT(t, widget.Properties, "gadget") + gadget := widget.Properties["gadget"] + assert.NotEqualT(t, "", gadget.Ref.String()) + assert.EqualT(t, "", gadget.Title) + assert.EqualT(t, "", gadget.Description) + + var sawEmptyOverride bool + for _, d := range diags { + if d.Code == grammar.CodeEmptyOverride { + sawEmptyOverride = true + } + } + assert.TrueT(t, sawEmptyOverride, "expected scan.empty-override for the bare swagger:description") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_description_title_override.json") +} + +// TestCoverage_DescriptionTitleOverride_RefSiblings witnesses the symmetric $ref-sibling threading: +// with EmitRefSiblings the $ref field's title and description overrides are preserved as direct +// siblings of the $ref ({$ref, title, description}) rather than dropped — the override flows +// through the same machinery a prose description would. +func TestCoverage_DescriptionTitleOverride_RefSiblings(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/description-title-override/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + EmitRefSiblings: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + require.MapContainsT(t, doc.Definitions, "Widget") + gadget := doc.Definitions["Widget"].Properties["gadget"] + assert.NotEqualT(t, "", gadget.Ref.String(), "the $ref is retained") + assert.EqualT(t, "Gadget Ref", gadget.Title, "title override preserved as a $ref sibling") + assert.EqualT(t, "The attached gadget, described for API consumers.", gadget.Description, + "description override preserved as a $ref sibling") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_description_title_override_ref_siblings.json") +} + +// TestCoverage_DescriptionTitleOverride_Responses exercises P3: a swagger:description override on a +// response declaration and on a response header replaces the godoc; a swagger:title on a response +// is rejected with scan.context-invalid (OpenAPI 2.0 has no Response/Header title) while the +// description override still applies. +func TestCoverage_DescriptionTitleOverride_Responses(t *testing.T) { + var diags []grammar.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/description-title-override-responses/..."}, + WorkDir: scantest.FixturesDir(), + OnDiagnostic: func(d grammar.Diagnostic) { + diags = append(diags, d) + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + require.MapContainsT(t, doc.Responses, "errorResponse") + resp := doc.Responses["errorResponse"] + + // Response description overridden (godoc gone). + assert.EqualT(t, "The error payload returned to API consumers.", resp.Description) + + // Header description overridden. + require.MapContainsT(t, resp.Headers, "X-Error-Code") + assert.EqualT(t, "The machine-readable error code.", resp.Headers["X-Error-Code"].Description) + + // swagger:title on the response is rejected as context-invalid. + var sawContextInvalid bool + for _, d := range diags { + if d.Code == grammar.CodeContextInvalid { + sawContextInvalid = true + } + } + assert.TrueT(t, sawContextInvalid, "expected scan.context-invalid for swagger:title on a response") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_description_title_override_responses.json") +} diff --git a/internal/integration/coverage_diagnostics_dedup_test.go b/internal/integration/coverage_diagnostics_dedup_test.go index f2005503..023214c5 100644 --- a/internal/integration/coverage_diagnostics_dedup_test.go +++ b/internal/integration/coverage_diagnostics_dedup_test.go @@ -13,12 +13,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_DiagnosticsDeduped pins the OnDiagnostic dedup: the build -// re-processes the same field/annotation across passes (most visibly a -// swagger:parameters struct applied to several operation ids, which rebuilds -// every field once per id), but the callback stream must surface each exact -// diagnostic — same position, code and message — at most once. The -// classification corpus exercises exactly that multi-operation pattern. +// TestCoverage_DiagnosticsDeduped pins the OnDiagnostic dedup: the build re-processes the same +// field/annotation across passes (most visibly a swagger:parameters struct applied to several +// operation ids, which rebuilds every field once per id), but the callback stream must surface each +// exact diagnostic — same position, code and message — at most once. +// +// The classification corpus exercises exactly that multi-operation pattern. func TestCoverage_DiagnosticsDeduped(t *testing.T) { counts := map[string]int{} emitted := 0 diff --git a/internal/integration/coverage_diagnostics_from_logger_test.go b/internal/integration/coverage_diagnostics_from_logger_test.go index adbe6c92..1ace74a6 100644 --- a/internal/integration/coverage_diagnostics_from_logger_test.go +++ b/internal/integration/coverage_diagnostics_from_logger_test.go @@ -13,10 +13,11 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestDiagnostics_UnsupportedGoType locks the Warning that replaced the legacy -// stderr "unsupported Go type" log line. A struct field typed complex128 has no -// Swagger 2.0 representation: its schema is left untyped (the build skips it) -// and a validate.unsupported-go-type Warning is delivered through OnDiagnostic +// TestDiagnostics_UnsupportedGoType locks the Warning that replaced the legacy stderr "unsupported +// Go type" log line. +// +// A struct field typed complex128 has no Swagger 2.0 representation: its schema is left untyped +// (the build skips it) and a validate.unsupported-go-type Warning is delivered through OnDiagnostic // (never to stderr). func TestDiagnostics_UnsupportedGoType(t *testing.T) { var diags []grammar.Diagnostic @@ -29,8 +30,8 @@ func TestDiagnostics_UnsupportedGoType(t *testing.T) { require.NoError(t, err) require.NotNil(t, doc) - // The representable field is typed; the unrepresentable one is left untyped - // (the builder skipped it after warning rather than guessing a type). + // The representable field is typed; the unrepresentable one is left untyped (the builder skipped + // it after warning rather than guessing a type). widget, ok := doc.Definitions["Widget"] require.True(t, ok, "Widget must be discovered") require.Contains(t, widget.Properties, "name") @@ -38,8 +39,9 @@ func TestDiagnostics_UnsupportedGoType(t *testing.T) { require.Contains(t, widget.Properties, "weird") assert.Empty(t, widget.Properties["weird"].Type, "complex128 has no Swagger type") - // A Warning diagnostic names the offending type. The position falls back to - // the declaration (no finer node is threaded into the type dispatch). + // A Warning diagnostic names the offending type. + // + // The position falls back to the declaration (no finer node is threaded into the type dispatch). var found bool for _, d := range diags { if d.Code == grammar.CodeUnsupportedGoType { @@ -51,10 +53,11 @@ func TestDiagnostics_UnsupportedGoType(t *testing.T) { assert.True(t, found, "a validate.unsupported-go-type Warning must be emitted") } -// TestDiagnostics_IgnoredByTag locks the Hint that replaced the legacy debug -// log line for tag-filtered routes. Excluding the "orders" tag drops the -// petstore's order routes and surfaces a scan.ignored-by-tag Hint per dropped -// route through OnDiagnostic. +// TestDiagnostics_IgnoredByTag locks the Hint that replaced the legacy debug log line for +// tag-filtered routes. +// +// Excluding the "orders" tag drops the petstore's order routes and surfaces a scan.ignored-by-tag +// Hint per dropped route through OnDiagnostic. func TestDiagnostics_IgnoredByTag(t *testing.T) { var diags []grammar.Diagnostic doc, err := codescan.Run(&codescan.Options{ diff --git a/internal/integration/coverage_embed_inheritance_test.go b/internal/integration/coverage_embed_inheritance_test.go index 2c1dd2e9..a07f2403 100644 --- a/internal/integration/coverage_embed_inheritance_test.go +++ b/internal/integration/coverage_embed_inheritance_test.go @@ -12,12 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_EmbedInheritance locks the generalized "annotations on an -// embedded field apply to the members it promotes" rule (go-swagger#2701) -// beyond parameters: a `required:` annotation on an embed propagates to the -// promoted properties of a model schema and of a response body schema (built -// through the same schema builder). Exportedness stays per-field — the -// unexported promoted member never surfaces. +// TestCoverage_EmbedInheritance locks the generalized "annotations on an embedded field apply to +// the members it promotes" rule (go-swagger#2701) beyond parameters: a `required:` annotation on an +// embed propagates to the promoted properties of a model schema and of a response body schema +// (built through the same schema builder). +// +// Exportedness stays per-field — the unexported promoted member never surfaces. func TestCoverage_EmbedInheritance(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/embed-inheritance/..."}, @@ -27,8 +27,8 @@ func TestCoverage_EmbedInheritance(t *testing.T) { require.NoError(t, err) require.NotNil(t, doc) - // Model schema: required: on the embed propagates to promoted id/name; - // the normal Extra field stays optional; unexported hidden never appears. + // Model schema: required: on the embed propagates to promoted id/name; the normal Extra field + // stays optional; unexported hidden never appears. widget := doc.Definitions["Widget"] assert.Contains(t, widget.Properties, "id") assert.Contains(t, widget.Properties, "name") @@ -37,9 +37,9 @@ func TestCoverage_EmbedInheritance(t *testing.T) { assert.ElementsMatch(t, []string{"id", "name"}, widget.Required, "required: on the embed must propagate to the promoted properties (go-swagger#2701)") - // Response body: a named struct body is emitted as a discovered - // definition and $ref'd. The same rule applies through the schema - // builder, so the body definition carries the inherited required list. + // Response body: a named struct body is emitted as a discovered definition and $ref'd. The same + // rule applies through the schema builder, so the body definition carries the inherited required + // list. resp, ok := doc.Responses["widgetResponse"] require.True(t, ok) require.NotNil(t, resp.Schema) @@ -50,20 +50,19 @@ func TestCoverage_EmbedInheritance(t *testing.T) { "required: on an embed inside a response body must propagate too (go-swagger#2701)") assert.NotContains(t, envelope.Properties, "hidden") - // Responses builder's own embed recursion: an embedded struct promotes - // its fields as headers (in: header, the default), confirming the path is - // intact. + // Responses builder's own embed recursion: an embedded struct promotes its fields as headers (in: + // header, the default), confirming the path is intact. hdrs, ok := doc.Responses["embeddedHeadersResponse"] require.True(t, ok) assert.Contains(t, hdrs.Headers, "X-Request-Id") assert.Contains(t, hdrs.Headers, "X-Count") - // Distinguishing case: `in: body` on the embed routes to the response body - // (it would otherwise default to a header) — proving the responses builder - // inherits in: from the embed (go-swagger#2701). An `in: body` embed means - // the embedded struct IS the body, exactly like a named `Body Foo` field, - // so the body $refs the embedded type rather than promoting its fields - // (go-swagger#1635). + // Distinguishing case: `in: body` on the embed routes to the response body (it would otherwise + // default to a header) — proving the responses builder inherits in: from the embed + // (go-swagger#2701). + // + // An `in: body` embed means the embedded struct IS the body, exactly like a named `Body Foo` + // field, so the body $refs the embedded type rather than promoting its fields (go-swagger#1635). body, ok := doc.Responses["embeddedBodyResponse"] require.True(t, ok) assert.Empty(t, body.Headers, diff --git a/internal/integration/coverage_emit_x_go_type_test.go b/internal/integration/coverage_emit_x_go_type_test.go index 768a4c44..4111a594 100644 --- a/internal/integration/coverage_emit_x_go_type_test.go +++ b/internal/integration/coverage_emit_x_go_type_test.go @@ -12,10 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_EmitXGoType locks the answer to go-swagger issue #2924 ("emit -// x-go-type vendor extension"): the opt-in EmitXGoType option stamps an -// x-go-type extension on every emitted definition (the fully-qualified Go -// type), default-off, and suppressed under the SkipExtensions umbrella. +// TestCoverage_EmitXGoType locks the answer to go-swagger issue #2924 ("emit x-go-type vendor +// extension"): the opt-in EmitXGoType option stamps an x-go-type extension on every emitted +// definition (the fully-qualified Go type), default-off, and suppressed under the SkipExtensions +// umbrella. func TestCoverage_EmitXGoType(t *testing.T) { const pkgType = "github.com/go-openapi/codescan/fixtures/enhancements/emit-x-go-type.Widget" diff --git a/internal/integration/coverage_enhancements_test.go b/internal/integration/coverage_enhancements_test.go index 97be930a..0b5d7b30 100644 --- a/internal/integration/coverage_enhancements_test.go +++ b/internal/integration/coverage_enhancements_test.go @@ -14,10 +14,10 @@ import ( oaispec "github.com/go-openapi/spec" ) -// These tests mirror the baseline coverage-enhancement tests. They scan -// dedicated fixtures under fixtures/enhancements/ and compare the result to -// the golden JSON captured on the baseline worktree, so we can catch any -// behavioural drift introduced by the refactor. +// These tests mirror the baseline coverage-enhancement tests. +// They scan dedicated fixtures under fixtures/enhancements/ and compare the result to the golden +// JSON captured on the baseline worktree, so we can catch any behavioural drift introduced by the +// refactor. func TestCoverage_EmbeddedTypes(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ @@ -92,10 +92,9 @@ func TestCoverage_InterfaceMethods_XNullable(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_interface_methods_xnullable.json") } -// TestCoverage_AliasExpand scans the alias-expand fixture with default -// Options so that buildAlias / buildFieldAlias take the non-transparent -// expansion path: each alias resolves to the underlying type and the -// target is emitted inline rather than as a $ref. +// TestCoverage_AliasExpand scans the alias-expand fixture with default Options so that buildAlias / +// buildFieldAlias take the non-transparent expansion path: each alias resolves to the underlying +// type and the target is emitted inline rather than as a $ref. func TestCoverage_AliasExpand(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/alias-expand/..."}, @@ -105,16 +104,15 @@ func TestCoverage_AliasExpand(t *testing.T) { require.NoError(t, err) require.NotNil(t, doc) - // Bidirectional witness — same fixture, two body-payload - // shapes side by side: + // Bidirectional witness — same fixture, two body-payload shapes side by side: // // - ResponseEnvelope.payload typed PayloadAlias (UNannotated) // → dissolves to $ref: Payload (the unaliased target) // - ResponseEnvelopeModeled.payload typed PayloadAliasModeled // (annotated) → preserves $ref: PayloadAliasModeled // - // Together they pin the rule: `swagger:model` is the sole - // gate for whether an alias name surfaces in field-site $refs. + // Together they pin the rule: `swagger:model` is the sole gate for whether an alias name surfaces + // in field-site $refs. respUnann := doc.Definitions["ResponseEnvelope"].Properties["payload"] assert.Equal(t, "#/definitions/Payload", respUnann.Ref.String(), "unannotated PayloadAlias dissolves to its unaliased target") @@ -125,10 +123,10 @@ func TestCoverage_AliasExpand(t *testing.T) { assert.Equal(t, "#/definitions/PayloadAliasModeled", respAnn.Ref.String(), "annotated PayloadAliasModeled preserves its identity in the field $ref") - // Bidirectional response-side witness — the same pattern - // applied to top-level swagger:response body fields. The - // unannotated AliasedResponse and the annotated - // AliasedModeledResponse sit on the same fixture canvas. + // Bidirectional response-side witness — the same pattern applied to top-level swagger:response + // body fields. + // The unannotated AliasedResponse and the annotated AliasedModeledResponse sit on the same fixture + // canvas. assert.Equal(t, "#/definitions/ResponseEnvelope", doc.Responses["aliasedResponse"].Schema.Ref.String(), "unannotated response body alias dissolves to canonical") @@ -141,9 +139,9 @@ func TestCoverage_AliasExpand(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_alias_expand.json") } -// TestCoverage_AliasRef scans the alias-expand fixture with RefAliases=true -// so body-parameter and response aliases resolve to $ref via makeRef, and -// the alias-of-alias chain resolves through the non-transparent switch. +// TestCoverage_AliasRef scans the alias-expand fixture with RefAliases=true so body-parameter and +// response aliases resolve to $ref via makeRef, and the alias-of-alias chain resolves through the +// non-transparent switch. func TestCoverage_AliasRef(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/alias-expand/..."}, @@ -157,9 +155,10 @@ func TestCoverage_AliasRef(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_alias_ref.json") } -// TestCoverage_AliasResponseRef scans a fixture where the swagger:response -// annotation is itself on an alias declaration. Under RefAliases=true the -// scanner takes the responseBuilder.buildAlias refAliases switch, which +// TestCoverage_AliasResponseRef scans a fixture where the swagger:response annotation is itself on +// an alias declaration. +// +// Under RefAliases=true the scanner takes the responseBuilder.buildAlias refAliases switch, which // is not covered by any other test. func TestCoverage_AliasResponseRef(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ @@ -198,9 +197,10 @@ 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. +// 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{ @@ -231,13 +231,15 @@ func TestCoverage_WrapperDeclTypeOverride(t *testing.T) { }) } -// 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 +// 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). +// +// 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/..."}, @@ -264,10 +266,9 @@ func TestCoverage_RawMessageOverride(t *testing.T) { def, ok := defs["TypedContainer"] require.TrueT(t, ok) - // F2: AsObject / AsArray carry swagger:type + swagger:model, so the - // fields $ref their definitions (which hold the overridden shape) — - // matching the fixture's stated "expect {$ref: ...}" intent, rather - // than inlining the override at the field site. + // F2: AsObject / AsArray carry swagger:type + swagger:model, so the fields $ref their definitions + // (which hold the overridden shape) — matching the fixture's stated "expect {$ref: ...}" + // intent, rather than inlining the override at the field site. obj, ok := def.Properties["obj"] require.TrueT(t, ok) objRef := obj.Ref @@ -280,9 +281,8 @@ func TestCoverage_RawMessageOverride(t *testing.T) { }) 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 + // 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) @@ -297,9 +297,8 @@ func TestCoverage_RawMessageOverride(t *testing.T) { }) 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. + // 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) @@ -316,11 +315,11 @@ func TestCoverage_RawMessageOverride(t *testing.T) { }) } -// 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 -// type instead of emitting an empty schema. Covers buildNamedSlice, -// buildNamedArray and buildNamedStruct fallthrough branches. +// 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 type instead of emitting an empty schema. +// +// Covers buildNamedSlice, buildNamedArray and buildNamedStruct fallthrough branches. func TestCoverage_SwaggerTypeArray(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/swagger-type-array/..."}, @@ -383,9 +382,8 @@ 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: +// 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 @@ -396,8 +394,8 @@ func TestCoverage_EnumDocs(t *testing.T) { // // 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. +// 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/..."}, @@ -422,12 +420,11 @@ 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). +// 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/..."}, @@ -451,9 +448,10 @@ func TestCoverage_AllHTTPMethods(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_all_http_methods.json") } -// TestCoverage_UnknownAnnotation asserts that scanning a file with an -// unknown swagger: annotation returns a classifier error. This exercises -// the default branch of typeIndex.detectNodes. +// TestCoverage_UnknownAnnotation asserts that scanning a file with an unknown swagger: annotation +// returns a classifier error. +// +// This exercises the default branch of typeIndex.detectNodes. func TestCoverage_UnknownAnnotation(t *testing.T) { _, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/unknown-annotation/..."}, @@ -499,9 +497,8 @@ func TestCoverage_TopLevelKinds(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_top_level_kinds.json") } -// TestCoverage_InputOverlay feeds an InputSpec carrying paths with every -// HTTP verb so that operations from the input spec are indexed before the -// scanner merges its own discoveries. +// TestCoverage_InputOverlay feeds an InputSpec carrying paths with every HTTP verb so that +// operations from the input spec are indexed before the scanner merges its own discoveries. func TestCoverage_InputOverlay(t *testing.T) { input := &oaispec.Swagger{ SwaggerProps: oaispec.SwaggerProps{ @@ -541,15 +538,13 @@ 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. +// 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. +// 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/..."}, @@ -561,12 +556,13 @@ func TestCoverage_ParametersMapPostDecl(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_parameters_map_postdecl.json") } -// The TestCoverage_Routes* family captures the swagger:route body -// sub-language surface (`Parameters:` and `Responses:` blocks) under -// integration goldens. The legacy SetOpParams / SetOpResponses parsers -// in builders/routes are unit-tested in-package only; without these -// fixtures, retiring those parsers in favour of routebody + -// handlers.Dispatch* would lose its safety net. See M6.5-PRE plan. +// The TestCoverage_Routes* family captures the swagger:route body sub-language surface +// (`Parameters:` and `Responses:` blocks) under integration goldens. +// +// The legacy SetOpParams / SetOpResponses parsers in builders/routes are unit-tested in-package +// only; without these fixtures, retiring those parsers in favour of routebody + handlers.Dispatch* +// would lose its safety net. +// See M6.5-PRE plan. func TestCoverage_RoutesParamsPath(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ diff --git a/internal/integration/coverage_external_docs_objects_test.go b/internal/integration/coverage_external_docs_objects_test.go index f96b9fcb..8aa11c70 100644 --- a/internal/integration/coverage_external_docs_objects_test.go +++ b/internal/integration/coverage_external_docs_objects_test.go @@ -13,10 +13,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_ExternalDocsObjects locks externalDocs on non-meta objects: -// emitted on swagger:route + swagger:operation operations and on a -// swagger:model schema, and rejected (with a diagnostic) on a simple-schema -// query parameter. +// TestCoverage_ExternalDocsObjects locks externalDocs on non-meta objects: emitted on swagger:route +// + swagger:operation operations and on a swagger:model schema, and rejected (with a diagnostic) on +// a simple-schema query parameter. func TestCoverage_ExternalDocsObjects(t *testing.T) { var diags []grammar.Diagnostic doc, err := codescan.Run(&codescan.Options{ @@ -55,8 +54,8 @@ func TestCoverage_ExternalDocsObjects(t *testing.T) { require.NotNil(t, name.ExternalDocs) assert.Equal(t, "https://name.example.org", name.ExternalDocs.URL) - // $ref'd field: the sibling externalDocs lifts onto the allOf - // compound (sibling of the $ref), not into the override schema. + // $ref'd field: the sibling externalDocs lifts onto the allOf compound (sibling of the $ref), not + // into the override schema. ref := model.Properties["ref"] require.Len(t, ref.AllOf, 1) assert.Equal(t, "#/definitions/Nested", ref.AllOf[0].Ref.String()) diff --git a/internal/integration/coverage_godoc_links_test.go b/internal/integration/coverage_godoc_links_test.go new file mode 100644 index 00000000..463d2572 --- /dev/null +++ b/internal/integration/coverage_godoc_links_test.go @@ -0,0 +1,90 @@ +// 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/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func runGodocLinks(t *testing.T, on bool) *spec.Swagger { + t.Helper() + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/godoc-links/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + CleanGoDoc: on, + }) + require.NoError(t, err) + require.NotNil(t, doc) + return doc +} + +// TestCoverage_GodocLinks_Off is the control: with CleanGoDoc off, godoc doc-link syntax is carried +// into the spec verbatim (byte-identical to the historic behaviour). +// +// The golden pins the full untouched output. +func TestCoverage_GodocLinks_Off(t *testing.T) { + doc := runGodocLinks(t, false) + + gizmo := doc.Definitions["gizmo"] // swagger:model gizmo on type Widget + // brackets and the reference-definition line are preserved when off. + assert.Contains(t, gizmo.Title, "[Gadget]") + assert.Contains(t, gizmo.Title, "[Order.CustName]") + assert.Contains(t, gizmo.Description, "[the spec]: https://example.com/spec") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_godoc_links_off.json") +} + +// TestCoverage_GodocLinks_On exercises the full feature: resolvable doc-links are recomposed to the +// referenced schema's EXPOSED name, unresolved links are humanized, reference-definition lines are +// dropped, and non-doc-link brackets are left intact. +func TestCoverage_GodocLinks_On(t *testing.T) { + doc := runGodocLinks(t, true) + + gizmo := doc.Definitions["gizmo"] + + // leading self-name recomposed to the exposed model name (swagger:model gizmo), restored to + // sentence case: "Widget ..." -> "Gizmo ...". + assert.Contains(t, gizmo.Title, "Gizmo is the primary") + assert.NotContains(t, gizmo.Title, "Widget") + // doc-link to another model -> its exposed name (no override -> Go name). + assert.Contains(t, gizmo.Title, "Gadget") + assert.NotContains(t, gizmo.Title, "[Gadget]") + // type.field doc-link -> exposed model name + exposed (json) property name. + assert.Contains(t, gizmo.Title, "Order.customer_name") + assert.NotContains(t, gizmo.Title, "[Order.CustName]") + + // reference-definition line dropped; pointer link resolved; unknown link humanized (not a scanned + // model). + assert.NotContains(t, gizmo.Description, "the spec") + assert.NotContains(t, gizmo.Description, "https://example.com/spec") + assert.Contains(t, gizmo.Description, "Gadget pointer") + assert.Contains(t, gizmo.Description, "unknown sprocket") + assert.NotContains(t, gizmo.Description, "[") + // cross-package doc-link ([inventory.Ledger]) resolved to its exposed name via the file's imports. + assert.Contains(t, gizmo.Description, "Ledger") + require.MapContainsT(t, doc.Definitions, "Ledger") + + // field doc-link recomposed; leading field self-name is left as-is (only a declaration's own + // leading name is recomposed). + assert.Contains(t, gizmo.Properties["holder"].Description, "Gadget that owns") + assert.NotContains(t, gizmo.Properties["holder"].Description, "[Gadget]") + + // conservative recognizer: non-doc-link brackets are left intact. + idx := gizmo.Properties["index"].Description + assert.Contains(t, idx, "[0]") + assert.Contains(t, idx, "[see notes]") + assert.Contains(t, idx, "[id]") + + // no markers ever leak into the output. + assert.NotContains(t, gizmo.Title+gizmo.Description, "\x00") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_godoc_links.json") +} diff --git a/internal/integration/coverage_header_extensions_test.go b/internal/integration/coverage_header_extensions_test.go index 673a2ac7..2b8deeab 100644 --- a/internal/integration/coverage_header_extensions_test.go +++ b/internal/integration/coverage_header_extensions_test.go @@ -13,10 +13,10 @@ import ( "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. +// 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/..."}, @@ -25,18 +25,17 @@ func TestCoverage_HeaderExtensions(t *testing.T) { 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. + // 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). + // 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) diff --git a/internal/integration/coverage_header_named_basic_test.go b/internal/integration/coverage_header_named_basic_test.go index 29cfd253..ad0c97b5 100644 --- a/internal/integration/coverage_header_named_basic_test.go +++ b/internal/integration/coverage_header_named_basic_test.go @@ -12,10 +12,10 @@ import ( "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 +// 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{ @@ -36,8 +36,8 @@ func TestCoverage_HeaderNamedBasic(t *testing.T) { 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. + // 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_in_case_insensitive_test.go b/internal/integration/coverage_in_case_insensitive_test.go index 32b494e6..79f319bc 100644 --- a/internal/integration/coverage_in_case_insensitive_test.go +++ b/internal/integration/coverage_in_case_insensitive_test.go @@ -12,11 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_InCaseInsensitive witnesses Q29 — the `in:` value -// must be normalised case-insensitively so go-swagger-generated -// `in: Body` lands at the body location, not silently degrading to -// the `query` default (parameters) or producing an invalidIn -// diagnostic (responses). +// TestCoverage_InCaseInsensitive witnesses Q29 — the `in:` value must be normalised +// case-insensitively so go-swagger-generated `in: Body` lands at the body location, not silently +// degrading to the `query` default (parameters) or producing an invalidIn diagnostic (responses). // // Pre-fix shape (silent miscategorisation): // diff --git a/internal/integration/coverage_inner_markdown_test.go b/internal/integration/coverage_inner_markdown_test.go new file mode 100644 index 00000000..8647127e --- /dev/null +++ b/internal/integration/coverage_inner_markdown_test.go @@ -0,0 +1,51 @@ +// 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/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestCoverage_InnerMarkdown exercises the swagger:description | literal block scalar end to end: a +// model and a field description carry verbatim markdown (table with leading pipes, significant +// blank lines, indented ordered list) into the emitted spec. +// +// Reframes go-swagger#3211. +func TestCoverage_InnerMarkdown(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/inner-markdown/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + require.Contains(t, doc.Definitions, "Widget") + w := doc.Definitions["Widget"] + + // Title still comes from the godoc preamble; the markdown is the description. + assert.Equal(t, "Widget is a thing.", w.Title) + + // Table leading pipes survive (the literal #3211 grievance), the blank line between paragraphs is + // preserved, and the marker never leaks. + assert.Contains(t, w.Description, "| name | purpose |") + assert.Contains(t, w.Description, "**markdown**") + assert.Contains(t, w.Description, "\n\n", "significant blank line preserved") + assert.NotContains(t, w.Description, "swagger:description") + assert.False(t, strings.HasPrefix(w.Description, "|"), "the | marker must not leak into the body") + + // Field description keeps indentation of the ordered list. + name := w.Properties["name"] + assert.Contains(t, name.Description, "The name must be:") + assert.Contains(t, name.Description, " 1. unique") + assert.Contains(t, name.Description, " 2. lowercase") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_inner_markdown.json") +} diff --git a/internal/integration/coverage_interface_name_verbatim_test.go b/internal/integration/coverage_interface_name_verbatim_test.go index 2cbcce6e..93afa1f0 100644 --- a/internal/integration/coverage_interface_name_verbatim_test.go +++ b/internal/integration/coverage_interface_name_verbatim_test.go @@ -12,17 +12,15 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_InterfaceNameVerbatim pins the Q9 contract: when the -// user provides `swagger:name X` on an interface method, X is -// emitted verbatim — the auto-jsonify mangler is bypassed. +// TestCoverage_InterfaceNameVerbatim pins the Q9 contract: when the user provides `swagger:name X` +// on an interface method, X is emitted verbatim — the auto-jsonify mangler is bypassed. // -// The default-path method (no swagger:name) is asserted alongside to -// triangulate against the mangler's lowercase-first-camelCase -// behaviour: any future regression that runs the mangler over +// The default-path method (no swagger:name) is asserted alongside to triangulate against the +// mangler's lowercase-first-camelCase behaviour: any future regression that runs the mangler over // user-provided names would change the four override entries. // -// See observed-quirks Q9 (RESOLVED, Stream M) and -// internal/builders/schema/README.md §interface-naming. +// See observed-quirks Q9 (RESOLVED, Stream M) and internal/builders/schema/README.md +// §interface-naming. func TestCoverage_InterfaceNameVerbatim(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/interface-name-verbatim/..."}, @@ -38,9 +36,9 @@ func TestCoverage_InterfaceNameVerbatim(t *testing.T) { // Default path — mangler runs, lowercase-first camelCase. require.Contains(t, props, "userIdentifier", "default path must auto-jsonify") - // Each swagger:name override must reach the spec verbatim. If - // any of these fails, the mangler is running over user input — - // the exact regression Q9's resolution forbids. + // Each swagger:name override must reach the spec verbatim. + // If any of these fails, the mangler is running over user input — the exact regression Q9's + // resolution forbids. assert.Contains(t, props, "UserIdentifier", "PascalCase override must stay PascalCase") assert.Contains(t, props, "user_identifier", "snake_case override must stay snake_case") assert.Contains(t, props, "USER_ID", "SCREAMING_CASE override must stay SCREAMING_CASE") diff --git a/internal/integration/coverage_keyword_xpkg_test.go b/internal/integration/coverage_keyword_xpkg_test.go index 7f3e8dec..74b83bb1 100644 --- a/internal/integration/coverage_keyword_xpkg_test.go +++ b/internal/integration/coverage_keyword_xpkg_test.go @@ -13,11 +13,11 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestKeywordCrossPackageLeaf locks cross-package leaf resolution for the -// type-name keyword sites. A bare type name that is NOT in the annotating -// type's own package resolves by leaf against the discovered model set: -// uniquely -> the model's definition; ambiguously -> diagnostic + drop. This -// matches the leaf resolution the name-identity engine gave routes/responses. +// TestKeywordCrossPackageLeaf locks cross-package leaf resolution for the type-name keyword sites. +// +// A bare type name that is NOT in the annotating type's own package resolves by leaf against the +// discovered model set: uniquely -> the model's definition; ambiguously -> diagnostic + drop. +// This matches the leaf resolution the name-identity engine gave routes/responses. func TestKeywordCrossPackageLeaf(t *testing.T) { var diags []grammar.Diagnostic doc, err := codescan.Run(&codescan.Options{ diff --git a/internal/integration/coverage_malformed_test.go b/internal/integration/coverage_malformed_test.go index 6289f1f8..361ec80b 100644 --- a/internal/integration/coverage_malformed_test.go +++ b/internal/integration/coverage_malformed_test.go @@ -11,10 +11,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// Bucket-B error-path tests. Each subfixture under fixtures/enhancements/ -// malformed/ carries exactly one annotation that the scanner cannot -// reconcile, so Run() must return a non-nil error. No goldens are -// produced — these tests exist purely to pin the error surface. +// Bucket-B error-path tests. +// Each subfixture under fixtures/enhancements/ malformed/ carries exactly one annotation that the +// scanner cannot reconcile, so Run() must return a non-nil error. +// No goldens are produced — these tests exist purely to pin the error surface. func TestMalformed_DefaultInt(t *testing.T) { _, err := codescan.Run(&codescan.Options{ @@ -32,12 +32,12 @@ func TestMalformed_ExampleInt(t *testing.T) { require.Error(t, err) } -// TestMalformed_MetaBadExtensionKey was an error-returning test -// against the legacy meta validateExtensionNames path. M6.5-E -// aligns meta extension handling with routes — non-x-* keys emit -// a CodeInvalidAnnotation diagnostic at grammar parse time and -// drop, but Run still succeeds. The fixture's bad key ends up -// absent from the captured golden. +// TestMalformed_MetaBadExtensionKey was an error-returning test against the legacy meta +// validateExtensionNames path. +// +// M6.5-E aligns meta extension handling with routes — non-x-* keys emit a CodeInvalidAnnotation +// diagnostic at grammar parse time and drop, but Run still succeeds. +// The fixture's bad key ends up absent from the captured golden. func TestMalformed_MetaBadExtensionKey(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/malformed/meta-bad-ext-key/..."}, @@ -49,9 +49,9 @@ func TestMalformed_MetaBadExtensionKey(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "malformed_meta_bad_ext_key.json") } -// TestMalformed_InfoBadExtensionKey — see -// TestMalformed_MetaBadExtensionKey. Same diagnose-and-drop shift, -// here under the InfoExtensions: keyword. +// TestMalformed_InfoBadExtensionKey — see TestMalformed_MetaBadExtensionKey. +// +// Same diagnose-and-drop shift, here under the InfoExtensions: keyword. func TestMalformed_InfoBadExtensionKey(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/malformed/info-bad-ext-key/..."}, @@ -71,13 +71,14 @@ func TestMalformed_BadContact(t *testing.T) { require.Error(t, err) } -// TestMalformed_DuplicateBodyTag was an error-returning test against -// the legacy routes body parser. M6.5-C shifts the routes body -// sub-language to a diagnose-and-continue contract (matching the -// rest of the grammar2 surface): malformed lines emit -// CodeInvalidAnnotation and the response is dropped, but Run still -// succeeds. The fixture's malformed response line ends up absent -// from the captured golden — the witness IS the dropped output. +// TestMalformed_DuplicateBodyTag was an error-returning test against the legacy routes body parser. +// +// M6.5-C shifts the routes body sub-language to a diagnose-and-continue contract (matching the rest +// of the grammar2 surface): malformed lines emit CodeInvalidAnnotation and the response is dropped, +// but Run still succeeds. +// +// The fixture's malformed response line ends up absent from the captured golden — the witness IS +// the dropped output. func TestMalformed_DuplicateBodyTag(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/malformed/duplicate-body-tag/..."}, @@ -90,8 +91,9 @@ func TestMalformed_DuplicateBodyTag(t *testing.T) { } // TestMalformed_BadResponseTag — see TestMalformed_DuplicateBodyTag. -// Unknown tag prefixes emit a diagnostic and drop the response line; -// the rest of the route builds normally. +// +// Unknown tag prefixes emit a diagnostic and drop the response line; the rest of the route builds +// normally. func TestMalformed_BadResponseTag(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/malformed/bad-response-tag/..."}, diff --git a/internal/integration/coverage_meta_securitydefs_duplicate_keys_test.go b/internal/integration/coverage_meta_securitydefs_duplicate_keys_test.go index 951cd155..320262ae 100644 --- a/internal/integration/coverage_meta_securitydefs_duplicate_keys_test.go +++ b/internal/integration/coverage_meta_securitydefs_duplicate_keys_test.go @@ -12,8 +12,8 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_MetaSecurityDefsDuplicateKeys — locks the post-Q28 -// behaviour of the swagger:meta SecurityDefinitions raw block. +// TestCoverage_MetaSecurityDefsDuplicateKeys — locks the post-Q28 behaviour of the swagger:meta +// SecurityDefinitions raw block. // // Two independent wires meet here: // @@ -30,8 +30,8 @@ import ( // `api_key` block here has `type:` twice on purpose so the // dedupe layer is exercised in the captured spec. // -// Diagnostic emission on duplicates is deferred to the yaml-library -// swap (see the forthcoming-features roadmap). +// Diagnostic emission on duplicates is deferred to the yaml-library swap (see the +// forthcoming-features roadmap). func TestCoverage_MetaSecurityDefsDuplicateKeys(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/meta-securitydefs-duplicate-keys/..."}, diff --git a/internal/integration/coverage_meta_tos_schemes_terminator_test.go b/internal/integration/coverage_meta_tos_schemes_terminator_test.go index 8f49927c..f207c47c 100644 --- a/internal/integration/coverage_meta_tos_schemes_terminator_test.go +++ b/internal/integration/coverage_meta_tos_schemes_terminator_test.go @@ -12,15 +12,13 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_MetaTOSSchemesTerminator pins the sibling-terminator -// rule for `Terms Of Service:` → `Schemes:` (Q26). +// TestCoverage_MetaTOSSchemesTerminator pins the sibling-terminator rule for `Terms Of Service:` +// → `Schemes:` (Q26). // -// Q26's original observation (Schemes absorbed into TOS body) no -// longer reproduces on master — the fix landed during Stream M's -// merge sequence, point of resolution not isolated. This test serves -// as a regression-detector: if a future lexer change re-breaks the -// terminator-family overlap for asRawBlock siblings in the meta -// scope, this turns red. +// Q26's original observation (Schemes absorbed into TOS body) no longer reproduces on master — +// the fix landed during Stream M's merge sequence, point of resolution not isolated. +// This test serves as a regression-detector: if a future lexer change re-breaks the +// terminator-family overlap for asRawBlock siblings in the meta scope, this turns red. // // Expected shape: // diff --git a/internal/integration/coverage_name_from_tags_test.go b/internal/integration/coverage_name_from_tags_test.go new file mode 100644 index 00000000..95e6dfdc --- /dev/null +++ b/internal/integration/coverage_name_from_tags_test.go @@ -0,0 +1,123 @@ +// 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/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestCoverage_NameFromTags exercises the NameFromTags option end-to-end (go-swagger#2912, +// go-swagger#1391): the emitted name of a schema property, query parameter, or response header is +// derived from the first struct-tag type listed in Options.NameFromTags. +// +// The fixture's fields each carry a json: and a form: tag with differing names. +// +// - default (nil → ["json"]): json names, the historic behaviour. +// - ["form","json"]: the form name wins, falling back to json. +// - explicit empty slice: no tag consulted; the Go field name is used. +// +// The encoding/json directives are independent of the setting: json:"-" always excludes +// (Filter.Internal), and ,omitempty is always read from the json tag. +func TestCoverage_NameFromTags(t *testing.T) { + run := func(t *testing.T, nameTags []string) *spec.Swagger { + t.Helper() + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/name-from-tags/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + NameFromTags: nameTags, + }) + require.NoError(t, err) + require.NotNil(t, doc) + return doc + } + + // queryParamNames collects the GET /items query parameter names. + queryParamNames := func(t *testing.T, doc *spec.Swagger) []string { + t.Helper() + require.NotNil(t, doc.Paths) + pi, ok := doc.Paths.Paths["/items"] + require.TrueT(t, ok, "expected a /items path") + require.NotNil(t, pi.Get) + names := make([]string, 0, len(pi.Get.Parameters)) + for _, p := range pi.Get.Parameters { + names = append(names, p.Name) + } + return names + } + + // responseHeaders returns the header map of the listResponse. + // The route references it by name, so the response (with its Headers) lands in the top-level + // responses section and the path holds a $ref to it. + responseHeaders := func(t *testing.T, doc *spec.Swagger) map[string]spec.Header { + t.Helper() + resp, ok := doc.Responses["listResponse"] + require.TrueT(t, ok, "expected a listResponse in the responses section") + return resp.Headers + } + + t.Run("default uses json names", func(t *testing.T) { + doc := run(t, nil) + + props := doc.Definitions["Filter"].Properties + require.MapContainsT(t, props, "sortKey") + require.MapContainsT(t, props, "pageSize") + require.MapContainsT(t, props, "label") + assert.MapNotContainsT(t, props, "sort_key") + assert.MapNotContainsT(t, props, "internal") + assert.MapNotContainsT(t, props, "Internal", "json:\"-\" always excludes") + + assert.SliceContainsT(t, queryParamNames(t, doc), "sortKey") + assert.SliceContainsT(t, queryParamNames(t, doc), "pageSize") + + _, ok := responseHeaders(t, doc)["X-Request-Id"] + assert.TrueT(t, ok) + }) + + t.Run("form-first uses form names, falling back to json", func(t *testing.T) { + doc := run(t, []string{"form", "json"}) + + props := doc.Definitions["Filter"].Properties + require.MapContainsT(t, props, "sort_key") + require.MapContainsT(t, props, "page_size") + // Label has form:"-" (not a usable name) → falls back to json "label". + require.MapContainsT(t, props, "label") + assert.MapNotContainsT(t, props, "sortKey") + assert.MapNotContainsT(t, props, "internal", "json:\"-\" always excludes") + + assert.SliceContainsT(t, queryParamNames(t, doc), "sort_key") + assert.SliceContainsT(t, queryParamNames(t, doc), "page_size") + + _, ok := responseHeaders(t, doc)["x_request_id"] + assert.TrueT(t, ok) + + // Capture the full form-first spec as the golden exhibit of the feature (form names across schema + // properties, query parameters and headers). + scantest.CompareOrDumpJSON(t, doc, "enhancements_name_from_tags_form_first.json") + }) + + t.Run("empty slice uses Go field names, json directives still apply", func(t *testing.T) { + doc := run(t, []string{}) + + props := doc.Definitions["Filter"].Properties + require.MapContainsT(t, props, "SortKey") + require.MapContainsT(t, props, "PageSize") + require.MapContainsT(t, props, "Label") + assert.MapNotContainsT(t, props, "Internal", "json:\"-\" always excludes") + assert.MapNotContainsT(t, props, "sortKey") + assert.MapNotContainsT(t, props, "sort_key") + + assert.SliceContainsT(t, queryParamNames(t, doc), "SortKey") + assert.SliceContainsT(t, queryParamNames(t, doc), "PageSize") + + _, ok := responseHeaders(t, doc)["RequestID"] + assert.TrueT(t, ok) + }) +} diff --git a/internal/integration/coverage_name_identity_test.go b/internal/integration/coverage_name_identity_test.go index 63ca23ac..87142696 100644 --- a/internal/integration/coverage_name_identity_test.go +++ b/internal/integration/coverage_name_identity_test.go @@ -25,8 +25,8 @@ import ( // PascalCase concat of their nearest package segments (b.Test -> BTest), // with a diagnostic; same-package duplicates revert the loser to its Go name. // -// Triager-flagged name-conflict family covered here (the whole family resolves -// at once via this one engine; the merge should `contributes` all of them): +// Triager-flagged name-conflict family covered here (the whole family resolves at once via this one +// engine; the merge should `contributes` all of them): // // - #2783 — models mixed across packages (TestNameIdentity_3Way/Mixed, Bug2783) // - #2637 — self-cyclic $ref, same-named type (Bug2637) @@ -67,10 +67,9 @@ func hasDiagnostic(diags []grammar.Diagnostic, code grammar.Code) bool { // --- CONTROLS (deterministic; golden-locked) ---------------------------------- -// TestNameIdentity_Recursion is the G3 control: legitimate recursion (direct -// self-reference Node.next, and mutual Loop<->Knot) produces property back-$refs -// to ancestor definitions, which is valid OAS and must keep working unchanged -// through every engine stage. +// TestNameIdentity_Recursion is the G3 control: legitimate recursion (direct self-reference +// Node.next, and mutual Loop<->Knot) produces property back-$refs to ancestor definitions, which is +// valid OAS and must keep working unchanged through every engine stage. func TestNameIdentity_Recursion(t *testing.T) { doc := nameIdentityDoc(t, "./enhancements/name-identity-recursion/...") @@ -96,9 +95,10 @@ func TestNameIdentity_Recursion(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_name_identity_recursion.json") } -// TestNameIdentity_NoCollision is the G5 control: distinct names across packages, -// no collision. Its golden must stay byte-identical through every stage — proof -// the engine introduces zero churn when there is nothing to deconflict. +// TestNameIdentity_NoCollision is the G5 control: distinct names across packages, no collision. +// +// Its golden must stay byte-identical through every stage — proof the engine introduces zero +// churn when there is nothing to deconflict. func TestNameIdentity_NoCollision(t *testing.T) { doc := nameIdentityDoc(t, "./enhancements/name-identity-no-collision/...") @@ -114,6 +114,7 @@ func TestNameIdentity_NoCollision(t *testing.T) { // --- COLLISION fixtures (distinct + deterministic; concat-qualified) ----------- // TestNameIdentity_3Way: three packages each declare `swagger:model Widget`. +// // Distinct, one-segment concat: AWidget / BWidget / CWidget. func TestNameIdentity_3Way(t *testing.T) { doc, diags := nameIdentityDocDiags(t, "./enhancements/name-identity-3way/...") @@ -128,9 +129,9 @@ func TestNameIdentity_3Way(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_name_identity_3way.json") } -// TestNameIdentity_Mixed: BOTH collision kinds resolve distinctly — explicit -// (x.Item / y.Item, each `swagger:model Item`) and implicit (x.Record / -// y.Record, unannotated, discovered via reference). +// TestNameIdentity_Mixed: BOTH collision kinds resolve distinctly — explicit (x.Item / y.Item, +// each `swagger:model Item`) and implicit (x.Record / y.Record, unannotated, discovered via +// reference). func TestNameIdentity_Mixed(t *testing.T) { doc, diags := nameIdentityDocDiags(t, "./enhancements/name-identity-mixed/...") @@ -143,10 +144,11 @@ func TestNameIdentity_Mixed(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_name_identity_mixed.json") } -// TestNameIdentity_SamePkgDup (D-4): two DIFFERENT types in the SAME package -// both claim `swagger:model Dup`. The first keeps "Dup"; the duplicate reverts -// to its Go name ("Second") with a same-package-duplicate diagnostic. (No -// cross-package collision here, so the names stay bare.) +// TestNameIdentity_SamePkgDup (D-4): two DIFFERENT types in the SAME package both claim +// `swagger:model Dup`. +// +// The first keeps "Dup"; the duplicate reverts to its Go name ("Second") with a +// same-package-duplicate diagnostic. (No cross-package collision here, so the names stay bare.) func TestNameIdentity_SamePkgDup(t *testing.T) { doc, diags := nameIdentityDocDiags(t, "./enhancements/name-identity-same-pkg-dup/...") @@ -159,9 +161,8 @@ func TestNameIdentity_SamePkgDup(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_name_identity_same_pkg_dup.json") } -// TestNameIdentity_Deep: a/mongo.Book and b/mongo.Book share both the leaf name -// AND the one-segment concat ("MongoBook"), so the concat rung must deepen to -// two segments: AMongoBook / BMongoBook. +// TestNameIdentity_Deep: a/mongo.Book and b/mongo.Book share both the leaf name AND the one-segment +// concat ("MongoBook"), so the concat rung must deepen to two segments: AMongoBook / BMongoBook. func TestNameIdentity_Deep(t *testing.T) { doc, diags := nameIdentityDocDiags(t, "./enhancements/name-identity-deep/...") @@ -175,12 +176,13 @@ func TestNameIdentity_Deep(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_name_identity_deep.json") } -// TestNameIdentity_RecursionCollision is the cyclic-within-collision guard: -// p.Node and q.Node are each self-recursive AND collide on "Node". They must -// resolve to distinct names (PNode / QNode), and — the crucial part — each -// self-`$ref` must be rewritten in lockstep with its OWN renamed key (a ref -// that points into the very group being renamed), never dangling at the -// pre-reduce deep key and never pointing at the sibling. +// TestNameIdentity_RecursionCollision is the cyclic-within-collision guard: p.Node and q.Node are +// each self-recursive AND collide on "Node". +// +// They must resolve to distinct names (PNode / QNode), and — the crucial part — each +// self-`$ref` must be rewritten in lockstep with its OWN renamed key (a ref that points into the +// very group being renamed), never dangling at the pre-reduce deep key and never pointing at the +// sibling. func TestNameIdentity_RecursionCollision(t *testing.T) { doc, diags := nameIdentityDocDiags(t, "./enhancements/name-identity-recursion-collision/...") @@ -191,8 +193,8 @@ func TestNameIdentity_RecursionCollision(t *testing.T) { assert.NotContains(t, doc.Definitions, "Node", "no merged bare Node key") assert.True(t, hasDiagnostic(diags, grammar.CodeCollidingModelName)) - // Each self-ref points at its OWN renamed definition (lockstep rewrite), - // and the body is not a degenerate self-`$ref` (G2/G3). + // Each self-ref points at its OWN renamed definition (lockstep rewrite), and the body is not a + // degenerate self-`$ref` (G2/G3). pNode := doc.Definitions["PNode"] qNode := doc.Definitions["QNode"] assert.Empty(t, pNode.Ref.String(), "PNode body must not be a self-$ref") @@ -211,11 +213,11 @@ func TestNameIdentity_RecursionCollision(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_name_identity_recursion_collision.json") } -// TestNameIdentity_Hierarchical exercises the hierarchical fail-safe: two -// long-named packages each declare `swagger:model Config`, whose flat minimal -// concat exceeds the readability budget. By default the (always-correct) flat -// concat is kept; with EmitHierarchicalNames the over-budget group is emitted -// as nested container definitions instead. +// TestNameIdentity_Hierarchical exercises the hierarchical fail-safe: two long-named packages each +// declare `swagger:model Config`, whose flat minimal concat exceeds the readability budget. +// +// By default the (always-correct) flat concat is kept; with EmitHierarchicalNames the over-budget +// group is emitted as nested container definitions instead. func TestNameIdentity_Hierarchical(t *testing.T) { const pkg = "./enhancements/name-identity-hierarchical/..." @@ -272,11 +274,11 @@ func TestNameIdentity_Hierarchical(t *testing.T) { }) } -// TestNameIdentity_ExamplesNoMix locks go-swagger #2126: two packages each -// declare `swagger:model Widget` with the SAME json field but DIFFERENT example -// values. The fix keeps the definitions distinct (AWidget / BWidget), so each -// retains its OWN example — neither package's example bleeds into the other's -// (the merge used to clobber one with the other). +// TestNameIdentity_ExamplesNoMix locks go-swagger #2126: two packages each declare `swagger:model +// Widget` with the SAME json field but DIFFERENT example values. +// +// The fix keeps the definitions distinct (AWidget / BWidget), so each retains its OWN example — +// neither package's example bleeds into the other's (the merge used to clobber one with the other). func TestNameIdentity_ExamplesNoMix(t *testing.T) { doc := nameIdentityDoc(t, "./enhancements/name-identity-examples-no-mix/...") diff --git a/internal/integration/coverage_object_keywords_context_test.go b/internal/integration/coverage_object_keywords_context_test.go index 59068d81..bea8cbe4 100644 --- a/internal/integration/coverage_object_keywords_context_test.go +++ b/internal/integration/coverage_object_keywords_context_test.go @@ -13,9 +13,8 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_ObjectKeywordsContext locks the context gating of the -// object validation keywords (minProperties / maxProperties / -// patternProperties): +// TestCoverage_ObjectKeywordsContext locks the context gating of the object validation keywords +// (minProperties / maxProperties / patternProperties): // // - kept on an object-typed model; // - stripped + CodeShapeMismatch on a non-object (scalar) model; @@ -56,8 +55,8 @@ func TestCoverage_ObjectKeywordsContext(t *testing.T) { require.Len(t, op.Parameters, 1) assert.Equal(t, "string", op.Parameters[0].Type) - // Diagnostics: 3 shape-mismatch (scalar model) + 3 unsupported-in- - // simple-schema (query param), one per object keyword each. + // Diagnostics: 3 shape-mismatch (scalar model) + 3 unsupported-in- simple-schema (query param), + // one per object keyword each. var shape, simple int for _, d := range diags { switch d.Code { diff --git a/internal/integration/coverage_param_name_keyword_test.go b/internal/integration/coverage_param_name_keyword_test.go index d364cfd7..d3439737 100644 --- a/internal/integration/coverage_param_name_keyword_test.go +++ b/internal/integration/coverage_param_name_keyword_test.go @@ -13,12 +13,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_ParamNameKeyword locks the `name:` keyword on a -// swagger:parameters struct field: it renames the JSON parameter name, -// overriding the json-tag / Go-field derivation, and — being a structural -// keyword — is stripped from the parameter description rather than leaking -// into it as prose (it previously had no handler and fell through to -// Block.Prose()). +// TestCoverage_ParamNameKeyword locks the `name:` keyword on a swagger:parameters struct field: it +// renames the JSON parameter name, overriding the json-tag / Go-field derivation, and — being a +// structural keyword — is stripped from the parameter description rather than leaking into it as +// prose (it previously had no handler and fell through to Block.Prose()). func TestCoverage_ParamNameKeyword(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/param-name-keyword/..."}, diff --git a/internal/integration/coverage_path_regex_stripping_test.go b/internal/integration/coverage_path_regex_stripping_test.go index fd3957c3..281ac7c6 100644 --- a/internal/integration/coverage_path_regex_stripping_test.go +++ b/internal/integration/coverage_path_regex_stripping_test.go @@ -13,9 +13,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_PathRegexStripping locks inline-regex path-parameter stripping -// across both the swagger:route and swagger:operation builders, plus the -// no-op control (a bare {id} template raises no warning). +// TestCoverage_PathRegexStripping locks inline-regex path-parameter stripping across both the +// swagger:route and swagger:operation builders, plus the no-op control (a bare {id} template raises +// no warning). func TestCoverage_PathRegexStripping(t *testing.T) { var diags []grammar.Diagnostic doc, err := codescan.Run(&codescan.Options{ @@ -36,9 +36,8 @@ func TestCoverage_PathRegexStripping(t *testing.T) { // Control: plain template emitted unchanged. assert.Contains(t, doc.Paths.Paths, "/plain/{id}") - // Warnings: one per route/operation that carried a constraint (the - // multi-param route reports both names in a single diagnostic); the - // plain control raises none. + // Warnings: one per route/operation that carried a constraint (the multi-param route reports both + // names in a single diagnostic); the plain control raises none. var warns int for _, d := range diags { if d.Code == grammar.CodeInvalidAnnotation && d.Severity == grammar.SeverityWarning { diff --git a/internal/integration/coverage_pattern_properties_test.go b/internal/integration/coverage_pattern_properties_test.go index b66936a3..b91869d5 100644 --- a/internal/integration/coverage_pattern_properties_test.go +++ b/internal/integration/coverage_pattern_properties_test.go @@ -13,10 +13,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_PatternProperties verifies the patternProperties object -// schema keyword: each annotation line adds a regex → empty-schema entry -// to schema.patternProperties, and the regex is RE2-hygiene-checked like -// the pattern keyword — an invalid regex is preserved on the schema but +// TestCoverage_PatternProperties verifies the patternProperties object schema keyword: each +// annotation line adds a regex → empty-schema entry to schema.patternProperties, and the regex is +// RE2-hygiene-checked like the pattern keyword — an invalid regex is preserved on the schema but // raises a CodeInvalidAnnotation diagnostic (never dropped silently). func TestCoverage_PatternProperties(t *testing.T) { var diags []grammar.Diagnostic @@ -44,8 +43,8 @@ func TestCoverage_PatternProperties(t *testing.T) { require.Len(t, invalid.PatternProperties, 1) assert.Contains(t, invalid.PatternProperties, "[unclosed(") - // ... and exactly one CodeInvalidAnnotation diagnostic is raised, - // naming the patternProperties keyword (mirroring pattern's wording). + // ... and exactly one CodeInvalidAnnotation diagnostic is raised, naming the patternProperties + // keyword (mirroring pattern's wording). var re2 []grammar.Diagnostic for _, d := range diags { if d.Code == grammar.CodeInvalidAnnotation { diff --git a/internal/integration/coverage_pattern_properties_typed_test.go b/internal/integration/coverage_pattern_properties_typed_test.go index 272813ab..e19d4002 100644 --- a/internal/integration/coverage_pattern_properties_typed_test.go +++ b/internal/integration/coverage_pattern_properties_typed_test.go @@ -13,9 +13,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_PatternProperties_Typed locks the typed swagger:patternProperties -// marker (Phase 2c): quoted-regex → typed value (primitive / $ref) pairs, -// RE2-hygiene checking, and the lowest-priority precedence rule. +// TestCoverage_PatternProperties_Typed locks the typed swagger:patternProperties marker (Phase 2c): +// quoted-regex → typed value (primitive / $ref) pairs, RE2-hygiene checking, and the +// lowest-priority precedence rule. func TestCoverage_PatternProperties_Typed(t *testing.T) { var diags []grammar.Diagnostic doc, err := codescan.Run(&codescan.Options{ diff --git a/internal/integration/coverage_primitive_response_test.go b/internal/integration/coverage_primitive_response_test.go index 2295c209..f671dafb 100644 --- a/internal/integration/coverage_primitive_response_test.go +++ b/internal/integration/coverage_primitive_response_test.go @@ -15,10 +15,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_PrimitiveResponse locks the primitive response-body matrix: -// tagged primitive (body:integer), array-of-primitive (body:[]string), the -// swagger:response array-body workaround, and a model-ref control that must -// still resolve to a $ref. +// TestCoverage_PrimitiveResponse locks the primitive response-body matrix: tagged primitive +// (body:integer), array-of-primitive (body:[]string), the swagger:response array-body workaround, +// and a model-ref control that must still resolve to a $ref. func TestCoverage_PrimitiveResponse(t *testing.T) { var diags []grammar.Diagnostic doc, err := codescan.Run(&codescan.Options{ @@ -60,8 +59,8 @@ func TestCoverage_PrimitiveResponse(t *testing.T) { assert.Equal(t, "#/definitions/Thing", thing.Schema.Ref.String()) assert.Empty(t, thing.Schema.Type) - // 200: arrayBodyResponse → $ref to the named response, which itself - // carries an array-of-primitive body schema. + // 200: arrayBodyResponse → $ref to the named response, which itself carries an + // array-of-primitive body schema. named := resp200("/named") assert.Equal(t, "#/responses/arrayBodyResponse", named.Ref.String()) nr, ok := doc.Responses["arrayBodyResponse"] @@ -72,9 +71,8 @@ func TestCoverage_PrimitiveResponse(t *testing.T) { require.NotNil(t, nr.Schema.Items.Schema) assert.Equal(t, []string{"string"}, []string(nr.Schema.Items.Schema.Type)) - // Bare/untagged primitive (`200: string`) is NOT supported: the token - // is read as a response name, the response is dropped, and a diagnostic - // points the author at the unambiguous `body:` form. + // Bare/untagged primitive (`200: string`) is NOT supported: the token is read as a response name, + // the response is dropped, and a diagnostic points the author at the unambiguous `body:` form. bare := doc.Paths.Paths["/bare"].Get require.NotNil(t, bare) if bare.Responses != nil { @@ -89,8 +87,8 @@ func TestCoverage_PrimitiveResponse(t *testing.T) { } assert.True(t, hinted, "expected a diagnostic pointing at `body:string`") - // Reserved type keyword after body: (`body:file`) is not a valid response - // body type: dropped with a diagnostic. array/object/null behave the same. + // Reserved type keyword after body: (`body:file`) is not a valid response body type: dropped with + // a diagnostic. array/object/null behave the same. reserved := doc.Paths.Paths["/reserved"].Get require.NotNil(t, reserved) if reserved.Responses != nil { diff --git a/internal/integration/coverage_provenance_test.go b/internal/integration/coverage_provenance_test.go index ffd5e114..ee3bb0fe 100644 --- a/internal/integration/coverage_provenance_test.go +++ b/internal/integration/coverage_provenance_test.go @@ -17,12 +17,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_ProvenanceDefinitions exercises LX-prov-0 at its simplest: on a -// models-only fixture the OnProvenance callback fires for the definition node -// and its properties, each carrying its JSON pointer and source position. The -// wider anchor surface (paths, params, responses, enum, meta) is exercised by -// TestCoverage_ProvenanceAnchorKinds; the geometry safety net by -// TestCoverage_ProvenanceGeometry. +// TestCoverage_ProvenanceDefinitions exercises LX-prov-0 at its simplest: on a models-only fixture +// the OnProvenance callback fires for the definition node and its properties, each carrying its +// JSON pointer and source position. +// +// The wider anchor surface (paths, params, responses, enum, meta) is exercised by +// TestCoverage_ProvenanceAnchorKinds; the geometry safety net by TestCoverage_ProvenanceGeometry. func TestCoverage_ProvenanceDefinitions(t *testing.T) { byPointer := map[string]scanner.Provenance{} doc, err := codescan.Run(&codescan.Options{ @@ -36,8 +36,8 @@ func TestCoverage_ProvenanceDefinitions(t *testing.T) { require.NoError(t, err) require.NotNil(t, doc) - // The swagger:model type surfaces as /definitions/User, anchored to its - // declaration in the named-basic source. + // The swagger:model type surfaces as /definitions/User, anchored to its declaration in the + // named-basic source. root, ok := byPointer["/definitions/User"] require.True(t, ok, "expected a provenance record for /definitions/User; got %v", keysOf(byPointer)) assert.Positive(t, root.Pos.Line, "definition position should carry a source line") @@ -46,8 +46,8 @@ func TestCoverage_ProvenanceDefinitions(t *testing.T) { assert.Contains(t, root.Pos.Filename, "named-basic", "definition should be anchored inside the scanned fixture") - // Its fields surface as /definitions/User/properties/{json}, anchored to the - // struct field (a deeper, distinct source line than the type declaration). + // Its fields surface as /definitions/User/properties/{json}, anchored to the struct field (a + // deeper, distinct source line than the type declaration). prop, ok := byPointer["/definitions/User/properties/email"] require.True(t, ok, "expected a provenance record for the email property; got %v", keysOf(byPointer)) assert.Greater(t, prop.Pos.Line, root.Pos.Line, @@ -62,14 +62,18 @@ func TestCoverage_ProvenanceDefinitions(t *testing.T) { } } -// TestCoverage_ProvenanceCollisionRename is the regression witness for the -// definition-provenance / name-reduction ordering bug. Two packages declare the -// same short-named model (x.Item + y.Item); reduceDefinitionNames renames them -// (Item -> XItem / YItem). Provenance is buffered under the fully-qualified key -// while building and re-pointed to the final name on flush, so every anchor — -// the definition node AND its fields — resolves against the renamed definition. -// Before the fix, anchors fired inline under the un-renamed leaf -// (/definitions/Item/...) and dangled after the rename. +// TestCoverage_ProvenanceCollisionRename is the regression witness for the definition-provenance / +// name-reduction ordering bug. +// +// Two packages declare the same short-named model (x.Item + y.Item); reduceDefinitionNames renames +// them (Item -> XItem / YItem). +// +// Provenance is buffered under the fully-qualified key while building and re-pointed to the final +// name on flush, so every anchor — the definition node AND its fields — resolves against the +// renamed definition. +// +// Before the fix, anchors fired inline under the un-renamed leaf (/definitions/Item/...) and +// dangled after the rename. func TestCoverage_ProvenanceCollisionRename(t *testing.T) { byPointer := map[string]scanner.Provenance{} doc, err := codescan.Run(&codescan.Options{ @@ -101,8 +105,8 @@ func TestCoverage_ProvenanceCollisionRename(t *testing.T) { "anchor %q references the un-renamed leaf — provenance was not re-pointed", ptr) } - // Every emitted anchor resolves in the rendered spec, including at least one - // field-level anchor under a renamed definition. + // Every emitted anchor resolves in the rendered spec, including at least one field-level anchor + // under a renamed definition. raw, err := json.Marshal(doc) require.NoError(t, err) var root any @@ -119,8 +123,8 @@ func TestCoverage_ProvenanceCollisionRename(t *testing.T) { assert.Positive(t, fieldAnchors, "expected at least one field-level anchor under a renamed definition") } -// TestCoverage_ProvenanceOffByDefault confirms the callback is opt-in: a scan -// without OnProvenance set produces the same spec and records nothing. +// TestCoverage_ProvenanceOffByDefault confirms the callback is opt-in: a scan without OnProvenance +// set produces the same spec and records nothing. func TestCoverage_ProvenanceOffByDefault(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/named-basic"}, @@ -140,15 +144,15 @@ func keysOf(m map[string]scanner.Provenance) []string { return out } -// TestCoverage_ProvenanceGeometry is the anchors-only safety invariant: every -// emitted provenance pointer MUST resolve to a node that exists in the rendered -// spec. The cross-ref linker tolerates a finer node resolving to its nearest -// anchored ancestor, but it must NEVER be handed a pointer that points nowhere -// (or at the wrong node). Run across fixtures rich in the geometry that diverges -// from the simple top-level case — allOf composition, plain/aliased embeds, -// interface members, nested inline structs, slices and maps — so any path that -// fails to thread (or clear) the base pointer surfaces here as a dangling -// anchor. +// TestCoverage_ProvenanceGeometry is the anchors-only safety invariant: every emitted provenance +// pointer MUST resolve to a node that exists in the rendered spec. +// +// The cross-ref linker tolerates a finer node resolving to its nearest anchored ancestor, but it +// must NEVER be handed a pointer that points nowhere (or at the wrong node). +// +// Run across fixtures rich in the geometry that diverges from the simple top-level case — allOf +// composition, plain/aliased embeds, interface members, nested inline structs, slices and maps — +// so any path that fails to thread (or clear) the base pointer surfaces here as a dangling anchor. func TestCoverage_ProvenanceGeometry(t *testing.T) { fixtures := []string{ "./enhancements/named-basic", @@ -158,26 +162,25 @@ func TestCoverage_ProvenanceGeometry(t *testing.T) { "./enhancements/top-level-kinds", "./enhancements/named-struct-tags", "./enhancements/defaults-examples", // validation-keyword anchors (default/example) - // Override / open-object shapes: the descend()-threaded items, - // additionalProperties and patternProperties subtrees. Guards against a - // future value that inlines (today's $ref/scalar values record nothing - // under these segments, so the exact pointers live in - // TestCoverage_ProvenanceSchemaShapes). + // Override / open-object shapes: the descend()-threaded items, additionalProperties and + // patternProperties subtrees. + // Guards against a future value that inlines (today's $ref/scalar values record nothing under + // these segments, so the exact pointers live in TestCoverage_ProvenanceSchemaShapes). "./enhancements/provenance-schema-shapes", "./enhancements/additional-properties", "./enhancements/pattern-properties-typed", "./enhancements/swagger-type-array", "./enhancements/provenance-params-responses", // param + response header/body anchors - // Cross-package name collisions: definitions are renamed by - // reduceDefinitionNames (Item -> XItem/YItem, Widget -> …). Provenance is - // buffered under the fully-qualified key and re-pointed to the final name - // on flush; before that fix these anchored under the un-renamed leaf and - // dangled. See TestCoverage_ProvenanceCollisionRename. + // Cross-package name collisions: definitions are renamed by reduceDefinitionNames (Item -> + // XItem/YItem, Widget -> …). + // Provenance is buffered under the fully-qualified key and re-pointed to the final name on flush; + // before that fix these anchored under the un-renamed leaf and dangled. + // + // See TestCoverage_ProvenanceCollisionRename. "./enhancements/name-identity-mixed/...", "./enhancements/name-identity-3way/...", - // Full-surface fixture: meta, routes/operations, parameters, - // top-level responses and enum definitions — exercises every anchor - // kind against the resolves-in-spec invariant. + // Full-surface fixture: meta, routes/operations, parameters, top-level responses and enum + // definitions — exercises every anchor kind against the resolves-in-spec invariant. "./goparsing/petstore/...", } @@ -211,20 +214,20 @@ func TestCoverage_ProvenanceGeometry(t *testing.T) { } } -// TestCoverage_ProvenanceAnchorKinds asserts every Phase-B anchor kind fires: -// definitions, properties, top-level responses, paths/operations, parameters, -// enum values and meta/info. Each must carry a source line and resolve in the -// rendered spec. The petstore covers most kinds; enum anchors only fire when a -// swagger:enum type becomes its own definition (in the petstore it inlines into -// a field), so enum-docs is folded in for that kind. +// TestCoverage_ProvenanceAnchorKinds asserts every Phase-B anchor kind fires: definitions, +// properties, top-level responses, paths/operations, parameters, enum values and meta/info. +// +// Each must carry a source line and resolve in the rendered spec. +// The petstore covers most kinds; enum anchors only fire when a swagger:enum type becomes its own +// definition (in the petstore it inlines into a field), so enum-docs is folded in for that kind. func TestCoverage_ProvenanceAnchorKinds(t *testing.T) { seen := map[string]scanner.Provenance{} for _, pkg := range []string{"./goparsing/petstore/...", "./enhancements/enum-docs"} { maps.Copy(seen, scanAndResolve(t, pkg)) } - // Each kind is recognised by the shape of its pointer; require at least one - // of every kind, with a positive source line. + // Each kind is recognised by the shape of its pointer; require at least one of every kind, with a + // positive source line. kinds := map[string]func(string) bool{ "definition": func(p string) bool { return strings.HasPrefix(p, "/definitions/") && !strings.Contains(p, "/properties/") && !strings.Contains(p, "/enum/") @@ -253,11 +256,10 @@ func TestCoverage_ProvenanceAnchorKinds(t *testing.T) { } } -// TestCoverage_ProvenanceValidations exercises the validation-keyword anchors: -// a field's `default`/`example` (and the other scalar validations) anchor to -// their own comment line — distinct from, and above, the struct field — so -// following e.g. a `default` node in the spec lands on `// default: 1.5` rather -// than the field declaration. +// TestCoverage_ProvenanceValidations exercises the validation-keyword anchors: a field's +// `default`/`example` (and the other scalar validations) anchor to their own comment line — +// distinct from, and above, the struct field — so following e.g. a `default` node in the spec +// lands on `// default: 1.5` rather than the field declaration. func TestCoverage_ProvenanceValidations(t *testing.T) { byPointer := map[string]scanner.Provenance{} doc, err := codescan.Run(&codescan.Options{ @@ -278,24 +280,25 @@ func TestCoverage_ProvenanceValidations(t *testing.T) { ex, ok := byPointer["/definitions/Metrics/properties/ratio/example"] require.True(t, ok, "the example validation should anchor to its own line") - // The keyword lines sit in the doc comment, above the field declaration, - // and on distinct lines from each other. + // The keyword lines sit in the doc comment, above the field declaration, and on distinct lines + // from each other. assert.Less(t, def.Pos.Line, field.Pos.Line, "default's comment precedes the field") assert.Less(t, ex.Pos.Line, field.Pos.Line, "example's comment precedes the field") assert.NotEqual(t, def.Pos.Line, ex.Pos.Line, "each keyword anchors to its own line") assert.Equal(t, field.Pos.Filename, def.Pos.Filename, "same source file") - // Array-element validations anchor under /items via the keyword's ItemsDepth - // (none here at depth>0, but the slice field's own default still anchors). + // Array-element validations anchor under /items via the keyword's ItemsDepth (none here at + // depth>0, but the slice field's own default still anchors). _, ok = byPointer["/definitions/Metrics/properties/tags/default"] assert.True(t, ok, "a slice field's default anchors to its line too") } -// TestCoverage_ProvenanceMetaAndRoutes exercises the meta and route keyword -// anchors: each swagger:meta keyword (and each route-header keyword) anchors to -// its own comment line, not just the coarse /info or /paths/{path}/{method} -// block. The top-level meta fields (host/consumes/…) have no ancestor anchor -// otherwise (/info is their sibling), so this is the only way to reach them. +// TestCoverage_ProvenanceMetaAndRoutes exercises the meta and route keyword anchors: each +// swagger:meta keyword (and each route-header keyword) anchors to its own comment line, not just +// the coarse /info or /paths/{path}/{method} block. +// +// The top-level meta fields (host/consumes/…) have no ancestor anchor otherwise (/info is their +// sibling), so this is the only way to reach them. func TestCoverage_ProvenanceMetaAndRoutes(t *testing.T) { byPointer := map[string]scanner.Provenance{} doc, err := codescan.Run(&codescan.Options{ @@ -314,8 +317,9 @@ func TestCoverage_ProvenanceMetaAndRoutes(t *testing.T) { var root any require.NoError(t, json.Unmarshal(raw, &root)) - // A representative spread: an Info.* field, two root-level meta fields, and a - // route-header keyword. Each must resolve in the spec and carry a line. + // A representative spread: an Info.* field, two root-level meta fields, and a route-header + // keyword. + // Each must resolve in the spec and carry a line. for _, ptr := range []string{ "/info/version", "/host", "/consumes", "/paths/~1pets/get/deprecated", } { @@ -329,17 +333,17 @@ func TestCoverage_ProvenanceMetaAndRoutes(t *testing.T) { assert.NotEqual(t, byPointer["/info/version"].Pos.Line, byPointer["/host"].Pos.Line) } -// TestCoverage_ProvenanceSchemaShapes locks the exact jsonpointers for nested -// schema shapes — not merely that they resolve, but that an inlined element / -// value's properties carry the items / additionalProperties segment of the node -// they actually render under. These are the descend()-threaded paths; a missing -// or wrong descend would anchor the inner property at the parent's pointer (a -// dangling or mis-located anchor the TUI cross-ref would mis-resolve). +// TestCoverage_ProvenanceSchemaShapes locks the exact jsonpointers for nested schema shapes — not +// merely that they resolve, but that an inlined element / value's properties carry the items / +// additionalProperties segment of the node they actually render under. +// +// These are the descend()-threaded paths; a missing or wrong descend would anchor the inner +// property at the parent's pointer (a dangling or mis-located anchor the TUI cross-ref would +// mis-resolve). // -// The `over` case is the regression witness for the swagger:type []Inner -// override: before the items-descend fix, Inner's `code` anchored at -// …/properties/over/properties/code (dangling — `over` is an array, not an -// object). +// The `over` case is the regression witness for the swagger:type []Inner override: before the +// items-descend fix, Inner's `code` anchored at …/properties/over/properties/code (dangling — +// `over` is an array, not an object). func TestCoverage_ProvenanceSchemaShapes(t *testing.T) { byPointer := map[string]scanner.Provenance{} doc, err := codescan.Run(&codescan.Options{ @@ -366,8 +370,8 @@ func TestCoverage_ProvenanceSchemaShapes(t *testing.T) { assert.Positivef(t, prov.Pos.Line, "%q should carry a source line", ptr) } - // The wrong (pre-fix) override pointer must NOT be emitted — `over` is an - // array, so an /properties/over/properties/* anchor would be dangling. + // The wrong (pre-fix) override pointer must NOT be emitted — `over` is an array, so an + // /properties/over/properties/* anchor would be dangling. _, bad := byPointer["/definitions/Shapes/properties/over/properties/code"] assert.False(t, bad, "swagger:type []Inner element must anchor under /items, not /properties") @@ -382,12 +386,15 @@ func TestCoverage_ProvenanceSchemaShapes(t *testing.T) { } } -// TestCoverage_ProvenancePatternPropertyPointer locks the regex-key segment of a -// patternProperties pointer. A patternProperties value node lives at -// .../patternProperties/, where is the RAW regex used as the JSON -// map key — so the pointer segment must escape it per RFC 6901 ('~' → '~0', -// '/' → '~1') to stay byte-identical to the spec-side index and resolve against -// the rendered spec. The fixture's regex deliberately contains both '/' and '~'. +// TestCoverage_ProvenancePatternPropertyPointer locks the regex-key segment of a patternProperties +// pointer. +// +// A patternProperties value node lives at .../patternProperties/, where is the RAW +// regex used as the JSON map key — so the pointer segment must escape it per RFC 6901 ('~' → +// '~0', '/' → '~1') to stay byte-identical to the spec-side index and resolve against the +// rendered spec. +// +// The fixture's regex deliberately contains both '/' and '~'. func TestCoverage_ProvenancePatternPropertyPointer(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/provenance-schema-shapes"}, @@ -404,8 +411,8 @@ func TestCoverage_ProvenancePatternPropertyPointer(t *testing.T) { _, ok = def.PatternProperties[regex] require.Truef(t, ok, "raw regex must be the patternProperties map key; got %v", def.PatternProperties) - // The cross-ref pointer escapes the regex segment and round-trips: '/' → '~1', - // '~' → '~0'. This is exactly the descend("patternProperties", regex) output. + // The cross-ref pointer escapes the regex segment and round-trips: '/' → '~1', '~' → '~0'. + // This is exactly the descend("patternProperties", regex) output. ptr := scanner.JSONPointer("definitions", "PatternPaths", "patternProperties", regex) assert.Equal(t, "/definitions/PatternPaths/patternProperties/^~1api~1~0v[0-9]+", ptr, "the regex segment must be RFC 6901 escaped") @@ -418,12 +425,11 @@ func TestCoverage_ProvenancePatternPropertyPointer(t *testing.T) { "the escaped regex-key pointer must resolve against the rendered spec") } -// TestCoverage_ProvenanceParamsResponses locks the parameters / responses anchor -// surface: a parameter anchors at /paths/{path}/{method}/parameters/{i} and stops -// there (the array index is resolved only after path binding, so a body -// parameter's inner schema is intentionally not drilled into); a response header -// anchors at /responses/{name}/headers/{h}; and an in:body response field's -// inline struct anchors its properties at /responses/{name}/schema/properties/f. +// TestCoverage_ProvenanceParamsResponses locks the parameters / responses anchor surface: a +// parameter anchors at /paths/{path}/{method}/parameters/{i} and stops there (the array index is +// resolved only after path binding, so a body parameter's inner schema is intentionally not drilled +// into); a response header anchors at /responses/{name}/headers/{h}; and an in:body response +// field's inline struct anchors its properties at /responses/{name}/schema/properties/f. func TestCoverage_ProvenanceParamsResponses(t *testing.T) { byPointer := map[string]scanner.Provenance{} doc, err := codescan.Run(&codescan.Options{ @@ -458,9 +464,8 @@ func TestCoverage_ProvenanceParamsResponses(t *testing.T) { } } -// scanAndResolve runs codescan over pkg with provenance enabled, asserts every -// emitted anchor resolves in the rendered spec, and returns the deduplicated -// pointer → provenance map. +// scanAndResolve runs codescan over pkg with provenance enabled, asserts every emitted anchor +// resolves in the rendered spec, and returns the deduplicated pointer → provenance map. func scanAndResolve(t *testing.T, pkg string) map[string]scanner.Provenance { t.Helper() seen := map[string]scanner.Provenance{} @@ -487,11 +492,12 @@ func scanAndResolve(t *testing.T, pkg string) map[string]scanner.Provenance { return seen } -// resolveJSONPointer walks an RFC 6901 pointer over a decoded JSON value -// (map[string]any / []any / scalar) and reports whether the target node exists. -// Pure stdlib so the library test stays free of the jsontext experiment that -// the TUI-side index relies on; the two must agree on escaping, which the -// matching enum/escaped-key fixtures cross-check. +// resolveJSONPointer walks an RFC 6901 pointer over a decoded JSON value (map[string]any / []any / +// scalar) and reports whether the target node exists. +// +// Pure stdlib so the library test stays free of the jsontext experiment that the TUI-side index +// relies on; the two must agree on escaping, which the matching enum/escaped-key fixtures +// cross-check. func resolveJSONPointer(root any, ptr string) bool { if ptr == "" { return true diff --git a/internal/integration/coverage_prune_unused_test.go b/internal/integration/coverage_prune_unused_test.go index 523747aa..9a99d0a0 100644 --- a/internal/integration/coverage_prune_unused_test.go +++ b/internal/integration/coverage_prune_unused_test.go @@ -37,10 +37,11 @@ func runPrune(t *testing.T, scanModels, prune bool, input *oaispec.Swagger) (*oa return doc, byCode } -// TestPruneUnused_Off is the control: with ScanModels and no prune, every -// swagger:model is emitted — including the unreferenced Unused / OnlyByUnused — -// and the a.Thing / b.Thing collision is deconflicted to AThing / BThing (two -// rename Hints). This is the shape the prune pass reduces. +// TestPruneUnused_Off is the control: with ScanModels and no prune, every swagger:model is emitted +// — including the unreferenced Unused / OnlyByUnused — and the a.Thing / b.Thing collision is +// deconflicted to AThing / BThing (two rename Hints). +// +// This is the shape the prune pass reduces. func TestPruneUnused_Off(t *testing.T) { doc, byCode := runPrune(t, true, false, nil) @@ -55,11 +56,12 @@ func TestPruneUnused_Off(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_prune_unused_all.json") } -// TestPruneUnused_On is the core case. ScanModels + PruneUnusedModels keeps only -// the route-reachable closure (Used -> B -> C, the recursive Node, and a.Thing), -// drops the dead set (Unused, OnlyByUnused, b.Thing), and — because the prune -// runs BEFORE name reduction — the a.Thing / b.Thing collision never surfaces, -// so a.Thing keeps the bare name "Thing" with no AThing / BThing churn. +// TestPruneUnused_On is the core case. +// +// ScanModels + PruneUnusedModels keeps only the route-reachable closure (Used -> B -> C, the +// recursive Node, and a.Thing), drops the dead set (Unused, OnlyByUnused, b.Thing), and — because +// the prune runs BEFORE name reduction — the a.Thing / b.Thing collision never surfaces, so +// a.Thing keeps the bare name "Thing" with no AThing / BThing churn. func TestPruneUnused_On(t *testing.T) { doc, byCode := runPrune(t, true, true, nil) @@ -90,9 +92,9 @@ func TestPruneUnused_On(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_prune_unused.json") } -// TestPruneUnused_Diagnostics asserts the prune is loud: one located Hint per -// pruned definition (Unused, OnlyByUnused, b.Thing), each a Hint severity with a -// source line, and no rename Hints (the collision was pruned away). +// TestPruneUnused_Diagnostics asserts the prune is loud: one located Hint per pruned definition +// (Unused, OnlyByUnused, b.Thing), each a Hint severity with a source line, and no rename Hints +// (the collision was pruned away). func TestPruneUnused_Diagnostics(t *testing.T) { _, byCode := runPrune(t, true, true, nil) @@ -105,9 +107,9 @@ func TestPruneUnused_Diagnostics(t *testing.T) { assert.Empty(t, byCode[grammar.CodeRenamedDefinition]) } -// TestPruneUnused_NoOpWithoutScanModels confirms the flag is inert without -// ScanModels (the emitted set is already reachable-only): the output matches the -// plain reachable scan, and exactly one positionless Hint explains the no-op. +// TestPruneUnused_NoOpWithoutScanModels confirms the flag is inert without ScanModels (the emitted +// set is already reachable-only): the output matches the plain reachable scan, and exactly one +// positionless Hint explains the no-op. func TestPruneUnused_NoOpWithoutScanModels(t *testing.T) { doc, byCode := runPrune(t, false, true, nil) @@ -123,9 +125,8 @@ func TestPruneUnused_NoOpWithoutScanModels(t *testing.T) { assert.Zero(t, hints[0].Pos.Line, "the no-op notice is positionless") } -// TestPruneUnused_InputSpecPinned confirms decision 2: a definition supplied via -// InputSpec is pinned — never pruned even when no path references it — and seeds -// the reachability roots. +// TestPruneUnused_InputSpecPinned confirms decision 2: a definition supplied via InputSpec is +// pinned — never pruned even when no path references it — and seeds the reachability roots. func TestPruneUnused_InputSpecPinned(t *testing.T) { input := &oaispec.Swagger{ SwaggerProps: oaispec.SwaggerProps{ @@ -141,9 +142,8 @@ func TestPruneUnused_InputSpecPinned(t *testing.T) { assert.NotContains(t, doc.Definitions, "Unused") } -// TestPruneUnused_Provenance confirms a pruned definition leaves no orphan -// provenance: no anchor references a pruned node, and every emitted anchor -// resolves in the rendered spec. +// TestPruneUnused_Provenance confirms a pruned definition leaves no orphan provenance: no anchor +// references a pruned node, and every emitted anchor resolves in the rendered spec. func TestPruneUnused_Provenance(t *testing.T) { var recorded []scanner.Provenance doc, err := codescan.Run(&codescan.Options{ diff --git a/internal/integration/coverage_quirk_alias_deprecated_test.go b/internal/integration/coverage_quirk_alias_deprecated_test.go index 1f95b541..45891500 100644 --- a/internal/integration/coverage_quirk_alias_deprecated_test.go +++ b/internal/integration/coverage_quirk_alias_deprecated_test.go @@ -13,12 +13,13 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestQuirk_AliasDeprecated locks the F8 deprecation of swagger:alias -// (doc-site-quirks.md). swagger:alias used to force a named primitive to -// inline ({type:string}) instead of the $ref a named type otherwise gets. It -// is now an empty sink: it emits a validate.deprecated diagnostic and has no -// effect on output, so the type resolves through default handling (here, a -// $ref to its definition under swagger:model). +// TestQuirk_AliasDeprecated locks the F8 deprecation of swagger:alias (doc-site-quirks.md). +// swagger:alias used to force a named primitive to inline ({type:string}) instead of the $ref a +// named type otherwise gets. +// +// It is now an empty sink: it emits a validate.deprecated diagnostic and has no effect on output, +// so the type resolves through default handling (here, a $ref to its definition under +// swagger:model). func TestQuirk_AliasDeprecated(t *testing.T) { var diags []grammar.Diagnostic doc, err := codescan.Run(&codescan.Options{ @@ -40,8 +41,8 @@ func TestQuirk_AliasDeprecated(t *testing.T) { } assert.True(t, deprecated, "swagger:alias must emit a deprecation diagnostic") - // No effect on output: the field resolves to a $ref (default handling), - // not the inline {type:string} swagger:alias used to force. + // No effect on output: the field resolves to a $ref (default handling), not the inline + // {type:string} swagger:alias used to force. contact := doc.Definitions["Account"].Properties["contact"] ref := contact.Ref assert.Equal(t, "#/definitions/Email", ref.String()) diff --git a/internal/integration/coverage_quirk_alias_model_test.go b/internal/integration/coverage_quirk_alias_model_test.go index 937717f3..df44fd39 100644 --- a/internal/integration/coverage_quirk_alias_model_test.go +++ b/internal/integration/coverage_quirk_alias_model_test.go @@ -12,14 +12,15 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestQuirk_AliasModelNoHang is the F9 regression lock (doc-site-quirks.md). A -// swagger:model-annotated Go type alias (type Price = Money, both -// swagger:model) once sent the scanner into an infinite loop when the alias -// was built — under the default (expand) and RefAliases modes; -// TransparentAliases dissolved the alias before the looping path. The hang is -// gone on the current base; this test keeps it gone across all three alias -// modes. A regression would resurface as a test timeout (CI runs with -// -timeout), not an assertion failure — that is the point of the lock. +// TestQuirk_AliasModelNoHang is the F9 regression lock (doc-site-quirks.md). +// +// A swagger:model-annotated Go type alias (type Price = Money, both swagger:model) once sent the +// scanner into an infinite loop when the alias was built — under the default (expand) and +// RefAliases modes; TransparentAliases dissolved the alias before the looping path. +// The hang is gone on the current base; this test keeps it gone across all three alias modes. +// +// A regression would resurface as a test timeout (CI runs with -timeout), not an assertion failure +// — that is the point of the lock. func TestQuirk_AliasModelNoHang(t *testing.T) { cases := []struct { name string diff --git a/internal/integration/coverage_quirk_body_response_description_test.go b/internal/integration/coverage_quirk_body_response_description_test.go index 07dbfc17..18cea3af 100644 --- a/internal/integration/coverage_quirk_body_response_description_test.go +++ b/internal/integration/coverage_quirk_body_response_description_test.go @@ -12,11 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestQuirk_BodyResponseDescription verifies the fix for quirk G1 -// (doc-site-quirks.md): a `body:` response with no trailing description no -// longer fills response.description with the bare Go type token. Instead the -// description is derived from the referenced model's godoc, falling back to -// the HTTP status reason phrase, then a neutral placeholder. +// TestQuirk_BodyResponseDescription verifies the fix for quirk G1 (doc-site-quirks.md): a `body:` +// response with no trailing description no longer fills response.description with the bare Go type +// token. +// +// Instead the description is derived from the referenced model's godoc, falling back to the HTTP +// status reason phrase, then a neutral placeholder. // // 200: body:Pet → "Pet is a documented model used as a response body." // 404: body:Blank → "Not Found" (model has no godoc → status phrase) diff --git a/internal/integration/coverage_quirk_gofmt_meta_test.go b/internal/integration/coverage_quirk_gofmt_meta_test.go index 8abbb261..7077eb51 100644 --- a/internal/integration/coverage_quirk_gofmt_meta_test.go +++ b/internal/integration/coverage_quirk_gofmt_meta_test.go @@ -12,19 +12,20 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestQuirk_GofmtMetaYAML verifies the fix for quirk F7 (doc-site-quirks.md): a -// swagger:meta YAML block in its gofmt-canonical shape — a column-0 key, the -// blank "//" line gofmt inserts below it, then tab-indented children — now -// scans cleanly. Previously the tab indentation after the blank line reached -// the YAML sub-parser and aborted the scan with "found character that cannot -// start any token". +// TestQuirk_GofmtMetaYAML verifies the fix for quirk F7 (doc-site-quirks.md): a swagger:meta YAML +// block in its gofmt-canonical shape — a column-0 key, the blank "//" line gofmt inserts below +// it, then tab-indented children — now scans cleanly. // -// Root: RemoveIndent keyed the dedent strip width off the literal first body -// line. gofmt's inserted blank line made that width zero, so the children's -// tabs survived. The fix keys the strip width off the first non-blank line, so -// the gofmt-canonical form dedents (and retabs) identically to the -// uniformly-indented form. This closes the residual behind #2959-class meta -// failures: write the natural column-0 form, run gofmt, still scans. +// Previously the tab indentation after the blank line reached the YAML sub-parser and aborted the +// scan with "found character that cannot start any token". +// +// Root: RemoveIndent keyed the dedent strip width off the literal first body line. gofmt's inserted +// blank line made that width zero, so the children's tabs survived. +// The fix keys the strip width off the first non-blank line, so the gofmt-canonical form dedents +// (and retabs) identically to the uniformly-indented form. +// +// This closes the residual behind #2959-class meta failures: write the natural column-0 form, run +// gofmt, still scans. func TestQuirk_GofmtMetaYAML(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./quirks/gofmt-meta/..."}, diff --git a/internal/integration/coverage_quirk_gofmt_operation_test.go b/internal/integration/coverage_quirk_gofmt_operation_test.go index 6985160e..6f79d60f 100644 --- a/internal/integration/coverage_quirk_gofmt_operation_test.go +++ b/internal/integration/coverage_quirk_gofmt_operation_test.go @@ -12,19 +12,19 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestQuirk_GofmtOperationYAML is the operations counterpart of quirk F7 -// (TestQuirk_GofmtMetaYAML). A swagger:operation YAML body in its -// gofmt-canonical shape — column-0 top-level keys rendered as prose (one -// leading space), their value blocks rendered as tab-indented code blocks, -// separated by blank "//" lines — must parse identically to the space-indented -// form. Previously the tab indentation reached the YAML sub-parser, which -// rejects tabs, and the nesting collapsed: `responses` degraded to a single -// `default` with an empty description and the vendor extension became `null`. +// TestQuirk_GofmtOperationYAML is the operations counterpart of quirk F7 (TestQuirk_GofmtMetaYAML). // -// F7 fixed the swagger:meta case (a uniformly tab-prefixed body); this asserts -// the operation case, whose body interleaves 1-space prose keys with -// tab-prefixed value blocks. The dedent now expands leading tabs to spaces -// before stripping the common indent. +// A swagger:operation YAML body in its gofmt-canonical shape — column-0 top-level keys rendered +// as prose (one leading space), their value blocks rendered as tab-indented code blocks, separated +// by blank "//" lines — must parse identically to the space-indented form. +// +// Previously the tab indentation reached the YAML sub-parser, which rejects tabs, and the nesting +// collapsed: `responses` degraded to a single `default` with an empty description and the vendor +// extension became `null`. +// +// F7 fixed the swagger:meta case (a uniformly tab-prefixed body); this asserts the operation case, +// whose body interleaves 1-space prose keys with tab-prefixed value blocks. +// The dedent now expands leading tabs to spaces before stripping the common indent. func TestQuirk_GofmtOperationYAML(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./quirks/gofmt-operation/..."}, @@ -40,8 +40,7 @@ func TestQuirk_GofmtOperationYAML(t *testing.T) { require.True(t, ok, "the 200 response must survive the gofmt tab indentation") assert.Equal(t, "ok", r200.Description) - // The vendor extension is preserved with its nested map (incl. responses), - // not dropped to null. + // The vendor extension is preserved with its nested map (incl. responses), not dropped to null. ext, ok := op.Extensions["x-amazon-apigateway-integration"] require.True(t, ok) m, ok := ext.(map[string]any) diff --git a/internal/integration/coverage_quirk_model_override_simpleschema_test.go b/internal/integration/coverage_quirk_model_override_simpleschema_test.go index 3a0107cc..22f3df92 100644 --- a/internal/integration/coverage_quirk_model_override_simpleschema_test.go +++ b/internal/integration/coverage_quirk_model_override_simpleschema_test.go @@ -12,13 +12,15 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestQuirk_ModelOverrideSimpleSchema guards the F1/F4 fix against leaking a -// $ref into a SimpleSchema. A swagger:model type with an override (enum / -// strfmt) is referenced by $ref in full-schema mode — but $ref is illegal in -// an OAS-2 SimpleSchema (non-body parameters, response headers), so there the -// override must INLINE instead. The buildNamedType gate is therefore -// (isModel && !simpleSchema): a regression would dissolve the schema to {} via -// the simple-schema $ref safety net, losing the enum/format. +// TestQuirk_ModelOverrideSimpleSchema guards the F1/F4 fix against leaking a $ref into a +// SimpleSchema. +// +// A swagger:model type with an override (enum / strfmt) is referenced by $ref in full-schema mode +// — but $ref is illegal in an OAS-2 SimpleSchema (non-body parameters, response headers), so +// there the override must INLINE instead. +// +// The buildNamedType gate is therefore (isModel && !simpleSchema): a regression would dissolve the +// schema to {} via the simple-schema $ref safety net, losing the enum/format. func TestQuirk_ModelOverrideSimpleSchema(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./quirks/model-override-simpleschema/..."}, diff --git a/internal/integration/coverage_quirk_model_override_test.go b/internal/integration/coverage_quirk_model_override_test.go index 7c41669d..f3791836 100644 --- a/internal/integration/coverage_quirk_model_override_test.go +++ b/internal/integration/coverage_quirk_model_override_test.go @@ -13,15 +13,15 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestQuirk_ModelOverrideMatrix verifies the F1/F2/F4 reconciliation: a -// swagger:model type carrying a per-type override (swagger:strfmt / swagger:type -// / swagger:enum) now publishes a first-class definition carrying the FULL -// override schema, and referencing fields $ref it (instead of inlining + an -// orphan, degenerate definition). The golden quirk_model_override_matrix.json -// captures the fixed shape. +// TestQuirk_ModelOverrideMatrix verifies the F1/F2/F4 reconciliation: a swagger:model type carrying +// a per-type override (swagger:strfmt / swagger:type / swagger:enum) now publishes a first-class +// definition carrying the FULL override schema, and referencing fields $ref it (instead of inlining +// + an orphan, degenerate definition). // -// F4b (bare swagger:enum const collection) is NOT yet done — its assertions -// carry TODO Part C and still pin the current (no-enum) behaviour. +// The golden quirk_model_override_matrix.json captures the fixed shape. +// +// F4b (bare swagger:enum const collection) is NOT yet done — its assertions carry TODO Part C and +// still pin the current (no-enum) behaviour. func TestQuirk_ModelOverrideMatrix(t *testing.T) { var diags []grammar.Diagnostic doc, err := codescan.Run(&codescan.Options{ @@ -36,28 +36,27 @@ func TestQuirk_ModelOverrideMatrix(t *testing.T) { h := doc.Definitions["Holder"].Properties require.NotNil(t, h) - // F1: strfmt+model — field $refs UUID; the UUID definition carries the - // format ({type:string, format:uuid}), no longer an orphan. + // F1: strfmt+model — field $refs UUID; the UUID definition carries the format ({type:string, + // format:uuid}), no longer an orphan. id := h["id"] assert.Equal(t, "#/definitions/UUID", id.Ref.String()) assert.Empty(t, id.Type, "field is a $ref, not an inlined scalar") assert.Equal(t, "uuid", doc.Definitions["UUID"].Format) - // F2: type+model — field $refs RawID; the RawID definition is {type:string} - // (the swagger:type override applied to the decl). + // F2: type+model — field $refs RawID; the RawID definition is {type:string} (the swagger:type + // override applied to the decl). raw := h["raw"] assert.Equal(t, "#/definitions/RawID", raw.Ref.String()) assert.Equal(t, []string{"string"}, []string(doc.Definitions["RawID"].Type)) - // F4: enum+model — field $refs Status; the Status definition carries the - // enum values, no longer value-less. + // F4: enum+model — field $refs Status; the Status definition carries the enum values, no longer + // value-less. state := h["state"] assert.Equal(t, "#/definitions/Status", state.Ref.String()) assert.Equal(t, []any{"active", "closed"}, doc.Definitions["Status"].Enum) - // F4b: bare swagger:enum (no name) on a type decl now infers the enum name - // from the declaration and collects its consts; the definition carries the - // enum and the field $refs it. + // F4b: bare swagger:enum (no name) on a type decl now infers the enum name from the declaration + // and collects its consts; the definition carries the enum and the field $refs it. priority := h["priority"] assert.Equal(t, "#/definitions/Priority", priority.Ref.String()) require.Len(t, doc.Definitions["Priority"].Enum, 2, "bare swagger:enum collects the decl's consts") diff --git a/internal/integration/coverage_quirk_name_field_test.go b/internal/integration/coverage_quirk_name_field_test.go index 0aeb71f6..4565947c 100644 --- a/internal/integration/coverage_quirk_name_field_test.go +++ b/internal/integration/coverage_quirk_name_field_test.go @@ -12,13 +12,15 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestQuirk_NameOnField verifies the fix for quirk F5 (doc-site-quirks.md): -// swagger:name on a struct field now overrides the emitted property name, just -// as it already did on interface methods. Previously the override was scanned -// but silently dropped — the property kept its Go field name or json-tag name. +// TestQuirk_NameOnField verifies the fix for quirk F5 (doc-site-quirks.md): swagger:name on a +// struct field now overrides the emitted property name, just as it already did on interface +// methods. // -// The Go field name is still recorded as x-go-name when it differs from the -// emitted JSON name, so the override does not lose traceability. +// Previously the override was scanned but silently dropped — the property kept its Go field name +// or json-tag name. +// +// The Go field name is still recorded as x-go-name when it differs from the emitted JSON name, so +// the override does not lose traceability. func TestQuirk_NameOnField(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./quirks/name-on-field/..."}, diff --git a/internal/integration/coverage_quirk_name_keyword_universal_test.go b/internal/integration/coverage_quirk_name_keyword_universal_test.go index e030982b..1eff8db7 100644 --- a/internal/integration/coverage_quirk_name_keyword_universal_test.go +++ b/internal/integration/coverage_quirk_name_keyword_universal_test.go @@ -15,11 +15,11 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestQuirk_NameKeywordUniversal verifies the fix for quirk G2 -// (doc-site-quirks.md): the `name:` keyword now renames a field at every -// site — swagger:model properties and interface methods included — not just -// swagger:parameters / swagger:response fields. The legacy swagger:name -// annotation is still honoured, and `name:` takes precedence over it. +// TestQuirk_NameKeywordUniversal verifies the fix for quirk G2 (doc-site-quirks.md): the `name:` +// keyword now renames a field at every site — swagger:model properties and interface methods +// included — not just swagger:parameters / swagger:response fields. +// +// The legacy swagger:name annotation is still honoured, and `name:` takes precedence over it. // // Precedence: name: keyword > swagger:name > json tag > Go field name. func TestQuirk_NameKeywordUniversal(t *testing.T) { @@ -75,8 +75,8 @@ func TestQuirk_NameKeywordUniversal(t *testing.T) { assert.True(t, region, "swagger:name was dropped on the param field; Go name retained") assert.NotContains(t, paramNames(op.Parameters), "region_code", "swagger:name must not rename a param") - // G2 diagnostics: swagger:name in a param / response-header context is - // inert and now draws a CodeContextInvalid warning pointing at `name:`. + // G2 diagnostics: swagger:name in a param / response-header context is inert and now draws a + // CodeContextInvalid warning pointing at `name:`. var paramWarn, headerWarn bool for _, d := range diags { if d.Code != grammar.CodeContextInvalid || !strings.Contains(d.Message, "swagger:name") { diff --git a/internal/integration/coverage_quirk_ref_example_coercion_test.go b/internal/integration/coverage_quirk_ref_example_coercion_test.go index b6692d8a..de9a9b96 100644 --- a/internal/integration/coverage_quirk_ref_example_coercion_test.go +++ b/internal/integration/coverage_quirk_ref_example_coercion_test.go @@ -12,11 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestQuirk_RefExampleCoercion verifies the fix for quirk G3 -// (doc-site-quirks.md): a JSON-literal example: / default: on a field -// whose type is a $ref is coerced into a structured value on the allOf -// override arm — matching the direct-field path — instead of riding the -// arm as a raw string. +// TestQuirk_RefExampleCoercion verifies the fix for quirk G3 (doc-site-quirks.md): a JSON-literal +// example: / default: on a field whose type is a $ref is coerced into a structured value on the +// allOf override arm — matching the direct-field path — instead of riding the arm as a raw +// string. func TestQuirk_RefExampleCoercion(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./quirks/ref-example-coercion/..."}, @@ -55,8 +54,7 @@ func TestQuirk_RefExampleCoercion(t *testing.T) { map[string]any{"value": "b"}, }, armExample("tags")) - // a non-JSON scalar is left as a plain string (referenced type - // unknown on the override arm). + // a non-JSON scalar is left as a plain string (referenced type unknown on the override arm). assert.Equal(t, "plain-scalar", armExample("plain")) scantest.CompareOrDumpJSON(t, doc, "quirk_ref_example_coercion.json") diff --git a/internal/integration/coverage_quirk_type_matrix_test.go b/internal/integration/coverage_quirk_type_matrix_test.go index e5ccec45..d839a545 100644 --- a/internal/integration/coverage_quirk_type_matrix_test.go +++ b/internal/integration/coverage_quirk_type_matrix_test.go @@ -13,16 +13,17 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestQuirk_TypeMatrix verifies the F3 reconciliation: swagger:type is now a -// uniform "inline this type" directive (never a $ref), resolved consistently at -// the field site and the named-type site. It supersedes the documented-current -// witness this test started as. The golden quirk_type_matrix.json is the -// fixed baseline; this test pins the headline behaviours and the diagnostics. +// TestQuirk_TypeMatrix verifies the F3 reconciliation: swagger:type is now a uniform "inline this +// type" directive (never a $ref), resolved consistently at the field site and the named-type site. // -// Vocabulary (always inlined): canonical OAS-2 scalars + Go-builtin spellings, -// `[]T` arrays, the `inline` keyword (expand the Go type), the deprecated -// `array` keyword, type-name references (inline a known definition), with a -// `file` / unknown diagnostic and a strfmt-iff-format-compatible rule. +// It supersedes the documented-current witness this test started as. +// The golden quirk_type_matrix.json is the fixed baseline; this test pins the headline behaviours +// and the diagnostics. +// +// Vocabulary (always inlined): canonical OAS-2 scalars + Go-builtin spellings, `[]T` arrays, the +// `inline` keyword (expand the Go type), the deprecated `array` keyword, type-name references +// (inline a known definition), with a `file` / unknown diagnostic and a +// strfmt-iff-format-compatible rule. func TestQuirk_TypeMatrix(t *testing.T) { var diags []grammar.Diagnostic doc, err := codescan.Run(&codescan.Options{ @@ -65,8 +66,8 @@ func TestQuirk_TypeMatrix(t *testing.T) { assert.Contains(t, nInline.Properties, "x") assert.Empty(t, nInline.Ref.String()) - // Type-name reference inlines a known definition (no $ref), even across a - // different Go type (pCrossRef is a Go string overridden to Custom). + // Type-name reference inlines a known definition (no $ref), even across a different Go type + // (pCrossRef is a Go string overridden to Custom). kScanned := m["kScanned"] assert.Contains(t, kScanned.Properties, "x") assert.Empty(t, kScanned.Ref.String()) @@ -77,8 +78,8 @@ func TestQuirk_TypeMatrix(t *testing.T) { assert.Equal(t, []string{"array"}, []string(m["oArrayCustom"].Type)) assert.Contains(t, m["oArrayCustom"].Items.Schema.Properties, "x") - // strfmt + type precedence: type wins; a compatible format is applied, an - // incompatible one is dropped (with a diagnostic, asserted below). + // strfmt + type precedence: type wins; a compatible format is applied, an incompatible one is + // dropped (with a diagnostic, asserted below). assert.Equal(t, []string{"string"}, []string(m["mStrfmtThenString"].Type)) assert.Equal(t, "uuid", m["mStrfmtThenString"].Format) // uuid compatible with string assert.Equal(t, []string{"integer"}, []string(m["lStrfmtThenType"].Type)) @@ -93,9 +94,9 @@ func TestQuirk_TypeMatrix(t *testing.T) { assert.Equal(t, []string{"array"}, []string(doc.Definitions["NamedSlice"].Type)) assert.Equal(t, []string{"integer"}, []string(doc.Definitions["NamedStrfmtAndType"].Type)) - // Diagnostics: array-deprecated ×2 (field gArray + named NamedSlice), - // strfmt-incompatible ×2 (field lStrfmtThenType + named NamedStrfmtAndType), - // file ×1, unknown ×1. No grammar invalid-type-ref any more. + // Diagnostics: array-deprecated ×2 (field gArray + named NamedSlice), strfmt-incompatible ×2 + // (field lStrfmtThenType + named NamedStrfmtAndType), file ×1, unknown ×1. No grammar + // invalid-type-ref any more. byCode := map[grammar.Code]int{} for _, d := range diags { byCode[d.Code]++ diff --git a/internal/integration/coverage_response_bodyfield_example_test.go b/internal/integration/coverage_response_bodyfield_example_test.go index ae9142d1..29cd75c7 100644 --- a/internal/integration/coverage_response_bodyfield_example_test.go +++ b/internal/integration/coverage_response_bodyfield_example_test.go @@ -12,9 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_ResponseBodyFieldExample locks example: on a swagger:response -// struct Body field — the body-field keywords land on the body schema rather -// than the discarded header (go-swagger#3013, #2942 family). +// TestCoverage_ResponseBodyFieldExample locks example: on a swagger:response struct Body field — +// the body-field keywords land on the body schema rather than the discarded header +// (go-swagger#3013, #2942 family). func TestCoverage_ResponseBodyFieldExample(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/response-bodyfield-example/..."}, diff --git a/internal/integration/coverage_response_examples_test.go b/internal/integration/coverage_response_examples_test.go new file mode 100644 index 00000000..e2e81038 --- /dev/null +++ b/internal/integration/coverage_response_examples_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" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestCoverage_ResponseExamplesByMime covers response-level `examples` (a map keyed by mime type) +// on a struct-based `swagger:response` (go-swagger#2871). +// +// The `swagger:operation` YAML path already carries examples for free (see coverage_bug_1713 / +// coverage_bug_2871); this verifies the Go-struct `swagger:response` path, where the `examples:` +// block in the decl comment is parsed into the OAS2 Response.examples field. +func TestCoverage_ResponseExamplesByMime(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/response-examples-by-mime/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + resp, ok := doc.Responses["widgetResponse"] + require.TrueT(t, ok, "expected a widgetResponse in the responses section") + require.NotNil(t, resp.Examples, "response must carry an examples map") + + jsonEx, ok := resp.Examples["application/json"].(map[string]any) + require.TrueT(t, ok, "expected an application/json example object") + assert.MapEqualT(t, map[string]any{"name": "alice", "count": float64(3)}, jsonEx) + + xmlEx, ok := resp.Examples["application/xml"] + require.TrueT(t, ok, "expected an application/xml example") + assert.EqualT(t, any("alice"), xmlEx) + + // The body schema and description survive alongside the examples. + assert.EqualT(t, "WidgetResponse returns a widget, with response-level examples per mime type.", resp.Description) + require.NotNil(t, resp.Schema) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_response_examples_by_mime.json") +} diff --git a/internal/integration/coverage_response_file_types_test.go b/internal/integration/coverage_response_file_types_test.go index 0597e4ab..1d902993 100644 --- a/internal/integration/coverage_response_file_types_test.go +++ b/internal/integration/coverage_response_file_types_test.go @@ -27,8 +27,8 @@ import ( // `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). +// 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{ @@ -63,8 +63,8 @@ func TestCoverage_ResponseFileTypes(t *testing.T) { 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`. + // 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 { diff --git a/internal/integration/coverage_response_header_ref_leak_test.go b/internal/integration/coverage_response_header_ref_leak_test.go index 49ea71f4..2d7b961b 100644 --- a/internal/integration/coverage_response_header_ref_leak_test.go +++ b/internal/integration/coverage_response_header_ref_leak_test.go @@ -14,13 +14,12 @@ import ( "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), +// 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. +// +// 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: // diff --git a/internal/integration/coverage_response_implicit_header_test.go b/internal/integration/coverage_response_implicit_header_test.go index c554f544..9b1cac36 100644 --- a/internal/integration/coverage_response_implicit_header_test.go +++ b/internal/integration/coverage_response_implicit_header_test.go @@ -14,8 +14,7 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_ResponseImplicitHeader exercises the four Q1 variants -// on the response side: +// 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. @@ -28,9 +27,8 @@ import ( // 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. +// 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{ @@ -89,7 +87,7 @@ func TestCoverage_ResponseImplicitHeader(t *testing.T) { } 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). + // 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_response_name_keyword_test.go b/internal/integration/coverage_response_name_keyword_test.go index c6daf096..e6999488 100644 --- a/internal/integration/coverage_response_name_keyword_test.go +++ b/internal/integration/coverage_response_name_keyword_test.go @@ -12,12 +12,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_ResponseNameKeyword locks the `name:` keyword on a -// swagger:response struct field: it renames the response header (the -// Headers map key), overriding the json-tag / Go-field derivation, and — -// being a structural keyword — is stripped from the header description -// rather than leaking into it as prose. Mirrors the parameter-side -// TestCoverage_ParamNameKeyword. +// TestCoverage_ResponseNameKeyword locks the `name:` keyword on a swagger:response struct field: it +// renames the response header (the Headers map key), overriding the json-tag / Go-field derivation, +// and — being a structural keyword — is stripped from the header description rather than +// leaking into it as prose. +// +// Mirrors the parameter-side TestCoverage_ParamNameKeyword. func TestCoverage_ResponseNameKeyword(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/response-name-keyword/..."}, diff --git a/internal/integration/coverage_response_toplevel_example_test.go b/internal/integration/coverage_response_toplevel_example_test.go index 23dcfdb9..a00e80d1 100644 --- a/internal/integration/coverage_response_toplevel_example_test.go +++ b/internal/integration/coverage_response_toplevel_example_test.go @@ -12,9 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_ResponseTopLevelExample locks example: on top-level non-struct -// response bodies: an array (example on its own paragraph) and a scalar -// (trailing example line). Both land on the response body schema (go-swagger#3013). +// TestCoverage_ResponseTopLevelExample locks example: on top-level non-struct response bodies: an +// array (example on its own paragraph) and a scalar (trailing example line). +// +// Both land on the response body schema (go-swagger#3013). func TestCoverage_ResponseTopLevelExample(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/response-toplevel-example/..."}, diff --git a/internal/integration/coverage_security_yaml_idiom_test.go b/internal/integration/coverage_security_yaml_idiom_test.go index 30bd5f31..3b847f0a 100644 --- a/internal/integration/coverage_security_yaml_idiom_test.go +++ b/internal/integration/coverage_security_yaml_idiom_test.go @@ -12,12 +12,13 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_SecurityYAMLIdiom locks the recommended form of a global -// Security requirements block: idiomatic OpenAPI 2.0 YAML — a sequence of -// Security Requirement Objects, scopes in block style. The captured spec is -// identical to what a real YAML parse of the same source produces, which is -// now literally true: the security sub-parser (internal/parsers/security) -// decodes the body as YAML rather than hand-parsing it. +// TestCoverage_SecurityYAMLIdiom locks the recommended form of a global Security requirements +// block: idiomatic OpenAPI 2.0 YAML — a sequence of Security Requirement Objects, scopes in block +// style. +// +// The captured spec is identical to what a real YAML parse of the same source produces, which is +// now literally true: the security sub-parser (internal/parsers/security) decodes the body as YAML +// rather than hand-parsing it. // // Security: // - oauth: # ┐ one requirement object: @@ -26,9 +27,9 @@ import ( // api_key: [] # ┘ api_key // - basic: [] # OR this alternative // -// Doc-facing companion to the per-issue witnesses #2403 (sequence marker / -// inline scopes), #2479 (explicit empty opt-out) and #2294 (multi-key AND -// grouping): the positive demonstration that those forms are just YAML. +// Doc-facing companion to the per-issue witnesses #2403 (sequence marker / inline scopes), #2479 +// (explicit empty opt-out) and #2294 (multi-key AND grouping): the positive demonstration that +// those forms are just YAML. func TestCoverage_SecurityYAMLIdiom(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/security-yaml-idiom/..."}, @@ -50,8 +51,8 @@ func TestCoverage_SecurityYAMLIdiom(t *testing.T) { require.Contains(t, or, "basic", "second alternative is basic alone") assert.Empty(t, or["basic"]) - // The schemes are also defined (proves the YAML-bodied SecurityDefinitions - // path and the requirements path agree on the same scheme names). + // The schemes are also defined (proves the YAML-bodied SecurityDefinitions path and the + // requirements path agree on the same scheme names). require.Contains(t, doc.SecurityDefinitions, "oauth") require.Contains(t, doc.SecurityDefinitions, "api_key") require.Contains(t, doc.SecurityDefinitions, "basic") diff --git a/internal/integration/coverage_shared_parameters_test.go b/internal/integration/coverage_shared_parameters_test.go new file mode 100644 index 00000000..2392cf5f --- /dev/null +++ b/internal/integration/coverage_shared_parameters_test.go @@ -0,0 +1,490 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "slices" + "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/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestCoverage_SharedParameters_TopLevel exercises P2 of the shared-parameters feature +// (go-swagger#2632): a `swagger:parameters *` struct registers its fields at the spec top level +// (#/parameters/{name}), keyed by the resolved parameter name. +// +// Register-only: the entries appear in the top-level map regardless of whether any operation +// references them yet (the $ref wiring is a later phase). +func TestCoverage_SharedParameters_TopLevel(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/shared-parameters/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + + // CommonHeaders (swagger:parameters *) → #/parameters/X-Request-ID AuthHeader + // (swagger:parameters * createPet) → #/parameters/X-API-Key. + reqID, ok := doc.Parameters["X-Request-ID"] + require.TrueT(t, ok, "expected #/parameters/X-Request-ID") + assert.EqualT(t, "header", reqID.In) + assert.EqualT(t, "X-Request-ID", reqID.Name) + assert.EqualT(t, "string", reqID.Type) + + apiKey, ok := doc.Parameters["X-API-Key"] + require.TrueT(t, ok, "expected #/parameters/X-API-Key") + assert.EqualT(t, "header", apiKey.In) + assert.TrueT(t, apiKey.Required) + + // The operation-id targets (ListPetsParams → listPets, CreatePetParams → createPet) are NOT + // registered at the top level. + _, hasLimit := doc.Parameters["limit"] + assert.FalseT(t, hasLimit, "inline operation params must not leak into #/parameters") +} + +// paramRefs collects the #/parameters/{name} $ref targets on an operation. +func paramRefs(op *spec.Operation) []string { + var refs []string + for _, p := range op.Parameters { + if r := p.Ref.String(); r != "" { + refs = append(refs, r) + } + } + return refs +} + +// TestCoverage_SharedParameters_Refs exercises P3 (go-swagger#2632): a shared parameter is wired +// into an operation as a #/parameters/{name} $ref through both reference channels — the +// `swagger:parameters * opid` definition convenience and the standalone `swagger:parameters opid +// name` reference marker on a func. +func TestCoverage_SharedParameters_Refs(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/shared-parameters/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + require.NotNil(t, doc.Paths) + + pets, ok := doc.Paths.Paths["/pets"] + require.TrueT(t, ok, "expected a /pets path") + + // createPet: AuthHeader (`swagger:parameters * createPet`) refs X-API-Key. + require.NotNil(t, pets.Post) + assert.SliceContainsT(t, paramRefs(pets.Post), "#/parameters/X-API-Key", + "createPet should $ref the shared X-API-Key (via `* createPet`)") + + // listPets: standalone `swagger:parameters listPets X-Request-ID` ref. + require.NotNil(t, pets.Get) + assert.SliceContainsT(t, paramRefs(pets.Get), "#/parameters/X-Request-ID", + "listPets should $ref the shared X-Request-ID (standalone reference)") + // the inline query parameter `limit` is still present alongside the ref. + var hasLimit bool + for _, p := range pets.Get.Parameters { + if p.Name == "limit" { + hasLimit = true + } + } + assert.TrueT(t, hasLimit, "listPets keeps its inline `limit` parameter") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_shared_parameters.json") +} + +// TestCoverage_SharedParameters_PathItem exercises P4 (fixture 2, go-swagger#2632): +// `swagger:parameters /path` inlines a struct's fields into the path-item; `swagger:parameters +// /path name` adds a #/parameters/{name} $ref to it. +// +// Application is exact-path (no hierarchy), and path-item parameters co-exist with operation-level +// ones (the operation one wins at resolution — co-presence, not removal). +func TestCoverage_SharedParameters_PathItem(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/shared-parameters-pathitem/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + require.NotNil(t, doc.Paths) + + pets, ok := doc.Paths.Paths["/pets"] + require.TrueT(t, ok, "expected a /pets path") + + // Path-item parameters: inline X-API-Key (from the /pets struct) + a $ref to the shared + // X-Request-ID (from the /pets reference marker). + var inlineAPIKey *spec.Parameter + var refReqID bool + for i := range pets.Parameters { + p := pets.Parameters[i] + switch { + case p.Name == "X-API-Key" && p.In == "header": + inlineAPIKey = &pets.Parameters[i] + case p.Ref.String() == "#/parameters/X-Request-ID": + refReqID = true + } + } + require.NotNil(t, inlineAPIKey, "expected inline X-API-Key on the /pets path-item") + assert.TrueT(t, inlineAPIKey.Required, "path-item X-API-Key is required:true") + assert.TrueT(t, refReqID, "expected a #/parameters/X-Request-ID $ref on the /pets path-item") + + // Co-presence override: listPets carries its OWN X-API-Key (required:false) at the operation + // level; the path-item's required:true one is untouched. + require.NotNil(t, pets.Get) + var opAPIKey *spec.Parameter + for i := range pets.Get.Parameters { + if pets.Get.Parameters[i].Name == "X-API-Key" { + opAPIKey = &pets.Get.Parameters[i] + } + } + require.NotNil(t, opAPIKey, "listPets has its own X-API-Key (operation-level override)") + assert.FalseT(t, opAPIKey.Required, "operation-level X-API-Key is required:false") + + // Exact path, no hierarchy: /pets/{id} must NOT inherit the /pets path-item parameters. + petByID, ok := doc.Paths.Paths["/pets/{id}"] + require.TrueT(t, ok, "expected a /pets/{id} path") + for _, p := range petByID.Parameters { + assert.FalseT(t, p.Name == "X-API-Key" || p.Ref.String() == "#/parameters/X-Request-ID", + "/pets/{id} must not inherit /pets path-item parameters (no hierarchy)") + } + + // Full-spec snapshot: the path-item parameters array, the #/parameters $ref target, and the + // co-present operation override are the most novel output of the feature — pin them in a golden + // for review. + scantest.CompareOrDumpJSON(t, doc, "enhancements_shared_parameters_pathitem.json") +} + +// TestCoverage_SharedResponses exercises P5 (go-swagger#2632): a `swagger:response *` struct +// registers a shared response at #/responses/ (the `*` is a synonym for the bare/named form, keyed +// by the type name), and operations that name it in their Responses block resolve to a $ref: +// #/responses/{name}. +// +// Before P5 the `*` failed the name regex and the response was silently dropped, leaving the route +// ref dangling. +func TestCoverage_SharedResponses(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/shared-parameters/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + + // ErrorResponse (swagger:response *) is registered at #/responses. + _, ok := doc.Responses["ErrorResponse"] + require.TrueT(t, ok, "expected #/responses/ErrorResponse from swagger:response *") + + // Both routes' `default: ErrorResponse` now resolve to a $ref (previously dropped as dangling). + require.NotNil(t, doc.Paths) + pets := doc.Paths.Paths["/pets"] + for _, op := range []*spec.Operation{pets.Get, pets.Post} { + require.NotNil(t, op) + require.NotNil(t, op.Responses) + require.NotNil(t, op.Responses.Default, "operation should have a default response") + assert.EqualT(t, "#/responses/ErrorResponse", op.Responses.Default.Ref.String()) + } +} + +// TestCoverage_SharedParameters_OverridesAndDedup exercises P3 reference edge cases (fixture 5, +// go-swagger#2632): the shared key/reference is the resolved (overridden) name (C3); duplicate +// operation-id targets (C1) and duplicate reference names (C2) are dropped with warnings; and a +// reference to an unregistered shared parameter is dropped with a scan.dangling-parameter-ref +// warning. +func TestCoverage_SharedParameters_OverridesAndDedup(t *testing.T) { + var diags []grammar.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/shared-parameters-overrides/..."}, + WorkDir: scantest.FixturesDir(), + OnDiagnostic: func(d grammar.Diagnostic) { + diags = append(diags, d) + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + // C3: the `name:` override is the registered key (X-Correlation-ID, not the json-tag + // X-Request-ID), and a reference resolves by that name. + _, ok := doc.Parameters["X-Correlation-ID"] + require.TrueT(t, ok, "expected #/parameters/X-Correlation-ID (overridden name)") + _, hasOld := doc.Parameters["X-Request-ID"] + assert.FalseT(t, hasOld, "the json-tag name must not be registered when `name:` overrides it") + + require.NotNil(t, doc.Paths) + things := doc.Paths.Paths["/things"] + require.NotNil(t, things.Get) + listRefs := paramRefs(things.Get) + assert.SliceContainsT(t, listRefs, "#/parameters/X-Correlation-ID", + "listThings references the shared param by its overridden name") + // C2: the duplicated X-Correlation-ID reference yields a single $ref. + var n int + for _, r := range listRefs { + if r == "#/parameters/X-Correlation-ID" { + n++ + } + } + assert.EqualT(t, 1, n, "duplicate reference name must collapse to one $ref") + // dangling: NoSuchParam was dropped, never emitted as a $ref. + assert.FalseT(t, slices.Contains(listRefs, "#/parameters/NoSuchParam"), "dangling ref must be dropped") + + codes := map[grammar.Code]bool{} + for _, d := range diags { + codes[d.Code] = true + } + assert.TrueT(t, codes[grammar.CodeDuplicateTarget], "expected a duplicate-target warning (C1)") + assert.TrueT(t, codes[grammar.CodeDuplicateRef], "expected a duplicate-ref warning (C2)") + assert.TrueT(t, codes[grammar.CodeDanglingParameterRef], "expected a dangling-parameter-ref warning") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_shared_parameters_overrides.json") +} + +// TestCoverage_SharedParameters_YAMLRefs exercises P6 (fixture 4, go-swagger#2632): a +// swagger:operation wholesale-YAML body that references the shared namespace is validated against +// the completed #/parameters and #/responses maps. +// +// A resolving $ref is kept verbatim; a dangling one is dropped with a +// scan.dangling-{parameter,response}-ref warning rather than emitting an invalid reference. +func TestCoverage_SharedParameters_YAMLRefs(t *testing.T) { + var diags []grammar.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/shared-parameters-yaml/..."}, + WorkDir: scantest.FixturesDir(), + OnDiagnostic: func(d grammar.Diagnostic) { + diags = append(diags, d) + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + require.NotNil(t, doc.Paths) + + // opA: both refs resolve and are kept. + opA := doc.Paths.Paths["/a"].Get + require.NotNil(t, opA) + assert.SliceContainsT(t, paramRefs(opA), "#/parameters/X-Request-ID", + "opA keeps its resolving #/parameters/X-Request-ID ref") + require.NotNil(t, opA.Responses) + require.NotNil(t, opA.Responses.Default) + assert.EqualT(t, "#/responses/ErrorResponse", opA.Responses.Default.Ref.String()) + + // opB: both refs are dangling → dropped. + opB := doc.Paths.Paths["/b"].Get + require.NotNil(t, opB) + assert.FalseT(t, slices.Contains(paramRefs(opB), "#/parameters/DoesNotExist"), + "opB dangling parameter ref must be dropped") + if opB.Responses != nil && opB.Responses.Default != nil { + assert.NotEqualT(t, "#/responses/Missing", opB.Responses.Default.Ref.String(), + "opB dangling response ref must be dropped") + } + + codes := map[grammar.Code]bool{} + for _, d := range diags { + codes[d.Code] = true + } + assert.TrueT(t, codes[grammar.CodeDanglingParameterRef], "expected a dangling-parameter-ref warning") + assert.TrueT(t, codes[grammar.CodeDanglingResponseRef], "expected a dangling-response-ref warning") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_shared_parameters_yaml.json") +} + +// TestCoverage_SharedParameters_Conflict exercises the keep-first conflict policy (P2, fixture 3): +// two `swagger:parameters *` structs in different packages register the same short name. +// +// The first wins, the later is dropped with a scan.shared-parameter-conflict warning — never +// renamed, since shared parameters are referenced only by short name. +// Independent namespaces: #/definitions/Status coexists with #/parameters/Status. +func TestCoverage_SharedParameters_Conflict(t *testing.T) { + var diags []grammar.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/shared-parameters-conflict/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + OnDiagnostic: func(d grammar.Diagnostic) { + diags = append(diags, d) + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + // X-Token registered once (the survivor wins; the loser is dropped, not renamed — no X-Token + // clone under a mangled key). pkga (header) is scanned before pkgb (query), so the header form is + // the keep-first survivor. + xtoken, ok := doc.Parameters["X-Token"] + require.TrueT(t, ok, "expected #/parameters/X-Token") + assert.EqualT(t, "header", xtoken.In, "pkga (header) wins keep-first over pkgb (query)") + assert.Len(t, doc.Parameters, 2, "X-Token + Status, no renamed duplicate") + + // Independent namespaces: a shared parameter named Status coexists with a definition named Status. + _, hasParamStatus := doc.Parameters["Status"] + assert.TrueT(t, hasParamStatus, "expected #/parameters/Status") + _, hasDefStatus := doc.Definitions["Status"] + assert.TrueT(t, hasDefStatus, "expected #/definitions/Status (independent namespace)") + + // Responses follow the same keep-first policy: pkga and pkgb both declare `swagger:response *` + // ErrorResponse; pkga (scanned first by import-path order) wins, pkgb is dropped — registered + // once, not renamed. + _, hasErr := doc.Responses["ErrorResponse"] + require.TrueT(t, hasErr, "expected #/responses/ErrorResponse") + assert.Len(t, doc.Responses, 1, "ErrorResponse registered once, no renamed duplicate") + + // Keep-first conflict warnings were emitted for both namespaces. + codes := map[grammar.Code]bool{} + for _, d := range diags { + codes[d.Code] = true + } + assert.TrueT(t, codes[grammar.CodeSharedParameterConflict], "expected a scan.shared-parameter-conflict warning") + assert.TrueT(t, codes[grammar.CodeSharedResponseConflict], "expected a scan.shared-response-conflict warning") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_shared_parameters_conflict.json") +} + +// runSharedPrune scans Fixture 6 (shared-parameters-prune) under ScanModels, toggling +// PruneUnusedModels, and collects the scan.pruned-unused Hints. +func runSharedPrune(t *testing.T, prune bool) (*spec.Swagger, []grammar.Diagnostic) { + t.Helper() + var pruned []grammar.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/shared-parameters-prune/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + PruneUnusedModels: prune, + OnDiagnostic: func(d grammar.Diagnostic) { + if d.Code == grammar.CodePrunedUnused { + pruned = append(pruned, d) + } + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + return doc, pruned +} + +// TestCoverage_SharedParameters_Prune_Off is the control for the prune extension (C4, P7): with +// ScanModels and no PruneUnusedModels, every shared parameter and response is emitted — including +// the ones no operation references (X-Unused, UnusedResponse). +func TestCoverage_SharedParameters_Prune_Off(t *testing.T) { + doc, pruned := runSharedPrune(t, false) + + for _, name := range []string{"X-Used", "X-Unused"} { + assert.Contains(t, doc.Parameters, name) + } + for _, name := range []string{"UsedResponse", "UnusedResponse"} { + assert.Contains(t, doc.Responses, name) + } + assert.Empty(t, pruned, "nothing is pruned without the flag") +} + +// TestCoverage_SharedParameters_Prune_On is the core case (C4, P7): ScanModels +// + PruneUnusedModels drops the shared parameter and response that no operation +// or path-item references (X-Unused, UnusedResponse) while keeping the +// referenced pair (X-Used via the standalone reference, UsedResponse via the +// route's Responses block). Each drop raises one located scan.pruned-unused Hint. +func TestCoverage_SharedParameters_Prune_On(t *testing.T) { + doc, pruned := runSharedPrune(t, true) + + // Referenced shared objects survive. + assert.Contains(t, doc.Parameters, "X-Used") + assert.Contains(t, doc.Responses, "UsedResponse") + assert.Len(t, doc.Parameters, 1, "only the referenced shared parameter survives") + assert.Len(t, doc.Responses, 1, "only the referenced shared response survives") + + // Unreferenced shared objects are pruned. + assert.NotContains(t, doc.Parameters, "X-Unused") + assert.NotContains(t, doc.Responses, "UnusedResponse") + + // The surviving reference still resolves: listP keeps its #/parameters/X-Used $ref and its default + // #/responses/UsedResponse. + op := doc.Paths.Paths["/p"].Get + require.NotNil(t, op) + assert.Contains(t, paramRefs(op), "#/parameters/X-Used") + require.NotNil(t, op.Responses.Default) + assert.EqualT(t, "#/responses/UsedResponse", op.Responses.Default.Ref.String()) + + // One located Hint per pruned shared object (severity Hint, sourced line). + require.Len(t, pruned, 2, "one Hint per pruned shared object") + for _, d := range pruned { + assert.Equal(t, grammar.SeverityHint, d.Severity, "a prune is informational") + assert.Positive(t, d.Pos.Line, "the Hint is located at the pruned declaration") + } + + scantest.CompareOrDumpJSON(t, doc, "enhancements_shared_parameters_prune.json") +} + +// runSharedPruneCascade scans the shared-parameters-prune-cascade fixture under ScanModels, +// toggling PruneUnusedModels, and collects the scan.pruned-unused Hints. +func runSharedPruneCascade(t *testing.T, prune bool) (*spec.Swagger, []grammar.Diagnostic) { + t.Helper() + var pruned []grammar.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/shared-parameters-prune-cascade/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + PruneUnusedModels: prune, + OnDiagnostic: func(d grammar.Diagnostic) { + if d.Code == grammar.CodePrunedUnused { + pruned = append(pruned, d) + } + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + return doc, pruned +} + +// TestCoverage_SharedParameters_PruneCascade_Off is the control: with ScanModels and no prune, +// every swagger:model is emitted — including Orphan, reachable only through the unreferenced +// UnusedResponse — and both shared responses are present. +func TestCoverage_SharedParameters_PruneCascade_Off(t *testing.T) { + doc, pruned := runSharedPruneCascade(t, false) + + for _, name := range []string{"Survivor", "Orphan", "Shared"} { + assert.Contains(t, doc.Definitions, name) + } + for _, name := range []string{"UsedResponse", "UnusedResponse"} { + assert.Contains(t, doc.Responses, name) + } + assert.Empty(t, pruned, "nothing is pruned without the flag") +} + +// TestCoverage_SharedParameters_PruneCascade_On witnesses the second step of the prune extension +// (C4 / P7, §6b): the shared-object prune runs BEFORE the definition reachability walk, so pruning +// the unreferenced UnusedResponse also prunes Orphan — the definition reachable ONLY through it +// (the cascade). +// +// Shared is kept, because the surviving Survivor still references it: the prune is +// reachability-correct, not a naive "drop everything the pruned response touched". +func TestCoverage_SharedParameters_PruneCascade_On(t *testing.T) { + doc, pruned := runSharedPruneCascade(t, true) + + // The unreferenced shared response and the definition reached only through it are both gone. + assert.NotContains(t, doc.Responses, "UnusedResponse") + assert.NotContains(t, doc.Definitions, "Orphan", "Orphan cascades away with its only keeper") + + // The referenced response, its body model, and the model shared with a surviving root all stay. + assert.Contains(t, doc.Responses, "UsedResponse") + assert.Contains(t, doc.Definitions, "Survivor") + assert.Contains(t, doc.Definitions, "Shared", + "Shared survives: still reachable via the kept Survivor") + assert.Len(t, doc.Responses, 1, "only the referenced shared response survives") + assert.Len(t, doc.Definitions, 2, "Survivor + Shared; Orphan pruned") + + // Two located Hints: the shared response (pruned first) and the cascaded definition (pruned by the + // definition pass that follows). + require.Len(t, pruned, 2, "one Hint for the shared response, one for the cascaded definition") + var sawResponse, sawDefinition bool + for _, d := range pruned { + assert.Equal(t, grammar.SeverityHint, d.Severity, "a prune is informational") + assert.Positive(t, d.Pos.Line, "the Hint is located at the pruned declaration") + switch { + case strings.Contains(d.Message, `shared response "UnusedResponse"`): + sawResponse = true + case strings.Contains(d.Message, "Orphan"): + sawDefinition = true + } + } + assert.TrueT(t, sawResponse, "expected a Hint for the pruned shared response") + assert.TrueT(t, sawDefinition, "expected a Hint for the cascaded Orphan definition") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_shared_parameters_prune_cascade.json") +} diff --git a/internal/integration/coverage_simple_schema_readonly_test.go b/internal/integration/coverage_simple_schema_readonly_test.go index 28b91ad4..d93661b2 100644 --- a/internal/integration/coverage_simple_schema_readonly_test.go +++ b/internal/integration/coverage_simple_schema_readonly_test.go @@ -14,20 +14,19 @@ import ( "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. +// 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. +// 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{ diff --git a/internal/integration/coverage_simple_schema_test.go b/internal/integration/coverage_simple_schema_test.go index 53c6de0c..70c211bf 100644 --- a/internal/integration/coverage_simple_schema_test.go +++ b/internal/integration/coverage_simple_schema_test.go @@ -13,9 +13,8 @@ import ( "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 +// 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: diff --git a/internal/integration/coverage_single_line_description_test.go b/internal/integration/coverage_single_line_description_test.go index aab2627c..8aa25024 100644 --- a/internal/integration/coverage_single_line_description_test.go +++ b/internal/integration/coverage_single_line_description_test.go @@ -12,10 +12,10 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_SingleLineCommentAsDescription locks the answer to go-swagger -// issue #2626: the opt-in SingleLineCommentAsDescription option routes a -// one-line doc comment to the model `description` (never `title`), while a -// multi-line comment keeps the existing title/description split. +// TestCoverage_SingleLineCommentAsDescription locks the answer to go-swagger issue #2626: the +// opt-in SingleLineCommentAsDescription option routes a one-line doc comment to the model +// `description` (never `title`), while a multi-line comment keeps the existing title/description +// split. func TestCoverage_SingleLineCommentAsDescription(t *testing.T) { pkgs := []string{"./enhancements/single-line-description/..."} @@ -38,8 +38,8 @@ func TestCoverage_SingleLineCommentAsDescription(t *testing.T) { assert.Empty(t, widgetOn.Title, "single-line comment no longer a title (go-swagger#2626)") assert.Equal(t, "Widget is a one-line model comment ending in a period.", widgetOn.Description) - // Multi-line comments are unaffected by the option: the title/description - // split is preserved in both modes. + // Multi-line comments are unaffected by the option: the title/description split is preserved in + // both modes. gadgetDef := def.Definitions["Gadget"] gadgetOn := on.Definitions["Gadget"] assert.Equal(t, "Gadget is the title line.", gadgetDef.Title) diff --git a/internal/integration/coverage_skip_jsonify_interface_test.go b/internal/integration/coverage_skip_jsonify_interface_test.go new file mode 100644 index 00000000..5c5d7806 --- /dev/null +++ b/internal/integration/coverage_skip_jsonify_interface_test.go @@ -0,0 +1,67 @@ +// 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/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func runSkipJSONifyInterface(t *testing.T, skip bool) *spec.Swagger { + t.Helper() + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/interface-no-mangle/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + SkipJSONifyInterfaceMethods: skip, + }) + require.NoError(t, err) + require.NotNil(t, doc) + return doc +} + +// TestCoverage_SkipJSONifyInterface_Off is the control: with the flag off the default-path methods +// auto-jsonify (lowercase-first camelCase), matching the historic behaviour. +func TestCoverage_SkipJSONifyInterface_Off(t *testing.T) { + doc := runSkipJSONifyInterface(t, false) + + require.Contains(t, doc.Definitions, "Account") + props := doc.Definitions["Account"].Properties + + // Default path — mangler runs. + assert.Contains(t, props, "id", "default path must auto-jsonify when the flag is off") + assert.Contains(t, props, "createdAt", "default path must auto-jsonify when the flag is off") + assert.NotContains(t, props, "ID", "the verbatim Go name must not appear when the flag is off") + assert.NotContains(t, props, "CreatedAt", "the verbatim Go name must not appear when the flag is off") + + // swagger:name override is verbatim regardless. + assert.Contains(t, props, "explicit_name", "swagger:name override must stay verbatim") +} + +// TestCoverage_SkipJSONifyInterface_On exercises Options.SkipJSONifyInterfaceMethods: with the +// opt-out on, default-path interface methods are emitted verbatim (the auto-jsonify mangler is +// skipped) while the swagger:name override still reaches the spec exactly as written. +func TestCoverage_SkipJSONifyInterface_On(t *testing.T) { + doc := runSkipJSONifyInterface(t, true) + + require.Contains(t, doc.Definitions, "Account") + props := doc.Definitions["Account"].Properties + + // Default path — mangler skipped, Go method name verbatim. + assert.Contains(t, props, "ID", "opt-out must skip the mangler on the default path") + assert.Contains(t, props, "CreatedAt", "opt-out must skip the mangler on the default path") + assert.NotContains(t, props, "id", "the mangled spelling must not appear when opted out") + assert.NotContains(t, props, "createdAt", "the mangled spelling must not appear when opted out") + + // swagger:name override is honored verbatim regardless of the flag. + assert.Contains(t, props, "explicit_name", "swagger:name override must stay verbatim") + assert.NotContains(t, props, "explicitName", "the override must not be re-mangled") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_interface_no_mangle_on.json") +} diff --git a/internal/integration/go_swagger_spec_test.go b/internal/integration/go_swagger_spec_test.go index 216785e9..5097217f 100644 --- a/internal/integration/go_swagger_spec_test.go +++ b/internal/integration/go_swagger_spec_test.go @@ -13,10 +13,11 @@ import ( ) // These tests are the migration canary ported from go-swagger's -// cmd/swagger/commands/generate/spec_test.go. They exercise the same api.go -// fixture with the three Options variants go-swagger tests, and compare -// against go-swagger's pre-existing golden JSON files. If these pass, codescan -// still produces the specs go-swagger has depended on for years. +// cmd/swagger/commands/generate/spec_test.go. +// They exercise the same api.go fixture with the three Options variants go-swagger tests, and +// compare against go-swagger's pre-existing golden JSON files. +// +// If these pass, codescan still produces the specs go-swagger has depended on for years. func TestGoSwagger_GenerateJSONSpec(t *testing.T) { opts := codescan.Options{ diff --git a/internal/integration/golden_no_fqleak_test.go b/internal/integration/golden_no_fqleak_test.go index 14cd672b..0db54a3e 100644 --- a/internal/integration/golden_no_fqleak_test.go +++ b/internal/integration/golden_no_fqleak_test.go @@ -15,18 +15,20 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// fqDefKey matches a fully-qualified definition KEY (an object-valued member -// whose name is a Go import path), e.g. `"github.com/acme/foo/Bar": {`. The -// x-go-package extension carries an import path too, but as a STRING value -// (`"x-go-package": "github.com/..."`), which this pattern does not match. +// fqDefKey matches a fully-qualified definition KEY (an object-valued member whose name is a Go +// import path), e.g. `"github.com/acme/foo/Bar": {`. +// +// The x-go-package extension carries an import path too, but as a STRING value (`"x-go-package": +// "github.com/..."`), which this pattern does not match. var fqDefKey = regexp.MustCompile(`"[a-z0-9.-]+\.[a-z]{2,}/[^"]*":\s*\{`) -// TestGoldens_NoFullyQualifiedLeak locks the invariant that every committed -// golden is the REDUCED, full-pipeline output: no definition key or $ref may -// carry a fully-qualified `/` identity. The build keys -// definitions by that fq identity, and the reduce stage shortens them; a leak -// here means either a builder-unit snapshot crept back in (golden dumps belong -// to full-pipeline integration tests) or the reduce rewrite pass missed a site. +// TestGoldens_NoFullyQualifiedLeak locks the invariant that every committed golden is the REDUCED, +// full-pipeline output: no definition key or $ref may carry a fully-qualified `/` +// identity. +// +// The build keys definitions by that fq identity, and the reduce stage shortens them; a leak here +// means either a builder-unit snapshot crept back in (golden dumps belong to full-pipeline +// integration tests) or the reduce rewrite pass missed a site. func TestGoldens_NoFullyQualifiedLeak(t *testing.T) { dir := filepath.Join(scantest.FixturesDir(), "integration", "golden") entries, err := os.ReadDir(dir) diff --git a/internal/integration/petstore_test.go b/internal/integration/petstore_test.go index f2eb4cad..32aeadee 100644 --- a/internal/integration/petstore_test.go +++ b/internal/integration/petstore_test.go @@ -32,12 +32,13 @@ func TestAppScanner_NewSpec(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "petstore_spec.json") } -// TestAppScanner_Definitions is the regression lock for go-swagger issue -// #624 ("Model responses don't get included in definitions", a regression -// from #596). The model-discovery rule: a struct lands in definitions when -// it is swagger:model-annotated OR referenced (transitively) by a route / -// response body; an un-annotated, unreferenced struct is excluded. The -// bookings fixture is the reporter's own repro. +// TestAppScanner_Definitions is the regression lock for go-swagger issue #624 ("Model responses +// don't get included in definitions", a regression from #596). +// +// The model-discovery rule: a struct lands in definitions when it is swagger:model-annotated OR +// referenced (transitively) by a route / response body; an un-annotated, unreferenced struct is +// excluded. +// The bookings fixture is the reporter's own repro. func TestAppScanner_Definitions(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./goparsing/bookings/..."}, diff --git a/internal/integration/schema_aliased_test.go b/internal/integration/schema_aliased_test.go index a7e5e4db..ee3d79a2 100644 --- a/internal/integration/schema_aliased_test.go +++ b/internal/integration/schema_aliased_test.go @@ -39,10 +39,9 @@ func TestAliasedSchemas(t *testing.T) { } t.Run("unannotated alias to any should not produce a standalone definition", func(t *testing.T) { - // Aliases without `swagger:model` are a Go implementation - // detail; they dissolve at use sites and do not surface as - // `definitions` entries. The use-site shape is what carries - // the open schema (see `with alias to any` subtest below). + // Aliases without `swagger:model` are a Go implementation detail; they dissolve at use sites and + // do not surface as `definitions` entries. + // The use-site shape is what carries the open schema (see `with alias to any` subtest below). _, ok := sp.Definitions["Anything"] require.FalseT(t, ok, "unannotated alias to any must not appear in definitions") @@ -68,28 +67,26 @@ func TestAliasedSchemas(t *testing.T) { }) t.Run("unannotated aliased primitive types do not produce a standalone definition", func(t *testing.T) { - // Same as Anything above. UUID is `type UUID = int64` with - // no `swagger:model`; it dissolves at use sites. The inline - // integer shape lives on each field that referred to it - // (see `with alias to primitive type` subtest below). + // Same as Anything above. + // UUID is `type UUID = int64` with no `swagger:model`; it dissolves at use sites. + // The inline integer shape lives on each field that referred to it (see `with alias to primitive + // type` subtest below). _, ok := sp.Definitions["UUID"] require.FalseT(t, ok, "unannotated alias to a primitive must not appear in definitions") }) t.Run("annotated alias companions recover the alias-name $ref shape", func(t *testing.T) { - // Bidirectional witness: UUIDModeled / AnythingModeled are - // the swagger:model-annotated counterparts of UUID / - // Anything. With the annotation, each alias surfaces as a - // first-class spec entity, and any field typed with the - // annotated alias keeps `$ref: ` rather than - // dissolving to the underlying primitive / open shape. + // Bidirectional witness: UUIDModeled / AnythingModeled are the swagger:model-annotated + // counterparts of UUID / Anything. // - // Together with the UUID / Anything subtests above (and - // with the `field defined on an alias inlines its target - // shape` block below), these pin the rule: `swagger:model` - // is the SOLE gate for whether the alias name surfaces in - // the spec. + // With the annotation, each alias surfaces as a first-class spec entity, and any field typed with + // the annotated alias keeps `$ref: ` rather than dissolving to the underlying + // primitive / open shape. + // + // Together with the UUID / Anything subtests above (and with the `field defined on an alias + // inlines its target shape` block below), these pin the rule: `swagger:model` is the SOLE gate + // for whether the alias name surfaces in the spec. require.Contains(t, sp.Definitions, "UUIDModeled", "annotated alias to int64 must have its own definition") uuidModeled := sp.Definitions["UUIDModeled"] @@ -107,8 +104,8 @@ func TestAliasedSchemas(t *testing.T) { orderModeled := sp.Definitions["order_modeled"] scantest.AssertRef(t, &orderModeled, "id", "", "#/definitions/UUIDModeled") scantest.AssertRef(t, &orderModeled, "deliveryOption", "", "#/definitions/AnythingModeled") - // extended_id stays $ref'd to ExtendedID; ExtendedID is a - // named struct (not an alias) — alias rule doesn't apply. + // extended_id stays $ref'd to ExtendedID; ExtendedID is a named struct (not an alias) — alias + // rule doesn't apply. scantest.AssertRef(t, &orderModeled, "extended_id", "", "#/definitions/ExtendedID") }) @@ -117,11 +114,10 @@ func TestAliasedSchemas(t *testing.T) { require.TrueT(t, ok) t.Run("field defined on an alias inlines its target shape", func(t *testing.T) { - // Unannotated aliases dissolve at use sites — the - // underlying shape lives inline on each property. - // ExtendedID is a NAMED struct (not an alias) so it - // continues to surface as $ref; the rule only affects - // alias-as-use-type. + // Unannotated aliases dissolve at use sites — the underlying shape lives inline on each + // property. + // ExtendedID is a NAMED struct (not an alias) so it continues to surface as $ref; the rule only + // affects alias-as-use-type. t.Run("with alias to any", func(t *testing.T) { delivery, ok := order.Properties["DeliveryOption"] require.TrueT(t, ok) @@ -361,10 +357,10 @@ func testAliasedExtendedIDAllOf(t *testing.T, sp *oaispec.Swagger) { // Embedding an alias no longer auto-promotes to allOf. // The documented rule is "allOf only when swagger:allOf-tagged." - // `ExtendedID` embeds `Empty = struct{}` (alias of an anonymous - // empty struct) without that annotation, so the embed contributes - // nothing — Empty has no fields to promote. The outer struct - // remains flat with just its own properties. + // `ExtendedID` embeds `Empty = struct{}` (alias of an anonymous empty struct) without that + // annotation, so the embed contributes nothing — Empty has no fields to promote. + // + // The outer struct remains flat with just its own properties. t.Run("struct with an embedded alias-of-anonymous-empty-struct contributes nothing", func(t *testing.T) { assert.Empty(t, extended.AllOf, "no allOf without explicit swagger:allOf annotation") assertHasTitle(t, extended) @@ -538,11 +534,12 @@ func testAliasedEmbeddedTypes(t *testing.T, sp *oaispec.Swagger) { t.Helper() // Embedding an alias no longer auto-promotes to allOf. - // `EmbeddedWithAlias` embeds `Anything = any` and `UUID = int64` - // without `swagger:allOf` annotation. Both are aliases of non- - // Named types (an interface and a primitive); embedding them in - // Go promotes nothing. The spec output reflects that: flat - // properties from non-aliased fields only, no allOf, no + // `EmbeddedWithAlias` embeds `Anything = any` and `UUID = int64` without `swagger:allOf` + // annotation. + // + // Both are aliases of non- Named types (an interface and a primitive); embedding them in Go + // promotes nothing. + // The spec output reflects that: flat properties from non-aliased fields only, no allOf, no // dangling $refs. t.Run("embedded aliases of non-Named types contribute nothing without swagger:allOf", func(t *testing.T) { got, ok := sp.Definitions["embedded_with_alias"] diff --git a/internal/integration/schema_special_test.go b/internal/integration/schema_special_test.go index 92b57b4f..301a0a25 100644 --- a/internal/integration/schema_special_test.go +++ b/internal/integration/schema_special_test.go @@ -88,9 +88,9 @@ func TestSpecialSchemas(t *testing.T) { }) t.Run("tagged int-keyed map renders additionalProperties", func(t *testing.T) { - // map[int]struct{}: encoding/json stringifies integer keys, so this is - // representable as {type: object, additionalProperties: {…}} - // (go-swagger#2251, §18). It was previously dropped to an empty schema. + // map[int]struct{}: encoding/json stringifies integer keys, so this is representable as {type: + // object, additionalProperties: {…}} (go-swagger#2251, §18). + // It was previously dropped to an empty schema. idx, ok := sp.Definitions["index_map"] require.TrueT(t, ok) require.TrueT(t, idx.Type.Contains("object")) @@ -118,8 +118,8 @@ func TestSpecialSchemas(t *testing.T) { // NOTE: codescan does not really support generic types. // This test just makes sure generic definitions don't crash the scanner. // - // The general approach of the scanner is to make an empty schema out of anything - // it doesn't understand. + // The general approach of the scanner is to make an empty schema out of anything it doesn't + // understand. // generic_constraint t.Run("generic type constraint should render like an interface", func(t *testing.T) { @@ -189,11 +189,12 @@ func TestSpecialSchemas(t *testing.T) { }) }) - // Strip stdlib-dependent definitions whose shape drifts across Go - // versions (e.g. reflect.Type grew iterator methods in Go 1.26). The - // sub-tests above already pin the scanner behaviour (the fields resolve - // to a $ref with x-go-package: reflect); the snapshot only needs to - // cover the stable, fixture-local parts of the spec. + // Strip stdlib-dependent definitions whose shape drifts across Go versions (e.g. reflect.Type grew + // iterator methods in Go 1.26). + // + // The sub-tests above already pin the scanner behaviour (the fields resolve to a $ref with + // x-go-package: reflect); the snapshot only needs to cover the stable, fixture-local parts of the + // spec. for name, def := range sp.Definitions { if pkg, ok := def.Extensions.GetString("x-go-package"); ok && pkg == "reflect" { delete(sp.Definitions, name) @@ -405,8 +406,8 @@ func testSpecialTypesWhatNot(t *testing.T, sp *oaispec.Swagger, props map[string require.NotNil(t, itemsSchema) require.TrueT(t, itemsSchema.Type.Contains("integer")) - // [5]byte is not recognized an array of bytes, but of uint8 - // (internally this is the same for go) + // [5]byte is not recognized an array of bytes, but of uint8 (internally this is the same for + // go) require.EqualT(t, "uint8", itemsSchema.Format) }) case "J", "K": diff --git a/internal/parsers/grammar/README.md b/internal/parsers/grammar/README.md index ec9e1c39..d4fb69bc 100644 --- a/internal/parsers/grammar/README.md +++ b/internal/parsers/grammar/README.md @@ -17,6 +17,7 @@ live here. - [§prose-classification](#prose-classification) — TITLE / DESC split heuristics - [§raw-block-terminators](#raw-block-terminators) — sibling-terminator rule for raw bodies - [§yaml-fence-handling](#yaml-fence-handling) — opaque YAML bodies and decorative fences +- [§literal-description](#literal-description) — `swagger:description \|` verbatim markdown body - [§disambiguation](#disambiguation) — value-shape dispatch (default / enum / type-ref) - [§parser-contract](#parser-contract) — Block family dispatch, AnnotationKind - [§block-shapes](#block-shapes) — typed Block kinds and their fields @@ -43,7 +44,7 @@ typed `Block` carrying: - the recognised annotation (`swagger:model`, `swagger:route`, …) as an `AnnotationKind`; - per-Block fields for the annotation's positional arguments (e.g. - `RouteBlock.Method`, `ParametersBlock.OperationIDs`); + `RouteBlock.Method`, `ParametersBlock.Target` / `.Args`); - `Property` entries for every recognised body keyword (`maximum:`, `pattern:`, `consumes:`, …) carrying the keyword's lexer-typed value or raw body bytes; @@ -401,6 +402,66 @@ route with `CodeUnexpectedToken`. --- +## §literal-description — `swagger:description |` verbatim markdown body + +By default a multi-line `swagger:description` folds its body with the +**Option B** rule (`collectDescriptionBody`): contiguous prose lines up +to the first blank line, keyword, or annotation, each `TrimSpace`d. That +is fine for plain prose but loses markdown — blank lines terminate it and +indentation / table pipes are stripped. + +A YAML **literal block-scalar marker** opts the body into verbatim +capture. When the annotation line ends with a lone `|` +(`isLiteralDescMarker`), everything below is taken exactly — blank lines, +indentation, table pipes, and `---` all preserved — until the next +annotation or EOF: + +``` +// swagger:description | +// Widgets support **markdown**: +// +// | name | purpose | +// |------|---------| +// +// - a bullet +// swagger:model Widget +``` + +The capture is implemented in **two stages**, and stage 1 is load-bearing: + +- **Stage 1 (`classifyLines`, `inLiteralDesc`).** On emitting a + `swagger:description |` annotation, the line classifier enters literal + mode and emits every following line as a verbatim `tokenRawLine` — **no + `---` fence toggling, no blank-line or keyword termination**. This must + happen here: a lone `---` in the body would otherwise flip the global + `inFence` state ([§yaml-fence-handling](#yaml-fence-handling)) and + swallow a following annotation as opaque YAML. Literal mode ends when a + line classifies as an annotation (re-processed normally, so a following + `swagger:model` survives) or at EOF. +- **Stage 2 (`collectDescriptionLiteral`).** Folds the `tokenRawLine` run + into the annotation's single `TokenRawValue` arg: joins with `\n`, drops + the `|` marker, clips trailing blank lines (bare-`|` clip), and strips + the single godoc `// ` convention space per line. Interior indentation + and trailing whitespace (markdown hard breaks) are kept. + +Because the body becomes the annotation's `AnnotationArg()`, every +`swagger:description` consumer (schema model / field description) gets the +verbatim markdown with no per-builder change. + +**Terminator contract.** Only an annotation at the **start of a line** +ends the block; a `swagger:` token mid-line is prose. The comment-prefix +strip removes leading indentation *before* the annotation check, so an +indented line that begins with `swagger:` still terminates — a +`swagger:`-leading line cannot be hidden inside an indented markdown code +block. Plain `swagger:description` (no `|`) keeps Option B unchanged. + +This reframes go-swagger#3211: markdown is authored explicitly via +`swagger:description |`, never recovered from ambient godoc prose (the +preamble path still strips the leading table pipe — see +[§preprocess-contract](#preprocess-contract)). + +--- + ## §disambiguation — value-shape dispatch `disambiguate.go` centralises the value-shape rules so the lexer @@ -557,7 +618,7 @@ fields: |---|---|---| | `ModelBlock` | `swagger:model [Name]` | `Name string` | | `ResponseBlock` | `swagger:response [Name]` | `Name string` | -| `ParametersBlock` | `swagger:parameters T1 T2 …` | `OperationIDs []string` | +| `ParametersBlock` | `swagger:parameters [args…]` | `Target ParametersTarget`, `Path string`, `Args []string`, `Dups []string` (+ `OperationIDs()` accessor) | | `NameBlock` | `swagger:name ` | `Name string` | | `RouteBlock` | `swagger:route METHOD /path [tags] opID` | `Method, Path string; Tags []string; OpID string` | | `InlineOperationBlock` | `swagger:operation METHOD /path [tags] opID` | same as `RouteBlock` | @@ -884,7 +945,7 @@ Per-annotation argument shapes are classified by | `AnnDefaultName` | one `TokenJSONValue` or `TokenRawValue` per `classifyDefaultValue` | | `AnnType` | one `TokenTypeRef` for any well-formed token (or fallback `TokenIdentName` when malformed) per `looksLikeTypeRef` | | `AnnEnum` | per `classifyEnumArgs` — `TokenIdentName` (name) + `TokenJSONValue` / `TokenCommaListValue` (values), in source order | -| `AnnParameters` | `TokenIdentName`* (operation IDs) | +| `AnnParameters` | first token: `TokenWildcard` (`*`), `TokenURLPath` (`/path`), or `TokenIdentName` (operation id); trailing `TokenIdentName`* (operation ids or shared-parameter names) | | `AnnAllOf`, `AnnModel`, `AnnResponse`, `AnnStrfmt`, `AnnName` | one `TokenIdentName` (first identifier only — single-word capture) | | `AnnAlias`, `AnnIgnore`, `AnnFile`, `AnnMeta`, `AnnUnknown` | trailing fields as `TokenIdentName`* so the parser can diagnose | @@ -897,8 +958,13 @@ emit `CodeMalformedOperation`. ### Schema-family arg validation -- `AnnParameters` requires at least one IDENT_NAME (operation id) - — empty emits `CodeMissingRequiredArg`. +- `AnnParameters` requires a target token (an operation id, the + shared-namespace `*`, or a `/path`) — empty emits + `CodeMissingRequiredArg`. The first token classifies the + `ParametersBlock.Target` (operations / shared / path); `parseParametersArgs` + collects the remaining tokens into `Args` (de-duplicated; dropped + duplicates recorded in `Dups`). The definition-vs-reference reading of + `Args` is the builder's, since it depends on the host declaration. - `AnnName` requires a single IDENT_NAME — empty emits `CodeMissingRequiredArg`. diff --git a/internal/parsers/grammar/annotations.go b/internal/parsers/grammar/annotations.go index 51a50860..307a0d60 100644 --- a/internal/parsers/grammar/annotations.go +++ b/internal/parsers/grammar/annotations.go @@ -3,9 +3,10 @@ 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. +// 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. const AnnotationPrefix = "swagger:" // AnnotationKind identifies the top-level swagger: directive. @@ -30,16 +31,29 @@ const ( AnnType // swagger:type AnnFile // swagger:file // AnnAdditionalProperties — swagger:additionalProperties . - // A type/model-level classifier whose arg is true | false | a - // swagger:type-style spec. See the schema builder's - // classifierAdditionalProperties. + // + // A type/model-level classifier whose arg is true | false | a swagger:type-style spec. + // See the schema builder's classifierAdditionalProperties. AnnAdditionalProperties - // AnnPatternProperties — swagger:patternProperties "": , … - // A type/model-level classifier whose arg is a comma-separated list of - // quoted-regex → swagger:type-style-spec pairs. The whole remainder is - // captured as one raw arg token; the schema builder parses the pairs. See - // classifierPatternProperties. + // AnnPatternProperties — swagger:patternProperties "": , … A type/model-level + // classifier whose arg is a comma-separated list of quoted-regex → swagger:type-style-spec + // pairs. + // + // The whole remainder is captured as one raw arg token; the schema builder parses the pairs. + // See classifierPatternProperties. AnnPatternProperties + // AnnTitle — swagger:title . + // + // A schema-level override that replaces the godoc-derived title on a model / field. + // Single-line: the arg is the whole rest of the line. + // See .claude/plans/features/swagger-description-override-design.md. + AnnTitle + // AnnDescription — swagger:description [+ body]. + // + // A schema / response / header override that replaces the godoc-derived description. + // The arg is the rest of the head line; under Option B a blank-terminated body may extend it (P4). + // See the design doc above. + AnnDescription ) const ( @@ -60,6 +74,8 @@ const ( labelFile = "file" labelAdditionalProperties = "additionalProperties" labelPatternProperties = "patternProperties" + labelTitle = "title" + labelDescription = "description" labelUnknown = "unknown" ) @@ -100,6 +116,10 @@ func (a AnnotationKind) String() string { return labelAdditionalProperties case AnnPatternProperties: return labelPatternProperties + case AnnTitle: + return labelTitle + case AnnDescription: + return labelDescription case AnnUnknown: fallthrough default: @@ -108,6 +128,7 @@ func (a AnnotationKind) String() string { } // AnnotationKindFromName resolves the swagger: label to its kind. +// // Returns AnnUnknown for labels outside the recognised set. func AnnotationKindFromName(name string) AnnotationKind { switch name { @@ -145,13 +166,18 @@ func AnnotationKindFromName(name string) AnnotationKind { return AnnAdditionalProperties case labelPatternProperties: return AnnPatternProperties + case labelTitle: + return AnnTitle + case labelDescription: + return AnnDescription default: return AnnUnknown } } -// annotationFamily classifies an AnnotationKind into one of the four -// family sub-grammars. Used by the parser dispatcher. +// annotationFamily classifies an AnnotationKind into one of the four family sub-grammars. +// +// Used by the parser dispatcher. type annotationFamily int const ( @@ -165,12 +191,18 @@ const ( 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.). It dispatches through the schema parser so the - // body keywords surface as Properties rather than being rejected - // as context-invalid under a classifier block. See README §parser-contract. - AnnName: + // swagger:name is a field-level rename that accepts the same validation-keyword body as a schema + // field (min length, pattern, required, etc.). + // It dispatches through the schema parser so the body keywords surface as Properties rather than + // being rejected as context-invalid under a classifier block. + // + // See README §parser-contract. + AnnName, + // swagger:title / swagger:description are field/decl overrides that, like swagger:name, must + // coexist with the field's own validation keywords on the same comment group — so they dispatch + // through the schema parser too (familySchema), not the classifier parser which would reject + // those co-located keywords. + AnnTitle, AnnDescription: return familySchema case AnnRoute, AnnOperation: return familyOperation diff --git a/internal/parsers/grammar/ast.go b/internal/parsers/grammar/ast.go index bfb1002b..9d37b64d 100644 --- a/internal/parsers/grammar/ast.go +++ b/internal/parsers/grammar/ast.go @@ -42,42 +42,40 @@ type Block interface { Contact() (Contact, error) License() (License, bool) - // IsDeprecated reports whether the block marks its subject - // deprecated — via the explicit `deprecated: true` keyword or a - // godoc-style "Deprecated:" paragraph. See deprecated.go. + // IsDeprecated reports whether the block marks its subject deprecated — via the explicit + // `deprecated: true` keyword or a godoc-style "Deprecated:" paragraph. + // + // See deprecated.go. IsDeprecated() bool - // Walk dispatches properties / prose / extensions / diagnostics - // through the callbacks set on w. See walker.go for the contract. + // 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 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. Schema and meta builders consume only pre-annotation - // prose for title/description; routes/operations consult ProseLines() - // and observe post-annotation text too. + // PreambleLines returns prose lines that appear BEFORE the block's annotation. + // + // For UnboundBlock (no annotation), returns the same as ProseLines. + // 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 so - // post-annotation prose reads as body content rather than - // title/description. + // PreambleTitle / PreambleDescription return the title/description computed from PreambleLines + // (pre-annotation prose only). + // + // Schema's top-level model builder uses these so post-annotation prose reads 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. + // 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. // - // Used by field-level callers (struct field / interface method - // docs) where the entire prose is the description and there's - // no separate title concept. + // 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 @@ -87,39 +85,36 @@ type Block interface { 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(). + // 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. // - // 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. // See README §block-shapes. AnnotationArg() (string, bool) } // Property is one keyword:value (or keyword body) attached to a Block. // -// For inline-value keywords (Number / Integer / Bool / String / -// EnumOption / CommaList shapes), Value is the raw string and Typed -// carries the lexically-typed form. +// For inline-value keywords (Number / Integer / Bool / String / EnumOption / CommaList shapes), +// Value is the raw string and Typed carries the lexically-typed form. // -// For body keywords (ShapeRawBlock / ShapeRawValue), Body holds the -// accumulated body content, Raw holds the verbatim source content, -// and Typed.Type indicates the body shape. +// For body keywords (ShapeRawBlock / ShapeRawValue), Body holds the accumulated body content, Raw +// holds the verbatim source content, and Typed.Type indicates the body shape. // // ItemsDepth records the leading items.* depth from the keyword head. // // # Details // -// See README §property-shape for the field-population matrix and -// the AsList unification rule. +// See README §property-shape for the field-population matrix and the AsList unification rule. type Property struct { Keyword Keyword Pos token.Position @@ -130,12 +125,12 @@ type Property struct { ItemsDepth int } -// IsTyped reports whether the property carries a primitive-typed -// value the caller can consume directly without further coercion. +// 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: +// 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 @@ -153,8 +148,8 @@ type Property struct { // // coerce p.Value against the resolved schema type // } // -// Replaces the explicit `switch p.Typed.Type` boilerplate consumers -// would otherwise need at every call site. +// 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: @@ -168,10 +163,10 @@ func (p Property) IsTyped() bool { // 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. Accepts e.g. `maximum: <5`; the analyzer -// interprets Op + Number to decide inclusive vs exclusive semantics. +// Op is the leading comparison operator stripped from a NumberValue ("<", "<=", ">", ">=", "="). +// Empty for non-Number values or when no operator was present. +// Accepts e.g. `maximum: <5`; the analyzer interprets Op + Number to decide inclusive vs exclusive +// semantics. type TypedValue struct { Type ValueShape Op string @@ -181,8 +176,9 @@ type TypedValue struct { 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 +// 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 @@ -192,18 +188,18 @@ type RawYAML struct { // 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`. -// -// Source carries the keyword that produced the entry: KwExtensions -// for `extensions:` blocks (top-level vendor extensions) or -// KwInfoExtensions for `infoExtensions:` blocks (Info-scoped vendor -// extensions, meta-only). Consumers that need to route entries to -// different targets — meta's swspec.Extensions vs -// swspec.Info.Extensions — switch on this field; consumers that -// treat extensions uniformly (routes / operations) can ignore it. +// 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`. +// +// Source carries the keyword that produced the entry: KwExtensions for `extensions:` blocks +// (top-level vendor extensions) or KwInfoExtensions for `infoExtensions:` blocks (Info-scoped +// vendor extensions, meta-only). +// +// Consumers that need to route entries to different targets — meta's swspec.Extensions vs +// swspec.Info.Extensions — switch on this field; consumers that treat extensions uniformly +// (routes / operations) can ignore it. type Extension struct { Name string Source string @@ -211,8 +207,9 @@ type Extension struct { Value any } -// baseBlock provides the fields and accessors common to every typed -// Block. Typed kinds embed *baseBlock and add per-annotation fields. +// 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 @@ -238,10 +235,10 @@ 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. The trim and join happen on -// the cached slice; no extra parse work. +// Prose joins the cached proseLines (TITLE + DESC + internal blanks, in source order) with "\n" and +// drops a single whitespace-only trailing line. +// +// 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]) == "" { @@ -252,10 +249,10 @@ func (b *baseBlock) Prose() string { 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). +// 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 } @@ -289,20 +286,21 @@ func (b *baseBlock) Extensions() iter.Seq[Extension] { } } -// 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`. +// 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. +// 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 { @@ -312,9 +310,10 @@ func (b *baseBlock) Contact() (Contact, error) { 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. +// 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 { @@ -364,14 +363,14 @@ func (b *baseBlock) GetString(name string) (string, bool) { return p.Value, true } -// GetList returns the token list represented by the named keyword, -// unifying every list-shaped surface form the grammar accepts. -// Delegates to Property.AsList; see that method for the algorithm -// and accepted surface forms. +// GetList returns the token list represented by the named keyword, unifying every list-shaped +// surface form the grammar accepts. +// +// Delegates to Property.AsList; see that method for the algorithm and accepted surface forms. // -// Returns (nil, false) when the keyword is absent. Returns (nil, -// true) when the keyword is present but every line trims to empty — -// the bool reports presence, not non-emptiness. +// Returns (nil, false) when the keyword is absent. +// Returns (nil, true) when the keyword is present but every line trims to empty — the bool +// reports presence, not non-emptiness. func (b *baseBlock) GetList(name string) ([]string, bool) { p, ok := b.findProperty(name) if !ok { @@ -380,15 +379,13 @@ func (b *baseBlock) GetList(name string) ([]string, bool) { return p.AsList(), true } -// AsList returns the token list represented by p, unifying every -// list-shaped surface form the grammar accepts (inline comma list, -// multi-line indented, YAML `- ` markers, or any combination). +// AsList returns the token list represented by p, unifying every list-shaped surface form the +// grammar accepts (inline comma list, multi-line indented, YAML `- ` markers, or any combination). // // # Details // -// See README §property-shape (`AsList — unified list extraction`) -// for the surface forms, the algorithm, and the explicit -// non-targets (enum values, routebody Parameters chunks, raw YAML +// See README §property-shape (`AsList — unified list extraction`) for the surface forms, the +// algorithm, and the explicit non-targets (enum values, routebody Parameters chunks, raw YAML // bodies). func (p Property) AsList() []string { var lines []string @@ -438,8 +435,8 @@ type ModelBlock struct { Name string } -// AnnotationArg returns the IDENT_NAME arg (`Pet` for -// `swagger:model Pet`) or ("", false) for a bare `swagger:model`. +// 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 @@ -454,8 +451,7 @@ type ResponseBlock struct { Name string } -// AnnotationArg returns the IDENT_NAME arg or ("", false) for a -// bare `swagger:response`. +// AnnotationArg returns the IDENT_NAME arg or ("", false) for a bare `swagger:response`. func (b *ResponseBlock) AnnotationArg() (string, bool) { if b.Name == "" { return "", false @@ -463,20 +459,21 @@ func (b *ResponseBlock) AnnotationArg() (string, bool) { 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. +// 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. +// 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 @@ -484,16 +481,61 @@ func (b *NameBlock) AnnotationArg() (string, bool) { return b.Name, true } -// ParametersBlock is produced by `swagger:parameters T1 T2 …`. +// ParametersTarget classifies the first argument token of a `swagger:parameters` marker. +type ParametersTarget int + +const ( + // ParamTargetOperations: the first token is an operation id. + // + // Covers the historical inline form (`swagger:parameters listPets …`) and the standalone + // reference form (`swagger:parameters listPets X-Name …`); the two are disambiguated by the + // builder using the host declaration (struct ⇒ definition/inline, otherwise reference). + ParamTargetOperations ParametersTarget = iota + // ParamTargetShared: the first token is the `*` shared-namespace marker (register the fields at + // `#/parameters/{name}`). + ParamTargetShared + // ParamTargetPath: the first token is a `/path` (path-item parameters). + ParamTargetPath +) + +// ParametersBlock is produced by `swagger:parameters [args…]`. +// +// The target is the shared-namespace wildcard `*`, a `/path`, or an operation id (see +// [ParametersTarget]). +// Args carries the remaining argument tokens, de-duplicated; the definition-vs-reference reading of +// those tokens is the builder's, since it depends on whether the marker is attached to a struct. +// +// See .claude/plans/features/shared-parameters-fixtures.md §1. type ParametersBlock struct { *baseBlock - OperationIDs []string + // Target is the kind of the first argument token. + Target ParametersTarget + // Path is the `/path` value when Target == ParamTargetPath. + Path string + // Args are the de-duplicated meaningful argument tokens in source order: + // - ParamTargetOperations: the operation ids (first token included) + // - ParamTargetShared: the operation ids to reference into + // - ParamTargetPath: the shared-parameter names to reference + Args []string + // Dups are argument tokens dropped as exact duplicates of an earlier one (the basis for a + // duplicate-target / duplicate-ref warning). + Dups []string +} + +// OperationIDs returns the operation ids this block targets for inline application: Args when +// Target == ParamTargetOperations, else nil. +// +// The shared (`*`) and path (`/path`) targets are not operation ids. +func (b *ParametersBlock) OperationIDs() []string { + if b.Target != ParamTargetOperations { + return nil + } + return b.Args } -// RouteBlock is produced by `swagger:route METHOD /path [tags] opID`. -// swagger:route's body is inline keyword raw-blocks and **never** an -// OPAQUE_YAML. +// RouteBlock is produced by `swagger:route METHOD /path [tags] opID`. swagger:route's body is +// inline keyword raw-blocks and **never** an OPAQUE_YAML. type RouteBlock struct { *baseBlock @@ -503,9 +545,9 @@ type RouteBlock struct { 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. +// 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 @@ -520,8 +562,14 @@ type MetaBlock struct { *baseBlock } -// ClassifierBlock covers single-line classifier annotations: -// strfmt / allOf / ignore / alias / file / type / default. +// ClassifierBlock covers single-line classifier annotations: strfmt / allOf / ignore / alias / file +// / type / default. +// +// It also carries the schema-family override annotations swagger:title / swagger:description, whose +// free-text arg lives in Args[0] while any co-located validation keywords ride the embedded +// baseBlock.properties (those dispatch through the schema parser, not the classifier parser — see +// annotationFamily). +// // The Args slice carries the lexer-tokenised positional arguments. type ClassifierBlock struct { *baseBlock @@ -529,9 +577,9 @@ type ClassifierBlock struct { Args []Token } -// AnnotationArg returns the first positional argument's text when -// non-empty (single-word capture). Bare classifier annotations -// (e.g. `swagger:ignore`, `swagger:alias`) return ("", false). +// AnnotationArg returns the first positional argument's text when non-empty (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 @@ -542,9 +590,10 @@ func (b *ClassifierBlock) AnnotationArg() (string, bool) { return b.Args[0].Text, true } -// EnumDeclBlock is produced by `swagger:enum [name] [values…]`. The -// optional multi-line value-list is carried in BodyValues when -// present (lexer accumulates the body as RAW_VALUE_ENUM). +// EnumDeclBlock is produced by `swagger:enum [name] [values…]`. +// +// The optional multi-line value-list is carried in BodyValues when present (lexer accumulates the +// body as RAW_VALUE_ENUM). type EnumDeclBlock struct { *baseBlock @@ -554,10 +603,8 @@ type EnumDeclBlock struct { 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. +// 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 @@ -565,8 +612,8 @@ func (b *EnumDeclBlock) AnnotationArg() (string, bool) { return b.Name, true } -// UnboundBlock represents a comment group with no annotation line — -// e.g. a struct field's docstring. +// UnboundBlock represents a comment group with no annotation line — e.g. a struct field's +// docstring. type UnboundBlock struct { *baseBlock } diff --git a/internal/parsers/grammar/deprecated.go b/internal/parsers/grammar/deprecated.go index e49b06f8..2f75ee26 100644 --- a/internal/parsers/grammar/deprecated.go +++ b/internal/parsers/grammar/deprecated.go @@ -8,28 +8,28 @@ import ( "strings" ) -// deprecatedRx matches a godoc-style "Deprecated:" mark at the start of a -// paragraph — the pkgsite convention. Reused verbatim from -// golang.org/x/pkgsite internal/godoc/dochtml/deprecated.go so a single -// `Deprecated:` doc paragraph drives both godoc and the spec, with no need -// to repeat the mark as an explicit `deprecated: true` annotation. +// deprecatedRx matches a godoc-style "Deprecated:" mark at the start of a paragraph — the pkgsite +// convention. // -// The lexer leaves such a paragraph as prose (a `Deprecated:` line whose -// argument is not a bool is not lexed as the `deprecated` keyword — see -// lexKeyword), so the reason text survives in the description while -// IsDeprecated reports the deprecation. +// Reused verbatim from golang.org/x/pkgsite internal/godoc/dochtml/deprecated.go so a single +// `Deprecated:` doc paragraph drives both godoc and the spec, with no need to repeat the mark as an +// explicit `deprecated: true` annotation. +// +// The lexer leaves such a paragraph as prose (a `Deprecated:` line whose argument is not a bool is +// not lexed as the `deprecated` keyword — see lexKeyword), so the reason text survives in the +// description while IsDeprecated reports the deprecation. var deprecatedRx = regexp.MustCompile(`(^|\n\s*\n)\s*Deprecated:`) -// IsDeprecated reports whether the block marks its subject deprecated, -// via either trigger: +// IsDeprecated reports whether the block marks its subject deprecated, via either trigger: // // - the explicit `deprecated: true` keyword (this also drives the // native OAS2 operation `deprecated` field for route/operation // blocks); an explicit `deprecated: false` wins and returns false. // - a godoc-style "Deprecated:" paragraph in the prose. // -// Schema builders emit `x-deprecated: true` (see resolvers.MarkDeprecated) -// for a deprecated model or field. See go-swagger/go-swagger#3138. +// Schema builders emit `x-deprecated: true` (see resolvers.MarkDeprecated) for a deprecated model +// or field. +// See go-swagger/go-swagger#3138. func (b *baseBlock) IsDeprecated() bool { if v, ok := b.GetBool(KwDeprecated); ok { return v diff --git a/internal/parsers/grammar/deprecated_test.go b/internal/parsers/grammar/deprecated_test.go index 51629416..288361d1 100644 --- a/internal/parsers/grammar/deprecated_test.go +++ b/internal/parsers/grammar/deprecated_test.go @@ -50,8 +50,7 @@ func TestBlock_IsDeprecated(t *testing.T) { b := parseString(t, tc.src) assert.Equal(t, tc.want, b.IsDeprecated()) - // A godoc "Deprecated:" paragraph must never be mistaken for a - // malformed bool keyword. + // A godoc "Deprecated:" paragraph must never be mistaken for a malformed bool keyword. for _, d := range b.Diagnostics() { assert.NotEqual(t, CodeInvalidBoolean, d.Code, "unexpected invalid-boolean diagnostic: %s", d) diff --git a/internal/parsers/grammar/diagnostic.go b/internal/parsers/grammar/diagnostic.go index 3b5e6871..2a1a0a46 100644 --- a/internal/parsers/grammar/diagnostic.go +++ b/internal/parsers/grammar/diagnostic.go @@ -8,9 +8,10 @@ import ( "go/token" ) -// Severity classifies a Diagnostic's seriousness. The parser never -// aborts; callers (analyzers, LSP, the CLI) decide policy by -// severity. See README §diagnostics. +// Severity classifies a Diagnostic's seriousness. +// +// The parser never aborts; callers (analyzers, LSP, the CLI) decide policy by severity. +// See README §diagnostics. type Severity int const ( @@ -36,12 +37,12 @@ func (s Severity) String() string { // 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); `scan.*` marks -// scan-environment observations (package loading, recovered panics) -// raised by the scanner / spec builder rather than the grammar parser. +// 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); `scan.*` marks scan-environment observations (package +// loading, recovered panics) raised by the scanner / spec builder rather than the grammar parser. const ( CodeInvalidNumber Code = "parse.invalid-number" CodeInvalidInteger Code = "parse.invalid-integer" @@ -49,9 +50,9 @@ const ( 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 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" @@ -60,139 +61,201 @@ const ( 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 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 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 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" - // CodeUnsupportedType fires when a `swagger:type` argument cannot be - // resolved to an inline schema: an unknown type name, a `file` - // override (use swagger:file instead), or a keyword used where it is - // not valid (e.g. `inline`/`array` as an array element). The override - // is dropped and the subject falls through to its Go type. See the F3 - // reconciliation in .claude/plans/quirks-F-series-fix.md. + // CodeUnsupportedType fires when a `swagger:type` argument cannot be resolved to an inline schema: + // an unknown type name, a `file` override (use swagger:file instead), or a keyword used where it + // is not valid (e.g. `inline`/`array` as an array element). + // + // The override is dropped and the subject falls through to its Go type. + // See the F3 reconciliation in .claude/plans/quirks-F-series-fix.md. CodeUnsupportedType Code = "validate.unsupported-type" - // CodeDeprecated fires when an accepted-but-deprecated annotation or - // keyword value is used (the input is still processed). Carries a - // migration hint in the message — e.g. `swagger:type array` → - // `inline`, or the deprecated `swagger:alias` annotation. + // CodeDeprecated fires when an accepted-but-deprecated annotation or keyword value is used (the + // input is still processed). + // + // Carries a migration hint in the message — e.g. `swagger:type array` → `inline`, or the + // deprecated `swagger:alias` annotation. CodeDeprecated Code = "validate.deprecated" - // CodeUnsupportedGoType fires when a Go type, a `go/types` kind, or a - // builtin cannot be translated to a Swagger 2.0 construct and is - // therefore dropped from the spec. The scanner runs on arbitrary user - // code, so an unmodeled construct must not panic — it is skipped and - // surfaced as a Warning (real data loss, but the scan continues). The - // message names the construct (and the dispatch site) so a future - // go/types evolution surfaces one grep-able diagnostic instead of - // vanishing behind a silent default. + // CodeUnsupportedGoType fires when a Go type, a `go/types` kind, or a builtin cannot be translated + // to a Swagger 2.0 construct and is therefore dropped from the spec. + // + // The scanner runs on arbitrary user code, so an unmodeled construct must not panic — it is + // skipped and surfaced as a Warning (real data loss, but the scan continues). + // The message names the construct (and the dispatch site) so a future go/types evolution surfaces + // one grep-able diagnostic instead of vanishing behind a silent default. CodeUnsupportedGoType Code = "validate.unsupported-go-type" - // CodeDuplicateModelName fires when two distinct Go types in the - // SAME package claim the same definition name (necessarily via a - // `swagger:model ` override, since Go type names are unique - // per package). The first declaration keeps the name; later ones - // fall back to their Go type name. See the name-identity design D-4. + // CodeDuplicateModelName fires when two distinct Go types in the SAME package claim the same + // definition name (necessarily via a `swagger:model ` override, since Go type names are + // unique per package). + // + // The first declaration keeps the name; later ones fall back to their Go type name. + // See the name-identity design D-4. CodeDuplicateModelName Code = "validate.duplicate-model-name" - // CodeCollidingModelName fires when the same definition name is - // declared across SEVERAL packages. The reduce stage keeps each - // distinct by qualifying the colliding ones with a PascalCase - // package-prefix concat (e.g. b.Test / c.Test -> BTest / CTest); - // the author can force a specific name with `swagger:model `. + // CodeCollidingModelName fires when the same definition name is declared across SEVERAL packages. + // + // The reduce stage keeps each distinct by qualifying the colliding ones with a PascalCase + // package-prefix concat (e.g. b.Test / c.Test -> BTest / CTest); the author can force a specific + // name with `swagger:model `. // See the name-identity design (D-8). CodeCollidingModelName Code = "validate.colliding-model-name" - // CodeHierarchicalModelName fires when a colliding definition name's - // best flat concat exceeds the readability budget and the caller - // enabled EmitHierarchicalNames: the reduce stage emits nested - // container definitions (`#/definitions//`) instead of a - // long flat concat. The author can force a flat name with - // `swagger:model `. See the name-identity design (rung 3 / W2). + // CodeHierarchicalModelName fires when a colliding definition name's best flat concat exceeds the + // readability budget and the caller enabled EmitHierarchicalNames: the reduce stage emits nested + // container definitions (`#/definitions//`) instead of a long flat concat. + // + // The author can force a flat name with `swagger:model `. + // See the name-identity design (rung 3 / W2). CodeHierarchicalModelName Code = "validate.hierarchical-model-name" - // CodeAmbiguousTypeName fires when a type-name keyword argument - // (swagger:type, swagger:additionalProperties, swagger:patternProperties) - // names a bare leaf that, after failing to resolve in the builder's own - // package, matches a discovered model in SEVERAL packages. The reference - // is ambiguous so it is dropped; the author can disambiguate with a - // same-package type or a swagger:model override. See the name-identity - // leaf-resolution design. + // CodeAmbiguousTypeName fires when a type-name keyword argument (swagger:type, + // swagger:additionalProperties, swagger:patternProperties) names a bare leaf that, after failing + // to resolve in the builder's own package, matches a discovered model in SEVERAL packages. + // + // The reference is ambiguous so it is dropped; the author can disambiguate with a same-package + // type or a swagger:model override. + // See the name-identity leaf-resolution design. CodeAmbiguousTypeName Code = "validate.ambiguous-type-name" - // CodeDegradedLoad fires when `packages.Load` returns a degraded - // result. It is tiered by what is still recoverable: an Error - // (aborting) when nothing usable loaded — no packages matched, a - // package could not be loaded at all, or its type information - // (`Types` / `TypesInfo`) is unavailable (the #2874 case where - // swagger:allOf silently stops resolving); a Warning (non-fatal) - // when a package carries only parse/type errors but its type - // information is still usable, so a single non-building package does - // not sink a whole `./...` scan. See go-swagger/go-swagger#2874. + // CodeDegradedLoad fires when `packages.Load` returns a degraded result. + // + // It is tiered by what is still recoverable: an Error (aborting) when nothing usable loaded — no + // packages matched, a package could not be loaded at all, or its type information (`Types` / + // `TypesInfo`) is unavailable (the #2874 case where swagger:allOf silently stops resolving); a + // Warning (non-fatal) when a package carries only parse/type errors but its type information is + // still usable, so a single non-building package does not sink a whole `./...` scan. + // + // See go-swagger/go-swagger#2874. CodeDegradedLoad Code = "scan.degraded-load" - // CodeInternalPanic fires when a builder panics while processing a - // single declaration. The scan recovers, names the offending source - // declaration (file:line), and aborts with a located error rather - // than surfacing a raw Go stack trace. See - // go-swagger/go-swagger#2886. + // CodeInternalPanic fires when a builder panics while processing a single declaration. + // + // The scan recovers, names the offending source declaration (file:line), and aborts with a located + // error rather than surfacing a raw Go stack trace. + // See go-swagger/go-swagger#2886. CodeInternalPanic Code = "scan.internal-panic" - // CodeIgnoredByRules fires when a package is skipped because it does not - // pass the caller's Include/Exclude package rules. Informational (Hint): - // the omission is the caller's own configuration, surfaced to aid - // "why is my package missing" triage. + // CodeIgnoredByRules fires when a package is skipped because it does not pass the caller's + // Include/Exclude package rules. + // + // Informational (Hint): the omission is the caller's own configuration, surfaced to aid "why is my + // package missing" triage. CodeIgnoredByRules Code = "scan.ignored-by-rules" - // CodeIgnoredByTag fires when a route or operation is skipped because its - // tags do not pass the caller's IncludeTags/ExcludeTags rules. + // CodeIgnoredByTag fires when a route or operation is skipped because its tags do not pass the + // caller's IncludeTags/ExcludeTags rules. + // // Informational (Hint), like CodeIgnoredByRules. CodeIgnoredByTag Code = "scan.ignored-by-tag" - // CodeDroppedRefSibling fires when SkipAllOfCompounding is set and a - // $ref'd struct field carries sibling decoration (description, - // validations, vendor extensions, externalDocs) that cannot ride a - // bare $ref. With compounding disabled the field emits as a bare - // $ref and each such sibling is dropped — one diagnostic per dropped - // keyword so the loss is never silent. See scanner.Options - // SkipAllOfCompounding. + // CodeDroppedRefSibling fires when SkipAllOfCompounding is set and a $ref'd struct field carries + // sibling decoration (description, validations, vendor extensions, externalDocs) that cannot ride + // a bare $ref. + // + // With compounding disabled the field emits as a bare $ref and each such sibling is dropped — + // one diagnostic per dropped keyword so the loss is never silent. + // See scanner.Options SkipAllOfCompounding. CodeDroppedRefSibling Code = "validate.dropped-ref-sibling" - // CodePrunedUnused fires when PruneUnusedModels is set and a discovered - // definition is dropped because it is not transitively referenced from any - // path, shared response, shared parameter or overlay definition. Carries the - // originating Go type's source position so the loss is never silent. - // Informational (Hint): the prune is the caller's own opt-in, surfaced to aid - // "why is my model missing" triage. See scanner.Options PruneUnusedModels. + // CodePrunedUnused fires when PruneUnusedModels is set and a discovered definition is dropped + // because it is not transitively referenced from any path, shared response, shared parameter or + // overlay definition. + // + // Carries the originating Go type's source position so the loss is never silent. + // Informational (Hint): the prune is the caller's own opt-in, surfaced to aid "why is my model + // missing" triage. + // See scanner.Options PruneUnusedModels. CodePrunedUnused Code = "scan.pruned-unused" - // CodeRenamedDefinition fires when the reduce stage renames a definition to - // deconflict a cross-package name collision (e.g. b.Test / c.Test -> BTest / - // CTest), so a consumer that tracks source <-> spec links (the genspec TUI) - // learns the final name a Go type landed under. Informational (Hint); carries - // the Go type's source position. The bare-leaf zero-churn case (a globally - // unique name lifted to its leaf) is NOT reported — only true renames. + // CodeRenamedDefinition fires when the reduce stage renames a definition to deconflict a + // cross-package name collision (e.g. b.Test / c.Test -> BTest / CTest), so a consumer that tracks + // source <-> spec links (the genspec TUI) learns the final name a Go type landed under. + // + // Informational (Hint); carries the Go type's source position. + // The bare-leaf zero-churn case (a globally unique name lifted to its leaf) is NOT reported — + // only true renames. CodeRenamedDefinition Code = "scan.renamed-definition" + + // CodeSharedParameterConflict fires when two `swagger:parameters *` declarations register the same + // shared parameter short name (#/parameters/{name}). + // + // Shared parameters are referenced only by short name, so — unlike definitions — they are + // never renamed: the first registration is kept, later ones are dropped. + // Warning, so the shadowed declaration is never lost silently. + // See .claude/plans/features/shared-parameters-fixtures.md §2. + CodeSharedParameterConflict Code = "scan.shared-parameter-conflict" + + // CodeSharedResponseConflict fires when two `swagger:response` declarations register the same + // top-level response short name (#/responses/{name}). + // + // Like shared parameters, responses are referenced only by short name and are never renamed: the + // first registration is kept, later ones are dropped. + // Warning. + // + // An InputSpec (overlay) response of the same name is not a conflict — a scanned struct extends + // it. + CodeSharedResponseConflict Code = "scan.shared-response-conflict" + + // CodeDanglingParameterRef fires when a `swagger:parameters` reference names a shared parameter + // that no `swagger:parameters *` declaration registered (#/parameters/{name} does not exist). + // + // The reference is dropped rather than emitting a dangling $ref. + // Warning. + // See .claude/plans/features/shared-parameters-fixtures.md §1b. + CodeDanglingParameterRef Code = "scan.dangling-parameter-ref" + + // CodeDanglingResponseRef fires when an operation references a shared response + // (#/responses/{name}) that no `swagger:response` declaration registered — e.g. a `$ref` in a + // swagger:operation wholesale-YAML body pointing at an unknown response. + // + // The reference is dropped rather than emitting a dangling $ref. + // Warning. + CodeDanglingResponseRef Code = "scan.dangling-response-ref" + + // CodeDuplicateTarget fires when a `swagger:parameters * opid …` marker repeats an operation id; + // the duplicate is dropped. + // + // Warning (C1). + CodeDuplicateTarget Code = "scan.duplicate-target" + + // CodeDuplicateRef fires when a `swagger:parameters {target} name …` reference repeats a + // shared-parameter name; the duplicate is dropped. + // + // Warning (C2). + CodeDuplicateRef Code = "scan.duplicate-ref" + + // CodeEmptyOverride fires when a `swagger:description` / `swagger:title` override annotation + // resolves to an empty value (bare marker, or a whitespace/blank-only body). + // + // The empty value is still applied — empty is the deliberate godoc-suppression affordance — + // but the case is flagged in case it was accidental (a leftover marker). + // Warning. + // See .claude/plans/features/swagger-description-override-design.md (D7). + CodeEmptyOverride Code = "scan.empty-override" ) // Diagnostic is one observation about a comment block. diff --git a/internal/parsers/grammar/disambiguate.go b/internal/parsers/grammar/disambiguate.go index 45c68599..26b7b1ed 100644 --- a/internal/parsers/grammar/disambiguate.go +++ b/internal/parsers/grammar/disambiguate.go @@ -10,18 +10,18 @@ import ( "unicode/utf8" ) -// Value-shape dispatch lives in this module so the lexer emits -// already-disambiguated typed tokens and the parser's productions -// stay context-free. See README §disambiguation. - -// classifyDefaultValue chooses between JSON_VALUE and RAW_VALUE for -// the argument of swagger:default. Tries JSON_VALUE first (full -// JSON validation via the stdlib decoder), falling back to -// RAW_VALUE. The quick check uses the leading character; full JSON -// validation confirms. +// Value-shape dispatch lives in this module so the lexer emits already-disambiguated typed tokens +// and the parser's productions stay context-free. +// See README §disambiguation. + +// classifyDefaultValue chooses between JSON_VALUE and RAW_VALUE for the argument of +// swagger:default. +// +// Tries JSON_VALUE first (full JSON validation via the stdlib decoder), falling back to RAW_VALUE. +// 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. +// 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 == "" { @@ -46,8 +46,9 @@ func classifyDefaultValue(s string) TokenKind { return TokenRawValue } -// isJSONValue reports whether s is a valid JSON literal. Uses the -// stdlib JSON decoder for correctness. +// 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() @@ -62,6 +63,7 @@ func isJSONValue(s string) bool { } // enumArgsForm captures the four-way EnumArgs dispatch outcome. +// // See README §disambiguation. type enumArgsForm int @@ -74,10 +76,11 @@ const ( enumFormNamePlusPlain // EnumWithName + EnumPlainList ) -// classifyEnumArgs implements the four-way 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. +// classifyEnumArgs implements the four-way 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 == "" { @@ -100,10 +103,11 @@ func classifyEnumArgs(arg string) (form enumArgsForm, name, values string) { return enumFormNamePlusPlain, s[:identEnd], rest } -// scanEnumIdentifier returns the byte length of a leading -// IdentifierName: Letter followed by NameChar*. Stops at the first -// non-NameChar byte. Returns 0 if the leading character is not a -// letter. +// scanEnumIdentifier returns the byte length of a leading IdentifierName: 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) { @@ -123,8 +127,7 @@ func scanEnumIdentifier(s string) int { return i } -// isIdentifierContinue matches NameChar: -// Letter | Digit | "_" | "-" | ".". +// isIdentifierContinue matches NameChar: Letter | Digit | "_" | "-" | ".". func isIdentifierContinue(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == '.' @@ -168,22 +171,22 @@ var httpMethods = map[string]string{ "trace": "TRACE", } -// classifyHTTPMethod returns the canonical method name and true iff s -// is a recognised HTTP method (case-insensitive). +// 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 } -// looksLikeTypeRef reports whether s is a well-formed `swagger:type` -// argument token — an optionally `[]`-prefixed (array), optionally -// dot-qualified Go-style identifier. It is a LEXICAL check only: the -// grammar no longer owns the closed type vocabulary. Semantic validity -// (is it a known keyword / scanned type? is the format compatible?) is -// resolved by the builder, which alone knows the scanned definitions and -// the annotated Go type (the F3 reconciliation — see -// .claude/plans/quirks-F-series-fix.md). The lexer only rejects -// structurally malformed tokens (empty, embedded spaces, a bare `[]`, a +// looksLikeTypeRef reports whether s is a well-formed `swagger:type` argument token — an +// optionally `[]`-prefixed (array), optionally dot-qualified Go-style identifier. +// +// It is a LEXICAL check only: the grammar no longer owns the closed type vocabulary. +// Semantic validity (is it a known keyword / scanned type? is the format compatible?) is resolved +// by the builder, which alone knows the scanned definitions and the annotated Go type (the F3 +// reconciliation — see .claude/plans/quirks-F-series-fix.md). +// +// The lexer only rejects structurally malformed tokens (empty, embedded spaces, a bare `[]`, a // leading digit, illegal characters), which the parser flags. func looksLikeTypeRef(s string) bool { s = strings.TrimSpace(s) @@ -206,9 +209,9 @@ func looksLikeTypeRef(s string) bool { return true } -// 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. +// 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 index f4f31720..aa208eb3 100644 --- a/internal/parsers/grammar/doc.go +++ b/internal/parsers/grammar/doc.go @@ -1,9 +1,10 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -// Package grammar is the annotation parser for codescan. It consumes -// one Go comment group at a time, recognises the swagger: -// annotation header, and produces a typed Block carrying: +// Package grammar is the annotation parser for codescan. +// +// It consumes one Go comment group at a time, recognises the swagger: annotation header, and +// produces a typed Block carrying: // // - the recognised annotation as an AnnotationKind; // - per-Block fields for the annotation's positional arguments; @@ -28,8 +29,7 @@ // // # Details // -// See README.md in this package for the full contract: pipeline -// stages, lexer / parser rules, keyword table, walker dispatch -// table, body-termination rules, diagnostics codes, and known +// See README.md in this package for the full contract: pipeline stages, lexer / parser rules, +// keyword table, walker dispatch table, body-termination rules, diagnostics codes, and known // follow-ups. package grammar diff --git a/internal/parsers/grammar/fixtures_test.go b/internal/parsers/grammar/fixtures_test.go index d2c3d1d8..e70aee55 100644 --- a/internal/parsers/grammar/fixtures_test.go +++ b/internal/parsers/grammar/fixtures_test.go @@ -14,9 +14,9 @@ import ( "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. +// 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. @@ -32,10 +32,9 @@ swagger:model pet 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. +// 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. @@ -71,9 +70,9 @@ maximum length: 50 } } -// 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. +// 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. @@ -95,8 +94,8 @@ items pattern: \.(jpe?g|png)$ assert.True(t, found) } -// TestFixtures_Petstore_ItemsPrefix_DeepNesting covers multiple levels -// of items.* prefix accumulation. +// 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 @@ -109,9 +108,8 @@ items.items.items.maxLength: 4 } } -// TestFixtures_Petstore_ParametersBlock mirrors PetID with -// `swagger:parameters getPetById deletePet updatePet` — three -// operationID references. +// 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. @@ -123,12 +121,11 @@ 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) + 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. +// 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 @@ -166,8 +163,8 @@ Responses: } // TestFixtures_OperationsAnnotation_YAMLBody mirrors -// fixtures/goparsing/classification/operations_annotation/operations.go's -// first operation: a YAML-fenced body holding parameters/responses. +// 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 @@ -202,9 +199,8 @@ responses: 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 + // "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()) @@ -219,10 +215,9 @@ 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). +// 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. @@ -323,12 +318,11 @@ swagger:meta` assert.Contains(t, strings.Join(secDef, "\n"), "type: apiKey") } -// TestFixtures_Meta_TagsBlock pins the meta `Tags:` raw block -// (go-swagger#2655): the body is a YAML sequence of tag objects whose -// nested `externalDocs:` mapping — itself a meta-family keyword — -// must be absorbed as body text via the YAML-bodied indentation -// override, not terminate the block. A following sibling keyword at -// the same indent as `Tags:` still terminates it. +// TestFixtures_Meta_TagsBlock pins the meta `Tags:` raw block (go-swagger#2655): the body is a YAML +// sequence of tag objects whose nested `externalDocs:` mapping — itself a meta-family keyword — +// must be absorbed as body text via the YAML-bodied indentation override, not terminate the block. +// +// A following sibling keyword at the same indent as `Tags:` still terminates it. func TestFixtures_Meta_TagsBlock(t *testing.T) { src := "\tTags:\n" + "\t- name: pet\n" + @@ -349,9 +343,8 @@ func TestFixtures_Meta_TagsBlock(t *testing.T) { require.True(t, ok, "Version sibling must terminate the Tags block") assert.Equal(t, "1.0.0", version) - // The raw Tags body preserves per-line indentation: the nested - // externalDocs mapping is absorbed (indentation override) and the - // list markers/depth survive, so the downstream YAML list parses. + // The raw Tags body preserves per-line indentation: the nested externalDocs mapping is absorbed + // (indentation override) and the list markers/depth survive, so the downstream YAML list parses. var tagsBody string var found bool for p := range mb.Properties() { @@ -369,9 +362,8 @@ func TestFixtures_Meta_TagsBlock(t *testing.T) { assert.Empty(t, b.Diagnostics()) } -// TestFixtures_Meta_TosKeywordVariants exercises the trailing-dot, -// alias spelling ("Terms Of Service" / "TermsOfService" / "tos") that -// the meta v3 / v4 fixtures show. +// 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", @@ -404,8 +396,7 @@ swagger:response genericError 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. +// 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 @@ -421,8 +412,8 @@ swagger:strfmt date assert.Equal(t, TokenIdentName, cb.Args[0].Kind) } -// TestFixtures_Petstore_ParameterIn covers the `in:` keyword inside an -// UnboundBlock (a parameter struct field). +// 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 @@ -440,8 +431,8 @@ required: true assert.True(t, required) } -// TestFixtures_AllowedHTTPMethods covers the closed HTTP method -// vocabulary inspired by fixtures/enhancements/all-http-methods. +// 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 { @@ -454,8 +445,7 @@ func TestFixtures_AllowedHTTPMethods(t *testing.T) { } } -// TestFixtures_AllowedHTTPMethods_LowercaseNormalised checks -// case-insensitive HTTP method matching. +// 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) @@ -463,8 +453,8 @@ func TestFixtures_AllowedHTTPMethods_LowercaseNormalised(t *testing.T) { 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. +// TestFixtures_GodocLinterTrailingDot covers the trailing-dot elision the godot linter triggers +// across annotation lines. func TestFixtures_GodocLinterTrailingDot(t *testing.T) { cases := []struct { src string @@ -498,8 +488,7 @@ func TestFixtures_GodocLinterTrailingDot(t *testing.T) { } } -// TestFixtures_CRLFNormalisation ensures \r\n line endings produce the -// same token stream as \n. +// 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" @@ -538,9 +527,8 @@ minimum: >0 } } -// TestFixtures_AllOf_OptionalClassName covers swagger:allOf with and -// without the polymorphic class name (mirrors the docs examples in -// 23-classifier-grammar.md). +// 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) @@ -555,8 +543,8 @@ func TestFixtures_AllOf_OptionalClassName(t *testing.T) { assert.Equal(t, "Animal", cbNamed.Args[0].Text) } -// TestFixtures_DefaultAnnotation_JSONForms covers the JsonValue branch -// (objects, arrays, numbers, booleans) and the RawValue fallback. +// 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}`, @@ -583,8 +571,8 @@ func TestFixtures_DefaultAnnotation_JSONForms(t *testing.T) { 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], …"). +// 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) @@ -595,8 +583,8 @@ func TestFixtures_EnumDecl_BracketedHybrid(t *testing.T) { assert.Equal(t, TokenJSONValue, eb.InlineArgs[0].Kind) } -// TestFixtures_EnumDecl_MultilineBody backports the Q15 multi-line -// value-list body shape on swagger:enum. +// 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 @@ -616,8 +604,8 @@ enum: assert.Contains(t, eb.BodyValues, "high") } -// TestFixtures_BookingMeta_LeadingAnnotation covers the -// `swagger:meta` placed at the top of the comment group. +// 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 @@ -640,9 +628,8 @@ Contact: API Team team@example.com assert.Equal(t, "1.4.0", v) } -// TestFixtures_OperationDeprecatedAndExternalDocs covers the cross- -// over deprecated keyword + externalDocs raw block under an inline -// operation block. +// 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 @@ -669,10 +656,9 @@ externalDocs: 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). +// 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 @@ -699,9 +685,8 @@ x-baz: 1 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. +// 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 @@ -726,8 +711,8 @@ Produces: 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. +// 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 @@ -777,9 +762,8 @@ type Pet struct { 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). +// 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 @@ -807,8 +791,8 @@ func TestFixtures_CrossSchemeKeyword_Schemes(t *testing.T) { } } -// TestFixtures_StrfmtKeywordAlias_MaxLen confirms that the alias -// "max len" / "max-len" / "maxLen" are equivalent to "maxLength". +// 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" @@ -819,8 +803,8 @@ func TestFixtures_StrfmtKeywordAlias_MaxLen(t *testing.T) { } } -// TestFixtures_DecimalNumberValue_Roundtrip confirms NUMBER_VALUE -// parses signed/unsigned decimals and fractional values. +// TestFixtures_DecimalNumberValue_Roundtrip confirms NUMBER_VALUE parses signed/unsigned decimals +// and fractional values. func TestFixtures_DecimalNumberValue_Roundtrip(t *testing.T) { cases := []struct { src string @@ -846,9 +830,10 @@ func TestFixtures_DecimalNumberValue_Roundtrip(t *testing.T) { } } -// 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. +// 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() { diff --git a/internal/parsers/grammar/in_normalize.go b/internal/parsers/grammar/in_normalize.go index 96085078..a7314593 100644 --- a/internal/parsers/grammar/in_normalize.go +++ b/internal/parsers/grammar/in_normalize.go @@ -5,35 +5,33 @@ package grammar import "strings" -// NormalizeIn returns the canonical OAS v2 parameter-location value -// matching raw, case-insensitively, against the closed vocabulary -// declared on KwIn (`query` / `path` / `header` / `body` / -// `formData`). Returns ("", false) when raw is not recognised. -// -// When allowFormAlias is true, the routes-inline-param affordance -// from v1 is enabled: a raw value of `form` (case-insensitive) is -// accepted and normalised to `formData`. This is documented in -// observed-quirks Q27 and is intentionally contained to -// internal/parsers/routebody — every other capture site MUST pass -// allowFormAlias=false so the canonical OAS v2 vocabulary is the -// single source of truth. -// -// The normalisation here mirrors what the grammar's enum-option -// parser does for typed KwIn properties (parser.go:740, -// strings.EqualFold). It exists as a public helper because three -// capture sites read `in:` by scanning doc text directly rather -// than going through grammar's typed property path: +// NormalizeIn returns the canonical OAS v2 parameter-location value matching raw, +// case-insensitively, against the closed vocabulary declared on KwIn (`query` / `path` / `header` / +// `body` / `formData`). +// +// Returns ("", false) when raw is not recognised. +// +// When allowFormAlias is true, the routes-inline-param affordance from v1 is enabled: a raw value +// of `form` (case-insensitive) is accepted and normalised to `formData`. +// +// This is documented in observed-quirks Q27 and is intentionally contained to +// internal/parsers/routebody — every other capture site MUST pass allowFormAlias=false so the +// canonical OAS v2 vocabulary is the single source of truth. +// +// The normalisation here mirrors what the grammar's enum-option parser does for typed KwIn +// properties (parser.go:740, strings.EqualFold). +// It exists as a public helper because three capture sites read `in:` by scanning doc text directly +// rather than going through grammar's typed property path: // // - internal/builders/parameters/doc_signals.go (scanInLocation) // - internal/builders/responses/doc_signals.go (scanInLocation) // - internal/parsers/routebody/parameters.go (applyParamLine) // -// All three route through this helper so case-insensitivity is -// enforced uniformly and the closed vocabulary lives in one place. +// All three route through this helper so case-insensitivity is enforced uniformly and the closed +// vocabulary lives in one place. // -// Q29 (2026-06-03) — go-swagger-generated code emits capitalised -// forms like `in: Body`; the pre-fix strict-case map lookup -// silently miscategorised them, dropping fields to the `query` +// Q29 (2026-06-03) — go-swagger-generated code emits capitalised forms like `in: Body`; the +// pre-fix strict-case map lookup silently miscategorised them, dropping fields to the `query` // default. func NormalizeIn(raw string, allowFormAlias bool) (string, bool) { raw = strings.TrimSpace(raw) diff --git a/internal/parsers/grammar/keywords.go b/internal/parsers/grammar/keywords.go index 0be28ca4..6e39b65e 100644 --- a/internal/parsers/grammar/keywords.go +++ b/internal/parsers/grammar/keywords.go @@ -8,8 +8,8 @@ import ( "strings" ) -// ValueShape names the lexical shape of a keyword's value, mapping -// directly onto the value terminals: +// ValueShape names the lexical shape of a keyword's value, mapping directly onto the value +// terminals: // // - ShapeNumber → NUMBER_VALUE // - ShapeInt → INT_VALUE @@ -20,8 +20,7 @@ import ( // - ShapeRawBlock → RAW_BLOCK_ (multi-line body terminal) // - ShapeRawValue → RAW_VALUE_ (multi-line OR single-line body terminal) // -// See README §keyword-table for the per-shape callback dispatched -// by Walker. +// See README §keyword-table for the per-shape callback dispatched by Walker. type ValueShape int const ( @@ -62,9 +61,10 @@ func (v ValueShape) String() string { } } -// 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. +// 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 } @@ -76,13 +76,15 @@ type Keyword struct { 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.). +// +// Distinct from AnnotationKind because validations are legal across several annotation kinds (model +// + parameters + response, etc.). type KeywordContext int const ( @@ -153,16 +155,17 @@ 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/ +// 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. +// 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" @@ -180,6 +183,7 @@ const ( KwAdditionalProperties = "additionalProperties" KwDefault = "default" KwExample = "example" + KwExamples = "examples" KwEnum = "enum" KwRequired = "required" KwReadOnly = "readOnly" @@ -254,9 +258,9 @@ var keywords = []Keyword{ asEnumOption("csv", "ssv", "tsv", "pipes", "multi"), ctx(CtxParam, CtxHeader, CtxItems)), - // Object validations. Full-Schema-only (CtxSchema): object - // property-count bounds have no SimpleSchema (param/header/items) - // equivalent in OAS v2. Shape-restricted to `object` schemas by + // Object validations. + // Full-Schema-only (CtxSchema): object property-count bounds have no SimpleSchema + // (param/header/items) equivalent in OAS v2. Shape-restricted to `object` schemas by // validations.keywordTypeRules. keyword(KwMaxProperties, aka("max properties", "max-properties", @@ -272,10 +276,10 @@ var keywords = []Keyword{ aka("pattern properties", "pattern-properties"), asString(), ctx(CtxSchema)), - // additionalProperties: field keyword (true | false | a - // swagger:type-style spec). Full-Schema-only; the schema builder resolves - // the spec and applies the lowest-priority precedence. See - // schema/additional_properties.go. + // additionalProperties: field keyword (true | false | a swagger:type-style spec). + // Full-Schema-only; the schema builder resolves the spec and applies the lowest-priority + // precedence. + // See schema/additional_properties.go. keyword(KwAdditionalProperties, aka("additional properties", "additional-properties"), asString(), @@ -301,39 +305,41 @@ var keywords = []Keyword{ keyword(KwDiscriminator, asBool(), ctx(CtxSchema)), keyword(KwDeprecated, asBool(), ctx(CtxOperation, CtxRoute, CtxSchema)), - // Parameter-location directive. Not part of the formal schema- - // body grammar. 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. See README §keyword-table ("`in:` is a parameter-location - // directive"). + // Parameter-location directive. + // Not part of the formal schema- body grammar. + // 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. + // + // See README §keyword-table ("`in:` is a parameter-location directive"). keyword(KwIn, asEnumOption("query", "path", "header", "body", "formData"), ctx(CtxParam)), - // Name directive — the canonical field-naming keyword. Like `in:`, - // this is a structural keyword rather than part of the schema-body - // grammar: it renames the published name of a field at every field - // site — a swagger:parameters field (the JSON parameter name), a - // swagger:response field (the response header / Headers map key) and - // a swagger:model property or interface method (the JSON property - // name) — always overriding the json-tag / Go-field derivation. - // Legal in CtxParam, CtxHeader and CtxSchema so it is removed from - // the description prose and silently ignored by the (Simple)Schema - // walker (isFullSchemaOnly stays false; no shape rule, so checkShape - // passes) at all three; the parameters / responses / schema builders - // read the value via GetString(KwName). The older `swagger:name` - // annotation remains honoured as a legacy form (and is the only form - // on interface methods historically), but `name:` takes precedence - // when both appear. See README §keyword-table. (doc-quirk G2.) + // Name directive — the canonical field-naming keyword. + // + // Like `in:`, this is a structural keyword rather than part of the schema-body grammar: it renames + // the published name of a field at every field site — a swagger:parameters field (the JSON + // parameter name), a swagger:response field (the response header / Headers map key) and a + // swagger:model property or interface method (the JSON property name) — always overriding the + // json-tag / Go-field derivation. + // + // Legal in CtxParam, CtxHeader and CtxSchema so it is removed from the description prose and + // silently ignored by the (Simple)Schema walker (isFullSchemaOnly stays false; no shape rule, so + // checkShape passes) at all three; the parameters / responses / schema builders read the value via + // GetString(KwName). + // + // The older `swagger:name` annotation remains honoured as a legacy form (and is the only form on + // interface methods historically), but `name:` takes precedence when both appear. + // See README §keyword-table. (doc-quirk G2.) keyword(KwName, asString(), ctx(CtxParam, CtxHeader, CtxSchema)), - // List-shaped keywords. KwSchemes uses asRawBlock() so multi-line - // bodies (`Schemes:\n - http\n - https`) populate the same way - // they do for Consumes/Produces; the inline comma-list form - // (`Schemes: http, https`) still works via the inline-value - // capture in collectRawBlock. Block.GetList unifies both - // surfaces. See README §keyword-table. + // List-shaped keywords. + // KwSchemes uses asRawBlock() so multi-line bodies (`Schemes:\n - http\n - https`) populate the + // same way they do for Consumes/Produces; the inline comma-list form (`Schemes: http, https`) + // still works via the inline-value capture in collectRawBlock. + // + // Block.GetList unifies both surfaces. + // See README §keyword-table. keyword(KwSchemes, asRawBlock(), ctx(CtxMeta, CtxRoute, CtxOperation)), @@ -359,6 +365,16 @@ var keywords = []Keyword{ ctx(CtxMeta)), keyword(KwResponses, asRawBlock(), ctx(CtxRoute, CtxOperation)), keyword(KwParameters, asRawBlock(), ctx(CtxRoute, CtxOperation)), + // `examples:` is a YAML map keyed by mime type (`examples:` then an indented `application/json: + // {…}`) populating spec.Response.examples on a `swagger:response`. + // OAS2 reserves the PLURAL `examples` for responses; the singular `example` above is the schema / + // param / header / items decorator. + // + // Only the struct- based `swagger:response` path needs this keyword — the `swagger:operation` + // YAML path carries examples for free via the spec unmarshal. + // The body is YAML-parsed downstream, so it joins the yamlBody set in the lexer. + // See features/response-examples-by-mime. + keyword(KwExamples, asRawBlock(), ctx(CtxResponse)), keyword(KwExtensions, asRawBlock(), ctx(CtxMeta, CtxRoute, CtxOperation, CtxSchema, CtxParam, CtxHeader)), @@ -374,27 +390,29 @@ var keywords = []Keyword{ aka("external docs", "external-docs"), asRawBlock(), ctx(CtxMeta, CtxRoute, CtxOperation, CtxSchema)), - // `Tags:` block. On swagger:meta it is a YAML list of tag objects - // ({name, description, externalDocs}) populating spec.Swagger.Tags. - // On swagger:route/operation it is a plain string list of tag - // names, unioned onto the operation's tags (the same names may also - // appear on the swagger:route header line). The single keyword - // carries two shapes; the consuming builder decides which (the meta - // walker unmarshals objects, the route walker reads AsList). See - // go-swagger/go-swagger#2655. + // `Tags:` block. + // On swagger:meta it is a YAML list of tag objects ({name, description, externalDocs}) populating + // spec.Swagger.Tags. + // + // On swagger:route/operation it is a plain string list of tag names, unioned onto the operation's + // tags (the same names may also appear on the swagger:route header line). + // The single keyword carries two shapes; the consuming builder decides which (the meta walker + // unmarshals objects, the route walker reads AsList). + // + // See go-swagger/go-swagger#2655. keyword(KwTags, asRawBlock(), ctx(CtxMeta, CtxRoute, CtxOperation)), } -// specPointer records where a keyword's value renders in the produced spec: -// the JSON-pointer segments (relative to the node the keyword decorates, or the -// document root for the meta-only keywords) and, optionally, the contexts where -// that node is actually produced when they differ from the keyword's lexical -// Contexts. +// specPointer records where a keyword's value renders in the produced spec: the JSON-pointer +// segments (relative to the node the keyword decorates, or the document root for the meta-only +// keywords) and, optionally, the contexts where that node is actually produced when they differ +// from the keyword's lexical Contexts. type specPointer struct { segs []string // ctxs overrides the render contexts; nil means "use the keyword's Contexts". - // Set only where lexical legality and node production diverge — e.g. - // `deprecated` is legal on a schema but only renders a node on operations. + // + // Set only where lexical legality and node production diverge — e.g. `deprecated` is legal on a + // schema but only renders a node on operations. ctxs []KeywordContext } @@ -427,7 +445,8 @@ var specPointers = map[string]specPointer{ KwProduces: {segs: []string{KwProduces}}, KwSchemes: {segs: []string{KwSchemes}}, KwDeprecated: {segs: []string{KwDeprecated}, ctxs: []KeywordContext{CtxOperation, CtxRoute}}, - // Meta keywords. Info.* nest under /info; the rest land at the document root. + // Meta keywords. + // Info.* nest under /info; the rest land at the document root. KwVersion: {segs: []string{"info", KwVersion}}, KwTOS: {segs: []string{"info", "termsOfService"}}, KwContact: {segs: []string{"info", KwContact}}, @@ -436,11 +455,12 @@ var specPointers = map[string]specPointer{ KwBasePath: {segs: []string{KwBasePath}}, } -// PointerPath returns the JSON-pointer segments where kw's value renders, when -// kw produces an addressable node legal in ctx. Consumers (the builder walkers) -// prepend the decorated node's base pointer and any items depth. The ctx gate -// reuses the keyword's lexical Contexts unless the entry overrides them, so a -// keyword is never anchored in a context where its node does not exist. +// PointerPath returns the JSON-pointer segments where kw's value renders, when kw produces an +// addressable node legal in ctx. +// +// Consumers (the builder walkers) prepend the decorated node's base pointer and any items depth. +// The ctx gate reuses the keyword's lexical Contexts unless the entry overrides them, so a keyword +// is never anchored in a context where its node does not exist. func PointerPath(kw Keyword, ctx KeywordContext) ([]string, bool) { sp, ok := specPointers[kw.Name] if !ok { @@ -456,9 +476,10 @@ func PointerPath(kw Keyword, ctx KeywordContext) ([]string, bool) { return nil, false } -// 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. +// 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 == "" { diff --git a/internal/parsers/grammar/lexer.go b/internal/parsers/grammar/lexer.go index a4a831e3..7d2a605d 100644 --- a/internal/parsers/grammar/lexer.go +++ b/internal/parsers/grammar/lexer.go @@ -12,8 +12,9 @@ import ( "unicode/utf8" ) -// Lex turns a preprocessed []Line into the token stream consumed by -// the grammar parser. Pipeline: +// 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). @@ -25,9 +26,8 @@ import ( // // # Details // -// See README §lexer-contract for the per-stage rules, -// §raw-block-terminators for body-termination rules, and -// §prose-classification for the TITLE / DESC split heuristics. +// See README §lexer-contract for the per-stage rules, §raw-block-terminators for body-termination +// rules, and §prose-classification for the TITLE / DESC split heuristics. func Lex(lines []Line) []Token { raw := classifyLines(lines) bodied := accumulateBodies(raw) @@ -36,23 +36,61 @@ func Lex(lines []Line) []Token { // ----- 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. +// 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 + inLiteralDesc := false for _, line := range lines { + if inLiteralDesc { + // Inside a `swagger:description |` literal block, every line is captured verbatim until the next + // annotation or EOF — no `---` fence toggling, no blank-line or keyword termination. + // This MUST happen here (stage 1) because a lone `---` in the body would otherwise flip inFence + // and swallow a following annotation as raw YAML. + // + // See README §literal-description. + tok := lexLine(line, false) + if tok.Kind != TokenAnnotation { + out = append(out, Token{Kind: tokenRawLine, Pos: line.Pos, Text: line.Raw, Raw: line.Raw}) + continue + } + // An annotation terminates the block; re-process it normally so a back-to-back + // `swagger:description |` re-opens literal mode. + inLiteralDesc = false + out = append(out, tok) + if isLiteralDescMarker(tok) { + inLiteralDesc = true + } + continue + } + tok := lexLine(line, inFence) out = append(out, tok) if tok.Kind == tokenYAMLFence { inFence = !inFence } + if !inFence && isLiteralDescMarker(tok) { + inLiteralDesc = true + } } return out } +// isLiteralDescMarker reports whether tok is a `swagger:description |` annotation: a description +// override whose sole inline argument is the YAML literal block-scalar marker `|`. +// +// The marker opts the body into verbatim capture (blank lines and indentation preserved) instead of +// the default blank-terminated Option B fold. +func isLiteralDescMarker(tok Token) bool { + return tok.Kind == TokenAnnotation && + tok.Name == labelDescription && + len(tok.Args) == 1 && + strings.TrimSpace(tok.Args[0].Text) == "|" +} + // lexLine classifies one line. Returns one of: // - TokenBlank // - tokenYAMLFence @@ -73,8 +111,7 @@ func lexLine(line Line, inFence bool) Token { return Token{Kind: TokenBlank, Pos: line.Pos} } - // First-character case insensitivity on swagger:: only the - // leading character flips. + // First-character case insensitivity on swagger:: only the leading character flips. if hasSwaggerPrefix(text) { return lexAnnotation(text, line.Pos) } @@ -84,12 +121,12 @@ func lexLine(line Line, inFence bool) Token { 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. + // 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} } @@ -99,9 +136,9 @@ func lexLine(line Line, inFence bool) Token { 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: +// 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`); @@ -110,13 +147,12 @@ func lexLine(line Line, inFence bool) Token { // 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). +// 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. +// 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 @@ -138,21 +174,19 @@ func isGoDirective(raw string) bool { return true } -// isDirectiveMarker reports whether text is a Go "marker" comment of the -// kind emitted by Kubernetes code-generation tooling (kubebuilder, -// controller-gen, k8s deepcopy-gen, genclient): a line whose content -// begins with `+` immediately followed by an ASCII letter, e.g. -// `+genclient`, `+kubebuilder:validation:Required`, `+k8s:deepcopy-gen=…`. +// isDirectiveMarker reports whether text is a Go "marker" comment of the kind emitted by Kubernetes +// code-generation tooling (kubebuilder, controller-gen, k8s deepcopy-gen, genclient): a line whose +// content begins with `+` immediately followed by an ASCII letter, e.g. `+genclient`, +// `+kubebuilder:validation:Required`, `+k8s:deepcopy-gen=…`. // -// These markers are not part of the swagger annotation grammar and must -// not leak into model / property descriptions (go-swagger#2687, the -// residual of #3007); lexLine drops them from the prose surface exactly -// like Go directives. +// These markers are not part of the swagger annotation grammar and must not leak into model / +// property descriptions (go-swagger#2687, the residual of #3007); lexLine drops them from the prose +// surface exactly like Go directives. // -// text is the godoc-stripped Line.Text, so both the common kubebuilder -// form `// +marker` (space after the comment marker) and the bare -// `//+marker` arrive here as `+marker`. Requiring a letter after the `+` -// avoids eating prose that merely opens with a sign (e.g. "+1 for …"). +// text is the godoc-stripped Line.Text, so both the common kubebuilder form `// +marker` (space +// after the comment marker) and the bare `//+marker` arrive here as `+marker`. +// Requiring a letter after the `+` avoids eating prose that merely opens with a sign (e.g. "+1 for +// …"). func isDirectiveMarker(text string) bool { if len(text) < 2 || text[0] != '+' { return false @@ -161,12 +195,12 @@ func isDirectiveMarker(text string) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') } -// hasSwaggerPrefix is the case-insensitive match on the first char of -// AnnotationPrefix — only the first character is permissive. +// hasSwaggerPrefix is the case-insensitive match on the first char of AnnotationPrefix — only the +// first character is permissive. // -// AnnotationPrefix is fixed at "swagger:" so the case-insensitive -// fallback is tied to ASCII letter casing of its first byte. See -// README §quirks-open. +// AnnotationPrefix is fixed at "swagger:" so the case-insensitive fallback is tied to ASCII letter +// casing of its first byte. +// See README §quirks-open. func hasSwaggerPrefix(s string) bool { if len(s) < len(AnnotationPrefix) { return false @@ -178,9 +212,9 @@ func hasSwaggerPrefix(s string) bool { 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. +// 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') @@ -189,9 +223,10 @@ func asciiUpper(b byte) byte { } // matchGodocRoutePrefix recognises a leading "GoIdent swagger:route". -// Returns the byte offset where "swagger:route" begins. Only "route" -// gets this exception. See README §lexer-contract -// ("Godoc-prefix exception for swagger:route"). +// +// Returns the byte offset where "swagger:route" begins. +// Only "route" gets this exception. +// See README §lexer-contract ("Godoc-prefix exception for swagger:route"). func matchGodocRoutePrefix(s string) (int, bool) { identEnd := scanGoIdentifier(s) if identEnd == 0 { @@ -215,9 +250,10 @@ func matchGodocRoutePrefix(s string) (int, bool) { 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. +// 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 @@ -237,30 +273,41 @@ func scanGoIdentifier(s string) int { 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. +// 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) + rawRest := strings.TrimRightFunc(text[len(AnnotationPrefix):], unicode.IsSpace) + // Classify the kind from a dot-stripped view so a bare `swagger:model.` (and likewise + // `swagger:description.`) still resolves to its kind. + name, _ := splitFirstField(stripTrailingDot(rawRest)) if name == "" { return Token{Kind: tokenText, Pos: pos, Text: text} } kind := AnnotationKindFromName(name) + // Free-text overrides (swagger:title / swagger:description) keep a trailing "." — it is sentence + // content, not punctuation noise. + // Every other annotation elides a single trailing dot (e.g. `swagger:model Pet.`). + rest := rawRest + if kind != AnnTitle && kind != AnnDescription { + rest = stripTrailingDot(rawRest) + } + _, after := splitFirstField(rest) args := classifyAnnotationArgs(kind, after, pos, len(text)-len(after)) return Token{Kind: TokenAnnotation, Pos: pos, Name: name, Args: args} } -// stripTrailingDot elides a single trailing ".". Source -// preservation lives upstream on Line.Raw. +// stripTrailingDot elides a single trailing ".". +// +// Source preservation lives upstream on Line.Raw. 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). +// 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 == "" { @@ -275,12 +322,13 @@ func splitFirstField(s string) (head, rest string) { return head, rest } -// classifyAnnotationArgs converts the post-name remainder of an -// annotation line into the typed argument tokens per annotation -// kind. See README §annotation-args. +// classifyAnnotationArgs converts the post-name remainder of an annotation line into the typed +// argument tokens per annotation kind. +// +// See README §annotation-args. // -// The byte-offset baseColumn is the column at which `rest` begins -// inside the source line; positions are computed relative to that. +// 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 == "" { @@ -296,34 +344,49 @@ func classifyAnnotationArgs(kind AnnotationKind, rest string, linePos token.Posi case AnnDefaultName: return []Token{argDefaultValue(rest, pos)} case AnnType, AnnAdditionalProperties: - // Both take a swagger:type-style spec as a single ref token - // (true / false / primitive / []T / type-name). The builder does - // the semantic resolution. + // Both take a swagger:type-style spec as a single ref token (true / false / primitive / []T / + // type-name). + // The builder does the semantic resolution. return []Token{argTypeRef(rest, pos)} case AnnPatternProperties: - // The arg is a `"": , …` pair list that may contain - // spaces/colons/commas inside quoted regexes — capture the whole - // remainder verbatim; the builder parses the pairs. + // The arg is a `"": , …` pair list that may contain spaces/colons/commas inside + // quoted regexes — capture the whole remainder verbatim; the builder parses the pairs. + return []Token{{Kind: TokenRawValue, Pos: pos, Text: strings.TrimSpace(rest)}} + case AnnTitle, AnnDescription: + // Free-text override: the arg is the whole rest of the line, verbatim (a title/description + // sentence with spaces). + // Captured as one TokenRawValue so ClassifierBlock.AnnotationArg() returns it intact. (Multi-line + // description bodies are folded in a later phase.) return []Token{{Kind: TokenRawValue, Pos: pos, Text: strings.TrimSpace(rest)}} case AnnEnum: return classifyEnumAnnotationArgs(rest, pos) case AnnParameters: - return classifyIdentList(rest, pos) - case AnnAllOf, AnnModel, AnnResponse, AnnStrfmt, AnnName: + return classifyParametersArgs(rest, pos) + case AnnResponse: + // `swagger:response *` is a synonym for the bare form (a shared response keyed by the type name). + // The wildcard carries no trailing tokens — responses have no op-id injection (unlike + // parameters) and no /path form. + // Any other argument is the explicit response name. + if head, _ := splitFirstField(rest); head == "*" { + return []Token{{Kind: TokenWildcard, Pos: pos, Text: "*"}} + } + return []Token{firstIdent(rest, pos)} + case AnnAllOf, AnnModel, 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. + // 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. The trailing IDENT_NAME is the OpID; -// everything between path and the trailing ident is treated as a -// (potentially space-separated) tag list. See README §annotation-args. +// classifyOperationArgs extracts METHOD, /path, [tags...], and the trailing operationID. +// +// The trailing IDENT_NAME is the OpID; everything between path and the trailing ident is treated as +// a (potentially space-separated) tag list. +// See README §annotation-args. func classifyOperationArgs(rest string, basePos token.Position) []Token { fields := splitFields(rest, basePos) if len(fields) == 0 { @@ -344,25 +407,27 @@ func classifyOperationArgs(rest string, basePos token.Position) []Token { fields = fields[1:] } - // Remaining fields: every IDENT_NAME — the parser layer marks the - // trailing one as the OpID; everything before it is a tag. + // Remaining fields: every IDENT_NAME — the parser layer marks the trailing one as the OpID; + // everything before it is a tag. 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. See README §disambiguation. +// argDefaultValue handles the JSON_VALUE | RAW_VALUE alternation for swagger:default. +// +// See README §disambiguation. func argDefaultValue(rest string, pos token.Position) Token { kind := classifyDefaultValue(rest) return Token{Kind: kind, Pos: pos, Text: strings.TrimSpace(rest)} } -// argTypeRef tags a well-formed `swagger:type` argument as TYPE_REF and -// leaves the semantic check (known keyword / scanned type, format -// compatibility) to the builder. A structurally malformed token falls back -// to TokenIdentName so the parser can flag it (see looksLikeTypeRef). +// argTypeRef tags a well-formed `swagger:type` argument as TYPE_REF and leaves the semantic check +// (known keyword / scanned type, format compatibility) to the builder. +// +// A structurally malformed token falls back to TokenIdentName so the parser can flag it (see +// looksLikeTypeRef). func argTypeRef(rest string, pos token.Position) Token { rest = strings.TrimSpace(rest) if looksLikeTypeRef(rest) { @@ -371,11 +436,11 @@ func argTypeRef(rest string, pos token.Position) Token { return Token{Kind: TokenIdentName, Pos: pos, Text: rest} } -// classifyEnumAnnotationArgs implements the four-step EnumArgs -// dispatch rule. 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. See README §disambiguation. +// classifyEnumAnnotationArgs implements the four-step EnumArgs dispatch rule. +// +// 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. +// See README §disambiguation. func classifyEnumAnnotationArgs(rest string, pos token.Position) []Token { form, name, values := classifyEnumArgs(rest) switch form { @@ -408,8 +473,32 @@ func classifyEnumAnnotationArgs(rest string, pos token.Position) []Token { } } -// classifyIdentList tokenises a whitespace-separated list as IDENT_NAME -// tokens. +// classifyParametersArgs tokenises `swagger:parameters` arguments. +// +// The FIRST token may be the shared-namespace wildcard `*` (TokenWildcard) or a `/path` +// (TokenURLPath); every other token (and any first token that is neither) is an IDENT_NAME — an +// operation id or a shared-parameter name, disambiguated downstream by the parser/builder. +// +// `*` and `/path` are only recognised in first position, so `*blah` / a mid-list `/x` fall through +// to IDENT_NAME. +// See README §annotation-args. +func classifyParametersArgs(rest string, basePos token.Position) []Token { + fields := splitFields(rest, basePos) + out := make([]Token, 0, len(fields)) + for i, f := range fields { + switch { + case i == 0 && f.text == "*": + out = append(out, Token{Kind: TokenWildcard, Pos: f.pos, Text: f.text}) + case i == 0 && looksLikeURLPath(f.text): + out = append(out, Token{Kind: TokenURLPath, Pos: f.pos, Text: f.text}) + default: + out = append(out, Token{Kind: TokenIdentName, Pos: f.pos, Text: f.text}) + } + } + return out +} + +// 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)) @@ -419,8 +508,9 @@ func classifyIdentList(rest string, basePos token.Position) []Token { return out } -// firstIdent emits a single TokenIdentName for the first whitespace- -// separated token in rest. Used for single-arg classifier annotations. +// 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 { @@ -435,8 +525,8 @@ type field struct { pos token.Position } -// splitFields breaks s into whitespace-separated fields, advancing the -// position by byte offset for each field. +// 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) @@ -471,10 +561,11 @@ func splitFields(s string, basePos token.Position) []field { 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. +// 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) @@ -487,9 +578,8 @@ func lexKeyword(text, raw string, pos token.Position) (Token, bool) { if name == "" { return Token{}, false } - // First-character case insensitivity: lowercase only the first - // character before lookup. See README §lexer-contract - // ("First-character case insensitivity on keywords"). + // First-character case insensitivity: lowercase only the first character before lookup. + // See README §lexer-contract ("First-character case insensitivity on keywords"). canonName := lowerFirst(name) kw, ok := Lookup(canonName) @@ -505,12 +595,13 @@ func lexKeyword(text, raw string, pos token.Position) (Token, bool) { value := strings.TrimSpace(after) value = stripTrailingDot(value) - // A `deprecated:` line whose argument is not a bool is the godoc - // "Deprecated: " convention, not the bool keyword. Leave it as - // prose (Block.IsDeprecated detects it via the godoc regexp) instead of - // forcing a bool parse that would spuriously error and strip the reason - // from the description. The bool form keeps being a keyword (and drives - // the native operation `deprecated` field). See go-swagger/go-swagger#3138. + // A `deprecated:` line whose argument is not a bool is the godoc "Deprecated: " + // convention, not the bool keyword. + // Leave it as prose (Block.IsDeprecated detects it via the godoc regexp) instead of forcing a bool + // parse that would spuriously error and strip the reason from the description. + // + // The bool form keeps being a keyword (and drives the native operation `deprecated` field). + // See go-swagger/go-swagger#3138. if kw.Name == KwDeprecated { if _, isBool := parseBool(value); !isBool { return Token{}, false @@ -530,9 +621,10 @@ func lexKeyword(text, raw string, pos token.Position) (Token, bool) { }, true } -// lowerFirst applies first-character lowercase; only the first -// character is case-permissive on keyword recognition. See README -// §lexer-contract ("First-character case insensitivity on keywords"). +// lowerFirst applies first-character lowercase; only the first character is case-permissive on +// keyword recognition. +// +// See README §lexer-contract ("First-character case insensitivity on keywords"). func lowerFirst(s string) string { if s == "" { return s @@ -547,8 +639,9 @@ func lowerFirst(s string) string { return s } -// stripItemsPrefix peels leading "items." (or "items ") segments, -// counting depth. Bare "items:" (no separator) is preserved. +// stripItemsPrefix peels leading "items." (or "items ") segments, counting depth. +// +// Bare "items:" (no separator) is preserved. func stripItemsPrefix(s string) (string, int) { depth := 0 for { @@ -579,10 +672,10 @@ func stripOneItemsPrefix(s string) (string, bool) { // ----- 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). +// 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 @@ -607,15 +700,24 @@ func accumulateBodies(in []Token) []Token { i++ } case tokenRawLine: - // Stale raw line outside a fence — should not happen given - // classifyLines' state machine. Drop silently. + // 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. + // 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: + case TokenAnnotation: + switch { + case t.Name == labelDescription && isLiteralDescMarker(t): + i = collectDescriptionLiteral(in, i, &out) + case t.Name == labelDescription: + i = collectDescriptionBody(in, i, &out) + default: + out = append(out, t) + i++ + } + case TokenBlank, tokenText, TokenEOF: out = append(out, t) i++ default: @@ -627,11 +729,79 @@ func accumulateBodies(in []Token) []Token { 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). +// collectDescriptionBody folds the contiguous prose lines following a `swagger:description` +// annotation into its argument (Option B, blank-line terminator): the body runs to the first blank +// line, keyword, annotation, or EOF — anything that is not a plain prose line ends it (so a +// following `maximum:` keyword or `swagger:*` annotation is never swallowed). +// +// The combined inline-plus-body text becomes the annotation's single raw arg, so AnnotationArg() +// returns the whole multi-line description and the folded lines never reach the prose (TITLE/DESC) +// surface. +// Returns the index past the folded lines. +// +// `swagger:title` is single-line and is not handled here. +func collectDescriptionBody(in []Token, i int, out *[]Token) int { + ann := in[i] + j := i + 1 + var body []string + for j < len(in) && in[j].Kind == tokenText { + body = append(body, strings.TrimSpace(in[j].Text)) + j++ + } + if len(body) > 0 { + ann.Args = []Token{combineDescriptionArg(ann, body)} + } + *out = append(*out, ann) + return j +} + +// collectDescriptionLiteral folds the verbatim body of a `swagger:description |` literal block into +// the annotation's single raw argument. classifyLines' literal mode has already emitted the body as +// a contiguous run of tokenRawLine (every source line after the marker, until the next annotation +// or EOF), so the body is preserved exactly — indentation, blank lines, table pipes, and `---` +// all intact. +// +// Trailing blank lines are clipped (bare-`|` semantics) and the `|` marker itself is dropped. +// Returns the index past the folded body. +func collectDescriptionLiteral(in []Token, i int, out *[]Token) int { + ann := in[i] + j := i + 1 + var body []string + for j < len(in) && in[j].Kind == tokenRawLine { + // Drop the single godoc convention space after `//` (gofmt-canonical `// text`); it is comment + // decoration, not body. + // Author indentation beyond it, trailing whitespace (markdown hard breaks), pipes, and blank + // lines are all preserved verbatim. + body = append(body, strings.TrimPrefix(in[j].Raw, " ")) + j++ + } + for len(body) > 0 && strings.TrimSpace(body[len(body)-1]) == "" { + body = body[:len(body)-1] + } + ann.Args = []Token{{Kind: TokenRawValue, Pos: ann.Pos, Text: strings.Join(body, "\n")}} + *out = append(*out, ann) + return j +} + +// combineDescriptionArg joins the inline argument (if any) with the folded body lines using "\n" +// — the same join the prose Description() accumulator uses — preserving the inline arg's +// position when present. +func combineDescriptionArg(ann Token, body []string) Token { + pos := ann.Pos + var parts []string + if len(ann.Args) > 0 && ann.Args[0].Text != "" { + parts = append(parts, ann.Args[0].Text) + pos = ann.Args[0].Pos + } + parts = append(parts, body...) + return Token{Kind: TokenRawValue, Pos: pos, Text: strings.Join(parts, "\n")} +} + +// 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++ @@ -667,16 +837,15 @@ func collectFencedYAML(in []Token, i int, out *[]Token) int { return i } -// collectRawBlock accumulates the body of a RAW_BLOCK_ keyword -// (consumes / produces / responses / parameters / extensions / …). -// Stops at the next sibling structural item or EOF; blank lines do -// not terminate. +// collectRawBlock accumulates the body of a RAW_BLOCK_ keyword (consumes / produces / responses +// / parameters / extensions / …). +// +// Stops at the next sibling structural item or EOF; blank lines do not terminate. // // # Details // -// See README §raw-block-terminators for the sibling-terminator -// rule, the inline-value capture on the head, and the per-body -// indentation handling. +// See README §raw-block-terminators for the sibling-terminator rule, the inline-value capture on +// the head, and the per-body indentation handling. func collectRawBlock(in []Token, i int, kw Keyword, out *[]Token) int { head := in[i] headPos := head.Pos @@ -684,31 +853,30 @@ func collectRawBlock(in []Token, i int, kw Keyword, out *[]Token) int { var bodyText, bodyRaw []string pendingBlanks := 0 - // Inline-value capture. `Consumes: application/json` on a single - // line carries the value on head.Text; prepending it as the first - // body line keeps the inline-plus-indented-continuation form - // working uniformly. Without the prepend the post-colon payload - // would be silently lost. + // Inline-value capture. + // `Consumes: application/json` on a single line carries the value on head.Text; prepending it as + // the first body line keeps the inline-plus-indented-continuation form working uniformly. + // Without the prepend the post-colon payload would be silently lost. if head.Text != "" { bodyText = append(bodyText, head.Text) bodyRaw = append(bodyRaw, head.Text) } - // extensions / infoExtensions / securityDefinitions / Tags / - // security bodies are YAML-parsed downstream (yaml.TypedExtensions, - // yaml.UnmarshalBody via the meta walker, or security.Parse), so - // every body line MUST preserve its original indentation — Tags in - // particular is a sequence of mappings whose nesting collapses if - // the per-item indent is dropped, and a `Security:` requirement - // with block-style scopes (`- name:` then indented `- scope`) needs - // the same. Flat raw blocks (consumes / produces / …) use the Text - // view (leading whitespace dropped, recognised keywords - // reformatted). Both branches converge on the same bodyText slice. + // extensions / infoExtensions / securityDefinitions / Tags / security bodies are YAML-parsed + // downstream (yaml.TypedExtensions, yaml.UnmarshalBody via the meta walker, or security.Parse), so + // every body line MUST preserve its original indentation — Tags in particular is a sequence of + // mappings whose nesting collapses if the per-item indent is dropped, and a `Security:` + // requirement with block-style scopes (`- name:` then indented `- scope`) needs the same. + // + // Flat raw blocks (consumes / produces / …) use the Text view (leading whitespace dropped, + // recognised keywords reformatted). + // Both branches converge on the same bodyText slice. yamlBody := kw.Name == "extensions" || kw.Name == "infoExtensions" || kw.Name == "securityDefinitions" || kw.Name == KwTags || - kw.Name == KwSecurity + kw.Name == KwSecurity || + kw.Name == KwExamples bodyLine := func(t Token) string { if yamlBody { return strings.TrimRightFunc(t.Raw, unicode.IsSpace) @@ -717,8 +885,7 @@ func collectRawBlock(in []Token, i int, kw Keyword, out *[]Token) int { } consumed := func() { - // flush any pending blanks into the body so visual separators - // inside list-shaped bodies survive. + // flush any pending blanks into the body so visual separators inside list-shaped bodies survive. for range pendingBlanks { bodyText = append(bodyText, "") bodyRaw = append(bodyRaw, "") @@ -733,22 +900,22 @@ func collectRawBlock(in []Token, i int, kw Keyword, out *[]Token) int { 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. + // 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. + // + // Indentation override (YAML-bodied blocks only): inside a YAML body — Tags / + // securityDefinitions / extensions — a same-family keyword indented strictly deeper than the + // head is a nested YAML key, not a sibling (e.g. `externalDocs:` under a `Tags:` list item, both + // meta-family). // - // Indentation override (YAML-bodied blocks only): inside a - // YAML body — Tags / securityDefinitions / extensions — a - // same-family keyword indented strictly deeper than the head - // is a nested YAML key, not a sibling (e.g. `externalDocs:` - // under a `Tags:` list item, both meta-family). Absorb it so - // the YAML structure survives. Flat raw blocks (TOS / - // consumes / …) do NOT apply this: their keyword indentation - // is cosmetic — the petstore meta indents Schemes/Host deeper - // than a column-0 `Terms Of Service:`, yet they are siblings. + // Absorb it so the YAML structure survives. + // Flat raw blocks (TOS / consumes / …) do NOT apply this: their keyword indentation is + // cosmetic — the petstore meta indents Schemes/Host deeper than a column-0 `Terms Of + // Service:`, yet they are siblings. sibling := isSiblingTerminatorFor(kw, next.Name) if sibling && yamlBody && leadingIndentWidth(next.Raw) > leadingIndentWidth(head.Raw) { @@ -767,8 +934,8 @@ func collectRawBlock(in []Token, i int, kw Keyword, out *[]Token) int { bodyRaw = append(bodyRaw, next.Raw) i++ case tokenYAMLFence: - // extensions blocks may decorate the body with a `---` - // fence; absorb its contents and drop the fence markers. + // extensions blocks may decorate the body with a `---` fence; absorb its contents and drop the + // fence markers. // See README §yaml-fence-handling. if kw.Name == "extensions" { i = absorbDecorativeFenceInto(in, i+1, &bodyText, &bodyRaw) @@ -802,10 +969,11 @@ func collectRawBlock(in []Token, i int, kw Keyword, out *[]Token) int { 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). +// 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 { @@ -822,12 +990,12 @@ func absorbDecorativeFenceInto(in []Token, i int, bodyText, bodyRaw *[]string) 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). +// 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, @@ -841,10 +1009,10 @@ func emitRawBlock(out *[]Token, headPos token.Position, head Token, kw Keyword, }) } -// 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. +// 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 @@ -928,9 +1096,8 @@ func emitRawValue(out *[]Token, headPos token.Position, head Token, kw Keyword, }) } -// formatKeywordLine recreates the textual `: ` line for -// a keyword token absorbed into a raw body — line-preserving -// rendering for downstream consumers that read the body as text. +// formatKeywordLine recreates the textual `: ` line for a keyword token absorbed into +// a raw body — line-preserving rendering for downstream consumers that read the body as text. func formatKeywordLine(t Token) string { name := t.SourceName if name == "" { @@ -942,10 +1109,9 @@ func formatKeywordLine(t Token) string { 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). +// 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). // // Rule: // @@ -956,17 +1122,17 @@ func formatKeywordLine(t Token) string { // - 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. See README -// §raw-block-terminators. -// tabStopWidth is the column width a tab advances to when measuring -// leading indentation — the conventional 8-column tab stop. +// Look-up uses the keyword table's Contexts. +// See README §raw-block-terminators. tabStopWidth is the column width a tab advances to when +// measuring leading indentation — the conventional 8-column tab stop. const tabStopWidth = 8 -// leadingIndentWidth measures the visual width of raw's leading -// whitespace run, expanding tabs to 8-column tab stops and counting -// spaces as one column each. Used by the raw-block terminator to tell -// a nested YAML key (indented deeper than its block head) from a true -// sibling keyword at the same indentation. Non-whitespace ends the run. +// leadingIndentWidth measures the visual width of raw's leading whitespace run, expanding tabs to +// 8-column tab stops and counting spaces as one column each. +// +// Used by the raw-block terminator to tell a nested YAML key (indented deeper than its block head) +// from a true sibling keyword at the same indentation. +// Non-whitespace ends the run. func leadingIndentWidth(raw string) int { w := 0 for _, r := range raw { @@ -997,8 +1163,7 @@ func isSiblingTerminatorFor(kw Keyword, nextName string) bool { return false } -// familyOf classifies a keyword into one or more "family" buckets per -// its declared contexts. +// 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 { @@ -1014,12 +1179,12 @@ func familyOf(kw Keyword) []KeywordContext { return out } -// finaliseInlineKeyword converts a tokenKeywordPre into a TokenKeyword -// carrying the lexer-typed value via its Args field. +// finaliseInlineKeyword converts a tokenKeywordPre into a TokenKeyword carrying the lexer-typed +// value via its Args field. // -// Emitting a single TokenKeyword (rather than two adjacent tokens) -// keeps the body accumulator's output atomic — exactly one token -// per keyword regardless of how many sub-tokens the value carries. +// Emitting a single TokenKeyword (rather than two adjacent tokens) keeps the body accumulator's +// output atomic — exactly one token per keyword regardless of how many sub-tokens the value +// carries. // The parser unpacks Args to read the typed value. func finaliseInlineKeyword(t Token, kw Keyword) Token { value := t.Text @@ -1042,9 +1207,9 @@ func finaliseInlineKeyword(t Token, kw Keyword) Token { 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. + // 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 @@ -1066,15 +1231,14 @@ func finaliseInlineKeyword(t Token, kw Keyword) Token { // classifyProse re-types tokenText tokens as TITLE / DESC. // -// 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. +// 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. // // # Details // -// See README §prose-classification for the four heuristics and -// the rationale for applying them to unbound (no-annotation) -// comments as well as annotated ones. +// See README §prose-classification for the four heuristics and the rationale for applying them to +// unbound (no-annotation) comments as well as annotated ones. func classifyProse(in []Token) []Token { hasAnnotation := false for _, t := range in { @@ -1096,14 +1260,14 @@ func classifyProse(in []Token) []Token { } continue } - // Drop Kubernetes-style marker comments (`+kubebuilder:…`, - // `+genclient`, `+k8s:…`) from the prose surface so they never leak - // into model / property descriptions (go-swagger#2687, the residual - // of #3007). Done here (Stage 3) rather than at line classification - // so annotation bodies are untouched — the inline swagger:route - // parameters grammar uses `+name:` as a parameter separator - // (go-swagger#3100), and by this stage that body has already been - // folded into its keyword token by accumulateBodies. + // Drop Kubernetes-style marker comments (`+kubebuilder:…`, `+genclient`, `+k8s:…`) from the + // prose surface so they never leak into model / property descriptions (go-swagger#2687, the + // residual of #3007). + // + // Done here (Stage 3) rather than at line classification so annotation bodies are untouched — + // the inline swagger:route parameters grammar uses `+name:` as a parameter separator + // (go-swagger#3100), and by this stage that body has already been folded into its keyword token + // by accumulateBodies. if t.Kind == tokenText && isDirectiveMarker(t.Text) { continue } @@ -1112,10 +1276,9 @@ func classifyProse(in []Token) []Token { _ = 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. + // 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. return classifyProseRunsInPlace(out, hasAnnotation) } @@ -1127,15 +1290,15 @@ const ( proseInBody ) -// classifyProseRunsInPlace walks `out` and re-types contiguous runs of -// (tokenText / TokenBlank) into TITLE / DESC. The first prose run is -// split into title + description; later prose runs become DESC. +// classifyProseRunsInPlace walks `out` and re-types contiguous runs of (tokenText / TokenBlank) +// into TITLE / DESC. +// +// The first prose run is split into title + description; later prose runs become DESC. // -// The annotation flag is no longer consulted — heuristics fire on -// UnboundBlock-style comments (no swagger annotation) too, because -// such comments render as schemas through indirect references (e.g. -// a non-annotated interface embedded by a swagger:model parent) and -// the consumer still wants the title/description split. +// The annotation flag is no longer consulted — heuristics fire on UnboundBlock-style comments (no +// swagger annotation) too, because such comments render as schemas through indirect references +// (e.g. a non-annotated interface embedded by a swagger:model parent) and the consumer still wants +// the title/description split. func classifyProseRunsInPlace(out []Token, _ bool) []Token { firstRun := true for i := 0; i < len(out); { @@ -1158,8 +1321,10 @@ func classifyProseRunsInPlace(out []Token, _ bool) []Token { return out } -// classifyTitleDescRun applies the four prose heuristics to a single -// contiguous prose run [start, end). See README §prose-classification. +// classifyTitleDescRun applies the four prose heuristics to a single contiguous prose run [start, +// end). +// +// See README §prose-classification. func classifyTitleDescRun(out []Token, start, end int) { // Find the first text-line index inside the run. firstText := -1 @@ -1175,15 +1340,12 @@ func classifyTitleDescRun(out []Token, start, end int) { 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. + // 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. // - // On a heuristic-1 split, also strip an ATX heading marker from - // the first title line so the rendered title doesn't carry the - // `#`+ prefix. + // On a heuristic-1 split, also strip an ATX heading marker from the first title line so the + // rendered title doesn't carry the `#`+ prefix. for k := firstText + 1; k < end; k++ { if out[k].Kind != TokenBlank { continue @@ -1214,8 +1376,7 @@ func classifyTitleDescRun(out []Token, start, end int) { return } - // Heuristic 3: first line matches a markdown ATX heading -> strip - // marker, title is line 1. + // 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) @@ -1227,10 +1388,10 @@ func classifyTitleDescRun(out []Token, start, end int) { 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. +// 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 { @@ -1239,10 +1400,11 @@ func retypeRunAs(out []Token, start, end int, kind TokenKind) { } } -// 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. +// 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] == '#' { @@ -1251,8 +1413,7 @@ func stripATXHeading(s string) (rest string, ok bool) { if i == 0 { return s, false } - // Need at least one whitespace separator between the # run and - // the heading text. + // Need at least one whitespace separator between the # run and the heading text. if i >= len(s) { return s, false } @@ -1264,9 +1425,8 @@ func stripATXHeading(s string) (rest string, ok bool) { return strings.TrimSpace(s[i+1:]), true } -// endsWithPunct reports whether s ends in Unicode punctuation other -// than dash/connector — implementation looks for category Po -// ("punctuation, other") on the last rune. +// endsWithPunct reports whether s ends in Unicode punctuation other than dash/connector — +// implementation looks for category Po ("punctuation, other") on the last rune. func endsWithPunct(s string) bool { s = strings.TrimRightFunc(s, unicode.IsSpace) if s == "" { @@ -1277,6 +1437,7 @@ func endsWithPunct(s string) bool { } // FormatToken renders a token compactly for diagnostics and tests. +// // Avoids leaking internal kinds in production output. func FormatToken(t Token) string { switch t.Kind { diff --git a/internal/parsers/grammar/lexer_literal_desc_test.go b/internal/parsers/grammar/lexer_literal_desc_test.go new file mode 100644 index 00000000..4298e82b --- /dev/null +++ b/internal/parsers/grammar/lexer_literal_desc_test.go @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "strings" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// descArg returns the folded argument text of the (first) swagger:description annotation in the +// token stream, or fails the test. +func descArg(t *testing.T, out []Token) string { + t.Helper() + for _, tk := range out { + if tk.Kind == TokenAnnotation && tk.Name == labelDescription { + require.Len(t, tk.Args, 1, "description annotation should carry one folded arg") + return tk.Args[0].Text + } + } + t.Fatalf("no swagger:description annotation in token stream") + return "" +} + +func hasAnnotation(out []Token, name string) bool { //nolint:unparam // other values than "model" possible in the future. + for _, tk := range out { + if tk.Kind == TokenAnnotation && tk.Name == name { + return true + } + } + return false +} + +func hasKind(out []Token, k TokenKind) bool { + for _, tk := range out { + if tk.Kind == k { + return true + } + } + return false +} + +// TestLexer_DescriptionLiteral_PreservesMarkdown is the core contract: a `swagger:description |` +// block captures the body verbatim — indentation, a significant blank line, and markdown table +// pipes all survive. +func TestLexer_DescriptionLiteral_PreservesMarkdown(t *testing.T) { + // Body lines carry the leading godoc `// ` convention space (modelled here as a leading space in + // each line); it is dropped while author indentation beyond it — the 2-space nested list — is + // preserved. + src := strings.Join([]string{ + "swagger:description |", + " Overview", + "", + " | col1 | col2 |", + " |------|------|", + " - nested item", + }, "\n") + arg := descArg(t, lexString(t, src)) + + want := "Overview\n\n| col1 | col2 |\n|------|------|\n - nested item" + assert.Equal(t, want, arg) + // the `|` marker itself must not leak into the body. + assert.NotEqual(t, "|", strings.Split(arg, "\n")[0]) +} + +// TestLexer_DescriptionLiteral_DashKeepsFollowingAnnotation is the regression the stage-1 literal +// mode exists for: a lone `---` in the body must not open a YAML fence and swallow the following +// annotation. +func TestLexer_DescriptionLiteral_DashKeepsFollowingAnnotation(t *testing.T) { + src := strings.Join([]string{ + "swagger:description |", + "Overview", + "---", + "after the dash", + "swagger:model Foo", + }, "\n") + out := lexString(t, src) + + arg := descArg(t, out) + assert.Equal(t, "Overview\n---\nafter the dash", arg, "the --- line is body, captured verbatim") + assert.True(t, hasAnnotation(out, labelModel), "the following swagger:model must survive the --- in the body") + assert.False(t, hasKind(out, TokenOpaqueYaml), "the body --- must not open a YAML fence") +} + +// TestLexer_DescriptionLiteral_BlankDoesNotTerminate: unlike the default Option B fold, a blank +// line inside the literal block is body, not a terminator; the block ends at the next annotation. +func TestLexer_DescriptionLiteral_BlankDoesNotTerminate(t *testing.T) { + src := strings.Join([]string{ + "swagger:description |", + "para one", + "", + "para two", + "swagger:model Foo", + }, "\n") + out := lexString(t, src) + assert.Equal(t, "para one\n\npara two", descArg(t, out)) + assert.True(t, hasAnnotation(out, labelModel)) +} + +// TestLexer_DescriptionLiteral_KeywordLineIsBody: a keyword-looking line inside the block is +// captured as body, never treated as a terminator (decision 3 — no keyword sensitivity). +func TestLexer_DescriptionLiteral_KeywordLineIsBody(t *testing.T) { + src := strings.Join([]string{ + "swagger:description |", + "Body mentioning maximum: 5 inline", + "default: not a keyword here", + }, "\n") + arg := descArg(t, lexString(t, src)) + assert.Contains(t, arg, "maximum: 5") + assert.Contains(t, arg, "default: not a keyword here") +} + +// TestLexer_DescriptionLiteral_MidLineSwaggerIsBody: only an annotation at the START of a line +// terminates the block; a `swagger:` token mid-line is prose. +func TestLexer_DescriptionLiteral_MidLineSwaggerIsBody(t *testing.T) { + src := strings.Join([]string{ + "swagger:description |", + "You can write swagger:model in the middle of a sentence.", + "And mention swagger:meta too.", + "swagger:model Foo", + }, "\n") + out := lexString(t, src) + arg := descArg(t, out) + assert.Equal(t, "You can write swagger:model in the middle of a sentence.\nAnd mention swagger:meta too.", arg) + assert.True(t, hasAnnotation(out, labelModel), "the line-leading swagger:model terminates and survives") +} + +// TestLexer_DescriptionLiteral_IndentedSwaggerStillTerminates documents the edge: the +// comment-prefix strip removes leading indentation before the annotation check, so even an indented +// line that BEGINS with `swagger:` terminates the block (you cannot hide it by indenting, e.g. in a +// code block). +func TestLexer_DescriptionLiteral_IndentedSwaggerStillTerminates(t *testing.T) { + src := strings.Join([]string{ + "swagger:description |", + "body line", + " swagger:model Foo", + }, "\n") + out := lexString(t, src) + assert.Equal(t, "body line", descArg(t, out)) + assert.True(t, hasAnnotation(out, labelModel)) +} + +// TestLexer_DescriptionLiteral_TrailingBlankClipped: bare `|` clips trailing blank lines (interior +// ones are kept, see above). +func TestLexer_DescriptionLiteral_TrailingBlankClipped(t *testing.T) { + src := strings.Join([]string{ + "swagger:description |", + "body", + "", + "", + }, "\n") + assert.Equal(t, "body", descArg(t, lexString(t, src))) +} + +// TestLexer_DescriptionLiteral_EmptyBody: a marker with no body folds to an empty description +// rather than leaking the `|`. +func TestLexer_DescriptionLiteral_EmptyBody(t *testing.T) { + assert.Equal(t, "", descArg(t, lexString(t, "swagger:description |"))) +} + +// TestLexer_DescriptionLiteral_PlainUnchanged: without the `|` marker the default Option B +// behaviour is intact — the body folds to the first blank line and trailing prose stays out of +// the description. +func TestLexer_DescriptionLiteral_PlainUnchanged(t *testing.T) { + src := strings.Join([]string{ + "swagger:description short desc", + "continued on next line", + "", + "this trailing prose is not part of the description", + }, "\n") + out := lexString(t, src) + arg := descArg(t, out) + assert.Equal(t, "short desc\ncontinued on next line", arg) + assert.NotContains(t, arg, "trailing prose") +} diff --git a/internal/parsers/grammar/lexer_test.go b/internal/parsers/grammar/lexer_test.go index d5102022..d07162de 100644 --- a/internal/parsers/grammar/lexer_test.go +++ b/internal/parsers/grammar/lexer_test.go @@ -42,8 +42,10 @@ func parseGoSource(t *testing.T, src string) Block { return nil } -// lexString preprocesses a comment block (without leading // markers) and -// runs the full Lex pipeline. Each input line becomes one Line entry. +// 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 @@ -106,6 +108,37 @@ func TestLexer_RouteWithGodocPrefix(t *testing.T) { assert.Equal(t, "listPets", out[0].Args[3].Text) } +func TestLexer_ParametersArgs(t *testing.T) { + t.Run("wildcard target", func(t *testing.T) { + out := lexString(t, "swagger:parameters * listPets") + require.NotEmpty(t, out) + require.Len(t, out[0].Args, 2) + assert.Equal(t, TokenWildcard, out[0].Args[0].Kind) + assert.Equal(t, "*", out[0].Args[0].Text) + assert.Equal(t, TokenIdentName, out[0].Args[1].Kind) + assert.Equal(t, "listPets", out[0].Args[1].Text) + }) + + t.Run("path target with shared name", func(t *testing.T) { + out := lexString(t, "swagger:parameters /pets X-Request-ID") + require.NotEmpty(t, out) + require.Len(t, out[0].Args, 2) + assert.Equal(t, TokenURLPath, out[0].Args[0].Kind) + assert.Equal(t, "/pets", out[0].Args[0].Text) + assert.Equal(t, TokenIdentName, out[0].Args[1].Kind) + assert.Equal(t, "X-Request-ID", out[0].Args[1].Text) + }) + + t.Run("wildcard only recognised in first position", func(t *testing.T) { + out := lexString(t, "swagger:parameters listPets *") + require.NotEmpty(t, out) + require.Len(t, out[0].Args, 2) + assert.Equal(t, TokenIdentName, out[0].Args[0].Kind) + assert.Equal(t, TokenIdentName, out[0].Args[1].Kind, "a non-leading * is a plain ident, not a wildcard") + assert.Equal(t, "*", out[0].Args[1].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") @@ -263,12 +296,10 @@ func TestLexer_TitleDescriptionSplit_BlankLineHeuristic(t *testing.T) { } 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. + // 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") @@ -291,12 +322,13 @@ func TestLexer_DefaultAnnotation_RawFallback(t *testing.T) { assert.Equal(t, TokenRawValue, out[0].Args[0].Kind) } -// TestLexer_TypeAnnotation_WellFormed pins the relaxed swagger:type lexing -// (F3): the grammar no longer owns a closed type vocabulary. Any well-formed -// token — a canonical name, a Go-builtin spelling, a []-prefixed array, a -// dot-qualified or arbitrary identifier (a scanned-type reference) — lexes as -// TYPE_REF; semantic validity is resolved by the builder. Only a structurally -// malformed token falls back to IDENT_NAME for the parser to flag. +// TestLexer_TypeAnnotation_WellFormed pins the relaxed swagger:type lexing (F3): the grammar no +// longer owns a closed type vocabulary. +// +// Any well-formed token — a canonical name, a Go-builtin spelling, a []-prefixed array, a +// dot-qualified or arbitrary identifier (a scanned-type reference) — lexes as TYPE_REF; semantic +// validity is resolved by the builder. +// Only a structurally malformed token falls back to IDENT_NAME for the parser to flag. func TestLexer_TypeAnnotation_WellFormed(t *testing.T) { for _, arg := range []string{"string", "integer", "int64", "[]string", "[][]int64", "Custom", "pkg.Type", "inline"} { out := lexString(t, "swagger:type "+arg) @@ -391,9 +423,8 @@ func TestLexer_TrailingWhitespaceOnKeywordLine(t *testing.T) { } 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. + // 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) @@ -438,16 +469,15 @@ func TestLexer_GoDirectivesDroppedFromProse(t *testing.T) { 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. + // `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. + // 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.", "", @@ -462,9 +492,8 @@ func TestLexer_DirectiveDoesNotPolluteTitle(t *testing.T) { "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. + // 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) @@ -475,9 +504,8 @@ func TestLexer_DirectiveDoesNotPolluteTitle(t *testing.T) { 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. + // 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. @@ -547,8 +575,8 @@ func TestLexer_DirectiveMarkerPredicate(t *testing.T) { } func TestLexer_DirectiveMarkersDroppedFromProse(t *testing.T) { - // go-swagger#2687 / #3007: Kubernetes marker comments must not leak into - // the model title/description. + // go-swagger#2687 / #3007: Kubernetes marker comments must not leak into the model + // title/description. src := `package fake // MyType description @@ -570,8 +598,9 @@ type MyType struct{} assert.NotContains(t, prose, "+k8s") } -// Note: the #3100 boundary — the inline swagger:route `+name:` parameter -// separator must NOT be stripped despite matching the marker shape — is locked -// end-to-end by TestCoverage_Bug3100 in the integration suite. The filter runs -// at Stage 3 (prose), after accumulateBodies has folded the route body into its +// Note: the #3100 boundary — the inline swagger:route `+name:` parameter separator must NOT be +// stripped despite matching the marker shape — is locked end-to-end by TestCoverage_Bug3100 in +// the integration suite. +// +// The filter runs at Stage 3 (prose), after accumulateBodies has folded the route body into its // keyword token, so the separator never reaches the marker check. diff --git a/internal/parsers/grammar/meta_info.go b/internal/parsers/grammar/meta_info.go index 82aa21a4..45970c3c 100644 --- a/internal/parsers/grammar/meta_info.go +++ b/internal/parsers/grammar/meta_info.go @@ -8,16 +8,16 @@ import ( "strings" ) -// Contact is the typed shape of a `contact:` inline value on a -// swagger:meta block. The convention is: +// 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(). +// 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 } @@ -26,20 +26,19 @@ type Contact struct { // // 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(). +// 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). +// 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 == "" { @@ -57,9 +56,9 @@ func parseContact(line string) (Contact, error) { } // 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 +// +// 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) @@ -76,9 +75,10 @@ func parseLicense(line string) (License, bool) { //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. +// 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 diff --git a/internal/parsers/grammar/parser.go b/internal/parsers/grammar/parser.go index ebc0d5b6..29f171fb 100644 --- a/internal/parsers/grammar/parser.go +++ b/internal/parsers/grammar/parser.go @@ -14,15 +14,14 @@ import ( "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. +// 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. // // # Details // -// See README §parser-contract for the family dispatch table and -// the body-token consumption rules. +// See README §parser-contract for the family dispatch table and the body-token consumption rules. type Parser interface { Parse(cg *ast.CommentGroup) Block ParseAll(cg *ast.CommentGroup) []Block @@ -37,8 +36,8 @@ type DefaultParser struct { singleLineAsDesc bool } -// NewParser constructs a DefaultParser bound to a FileSet (needed to -// map *ast.CommentGroup positions to absolute source positions). +// 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 { @@ -50,16 +49,17 @@ func NewParser(fset *token.FileSet, opts ...Option) *DefaultParser { // Option configures a DefaultParser. type Option func(*DefaultParser) -// WithDiagnosticSink streams diagnostics to a callback in addition to -// accumulating them on the returned Block. +// 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 } } -// WithSingleLineCommentAsDescription makes a single-line prose comment -// resolve to the block Description instead of the Title, regardless of -// trailing punctuation. Multi-line comments keep the normal -// title/description split. See go-swagger/go-swagger#2626. +// WithSingleLineCommentAsDescription makes a single-line prose comment resolve to the block +// Description instead of the Title, regardless of trailing punctuation. +// +// Multi-line comments keep the normal title/description split. +// See go-swagger/go-swagger#2626. func WithSingleLineCommentAsDescription(on bool) Option { return func(p *DefaultParser) { p.singleLineAsDesc = on } } @@ -72,25 +72,25 @@ func (p *DefaultParser) Parse(cg *ast.CommentGroup) Block { } // 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. +// 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. // -// Multi-annotation comments like +// Multi-annotation comments like. // // // swagger:model // // swagger:strfmt date-time // -// pair a schema-family annotation with a classifier; the schema -// builder's Walker dispatches on each Block's AnnotationKind() -// without further partitioning. +// pair a schema-family annotation with a classifier; the schema builder's Walker dispatches on each +// Block's AnnotationKind() without further partitioning. func (p *DefaultParser) ParseAll(cg *ast.CommentGroup) []Block { lines := Preprocess(cg, p.fset) tokens := Lex(lines) @@ -116,8 +116,7 @@ func Parse(cg *ast.CommentGroup, fset *token.FileSet) Block { return NewParser(fset).Parse(cg) } -// ParseAll is the convenience wrapper around -// NewParser(fset).ParseAll(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) } @@ -131,8 +130,7 @@ func ParseTokens(tokens []Token) Block { return p.parseTokens(tokens) } -// parseAllTokens implements the multi-annotation slicing rule -// documented on ParseAll. +// 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 { @@ -141,9 +139,8 @@ func (p *DefaultParser) parseAllTokens(tokens []Token) []Block { } } 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. + // 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)) @@ -161,14 +158,13 @@ func (p *DefaultParser) parseAllTokens(tokens []Token) []Block { // parseState holds per-block parsing state. // -// Today's parsers walk s.tokens via range loops because the token -// classifier serialises the body — 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 future -// order-sensitive productions (strict positional checks on -// EnumDeclBlock's annotation header → RAW_VALUE_ENUM body, or LSP -// partial-parse resumption from a cursor). See README -// §parser-contract. +// Today's parsers walk s.tokens via range loops because the token classifier serialises the body +// — 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 future order-sensitive +// productions (strict positional checks on EnumDeclBlock's annotation header → RAW_VALUE_ENUM +// body, or LSP partial-parse resumption from a cursor). +// See README §parser-contract. // // To find every unused-on-purpose site: // @@ -250,8 +246,8 @@ func findAnnotation(tokens []Token) int { return -1 } -// extractTitleDesc walks the prose tokens of a block (or a sub-slice -// such as the pre-annotation preamble) and returns: +// 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 @@ -260,9 +256,8 @@ func findAnnotation(tokens []Token) int { // blanks rendered as "" — the ProseLines / PreambleLines shape // consumers use // -// Join semantics: a single trailing blank is dropped from each -// side, internal blanks are kept verbatim so paragraph breaks -// survive. +// Join semantics: a single trailing blank is dropped from each side, internal blanks are kept +// verbatim so paragraph breaks survive. func extractTitleDesc(tokens []Token) (title, desc string, lines []string) { var titleLines, descLines []string const ( @@ -286,9 +281,9 @@ func extractTitleDesc(tokens []Token) (title, desc string, lines []string) { 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. + // 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, "") @@ -306,11 +301,11 @@ func extractTitleDesc(tokens []Token) (title, desc string, lines []string) { return title, desc, lines } -// dropTrailingBlankLines drops every trailing whitespace-only line -// from ls. The state-machine in extractTitleDesc over-appends -// separator blanks (e.g. a TITLE → BLANK+BLANK → DESC sequence -// pushes both blanks onto titleLines); trimming the whole tail -// lands on the desired shape. +// dropTrailingBlankLines drops every trailing whitespace-only line from ls. +// +// The state-machine in extractTitleDesc over-appends separator blanks (e.g. a TITLE → BLANK+BLANK +// → DESC sequence pushes both blanks onto titleLines); trimming the whole tail lands on the +// desired shape. func dropTrailingBlankLines(ls []string) []string { for len(ls) > 0 && strings.TrimSpace(ls[len(ls)-1]) == "" { ls = ls[:len(ls)-1] @@ -318,15 +313,15 @@ func dropTrailingBlankLines(ls []string) []string { return ls } -// finaliseBase populates Title/Description/ProseLines/PreambleLines -// and copies accumulated diagnostics onto the base block. +// 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. // -// 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. Routes / operations / -// meta consult Title() and Description(), which span the whole -// block. +// Routes / operations / meta consult Title() and Description(), which span the whole block. func (s *parseState) finaliseBase(base *baseBlock) { t, d, lines := extractTitleDesc(s.tokens) t, d = s.demoteSingleLineTitle(t, d) @@ -347,12 +342,12 @@ func (s *parseState) finaliseBase(base *baseBlock) { base.diagnostics = append(base.diagnostics, s.diags...) } -// demoteSingleLineTitle implements the SingleLineCommentAsDescription -// option (go-swagger#2626): when the prose resolved to a one-line title -// with no description, move that single line to the description so a -// lone doc comment never becomes a title / summary. A title spanning -// multiple lines, or any prose that already produced a description -// (i.e. a multi-line comment), is left untouched. +// demoteSingleLineTitle implements the SingleLineCommentAsDescription option (go-swagger#2626): +// when the prose resolved to a one-line title with no description, move that single line to the +// description so a lone doc comment never becomes a title / summary. +// +// A title spanning multiple lines, or any prose that already produced a description (i.e. a +// multi-line comment), is left untouched. func (s *parseState) demoteSingleLineTitle(title, desc string) (string, string) { if !s.singleLineAsDesc { return title, desc @@ -375,8 +370,8 @@ func (s *parseState) parseUnboundBlock() Block { return &UnboundBlock{baseBlock: base} } -// firstMeaningfulPos picks the first non-blank, non-EOF token's -// position so an UnboundBlock has a sensible Pos(). +// 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 { @@ -395,13 +390,13 @@ func firstMeaningfulPos(tokens []Token) token.Position { 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. + // 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 { + if len(annTok.Args) == 0 { s.emit(Errorf(annTok.Pos, CodeMissingRequiredArg, - "swagger:parameters requires at least one operation id reference")) + "swagger:parameters requires a target (an operation id, `*`, or `/path`)")) } case AnnName: if firstIdentArg(annTok) == "" { @@ -409,14 +404,13 @@ func (s *parseState) parseSchemaBlock(annIdx int, annTok Token, kind AnnotationK "swagger:name requires a member name override argument")) } default: - // other schema-family annotations either accept no args (AnnModel) - // or accept an optional name (AnnResponse). + // 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. + // 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 @@ -431,9 +425,15 @@ func (s *parseState) parseSchemaBlock(annIdx int, annTok Token, kind AnnotationK case AnnResponse: return &ResponseBlock{baseBlock: base, Name: firstIdentArg(annTok)} case AnnParameters: - return &ParametersBlock{baseBlock: base, OperationIDs: identArgs(annTok)} + target, path, args, dups := parseParametersArgs(annTok) + return &ParametersBlock{baseBlock: base, Target: target, Path: path, Args: args, Dups: dups} case AnnName: return &NameBlock{baseBlock: base, Name: firstIdentArg(annTok)} + case AnnTitle, AnnDescription: + // Override annotations dispatch through the schema parser (so co-located validation keywords + // surface as Properties) but carry no typed block of their own — a ClassifierBlock holds the + // free-text arg (AnnotationArg) plus any harvested body keywords. + return &ClassifierBlock{baseBlock: base, Args: annTok.Args} default: return &UnboundBlock{baseBlock: base} } @@ -481,8 +481,9 @@ func (s *parseState) parseOperationBlock(annIdx int, annTok Token, kind Annotati } // parseOperationArgs extracts METHOD, /path, [tags…], OperationID. -// Trailing IDENT_NAME is the OpID; any preceding IDENT_NAMEs are -// tags. See README §annotation-args. +// +// Trailing IDENT_NAME is the OpID; any preceding IDENT_NAMEs are tags. +// See README §annotation-args. func (s *parseState) parseOperationArgs(annTok Token) (method, path string, tags []string, opID string) { args := annTok.Args // Method. @@ -543,9 +544,10 @@ func (s *parseState) parseMetaBlock(annIdx int, annTok Token) Block { func (s *parseState) parseClassifierBlock(annIdx int, annTok Token, kind AnnotationKind) Block { base := newBaseBlock(kind, annTok.Pos) - // Classifier bodies are prose-only. EnumDeclBlock additionally - // allows a multi-line RAW_VALUE_ENUM body. Other body content - // surfaces as a context-invalid warning. + // Classifier bodies are prose-only. + // + // 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 { @@ -575,16 +577,13 @@ func (s *parseState) parseClassifierBlock(annIdx int, annTok Token, kind Annotat } } - // Argument validation runs before finaliseBase so its diagnostics - // reach the returned Block. + // Argument validation runs before finaliseBase so its diagnostics reach the returned Block. switch kind { case AnnEnum: - // A bare `swagger:enum` (no name, no inline values, no body) is valid - // on a type declaration: the builder infers the enum name from the - // declared type and collects its consts (F4b). Only the grammar's - // structural shape is checked here, and the bare form is structurally - // fine — semantic resolution (does a type with consts exist?) is the - // builder's job. + // A bare `swagger:enum` (no name, no inline values, no body) is valid on a type declaration: the + // builder infers the enum name from the declared type and collects its consts (F4b). + // Only the grammar's structural shape is checked here, and the bare form is structurally fine — + // semantic resolution (does a type with consts exist?) is the builder's job. form, name, _, valuesArgs := splitEnumArgs(annTok) s.finaliseBase(base) return &EnumDeclBlock{ @@ -605,11 +604,10 @@ func (s *parseState) parseClassifierBlock(annIdx int, annTok Token, kind Annotat "swagger:default requires a value argument")) } case AnnType: - // Only the STRUCTURAL shape is checked here: a missing arg, or a - // malformed token (embedded spaces, bare `[]`, illegal chars). - // Whether the (well-formed) name is a known keyword / scanned type - // is resolved by the builder, which alone knows the scanned - // definitions and the annotated Go type (F3 reconciliation). + // Only the STRUCTURAL shape is checked here: a missing arg, or a malformed token (embedded + // spaces, bare `[]`, illegal chars). + // Whether the (well-formed) name is a known keyword / scanned type is resolved by the builder, + // which alone knows the scanned definitions and the annotated Go type (F3 reconciliation). if len(annTok.Args) == 0 { s.emit(Errorf(annTok.Pos, CodeMissingRequiredArg, "swagger:type requires a type-reference argument")) @@ -626,8 +624,8 @@ func (s *parseState) parseClassifierBlock(annIdx int, annTok Token, kind Annotat return &ClassifierBlock{baseBlock: base, Args: annTok.Args} } -// splitEnumArgs reconstructs the (form, name, name-pos, value-tokens) -// from a TokenAnnotation produced for swagger:enum. +// 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 @@ -668,22 +666,51 @@ func firstIdentArg(annTok Token) string { 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) +// parseParametersArgs classifies the arguments of a `swagger:parameters` marker into a target +// (operations / shared `*` / `/path`) plus the remaining argument tokens, de-duplicated. +// +// Dropped duplicates are returned separately so the builder can raise a duplicate-target / +// duplicate-ref warning. +// The definition-vs-reference reading of the args is the builder's, since it depends on the host +// declaration. +func parseParametersArgs(annTok Token) (target ParametersTarget, path string, args, dups []string) { + target = ParamTargetOperations + rest := annTok.Args + if len(rest) > 0 { + switch rest[0].Kind { + case TokenWildcard: + target = ParamTargetShared + rest = rest[1:] + case TokenURLPath: + target = ParamTargetPath + path = rest[0].Text + rest = rest[1:] + default: + // First token is an operation id: keep it among the args. } } - return out + + seen := make(map[string]struct{}, len(rest)) + for _, a := range rest { + if a.Text == "" { + continue + } + if _, dup := seen[a.Text]; dup { + dups = append(dups, a.Text) + continue + } + seen[a.Text] = struct{}{} + args = append(args, a.Text) + } + return target, path, args, dups } // --- 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). +// 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: @@ -705,8 +732,8 @@ func (s *parseState) consumeBodyToken(base *baseBlock, t Token, kind AnnotationK "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. + // 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)) } @@ -716,8 +743,7 @@ func (s *parseState) consumeBodyToken(base *baseBlock, t Token, kind AnnotationK 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. + // Lexer should never emit TokenKeyword for unknown keywords, but guard defensively. return } if !contextLegal(kw, kind) { @@ -735,10 +761,11 @@ func (s *parseState) emitInlineKeyword(base *baseBlock, t Token, kind Annotation 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". +// 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{} @@ -788,8 +815,8 @@ func (s *parseState) typeInlineValue(kw Keyword, t Token) TypedValue { return TypedValue{} } -// emitRawBlock stores a multi-line raw-block body and, for extensions -// blocks, parses out the x-* entries. +// 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 { @@ -841,17 +868,17 @@ func (s *parseState) emitRawValue(base *baseBlock, t Token, kind AnnotationKind) 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`). +// 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 emit a CodeInvalidAnnotation warning and are dropped. -// A YAML parse failure emits CodeInvalidYAMLExtensions and the block -// is skipped (no Extension entries are registered). +// A YAML parse failure emits CodeInvalidYAMLExtensions and the block is skipped (no Extension +// entries are registered). // -// Position is currently coarse — every Extension shares t.Pos -// (the `extensions:` keyword's position). See README §typed-extensions. +// Position is currently coarse — every Extension shares t.Pos (the `extensions:` keyword's +// position). +// See README §typed-extensions. func (s *parseState) collectExtensionsFromBody(base *baseBlock, t Token, source string) { data, err := yaml.TypedExtensions(t.Body) if err != nil { @@ -861,13 +888,12 @@ func (s *parseState) collectExtensionsFromBody(base *baseBlock, t Token, source } for name, value := range data { if !isExtensionName(name) { - // Non-x-* key — diagnose then drop. Authors who typo a - // vendor-extension key (e.g. `invalid-key:` under - // `Extensions:`) get a CodeInvalidAnnotation warning - // rather than silent loss. Builders that consume - // block.Extensions() never see the rejected entry; they - // observe the diagnostic via the Diagnostic Walker - // callback. + // Non-x-* key — diagnose then drop. + // Authors who typo a vendor-extension key (e.g. `invalid-key:` under `Extensions:`) get a + // CodeInvalidAnnotation warning rather than silent loss. + // + // Builders that consume block.Extensions() never see the rejected entry; they observe the + // diagnostic via the Diagnostic Walker callback. s.emit(Warnf(t.Pos, CodeInvalidAnnotation, "extensions block: %q is not a valid vendor-extension name (must start with x- or X-); dropped", name)) @@ -896,10 +922,10 @@ func isExtensionName(s string) bool { // --- 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). +// 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 { @@ -913,10 +939,10 @@ func contextLegal(kw Keyword, kind AnnotationKind) bool { return false } -// allowedContexts maps an annotation kind to the keyword contexts -// that are legal under it. Classifier kinds have no body keywords -// (return nil — no parser-layer policy). See README -// §context-legality. +// allowedContexts maps an annotation kind to the keyword contexts that are legal under it. +// +// Classifier kinds have no body keywords (return nil — no parser-layer policy). +// See README §context-legality. func allowedContexts(kind AnnotationKind) []KeywordContext { switch kind { case AnnModel: @@ -949,8 +975,9 @@ func formatContexts(kw Keyword) string { return strings.Join(parts, ", ") } -// splitCmpOperator strips a leading comparison operator from a number -// value. Supports the `maximum: <5` form. +// splitCmpOperator strips a leading comparison operator from a number value. +// +// Supports the `maximum: <5` form. func splitCmpOperator(s string) (op, rest string) { s = strings.TrimLeft(s, " \t") for _, c := range []string{"<=", ">=", "<", ">", "="} { @@ -961,8 +988,8 @@ func splitCmpOperator(s string) (op, rest string) { return "", s } -// parseBool accepts only "true" or "false" (case-insensitive). stdlib -// strconv.ParseBool is too lenient for the annotation grammar. +// parseBool accepts only "true" or "false" (case-insensitive). stdlib strconv.ParseBool is too +// lenient for the annotation grammar. func parseBool(s string) (bool, bool) { s = strings.TrimSpace(s) switch { diff --git a/internal/parsers/grammar/parser_test.go b/internal/parsers/grammar/parser_test.go index 53a3cb35..eae37614 100644 --- a/internal/parsers/grammar/parser_test.go +++ b/internal/parsers/grammar/parser_test.go @@ -38,6 +38,14 @@ func TestParser_ResponseBlock_OptionalName(t *testing.T) { rb2, ok := b2.(*ResponseBlock) require.True(t, ok) assert.Empty(t, rb2.Name) + + // `swagger:response *` is a synonym for the bare form: no name (the builder keys it by the type + // name), no diagnostics. + b3 := parseString(t, "swagger:response *") + rb3, ok := b3.(*ResponseBlock) + require.True(t, ok) + assert.Empty(t, rb3.Name) + assert.Empty(t, b3.Diagnostics()) } func TestParser_NameBlock_CapturesIdentArg(t *testing.T) { @@ -74,8 +82,8 @@ func TestParser_SingleLineCommentAsDescription(t *testing.T) { WithSingleLineCommentAsDescription(on)).ParseText(src, pos) } - // Single-line title-shaped comment: default keeps it as title; the - // option moves it to the description. + // Single-line title-shaped comment: default keeps it as title; the option moves it to the + // description. const single = "A one-line comment.\n\nswagger:model Pet" def := parse(single, false) assert.Equal(t, "A one-line comment.", def.Title()) @@ -85,8 +93,8 @@ func TestParser_SingleLineCommentAsDescription(t *testing.T) { assert.Empty(t, on.Title(), "single-line comment is no longer a title") assert.Equal(t, "A one-line comment.", on.Description()) - // Multi-line comment: the title/description split is preserved in both - // modes — the option only affects single-line comments. + // Multi-line comment: the title/description split is preserved in both modes — the option only + // affects single-line comments. const multi = "Title line.\n\nDescription body.\n\nswagger:model Pet" multiOn := parse(multi, true) assert.Equal(t, "Title line.", multiOn.Title(), "multi-line title unchanged by the option") @@ -200,8 +208,8 @@ func TestAnnotationKind_PatternProperties_RoundTrip(t *testing.T) { } func TestParser_PatternPropertiesBlock_CapturesRawPairList(t *testing.T) { - // The whole `"": , …` remainder is captured verbatim as one arg - // (it contains spaces/colons/commas the builder parses). + // The whole `"": , …` remainder is captured verbatim as one arg (it contains + // spaces/colons/commas the builder parses). b := parseString(t, `swagger:patternProperties "^x-": string, "^\d+$": integer`) cb, ok := b.(*ClassifierBlock) require.True(t, ok, "expected *ClassifierBlock, got %T", b) @@ -211,21 +219,168 @@ func TestParser_PatternPropertiesBlock_CapturesRawPairList(t *testing.T) { assert.Equal(t, `"^x-": string, "^\d+$": integer`, arg) } +func TestAnnotationKind_Title_RoundTrip(t *testing.T) { + assert.Equal(t, "title", AnnTitle.String()) + assert.Equal(t, AnnTitle, AnnotationKindFromName("title")) +} + +func TestAnnotationKind_Description_RoundTrip(t *testing.T) { + assert.Equal(t, "description", AnnDescription.String()) + assert.Equal(t, AnnDescription, AnnotationKindFromName("description")) +} + +func TestParser_TitleOverride_CapturesWholeLine(t *testing.T) { + // The whole rest of the line is the title (a sentence with spaces). + b := parseString(t, "swagger:title A Foo Widget") + cb, ok := b.(*ClassifierBlock) + require.True(t, ok, "expected *ClassifierBlock, got %T", b) + assert.Equal(t, AnnTitle, cb.AnnotationKind()) + arg, hasArg := cb.AnnotationArg() + require.True(t, hasArg) + assert.Equal(t, "A Foo Widget", arg) + assert.Empty(t, cb.Diagnostics()) +} + +func TestParser_DescriptionOverride_CapturesWholeLine(t *testing.T) { + b := parseString(t, "swagger:description A foo widget exposed via the public API.") + cb, ok := b.(*ClassifierBlock) + require.True(t, ok, "expected *ClassifierBlock, got %T", b) + assert.Equal(t, AnnDescription, cb.AnnotationKind()) + arg, hasArg := cb.AnnotationArg() + require.True(t, hasArg) + assert.Equal(t, "A foo widget exposed via the public API.", arg) + assert.Empty(t, cb.Diagnostics()) +} + +func TestParser_DescriptionOverride_BareIsWellFormed(t *testing.T) { + // A bare swagger:description / swagger:title is well-formed grammar (no parse diagnostic): the + // empty value is the deliberate godoc-suppression affordance. + // The emptiness *warning* (scan.empty-override) is the builder consumption point's job, not the + // parser's (design D7 / §4). + for _, src := range []string{"swagger:description", "swagger:title", "swagger:description "} { + b := parseString(t, src) + assert.Emptyf(t, b.Diagnostics(), "%q is well-formed → no parser diagnostic", src) + arg, hasArg := b.AnnotationArg() + assert.Falsef(t, hasArg, "%q has no arg", src) + assert.Emptyf(t, arg, "%q arg is empty", src) + } +} + +func TestParser_DescriptionOverride_MultiLineBody(t *testing.T) { + // Option B: lines following swagger:description fold into the description (blank-line / keyword / + // annotation / EOF terminated), joined with "\n". + b := parseString(t, "swagger:description First line of the description.\nSecond line continues it.") + cb, ok := b.(*ClassifierBlock) + require.True(t, ok, "expected *ClassifierBlock, got %T", b) + assert.Equal(t, AnnDescription, cb.AnnotationKind()) + arg, hasArg := cb.AnnotationArg() + require.True(t, hasArg) + assert.Equal(t, "First line of the description.\nSecond line continues it.", arg) +} + +func TestParser_DescriptionOverride_BodyStopsAtBlankAndKeyword(t *testing.T) { + // A blank line ends the body; a following keyword is not swallowed. + b := parseString(t, "swagger:description The value.\nmaximum: 100") + cb, ok := b.(*ClassifierBlock) + require.True(t, ok, "expected *ClassifierBlock, got %T", b) + arg, _ := cb.AnnotationArg() + assert.Equal(t, "The value.", arg, "the maximum: keyword must not fold into the description") + + // Bare head + body-only (no inline) folds the body as the whole value. + b2 := parseString(t, "swagger:description\nBody only, no inline head.") + arg2, hasArg2 := b2.AnnotationArg() + require.True(t, hasArg2) + assert.Equal(t, "Body only, no inline head.", arg2) +} + +func TestParser_DescriptionOverride_CoexistsWithKeywords(t *testing.T) { + // description/title dispatch through the schema family (like swagger:name), so a co-located + // validation keyword surfaces as a Property rather than being rejected as context-invalid under a + // classifier block. + b := parseString(t, "swagger:description The value.\nmaximum: 100") + cb, ok := b.(*ClassifierBlock) + require.True(t, ok, "expected *ClassifierBlock, got %T", b) + assert.Equal(t, AnnDescription, cb.AnnotationKind()) + arg, _ := cb.AnnotationArg() + assert.Equal(t, "The value.", arg) + assert.Empty(t, cb.Diagnostics(), "the co-located keyword must not be rejected") + + var hasMax bool + for p := range cb.Properties() { + if p.Keyword.Name == KwMaximum { + hasMax = true + assert.Equal(t, "100", p.Value) + } + } + assert.True(t, hasMax, "maximum: must surface as a Property") +} + 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.Equal(t, ParamTargetOperations, pb.Target) + 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) + assert.Empty(t, pbad.OperationIDs()) require.NotEmpty(t, bad.Diagnostics()) assert.Equal(t, CodeMissingRequiredArg, bad.Diagnostics()[0].Code) } +func TestParser_ParametersBlock_Targets(t *testing.T) { + t.Run("shared register-only", func(t *testing.T) { + pb, ok := parseString(t, "swagger:parameters *").(*ParametersBlock) + require.True(t, ok) + assert.Equal(t, ParamTargetShared, pb.Target) + assert.Empty(t, pb.OperationIDs()) + assert.Empty(t, pb.Args) + assert.Empty(t, pb.Diagnostics()) + }) + + t.Run("shared register plus operation ids", func(t *testing.T) { + pb, ok := parseString(t, "swagger:parameters * listPets createPet").(*ParametersBlock) + require.True(t, ok) + assert.Equal(t, ParamTargetShared, pb.Target) + assert.Equal(t, []string{"listPets", "createPet"}, pb.Args) + assert.Nil(t, pb.OperationIDs()) // not an operations target + }) + + t.Run("path target", func(t *testing.T) { + pb, ok := parseString(t, "swagger:parameters /pets").(*ParametersBlock) + require.True(t, ok) + assert.Equal(t, ParamTargetPath, pb.Target) + assert.Equal(t, "/pets", pb.Path) + assert.Empty(t, pb.Args) + }) + + t.Run("path reference with shared name", func(t *testing.T) { + pb, ok := parseString(t, "swagger:parameters /pets X-Request-ID").(*ParametersBlock) + require.True(t, ok) + assert.Equal(t, ParamTargetPath, pb.Target) + assert.Equal(t, "/pets", pb.Path) + assert.Equal(t, []string{"X-Request-ID"}, pb.Args) + }) + + t.Run("operation reference with shared name (dashes preserved)", func(t *testing.T) { + pb, ok := parseString(t, "swagger:parameters listPets X-Request-ID").(*ParametersBlock) + require.True(t, ok) + assert.Equal(t, ParamTargetOperations, pb.Target) + assert.Equal(t, []string{"listPets", "X-Request-ID"}, pb.Args) + }) + + t.Run("duplicate argument dropped + recorded", func(t *testing.T) { + dup := "createThing" + pb, ok := parseString(t, "swagger:parameters * "+dup+" "+dup).(*ParametersBlock) + require.True(t, ok) + assert.Equal(t, []string{"createThing"}, pb.Args) + assert.Equal(t, []string{"createThing"}, pb.Dups) + }) +} + func TestParser_RouteBlock_BasicArgs(t *testing.T) { src := `Lists pets. @@ -348,12 +503,12 @@ func TestParser_ClassifierBlock_StrfmtMissingArg(t *testing.T) { assert.Equal(t, CodeMissingRequiredArg, b.Diagnostics()[0].Code) } -// TestParser_ClassifierBlock_TypeWellFormed pins the relaxed swagger:type -// parsing (F3): a well-formed argument — canonical name, Go builtin, array, or -// an arbitrary identifier standing for a scanned-type reference — no longer -// raises a parser diagnostic; semantic resolution (and any unknown-type -// diagnostic) is the builder's job. Only a structurally malformed token still -// raises CodeInvalidTypeRef. +// TestParser_ClassifierBlock_TypeWellFormed pins the relaxed swagger:type parsing (F3): a +// well-formed argument — canonical name, Go builtin, array, or an arbitrary identifier standing +// for a scanned-type reference — no longer raises a parser diagnostic; semantic resolution (and +// any unknown-type diagnostic) is the builder's job. +// +// Only a structurally malformed token still raises CodeInvalidTypeRef. func TestParser_ClassifierBlock_TypeWellFormed(t *testing.T) { for _, arg := range []string{"string", "integer", "int64", "[]string", "custom", "Custom"} { b := parseString(t, "swagger:type "+arg) @@ -381,12 +536,12 @@ func TestParser_EnumDecl_PlainList(t *testing.T) { assert.Equal(t, enumFormPlainOnly, eb.InlineForm) } -// TestParser_EnumDecl_Bare pins the relaxed bare-swagger:enum contract (F4b): -// a bare `swagger:enum` (no name, no inline values, no body) is structurally -// valid — it produces an EnumDeclBlock with an empty Name and raises NO parse -// diagnostic. The builder infers the enum name from the declared type and -// collects its consts; "no consts found" is a builder-level concern, not a -// grammar error. +// TestParser_EnumDecl_Bare pins the relaxed bare-swagger:enum contract (F4b): a bare `swagger:enum` +// (no name, no inline values, no body) is structurally valid — it produces an EnumDeclBlock with +// an empty Name and raises NO parse diagnostic. +// +// The builder infers the enum name from the declared type and collects its consts; "no consts +// found" is a builder-level concern, not a grammar error. func TestParser_EnumDecl_Bare(t *testing.T) { b := parseString(t, "swagger:enum") eb, ok := b.(*EnumDeclBlock) @@ -405,10 +560,10 @@ maxLength: 64` 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). + // 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()) @@ -477,10 +632,10 @@ Extensions: 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`. +// 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 @@ -508,9 +663,8 @@ Extensions: 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. +// 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 diff --git a/internal/parsers/grammar/pointer_path_test.go b/internal/parsers/grammar/pointer_path_test.go index e484bbff..d224a1d5 100644 --- a/internal/parsers/grammar/pointer_path_test.go +++ b/internal/parsers/grammar/pointer_path_test.go @@ -49,8 +49,8 @@ func TestPointerPath(t *testing.T) { }) t.Run("deprecated anchors on operations/routes but NOT schema", func(t *testing.T) { - // Lexically legal on a schema (so the lexer accepts it), but renders no - // schema node in OAS2 — must not be anchored there. + // Lexically legal on a schema (so the lexer accepts it), but renders no schema node in OAS2 — + // must not be anchored there. require.Contains(t, kw(t, KwDeprecated).Contexts, CtxSchema) _, ok := PointerPath(kw(t, KwDeprecated), CtxSchema) assert.False(t, ok, "deprecated must not anchor in a schema") @@ -68,8 +68,7 @@ func TestPointerPath(t *testing.T) { }) t.Run("context gate: a keyword not legal in ctx does not anchor", func(t *testing.T) { - // version is meta-only; it must not anchor in a schema even though it has - // a pointer path. + // version is meta-only; it must not anchor in a schema even though it has a pointer path. _, ok := PointerPath(kw(t, KwVersion), CtxSchema) assert.False(t, ok) }) diff --git a/internal/parsers/grammar/preprocess.go b/internal/parsers/grammar/preprocess.go index 908493dd..fa55c02a 100644 --- a/internal/parsers/grammar/preprocess.go +++ b/internal/parsers/grammar/preprocess.go @@ -13,15 +13,13 @@ import ( // 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. +// 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. +// 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 { @@ -34,10 +32,12 @@ type Line struct { 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. // -// Line endings are normalised before line splitting (\r\n → \n, -// lone \r → \n). See README §preprocess-contract. +// Returns nil for nil inputs. +// Pure: no syscalls, no side-effects. +// +// Line endings are normalised before line splitting (\r\n → \n, lone \r → \n). +// See README §preprocess-contract. func Preprocess(cg *ast.CommentGroup, fset *token.FileSet) []Line { if cg == nil || fset == nil { return nil @@ -51,8 +51,9 @@ func Preprocess(cg *ast.CommentGroup, fset *token.FileSet) []Line { return out } -// stripComment yields one Line per physical source line of one -// *ast.Comment. Handles both the // and /* */ forms. +// 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 { @@ -100,11 +101,11 @@ func stripComment(raw string, basePos token.Position) []Line { } func stripLine(s string, pos token.Position, rawStrip func(string) string) Line { - // Apply the comment-kind decoration strip (block-comment `* ` - // continuation, or a no-op for `//`) BEFORE the content-prefix trim, so - // the only leading `*` trimContentPrefix can see is a markdown list - // bullet — never godoc decoration. This keeps `* item` bullets - // identifiable as lists without mangling block-comment framing + // Apply the comment-kind decoration strip (block-comment `* ` continuation, or a no-op for `//`) + // BEFORE the content-prefix trim, so the only leading `*` trimContentPrefix can see is a markdown + // list bullet — never godoc decoration. + // + // This keeps `* item` bullets identifiable as lists without mangling block-comment framing // (go-swagger#1726). raw := rawStrip(s) stripped := trimContentPrefix(raw) @@ -114,13 +115,14 @@ func stripLine(s string, pos token.Position, rawStrip func(string) string) Line return Line{Text: stripped, Raw: raw, 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. +// 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. +// 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 { @@ -143,19 +145,18 @@ func stripBlockContinuation(s string) string { return s } -// trimContentPrefix strips godoc-style leading decoration (indentation, -// slashes, an optional markdown table pipe) and normalises a markdown list -// bullet to the canonical YAML `- ` form. +// trimContentPrefix strips godoc-style leading decoration (indentation, slashes, an optional +// markdown table pipe) and normalises a markdown list bullet to the canonical YAML `- ` form. +// +// `*` is NOT in the strip set: block-comment `* ` continuation decoration is already removed by +// stripLine before this runs, so a leading `*`/`+` here is a markdown bullet, not decoration. // -// `*` is NOT in the strip set: block-comment `* ` continuation decoration is -// already removed by stripLine before this runs, so a leading `*`/`+` here is -// a markdown bullet, not decoration. Normalising `* item` / `+ item` to -// `- item` makes every downstream consumer that already understands `- ` -// (prose descriptions, Property.AsList, enum bodies) treat markdown-style and -// YAML-style lists identically, in one place (go-swagger#1726). gofmt performs -// the same `*`→`-` rewrite on // doc-comment bullets, so this also matches the -// gofmt-canonical source form. A leading `-` is preserved so the YAML fence -// `---` survives intact. +// Normalising `* item` / `+ item` to `- item` makes every downstream consumer that already +// understands `- ` (prose descriptions, Property.AsList, enum bodies) treat markdown-style and +// YAML-style lists identically, in one place (go-swagger#1726). gofmt performs the same `*`→`-` +// rewrite on // doc-comment bullets, so this also matches the gofmt-canonical source form. +// +// A leading `-` is preserved so the YAML fence `---` survives intact. func trimContentPrefix(s string) string { s = strings.TrimLeft(s, " \t/") s = strings.TrimPrefix(s, "|") @@ -163,9 +164,10 @@ func trimContentPrefix(s string) string { return normalizeBullet(s) } -// normalizeBullet rewrites a leading markdown bullet marker (`* ` or `+ `) to -// the canonical `- `. The marker must be followed by a space (a CommonMark -// bullet), so `*emphasis*` and `**bold**` prose are left untouched. +// normalizeBullet rewrites a leading markdown bullet marker (`* ` or `+ `) to the canonical `- `. +// +// The marker must be followed by a space (a CommonMark bullet), so `*emphasis*` and `**bold**` +// prose are left untouched. func normalizeBullet(s string) string { if len(s) >= 2 && (s[0] == '*' || s[0] == '+') && s[1] == ' ' { return "- " + s[2:] @@ -173,9 +175,10 @@ func normalizeBullet(s string) string { return s } -// preprocessText handles raw text inputs (already stripped of -// comment markers) for ParseText / ParseAs. Line endings are -// normalised (\r\n → \n, lone \r → \n) before splitting. +// preprocessText handles raw text inputs (already stripped of comment markers) for ParseText / +// ParseAs. +// +// 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") diff --git a/internal/parsers/grammar/preprocess_test.go b/internal/parsers/grammar/preprocess_test.go index 0d354b8f..73b239b3 100644 --- a/internal/parsers/grammar/preprocess_test.go +++ b/internal/parsers/grammar/preprocess_test.go @@ -16,8 +16,8 @@ func TestStripBlockContinuation(t *testing.T) { } } -// TestNormalizeBullet covers the markdown-bullet → canonical `- ` rewrite that -// makes `* item` / `+ item` lists identifiable like `- item` (go-swagger#1726). +// TestNormalizeBullet covers the markdown-bullet → canonical `- ` rewrite that makes `* item` / +// `+ item` lists identifiable like `- item` (go-swagger#1726). func TestNormalizeBullet(t *testing.T) { cases := []struct { name string @@ -41,9 +41,9 @@ func TestNormalizeBullet(t *testing.T) { } } -// TestTrimContentPrefixBullets locks the end-to-end content-prefix behaviour: -// leading godoc decoration is shed and a markdown bullet is normalised, while -// the YAML fence and prose are preserved. +// TestTrimContentPrefixBullets locks the end-to-end content-prefix behaviour: leading godoc +// decoration is shed and a markdown bullet is normalised, while the YAML fence and prose are +// preserved. func TestTrimContentPrefixBullets(t *testing.T) { cases := []struct { name string diff --git a/internal/parsers/grammar/synthetic.go b/internal/parsers/grammar/synthetic.go index f5c62643..664bee5c 100644 --- a/internal/parsers/grammar/synthetic.go +++ b/internal/parsers/grammar/synthetic.go @@ -5,22 +5,22 @@ package grammar import "go/token" -// NewSyntheticBlock builds a Block from a manually-curated set of -// properties. Used by sub-parsers (routebody, future input modes) -// that lower a non-grammar text surface into the standard Block -// shape so consumers can dispatch through the usual Walker. +// NewSyntheticBlock builds a Block from a manually-curated set of properties. // -// title and description become the Block's Title()/Description(), -// also surfaced via Prose() with internal blank separation. pos is -// the source position of the synthetic block's head — Properties -// that lack their own Pos inherit it implicitly when consumers -// build diagnostics. +// Used by sub-parsers (routebody, future input modes) that lower a non-grammar text surface into +// the standard Block shape so consumers can dispatch through the usual Walker. // -// The returned Block exposes empty Diagnostics(), AnnotationKind() -// == AnnUnknown, no YAML blocks, no extensions, and no security -// requirements. AnnotationArg() returns ("", false). Walk fires -// Title/Description first when non-empty, then properties in slice -// order — the regular Walker contract. See README §synthetic-block. +// title and description become the Block's Title()/Description(), also surfaced via Prose() with +// internal blank separation. pos is the source position of the synthetic block's head — +// Properties that lack their own Pos inherit it implicitly when consumers build diagnostics. +// +// The returned Block exposes empty Diagnostics(), AnnotationKind() == AnnUnknown, no YAML blocks, +// no extensions, and no security requirements. +// AnnotationArg() returns ("", false). +// +// Walk fires Title/Description first when non-empty, then properties in slice order — the regular +// Walker contract. +// See README §synthetic-block. func NewSyntheticBlock(pos token.Position, title, description string, props []Property) Block { return &baseBlock{ pos: pos, diff --git a/internal/parsers/grammar/token.go b/internal/parsers/grammar/token.go index eb742bec..a2293ca6 100644 --- a/internal/parsers/grammar/token.go +++ b/internal/parsers/grammar/token.go @@ -5,8 +5,9 @@ package grammar import "go/token" -// TokenKind classifies a lexer-emitted token. The kinds map onto -// the grammar's terminal vocabulary: +// TokenKind classifies a lexer-emitted token. +// +// The kinds map onto the grammar's terminal vocabulary: // // - ANN_* → TokenAnnotation, Name carries the annotation label // - KW_* → TokenKeyword, Name carries the keyword label @@ -19,9 +20,8 @@ import "go/token" // - OPAQUE_YAML → TokenOpaqueYaml // - TITLE / DESC / BLANK / EOF → TokenTitle / TokenDesc / TokenBlank / TokenEOF // -// The lexer also emits a few internal kinds (tokenYAMLFence, -// tokenText, tokenKeywordPre, tokenRawLine, tokenDirective) that -// are consumed by intermediate stages and never appear in the +// The lexer also emits a few internal kinds (tokenYAMLFence, tokenText, tokenKeywordPre, +// tokenRawLine, tokenDirective) that are consumed by intermediate stages and never appear in the // final stream the parser consumes. type TokenKind int @@ -40,6 +40,7 @@ const ( TokenTypeRef // TYPE_REF TokenHTTPMethod // HTTP_METHOD TokenURLPath // URL_PATH + TokenWildcard // WILDCARD — the `*` shared-namespace target (swagger:parameters) TokenNumberValue // NUMBER_VALUE TokenIntValue // INT_VALUE TokenBoolValue // BOOL_VALUE @@ -85,6 +86,8 @@ func (k TokenKind) String() string { return "HTTP_METHOD" case TokenURLPath: return "URL_PATH" + case TokenWildcard: + return "WILDCARD" case TokenNumberValue: return "NUMBER_VALUE" case TokenIntValue: @@ -118,7 +121,9 @@ func (k TokenKind) String() string { } } -// Token is one lexer-emitted item. Field population varies by kind: +// Token is one lexer-emitted item. +// +// Field population varies by kind: // // - TokenAnnotation: Name = annotation label, Args are pre-tokenised // argument tokens already disambiguated by the lexer @@ -151,6 +156,7 @@ type Token struct { 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. +// 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 index 23af4d3f..7794eafa 100644 --- a/internal/parsers/grammar/walker.go +++ b/internal/parsers/grammar/walker.go @@ -3,16 +3,14 @@ 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. +// 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. // // # Details // -// See README §walker-contract for the dispatch order, the per- -// Keyword.Shape callback table, the Number/Integer/Bool typing- -// failure rule, the FilterDepth gating, and the concurrency -// contract. +// See README §walker-contract for the dispatch order, the per- Keyword.Shape callback table, the +// Number/Integer/Bool typing- failure rule, the FilterDepth gating, and the concurrency contract. type Walker struct { Title func(s string) Description func(s string) @@ -30,20 +28,21 @@ type Walker struct { 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. +// 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 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. +// 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. + // 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) @@ -71,8 +70,10 @@ func (b *baseBlock) Walk(w Walker) { } } -// walkerAcceptsDepth reports whether a property at itemsDepth should be -// dispatched given the walker's FilterDepth. AllDepths admits everything. +// 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 @@ -81,15 +82,14 @@ func walkerAcceptsDepth(filter, itemsDepth int) bool { } // 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. See README -// §walker-contract. +// 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. +// See README §walker-contract. func walkerDispatchProperty(w Walker, p Property) { if p.Keyword.Name == "" { if w.Unknown != nil { @@ -112,8 +112,8 @@ func walkerDispatchProperty(w Walker, p Property) { 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. + // 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) } @@ -132,11 +132,11 @@ func walkerDispatchProperty(w Walker, p Property) { } } -// isExclusiveOp reports whether the leading operator on a Number -// value signals exclusive-bound semantics. The 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. +// isExclusiveOp reports whether the leading operator on a Number value signals exclusive-bound +// semantics. +// +// The 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 index 895450fa..99208d5f 100644 --- a/internal/parsers/grammar/walker_test.go +++ b/internal/parsers/grammar/walker_test.go @@ -100,9 +100,8 @@ swagger:model Pet` } 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). + // 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` @@ -183,8 +182,9 @@ default: hello` } func TestProperty_IsTyped_FailedTypingIsFalse(t *testing.T) { - // Failed typing: lexer rejects "notanumber" as ShapeNumber, leaves - // Typed.Type at ShapeNone, emits a CodeInvalidNumber diagnostic. + // 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") @@ -196,8 +196,8 @@ func TestProperty_IsTyped_FailedTypingIsFalse(t *testing.T) { } 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). + // "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) @@ -207,11 +207,12 @@ fakekeyword: 42` 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. + // 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/matchers.go b/internal/parsers/matchers.go index b870b143..84747b3f 100644 --- a/internal/parsers/matchers.go +++ b/internal/parsers/matchers.go @@ -11,9 +11,10 @@ import ( const minMatchCount = 2 -// 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. +// 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 { @@ -23,48 +24,50 @@ func ExtractAnnotation(line string) (string, bool) { return matches[1], true } -// 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. +// 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. +// 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. +// 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) } -// MalformedModelName reports a `swagger:model` marker on line whose name -// argument is not a plain identifier. Definition/response names are JSON -// labels, not Go-qualified identifiers, so a package-qualified name such -// as `utils.Error` is rejected by the strict rxModelOverride and the -// marker would otherwise be silently dropped. Returns the offending raw -// argument and true; a bare marker or a well-formed name returns -// ("", false). +// MalformedModelName reports a `swagger:model` marker on line whose name argument is not a plain +// identifier. +// +// Definition/response names are JSON labels, not Go-qualified identifiers, so a package-qualified +// name such as `utils.Error` is rejected by the strict rxModelOverride and the marker would +// otherwise be silently dropped. +// +// Returns the offending raw argument and true; a bare marker or a well-formed name returns ("", +// false). func MalformedModelName(line string) (string, bool) { return malformedOverrideName(line, rxModelArg, rxModelOverride) } -// MalformedResponseName is the `swagger:response` counterpart of -// MalformedModelName. +// MalformedResponseName is the `swagger:response` counterpart of MalformedModelName. func MalformedResponseName(line string) (string, bool) { return malformedOverrideName(line, rxResponseArg, rxResponseOverride) } -// malformedOverrideName returns the raw name argument and true when line -// carries a single-name struct marker (captured by rxArg) whose name the -// strict rxOverride rejects. A bare marker (no argument) or a name the -// strict matcher accepts returns ("", false). +// malformedOverrideName returns the raw name argument and true when line carries a single-name +// struct marker (captured by rxArg) whose name the strict rxOverride rejects. +// +// A bare marker (no argument) or a name the strict matcher accepts returns ("", false). func malformedOverrideName(line string, rxArg, rxOverride *regexp.Regexp) (string, bool) { m := rxArg.FindStringSubmatch(line) if len(m) < minMatchCount { @@ -77,11 +80,12 @@ func malformedOverrideName(line string, rxArg, rxOverride *regexp.Regexp) (strin return strings.TrimSpace(m[1]), true } -// 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. +// 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 { @@ -105,9 +109,10 @@ 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. +// 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 6e76406c..696ff9cb 100644 --- a/internal/parsers/matchers_test.go +++ b/internal/parsers/matchers_test.go @@ -81,12 +81,12 @@ func TestCommentBlankSubMatcher(t *testing.T) { }) } -// TestMalformedOverrideName covers go-swagger issue #874: a package- -// qualified swagger:response / swagger:model name (e.g. "utils.Error") -// is not a plain identifier. The strict override regex rejects it, so it -// would be silently dropped; these detectors let the scanner warn instead. -// A bare marker or a well-formed name (including a trailing sentence -// period) must NOT be flagged. +// TestMalformedOverrideName covers go-swagger issue #874: a package- qualified swagger:response / +// swagger:model name (e.g. "utils.Error") is not a plain identifier. +// +// The strict override regex rejects it, so it would be silently dropped; these detectors let the +// scanner warn instead. +// A bare marker or a well-formed name (including a trailing sentence period) must NOT be flagged. func TestMalformedOverrideName(t *testing.T) { t.Parallel() diff --git a/internal/parsers/parsed_path_content.go b/internal/parsers/parsed_path_content.go index 2e1bd898..1acd15ed 100644 --- a/internal/parsers/parsed_path_content.go +++ b/internal/parsers/parsed_path_content.go @@ -22,31 +22,33 @@ type ParsedPathContent struct { Tags []string Remaining *ast.CommentGroup - // Pos is the position of the matched route/operation annotation - // line (the comment group's start). Used to anchor diagnostics - // such as the stripped-regex warning. - // Pos is the (coarse) source position of the matched route/operation annotation line — the comment's Slash. + // Pos is the position of the matched route/operation annotation line (the comment group's start). + // + // Used to anchor diagnostics such as the stripped-regex warning. + // Pos is the (coarse) source position of the matched route/operation annotation line — the + // comment's Slash. // // Used as: // - anchor for diagnostics, such as the stripped-regex warning. // - the cross-ref anchor for the /paths/{path}/{method} node. Invalid when no annotation matched. Pos token.Pos - // StrippedParams names the path parameters whose inline regex - // constraint (gorilla/chi style, e.g. `{id:[0-9]+}`) was stripped - // to the bare `{id}` template form. OpenAPI 2.0 path templating - // supports only RFC 6570 URI Template Level-1 expansion (simple - // `{var}` substitution), so any `:regex` constraint is dropped and - // surfaced to the author by the route/operation builder. + // StrippedParams names the path parameters whose inline regex constraint (gorilla/chi style, e.g. + // `{id:[0-9]+}`) was stripped to the bare `{id}` template form. + // + // OpenAPI 2.0 path templating supports only RFC 6570 URI Template Level-1 expansion (simple + // `{var}` substitution), so any `:regex` constraint is dropped and surfaced to the author by the + // route/operation builder. StrippedParams []string } -// stripPathParamRegex rewrites inline-regex path-parameter segments -// (`{name:regex}`) to the bare RFC 6570 Level-1 form (`{name}`) and -// returns the cleaned string alongside the names whose constraint was -// stripped. Brace matching is depth-aware so regex quantifiers carrying -// their own braces (`{id:[0-9]{2,4}}`) are handled. A plain `{name}` -// template (no colon) and unbalanced braces are left untouched. +// stripPathParamRegex rewrites inline-regex path-parameter segments (`{name:regex}`) to the bare +// RFC 6570 Level-1 form (`{name}`) and returns the cleaned string alongside the names whose +// constraint was stripped. +// +// Brace matching is depth-aware so regex quantifiers carrying their own braces (`{id:[0-9]{2,4}}`) +// are handled. +// A plain `{name}` template (no colon) and unbalanced braces are left untouched. func stripPathParamRegex(s string) (cleaned string, stripped []string) { var b strings.Builder for i := 0; i < len(s); { @@ -57,8 +59,8 @@ func stripPathParamRegex(s string) (cleaned string, stripped []string) { continue } - // Find the brace that closes the one at i, counting depth so - // nested `{...}` (regex quantifiers) don't terminate early. + // Find the brace that closes the one at i, counting depth so nested `{...}` (regex quantifiers) + // don't terminate early. depth, j := 0, i for ; j < len(s); j++ { switch s[j] { @@ -102,11 +104,12 @@ func ParseRoutePathAnnotation(lines []*ast.Comment) (cnt ParsedPathContent) { return parsePathAnnotation(rxRoute, lines) } -// ensureCommentMarker returns line with a leading `// ` prepended -// unless it already starts with `//` or `/*`. Used by -// parsePathAnnotation when reshaping a multi-line block comment into -// per-line *ast.Comment entries; the grammar lexer only runs its -// content-prefix strip on the `//` / `/*` branches of stripComment. +// ensureCommentMarker returns line with a leading `// ` prepended unless it already starts with +// `//` or `/*`. +// +// Used by parsePathAnnotation when reshaping a multi-line block comment into per-line *ast.Comment +// entries; the grammar lexer only runs its content-prefix strip on the `//` / `/*` branches of +// stripComment. func ensureCommentMarker(line string) string { if strings.HasPrefix(line, "//") || strings.HasPrefix(line, "/*") { return line @@ -114,13 +117,15 @@ func ensureCommentMarker(line string) string { return "// " + line } -// stripBlockFraming removes the `/* … */` markers from a block comment's -// full text, leaving the inner lines. A `swagger:operation` written in a -// `/* */` block comment arrives as a single *ast.Comment whose Text still -// carries the framing; without stripping it, the closing `*/` (and the -// empty opener line) leak into the reconstructed Remaining block, where a -// stray `*/` reads as a YAML alias indicator and fails the body parse -// (go-swagger#1595). Returns the text unchanged for `//` comments. +// stripBlockFraming removes the `/* … */` markers from a block comment's full text, leaving the +// inner lines. +// +// A `swagger:operation` written in a `/* */` block comment arrives as a single *ast.Comment whose +// Text still carries the framing; without stripping it, the closing `*/` (and the empty opener +// line) leak into the reconstructed Remaining block, where a stray `*/` reads as a YAML alias +// indicator and fails the body parse (go-swagger#1595). +// +// Returns the text unchanged for `//` comments. func stripBlockFraming(text string) string { if !strings.HasPrefix(text, "/*") { return text @@ -128,13 +133,13 @@ func stripBlockFraming(text string) string { return strings.TrimSuffix(strings.TrimPrefix(text, "/*"), "*/") } -// stripBlockContinuation removes the `\s*\*\s?` godoc decoration a `/* */` -// continuation line may carry, preserving all other indentation (so YAML -// body indentation under a block-comment swagger:operation survives). It -// mirrors grammar.stripBlockContinuation; the duplication keeps the -// scanner-level parsers package free of a dependency on the grammar -// sub-package. A line with no `*` decoration (the flush-left block style) -// is returned untouched. +// stripBlockContinuation removes the `\s*\*\s?` godoc decoration a `/* */` continuation line may +// carry, preserving all other indentation (so YAML body indentation under a block-comment +// swagger:operation survives). +// +// It mirrors grammar.stripBlockContinuation; the duplication keeps the scanner-level parsers +// package free of a dependency on the grammar sub-package. +// A line with no `*` decoration (the flush-left block style) is returned untouched. func stripBlockContinuation(s string) string { leading := -1 for i, r := range s { @@ -164,16 +169,14 @@ func parsePathAnnotation(annotation *regexp.Regexp, lines []*ast.Comment) (cnt P txt = stripBlockFraming(txt) for line := range strings.SplitSeq(txt, "\n") { if isBlock { - // Shed the godoc `* ` continuation decoration so a - // `*`-styled block comment's body lines don't carry a - // leading `*` into Remaining / the YAML body. Indentation - // is preserved for flush-left block bodies (go-swagger#1595). + // Shed the godoc `* ` continuation decoration so a `*`-styled block comment's body lines don't + // carry a leading `*` into Remaining / the YAML body. + // Indentation is preserved for flush-left block bodies (go-swagger#1595). line = stripBlockContinuation(line) } - // Strip inline-regex path-param constraints (`{id:[0-9]+}`) - // to the RFC 6570 Level-1 form (`{id}`) BEFORE matching: - // rxPath's alphabet has no `[`/`]`, so the raw line would - // fail to match and the route would be dropped silently. + // Strip inline-regex path-param constraints (`{id:[0-9]+}`) to the RFC 6570 Level-1 form + // (`{id}`) BEFORE matching: rxPath's alphabet has no `[`/`]`, so the raw line would fail to + // match and the route would be dropped silently. // The original `line` is preserved for the Remaining block. cleaned, stripped := stripPathParamRegex(line) matches := annotation.FindStringSubmatch(cleaned) @@ -201,17 +204,16 @@ func parsePathAnnotation(annotation *regexp.Regexp, lines []*ast.Comment) (cnt P if !justMatched || strings.TrimSpace(rxStripComments.ReplaceAllString(line, "")) != "" { cc := new(ast.Comment) cc.Slash = cmt.Slash - // Force a `//` prefix on the synthetic per-line - // comment so grammar's lexer sees a shape it strips - // (the `//` branch of stripComment runs - // trimContentPrefix, which sheds leading ` \t*/|`). - // Without the prefix, the lexer falls through its - // default case and preserves the source line - // verbatim — leading tabs from `/* ... */` block- - // comment route docs then leak into Title / - // Description. Lines that already start with `//` - // or `/*` are left alone so their leading whitespace - // is recorded correctly via the matching strip path. + // Force a `//` prefix on the synthetic per-line comment so grammar's lexer sees a shape it + // strips (the `//` branch of stripComment runs trimContentPrefix, which sheds leading ` + // \t*/|`). + // + // Without the prefix, the lexer falls through its default case and preserves the source line + // verbatim — leading tabs from `/* ... */` block- comment route docs then leak into Title / + // Description. + // + // Lines that already start with `//` or `/*` are left alone so their leading whitespace is + // recorded correctly via the matching strip path. cc.Text = ensureCommentMarker(line) cnt.Remaining.List = append(cnt.Remaining.List, cc) justMatched = false diff --git a/internal/parsers/regexprs.go b/internal/parsers/regexprs.go index 1f8b6448..e4fc2ccd 100644 --- a/internal/parsers/regexprs.go +++ b/internal/parsers/regexprs.go @@ -6,27 +6,26 @@ package parsers 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 trailing - // spaces. + // 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 + // trailing spaces. // - // Annotations must START the comment line — any prose before the - // `swagger:xxx` keyword disqualifies the line, so an annotation - // buried in prose is ignored. + // Annotations must START the comment line — any prose before the `swagger:xxx` keyword + // disqualifies the line, so an annotation buried in prose is ignored. // - // The sole documented 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`. 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. // - // This exception is reserved for `swagger:route`. All other - // annotations must start the comment line, per rxCommentPrefix. + // 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. rxRoutePrefix = rxCommentPrefix + `(?:\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]*\p{Zs}+)?` rxMethod = "(\\p{L}+)" @@ -37,26 +36,46 @@ const ( // compile-once regexes; read-only. var ( - // 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. + // 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. + // 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}]+)(?:\.)?$`) + rxModelOverride = regexp.MustCompile(rxCommentPrefix + `swagger:model\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)?(?:\.)?$`) + // rxResponseOverride is the scanner's classification gate for `swagger:response`. + // + // The argument is optional (bare marker → name inferred from the type), an identifier name, or + // the shared-namespace wildcard `*` (a synonym for the bare form — `swagger:response *` and + // `swagger:response` both register a shared response keyed by the type name). + // + // A malformed name (e.g. a package-qualified `utils.Error`) still fails the match, so + // MalformedResponseName can flag it. + // The response NAME itself is resolved from the grammar (grammar.ResponseBlock), not from this + // capture. + rxResponseOverride = regexp.MustCompile(rxCommentPrefix + `swagger:response\p{Zs}*(\*|\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)?(?:\.)?$`) + // rxParametersOverride is the scanner's PERMISSIVE classification gate for `swagger:parameters`: + // it matches the keyword followed by any non-empty argument and captures it. + // + // The capture is required by the shared comment matcher (commentMultipleSubMatcher), but its + // CONTENT is unused — the argument tokens are parsed and validated by the grammar + // (grammar.ParametersBlock), which emits diagnostics for malformed forms. + // + // The scanner does not re-validate arg shapes: a malformed-but-non-empty argument is classified + // and passed on so the grammar can diagnose it, rather than being silently skipped here. + rxParametersOverride = regexp.MustCompile(rxCommentPrefix + `swagger:parameters\p{Zs}+(\S.*?)\p{Zs}*$`) - // rxModelArg / rxResponseArg loosely capture the raw name argument - // following a single-name struct marker, regardless of whether it is a - // well-formed identifier. They back the malformed-name detection that - // warns instead of silently dropping a marker whose name the strict - // rxModelOverride / rxResponseOverride rejects (e.g. a package-qualified - // `utils.Error`). See parsers.MalformedModelName / MalformedResponseName. + // rxModelArg / rxResponseArg loosely capture the raw name argument following a single-name struct + // marker, regardless of whether it is a well-formed identifier. + // + // They back the malformed-name detection that warns instead of silently dropping a marker whose + // name the strict rxModelOverride / rxResponseOverride rejects (e.g. a package-qualified + // `utils.Error`). + // See parsers.MalformedModelName / MalformedResponseName. rxModelArg = regexp.MustCompile(rxCommentPrefix + `swagger:model\p{Zs}+(\S.*?)\p{Zs}*$`) rxResponseArg = regexp.MustCompile(rxCommentPrefix + `swagger:response\p{Zs}+(\S.*?)\p{Zs}*$`) diff --git a/internal/parsers/regexprs_test.go b/internal/parsers/regexprs_test.go index 2627378f..22d2c0c8 100644 --- a/internal/parsers/regexprs_test.go +++ b/internal/parsers/regexprs_test.go @@ -35,19 +35,6 @@ func TestSchemaValueExtractors(t *testing.T) { "swagger:model ", } - parameters := []string{ - "// swagger:parameters ", - "* swagger:parameters ", - "* swagger:parameters ", - " swagger:parameters ", - "swagger:parameters ", - "// swagger:parameters ", - "* swagger:parameters ", - "* swagger:parameters ", - " swagger:parameters ", - "swagger:parameters ", - } - const numValid = 4 + 3 // +3 extra space validParams := make([]string, 0, numValid) validParams = append(validParams, @@ -66,52 +53,61 @@ func TestSchemaValueExtractors(t *testing.T) { } verifySwaggerOneArgSwaggerTag(t, rxModelOverride, models, append(validParams, "", " ", " "), invalidParams) - verifySwaggerMultiArgSwaggerTag(t, rxParametersOverride, parameters, validParams, invalidParams) } -func verifySwaggerOneArgSwaggerTag(t *testing.T, matcher *regexp.Regexp, prefixes, validParams, invalidParams []string) { - t.Helper() - +// TestParametersClassificationGate verifies that rxParametersOverride is a PERMISSIVE presence +// gate: it matches `swagger:parameters` plus any non-empty argument and captures it verbatim, +// leaving shape validation to the grammar. +// +// Forms the strict model matcher rejects (a leading `*`, a `/path`, malformed idents) are accepted +// here so the grammar can parse and diagnose them. +// A bare keyword with no argument is not classified. +func TestParametersClassificationGate(t *testing.T) { + prefixes := []string{ + "// swagger:parameters ", + "swagger:parameters ", + "* swagger:parameters ", + } + accepted := []string{ + "listPets", // operation id + "listPets createPet", // multiple operation ids + "*", // shared-namespace target + "* listPets", // shared register + op id + "/pets", // path target + "/pets X-Request-ID", // path reference + "listPets X-Request-ID", // operation reference + "*blah", // malformed — accepted; the grammar's concern + "1-2-3", // malformed — accepted; the grammar's concern + } for _, pref := range prefixes { - for _, param := range validParams { - line := pref + param - matches := matcher.FindStringSubmatch(line) - if assert.Len(t, matches, 2) { - assert.EqualT(t, strings.TrimSpace(param), matches[1]) + for _, arg := range accepted { + line := pref + arg + m := rxParametersOverride.FindStringSubmatch(line) + if !assert.Len(t, m, 2) { + t.Logf("expected %q to be classified", line) + continue } + assert.EqualT(t, strings.TrimSpace(arg), m[1]) } } - for _, pref := range prefixes { - for _, param := range invalidParams { - line := pref + param - matches := matcher.FindStringSubmatch(line) - assert.Empty(t, matches) - } + // A bare keyword with no argument is not classified (the grammar's missing-target diagnostic fires + // only once a node is classified). + for _, line := range []string{"swagger:parameters", "swagger:parameters ", "// swagger:parameters "} { + assert.Empty(t, rxParametersOverride.FindStringSubmatch(line), "bare keyword must not classify") } } -func verifySwaggerMultiArgSwaggerTag(t *testing.T, matcher *regexp.Regexp, prefixes, validParams, invalidParams []string) { +func verifySwaggerOneArgSwaggerTag(t *testing.T, matcher *regexp.Regexp, prefixes, validParams, invalidParams []string) { t.Helper() - actualParams := make([]string, 0, len(validParams)) - vp := make([]string, 0, len(validParams)+1) - - for i := range validParams { - vp = vp[:0] - for j := range i + 1 { - vp = append(vp, validParams[j]) // G602 (false positive from gosec now fixed): j is bounded by i+1 which is bounded by len(validParams) - } - - actualParams = append(actualParams, strings.Join(vp, " ")) - } - for _, pref := range prefixes { - for _, param := range actualParams { + for _, param := range validParams { line := pref + param matches := matcher.FindStringSubmatch(line) - assert.Len(t, matches, 2) - assert.EqualT(t, strings.TrimSpace(param), matches[1]) + if assert.Len(t, matches, 2) { + assert.EqualT(t, strings.TrimSpace(param), matches[1]) + } } } diff --git a/internal/parsers/routebody/diag.go b/internal/parsers/routebody/diag.go index d03609a1..6f0e1b66 100644 --- a/internal/parsers/routebody/diag.go +++ b/internal/parsers/routebody/diag.go @@ -10,13 +10,14 @@ import ( "github.com/go-openapi/codescan/internal/parsers/grammar" ) -// emitDiagf forwards a CodeInvalidAnnotation diagnostic to the -// caller's sink. A nil sink silently drops the diagnostic — matches -// the optionality used by handlers' dispatchers. +// emitDiagf forwards a CodeInvalidAnnotation diagnostic to the caller's sink. // -// Routebody emits exactly one diagnostic code, so the helper bakes it -// in rather than accepting an argument. Future codes can be added by -// adding sibling helpers (emitShapeMismatchf, etc.) — keep the call +// A nil sink silently drops the diagnostic — matches the optionality used by handlers' +// dispatchers. +// +// Routebody emits exactly one diagnostic code, so the helper bakes it in rather than accepting an +// argument. +// Future codes can be added by adding sibling helpers (emitShapeMismatchf, etc.) — keep the call // sites explicit about what code they ride on. func emitDiagf(diag func(grammar.Diagnostic), pos token.Position, format string, args ...any) { if diag == nil { @@ -30,10 +31,11 @@ func emitDiagf(diag func(grammar.Diagnostic), pos token.Position, format string, }) } -// offsetPos returns a new Position whose Line is base.Line + lineNo - 1 -// (1-indexed line numbers within body). Column and Filename are -// inherited from base. Used to give per-line diagnostics a -// reasonable Pos when the body parser doesn't track precise lex +// offsetPos returns a new Position whose Line is base.Line + lineNo - 1 (1-indexed line numbers +// within body). +// +// Column and Filename are inherited from base. +// Used to give per-line diagnostics a reasonable Pos when the body parser doesn't track precise lex // state. func offsetPos(base token.Position, lineNo int) token.Position { return token.Position{ diff --git a/internal/parsers/routebody/doc.go b/internal/parsers/routebody/doc.go index 67d997c9..6462c9f6 100644 --- a/internal/parsers/routebody/doc.go +++ b/internal/parsers/routebody/doc.go @@ -1,22 +1,20 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -// Package routebody parses the body sub-language of swagger:route / -// swagger:operation `Parameters:` and `Responses:` raw blocks. +// Package routebody parses the body sub-language of swagger:route / swagger:operation `Parameters:` +// and `Responses:` raw blocks. // -// The package tokenises the `+ name:` chunk grammar (parameters) and -// the `: ` line grammar (responses) into typed -// declarations ([ParamDecl], [ResponseDecl]) plus a [grammar.Block] -// carrying the validation properties. The orchestrating builder -// reads the head fields directly and dispatches the Block through -// the shared handlers seam ([handlers.DispatchParamLevel0], -// [handlers.DispatchSchemaLevel0]). +// The package tokenises the `+ name:` chunk grammar (parameters) and the `: ` line +// grammar (responses) into typed declarations ([ParamDecl], [ResponseDecl]) plus a [grammar.Block] +// carrying the validation properties. +// +// The orchestrating builder reads the head fields directly and dispatches the Block through the +// shared handlers seam ([handlers.DispatchParamLevel0], [handlers.DispatchSchemaLevel0]). // // Diagnostics ride on a single code, [grammar.CodeInvalidAnnotation]. -// The diag callback may be nil — a nil sink drops diagnostics -// silently and matches the optional-sink posture used elsewhere. +// The diag callback may be nil — a nil sink drops diagnostics silently and matches the +// optional-sink posture used elsewhere. // -// See the package README for the full grammar specifications, the -// definition-fallback behaviour on untagged response refs, and the -// list of head vs validation fields. +// See the package README for the full grammar specifications, the definition-fallback behaviour on +// untagged response refs, and the list of head vs validation fields. package routebody diff --git a/internal/parsers/routebody/parameters.go b/internal/parsers/routebody/parameters.go index 90456515..0396f561 100644 --- a/internal/parsers/routebody/parameters.go +++ b/internal/parsers/routebody/parameters.go @@ -14,20 +14,19 @@ import ( "github.com/go-openapi/codescan/internal/parsers/grammar" ) -// errInvalidValue is the sentinel wrapped by buildProperty when a -// validation property's raw value fails to parse against the keyword's -// declared shape. The caller (applyParamLine) catches it and forwards -// the wrapping message as a CodeInvalidAnnotation diagnostic. +// errInvalidValue is the sentinel wrapped by buildProperty when a validation property's raw value +// fails to parse against the keyword's declared shape. +// +// The caller (applyParamLine) catches it and forwards the wrapping message as a +// CodeInvalidAnnotation diagnostic. var errInvalidValue = errors.New("invalid value") -// ParamDecl is one parsed `+ name:`-delimited chunk from a -// swagger:route `Parameters:` body. +// ParamDecl is one parsed `+ name:`-delimited chunk from a swagger:route `Parameters:` body. // -// Head fields are routebody-owned (the orchestrator reads them -// directly to populate the *spec.Parameter shell). Validation fields -// land on Block as grammar.Property entries that the orchestrator -// dispatches via the standard handlers seam — see package godoc for -// the field split. +// Head fields are routebody-owned (the orchestrator reads them directly to populate the +// *spec.Parameter shell). +// Validation fields land on Block as grammar.Property entries that the orchestrator dispatches via +// the standard handlers seam — see package godoc for the field split. type ParamDecl struct { Name string In string @@ -40,22 +39,22 @@ type ParamDecl struct { Pos token.Position } -// chunkParseState tracks the in-flight param chunk while iterating -// lines. The state machine: a `+` or `-` line opens a new chunk, -// subsequent lines fill its fields, the next `+`/`-` (or end-of-body) -// commits the current chunk. +// chunkParseState tracks the in-flight param chunk while iterating lines. +// +// The state machine: a `+` or `-` line opens a new chunk, subsequent lines fill its fields, the +// next `+`/`-` (or end-of-body) commits the current chunk. type chunkParseState struct { cur *ParamDecl props []grammar.Property basePos token.Position } -// ParseParameters lowers a Parameters: raw block body into typed -// param chunks. See package godoc for the grammar spec. +// ParseParameters lowers a Parameters: raw block body into typed param chunks. +// +// See package godoc for the grammar spec. // -// basePos is the source position of the `parameters:` keyword head; -// each chunk's Pos is offset by the chunk's line number within body -// (1-indexed) so diagnostics point at the offending line in the +// basePos is the source position of the `parameters:` keyword head; each chunk's Pos is offset by +// the chunk's line number within body (1-indexed) so diagnostics point at the offending line in the // original source. // // diag may be nil; when nil, diagnostics are dropped. @@ -76,9 +75,9 @@ func ParseParameters(body string, basePos token.Position, diag func(grammar.Diag } pos := offsetPos(basePos, lineNo) - // Chunk-start sigil: `+ ` or `- ` (alias). The sigil itself may - // be the entire line (bare `+` / `-`) or be followed by a - // `key: value` on the same line. + // Chunk-start sigil: `+ ` or `- ` (alias). + // The sigil itself may be the entire line (bare `+` / `-`) or be followed by a `key: value` on + // the same line. if isChunkSigil(line) { commitChunk(&state, &out, diag) state.cur = &ParamDecl{Pos: pos} @@ -89,8 +88,7 @@ func ParseParameters(body string, basePos token.Position, diag func(grammar.Diag } } - // Lines without a `:` are silently ignored, as are lines - // whose key trims to empty. + // Lines without a `:` are silently ignored, as are lines whose key trims to empty. key, value, ok := splitKeyValue(line) if !ok { continue @@ -109,9 +107,10 @@ func ParseParameters(body string, basePos token.Position, diag func(grammar.Diag return out } -// isChunkSigil reports whether line begins with the `+ ` (canonical) -// or `- ` (alias) chunk-start sigil. The sigil is the very first -// character — leading whitespace was trimmed by the caller. +// isChunkSigil reports whether line begins with the `+ ` (canonical) or `- ` (alias) chunk-start +// sigil. +// +// The sigil is the very first character — leading whitespace was trimmed by the caller. func isChunkSigil(line string) bool { if line == "" { return false @@ -120,8 +119,9 @@ func isChunkSigil(line string) bool { } // splitKeyValue splits one `key: value` line on the first colon. -// Returns (key, value, true) when both halves are non-empty after -// trimming; (_, _, false) otherwise. +// +// Returns (key, value, true) when both halves are non-empty after trimming; (_, _, false) +// otherwise. func splitKeyValue(line string) (key, value string, ok bool) { before, after, ok := strings.Cut(line, ":") if !ok { @@ -135,9 +135,10 @@ func splitKeyValue(line string) (key, value string, ok bool) { return key, value, true } -// applyParamLine dispatches one `key: value` line onto the current -// chunk. Head keys land directly on cur; validation keys are lowered -// to grammar.Property entries on props (the eventual ParamDecl.Block). +// applyParamLine dispatches one `key: value` line onto the current chunk. +// +// Head keys land directly on cur; validation keys are lowered to grammar.Property entries on props +// (the eventual ParamDecl.Block). func applyParamLine(cur *ParamDecl, props *[]grammar.Property, key, value string, pos token.Position, diag func(grammar.Diagnostic)) { switch strings.ToLower(key) { case "name": @@ -149,10 +150,9 @@ func applyParamLine(cur *ParamDecl, props *[]grammar.Property, key, value string "in: %q is not one of path/query/header/body/formData", value) return } - // allowFormAlias=true accepts the v1 routes affordance - // `form` and canonicalises to `formData`. See Q27 — the - // alias is contained to this parser; no other capture site - // passes allowFormAlias=true. + // allowFormAlias=true accepts the v1 routes affordance `form` and canonicalises to `formData`. + // See Q27 — the alias is contained to this parser; no other capture site passes + // allowFormAlias=true. cur.In = canonical case "type": cur.TypeRef = value @@ -178,8 +178,7 @@ func applyParamLine(cur *ParamDecl, props *[]grammar.Property, key, value string cur.AllowEmpty = v default: // Validation property: look up in the grammar keyword table. - // Unknown keys emit CodeInvalidAnnotation rather than being - // dropped silently. + // Unknown keys emit CodeInvalidAnnotation rather than being dropped silently. kw, ok := grammar.Lookup(key) if !ok { emitDiagf(diag, pos, @@ -195,11 +194,11 @@ func applyParamLine(cur *ParamDecl, props *[]grammar.Property, key, value string } } -// commitChunk finalises the in-flight chunk (if any), builds its -// validation Block, and appends to out. A bare `+`/`-` sigil with -// no key:value follow-up (no name, no other head fields, no -// properties) emits CodeInvalidAnnotation and is dropped rather -// than being committed as an empty parameter. +// commitChunk finalises the in-flight chunk (if any), builds its validation Block, and appends to +// out. +// +// A bare `+`/`-` sigil with no key:value follow-up (no name, no other head fields, no properties) +// emits CodeInvalidAnnotation and is dropped rather than being committed as an empty parameter. func commitChunk(state *chunkParseState, out *[]ParamDecl, diag func(grammar.Diagnostic)) { if state.cur == nil { return @@ -218,19 +217,21 @@ func commitChunk(state *chunkParseState, out *[]ParamDecl, diag func(grammar.Dia state.props = nil } -// isEmptyChunk reports whether the in-flight chunk has nothing the -// orchestrator could use — no name, no in, no type, no description, -// no validations. A chunk with just `required: true` and nothing else -// is still considered empty (no name → no usable param). +// isEmptyChunk reports whether the in-flight chunk has nothing the orchestrator could use — no +// name, no in, no type, no description, no validations. +// +// A chunk with just `required: true` and nothing else is still considered empty (no name → no +// usable param). func isEmptyChunk(cur *ParamDecl, props []grammar.Property) bool { return cur.Name == "" && cur.In == "" && cur.TypeRef == "" && cur.Format == "" && cur.Description == "" && len(props) == 0 } -// buildProperty constructs a grammar.Property from one validation -// key/value pair, populating Typed when the keyword's Shape demands -// it. Returns an error when typing fails (caller surfaces it as a -// diagnostic with the keyword name attached). +// buildProperty constructs a grammar.Property from one validation key/value pair, populating Typed +// when the keyword's Shape demands it. +// +// Returns an error when typing fails (caller surfaces it as a diagnostic with the keyword name +// attached). func buildProperty(kw grammar.Keyword, raw string, pos token.Position) (grammar.Property, error) { p := grammar.Property{ Keyword: kw, @@ -257,24 +258,21 @@ func buildProperty(kw grammar.Keyword, raw string, pos token.Position) (grammar. } p.Typed = grammar.TypedValue{Type: grammar.ShapeBool, Boolean: v} case grammar.ShapeEnumOption: - // Closed-vocab string-enum (e.g. collectionFormat). Set Typed - // only when raw is in the allowed set; otherwise leave - // ShapeNone so the dispatcher's IsTyped() check drops the - // write while the handler-side string fallback recovers the - // raw value where supported (handlers.CollectionFormatString - // reads pr.Value when Typed is empty). + // Closed-vocab string-enum (e.g. collectionFormat). + // Set Typed only when raw is in the allowed set; otherwise leave ShapeNone so the dispatcher's + // IsTyped() check drops the write while the handler-side string fallback recovers the raw value + // where supported (handlers.CollectionFormatString reads pr.Value when Typed is empty). if slices.Contains(kw.Values, raw) { p.Typed = grammar.TypedValue{Type: grammar.ShapeEnumOption, String: raw} } case grammar.ShapeNone, grammar.ShapeString, grammar.ShapeCommaList, grammar.ShapeRawBlock, grammar.ShapeRawValue: - // Raw / string / comma-list keywords carry their value - // through Property.Value unchanged; the dispatcher's Raw and - // String callbacks read it from there and coerce per the - // resolved schema type at write time. + // Raw / string / comma-list keywords carry their value through Property.Value unchanged; the + // dispatcher's Raw and String callbacks read it from there and coerce per the resolved schema + // type at write time. default: - // Unknown shape — future grammar additions. Keep the raw value - // on Property.Value; the dispatcher will treat it as untyped. + // Unknown shape — future grammar additions. + // Keep the raw value on Property.Value; the dispatcher will treat it as untyped. } return p, nil } diff --git a/internal/parsers/routebody/responses.go b/internal/parsers/routebody/responses.go index e8237186..3e68ff19 100644 --- a/internal/parsers/routebody/responses.go +++ b/internal/parsers/routebody/responses.go @@ -10,19 +10,18 @@ import ( "github.com/go-openapi/codescan/internal/parsers/grammar" ) -// ResponseDecl is one parsed response line from a swagger:route -// `Responses:` body. +// ResponseDecl is one parsed response line from a swagger:route `Responses:` body. // -// Code is the line's head — "default" (case-insensitive) or -// a decimal HTTP status code string. BodyTypeRef / ResponseRef are -// mutually exclusive: at most one is non-empty. Arrays carries the -// number of `[]` prefixes stripped from the ref target (0 for a -// scalar ref). Description is the post-tag prose tail. +// Code is the line's head — "default" (case-insensitive) or a decimal HTTP status code +// string. +// BodyTypeRef / ResponseRef are mutually exclusive: at most one is non-empty. +// Arrays carries the number of `[]` prefixes stripped from the ref target (0 for a scalar ref). // -// An empty-value line (`204:` with nothing after the colon) -// produces a ResponseDecl with Code set and every other field -// zero. The orchestrator emits the response with an explicitly -// empty description. +// Description is the post-tag prose tail. +// +// An empty-value line (`204:` with nothing after the colon) produces a ResponseDecl with Code set +// and every other field zero. +// The orchestrator emits the response with an explicitly empty description. type ResponseDecl struct { Code string BodyTypeRef string @@ -32,12 +31,12 @@ type ResponseDecl struct { Pos token.Position } -// ParseResponses lowers a Responses: raw block body into typed -// response lines. See package godoc for the grammar spec. +// ParseResponses lowers a Responses: raw block body into typed response lines. +// +// See package godoc for the grammar spec. // -// basePos is the source position of the `responses:` keyword head; -// each line's Pos is offset by the line number within body -// (1-indexed) so diagnostics point at the offending line. +// basePos is the source position of the `responses:` keyword head; each line's Pos is offset by the +// line number within body (1-indexed) so diagnostics point at the offending line. // // diag may be nil; when nil, diagnostics are dropped. func ParseResponses(body string, basePos token.Position, diag func(grammar.Diagnostic)) []ResponseDecl { @@ -80,9 +79,10 @@ func ParseResponses(body string, basePos token.Position, diag func(grammar.Diagn return out } -// parseResponseValue tokenises the right-hand side of a response -// line and lowers it into a ResponseDecl. Empty value yields an -// empty-body Decl carrying just the code. +// parseResponseValue tokenises the right-hand side of a response line and lowers it into a +// ResponseDecl. +// +// Empty value yields an empty-body Decl carrying just the code. func parseResponseValue(code, value string, pos token.Position, diag func(grammar.Diagnostic)) (ResponseDecl, bool) { decl := ResponseDecl{Code: code, Pos: pos} if value == "" { @@ -113,11 +113,10 @@ func parseResponseValue(code, value string, pos token.Position, diag func(gramma seenBodyOrResponse = true decl.ResponseRef, decl.Arrays = stripArrayPrefixes(val) case isTagged && tag == "description": - // `description:Foo bar baz` — value is everything after - // the colon on this token, joined with subsequent tokens - // as raw prose. Skip the empty val that arises from a - // bare `description:` token (val=="") so the joined - // result does not lead with a stray space. + // `description:Foo bar baz` — value is everything after the colon on this token, joined with + // subsequent tokens as raw prose. + // Skip the empty val that arises from a bare `description:` token (val=="") so the joined result + // does not lead with a stray space. if val != "" { descTokens = append(descTokens, val) } @@ -131,11 +130,10 @@ func parseResponseValue(code, value string, pos token.Position, diag func(gramma "response line %q: unknown tag %q", code+": "+value, tag) return ResponseDecl{}, false default: - // Untagged token. If the first untagged token is literally - // "body" or "response", treat it as a typo for `body:Foo` - // / `response:Foo` (missing colon) and drop the line with - // a diagnostic rather than silently parsing it as a ref - // named "body" / "response". + // Untagged token. + // If the first untagged token is literally "body" or "response", treat it as a typo for + // `body:Foo` / `response:Foo` (missing colon) and drop the line with a diagnostic rather than + // silently parsing it as a ref named "body" / "response". if i == 0 && (tok == "body" || tok == "response") { emitDiagf(diag, pos, "response line %q: missing `:` after %q — write `%s:Foo` not `%s Foo`", @@ -144,11 +142,11 @@ func parseResponseValue(code, value string, pos token.Position, diag func(gramma } if i == 0 { // First untagged token is the response ref candidate. - // The orchestrator resolves it against the responses - // map first and falls back to definitions (treating - // the hit as a body ref). `[]` prefixes apply just as - // on tagged refs, so the orchestrator can wrap arrays - // around the resolved body schema. + // The orchestrator resolves it against the responses map first and falls back to definitions + // (treating the hit as a body ref). + // + // `[]` prefixes apply just as on tagged refs, so the orchestrator can wrap arrays around the + // resolved body schema. seenBodyOrResponse = true decl.ResponseRef, decl.Arrays = stripArrayPrefixes(tok) continue @@ -163,10 +161,10 @@ func parseResponseValue(code, value string, pos token.Position, diag func(gramma return decl, true } -// splitTagToken splits a single `tag:value` token. Returns -// (tag, value, true) when the colon is present; (_, _, false) -// otherwise. The split takes only the FIRST colon — anything -// after it is the value. +// splitTagToken splits a single `tag:value` token. +// +// Returns (tag, value, true) when the colon is present; (_, _, false) otherwise. +// The split takes only the FIRST colon — anything after it is the value. func splitTagToken(tok string) (tag, value string, ok bool) { before, after, ok := strings.Cut(tok, ":") if !ok { @@ -175,8 +173,10 @@ func splitTagToken(tok string) (tag, value string, ok bool) { return before, after, true } -// stripArrayPrefixes counts leading `[]` prefixes on a body/response -// ref token. Returns (name, arrayCount). `[][]Foo` → ("Foo", 2). +// stripArrayPrefixes counts leading `[]` prefixes on a body/response ref token. +// +// Returns (name, arrayCount). +// `[][]Foo` → ("Foo", 2). func stripArrayPrefixes(ref string) (string, int) { arrays := 0 for strings.HasPrefix(ref, "[]") { diff --git a/internal/parsers/security/security.go b/internal/parsers/security/security.go index 8b24e70a..bc63e820 100644 --- a/internal/parsers/security/security.go +++ b/internal/parsers/security/security.go @@ -1,12 +1,12 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -// Package security is the sub-parser for the `Security:` block body that -// appears under `swagger:meta`, `swagger:route` and `swagger:operation`. +// Package security is the sub-parser for the `Security:` block body that appears under +// `swagger:meta`, `swagger:route` and `swagger:operation`. // -// The body is parsed as genuine YAML — the same path `securityDefinitions` -// already takes — and normalised into the OpenAPI 2.0 shape -// `[]map[string][]string`: an array of Security Requirement Objects. +// The body is parsed as genuine YAML — the same path `securityDefinitions` already takes — and +// normalised into the OpenAPI 2.0 shape `[]map[string][]string`: an array of Security Requirement +// Objects. // // Supported forms (all idiomatic YAML): // @@ -25,21 +25,19 @@ // security: [petstore_auth, api_key] # flow form of the shorthand // security: [] # explicit opt-out (see below) // -// An explicit empty sequence (`[]`) is distinct from an absent block: it is the -// OAS 2.0 idiom for opting OUT of security and returns a non-nil empty list, so -// the spec marshals `"security": []` (overriding any global requirement) rather -// than omitting the key. go-swagger#2479. +// An explicit empty sequence (`[]`) is distinct from an absent block: it is the OAS 2.0 idiom for +// opting OUT of security and returns a non-nil empty list, so the spec marshals `"security": []` +// (overriding any global requirement) rather than omitting the key. go-swagger#2479. // -// Legacy form (preserved, NOT idiomatic YAML): a bare top-level mapping with one -// scheme per line is read as a list of single-scheme requirements (OR), and a -// scalar scope value is comma-split: +// Legacy form (preserved, NOT idiomatic YAML): a bare top-level mapping with one scheme per line is +// read as a list of single-scheme requirements (OR), and a scalar scope value is comma-split: // // api_key: // oauth: read, write # → {oauth: [read, write]} // -// Note this is the one shape whose meaning diverges from YAML — a YAML mapping -// is a single object (AND). It is kept working for back-compat; new specs should -// use the sequence form above. +// Note this is the one shape whose meaning diverges from YAML — a YAML mapping is a single object +// (AND). +// It is kept working for back-compat; new specs should use the sequence form above. package security import ( @@ -49,22 +47,24 @@ import ( "go.yaml.in/yaml/v3" ) -// Requirement is one Security Requirement Object: a map from scheme name to its -// scope list. Multiple keys in one Requirement are AND-combined; multiple -// Requirements in the returned slice are OR-combined. +// Requirement is one Security Requirement Object: a map from scheme name to its scope list. +// +// Multiple keys in one Requirement are AND-combined; multiple Requirements in the returned slice +// are OR-combined. type Requirement = map[string][]string -// Parse decodes a `Security:` block body into its requirement list. An empty -// body returns nil (block absent → inherit); an explicit empty YAML sequence -// returns a non-nil empty list (opt-out). Malformed YAML returns nil rather -// than failing the whole scan. +// Parse decodes a `Security:` block body into its requirement list. +// +// An empty body returns nil (block absent → inherit); an explicit empty YAML sequence returns a +// non-nil empty list (opt-out). +// Malformed YAML returns nil rather than failing the whole scan. func Parse(body string) []Requirement { if strings.TrimSpace(body) == "" { return nil } - // Dedent (and expand leading tabs) so the godoc-preserved indentation - // becomes YAML-legal, exactly as the securityDefinitions / Tags bodies do. + // Dedent (and expand leading tabs) so the godoc-preserved indentation becomes YAML-legal, exactly + // as the securityDefinitions / Tags bodies do. normalised := strings.Join(yamlparser.RemoveIndent(strings.Split(body, "\n")), "\n") var doc yaml.Node @@ -82,9 +82,8 @@ func Parse(body string) []Requirement { func fromRoot(root *yaml.Node) []Requirement { switch root.Kind { case yaml.SequenceNode: - // Canonical OpenAPI: a list of requirement objects (plus the - // bare-name shorthand). An empty list is the explicit opt-out, so the - // non-nil empty slice is intentional. + // Canonical OpenAPI: a list of requirement objects (plus the bare-name shorthand). + // An empty list is the explicit opt-out, so the non-nil empty slice is intentional. result := make([]Requirement, 0, len(root.Content)) for _, item := range root.Content { if req := requirementFromItem(item); req != nil { @@ -106,8 +105,8 @@ func fromRoot(root *yaml.Node) []Requirement { } } -// requirementFromItem builds one Requirement from a sequence item: either a -// mapping (one or more AND-combined schemes) or a bare scalar scheme name. +// requirementFromItem builds one Requirement from a sequence item: either a mapping (one or more +// AND-combined schemes) or a bare scalar scheme name. func requirementFromItem(item *yaml.Node) Requirement { switch item.Kind { case yaml.ScalarNode: @@ -134,8 +133,8 @@ func requirementFromItem(item *yaml.Node) Requirement { } } -// legacyMapping turns a bare top-level mapping into one single-scheme -// requirement per key, in document order (OR semantics). +// legacyMapping turns a bare top-level mapping into one single-scheme requirement per key, in +// document order (OR semantics). func legacyMapping(node *yaml.Node) []Requirement { var result []Requirement for i := 0; i+1 < len(node.Content); i += 2 { @@ -148,10 +147,11 @@ func legacyMapping(node *yaml.Node) []Requirement { return result } -// coerceScopes extracts the scope list from a requirement value: a YAML -// sequence (flow or block) yields its scalar items; a scalar value is treated -// as the legacy comma-separated list; null/empty yields no scopes. The result -// is always non-nil so an empty scope list marshals as `[]`. +// coerceScopes extracts the scope list from a requirement value: a YAML sequence (flow or block) +// yields its scalar items; a scalar value is treated as the legacy comma-separated list; null/empty +// yields no scopes. +// +// The result is always non-nil so an empty scope list marshals as `[]`. func coerceScopes(node *yaml.Node) []string { scopes := []string{} if node == nil { diff --git a/internal/parsers/security/security_test.go b/internal/parsers/security/security_test.go index 6cbb5295..a48a7e8e 100644 --- a/internal/parsers/security/security_test.go +++ b/internal/parsers/security/security_test.go @@ -17,8 +17,8 @@ func TestParse(t *testing.T) { }) // go-swagger#2479: an explicit empty sequence is the OAS 2.0 opt-out idiom. - // It must be distinguishable from an absent block (nil) so the spec marshals - // `"security": []` rather than omitting the key. + // It must be distinguishable from an absent block (nil) so the spec marshals `"security": []` + // rather than omitting the key. t.Run("explicit empty list opts out (non-nil empty)", func(t *testing.T) { got := Parse("[]") require.NotNil(t, got, "`[]` must yield a non-nil empty list, not nil") @@ -54,8 +54,7 @@ func TestParse(t *testing.T) { }) t.Run("sequence item: block-style scope list", func(t *testing.T) { - // the other idiomatic YAML scope form — was silently broken before the - // YAML-decode rewrite. + // the other idiomatic YAML scope form — was silently broken before the YAML-decode rewrite. got := Parse("- oauth:\n - read\n - write") require.Len(t, got, 1) assert.Equal(t, []string{"read", "write"}, got[0]["oauth"]) @@ -68,8 +67,8 @@ func TestParse(t *testing.T) { assert.Equal(t, []string{"read"}, got[1]["oauth"]) }) - // go-swagger#2294: two keys under ONE sequence item mean AND — a single - // requirement object carrying both schemes. + // go-swagger#2294: two keys under ONE sequence item mean AND — a single requirement object + // carrying both schemes. t.Run("AND form: two keys in one sequence item", func(t *testing.T) { got := Parse("- x_client_id: []\n access_token: []") require.Len(t, got, 1, "AND is a single requirement, not two") diff --git a/internal/parsers/yaml/dedent.go b/internal/parsers/yaml/dedent.go index cb7a79ac..8fcba98a 100644 --- a/internal/parsers/yaml/dedent.go +++ b/internal/parsers/yaml/dedent.go @@ -5,8 +5,10 @@ package yaml import "strings" -// RemoveIndent normalises the common leading indentation of a YAML -// body lifted from a godoc comment block. It runs in two passes: +// RemoveIndent normalises the common leading indentation of a YAML body lifted from a godoc comment +// block. +// +// It runs in two passes: // // 1. Expand the leading tabs of every (non-blank) line to two spaces. // YAML refuses tab indentation, and gofmt renders an indented @@ -15,53 +17,49 @@ import "strings" // 2. Treat the first non-blank line's indent length as the canonical // strip width and strip it from every line. // -// The first-(non-blank-)line dedent (vs "shortest leading-whitespace -// run across every non-blank line") is the operations / meta path's -// contract — the existing operation goldens depend on it. The -// typed-extensions path uses common-prefix dedent instead; see -// README.md §dedent. +// The first-(non-blank-)line dedent (vs "shortest leading-whitespace run across every non-blank +// line") is the operations / meta path's contract — the existing operation goldens depend on it. +// The typed-extensions path uses common-prefix dedent instead; see README.md §dedent. +// +// Why expand BEFORE stripping (not the post-strip retab it replaced): gofmt rewrites a column-0 +// doc-comment key as a prose line (one leading space) and its indented value block as a code block +// (a leading tab), separated by a blank "//" line. // -// Why expand BEFORE stripping (not the post-strip retab it replaced): -// gofmt rewrites a column-0 doc-comment key as a prose line (one -// leading space) and its indented value block as a code block (a -// leading tab), separated by a blank "//" line. A swagger:meta body is -// uniformly tab-prefixed (quirk F7), but a swagger:operation body -// interleaves 1-space prose keys (`responses:`, `x-*:`) with -// tab-prefixed value blocks. Stripping the prose-keyed width (1) off a -// child's lone leading tab would consume the child's whole indent and -// flatten the nesting; the YAML parser then rejects or mis-nests the -// body. Expanding first turns each tab into two spaces, so the strip -// leaves the relative nesting intact for both shapes. Leading blank -// lines are skipped when choosing the canonical line (gofmt inserts one -// under a column-0 key). +// A swagger:meta body is uniformly tab-prefixed (quirk F7), but a swagger:operation body +// interleaves 1-space prose keys (`responses:`, `x-*:`) with tab-prefixed value blocks. +// Stripping the prose-keyed width (1) off a child's lone leading tab would consume the child's +// whole indent and flatten the nesting; the YAML parser then rejects or mis-nests the body. // -// 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. If a corpus surfaces that depends on it, reintroduce -// the Unicode branch. +// Expanding first turns each tab into two spaces, so the strip leaves the relative nesting intact +// for both shapes. +// Leading blank lines are skipped when choosing the canonical line (gofmt inserts one under a +// column-0 key). +// +// 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. +// If a corpus surfaces that depends on it, reintroduce the Unicode branch. func RemoveIndent(lines []string) []string { if len(lines) == 0 { return lines } - // Expand leading tabs to two spaces on EVERY line first, so tab- and - // space-indented lines become comparable before the common-indent strip. - // gofmt renders an indented doc-comment line (a code block) with a leading - // tab, and YAML refuses tab indentation. A swagger:meta body is uniformly - // tab-prefixed (quirk F7), but a swagger:operation body interleaves - // 1-space prose keys (`responses:`, `x-*:`) with tab-prefixed value blocks; - // retabbing only the post-strip remainder (the previous approach) left the - // children's lone leading tab to be stripped by the prose-keyed width, - // collapsing the nesting. Expanding up front, then stripping a pure-space - // width, preserves the relative nesting in both shapes. + // Expand leading tabs to two spaces on EVERY line first, so tab- and space-indented lines become + // comparable before the common-indent strip. gofmt renders an indented doc-comment line (a code + // block) with a leading tab, and YAML refuses tab indentation. + // + // A swagger:meta body is uniformly tab-prefixed (quirk F7), but a swagger:operation body + // interleaves 1-space prose keys (`responses:`, `x-*:`) with tab-prefixed value blocks; retabbing + // only the post-strip remainder (the previous approach) left the children's lone leading tab to be + // stripped by the prose-keyed width, collapsing the nesting. + // + // Expanding up front, then stripping a pure-space width, preserves the relative nesting in both + // shapes. expanded := make([]string, len(lines)) for i, line := range lines { if strings.TrimSpace(line) == "" { - // Whitespace-only lines are blank separators with no content to - // indent; leave them untouched (a lone tab here is not YAML - // indentation). + // Whitespace-only lines are blank separators with no content to indent; leave them untouched (a + // lone tab here is not YAML indentation). expanded[i] = line continue } @@ -93,17 +91,16 @@ func RemoveIndent(lines []string) []string { 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. +// 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. +// 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. +// 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]) { @@ -121,9 +118,9 @@ func leadingIndent(line string) int { 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. +// 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]) { diff --git a/internal/parsers/yaml/dedent_test.go b/internal/parsers/yaml/dedent_test.go index 6319c57f..e4fe8816 100644 --- a/internal/parsers/yaml/dedent_test.go +++ b/internal/parsers/yaml/dedent_test.go @@ -10,12 +10,12 @@ import ( "github.com/go-openapi/testify/v2/assert" ) -// TestRemoveIndent_LeadingBlankLine pins quirk F7: when a body starts with a -// blank line (the one gofmt inserts under a column-0 doc-comment key), the -// dedent strip width must come from the first non-blank line, not the blank -// line — otherwise the tab-indented children survive and the YAML parser -// rejects them. The fixed output dedents and retabs identically to the -// blank-line-free form. +// TestRemoveIndent_LeadingBlankLine pins quirk F7: when a body starts with a blank line (the one +// gofmt inserts under a column-0 doc-comment key), the dedent strip width must come from the first +// non-blank line, not the blank line — otherwise the tab-indented children survive and the YAML +// parser rejects them. +// +// The fixed output dedents and retabs identically to the blank-line-free form. func TestRemoveIndent_LeadingBlankLine(t *testing.T) { // gofmt-canonical shape: blank line, key at one tab, children at two. withBlank := []string{"", "\toauth2:", "\t\ttype: oauth2", "\t\tin: header"} @@ -36,12 +36,13 @@ func TestRemoveIndent_LeadingBlankLine(t *testing.T) { } } -// TestRemoveIndent_InterleavedProseAndCodeBlocks pins the swagger:operation -// counterpart of F7: a gofmt-canonical operation body interleaves prose-rendered -// top-level keys (one leading space) with tab-prefixed value blocks. Expanding -// leading tabs to spaces BEFORE the first-non-blank-line strip preserves the -// relative nesting; the previous strip-then-retab approach stripped the -// children's lone tab off and flattened them. +// TestRemoveIndent_InterleavedProseAndCodeBlocks pins the swagger:operation counterpart of F7: a +// gofmt-canonical operation body interleaves prose-rendered top-level keys (one leading space) with +// tab-prefixed value blocks. +// +// Expanding leading tabs to spaces BEFORE the first-non-blank-line strip preserves the relative +// nesting; the previous strip-then-retab approach stripped the children's lone tab off and +// flattened them. func TestRemoveIndent_InterleavedProseAndCodeBlocks(t *testing.T) { in := []string{ " responses:", @@ -73,15 +74,15 @@ func TestRemoveIndent_InterleavedProseAndCodeBlocks(t *testing.T) { } } -// TestRemoveIndent_ColumnZeroKey checks that a column-0 first non-blank line -// (no indent to strip) leaves the body untouched. +// TestRemoveIndent_ColumnZeroKey checks that a column-0 first non-blank line (no indent to strip) +// leaves the body untouched. func TestRemoveIndent_ColumnZeroKey(t *testing.T) { in := []string{"", "oauth2:", " type: oauth2"} assert.Equal(t, in, RemoveIndent(in)) } -// TestRemoveIndent_AllBlank returns the input unchanged when every line is -// blank (no canonical line to key off). +// TestRemoveIndent_AllBlank returns the input unchanged when every line is blank (no canonical line +// to key off). func TestRemoveIndent_AllBlank(t *testing.T) { in := []string{"", " ", "\t"} assert.Equal(t, in, RemoveIndent(in)) diff --git a/internal/parsers/yaml/dedupe.go b/internal/parsers/yaml/dedupe.go index fcb4d299..247f9433 100644 --- a/internal/parsers/yaml/dedupe.go +++ b/internal/parsers/yaml/dedupe.go @@ -9,22 +9,20 @@ import ( "go.yaml.in/yaml/v3" ) -// dropDuplicateMappingKeys walks the YAML AST rooted at node and drops -// earlier-occurrence duplicate keys from every MappingNode it -// encounters (last-wins semantics). It mutates Content in place; -// re-decoding the cleaned node through go.yaml.in/yaml/v3 then +// dropDuplicateMappingKeys walks the YAML AST rooted at node and drops earlier-occurrence duplicate +// keys from every MappingNode it encounters (last-wins semantics). +// +// It mutates Content in place; re-decoding the cleaned node through go.yaml.in/yaml/v3 then // succeeds even under the library's strict uniqueKeys check. // -// Two keys are considered duplicates when they share both Kind and -// Value — matching the library's own duplicate-detection rule -// (decode.go: mapping(), uniqueKeys branch). Tag differences -// therefore separate "1" and 1 as distinct keys. +// Two keys are considered duplicates when they share both Kind and Value — matching the library's +// own duplicate-detection rule (decode.go: mapping(), uniqueKeys branch). +// Tag differences therefore separate "1" and 1 as distinct keys. // -// Last-wins matches the v1 gopkg.in/yaml.v2 behaviour codescan -// callers historically relied on. Diagnostic emission on duplicates is -// intentionally NOT done here; it is tracked as a future enhancement -// alongside the position-tracking yaml library swap (see -// the forthcoming-features roadmap). +// Last-wins matches the v1 gopkg.in/yaml.v2 behaviour codescan callers historically relied on. +// Diagnostic emission on duplicates is intentionally NOT done here; it is tracked as a future +// enhancement alongside the position-tracking yaml library swap (see the forthcoming-features +// roadmap). func dropDuplicateMappingKeys(node *yaml.Node) { if node == nil { return @@ -43,15 +41,14 @@ func dropDuplicateMappingKeys(node *yaml.Node) { } } -// pairStride is the (key, value) stride of yaml.MappingNode.Content — -// the library packs each mapping as a flat slice of alternating key -// and value nodes. +// pairStride is the (key, value) stride of yaml.MappingNode.Content — the library packs each +// mapping as a flat slice of alternating key and value nodes. const pairStride = 2 -// dedupePairs returns content with earlier occurrences of duplicate -// keys removed, preserving the order of the surviving (last-wins) -// pairs. Keys are matched by (Kind, Value), matching yaml.v3's own -// uniqueKeys comparison. +// dedupePairs returns content with earlier occurrences of duplicate keys removed, preserving the +// order of the surviving (last-wins) pairs. +// +// Keys are matched by (Kind, Value), matching yaml.v3's own uniqueKeys comparison. func dedupePairs(content []*yaml.Node) []*yaml.Node { // At most one pair → no duplicates possible. if len(content) < pairStride*2 { @@ -80,15 +77,13 @@ func dedupePairs(content []*yaml.Node) []*yaml.Node { return out } -// decodeYAMLBody parses body as YAML into an intermediate *yaml.Node, -// drops earlier-occurrence duplicate mapping keys (last-wins), then -// decodes the cleaned tree into dst. dst may be a typed struct, a -// generic map, or *any. +// decodeYAMLBody parses body as YAML into an intermediate *yaml.Node, drops earlier-occurrence +// duplicate mapping keys (last-wins), then decodes the cleaned tree into dst. dst may be a typed +// struct, a generic map, or *any. // -// This is the single helper the package's public surface calls -// instead of yaml.Unmarshal directly, so duplicate keys never abort -// the decode under strict uniqueKeys. Empty body is a no-op (dst -// untouched, nil error). +// This is the single helper the package's public surface calls instead of yaml.Unmarshal directly, +// so duplicate keys never abort the decode under strict uniqueKeys. +// Empty body is a no-op (dst untouched, nil error). func decodeYAMLBody(body []byte, dst any) error { if len(body) == 0 { return nil diff --git a/internal/parsers/yaml/dedupe_test.go b/internal/parsers/yaml/dedupe_test.go index 0425ca57..6d204a18 100644 --- a/internal/parsers/yaml/dedupe_test.go +++ b/internal/parsers/yaml/dedupe_test.go @@ -9,10 +9,9 @@ import ( "go.yaml.in/yaml/v3" ) -// The dedupe layer is exercised end-to-end via Parse / ParseInto / -// TypedExtensions tests (yaml_test.go); these tests pin the helper's -// behaviour directly so future edits can't regress edge cases without -// turning red here too. +// The dedupe layer is exercised end-to-end via Parse / ParseInto / TypedExtensions tests +// (yaml_test.go); these tests pin the helper's behaviour directly so future edits can't regress +// edge cases without turning red here too. func TestDedupeFlatMappingLastWins(t *testing.T) { body := []byte("type: apiKey\nname: KEY\ntype: oauth2\n") @@ -32,9 +31,8 @@ func TestDedupeFlatMappingLastWins(t *testing.T) { } func TestDedupeNestedMapping(t *testing.T) { - // Q28 repro shape — duplicate type/in inside a nested - // SecurityDefinitions entry, plus a duplicate top-level scheme - // name so we exercise both depths. + // Q28 repro shape — duplicate type/in inside a nested SecurityDefinitions entry, plus a + // duplicate top-level scheme name so we exercise both depths. body := []byte(`SecurityDefinitions: api_key: type: apiKey @@ -130,8 +128,8 @@ func TestDedupeDeeplyNested(t *testing.T) { } func TestDedupeNoDuplicatesIsIdempotent(t *testing.T) { - // A mapping without dups should pass through unchanged — the - // fast path in dedupePairs returns the original slice. + // A mapping without dups should pass through unchanged — the fast path in dedupePairs returns + // the original slice. body := []byte("alpha: 1\nbeta: 2\ngamma: 3\n") var got map[string]any if err := decodeYAMLBody(body, &got); err != nil { @@ -165,8 +163,8 @@ func TestDedupeScalarOnlyBody(t *testing.T) { } func TestDedupeIntoTypedStruct(t *testing.T) { - // ParseInto-style target — the dedupe must precede the struct - // decode so yaml.v3's strict check doesn't fire on the duplicate. + // ParseInto-style target — the dedupe must precede the struct decode so yaml.v3's strict check + // doesn't fire on the duplicate. type sec struct { Type string `yaml:"type"` Name string `yaml:"name"` @@ -183,10 +181,9 @@ func TestDedupeIntoTypedStruct(t *testing.T) { } func TestDedupeDistinctKindsNotMerged(t *testing.T) { - // (Kind, Value) is the dedupe key — a string "1" and an int 1 - // have different scalar styles but the same Value+Kind in YAML, - // so they DO collapse. Document the choice: this mirrors - // yaml.v3's own uniqueKeys comparison (decode.go:775). + // (Kind, Value) is the dedupe key — a string "1" and an int 1 have different scalar styles but + // the same Value+Kind in YAML, so they DO collapse. + // Document the choice: this mirrors yaml.v3's own uniqueKeys comparison (decode.go:775). body := []byte(`"1": quoted 1: unquoted `) @@ -194,9 +191,8 @@ func TestDedupeDistinctKindsNotMerged(t *testing.T) { if err := decodeYAMLBody(body, &got); err != nil { t.Fatalf("decode: %v", err) } - // Both keys land in the same scalar-kind bucket; last-wins - // keeps the second entry. The surviving key may be quoted or - // unquoted depending on the yaml lib's round-trip; assert on + // Both keys land in the same scalar-kind bucket; last-wins keeps the second entry. + // The surviving key may be quoted or unquoted depending on the yaml lib's round-trip; assert on // the value. if len(got) != 1 { t.Errorf("want 1 surviving key (kind+value collapse), got %d: %v", len(got), got) diff --git a/internal/parsers/yaml/operation.go b/internal/parsers/yaml/operation.go index ca27ccb1..e2b32f3f 100644 --- a/internal/parsers/yaml/operation.go +++ b/internal/parsers/yaml/operation.go @@ -10,9 +10,8 @@ import ( "github.com/go-openapi/swag/yamlutils" ) -// 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: +// 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 @@ -26,10 +25,9 @@ import ( // // Empty body returns nil — the caller's target is left untouched. // -// Used by the operations bridge (swagger:operation YAML body), -// the meta bridge (securityDefinitions, externalDocs), and any -// future mapping target that needs the same shape. Sequence-shaped -// bodies (e.g. meta `Tags:`) use [UnmarshalListBody]. +// Used by the operations bridge (swagger:operation YAML body), the meta bridge +// (securityDefinitions, externalDocs), and any future mapping target that needs the same shape. +// Sequence-shaped bodies (e.g. meta `Tags:`) use [UnmarshalListBody]. func UnmarshalBody(body string, unmarshal func([]byte) error) error { if body == "" { return nil @@ -71,9 +69,8 @@ func UnmarshalListBody(body string, unmarshal func([]byte) error) error { return unmarshal(jsonValue) } -// normaliseBody applies the godoc-comment dedent shared by the -// body unmarshal helpers: strip the common leading indent and expand -// residual leading tabs to spaces (YAML refuses tab indentation). +// normaliseBody applies the godoc-comment dedent shared by the body unmarshal helpers: strip the +// common leading indent and expand residual leading tabs to spaces (YAML refuses tab indentation). func normaliseBody(body string) string { lines := strings.Split(body, "\n") lines = RemoveIndent(lines) diff --git a/internal/parsers/yaml/operation_test.go b/internal/parsers/yaml/operation_test.go index 7dc0b8bb..c2ed3510 100644 --- a/internal/parsers/yaml/operation_test.go +++ b/internal/parsers/yaml/operation_test.go @@ -11,10 +11,11 @@ import ( 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). +// 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 @@ -52,16 +53,15 @@ func TestUnmarshalBody_InvalidYAML(t *testing.T) { } func TestUnmarshalBody_EmptyBody(t *testing.T) { - // Empty body short-circuits before unmarshal — caller's target - // stays untouched. + // 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). +// 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) @@ -73,10 +73,11 @@ func TestUnmarshalBody_TabIndent(t *testing.T) { } } -// TestUnmarshalListBody_Tags checks the sequence-shaped pipeline used -// by the meta `Tags:` bridge: a YAML list of tag objects (with a -// nested externalDocs mapping and a vendor extension) round-trips into -// []spec.Tag. Tab-indented like a real godoc comment body. +// TestUnmarshalListBody_Tags checks the sequence-shaped pipeline used by the meta `Tags:` bridge: a +// YAML list of tag objects (with a nested externalDocs mapping and a vendor extension) round-trips +// into []spec.Tag. +// +// Tab-indented like a real godoc comment body. func TestUnmarshalListBody_Tags(t *testing.T) { body := "\t- name: pet\n" + "\t description: Everything about your Pets\n" + diff --git a/internal/parsers/yaml/yaml.go b/internal/parsers/yaml/yaml.go index d0cc670f..9c52b0da 100644 --- a/internal/parsers/yaml/yaml.go +++ b/internal/parsers/yaml/yaml.go @@ -1,10 +1,9 @@ // 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 grammar lexer calls for `extensions:` raw blocks. +// 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 +// grammar lexer calls for `extensions:` raw blocks. // // Importers: // @@ -13,11 +12,10 @@ // - internal/parsers/grammar — calls [TypedExtensions] from its // extensions raw-block lexer so Extension.Value ships typed. // -// The grammar import is the one carve-out from the "grammar stays -// YAML-free" architecture rule. +// The grammar import is the one carve-out from the "grammar stays YAML-free" architecture rule. // -// See README.md for the long-form rationale (typed-extensions -// pipeline, dedent strategies, sibling-sub-parser seam). +// See README.md for the long-form rationale (typed-extensions pipeline, dedent strategies, +// sibling-sub-parser seam). package yaml import ( @@ -50,10 +48,10 @@ func Parse(body string) (any, error) { 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. +// 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 @@ -61,21 +59,19 @@ func ParseInto(body string, dst any) error { return decodeYAMLBody([]byte(body), dst) } -// 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). +// 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 grammar lexer preserves godoc-level indentation per +// line, but YAML refuses tab indentation and treats leading whitespace as structural. // -// The body is dedented before parsing — the grammar lexer preserves -// godoc-level indentation per line, but YAML refuses tab indentation -// and treats leading whitespace as structural. The -// YAML → JSON normalisation enforces map[string]any with concrete -// leaf types via swag/yamlutils.YAMLToJSON; downstream consumers -// (vendor-extension targets, code generators, AddExtension surfaces) -// rely on that shape. +// The YAML → JSON normalisation enforces map[string]any with concrete leaf types via +// swag/yamlutils.YAMLToJSON; downstream consumers (vendor-extension targets, code generators, +// AddExtension surfaces) rely on 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). +// 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). // // See README.md §typed-extensions for the full contract. func TypedExtensions(body string) (map[string]any, error) { @@ -98,17 +94,16 @@ func TypedExtensions(body string) (map[string]any, error) { 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). +// 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 grammar lexer preserves each line's original whitespace so that -// indentation survives for nested YAML; the dedent therefore lives -// downstream of it. Tab-and-space mixes in godoc-style sources parse -// identically after this pass — both petstore's tab-indented -// Extensions block and the typed-nested case using two-space -// indentation round-trip uniformly. +// The grammar lexer preserves each line's original whitespace so that indentation survives for +// nested YAML; the dedent therefore lives downstream of it. +// +// Tab-and-space mixes in godoc-style sources parse identically after this pass — both petstore's +// tab-indented Extensions block and the typed-nested case using two-space indentation round-trip +// uniformly. func normaliseExtensionBody(body string) string { lines := strings.Split(body, "\n") @@ -127,9 +122,10 @@ func normaliseExtensionBody(body string) string { 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). +// 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 { @@ -153,9 +149,8 @@ func commonLeadingWhitespace(lines []string) int { 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. +// 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') { diff --git a/internal/parsers/yaml/yaml_test.go b/internal/parsers/yaml/yaml_test.go index c76c7aff..3ce838c2 100644 --- a/internal/parsers/yaml/yaml_test.go +++ b/internal/parsers/yaml/yaml_test.go @@ -22,9 +22,9 @@ func TestParseEmpty(t *testing.T) { } 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. + // 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 { @@ -44,8 +44,8 @@ func TestParseFlatMap(t *testing.T) { 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. + // 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 { @@ -55,8 +55,8 @@ func TestParseNestedStructure(t *testing.T) { 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). + // 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"]) @@ -134,9 +134,8 @@ func TestTypedExtensionsFlatScalars(t *testing.T) { } 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. + // 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 { diff --git a/internal/scanner/README.md b/internal/scanner/README.md index ca50a2ca..8820a816 100644 --- a/internal/scanner/README.md +++ b/internal/scanner/README.md @@ -24,6 +24,10 @@ parameters, responses) consumed by the builder layer. pure read vs implicit registration - [§classifier](#classifier) — `detectNodes` bitmask semantics and struct-annotation exclusivity +- [§after-decl](#after-decl) — `AfterDeclComments` — reading annotations + inside / below a declaration +- [§clean-godoc](#clean-godoc) — `CleanGoDoc` — filtering godoc syntax out + of carried-over title / description prose - [§quirks-open](#quirks-open) — deferred follow-ups --- @@ -115,14 +119,29 @@ three emission modes: Without `ScanModels` the flag is a no-op (the set is already reachable-only) and raises one positionless `scan.pruned-unused` Hint. +**Shared objects pruned first (C4).** Before the definition walk, the +shared parameters (`#/parameters/*`) and responses (`#/responses/*`) that +no operation and no path-item references are themselves pruned +(`spec/prune.go`, `pruneUnusedSharedObjects`; the read-only "is +referenced" mirror is `collectSharedRefs`). `InputSpec`-supplied shared +objects are pinned (never pruned), mirroring the definitions rule. Each +drop raises a located `scan.pruned-unused` Hint. Because this precedes +the definition walk, a definition kept alive only by a now-pruned shared +object becomes prunable in turn. A pruned shared response's buffered +provenance anchors are dropped (`DropDeferredOrigins`) so none dangle — +shared-response anchors are buffered (`BeginDeferredOrigins`) and flushed +verbatim after the prune only when `PruneUnusedModels` is set, so the +non-prune anchor stream is unchanged. + **Reachability.** Roots are the paths (operation body parameters + -response schemas), the shared `responses` and `parameters`, and every -definition supplied via `InputSpec`. Overlay definitions are **pinned**: -never pruned and seeded as roots so their `$ref` targets survive. The -walk (`spec/prune.go`, `collectDefRefs`) is the read-only mirror of the -ref-rewriter (`reduce.go`, `rewriteSchemaRefs`) and must cover the same -container set; a `visited` set handles recursive / cyclic models. A -model referenced only by another unreferenced model is itself pruned. +response schemas), the *surviving* shared `responses` and `parameters`, +and every definition supplied via `InputSpec`. Overlay definitions are +**pinned**: never pruned and seeded as roots so their `$ref` targets +survive. The walk (`spec/prune.go`, `collectDefRefs`) is the read-only +mirror of the ref-rewriter (`reduce.go`, `rewriteSchemaRefs`) and must +cover the same container set; a `visited` set handles recursive / cyclic +models. A model referenced only by another unreferenced model is itself +pruned. **Ordering — before name reduction.** The prune runs *before* `reduceDefinitionNames`, in the fully-qualified `#/definitions// @@ -211,9 +230,70 @@ violated. The annotation vocabulary recognised by the classifier is a closed set. Unknown annotations beginning with `swagger:` raise a classifier error. A handful of annotation tokens (`strfmt`, `name`, -`enum`, `default`, `alias`, `type`, …) are recognised but produce -no bit — they are field-level decorations that downstream builders -parse out of the comment block directly. +`enum`, `default`, `alias`, `type`, `title`, `description`, …) are +recognised but produce no bit — they are field/decl-level decorations +that downstream builders parse out of the comment block directly. +(`title` / `description` are the godoc title/description overrides; see +the schema builder's [§user-overrides](../builders/schema/README.md#user-overrides).) + +## §after-decl — `AfterDeclComments` + +`Options.AfterDeclComments` (opt-in, default false) lets swagger annotations +live **inside** a declaration or **inlined** as a trailing comment, so the godoc +*above* the declaration stays clean and human-facing. It is **solely a scanner +concern** — the located comments are folded into the comment source the builders +already consume (`EntityDecl.Comments` and `ast.Field.Doc`), so the grammar and +builders are untouched. Same annotation grammar, no new syntax. + +What the scanner folds, by shape (`index.go`): + +| Shape | Folded comment | Into | +|---|---|---| +| struct type | leading body comment groups (after `{`, before the first field, excluding any field `.Doc`) — `leadingBodyComments` | a fresh merged `EntityDecl.Comments` (`ts.Doc` untouched) | +| alias / non-struct type | trailing `TypeSpec.Comment` (`type X = Y // swagger:model …`) | same | +| struct field | trailing `Field.Comment` (`B string // swagger:strfmt date`) — `enrichStructFields` | the shared `Field.Doc` (the one mutation, see below) | + +The clean godoc above still provides the title/description: the merged group is +`docAbove ++ located`, and because positions stay ascending (doc above < the +inside/trailing comment below), the grammar reconstructs a blank-line gap and +parses it without change. Discovery works because `detectNodes` already scans +every `file.Comments` group (the file bitmask flips), and the merged +`EntityDecl.Comments` makes the per-decl `HasModelAnnotation` gate pass. + +**Idempotency.** Decl-level folding is pure construction — `ts.Doc` is never +mutated, so re-processing is safe with no guard. Field-level folding is the only +place the shared AST is mutated (`Field.Doc` is repointed to the merged group), +guarded by `TypeIndex.enrichedFields` so a field is rewritten at most once. + +**Routes / operations** are already position-agnostic +(`collectRoute/OperationPathAnnotations` scan all `file.Comments`), so a +`swagger:route` inside a func body is discovered with or without this option. + +**Out of scope.** A standalone `const X = … // swagger:enum`: `swagger:enum` is +type-based (it resolves a *type* and collects that type's consts via +`FindEnumValues`), so a lone const is not an enum carrier and has no builder +semantics today. Supporting it would mean new builder behaviour, which this +scanner-only feature deliberately avoids. Nested/anonymous inline structs are +likewise not enriched (only named struct type decls are walked). + +## §clean-godoc — `CleanGoDoc` + +`Options.CleanGoDoc` (opt-in, default false) rewrites godoc-specific syntax that +reads as bracket noise when a title / description is carried **from godoc** into +the spec, and recomposes resolvable doc-links to the name the referenced schema +is **exposed under**. Off ⇒ output is byte-identical. + +The scanner side is thin: it holds the flag (`CleanGoDoc()`) and a shared +`mangling.NameMangler` (`Mangler()`, used for humanization). The transform, +the consumption-seam wiring, the go/types resolver, and the post-reduce marker +substitution all live in the **builders** — see +[`internal/builders/godoclink/README.md`](../builders/godoclink/README.md) for +the two-phase marker contract and the full mechanics. + +Like `swagger:title` / `swagger:description` (overrides) and `AfterDeclComments`, +this is part of the **clean-godoc cluster**: keep the Go-facing doc clean while +the API spec carries curated text. Crucially it touches **only godoc-derived +prose** — author-written overrides (harvested separately) are never filtered. ## §quirks-open — deferred follow-ups @@ -224,8 +304,8 @@ parse out of the comment block directly. - **Recognised-but-unused annotation tokens.** `detectNodes` recognises a list of field-level tokens (`strfmt`, `name`, `discriminated`, `file`, `enum`, `default`, `alias`, `type`, - `allOf`, `ignore`) only to avoid raising the "unknown annotation" - error. Promoting them to per-file bits would let downstream + `allOf`, `ignore`, `title`, `description`) only to avoid raising the + "unknown annotation" error. Promoting them to per-file bits would let downstream builders skip whole files that carry no decorations — an optimisation, not a correctness change. - **`shouldAcceptTag` precedence.** When both `includeTags` and diff --git a/internal/scanner/classify/extension.go b/internal/scanner/classify/extension.go index 5ae7a616..536eac59 100644 --- a/internal/scanner/classify/extension.go +++ b/internal/scanner/classify/extension.go @@ -1,22 +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. +// 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/. +// 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). +// 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/declaration.go b/internal/scanner/declaration.go index 06adfffd..7edc4477 100644 --- a/internal/scanner/declaration.go +++ b/internal/scanner/declaration.go @@ -6,12 +6,27 @@ package scanner import ( "go/ast" "go/types" - "strings" "github.com/go-openapi/codescan/internal/parsers" "golang.org/x/tools/go/packages" ) +// ParameterRef is a standalone `swagger:parameters` marker hosted by a func (or other non-struct +// declaration) rather than a struct definition. +// +// Per the disambiguation rule, such a marker is a *reference*: it wires existing shared parameters +// into an operation or path-item as `$ref`s — its first argument token is the target (an +// operation id or a `/path`) and the remaining tokens are shared-parameter names. +// +// The scanner only discovers and locates the marker; its target and names are parsed from Comments +// by the grammar (grammar.ParametersBlock) when the shared-parameters builder consumes it. +// See .claude/plans/features/shared-parameters-fixtures.md §1b. +type ParameterRef struct { + Comments *ast.CommentGroup + File *ast.File + Pkg *packages.Package +} + type EntityDecl struct { Comments *ast.CommentGroup Type *types.Named @@ -26,12 +41,12 @@ type EntityDecl struct { modelOverrideSuppressed bool } -// SuppressModelOverride drops this declaration's `swagger:model ` -// override so that Names / DefKey fall back to the Go type name. Used to -// resolve a same-package duplicate, where two distinct types in one -// package claim the same override name (a user error): the first keeps -// the name, later ones revert to their Go name. See name-identity design -// D-4 (.claude/plans/name-identity-cyclic-ref.md §9.1). +// SuppressModelOverride drops this declaration's `swagger:model ` override so that Names / +// DefKey fall back to the Go type name. +// +// Used to resolve a same-package duplicate, where two distinct types in one package claim the same +// override name (a user error): the first keeps the name, later ones revert to their Go name. +// See name-identity design D-4 (.claude/plans/name-identity-cyclic-ref.md §9.1). func (d *EntityDecl) SuppressModelOverride() { d.modelOverrideSuppressed = true } // ModelOverrideSuppressed reports whether SuppressModelOverride was set. @@ -75,18 +90,19 @@ func (d *EntityDecl) Names() (name, goName string) { return model, goName } -// DefKey returns the fully-qualified, compiler-unique definition key for -// this declaration: "/", where is the -// swagger:model override when present, else the Go type name (the first -// return of Names). This is the build-time key for the definitions map -// and for every "#/definitions/" $ref target, so two distinct Go types -// that share a short name can never collide before the spec.Builder's -// reduce stage shortens names back. See the name-identity / cyclic-$ref -// design (.claude/plans/name-identity-cyclic-ref.md §9.1, §12.1). +// DefKey returns the fully-qualified, compiler-unique definition key for this declaration: +// "/", where is the swagger:model override when present, else the Go type +// name (the first return of Names). +// +// This is the build-time key for the definitions map and for every "#/definitions/" $ref target, so +// two distinct Go types that share a short name can never collide before the spec.Builder's reduce +// stage shortens names back. +// +// See the name-identity / cyclic-$ref design (.claude/plans/name-identity-cyclic-ref.md §9.1, +// §12.1). // -// Universe / package-less types (no enclosing package) fall back to the -// bare name; in practice those are intercepted as stdlib specials before -// they ever reach a definition key. +// Universe / package-less types (no enclosing package) fall back to the bare name; in practice +// those are intercepted as stdlib specials before they ever reach a definition key. func (d *EntityDecl) DefKey() string { name, _ := d.Names() if pkg := d.Obj().Pkg(); pkg != nil { @@ -95,45 +111,6 @@ func (d *EntityDecl) DefKey() string { return name } -func (d *EntityDecl) ResponseNames() (name, goName string) { - goName = d.Ident.Name - response, ok := parsers.ResponseOverride(d.Comments) - if !ok { - return name, goName - } - - d.hasResponseAnnotation = true - if response == "" { - return goName, goName - } - - return response, goName -} - -func (d *EntityDecl) OperationIDs() (result []string) { - if d == nil { - return nil - } - - parameters, ok := parsers.ParametersOverride(d.Comments) - if !ok { - return nil - } - - d.hasParameterAnnotation = true - - for _, parameter := range parameters { - for param := range strings.SplitSeq(parameter, " ") { - name := strings.TrimSpace(param) - if len(name) > 0 { - result = append(result, name) - } - } - } - - return result -} - func (d *EntityDecl) HasModelAnnotation() bool { if d.hasModelAnnotation { return true diff --git a/internal/scanner/declaration_test.go b/internal/scanner/declaration_test.go index 7c3e2824..7fa26d08 100644 --- a/internal/scanner/declaration_test.go +++ b/internal/scanner/declaration_test.go @@ -6,7 +6,6 @@ package scanner import ( "go/ast" "go/types" - "slices" "testing" "github.com/go-openapi/testify/v2/assert" @@ -120,86 +119,13 @@ func TestEntityDecl(t *testing.T) { }) }) - t.Run("ResponseNames", func(t *testing.T) { - t.Run("response with override name", func(t *testing.T) { - decl, ok := sctx.FindDecl( - "github.com/go-openapi/codescan/fixtures/goparsing/classification/operations", - "GenericError", - ) - require.True(t, ok) - - name, goName := decl.ResponseNames() - assert.EqualT(t, "GenericError", goName) - assert.EqualT(t, "genericError", name) - }) - - t.Run("type without response annotation", func(t *testing.T) { - decl, ok := sctx.FindDecl( - "github.com/go-openapi/codescan/fixtures/goparsing/classification/operations", - "SimpleOne", - ) - require.True(t, ok) + // Response name resolution moved to the grammar (grammar.ResponseBlock → + // responses.Builder.ResponseName); the scanner no longer parses the swagger:response argument. + // See the grammar parser tests and the shared-parameters response coverage. - name, goName := decl.ResponseNames() - assert.EqualT(t, "SimpleOne", goName) - assert.EqualT(t, "", name) - }) - - t.Run("response with bare annotation no override", func(t *testing.T) { - decl, ok := sctx.FindDecl( - "github.com/go-openapi/codescan/fixtures/goparsing/classification/operations", - "NumPlatesResp", - ) - require.True(t, ok) - - name, goName := decl.ResponseNames() - assert.EqualT(t, "NumPlatesResp", goName) - assert.EqualT(t, "NumPlatesResp", name) - }) - }) - - t.Run("OperationIDs", func(t *testing.T) { - t.Run("nil receiver returns nil", func(t *testing.T) { - var decl *EntityDecl - assert.Nil(t, decl.OperationIDs()) - }) - - t.Run("type with single parameter annotation", func(t *testing.T) { - decl, ok := sctx.FindDecl( - "github.com/go-openapi/codescan/fixtures/goparsing/classification/operations", - "MyFileParams", - ) - require.True(t, ok) - - ids := decl.OperationIDs() - require.Len(t, ids, 1) - assert.EqualT(t, "myOperation", ids[0]) - }) - - t.Run("type with multiple parameter annotations", func(t *testing.T) { - decl, ok := sctx.FindDecl( - "github.com/go-openapi/codescan/fixtures/goparsing/classification/operations", - "OrderBodyParams", - ) - require.True(t, ok) - - ids := decl.OperationIDs() - require.Len(t, ids, 2) - assert.True(t, slices.Contains(ids, "updateOrder"), "expected ids to contain updateOrder") - assert.True(t, slices.Contains(ids, "createOrder"), "expected ids to contain createOrder") - }) - - t.Run("type without parameter annotation returns nil", func(t *testing.T) { - decl, ok := sctx.FindDecl( - "github.com/go-openapi/codescan/fixtures/goparsing/classification/operations", - "SimpleOne", - ) - require.True(t, ok) - - ids := decl.OperationIDs() - assert.Nil(t, ids) - }) - }) + // Operation-id targeting parse moved to the grammar (grammar.ParametersBlock); see the grammar + // parser tests. + // The scanner no longer parses swagger:parameters arguments. t.Run("HasAnnotation caching", func(t *testing.T) { t.Run("HasModelAnnotation caches result", func(t *testing.T) { diff --git a/internal/scanner/enum_value.go b/internal/scanner/enum_value.go index 9975d674..c8373a21 100644 --- a/internal/scanner/enum_value.go +++ b/internal/scanner/enum_value.go @@ -9,14 +9,12 @@ import ( "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. +// 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). +// 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": diff --git a/internal/scanner/export_test.go b/internal/scanner/export_test.go index b72454e1..6392f436 100644 --- a/internal/scanner/export_test.go +++ b/internal/scanner/export_test.go @@ -6,8 +6,9 @@ package scanner import "golang.org/x/tools/go/packages" // PkgForPath exposes the internal package lookup for test use only. -// It is not part of the production API; production code resolves packages -// through typed entry points like FindDecl/FindModel/DeclForType. +// +// It is not part of the production API; production code resolves packages through typed entry +// points like FindDecl/FindModel/DeclForType. func (s *ScanCtx) PkgForPath(pkgPath string) (*packages.Package, bool) { v, ok := s.app.AllPackages[pkgPath] return v, ok diff --git a/internal/scanner/index.go b/internal/scanner/index.go index 01bc704f..8f089dbe 100644 --- a/internal/scanner/index.go +++ b/internal/scanner/index.go @@ -65,11 +65,22 @@ func WithTransparentAliases(enabled bool) TypeIndexOption { } } -// WithOnDiagnostic wires the consumer's diagnostic sink so the index can -// surface scan-environment observations (e.g. a package or route omitted by -// the caller's own include/exclude rules) as informational Hints. The index -// is built before the ScanCtx exists, so it reports through the raw callback -// directly, exactly as detectDegradedLoad does. +// WithAfterDeclComments enables folding a declaration's inside-body leading comment (struct) or +// trailing comment (alias / non-struct type) into the decl's annotation source. +// +// See Options.AfterDeclComments. +func WithAfterDeclComments(enabled bool) TypeIndexOption { + return func(a *TypeIndex) { + a.afterDeclComments = enabled + } +} + +// WithOnDiagnostic wires the consumer's diagnostic sink so the index can surface scan-environment +// observations (e.g. a package or route omitted by the caller's own include/exclude rules) as +// informational Hints. +// +// The index is built before the ScanCtx exists, so it reports through the raw callback directly, +// exactly as detectDegradedLoad does. func WithOnDiagnostic(cb func(grammar.Diagnostic)) TypeIndexOption { return func(a *TypeIndex) { a.onDiagnostic = cb @@ -84,6 +95,7 @@ type TypeIndex struct { Routes []parsers.ParsedPathContent Operations []parsers.ParsedPathContent Parameters []*EntityDecl + ParameterRefs []*ParameterRef Responses []*EntityDecl excludeDeps bool includeTags map[string]bool @@ -93,31 +105,23 @@ type TypeIndex struct { setXNullableForPointers bool refAliases bool transparentAliases bool + afterDeclComments bool onDiagnostic func(grammar.Diagnostic) -} -// emit delivers d to the consumer's diagnostic sink when one is wired. No-op -// otherwise. The index is built before the ScanCtx exists, so it reports -// through the raw callback (no dedup), exactly as detectNodes-level and -// detectDegradedLoad observations do. -func (a *TypeIndex) emit(d grammar.Diagnostic) { - if a.onDiagnostic == nil { - return - } - a.onDiagnostic(d) -} - -// emitHintf delivers an informational Hint with no source position (the index -// observes whole-package / whole-route omissions, not a single token). -func (a *TypeIndex) emitHintf(code grammar.Code, format string, args ...any) { - a.emit(grammar.Hintf(token.Position{}, code, format, args...)) + // enrichedFields guards the Phase-B AfterDeclComments field rewrite (append Field.Comment onto + // Field.Doc) so a given field is enriched at most once even if its struct is visited more than + // once. + // + // This is the only place AfterDeclComments mutates the shared AST. + enrichedFields map[*ast.Field]struct{} } func NewTypeIndex(pkgs []*packages.Package, opts ...TypeIndexOption) (*TypeIndex, error) { ac := &TypeIndex{ - AllPackages: make(map[string]*packages.Package), - Models: make(map[*ast.Ident]*EntityDecl), - ExtraModels: make(map[*ast.Ident]*EntityDecl), + AllPackages: make(map[string]*packages.Package), + Models: make(map[*ast.Ident]*EntityDecl), + ExtraModels: make(map[*ast.Ident]*EntityDecl), + enrichedFields: make(map[*ast.Field]struct{}), } for _, apply := range opts { apply(ac) @@ -129,6 +133,24 @@ func NewTypeIndex(pkgs []*packages.Package, opts ...TypeIndexOption) (*TypeIndex return ac, nil } +// emit delivers d to the consumer's diagnostic sink when one is wired. +// +// No-op otherwise. +// The index is built before the ScanCtx exists, so it reports through the raw callback (no dedup), +// exactly as detectNodes-level and detectDegradedLoad observations do. +func (a *TypeIndex) emit(d grammar.Diagnostic) { + if a.onDiagnostic == nil { + return + } + a.onDiagnostic(d) +} + +// emitHintf delivers an informational Hint with no source position (the index observes +// whole-package / whole-route omissions, not a single token). +func (a *TypeIndex) emitHintf(code grammar.Code, format string, args ...any) { + a.emit(grammar.Hintf(token.Position{}, code, format, args...)) +} + func (a *TypeIndex) build(pkgs []*packages.Package) error { for _, pkg := range pkgs { if _, known := a.AllPackages[pkg.PkgPath]; known { @@ -227,6 +249,11 @@ func (a *TypeIndex) processFileDecls(pkg *packages.Package, file *ast.File, n no case *ast.BadDecl: continue case *ast.FuncDecl: + // A `swagger:parameters` marker on a func is a reference (it wires shared parameters into an + // operation / path-item as $refs), never a definition — definitions live on struct types. + if n¶metersNode != 0 { + a.collectParameterRef(pkg, file, fd.Doc) + } if fd.Body == nil { continue } @@ -265,6 +292,18 @@ func (a *TypeIndex) processDecl(pkg *packages.Package, file *ast.File, n node, g if comments == nil { comments = gd.Doc // /* doc */ type ( Foo struct{} ) } + // AfterDeclComments (opt-in): also read the swagger annotations that live inside the declaration + // (a struct's leading body comment) or inlined after it (an alias / non-struct type's trailing + // comment), so the godoc above stays clean. + // Folded into a fresh comment group — ts.Doc is never mutated, so this is idempotent. + if a.afterDeclComments { + comments = mergeCommentGroups(comments, afterDeclSource(file, ts)) + // Phase B: fold each struct field's trailing comment into its Doc (the field-level inlined + // form, e.g. `B string // swagger:strfmt date`). + // Runs AFTER afterDeclSource so leadingBodyComments still sees the original field Docs for its + // exclusion set. + a.enrichStructFields(ts) + } decl := &EntityDecl{ Comments: comments, @@ -288,6 +327,126 @@ func (a *TypeIndex) processDecl(pkg *packages.Package, file *ast.File, n node, g } } +// afterDeclSource returns the comment group carrying swagger annotations that +// lives inside / after a type declaration, when AfterDeclComments is set: +// - struct type: the leading comment groups at the top of the body (before the +// first field, excluding any field's own Doc), collected in source order; +// - alias / non-struct type: the trailing line comment (TypeSpec.Comment), +// e.g. `type X = Y // swagger:model apiType`. +// +// Returns nil when there is nothing to fold. +func afterDeclSource(file *ast.File, ts *ast.TypeSpec) *ast.CommentGroup { + if st, ok := ts.Type.(*ast.StructType); ok { + return leadingBodyComments(file, st) + } + return ts.Comment +} + +// leadingBodyComments collects every comment group positioned at the top of a struct body — after +// the opening brace and before the first field — that is not itself a field's Doc, in source +// order. +// +// Excluding field Docs keeps an adjacent `// swagger:allOf` above the first field attached to that +// field rather than stolen as a type-level annotation. +// Returns nil when there are none. +func leadingBodyComments(file *ast.File, st *ast.StructType) *ast.CommentGroup { + fields := st.Fields + if fields == nil { + return nil + } + limit := fields.Closing // empty struct: up to the closing brace + if len(fields.List) > 0 { + limit = fields.List[0].Pos() + } + docs := make(map[*ast.CommentGroup]struct{}, len(fields.List)) + for _, f := range fields.List { + if f.Doc != nil { + docs[f.Doc] = struct{}{} + } + } + var collected []*ast.Comment + for _, cg := range file.Comments { + if cg.Pos() <= fields.Opening || cg.Pos() >= limit { + continue + } + if _, isFieldDoc := docs[cg]; isFieldDoc { + continue + } + collected = append(collected, cg.List...) + } + if len(collected) == 0 { + return nil + } + return &ast.CommentGroup{List: collected} +} + +// enrichStructFields folds each struct field's trailing line comment (Field.Comment) into its +// Field.Doc — the field-level inlined form of AfterDeclComments (`B string // swagger:strfmt +// date`). +// +// This mutates the shared AST (the builders read Field.Doc directly), so the enrichedFields guard +// ensures each field is rewritten at most once. +// Positions stay ascending (Doc above < trailing comment), so the grammar parses the merged group +// unchanged. +// +// No-op for non-struct types and fields without a trailing comment. +func (a *TypeIndex) enrichStructFields(ts *ast.TypeSpec) { + st, ok := ts.Type.(*ast.StructType) + if !ok || st.Fields == nil { + return + } + for _, f := range st.Fields.List { + if f.Comment == nil || len(f.Comment.List) == 0 { + continue + } + if _, done := a.enrichedFields[f]; done { + continue + } + f.Doc = mergeCommentGroups(f.Doc, f.Comment) + a.enrichedFields[f] = struct{}{} + } +} + +// mergeCommentGroups returns a comment group whose List is above ++ extra in source order — above +// is the doc ABOVE the decl, extra lives inside/below it so positions stay ascending and the +// grammar reconstructs a clean blank-line gap. +// +// Returns the non-nil one when the other is nil (nil only when both are). +// The input groups are never mutated. +func mergeCommentGroups(above, extra *ast.CommentGroup) *ast.CommentGroup { + switch { + case extra == nil || len(extra.List) == 0: + return above + case above == nil || len(above.List) == 0: + return extra + default: + merged := make([]*ast.Comment, 0, len(above.List)+len(extra.List)) + merged = append(merged, above.List...) + merged = append(merged, extra.List...) + return &ast.CommentGroup{List: merged} + } +} + +// collectParameterRef records a standalone `swagger:parameters` reference marker found on a func's +// doc comment. +// +// The marker's argument tokens are not parsed here — the grammar does that when a builder +// consumes the ParameterRef; the scanner only classifies the comment group as carrying a reference. +// No-op when doc carries no `swagger:parameters` marker. +func (a *TypeIndex) collectParameterRef(pkg *packages.Package, file *ast.File, doc *ast.CommentGroup) { + if doc == nil { + return + } + if _, ok := parsers.ParametersOverride(doc); !ok { + return + } + a.ParameterRefs = append(a.ParameterRefs, &ParameterRef{ + Comments: doc, + File: file, + Pkg: pkg, + }) +} + func (a *TypeIndex) walkImports(pkg *packages.Package) error { if a.excludeDeps { return nil @@ -309,14 +468,13 @@ func (a *TypeIndex) walkImports(pkg *packages.Package) error { return nil } -// detectNodes scans all comment groups in a file and returns a bitmask -// of detected swagger annotation kinds. +// detectNodes scans all comment groups in a file and returns a bitmask of detected swagger +// annotation kinds. // // # Details // -// See [§classifier](./README.md#classifier) — bitmask semantics, -// struct-annotation exclusivity rule, and the recognised-but-bitless -// field-decoration tokens. +// See [§classifier](./README.md#classifier) — bitmask semantics, struct-annotation exclusivity +// rule, and the recognised-but-bitless field-decoration tokens. func (a *TypeIndex) detectNodes(file *ast.File) (node, error) { var n node for _, comments := range file.Comments { @@ -361,7 +519,7 @@ func (a *TypeIndex) detectNodes(file *ast.File) (node, error) { if err := checkStructConflict(&seenStruct, annotation, cline.Text); err != nil { return 0, err } - case "strfmt", "name", "discriminated", "file", "enum", "default", "alias", "type", "additionalProperties", "patternProperties": + case "strfmt", "name", "discriminated", "file", "enum", "default", "alias", "type", "additionalProperties", "patternProperties", "title", "description": // Proposal for enhancement: perhaps collect these and pass along to avoid lookups later on case "allOf": case "ignore": @@ -374,14 +532,16 @@ func (a *TypeIndex) detectNodes(file *ast.File) (node, error) { return n, nil } -// warnMalformedStructName emits a Warning diagnostic when a single-name struct -// marker (swagger:model / swagger:response) on line carries a name that is -// not a plain identifier — e.g. a package-qualified "utils.Error" -// (go-swagger#874). Such names are JSON labels, not Go-qualified -// identifiers; the strict override matcher rejects them and the marker is -// ignored. The diagnostic gives the author a clue rather than silently -// dropping it. The type's package is resolved automatically, so a plain name -// suffices regardless of which package the type lives in. +// warnMalformedStructName emits a Warning diagnostic when a single-name struct marker +// (swagger:model / swagger:response) on line carries a name that is not a plain identifier — e.g. +// a package-qualified "utils.Error" (go-swagger#874). +// +// Such names are JSON labels, not Go-qualified identifiers; the strict override matcher rejects +// them and the marker is ignored. +// The diagnostic gives the author a clue rather than silently dropping it. +// +// The type's package is resolved automatically, so a plain name suffices regardless of which +// package the type lives in. func (a *TypeIndex) warnMalformedStructName(annotation, line string) { switch annotation { case "model": diff --git a/internal/scanner/index_test.go b/internal/scanner/index_test.go index 7e732879..724dd46e 100644 --- a/internal/scanner/index_test.go +++ b/internal/scanner/index_test.go @@ -168,7 +168,7 @@ func TestDetectNodes_AllAnnotationTypes(t *testing.T) { }) t.Run("known non-struct annotations are accepted", func(t *testing.T) { - for _, annotation := range []string{"strfmt", "name", "discriminated", "file", "enum", "default", "alias", "type"} { + for _, annotation := range []string{"strfmt", "name", "discriminated", "file", "enum", "default", "alias", "type", "title", "description"} { file := &ast.File{ Comments: []*ast.CommentGroup{ {List: []*ast.Comment{{Text: "// swagger:" + annotation + " something"}}}, @@ -320,9 +320,8 @@ func TestNewTypeIndex_ExcludeDeps(t *testing.T) { require.NotNil(t, sctx) require.NotNil(t, sctx.app) - // With ExcludeDeps, imports should NOT be walked: - // the AllPackages map should only contain the explicitly-loaded packages, - // NOT transitive dependencies like strfmt. + // With ExcludeDeps, imports should NOT be walked: the AllPackages map should only contain the + // explicitly-loaded packages, NOT transitive dependencies like strfmt. _, hasStrfmt := sctx.app.AllPackages["github.com/go-openapi/strfmt"] assert.False(t, hasStrfmt, "strfmt should not be indexed when ExcludeDeps is true") } @@ -460,9 +459,9 @@ func TestCollectOperationPathAnnotations_TagFiltering(t *testing.T) { func TestNewTypeIndex_ErrorPropagation(t *testing.T) { t.Run("struct annotation conflict propagates error through build chain", func(t *testing.T) { - // The invalid_model_param fixture has swagger:model and swagger:parameters - // on the same struct, which triggers a struct conflict error in detectNodes, - // propagated through processFile → processPackage → build → NewTypeIndex → NewScanCtx. + // The invalid_model_param fixture has swagger:model and swagger:parameters on the same struct, + // which triggers a struct conflict error in detectNodes, propagated through processFile → + // processPackage → build → NewTypeIndex → NewScanCtx. _, err := NewScanCtx(&Options{ Packages: []string{"./goparsing/invalid_model_param"}, WorkDir: "../../fixtures", @@ -499,8 +498,7 @@ func TestNewTypeIndex_ErrorPropagation(t *testing.T) { }) t.Run("duplicate package is de-duplicated", func(t *testing.T) { - // Load the same package twice — the second occurrence should be - // skipped (line 109 in build). + // Load the same package twice — the second occurrence should be skipped (line 109 in build). sctx, err := NewScanCtx(&Options{ Packages: []string{ "./goparsing/petstore/enums", @@ -514,8 +512,8 @@ func TestNewTypeIndex_ErrorPropagation(t *testing.T) { t.Run("walkImports error propagation", func(t *testing.T) { // Load a valid package alongside an invalid one that shares imports. - // The invalid_model_param package is a main package; loading it should - // trigger the conflict error even if other packages are fine. + // The invalid_model_param package is a main package; loading it should trigger the conflict error + // even if other packages are fine. _, err := NewScanCtx(&Options{ Packages: []string{ "./goparsing/petstore/enums", diff --git a/internal/scanner/options.go b/internal/scanner/options.go index 4a308cd9..752a1ae8 100644 --- a/internal/scanner/options.go +++ b/internal/scanner/options.go @@ -8,15 +8,16 @@ import ( "github.com/go-openapi/spec" ) -// Options configures a scan. The zero value is a valid configuration: -// every flag defaults to false and every slice/map defaults to nil. +// Options configures a scan. +// +// The zero value is a valid configuration: every flag defaults to false and every slice/map +// defaults to nil. // // # Details // // See [§options](./README.md#options) for the field overview, and -// [§descwithref](./README.md#descwithref) and -// [§diagnostics](./README.md#diagnostics) for the two fields with -// non-trivial semantics (DescWithRef and OnDiagnostic). +// [§descwithref](./README.md#descwithref) and [§diagnostics](./README.md#diagnostics) for the two +// fields with non-trivial semantics (DescWithRef and OnDiagnostic). type Options struct { Packages []string InputSpec *spec.Swagger @@ -31,11 +32,10 @@ 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 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). + // 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: ...}`. @@ -44,26 +44,23 @@ type Options struct { // allOf: [{$ref}]}` — 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. + // 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. // - // Deprecated: prefer EmitRefSiblings, which preserves description - // AND extensions as direct $ref siblings (the modern, lenient - // shape). DescWithRef is retained with its original semantics (the - // strict draft-4 single-arm allOf wrap for the description-only - // case) and remains a no-op when EmitRefSiblings is set. + // Deprecated: prefer EmitRefSiblings, which preserves description AND extensions as direct $ref + // siblings (the modern, lenient shape). + // DescWithRef is retained with its original semantics (the strict draft-4 single-arm allOf wrap + // for the description-only case) and remains a no-op when EmitRefSiblings is set. // // See [§ref-override](../builders/schema/README.md#ref-override). DescWithRef bool - // EmitRefSiblings emits a $ref'd field's description and vendor - // extensions as DIRECT siblings of the `$ref` - // (`{$ref, description, x-*}`) instead of wrapping them in an allOf - // compound. Strict JSON-Schema-draft-4 ignores siblings of `$ref` - // (hence the default allOf wrap), but OpenAPI 3.1 / JSON Schema - // 2020-12 and most modern Swagger-UI renderers honour them. + // EmitRefSiblings emits a $ref'd field's description and vendor extensions as DIRECT siblings of + // the `$ref` (`{$ref, description, x-*}`) instead of wrapping them in an allOf compound. + // + // Strict JSON-Schema-draft-4 ignores siblings of `$ref` (hence the default allOf wrap), but + // OpenAPI 3.1 / JSON Schema 2020-12 and most modern Swagger-UI renderers honour them. // // - false (default): description / extensions follow the legacy // wrap behaviour (extensions lift onto a single-arm allOf; @@ -71,16 +68,16 @@ type Options struct { // - true: description and extensions ride directly alongside the // `$ref`, no allOf. // - // Validations and externalDocs are NOT siblings-eligible: when - // present they still force an allOf compound (validations on the - // override arm), and description / extensions then ride the outer - // compound. This flag changes only the no-forced-compound cases. + // Validations and externalDocs are NOT siblings-eligible: when present they still force an allOf + // compound (validations on the override arm), and description / extensions then ride the outer + // compound. + // This flag changes only the no-forced-compound cases. // // See [§ref-override](../builders/schema/README.md#ref-override). EmitRefSiblings bool - // SkipAllOfCompounding disables the allOf-compound rewrite for - // $ref'd struct fields entirely: no allOf compound is ever emitted. + // SkipAllOfCompounding disables the allOf-compound rewrite for $ref'd struct fields entirely: no + // allOf compound is ever emitted. // // - false (default): siblings are preserved via the allOf compound // (or, under EmitRefSiblings, as direct $ref siblings). @@ -91,20 +88,95 @@ type Options struct { // Every drop raises one diagnostic through OnDiagnostic — the // loss is never silent. // - // `required:` is a parent-side concern (it lands on the enclosing - // object's `required` list, not as a $ref sibling) and is preserved - // regardless of this flag. + // `required:` is a parent-side concern (it lands on the enclosing object's `required` list, not as + // a $ref sibling) and is preserved regardless of this flag. // - // Intended for downstream consumers (e.g. go-swagger codegen) that - // expect a bare `$ref` for a field pointing at a model and do not - // handle the allOf-compounded shape. See [§ref-override]. + // Intended for downstream consumers (e.g. go-swagger codegen) that expect a bare `$ref` for a + // field pointing at a model and do not handle the allOf-compounded shape. + // See [§ref-override]. SkipAllOfCompounding bool + // DefaultAllOfForEmbeds changes how a plain (non-`swagger:allOf`-tagged) struct embed renders: + // into allOf composition instead of inlined properties. + // + // By default codescan inlines an embedded struct's properties into the embedding schema (mirroring + // Go field promotion), so the "this composes Y" relationship is lost — every embedding struct + // emits a flat copy of the embedded fields. + // + // Downstream client generators that want a reusable base type per embed prefer the composition + // shape instead. + // + // - false (default): plain embeds inline their properties (existing + // behaviour). + // - true: a plain embed is treated as if it carried `swagger:allOf` — + // it becomes an allOf member ($ref to the embedded type's definition + // when that type is a model, otherwise an inline member), and the + // embedding struct's own fields move into a sibling allOf member. + // + // Scope and precedence: + // - Only STRUCT embeds are affected. Interface embeds already compose via + // allOf and are unchanged. + // - An explicit `swagger:allOf` annotation already produces this shape; + // the flag only makes it the default for untagged embeds. + // - An embed carrying an explicit json tag name (or `swagger:name`) is a + // single named property, not a promotion, so it is left as a nested + // property regardless of this flag (go-swagger#2038). + // - Pointer embeds are peeled; aliased embeds resolve to their unaliased + // type; stdlib specials (`error`, `time.Time`) keep their canonical + // recognizer shape — all via the existing allOf path. + // + // See [§allof](../builders/schema/README.md#allof). + DefaultAllOfForEmbeds bool + SkipExtensions bool // skip generating x-go-* vendor extensions in the spec - // SkipEnumDescriptions controls whether the per-enum-value const-name - // mapping built from `swagger:enum` (e.g. "FIRST TestEnumFirst") is - // folded into the property / parameter / header `description`. + // NameFromTags is the ordered list of struct-tag types consulted to derive the emitted name of a + // schema property, parameter, or response header from a Go struct field. + // + // The first listed tag type that supplies a usable name wins; a tag type that is absent or carries + // only options (e.g. `,omitempty`) is skipped and the next is tried. + // When no listed tag names the field, the Go field name is used. + // + // - nil / unset (default): ["json"] — the historic behaviour. + // - explicit empty slice: no struct tag is consulted; the name derives + // from the Go field name. + // - e.g. ["form","json"]: prefer the `form:` name (used by gin), falling + // back to `json:` (go-swagger#2912, go-swagger#1391). + // + // Only the NAME is sourced this way. + // The encoding/json directives `-` (exclude), `,omitempty` (→ not required) and `,string` are + // always read from the `json` tag regardless of this setting. + // + // Targeted renames — the `name:` keyword (parameters / response headers) and `swagger:name` / + // `swagger:model {name}` (schema) — still take precedence over any tag-derived name. + NameFromTags []string + + // SkipJSONifyInterfaceMethods opts out of the auto-jsonify mangler applied to interface-method + // property names. + // + // An interface method has no "natural" JSON serialization (Go's encoding/json cannot marshal + // embedded interface methods without a custom marshaler), so codescan invents a default property + // name by running the swag/mangling ToJSONName transform on the Go method name (`CreatedAt` → + // `createdAt`, `ID` → `id`). + // + // This convention will not always match the author's intent — e.g. an interface already named + // for its JSON shape, or a codebase with its own canonical-name discipline. + // + // - false (default): interface-method names auto-jsonify (existing + // behaviour). + // - true: the Go method name is emitted verbatim; the mangler is skipped. + // + // A `swagger:name X` override is taken verbatim regardless of this flag — it already bypasses + // the mangler. + // This flag only changes the fallback used when no override is present. + // It does not affect struct-field naming, which mirrors what encoding/json actually produces. + // + // See [§interface-naming](../builders/schema/README.md#interface-naming). + SkipJSONifyInterfaceMethods bool + + // SkipEnumDescriptions controls whether the per-enum-value const-name mapping built from + // `swagger:enum` (e.g. "FIRST TestEnumFirst") is folded into the property / parameter / header + // `description`. // // - false (default): the mapping is appended to the authored // description AND exposed via the `x-go-enum-desc` vendor extension @@ -112,43 +184,42 @@ type Options struct { // - true: the description is left as the authored prose; the mapping // rides `x-go-enum-desc` only. // - // Independent of SkipExtensions: with SkipExtensions also set, the - // mapping is suppressed everywhere. See go-swagger/go-swagger#2922. + // Independent of SkipExtensions: with SkipExtensions also set, the mapping is suppressed + // everywhere. + // See go-swagger/go-swagger#2922. SkipEnumDescriptions bool - // NameConcatBudget tunes the readability cutoff used when the - // name-identity reduce stage deconflicts colliding definition names - // by concatenating package segments (b.Test / c.Test -> BTest / - // CTest). Each candidate concat is scored in [0,1] — lower is more - // readable (shorter overall, fewer parts, no over-long segment). A - // collision group whose best concat scores ABOVE the budget is a - // candidate for the hierarchical fallback (name-identity Stage 3 / - // K3). - // - // The zero value selects the built-in default (0.65). Raise it - // toward 1.0 to accept longer concats; lower it to fall back sooner. + // NameConcatBudget tunes the readability cutoff used when the name-identity reduce stage + // deconflicts colliding definition names by concatenating package segments (b.Test / c.Test -> + // BTest / CTest). + // + // Each candidate concat is scored in [0,1] — lower is more readable (shorter overall, fewer + // parts, no over-long segment). + // A collision group whose best concat scores ABOVE the budget is a candidate for the hierarchical + // fallback (name-identity Stage 3 / K3). + // + // The zero value selects the built-in default (0.65). + // Raise it toward 1.0 to accept longer concats; lower it to fall back sooner. NameConcatBudget float64 - // EmitHierarchicalNames enables the hierarchical fail-safe for the - // rare collision groups whose best flat concat exceeds - // NameConcatBudget. When set, such a group is emitted as nested - // container definitions (`#/definitions//`, with - // `additionalProperties:true` + `x-go-package` on each container) - // instead of a long flat concat, and an explanatory diagnostic is - // raised. - // - // Default false — and deliberately so: a nested definition is a deep - // JSON pointer that only `ExpandSpec` resolves, and a definitions- - // enumerating consumer (e.g. go-swagger codegen, one model per entry) - // sees the container nodes rather than the models. The always-correct - // flat concat stays the default; enable this only when you prefer the - // nested shape for the over-budget tail. + // EmitHierarchicalNames enables the hierarchical fail-safe for the rare collision groups whose + // best flat concat exceeds NameConcatBudget. + // + // When set, such a group is emitted as nested container definitions (`#/definitions//`, + // with `additionalProperties:true` + `x-go-package` on each container) instead of a long flat + // concat, and an explanatory diagnostic is raised. + // + // Default false — and deliberately so: a nested definition is a deep JSON pointer that only + // `ExpandSpec` resolves, and a definitions- enumerating consumer (e.g. go-swagger codegen, one + // model per entry) sees the container nodes rather than the models. + // + // The always-correct flat concat stays the default; enable this only when you prefer the nested + // shape for the over-budget tail. EmitHierarchicalNames bool - // EmitXGoType stamps an `x-go-type` vendor extension on every emitted - // definition, recording the fully-qualified originating Go type - // (`.`) alongside the existing `x-go-name` / - // `x-go-package` traceability extensions. + // EmitXGoType stamps an `x-go-type` vendor extension on every emitted definition, recording the + // fully-qualified originating Go type (`.`) alongside the existing + // `x-go-name` / `x-go-package` traceability extensions. // // - false (default): no `x-go-type` is emitted for ordinary types // (the extension still appears on the narrow special-type cases @@ -157,14 +228,13 @@ type Options struct { // - true: each definition carries `x-go-type`, useful for // round-tripping a generated spec back to its source Go types. // - // Under the SkipExtensions umbrella: with SkipExtensions also set, - // no vendor extension is emitted regardless. See - // go-swagger/go-swagger#2924. + // Under the SkipExtensions umbrella: with SkipExtensions also set, no vendor extension is emitted + // regardless. + // See go-swagger/go-swagger#2924. EmitXGoType bool - // SingleLineCommentAsDescription routes a single-line doc comment to - // the object's `description` regardless of trailing punctuation, - // never to `title` / `summary`. + // SingleLineCommentAsDescription routes a single-line doc comment to the object's `description` + // regardless of trailing punctuation, never to `title` / `summary`. // // - false (default): the first-sentence convention applies — a // single-line comment ending in punctuation (`.`, `!`, `?`) @@ -178,10 +248,47 @@ type Options struct { // See go-swagger/go-swagger#2626. SingleLineCommentAsDescription bool - // PruneUnusedModels, when set together with ScanModels, drops every - // discovered definition that is not transitively referenced from a path, a - // shared response, a shared parameter, or a definition supplied via - // InputSpec. It is the middle ground between the two default modes: + // AfterDeclComments, when set, lets swagger annotations live INSIDE a declaration (the leading + // comment of a struct body) or INLINED as a trailing comment, in addition to the doc comment above + // the declaration. + // + // The godoc above the declaration then stays clean and human-facing while the swagger machinery + // lives out of the published documentation. + // The scanner folds the located comments into the comment source the builders already consume — + // same annotation grammar, no new syntax. + // + // Default false. + // + // v0.36 scope: type declarations (swagger:model / swagger:parameters / swagger:response) — + // struct inside-body leading comments and the trailing comment of an alias / non-struct type. + // Routes / operations are already position-agnostic. + // Struct fields and const enums are follow-ups. + AfterDeclComments bool + + // CleanGoDoc rewrites godoc-specific syntax that reads as noise when a title / description is + // carried from a Go doc comment into the spec. + // + // It applies ONLY to godoc-derived prose — author-written swagger:title / swagger:description + // overrides are never touched. + // + // - false (default): godoc prose is emitted verbatim (existing + // behaviour; output is byte-identical). + // - true: godoc doc-link brackets are removed and the identifier is + // humanized (`[CustName]` → "cust name"); reference-style link + // definition lines (`[text]: url`) are dropped; and when a doc-link + // resolves to an emitted schema, it is recomposed to the name that + // schema is actually exposed under (so the prose stays true to the + // generated definitions). The first identifier of a title / + // description is restored to sentence case. + // + // See .claude/plans/features/godoc-filter-design.md. + CleanGoDoc bool + + // PruneUnusedModels, when set together with ScanModels, drops every discovered definition that is + // not transitively referenced from a path, a shared response, a shared parameter, or a definition + // supplied via InputSpec. + // + // It is the middle ground between the two default modes: // // - without ScanModels: only route-reachable models are emitted; // - with ScanModels (`-m`): every swagger:model type is emitted, reachable @@ -190,54 +297,54 @@ type Options struct { // the unreachable definitions are pruned again — useful when scanning a // large shared library where only the $ref'd subset is wanted. // - // Pruning runs BEFORE definition-name reduction, so an unused model can no - // longer force a spurious cross-package name collision on a model that IS - // used (the survivor keeps its clean short name). Definitions supplied via - // InputSpec are pinned: they are never pruned and seed the reachability - // roots. Each pruned definition raises a scan.pruned-unused Hint through - // OnDiagnostic — the loss is never silent. + // Pruning runs BEFORE definition-name reduction, so an unused model can no longer force a spurious + // cross-package name collision on a model that IS used (the survivor keeps its clean short name). + // Definitions supplied via InputSpec are pinned: they are never pruned and seed the reachability + // roots. + // + // Each pruned definition raises a scan.pruned-unused Hint through OnDiagnostic — the loss is + // never silent. // - // Without ScanModels this flag is a no-op (the emitted set is already - // reachable-only); setting it alone raises one Hint. Default false. + // Without ScanModels this flag is a no-op (the emitted set is already reachable-only); setting it + // alone raises one Hint. + // Default false. // - // Note: a discriminator base references its subtypes by mapping string, not - // by $ref, so a subtype reachable only through a discriminator could be - // pruned. codescan does not auto-wire discriminator subtypes today; revisit - // if it ever does. See go-swagger/go-swagger#2639. + // Note: a discriminator base references its subtypes by mapping string, not by $ref, so a subtype + // reachable only through a discriminator could be pruned. codescan does not auto-wire + // discriminator subtypes today; revisit if it ever does. + // See go-swagger/go-swagger#2639. PruneUnusedModels bool // Debug is deprecated and has no effect. // // It formerly enabled verbose debug logging to stderr during scanning. - // That logger was retired: scan-time observations now flow exclusively - // through OnDiagnostic (which the caller routes to a logger of their - // choice), and codescan no longer writes to stdout/stderr — keeping it - // usable from a TUI or a WASI/WASM host. + // That logger was retired: scan-time observations now flow exclusively through OnDiagnostic (which + // the caller routes to a logger of their choice), and codescan no longer writes to stdout/stderr + // — keeping it usable from a TUI or a WASI/WASM host. // - // Deprecated: wire OnDiagnostic instead. This field is retained for API - // compatibility and is ignored. + // Deprecated: wire OnDiagnostic instead. + // This field is retained for API compatibility and is ignored. Debug bool - // 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. + // OnDiagnostic, when non-nil, is invoked for every diagnostic the builder layer records + // (lexer/parser warnings, semantic-validation failures from the validations package, etc.). // - // Experimental: the public API surface for diagnostics is subject - // to change while LSP integration matures. See - // [§diagnostics](./README.md#diagnostics). + // 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. + // See [§diagnostics](./README.md#diagnostics). OnDiagnostic func(grammar.Diagnostic) - // OnProvenance, when non-nil, is invoked once per anchor node in the - // produced spec, carrying its JSON pointer and the source position of - // the Go construct that produced it (see [Provenance]). Anchors are - // code-detail nodes (type decls, fields, values, route/meta blocks); - // finer nodes resolve to their nearest anchored ancestor at the - // consumer. The callback never blocks the build. + // OnProvenance, when non-nil, is invoked once per anchor node in the produced spec, carrying its + // JSON pointer and the source position of the Go construct that produced it (see [Provenance]). + // + // Anchors are code-detail nodes (type decls, fields, values, route/meta blocks); finer nodes + // resolve to their nearest anchored ancestor at the consumer. + // The callback never blocks the build. // - // Experimental: the cross-ref surface may change while LSP / TUI - // integration matures. + // Experimental: the cross-ref surface may change while LSP / TUI integration matures. OnProvenance func(Provenance) } diff --git a/internal/scanner/parameter_ref_test.go b/internal/scanner/parameter_ref_test.go new file mode 100644 index 00000000..091f2a68 --- /dev/null +++ b/internal/scanner/parameter_ref_test.go @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package scanner + +import ( + "slices" + "testing" + + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// parametersBlock parses ref.Comments through the grammar and returns the first ParametersBlock — +// the targeting parse lives in the grammar, so the test asserts discovery via the same path a +// builder would consume. +func parametersBlock(t *testing.T, sctx *ScanCtx, ref *ParameterRef) *grammar.ParametersBlock { + t.Helper() + for _, b := range grammar.NewParser(sctx.FileSet()).ParseAll(ref.Comments) { + if pb, ok := b.(*grammar.ParametersBlock); ok { + return pb + } + } + t.Fatalf("no ParametersBlock parsed from discovered reference marker") + return nil +} + +// TestParameterRefDiscovery verifies the scanner discovers standalone `swagger:parameters` +// reference markers hosted on func declarations — the references that wire shared parameters into +// operations / path-items. +// +// Struct-hosted markers (definitions) must NOT appear among the refs. +func TestParameterRefDiscovery(t *testing.T) { + t.Run("operation reference on a route func", func(t *testing.T) { + sctx, err := NewScanCtx(&Options{ + Packages: []string{"./enhancements/shared-parameters"}, + WorkDir: "../../fixtures", + }) + require.NoError(t, err) + + refs := slices.Collect(sctx.ParameterRefs()) + // Fixture 1 carries exactly one reference marker: + // swagger:parameters listPets X-Request-ID (on ListPets) + // every other swagger:parameters is on a struct (a definition). + require.Len(t, refs, 1) + + pb := parametersBlock(t, sctx, refs[0]) + assert.Equal(t, grammar.ParamTargetOperations, pb.Target) + assert.Equal(t, []string{"listPets", "X-Request-ID"}, pb.Args) + }) + + t.Run("path reference on a standalone func", func(t *testing.T) { + sctx, err := NewScanCtx(&Options{ + Packages: []string{"./enhancements/shared-parameters-pathitem"}, + WorkDir: "../../fixtures", + }) + require.NoError(t, err) + + refs := slices.Collect(sctx.ParameterRefs()) + // Fixture 2 carries exactly one reference marker: + // swagger:parameters /pets X-Request-ID (on pathItemRefs) + require.Len(t, refs, 1) + + pb := parametersBlock(t, sctx, refs[0]) + assert.Equal(t, grammar.ParamTargetPath, pb.Target) + assert.Equal(t, "/pets", pb.Path) + assert.Equal(t, []string{"X-Request-ID"}, pb.Args) + }) + + t.Run("no reference markers when all swagger:parameters are struct definitions", func(t *testing.T) { + sctx, err := NewScanCtx(&Options{ + Packages: []string{"./goparsing/classification/operations"}, + WorkDir: "../../fixtures", + }) + require.NoError(t, err) + + assert.Empty(t, slices.Collect(sctx.ParameterRefs())) + }) +} + +// TestSharedParametersFixturesScanClean is a smoke test that every shared-parameters fixture +// package classifies without a scan error (e.g. no spurious struct-annotation conflict from prose +// in a doc comment). +// +// It guards the fixtures before the later build phases consume them. +func TestSharedParametersFixturesScanClean(t *testing.T) { + pkgs := []string{ + "./enhancements/shared-parameters", + "./enhancements/shared-parameters-pathitem", + "./enhancements/shared-parameters-conflict/pkga", + "./enhancements/shared-parameters-conflict/pkgb", + "./enhancements/shared-parameters-yaml", + "./enhancements/shared-parameters-overrides", + "./enhancements/shared-parameters-prune", + } + for _, pkg := range pkgs { + t.Run(pkg, func(t *testing.T) { + _, err := NewScanCtx(&Options{Packages: []string{pkg}, WorkDir: "../../fixtures"}) + assert.NoError(t, err) + }) + } +} diff --git a/internal/scanner/provenance.go b/internal/scanner/provenance.go index bd8cd29b..8fc87a4e 100644 --- a/internal/scanner/provenance.go +++ b/internal/scanner/provenance.go @@ -8,29 +8,31 @@ import ( "strings" ) -// Provenance ties a node in the produced Swagger spec (by RFC 6901 JSON -// pointer) to the source position of the Go construct that produced it. +// Provenance ties a node in the produced Swagger spec (by RFC 6901 JSON pointer) to the source +// position of the Go construct that produced it. // -// It is the source-side half of the cross-ref linker (see the genspec-tui -// linkage design). Provenance is emitted via [Options.OnProvenance] only at -// "anchor" nodes — those born from a code detail (a type declaration, a struct -// field, a const/var value, a route/meta annotation block). Finer nodes carry -// no Provenance of their own; a consumer resolves them to their nearest -// anchored ancestor. +// It is the source-side half of the cross-ref linker (see the genspec-tui linkage design). +// Provenance is emitted via [Options.OnProvenance] only at "anchor" nodes — those born from a +// code detail (a type declaration, a struct field, a const/var value, a route/meta annotation +// block). +// +// Finer nodes carry no Provenance of their own; a consumer resolves them to their nearest anchored +// ancestor. // // Experimental: this surface may change while LSP / TUI integration matures. type Provenance struct { - // Pointer is the RFC 6901 JSON pointer of the anchored spec node, - // e.g. "/definitions/User" or "/paths/~1pets/get". + // Pointer is the RFC 6901 JSON pointer of the anchored spec node, e.g. "/definitions/User" or + // "/paths/~1pets/get". Pointer string // Pos is the source location (file:line:col) of the producing construct. Pos token.Position } -// JSONPointer builds an RFC 6901 pointer from raw (unescaped) segments, -// escaping each per the spec (~ → ~0, / → ~1). The output matches what the -// spec-side index derives via jsontext, so source- and spec-side pointers for -// the same node are byte-identical and join cleanly. +// JSONPointer builds an RFC 6901 pointer from raw (unescaped) segments, escaping each per the spec +// (~ → ~0, / → ~1). +// +// The output matches what the spec-side index derives via jsontext, so source- and spec-side +// pointers for the same node are byte-identical and join cleanly. func JSONPointer(segments ...string) string { var b strings.Builder for _, seg := range segments { diff --git a/internal/scanner/scan_context.go b/internal/scanner/scan_context.go index df153f90..0abeb542 100644 --- a/internal/scanner/scan_context.go +++ b/internal/scanner/scan_context.go @@ -16,13 +16,14 @@ import ( "github.com/go-openapi/codescan/internal/parsers" "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/swag/mangling" "golang.org/x/tools/go/packages" ) -// ErrDegradedLoad is the base error for a degraded package load detected by -// detectDegradedLoad (no packages matched, or a scanned package failed to -// load / type-check). It is wrapped with the per-package detail and, at the -// public API boundary, with ErrCodeScan. +// ErrDegradedLoad is the base error for a degraded package load detected by detectDegradedLoad (no +// packages matched, or a scanned package failed to load / type-check). +// +// It is wrapped with the per-package detail and, at the public API boundary, with ErrCodeScan. var ErrDegradedLoad = errors.New("degraded package load") const pkgLoadMode = packages.NeedName | packages.NeedFiles | packages.NeedImports | packages.NeedDeps | packages.NeedTypes | packages.NeedSyntax | packages.NeedTypesInfo @@ -44,30 +45,52 @@ type ScanCtx struct { opts *Options - // paramOrigins captures (operationID → parameterName → source position) - // during the parameters build. Parameter anchors can't be emitted inline: - // at parameters-build time the operation isn't yet bound to a path/method - // and the array index isn't final. They are resolved in a deferred pass - // (see the spec builder) once paths are built. Cross-ref linkage only. + // paramOrigins captures (operationID → parameterName → source position) during the parameters + // build. + // + // Parameter anchors can't be emitted inline: at parameters-build time the operation isn't yet + // bound to a path/method and the array index isn't final. + // They are resolved in a deferred pass (see the spec builder) once paths are built. + // Cross-ref linkage only. paramOrigins map[string]map[string]token.Position - // defOrigins buffers definition-scoped provenance anchors (the definition - // node and every field/enum sub-anchor under it), keyed by the - // fully-qualified definition key (EntityDecl.DefKey). They cannot fire - // inline: the definition is keyed by its fqn during discovery but renamed to - // its final user-facing name only at the end of the build - // (reduceDefinitionNames), and an unreferenced definition may be pruned - // before that. Buffered here, then re-pointed to the final name and emitted - // by FlushDefOrigins after prune + name reduction, so every pointer handed - // to OnProvenance resolves against the final document. curDefKey marks the - // definition currently being built (empty outside a definition build); + // defOrigins buffers definition-scoped provenance anchors (the definition node and every + // field/enum sub-anchor under it), keyed by the fully-qualified definition key + // (EntityDecl.DefKey). + // + // They cannot fire inline: the definition is keyed by its fqn during discovery but renamed to its + // final user-facing name only at the end of the build (reduceDefinitionNames), and an unreferenced + // definition may be pruned before that. + // + // Buffered here, then re-pointed to the final name and emitted by FlushDefOrigins after prune + + // name reduction, so every pointer handed to OnProvenance resolves against the final document. + // curDefKey marks the definition currently being built (empty outside a definition build); // non-reentrant, since each definition is built in its own pass. defOrigins map[string][]Provenance curDefKey string - // seenDiags suppresses exact-duplicate diagnostics on the OnDiagnostic - // stream over one scan (see EmitDiagnostic). + // deferredOrigins buffers provenance anchors for top-level spec nodes that may be pruned after the + // build (shared responses under PruneUnusedModels), keyed by an arbitrary caller-chosen key. + // + // Unlike defOrigins these are flushed verbatim — the nodes are never renamed, only possibly + // dropped — so a pruned node's anchors can be discarded (DropDeferredOrigins) before + // FlushDeferredOrigins fires the survivors. curDeferredKey marks the node currently being built + // (empty outside such a window). + // + // Distinct from defOrigins: a definition build is never nested inside a deferred window. + deferredOrigins map[string][]Provenance + curDeferredKey string + + // seenDiags suppresses exact-duplicate diagnostics on the OnDiagnostic stream over one scan (see + // EmitDiagnostic). seenDiags map[diagKey]struct{} + + // mangler is the shared name mangler used for godoc humanization (the CleanGoDoc option) and + // reusable by any builder needing swag-style name transforms. + // + // Constructed once per scan; its methods are pool-backed and safe to call across the (currently + // sequential) per-decl builds. + mangler *mangling.NameMangler } func NewScanCtx(opts *Options) (*ScanCtx, error) { @@ -97,25 +120,30 @@ func NewScanCtx(opts *Options) (*ScanCtx, error) { WithXNullableForPointers(opts.SetXNullableForPointers), WithRefAliases(opts.RefAliases), WithTransparentAliases(opts.TransparentAliases), + WithAfterDeclComments(opts.AfterDeclComments), WithOnDiagnostic(opts.OnDiagnostic), ) if err != nil { return nil, err } + mangler := mangling.NewNameMangler() + return &ScanCtx{ - pkgs: pkgs, - app: app, - opts: opts, + pkgs: pkgs, + app: app, + opts: opts, + mangler: &mangler, }, nil } -// detectDegradedLoad reacts to a degraded `packages.Load` result. packages.Load -// only returns the catastrophic error; degraded-but-loaded states otherwise pass -// silently and produce an incomplete spec. The reaction is tiered by what is -// still recoverable — only the pattern-matched root packages are inspected -// (transitive deps live in the import graph and are not scanned, so dep noise -// does not trip the check): +// detectDegradedLoad reacts to a degraded `packages.Load` result. packages.Load only returns the +// catastrophic error; degraded-but-loaded states otherwise pass silently and produce an incomplete +// spec. +// +// The reaction is tiered by what is still recoverable — only the pattern-matched root packages +// are inspected (transitive deps live in the import graph and are not scanned, so dep noise does +// not trip the check): // // - ABORT (Error + returned error) when nothing usable loaded: no packages // matched the patterns; a root package could not be loaded at all (a @@ -129,9 +157,8 @@ func NewScanCtx(opts *Options) (*ScanCtx, error) { // non-building package must not sink a whole `./...` scan. The spec is // emitted from what loaded, with the affected package flagged. // -// Every observation is reported through opts.OnDiagnostic as a -// scan.degraded-load diagnostic; abort observations are also summarised in the -// returned (wrapped ErrDegradedLoad) error. +// Every observation is reported through opts.OnDiagnostic as a scan.degraded-load diagnostic; abort +// observations are also summarised in the returned (wrapped ErrDegradedLoad) error. func detectDegradedLoad(pkgs []*packages.Package, opts *Options) error { emit := func(sev grammar.Severity, format string, args ...any) string { ctor := grammar.Errorf @@ -173,9 +200,8 @@ func detectDegradedLoad(pkgs []*packages.Package, opts *Options) error { return nil } -// hasListError reports whether any error is a packages.ListError — the package -// or pattern could not be loaded at all (vs. a parse/type error on code that -// did load). +// hasListError reports whether any error is a packages.ListError — the package or pattern could +// not be loaded at all (vs. a parse/type error on code that did load). func hasListError(errs []packages.Error) bool { for _, e := range errs { if e.Kind == packages.ListError { @@ -186,8 +212,8 @@ func hasListError(errs []packages.Error) bool { return false } -// firstListError returns the first packages.ListError for messaging; callers -// guard with hasListError. +// firstListError returns the first packages.ListError for messaging; callers guard with +// hasListError. func firstListError(errs []packages.Error) packages.Error { for _, e := range errs { if e.Kind == packages.ListError { @@ -214,6 +240,16 @@ func (s *ScanCtx) SingleLineCommentAsDescription() bool { return s.opts.SingleLineCommentAsDescription } +// CleanGoDoc reports whether godoc-syntax filtering is enabled (Options.CleanGoDoc). +func (s *ScanCtx) CleanGoDoc() bool { + return s.opts.CleanGoDoc +} + +// Mangler returns the scan's shared name mangler (swag-style name transforms). +func (s *ScanCtx) Mangler() *mangling.NameMangler { + return s.mangler +} + func (s *ScanCtx) DescWithRef() bool { return s.opts.DescWithRef } @@ -222,6 +258,12 @@ func (s *ScanCtx) SkipAllOfCompounding() bool { return s.opts.SkipAllOfCompounding } +// DefaultAllOfForEmbeds reports whether plain struct embeds should render as allOf composition +// instead of inlined properties (Options.DefaultAllOfForEmbeds). +func (s *ScanCtx) DefaultAllOfForEmbeds() bool { + return s.opts.DefaultAllOfForEmbeds +} + func (s *ScanCtx) EmitRefSiblings() bool { return s.opts.EmitRefSiblings } @@ -230,6 +272,26 @@ func (s *ScanCtx) SetXNullableForPointers() bool { return s.opts.SetXNullableForPointers } +// NameFromTags returns the ordered list of struct-tag types consulted to derive a field's emitted +// name. +// +// A nil/unset option defaults to ["json"] (the historic behaviour); an explicit empty slice means +// no tag is consulted and names fall back to the Go field name. +func (s *ScanCtx) NameFromTags() []string { + if s.opts.NameFromTags == nil { + return []string{"json"} + } + return s.opts.NameFromTags +} + +// SkipJSONifyInterfaceMethods reports whether the interface-method auto-jsonify mangler is disabled +// (Options.SkipJSONifyInterfaceMethods). +// +// A `swagger:name` override is honored verbatim regardless. +func (s *ScanCtx) SkipJSONifyInterfaceMethods() bool { + return s.opts.SkipJSONifyInterfaceMethods +} + func (s *ScanCtx) TransparentAliases() bool { return s.opts.TransparentAliases } @@ -238,14 +300,12 @@ func (s *ScanCtx) RefAliases() bool { return s.opts.RefAliases } -// FileSet returns the shared *token.FileSet used by the scan's -// loaded packages. +// FileSet returns the shared *token.FileSet used by the scan's loaded packages. // -// Callers that construct a grammar.Parser for comment groups not -// owned by a single EntityDecl's *packages.Package (notably -// operation and route path-level annotations aggregated across -// packages) read the FileSet from here so the produced positions -// resolve against the same file table the rest of the scan uses. +// Callers that construct a grammar.Parser for comment groups not owned by a single EntityDecl's +// *packages.Package (notably operation and route path-level annotations aggregated across packages) +// read the FileSet from here so the produced positions resolve against the same file table the rest +// of the scan uses. func (s *ScanCtx) FileSet() *token.FileSet { if len(s.pkgs) == 0 { return nil @@ -253,10 +313,11 @@ func (s *ScanCtx) FileSet() *token.FileSet { 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. +// 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{} @@ -268,21 +329,23 @@ func (s *ScanCtx) PosOf(p token.Pos) token.Position { return fset.Position(p) } -// diagKey identifies a diagnostic by its source location and content, for -// suppressing exact duplicates over the lifetime of one scan. +// diagKey identifies a diagnostic by its source location and content, for suppressing exact +// duplicates over the lifetime of one scan. type diagKey struct { pos string code grammar.Code msg string } -// EmitDiagnostic delivers d to the consumer's [Options.OnDiagnostic] sink, -// suppressing exact duplicates — same position, code and message — for the -// lifetime of the scan. The build re-processes the same field/annotation in -// several passes (most visibly a swagger:parameters struct applied to multiple -// operation ids, which rebuilds every field once per id), so the identical -// diagnostic would otherwise surface once per visit. The accumulator returned by -// common.Builder.Diagnostics() is unaffected — only the callback stream dedups. +// EmitDiagnostic delivers d to the consumer's [Options.OnDiagnostic] sink, suppressing exact +// duplicates — same position, code and message — for the lifetime of the scan. +// +// The build re-processes the same field/annotation in several passes (most visibly a +// swagger:parameters struct applied to multiple operation ids, which rebuilds every field once per +// id), so the identical diagnostic would otherwise surface once per visit. +// +// The accumulator returned by common.Builder.Diagnostics() is unaffected — only the callback +// stream dedups. func (s *ScanCtx) EmitDiagnostic(d grammar.Diagnostic) { cb := s.opts.OnDiagnostic if cb == nil { @@ -299,52 +362,57 @@ func (s *ScanCtx) EmitDiagnostic(d grammar.Diagnostic) { cb(d) } -// OnDiagnostic returns the user-supplied diagnostic sink, or nil when -// the consumer has not opted into diagnostic delivery. +// OnDiagnostic returns the user-supplied diagnostic sink, or nil when the consumer has not opted +// into diagnostic delivery. // // # Details // -// See [§diagnostics](./README.md#diagnostics) — callback contract, -// ordering guarantee, experimental-API caveat. +// See [§diagnostics](./README.md#diagnostics) — callback contract, ordering guarantee, +// experimental-API caveat. func (s *ScanCtx) OnDiagnostic() func(grammar.Diagnostic) { return s.opts.OnDiagnostic } -// NameConcatBudget returns the caller-supplied readability budget for -// collision-deconflicted definition names, or 0 when unset — the spec -// builder substitutes its built-in default in that case. +// NameConcatBudget returns the caller-supplied readability budget for collision-deconflicted +// definition names, or 0 when unset — the spec builder substitutes its built-in default in that +// case. func (s *ScanCtx) NameConcatBudget() float64 { return s.opts.NameConcatBudget } -// EmitHierarchicalNames reports whether the caller opted into the -// hierarchical fail-safe for over-budget collision groups. +// EmitHierarchicalNames reports whether the caller opted into the hierarchical fail-safe for +// over-budget collision groups. func (s *ScanCtx) EmitHierarchicalNames() bool { return s.opts.EmitHierarchicalNames } -// PruneUnusedModels reports whether the caller opted into pruning discovered -// definitions that are not transitively referenced from a root (paths, shared -// responses/parameters, overlay definitions). See [Options.PruneUnusedModels]. +// PruneUnusedModels reports whether the caller opted into pruning discovered definitions that are +// not transitively referenced from a root (paths, shared responses/parameters, overlay +// definitions). +// +// See [Options.PruneUnusedModels]. func (s *ScanCtx) PruneUnusedModels() bool { return s.opts.PruneUnusedModels } -// OriginEnabled reports whether a provenance sink is wired, so callers can skip -// JSON-pointer construction entirely when no consumer is listening. +// OriginEnabled reports whether a provenance sink is wired, so callers can skip JSON-pointer +// construction entirely when no consumer is listening. func (s *ScanCtx) OriginEnabled() bool { return s.opts.OnProvenance != nil } -// RecordOrigin fires the consumer's [Options.OnProvenance] callback for one -// anchor node, when wired. Unlike diagnostics it accumulates nothing — the -// cross-ref index is owned by the consumer (see the genspec-tui linkage design). +// RecordOrigin fires the consumer's [Options.OnProvenance] callback for one anchor node, when +// wired. +// +// Unlike diagnostics it accumulates nothing — the cross-ref index is owned by the consumer (see +// the genspec-tui linkage design). // -// Exception: while a definition build is in progress (between [BeginDefOrigins] -// and [EndDefOrigins]) the anchor is buffered instead of fired, so it can be -// re-pointed to the definition's final name — or dropped if the definition is -// pruned — by [FlushDefOrigins] at the end of the build. Anchors outside a -// definition build (paths, responses, info, parameters) fire inline as before; +// Exception: while a definition build is in progress (between [BeginDefOrigins] and +// [EndDefOrigins]) the anchor is buffered instead of fired, so it can be re-pointed to the +// definition's final name — or dropped if the definition is pruned — by [FlushDefOrigins] at +// the end of the build. +// +// Anchors outside a definition build (paths, responses, info, parameters) fire inline as before; // name reduction never renames those. func (s *ScanCtx) RecordOrigin(pointer string, pos token.Position) { cb := s.opts.OnProvenance @@ -355,14 +423,19 @@ func (s *ScanCtx) RecordOrigin(pointer string, pos token.Position) { s.defOrigins[s.curDefKey] = append(s.defOrigins[s.curDefKey], Provenance{Pointer: pointer, Pos: pos}) return } + if s.curDeferredKey != "" { + s.deferredOrigins[s.curDeferredKey] = append(s.deferredOrigins[s.curDeferredKey], Provenance{Pointer: pointer, Pos: pos}) + return + } cb(Provenance{Pointer: pointer, Pos: pos}) } -// BeginDefOrigins opens a buffering window for the definition keyed by defKey -// (its fully-qualified [EntityDecl.DefKey]). Until [EndDefOrigins], every -// [RecordOrigin] call is buffered under defKey instead of fired. No-op when no -// provenance sink is wired. Non-reentrant: each definition is built in its own -// pass, so windows never nest. +// BeginDefOrigins opens a buffering window for the definition keyed by defKey (its fully-qualified +// [EntityDecl.DefKey]). +// +// Until [EndDefOrigins], every [RecordOrigin] call is buffered under defKey instead of fired. +// No-op when no provenance sink is wired. +// Non-reentrant: each definition is built in its own pass, so windows never nest. func (s *ScanCtx) BeginDefOrigins(defKey string) { if s.opts.OnProvenance == nil { return @@ -378,18 +451,18 @@ func (s *ScanCtx) EndDefOrigins() { s.curDefKey = "" } -// DropDefOrigins discards the buffered anchors for a definition that has been -// pruned, so its provenance is never emitted (no orphan pointer into a -// definition absent from the final document). +// DropDefOrigins discards the buffered anchors for a definition that has been pruned, so its +// provenance is never emitted (no orphan pointer into a definition absent from the final document). func (s *ScanCtx) DropDefOrigins(defKey string) { delete(s.defOrigins, defKey) } -// FlushDefOrigins fires every buffered definition anchor, re-pointing each from -// its build-time fully-qualified base (#/definitions/) to the -// definition's final name. finalName maps a definition key to the name the spec -// emits for it (identity when unchanged). Pointers are emitted in a -// deterministic (sorted) order. After the flush the buffer is cleared. +// FlushDefOrigins fires every buffered definition anchor, re-pointing each from its build-time +// fully-qualified base (#/definitions/) to the definition's final name. finalName maps a +// definition key to the name the spec emits for it (identity when unchanged). +// +// Pointers are emitted in a deterministic (sorted) order. +// After the flush the buffer is cleared. func (s *ScanCtx) FlushDefOrigins(finalName func(defKey string) string) { cb := s.opts.OnProvenance if cb == nil || len(s.defOrigins) == 0 { @@ -414,9 +487,65 @@ func (s *ScanCtx) FlushDefOrigins(finalName func(defKey string) string) { s.curDefKey = "" } -// RecordParamOrigin stashes the source position of one parameter field, keyed -// by the operation id it applies to and the parameter name, for deferred anchor -// emission. No-op when no provenance sink is wired. See [ParamOrigin]. +// BeginDeferredOrigins opens a buffering window keyed by key for a top-level spec node that may be +// pruned after the build (a shared response). +// +// Until [EndDeferredOrigins], every [RecordOrigin] call is buffered under key instead of fired, so +// it can be dropped wholesale ([DropDeferredOrigins]) if the node is pruned, or flushed verbatim +// ([FlushDeferredOrigins]) if it survives. +// No-op when no provenance sink is wired. +// +// Non-reentrant. +func (s *ScanCtx) BeginDeferredOrigins(key string) { + if s.opts.OnProvenance == nil { + return + } + if s.deferredOrigins == nil { + s.deferredOrigins = make(map[string][]Provenance) + } + s.curDeferredKey = key +} + +// EndDeferredOrigins closes the current deferred buffering window. +func (s *ScanCtx) EndDeferredOrigins() { + s.curDeferredKey = "" +} + +// DropDeferredOrigins discards the buffered anchors for a deferred node that has been pruned, so +// its provenance is never emitted (no orphan pointer into a node absent from the final document). +func (s *ScanCtx) DropDeferredOrigins(key string) { + delete(s.deferredOrigins, key) +} + +// FlushDeferredOrigins fires every still-buffered deferred anchor verbatim (the nodes are never +// renamed) in a deterministic order, then clears the buffer. +func (s *ScanCtx) FlushDeferredOrigins() { + cb := s.opts.OnProvenance + if cb == nil || len(s.deferredOrigins) == 0 { + return + } + + keys := make([]string, 0, len(s.deferredOrigins)) + for k := range s.deferredOrigins { + keys = append(keys, k) + } + slices.Sort(keys) + + for _, key := range keys { + for _, rec := range s.deferredOrigins[key] { + cb(rec) + } + } + + s.deferredOrigins = nil + s.curDeferredKey = "" +} + +// RecordParamOrigin stashes the source position of one parameter field, keyed by the operation id +// it applies to and the parameter name, for deferred anchor emission. +// +// No-op when no provenance sink is wired. +// See [ParamOrigin]. func (s *ScanCtx) RecordParamOrigin(opID, name string, pos token.Position) { if s.opts.OnProvenance == nil { return @@ -432,9 +561,10 @@ func (s *ScanCtx) RecordParamOrigin(opID, name string, pos token.Position) { byName[name] = pos } -// ParamOrigin returns the captured source position for parameter name on -// operation opID, recorded earlier via [RecordParamOrigin]. The spec builder's -// deferred pass uses it to emit /paths/{path}/{method}/parameters/{i} anchors +// ParamOrigin returns the captured source position for parameter name on operation opID, recorded +// earlier via [RecordParamOrigin]. +// +// The spec builder's deferred pass uses it to emit /paths/{path}/{method}/parameters/{i} anchors // once the final path binding and array index are known. func (s *ScanCtx) ParamOrigin(opID, name string) (token.Position, bool) { byName := s.paramOrigins[opID] @@ -485,6 +615,18 @@ func (s *ScanCtx) Parameters() iter.Seq[*EntityDecl] { return slices.Values(s.app.Parameters) } +// ParameterRefs iterates the standalone `swagger:parameters` reference markers discovered on func +// declarations (the references that wire shared parameters into operations / path-items as $refs). +// +// See [ParameterRef]. +func (s *ScanCtx) ParameterRefs() iter.Seq[*ParameterRef] { + if s.app == nil { + return nil + } + + return slices.Values(s.app.ParameterRefs) +} + func (s *ScanCtx) Models() iter.Seq2[*ast.Ident, *EntityDecl] { if s.app == nil { return nil @@ -570,18 +712,16 @@ 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. +// GetModel is a pure read: it returns the model decl for (pkgPath, name) without any side effect. // // # Details // -// See [§model-lookup](./README.md#model-lookup) — the three-source -// lookup order (Models, ExtraModels, FindDecl), and how this -// differs from FindModel. +// See [§model-lookup](./README.md#model-lookup) — the three-source lookup order (Models, +// ExtraModels, FindDecl), and how this differs from FindModel. // -// Returns (nil, false) when no matching decl exists in any of the -// three sources. Callers that want the lookup hit registered as a -// discovered model must follow up with AddDiscoveredModel explicitly. +// Returns (nil, false) when no matching decl exists in any of the three sources. +// 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() @@ -600,16 +740,15 @@ func (s *ScanCtx) GetModel(pkgPath, name string) (*EntityDecl, bool) { return s.FindDecl(pkgPath, name) } -// FindModelsByLeaf returns every annotated swagger:model whose Go type -// name equals name, across all scanned packages, sorted by package path -// for determinism. It is the build-time analogue of the reduce stage's -// resolveDefinitionByLeaf: the type-name keyword sites use it to resolve a -// bare leaf to a model declared in another package (unique -> promote; -// several -> ambiguous). +// FindModelsByLeaf returns every annotated swagger:model whose Go type name equals name, across all +// scanned packages, sorted by package path for determinism. // -// Only the annotated model set (fixed before building) is searched — not -// the discovery-grown ExtraModels — so the result is a pure function of the -// source, independent of build order (W6). +// It is the build-time analogue of the reduce stage's resolveDefinitionByLeaf: the type-name +// keyword sites use it to resolve a bare leaf to a model declared in another package (unique -> +// promote; several -> ambiguous). +// +// Only the annotated model set (fixed before building) is searched — not the discovery-grown +// ExtraModels — so the result is a pure function of the source, independent of build order (W6). func (s *ScanCtx) FindModelsByLeaf(name string) []*EntityDecl { var out []*EntityDecl for _, cand := range s.app.Models { @@ -625,18 +764,17 @@ func (s *ScanCtx) FindModelsByLeaf(name string) []*EntityDecl { return out } -// AddDiscoveredModel registers decl in the ExtraModels index so the -// spec orchestrator emits a top-level definition for it. +// 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); -// annotated decls are emitted unconditionally and re-registering -// them as "discovered" would create a Models↔ExtraModels bouncing -// loop in joinExtraModels. Nil and Ident-less decls are silently -// ignored. +// No-op when decl is already an annotated swagger:model (in Models); annotated decls are emitted +// unconditionally and re-registering them as "discovered" would create a Models↔ExtraModels +// bouncing loop in joinExtraModels. +// Nil and Ident-less decls are silently ignored. // -// Use only at sites that explicitly intend the registration — -// pure-read lookups should use GetModel. See -// [§model-lookup](./README.md#model-lookup). +// Use only at sites that explicitly intend the registration — pure-read lookups should use +// GetModel. +// See [§model-lookup](./README.md#model-lookup). func (s *ScanCtx) AddDiscoveredModel(decl *EntityDecl) { if decl == nil || decl.Ident == nil { return @@ -647,16 +785,16 @@ func (s *ScanCtx) AddDiscoveredModel(decl *EntityDecl) { 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. +// 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). // -// 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. See -// [§model-lookup](./README.md#model-lookup). +// 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. +// See [§model-lookup](./README.md#model-lookup). func (s *ScanCtx) FindModel(pkgPath, name string) (*EntityDecl, bool) { for _, cand := range s.app.Models { ct := cand.Obj() @@ -691,13 +829,8 @@ func (s *ScanCtx) DeclForType(t types.Type) (*EntityDecl, bool) { func (s *ScanCtx) PkgForType(t types.Type) (*packages.Package, bool) { switch tpe := t.(type) { - // case *types.Basic: - // case *types.Struct: - // case *types.Pointer: - // case *types.Interface: - // case *types.Array: - // case *types.Slice: - // case *types.Map: + // case *types.Basic: case *types.Struct: case *types.Pointer: case *types.Interface: case + // *types.Array: case *types.Slice: case *types.Map: case *types.Named: v, ok := s.app.AllPackages[tpe.Obj().Pkg().Path()] return v, ok @@ -711,16 +844,15 @@ func (s *ScanCtx) PkgForType(t types.Type) (*packages.Package, bool) { } } -// FileForPos returns the *ast.File in package pkgPath whose source -// interval contains pos. Used when a struct's fields are defined in a -// different file than the decl that carries them — e.g. embedding a -// cross-package defined type (`type AnotherPackageAlias color.Color`), -// where the promoted fields live in the underlying type's source file, -// not in the embedding type's file. See go-swagger#2417. +// FileForPos returns the *ast.File in package pkgPath whose source interval contains pos. // -// Matching is done via the shared FileSet: positions and ast.File starts -// resolve through the same *token.File, so the comparison is independent -// of go/ast's File range accessors. +// Used when a struct's fields are defined in a different file than the decl that carries them — +// e.g. embedding a cross-package defined type (`type AnotherPackageAlias color.Color`), where the +// promoted fields live in the underlying type's source file, not in the embedding type's file. +// See go-swagger#2417. +// +// Matching is done via the shared FileSet: positions and ast.File starts resolve through the same +// *token.File, so the comparison is independent of go/ast's File range accessors. func (s *ScanCtx) FileForPos(pkgPath string, pos token.Pos) (*ast.File, bool) { pkg, ok := s.app.AllPackages[pkgPath] if !ok || pkg.Fset == nil { @@ -761,11 +893,11 @@ func (s *ScanCtx) FindComments(pkg *packages.Package, name string) (*ast.Comment return nil, false } -// FindEnumValues returns the enum values, per-value descriptions and per-value -// source positions for the constants typed enumName, plus ok. The positions are -// parallel to the values (one token.Pos per value, the const identifier) and -// feed the cross-ref /…/enum/{i} anchors; callers that don't need them ignore -// the third result. +// FindEnumValues returns the enum values, per-value descriptions and per-value source positions for +// the constants typed enumName, plus ok. +// +// The positions are parallel to the values (one token.Pos per value, the const identifier) and feed +// the cross-ref /…/enum/{i} anchors; callers that don't need them ignore the third result. func (s *ScanCtx) FindEnumValues(pkg *packages.Package, enumName string) (list []any, descList []string, posList []token.Pos, _ bool) { for _, f := range pkg.Syntax { for _, d := range f.Decls { @@ -794,11 +926,11 @@ func (s *ScanCtx) FindEnumValues(pkg *packages.Package, enumName string) (list [ return list, descList, posList, true } -// findEnumValue extracts one (value, description) pair per (name, value) -// position in a const spec. For a multi-name spec like -// `const A, B T = "a", "b"` it emits two rows — A↔"a" and B↔"b" — each -// sharing the spec's doc comment. The Go compiler guarantees -// len(Names) == len(Values) when Values is non-empty, so out-of-parity +// findEnumValue extracts one (value, description) pair per (name, value) position in a const spec. +// +// For a multi-name spec like `const A, B T = "a", "b"` it emits two rows — A↔"a" and B↔"b" +// — each sharing the spec's doc comment. +// The Go compiler guarantees len(Names) == len(Values) when Values is non-empty, so out-of-parity // specs are ignored defensively. func (s *ScanCtx) findEnumValue(spec ast.Spec, enumName string) (values []any, descriptions []string, positions []token.Pos) { vs, ok := spec.(*ast.ValueSpec) @@ -841,13 +973,13 @@ func (s *ScanCtx) findEnumValue(spec ast.Spec, enumName string) (values []any, d return values, descriptions, positions } -// buildEnumDocSuffix renders the shared doc comment as " ..." -// (with a leading single space, keeping the per-line leading whitespace that -// survives TrimPrefix("//")), or the empty string if there is no doc. +// buildEnumDocSuffix renders the shared doc comment as " ..." (with a leading single +// space, keeping the per-line leading whitespace that survives TrimPrefix("//")), or the empty +// string if there is no doc. // -// If the first non-empty doc line begins with one of the spec's names -// (idiomatic godoc convention: "Identifier does X"), that leading identifier -// is stripped so it does not duplicate the name already present in the row. +// If the first non-empty doc line begins with one of the spec's names (idiomatic godoc convention: +// "Identifier does X"), that leading identifier is stripped so it does not duplicate the name +// already present in the row. func buildEnumDocSuffix(doc *ast.CommentGroup, names []*ast.Ident) string { if doc == nil || len(doc.List) == 0 { return "" @@ -877,14 +1009,15 @@ func buildEnumDocSuffix(doc *ast.CommentGroup, names []*ast.Ident) string { return b.String() } -// stripLeadingName removes a leading identifier from text when that identifier -// matches one of the provided names. Used to drop the godoc convention prefix -// ("Identifier does X") from an enum value's doc comment so the identifier is -// not printed twice in the rendered description row. +// stripLeadingName removes a leading identifier from text when that identifier matches one of the +// provided names. +// +// Used to drop the godoc convention prefix ("Identifier does X") from an enum value's doc comment +// so the identifier is not printed twice in the rendered description row. // -// On match, the original leading whitespace (from TrimPrefix("//")) is also -// dropped so the caller's single-space separator is not compounded into a -// double-space gap between the row's name and the remaining prose. +// On match, the original leading whitespace (from TrimPrefix("//")) is also dropped so the caller's +// single-space separator is not compounded into a double-space gap between the row's name and the +// remaining prose. func stripLeadingName(text string, names []*ast.Ident) string { trimmed := strings.TrimLeft(text, " \t") diff --git a/internal/scanner/scan_context_test.go b/internal/scanner/scan_context_test.go index 36e1d35e..9d1115ea 100644 --- a/internal/scanner/scan_context_test.go +++ b/internal/scanner/scan_context_test.go @@ -462,11 +462,10 @@ func TestNewScanCtx_WithBuildTags(t *testing.T) { } func TestNewScanCtx_InvalidPackage(t *testing.T) { - // A package that cannot be loaded at all (here: a nonexistent - // package, which packages.Load reports as a ListError embedded on a - // placeholder package) must fail loud — an Error-severity - // scan.degraded-load diagnostic plus an aborting error — instead of - // silently producing an incomplete spec (go-swagger#2874). + // A package that cannot be loaded at all (here: a nonexistent package, which packages.Load reports + // as a ListError embedded on a placeholder package) must fail loud — an Error-severity + // scan.degraded-load diagnostic plus an aborting error — instead of silently producing an + // incomplete spec (go-swagger#2874). var diags []grammar.Diagnostic sctx, err := NewScanCtx(&Options{ Packages: []string{"./nonexistent"}, @@ -489,18 +488,18 @@ func TestNewScanCtx_InvalidPackage(t *testing.T) { assert.Positive(t, degraded, "expected at least one scan.degraded-load diagnostic") } -// TestNewScanCtx_PartialLoad_WarnsAndContinues locks the refinement of §8.2: a -// package that does not fully type-check but whose type information remains -// usable (go/packages type-checks best-effort) is a Warning, not an abort — a -// single non-building package must not sink a whole `./...` scan (#2874). +// TestNewScanCtx_PartialLoad_WarnsAndContinues locks the refinement of §8.2: a package that does +// not fully type-check but whose type information remains usable (go/packages type-checks +// best-effort) is a Warning, not an abort — a single non-building package must not sink a whole +// `./...` scan (#2874). func TestNewScanCtx_PartialLoad_WarnsAndContinues(t *testing.T) { dir := t.TempDir() write := func(name, content string) { require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)) } write("go.mod", "module probe\n\ngo 1.21\n") - // Gadget type-checks fine; the trailing bad declaration produces a - // TypeError on the package without erasing its type information. + // Gadget type-checks fine; the trailing bad declaration produces a TypeError on the package + // without erasing its type information. write("api.go", "package probe\n\n"+ "// Gadget is a model.\n//\n// swagger:model\n"+ "type Gadget struct {\n\tName string `json:\"name\"`\n}\n\n"+ @@ -589,8 +588,8 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { }) t.Run("ValueSpec with names/values parity mismatch returns nil", func(t *testing.T) { - // The Go compiler forbids this, but we guard defensively so a - // malformed AST (e.g. from tests) doesn't panic on index access. + // The Go compiler forbids this, but we guard defensively so a malformed AST (e.g. from tests) + // doesn't panic on index access. spec := &ast.ValueSpec{ Names: []*ast.Ident{ast.NewIdent("A"), ast.NewIdent("B")}, Type: ast.NewIdent("Foo"), diff --git a/internal/scantest/golden.go b/internal/scantest/golden.go index e3f1f10e..820428c8 100644 --- a/internal/scantest/golden.go +++ b/internal/scantest/golden.go @@ -15,15 +15,14 @@ import ( ) // CompareOrDumpJSON marshals got to stable JSON and either writes it to -// /fixtures/integration/golden/ (when UPDATE_GOLDEN=1) or -// asserts that it JSON-equals the stored golden. +// /fixtures/integration/golden/ (when UPDATE_GOLDEN=1) or asserts that it JSON-equals +// the stored golden. // -// This is the regression-testing harness used to detect any behavior change -// in the go-openapi/spec objects produced by the scanner, compared against -// a captured baseline. +// This is the regression-testing harness used to detect any behavior change in the go-openapi/spec +// objects produced by the scanner, compared against a captured baseline. // -// Golden files are named by content (fixture bundle + object kind + entity), -// not by test name, so they survive test reshuffling. +// Golden files are named by content (fixture bundle + object kind + entity), not by test name, so +// they survive test reshuffling. func CompareOrDumpJSON(t *testing.T, got any, name string) { t.Helper() diff --git a/internal/scantest/load.go b/internal/scantest/load.go index c9ef667f..c9e494b1 100644 --- a/internal/scantest/load.go +++ b/internal/scantest/load.go @@ -18,8 +18,8 @@ var ( go118ClassificationCtx *scanner.ScanCtx //nolint:gochecknoglobals // test package cache shared across test functions ) -// FixturesDir returns the absolute path to the repo-level fixtures/ directory, -// so tests can run from any package depth without fragile relative paths. +// FixturesDir returns the absolute path to the repo-level fixtures/ directory, so tests can run +// from any package depth without fragile relative paths. func FixturesDir() string { _, thisFile, _, ok := runtime.Caller(0) if !ok { diff --git a/internal/scantest/property.go b/internal/scantest/property.go index b421fc36..4fb3aabc 100644 --- a/internal/scantest/property.go +++ b/internal/scantest/property.go @@ -12,9 +12,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// ResolveTestKey returns the fully-qualified definitions key whose leaf -// (the segment after the last '/') equals short, so a sub-builder unit -// test can keep indexing the definitions map by the short Go name: +// ResolveTestKey returns the fully-qualified definitions key whose leaf (the segment after the last +// '/') equals short, so a sub-builder unit test can keep indexing the definitions map by the short +// Go name: // // schema := models[scantest.ResolveTestKey(t, models, "NoModel")] // @@ -23,15 +23,14 @@ import ( // scantest.AssertRef(t, &schema, "pet", "Pet", // "#/definitions/"+scantest.ResolveTestKey(t, models, "pet")) // -// The schema builder keys the definitions map by the fully-qualified -// identity ("/", see scanner.EntityDecl.DefKey); the spec -// orchestrator's reduce stage later shortens unique leaves back to the -// bare name, but sub-builder unit tests run WITHOUT that stage. When no -// definition carries the leaf, short is returned unchanged so absence -// checks (`_, ok := models[ResolveTestKey(...)]; assert.False(ok)`) still -// observe a miss. It fails only on a genuinely ambiguous match, which no -// current unit fixture produces. See -// .claude/plans/name-identity-cyclic-ref.md §12.1. +// The schema builder keys the definitions map by the fully-qualified identity ("/", +// see scanner.EntityDecl.DefKey); the spec orchestrator's reduce stage later shortens unique leaves +// back to the bare name, but sub-builder unit tests run WITHOUT that stage. +// +// When no definition carries the leaf, short is returned unchanged so absence checks (`_, ok := +// models[ResolveTestKey(...)]; assert.False(ok)`) still observe a miss. +// It fails only on a genuinely ambiguous match, which no current unit fixture produces. +// See .claude/plans/name-identity-cyclic-ref.md §12.1. func ResolveTestKey(t *testing.T, defs map[string]oaispec.Schema, short string) string { t.Helper() @@ -116,9 +115,9 @@ func AssertArrayRef(t *testing.T, schema *oaispec.Schema, jsonName, goName, frag } // 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]). +// +// 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()