diff --git a/fixtures/enhancements/named-struct-tags-ref/types.go b/fixtures/enhancements/named-struct-tags-ref/types.go new file mode 100644 index 0000000..cd58296 --- /dev/null +++ b/fixtures/enhancements/named-struct-tags-ref/types.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package named_struct_tags exercises schemaBuilder.buildNamedStruct +// branches where a named struct referenced as a field carries a +// swagger:strfmt or swagger:type annotation that overrides its schema. +package named_struct_tags + +// PhoneNumber is a named struct annotated as a strfmt. When used as a +// field type the scanner emits {type: "string", format: "phone"} via the +// strfmt branch of buildNamedStruct. +// +// Since we have added the explicit model annotation, we expect this definition +// to bubble up and appear as a $ref in the spec. +// +// swagger:strfmt phone +// swagger:model +type PhoneNumber struct { + CountryCode string + Number string +} + +// LegacyCode is a named struct annotated with swagger:type so that its +// referenced schema is coerced to the declared swagger type rather than +// emitted as an object. +// +// swagger:type string +type LegacyCode struct { + Version int +} + +// Contact references both tagged struct types so the scanner walks the +// buildNamedStruct strfmt and typeName branches on distinct fields. +// +// swagger:model Contact +type Contact struct { + // required: true + ID int64 `json:"id"` + + Phone PhoneNumber `json:"phone"` + + Code LegacyCode `json:"code"` +} diff --git a/fixtures/integration/golden/classification_responses.json b/fixtures/integration/golden/classification_responses.json index f831651..70fb656 100644 --- a/fixtures/integration/golden/classification_responses.json +++ b/fixtures/integration/golden/classification_responses.json @@ -243,8 +243,7 @@ ], "type": "integer", "format": "int64", - "default": 400, - "description": "in: header" + "default": 400 } } } diff --git a/fixtures/integration/golden/enhancements_alias_expand.json b/fixtures/integration/golden/enhancements_alias_expand.json index d674961..2fe6aca 100644 --- a/fixtures/integration/golden/enhancements_alias_expand.json +++ b/fixtures/integration/golden/enhancements_alias_expand.json @@ -10,7 +10,6 @@ ], "properties": { "data": { - "description": "in: body", "type": "object", "required": [ "id" @@ -29,7 +28,6 @@ "x-go-name": "Data" }, "search": { - "description": "in: query", "type": "string", "x-go-name": "Search" } @@ -44,7 +42,6 @@ ], "properties": { "data": { - "description": "in: body", "type": "object", "required": [ "id" @@ -63,7 +60,6 @@ "x-go-name": "Data" }, "search": { - "description": "in: query", "type": "string", "x-go-name": "Search" } @@ -162,7 +158,6 @@ "$ref": "#/definitions/Payload" }, "search": { - "description": "in: query", "type": "string", "x-go-name": "Search" } diff --git a/fixtures/integration/golden/enhancements_alias_ref.json b/fixtures/integration/golden/enhancements_alias_ref.json index 7fdd722..e92e4ad 100644 --- a/fixtures/integration/golden/enhancements_alias_ref.json +++ b/fixtures/integration/golden/enhancements_alias_ref.json @@ -74,7 +74,6 @@ "$ref": "#/definitions/Payload" }, "search": { - "description": "in: query", "type": "string", "x-go-name": "Search" } diff --git a/fixtures/integration/golden/enhancements_allof_edges.json b/fixtures/integration/golden/enhancements_allof_edges.json index 762602c..8f512b0 100644 --- a/fixtures/integration/golden/enhancements_allof_edges.json +++ b/fixtures/integration/golden/enhancements_allof_edges.json @@ -40,8 +40,22 @@ "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/allof-edges" }, "AllOfStdTime": { - "type": "string", "title": "AllOfStdTime composes an allOf member that is time.Time.", + "allOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "object", + "properties": { + "label": { + "type": "string", + "x-go-name": "Label" + } + } + } + ], "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/allof-edges" }, "AllOfStrfmt": { @@ -84,17 +98,13 @@ "type": "object", "title": "Tagger is a non-empty named interface used as an allOf member.", "properties": { - "Tag": { + "tag": { "description": "Tag returns an identifier.", - "type": "string" + "type": "string", + "x-go-name": "Tag" } }, "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/allof-edges" - }, - "ULID": { - "description": "ULID is a named struct formatted as a swagger strfmt. Using a struct\nunderlying type exercises the strfmt branch of buildNamedAllOf for\nstruct members.", - "type": "object", - "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/allof-edges" } } } \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_embedded_types.json b/fixtures/integration/golden/enhancements_embedded_types.json index b46d244..6031870 100644 --- a/fixtures/integration/golden/enhancements_embedded_types.json +++ b/fixtures/integration/golden/enhancements_embedded_types.json @@ -86,9 +86,10 @@ "type": "object", "title": "EmbedsNamedInterface embeds a non-empty, non-error named interface.", "properties": { - "Handle": { + "handle": { "description": "Handle is a unary method exposed as a schema property.", - "type": "string" + "type": "string", + "x-go-name": "Handle" }, "tag": { "type": "string", @@ -101,9 +102,10 @@ "type": "object", "title": "Handler is a non-empty named interface with a single exported method.", "properties": { - "Handle": { + "handle": { "description": "Handle is a unary method exposed as a schema property.", - "type": "string" + "type": "string", + "x-go-name": "Handle" } }, "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/embedded-types" diff --git a/fixtures/integration/golden/enhancements_enum_docs.json b/fixtures/integration/golden/enhancements_enum_docs.json index c85d25a..57fa8e6 100644 --- a/fixtures/integration/golden/enhancements_enum_docs.json +++ b/fixtures/integration/golden/enhancements_enum_docs.json @@ -10,13 +10,14 @@ ], "properties": { "channel": { - "description": "The delivery channel.\nemail ChannelEmail ChannelSMS ChannelEmail and ChannelSMS share a single spec.\npush ChannelPush ChannelPush is the push notification channel.", + "description": "The delivery channel.\nemail ChannelEmail and ChannelSMS share a single spec.\nsms ChannelSMS and ChannelSMS share a single spec.\npush ChannelPush is the push notification channel.", "type": "string", "enum": [ "email", + "sms", "push" ], - "x-go-enum-desc": "email ChannelEmail ChannelSMS ChannelEmail and ChannelSMS share a single spec.\npush ChannelPush ChannelPush is the push notification channel.", + "x-go-enum-desc": "email ChannelEmail and ChannelSMS share a single spec.\nsms ChannelSMS and ChannelSMS share a single spec.\npush ChannelPush is the push notification channel.", "x-go-name": "Channel" }, "id": { @@ -25,14 +26,14 @@ "x-go-name": "ID" }, "priority": { - "description": "The priority level.\nlow PriorityLow PriorityLow is a low-priority level.\nmedium PriorityMed PriorityMed is a medium-priority level.\nhigh PriorityHigh PriorityHigh is a high-priority level.", + "description": "The priority level.\nlow PriorityLow is a low-priority level.\nmedium PriorityMed is a medium-priority level.\nhigh PriorityHigh is a high-priority level.", "type": "string", "enum": [ "low", "medium", "high" ], - "x-go-enum-desc": "low PriorityLow PriorityLow is a low-priority level.\nmedium PriorityMed PriorityMed is a medium-priority level.\nhigh PriorityHigh PriorityHigh is a high-priority level.", + "x-go-enum-desc": "low PriorityLow is a low-priority level.\nmedium PriorityMed is a medium-priority level.\nhigh PriorityHigh is a high-priority level.", "x-go-name": "Priority" } }, diff --git a/fixtures/integration/golden/enhancements_interface_methods.json b/fixtures/integration/golden/enhancements_interface_methods.json index 0020f0d..6ef3996 100644 --- a/fixtures/integration/golden/enhancements_interface_methods.json +++ b/fixtures/integration/golden/enhancements_interface_methods.json @@ -5,15 +5,17 @@ "Audited": { "type": "object", "properties": { - "CreatedAt": { + "createdAt": { "description": "CreatedAt is the creation timestamp.", "type": "string", - "format": "date-time" + "format": "date-time", + "x-go-name": "CreatedAt" }, - "UpdatedAt": { + "updatedAt": { "description": "UpdatedAt is the update timestamp.", "type": "string", - "format": "date-time" + "format": "date-time", + "x-go-name": "UpdatedAt" } }, "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/interface-methods" @@ -22,9 +24,10 @@ "description": "Public exposes just a single scalar so we get a minimal, deterministic\ncompanion to assert the default code path.", "type": "object", "properties": { - "Kind": { + "kind": { "description": "Kind names the public flavor.", - "type": "string" + "type": "string", + "x-go-name": "Kind" } }, "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/interface-methods" @@ -38,42 +41,47 @@ { "type": "object", "required": [ - "ID" + "id" ], "properties": { - "Bio": { + "bio": { "description": "Bio is a nullable pointer string.", - "type": "string" + "type": "string", + "x-go-name": "Bio" }, - "Email": { + "email": { "description": "Email is formatted as an email strfmt.", "type": "string", - "format": "email" + "format": "email", + "x-go-name": "Email" + }, + "fullName": { + "description": "Name is re-exposed as \"fullName\" in JSON.", + "type": "string", + "x-go-name": "Name" }, - "ID": { + "id": { "description": "ID is the user identifier.", "type": "integer", "format": "int64", - "minimum": 1 + "minimum": 1, + "x-go-name": "ID" }, - "Profile": { + "profile": { "description": "Profile returns nested structured data.", "type": "object", "additionalProperties": { "type": "string" - } + }, + "x-go-name": "Profile" }, - "Tags": { + "tags": { "description": "Tags returns the user's labels.", "type": "array", "items": { "type": "string" - } - }, - "fullName": { - "description": "Name is re-exposed as \"fullName\" in JSON.", - "type": "string", - "x-go-name": "Name" + }, + "x-go-name": "Tags" } } } @@ -86,29 +94,32 @@ { "type": "object", "properties": { - "ExternalID": { + "audit": { + "description": "AuditTrail is exposed via the anonymous embedded interface.", + "type": "string", + "x-go-name": "AuditTrail" + }, + "externalId": { "description": "ExternalID is tagged as uuid so the anon-method strfmt branch\nis exercised.", "type": "string", - "format": "uuid" + "format": "uuid", + "x-go-name": "ExternalID" }, - "Revision": { + "revision": { "description": "Revision is a nullable pointer return for x-nullable coverage.", "type": "integer", - "format": "int64" - }, - "audit": { - "description": "AuditTrail is exposed via the anonymous embedded interface.", - "type": "string", - "x-go-name": "AuditTrail" + "format": "int64", + "x-go-name": "Revision" } } }, { "type": "object", "properties": { - "Kind": { + "kind": { "description": "Kind names the root flavor.", - "type": "string" + "type": "string", + "x-go-name": "Kind" } } } diff --git a/fixtures/integration/golden/enhancements_interface_methods_xnullable.json b/fixtures/integration/golden/enhancements_interface_methods_xnullable.json index a72e999..3c677c4 100644 --- a/fixtures/integration/golden/enhancements_interface_methods_xnullable.json +++ b/fixtures/integration/golden/enhancements_interface_methods_xnullable.json @@ -5,15 +5,17 @@ "Audited": { "type": "object", "properties": { - "CreatedAt": { + "createdAt": { "description": "CreatedAt is the creation timestamp.", "type": "string", - "format": "date-time" + "format": "date-time", + "x-go-name": "CreatedAt" }, - "UpdatedAt": { + "updatedAt": { "description": "UpdatedAt is the update timestamp.", "type": "string", - "format": "date-time" + "format": "date-time", + "x-go-name": "UpdatedAt" } }, "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/interface-methods" @@ -22,9 +24,10 @@ "description": "Public exposes just a single scalar so we get a minimal, deterministic\ncompanion to assert the default code path.", "type": "object", "properties": { - "Kind": { + "kind": { "description": "Kind names the public flavor.", - "type": "string" + "type": "string", + "x-go-name": "Kind" } }, "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/interface-methods" @@ -38,43 +41,48 @@ { "type": "object", "required": [ - "ID" + "id" ], "properties": { - "Bio": { + "bio": { "description": "Bio is a nullable pointer string.", "type": "string", + "x-go-name": "Bio", "x-nullable": true }, - "Email": { + "email": { "description": "Email is formatted as an email strfmt.", "type": "string", - "format": "email" + "format": "email", + "x-go-name": "Email" + }, + "fullName": { + "description": "Name is re-exposed as \"fullName\" in JSON.", + "type": "string", + "x-go-name": "Name" }, - "ID": { + "id": { "description": "ID is the user identifier.", "type": "integer", "format": "int64", - "minimum": 1 + "minimum": 1, + "x-go-name": "ID" }, - "Profile": { + "profile": { "description": "Profile returns nested structured data.", "type": "object", "additionalProperties": { "type": "string" - } + }, + "x-go-name": "Profile" }, - "Tags": { + "tags": { "description": "Tags returns the user's labels.", "type": "array", "items": { "type": "string" - } - }, - "fullName": { - "description": "Name is re-exposed as \"fullName\" in JSON.", - "type": "string", - "x-go-name": "Name" + }, + "x-go-name": "Tags" } } } @@ -87,30 +95,33 @@ { "type": "object", "properties": { - "ExternalID": { + "audit": { + "description": "AuditTrail is exposed via the anonymous embedded interface.", + "type": "string", + "x-go-name": "AuditTrail" + }, + "externalId": { "description": "ExternalID is tagged as uuid so the anon-method strfmt branch\nis exercised.", "type": "string", - "format": "uuid" + "format": "uuid", + "x-go-name": "ExternalID" }, - "Revision": { + "revision": { "description": "Revision is a nullable pointer return for x-nullable coverage.", "type": "integer", "format": "int64", + "x-go-name": "Revision", "x-nullable": true - }, - "audit": { - "description": "AuditTrail is exposed via the anonymous embedded interface.", - "type": "string", - "x-go-name": "AuditTrail" } } }, { "type": "object", "properties": { - "Kind": { + "kind": { "description": "Kind names the root flavor.", - "type": "string" + "type": "string", + "x-go-name": "Kind" } } } diff --git a/fixtures/integration/golden/enhancements_named_struct_tags-ref.json b/fixtures/integration/golden/enhancements_named_struct_tags-ref.json new file mode 100644 index 0000000..75fad3b --- /dev/null +++ b/fixtures/integration/golden/enhancements_named_struct_tags-ref.json @@ -0,0 +1,44 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Contact": { + "description": "Contact references both tagged struct types so the scanner walks the\nbuildNamedStruct strfmt and typeName branches on distinct fields.", + "type": "object", + "required": [ + "id" + ], + "properties": { + "code": { + "type": "string", + "x-go-name": "Code" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "phone": { + "type": "string", + "format": "phone", + "x-go-name": "Phone" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/named-struct-tags-ref" + }, + "PhoneNumber": { + "description": "Since we have added the explicit model annotation, we expect this definition\nto bubble up and appear as a $ref in the spec.", + "type": "object", + "title": "PhoneNumber is a named struct annotated as a strfmt. When used as a\nfield type the scanner emits {type: \"string\", format: \"phone\"} via the\nstrfmt branch of buildNamedStruct.", + "properties": { + "CountryCode": { + "type": "string" + }, + "Number": { + "type": "string" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/named-struct-tags-ref" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_named_struct_tags.json b/fixtures/integration/golden/enhancements_named_struct_tags.json index 187cb2f..0b8cd13 100644 --- a/fixtures/integration/golden/enhancements_named_struct_tags.json +++ b/fixtures/integration/golden/enhancements_named_struct_tags.json @@ -25,19 +25,6 @@ } }, "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/named-struct-tags" - }, - "PhoneNumber": { - "description": "PhoneNumber is a named struct annotated as a strfmt. When used as a\nfield type the scanner emits {type: \"string\", format: \"phone\"} via the\nstrfmt branch of buildNamedStruct.", - "type": "object", - "properties": { - "CountryCode": { - "type": "string" - }, - "Number": { - "type": "string" - } - }, - "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/named-struct-tags" } } } \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_pointers_no_xnullable.json b/fixtures/integration/golden/enhancements_pointers_no_xnullable.json index 1862437..4f78b81 100644 --- a/fixtures/integration/golden/enhancements_pointers_no_xnullable.json +++ b/fixtures/integration/golden/enhancements_pointers_no_xnullable.json @@ -30,29 +30,34 @@ "ItemInterface": { "type": "object", "properties": { - "Value1": { + "value1": { "type": "integer", - "format": "int64" + "format": "int64", + "x-go-name": "Value1" }, - "Value2": { + "value2": { "type": "integer", - "format": "int64" + "format": "int64", + "x-go-name": "Value2" }, - "Value3": { + "value3": { "description": "Value3 is a nullable value", "type": "integer", "format": "int64", + "x-go-name": "Value3", "x-nullable": false }, - "Value4": { + "value4": { "description": "Value4 is a non-nullable value", "type": "integer", "format": "int64", + "x-go-name": "Value4", "x-isnullable": false }, - "Value5": { + "value5": { "type": "integer", - "format": "int64" + "format": "int64", + "x-go-name": "Value5" } }, "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/pointers-nullable-by-default" diff --git a/fixtures/integration/golden/enhancements_pointers_xnullable.json b/fixtures/integration/golden/enhancements_pointers_xnullable.json index d27b827..934d5f2 100644 --- a/fixtures/integration/golden/enhancements_pointers_xnullable.json +++ b/fixtures/integration/golden/enhancements_pointers_xnullable.json @@ -31,30 +31,35 @@ "ItemInterface": { "type": "object", "properties": { - "Value1": { + "value1": { "type": "integer", "format": "int64", + "x-go-name": "Value1", "x-nullable": true }, - "Value2": { + "value2": { "type": "integer", - "format": "int64" + "format": "int64", + "x-go-name": "Value2" }, - "Value3": { + "value3": { "description": "Value3 is a nullable value", "type": "integer", "format": "int64", + "x-go-name": "Value3", "x-nullable": false }, - "Value4": { + "value4": { "description": "Value4 is a non-nullable value", "type": "integer", "format": "int64", + "x-go-name": "Value4", "x-isnullable": false }, - "Value5": { + "value5": { "type": "integer", - "format": "int64" + "format": "int64", + "x-go-name": "Value5" } }, "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/pointers-nullable-by-default" diff --git a/fixtures/integration/golden/enhancements_ref_alias_chain.json b/fixtures/integration/golden/enhancements_ref_alias_chain.json index e1b820d..33eed7b 100644 --- a/fixtures/integration/golden/enhancements_ref_alias_chain.json +++ b/fixtures/integration/golden/enhancements_ref_alias_chain.json @@ -26,8 +26,7 @@ "type": "object", "properties": { "createdAt": { - "type": "string", - "x-go-name": "CreatedAt" + "$ref": "#/definitions/Timestamp" }, "first": { "$ref": "#/definitions/LinkA" diff --git a/fixtures/integration/golden/enhancements_response_edges.json b/fixtures/integration/golden/enhancements_response_edges.json index fdaccea..193e00e 100644 --- a/fixtures/integration/golden/enhancements_response_edges.json +++ b/fixtures/integration/golden/enhancements_response_edges.json @@ -32,17 +32,17 @@ "X-Rate-Limit": { "type": "integer", "format": "int64", - "description": "The request rate-limit window.\n\nin: header" + "description": "The request rate-limit window." }, "X-Timestamp": { "type": "string", "format": "date-time", - "description": "The server-side timestamp for this response.\n\nin: header" + "description": "The server-side timestamp for this response." }, "X-Trace-ID": { "type": "string", "format": "uuid", - "description": "The request trace identifier.\n\nin: header" + "description": "The request trace identifier." } } }, diff --git a/fixtures/integration/golden/enhancements_text_marshal.json b/fixtures/integration/golden/enhancements_text_marshal.json index 3e483bd..1cee1ce 100644 --- a/fixtures/integration/golden/enhancements_text_marshal.json +++ b/fixtures/integration/golden/enhancements_text_marshal.json @@ -21,7 +21,7 @@ }, "mac": { "type": "string", - "format": "so", + "format": "mac", "x-go-name": "MAC" } }, diff --git a/fixtures/integration/golden/enhancements_top_level_kinds.json b/fixtures/integration/golden/enhancements_top_level_kinds.json index 93f1d2a..11d6792 100644 --- a/fixtures/integration/golden/enhancements_top_level_kinds.json +++ b/fixtures/integration/golden/enhancements_top_level_kinds.json @@ -31,9 +31,10 @@ "type": "object", "title": "MyInterface is a top-level named interface.", "properties": { - "Identify": { + "identify": { "description": "Identify returns the name of this object.", - "type": "string" + "type": "string", + "x-go-name": "Identify" } }, "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/top-level-kinds" diff --git a/fixtures/integration/golden/go123_aliased_spec.json b/fixtures/integration/golden/go123_aliased_spec.json index aebd157..e6fa7d9 100644 --- a/fixtures/integration/golden/go123_aliased_spec.json +++ b/fixtures/integration/golden/go123_aliased_spec.json @@ -47,8 +47,9 @@ "anonymous_iface": { "type": "object", "properties": { - "String": { - "type": "string" + "string": { + "type": "string", + "x-go-name": "String" } }, "x-go-name": "AnonymousIface", @@ -104,8 +105,9 @@ "iface": { "type": "object", "properties": { - "Get": { - "type": "string" + "get": { + "type": "string", + "x-go-name": "Get" } }, "x-go-name": "Iface", @@ -119,20 +121,22 @@ { "type": "object", "properties": { - "Get": { - "type": "string" + "get": { + "type": "string", + "x-go-name": "Get" } } }, { "type": "object", "properties": { - "Dump": { + "dump": { "type": "array", "items": { "type": "integer", "format": "uint8" - } + }, + "x-go-name": "Dump" } } } @@ -145,16 +149,18 @@ { "type": "object", "properties": { - "String": { - "type": "string" + "string": { + "type": "string", + "x-go-name": "String" } } }, { "type": "object", "properties": { - "Error": { - "type": "string" + "error": { + "type": "string", + "x-go-name": "Error" } } } @@ -174,20 +180,22 @@ { "type": "object", "properties": { - "String": { - "type": "string" + "string": { + "type": "string", + "x-go-name": "String" } } }, { "type": "object", "properties": { - "Dump": { + "dump": { "type": "array", "items": { "type": "integer", "format": "uint8" - } + }, + "x-go-name": "Dump" } } } diff --git a/fixtures/integration/golden/go123_special_spec.json b/fixtures/integration/golden/go123_special_spec.json index fb2e108..3cce724 100644 --- a/fixtures/integration/golden/go123_special_spec.json +++ b/fixtures/integration/golden/go123_special_spec.json @@ -23,9 +23,10 @@ { "type": "object", "properties": { - "Uint": { + "uint": { "type": "integer", - "format": "uint16" + "format": "uint16", + "x-go-name": "Uint" } } } diff --git a/go.mod b/go.mod index f968712..01ad6e9 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.26.1 require ( github.com/go-openapi/loads v0.23.3 github.com/go-openapi/spec v0.22.4 + github.com/go-openapi/swag/mangling v0.25.5 github.com/go-openapi/testify/v2 v2.4.2 go.yaml.in/yaml/v3 v3.0.4 golang.org/x/tools v0.44.0 diff --git a/go.sum b/go.sum index 44f7f41..b3711cf 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbF github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw= +github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY= github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= diff --git a/internal/builders/responses/taggers.go b/internal/builders/responses/taggers.go index 18c22d7..79e9b77 100644 --- a/internal/builders/responses/taggers.go +++ b/internal/builders/responses/taggers.go @@ -15,6 +15,8 @@ import ( // baseResponseHeaderTaggers configures taggers for a response header field. func baseResponseHeaderTaggers(header *oaispec.Header) []parsers.TagParser { return []parsers.TagParser{ + // Match-only: claim `in: header` so it does not leak into the header's description. + parsers.NewSingleLineTagParser("in", parsers.NewMatchIn()), parsers.NewSingleLineTagParser("maximum", parsers.NewSetMaximum(headerValidations{header})), parsers.NewSingleLineTagParser("minimum", parsers.NewSetMinimum(headerValidations{header})), parsers.NewSingleLineTagParser("multipleOf", parsers.NewSetMultipleOf(headerValidations{header})), diff --git a/internal/builders/schema/schema.go b/internal/builders/schema/schema.go index 04eb405..0a5002d 100644 --- a/internal/builders/schema/schema.go +++ b/internal/builders/schema/schema.go @@ -12,6 +12,7 @@ import ( "reflect" "strings" + "github.com/go-openapi/swag/mangling" "golang.org/x/tools/go/packages" "github.com/go-openapi/codescan/internal/builders/resolvers" @@ -30,12 +31,28 @@ type Builder struct { annotated bool discovered []*scanner.EntityDecl postDecls []*scanner.EntityDecl + + // interfaceMethodMangler produces JSON-style property names from Go + // interface-method names. Interface methods cannot carry struct tags, so + // codescan can't read a per-field convention — instead it applies the + // same transform go-swagger uses for tag-less struct fields (acronym-aware + // lower-first, e.g. `CreatedAt → createdAt`, `ID → id`, + // `ExternalID → externalId`). `swagger:name` still takes precedence when + // present. NameMangler is thread-safe per its godoc. + // + // Pointer so that the zero value (nil) is safely detected and lazily + // initialized by interfaceJSONName — a zero mangling.NameMangler value + // panics on use, and tests that construct &Builder{…} directly bypass + // NewBuilder. + interfaceMethodMangler *mangling.NameMangler } func NewBuilder(ctx *scanner.ScanCtx, decl *scanner.EntityDecl) *Builder { + m := mangling.NewNameMangler() return &Builder{ - ctx: ctx, - decl: decl, + ctx: ctx, + decl: decl, + interfaceMethodMangler: &m, } } @@ -89,6 +106,17 @@ func (s *Builder) inferNames() { } } +// interfaceJSONName maps a Go interface-method name to its JSON property +// name via the Builder's mangler, lazily initializing the mangler on first +// use so a zero-value Builder remains usable. +func (s *Builder) interfaceJSONName(goName string) string { + if s.interfaceMethodMangler == nil { + m := mangling.NewNameMangler() + s.interfaceMethodMangler = &m + } + return s.interfaceMethodMangler.ToJSONName(goName) +} + func (s *Builder) buildFromDecl(_ *scanner.EntityDecl, schema *oaispec.Schema) error { // analyze doc comment for the model // This includes parsing "example", "default" and other validation at the top-level declaration. @@ -168,6 +196,14 @@ func (s *Builder) buildFromTextMarshal(tpe types.Type, tgt ifaces.SwaggerTypable return s.buildFromTextMarshal(typePtr.Elem(), tgt) } + // An alias surfaced under a pointer (e.g. *Timestamp where + // Timestamp = time.Time) — route through buildAlias so the alias + // indirection is honored per RefAliases/TransparentAliases, same as + // the non-pointer path in buildFromType. + if typeAlias, ok := tpe.(*types.Alias); ok { + return s.buildAlias(typeAlias, tgt) + } + typeNamed, ok := tpe.(*types.Named) if !ok { tgt.Typed("string", "") @@ -220,12 +256,47 @@ func (s *Builder) buildFromTextMarshal(tpe types.Type, tgt ifaces.SwaggerTypable return nil } +// hasNamedCore reports whether tpe is a *types.Named, or resolves to one +// by peeling one or more pointer layers. Used to gate content-based +// shortcuts (like the TextMarshaler check) to types whose name can be +// inspected — anonymous structural kinds cannot yield meaningful output +// from those shortcuts and should take the structural dispatch instead. +func hasNamedCore(tpe types.Type) bool { + for { + switch t := tpe.(type) { + case *types.Named: + return true + case *types.Pointer: + tpe = t.Elem() + default: + return false + } + } +} + func (s *Builder) buildFromType(tpe types.Type, tgt ifaces.SwaggerTypable) error { - // check if the type implements encoding.TextMarshaler interface - // if so, the type is rendered as a string. logger.DebugLogf(s.ctx.Debug(), "schema buildFromType %v (%T)", tpe, tpe) - if resolvers.IsTextMarshaler(tpe) { + // Aliases are dispatched first, before any content-based shortcut, + // so the alias indirection is honored consistently with the caller's + // RefAliases/TransparentAliases intent. Without this, a text- + // marshalable alias (e.g. `type Timestamp = time.Time`) would be + // inlined as a plain string — losing both the alias semantics and + // (because buildFromTextMarshal only unwraps pointers) the target's + // format. + if titpe, ok := tpe.(*types.Alias); ok { + logger.DebugLogf(s.ctx.Debug(), "alias(schema.buildFromType): got alias %v to %v", titpe, titpe.Rhs()) + return s.buildAlias(titpe, tgt) + } + + // Only shortcut to the TextMarshaler renderer when we can reach a + // *types.Named by peeling pointers — buildFromTextMarshal uses the + // name to map to known formats (time/uuid/json.RawMessage/strfmt) and + // falls back to {string, ""} otherwise. An anonymous struct that only + // satisfies TextMarshaler by embedding time.Time (method promotion) + // would otherwise be flattened to {string}, erasing its body and any + // allOf composition. See Q4 in .claude/plans/observed-quirks.md. + if hasNamedCore(tpe) && resolvers.IsTextMarshaler(tpe) { return s.buildFromTextMarshal(tpe, tgt) } @@ -253,10 +324,6 @@ func (s *Builder) buildFromType(tpe types.Type, tgt ifaces.SwaggerTypable) error case *types.Named: // a named type, e.g. type X struct {} return s.buildNamedType(titpe, tgt) - case *types.Alias: - // a named alias, e.g. type X = {RHS type}. - logger.DebugLogf(s.ctx.Debug(), "alias(schema.buildFromType): got alias %v to %v", titpe, titpe.Rhs()) - return s.buildAlias(titpe, tgt) default: // Warn-and-skip for unsupported kinds (TypeParam, Chan, Signature, // Union, or future go/types additions). The scanner runs on user @@ -435,14 +502,25 @@ func (s *Builder) buildNamedBasic(tio *types.TypeName, pkg *packages.Package, cm func (s *Builder) buildNamedStruct(tio *types.TypeName, cmt *ast.CommentGroup, tgt ifaces.SwaggerTypable) error { logger.DebugLogf(s.ctx.Debug(), "found struct: %s.%s", tio.Pkg().Path(), tio.Name()) - decl, ok := s.ctx.FindModel(tio.Pkg().Path(), tio.Name()) - if !ok { - logger.DebugLogf(s.ctx.Debug(), "could not find model in index: %s.%s", tio.Pkg().Path(), tio.Name()) + // Run strfmt first, before FindModel, so a `swagger:strfmt` type is + // inlined as {string, format} *without* registering the struct in + // ExtraModels — FindModel has a side effect that would otherwise emit + // the struct as an orphan object definition no field references. See + // Q10 in .claude/plans/observed-quirks.md. + // + // A caveat remains: when the author combines `swagger:strfmt` with + // `swagger:model` (a "named strfmt" shape), the field still inlines + // here while the top-level definition body is emitted by walking the + // underlying struct. That inconsistency is documented in + // .claude/plans/deferred-quirks.md and left for v2. + if sfnm, isf := parsers.StrfmtName(cmt); isf { + tgt.Typed("string", sfnm) return nil } - if sfnm, isf := parsers.StrfmtName(cmt); isf { - tgt.Typed("string", sfnm) + decl, ok := s.ctx.FindModel(tio.Pkg().Path(), tio.Name()) + if !ok { + logger.DebugLogf(s.ctx.Debug(), "could not find model in index: %s.%s", tio.Pkg().Path(), tio.Name()) return nil } @@ -517,10 +595,6 @@ func (s *Builder) buildNamedSlice(tio *types.TypeName, cmt *ast.CommentGroup, el // IsStdError(ro) inside the `case *types.Alias:` branch of the RHS // switch below do fire: they inspect the alias target, which for // `type X = any` resolves to the predeclared any TypeName. -// -// For `type Timestamp = time.Time` the date-time format is currently -// lost — see Q3 in .claude/plans/observed-quirks.md. Any fix belongs -// in the RHS switch, not here. func (s *Builder) buildDeclAlias(tpe *types.Alias, tgt ifaces.SwaggerTypable) error { if resolvers.UnsupportedBuiltinType(tpe) { log.Printf("WARNING: skipped unsupported builtin type: %v", tpe) @@ -629,7 +703,7 @@ func (s *Builder) processAnonInterfaceMethod(fld *types.Func, it *types.Interfac name, ok := parsers.NameOverride(afld.Doc) if !ok { - name = fld.Name() + name = s.interfaceJSONName(fld.Name()) } if schema.Properties == nil { @@ -859,7 +933,7 @@ func (s *Builder) processInterfaceMethod(fld *types.Func, it *types.Interface, d name, ok := parsers.NameOverride(afld.Doc) if !ok { - name = fld.Name() + name = s.interfaceJSONName(fld.Name()) } ps := tgt.Properties[name] @@ -1175,19 +1249,30 @@ func (s *Builder) buildAllOf(tpe types.Type, schema *oaispec.Schema) error { func (s *Builder) buildNamedAllOf(ftpe *types.Named, schema *oaispec.Schema) error { switch utpe := ftpe.Underlying().(type) { case *types.Struct: - decl, found := s.ctx.FindModel(ftpe.Obj().Pkg().Path(), ftpe.Obj().Name()) - if !found { - return fmt.Errorf("can't find source file for struct: %s: %w", ftpe.String(), ErrSchema) - } - - if resolvers.IsStdTime(ftpe.Obj()) { + tio := ftpe.Obj() + + // Run inlining shortcuts (stdlib time, swagger:strfmt) before + // FindModel — FindModel registers the type in ExtraModels as a + // side effect, which would emit an orphan top-level definition + // for a type whose schema we've already inlined. See Q10 in + // .claude/plans/observed-quirks.md. + if resolvers.IsStdTime(tio) { schema.Typed("string", "date-time") return nil } - if sfnm, isf := parsers.StrfmtName(decl.Comments); isf { - schema.Typed("string", sfnm) - return nil + if pkg, ok := s.ctx.PkgForType(ftpe); ok { + if cmt, hasComments := s.ctx.FindComments(pkg, tio.Name()); hasComments { + if sfnm, isf := parsers.StrfmtName(cmt); isf { + schema.Typed("string", sfnm) + return nil + } + } + } + + decl, found := s.ctx.FindModel(tio.Pkg().Path(), tio.Name()) + if !found { + return fmt.Errorf("can't find source file for struct: %s: %w", ftpe.String(), ErrSchema) } if decl.HasModelAnnotation() { diff --git a/internal/builders/schema/schema_test.go b/internal/builders/schema/schema_test.go index 24b45e2..8e8778f 100644 --- a/internal/builders/schema/schema_test.go +++ b/internal/builders/schema/schema_test.go @@ -993,26 +993,27 @@ func TestPointersAreNullableByDefaultWhenSetXNullableForPointersIsSet(t *testing assertModel := func(ctx *scanner.ScanCtx, packagePath, modelName string) { decl, _ := ctx.FindDecl(packagePath, modelName) require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) require.NoError(t, prs.Build(allModels)) schema := allModels[modelName] require.Len(t, schema.Properties, 5) - require.MapContainsT(t, schema.Properties, "Value1") - assert.Equal(t, true, schema.Properties["Value1"].Extensions["x-nullable"]) - require.MapContainsT(t, schema.Properties, "Value2") - assert.MapNotContainsT(t, schema.Properties["Value2"].Extensions, "x-nullable") - require.MapContainsT(t, schema.Properties, "Value3") - assert.Equal(t, false, schema.Properties["Value3"].Extensions["x-nullable"]) - require.MapContainsT(t, schema.Properties, "Value4") - assert.MapNotContainsT(t, schema.Properties["Value4"].Extensions, "x-nullable") - assert.Equal(t, false, schema.Properties["Value4"].Extensions["x-isnullable"]) - require.MapContainsT(t, schema.Properties, "Value5") - assert.MapNotContainsT(t, schema.Properties["Value5"].Extensions, "x-nullable") + // 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) + assert.Equal(t, true, schema.Properties[v1].Extensions["x-nullable"]) + require.MapContainsT(t, schema.Properties, v2) + assert.MapNotContainsT(t, schema.Properties[v2].Extensions, "x-nullable") + require.MapContainsT(t, schema.Properties, v3) + assert.Equal(t, false, schema.Properties[v3].Extensions["x-nullable"]) + require.MapContainsT(t, schema.Properties, v4) + assert.MapNotContainsT(t, schema.Properties[v4].Extensions, "x-nullable") + assert.Equal(t, false, schema.Properties[v4].Extensions["x-isnullable"]) + require.MapContainsT(t, schema.Properties, v5) + assert.MapNotContainsT(t, schema.Properties[v5].Extensions, "x-nullable") } packagePattern := "./enhancements/pointers-nullable-by-default" @@ -1026,31 +1027,40 @@ func TestPointersAreNullableByDefaultWhenSetXNullableForPointersIsSet(t *testing scantest.CompareOrDumpJSON(t, allModels, "enhancements_pointers_xnullable.json") } +// valueKeys returns the five property keys expected for the fixtures +// Item (struct, Go names verbatim) and ItemInterface (interface methods, +// camelCased per Q9). +func valueKeys(modelName string) (string, string, string, string, string) { + if modelName == "ItemInterface" { + return "value1", "value2", "value3", "value4", "value5" + } + return "Value1", "Value2", "Value3", "Value4", "Value5" +} + func TestPointersAreNotNullableByDefaultWhenSetXNullableForPointersIsNotSet(t *testing.T) { allModels := make(map[string]oaispec.Schema) assertModel := func(ctx *scanner.ScanCtx, packagePath, modelName string) { decl, _ := ctx.FindDecl(packagePath, modelName) require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) require.NoError(t, prs.Build(allModels)) schema := allModels[modelName] require.Len(t, schema.Properties, 5) - require.MapContainsT(t, schema.Properties, "Value1") - assert.MapNotContainsT(t, schema.Properties["Value1"].Extensions, "x-nullable") - require.MapContainsT(t, schema.Properties, "Value2") - assert.MapNotContainsT(t, schema.Properties["Value2"].Extensions, "x-nullable") - require.MapContainsT(t, schema.Properties, "Value3") - assert.Equal(t, false, schema.Properties["Value3"].Extensions["x-nullable"]) - require.MapContainsT(t, schema.Properties, "Value4") - assert.MapNotContainsT(t, schema.Properties["Value4"].Extensions, "x-nullable") - assert.Equal(t, false, schema.Properties["Value4"].Extensions["x-isnullable"]) - require.MapContainsT(t, schema.Properties, "Value5") - assert.MapNotContainsT(t, schema.Properties["Value5"].Extensions, "x-nullable") + v1, v2, v3, v4, v5 := valueKeys(modelName) + + require.MapContainsT(t, schema.Properties, v1) + assert.MapNotContainsT(t, schema.Properties[v1].Extensions, "x-nullable") + require.MapContainsT(t, schema.Properties, v2) + assert.MapNotContainsT(t, schema.Properties[v2].Extensions, "x-nullable") + require.MapContainsT(t, schema.Properties, v3) + assert.Equal(t, false, schema.Properties[v3].Extensions["x-nullable"]) + require.MapContainsT(t, schema.Properties, v4) + assert.MapNotContainsT(t, schema.Properties[v4].Extensions, "x-nullable") + assert.Equal(t, false, schema.Properties[v4].Extensions["x-isnullable"]) + require.MapContainsT(t, schema.Properties, v5) + assert.MapNotContainsT(t, schema.Properties[v5].Extensions, "x-nullable") } packagePattern := "./enhancements/pointers-nullable-by-default" @@ -1068,10 +1078,7 @@ func TestSwaggerTypeNamed(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "NamedWithType") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) require.NoError(t, prs.Build(models)) schema := models["namedWithType"] @@ -1117,10 +1124,7 @@ func TestSwaggerTypeNamedWithGenerics(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, testName) require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) require.NoError(t, prs.Build(models)) testFunc(t, models) @@ -1132,10 +1136,7 @@ func TestSwaggerTypeStruct(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "NullString") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) require.NoError(t, prs.Build(models)) schema := models["NullString"] @@ -1152,10 +1153,7 @@ func TestStructDiscriminators(t *testing.T) { for _, tn := range []string{"BaseStruct", "Giraffe", "Gazelle"} { decl := getClassificationModel(ctx, tn) require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) require.NoError(t, prs.Build(models)) } @@ -1193,10 +1191,7 @@ func TestInterfaceDiscriminators(t *testing.T) { decl := getClassificationModel(ctx, tn) require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) require.NoError(t, prs.Build(models)) } @@ -1406,10 +1401,7 @@ func TestEmbeddedDescriptionAndTags(t *testing.T) { require.NoError(t, err) decl, _ := ctx.FindDecl(packagePath, "Item") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) require.NoError(t, prs.Build(models)) schema := models["Item"] @@ -1491,11 +1483,7 @@ func testIssue2540(descWithRef bool, expectedJSON string) func(*testing.T) { decl, _ := ctx.FindDecl(packagePath, "Book") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } - + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) require.NoError(t, prs.Build(models)) diff --git a/internal/builders/schema/taggers.go b/internal/builders/schema/taggers.go index 3e1c33f..60178d8 100644 --- a/internal/builders/schema/taggers.go +++ b/internal/builders/schema/taggers.go @@ -20,6 +20,11 @@ func schemaTaggers(schema, ps *oaispec.Schema, nm string) []parsers.TagParser { scheme := &oaispec.SimpleSchema{Type: string(schemeType)} return []parsers.TagParser{ + // Match-only: claim `in: ` lines so they do not leak into the + // schema description. `in:` only matters for parameter/response dispatch; + // if it reaches a schema field (e.g. via the alias-expand path), it is + // still metadata, not prose. + parsers.NewSingleLineTagParser("in", parsers.NewMatchIn()), parsers.NewSingleLineTagParser("maximum", parsers.NewSetMaximum(schemaValidations{ps})), parsers.NewSingleLineTagParser("minimum", parsers.NewSetMinimum(schemaValidations{ps})), parsers.NewSingleLineTagParser("multipleOf", parsers.NewSetMultipleOf(schemaValidations{ps})), diff --git a/internal/integration/coverage_enhancements_test.go b/internal/integration/coverage_enhancements_test.go index a9b93f6..083ae09 100644 --- a/internal/integration/coverage_enhancements_test.go +++ b/internal/integration/coverage_enhancements_test.go @@ -253,6 +253,18 @@ func TestCoverage_NamedStructTags(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_named_struct_tags.json") } +func TestCoverage_NamedStructTagsRef(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/named-struct-tags-ref/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_named_struct_tags-ref.json") +} + func TestCoverage_TopLevelKinds(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/top-level-kinds/..."}, diff --git a/internal/integration/schema_aliased_test.go b/internal/integration/schema_aliased_test.go index 9b74ef1..89076b1 100644 --- a/internal/integration/schema_aliased_test.go +++ b/internal/integration/schema_aliased_test.go @@ -211,7 +211,7 @@ func TestAliasedSchemas(t *testing.T) { require.TrueT(t, ok) require.NotEmpty(t, iface.Properties) - require.MapContainsT(t, iface.Properties, "String") + require.MapContainsT(t, iface.Properties, "string") }) t.Run("anonymous struct should render as an anonymous schema", func(t *testing.T) { @@ -379,8 +379,8 @@ func testAliasedInterfaceVariants(t *testing.T, sp *oaispec.Swagger) { require.TrueT(t, ok) require.TrueT(t, iface.Type.Contains("object")) - require.MapContainsT(t, iface.Properties, "String") - prop := iface.Properties["String"] + require.MapContainsT(t, iface.Properties, "string") + prop := iface.Properties["string"] require.TrueT(t, prop.Type.Contains("string")) assert.Len(t, iface.Properties, 1) }) @@ -397,8 +397,8 @@ func testAliasedInterfaceVariants(t *testing.T, sp *oaispec.Swagger) { require.TrueT(t, ok) require.TrueT(t, iface.Type.Contains("object")) - require.MapContainsT(t, iface.Properties, "Get") - prop := iface.Properties["Get"] + require.MapContainsT(t, iface.Properties, "get") + prop := iface.Properties["get"] require.TrueT(t, prop.Type.Contains("string")) assert.Len(t, iface.Properties, 1) }) @@ -414,8 +414,8 @@ func testAliasedInterfaceVariants(t *testing.T, sp *oaispec.Swagger) { require.TrueT(t, member.Type.Contains("object")) require.NotEmpty(t, member.Properties) require.Len(t, member.Properties, 1) - propGet, isEmbedded := member.Properties["Get"] - propMethod, isMethod := member.Properties["Dump"] + propGet, isEmbedded := member.Properties["get"] + propMethod, isMethod := member.Properties["dump"] switch { case isEmbedded: @@ -443,8 +443,8 @@ func testAliasedInterfaceVariants(t *testing.T, sp *oaispec.Swagger) { require.TrueT(t, member.Type.Contains("object")) require.NotEmpty(t, member.Properties) require.Len(t, member.Properties, 1) - propGet, isEmbedded := member.Properties["String"] - propAnonymous, isAnonymous := member.Properties["Error"] + propGet, isEmbedded := member.Properties["string"] + propAnonymous, isAnonymous := member.Properties["error"] switch { case isEmbedded: @@ -478,8 +478,8 @@ func testAliasedInterfaceVariants(t *testing.T, sp *oaispec.Swagger) { foundEmbeddedAnon := false foundRef := false for idx, member := range iface.AllOf { - propGet, isEmbedded := member.Properties["String"] - propAnonymous, isAnonymous := member.Properties["Dump"] + propGet, isEmbedded := member.Properties["string"] + propAnonymous, isAnonymous := member.Properties["dump"] isRef := member.Ref.String() != "" switch { diff --git a/internal/integration/schema_special_test.go b/internal/integration/schema_special_test.go index 4848507..55303ce 100644 --- a/internal/integration/schema_special_test.go +++ b/internal/integration/schema_special_test.go @@ -126,7 +126,7 @@ func TestSpecialSchemas(t *testing.T) { member := generic.AllOf[0] require.TrueT(t, member.Type.Contains("object")) require.Len(t, member.Properties, 1) - prop, ok := member.Properties["Uint"] + prop, ok := member.Properties["uint"] require.TrueT(t, ok) require.TrueT(t, prop.Type.Contains("integer")) require.EqualT(t, "uint16", prop.Format) diff --git a/internal/parsers/matchers_test.go b/internal/parsers/matchers_test.go index 54adb02..630daf8 100644 --- a/internal/parsers/matchers_test.go +++ b/internal/parsers/matchers_test.go @@ -23,13 +23,14 @@ func (s stubTypable) In() string { return s.in } func (s stubTypable) Typed(string, string) {} func (s stubTypable) SetRef(oaispec.Ref) {} -//nolint:ireturn // test stub -func (s stubTypable) Items() ifaces.SwaggerTypable { return s } -func (s stubTypable) Schema() *oaispec.Schema { return nil } -func (s stubTypable) Level() int { return 0 } -func (s stubTypable) AddExtension(string, any) {} -func (s stubTypable) WithEnum(...any) {} -func (s stubTypable) WithEnumDescription(string) {} +func (s stubTypable) Items() ifaces.SwaggerTypable { //nolint:ireturn // test stub + return s +} +func (s stubTypable) Schema() *oaispec.Schema { return nil } +func (s stubTypable) Level() int { return 0 } +func (s stubTypable) AddExtension(string, any) {} +func (s stubTypable) WithEnum(...any) {} +func (s stubTypable) WithEnumDescription(string) {} func TestHasAnnotation(t *testing.T) { t.Parallel() diff --git a/internal/parsers/parsers.go b/internal/parsers/parsers.go index 5ebb0d4..b7708a5 100644 --- a/internal/parsers/parsers.go +++ b/internal/parsers/parsers.go @@ -35,6 +35,14 @@ type MatchParamIn struct { } func NewMatchParamIn(_ *oaispec.Parameter) *MatchParamIn { + return NewMatchIn() +} + +// NewMatchIn returns a match-only tagger that claims `in: ` +// lines. The `in:` directive is extracted separately via +// parsers.ParamLocation; this tagger only prevents the line from +// being absorbed into the surrounding description by a SectionedParser. +func NewMatchIn() *MatchParamIn { return &MatchParamIn{ matchOnlyParam: &matchOnlyParam{ rx: rxIn, diff --git a/internal/parsers/regexprs.go b/internal/parsers/regexprs.go index 37ea349..1efc912 100644 --- a/internal/parsers/regexprs.go +++ b/internal/parsers/regexprs.go @@ -9,46 +9,78 @@ import ( ) const ( + // rxCommentPrefix matches the leading comment noise that precedes an + // annotation keyword on a raw comment line: whitespace, tabs, slashes, + // asterisks, dashes, optional markdown table pipe, then any trailing + // spaces. Mirrors the prefix class used by rxUncommentHeaders so + // Matches() can still see through the `//` / `*` / ` * ` comment + // prefixes on raw lines. + // + // Annotations must START the comment line — any prose before the + // swagger:xxx keyword disqualifies the line: an annotation buried in prose is ignored. + // + // Example: + // `swagger:strfmt` buried inside the sentence + // `// MAC is a text-marshalable ... swagger:strfmt so ...` is ignored and no longer captures + // "so" instead of the intended strfmt name. + // + // The sole documented-by-example exception is `swagger:route`, which is + // allowed to follow a single godoc identifier (see rxRoutePrefix). + rxCommentPrefix = `^[\p{Zs}\t/\*-]*\|?\p{Zs}*` + + // rxRoutePrefix extends rxCommentPrefix with an OPTIONAL single leading + // identifier. Godoc convention places the function/type name before the + // annotation body, e.g. `// DoBad swagger:route GET /path`. Without + // this allowance we would reject every `swagger:route` annotation + // attached to a documented handler. The allowance is intentionally + // narrow — ONE identifier, then whitespace — so multi-word prose + // prefixes still fail. + // + // This exception is reserved for `swagger:route`. All other annotations + // must start the comment line, per rxCommentPrefix. + rxRoutePrefix = rxCommentPrefix + `(?:\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]*\p{Zs}+)?` + rxMethod = "(\\p{L}+)" rxPath = "((?:/[\\p{L}\\p{N}\\p{Pd}\\p{Pc}{}\\-\\.\\?_~%!$&'()*+,;=:@/]*)+/?)" rxOpTags = "(\\p{L}[\\p{L}\\p{N}\\p{Pd}\\.\\p{Pc}\\p{Zs}]+)" rxOpID = "((?:\\p{L}[\\p{L}\\p{N}\\p{Pd}\\p{Pc}]+)+)" - rxMaximumFmt = "%s[Mm]ax(?:imum)?\\p{Zs}*:\\p{Zs}*([\\<=])?\\p{Zs}*([\\+-]?(?:\\p{N}+\\.)?\\p{N}+)(?:\\.)?$" - rxMinimumFmt = "%s[Mm]in(?:imum)?\\p{Zs}*:\\p{Zs}*([\\>=])?\\p{Zs}*([\\+-]?(?:\\p{N}+\\.)?\\p{N}+)(?:\\.)?$" - rxMultipleOfFmt = "%s[Mm]ultiple\\p{Zs}*[Oo]f\\p{Zs}*:\\p{Zs}*([\\+-]?(?:\\p{N}+\\.)?\\p{N}+)(?:\\.)?$" + rxMaximumFmt = rxCommentPrefix + "%s[Mm]ax(?:imum)?\\p{Zs}*:\\p{Zs}*([\\<=])?\\p{Zs}*([\\+-]?(?:\\p{N}+\\.)?\\p{N}+)(?:\\.)?$" + rxMinimumFmt = rxCommentPrefix + "%s[Mm]in(?:imum)?\\p{Zs}*:\\p{Zs}*([\\>=])?\\p{Zs}*([\\+-]?(?:\\p{N}+\\.)?\\p{N}+)(?:\\.)?$" + rxMultipleOfFmt = rxCommentPrefix + "%s[Mm]ultiple\\p{Zs}*[Oo]f\\p{Zs}*:\\p{Zs}*([\\+-]?(?:\\p{N}+\\.)?\\p{N}+)(?:\\.)?$" - rxMaxLengthFmt = "%s[Mm]ax(?:imum)?(?:\\p{Zs}*[\\p{Pd}\\p{Pc}]?[Ll]en(?:gth)?)\\p{Zs}*:\\p{Zs}*(\\p{N}+)(?:\\.)?$" - rxMinLengthFmt = "%s[Mm]in(?:imum)?(?:\\p{Zs}*[\\p{Pd}\\p{Pc}]?[Ll]en(?:gth)?)\\p{Zs}*:\\p{Zs}*(\\p{N}+)(?:\\.)?$" - rxPatternFmt = "%s[Pp]attern\\p{Zs}*:\\p{Zs}*(.*)$" - rxCollectionFormatFmt = "%s[Cc]ollection(?:\\p{Zs}*[\\p{Pd}\\p{Pc}]?[Ff]ormat)\\p{Zs}*:\\p{Zs}*(.*)$" - rxEnumFmt = "%s[Ee]num\\p{Zs}*:\\p{Zs}*(.*)$" - rxDefaultFmt = "%s[Dd]efault\\p{Zs}*:\\p{Zs}*(.*)$" - rxExampleFmt = "%s[Ee]xample\\p{Zs}*:\\p{Zs}*(.*)$" + rxMaxLengthFmt = rxCommentPrefix + "%s[Mm]ax(?:imum)?(?:\\p{Zs}*[\\p{Pd}\\p{Pc}]?[Ll]en(?:gth)?)\\p{Zs}*:\\p{Zs}*(\\p{N}+)(?:\\.)?$" + rxMinLengthFmt = rxCommentPrefix + "%s[Mm]in(?:imum)?(?:\\p{Zs}*[\\p{Pd}\\p{Pc}]?[Ll]en(?:gth)?)\\p{Zs}*:\\p{Zs}*(\\p{N}+)(?:\\.)?$" + rxPatternFmt = rxCommentPrefix + "%s[Pp]attern\\p{Zs}*:\\p{Zs}*(.*)$" + rxCollectionFormatFmt = rxCommentPrefix + "%s[Cc]ollection(?:\\p{Zs}*[\\p{Pd}\\p{Pc}]?[Ff]ormat)\\p{Zs}*:\\p{Zs}*(.*)$" + rxEnumFmt = rxCommentPrefix + "%s[Ee]num\\p{Zs}*:\\p{Zs}*(.*)$" + rxDefaultFmt = rxCommentPrefix + "%s[Dd]efault\\p{Zs}*:\\p{Zs}*(.*)$" + rxExampleFmt = rxCommentPrefix + "%s[Ee]xample\\p{Zs}*:\\p{Zs}*(.*)$" - rxMaxItemsFmt = "%s[Mm]ax(?:imum)?(?:\\p{Zs}*|[\\p{Pd}\\p{Pc}]|\\.)?[Ii]tems\\p{Zs}*:\\p{Zs}*(\\p{N}+)(?:\\.)?$" - rxMinItemsFmt = "%s[Mm]in(?:imum)?(?:\\p{Zs}*|[\\p{Pd}\\p{Pc}]|\\.)?[Ii]tems\\p{Zs}*:\\p{Zs}*(\\p{N}+)(?:\\.)?$" - rxUniqueFmt = "%s[Uu]nique\\p{Zs}*:\\p{Zs}*(true|false)(?:\\.)?$" + rxMaxItemsFmt = rxCommentPrefix + "%s[Mm]ax(?:imum)?(?:\\p{Zs}*|[\\p{Pd}\\p{Pc}]|\\.)?[Ii]tems\\p{Zs}*:\\p{Zs}*(\\p{N}+)(?:\\.)?$" + rxMinItemsFmt = rxCommentPrefix + "%s[Mm]in(?:imum)?(?:\\p{Zs}*|[\\p{Pd}\\p{Pc}]|\\.)?[Ii]tems\\p{Zs}*:\\p{Zs}*(\\p{N}+)(?:\\.)?$" + rxUniqueFmt = rxCommentPrefix + "%s[Uu]nique\\p{Zs}*:\\p{Zs}*(true|false)(?:\\.)?$" rxItemsPrefixFmt = "(?:[Ii]tems[\\.\\p{Zs}]*){%d}" ) var ( rxSwaggerAnnotation = regexp.MustCompile(`(?:^|[\s/])swagger:([\p{L}\p{N}\p{Pd}\p{Pc}]+)`) - rxFileUpload = regexp.MustCompile(`swagger:file`) - rxStrFmt = regexp.MustCompile(`swagger:strfmt\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)(?:\.)?$`) - rxAlias = regexp.MustCompile(`swagger:alias`) - rxName = regexp.MustCompile(`swagger:name\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}\.]+)(?:\.)?$`) - rxAllOf = regexp.MustCompile(`swagger:allOf\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}\.]+)?(?:\.)?$`) - rxModelOverride = regexp.MustCompile(`swagger:model\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)?(?:\.)?$`) - rxResponseOverride = regexp.MustCompile(`swagger:response\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)?(?:\.)?$`) - rxParametersOverride = regexp.MustCompile(`swagger:parameters\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}\p{Zs}]+)(?:\.)?$`) - rxEnum = regexp.MustCompile(`swagger:enum\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)(?:\.)?$`) - rxIgnoreOverride = regexp.MustCompile(`swagger:ignore\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)?(?:\.)?$`) - rxDefault = regexp.MustCompile(`swagger:default\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)(?:\.)?$`) - rxType = regexp.MustCompile(`swagger:type\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)(?:\.)?$`) + rxFileUpload = regexp.MustCompile(rxCommentPrefix + `swagger:file`) + rxStrFmt = regexp.MustCompile(rxCommentPrefix + `swagger:strfmt\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)(?:\.)?$`) + rxAlias = regexp.MustCompile(rxCommentPrefix + `swagger:alias`) + rxName = regexp.MustCompile(rxCommentPrefix + `swagger:name\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}\.]+)(?:\.)?$`) + rxAllOf = regexp.MustCompile(rxCommentPrefix + `swagger:allOf\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}\.]+)?(?:\.)?$`) + rxModelOverride = regexp.MustCompile(rxCommentPrefix + `swagger:model\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)?(?:\.)?$`) + rxResponseOverride = regexp.MustCompile(rxCommentPrefix + `swagger:response\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)?(?:\.)?$`) + rxParametersOverride = regexp.MustCompile(rxCommentPrefix + `swagger:parameters\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}\p{Zs}]+)(?:\.)?$`) + rxEnum = regexp.MustCompile(rxCommentPrefix + `swagger:enum\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)(?:\.)?$`) + rxIgnoreOverride = regexp.MustCompile(rxCommentPrefix + `swagger:ignore\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)?(?:\.)?$`) + rxDefault = regexp.MustCompile(rxCommentPrefix + `swagger:default\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)(?:\.)?$`) + rxType = regexp.MustCompile(rxCommentPrefix + `swagger:type\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)(?:\.)?$`) rxRoute = regexp.MustCompile( - "swagger:route\\p{Zs}*" + + rxRoutePrefix + + "swagger:route\\p{Zs}*" + rxMethod + "\\p{Zs}*" + rxPath + @@ -56,11 +88,12 @@ var ( rxOpTags + ")?\\p{Zs}+" + rxOpID + "\\p{Zs}*$") - rxBeginYAMLSpec = regexp.MustCompile(`---\p{Zs}*$`) + rxBeginYAMLSpec = regexp.MustCompile(rxCommentPrefix + `---\p{Zs}*$`) rxUncommentHeaders = regexp.MustCompile(`^[\p{Zs}\t/\*-]*\|?`) rxUncommentYAML = regexp.MustCompile(`^[\p{Zs}\t]*/*`) rxOperation = regexp.MustCompile( - "swagger:operation\\p{Zs}*" + + rxCommentPrefix + + "swagger:operation\\p{Zs}*" + rxMethod + "\\p{Zs}*" + rxPath + @@ -76,26 +109,26 @@ var ( rxStripTitleComments = regexp.MustCompile(`^[^\p{L}]*[Pp]ackage\p{Zs}+[^\p{Zs}]+\p{Zs}*`) rxAllowedExtensions = regexp.MustCompile(`^[Xx]-`) - rxIn = regexp.MustCompile(`[Ii]n\p{Zs}*:\p{Zs}*(query|path|header|body|formData)(?:\.)?$`) - rxRequired = regexp.MustCompile(`[Rr]equired\p{Zs}*:\p{Zs}*(true|false)(?:\.)?$`) - rxDiscriminator = regexp.MustCompile(`[Dd]iscriminator\p{Zs}*:\p{Zs}*(true|false)(?:\.)?$`) - rxReadOnly = regexp.MustCompile(`[Rr]ead(?:\p{Zs}*|[\p{Pd}\p{Pc}])?[Oo]nly\p{Zs}*:\p{Zs}*(true|false)(?:\.)?$`) - rxConsumes = regexp.MustCompile(`[Cc]onsumes\p{Zs}*:`) - rxProduces = regexp.MustCompile(`[Pp]roduces\p{Zs}*:`) - rxSecuritySchemes = regexp.MustCompile(`[Ss]ecurity\p{Zs}*:`) - rxSecurity = regexp.MustCompile(`[Ss]ecurity\p{Zs}*[Dd]efinitions:`) - rxResponses = regexp.MustCompile(`[Rr]esponses\p{Zs}*:`) - rxParameters = regexp.MustCompile(`[Pp]arameters\p{Zs}*:`) - rxSchemes = regexp.MustCompile(`[Ss]chemes\p{Zs}*:\p{Zs}*((?:(?:https?|HTTPS?|wss?|WSS?)[\p{Zs},]*)+)(?:\.)?$`) - rxVersion = regexp.MustCompile(`[Vv]ersion\p{Zs}*:\p{Zs}*(.+)$`) - rxHost = regexp.MustCompile(`[Hh]ost\p{Zs}*:\p{Zs}*(.+)$`) - rxBasePath = regexp.MustCompile(`[Bb]ase\p{Zs}*-*[Pp]ath\p{Zs}*:\p{Zs}*` + rxPath + "(?:\\.)?$") - rxLicense = regexp.MustCompile(`[Ll]icense\p{Zs}*:\p{Zs}*(.+)$`) - rxContact = regexp.MustCompile(`[Cc]ontact\p{Zs}*-?(?:[Ii]info\p{Zs}*)?:\p{Zs}*(.+)$`) - rxTOS = regexp.MustCompile(`[Tt](:?erms)?\p{Zs}*-?[Oo]f?\p{Zs}*-?[Ss](?:ervice)?\p{Zs}*:`) - rxExtensions = regexp.MustCompile(`[Ee]xtensions\p{Zs}*:`) - rxInfoExtensions = regexp.MustCompile(`[In]nfo\p{Zs}*[Ee]xtensions:`) - rxDeprecated = regexp.MustCompile(`[Dd]eprecated\p{Zs}*:\p{Zs}*(true|false)(?:\.)?$`) + rxIn = regexp.MustCompile(rxCommentPrefix + `[Ii]n\p{Zs}*:\p{Zs}*(query|path|header|body|formData)(?:\.)?$`) + rxRequired = regexp.MustCompile(rxCommentPrefix + `[Rr]equired\p{Zs}*:\p{Zs}*(true|false)(?:\.)?$`) + rxDiscriminator = regexp.MustCompile(rxCommentPrefix + `[Dd]iscriminator\p{Zs}*:\p{Zs}*(true|false)(?:\.)?$`) + rxReadOnly = regexp.MustCompile(rxCommentPrefix + `[Rr]ead(?:\p{Zs}*|[\p{Pd}\p{Pc}])?[Oo]nly\p{Zs}*:\p{Zs}*(true|false)(?:\.)?$`) + rxConsumes = regexp.MustCompile(rxCommentPrefix + `[Cc]onsumes\p{Zs}*:`) + rxProduces = regexp.MustCompile(rxCommentPrefix + `[Pp]roduces\p{Zs}*:`) + rxSecuritySchemes = regexp.MustCompile(rxCommentPrefix + `[Ss]ecurity\p{Zs}*:`) + rxSecurity = regexp.MustCompile(rxCommentPrefix + `[Ss]ecurity\p{Zs}*[Dd]efinitions:`) + rxResponses = regexp.MustCompile(rxCommentPrefix + `[Rr]esponses\p{Zs}*:`) + rxParameters = regexp.MustCompile(rxCommentPrefix + `[Pp]arameters\p{Zs}*:`) + rxSchemes = regexp.MustCompile(rxCommentPrefix + `[Ss]chemes\p{Zs}*:\p{Zs}*((?:(?:https?|HTTPS?|wss?|WSS?)[\p{Zs},]*)+)(?:\.)?$`) + rxVersion = regexp.MustCompile(rxCommentPrefix + `[Vv]ersion\p{Zs}*:\p{Zs}*(.+)$`) + rxHost = regexp.MustCompile(rxCommentPrefix + `[Hh]ost\p{Zs}*:\p{Zs}*(.+)$`) + rxBasePath = regexp.MustCompile(rxCommentPrefix + `[Bb]ase\p{Zs}*-*[Pp]ath\p{Zs}*:\p{Zs}*` + rxPath + "(?:\\.)?$") + rxLicense = regexp.MustCompile(rxCommentPrefix + `[Ll]icense\p{Zs}*:\p{Zs}*(.+)$`) + rxContact = regexp.MustCompile(rxCommentPrefix + `[Cc]ontact\p{Zs}*-?(?:[Ii]info\p{Zs}*)?:\p{Zs}*(.+)$`) + rxTOS = regexp.MustCompile(rxCommentPrefix + `[Tt](:?erms)?\p{Zs}*-?[Oo]f?\p{Zs}*-?[Ss](?:ervice)?\p{Zs}*:`) + rxExtensions = regexp.MustCompile(rxCommentPrefix + `[Ee]xtensions\p{Zs}*:`) + rxInfoExtensions = regexp.MustCompile(rxCommentPrefix + `[In]nfo\p{Zs}*[Ee]xtensions:`) + rxDeprecated = regexp.MustCompile(rxCommentPrefix + `[Dd]eprecated\p{Zs}*:\p{Zs}*(true|false)(?:\.)?$`) // currently unused: rxExample = regexp.MustCompile(`[Ex]ample\p{Zs}*:\p{Zs}*(.*)$`). ) diff --git a/internal/scanner/scan_context.go b/internal/scanner/scan_context.go index 28c0708..24eec84 100644 --- a/internal/scanner/scan_context.go +++ b/internal/scanner/scan_context.go @@ -314,13 +314,13 @@ func (s *ScanCtx) FindEnumValues(pkg *packages.Package, enumName string) (list [ } for _, spec := range gd.Specs { - literalValue, description := s.findEnumValue(spec, enumName) - if literalValue == nil { + values, descriptions := s.findEnumValue(spec, enumName) + if len(values) == 0 { continue } - list = append(list, literalValue) - descList = append(descList, description) + list = append(list, values...) + descList = append(descList, descriptions...) } } } @@ -328,66 +328,111 @@ func (s *ScanCtx) FindEnumValues(pkg *packages.Package, enumName string) (list [ return list, descList, true } -func (s *ScanCtx) findEnumValue(spec ast.Spec, enumName string) (literalValue any, description string) { +// 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) { vs, ok := spec.(*ast.ValueSpec) if !ok { - return nil, "" + return nil, nil } vsIdent, ok := vs.Type.(*ast.Ident) if !ok { - return nil, "" + return nil, nil } if vsIdent.Name != enumName { - return nil, "" + return nil, nil } - if len(vs.Values) == 0 { - return nil, "" + if len(vs.Values) == 0 || len(vs.Values) != len(vs.Names) { + return nil, nil } - bl, ok := vs.Values[0].(*ast.BasicLit) - if !ok { - return nil, "" + docSuffix := buildEnumDocSuffix(vs.Doc, vs.Names) + + for i, nameIdent := range vs.Names { + bl, ok := vs.Values[i].(*ast.BasicLit) + if !ok { + continue + } + + literalValue := parsers.GetEnumBasicLitValue(bl) + + var desc strings.Builder + fmt.Fprintf(&desc, "%v %s", literalValue, nameIdent.Name) + desc.WriteString(docSuffix) + + values = append(values, literalValue) + descriptions = append(descriptions, desc.String()) } - literalValue = parsers.GetEnumBasicLitValue(bl) + return values, descriptions +} - // build the enum description - var ( - desc = &strings.Builder{} - namesLen = len(vs.Names) - ) +// 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. +func buildEnumDocSuffix(doc *ast.CommentGroup, names []*ast.Ident) string { + if doc == nil || len(doc.List) == 0 { + return "" + } + + var b strings.Builder + b.WriteString(" ") - fmt.Fprintf(desc, "%v ", literalValue) - for i, name := range vs.Names { - desc.WriteString(name.Name) - if i < namesLen-1 { - desc.WriteString(" ") + stripped := false + for i, line := range doc.List { + if line.Text == "" { + continue } - } - if vs.Doc != nil { - docListLen := len(vs.Doc.List) - if docListLen > 0 { - desc.WriteString(" ") + text := strings.TrimPrefix(line.Text, "//") + if !stripped { + text = stripLeadingName(text, names) + stripped = true } + b.WriteString(text) - for i, doc := range vs.Doc.List { - if doc.Text != "" { - text := strings.TrimPrefix(doc.Text, "//") - desc.WriteString(text) - if i < docListLen-1 { - desc.WriteString(" ") - } - } + if i < len(doc.List)-1 { + b.WriteString(" ") } } - description = desc.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. +// +// 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") + + word, rest, found := strings.Cut(trimmed, " ") + if !found || word == "" { + return text + } + + for _, n := range names { + if n.Name == word { + return rest + } + } - return literalValue, description + return text } func sliceToSet(names []string) map[string]bool { diff --git a/internal/scanner/scan_context_test.go b/internal/scanner/scan_context_test.go index a4631f8..70da4c6 100644 --- a/internal/scanner/scan_context_test.go +++ b/internal/scanner/scan_context_test.go @@ -482,29 +482,28 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { t.Run("non-ValueSpec returns nil", func(t *testing.T) { spec := &ast.ImportSpec{Path: &ast.BasicLit{Value: `"fmt"`}} - val, desc := sctx.findEnumValue(spec, "Foo") - assert.Nil(t, val) - assert.EqualT(t, "", desc) + values, descs := sctx.findEnumValue(spec, "Foo") + assert.Nil(t, values) + assert.Nil(t, descs) }) t.Run("ValueSpec with nil Type returns nil", func(t *testing.T) { spec := &ast.ValueSpec{ Names: []*ast.Ident{ast.NewIdent("X")}, } - val, desc := sctx.findEnumValue(spec, "Foo") - assert.Nil(t, val) - assert.EqualT(t, "", desc) + values, descs := sctx.findEnumValue(spec, "Foo") + assert.Nil(t, values) + assert.Nil(t, descs) }) t.Run("ValueSpec with selector type returns nil", func(t *testing.T) { - // Type is *ast.SelectorExpr, not *ast.Ident spec := &ast.ValueSpec{ Names: []*ast.Ident{ast.NewIdent("X")}, Type: &ast.SelectorExpr{X: ast.NewIdent("pkg"), Sel: ast.NewIdent("Type")}, } - val, desc := sctx.findEnumValue(spec, "Foo") - assert.Nil(t, val) - assert.EqualT(t, "", desc) + values, descs := sctx.findEnumValue(spec, "Foo") + assert.Nil(t, values) + assert.Nil(t, descs) }) t.Run("ValueSpec with non-matching enum name returns nil", func(t *testing.T) { @@ -513,9 +512,9 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { Type: ast.NewIdent("Bar"), Values: []ast.Expr{&ast.BasicLit{Kind: token.INT, Value: "1"}}, } - val, desc := sctx.findEnumValue(spec, "Foo") - assert.Nil(t, val) - assert.EqualT(t, "", desc) + values, descs := sctx.findEnumValue(spec, "Foo") + assert.Nil(t, values) + assert.Nil(t, descs) }) t.Run("ValueSpec with no values returns nil", func(t *testing.T) { @@ -523,38 +522,57 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { Names: []*ast.Ident{ast.NewIdent("X")}, Type: ast.NewIdent("Foo"), } - val, desc := sctx.findEnumValue(spec, "Foo") - assert.Nil(t, val) - assert.EqualT(t, "", desc) + values, descs := sctx.findEnumValue(spec, "Foo") + assert.Nil(t, values) + assert.Nil(t, descs) }) - t.Run("ValueSpec with non-BasicLit value returns nil", func(t *testing.T) { + t.Run("ValueSpec with non-BasicLit value skips that position", func(t *testing.T) { spec := &ast.ValueSpec{ Names: []*ast.Ident{ast.NewIdent("X")}, Type: ast.NewIdent("Foo"), - Values: []ast.Expr{ast.NewIdent("someFunc")}, // *ast.Ident, not *ast.BasicLit + Values: []ast.Expr{ast.NewIdent("someFunc")}, } - val, desc := sctx.findEnumValue(spec, "Foo") - assert.Nil(t, val) - assert.EqualT(t, "", desc) + values, descs := sctx.findEnumValue(spec, "Foo") + assert.Empty(t, values) + assert.Empty(t, descs) }) - t.Run("ValueSpec with multiple names builds description", func(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. spec := &ast.ValueSpec{ Names: []*ast.Ident{ast.NewIdent("A"), ast.NewIdent("B")}, Type: ast.NewIdent("Foo"), Values: []ast.Expr{&ast.BasicLit{Kind: token.INT, Value: "42"}}, } - val, desc := sctx.findEnumValue(spec, "Foo") - require.NotNil(t, val) - intVal, ok := val.(int64) - require.True(t, ok) - assert.EqualT(t, int64(42), intVal) - // Description should contain "42 A B" - assert.True(t, len(desc) > 0) + values, descs := sctx.findEnumValue(spec, "Foo") + assert.Nil(t, values) + assert.Nil(t, descs) + }) + + t.Run("ValueSpec with multi-name pair emits one row per name/value", func(t *testing.T) { + spec := &ast.ValueSpec{ + Names: []*ast.Ident{ast.NewIdent("A"), ast.NewIdent("B")}, + Type: ast.NewIdent("Foo"), + Values: []ast.Expr{ + &ast.BasicLit{Kind: token.STRING, Value: `"a"`}, + &ast.BasicLit{Kind: token.STRING, Value: `"b"`}, + }, + Doc: &ast.CommentGroup{ + List: []*ast.Comment{{Text: "// shared doc"}}, + }, + } + values, descs := sctx.findEnumValue(spec, "Foo") + require.Len(t, values, 2) + require.Len(t, descs, 2) + assert.EqualT(t, "a", values[0]) + assert.EqualT(t, "b", values[1]) + assert.EqualT(t, "a A shared doc", descs[0]) + assert.EqualT(t, "b B shared doc", descs[1]) }) - t.Run("ValueSpec with doc comments builds description", func(t *testing.T) { + t.Run("ValueSpec with single name preserves legacy description shape", func(t *testing.T) { spec := &ast.ValueSpec{ Names: []*ast.Ident{ast.NewIdent("X")}, Type: ast.NewIdent("Foo"), @@ -566,11 +584,72 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { }, }, } - val, desc := sctx.findEnumValue(spec, "Foo") - require.NotNil(t, val) - assert.EqualT(t, "hello", val) - // Description should contain the doc comment text. - assert.True(t, len(desc) > 0) + values, descs := sctx.findEnumValue(spec, "Foo") + require.Len(t, values, 1) + require.Len(t, descs, 1) + assert.EqualT(t, "hello", values[0]) + assert.EqualT(t, "hello X first line second line", descs[0]) + }) + + t.Run("ValueSpec strips godoc leading identifier on single-name spec", func(t *testing.T) { + spec := &ast.ValueSpec{ + Names: []*ast.Ident{ast.NewIdent("PriorityLow")}, + Type: ast.NewIdent("Foo"), + Values: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: `"low"`}}, + Doc: &ast.CommentGroup{ + List: []*ast.Comment{{Text: "// PriorityLow is a low-priority level."}}, + }, + } + values, descs := sctx.findEnumValue(spec, "Foo") + require.Len(t, values, 1) + assert.EqualT(t, "low PriorityLow is a low-priority level.", descs[0]) + }) + + t.Run("ValueSpec strips leading identifier matching any name in multi-name spec", func(t *testing.T) { + spec := &ast.ValueSpec{ + Names: []*ast.Ident{ast.NewIdent("ChannelEmail"), ast.NewIdent("ChannelSMS")}, + Type: ast.NewIdent("Foo"), + Values: []ast.Expr{ + &ast.BasicLit{Kind: token.STRING, Value: `"email"`}, + &ast.BasicLit{Kind: token.STRING, Value: `"sms"`}, + }, + Doc: &ast.CommentGroup{ + List: []*ast.Comment{{Text: "// ChannelEmail and ChannelSMS share a single spec."}}, + }, + } + values, descs := sctx.findEnumValue(spec, "Foo") + require.Len(t, values, 2) + // Both rows strip leading "ChannelEmail" because it matches one of the names. + assert.EqualT(t, "email ChannelEmail and ChannelSMS share a single spec.", descs[0]) + assert.EqualT(t, "sms ChannelSMS and ChannelSMS share a single spec.", descs[1]) + }) + + t.Run("ValueSpec keeps doc unchanged when leading word is not a name", func(t *testing.T) { + spec := &ast.ValueSpec{ + Names: []*ast.Ident{ast.NewIdent("X")}, + Type: ast.NewIdent("Foo"), + Values: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: `"x"`}}, + Doc: &ast.CommentGroup{ + List: []*ast.Comment{{Text: "// The x value."}}, + }, + } + values, descs := sctx.findEnumValue(spec, "Foo") + require.Len(t, values, 1) + assert.EqualT(t, "x X The x value.", descs[0]) + }) + + t.Run("ValueSpec with no doc comment yields bare row", func(t *testing.T) { + spec := &ast.ValueSpec{ + Names: []*ast.Ident{ast.NewIdent("X")}, + Type: ast.NewIdent("Foo"), + Values: []ast.Expr{&ast.BasicLit{Kind: token.INT, Value: "7"}}, + } + values, descs := sctx.findEnumValue(spec, "Foo") + require.Len(t, values, 1) + intVal, ok := values[0].(int64) + require.True(t, ok) + assert.EqualT(t, int64(7), intVal) + assert.EqualT(t, "7 X", descs[0]) }) }