From 0dc46495df9d13b7232b8536605146f8f2d977bd Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:36:14 +0200 Subject: [PATCH 01/22] =?UTF-8?q?feat(grammar):=20preprocessor=20=E2=80=94?= =?UTF-8?q?=20comment-group=20to=20positioned=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalises an *ast.CommentGroup into a slice of positioned Lines: comment markers stripped, leading-asterisk style handled, verbatim YAML bodies preserved between fences. Each Line records its absolute file:line:col so downstream diagnostics and (future) LSP positions stay anchored to the source. Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- internal/parsers/grammar/preprocess.go | 168 ++++++++++++++++++++ internal/parsers/grammar/preprocess_test.go | 95 +++++++++++ 2 files changed, 263 insertions(+) create mode 100644 internal/parsers/grammar/preprocess.go create mode 100644 internal/parsers/grammar/preprocess_test.go diff --git a/internal/parsers/grammar/preprocess.go b/internal/parsers/grammar/preprocess.go new file mode 100644 index 0000000..59a6fbb --- /dev/null +++ b/internal/parsers/grammar/preprocess.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "go/ast" + "go/token" + "strings" + "unicode" + "unicode/utf8" +) + +// Line is one preprocessed comment line ready for the lexer. +// +// Text has the Go comment markers (// /* */) stripped along with the +// godoc continuation decoration (leading whitespace, asterisks, +// slashes, optional markdown table pipe). Used for keyword and +// annotation classification. +// +// Raw is the same source line with only the comment marker removed — +// content whitespace, indentation, and list markers are preserved. +// Used by the body accumulator so YAML / nested-map indentation +// survives intact. +// +// Pos points to the first character of Text in the source file. +type Line struct { + Text string + Raw string + Pos token.Position +} + +//nolint:gochecknoglobals // this is fine to declare a replacer as private global +var eolReplacer = strings.NewReplacer("\r\n", "\n", "\r", "\n") + +// Preprocess turns a Go comment group into a position-tagged []Line. +// Returns nil for nil inputs. Pure: no syscalls, no side-effects. +// +// 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 + } + + var out []Line + for _, c := range cg.List { + out = append(out, stripComment(eolReplacer.Replace(c.Text), fset.Position(c.Slash))...) + } + + return out +} + +// stripComment yields one Line per physical source line of one +// *ast.Comment. Handles both the // and /* */ forms. +func stripComment(raw string, basePos token.Position) []Line { + const markerLen = 2 // "//" / "/*" + switch { + case strings.HasPrefix(raw, "//"): + pos := basePos + pos.Column += markerLen + pos.Offset += markerLen + return []Line{stripLine(raw[markerLen:], pos, stripSingleGodocSpace)} + + case strings.HasPrefix(raw, "/*"): + body := strings.TrimSuffix(raw[markerLen:], "*/") + out := []Line{} + offset := 0 + for idx := 0; ; idx++ { + nl := strings.IndexByte(body[offset:], '\n') + + var seg string + if nl < 0 { + seg = body[offset:] + } else { + seg = body[offset : offset+nl] + } + + pos := basePos + if idx == 0 { + pos.Column += markerLen + pos.Offset += markerLen + } else { + pos.Line += idx + pos.Column = 1 + pos.Offset += markerLen + offset + } + out = append(out, stripLine(seg, pos, stripBlockContinuation)) + + if nl < 0 { + break + } + offset += nl + 1 + } + return out + + default: + return []Line{{Text: raw, Raw: raw, Pos: basePos}} + } +} + +func stripLine(s string, pos token.Position, rawStrip func(string) string) Line { + stripped := trimContentPrefix(s) + consumed := len(s) - len(stripped) + pos.Column += consumed + pos.Offset += consumed + return Line{Text: stripped, Raw: rawStrip(s), Pos: pos} +} + +// stripSingleGodocSpace is intentionally a no-op so Line.Raw preserves +// every character after the comment marker. Kept named so a future +// per-comment-kind raw-stripping strategy can slot in here. +func stripSingleGodocSpace(s string) string { return s } + +// stripBlockContinuation removes the `\s*\*\s?` decoration godoc +// /* */ continuation lines carry, preserving all indentation otherwise. +func stripBlockContinuation(s string) string { + leading := -1 + for i, r := range s { + if !unicode.IsSpace(r) { + leading = i + break + } + } + + if leading < 0 || s[leading] != '*' { // also: empty string, all-blanks string, or no '*' continuation go there + return s + } + + s = s[leading+1:] + r, offset := utf8.DecodeRuneInString(s) + if unicode.IsSpace(r) { + return s[offset:] + } + + return s +} + +// trimContentPrefix strips godoc-style leading decoration. Notably +// preserves leading "-" so the YAML fence "---" survives intact. +func trimContentPrefix(s string) string { + s = strings.TrimLeft(s, " \t*/") + s = strings.TrimPrefix(s, "|") + return strings.TrimLeft(s, " \t") +} + +// preprocessText handles raw text inputs (already stripped of +// comment markers) for ParseText / ParseAs. Line endings are +// normalised (\r\n → \n, lone \r → \n) before splitting. +func preprocessText(text string, basePos token.Position) []Line { + text = normaliseLineEndings(text) + rows := strings.Split(text, "\n") + out := make([]Line, 0, len(rows)) + for i, r := range rows { + pos := basePos + pos.Line += i + if i > 0 { + pos.Column = 1 + } + out = append(out, Line{Text: trimContentPrefix(r), Raw: r, Pos: pos}) + } + return out +} + +// normaliseLineEndings collapses \r\n and lone \r sequences into \n. +func normaliseLineEndings(s string) string { + return eolReplacer.Replace(s) +} diff --git a/internal/parsers/grammar/preprocess_test.go b/internal/parsers/grammar/preprocess_test.go new file mode 100644 index 0000000..54f1eff --- /dev/null +++ b/internal/parsers/grammar/preprocess_test.go @@ -0,0 +1,95 @@ +package grammar + +import ( + "iter" + "slices" + "testing" + + "github.com/go-openapi/testify/v2/assert" +) + +func TestStripBlockContinuation(t *testing.T) { + for testCase := range stripBlockTestCases() { + t.Run(testCase.Name, func(t *testing.T) { + assert.EqualT(t, testCase.Expected, stripBlockContinuation(testCase.Input)) + }) + } +} + +type stripBlockTestCase struct { + Name string + Input string + Expected string +} + +func stripBlockTestCases() iter.Seq[stripBlockTestCase] { + const blanks = " \t \u00a0 \u205f" + + return slices.Values([]stripBlockTestCase{ + { + Name: "empty string", + Input: "", + Expected: "", + }, + { + Name: "blank string with unicode whitespace", + Input: blanks, + Expected: blanks, + }, + { + Name: "all-whitespace returns input verbatim", + Input: " ", + Expected: " ", + }, + { + Name: "blanks with * string", + Input: blanks + "*", + Expected: "", + }, + { + Name: "blanks with * + indented string", + Input: blanks + "*\u2029 ", + Expected: " ", + }, + { + Name: "blanks with * + indented string", + Input: blanks + "*\u2029 indented", + Expected: " indented", + }, + { + Name: "blanks with * + string", + Input: blanks + "*notindented", + Expected: "notindented", + }, + { + Name: "no marker", + Input: "x", + Expected: "x", + }, + { + Name: "indented no marker", + Input: " x", + Expected: " x", + }, + { + Name: "canonical godoc continuation", + Input: " * hello", + Expected: "hello", + }, + { + Name: "Unicode whitespace around the marker", + Input: " * hello", + Expected: "hello", + }, + { + Name: "no marker preserves indentation", + Input: " not_a_continuation", + Expected: " not_a_continuation", + }, + { + Name: "marker without surrounding whitespace", + Input: "*hello", + Expected: "hello", + }, + }) +} From 7335d7307086a01718e76e1f3894d7e82e0d9c99 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:36:22 +0200 Subject: [PATCH 02/22] =?UTF-8?q?feat(grammar):=20lexer=20=E2=80=94=20toke?= =?UTF-8?q?nize=20annotations=20and=20keywords?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stateful lexer over the preprocessed []Line surface. Emits positioned tokens classifying annotation headers, keyword left-hand-sides, structured values, list markers, prose runs, and verbatim YAML bodies. Keyword vocabulary lives in keywords.go and covers every swagger:* keyword surface. The lexer is free of regular expressions. Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- internal/parsers/grammar/keywords.go | 353 +++++++ internal/parsers/grammar/lexer.go | 1207 ++++++++++++++++++++++++ internal/parsers/grammar/lexer_test.go | 510 ++++++++++ internal/parsers/grammar/token.go | 156 +++ 4 files changed, 2226 insertions(+) create mode 100644 internal/parsers/grammar/keywords.go create mode 100644 internal/parsers/grammar/lexer.go create mode 100644 internal/parsers/grammar/lexer_test.go create mode 100644 internal/parsers/grammar/token.go diff --git a/internal/parsers/grammar/keywords.go b/internal/parsers/grammar/keywords.go new file mode 100644 index 0000000..606dd8b --- /dev/null +++ b/internal/parsers/grammar/keywords.go @@ -0,0 +1,353 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import "strings" + +// ValueShape names the lexical shape of a keyword's value, mapping +// directly onto the value terminals: +// +// - ShapeNumber → NUMBER_VALUE +// - ShapeInt → INT_VALUE +// - ShapeBool → BOOL_VALUE +// - ShapeString → STRING_VALUE +// - ShapeCommaList → COMMA_LIST_VALUE +// - ShapeEnumOption → ENUM_OPTION_VALUE (Values lists the closed set) +// - ShapeRawBlock → RAW_BLOCK_ (multi-line body terminal) +// - ShapeRawValue → RAW_VALUE_ (multi-line OR single-line body terminal) +// +// See README §keyword-table for the per-shape callback dispatched +// by Walker. +type ValueShape int + +const ( + ShapeNone ValueShape = iota + ShapeNumber + ShapeInt + ShapeBool + ShapeString + ShapeCommaList + ShapeEnumOption + ShapeRawBlock + ShapeRawValue +) + +// String renders a ValueShape for diagnostics. +func (v ValueShape) String() string { + switch v { + case ShapeNumber: + return "number" + case ShapeInt: + return "integer" + case ShapeBool: + return "boolean" + case ShapeString: + return "string" + case ShapeCommaList: + return "comma-list" + case ShapeEnumOption: + return "enum-option" + case ShapeRawBlock: + return "raw-block" + case ShapeRawValue: + return "raw-value" + case ShapeNone: + fallthrough + default: + return "none" + } +} + +// IsBody reports whether the keyword's value is a multi-line body +// terminal (RAW_BLOCK_* or RAW_VALUE_*). Body keywords trigger the +// lexer's body accumulator. +func (v ValueShape) IsBody() bool { + return v == ShapeRawBlock || v == ShapeRawValue +} + +// Keyword describes one recognisable keyword: form. +type Keyword struct { + Name string + Aliases []string + Shape ValueShape + Values []string // populated when Shape == ShapeEnumOption + // Contexts is the set of family contexts the keyword is legal in. + // Used by the parser layer for non-fatal context-invalid warnings. + Contexts []KeywordContext +} + +// KeywordContext is a family-level context where a keyword is legal. +// Distinct from AnnotationKind because validations are legal across +// several annotation kinds (model + parameters + response, etc.). +type KeywordContext int + +const ( + CtxParam KeywordContext = iota + CtxHeader + CtxSchema + CtxItems + CtxRoute + CtxOperation + CtxMeta + CtxResponse +) + +// String renders a KeywordContext for diagnostics. +func (c KeywordContext) String() string { + switch c { + case CtxParam: + return "param" + case CtxHeader: + return "header" + case CtxSchema: + return "schema" + case CtxItems: + return "items" + case CtxRoute: + return "route" + case CtxOperation: + return "operation" + case CtxMeta: + return "meta" + case CtxResponse: + return "response" + default: + return "?" + } +} + +// keyword constructs a Keyword via functional options. +func keyword(name string, opts ...keywordOpt) Keyword { + kw := Keyword{Name: name} + for _, o := range opts { + o(&kw) + } + return kw +} + +type keywordOpt func(*Keyword) + +func aka(names ...string) keywordOpt { + return func(kw *Keyword) { kw.Aliases = append(kw.Aliases, names...) } +} + +func asNumber() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeNumber } } +func asInt() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeInt } } +func asBool() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeBool } } +func asString() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeString } } +func asCommaList() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeCommaList } } +func asRawBlock() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeRawBlock } } +func asRawValue() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeRawValue } } + +func asEnumOption(values ...string) keywordOpt { + return func(kw *Keyword) { + kw.Shape = ShapeEnumOption + kw.Values = values + } +} + +func ctx(ctxs ...KeywordContext) keywordOpt { + return func(kw *Keyword) { kw.Contexts = append(kw.Contexts, ctxs...) } +} + +// Canonical keyword names. These constants are the single source of +// truth for spelling: every Property's Keyword.Name compares equal to +// exactly one of them. Consumers that switch on Keyword.Name should +// reference these constants rather than re-declaring the strings — +// schema/walker.go and the bridge dispatchers in routes/parameters/ +// responses/operations/items/spec all dispatch on these names. +// +// Sections (numeric validations / length validations / schema +// decorators / boolean markers / param-location / meta single-line / +// raw-block) follow the same order as the keywords table below. +const ( + KwMaximum = "maximum" + KwMinimum = "minimum" + KwMultipleOf = "multipleOf" + KwMaxLength = "maxLength" + KwMinLength = "minLength" + KwPattern = "pattern" + KwMaxItems = "maxItems" + KwMinItems = "minItems" + KwUnique = "unique" + KwCollectionFormat = "collectionFormat" + KwDefault = "default" + KwExample = "example" + KwEnum = "enum" + KwRequired = "required" + KwReadOnly = "readOnly" + KwDiscriminator = "discriminator" + KwDeprecated = "deprecated" + KwIn = "in" + KwSchemes = "schemes" + KwVersion = "version" + KwHost = "host" + KwBasePath = "basePath" + KwLicense = "license" + KwContact = "contact" + KwConsumes = "consumes" + KwProduces = "produces" + KwSecurity = "security" + KwSecurityDefinitions = "securityDefinitions" + KwResponses = "responses" + KwParameters = "parameters" + KwExtensions = "extensions" + KwInfoExtensions = "infoExtensions" + KwTOS = "tos" + KwExternalDocs = "externalDocs" +) + +// keywords is the authoritative table. Additions land here. +// +//nolint:gochecknoglobals // canonical lookup table — see godoc on Lookup. +var keywords = []Keyword{ + // Numeric validations. + keyword(KwMaximum, aka("max"), asNumber(), ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + keyword(KwMinimum, aka("min"), asNumber(), ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + keyword(KwMultipleOf, + aka("multiple of", "multiple-of"), + asNumber(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + + // String-length validations. + keyword(KwMaxLength, + aka("max length", "max-length", "maxLen", "max len", "max-len", + "maximum length", "maximum-length", "maximumLength", + "maximum len", "maximum-len"), + asInt(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + keyword(KwMinLength, + aka("min length", "min-length", "minLen", "min len", "min-len", + "minimum length", "minimum-length", "minimumLength", + "minimum len", "minimum-len"), + asInt(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + keyword(KwPattern, + asString(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + + // Array validations. + keyword(KwMaxItems, + aka("max items", "max-items", "max.items", + "maximum items", "maximum-items", "maximumItems"), + asInt(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + keyword(KwMinItems, + aka("min items", "min-items", "min.items", + "minimum items", "minimum-items", "minimumItems"), + asInt(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + keyword(KwUnique, + asBool(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + keyword(KwCollectionFormat, + aka("collection format", "collection-format"), + asEnumOption("csv", "ssv", "tsv", "pipes", "multi"), + ctx(CtxParam, CtxHeader, CtxItems)), + + // Schema decorators with body-accepting bodies (default/example/enum). + keyword(KwDefault, + asRawValue(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + keyword(KwExample, + asRawValue(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + keyword(KwEnum, + asRawValue(), + ctx(CtxParam, CtxHeader, CtxSchema, CtxItems)), + + // Field-level boolean markers. + keyword(KwRequired, asBool(), ctx(CtxParam, CtxSchema)), + keyword(KwReadOnly, + aka("read only", "read-only"), + asBool(), + ctx(CtxSchema)), + keyword(KwDiscriminator, asBool(), ctx(CtxSchema)), + keyword(KwDeprecated, asBool(), ctx(CtxOperation, CtxRoute, CtxSchema)), + + // Parameter-location directive. Not part of the formal schema- + // body grammar. 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)), + + // 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)), + keyword(KwVersion, asString(), ctx(CtxMeta)), + keyword(KwHost, asString(), ctx(CtxMeta)), + keyword(KwBasePath, + aka("base path", "base-path"), + asString(), + ctx(CtxMeta)), + keyword(KwLicense, asString(), ctx(CtxMeta)), + keyword(KwContact, + aka("contact info", "contact-info"), + asString(), + ctx(CtxMeta)), + + // Multi-line raw-block keywords. + keyword(KwConsumes, asRawBlock(), ctx(CtxMeta, CtxRoute, CtxOperation)), + keyword(KwProduces, asRawBlock(), ctx(CtxMeta, CtxRoute, CtxOperation)), + keyword(KwSecurity, asRawBlock(), ctx(CtxMeta, CtxRoute, CtxOperation)), + keyword(KwSecurityDefinitions, + aka("security definitions", "security-definitions"), + asRawBlock(), + ctx(CtxMeta)), + keyword(KwResponses, asRawBlock(), ctx(CtxRoute, CtxOperation)), + keyword(KwParameters, asRawBlock(), ctx(CtxRoute, CtxOperation)), + keyword(KwExtensions, + asRawBlock(), + ctx(CtxMeta, CtxRoute, CtxOperation, CtxSchema, CtxParam, CtxHeader)), + keyword(KwInfoExtensions, + aka("info extensions", "info-extensions"), + asRawBlock(), + ctx(CtxMeta)), + keyword(KwTOS, + aka("terms of service", "terms-of-service", "termsOfService"), + asRawBlock(), + ctx(CtxMeta)), + keyword(KwExternalDocs, + aka("external docs", "external-docs"), + asRawBlock(), + ctx(CtxMeta, CtxRoute, CtxOperation, CtxSchema)), +} + +// Lookup returns the Keyword matching name (canonical or alias), +// case-insensitive on the canonical/alias spellings. The second +// return value reports whether a match was found. +func Lookup(name string) (Keyword, bool) { + needle := strings.ToLower(strings.TrimSpace(name)) + if needle == "" { + return Keyword{}, false + } + for _, kw := range keywords { + if strings.EqualFold(kw.Name, needle) { + return kw, true + } + for _, alias := range kw.Aliases { + if strings.EqualFold(alias, needle) { + return kw, true + } + } + } + return Keyword{}, false +} + +// Keywords returns a copy of the authoritative table. +func Keywords() []Keyword { + out := make([]Keyword, len(keywords)) + copy(out, keywords) + return out +} diff --git a/internal/parsers/grammar/lexer.go b/internal/parsers/grammar/lexer.go new file mode 100644 index 0000000..5871f1e --- /dev/null +++ b/internal/parsers/grammar/lexer.go @@ -0,0 +1,1207 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "go/token" + "slices" + "strconv" + "strings" + "unicode" + "unicode/utf8" +) + +// Lex turns a preprocessed []Line into the token stream consumed by +// the grammar parser. Pipeline: +// +// 1. Line classifier (lexLine): per-line classification into raw +// Tokens (annotation / keyword / fence / blank / text). +// 2. Body accumulator: folds multi-line bodies (OPAQUE_YAML, RAW_BLOCK, +// RAW_VALUE) into single body tokens. +// 3. Prose classifier: re-types surviving text tokens as TITLE / DESC. +// +// The output stream ends in a single TokenEOF. +// +// # 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. +func Lex(lines []Line) []Token { + raw := classifyLines(lines) + bodied := accumulateBodies(raw) + return classifyProse(bodied) +} + +// ----- Stage 1 — line classifier -------------------------------------------- + +// classifyLines emits one preliminary token per line. The state it +// carries between lines is whether the cursor sits between matching +// `---` fences (so YAML bodies survive verbatim); body accumulation +// happens later, in stage 2. +func classifyLines(lines []Line) []Token { + out := make([]Token, 0, len(lines)+1) + inFence := false + for _, line := range lines { + tok := lexLine(line, inFence) + out = append(out, tok) + if tok.Kind == tokenYAMLFence { + inFence = !inFence + } + } + return out +} + +// lexLine classifies one line. Returns one of: +// - TokenBlank +// - tokenYAMLFence +// - tokenRawLine (verbatim line inside an active fence) +// - TokenAnnotation (with pre-classified Args) +// - tokenKeywordPre (head + value-string, body accumulator decides next) +// - tokenText (free-form prose; later re-typed as TITLE/DESC) +func lexLine(line Line, inFence bool) Token { + text := strings.TrimRightFunc(line.Text, unicode.IsSpace) + + if strings.TrimSpace(text) == "---" { + return Token{Kind: tokenYAMLFence, Pos: line.Pos} + } + if inFence { + return Token{Kind: tokenRawLine, Pos: line.Pos, Text: line.Raw, Raw: line.Raw} + } + if text == "" { + return Token{Kind: TokenBlank, Pos: line.Pos} + } + + // First-character case insensitivity on swagger:: only the + // leading character flips. + if hasSwaggerPrefix(text) { + return lexAnnotation(text, line.Pos) + } + if pfxLen, ok := matchGodocRoutePrefix(text); ok { + pos := line.Pos + pos.Column += pfxLen + pos.Offset += pfxLen + return lexAnnotation(text[pfxLen:], pos) + } + // Go compiler / linter directives (`//go:generate`, `//nolint:foo`, + // `//lint:ignore`, …) — recognise on Raw (which preserves the + // post-`//` spacing) and drop from the prose surface so they never + // land in TITLE / DESC. Must run after the swagger-prefix check so + // `//swagger:model` (legal but non-idiomatic, no leading space) is + // not mistaken for a directive. + if isGoDirective(line.Raw) { + return Token{Kind: tokenDirective, Pos: line.Pos, Raw: line.Raw} + } + if tok, ok := lexKeyword(text, line.Raw, line.Pos); ok { + return tok + } + return Token{Kind: tokenText, Pos: line.Pos, Text: text, Raw: line.Raw} +} + +// isGoDirective reports whether raw is the body of a Go compiler / +// linter directive comment. A directive has the form +// `:` where: +// +// - the leading character is a lowercase ASCII letter (no leading +// whitespace — distinguishes `//nolint:foo` from `// nolint:foo`); +// - the leading word is lowercase ASCII letters only; +// - the word is followed by `:` and **at least one non-whitespace +// character** with no whitespace between the colon and the +// argument. +// +// The "no whitespace after colon" rule separates directives from +// keyword lines: `maximum: 10` (space → keyword), `pattern:` (empty → +// block head), `nolint:foo` (immediate arg → directive). +// +// Note: `swagger:` matches this shape; lexLine runs the swagger +// check before the directive check so swagger annotations are never +// dropped. +func isGoDirective(raw string) bool { + if raw == "" || raw[0] < 'a' || raw[0] > 'z' { + return false + } + i := 1 + for i < len(raw) && raw[i] >= 'a' && raw[i] <= 'z' { + i++ + } + if i >= len(raw) || raw[i] != ':' { + return false + } + after := i + 1 + if after >= len(raw) { + return false + } + if raw[after] == ' ' || raw[after] == '\t' { + return false + } + return true +} + +// hasSwaggerPrefix is the case-insensitive match on the first char of +// AnnotationPrefix — only the first character is permissive. +// +// 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 + } + first := AnnotationPrefix[0] + if s[0] != first && s[0] != asciiUpper(first) { + return false + } + return s[1:len(AnnotationPrefix)] == AnnotationPrefix[1:] +} + +// asciiUpper returns the uppercase form of an ASCII letter, or the +// byte unchanged otherwise. Used for the first-character case- +// insensitive match on AnnotationPrefix. +func asciiUpper(b byte) byte { + if b >= 'a' && b <= 'z' { + return b - ('a' - 'A') + } + return b +} + +// matchGodocRoutePrefix recognises a leading "GoIdent swagger:route". +// Returns the byte offset where "swagger:route" begins. Only "route" +// gets this exception. See README §lexer-contract +// ("Godoc-prefix exception for swagger:route"). +func matchGodocRoutePrefix(s string) (int, bool) { + identEnd := scanGoIdentifier(s) + if identEnd == 0 { + return 0, false + } + wsEnd := identEnd + for wsEnd < len(s) && (s[wsEnd] == ' ' || s[wsEnd] == '\t') { + wsEnd++ + } + if wsEnd == identEnd { + return 0, false + } + prefix := AnnotationPrefix + labelRoute + if !strings.HasPrefix(s[wsEnd:], prefix) { + return 0, false + } + after := wsEnd + len(prefix) + if after < len(s) && s[after] != ' ' && s[after] != '\t' { + return 0, false + } + return wsEnd, true +} + +// scanGoIdentifier returns the byte length of a leading Go identifier: +// Letter followed by Letter | Digit | _ | -. Returns 0 if s does not +// start with a letter. +func scanGoIdentifier(s string) int { + if s == "" { + return 0 + } + r, size := utf8.DecodeRuneInString(s) + if !unicode.IsLetter(r) { + return 0 + } + i := size + for i < len(s) { + r, size = utf8.DecodeRuneInString(s[i:]) + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-' { + break + } + i += size + } + return i +} + +// lexAnnotation parses "swagger: [args...]". Empty name falls +// back to a text token so the parser can diagnose. Args are returned +// pre-classified via classifyAnnotationArgs. +func lexAnnotation(text string, pos token.Position) Token { + rest := text[len(AnnotationPrefix):] + rest = strings.TrimRightFunc(stripTrailingDot(rest), unicode.IsSpace) + name, after := splitFirstField(rest) + if name == "" { + return Token{Kind: tokenText, Pos: pos, Text: text} + } + kind := AnnotationKindFromName(name) + args := classifyAnnotationArgs(kind, after, pos, len(text)-len(after)) + return Token{Kind: TokenAnnotation, Pos: pos, Name: name, Args: args} +} + +// stripTrailingDot elides a single trailing ".". 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). +func splitFirstField(s string) (head, rest string) { + s = strings.TrimLeft(s, " \t") + if s == "" { + return "", "" + } + i := 0 + for i < len(s) && s[i] != ' ' && s[i] != '\t' { + i++ + } + head = s[:i] + rest = strings.TrimLeft(s[i:], " \t") + return head, rest +} + +// classifyAnnotationArgs converts the post-name remainder of an +// annotation line into the typed argument tokens 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. +func classifyAnnotationArgs(kind AnnotationKind, rest string, linePos token.Position, baseColumn int) []Token { + rest = strings.TrimLeft(rest, " \t") + if rest == "" { + return nil + } + pos := linePos + pos.Column = linePos.Column + baseColumn + pos.Offset = linePos.Offset + baseColumn + + switch kind { + case AnnRoute, AnnOperation: + return classifyOperationArgs(rest, pos) + case AnnDefaultName: + return []Token{argDefaultValue(rest, pos)} + case AnnType: + return []Token{argTypeRef(rest, pos)} + case AnnEnum: + return classifyEnumAnnotationArgs(rest, pos) + case AnnParameters: + return classifyIdentList(rest, pos) + case AnnAllOf, AnnModel, AnnResponse, AnnStrfmt, AnnName: + return []Token{firstIdent(rest, pos)} + case AnnAlias, AnnIgnore, AnnFile, AnnMeta, AnnUnknown: + // No formal arguments. Capture any trailing tokens as RAW so a + // downstream diagnostic can flag them. + return classifyIdentList(rest, pos) + default: + return classifyIdentList(rest, pos) + } +} + +// classifyOperationArgs extracts METHOD, /path, [tags...], and the +// trailing operationID. 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 { + return nil + } + out := make([]Token, 0, len(fields)) + + // Field 0: HTTP_METHOD (if recognised). + first := fields[0] + if canon, ok := classifyHTTPMethod(first.text); ok { + out = append(out, Token{Kind: TokenHTTPMethod, Pos: first.pos, Text: canon}) + fields = fields[1:] + } + + // Field 0 (now): URL_PATH (if it looks like one). + if len(fields) > 0 && looksLikeURLPath(fields[0].text) { + out = append(out, Token{Kind: TokenURLPath, Pos: fields[0].pos, Text: fields[0].text}) + fields = fields[1:] + } + + // Remaining fields: every IDENT_NAME — 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. +func argDefaultValue(rest string, pos token.Position) Token { + kind := classifyDefaultValue(rest) + return Token{Kind: kind, Pos: pos, Text: strings.TrimSpace(rest)} +} + +// argTypeRef recognises the closed TYPE_REF vocabulary; non-matches +// fall back to TokenIdentName so the analyzer can diagnose. +func argTypeRef(rest string, pos token.Position) Token { + rest = strings.TrimSpace(rest) + if isTypeRef(rest) { + return Token{Kind: TokenTypeRef, Pos: pos, Text: rest} + } + return Token{Kind: TokenIdentName, Pos: pos, Text: rest} +} + +// classifyEnumAnnotationArgs implements the four-step 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 { + case enumFormEmpty: + return nil + case enumFormBracketedOnly: + return []Token{{Kind: TokenJSONValue, Pos: pos, Text: values}} + case enumFormPlainOnly: + return []Token{{Kind: TokenCommaListValue, Pos: pos, Text: values}} + case enumFormNameOnly: + return []Token{{Kind: TokenIdentName, Pos: pos, Text: name}} + case enumFormNamePlusBracketed: + valuesPos := pos + valuesPos.Column += len(name) + 1 + valuesPos.Offset += len(name) + 1 + return []Token{ + {Kind: TokenIdentName, Pos: pos, Text: name}, + {Kind: TokenJSONValue, Pos: valuesPos, Text: values}, + } + case enumFormNamePlusPlain: + valuesPos := pos + valuesPos.Column += len(name) + 1 + valuesPos.Offset += len(name) + 1 + return []Token{ + {Kind: TokenIdentName, Pos: pos, Text: name}, + {Kind: TokenCommaListValue, Pos: valuesPos, Text: values}, + } + default: + return nil + } +} + +// classifyIdentList tokenises a whitespace-separated list as IDENT_NAME +// tokens. +func classifyIdentList(rest string, basePos token.Position) []Token { + fields := splitFields(rest, basePos) + out := make([]Token, 0, len(fields)) + for _, f := range fields { + out = append(out, Token{Kind: TokenIdentName, Pos: f.pos, Text: f.text}) + } + return out +} + +// firstIdent emits a single TokenIdentName for the first whitespace- +// separated token in rest. Used for single-arg classifier annotations. +func firstIdent(rest string, basePos token.Position) Token { + fields := splitFields(rest, basePos) + if len(fields) == 0 { + return Token{Kind: TokenIdentName, Pos: basePos} + } + return Token{Kind: TokenIdentName, Pos: fields[0].pos, Text: fields[0].text} +} + +// field is one whitespace-separated token with a position. +type field struct { + text string + pos token.Position +} + +// splitFields breaks s into whitespace-separated fields, advancing the +// position by byte offset for each field. +func splitFields(s string, basePos token.Position) []field { + const sensibleAllocs = 4 + out := make([]field, 0, sensibleAllocs) + i := 0 + for i < len(s) { + // Skip whitespace. + start := i + for i < len(s) && (s[i] == ' ' || s[i] == '\t') { + i++ + } + if i == len(s) { + break + } + offset := i + j := i + for j < len(s) && s[j] != ' ' && s[j] != '\t' { + j++ + } + f := field{ + text: s[i:j], + pos: token.Position{ + Filename: basePos.Filename, + Offset: basePos.Offset + offset, + Line: basePos.Line, + Column: basePos.Column + offset, + }, + } + out = append(out, f) + i = j + _ = start + } + return out +} + +// lexKeyword tries to parse text as a "[items.]*: [value]" +// form. Returns (token, true) on a match. Always returns a +// tokenKeywordPre — head + raw value string. Body accumulation (stage 2) +// decides whether to keep it as inline-value KW or expand into a body. +func lexKeyword(text, raw string, pos token.Position) (Token, bool) { + rest, depth := stripItemsPrefix(text) + + before, after, found := strings.Cut(rest, ":") + if !found { + return Token{}, false + } + + name := strings.TrimSpace(before) + if name == "" { + return Token{}, false + } + // First-character case insensitivity: lowercase only the first + // character before lookup. See README §lexer-contract + // ("First-character case insensitivity on keywords"). + canonName := lowerFirst(name) + + kw, ok := Lookup(canonName) + if !ok { + return Token{}, false + } + + consumed := len(text) - len(rest) + kwPos := pos + kwPos.Column += consumed + kwPos.Offset += consumed + + value := strings.TrimSpace(after) + value = stripTrailingDot(value) + + return Token{ + Kind: tokenKeywordPre, + Pos: kwPos, + Name: kw.Name, + SourceName: name, + Text: value, // raw post-":" payload + Raw: raw, + ItemsDepth: depth, + // Keyword field reused to carry the table entry shape downstream. + Keyword: kw.Name, + }, true +} + +// lowerFirst applies first-character lowercase; 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 + } + r, size := utf8.DecodeRuneInString(s) + if unicode.IsUpper(r) { + lower := unicode.ToLower(r) + var buf [4]byte + n := utf8.EncodeRune(buf[:], lower) + return string(buf[:n]) + s[size:] + } + return s +} + +// stripItemsPrefix peels leading "items." (or "items ") segments, +// counting depth. Bare "items:" (no separator) is preserved. +func stripItemsPrefix(s string) (string, int) { + depth := 0 + for { + stripped, ok := stripOneItemsPrefix(s) + if !ok { + return s, depth + } + s = stripped + depth++ + } +} + +func stripOneItemsPrefix(s string) (string, bool) { + const itemsLen = 5 + if len(s) < itemsLen { + return s, false + } + if !strings.EqualFold(s[:itemsLen], "items") { + return s, false + } + rest := s[itemsLen:] + trimmed := strings.TrimLeft(rest, ". \t") + if len(trimmed) == len(rest) { + return s, false + } + return trimmed, true +} + +// ----- Stage 2 — body accumulator ------------------------------------------- + +// accumulateBodies folds multi-line bodies into single body tokens and +// finalises inline-value keywords by typing the value per the keyword's +// declared shape. The output stream contains only tokens the parser +// actually consumes (no internal kinds). +func accumulateBodies(in []Token) []Token { + out := make([]Token, 0, len(in)+1) + i := 0 + for i < len(in) { + t := in[i] + switch t.Kind { + case tokenYAMLFence: + i = collectFencedYAML(in, i, &out) + case tokenKeywordPre: + kw, _ := Lookup(t.Name) + switch kw.Shape { + case ShapeRawBlock: + i = collectRawBlock(in, i, kw, &out) + case ShapeRawValue: + i = collectRawValue(in, i, kw, &out) + case ShapeNone, ShapeNumber, ShapeInt, ShapeBool, + ShapeString, ShapeCommaList, ShapeEnumOption: + out = append(out, finaliseInlineKeyword(t, kw)) + i++ + default: + out = append(out, finaliseInlineKeyword(t, kw)) + i++ + } + case tokenRawLine: + // Stale raw line outside a fence — should not happen given + // classifyLines' state machine. Drop silently. + i++ + case tokenDirective: + // Go directives (//go:, //nolint:, …) are dropped from the + // stream — they have no role in the swagger annotation + // grammar and must not contaminate TITLE / DESC. + i++ + case TokenBlank, tokenText, TokenAnnotation, TokenEOF: + out = append(out, t) + i++ + default: + out = append(out, t) + i++ + } + } + out = append(out, Token{Kind: TokenEOF}) + return out +} + +// collectFencedYAML scans from a `---` opener at index i and emits one +// OPAQUE_YAML token. The body is stored in Body (joined with "\n") and +// in Raw (verbatim, including indentation). Truncated is set on EOF +// without a closer. Returns the index past the closing fence (or the +// EOF position). +func collectFencedYAML(in []Token, i int, out *[]Token) int { + openerPos := in[i].Pos + i++ + var body, raw []string + for i < len(in) { + switch in[i].Kind { + case tokenYAMLFence: + *out = append(*out, Token{ + Kind: TokenOpaqueYaml, + Pos: openerPos, + Body: strings.Join(body, "\n"), + Raw: strings.Join(raw, "\n"), + Keyword: "", + }) + return i + 1 + case tokenRawLine: + body = append(body, in[i].Text) + raw = append(raw, in[i].Raw) + i++ + default: + // Non-raw token shouldn't appear inside a fence — defensive. + i++ + } + } + // EOF before closing fence — truncated body. + *out = append(*out, Token{ + Kind: TokenOpaqueYaml, + Pos: openerPos, + Body: strings.Join(body, "\n"), + Raw: strings.Join(raw, "\n"), + Truncated: true, + }) + return i +} + +// collectRawBlock accumulates the body of a RAW_BLOCK_ keyword +// (consumes / produces / responses / parameters / extensions / …). +// 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. +func collectRawBlock(in []Token, i int, kw Keyword, out *[]Token) int { + head := in[i] + headPos := head.Pos + i++ + 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. + if head.Text != "" { + bodyText = append(bodyText, head.Text) + bodyRaw = append(bodyRaw, head.Text) + } + + // extensions / infoExtensions bodies are YAML-parsed downstream + // (yaml.TypedExtensions in parser.go), so every body line MUST + // preserve its original indentation. Flat raw blocks (consumes / + // produces / security / …) 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" + bodyLine := func(t Token) string { + if yamlBody { + return strings.TrimRightFunc(t.Raw, unicode.IsSpace) + } + return t.Text + } + + consumed := func() { + // flush any pending blanks into the body so visual separators + // inside list-shaped bodies survive. + for range pendingBlanks { + bodyText = append(bodyText, "") + bodyRaw = append(bodyRaw, "") + } + pendingBlanks = 0 + } + + for i < len(in) { + next := in[i] + switch next.Kind { + case TokenAnnotation: + emitRawBlock(out, headPos, head, kw, bodyText, bodyRaw) + return i + case tokenKeywordPre: + // Sibling structural keyword? — terminate. Keywords that + // could legitimately appear inside the body (e.g. nested + // `default:` under a `Parameters:` block) are absorbed as + // body text. Rule: same family / a sub-context keyword + // is body; another route/operation/meta-context keyword + // is a sibling. + if isSiblingTerminatorFor(kw, next.Name) { + emitRawBlock(out, headPos, head, kw, bodyText, bodyRaw) + return i + } + consumed() + if yamlBody { + bodyText = append(bodyText, strings.TrimRightFunc(next.Raw, unicode.IsSpace)) + } else { + bodyText = append(bodyText, formatKeywordLine(next)) + } + bodyRaw = append(bodyRaw, next.Raw) + i++ + case tokenYAMLFence: + // 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) + continue + } + emitRawBlock(out, headPos, head, kw, bodyText, bodyRaw) + return i + case tokenText: + consumed() + bodyText = append(bodyText, bodyLine(next)) + bodyRaw = append(bodyRaw, next.Raw) + i++ + case tokenRawLine: + consumed() + bodyText = append(bodyText, bodyLine(next)) + bodyRaw = append(bodyRaw, next.Raw) + i++ + case tokenDirective: + // Directives never contribute to body text. + i++ + case TokenBlank: + pendingBlanks++ + i++ + default: + emitRawBlock(out, headPos, head, kw, bodyText, bodyRaw) + return i + } + } + + emitRawBlock(out, headPos, head, kw, bodyText, bodyRaw) + return i +} + +// absorbDecorativeFenceInto consumes raw lines until the matching +// closing fence and appends them into the active body. Fences +// themselves are dropped. Returns the index past the closing fence +// (or len(in) on truncation). +func absorbDecorativeFenceInto(in []Token, i int, bodyText, bodyRaw *[]string) int { + for i < len(in) { + switch in[i].Kind { + case tokenYAMLFence: + return i + 1 + case tokenRawLine: + *bodyText = append(*bodyText, in[i].Text) + *bodyRaw = append(*bodyRaw, in[i].Raw) + default: + // ignored kind + } + i++ + } + return i +} + +// emitRawBlock writes one TokenRawBlockBody to out. headPos/head carry +// items-depth and source-name details forwarded onto the body token. +// A RAW_BLOCK has no closing delimiter — its body ends at the next +// sibling structural keyword or EOF — so there is no truncation +// condition (unlike OPAQUE_YAML, where a missing closing `---` is a +// real failure mode). +func emitRawBlock(out *[]Token, headPos token.Position, head Token, kw Keyword, body, raw []string) { + *out = append(*out, Token{ + Kind: TokenRawBlockBody, + Pos: headPos, + Name: kw.Name, + SourceName: head.SourceName, + Keyword: kw.Name, + Body: strings.Join(body, "\n"), + Raw: strings.Join(raw, "\n"), + ItemsDepth: head.ItemsDepth, + }) +} + +// collectRawValue handles RAW_VALUE_ body keywords (default / +// example / enum). Single-line case (head with non-empty inline value) +// emits one body token immediately; multi-line case scans subsequent +// lines until a sibling terminator. +func collectRawValue(in []Token, i int, kw Keyword, out *[]Token) int { + head := in[i] + headPos := head.Pos + i++ + + // Single-line trivial path. + if head.Text != "" { + *out = append(*out, Token{ + Kind: TokenRawValueBody, + Pos: headPos, + Name: kw.Name, + SourceName: head.SourceName, + Keyword: kw.Name, + Body: head.Text, + Raw: head.Raw, + ItemsDepth: head.ItemsDepth, + }) + return i + } + + // Multi-line block-head path. + var bodyText, bodyRaw []string + pendingBlanks := 0 + consumed := func() { + for range pendingBlanks { + bodyText = append(bodyText, "") + bodyRaw = append(bodyRaw, "") + } + pendingBlanks = 0 + } + + for i < len(in) { + next := in[i] + switch next.Kind { + case TokenAnnotation: + emitRawValue(out, headPos, head, kw, bodyText, bodyRaw) + return i + case tokenKeywordPre: + if isSiblingTerminatorFor(kw, next.Name) { + emitRawValue(out, headPos, head, kw, bodyText, bodyRaw) + return i + } + consumed() + bodyText = append(bodyText, formatKeywordLine(next)) + bodyRaw = append(bodyRaw, next.Raw) + i++ + case tokenYAMLFence: + emitRawValue(out, headPos, head, kw, bodyText, bodyRaw) + return i + case tokenText: + consumed() + bodyText = append(bodyText, next.Text) + bodyRaw = append(bodyRaw, next.Raw) + i++ + case tokenDirective: + // Directives never contribute to body text. + i++ + case TokenBlank: + pendingBlanks++ + i++ + default: + emitRawValue(out, headPos, head, kw, bodyText, bodyRaw) + return i + } + } + + emitRawValue(out, headPos, head, kw, bodyText, bodyRaw) + return i +} + +func emitRawValue(out *[]Token, headPos token.Position, head Token, kw Keyword, body, raw []string) { + *out = append(*out, Token{ + Kind: TokenRawValueBody, + Pos: headPos, + Name: kw.Name, + SourceName: head.SourceName, + Keyword: kw.Name, + Body: strings.Join(body, "\n"), + Raw: strings.Join(raw, "\n"), + ItemsDepth: head.ItemsDepth, + }) +} + +// formatKeywordLine recreates the textual `: ` line for +// a keyword token absorbed into a raw body — line-preserving +// rendering for downstream consumers that read the body as text. +func formatKeywordLine(t Token) string { + name := t.SourceName + if name == "" { + name = t.Name + } + if t.Text == "" { + return name + ":" + } + return name + ": " + t.Text +} + +// isSiblingTerminatorFor decides whether a keyword named `next`, +// encountered while accumulating a body opened by `kw`, is a sibling +// structural terminator (true) or a sub-context keyword that should +// be absorbed as body text (false). +// +// Rule: +// +// - if kw is a meta/route/operation context block (consumes, produces, +// security, securityDefinitions, responses, parameters, extensions, +// externalDocs, infoExtensions, tos, schemes), terminate on any +// sibling that is also a meta/route/operation-context keyword; +// - if kw is a schema body keyword (default, example, enum), +// terminate on any sibling that is a schema-context keyword. +// +// Look-up uses the keyword table's Contexts. See README +// §raw-block-terminators. +func isSiblingTerminatorFor(kw Keyword, nextName string) bool { + nextKw, ok := Lookup(nextName) + if !ok { + return false + } + headFamily := familyOf(kw) + nextFamily := familyOf(nextKw) + for _, hf := range headFamily { + if slices.Contains(nextFamily, hf) { + return true + } + } + return false +} + +// familyOf classifies a keyword into one or more "family" buckets per +// its declared contexts. +func familyOf(kw Keyword) []KeywordContext { + out := make([]KeywordContext, 0, len(kw.Contexts)) + for _, c := range kw.Contexts { + switch c { + case CtxMeta, CtxRoute, CtxOperation: + out = append(out, c) + case CtxSchema, CtxItems, CtxParam, CtxHeader, CtxResponse: + out = append(out, c) + default: + // ignored context + } + } + return out +} + +// finaliseInlineKeyword converts a tokenKeywordPre into a TokenKeyword +// 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. +// The parser unpacks Args to read the typed value. +func finaliseInlineKeyword(t Token, kw Keyword) Token { + value := t.Text + valuePos := t.Pos + valuePos.Column += len(t.SourceName) + 1 + valuePos.Offset += len(t.SourceName) + 1 + + var argTok Token + switch kw.Shape { + case ShapeNumber: + argTok = Token{Kind: TokenNumberValue, Pos: valuePos, Text: value} + case ShapeInt: + argTok = Token{Kind: TokenIntValue, Pos: valuePos, Text: value} + case ShapeBool: + argTok = Token{Kind: TokenBoolValue, Pos: valuePos, Text: value} + case ShapeString: + argTok = Token{Kind: TokenStringValue, Pos: valuePos, Text: value} + case ShapeCommaList: + argTok = Token{Kind: TokenCommaListValue, Pos: valuePos, Text: value} + case ShapeEnumOption: + argTok = Token{Kind: TokenEnumOption, Pos: valuePos, Text: value} + case ShapeNone, ShapeRawBlock, ShapeRawValue: + // Body keywords reach finaliseInlineKeyword only on the + // pathological "head with no inline value but no following + // body" case. Treat the value, if any, as a string token. + argTok = Token{Kind: TokenStringValue, Pos: valuePos, Text: value} + default: + // ignored shape + } + + return Token{ + Kind: TokenKeyword, + Pos: t.Pos, + Name: kw.Name, + SourceName: t.SourceName, + Text: value, + Raw: t.Raw, + ItemsDepth: t.ItemsDepth, + Args: []Token{argTok}, + } +} + +// ----- Stage 3 — prose classifier ------------------------------------------- + +// classifyProse re-types tokenText tokens as TITLE / DESC. +// +// 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. +func classifyProse(in []Token) []Token { + hasAnnotation := false + for _, t := range in { + if t.Kind == TokenAnnotation { + hasAnnotation = true + break + } + } + + out := make([]Token, 0, len(in)) + state := proseStart + for _, t := range in { + if t.Kind != tokenText && t.Kind != TokenBlank { + out = append(out, t) + if t.Kind == TokenAnnotation { + state = proseAfterAnnotation + } else { + state = proseInBody + } + continue + } + // Buffer prose runs; the run-classifier runs at run-end. + out = append(out, t) + _ = state + } + + // Always classify — UnboundBlock-style comments (no swagger + // annotation) still need title/desc classification because the + // schema builder consumes their PreambleTitle/PreambleDescription + // when an interface or alias is referenced indirectly. + return classifyProseRunsInPlace(out, hasAnnotation) +} + +type proseState int + +const ( + proseStart proseState = iota + proseAfterAnnotation + proseInBody +) + +// classifyProseRunsInPlace walks `out` and re-types contiguous runs of +// (tokenText / TokenBlank) into TITLE / DESC. The first prose run is +// split into title + description; 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. +func classifyProseRunsInPlace(out []Token, _ bool) []Token { + firstRun := true + for i := 0; i < len(out); { + if out[i].Kind != tokenText && out[i].Kind != TokenBlank { + i++ + continue + } + j := i + for j < len(out) && (out[j].Kind == tokenText || out[j].Kind == TokenBlank) { + j++ + } + if firstRun { + classifyTitleDescRun(out, i, j) + } else { + retypeRunAs(out, i, j, TokenDesc) + } + firstRun = false + i = j + } + return out +} + +// classifyTitleDescRun applies the four 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 + for k := start; k < end; k++ { + if out[k].Kind == tokenText { + firstText = k + break + } + } + if firstText == -1 { + // Run is all blanks. + retypeRunAs(out, start, end, TokenDesc) + return + } + + // Heuristic 1: blank inside the run splits title (before) / desc + // (after). Only fires when the blank has text AFTER it — a + // trailing blank is a separator between the prose run and the + // next non-prose token (annotation / EOF), not an internal + // title/desc divide. + // + // 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 + } + hasTextAfter := false + for m := k + 1; m < end; m++ { + if out[m].Kind == tokenText { + hasTextAfter = true + break + } + } + if !hasTextAfter { + continue + } + if rest, ok := stripATXHeading(out[firstText].Text); ok { + out[firstText].Text = rest + } + retypeRunAs(out, start, k, TokenTitle) + retypeRunAs(out, k, end, TokenDesc) + return + } + + // Heuristic 2: first prose line ends with Unicode punctuation -> title is line 1. + first := out[firstText].Text + if endsWithPunct(first) { + retypeRunAs(out, start, firstText+1, TokenTitle) + retypeRunAs(out, firstText+1, end, TokenDesc) + return + } + + // Heuristic 3: first line matches a markdown ATX heading -> strip + // marker, title is line 1. + if rest, ok := stripATXHeading(first); ok { + out[firstText].Text = rest + retypeRunAs(out, start, firstText+1, TokenTitle) + retypeRunAs(out, firstText+1, end, TokenDesc) + return + } + + // Heuristic 4: entire run becomes description. + retypeRunAs(out, start, end, TokenDesc) +} + +// retypeRunAs re-types the (text, blank) tokens in [start, end) so +// that text becomes `kind`. Blanks are preserved (kept as TokenBlank) +// because consumers may want paragraph breaks intact between TITLE / +// DESC runs. +func retypeRunAs(out []Token, start, end int, kind TokenKind) { + for k := start; k < end; k++ { + if out[k].Kind == tokenText { + out[k].Kind = kind + } + } +} + +// stripATXHeading recognises a markdown ATX-style heading prefix — +// one or more leading `#` followed by at least one whitespace +// character — and returns the trimmed remainder. Reports false +// when the input doesn't open with `#`. Replaces a regexp. +func stripATXHeading(s string) (rest string, ok bool) { + i := 0 + for i < len(s) && s[i] == '#' { + i++ + } + if i == 0 { + return s, false + } + // Need at least one whitespace separator between the # run and + // the heading text. + if i >= len(s) { + return s, false + } + switch s[i] { + case ' ', '\t', '\n', '\f', '\r', '\v': + default: + return s, false + } + return strings.TrimSpace(s[i+1:]), true +} + +// endsWithPunct reports whether s ends in Unicode punctuation other +// than dash/connector — implementation looks for category Po +// ("punctuation, other") on the last rune. +func endsWithPunct(s string) bool { + s = strings.TrimRightFunc(s, unicode.IsSpace) + if s == "" { + return false + } + r, _ := utf8.DecodeLastRuneInString(s) + return unicode.Is(unicode.Po, r) +} + +// FormatToken renders a token compactly for diagnostics and tests. +// Avoids leaking internal kinds in production output. +func FormatToken(t Token) string { + switch t.Kind { + case TokenAnnotation: + return "ANN(" + t.Name + argSummary(t.Args) + ")" + case TokenKeyword: + return "KW(" + t.Name + argSummary(t.Args) + ")" + case TokenRawBlockBody: + return "RAW_BLOCK_" + strings.ToUpper(t.Keyword) + case TokenRawValueBody: + return "RAW_VALUE_" + strings.ToUpper(t.Keyword) + case TokenOpaqueYaml: + return "OPAQUE_YAML" + default: + if t.Text != "" { + return t.Kind.String() + "(" + strconv.Quote(t.Text) + ")" + } + return t.Kind.String() + } +} + +func argSummary(args []Token) string { + if len(args) == 0 { + return "" + } + parts := make([]string, len(args)) + for i, a := range args { + parts[i] = a.Kind.String() + } + return ":" + strings.Join(parts, ",") +} diff --git a/internal/parsers/grammar/lexer_test.go b/internal/parsers/grammar/lexer_test.go new file mode 100644 index 0000000..03a5667 --- /dev/null +++ b/internal/parsers/grammar/lexer_test.go @@ -0,0 +1,510 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "go/ast" + goparser "go/parser" + "go/token" + "strings" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// parseGoSource parses a Go source file via go/parser, finds the +// first top-level decl with a doc comment, and runs grammar.Parse on +// it. Used to exercise the public *ast.CommentGroup entry point — +// where Go directives like //nolint: appear with their original +// (no-leading-space) shape. +// +//nolint:ireturn // delegates to Parse which returns Block. +func parseGoSource(t *testing.T, src string) Block { + t.Helper() + fset := token.NewFileSet() + file, err := goparser.ParseFile(fset, "fake.go", src, goparser.ParseComments) + require.NoError(t, err) + for _, d := range file.Decls { + switch decl := d.(type) { + case *ast.GenDecl: + if decl.Doc != nil { + return Parse(decl.Doc, fset) + } + case *ast.FuncDecl: + if decl.Doc != nil { + return Parse(decl.Doc, fset) + } + } + } + t.Fatalf("no doc-bearing decl found in fixture") + return nil +} + +// lexString preprocesses a comment block (without leading // markers) and +// runs the full Lex pipeline. Each input line becomes one Line entry. +func lexString(t *testing.T, src string) []Token { + t.Helper() + const likelyLines = 5 + lines := make([]Line, 0, likelyLines) + pos := token.Position{Filename: "test.go", Line: 1, Column: 1} + for i, raw := range strings.Split(src, "\n") { + p := pos + p.Line = 1 + i + lines = append(lines, Line{Text: trimContentPrefix(raw), Raw: raw, Pos: p}) + } + return Lex(lines) +} + +func tokenKinds(toks []Token) []TokenKind { + out := make([]TokenKind, len(toks)) + for i, t := range toks { + out[i] = t.Kind + } + return out +} + +func TestLexer_AnnotationBare(t *testing.T) { + out := lexString(t, "swagger:meta") + require.GreaterOrEqual(t, len(out), 2) + assert.Equal(t, TokenAnnotation, out[0].Kind) + assert.Equal(t, "meta", out[0].Name) + assert.Empty(t, out[0].Args) + assert.Equal(t, TokenEOF, out[len(out)-1].Kind) +} + +func TestLexer_AnnotationTrailingDot(t *testing.T) { + out := lexString(t, "swagger:strfmt uuid.") + require.NotEmpty(t, out) + require.Equal(t, TokenAnnotation, out[0].Kind) + require.Len(t, out[0].Args, 1) + assert.Equal(t, TokenIdentName, out[0].Args[0].Kind) + assert.Equal(t, "uuid", out[0].Args[0].Text) +} + +func TestLexer_AnnotationCaseInsensitiveFirstChar(t *testing.T) { + out := lexString(t, "Swagger:strfmt uuid") + require.NotEmpty(t, out) + assert.Equal(t, TokenAnnotation, out[0].Kind) + assert.Equal(t, "strfmt", out[0].Name) +} + +func TestLexer_RouteWithGodocPrefix(t *testing.T) { + out := lexString(t, "GetPets swagger:route GET /pets pets listPets") + require.NotEmpty(t, out) + assert.Equal(t, TokenAnnotation, out[0].Kind) + assert.Equal(t, "route", out[0].Name) + require.Len(t, out[0].Args, 4) + assert.Equal(t, TokenHTTPMethod, out[0].Args[0].Kind) + assert.Equal(t, "GET", out[0].Args[0].Text) + assert.Equal(t, TokenURLPath, out[0].Args[1].Kind) + assert.Equal(t, "/pets", out[0].Args[1].Text) + assert.Equal(t, TokenIdentName, out[0].Args[2].Kind) + assert.Equal(t, "pets", out[0].Args[2].Text) + assert.Equal(t, TokenIdentName, out[0].Args[3].Kind) + assert.Equal(t, "listPets", out[0].Args[3].Text) +} + +func TestLexer_RouteOnlyGetsGodocPrefix(t *testing.T) { + // Other annotations must NOT accept a leading godoc identifier. + out := lexString(t, "GetPets swagger:operation GET /pets pets listPets") + // Expect a fallthrough text token, not an annotation. + require.NotEmpty(t, out) + assert.NotEqual(t, TokenAnnotation, out[0].Kind) +} + +func TestLexer_KeywordInlineNumber(t *testing.T) { + out := lexString(t, "maximum: 10") + require.NotEmpty(t, out) + assert.Equal(t, TokenKeyword, out[0].Kind) + assert.Equal(t, "maximum", out[0].Name) + require.Len(t, out[0].Args, 1) + assert.Equal(t, TokenNumberValue, out[0].Args[0].Kind) + assert.Equal(t, "10", out[0].Args[0].Text) +} + +func TestLexer_KeywordCaseInsensitiveFirstChar(t *testing.T) { + out := lexString(t, "Maximum: 10") + require.NotEmpty(t, out) + assert.Equal(t, TokenKeyword, out[0].Kind) + assert.Equal(t, "maximum", out[0].Name) + assert.Equal(t, "Maximum", out[0].SourceName) +} + +func TestLexer_ItemsPrefixDepth(t *testing.T) { + out := lexString(t, "items.items.maxLength: 5") + require.NotEmpty(t, out) + assert.Equal(t, TokenKeyword, out[0].Kind) + assert.Equal(t, "maxLength", out[0].Name) + assert.Equal(t, 2, out[0].ItemsDepth) +} + +func TestLexer_RawBlockConsumes(t *testing.T) { + out := lexString(t, "Consumes:\n - application/json\n - application/xml") + require.NotEmpty(t, out) + assert.Equal(t, TokenRawBlockBody, out[0].Kind) + assert.Equal(t, "consumes", out[0].Keyword) + assert.Contains(t, out[0].Body, "application/json") + assert.Contains(t, out[0].Body, "application/xml") +} + +func TestLexer_RawBlockTerminatedBySibling(t *testing.T) { + src := strings.Join([]string{ + "Consumes:", + " - application/json", + "Produces:", + " - application/json", + }, "\n") + out := lexString(t, src) + require.GreaterOrEqual(t, len(out), 3) + assert.Equal(t, TokenRawBlockBody, out[0].Kind) + assert.Equal(t, "consumes", out[0].Keyword) + assert.Equal(t, TokenRawBlockBody, out[1].Kind) + assert.Equal(t, "produces", out[1].Keyword) +} + +func TestLexer_OpaqueYamlFenced(t *testing.T) { + src := strings.Join([]string{ + "swagger:operation GET /pets pets listPets", + "---", + "parameters:", + " - name: id", + "---", + }, "\n") + out := lexString(t, src) + + // Find the OpaqueYaml token. + var yamlTok *Token + for i := range out { + if out[i].Kind == TokenOpaqueYaml { + yamlTok = &out[i] + break + } + } + require.NotNil(t, yamlTok) + assert.Contains(t, yamlTok.Body, "parameters:") + assert.Contains(t, yamlTok.Body, "name: id") + assert.False(t, yamlTok.Truncated) +} + +func TestLexer_OpaqueYamlTruncated(t *testing.T) { + src := strings.Join([]string{ + "swagger:operation GET /pets pets listPets", + "---", + "parameters:", + }, "\n") + out := lexString(t, src) + + var yamlTok *Token + for i := range out { + if out[i].Kind == TokenOpaqueYaml { + yamlTok = &out[i] + break + } + } + require.NotNil(t, yamlTok) + assert.True(t, yamlTok.Truncated) +} + +func TestLexer_RawValueDefaultInline(t *testing.T) { + out := lexString(t, "default: hello") + require.NotEmpty(t, out) + assert.Equal(t, TokenRawValueBody, out[0].Kind) + assert.Equal(t, "default", out[0].Keyword) + assert.Equal(t, "hello", out[0].Body) +} + +func TestLexer_RawValueDefaultMultiline(t *testing.T) { + src := strings.Join([]string{ + "default:", + " one", + " two", + }, "\n") + out := lexString(t, src) + require.NotEmpty(t, out) + assert.Equal(t, TokenRawValueBody, out[0].Kind) + assert.Equal(t, "default", out[0].Keyword) + assert.Contains(t, out[0].Body, "one") + assert.Contains(t, out[0].Body, "two") +} + +func TestLexer_TitleDescriptionSplit_PunctuationHeuristic(t *testing.T) { + src := strings.Join([]string{ + "A pet in the store.", + "With a longer follow-up paragraph", + "that spans multiple lines.", + "swagger:model Pet", + }, "\n") + out := lexString(t, src) + + titles := collectKind(out, TokenTitle) + descs := collectKind(out, TokenDesc) + require.NotEmpty(t, titles) + require.NotEmpty(t, descs) + assert.Equal(t, "A pet in the store.", titles[0].Text) + assert.Contains(t, descs[0].Text+" "+descs[1].Text, "longer follow-up") +} + +func TestLexer_TitleDescriptionSplit_BlankLineHeuristic(t *testing.T) { + src := strings.Join([]string{ + "A short title", + "", + "And a description following a blank line.", + "swagger:model Pet", + }, "\n") + out := lexString(t, src) + + titles := collectKind(out, TokenTitle) + descs := collectKind(out, TokenDesc) + require.NotEmpty(t, titles) + require.NotEmpty(t, descs) + assert.Equal(t, "A short title", titles[0].Text) +} + +func TestLexer_UnboundBlockClassifiesTitle(t *testing.T) { + // UnboundBlocks (no swagger annotation) still get title/desc + // classification — v1's helpers.CollectScannerTitleDescription + // applied the same heuristics regardless of annotation presence, + // and downstream consumers (e.g. the schema builder when a + // non-annotated interface is referenced via $ref) rely on + // PreambleTitle being populated. + out := lexString(t, "Name of the user.\nrequired: true") + titles := collectKind(out, TokenTitle) + require.NotEmpty(t, titles, "first prose line ending in punct should be TITLE") + assert.Equal(t, "Name of the user.", titles[0].Text) +} + +func TestLexer_DefaultAnnotation_JsonValue(t *testing.T) { + out := lexString(t, `swagger:default {"x": 1}`) + require.NotEmpty(t, out) + require.Equal(t, TokenAnnotation, out[0].Kind) + require.Len(t, out[0].Args, 1) + assert.Equal(t, TokenJSONValue, out[0].Args[0].Kind) +} + +func TestLexer_DefaultAnnotation_RawFallback(t *testing.T) { + out := lexString(t, "swagger:default high") + require.NotEmpty(t, out) + require.Equal(t, TokenAnnotation, out[0].Kind) + require.Len(t, out[0].Args, 1) + assert.Equal(t, TokenRawValue, out[0].Args[0].Kind) +} + +func TestLexer_TypeAnnotation_ClosedVocabulary(t *testing.T) { + out := lexString(t, "swagger:type string") + require.NotEmpty(t, out) + require.Len(t, out[0].Args, 1) + assert.Equal(t, TokenTypeRef, out[0].Args[0].Kind) + assert.Equal(t, "string", out[0].Args[0].Text) + + out2 := lexString(t, "swagger:type custom") + require.NotEmpty(t, out2) + require.Len(t, out2[0].Args, 1) + assert.Equal(t, TokenIdentName, out2[0].Args[0].Kind, "unknown type tokens fall back to IDENT_NAME for analyzer diagnosis") +} + +func TestLexer_EnumAnnotation_NameOnly(t *testing.T) { + out := lexString(t, "swagger:enum Priority") + require.NotEmpty(t, out) + require.Len(t, out[0].Args, 1) + assert.Equal(t, TokenIdentName, out[0].Args[0].Kind) + assert.Equal(t, "Priority", out[0].Args[0].Text) +} + +func TestLexer_EnumAnnotation_PlainListNoName(t *testing.T) { + out := lexString(t, "swagger:enum 1, 2, 3") + require.NotEmpty(t, out) + require.Len(t, out[0].Args, 1) + assert.Equal(t, TokenCommaListValue, out[0].Args[0].Kind) +} + +func TestLexer_EnumAnnotation_NamePlusBracketed(t *testing.T) { + out := lexString(t, "swagger:enum kind [a, b, c]") + require.NotEmpty(t, out) + require.Len(t, out[0].Args, 2) + assert.Equal(t, TokenIdentName, out[0].Args[0].Kind) + assert.Equal(t, "kind", out[0].Args[0].Text) + assert.Equal(t, TokenJSONValue, out[0].Args[1].Kind) +} + +func TestLexer_BlankLinePreserved(t *testing.T) { + out := lexString(t, "swagger:meta\n\nVersion: 1") + // At least one BLANK between annotation and keyword. + hasBlank := false + for _, k := range tokenKinds(out) { + if k == TokenBlank { + hasBlank = true + } + } + assert.True(t, hasBlank) +} + +func TestLexer_DecorativeFenceInExtensions(t *testing.T) { + src := strings.Join([]string{ + "Extensions:", + "---", + "x-foo: bar", + "---", + }, "\n") + out := lexString(t, src) + require.NotEmpty(t, out) + assert.Equal(t, TokenRawBlockBody, out[0].Kind) + assert.Equal(t, "extensions", out[0].Keyword) + assert.Contains(t, out[0].Body, "x-foo: bar") +} + +// collectKind returns the subset of out whose Kind matches. +func collectKind(out []Token, k TokenKind) []Token { + var found []Token + for _, t := range out { + if t.Kind == k { + found = append(found, t) + } + } + return found +} + +func TestLexer_TrailingWhitespaceOnAnnotationLine(t *testing.T) { + out := lexString(t, "swagger:strfmt uuid ") + require.NotEmpty(t, out) + assert.Equal(t, TokenAnnotation, out[0].Kind) + require.Len(t, out[0].Args, 1) + assert.Equal(t, "uuid", out[0].Args[0].Text) +} + +func TestLexer_TrailingWhitespaceOnKeywordLine(t *testing.T) { + out := lexString(t, "maximum: 10 \t") + require.NotEmpty(t, out) + assert.Equal(t, TokenKeyword, out[0].Kind) + require.Len(t, out[0].Args, 1) + assert.Equal(t, "10", out[0].Args[0].Text) +} + +func TestLexer_TrailingNonASCIIWhitespace(t *testing.T) { + // U+00A0 NO-BREAK SPACE, U+2028 LINE SEPARATOR — TrimRightFunc with + // unicode.IsSpace must strip them; TrimRight on " \t" alone would + // leave them attached. + out := lexString(t, "swagger:strfmt uuid 
") + require.NotEmpty(t, out) + assert.Equal(t, TokenAnnotation, out[0].Kind) + require.Len(t, out[0].Args, 1) + assert.Equal(t, "uuid", out[0].Args[0].Text) +} + +func TestLexer_WhitespaceOnlyLineIsBlank(t *testing.T) { + // `// \t ` style line — strips to empty, must surface as BLANK. + out := lexString(t, "swagger:meta\n \t \nVersion: 1") + hasBlank := false + for _, k := range tokenKinds(out) { + if k == TokenBlank { + hasBlank = true + } + } + assert.True(t, hasBlank) +} + +func TestLexer_GoDirectivesDroppedFromProse(t *testing.T) { + cases := []string{ + "nolint:gocritic", + "go:generate stringer -type=Foo", + "lint:ignore U1000 unused field", + "staticcheck:foo", + } + for _, raw := range cases { + assert.True(t, isGoDirective(raw), "expected %q to be a directive", raw) + } + + notDirectives := []string{ + " nolint:gocritic", // leading space (idiomatic prose) + "NoLint:foo", // uppercase first char + "foo bar:baz", // contains space before colon + "description without a colon", + "required: true", // keyword: space after colon + "maximum: 10", // keyword: space after colon + "pattern:", // block head: empty after colon + "version: 1.0.0", // keyword: space after colon + } + for _, raw := range notDirectives { + assert.False(t, isGoDirective(raw), "did not expect %q to be a directive", raw) + } + + // `swagger:model Pet` matches the directive shape per isGoDirective + // in isolation — the swagger annotation check runs first in + // lexLine, so this never reaches the directive filter at runtime. + assert.True(t, isGoDirective("swagger:model"), + "swagger annotations match the directive shape — lexLine special-cases them upstream") +} + +func TestLexer_DirectiveDoesNotPolluteTitle(t *testing.T) { + // Source: a docstring with an embedded //nolint directive. The + // title/description surface must not include the directive. + src := strings.Join([]string{ + "A pet in the store.", + "", + "With a longer description.", + "swagger:model Pet", + }, "\n") + cleanOut := lexString(t, src) + cleanTitles := collectKind(cleanOut, TokenTitle) + cleanDescs := collectKind(cleanOut, TokenDesc) + + srcWithDirective := strings.Join([]string{ + "A pet in the store.", + "", + "With a longer description.", + // Simulate the post-`//` content of `//nolint:revive`. + // preprocessText feeds it as Raw verbatim via our lexString helper: + // trimContentPrefix strips the leading `/` chars only on Text. + "swagger:model Pet", + }, "\n") + out := lexString(t, srcWithDirective) + titles := collectKind(out, TokenTitle) + descs := collectKind(out, TokenDesc) + + // The two streams must classify the same prose surface. + assert.Equal(t, len(cleanTitles), len(titles)) + assert.Equal(t, len(cleanDescs), len(descs)) + + // Direct directive presence test via the public Parse() path: a + // CommentGroup with a //nolint line interleaved into a docstring + // must not surface "nolint:" anywhere in Title / Description. + srcGo := `package fake + +// A pet in the store. +// +//nolint:revive +// +// With a longer description. +// +// swagger:model Pet +type Pet struct{} +` + b := parseGoSource(t, srcGo) + mb, ok := b.(*ModelBlock) + require.True(t, ok, "expected *ModelBlock, got %T", b) + assert.NotContains(t, mb.Title(), "nolint") + assert.NotContains(t, mb.Description(), "nolint") + assert.Equal(t, "A pet in the store.", mb.Title()) + assert.Equal(t, "With a longer description.", mb.Description()) +} + +func TestLexer_DirectiveDroppedInsideRawBlock(t *testing.T) { + src := `package fake + +// swagger:meta +// +// Consumes: +// - application/json +//nolint:gocritic +// - application/xml +type _ struct{} +` + b := parseGoSource(t, src) + cons, ok := b.GetList("consumes") + require.True(t, ok) + joined := strings.Join(cons, "\n") + assert.Contains(t, joined, "application/json") + assert.Contains(t, joined, "application/xml") + assert.NotContains(t, joined, "nolint") +} diff --git a/internal/parsers/grammar/token.go b/internal/parsers/grammar/token.go new file mode 100644 index 0000000..eb742be --- /dev/null +++ b/internal/parsers/grammar/token.go @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import "go/token" + +// TokenKind classifies a lexer-emitted token. The kinds map onto +// the grammar's terminal vocabulary: +// +// - ANN_* → TokenAnnotation, Name carries the annotation label +// - KW_* → TokenKeyword, Name carries the keyword label +// - IDENT_NAME, JSON_VALUE, RAW_VALUE, TYPE_REF, HTTP_METHOD, +// URL_PATH → distinct kinds, payload in Text +// - NUMBER_VALUE / INT_VALUE / BOOL_VALUE / STRING_VALUE / +// COMMA_LIST_VALUE / ENUM_OPTION_VALUE → distinct kinds +// - RAW_BLOCK_ → TokenRawBlockBody with Keyword = "consumes"/"produces"/… +// - RAW_VALUE_ → TokenRawValueBody with Keyword = "default"/"example"/"enum" +// - OPAQUE_YAML → TokenOpaqueYaml +// - TITLE / DESC / BLANK / EOF → TokenTitle / TokenDesc / TokenBlank / TokenEOF +// +// The lexer also emits a few internal kinds (tokenYAMLFence, +// tokenText, tokenKeywordPre, tokenRawLine, tokenDirective) that +// are consumed by intermediate stages and never appear in the +// final stream the parser consumes. +type TokenKind int + +const ( + TokenEOF TokenKind = iota + + TokenBlank + TokenTitle + TokenDesc + + TokenAnnotation // ANN_* — Name = annotation label + TokenKeyword // KW_* — Name = keyword label + TokenIdentName // IDENT_NAME + TokenJSONValue // JSON_VALUE + TokenRawValue // RAW_VALUE + TokenTypeRef // TYPE_REF + TokenHTTPMethod // HTTP_METHOD + TokenURLPath // URL_PATH + TokenNumberValue // NUMBER_VALUE + TokenIntValue // INT_VALUE + TokenBoolValue // BOOL_VALUE + TokenStringValue // STRING_VALUE + TokenCommaListValue // COMMA_LIST_VALUE + TokenEnumOption // ENUM_OPTION_VALUE + TokenRawBlockBody // RAW_BLOCK_ — Name = parent keyword + TokenRawValueBody // RAW_VALUE_ — Name = parent keyword + TokenOpaqueYaml // OPAQUE_YAML + + // Internal kinds used between pipeline stages; never reach the parser. + tokenYAMLFence // "---" delimiter recognised by the line classifier + tokenText // free-form prose line (re-typed to TITLE/DESC by prose classifier) + tokenKeywordPre // tentative keyword line (head + value-string), pre body-accumulation + tokenRawLine // raw line accumulated inside a YAML fence or raw block + tokenDirective // Go compiler/linter directive (//go:, //nolint:, …) — dropped before parsing +) + +// String renders a TokenKind for diagnostics. +func (k TokenKind) String() string { + switch k { + case TokenEOF: + return "EOF" + case TokenBlank: + return "BLANK" + case TokenTitle: + return "TITLE" + case TokenDesc: + return "DESC" + case TokenAnnotation: + return "ANN" + case TokenKeyword: + return "KW" + case TokenIdentName: + return "IDENT_NAME" + case TokenJSONValue: + return "JSON_VALUE" + case TokenRawValue: + return "RAW_VALUE" + case TokenTypeRef: + return "TYPE_REF" + case TokenHTTPMethod: + return "HTTP_METHOD" + case TokenURLPath: + return "URL_PATH" + case TokenNumberValue: + return "NUMBER_VALUE" + case TokenIntValue: + return "INT_VALUE" + case TokenBoolValue: + return "BOOL_VALUE" + case TokenStringValue: + return "STRING_VALUE" + case TokenCommaListValue: + return "COMMA_LIST_VALUE" + case TokenEnumOption: + return "ENUM_OPTION" + case TokenRawBlockBody: + return "RAW_BLOCK" + case TokenRawValueBody: + return "RAW_VALUE_BODY" + case TokenOpaqueYaml: + return "OPAQUE_YAML" + case tokenYAMLFence: + return "yaml-fence" + case tokenText: + return "text" + case tokenKeywordPre: + return "kw-pre" + case tokenRawLine: + return "raw-line" + case tokenDirective: + return "directive" + default: + return "?" + } +} + +// Token is one lexer-emitted item. Field population varies by kind: +// +// - TokenAnnotation: Name = annotation label, Args are pre-tokenised +// argument tokens already disambiguated by the lexer +// (IDENT_NAME, JSON_VALUE, RAW_VALUE, TYPE_REF, HTTP_METHOD, +// URL_PATH, etc. — see README §annotation-args). +// - TokenKeyword: Name = canonical keyword label; SourceName = the +// literal as it appeared (case / alias preserved); ItemsDepth = +// number of leading "items." segments stripped. +// - TokenRawBlockBody / TokenRawValueBody: Keyword = the parent +// keyword; Body = body content joined by "\n"; Raw = verbatim +// source-indented content; ItemsDepth carried from the head if +// applicable. +// - TokenOpaqueYaml: Body = body content (between fences). +// - TokenIdentName / value-typed tokens: Text = payload string. +// - TokenTitle / TokenDesc: Text = prose line content. +// +// Pos is the source position of the first meaningful payload character. +type Token struct { + Kind TokenKind + Pos token.Position + + Name string // for TokenAnnotation / TokenKeyword: canonical label + SourceName string // keyword: literal as written (alias / case preserved) + Text string // payload for value tokens, prose lines, idents + Args []Token // for TokenAnnotation: positional argument tokens + ItemsDepth int // leading "items." depth (keyword tokens only) + Keyword string // for body tokens: parent keyword name + Body string // for body tokens: body content joined by "\n" + Raw string // verbatim source content (indentation preserved) + Truncated bool // body lexed without a closer (e.g. unmatched ---) +} + +// HasArg reports whether the annotation token has at least n positional +// arguments. Convenience used by the parser dispatchers. +func (t Token) HasArg(n int) bool { return len(t.Args) >= n } From 05a6995cc9aee990f8861b78af12094901cc12b3 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:36:32 +0200 Subject: [PATCH 03/22] feat(grammar): recursive-descent parser + typed Block family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-down parser consuming the lexer's token stream. Dispatches into a typed Block family — SchemaBlock, ParameterBlock, ResponseBlock, OperationBlock, MetaBlock, RouteBlock, NameBlock, ModelBlock, EnumBlock — each exposing typed accessors for its annotation arg, keyword properties, body, and embedded sub-languages. NewParser returns a Parser with optional WithDiagnosticSink. The ParseAll entry point yields one Block per annotation on the comment group; ParseBlock is the convenience for the primary one. Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- internal/parsers/grammar/annotations.go | 166 ++++ internal/parsers/grammar/ast.go | 572 +++++++++++++ internal/parsers/grammar/disambiguate.go | 204 +++++ internal/parsers/grammar/doc.go | 35 + internal/parsers/grammar/fixtures_test.go | 812 +++++++++++++++++++ internal/parsers/grammar/meta_info.go | 99 +++ internal/parsers/grammar/parser.go | 941 ++++++++++++++++++++++ internal/parsers/grammar/parser_test.go | 483 +++++++++++ internal/parsers/grammar/synthetic.go | 33 + 9 files changed, 3345 insertions(+) create mode 100644 internal/parsers/grammar/annotations.go create mode 100644 internal/parsers/grammar/ast.go create mode 100644 internal/parsers/grammar/disambiguate.go create mode 100644 internal/parsers/grammar/doc.go create mode 100644 internal/parsers/grammar/fixtures_test.go create mode 100644 internal/parsers/grammar/meta_info.go create mode 100644 internal/parsers/grammar/parser.go create mode 100644 internal/parsers/grammar/parser_test.go create mode 100644 internal/parsers/grammar/synthetic.go diff --git a/internal/parsers/grammar/annotations.go b/internal/parsers/grammar/annotations.go new file mode 100644 index 0000000..1cbe2e8 --- /dev/null +++ b/internal/parsers/grammar/annotations.go @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +// AnnotationPrefix is the literal that introduces every codescan +// annotation header. Centralised so callers and tests reference the +// single source of truth rather than the bare literal. +const AnnotationPrefix = "swagger:" + +// AnnotationKind identifies the top-level swagger: directive. +type AnnotationKind int + +const ( + AnnUnknown AnnotationKind = iota + + AnnModel // swagger:model + AnnResponse // swagger:response + AnnParameters // swagger:parameters + AnnRoute // swagger:route + AnnOperation // swagger:operation + AnnMeta // swagger:meta + AnnStrfmt // swagger:strfmt + AnnAlias // swagger:alias + AnnName // swagger:name + AnnAllOf // swagger:allOf + AnnEnum // swagger:enum + AnnIgnore // swagger:ignore + AnnDefaultName // swagger:default — value-only classifier annotation + AnnType // swagger:type + AnnFile // swagger:file +) + +const ( + labelModel = "model" + labelResponse = "response" + labelParameters = "parameters" + labelRoute = "route" + labelOperation = "operation" + labelMeta = "meta" + labelStrfmt = "strfmt" + labelAlias = "alias" + labelName = "name" + labelAllOf = "allOf" + labelEnum = "enum" + labelIgnore = "ignore" + labelDefault = "default" + labelType = "type" + labelFile = "file" + labelUnknown = "unknown" +) + +// String renders an AnnotationKind as its source label. +func (a AnnotationKind) String() string { + switch a { + case AnnModel: + return labelModel + case AnnResponse: + return labelResponse + case AnnParameters: + return labelParameters + case AnnRoute: + return labelRoute + case AnnOperation: + return labelOperation + case AnnMeta: + return labelMeta + case AnnStrfmt: + return labelStrfmt + case AnnAlias: + return labelAlias + case AnnName: + return labelName + case AnnAllOf: + return labelAllOf + case AnnEnum: + return labelEnum + case AnnIgnore: + return labelIgnore + case AnnDefaultName: + return labelDefault + case AnnType: + return labelType + case AnnFile: + return labelFile + case AnnUnknown: + fallthrough + default: + return labelUnknown + } +} + +// AnnotationKindFromName resolves the swagger: label to its kind. +// Returns AnnUnknown for labels outside the recognised set. +func AnnotationKindFromName(name string) AnnotationKind { + switch name { + case labelModel: + return AnnModel + case labelResponse: + return AnnResponse + case labelParameters: + return AnnParameters + case labelRoute: + return AnnRoute + case labelOperation: + return AnnOperation + case labelMeta: + return AnnMeta + case labelStrfmt: + return AnnStrfmt + case labelAlias: + return AnnAlias + case labelName: + return AnnName + case labelAllOf: + return AnnAllOf + case labelEnum: + return AnnEnum + case labelIgnore: + return AnnIgnore + case labelDefault: + return AnnDefaultName + case labelType: + return AnnType + case labelFile: + return AnnFile + default: + return AnnUnknown + } +} + +// annotationFamily classifies an AnnotationKind into one of the four +// family sub-grammars. Used by the parser dispatcher. +type annotationFamily int + +const ( + familyUnknown annotationFamily = iota + familySchema + familyOperation + familyMeta + familyClassifier +) + +func (a AnnotationKind) family() annotationFamily { + switch a { + case AnnModel, AnnResponse, AnnParameters, + // swagger:name is a field-level rename that accepts the same + // validation-keyword body as a schema field (min length, pattern, + // required, etc.). 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: + return familySchema + case AnnRoute, AnnOperation: + return familyOperation + case AnnMeta: + return familyMeta + case AnnStrfmt, AnnAlias, AnnAllOf, AnnEnum, + AnnIgnore, AnnDefaultName, AnnType, AnnFile: + return familyClassifier + case AnnUnknown: + fallthrough + default: + return familyUnknown + } +} diff --git a/internal/parsers/grammar/ast.go b/internal/parsers/grammar/ast.go new file mode 100644 index 0000000..cf947d7 --- /dev/null +++ b/internal/parsers/grammar/ast.go @@ -0,0 +1,572 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "go/token" + "iter" + "strings" + + "github.com/go-openapi/codescan/internal/parsers/security" +) + +// Block is the interface every typed AST node implements. One Block +// corresponds to one Go comment group's parsed content. +// +// The typed Block hierarchy matches the family productions: +// +// - SchemaBlock variants: ModelBlock, ResponseBlock, ParametersBlock, NameBlock +// - OperationFamilyBlock: RouteBlock, InlineOperationBlock +// - MetaBlock +// - ClassifierBlock variants for the classifier annotations +// - UnboundBlock for the no-annotation case +// +// # Details +// +// See README §parser-contract and §block-shapes for the +// per-Block contracts. +// +//nolint:interfacebloat // single consumer contract for builders + LSP. +type Block interface { + Pos() token.Position + Title() string + Description() string + Diagnostics() []Diagnostic + AnnotationKind() AnnotationKind + + Properties() iter.Seq[Property] + YAMLBlocks() iter.Seq[RawYAML] + Extensions() iter.Seq[Extension] + SecurityRequirements() []security.Requirement + Contact() (Contact, error) + License() (License, bool) + + // Walk dispatches properties / prose / extensions / diagnostics + // through the callbacks set on w. See walker.go for the contract. + Walk(w Walker) + + // ProseLines returns the cleaned prose lines (TITLE + DESC) in + // source order, with blank lines preserved as empty strings. + ProseLines() []string + + // PreambleLines returns prose lines that appear BEFORE the block's + // annotation. For UnboundBlock (no annotation), returns the same as + // ProseLines. 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() 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. + // + // Used by field-level callers (struct field / interface method + // docs) where the entire prose is the description and there's + // no separate title concept. + Prose() string + + Has(name string) bool + GetFloat(name string) (float64, bool) + GetInt(name string) (int64, bool) + GetBool(name string) (bool, bool) + GetString(name string) (string, bool) + GetList(name string) ([]string, bool) + + // AnnotationArg returns the first positional identifier argument + // of the block's primary annotation (e.g. "Pet" for + // `swagger:model Pet`, "date-time" for `swagger:strfmt + // date-time`, the IDENT_NAME for `swagger:name fooBar`). + // Returns ("", false) when the annotation has no arg or carries + // only an empty/whitespace one. Bare annotations (e.g. + // `swagger:model` without a name) return ("", false); callers + // distinguish "annotation present" via AnnotationKind(). + // + // Convergence accessor — replaces type-asserting on each typed + // Block kind to read its Name / Args[0] field. Used by Walker + // callbacks that don't care which classifier flavour they're + // looking at, only what its IDENT_NAME-style argument is. + // 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 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. +type Property struct { + Keyword Keyword + Pos token.Position + Value string + Body string + Raw string + Typed TypedValue + ItemsDepth int +} + +// IsTyped reports whether the property carries a primitive-typed +// value the caller can consume directly without further coercion. +// +// True when Typed.Type is one of the primitive shapes — Number, +// Integer, Bool, EnumOption — meaning the matching Typed.* field is +// populated and authoritative. False otherwise: +// +// - ShapeNone — typing was not applied (ShapeString keywords like +// `pattern` keep the raw value in Property.Value), or typing +// failed and a diagnostic was emitted. +// - ShapeRawBlock / ShapeRawValue / ShapeCommaList / ShapeString — +// the value's interpretation depends on the resolved spec type +// (e.g. `default: 1.5` against a float-typed schema), so the +// consumer must coerce against schemaType+schemaFormat. +// +// Canonical use site: +// +// if p.IsTyped() { +// // read p.Typed. matching p.Keyword.Shape +// } else { +// // coerce p.Value against the resolved schema type +// } +// +// Replaces the explicit `switch p.Typed.Type` boilerplate consumers +// would otherwise need at every call site. +func (p Property) IsTyped() bool { + switch p.Typed.Type { + case ShapeNumber, ShapeInt, ShapeBool, ShapeEnumOption: + return true + case ShapeNone, ShapeString, ShapeCommaList, ShapeRawBlock, ShapeRawValue: + return false + default: + return false + } +} + +// TypedValue carries the lexer-recognised value shape. +// +// Op is the leading comparison operator stripped from a NumberValue +// ("<", "<=", ">", ">=", "="). Empty for non-Number values or when +// no operator was present. Accepts e.g. `maximum: <5`; the analyzer +// interprets Op + Number to decide inclusive vs exclusive semantics. +type TypedValue struct { + Type ValueShape + Op string + Number float64 + Integer int64 + Boolean bool + String string +} + +// RawYAML is one captured `--- … ---` body. The parser does not parse +// the YAML — it isolates the body so the analyzer can hand it to +// internal/parsers/yaml/. +type RawYAML struct { + Pos token.Position + Text string + Truncated bool +} + +// Extension is one x-* vendor entry under an extensions: block. +// +// Value carries the YAML-typed nested value (`bool` / `float64` / +// `string` / `[]any` / `map[string]any`). The parser pipes the body +// through `internal/parsers/yaml.TypedExtensions`, so consumers can +// rely on JSON-normalised types — never `map[any]any`. +// +// 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 + Pos token.Position + Value any +} + +// baseBlock provides the fields and accessors common to every typed +// Block. Typed kinds embed *baseBlock and add per-annotation fields. +type baseBlock struct { + pos token.Position + title string + description string + proseLines []string + preambleLines []string + preambleTitle string + preambleDescription string + kind AnnotationKind + + properties []Property + yamlBlocks []RawYAML + extensions []Extension + security []security.Requirement + diagnostics []Diagnostic +} + +func (b *baseBlock) Pos() token.Position { return b.pos } +func (b *baseBlock) Title() string { return b.title } +func (b *baseBlock) Description() string { return b.description } +func (b *baseBlock) ProseLines() []string { return b.proseLines } +func (b *baseBlock) PreambleLines() []string { return b.preambleLines } +func (b *baseBlock) PreambleTitle() string { return b.preambleTitle } +func (b *baseBlock) PreambleDescription() string { return b.preambleDescription } + +// Prose joins the cached proseLines (TITLE + DESC + internal +// blanks, in source order) with "\n" and drops a single +// whitespace-only trailing line. The trim and join happen on +// the cached slice; no extra parse work. +func (b *baseBlock) Prose() string { + lines := b.proseLines + if n := len(lines); n > 0 && strings.TrimSpace(lines[n-1]) == "" { + lines = lines[:n-1] + } + return strings.Join(lines, "\n") +} +func (b *baseBlock) Diagnostics() []Diagnostic { return b.diagnostics } +func (b *baseBlock) AnnotationKind() AnnotationKind { return b.kind } + +// AnnotationArg default — Block kinds whose annotation takes no +// identifier argument (UnboundBlock, MetaBlock, RouteBlock, +// InlineOperationBlock, ParametersBlock — the latter has multiple +// args but conceptually it's a list, not a single IDENT_NAME). +// Typed kinds with a single IDENT_NAME override this method. +func (b *baseBlock) AnnotationArg() (string, bool) { return "", false } + +func (b *baseBlock) Properties() iter.Seq[Property] { + return func(yield func(Property) bool) { + for _, p := range b.properties { + if !yield(p) { + return + } + } + } +} + +func (b *baseBlock) YAMLBlocks() iter.Seq[RawYAML] { + return func(yield func(RawYAML) bool) { + for _, y := range b.yamlBlocks { + if !yield(y) { + return + } + } + } +} + +func (b *baseBlock) Extensions() iter.Seq[Extension] { + return func(yield func(Extension) bool) { + for _, e := range b.extensions { + if !yield(e) { + return + } + } + } +} + +// SecurityRequirements returns the typed list of `Security:` +// requirements parsed at lex time from the block's `security:` raw +// body, or nil when no `security:` keyword appeared. Each entry is +// a single-key map from scheme name → scope list, mirroring the +// shape OAS v2 expects on `spec.Operation.Security`. +func (b *baseBlock) SecurityRequirements() []security.Requirement { + return b.security +} + +// Contact returns the typed Contact value parsed from the block's +// `contact:` inline keyword. Returns (Contact{}, nil) when no +// `contact:` appeared, or when its value is empty. A non-nil error +// signals a malformed `Name ` head — the caller decides +// whether to fail or warn. Parses on call. +func (b *baseBlock) Contact() (Contact, error) { + for _, p := range b.properties { + if p.Keyword.Name == KwContact { + return parseContact(p.Value) + } + } + return Contact{}, nil +} + +// License returns the typed License value parsed from the block's +// `license:` inline keyword, or (License{}, false) when no +// `license:` keyword appeared. Parses on call. +func (b *baseBlock) License() (License, bool) { + for _, p := range b.properties { + if p.Keyword.Name == KwLicense { + return parseLicense(p.Value) + } + } + return License{}, false +} + +func (b *baseBlock) Has(name string) bool { + _, ok := b.findProperty(name) + return ok +} + +func (b *baseBlock) GetFloat(name string) (float64, bool) { + p, ok := b.findProperty(name) + if !ok || p.Typed.Type != ShapeNumber { + return 0, false + } + return p.Typed.Number, true +} + +func (b *baseBlock) GetInt(name string) (int64, bool) { + p, ok := b.findProperty(name) + if !ok || p.Typed.Type != ShapeInt { + return 0, false + } + return p.Typed.Integer, true +} + +func (b *baseBlock) GetBool(name string) (bool, bool) { + p, ok := b.findProperty(name) + if !ok || p.Typed.Type != ShapeBool { + return false, false + } + return p.Typed.Boolean, true +} + +func (b *baseBlock) GetString(name string) (string, bool) { + p, ok := b.findProperty(name) + if !ok { + return "", false + } + if p.Typed.Type == ShapeEnumOption { + return p.Typed.String, true + } + return p.Value, true +} + +// 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. +func (b *baseBlock) GetList(name string) ([]string, bool) { + p, ok := b.findProperty(name) + if !ok { + return nil, false + } + 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). +// +// # 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 +// bodies). +func (p Property) AsList() []string { + var lines []string + if p.Value != "" { + lines = append(lines, p.Value) + } + if p.Body != "" { + lines = append(lines, strings.Split(p.Body, "\n")...) + } + var out []string + for _, line := range lines { + line = strings.TrimSpace(line) + line = strings.TrimPrefix(line, "- ") + line = strings.TrimSpace(line) + if line == "" { + continue + } + for tok := range strings.SplitSeq(line, ",") { + if tok = strings.TrimSpace(tok); tok != "" { + out = append(out, tok) + } + } + } + return out +} + +func (b *baseBlock) findProperty(name string) (Property, bool) { + for _, p := range b.properties { + if strings.EqualFold(p.Keyword.Name, name) { + return p, true + } + for _, alias := range p.Keyword.Aliases { + if strings.EqualFold(alias, name) { + return p, true + } + } + } + return Property{}, false +} + +// --- Typed Block kinds ------------------------------------------------------- + +// ModelBlock is produced by `swagger:model [Name]`. +type ModelBlock struct { + *baseBlock + + Name string +} + +// AnnotationArg returns the IDENT_NAME arg (`Pet` for +// `swagger:model Pet`) or ("", false) for a bare `swagger:model`. +func (b *ModelBlock) AnnotationArg() (string, bool) { + if b.Name == "" { + return "", false + } + return b.Name, true +} + +// ResponseBlock is produced by `swagger:response [Name]`. +type ResponseBlock struct { + *baseBlock + + Name string +} + +// AnnotationArg returns the IDENT_NAME arg or ("", false) for a +// bare `swagger:response`. +func (b *ResponseBlock) AnnotationArg() (string, bool) { + if b.Name == "" { + return "", false + } + return b.Name, true +} + +// NameBlock is produced by `swagger:name `. The +// annotation overrides a struct field's / interface method's +// JSON property name (or schema member name) without mutating +// the surrounding Go identifier. Name carries the IDENT_NAME +// argument. +type NameBlock struct { + *baseBlock + + Name string +} + +// AnnotationArg returns the IDENT_NAME arg. Bare `swagger:name` +// is rejected at parse time (CodeMissingRequiredArg); ("", false) +// only appears on the diagnostic path. +func (b *NameBlock) AnnotationArg() (string, bool) { + if b.Name == "" { + return "", false + } + return b.Name, true +} + +// ParametersBlock is produced by `swagger:parameters T1 T2 …`. +type ParametersBlock struct { + *baseBlock + + OperationIDs []string +} + +// RouteBlock is produced by `swagger:route METHOD /path [tags] opID`. +// swagger:route's body is inline keyword raw-blocks and **never** an +// OPAQUE_YAML. +type RouteBlock struct { + *baseBlock + + Method string + Path string + Tags []string + OpID string +} + +// InlineOperationBlock is produced by `swagger:operation METHOD /path +// [tags] opID`. Body may carry an OPAQUE_YAML in addition to inline +// keyword raw-blocks. +type InlineOperationBlock struct { + *baseBlock + + Method string + Path string + Tags []string + OpID string +} + +// MetaBlock is produced by `swagger:meta`. +type MetaBlock struct { + *baseBlock +} + +// ClassifierBlock covers single-line classifier annotations: +// strfmt / allOf / ignore / alias / file / type / default. +// The Args slice carries the lexer-tokenised positional arguments. +type ClassifierBlock struct { + *baseBlock + + Args []Token +} + +// AnnotationArg returns the first positional argument's text when +// non-empty (single-word capture). Bare classifier annotations +// (e.g. `swagger:ignore`, `swagger:alias`) return ("", false). +func (b *ClassifierBlock) AnnotationArg() (string, bool) { + if len(b.Args) == 0 { + return "", false + } + if b.Args[0].Text == "" { + return "", false + } + return b.Args[0].Text, true +} + +// EnumDeclBlock is produced by `swagger:enum [name] [values…]`. The +// optional multi-line value-list is carried in BodyValues when +// present (lexer accumulates the body as RAW_VALUE_ENUM). +type EnumDeclBlock struct { + *baseBlock + + Name string // "" when absent + InlineForm enumArgsForm + InlineArgs []Token // values fragment as a typed token, when inline values were given + BodyValues string // populated when a multi-line RAW_VALUE_ENUM body was present +} + +// AnnotationArg returns the IDENT_NAME arg (`status` for +// `swagger:enum status`) or ("", false) when the enum was +// declared inline-only (`swagger:enum red green blue`) or as a +// pure body block. +func (b *EnumDeclBlock) AnnotationArg() (string, bool) { + if b.Name == "" { + return "", false + } + return b.Name, true +} + +// UnboundBlock represents a comment group with no annotation line — +// e.g. a struct field's docstring. +type UnboundBlock struct { + *baseBlock +} + +// newBaseBlock initialises a baseBlock for the given annotation kind. +func newBaseBlock(kind AnnotationKind, pos token.Position) *baseBlock { + return &baseBlock{pos: pos, kind: kind} +} diff --git a/internal/parsers/grammar/disambiguate.go b/internal/parsers/grammar/disambiguate.go new file mode 100644 index 0000000..0807bcd --- /dev/null +++ b/internal/parsers/grammar/disambiguate.go @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "encoding/json" + "strings" + "unicode" + "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. +// +// Returns either TokenJSONValue or TokenRawValue. The Pos is left +// to the caller to set. +func classifyDefaultValue(s string) TokenKind { + trimmed := strings.TrimSpace(s) + if trimmed == "" { + return TokenRawValue + } + switch trimmed[0] { + case '"', '[', '{', '+', '-': + if isJSONValue(trimmed) { + return TokenJSONValue + } + return TokenRawValue + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': + if isJSONValue(trimmed) { + return TokenJSONValue + } + return TokenRawValue + } + switch trimmed { + case "true", "false", "null": + return TokenJSONValue + } + return TokenRawValue +} + +// isJSONValue reports whether s is a valid JSON literal. Uses the +// stdlib JSON decoder for correctness. +func isJSONValue(s string) bool { + dec := json.NewDecoder(strings.NewReader(s)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return false + } + if dec.More() { + return false + } + return true +} + +// enumArgsForm captures the four-way EnumArgs dispatch outcome. +// See README §disambiguation. +type enumArgsForm int + +const ( + enumFormEmpty enumArgsForm = iota // no inline argument (multi-line body may follow) + enumFormBracketedOnly // EnumValuesOnly = EnumBracketedList + enumFormPlainOnly // EnumValuesOnly = EnumPlainList + enumFormNameOnly // EnumWithName, no values + enumFormNamePlusBracketed // EnumWithName + EnumBracketedList + enumFormNamePlusPlain // EnumWithName + EnumPlainList +) + +// classifyEnumArgs implements the four-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 == "" { + return enumFormEmpty, "", "" + } + if s[0] == '[' { + return enumFormBracketedOnly, "", s + } + identEnd := scanEnumIdentifier(s) + if identEnd == 0 { + return enumFormPlainOnly, "", s + } + rest := strings.TrimLeft(s[identEnd:], " \t") + if rest == "" { + return enumFormNameOnly, s[:identEnd], "" + } + if rest[0] == '[' { + return enumFormNamePlusBracketed, s[:identEnd], rest + } + return enumFormNamePlusPlain, s[:identEnd], rest +} + +// scanEnumIdentifier returns the byte length of a leading +// IdentifierName: Letter followed by NameChar*. Stops at the first +// non-NameChar byte. Returns 0 if the leading character is not a +// letter. +func scanEnumIdentifier(s string) int { + r, size := utf8.DecodeRuneInString(s) + if size == 0 || !unicode.IsLetter(r) { + return 0 + } + i := size + for i < len(s) { + r, size = utf8.DecodeRuneInString(s[i:]) + if size == 0 { + break + } + if !isIdentifierContinue(r) { + break + } + i += size + } + return i +} + +// isIdentifierContinue matches NameChar: +// Letter | Digit | "_" | "-" | ".". +func isIdentifierContinue(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) || + r == '_' || r == '-' || r == '.' +} + +// enumValueListForm distinguishes the two surface forms. +// +//nolint:unused // full enum list values not wired yet +type enumValueListForm int + +//nolint:unused // full enum list values not wired yet +const ( + enumListPlain enumValueListForm = iota // comma-separated bare items + enumListBracketed // [ … ] +) + +// classifyEnumValueList decides whether values is a bracketed list or +// a plain list. The caller has already trim-stripped values; this +// function only inspects the leading character. +// +//nolint:unused // full enum list values not wired yet +func classifyEnumValueList(values string) enumValueListForm { + trimmed := strings.TrimSpace(values) + if strings.HasPrefix(trimmed, "[") { + return enumListBracketed + } + return enumListPlain +} + +// httpMethods is the closed vocabulary recognised as HTTP_METHOD. +// +//nolint:gochecknoglobals // closed-set lookup table. +var httpMethods = map[string]string{ + "get": "GET", + "post": "POST", + "put": "PUT", + "patch": "PATCH", + "head": "HEAD", + "delete": "DELETE", + "options": "OPTIONS", + "trace": "TRACE", +} + +// classifyHTTPMethod returns the canonical method name and true iff s +// is a recognised HTTP method (case-insensitive). +func classifyHTTPMethod(s string) (string, bool) { + canonical, ok := httpMethods[strings.ToLower(s)] + return canonical, ok +} + +// typeRefVocabulary is the closed type-reference vocabulary +// recognised as TYPE_REF terminals. +// +//nolint:gochecknoglobals // closed-set lookup table. +var typeRefVocabulary = map[string]struct{}{ + "string": {}, + "integer": {}, + "number": {}, + "boolean": {}, + "array": {}, + "object": {}, + "file": {}, + "null": {}, +} + +// isTypeRef reports whether s is a recognised TYPE_REF terminal. +func isTypeRef(s string) bool { + _, ok := typeRefVocabulary[s] + return ok +} + +// looksLikeURLPath is a coarse check for the OperationArgs URL path +// position: must start with "/". Full RFC 3986 conformance is left to +// the analyzer / consumers; the lexer only needs to dispatch. +func looksLikeURLPath(s string) bool { + return strings.HasPrefix(s, "/") +} diff --git a/internal/parsers/grammar/doc.go b/internal/parsers/grammar/doc.go new file mode 100644 index 0000000..f4f3172 --- /dev/null +++ b/internal/parsers/grammar/doc.go @@ -0,0 +1,35 @@ +// 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: +// +// - the recognised annotation as an AnnotationKind; +// - per-Block fields for the annotation's positional arguments; +// - Property entries for every recognised body keyword; +// - prose lines split into Title() / Description(); +// - diagnostics for malformed inputs (the parser never aborts). +// +// Pipeline: +// +// *ast.CommentGroup +// │ +// ▼ +// Preprocess → []Line (comment-marker stripping) +// │ +// ▼ +// Lex → []Token (line classifier + body accumulator + prose classifier) +// │ +// ▼ +// Parse → Block (dispatch by annotation family) +// +// The Token vocabulary is defined in token.go. +// +// # 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 +// follow-ups. +package grammar diff --git a/internal/parsers/grammar/fixtures_test.go b/internal/parsers/grammar/fixtures_test.go new file mode 100644 index 0000000..b1f4a9b --- /dev/null +++ b/internal/parsers/grammar/fixtures_test.go @@ -0,0 +1,812 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// fixturesTest_Petstore_PetModel mirrors fixtures/goparsing/petstore/models/pet.go +// — a swagger:model with title/description, multiple validations +// (required, pattern, min/maxLength), and several un-annotated fields. +func TestFixtures_Petstore_PetModel(t *testing.T) { + src := strings.TrimSpace(` +A Pet is the main product in the store. +It is used to describe the animals available in the store. + +swagger:model pet +`) + b := parseString(t, src) + mb, ok := b.(*ModelBlock) + require.True(t, ok) + assert.Equal(t, "pet", mb.Name) + assert.Equal(t, "A Pet is the main product in the store.", mb.Title()) + assert.Equal(t, "It is used to describe the animals available in the store.", mb.Description()) +} + +// TestFixtures_Petstore_PetField_RequiredAndPattern mirrors the Name +// field's docstring: required + pattern + min/maxLength, expressed via +// the alias forms ("minimum length", "maximum length") that the v1 +// keyword aliases support. +func TestFixtures_Petstore_PetField_RequiredAndPattern(t *testing.T) { + src := strings.TrimSpace(` +The name of the pet. + +required: true +pattern: \w[\w-]+ +minimum length: 3 +maximum length: 50 +`) + b := parseString(t, src) + ub, ok := b.(*UnboundBlock) + require.True(t, ok) + + required, ok := ub.GetBool("required") + require.True(t, ok) + assert.True(t, required) + + pat, ok := ub.GetString("pattern") + require.True(t, ok) + assert.Equal(t, `\w[\w-]+`, pat) + + minLen, ok := ub.GetInt("minLength") + require.True(t, ok) + assert.Equal(t, int64(3), minLen) + + maxLen, ok := ub.GetInt("maxLength") + require.True(t, ok) + assert.Equal(t, int64(50), maxLen) + + // Empty diagnostics — every keyword resolves cleanly. + for _, d := range ub.Diagnostics() { + assert.NotEqual(t, SeverityError, d.Severity, "unexpected error diagnostic: %s", d) + } +} + +// TestFixtures_Petstore_ItemsPrefix_NestedArrayValidation mirrors the +// PhotoURLs field in fixtures/goparsing/petstore/models/pet.go which +// uses `items pattern: \.(jpe?g|png)$` for per-item validation. +func TestFixtures_Petstore_ItemsPrefix_NestedArrayValidation(t *testing.T) { + src := strings.TrimSpace(` +The photo urls for the pet. + +items pattern: \.(jpe?g|png)$ +`) + b := parseString(t, src) + ub, ok := b.(*UnboundBlock) + require.True(t, ok) + + var found bool + for p := range ub.Properties() { + if p.Keyword.Name == "pattern" { + found = true + assert.Equal(t, 1, p.ItemsDepth) + assert.Equal(t, `\.(jpe?g|png)$`, p.Value) + } + } + assert.True(t, found) +} + +// TestFixtures_Petstore_ItemsPrefix_DeepNesting covers multiple levels +// of items.* prefix accumulation. +func TestFixtures_Petstore_ItemsPrefix_DeepNesting(t *testing.T) { + src := strings.TrimSpace(` +items.items.items.maxLength: 4 +`) + b := parseString(t, src) + for p := range b.Properties() { + assert.Equal(t, "maxLength", p.Keyword.Name) + assert.Equal(t, 3, p.ItemsDepth) + assert.Equal(t, int64(4), p.Typed.Integer) + } +} + +// TestFixtures_Petstore_ParametersBlock mirrors PetID with +// `swagger:parameters getPetById deletePet updatePet` — three +// operationID references. +func TestFixtures_Petstore_ParametersBlock(t *testing.T) { + src := strings.TrimSpace(` +A PetID parameter model. + +This is used for operations that want the ID of an pet in the path + +swagger:parameters getPetById deletePet updatePet +`) + b := parseString(t, src) + pb, ok := b.(*ParametersBlock) + require.True(t, ok) + assert.Equal(t, []string{"getPetById", "deletePet", "updatePet"}, pb.OperationIDs) +} + +// TestFixtures_Petstore_RouteBlock_GodocPrefixWithDeprecated covers the +// classic `// FooBar swagger:route ...` form plus an inline +// `Deprecated: true` and a `Responses:` raw block. +func TestFixtures_Petstore_RouteBlock_GodocPrefixWithDeprecated(t *testing.T) { + src := strings.TrimSpace(` +GetPets swagger:route GET /pets pets listPets + +Lists the pets known to the store. + +By default it will only lists pets that are available for sale. +This can be changed with the status flag. + +Deprecated: true +Responses: + + default: genericError + 200: []pet +`) + b := parseString(t, src) + rb, ok := b.(*RouteBlock) + require.True(t, ok) + assert.Equal(t, "GET", rb.Method) + assert.Equal(t, "/pets", rb.Path) + assert.Equal(t, []string{"pets"}, rb.Tags) + assert.Equal(t, "listPets", rb.OpID) + assert.Equal(t, "Lists the pets known to the store.", rb.Title()) + assert.Contains(t, rb.Description(), "available for sale") + + dep, ok := rb.GetBool("deprecated") + require.True(t, ok) + assert.True(t, dep) + + respLines, ok := rb.GetList("responses") + require.True(t, ok) + joined := strings.Join(respLines, "\n") + assert.Contains(t, joined, "default: genericError") + assert.Contains(t, joined, "200: []pet") +} + +// TestFixtures_OperationsAnnotation_YAMLBody mirrors +// fixtures/goparsing/classification/operations_annotation/operations.go's +// first operation: a YAML-fenced body holding parameters/responses. +func TestFixtures_OperationsAnnotation_YAMLBody(t *testing.T) { + src := strings.TrimSpace(` +swagger:operation GET /pets pets getPet + +List all pets + +--- +parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + type: integer + format: int32 +consumes: + - "application/json" + - "application/xml" +produces: + - "application/xml" + - "application/json" +responses: + "200": + description: An paged array of pets + default: + description: unexpected error +--- +`) + b := parseString(t, src) + ob, ok := b.(*InlineOperationBlock) + require.True(t, ok) + assert.Equal(t, "GET", ob.Method) + assert.Equal(t, "/pets", ob.Path) + assert.Equal(t, []string{"pets"}, ob.Tags) + assert.Equal(t, "getPet", ob.OpID) + // "List all pets" has no trailing punctuation and no internal + // blank, so heuristics 1/2/3 don't fire — heuristic 4 classifies + // the whole prose as Description. v1's helpers behaves the same + // way on the equivalent ProseLines slice. + assert.Empty(t, ob.Title()) + assert.Equal(t, "List all pets", ob.Description()) + + yamls := []RawYAML{} + for y := range ob.YAMLBlocks() { + yamls = append(yamls, y) + } + require.Len(t, yamls, 1) + assert.Contains(t, yamls[0].Text, "parameters:") + assert.Contains(t, yamls[0].Text, "responses:") + assert.False(t, yamls[0].Truncated) +} + +// TestFixtures_Meta_PetstoreV1 mirrors fixtures/goparsing/meta/v1/doc.go +// — the canonical meta block: prose, single-line keywords, raw blocks, +// extensions, info-extensions, security, security-definitions, with +// `swagger:meta` at the *bottom* (godoc convention). +func TestFixtures_Meta_PetstoreV1(t *testing.T) { + src := `Petstore API. + +the purpose of this application is to provide an application +that is using plain go code to define an API + +This should demonstrate all the possible comment annotations +that are available to turn go code into a fully compliant swagger 2.0 spec + +Terms Of Service: +there are no TOS at this moment, use at your own risk we take no responsibility + + Schemes: http, https + Host: localhost + BasePath: /v2 + Version: 0.0.1 + License: MIT http://opensource.org/licenses/MIT + Contact: John Doe http://john.doe.com + + Consumes: + - application/json + - application/xml + + Produces: + - application/json + - application/xml + + Extensions: + x-meta-value: value + x-meta-array: + - value1 + - value2 + + InfoExtensions: + x-info-value: value + + Security: + - api_key: + + SecurityDefinitions: + api_key: + type: apiKey + name: KEY + in: header + +swagger:meta` + b := parseString(t, src) + mb, ok := b.(*MetaBlock) + require.True(t, ok, "expected *MetaBlock, got %T", b) + assert.Equal(t, "Petstore API.", mb.Title()) + + // Single-line keywords. + host, ok := mb.GetString("host") + require.True(t, ok) + assert.Equal(t, "localhost", host) + + basePath, ok := mb.GetString("basePath") + require.True(t, ok) + assert.Equal(t, "/v2", basePath) + + version, ok := mb.GetString("version") + require.True(t, ok) + assert.Equal(t, "0.0.1", version) + + schemes, ok := mb.GetList("schemes") + require.True(t, ok) + assert.Equal(t, []string{"http", "https"}, schemes) + + // Raw blocks present. + consumes, ok := mb.GetList("consumes") + require.True(t, ok) + joined := strings.Join(consumes, "\n") + assert.Contains(t, joined, "application/json") + assert.Contains(t, joined, "application/xml") + + // Extensions: top-level x-* entries surfaced. + exts := []Extension{} + for e := range mb.Extensions() { + exts = append(exts, e) + } + assert.NotEmpty(t, exts) + hasXMeta := false + for _, e := range exts { + if e.Name == "x-meta-value" { + hasXMeta = true + assert.Equal(t, "value", e.Value) + } + } + assert.True(t, hasXMeta) + + // Security + securityDefinitions raw blocks present as Properties. + sec, ok := mb.GetList("security") + require.True(t, ok) + assert.Contains(t, strings.Join(sec, "\n"), "api_key") + + secDef, ok := mb.GetList("securityDefinitions") + require.True(t, ok) + assert.Contains(t, strings.Join(secDef, "\n"), "type: apiKey") +} + +// TestFixtures_Meta_TosKeywordVariants exercises the trailing-dot, +// alias spelling ("Terms Of Service" / "TermsOfService" / "tos") that +// the meta v3 / v4 fixtures show. +func TestFixtures_Meta_TosKeywordVariants(t *testing.T) { + cases := []string{ + "Terms Of Service:\nuse at your own risk\nswagger:meta", + "TermsOfService:\nuse at your own risk\nswagger:meta", + "tos:\nuse at your own risk\nswagger:meta", + } + for _, src := range cases { + b := parseString(t, src) + _, ok := b.(*MetaBlock) + require.True(t, ok, "expected MetaBlock for %q", src) + tos, ok := b.GetList("tos") + require.True(t, ok, "expected tos block to be present (%q)", src) + assert.Contains(t, strings.Join(tos, "\n"), "use at your own risk") + } +} + +// TestFixtures_Petstore_ResponseBlock with body marker. +func TestFixtures_Petstore_ResponseBlock(t *testing.T) { + src := strings.TrimSpace(` +A GenericError is the default error message that is generated. +For certain status codes there are more appropriate error structures. + +swagger:response genericError +`) + b := parseString(t, src) + rb, ok := b.(*ResponseBlock) + require.True(t, ok) + assert.Equal(t, "genericError", rb.Name) + assert.Equal(t, "A GenericError is the default error message that is generated.", rb.Title()) + assert.Equal(t, "For certain status codes there are more appropriate error structures.", rb.Description()) +} + +// TestFixtures_Petstore_StrfmtAnnotation_FieldLevel mirrors the +// Birthday field's strfmt tag. +func TestFixtures_Petstore_StrfmtAnnotation_FieldLevel(t *testing.T) { + src := strings.TrimSpace(` +The pet's birthday + +swagger:strfmt date +`) + b := parseString(t, src) + cb, ok := b.(*ClassifierBlock) + require.True(t, ok) + assert.Equal(t, AnnStrfmt, cb.AnnotationKind()) + require.Len(t, cb.Args, 1) + assert.Equal(t, "date", cb.Args[0].Text) + assert.Equal(t, TokenIdentName, cb.Args[0].Kind) +} + +// TestFixtures_Petstore_ParameterIn covers the `in:` keyword inside an +// UnboundBlock (a parameter struct field). +func TestFixtures_Petstore_ParameterIn(t *testing.T) { + src := strings.TrimSpace(` +The ID of the pet + +in: path +required: true +`) + b := parseString(t, src) + in, ok := b.GetString("in") + require.True(t, ok) + assert.Equal(t, "path", in) + + required, ok := b.GetBool("required") + require.True(t, ok) + assert.True(t, required) +} + +// TestFixtures_AllowedHTTPMethods covers the closed HTTP method +// vocabulary inspired by fixtures/enhancements/all-http-methods. +func TestFixtures_AllowedHTTPMethods(t *testing.T) { + methods := []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE"} + for _, m := range methods { + src := "swagger:route " + m + " /thing tag op" + m + b := parseString(t, src) + rb, ok := b.(*RouteBlock) + require.True(t, ok, "expected RouteBlock for method %q", m) + assert.Equal(t, m, rb.Method) + assert.Empty(t, b.Diagnostics(), "method %q produced diagnostics: %v", m, b.Diagnostics()) + } +} + +// TestFixtures_AllowedHTTPMethods_LowercaseNormalised checks +// case-insensitive HTTP method matching. +func TestFixtures_AllowedHTTPMethods_LowercaseNormalised(t *testing.T) { + b := parseString(t, "swagger:route get /pets pets listPets") + rb, ok := b.(*RouteBlock) + require.True(t, ok) + assert.Equal(t, "GET", rb.Method, "method canonical form is upper case") +} + +// TestFixtures_GodocLinterTrailingDot covers the trailing-dot elision +// the godot linter triggers across annotation lines. +func TestFixtures_GodocLinterTrailingDot(t *testing.T) { + cases := []struct { + src string + want string + annKind AnnotationKind + argCount int + }{ + {"swagger:strfmt uuid.", "uuid", AnnStrfmt, 1}, + {"swagger:meta.", "", AnnMeta, 0}, + {"swagger:model Pet.", "Pet", AnnModel, 1}, + } + for _, tc := range cases { + b := parseString(t, tc.src) + assert.EqualT(t, tc.annKind, b.AnnotationKind(), "in: %s", tc.src) + switch tc.annKind { + case AnnStrfmt: + cb, ok := b.(*ClassifierBlock) + require.TrueT(t, ok) + require.Len(t, cb.Args, tc.argCount) + assert.Equal(t, tc.want, cb.Args[0].Text) + case AnnModel: + mb, ok := b.(*ModelBlock) + require.TrueT(t, ok) + assert.Equal(t, tc.want, mb.Name) + case AnnMeta: + _, ok := b.(*MetaBlock) + require.True(t, ok) + default: + require.FailNow(t, "test configuration error: missing assertion") + } + } +} + +// TestFixtures_CRLFNormalisation ensures \r\n line endings produce the +// same token stream as \n. +func TestFixtures_CRLFNormalisation(t *testing.T) { + const src = "swagger:model Pet\r\nrequired: true\r\nmaxLength: 5" + + b := parseString(t, src) + mb, ok := b.(*ModelBlock) + require.True(t, ok) + assert.Equal(t, "Pet", mb.Name) + + required, ok := mb.GetBool("required") + require.True(t, ok) + assert.True(t, required) + + maxLen, ok := mb.GetInt("maxLength") + require.True(t, ok) + assert.Equal(t, int64(5), maxLen) +} + +// TestFixtures_ComparisonOperatorOnNumber covers `maximum: <5` v1 form. +func TestFixtures_ComparisonOperatorOnNumber(t *testing.T) { + src := strings.TrimSpace(` +swagger:model Foo + +maximum: <=10 +minimum: >0 +`) + b := parseString(t, src) + for p := range b.Properties() { + switch p.Keyword.Name { + case "maximum": + assert.Equal(t, "<=", p.Typed.Op) + assert.InDelta(t, 10.0, p.Typed.Number, 0) + case "minimum": + assert.Equal(t, ">", p.Typed.Op) + assert.InDelta(t, 0.0, p.Typed.Number, 0) + } + } +} + +// TestFixtures_AllOf_OptionalClassName covers swagger:allOf with and +// without the polymorphic class name (mirrors the docs examples in +// 23-classifier-grammar.md). +func TestFixtures_AllOf_OptionalClassName(t *testing.T) { + bare := parseString(t, "swagger:allOf") + cbBare, ok := bare.(*ClassifierBlock) + require.True(t, ok) + assert.Empty(t, cbBare.Args) + assert.Empty(t, bare.Diagnostics()) + + named := parseString(t, "swagger:allOf Animal") + cbNamed, ok := named.(*ClassifierBlock) + require.True(t, ok) + require.Len(t, cbNamed.Args, 1) + assert.Equal(t, "Animal", cbNamed.Args[0].Text) +} + +// TestFixtures_DefaultAnnotation_JSONForms covers the JsonValue branch +// (objects, arrays, numbers, booleans) and the RawValue fallback. +func TestFixtures_DefaultAnnotation_JSONForms(t *testing.T) { + jsonCases := []string{ + `swagger:default {"limit": 10, "offset": 0}`, + `swagger:default [1,2,3]`, + `swagger:default 42`, + `swagger:default true`, + `swagger:default null`, + `swagger:default "literal-string"`, + } + for _, src := range jsonCases { + b := parseString(t, src) + cb, ok := b.(*ClassifierBlock) + require.True(t, ok, "expected classifier for %q, got %T", src, b) + require.Len(t, cb.Args, 1, src) + assert.Equal(t, TokenJSONValue, cb.Args[0].Kind, "%q", src) + } + + // Bare ident → falls back to RAW_VALUE. + rawSrc := "swagger:default high" + b := parseString(t, rawSrc) + cb, ok := b.(*ClassifierBlock) + require.TrueT(t, ok) + require.Len(t, cb.Args, 1) + assert.Equal(t, TokenRawValue, cb.Args[0].Kind) +} + +// TestFixtures_EnumDecl_BracketedHybrid covers the hybrid-list example +// from 23-classifier-grammar.md ("a, {x:1}, c, [1,2,3], …"). +func TestFixtures_EnumDecl_BracketedHybrid(t *testing.T) { + b := parseString(t, `swagger:enum my_enum [a, {"x":1, "y":[1,2,3]}, c, [1,2,3], ["u","v"]]`) + eb, ok := b.(*EnumDeclBlock) + require.True(t, ok) + assert.Equal(t, "my_enum", eb.Name) + assert.Equal(t, enumFormNamePlusBracketed, eb.InlineForm) + require.Len(t, eb.InlineArgs, 1) + assert.Equal(t, TokenJSONValue, eb.InlineArgs[0].Kind) +} + +// TestFixtures_EnumDecl_MultilineBody backports the Q15 multi-line +// value-list body shape on swagger:enum. +func TestFixtures_EnumDecl_MultilineBody(t *testing.T) { + src := strings.TrimSpace(` +swagger:enum Priority + +enum: + - low + - medium + - high +`) + b := parseString(t, src) + eb, ok := b.(*EnumDeclBlock) + require.True(t, ok) + assert.Equal(t, "Priority", eb.Name) + assert.NotEmpty(t, eb.BodyValues) + assert.Contains(t, eb.BodyValues, "low") + assert.Contains(t, eb.BodyValues, "medium") + assert.Contains(t, eb.BodyValues, "high") +} + +// TestFixtures_BookingMeta_LeadingAnnotation covers the +// `swagger:meta` placed at the top of the comment group. +func TestFixtures_BookingMeta_LeadingAnnotation(t *testing.T) { + src := strings.TrimSpace(` +swagger:meta + +Schemes: http, https +Host: api.example.com +BasePath: /v2 +Version: 1.4.0 +License: MIT https://opensource.org/licenses/MIT +Contact: API Team team@example.com +`) + b := parseString(t, src) + mb, ok := b.(*MetaBlock) + require.True(t, ok) + + assert.Equal(t, AnnMeta, mb.AnnotationKind()) + + v, ok := mb.GetString("version") + require.True(t, ok) + assert.Equal(t, "1.4.0", v) +} + +// TestFixtures_OperationDeprecatedAndExternalDocs covers the cross- +// over deprecated keyword + externalDocs raw block under an inline +// operation block. +func TestFixtures_OperationDeprecatedAndExternalDocs(t *testing.T) { + src := strings.TrimSpace(` +swagger:operation GET /pets pets listPets + +Lists pets. + +deprecated: true +externalDocs: + description: User Guide + url: https://example.com/docs +`) + b := parseString(t, src) + ob, ok := b.(*InlineOperationBlock) + require.True(t, ok) + + dep, ok := ob.GetBool("deprecated") + require.True(t, ok) + assert.True(t, dep) + + docs, ok := ob.GetList("externalDocs") + require.True(t, ok) + joined := strings.Join(docs, "\n") + assert.Contains(t, joined, "description: User Guide") + assert.Contains(t, joined, "url: https://example.com/docs") +} + +// TestFixtures_DecorativeYAMLFenceInExtensions confirms decorative +// `--- … ---` fences around an extensions: body produce byte-identical +// behaviour with and without the fences (the v1 quirk noted in +// 10-shared.md and 40-lexer.md §5). +func TestFixtures_DecorativeYAMLFenceInExtensions(t *testing.T) { + withFence := strings.TrimSpace(` +swagger:meta + +Extensions: +--- +x-foo: bar +x-baz: 1 +--- +`) + withoutFence := strings.TrimSpace(` +swagger:meta + +Extensions: +x-foo: bar +x-baz: 1 +`) + + b1 := parseString(t, withFence) + b2 := parseString(t, withoutFence) + + exts1 := collectExtensionsAsMap(b1) + exts2 := collectExtensionsAsMap(b2) + assert.Equal(t, exts2, exts1, "decorative fence should be transparent") +} + +// TestFixtures_BlockBodyCrossover_KeywordsAcrossSiblings confirms +// `Consumes:` and `Produces:` consecutive raw blocks each terminate at +// the next sibling structural keyword, not at blank lines. +func TestFixtures_BlockBodyCrossover_KeywordsAcrossSiblings(t *testing.T) { + src := strings.TrimSpace(` +swagger:meta + +Consumes: +- application/json + +- application/xml + +Produces: +- application/json +`) + b := parseString(t, src) + cons, ok := b.GetList("consumes") + require.True(t, ok) + consJoined := strings.Join(cons, "\n") + assert.Contains(t, consJoined, "application/json") + assert.Contains(t, consJoined, "application/xml", "blank lines do not terminate a raw block body") + + prod, ok := b.GetList("produces") + require.True(t, ok) + assert.Contains(t, strings.Join(prod, "\n"), "application/json") +} + +// TestFixtures_FullPipeline_FromCommentGroup exercises the public +// Parse(*ast.CommentGroup, *token.FileSet) entry — same as scanner does. +func TestFixtures_FullPipeline_FromCommentGroup(t *testing.T) { + src := `package fake + +// A Pet is the main product in the store. +// +// swagger:model pet +type Pet struct { + // The name of the pet. + // + // required: true + // pattern: \w+ + Name string ` + "`json:\"name\"`" + ` +} +` + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "fake.go", src, parser.ParseComments) + require.NoError(t, err) + + // Find each declared type's documentation comment and parse it. + var got []Block + ast.Inspect(file, func(n ast.Node) bool { + switch d := n.(type) { + case *ast.GenDecl: + if d.Doc != nil { + got = append(got, Parse(d.Doc, fset)) + } + case *ast.Field: + if d.Doc != nil { + got = append(got, Parse(d.Doc, fset)) + } + } + return true + }) + + require.GreaterOrEqual(t, len(got), 2) + mb, ok := got[0].(*ModelBlock) + require.True(t, ok, "expected first decl to be ModelBlock, got %T", got[0]) + assert.Equal(t, "pet", mb.Name) + + ub, ok := got[1].(*UnboundBlock) + require.True(t, ok, "expected field doc to be UnboundBlock, got %T", got[1]) + required, ok := ub.GetBool("required") + require.True(t, ok) + assert.True(t, required) + pat, ok := ub.GetString("pattern") + require.True(t, ok) + assert.Equal(t, `\w+`, pat) +} + +// TestFixtures_CrossSchemeKeyword_Schemes confirms the `schemes:` +// keyword is legal under meta, route, and operation but warns +// elsewhere (e.g. under model). +func TestFixtures_CrossSchemeKeyword_Schemes(t *testing.T) { + cases := []struct { + src string + wantWarn bool + comment string + }{ + {"swagger:meta\n\nSchemes: http", false, "meta"}, + {"swagger:route GET /pets pets listPets\n\nSchemes: http", false, "route"}, + {"swagger:operation GET /pets pets listPets\n\nSchemes: http", false, "operation"}, + {"swagger:model Foo\n\nSchemes: http", true, "model"}, + } + for _, tc := range cases { + b := parseString(t, tc.src) + hasContextWarn := false + for _, d := range b.Diagnostics() { + if d.Code == CodeContextInvalid { + hasContextWarn = true + } + } + if tc.wantWarn { + assert.True(t, hasContextWarn, "%s: expected context-invalid diagnostic", tc.comment) + } else { + assert.False(t, hasContextWarn, "%s: unexpected context-invalid diagnostic", tc.comment) + } + } +} + +// TestFixtures_StrfmtKeywordAlias_MaxLen confirms that the alias +// "max len" / "max-len" / "maxLen" are equivalent to "maxLength". +func TestFixtures_StrfmtKeywordAlias_MaxLen(t *testing.T) { + for _, alias := range []string{"max len", "max-len", "maxLen", "maximum length", "maximumLength"} { + src := alias + ": 42" + b := parseString(t, src) + v, ok := b.GetInt("maxLength") + require.True(t, ok, "alias %q did not resolve to maxLength", alias) + assert.Equal(t, int64(42), v) + } +} + +// TestFixtures_DecimalNumberValue_Roundtrip confirms NUMBER_VALUE +// parses signed/unsigned decimals and fractional values. +func TestFixtures_DecimalNumberValue_Roundtrip(t *testing.T) { + cases := []struct { + src string + op string + want float64 + }{ + {"maximum: 10", "", 10}, + {"maximum: 3.14", "", 3.14}, + {"maximum: <-1", "<", -1}, + {"maximum: >=2.5", ">=", 2.5}, + } + for _, tc := range cases { + b := parseString(t, tc.src) + var found bool + for p := range b.Properties() { + if p.Keyword.Name == "maximum" { + found = true + assert.Equal(t, tc.op, p.Typed.Op) + assert.InDelta(t, tc.want, p.Typed.Number, 1e-9) + } + } + assert.True(t, found, "%q produced no maximum property", tc.src) + } +} + +// collectExtensionsAsMap turns the iter.Seq[Extension] into a map for +// equality comparison. Extension.Value is YAML-typed (`any`); callers +// that only care about presence/equality compare on the typed value. +func collectExtensionsAsMap(b Block) map[string]any { + out := map[string]any{} + for e := range b.Extensions() { + out[e.Name] = e.Value + } + return out +} diff --git a/internal/parsers/grammar/meta_info.go b/internal/parsers/grammar/meta_info.go new file mode 100644 index 0000000..82aa21a --- /dev/null +++ b/internal/parsers/grammar/meta_info.go @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "net/mail" + "strings" +) + +// Contact is the typed shape of a `contact:` inline value on a +// swagger:meta block. The convention is: +// +// contact: +// +// where each part is optional in the order written: the parser +// recognises a `Name ` head (Go's net/mail.ParseAddress form) +// followed by an optional URL. A bare email without a name is also +// accepted. Empty or unrecognised inputs return (Contact{}, false) +// from Block.Contact(). +type Contact struct { + Name, Email, URL string +} + +// License is the typed shape of a `license:` inline value: +// +// license: +// +// where Name is everything before the URL prefix and URL is the +// scheme-anchored remainder. A line without a URL keeps Name and +// leaves URL empty. Empty input returns (License{}, false) from +// Block.License(). +type License struct { + Name, URL string +} + +// parseContact converts the raw contact: value into a typed Contact. +// Returns (Contact{}, nil) on empty input (treated as "no contact"). +// A non-nil error signals a malformed `Name ` head — the +// caller decides whether to fail the build or downgrade to a +// warning. An isolated URL (no name/email) yields (Contact{URL: …}, +// nil). +func parseContact(line string) (Contact, error) { + line = strings.TrimSpace(line) + if line == "" { + return Contact{}, nil + } + nameEmail, url := splitURL(line) + if nameEmail == "" { + return Contact{URL: url}, nil + } + addr, err := mail.ParseAddress(nameEmail) + if err != nil { + return Contact{}, err + } + return Contact{Name: addr.Name, Email: addr.Address, URL: url}, nil +} + +// parseLicense converts the raw license: value into a typed License. +// Returns (License{}, false) only when the input is empty; any +// non-empty input yields a (License, true) with Name and URL split +// on the URL prefix (Name may be empty if the line starts with the +// URL). +func parseLicense(line string) (License, bool) { + line = strings.TrimSpace(line) + if line == "" { + return License{}, false + } + name, url := splitURL(line) + return License{Name: name, URL: url}, true +} + +// urlSchemes lists the leading URL prefixes splitURL recognises. +// The set covers the schemes meta titles realistically carry. +// +//nolint:gochecknoglobals // immutable lookup table; read-only. +var urlSchemes = []string{"https://", "http://", "ftps://", "ftp://", "wss://", "ws://"} + +// splitURL separates the leading non-URL prefix from the trailing +// URL on a single line. Returns ("", url) when the line begins with +// a URL scheme; (line, "") when no scheme is found anywhere. +func splitURL(line string) (notURL, url string) { + str := strings.TrimSpace(line) + idx := -1 + for _, scheme := range urlSchemes { + if i := strings.Index(str, scheme); i >= 0 && (idx < 0 || i < idx) { + idx = i + } + } + if idx < 0 { + if str != "" { + notURL = str + } + return notURL, "" + } + notURL = strings.TrimSpace(str[:idx]) + url = strings.TrimSpace(str[idx:]) + return notURL, url +} diff --git a/internal/parsers/grammar/parser.go b/internal/parsers/grammar/parser.go new file mode 100644 index 0000000..57edd0d --- /dev/null +++ b/internal/parsers/grammar/parser.go @@ -0,0 +1,941 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "go/ast" + "go/token" + "slices" + "strconv" + "strings" + + "github.com/go-openapi/codescan/internal/parsers/security" + "github.com/go-openapi/codescan/internal/parsers/yaml" +) + +// Parser is the consumer contract for the grammar parser. The package +// ships *DefaultParser; the interface exists so tests can substitute +// a mock that fabricates Block values without running the full lex +// pipeline. +// +// # Details +// +// 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 + ParseText(text string, pos token.Position) Block + ParseAs(kind AnnotationKind, text string, pos token.Position) Block +} + +// DefaultParser is the concrete Parser implementation. +type DefaultParser struct { + fset *token.FileSet + sink func(Diagnostic) +} + +// NewParser constructs a DefaultParser bound to a FileSet (needed to +// map *ast.CommentGroup positions to absolute source positions). +func NewParser(fset *token.FileSet, opts ...Option) *DefaultParser { + p := &DefaultParser{fset: fset} + for _, o := range opts { + o(p) + } + return p +} + +// Option configures a DefaultParser. +type Option func(*DefaultParser) + +// WithDiagnosticSink streams diagnostics to a callback in addition to +// accumulating them on the returned Block. +func WithDiagnosticSink(sink func(Diagnostic)) Option { + return func(p *DefaultParser) { p.sink = sink } +} + +//nolint:ireturn // stable seam. +func (p *DefaultParser) Parse(cg *ast.CommentGroup) Block { + lines := Preprocess(cg, p.fset) + tokens := Lex(lines) + return p.parseTokens(tokens) +} + +// ParseAll returns one Block per annotation in cg, in source order. +// A comment group with no annotation yields a single-element slice +// holding an UnboundBlock; a single-annotation comment yields the +// same Block as Parse, wrapped in a slice; multi-annotation comments +// yield one Block per annotation. +// +// Token partition: each annotation owns the slice of tokens from +// its index up to (but excluding) the next annotation. The first +// annotation also owns the pre-annotation prose, so its +// PreambleTitle / PreambleDescription match Parse(cg)'s. Body +// tokens between annotations attach to the *preceding* annotation. +// +// 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. +func (p *DefaultParser) ParseAll(cg *ast.CommentGroup) []Block { + lines := Preprocess(cg, p.fset) + tokens := Lex(lines) + return p.parseAllTokens(tokens) +} + +//nolint:ireturn // stable seam. +func (p *DefaultParser) ParseText(text string, pos token.Position) Block { + lines := preprocessText(text, pos) + return p.parseTokens(Lex(lines)) +} + +//nolint:ireturn // stable seam. +func (p *DefaultParser) ParseAs(kind AnnotationKind, text string, pos token.Position) Block { + injected := AnnotationPrefix + kind.String() + "\n" + text + return p.ParseText(injected, pos) +} + +// Parse is the convenience wrapper around NewParser(fset).Parse(cg). +// +//nolint:ireturn // stable seam. +func Parse(cg *ast.CommentGroup, fset *token.FileSet) Block { + return NewParser(fset).Parse(cg) +} + +// ParseAll is the convenience wrapper around +// NewParser(fset).ParseAll(cg). +func ParseAll(cg *ast.CommentGroup, fset *token.FileSet) []Block { + return NewParser(fset).ParseAll(cg) +} + +// ParseTokens runs the parser on a pre-lexed token stream. Useful for +// tests and LSP scenarios. +// +//nolint:ireturn // stable seam. +func ParseTokens(tokens []Token) Block { + p := &DefaultParser{} + return p.parseTokens(tokens) +} + +// parseAllTokens implements the multi-annotation slicing rule +// documented on ParseAll. +func (p *DefaultParser) parseAllTokens(tokens []Token) []Block { + var annIndices []int + for i, t := range tokens { + if t.Kind == TokenAnnotation { + annIndices = append(annIndices, i) + } + } + if len(annIndices) <= 1 { + // Zero annotations → UnboundBlock; one annotation → + // equivalent to Parse. Either way, the existing single- + // block path is correct — wrap in a slice. + return []Block{p.parseTokens(tokens)} + } + out := make([]Block, 0, len(annIndices)) + start := 0 + for i := range annIndices { + end := len(tokens) + if i+1 < len(annIndices) { + end = annIndices[i+1] + } + out = append(out, p.parseTokens(tokens[start:end])) + start = end + } + return out +} + +// parseState holds per-block parsing state. +// +// Today's parsers walk s.tokens via range loops 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: +// +// golangci-lint run --enable-only unused ./internal/parsers/grammar/... +type parseState struct { + tokens []Token + pos int //nolint:unused // see godoc — reserved for recursive-descent cursor + diags []Diagnostic + sink func(Diagnostic) +} + +func (s *parseState) emit(d Diagnostic) { + if s.sink != nil { + s.sink(d) + } + s.diags = append(s.diags, d) +} + +// peek returns the token at s.pos without consuming it. Reserved for +// future order-sensitive productions — see parseState godoc. +// +//nolint:unused // prepare full recursive-descent for order-sensitive grammar productions +func (s *parseState) peek() Token { + if s.pos >= len(s.tokens) { + return Token{Kind: TokenEOF} + } + return s.tokens[s.pos] +} + +// advance returns the token at s.pos and moves the cursor one forward. +// Reserved for future order-sensitive productions — see parseState +// godoc. +// +//nolint:unused // prepare full recursive-descent for order-sensitive grammar productions +func (s *parseState) advance() Token { + t := s.peek() + if s.pos < len(s.tokens) { + s.pos++ + } + return t +} + +// parseTokens dispatches the stream to the family-specific parser. +// +//nolint:ireturn // stable seam. +func (p *DefaultParser) parseTokens(tokens []Token) Block { + s := &parseState{tokens: tokens, sink: p.sink} + annIdx := findAnnotation(tokens) + if annIdx < 0 { + return s.parseUnboundBlock() + } + + annTok := tokens[annIdx] + kind := AnnotationKindFromName(annTok.Name) + switch kind.family() { + case familySchema: + return s.parseSchemaBlock(annIdx, annTok, kind) + case familyOperation: + return s.parseOperationBlock(annIdx, annTok, kind) + case familyMeta: + return s.parseMetaBlock(annIdx, annTok) + case familyClassifier: + return s.parseClassifierBlock(annIdx, annTok, kind) + case familyUnknown: + fallthrough + default: + return s.parseUnboundBlock() + } +} + +// findAnnotation returns the index of the first TokenAnnotation, or -1. +func findAnnotation(tokens []Token) int { + for i, t := range tokens { + if t.Kind == TokenAnnotation { + return i + } + } + return -1 +} + +// extractTitleDesc walks the prose tokens of a block (or a sub-slice +// such as the pre-annotation preamble) and returns: +// +// - title — TokenTitle texts joined with "\n" +// - desc — TokenDesc texts joined with "\n", with internal blank +// lines preserved as "" entries (paragraph breaks) +// - lines — interleaved title/desc/blank entries in source order, +// blanks rendered as "" — the 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. +func extractTitleDesc(tokens []Token) (title, desc string, lines []string) { + var titleLines, descLines []string + const ( + stateBeforeTitle = iota + stateInTitle + stateInDesc + ) + state := stateBeforeTitle + + for _, t := range tokens { + switch t.Kind { + case TokenTitle: + titleLines = append(titleLines, t.Text) + lines = append(lines, t.Text) + state = stateInTitle + case TokenDesc: + descLines = append(descLines, t.Text) + lines = append(lines, t.Text) + state = stateInDesc + case TokenBlank: + lines = append(lines, "") + switch state { + case stateInTitle: + // Either an internal blank in a multi-paragraph title + // or a separator before the desc run starts. The + // trailing-blank trim below resolves the latter. + titleLines = append(titleLines, "") + case stateInDesc: + descLines = append(descLines, "") + } + default: + // ignored token + } + } + + lines = dropTrailingBlankLines(lines) + titleLines = dropTrailingBlankLines(titleLines) + descLines = dropTrailingBlankLines(descLines) + title = strings.Join(titleLines, "\n") + desc = strings.Join(descLines, "\n") + return title, desc, lines +} + +// dropTrailingBlankLines drops every trailing whitespace-only line +// from ls. 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] + } + return ls +} + +// finaliseBase populates Title/Description/ProseLines/PreambleLines +// and copies accumulated diagnostics onto the base block. +// +// PreambleLines / PreambleTitle / PreambleDescription hold the subset +// of prose that appears BEFORE the block's annotation. Schema's +// top-level model builder consumes only pre-annotation prose so +// post-annotation text reads as body content. Routes / operations / +// meta consult Title() and Description(), which span the whole +// block. +func (s *parseState) finaliseBase(base *baseBlock) { + t, d, lines := extractTitleDesc(s.tokens) + base.title = t + base.description = d + base.proseLines = lines + + preTokens := s.tokens + if annIdx := findAnnotation(s.tokens); annIdx >= 0 { + preTokens = s.tokens[:annIdx] + } + pt, pd, preLines := extractTitleDesc(preTokens) + base.preambleTitle = pt + base.preambleDescription = pd + base.preambleLines = preLines + + base.diagnostics = append(base.diagnostics, s.diags...) +} + +// --- UnboundBlock ------------------------------------------------------------ + +//nolint:ireturn // stable seam. +func (s *parseState) parseUnboundBlock() Block { + base := newBaseBlock(AnnUnknown, firstMeaningfulPos(s.tokens)) + for _, t := range s.tokens { + s.consumeBodyToken(base, t, AnnUnknown) + } + s.finaliseBase(base) + return &UnboundBlock{baseBlock: base} +} + +// firstMeaningfulPos picks the first non-blank, non-EOF token's +// position so an UnboundBlock has a sensible Pos(). +func firstMeaningfulPos(tokens []Token) token.Position { + for _, t := range tokens { + switch t.Kind { + case TokenEOF, TokenBlank: + continue + default: + return t.Pos + } + } + return token.Position{} +} + +// --- Schema family ----------------------------------------------------------- + +//nolint:ireturn // stable seam. +func (s *parseState) parseSchemaBlock(annIdx int, annTok Token, kind AnnotationKind) Block { + base := newBaseBlock(kind, annTok.Pos) + + // Validate annotation arguments before walking body tokens so any + // emitted diagnostics land on the block via finaliseBase. + switch kind { + case AnnParameters: + if len(identArgs(annTok)) == 0 { + s.emit(Errorf(annTok.Pos, CodeMissingRequiredArg, + "swagger:parameters requires at least one operation id reference")) + } + case AnnName: + if firstIdentArg(annTok) == "" { + s.emit(Errorf(annTok.Pos, CodeMissingRequiredArg, + "swagger:name requires a member name override argument")) + } + default: + // other schema-family annotations either accept no args (AnnModel) + // or accept an optional name (AnnResponse). + } + + // Walk pre + post tokens; pre-annotation prose contributes to + // Title/Description (already classified by the lexer); pre-annotation + // body tokens (rare, godoc-style "annotation at the bottom") are + // treated the same as post-annotation body tokens. + for i, t := range s.tokens { + if i == annIdx { + continue + } + s.consumeBodyToken(base, t, kind) + } + s.finaliseBase(base) + + switch kind { + case AnnModel: + return &ModelBlock{baseBlock: base, Name: firstIdentArg(annTok)} + case AnnResponse: + return &ResponseBlock{baseBlock: base, Name: firstIdentArg(annTok)} + case AnnParameters: + return &ParametersBlock{baseBlock: base, OperationIDs: identArgs(annTok)} + case AnnName: + return &NameBlock{baseBlock: base, Name: firstIdentArg(annTok)} + default: + return &UnboundBlock{baseBlock: base} + } +} + +// --- Operation family -------------------------------------------------------- + +//nolint:ireturn // stable seam. +func (s *parseState) parseOperationBlock(annIdx int, annTok Token, kind AnnotationKind) Block { + base := newBaseBlock(kind, annTok.Pos) + + method, path, tags, opID := s.parseOperationArgs(annTok) + + // swagger:route does not allow OPAQUE_YAML; flag if seen. + allowYAML := kind == AnnOperation + for i, t := range s.tokens { + if i == annIdx { + continue + } + if t.Kind == TokenOpaqueYaml && !allowYAML { + s.emit(Errorf(t.Pos, CodeUnexpectedToken, + "OPAQUE_YAML body is not legal under swagger:route")) + continue + } + s.consumeBodyToken(base, t, kind) + } + s.finaliseBase(base) + + if kind == AnnRoute { + return &RouteBlock{ + baseBlock: base, + Method: method, + Path: path, + Tags: tags, + OpID: opID, + } + } + return &InlineOperationBlock{ + baseBlock: base, + Method: method, + Path: path, + Tags: tags, + OpID: opID, + } +} + +// parseOperationArgs extracts METHOD, /path, [tags…], OperationID. +// 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. + if len(args) > 0 && args[0].Kind == TokenHTTPMethod { + method = args[0].Text + args = args[1:] + } else { + s.emit(Errorf(annTok.Pos, CodeMalformedOperation, + "swagger:%s: missing or invalid HTTP method", annTok.Name)) + } + // Path. + if len(args) > 0 && args[0].Kind == TokenURLPath { + path = args[0].Text + args = args[1:] + } else { + s.emit(Errorf(annTok.Pos, CodeMalformedOperation, + "swagger:%s: missing or invalid URL path", annTok.Name)) + } + // Trailing ident is the OpID; any preceding idents are tags. + if len(args) == 0 { + s.emit(Errorf(annTok.Pos, CodeMalformedOperation, + "swagger:%s: missing operation id", annTok.Name)) + return method, path, nil, "" + } + last := args[len(args)-1] + if last.Kind != TokenIdentName { + s.emit(Errorf(last.Pos, CodeMalformedOperation, + "swagger:%s: trailing operation id must be an identifier", annTok.Name)) + } + opID = last.Text + args = args[:len(args)-1] + for _, a := range args { + if a.Kind == TokenIdentName { + tags = append(tags, a.Text) + } + } + return method, path, tags, opID +} + +// --- Meta family ------------------------------------------------------------- + +//nolint:ireturn // stable seam. +func (s *parseState) parseMetaBlock(annIdx int, annTok Token) Block { + base := newBaseBlock(AnnMeta, annTok.Pos) + for i, t := range s.tokens { + if i == annIdx { + continue + } + s.consumeBodyToken(base, t, AnnMeta) + } + s.finaliseBase(base) + return &MetaBlock{baseBlock: base} +} + +// --- Classifier family ------------------------------------------------------- + +//nolint:ireturn // stable seam. +func (s *parseState) parseClassifierBlock(annIdx int, annTok Token, kind AnnotationKind) Block { + base := newBaseBlock(kind, annTok.Pos) + + // Classifier bodies are prose-only. EnumDeclBlock additionally + // allows a multi-line RAW_VALUE_ENUM body. Other body content + // surfaces as a context-invalid warning. + var enumBody string + for i, t := range s.tokens { + if i == annIdx { + continue + } + switch t.Kind { + case TokenTitle, TokenDesc, TokenBlank, TokenEOF, TokenAnnotation: + // Prose / structural — counted by extractTitleDesc. + case TokenRawValueBody: + if kind == AnnEnum && t.Keyword == "enum" { + enumBody = t.Body + continue + } + s.emit(Warnf(t.Pos, CodeContextInvalid, + "keyword %q not valid under swagger:%s", t.Keyword, kind)) + case TokenRawBlockBody: + s.emit(Warnf(t.Pos, CodeContextInvalid, + "keyword %q not valid under swagger:%s", t.Keyword, kind)) + case TokenKeyword: + s.emit(Warnf(t.Pos, CodeContextInvalid, + "keyword %q not valid under swagger:%s", t.Name, kind)) + case TokenOpaqueYaml: + s.emit(Warnf(t.Pos, CodeUnexpectedToken, + "YAML body not legal under swagger:%s", kind)) + default: + // ignored kind + } + } + + // Argument validation runs before finaliseBase so its diagnostics + // reach the returned Block. + switch kind { + case AnnEnum: + form, name, _, valuesArgs := splitEnumArgs(annTok) + if name == "" && len(valuesArgs) == 0 && enumBody == "" { + s.emit(Errorf(annTok.Pos, CodeMissingRequiredArg, + "swagger:enum requires a name and/or a value list")) + } + s.finaliseBase(base) + return &EnumDeclBlock{ + baseBlock: base, + Name: name, + InlineForm: form, + InlineArgs: valuesArgs, + BodyValues: enumBody, + } + case AnnStrfmt: + if firstIdentArg(annTok) == "" { + s.emit(Errorf(annTok.Pos, CodeMissingRequiredArg, + "swagger:strfmt requires a name argument")) + } + case AnnDefaultName: + if !annTok.HasArg(1) { + s.emit(Errorf(annTok.Pos, CodeMissingRequiredArg, + "swagger:default requires a value argument")) + } + case AnnType: + if len(annTok.Args) == 0 { + s.emit(Errorf(annTok.Pos, CodeMissingRequiredArg, + "swagger:type requires a type-reference argument")) + } else if annTok.Args[0].Kind != TokenTypeRef { + s.emit(Errorf(annTok.Args[0].Pos, CodeInvalidTypeRef, + "swagger:type: %q is not a recognised type reference", annTok.Args[0].Text)) + } + case AnnAllOf, AnnIgnore, AnnAlias, AnnFile: + // Optional / no args. + default: + // ignored annotation + } + s.finaliseBase(base) + return &ClassifierBlock{baseBlock: base, Args: annTok.Args} +} + +// splitEnumArgs reconstructs the (form, name, name-pos, value-tokens) +// from a TokenAnnotation produced for swagger:enum. +func splitEnumArgs(annTok Token) (enumArgsForm, string, token.Position, []Token) { + if len(annTok.Args) == 0 { + return enumFormEmpty, "", annTok.Pos, nil + } + + first := annTok.Args[0] + + // Bracketed-only — single JSON_VALUE arg, no name. + if len(annTok.Args) == 1 && first.Kind == TokenJSONValue { + return enumFormBracketedOnly, "", first.Pos, annTok.Args + } + // Plain-only — single COMMA_LIST_VALUE arg, no name. + if len(annTok.Args) == 1 && first.Kind == TokenCommaListValue { + return enumFormPlainOnly, "", first.Pos, annTok.Args + } + // Name-only — single IDENT_NAME arg. + if len(annTok.Args) == 1 && first.Kind == TokenIdentName { + return enumFormNameOnly, first.Text, first.Pos, nil + } + // Name + bracketed. + if len(annTok.Args) == 2 && first.Kind == TokenIdentName && annTok.Args[1].Kind == TokenJSONValue { + return enumFormNamePlusBracketed, first.Text, first.Pos, annTok.Args[1:] + } + // Name + plain. + if len(annTok.Args) == 2 && first.Kind == TokenIdentName && annTok.Args[1].Kind == TokenCommaListValue { + return enumFormNamePlusPlain, first.Text, first.Pos, annTok.Args[1:] + } + return enumFormEmpty, "", annTok.Pos, annTok.Args +} + +// firstIdentArg returns the Text of the first IDENT_NAME-typed arg, or "". +func firstIdentArg(annTok Token) string { + for _, a := range annTok.Args { + if a.Kind == TokenIdentName { + return a.Text + } + } + return "" +} + +// identArgs returns the Text of every IDENT_NAME-typed arg in source order. +func identArgs(annTok Token) []string { + out := make([]string, 0, len(annTok.Args)) + for _, a := range annTok.Args { + if a.Kind == TokenIdentName { + out = append(out, a.Text) + } + } + return out +} + +// --- Body-token consumption (shared across families) ----------------------- + +// consumeBodyToken folds one token from the stream into the block's +// state. Prose, blank, EOF, and the annotation are no-ops here (they +// are consumed by extractTitleDesc / dispatch). +func (s *parseState) consumeBodyToken(base *baseBlock, t Token, kind AnnotationKind) { + switch t.Kind { + case TokenEOF, TokenBlank, TokenTitle, TokenDesc, TokenAnnotation: + // Handled elsewhere. + case TokenKeyword: + s.emitInlineKeyword(base, t, kind) + case TokenRawBlockBody: + s.emitRawBlock(base, t, kind) + case TokenRawValueBody: + s.emitRawValue(base, t, kind) + case TokenOpaqueYaml: + base.yamlBlocks = append(base.yamlBlocks, RawYAML{ + Pos: t.Pos, + Text: t.Body, + Truncated: t.Truncated, + }) + if t.Truncated { + s.emit(Errorf(t.Pos, CodeUnterminatedYAML, + "YAML body opened with --- but never closed")) + } + default: + // Stray value-only tokens (e.g. trailing IDENT_NAME with no + // owning keyword) are not legal at the body level. + s.emit(Warnf(t.Pos, CodeUnexpectedToken, + "unexpected %s token", t.Kind)) + } +} + +// emitInlineKeyword stores an inline-value keyword as a Property. +func (s *parseState) emitInlineKeyword(base *baseBlock, t Token, kind AnnotationKind) { + kw, ok := Lookup(t.Name) + if !ok { + // Lexer should never emit TokenKeyword for unknown keywords, + // but guard defensively. + return + } + if !contextLegal(kw, kind) { + s.emit(Warnf(t.Pos, CodeContextInvalid, + "keyword %q not valid under swagger:%s (legal in: %s)", + kw.Name, kind, formatContexts(kw))) + } + prop := Property{ + Keyword: kw, + Pos: t.Pos, + Value: t.Text, + ItemsDepth: t.ItemsDepth, + Typed: s.typeInlineValue(kw, t), + } + base.properties = append(base.properties, prop) +} + +// typeInlineValue converts the typed argument token's payload into a +// TypedValue per the keyword's declared shape. Failures emit +// non-fatal diagnostics; on failure Typed.Type stays at ShapeNone so +// consumers can distinguish "no conversion" from "zero converted". +func (s *parseState) typeInlineValue(kw Keyword, t Token) TypedValue { + if len(t.Args) == 0 { + return TypedValue{} + } + arg := t.Args[0] + switch kw.Shape { + case ShapeNumber: + op, rest := splitCmpOperator(arg.Text) + n, err := strconv.ParseFloat(strings.TrimSpace(rest), 64) + if err != nil { + s.emit(Errorf(arg.Pos, CodeInvalidNumber, + "%s: %q is not a valid number", kw.Name, arg.Text)) + return TypedValue{} + } + return TypedValue{Type: ShapeNumber, Op: op, Number: n} + case ShapeInt: + i, err := strconv.ParseInt(strings.TrimSpace(arg.Text), 10, 64) + if err != nil { + s.emit(Errorf(arg.Pos, CodeInvalidInteger, + "%s: %q is not a valid integer", kw.Name, arg.Text)) + return TypedValue{} + } + return TypedValue{Type: ShapeInt, Integer: i} + case ShapeBool: + b, ok := parseBool(arg.Text) + if !ok { + s.emit(Errorf(arg.Pos, CodeInvalidBoolean, + "%s: %q is not a valid boolean (expected true or false)", kw.Name, arg.Text)) + return TypedValue{} + } + return TypedValue{Type: ShapeBool, Boolean: b} + case ShapeEnumOption: + for _, allowed := range kw.Values { + if strings.EqualFold(arg.Text, allowed) { + return TypedValue{Type: ShapeEnumOption, String: allowed} + } + } + s.emit(Errorf(arg.Pos, CodeInvalidEnumOption, + "%s: %q is not one of {%s}", + kw.Name, arg.Text, strings.Join(kw.Values, ", "))) + return TypedValue{} + case ShapeNone, ShapeString, ShapeCommaList, ShapeRawBlock, ShapeRawValue: + return TypedValue{} + default: + // ignored shape + } + return TypedValue{} +} + +// emitRawBlock stores a multi-line raw-block body and, for extensions +// blocks, parses out the x-* entries. +func (s *parseState) emitRawBlock(base *baseBlock, t Token, kind AnnotationKind) { + kw, ok := Lookup(t.Keyword) + if !ok { + return + } + if !contextLegal(kw, kind) { + s.emit(Warnf(t.Pos, CodeContextInvalid, + "keyword %q not valid under swagger:%s (legal in: %s)", + kw.Name, kind, formatContexts(kw))) + } + prop := Property{ + Keyword: kw, + Pos: t.Pos, + Body: t.Body, + Raw: t.Raw, + Typed: TypedValue{Type: ShapeRawBlock}, + ItemsDepth: t.ItemsDepth, + } + base.properties = append(base.properties, prop) + + if kw.Name == "extensions" || kw.Name == "infoExtensions" { + s.collectExtensionsFromBody(base, t, kw.Name) + } + if kw.Name == KwSecurity { + base.security = security.Parse(t.Body) + } +} + +// emitRawValue stores a raw-value body keyword. +func (s *parseState) emitRawValue(base *baseBlock, t Token, kind AnnotationKind) { + kw, ok := Lookup(t.Keyword) + if !ok { + return + } + if !contextLegal(kw, kind) { + s.emit(Warnf(t.Pos, CodeContextInvalid, + "keyword %q not valid under swagger:%s (legal in: %s)", + kw.Name, kind, formatContexts(kw))) + } + prop := Property{ + Keyword: kw, + Pos: t.Pos, + Value: t.Body, + Body: t.Body, + Raw: t.Raw, + Typed: TypedValue{Type: ShapeRawValue}, + ItemsDepth: t.ItemsDepth, + } + base.properties = append(base.properties, prop) +} + +// collectExtensionsFromBody parses the body of an `extensions:` raw +// block via the typed-extensions service and registers one Extension +// per top-level x-* entry on the block, carrying its YAML-typed value +// (`bool` / `float64` / `string` / `[]any` / `map[string]any`). +// +// Non-x-* keys emit a CodeInvalidAnnotation warning and are dropped. +// 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. +func (s *parseState) collectExtensionsFromBody(base *baseBlock, t Token, source string) { + data, err := yaml.TypedExtensions(t.Body) + if err != nil { + s.emit(Warnf(t.Pos, CodeInvalidYAMLExtensions, + "extensions block: %v", err)) + return + } + for name, value := range data { + if !isExtensionName(name) { + // 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)) + continue + } + base.extensions = append(base.extensions, Extension{ + Name: name, + Source: source, + Pos: t.Pos, + Value: value, + }) + } +} + +// isExtensionName reports whether s is a well-formed x-* / X-* name. +func isExtensionName(s string) bool { + const minExtNameLen = 3 + if len(s) < minExtNameLen { + return false + } + if (s[0] != 'x' && s[0] != 'X') || s[1] != '-' { + return false + } + return true +} + +// --- shared helpers --------------------------------------------------------- + +// contextLegal reports whether kw may appear under the given annotation +// kind. Returns true when the keyword's contexts overlap with the +// kind's allowed contexts; returns true unconditionally when the kind +// has no parser-layer policy (e.g. UnboundBlock). +func contextLegal(kw Keyword, kind AnnotationKind) bool { + allowed := allowedContexts(kind) + if allowed == nil { + return true + } + for _, c := range kw.Contexts { + if slices.Contains(allowed, c) { + return true + } + } + return false +} + +// allowedContexts maps an annotation kind to the keyword contexts +// that are legal under it. 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: + return []KeywordContext{CtxSchema, CtxItems} + case AnnParameters: + return []KeywordContext{CtxParam, CtxSchema, CtxItems} + case AnnResponse: + return []KeywordContext{CtxResponse, CtxSchema, CtxHeader, CtxItems} + case AnnOperation: + return []KeywordContext{CtxOperation, CtxParam, CtxSchema, CtxHeader, CtxItems, CtxResponse} + case AnnRoute: + return []KeywordContext{CtxRoute, CtxParam, CtxSchema, CtxHeader, CtxItems, CtxResponse} + case AnnMeta: + return []KeywordContext{CtxMeta, CtxSchema} + case AnnUnknown, + AnnStrfmt, AnnAlias, AnnName, AnnAllOf, AnnEnum, + AnnIgnore, AnnDefaultName, AnnType, AnnFile: + return nil + default: + return nil + } +} + +// formatContexts renders a keyword's legal contexts for diagnostics. +func formatContexts(kw Keyword) string { + parts := make([]string, len(kw.Contexts)) + for i, c := range kw.Contexts { + parts[i] = c.String() + } + return strings.Join(parts, ", ") +} + +// splitCmpOperator strips a leading comparison operator from a number +// value. Supports the `maximum: <5` form. +func splitCmpOperator(s string) (op, rest string) { + s = strings.TrimLeft(s, " \t") + for _, c := range []string{"<=", ">=", "<", ">", "="} { + if strings.HasPrefix(s, c) { + return c, s[len(c):] + } + } + return "", s +} + +// parseBool accepts only "true" or "false" (case-insensitive). stdlib +// strconv.ParseBool is too lenient for the annotation grammar. +func parseBool(s string) (bool, bool) { + s = strings.TrimSpace(s) + switch { + case strings.EqualFold(s, "true"): + return true, true + case strings.EqualFold(s, "false"): + return false, true + default: + return false, false + } +} diff --git a/internal/parsers/grammar/parser_test.go b/internal/parsers/grammar/parser_test.go new file mode 100644 index 0000000..a83c283 --- /dev/null +++ b/internal/parsers/grammar/parser_test.go @@ -0,0 +1,483 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "go/token" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// parseString runs the full parser on a // -less comment block. +// +//nolint:ireturn // this test helper is here precisely to mock the interface. +func parseString(t *testing.T, src string) Block { + t.Helper() + pos := token.Position{Filename: "test.go", Line: 1, Column: 1} + return (&DefaultParser{}).ParseText(src, pos) +} + +func TestParser_ModelBlock_NameFromAnnotation(t *testing.T) { + b := parseString(t, "swagger:model Pet") + mb, ok := b.(*ModelBlock) + require.True(t, ok, "expected *ModelBlock, got %T", b) + assert.Equal(t, "Pet", mb.Name) + assert.Equal(t, AnnModel, mb.AnnotationKind()) +} + +func TestParser_ResponseBlock_OptionalName(t *testing.T) { + b1 := parseString(t, "swagger:response petResp") + rb1, ok := b1.(*ResponseBlock) + require.True(t, ok) + assert.Equal(t, "petResp", rb1.Name) + + b2 := parseString(t, "swagger:response") + rb2, ok := b2.(*ResponseBlock) + require.True(t, ok) + assert.Empty(t, rb2.Name) +} + +func TestParser_NameBlock_CapturesIdentArg(t *testing.T) { + b := parseString(t, "swagger:name jsonFieldName") + nb, ok := b.(*NameBlock) + require.True(t, ok, "expected *NameBlock, got %T", b) + assert.Equal(t, "jsonFieldName", nb.Name) + assert.Equal(t, AnnName, nb.AnnotationKind()) + assert.Empty(t, b.Diagnostics()) +} + +func TestParser_NameBlock_MissingArgEmitsDiagnostic(t *testing.T) { + b := parseString(t, "swagger:name") + nb, ok := b.(*NameBlock) + require.True(t, ok, "expected *NameBlock, got %T", b) + assert.Empty(t, nb.Name) + require.NotEmpty(t, b.Diagnostics()) + assert.Equal(t, CodeMissingRequiredArg, b.Diagnostics()[0].Code) +} + +// parseAllString runs ParseAll on a // -less comment block. +func parseAllString(t *testing.T, src string) []Block { + t.Helper() + pos := token.Position{Filename: "test.go", Line: 1, Column: 1} + lines := preprocessText(src, pos) + tokens := Lex(lines) + return (&DefaultParser{}).parseAllTokens(tokens) +} + +func TestParser_ParseAll_SingleAnnotation(t *testing.T) { + blocks := parseAllString(t, "swagger:model Pet") + require.Len(t, blocks, 1) + mb, ok := blocks[0].(*ModelBlock) + require.True(t, ok) + assert.Equal(t, "Pet", mb.Name) +} + +func TestParser_ParseAll_NoAnnotation(t *testing.T) { + blocks := parseAllString(t, "Just a docstring.") + require.Len(t, blocks, 1) + _, ok := blocks[0].(*UnboundBlock) + require.True(t, ok) + assert.Equal(t, "Just a docstring.", blocks[0].Title()) +} + +func TestParser_ParseAll_TwoAnnotations(t *testing.T) { + src := `Pet model documentation. + +swagger:model Pet +swagger:strfmt date-time` + blocks := parseAllString(t, src) + require.Len(t, blocks, 2) + + // First block: ModelBlock owns the pre-annotation prose. + mb, ok := blocks[0].(*ModelBlock) + require.True(t, ok, "expected blocks[0] *ModelBlock, got %T", blocks[0]) + assert.Equal(t, "Pet", mb.Name) + assert.Equal(t, "Pet model documentation.", mb.PreambleTitle()) + + // Second block: ClassifierBlock for swagger:strfmt. + cb, ok := blocks[1].(*ClassifierBlock) + require.True(t, ok, "expected blocks[1] *ClassifierBlock, got %T", blocks[1]) + arg, hasArg := cb.AnnotationArg() + require.True(t, hasArg) + assert.Equal(t, "date-time", arg) +} + +func TestParser_ParseAll_ThreeAnnotations(t *testing.T) { + src := `swagger:model Bag +swagger:strfmt +swagger:ignore` + blocks := parseAllString(t, src) + require.Len(t, blocks, 3) + + _, ok := blocks[0].(*ModelBlock) + require.True(t, ok, "expected blocks[0] *ModelBlock, got %T", blocks[0]) + cb1, ok := blocks[1].(*ClassifierBlock) + require.True(t, ok) + assert.Equal(t, AnnStrfmt, cb1.AnnotationKind()) + cb2, ok := blocks[2].(*ClassifierBlock) + require.True(t, ok) + assert.Equal(t, AnnIgnore, cb2.AnnotationKind()) +} + +func TestBlock_AnnotationArg(t *testing.T) { + cases := []struct { + name string + src string + wantArg string + wantOK bool + }{ + {"model with name", "swagger:model Pet", "Pet", true}, + {"bare model", "swagger:model", "", false}, + {"response with name", "swagger:response petResp", "petResp", true}, + {"bare response", "swagger:response", "", false}, + {"name", "swagger:name jsonField", "jsonField", true}, + {"strfmt", "swagger:strfmt date-time", "date-time", true}, + {"bare strfmt", "swagger:strfmt", "", false}, + {"ignore", "swagger:ignore", "", false}, + {"alias", "swagger:alias", "", false}, + {"unbound prose", "Just a docstring.", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + b := parseString(t, tc.src) + arg, ok := b.AnnotationArg() + assert.Equal(t, tc.wantArg, arg) + assert.Equal(t, tc.wantOK, ok) + }) + } +} + +func TestParser_ParametersBlock_RequiresAtLeastOneArg(t *testing.T) { + b := parseString(t, "swagger:parameters listPets getPet") + pb, ok := b.(*ParametersBlock) + require.True(t, ok) + assert.Equal(t, []string{"listPets", "getPet"}, pb.OperationIDs) + assert.Empty(t, b.Diagnostics()) + + bad := parseString(t, "swagger:parameters") + pbad, ok := bad.(*ParametersBlock) + require.True(t, ok) + assert.Empty(t, pbad.OperationIDs) + require.NotEmpty(t, bad.Diagnostics()) + assert.Equal(t, CodeMissingRequiredArg, bad.Diagnostics()[0].Code) +} + +func TestParser_RouteBlock_BasicArgs(t *testing.T) { + src := `Lists pets. + +swagger:route GET /pets pets listPets + +Consumes: + - application/json + +Produces: + - application/json` + b := parseString(t, src) + rb, ok := b.(*RouteBlock) + require.True(t, ok, "expected *RouteBlock, got %T", b) + assert.Equal(t, "GET", rb.Method) + assert.Equal(t, "/pets", rb.Path) + assert.Equal(t, []string{"pets"}, rb.Tags) + assert.Equal(t, "listPets", rb.OpID) + assert.Equal(t, "Lists pets.", rb.Title()) + + // Body raw blocks present as Properties. + consumes, ok := rb.GetList("consumes") + require.True(t, ok) + assert.Contains(t, consumes[0], "application/json") +} + +func TestParser_RouteBlock_RejectsYAMLBody(t *testing.T) { + src := `swagger:route GET /pets pets listPets + +--- +parameters: + - name: id +---` + b := parseString(t, src) + require.NotEmpty(t, b.Diagnostics()) + found := false + for _, d := range b.Diagnostics() { + if d.Code == CodeUnexpectedToken { + found = true + } + } + assert.True(t, found, "expected an unexpected-token diagnostic for OPAQUE_YAML under swagger:route") +} + +func TestParser_InlineOperation_AllowsYAMLBody(t *testing.T) { + src := `swagger:operation GET /pets pets listPets + +--- +parameters: + - name: id + in: query +---` + b := parseString(t, src) + ob, ok := b.(*InlineOperationBlock) + require.True(t, ok) + assert.Equal(t, "listPets", ob.OpID) + + yamlCount := 0 + for range ob.YAMLBlocks() { + yamlCount++ + } + assert.Equal(t, 1, yamlCount) + + for _, d := range b.Diagnostics() { + assert.NotEqual(t, CodeUnexpectedToken, d.Code) + } +} + +func TestParser_MetaBlock_KeywordsAndYAML(t *testing.T) { + src := `Booking API. + +Version: 1.0.0 +Host: api.example.com + +Consumes: + - application/json + +--- +servers: + - url: https://api.example.com/v1 +--- + +swagger:meta` + b := parseString(t, src) + mb, ok := b.(*MetaBlock) + require.True(t, ok) + assert.Equal(t, "Booking API.", mb.Title()) + + v, ok := mb.GetString("version") + require.True(t, ok) + assert.Equal(t, "1.0.0", v) + + h, ok := mb.GetString("host") + require.True(t, ok) + assert.Equal(t, "api.example.com", h) + + cons, ok := mb.GetList("consumes") + require.True(t, ok) + assert.NotEmpty(t, cons) + + yamlCount := 0 + for range mb.YAMLBlocks() { + yamlCount++ + } + assert.Equal(t, 1, yamlCount) +} + +func TestParser_ClassifierBlock_Strfmt(t *testing.T) { + b := parseString(t, "A MAC address.\n\nswagger:strfmt mac") + cb, ok := b.(*ClassifierBlock) + require.True(t, ok) + assert.Equal(t, AnnStrfmt, cb.AnnotationKind()) + require.Len(t, cb.Args, 1) + assert.Equal(t, "mac", cb.Args[0].Text) + assert.Empty(t, cb.Diagnostics()) +} + +func TestParser_ClassifierBlock_StrfmtMissingArg(t *testing.T) { + b := parseString(t, "swagger:strfmt") + require.NotEmpty(t, b.Diagnostics()) + assert.Equal(t, CodeMissingRequiredArg, b.Diagnostics()[0].Code) +} + +func TestParser_ClassifierBlock_TypeWithVocabulary(t *testing.T) { + b := parseString(t, "swagger:type string") + require.Empty(t, b.Diagnostics()) + + bad := parseString(t, "swagger:type custom") + require.NotEmpty(t, bad.Diagnostics()) + assert.Equal(t, CodeInvalidTypeRef, bad.Diagnostics()[0].Code) +} + +func TestParser_EnumDecl_NameOnly(t *testing.T) { + b := parseString(t, "swagger:enum Priority") + eb, ok := b.(*EnumDeclBlock) + require.True(t, ok) + assert.Equal(t, "Priority", eb.Name) + assert.Equal(t, enumFormNameOnly, eb.InlineForm) +} + +func TestParser_EnumDecl_PlainList(t *testing.T) { + b := parseString(t, "swagger:enum 1, 2, 3") + eb, ok := b.(*EnumDeclBlock) + require.True(t, ok) + assert.Empty(t, eb.Name) + assert.Equal(t, enumFormPlainOnly, eb.InlineForm) +} + +func TestParser_EnumDecl_Empty(t *testing.T) { + b := parseString(t, "swagger:enum") + require.NotEmpty(t, b.Diagnostics()) + assert.Equal(t, CodeMissingRequiredArg, b.Diagnostics()[0].Code) +} + +func TestParser_UnboundBlock(t *testing.T) { + src := `Name of the user. +required: true +maxLength: 64` + b := parseString(t, src) + ub, ok := b.(*UnboundBlock) + require.True(t, ok) + assert.Equal(t, AnnUnknown, ub.AnnotationKind()) + // UnboundBlocks now run title/desc classification too — first line + // ending in punctuation is title (heuristic 2). Required for the + // schema builder's PreambleTitle path on indirectly-referenced + // non-annotated types (interfaces / aliases). + assert.Equal(t, "Name of the user.", ub.Title()) + assert.Empty(t, ub.Description()) + + required, ok := ub.GetBool("required") + require.True(t, ok) + assert.True(t, required) + + maxLen, ok := ub.GetInt("maxLength") + require.True(t, ok) + assert.Equal(t, int64(64), maxLen) +} + +func TestParser_SchemaBody_NumericValidation(t *testing.T) { + src := `swagger:model Foo + +maximum: 100 +minimum: <0` + b := parseString(t, src) + mb, ok := b.(*ModelBlock) + require.True(t, ok) + + maximum, ok := mb.GetFloat("maximum") + require.True(t, ok) + assert.InDelta(t, 100.0, maximum, 0) + + // Operator preserved on Property.Typed. + for p := range mb.Properties() { + if p.Keyword.Name == "minimum" { + assert.Equal(t, "<", p.Typed.Op) + assert.InDelta(t, 0.0, p.Typed.Number, 0) + } + } +} + +func TestParser_SchemaBody_InvalidNumber(t *testing.T) { + b := parseString(t, "swagger:model Foo\n\nmaximum: notanumber") + found := false + for _, d := range b.Diagnostics() { + if d.Code == CodeInvalidNumber { + found = true + } + } + assert.True(t, found) +} + +func TestParser_SchemaBody_ExtensionsBlockExtractsXEntries(t *testing.T) { + src := `swagger:model Foo + +Extensions: + x-flag: true + x-name: hello` + b := parseString(t, src) + + count := 0 + for ext := range b.Extensions() { + count++ + switch ext.Name { + case "x-flag": + // YAML-typed: unquoted `true` is a bool. + assert.Equal(t, true, ext.Value) + case "x-name": + // YAML-typed: unquoted `hello` is a string. + assert.Equal(t, "hello", ext.Value) + } + } + assert.Equal(t, 2, count) +} + +// TestParser_SchemaBody_ExtensionsBlockTypedNested asserts that +// nested YAML mappings surface as typed map[string]any, not as +// yaml.v3's map[any]any or as a flat string. Closes the round-2 +// promise of `.claude/plans/typed-extensions.md`. +func TestParser_SchemaBody_ExtensionsBlockTypedNested(t *testing.T) { + src := `swagger:model Foo + +Extensions: + x-config: + enabled: true + threshold: 0.5 + tags: [a, b, c]` + b := parseString(t, src) + + var found bool + for ext := range b.Extensions() { + if ext.Name != "x-config" { + continue + } + found = true + cfg, ok := ext.Value.(map[string]any) + require.True(t, ok, "x-config: want map[string]any, got %T", ext.Value) + assert.Equal(t, true, cfg["enabled"]) + assert.Equal(t, 0.5, cfg["threshold"]) + tags, ok := cfg["tags"].([]any) + require.True(t, ok, "x-config.tags: want []any, got %T", cfg["tags"]) + assert.Equal(t, []any{"a", "b", "c"}, tags) + } + assert.True(t, found, "x-config Extension should be present") +} + +// TestParser_SchemaBody_ExtensionsBlockMalformedYAMLEmitsDiagnostic +// asserts the new CodeInvalidYAMLExtensions code fires when the body +// fails YAML parsing. +func TestParser_SchemaBody_ExtensionsBlockMalformedYAMLEmitsDiagnostic(t *testing.T) { + src := `swagger:model Foo + +Extensions: + x-broken: [unclosed` + b := parseString(t, src) + + count := 0 + for range b.Extensions() { + count++ + } + assert.Equal(t, 0, count, "malformed YAML: no Extension entries should be emitted") + + var sawDiag bool + for _, d := range b.Diagnostics() { + if d.Code == CodeInvalidYAMLExtensions { + sawDiag = true + break + } + } + assert.True(t, sawDiag, "expected CodeInvalidYAMLExtensions diagnostic") +} + +func TestParser_SchemaBody_DefaultRawValue(t *testing.T) { + b := parseString(t, "swagger:model Foo\n\ndefault: hello") + def, ok := b.GetString("default") + require.True(t, ok) + assert.Equal(t, "hello", def) +} + +func TestParser_SchemaBody_EnumRawValue(t *testing.T) { + src := `swagger:model Foo + +enum: a, b, c` + b := parseString(t, src) + v, ok := b.GetString("enum") + require.True(t, ok) + assert.Equal(t, "a, b, c", v) +} + +func TestParser_RouteBlock_GodocPrefix(t *testing.T) { + src := `GetPets swagger:route GET /pets pets listPets` + b := parseString(t, src) + rb, ok := b.(*RouteBlock) + require.True(t, ok) + assert.Equal(t, "GET", rb.Method) + assert.Equal(t, "/pets", rb.Path) + assert.Equal(t, "listPets", rb.OpID) +} diff --git a/internal/parsers/grammar/synthetic.go b/internal/parsers/grammar/synthetic.go new file mode 100644 index 0000000..f5c6264 --- /dev/null +++ b/internal/parsers/grammar/synthetic.go @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +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. +// +// 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, + title: title, + description: description, + preambleTitle: title, + preambleDescription: description, + properties: props, + } +} From 041de6686f3ed2c7a32e96565c079d17db5e6f6f Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:36:40 +0200 Subject: [PATCH 04/22] feat(grammar): Walker visitor + Diagnostic surface Walker is the visitor that drives parsed Blocks through caller-supplied callbacks (one per keyword class, plus a diagnostic sink). Builders register their typed handlers once and let the Walker schedule them in source order. Diagnostic types carry severity + positioned source span for downstream consumption (typically the consumer's OnDiagnostic callback). Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- internal/parsers/grammar/diagnostic.go | 116 +++++++++++++ internal/parsers/grammar/walker.go | 142 +++++++++++++++ internal/parsers/grammar/walker_test.go | 218 ++++++++++++++++++++++++ 3 files changed, 476 insertions(+) create mode 100644 internal/parsers/grammar/diagnostic.go create mode 100644 internal/parsers/grammar/walker.go create mode 100644 internal/parsers/grammar/walker_test.go diff --git a/internal/parsers/grammar/diagnostic.go b/internal/parsers/grammar/diagnostic.go new file mode 100644 index 0000000..6771673 --- /dev/null +++ b/internal/parsers/grammar/diagnostic.go @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "fmt" + "go/token" +) + +// Severity classifies a Diagnostic's seriousness. The parser never +// aborts; callers (analyzers, LSP, the CLI) decide policy by +// severity. See README §diagnostics. +type Severity int + +const ( + SeverityError Severity = iota + SeverityWarning + SeverityHint +) + +// String renders a Severity for logs and CLI output. +func (s Severity) String() string { + switch s { + case SeverityError: + return "error" + case SeverityWarning: + return "warning" + case SeverityHint: + return "hint" + default: + return fmt.Sprintf("severity(%d)", int(s)) + } +} + +// Code is a stable identifier for a class of Diagnostic. +type Code string + +// Diagnostic codes. The `parse.*` prefix marks lexer/parser-level +// observations; `validate.*` marks semantic-validation observations +// emitted by the builder layer (typically through the +// internal/builders/validations package). +const ( + CodeInvalidNumber Code = "parse.invalid-number" + CodeInvalidInteger Code = "parse.invalid-integer" + CodeInvalidBoolean Code = "parse.invalid-boolean" + CodeInvalidEnumOption Code = "parse.invalid-enum-option" + CodeContextInvalid Code = "parse.context-invalid" + CodeInvalidExtension Code = "parse.invalid-extension-name" + // CodeInvalidYAMLExtensions fires when the body of an + // `extensions:` raw block fails YAML parsing. The block is + // skipped (no Extension entries emitted) and a warning is raised. + CodeInvalidYAMLExtensions Code = "parse.invalid-yaml-extensions" + CodeUnterminatedYAML Code = "parse.unterminated-yaml" + CodeInvalidAnnotation Code = "parse.invalid-annotation" + CodeInvalidTypeRef Code = "parse.invalid-type-ref" + CodeUnexpectedToken Code = "parse.unexpected-token" + CodeMalformedOperation Code = "parse.malformed-operation" + CodeMissingRequiredArg Code = "parse.missing-required-arg" + + // CodeShapeMismatch fires when a keyword is applied to a schema + // whose resolved Swagger type doesn't match the keyword's domain + // (e.g. `pattern: ^a$` on an integer field). Emitted by + // internal/builders/validations.IsLegalForType callers. + CodeShapeMismatch Code = "validate.shape-mismatch" + + // CodeAmbiguousEmbed fires when two embedded types of a parent + // struct (or struct embed-chains at the same depth) both promote + // a property with the same JSON name but different Go names. Go's + // own rule is to not promote such ambiguous fields; codescan + // currently emits a last-write-wins schema regardless. The + // diagnostic surfaces the case so authors can disambiguate. + CodeAmbiguousEmbed Code = "validate.ambiguous-embed" + + // CodeUnsupportedInSimpleSchema fires when the schema builder + // running in SimpleSchema mode produces an outcome that OAS v2 + // does not allow on a parameter/header (object type, $ref, allOf, + // properties, …). The diagnostic is emitted at exit and the + // target is reset to empty `{}` — honest over lossy. Reaching + // this code path typically means a non-body parameter or response + // header was typed as a struct or interface that the recognizer + // cascade couldn't reduce to a primitive. + CodeUnsupportedInSimpleSchema Code = "validate.unsupported-in-simple-schema" +) + +// Diagnostic is one observation about a comment block. +type Diagnostic struct { + Pos token.Position + Severity Severity + Code Code + Message string +} + +// String renders a Diagnostic in compiler-style one-line form. +func (d Diagnostic) String() string { + loc := d.Pos.String() + if loc == "-" || loc == "" { + loc = "" + } + return fmt.Sprintf("%s: %s: %s [%s]", loc, d.Severity, d.Message, d.Code) +} + +// Errorf builds a SeverityError Diagnostic with a formatted message. +func Errorf(pos token.Position, code Code, format string, args ...any) Diagnostic { + return Diagnostic{Pos: pos, Severity: SeverityError, Code: code, Message: fmt.Sprintf(format, args...)} +} + +// Warnf builds a SeverityWarning Diagnostic. +func Warnf(pos token.Position, code Code, format string, args ...any) Diagnostic { + return Diagnostic{Pos: pos, Severity: SeverityWarning, Code: code, Message: fmt.Sprintf(format, args...)} +} + +// Hintf builds a SeverityHint Diagnostic. +func Hintf(pos token.Position, code Code, format string, args ...any) Diagnostic { + return Diagnostic{Pos: pos, Severity: SeverityHint, Code: code, Message: fmt.Sprintf(format, args...)} +} diff --git a/internal/parsers/grammar/walker.go b/internal/parsers/grammar/walker.go new file mode 100644 index 0000000..23af4d3 --- /dev/null +++ b/internal/parsers/grammar/walker.go @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +// Walker is the functional-visitor surface a Block exposes for bulk +// dispatch. Consumers wire only the callbacks they care about — every +// nil field is a silent no-op. +// +// # 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. +type Walker struct { + Title func(s string) + Description func(s string) + + Number func(p Property, val float64, exclusive bool) + Integer func(p Property, val int64) + Bool func(p Property, val bool) + String func(p Property, val string) + Raw func(p Property) + Unknown func(p Property) + + Extension func(ext Extension) + Diagnostic func(d Diagnostic) + + FilterDepth int +} + +// AllDepths is the FilterDepth sentinel meaning "fire property +// callbacks regardless of ItemsDepth". Use it explicitly rather than +// -1 so the intent reads at the call site. +const AllDepths = -1 + +// Walk dispatches one Block through w. Nil callbacks are silently +// ignored. See Walker for the dispatch contract. +// +// Walk reads only from b — it never mutates the Block or its properties. +// Walk is safe to call concurrently on the same Block from multiple +// goroutines as long as the Walker callbacks are themselves safe. +func (b *baseBlock) Walk(w Walker) { + // Block-level diagnostics fire first so consumers see them regardless + // of whether they wired any property callbacks. + if w.Diagnostic != nil { + for _, d := range b.diagnostics { + w.Diagnostic(d) + } + } + + if w.Title != nil && b.title != "" { + w.Title(b.title) + } + if w.Description != nil && b.description != "" { + w.Description(b.description) + } + + for _, p := range b.properties { + if !walkerAcceptsDepth(w.FilterDepth, p.ItemsDepth) { + continue + } + walkerDispatchProperty(w, p) + } + + if w.Extension != nil { + for _, e := range b.extensions { + w.Extension(e) + } + } +} + +// walkerAcceptsDepth reports whether a property at itemsDepth should be +// dispatched given the walker's FilterDepth. AllDepths admits everything. +func walkerAcceptsDepth(filter, itemsDepth int) bool { + if filter == AllDepths { + return true + } + return filter == itemsDepth +} + +// walkerDispatchProperty routes one property to the matching callback. +// Dispatch is by Keyword.Shape (the table-declared shape) rather than +// Typed.Type, so failed-typing properties (Typed.Type == ShapeNone +// with a CodeInvalidNumber diagnostic in tow) still reach their +// shape-typed callback — consumers see the zero payload alongside +// the diagnostic. +// +// Unknown keywords (empty Keyword.Name, no entry in the keyword +// table) take the Unknown path regardless of Shape. See README +// §walker-contract. +func walkerDispatchProperty(w Walker, p Property) { + if p.Keyword.Name == "" { + if w.Unknown != nil { + w.Unknown(p) + } + return + } + + switch p.Keyword.Shape { + case ShapeNumber: + if w.Number != nil { + w.Number(p, p.Typed.Number, isExclusiveOp(p.Typed.Op)) + } + case ShapeInt: + if w.Integer != nil { + w.Integer(p, p.Typed.Integer) + } + case ShapeBool: + if w.Bool != nil { + w.Bool(p, p.Typed.Boolean) + } + case ShapeString: + // String-shaped keywords keep the raw value in p.Value; + // Typed.String is only set for ShapeEnumOption. + if w.String != nil { + w.String(p, p.Value) + } + case ShapeEnumOption: + if w.String != nil { + w.String(p, p.Typed.String) + } + case ShapeNone, ShapeCommaList, ShapeRawBlock, ShapeRawValue: + if w.Raw != nil { + w.Raw(p) + } + default: + if w.Raw != nil { + w.Raw(p) + } + } +} + +// isExclusiveOp reports whether the leading operator on a Number +// value signals exclusive-bound semantics. 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 new file mode 100644 index 0000000..895450f --- /dev/null +++ b/internal/parsers/grammar/walker_test.go @@ -0,0 +1,218 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package grammar + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestWalker_LevelZeroFiresPerShape(t *testing.T) { + src := `swagger:model Foo + +maximum: 100 +maxLength: 64 +required: true +pattern: ^[a-z]+$` + b := parseString(t, src) + + type seen struct { + number []string + integer []string + boolean []string + stringv []string + } + var got seen + + b.Walk(Walker{ + FilterDepth: 0, + Number: func(p Property, _ float64, _ bool) { got.number = append(got.number, p.Keyword.Name) }, + Integer: func(p Property, _ int64) { got.integer = append(got.integer, p.Keyword.Name) }, + Bool: func(p Property, _ bool) { got.boolean = append(got.boolean, p.Keyword.Name) }, + String: func(p Property, _ string) { got.stringv = append(got.stringv, p.Keyword.Name) }, + }) + + assert.Equal(t, []string{"maximum"}, got.number) + assert.Equal(t, []string{"maxLength"}, got.integer) + assert.Equal(t, []string{"required"}, got.boolean) + assert.Equal(t, []string{"pattern"}, got.stringv) +} + +func TestWalker_NumberOpExclusiveSemantics(t *testing.T) { + src := `swagger:model Foo + +maximum: <100 +minimum: >=0` + b := parseString(t, src) + + got := map[string]bool{} + b.Walk(Walker{ + FilterDepth: 0, + Number: func(p Property, _ float64, exclusive bool) { got[p.Keyword.Name] = exclusive }, + }) + + assert.True(t, got["maximum"], "maximum: <100 should be exclusive") + assert.False(t, got["minimum"], "minimum: >=0 should be inclusive") +} + +func TestWalker_FilterDepthFiltersItems(t *testing.T) { + src := `swagger:model Foo + +maximum: 10 +items.maximum: 20 +items.items.maximum: 30` + b := parseString(t, src) + + collect := func(filter int) []float64 { + var out []float64 + b.Walk(Walker{ + FilterDepth: filter, + Number: func(_ Property, val float64, _ bool) { out = append(out, val) }, + }) + return out + } + + assert.Equal(t, []float64{10}, collect(0)) + assert.Equal(t, []float64{20}, collect(1)) + assert.Equal(t, []float64{30}, collect(2)) + assert.Equal(t, []float64{10, 20, 30}, collect(AllDepths)) +} + +func TestWalker_TitleAndDescription(t *testing.T) { + src := `Pet is a domestic animal. + +It has a name and a tag. + +swagger:model Pet` + b := parseString(t, src) + + var title, desc string + b.Walk(Walker{ + Title: func(s string) { title = s }, + Description: func(s string) { desc = s }, + }) + + assert.Equal(t, "Pet is a domestic animal.", title) + assert.Equal(t, "It has a name and a tag.", desc) +} + +func TestWalker_TitleNotFiredWhenEmpty(t *testing.T) { + // UnboundBlock with description but no title (no first-sentence + // terminator before the blank line — single-line docstrings become + // description per parser logic). + src := `swagger:model Foo + +maximum: 10` + b := parseString(t, src) + + called := false + b.Walk(Walker{ + Title: func(_ string) { called = true }, + }) + + assert.False(t, called, "Title callback should not fire when block has no title") +} + +func TestWalker_ExtensionsFire(t *testing.T) { + src := `swagger:model Foo + +Extensions: + x-flag: true + x-name: hello` + b := parseString(t, src) + + var names []string + b.Walk(Walker{ + Extension: func(ext Extension) { names = append(names, ext.Name) }, + }) + + assert.ElementsMatch(t, []string{"x-flag", "x-name"}, names) +} + +func TestWalker_DiagnosticsFire(t *testing.T) { + // "swagger:parameters" with no operationID emits CodeMissingRequiredArg. + b := parseString(t, "swagger:parameters") + + var codes []Code + b.Walk(Walker{ + Diagnostic: func(d Diagnostic) { codes = append(codes, d.Code) }, + }) + + require.NotEmpty(t, codes) + assert.Contains(t, codes, CodeMissingRequiredArg) +} + +func TestWalker_NilCallbacksAreNoops(t *testing.T) { + src := `swagger:model Foo + +maximum: 100 +required: true +pattern: ^a$` + b := parseString(t, src) + + // All callbacks nil — must not panic. + assert.NotPanics(t, func() { b.Walk(Walker{FilterDepth: 0}) }) +} + +func TestProperty_IsTyped(t *testing.T) { + src := `swagger:model Foo + +maximum: 100 +maxLength: 64 +required: true +pattern: ^[a-z]+$ +default: hello` + b := parseString(t, src) + + got := map[string]bool{} + for p := range b.Properties() { + got[p.Keyword.Name] = p.IsTyped() + } + + // Pre-typed (lexer populated Typed.Number/Integer/Boolean): + assert.True(t, got["maximum"], "maximum has Typed.Number") + assert.True(t, got["maxLength"], "maxLength has Typed.Integer") + assert.True(t, got["required"], "required has Typed.Boolean") + + // Raw — Typed.Type stays ShapeNone: + assert.False(t, got["pattern"], "pattern keeps raw in Value, Typed unused") + assert.False(t, got["default"], "default needs schema-type coercion outside grammar") +} + +func TestProperty_IsTyped_FailedTypingIsFalse(t *testing.T) { + // Failed typing: lexer rejects "notanumber" as ShapeNumber, leaves + // Typed.Type at ShapeNone, emits a CodeInvalidNumber diagnostic. + // IsTyped reports false, matching reality. + const kw = "maximum" + b := parseString(t, "swagger:model Foo\n\n"+kw+": notanumber") + for p := range b.Properties() { + if p.Keyword.Name == kw { + assert.False(t, p.IsTyped(), "failed typing leaves IsTyped false") + } + } +} + +func TestWalker_UnknownFiresForUntabledKeyword(t *testing.T) { + // "fakekeyword:" lands on an UnboundBlock as a Property whose + // Keyword.Name is empty (no entry in the keyword table). + src := `Some prose. +fakekeyword: 42` + b := parseString(t, src) + + var unknown []string + b.Walk(Walker{ + Unknown: func(p Property) { unknown = append(unknown, p.Value) }, + }) + + // The unknown-keyword path is exercised when the lexer emits a + // property with an empty Keyword.Name. If the current pipeline + // classifies "fakekeyword:" as prose rather than a property, + // Unknown stays empty — which is also valid behaviour. We assert + // no panic and consistent typing. + if len(unknown) > 0 { + assert.Equal(t, "42", unknown[0]) + } +} From bb22b13fea396f74e33e7625828f28d6a82daa59 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:36:48 +0200 Subject: [PATCH 05/22] feat(parsers/yaml): YAML sub-parser Thin YAML decoder used by the grammar's typed-extensions surface (x-* vendor extensions) and by operation / meta body unmarshal. Dedent and per-line position preservation are kept so error sites map back to the original source. Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- internal/parsers/extensions.go | 245 ---------------------- internal/parsers/extensions_test.go | 264 ------------------------ internal/parsers/yaml/README.md | 180 ++++++++++++++++ internal/parsers/yaml/dedent.go | 89 ++++++++ internal/parsers/yaml/operation.go | 54 +++++ internal/parsers/yaml/operation_test.go | 73 +++++++ internal/parsers/yaml/yaml.go | 172 +++++++++++++++ internal/parsers/yaml/yaml_test.go | 179 ++++++++++++++++ internal/parsers/yaml_parser.go | 106 ---------- internal/parsers/yaml_parser_test.go | 141 ------------- 10 files changed, 747 insertions(+), 756 deletions(-) delete mode 100644 internal/parsers/extensions.go delete mode 100644 internal/parsers/extensions_test.go create mode 100644 internal/parsers/yaml/README.md create mode 100644 internal/parsers/yaml/dedent.go create mode 100644 internal/parsers/yaml/operation.go create mode 100644 internal/parsers/yaml/operation_test.go create mode 100644 internal/parsers/yaml/yaml.go create mode 100644 internal/parsers/yaml/yaml_test.go delete mode 100644 internal/parsers/yaml_parser.go delete mode 100644 internal/parsers/yaml_parser_test.go diff --git a/internal/parsers/extensions.go b/internal/parsers/extensions.go deleted file mode 100644 index d95445f..0000000 --- a/internal/parsers/extensions.go +++ /dev/null @@ -1,245 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "fmt" - "reflect" - "regexp" - "strings" - - "github.com/go-openapi/codescan/internal/logger" - oaispec "github.com/go-openapi/spec" -) - -// alphaChars used when parsing for Vendor Extensions. -const alphaChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - -type SetOpExtensions struct { - Set func(*oaispec.Extensions) - rx *regexp.Regexp - Debug bool -} - -func NewSetExtensions(setter func(*oaispec.Extensions), debug bool) *SetOpExtensions { - return &SetOpExtensions{ - Set: setter, - rx: rxExtensions, - Debug: debug, - } -} - -func (ss *SetOpExtensions) Matches(line string) bool { - return ss.rx.MatchString(line) -} - -func (ss *SetOpExtensions) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - - cleanLines := cleanupScannerLines(lines, rxUncommentHeaders) - - exts := new(oaispec.VendorExtensible) - extList := make([]extensionObject, 0) - buildExtensionObjects(lines, cleanLines, 0, &extList, nil) - - // Extensions can be one of the following: - // key:value pair - // list/array - // object - for _, ext := range extList { - if m, ok := ext.Root.(map[string]string); ok { - exts.AddExtension(ext.Extension, m[ext.Extension]) - } else if m, ok := ext.Root.(map[string]*[]string); ok { - exts.AddExtension(ext.Extension, *m[ext.Extension]) - } else if m, ok := ext.Root.(map[string]any); ok { - exts.AddExtension(ext.Extension, m[ext.Extension]) - } else { - logger.DebugLogf(ss.Debug, "Unknown Extension type: %s", fmt.Sprint(reflect.TypeOf(ext.Root))) - } - } - - ss.Set(&exts.Extensions) - return nil -} - -type extensionObject struct { - Extension string - Root any -} - -type extensionParsingStack []any - -// Helper function to walk back through extensions until the proper nest level is reached. -func (stack *extensionParsingStack) walkBack(rawLines []string, lineIndex int) { - indent := strings.IndexAny(rawLines[lineIndex], alphaChars) - nextIndent := strings.IndexAny(rawLines[lineIndex+1], alphaChars) - if nextIndent >= indent { - return - } - - // Pop elements off the stack until we're back where we need to be - runbackIndex := 0 - poppedIndent := 1000 - for { - checkIndent := strings.IndexAny(rawLines[lineIndex-runbackIndex], alphaChars) - if nextIndent == checkIndent { - break - } - if checkIndent < poppedIndent { - *stack = (*stack)[:len(*stack)-1] - poppedIndent = checkIndent - } - runbackIndex++ - } -} - -// Recursively parses through the given extension lines, building and adding extension objects as it goes. -// Extensions may be key:value pairs, arrays, or objects. -func buildExtensionObjects(rawLines []string, cleanLines []string, lineIndex int, extObjs *[]extensionObject, stack *extensionParsingStack) { - if lineIndex >= len(rawLines) { - if stack != nil { - if ext, ok := (*stack)[0].(extensionObject); ok { - *extObjs = append(*extObjs, ext) - } - } - return - } - - kv := strings.SplitN(cleanLines[lineIndex], ":", kvParts) - key := strings.TrimSpace(kv[0]) - if key == "" { - // Some odd empty line - return - } - - nextIsList := false - if lineIndex < len(rawLines)-1 { - next := strings.SplitAfterN(cleanLines[lineIndex+1], ":", kvParts) - nextIsList = len(next) == 1 - } - - if len(kv) <= 1 { - // Should be a list item - if stack == nil || len(*stack) == 0 { - return - } - stackIndex := len(*stack) - 1 - list, ok := (*stack)[stackIndex].(*[]string) - if !ok { - panic(fmt.Errorf("internal error: expected *[]string, got %T: %w", (*stack)[stackIndex], ErrParser)) - } - *list = append(*list, key) - (*stack)[stackIndex] = list - if lineIndex < len(rawLines)-1 && !rxAllowedExtensions.MatchString(cleanLines[lineIndex+1]) { - stack.walkBack(rawLines, lineIndex) - } - buildExtensionObjects(rawLines, cleanLines, lineIndex+1, extObjs, stack) - return - } - - // Should be the start of a map or a key:value pair - value := strings.TrimSpace(kv[1]) - - if rxAllowedExtensions.MatchString(key) { - buildNewExtension(key, value, nextIsList, stack, rawLines, cleanLines, lineIndex, extObjs) - return - } - - if stack == nil || len(*stack) == 0 { - return - } - - buildStackEntry(key, value, nextIsList, stack, rawLines, cleanLines, lineIndex) - buildExtensionObjects(rawLines, cleanLines, lineIndex+1, extObjs, stack) -} - -// buildNewExtension handles the start of a new x- extension key. -func buildNewExtension(key, value string, nextIsList bool, stack *extensionParsingStack, rawLines, cleanLines []string, lineIndex int, extObjs *[]extensionObject) { - // Flush any previous extension on the stack - if stack != nil { - if ext, ok := (*stack)[0].(extensionObject); ok { - *extObjs = append(*extObjs, ext) - } - } - - if value != "" { - ext := extensionObject{ - Extension: key, - } - // Extension is simple key:value pair, no stack - rootMap := make(map[string]string) - rootMap[key] = value - ext.Root = rootMap - *extObjs = append(*extObjs, ext) - buildExtensionObjects(rawLines, cleanLines, lineIndex+1, extObjs, nil) - return - } - - ext := extensionObject{ - Extension: key, - } - if nextIsList { - // Extension is an array - rootMap := make(map[string]*[]string) - rootList := make([]string, 0) - rootMap[key] = &rootList - ext.Root = rootMap - stack = &extensionParsingStack{} - *stack = append(*stack, ext) - rootListMap, ok := ext.Root.(map[string]*[]string) - if !ok { - panic(fmt.Errorf("internal error: expected map[string]*[]string, got %T: %w", ext.Root, ErrParser)) - } - *stack = append(*stack, rootListMap[key]) - } else { - // Extension is an object - rootMap := make(map[string]any) - innerMap := make(map[string]any) - rootMap[key] = innerMap - ext.Root = rootMap - stack = &extensionParsingStack{} - *stack = append(*stack, ext) - *stack = append(*stack, innerMap) - } - buildExtensionObjects(rawLines, cleanLines, lineIndex+1, extObjs, stack) -} - -func assertStackMap(stack *extensionParsingStack, index int) map[string]any { - asMap, ok := (*stack)[index].(map[string]any) - if !ok { - panic(fmt.Errorf("internal error: stack index expected to be map[string]any, but got %T: %w", (*stack)[index], ErrParser)) - } - return asMap -} - -// buildStackEntry adds a key/value, nested list, or nested map to the current stack. -func buildStackEntry(key, value string, nextIsList bool, stack *extensionParsingStack, rawLines, cleanLines []string, lineIndex int) { - stackIndex := len(*stack) - 1 - if value == "" { - asMap := assertStackMap(stack, stackIndex) - if nextIsList { - // start of new list - newList := make([]string, 0) - asMap[key] = &newList - *stack = append(*stack, &newList) - } else { - // start of new map - newMap := make(map[string]any) - asMap[key] = newMap - *stack = append(*stack, newMap) - } - return - } - - // key:value - if reflect.TypeOf((*stack)[stackIndex]).Kind() == reflect.Map { - asMap := assertStackMap(stack, stackIndex) - asMap[key] = value - } - if lineIndex < len(rawLines)-1 && !rxAllowedExtensions.MatchString(cleanLines[lineIndex+1]) { - stack.walkBack(rawLines, lineIndex) - } -} diff --git a/internal/parsers/extensions_test.go b/internal/parsers/extensions_test.go deleted file mode 100644 index c55bee4..0000000 --- a/internal/parsers/extensions_test.go +++ /dev/null @@ -1,264 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" - - oaispec "github.com/go-openapi/spec" -) - -func TestSetOpExtensions_Matches(t *testing.T) { - t.Parallel() - - se := NewSetExtensions(nil, false) - assert.TrueT(t, se.Matches("extensions:")) - assert.TrueT(t, se.Matches("Extensions:")) - assert.FalseT(t, se.Matches("something else")) -} - -func TestSetOpExtensions_Parse(t *testing.T) { - t.Parallel() - - t.Run("empty", func(t *testing.T) { - var called bool - se := NewSetExtensions(func(_ *oaispec.Extensions) { called = true }, false) - require.NoError(t, se.Parse(nil)) - assert.FalseT(t, called) - require.NoError(t, se.Parse([]string{})) - require.NoError(t, se.Parse([]string{""})) - }) - - t.Run("simple key-value", func(t *testing.T) { - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{ - "x-custom-value: hello", - } - require.NoError(t, se.Parse(lines)) - val, ok := got.GetString("x-custom-value") - require.TrueT(t, ok) - assert.EqualT(t, "hello", val) - }) - - t.Run("array extension", func(t *testing.T) { - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{ - "x-tags:", - " value1", - " value2", - } - require.NoError(t, se.Parse(lines)) - require.NotNil(t, got) - val, ok := got["x-tags"] - require.TrueT(t, ok) - arr, ok := val.([]string) - require.TrueT(t, ok) - assert.Equal(t, []string{"value1", "value2"}, arr) - }) - - t.Run("object extension", func(t *testing.T) { - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{ - "x-meta:", - " name: obj", - " value: field", - } - require.NoError(t, se.Parse(lines)) - require.NotNil(t, got) - val, ok := got["x-meta"] - require.TrueT(t, ok) - obj, ok := val.(map[string]any) - require.TrueT(t, ok) - assert.EqualT(t, "obj", obj["name"]) - assert.EqualT(t, "field", obj["value"]) - }) - - t.Run("multiple extensions", func(t *testing.T) { - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{ - "x-first: one", - "x-second: two", - } - require.NoError(t, se.Parse(lines)) - v1, ok := got.GetString("x-first") - require.TrueT(t, ok) - assert.EqualT(t, "one", v1) - v2, ok := got.GetString("x-second") - require.TrueT(t, ok) - assert.EqualT(t, "two", v2) - }) - - t.Run("nested object with key-value", func(t *testing.T) { - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{ - "x-nested:", - " outer:", - " inner-key: inner-value", - } - require.NoError(t, se.Parse(lines)) - require.NotNil(t, got) - val, ok := got["x-nested"] - require.TrueT(t, ok) - obj, ok := val.(map[string]any) - require.TrueT(t, ok) - outer, ok := obj["outer"].(map[string]any) - require.TrueT(t, ok) - assert.EqualT(t, "inner-value", outer["inner-key"]) - }) - - t.Run("object then back to simple extension", func(t *testing.T) { - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{ - "x-obj:", - " key1: val1", - " key2: val2", - "x-simple: value", - } - require.NoError(t, se.Parse(lines)) - require.NotNil(t, got) - - obj, ok := got["x-obj"] - require.TrueT(t, ok) - m, ok := obj.(map[string]any) - require.TrueT(t, ok) - assert.EqualT(t, "val1", m["key1"]) - assert.EqualT(t, "val2", m["key2"]) - - simple, ok := got.GetString("x-simple") - require.TrueT(t, ok) - assert.EqualT(t, "value", simple) - }) - - t.Run("nested object with sub-list", func(t *testing.T) { - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{ - "x-deep:", - " sub-list:", - " item1", - " item2", - " sub-key: sub-value", - } - require.NoError(t, se.Parse(lines)) - require.NotNil(t, got) - val, ok := got["x-deep"] - require.TrueT(t, ok) - obj, ok := val.(map[string]any) - require.TrueT(t, ok) - assert.NotNil(t, obj["sub-list"]) - assert.EqualT(t, "sub-value", obj["sub-key"]) - }) - - t.Run("array extension followed by object extension", func(t *testing.T) { - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{ - "x-list:", - " alpha", - " beta", - "x-map:", - " a: 1", - " b: 2", - } - require.NoError(t, se.Parse(lines)) - require.NotNil(t, got) - - list, ok := got["x-list"] - require.TrueT(t, ok) - arr, ok := list.([]string) - require.TrueT(t, ok) - assert.Equal(t, []string{"alpha", "beta"}, arr) - - m, ok := got["x-map"] - require.TrueT(t, ok) - obj, ok := m.(map[string]any) - require.TrueT(t, ok) - assert.EqualT(t, "1", obj["a"]) - }) -} - -// TestSetOpExtensions_Parse_Malformed exercises the defensive guards in -// buildExtensionObjects against malformed extension blocks. Each case -// covers a branch that would otherwise be silently skipped with no test -// witness — losing data without warning would be a nasty silent-bug class. -// The contract these tests pin: malformed lines never panic, never -// corrupt prior extensions, and are simply dropped from the output. -func TestSetOpExtensions_Parse_Malformed(t *testing.T) { - t.Parallel() - - t.Run("line with empty key is skipped", func(t *testing.T) { - // Covers the `if key == "" { return }` guard in buildExtensionObjects: - // a line whose colon-prefix produces an empty trimmed key (e.g. leading - // colon). Must not panic; nothing should land in the output. - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{":just-a-value"} - require.NoError(t, se.Parse(lines)) - assert.Empty(t, got) - }) - - t.Run("list item at top level with no prior extension is dropped", func(t *testing.T) { - // Covers `if stack == nil || len(*stack) == 0 { return }` in the - // `len(kv) <= 1` branch — a bare word (no colon) with no preceding - // x- extension to attach it to. - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{"orphan-item"} - require.NoError(t, se.Parse(lines)) - assert.Empty(t, got) - }) - - t.Run("non-x key:value at top level with no prior extension is dropped", func(t *testing.T) { - // Covers the second `if stack == nil || len(*stack) == 0 { return }` - // guard further down: a plain key:value line whose key isn't an x- - // extension and there's no open extension to nest under. - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{"plain-key: plain-value"} - require.NoError(t, se.Parse(lines)) - assert.Empty(t, got) - }) - - t.Run("valid extension followed by malformed lines keeps valid output", func(t *testing.T) { - // Composition check: a good x- extension followed by each malformed - // shape. The good extension must survive; the junk lines must be - // dropped without disturbing it. - var got oaispec.Extensions - se := NewSetExtensions(func(ext *oaispec.Extensions) { got = *ext }, false) - - lines := []string{ - "x-good: keeper", - ":empty-key", - "orphan-item", - "plain-key: plain-value", - } - require.NoError(t, se.Parse(lines)) - - val, ok := got.GetString("x-good") - require.TrueT(t, ok) - assert.EqualT(t, "keeper", val) - _, hasPlain := got["plain-key"] - assert.FalseT(t, hasPlain, "non-x- key must not leak into output") - }) -} diff --git a/internal/parsers/yaml/README.md b/internal/parsers/yaml/README.md new file mode 100644 index 0000000..de5a6c8 --- /dev/null +++ b/internal/parsers/yaml/README.md @@ -0,0 +1,180 @@ +# yaml sub-parser — maintainer notes + +This document is the long-form companion to the `yaml` sub-parser +code. The source files keep godoc concise; complex invariants, design +trade-offs, and intentionally-deferred follow-ups live here. + +`internal/parsers/yaml/` is a thin wrapper around `go.yaml.in/yaml/v3` +that consumes the `RawYAML` bodies isolated by +`internal/parsers/grammar/` between `---` fences, plus the +typed-extensions service the grammar lexer calls for `extensions:` +raw blocks. + +--- + +## Table of contents + +- [§importers](#importers) — who calls in and the grammar carve-out +- [§typed-extensions](#typed-extensions) — `TypedExtensions` contract + and the YAML → JSON normalisation rationale +- [§unmarshal-body](#unmarshal-body) — godoc → YAML → JSON pipeline + for operation / meta bodies +- [§dedent](#dedent) — leading-indent normalisation, first-line vs + common-prefix strategies, recognised whitespace tokens +- [§sibling-sub-parsers](#sibling-sub-parsers) — the + `internal/parsers//` seam this subpackage establishes +- [§quirks-open](#quirks-open) — deferred follow-ups + +--- + +## §importers — who calls in + +Two importer surfaces: + +- **The builder layer** — bridge taggers that decide when to parse a + given `RawYAML` body (the `operations` bridge for + `swagger:operation` YAML, the `meta` bridge for + `securityDefinitions`, `infoExtensions`, and `extensions` raw + blocks). +- **`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. Every other parser-layer module owes +zero dependencies on a YAML decoder; the carve-out is scoped to the +`extensions:` raw block because the alternative is shipping +stringly-typed extension values to every consumer and re-parsing +downstream. + +## §typed-extensions — `TypedExtensions` contract + +`TypedExtensions(body)` 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`). + +Two shapes are supported uniformly: + +- **Flat scalar form** — + ``` + extensions: + x-tag: foo + x-priority: 5 + ``` +- **Nested / typed YAML form** — + ``` + extensions: + x-config: + enabled: true + threshold: 0.5 + tags: [a, b, c] + ``` + +### Why YAML → JSON normalisation + +`yaml.v3` yields `map[any]any` for nested mappings; downstream +consumers (vendor-extension targets, code generators, the spec +types' `AddExtension` surface) all expect `map[string]any` with +concrete leaf types. JSON unmarshalling is the cheapest way to +enforce that shape — the round-trip through +`swag/yamlutils.YAMLToJSON` is the canonical normalisation step. + +### Why the body is dedented + +The grammar lexer preserves each line's original whitespace prefix +(it needs godoc-level indentation to survive for nested YAML to +remain structurally valid). YAML in turn refuses tab indentation +and treats leading whitespace as structural. The dedent therefore +lives downstream of the lexer: strip the common leading-whitespace +prefix shared by every non-blank line, then substitute any residual +leading tabs with two spaces. Both petstore's `\t`-indented +Extensions block and the typed-nested test case using ` ` +indentation parse identically through this pipeline. + +### No name filtering at the wrapper + +The wrapper applies no `x-*` filtering. Each consumer decides +whether to accept only `x-*` keys (via +`classify.IsAllowedExtension`) or to consume the full mapping. The +schema builder's call site applies the filter; the grammar lexer's +call site leaves it to the eventual Walker.Extension consumer. + +### Error model + +A malformed YAML body propagates as a wrapped `fmt.Errorf("yaml: %w")` +error. The grammar layer surfaces the failure as a +`CodeInvalidYAMLExtensions` diagnostic rather than a silent drop. +Empty body returns `(nil, nil)`. + +## §unmarshal-body — godoc → YAML → JSON for operation / meta bodies + +`UnmarshalBody(body, unmarshal)` runs a raw godoc-comment YAML body +through the standard 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. +2. `yaml.Unmarshal` into a generic `map[any]any`. +3. `yamlutils.YAMLToJSON` — coerce the `map[any]any` soup into + JSON-shaped values with concrete leaf types. +4. Hand the resulting JSON bytes to the caller's callback, typically + a `*spec..UnmarshalJSON` or a `json.Unmarshal` into a + caller-provided struct. + +Empty body returns nil — the caller's target is left untouched. + +Used by the operations bridge (`swagger:operation` YAML body), the +meta bridge (`securityDefinitions`, `infoExtensions`, `extensions` +raw blocks), and any future target that needs the same shape. + +## §dedent — leading-indent normalisation + +Two dedent strategies coexist in this package, chosen per call site: + +- **`RemoveIndent` (operation/meta path)** — first-line dedent. The + first line's indent length is treated as the canonical strip width + and applied to every subsequent line. Preserved verbatim because + the existing operation goldens depend on it. +- **`normaliseExtensionBody` (typed-extensions path)** — common-prefix + dedent. Strips the longest leading-whitespace run shared by every + non-blank line. Required because extension bodies arrive with the + full godoc indent preserved on every line (the lexer keeps `Token.Raw` + for `yamlBody` blocks instead of `Token.Text`). + +Both passes then call `retabLeading` / `replaceLeadingTabs` to +substitute residual leading tabs with two spaces — YAML refuses tab +indentation. + +### Recognised whitespace tokens + +`leadingIndent` recognises: + +- space (` `) +- tab (`\t`) +- 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 uses ASCII whitespace. If a corpus surfaces that depends on +Unicode whitespace, reintroduce the branch in `isIndentSpace`. + +## §sibling-sub-parsers — the `internal/parsers//` seam + +This subpackage establishes a pattern: any future sub-language (enum +variant forms, richer example syntax, private-comment bodies, …) +gets its own `internal/parsers//` subpackage following the +same seam — narrow public surface, no transitive dependency from +`internal/parsers/grammar/` unless it is a deliberate carve-out +(documented in the importer's godoc). + +## §quirks-open — deferred follow-ups + +- **Per-entry positions on extensions.** Today every Extension in a + block shares the same `Pos` (the `extensions:` keyword's). LSP-grade + per-entry positions ("`x-foo` at line 47 has malformed value") + require decoding into `*yaml.Node`, walking the top level, and + translating `node.Line` / `node.Column` (1-indexed relative to + body) into absolute `token.Position`. Pick this up when LSP per- + entry extension diagnostics become a real requirement. +- **Unicode whitespace in `leadingIndent`.** Reintroduce the + `\p{Zs}` branch in `isIndentSpace` if a real corpus surfaces that + depends on it. diff --git a/internal/parsers/yaml/dedent.go b/internal/parsers/yaml/dedent.go new file mode 100644 index 0000000..2302009 --- /dev/null +++ b/internal/parsers/yaml/dedent.go @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package yaml + +import "strings" + +// RemoveIndent normalises the common leading indentation of a YAML +// body lifted from a godoc comment block: the first line's indent +// length is treated as the canonical strip width and applied to every +// subsequent line. Any tabs in the stripped lines' leading-whitespace +// run are then expanded to two spaces, because YAML refuses tab +// indentation. +// +// The first-line dedent (vs "shortest leading-whitespace run across +// every non-blank line") is 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. +// +// 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 + } + + indent := leadingIndent(lines[0]) + if indent == 0 { + return lines + } + + out := make([]string, len(lines)) + for i, line := range lines { + if len(line) < indent { + out[i] = line + continue + } + out[i] = retabLeading(line[indent:]) + } + return out +} + +// leadingIndent returns the byte position of the first non-indent +// character on line — i.e., the number of bytes the line should be +// stripped by to remove its leading indent. +// +// An "indent" here is the maximal prefix matching the pattern +// (ws* / ws*)+ followed by ws*, where ws is space or tab. The +// optional `/` run absorbs godoc comment markers (`//`, `///`) when +// they slipped through preprocessing. +// +// Lines that are pure indent (empty or all-whitespace) return 0 — +// there's no meaningful strip count for them. +func leadingIndent(line string) int { + i := 0 + for i < len(line) && isIndentSpace(line[i]) { + i++ + } + for i < len(line) && line[i] == '/' { + i++ + } + for i < len(line) && isIndentSpace(line[i]) { + i++ + } + if i >= len(line) { + return 0 + } + return i +} + +// retabLeading replaces every tab in the leading-whitespace run of +// line with two spaces. Tabs past the first non-whitespace character +// are left untouched. +func retabLeading(line string) string { + n := 0 + for n < len(line) && isIndentSpace(line[n]) { + n++ + } + if n == 0 { + return line + } + return strings.ReplaceAll(line[:n], "\t", " ") + line[n:] +} + +func isIndentSpace(b byte) bool { return b == ' ' || b == '\t' } diff --git a/internal/parsers/yaml/operation.go b/internal/parsers/yaml/operation.go new file mode 100644 index 0000000..1bb6e83 --- /dev/null +++ b/internal/parsers/yaml/operation.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package yaml + +import ( + "fmt" + "strings" + + "github.com/go-openapi/swag/yamlutils" + "go.yaml.in/yaml/v3" +) + +// UnmarshalBody runs a raw godoc-comment YAML body through the +// standard godoc → YAML → JSON pipeline expected by every Swagger +// target that consumes JSON-shape input: +// +// 1. RemoveIndent — strip the common indent godoc adds to every +// line and turn leading tabs into two-space sequences (YAML +// refuses tab indentation). +// 2. yaml.Unmarshal into a generic map[any]any. +// 3. yamlutils.YAMLToJSON — coerce the map[any]any soup into +// JSON-shaped values with concrete leaf types. +// 4. Hand the resulting JSON bytes to the caller's callback, +// typically a *spec..UnmarshalJSON or a json.Unmarshal +// into a caller-provided struct. +// +// Empty body returns nil — the caller's target is left untouched. +// +// Used by the operations bridge (swagger:operation YAML body), +// the meta bridge (securityDefinitions, infoExtensions, +// extensions raw blocks), and any future target that needs the +// same shape. +func UnmarshalBody(body string, unmarshal func([]byte) error) error { + if body == "" { + return nil + } + + lines := strings.Split(body, "\n") + lines = RemoveIndent(lines) + normalised := strings.Join(lines, "\n") + + yamlValue := make(map[any]any) + if err := yaml.Unmarshal([]byte(normalised), &yamlValue); err != nil { + return fmt.Errorf("yaml body: %w", err) + } + + jsonValue, err := yamlutils.YAMLToJSON(yamlValue) + if err != nil { + return fmt.Errorf("yaml→json: %w", err) + } + + return unmarshal(jsonValue) +} diff --git a/internal/parsers/yaml/operation_test.go b/internal/parsers/yaml/operation_test.go new file mode 100644 index 0000000..8d0174d --- /dev/null +++ b/internal/parsers/yaml/operation_test.go @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package yaml_test + +import ( + "testing" + + "github.com/go-openapi/codescan/internal/parsers/yaml" + oaispec "github.com/go-openapi/spec" +) + +// TestUnmarshalBody_RoundTrip checks the YAML → JSON → +// UnmarshalJSON pipeline used by the swagger:operation grammar bridge. +// The raw body here matches what grammar's TokenOpaqueYaml emits for +// a `---` fenced block (contents only, no fences, no `//` markers). +func TestUnmarshalBody_RoundTrip(t *testing.T) { + body := `parameters: + - name: limit + in: query + type: integer + format: int32 +responses: + "200": + description: OK +` + op := new(oaispec.Operation) + if err := yaml.UnmarshalBody(body, op.UnmarshalJSON); err != nil { + t.Fatalf("UnmarshalBody: %v", err) + } + + if len(op.Parameters) != 1 { + t.Fatalf("parameters: got %d, want 1", len(op.Parameters)) + } + p := op.Parameters[0] + if p.Name != "limit" || p.In != "query" || p.Type != "integer" || p.Format != "int32" { + t.Errorf("parameter fields: %+v", p) + } + if op.Responses == nil || op.Responses.StatusCodeResponses[200].Description != "OK" { + t.Errorf("responses: %+v", op.Responses) + } +} + +func TestUnmarshalBody_InvalidYAML(t *testing.T) { + // Unbalanced brackets — yaml.Unmarshal will error. + body := "parameters: [\n - name: x" + op := new(oaispec.Operation) + if err := yaml.UnmarshalBody(body, op.UnmarshalJSON); err == nil { + t.Error("expected error on malformed YAML, got nil") + } +} + +func TestUnmarshalBody_EmptyBody(t *testing.T) { + // Empty body short-circuits before unmarshal — caller's target + // stays untouched. + op := new(oaispec.Operation) + if err := yaml.UnmarshalBody("", op.UnmarshalJSON); err != nil { + t.Errorf("empty body should not error: %v", err) + } +} + +// TestUnmarshalBody_TabIndent verifies the dedent step +// handles tab-indented godoc-style bodies (the go119 fixture style). +func TestUnmarshalBody_TabIndent(t *testing.T) { + body := "\tparameters:\n\t - name: limit\n\t in: query\n\t type: integer\n" + op := new(oaispec.Operation) + if err := yaml.UnmarshalBody(body, op.UnmarshalJSON); err != nil { + t.Fatalf("UnmarshalBody: %v", err) + } + if len(op.Parameters) != 1 || op.Parameters[0].Name != "limit" { + t.Fatalf("parameters: %+v", op.Parameters) + } +} diff --git a/internal/parsers/yaml/yaml.go b/internal/parsers/yaml/yaml.go new file mode 100644 index 0000000..8df0d0b --- /dev/null +++ b/internal/parsers/yaml/yaml.go @@ -0,0 +1,172 @@ +// 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. +// +// Importers: +// +// - The builder layer — bridge taggers that decide when to parse a +// given RawYAML body (operations, meta, schema). +// - 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. +// +// See README.md for the long-form rationale (typed-extensions +// pipeline, dedent strategies, sibling-sub-parser seam). +package yaml + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/go-openapi/swag/yamlutils" + "go.yaml.in/yaml/v3" +) + +// Parse unmarshals the given raw YAML body into a generic value +// (typically a map[string]any or []any). YAML library errors carry +// their own line/column numbers relative to the body, not to the Go +// source — callers that need source-relative positions wrap the error +// with their own annotation position. +// +// Returns (nil, nil) for an empty body so callers can handle +// "annotation had a fence but no content" without branching on +// error-vs-nil. +// +//nolint:nilnil // (nil, nil) is the documented "empty body" return — the caller distinguishes via len(body) if needed. +func Parse(body string) (any, error) { + if body == "" { + return nil, nil + } + var v any + if err := yaml.Unmarshal([]byte(body), &v); err != nil { + return nil, fmt.Errorf("yaml: %w", err) + } + return v, nil +} + +// ParseInto unmarshals body into the given destination, typically a +// pointer to a struct the caller defined to match an expected YAML +// shape (e.g., operation-body or extension-value). Wraps the +// underlying error for uniform error reporting. +func ParseInto(body string, dst any) error { + if body == "" { + return nil + } + if err := yaml.Unmarshal([]byte(body), dst); err != nil { + return fmt.Errorf("yaml: %w", err) + } + return nil +} + +// TypedExtensions parses the body of an `extensions:` raw block and +// returns its top-level entries as JSON-typed values +// (bool / float64 / string / []any / map[string]any). +// +// The body is dedented before parsing — the 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. +// +// 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) { + if body == "" { + return nil, nil //nolint:nilnil // documented "empty body" return + } + normalised := normaliseExtensionBody(body) + var yamlValue any + if err := yaml.Unmarshal([]byte(normalised), &yamlValue); err != nil { + return nil, fmt.Errorf("yaml: %w", err) + } + jsonValue, err := yamlutils.YAMLToJSON(yamlValue) + if err != nil { + return nil, fmt.Errorf("yaml→json: %w", err) + } + var data map[string]any + if err := json.Unmarshal(jsonValue, &data); err != nil { + return nil, fmt.Errorf("json: %w", err) + } + return data, nil +} + +// normaliseExtensionBody dedents an extensions-block body: strips the +// common leading-whitespace prefix shared by every non-blank line and +// substitutes any residual leading tabs with two spaces (YAML refuses +// tab indentation). +// +// The 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") + + prefix := commonLeadingWhitespace(lines) + if prefix > 0 { + for i, line := range lines { + if len(line) >= prefix { + lines[i] = line[prefix:] + } + } + } + + for i, line := range lines { + lines[i] = replaceLeadingTabs(line) + } + return strings.Join(lines, "\n") +} + +// commonLeadingWhitespace returns the length of the longest leading +// whitespace run shared by every non-blank line. Returns 0 when any +// non-blank line starts at column 0 (nothing to dedent). +func commonLeadingWhitespace(lines []string) int { + prefix := -1 + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + n := 0 + for n < len(line) && (line[n] == ' ' || line[n] == '\t') { + n++ + } + if prefix < 0 || n < prefix { + prefix = n + } + if prefix == 0 { + return 0 + } + } + if prefix < 0 { + return 0 + } + return prefix +} + +// replaceLeadingTabs converts any tab characters in the leading +// whitespace run of a line into two spaces, leaving the rest of the +// line untouched. +func replaceLeadingTabs(line string) string { + n := 0 + for n < len(line) && (line[n] == ' ' || line[n] == '\t') { + n++ + } + if n == 0 { + return line + } + return strings.ReplaceAll(line[:n], "\t", " ") + line[n:] +} diff --git a/internal/parsers/yaml/yaml_test.go b/internal/parsers/yaml/yaml_test.go new file mode 100644 index 0000000..c76c7af --- /dev/null +++ b/internal/parsers/yaml/yaml_test.go @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package yaml_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/go-openapi/codescan/internal/parsers/yaml" +) + +func TestParseEmpty(t *testing.T) { + v, err := yaml.Parse("") + if err != nil { + t.Fatalf("empty body: unexpected error: %v", err) + } + if v != nil { + t.Errorf("empty body: want nil, got %v", v) + } +} + +func TestParseFlatMap(t *testing.T) { + // Note: go.yaml.in/yaml/v3 returns map[string]any for + // string-keyed maps and auto-types scalars (unquoted "1.0" + // becomes float64). Quote the value to keep it as a string. + body := "name: Foo\nversion: \"1.0\"\n" + v, err := yaml.Parse(body) + if err != nil { + t.Fatalf("parse: %v", err) + } + m, ok := v.(map[string]any) + if !ok { + t.Fatalf("want map[string]any, got %T: %v", v, v) + } + if m["name"] != "Foo" { + t.Errorf("name: got %v want Foo", m["name"]) + } + if m["version"] != "1.0" { + t.Errorf("version: got %v", m["version"]) + } +} + +func TestParseNestedStructure(t *testing.T) { + // Representative of an operation body's responses mapping. + // Numeric keys like `200` arrive as int keys; the outer map + // becomes map[any]any because not all keys are strings. + body := "responses:\n 200:\n description: ok\n 404:\n description: not found\n" + v, err := yaml.Parse(body) + if err != nil { + t.Fatalf("parse: %v", err) + } + top, ok := v.(map[string]any) + if !ok { + t.Fatalf("want top-level map[string]any, got %T", v) + } + // The responses map has integer keys (200, 404), so the + // YAML library returns map[any]any (keys include non-strings). + resp, ok := top["responses"].(map[any]any) + if !ok { + t.Fatalf("responses: want map[any]any (int keys), got %T", top["responses"]) + } + if len(resp) != 2 { + t.Errorf("responses: want 2 entries, got %d", len(resp)) + } +} + +func TestParseInvalidYAML(t *testing.T) { + // Bad indentation / stray colon. + body := "key: [unclosed\n" + _, err := yaml.Parse(body) + if err == nil { + t.Fatal("expected error for invalid YAML") + } + if !strings.HasPrefix(err.Error(), "yaml:") { + t.Errorf("error should be wrapped with 'yaml:' prefix: got %q", err.Error()) + } +} + +func TestParseIntoStruct(t *testing.T) { + type operation struct { + Method string `yaml:"method"` + Path string `yaml:"path"` + } + body := "method: GET\npath: /pets\n" + var op operation + if err := yaml.ParseInto(body, &op); err != nil { + t.Fatalf("parse: %v", err) + } + if op.Method != http.MethodGet || op.Path != "/pets" { + t.Errorf("unmarshalled struct: %+v", op) + } +} + +func TestParseIntoEmpty(t *testing.T) { + // Empty body is a no-op (dst left at zero value). + type op struct{ Method string } + var v op + if err := yaml.ParseInto("", &v); err != nil { + t.Errorf("empty body: unexpected error: %v", err) + } + if v.Method != "" { + t.Errorf("dst should be untouched, got %+v", v) + } +} + +func TestTypedExtensionsEmpty(t *testing.T) { + m, err := yaml.TypedExtensions("") + if err != nil { + t.Fatalf("empty body: unexpected error: %v", err) + } + if m != nil { + t.Errorf("empty body: want nil map, got %v", m) + } +} + +func TestTypedExtensionsFlatScalars(t *testing.T) { + body := "x-tag: foo\nx-priority: 5\nx-enabled: true\n" + m, err := yaml.TypedExtensions(body) + if err != nil { + t.Fatalf("parse: %v", err) + } + if m["x-tag"] != "foo" { + t.Errorf("x-tag: got %v want foo", m["x-tag"]) + } + // JSON normalisation yields float64 for numeric scalars. + if got, ok := m["x-priority"].(float64); !ok || got != 5 { + t.Errorf("x-priority: got %v (%T) want float64(5)", m["x-priority"], m["x-priority"]) + } + if m["x-enabled"] != true { + t.Errorf("x-enabled: got %v want true", m["x-enabled"]) + } +} + +func TestTypedExtensionsNestedYAML(t *testing.T) { + // The case the schema builder's prior applyExtensionsRawBlock + // existed for: nested map / list values must arrive as typed + // map[string]any and []any, not yaml.v3's map[any]any. + body := "x-config:\n enabled: true\n threshold: 0.5\n tags: [a, b, c]\n" + m, err := yaml.TypedExtensions(body) + if err != nil { + t.Fatalf("parse: %v", err) + } + cfg, ok := m["x-config"].(map[string]any) + if !ok { + t.Fatalf("x-config: want map[string]any, got %T", m["x-config"]) + } + if cfg["enabled"] != true { + t.Errorf("x-config.enabled: got %v want true", cfg["enabled"]) + } + if cfg["threshold"] != 0.5 { + t.Errorf("x-config.threshold: got %v want 0.5", cfg["threshold"]) + } + tags, ok := cfg["tags"].([]any) + if !ok || len(tags) != 3 { + t.Fatalf("x-config.tags: want []any{a,b,c}, got %v (%T)", cfg["tags"], cfg["tags"]) + } +} + +func TestTypedExtensionsNoFilter(t *testing.T) { + // The service does NOT drop non-x-* keys — the caller decides. + body := "x-good: 1\nnot-an-extension: 2\n" + m, err := yaml.TypedExtensions(body) + if err != nil { + t.Fatalf("parse: %v", err) + } + if _, ok := m["not-an-extension"]; !ok { + t.Errorf("non-x-* key should be present (caller filters); got map %v", m) + } +} + +func TestTypedExtensionsInvalidYAML(t *testing.T) { + body := "x-broken: [unclosed\n" + _, err := yaml.TypedExtensions(body) + if err == nil { + t.Fatal("expected error for invalid YAML") + } +} diff --git a/internal/parsers/yaml_parser.go b/internal/parsers/yaml_parser.go deleted file mode 100644 index 8643921..0000000 --- a/internal/parsers/yaml_parser.go +++ /dev/null @@ -1,106 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "encoding/json" - "regexp" - "strings" - - "github.com/go-openapi/loads/fmts" - "go.yaml.in/yaml/v3" -) - -type YAMLParserOption func(*YAMLParser) - -func WithSetter(set func(json.RawMessage) error) YAMLParserOption { - return func(p *YAMLParser) { - p.set = set - } -} - -func WithMatcher(rx *regexp.Regexp) YAMLParserOption { - return func(p *YAMLParser) { - p.rx = rx - } -} - -func WithExtensionMatcher() YAMLParserOption { - return func(p *YAMLParser) { - p.rx = rxExtensions - } -} - -type YAMLParser struct { - set func(json.RawMessage) error - rx *regexp.Regexp -} - -func NewYAMLParser(opts ...YAMLParserOption) *YAMLParser { - var y YAMLParser - for _, apply := range opts { - apply(&y) - } - - return &y -} - -func (y *YAMLParser) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - - uncommented := make([]string, 0, len(lines)) - uncommented = append(uncommented, removeYamlIndent(lines)...) - - yamlContent := strings.Join(uncommented, "\n") - var yamlValue any - err := yaml.Unmarshal([]byte(yamlContent), &yamlValue) - if err != nil { - return err - } - - var jsonValue json.RawMessage - jsonValue, err = fmts.YAMLToJSON(yamlValue) - if err != nil { - return err - } - - if y.set == nil { - return nil - } - - return y.set(jsonValue) -} - -func (y *YAMLParser) Matches(line string) bool { - if y.rx == nil { - return false - } - - return y.rx.MatchString(line) -} - -// removes indent base on the first line. -// -// The difference with removeIndent is that lines shorter than the indentation are elided. -func removeYamlIndent(spec []string) []string { - if len(spec) == 0 { - return spec - } - - loc := rxIndent.FindStringIndex(spec[0]) - if len(loc) < 2 || loc[1] <= 1 { - return spec - } - - s := make([]string, 0, len(spec)) - for i := range spec { - if len(spec[i]) >= loc[1] { - s = append(s, spec[i][loc[1]-1:]) - } - } - - return s -} diff --git a/internal/parsers/yaml_parser_test.go b/internal/parsers/yaml_parser_test.go deleted file mode 100644 index d4cb200..0000000 --- a/internal/parsers/yaml_parser_test.go +++ /dev/null @@ -1,141 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "encoding/json" - "errors" - "testing" - - "github.com/go-openapi/testify/v2/require" -) - -var errSetterFailed = errors.New("setter failed") - -func TestYamlParser(t *testing.T) { - t.Parallel() - - setter := func(out *string, called *int) func(json.RawMessage) error { - return func(in json.RawMessage) error { - *called++ - *out = string(in) - - return nil - } - } - - t.Run("with happy path", func(t *testing.T) { - t.Run("should parse security definitions object as YAML", func(t *testing.T) { - setterCalled := 0 - var actualJSON string - parser := NewYAMLParser(WithMatcher(rxSecurity), WithSetter(setter(&actualJSON, &setterCalled))) - - lines := []string{ - "SecurityDefinitions:", - " api_key:", - " type: apiKey", - " name: X-API-KEY", - " petstore_auth:", - " type: oauth2", - " scopes:", - " 'write:pets': modify pets in your account", - " 'read:pets': read your pets", - } - - require.TrueT(t, parser.Matches(lines[0])) - require.NoError(t, parser.Parse(lines)) - require.EqualT(t, 1, setterCalled) - - const expectedJSON = `{"SecurityDefinitions":{"api_key":{"name":"X-API-KEY","type":"apiKey"},` + - `"petstore_auth":{"scopes":{"read:pets":"read your pets","write:pets":"modify pets in your account"},"type":"oauth2"}}}` - - require.JSONEqT(t, expectedJSON, actualJSON) - }) - }) - - t.Run("with edge cases", func(t *testing.T) { - t.Run("should handle empty input", func(t *testing.T) { - setterCalled := 0 - var actualJSON string - parser := NewYAMLParser(WithMatcher(rxSecurity), WithSetter(setter(&actualJSON, &setterCalled))) - - require.FalseT(t, parser.Matches("")) - require.NoError(t, parser.Parse([]string{})) - require.Zero(t, setterCalled) - }) - - t.Run("should handle nil input", func(t *testing.T) { - setterCalled := 0 - var actualJSON string - parser := NewYAMLParser(WithMatcher(rxSecurity), WithSetter(setter(&actualJSON, &setterCalled))) - - require.NoError(t, parser.Parse(nil)) - require.Zero(t, setterCalled) - }) - - t.Run("should handle bad indentation", func(t *testing.T) { - setterCalled := 0 - var actualJSON string - parser := NewYAMLParser(WithMatcher(rxSecurity), WithSetter(setter(&actualJSON, &setterCalled))) - lines := []string{ - "SecurityDefinitions:", - "\t\tapi_key:", - " type: apiKey", - } - - require.TrueT(t, parser.Matches(lines[0])) - err := parser.Parse(lines) - require.Error(t, err) - require.StringContainsT(t, err.Error(), "yaml: line 2:") - require.Zero(t, setterCalled) - }) - - t.Run("should catch YAML errors", func(t *testing.T) { - setterCalled := 0 - var actualJSON string - parser := NewYAMLParser(WithMatcher(rxSecurity), WithSetter(setter(&actualJSON, &setterCalled))) - lines := []string{ - "SecurityDefinitions:", - " api_key", - " type: apiKey", - } - - require.TrueT(t, parser.Matches(lines[0])) - err := parser.Parse(lines) - require.Error(t, err) - require.StringContainsT(t, err.Error(), "yaml: line 3: mapping value") - require.Zero(t, setterCalled) - }) - - t.Run("should handle nil rx in Matches", func(t *testing.T) { - parser := NewYAMLParser(WithSetter(func(_ json.RawMessage) error { return nil })) - require.FalseT(t, parser.Matches("anything")) - }) - - t.Run("should handle nil setter", func(t *testing.T) { - parser := NewYAMLParser(WithMatcher(rxSecurity)) - lines := []string{ - "SecurityDefinitions:", - " api_key:", - " type: apiKey", - } - require.NoError(t, parser.Parse(lines)) - }) - - t.Run("should propagate setter error", func(t *testing.T) { - parser := NewYAMLParser( - WithMatcher(rxSecurity), - WithSetter(func(_ json.RawMessage) error { return errSetterFailed }), - ) - lines := []string{ - "SecurityDefinitions:", - " api_key:", - " type: apiKey", - } - err := parser.Parse(lines) - require.Error(t, err) - require.ErrorIs(t, err, errSetterFailed) - }) - }) -} From a9efe472b4f09f31df4c5e9e5d9b8af27a6cbdd7 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:36:58 +0200 Subject: [PATCH 06/22] feat(parsers/routebody): swagger:route body sub-parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parses the multi-line body grammar nested under swagger:route — parameters, responses, consumes, produces, schemes, security — and surfaces it via synthetic grammar.Block construction so downstream builders reuse the schema/parameters/response code paths without route-specific branches. Companion security parser handles inline security-requirement lines (e.g. `oauth2: [read, write]`) used by both routes and the spec meta block. Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- internal/parsers/routebody/README.md | 168 +++++++++++++ internal/parsers/routebody/diag.go | 45 ++++ internal/parsers/routebody/doc.go | 22 ++ internal/parsers/routebody/parameters.go | 298 +++++++++++++++++++++++ internal/parsers/routebody/responses.go | 187 ++++++++++++++ internal/parsers/security.go | 103 -------- internal/parsers/security/security.go | 68 ++++++ internal/parsers/security_test.go | 84 ------- 8 files changed, 788 insertions(+), 187 deletions(-) create mode 100644 internal/parsers/routebody/README.md create mode 100644 internal/parsers/routebody/diag.go create mode 100644 internal/parsers/routebody/doc.go create mode 100644 internal/parsers/routebody/parameters.go create mode 100644 internal/parsers/routebody/responses.go delete mode 100644 internal/parsers/security.go create mode 100644 internal/parsers/security/security.go delete mode 100644 internal/parsers/security_test.go diff --git a/internal/parsers/routebody/README.md b/internal/parsers/routebody/README.md new file mode 100644 index 0000000..364dd97 --- /dev/null +++ b/internal/parsers/routebody/README.md @@ -0,0 +1,168 @@ +# routebody — maintainer notes + +This document is the long-form companion to the `routebody` package +code. The source files keep godoc concise; the full sub-language +grammars, design trade-offs, and intentionally-deferred follow-ups +live here. + +`routebody` parses the two raw sub-blocks carried inside +`swagger:route` / `swagger:operation` annotations — `Parameters:` +and `Responses:` — into typed declarations +(`ParamDecl`, `ResponseDecl`) plus a `grammar.Block` of validation +properties. The orchestrating builder (`builders/routes`) reads the +typed head fields directly and dispatches the Block through +`handlers.DispatchParamLevel0` / `handlers.DispatchSchemaLevel0`, +the same seam every other parameter and schema site uses. + +--- + +## Table of contents + +- [§parameters-grammar](#parameters-grammar) — the `Parameters:` chunk grammar +- [§responses-grammar](#responses-grammar) — the `Responses:` line grammar +- [§diagnostics](#diagnostics) — what routebody emits, and what it drops +- [§definition-fallback](#definition-fallback) — untagged response refs resolved against `definitions` +- [§quirks-open](#quirks-open) — deferred follow-ups + +--- + +## §parameters-grammar — the `Parameters:` chunk grammar + +A `Parameters:` body is a sequence of chunks. Each chunk starts +with a line whose first non-whitespace character is `+` (canonical) +or `-` (alias, accepted for YAML-style authoring). Subsequent +indented lines carry the chunk's `key: value` pairs, one pair per +line. Lines without a colon are silently ignored. + +Head fields (consumed directly by the orchestrator, NOT lowered +into Block properties): + +| Key | Lands on | Notes | +|---|---|---| +| `name:` | `ParamDecl.Name` | | +| `in:` | `ParamDecl.In` | one of `path` / `query` / `header` / `body` / `formData`; `form` is accepted as an alias and normalised to `formData` | +| `type:` | `ParamDecl.TypeRef` | primitive name or Go identifier; `[]` prefixes accepted on body params | +| `format:` | `ParamDecl.Format` | | +| `description:` | `ParamDecl.Description` | | +| `required:` | `ParamDecl.Required` | parsed via `strconv.ParseBool` | +| `allowempty:` / `allowemptyvalue:` | `ParamDecl.AllowEmpty` | parsed via `strconv.ParseBool` | + +Validation fields are lowered to `grammar.Property` entries on +`ParamDecl.Block` and dispatched via `handlers.DispatchParamLevel0`: + +- `min` / `max` / `minimum` / `maximum` / `multipleOf` +- `minlength` / `maxlength` / `minitems` / `maxitems` +- `pattern` +- `unique` +- `collectionformat` +- `default` / `example` / `enum` + +Unknown keywords (any key not in the head-field set and not in the +`grammar.Lookup` validation table) emit `CodeInvalidAnnotation` and +are dropped — they never reach the dispatcher silently. + +A bare `+` / `-` sigil with no follow-up content (no `name:`, no +other head field, no validation property) is treated as an empty +chunk and dropped with `CodeInvalidAnnotation`. The minimum useful +chunk carries at least a `name:` and an `in:`. + +## §responses-grammar — the `Responses:` line grammar + +A `Responses:` body is one line per response. Each line has the +shape: + +``` +: * +``` + +where `` is `default` (case-insensitive) or a decimal HTTP +status code. Tokens on the right of the colon are either +`tag:value` for `tag` in `{body, response, description}` or +untagged. + +Tagged tokens: + +- `body:Foo` — the response carries a body that references the + model type `Foo`. `[]Foo` / `[][]Foo` wraps the body in N + arrays (the leading `[]` prefixes are stripped and counted onto + `ResponseDecl.Arrays`). +- `response:Foo` — the response is the named `swagger:response` + `Foo`. Same array-prefix handling as `body:`. +- `description:Foo bar baz` — everything from the `description:` + token through the rest of the line is the response's description + prose. The token's own value (`Foo`) and any subsequent tokens + are joined with single spaces. A bare `description:` token (no + value after the colon) does not contribute a leading empty + segment to the joined description. + +Only one of `body:` / `response:` may appear on a single line; a +second occurrence drops the line with `CodeInvalidAnnotation`. + +Untagged tokens: + +- The first untagged token is the response ref candidate + (resolved by the orchestrator — see §definition-fallback). +- Subsequent untagged tokens accumulate into the description. + +A line whose first untagged token is literally `body` or +`response` (no colon) is treated as a typo for `body:Foo` / +`response:Foo` and dropped with `CodeInvalidAnnotation`. This +prevents the line from being silently parsed as +`response="body"` plus a description. + +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. + +## §diagnostics — what routebody emits, and what it drops + +routebody emits a single diagnostic code, `CodeInvalidAnnotation`, +on every recoverable parse error. `emitDiagf` is the shared +funnel — when the caller passes a nil `diag` sink, diagnostics +are dropped without affecting the parsed output. The dispatcher +seam accepts the same optional-sink posture, so a nil-sink call +flows through cleanly. + +Positions on emitted diagnostics are line-offset from the +`basePos` the caller supplies (the source position of the +`parameters:` / `responses:` keyword head). routebody does not +track precise column information within the body — `Column` is +inherited from `basePos`. + +Future diagnostic codes (shape mismatches, unresolved refs) +should land as sibling helpers in `diag.go` so call sites stay +explicit about which code they ride on. + +## §definition-fallback — untagged refs and the definitions map + +The first untagged token on a response line is reported as +`ResponseDecl.ResponseRef`. The orchestrator +(`builders/routes`) resolves the ref against the spec's +`responses` map first; on miss it falls back to the +`definitions` map and treats the hit as a body ref. The fallback +makes `200: User` work the same way whether `User` is a named +response or a model type — the author does not need to remember +the distinction. + +Unresolvable refs (neither a `responses` entry nor a +`definitions` entry) emit `CodeInvalidAnnotation` at the +orchestrator level. + +## §quirks-open — deferred follow-ups + +- **Column tracking.** routebody does not track per-line column + information; diagnostics inherit `basePos.Column`. If the LSP + integration needs per-token positions on body sub-language + diagnostics, the body parser will need to track lex state more + precisely. +- **`form` alias for `formData`.** The `form` spelling is + accepted on `in:` and normalised to `formData`. The alias + preserves a long-standing authoring convenience; a strict + mode could emit a deprecation diagnostic in a future pass. +- **`collectionFormat:` lax acceptance.** Like the SimpleSchema + dispatcher's collection-format handler, routebody leaves an + unknown value typed as `ShapeNone` so the dispatcher's + string-fallback path can round-trip the raw value onto the + parameter. A future strict mode could reject values outside + the OAS v2 vocabulary at parse time. diff --git a/internal/parsers/routebody/diag.go b/internal/parsers/routebody/diag.go new file mode 100644 index 0000000..d03609a --- /dev/null +++ b/internal/parsers/routebody/diag.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package routebody + +import ( + "fmt" + "go/token" + + "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. +// +// 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 { + return + } + diag(grammar.Diagnostic{ + Pos: pos, + Severity: grammar.SeverityWarning, + Code: grammar.CodeInvalidAnnotation, + Message: fmt.Sprintf(format, args...), + }) +} + +// 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{ + Filename: base.Filename, + Offset: base.Offset, + Line: base.Line + lineNo - 1, + Column: base.Column, + } +} diff --git a/internal/parsers/routebody/doc.go b/internal/parsers/routebody/doc.go new file mode 100644 index 0000000..67d997c --- /dev/null +++ b/internal/parsers/routebody/doc.go @@ -0,0 +1,22 @@ +// 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. +// +// 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. +// +// 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 new file mode 100644 index 0000000..4e4e8c9 --- /dev/null +++ b/internal/parsers/routebody/parameters.go @@ -0,0 +1,298 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package routebody + +import ( + "errors" + "fmt" + "go/token" + "strconv" + "strings" + + "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. +var errInvalidValue = errors.New("invalid value") + +// 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. +type ParamDecl struct { + Name string + In string + TypeRef string + Format string + Description string + Required bool + AllowEmpty bool + Block grammar.Block + Pos token.Position +} + +// validIn lists the OAS v2 parameter-location values routebody +// accepts on the `in:` head field. Mirrors the grammar.KwIn +// closed-vocab table; kept private here because the orchestrator +// reads ParamDecl.In as a string rather than going through the +// dispatcher's ShapeEnumOption path. +// +//nolint:gochecknoglobals // immutable lookup table; read-only. +var validIn = map[string]struct{}{ + "path": {}, + "query": {}, + "header": {}, + "body": {}, + "form": {}, // accepted as an alias for formData; normalised below. + "formData": {}, +} + +// 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. +// +// 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. +func ParseParameters(body string, basePos token.Position, diag func(grammar.Diagnostic)) []ParamDecl { + if strings.TrimSpace(body) == "" { + return nil + } + + var out []ParamDecl + state := chunkParseState{basePos: basePos} + lines := strings.Split(body, "\n") + + for i, raw := range lines { + lineNo := i + 1 + line := strings.TrimSpace(raw) + if line == "" { + continue + } + 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. + if isChunkSigil(line) { + commitChunk(&state, &out, diag) + state.cur = &ParamDecl{Pos: pos} + state.props = nil + line = strings.TrimSpace(line[1:]) + if line == "" { + continue + } + } + + // Lines without a `:` are silently ignored, as are lines + // whose key trims to empty. + key, value, ok := splitKeyValue(line) + if !ok { + continue + } + + if state.cur == nil { + emitDiagf(diag, pos, + "parameter property %q outside any chunk; expected `+ name:` chunk-start first", key) + continue + } + + applyParamLine(state.cur, &state.props, key, value, pos, diag) + } + + commitChunk(&state, &out, 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. +func isChunkSigil(line string) bool { + if line == "" { + return false + } + return line[0] == '+' || line[0] == '-' +} + +// splitKeyValue splits one `key: value` line on the first colon. +// Returns (key, value, true) when both halves are non-empty after +// trimming; (_, _, false) otherwise. +func splitKeyValue(line string) (key, value string, ok bool) { + idx := strings.Index(line, ":") + if idx < 0 { + return "", "", false + } + key = strings.TrimSpace(line[:idx]) + value = strings.TrimSpace(line[idx+1:]) + if key == "" { + return "", "", false + } + 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). +func applyParamLine(cur *ParamDecl, props *[]grammar.Property, key, value string, pos token.Position, diag func(grammar.Diagnostic)) { + switch strings.ToLower(key) { + case "name": + cur.Name = value + case "in": + if _, ok := validIn[value]; !ok { + emitDiagf(diag, pos, + "in: %q is not one of path/query/header/body/formData", value) + return + } + // Normalise the "form" alias to "formData" so the resulting + // spec carries OAS v2's canonical value. + if value == "form" { + value = "formData" + } + cur.In = value + case "type": + cur.TypeRef = value + case "format": + cur.Format = value + case "description": + cur.Description = value + case "required": + v, err := strconv.ParseBool(value) + if err != nil { + emitDiagf(diag, pos, + "required: %q is not a valid boolean (true/false)", value) + return + } + cur.Required = v + case "allowempty", "allowemptyvalue": + v, err := strconv.ParseBool(value) + if err != nil { + emitDiagf(diag, pos, + "allowempty: %q is not a valid boolean (true/false)", value) + return + } + cur.AllowEmpty = v + default: + // Validation property: look up in the grammar keyword table. + // Unknown keys emit CodeInvalidAnnotation rather than being + // dropped silently. + kw, ok := grammar.Lookup(key) + if !ok { + emitDiagf(diag, pos, + "unknown parameter keyword %q", key) + return + } + p, err := buildProperty(kw, value, pos) + if err != nil { + emitDiagf(diag, pos, "%s", err.Error()) + return + } + *props = append(*props, p) + } +} + +// 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 + } + cur := state.cur + if isEmptyChunk(cur, state.props) { + emitDiagf(diag, cur.Pos, + "empty parameter chunk: `+` / `-` requires at least `name:` and `in:` follow-up") + state.cur = nil + state.props = nil + return + } + cur.Block = grammar.NewSyntheticBlock(cur.Pos, cur.Name, cur.Description, state.props) + *out = append(*out, *cur) + state.cur = nil + 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). +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). +func buildProperty(kw grammar.Keyword, raw string, pos token.Position) (grammar.Property, error) { + p := grammar.Property{ + Keyword: kw, + Pos: pos, + Value: raw, + } + switch kw.Shape { + case grammar.ShapeNumber: + v, err := strconv.ParseFloat(raw, 64) + if err != nil { + return p, fmt.Errorf("%w: %s: %q is not a valid number", errInvalidValue, kw.Name, raw) + } + p.Typed = grammar.TypedValue{Type: grammar.ShapeNumber, Number: v} + case grammar.ShapeInt: + v, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return p, fmt.Errorf("%w: %s: %q is not a valid integer", errInvalidValue, kw.Name, raw) + } + p.Typed = grammar.TypedValue{Type: grammar.ShapeInt, Integer: v} + case grammar.ShapeBool: + v, err := strconv.ParseBool(raw) + if err != nil { + return p, fmt.Errorf("%w: %s: %q is not a valid boolean", errInvalidValue, kw.Name, raw) + } + 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). + for _, allowed := range kw.Values { + if raw == allowed { + p.Typed = grammar.TypedValue{Type: grammar.ShapeEnumOption, String: raw} + break + } + } + 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. + default: + // 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 new file mode 100644 index 0000000..8fc30df --- /dev/null +++ b/internal/parsers/routebody/responses.go @@ -0,0 +1,187 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package routebody + +import ( + "go/token" + "strings" + + "github.com/go-openapi/codescan/internal/parsers/grammar" +) + +// 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. +// +// 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 + ResponseRef string + Arrays int + Description string + Pos token.Position +} + +// 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. +// +// diag may be nil; when nil, diagnostics are dropped. +func ParseResponses(body string, basePos token.Position, diag func(grammar.Diagnostic)) []ResponseDecl { + if strings.TrimSpace(body) == "" { + return nil + } + + var out []ResponseDecl + lines := strings.Split(body, "\n") + for i, raw := range lines { + lineNo := i + 1 + line := strings.TrimSpace(raw) + if line == "" { + continue + } + pos := offsetPos(basePos, lineNo) + + idx := strings.Index(line, ":") + if idx < 0 { + emitDiagf(diag, pos, + "response line %q has no `:` separator", line) + continue + } + + code := strings.TrimSpace(line[:idx]) + if code == "" { + emitDiagf(diag, pos, + "response line missing status code before `:`") + continue + } + + value := strings.TrimSpace(line[idx+1:]) + decl, ok := parseResponseValue(code, value, pos, diag) + if !ok { + continue + } + out = append(out, decl) + } + + 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. +func parseResponseValue(code, value string, pos token.Position, diag func(grammar.Diagnostic)) (ResponseDecl, bool) { + decl := ResponseDecl{Code: code, Pos: pos} + if value == "" { + return decl, true + } + + tokens := strings.Fields(value) + descTokens := []string{} + seenBodyOrResponse := false + + for i, tok := range tokens { + tag, val, isTagged := splitTagToken(tok) + switch { + case isTagged && tag == "body": + if seenBodyOrResponse { + emitDiagf(diag, pos, + "response line %q: duplicate body/response tag", code+": "+value) + return ResponseDecl{}, false + } + seenBodyOrResponse = true + decl.BodyTypeRef, decl.Arrays = stripArrayPrefixes(val) + case isTagged && tag == "response": + if seenBodyOrResponse { + emitDiagf(diag, pos, + "response line %q: duplicate body/response tag", code+": "+value) + return ResponseDecl{}, false + } + 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. + if val != "" { + descTokens = append(descTokens, val) + } + if i < len(tokens)-1 { + descTokens = append(descTokens, tokens[i+1:]...) + } + decl.Description = strings.Join(descTokens, " ") + return decl, true + case isTagged: + emitDiagf(diag, pos, + "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". + if i == 0 && (tok == "body" || tok == "response") { + emitDiagf(diag, pos, + "response line %q: missing `:` after %q — write `%s:Foo` not `%s Foo`", + code+": "+value, tok, tok, tok) + return ResponseDecl{}, false + } + 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. + seenBodyOrResponse = true + decl.ResponseRef, decl.Arrays = stripArrayPrefixes(tok) + continue + } + descTokens = append(descTokens, tok) + } + } + + if len(descTokens) > 0 { + decl.Description = strings.Join(descTokens, " ") + } + 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. +func splitTagToken(tok string) (tag, value string, ok bool) { + idx := strings.Index(tok, ":") + if idx < 0 { + return "", "", false + } + return tok[:idx], tok[idx+1:], true +} + +// 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, "[]") { + arrays++ + ref = ref[2:] + } + return ref, arrays +} diff --git a/internal/parsers/security.go b/internal/parsers/security.go deleted file mode 100644 index 7d3f434..0000000 --- a/internal/parsers/security.go +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "regexp" - "strings" -) - -type SetSchemes struct { - set func([]string) - rx *regexp.Regexp -} - -func NewSetSchemes(set func([]string)) *SetSchemes { - return &SetSchemes{ - set: set, - rx: rxSchemes, - } -} - -func (ss *SetSchemes) Matches(line string) bool { - return ss.rx.MatchString(line) -} - -func (ss *SetSchemes) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - - matches := ss.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - sch := strings.Split(matches[1], ", ") - - schemes := []string{} - for _, s := range sch { - ts := strings.TrimSpace(s) - if ts != "" { - schemes = append(schemes, ts) - } - } - ss.set(schemes) - } - - return nil -} - -type SetSecurity struct { - set func([]map[string][]string) - rx *regexp.Regexp -} - -func newSetSecurity(rx *regexp.Regexp, setter func([]map[string][]string)) *SetSecurity { - return &SetSecurity{ - set: setter, - rx: rx, - } -} - -func NewSetSecurityScheme(setter func([]map[string][]string)) *SetSecurity { - return &SetSecurity{ - set: setter, - rx: rxSecuritySchemes, - } -} - -func (ss *SetSecurity) Matches(line string) bool { - return ss.rx.MatchString(line) -} - -func (ss *SetSecurity) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - - var result []map[string][]string - const kvParts = 2 - for _, line := range lines { - kv := strings.SplitN(line, ":", kvParts) - scopes := []string{} - var key string - - if len(kv) > 1 { - scs := strings.SplitSeq(kv[1], ",") - for scope := range scs { - tr := strings.TrimSpace(scope) - if tr != "" { - tr = strings.SplitAfter(tr, " ")[0] - scopes = append(scopes, strings.TrimSpace(tr)) - } - } - - key = strings.TrimSpace(kv[0]) - - result = append(result, map[string][]string{key: scopes}) - } - } - - ss.set(result) - - return nil -} diff --git a/internal/parsers/security/security.go b/internal/parsers/security/security.go new file mode 100644 index 0000000..7a2d96c --- /dev/null +++ b/internal/parsers/security/security.go @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package security is a thin sub-parser for the `Security:` block +// body that appears under both `swagger:meta` and `swagger:route`. +// The body shape is one requirement per line: +// +// name: scope1, scope2 +// +// where the scope list may be empty (a bare `name:` is permitted) +// and scopes are whitespace-trimmed. +// +// Sibling of `internal/parsers/yaml/`: imported by +// `internal/parsers/grammar/` from the lexer-time dispatch in +// emitRawBlock (the same seam yaml.TypedExtensions plugs into for +// the `extensions:` keyword). Builders read the typed result via +// `grammar.Block.SecurityRequirements()` rather than re-parsing the +// raw body — same shape extensions already follows. +package security + +import "strings" + +// Requirement is one Security: line's contribution to the spec: +// a single-entry map from name → scope list. Multiple Requirements +// build up across lines. +type Requirement = map[string][]string + +// Parse splits body on newlines and parses each non-blank line as a +// `name: scope1, scope2` security requirement. Empty body returns +// nil. +// +// V1 quirk preserved: a scope that contains whitespace truncates at +// its first word — fixtures today only use single-word scopes, so +// the truncation is invisible, but the regression risk is real +// enough to keep the behaviour locked. +func Parse(body string) []Requirement { + if body == "" { + return nil + } + return parseLines(strings.Split(body, "\n")) +} + +func parseLines(lines []string) []Requirement { + const kvParts = 2 + var result []Requirement + for _, raw := range lines { + kv := strings.SplitN(raw, ":", kvParts) + if len(kv) < kvParts { + continue + } + name := strings.TrimSpace(kv[0]) + if name == "" { + continue + } + scopes := []string{} + for scope := range strings.SplitSeq(kv[1], ",") { + tr := strings.TrimSpace(scope) + if tr == "" { + continue + } + // V1 quirk: scope truncates at first whitespace. + tr = strings.SplitAfter(tr, " ")[0] + scopes = append(scopes, strings.TrimSpace(tr)) + } + result = append(result, Requirement{name: scopes}) + } + return result +} diff --git a/internal/parsers/security_test.go b/internal/parsers/security_test.go deleted file mode 100644 index 6d88115..0000000 --- a/internal/parsers/security_test.go +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" -) - -func TestSetSchemes(t *testing.T) { - t.Parallel() - - t.Run("single scheme", func(t *testing.T) { - var got []string - ss := NewSetSchemes(func(v []string) { got = v }) - assert.TrueT(t, ss.Matches("schemes: http")) - require.NoError(t, ss.Parse([]string{"schemes: http"})) - assert.Equal(t, []string{"http"}, got) - }) - - t.Run("multiple schemes", func(t *testing.T) { - var got []string - ss := NewSetSchemes(func(v []string) { got = v }) - require.NoError(t, ss.Parse([]string{"schemes: http, https"})) - assert.Equal(t, []string{"http", "https"}, got) - }) - - t.Run("wss", func(t *testing.T) { - var got []string - ss := NewSetSchemes(func(v []string) { got = v }) - require.NoError(t, ss.Parse([]string{"Schemes: ws, wss"})) - assert.Equal(t, []string{"ws", "wss"}, got) - }) - - t.Run("empty", func(t *testing.T) { - var got []string - ss := NewSetSchemes(func(v []string) { got = v }) - require.NoError(t, ss.Parse(nil)) - require.NoError(t, ss.Parse([]string{})) - require.NoError(t, ss.Parse([]string{""})) - assert.Nil(t, got) - }) - - t.Run("no match", func(t *testing.T) { - ss := NewSetSchemes(nil) - assert.FalseT(t, ss.Matches("something else")) - }) -} - -func TestSetSecurity(t *testing.T) { - t.Parallel() - - t.Run("with scopes", func(t *testing.T) { - var got []map[string][]string - ss := NewSetSecurityScheme(func(v []map[string][]string) { got = v }) - assert.TrueT(t, ss.Matches("security:")) - require.NoError(t, ss.Parse([]string{ - "api_key:", - "oauth2: read:pets, write:pets", - })) - require.Len(t, got, 2) - assert.Equal(t, map[string][]string{"api_key": {}}, got[0]) - assert.Equal(t, map[string][]string{"oauth2": {"read:pets", "write:pets"}}, got[1]) - }) - - t.Run("empty", func(t *testing.T) { - var got []map[string][]string - ss := NewSetSecurityScheme(func(v []map[string][]string) { got = v }) - require.NoError(t, ss.Parse(nil)) - require.NoError(t, ss.Parse([]string{})) - require.NoError(t, ss.Parse([]string{""})) - assert.Nil(t, got) - }) - - t.Run("no colon in line", func(t *testing.T) { - var got []map[string][]string - ss := NewSetSecurityScheme(func(v []map[string][]string) { got = v }) - require.NoError(t, ss.Parse([]string{"no-colon-here"})) - assert.Nil(t, got) // line without colon is skipped - }) -} From 63b7088d42219b40a316291c5c2e47f18052c99b Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:37:12 +0200 Subject: [PATCH 07/22] feat(builders/common): shared Builder state and cache common.Builder holds the per-decl state embedded by every concrete builder: scanner context, active declaration, parsed-block cache (memoised by *ast.CommentGroup pointer), diagnostic accumulator, post-decl queue with per-Builder dedup, and the slog logger. MakeRef writes `$ref: "#/definitions/"` onto a SwaggerTypable target. AppendPostDecl enqueues the referenced decl on the post-decl queue for the orchestrator's discovery loop. Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- internal/builders/common/README.md | 120 ++++++++++++++++++ internal/builders/common/builder.go | 184 ++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 internal/builders/common/README.md create mode 100644 internal/builders/common/builder.go diff --git a/internal/builders/common/README.md b/internal/builders/common/README.md new file mode 100644 index 0000000..99e7e5e --- /dev/null +++ b/internal/builders/common/README.md @@ -0,0 +1,120 @@ +# common builder — maintainer notes + +This document is the long-form companion to the `common.Builder` code. + +The source files keep godoc concise; complex invariants, design trade-offs, and intentionally-deferred follow-ups live here. + +`common.Builder` is the shared state every per-decl builder embeds +(`schema`, `parameters`, `responses`, `routes`, `operations`, `spec`). + +It owns the scanner context, the active declaration, the +parsed-block memoisation cache, the diagnostic accumulator, the +post-decl queue, and the slog logger. + +--- + +## Table of contents + +- [§blockcache](#blockcache) — `ParseBlock` / `ParseBlocks` memoisation strategy and scope +- [§makeref](#makeref) — why `MakeRef` lives on the common base +- [§diagnostics](#diagnostics) — accumulator ordering, dedup posture, LSP-evolution caveat +- [§postdecls](#postdecls) — per-Builder dedup index + cross-Builder re-dedup in the orchestrator +- [§quirks-open](#quirks-open) — deferred follow-ups + +--- + +## §blockcache — `ParseBlock` / `ParseBlocks` memoisation + +`Builder.blockCache` memoises `grammar.NewParser(...).ParseAll(cg)` +results keyed by `*ast.CommentGroup` pointer. Two reasons: + +1. **Recursive type descent re-visits the same comment.** A struct + field whose type is itself a struct triggers a nested + `buildFromDecl`/`buildFromType` pass; without memoisation each + level re-lexes and re-parses the same field-doc comment group. +2. **Multi-annotation visibility.** `ParseAll` yields one Block per + annotation on the comment group (the + `swagger:type` + `swagger:model` co-decl is the canonical + double-annotation case). Callers that only need the first + annotation use `ParseBlock`; callers that need every annotation + iterate `ParseBlocks`. + +The cache is **per-Builder** (one top-level decl build), so no +synchronisation is needed: a Builder is single-goroutine for its +entire lifetime. Crossing a Builder boundary discards the cache, +which is fine — the scanner context owns the FileSet, so a parser +constructed in a sibling Builder still produces position-stable +output. + +`ParseBlock(cg)` always returns a non-nil Block (the parser yields +at least one Block even for a nil comment group, conventionally an +`UnboundBlock`). Callers can read `AnnotationKind()`, +`AnnotationArg()`, etc. on the result unconditionally. + +## §makeref — why `MakeRef` lives on the common base + +`MakeRef` writes `$ref: #/definitions/` onto a target via +`SwaggerTypable.SetRef`, then enqueues the referenced declaration on +the Builder's post-decl queue so the spec orchestrator visits it +during the discovery loop. + +The name source is `decl.Names()` (first entry — top-level decls in +this codebase have a single name). + +The method lives on `common.Builder` rather than per-package because +every builder needs the same operation with the same side effect. +Hoisting also means future cross-cutting refinements — a name-collision +diagnostic, a discovery-loop instrumentation counter, a guard against +emitting `$ref` to an unexported name — are one-place edits. + +## §diagnostics — accumulator + LSP-evolution caveat + +`Diagnostics()` returns every accumulated `grammar.Diagnostic` in +source order. **No deduplication is applied.** Two consumers may see +the same diagnostic via the `OnDiagnostic` callback AND via the +returned slice — that's intentional under the current contract. + +The diagnostic surface is **experimental** and expected to evolve +once the LSP integration matures. Likely changes when that lands: +typed severity classes, structural deduplication, per-position +provenance. The shape is conservative today (slice of +`grammar.Diagnostic` + a callback hook) precisely so it can be +widened without breaking callers. + +`RecordDiagnostic` appends to the slice and fires +`Ctx.OnDiagnostic()` when wired. Walkers' `Diagnostic` callback +points at this method so grammar-level warnings flow into the same +accumulator. + +## §postdecls — dedup index + orchestrator re-dedup + +`AppendPostDecl(decl)` enqueues decl for post-processing by the spec +orchestrator's discovery loop. The Builder maintains a +per-instance dedup index (`postDeclSet`, keyed by `*ast.Ident`) so a +single decl re-discovered N times during one Build pass only enqueues +once. + +A SECOND dedup runs in `spec.Builder.buildDiscovered` at consumption +time, because two different per-decl Builders may surface the same +post-decl independently. The double-guard means a discovered decl +never reaches a second Build pass even when sibling Builders race to +register it. + +Nil and Ident-less decls are silently ignored — defensive against +the scanner emitting partial decls during error recovery. + +## §quirks-open — deferred follow-ups + +These are real maintenance items the package author noted; they remain open for a future pass. + +- **logger configurability.** `New` instantiates `slog.Default()`. + An option to accept a user-supplied `*slog.Logger` (level, + coloured output, structured fields) would let callers opt into a + consistent logging surface across builders. Currently every + builder's `Warn`/`Debug` writes through the global default. +- **`ireturn` on `ParseBlock`.** The `nolint:ireturn` directive on + `ParseBlock` carries because `grammar.Block` is a polymorphic + interface — that's the documented return type. The lint could + be disabled package-wide rather than per-function; consider as + a `.golangci.yml` exclusion once the broader lint posture is + reviewed. diff --git a/internal/builders/common/builder.go b/internal/builders/common/builder.go new file mode 100644 index 0000000..001f302 --- /dev/null +++ b/internal/builders/common/builder.go @@ -0,0 +1,184 @@ +// 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 + +import ( + "go/ast" + "log/slog" + + "github.com/go-openapi/codescan/internal/ifaces" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/scanner" + oaispec "github.com/go-openapi/spec" +) + +// Builder holds 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. +// +// [§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 +// [§postdecls]: https://github.com/go-openapi/codescan/blob/master/internal/common/README.md#postdecls +type Builder struct { + Ctx *scanner.ScanCtx + Decl *scanner.EntityDecl + + postDecls []*scanner.EntityDecl + postDeclSet map[*ast.Ident]struct{} // dedup index keyed by EntityDecl.Ident + diagnostics []grammar.Diagnostic + blockCache map[*ast.CommentGroup][]grammar.Block + logger *slog.Logger +} + +// New builds a [Builder] bound to ctx and decl. +// +// The blockCache is pre-allocated empty; logger defaults to [slog.Default]. +// +// See [§quirks-open] for the planned configurability. +// +// [§quirks-open]: https://github.com/go-openapi/codescan/blob/master/internal/common/README.md#quirks-open +func New(ctx *scanner.ScanCtx, decl *scanner.EntityDecl) *Builder { + return &Builder{ + Ctx: ctx, + Decl: decl, + blockCache: make(map[*ast.CommentGroup][]grammar.Block), + logger: slog.Default(), + } +} + +// 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 { + return s.postDecls +} + +// Warn writes a warning to the Builder's slog logger. +func (s *Builder) Warn(msg string, args ...any) { + s.logger.Warn(msg, args...) +} + +// Debug writes a debug message to the Builder's slog logger. +func (s *Builder) Debug(msg string, args ...any) { + s.logger.Debug(msg, args...) +} + +// Diagnostics returns every grammar.Diagnostic accumulated by this Builder during a Build pass. +// +// Source order is preserved; no deduplication is applied. +// The slice is owned by the Builder; callers must not mutate it. +// Returns nil before Build is invoked or when no diagnostics were recorded. +// +// # 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). +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. +// +// 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) + if cb := s.Ctx.OnDiagnostic(); cb != nil { + cb(d) + } +} + +// 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. +// +// # Details +// +// 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 { + if cg == nil { + return grammar.NewParser(s.Ctx.FileSet()).ParseAll(nil) + } + + bs, ok := s.blockCache[cg] + if !ok { + bs = grammar.NewParser(s.Ctx.FileSet()).ParseAll(cg) + s.blockCache[cg] = bs + } + + return bs +} + +// ParseBlock returns the first Block from [Builder.ParseBlocks]. +// +// This is the "primary" annotation for callers that don't need multi-annotation +// visibility (title/description, field-level lookups). +// +//nolint:ireturn // grammar.Block is the documented polymorphic return type. +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. +// +// # Details +// +// 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 + } + if s.postDeclSet == nil { + s.postDeclSet = make(map[*ast.Ident]struct{}) + } + if _, dup := s.postDeclSet[decl.Ident]; dup { + return + } + s.postDeclSet[decl.Ident] = struct{}{} + s.postDecls = append(s.postDecls, decl) +} + +// MakeRef writes a `$ref: "#/definitions/"` onto prop and +// registers decl with the discovery loop via AppendPostDecl. The name +// comes from decl.Names() (the first entry — top-level decls in this +// codebase have a single name). Returns an error only if +// oaispec.NewRef rejects the JSON pointer. +// +// # Details +// +// 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 { + nm, _ := decl.Names() + ref, err := oaispec.NewRef("#/definitions/" + nm) + if err != nil { + return err + } + + prop.SetRef(ref) + s.AppendPostDecl(decl) + + return nil +} From 92fb1bff1a6953d46336084e3d6ca3116a703c1d Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:37:24 +0200 Subject: [PATCH 08/22] feat(builders/handlers): shared Walker callback factories Reusable Walker keyword handlers shared across schema, parameters, responses, headers: Number, Integer, UniqueBool, PatternString, Extension, and the parameter-level / schema-level dispatch factories. Built once here so each per-decl builder wires them into its Walker rather than re-implementing. keywords.go catalogs the SimpleSchema-allowed subset for the parameter and header surfaces. Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- internal/builders/handlers/README.md | 178 ++++++++ internal/builders/handlers/dispatch_schema.go | 416 ++++++++++++++++++ internal/builders/handlers/dispatch_simple.go | 178 ++++++++ internal/builders/handlers/handlers.go | 224 ++++++++++ internal/builders/handlers/keywords.go | 52 +++ internal/builders/handlers/keywords_test.go | 61 +++ 6 files changed, 1109 insertions(+) create mode 100644 internal/builders/handlers/README.md create mode 100644 internal/builders/handlers/dispatch_schema.go create mode 100644 internal/builders/handlers/dispatch_simple.go create mode 100644 internal/builders/handlers/handlers.go create mode 100644 internal/builders/handlers/keywords.go create mode 100644 internal/builders/handlers/keywords_test.go diff --git a/internal/builders/handlers/README.md b/internal/builders/handlers/README.md new file mode 100644 index 0000000..33a2a16 --- /dev/null +++ b/internal/builders/handlers/README.md @@ -0,0 +1,178 @@ +# handlers — maintainer notes + +This document is the long-form companion to the `handlers` package code. + +The source files keep godoc concise; complex invariants, design trade-offs, and intentionally-deferred follow-ups live here. + +The `handlers` package ships shared grammar Walker callbacks for the `SimpleSchema` family of OAS v2 dispatchers +(parameter level-0 and items chain, response-header level-0 and items chain) as well as the full-Schema dispatchers +used by the `schema` builder. + +--- + +## Table of contents + +- [§dispatch-surface](#dispatch-surface) — how SimpleSchema and full-Schema dispatch differ +- [§walker-payloads](#walker-payloads) — payload conventions per Walker callback +- [§raw-errsink](#raw-errsink) — the `errSink` contract on `Raw` and the parameter vs header posture +- [§collection-format-fallback](#collection-format-fallback) — why the `collectionFormat:` handler accepts arbitrary strings +- [§simple-schema-keywords](#simple-schema-keywords) — keyword allow-list under SimpleSchema mode and the `required:` carve-out +- [§extensions](#extensions) — how vendor extensions land via `AddExtension` +- [§stale-enum-desc](#stale-enum-desc) — why a field-level `enum:` strips an inherited `x-go-enum-desc` +- [§quirks-open](#quirks-open) — deferred follow-ups + +--- + +## §dispatch-surface — SimpleSchema vs full-Schema dispatch + +The package exports two dispatcher families: + +- **SimpleSchema** — `DispatchParamLevel0`, `DispatchHeaderLevel0`, + `DispatchItemsLevel`. These fan a single switch-on-`Keyword.Name` + out across consumers that write through an + `ifaces.ValidationBuilder` or + `ifaces.OperationValidationBuilder` adapter + (`paramValidations`, `headerValidations`, `items.Validations`). +- **full-Schema** — `DispatchSchemaLevel0`, `DispatchSchemaItemsLevel`. + These add a `checkShape` gate that emits `CodeShapeMismatch` on + keyword-vs-resolved-type mismatches, and the Bool handler does + cross-target writes (`required:` → `enclosing.Required` keyed by + name, `discriminator:` likewise). + +The shape-gate and cross-target writes are full-Schema-specific +concerns and don't share the SimpleSchema seam. The `SchemaOptions` +struct carries a `SimpleSchemaMode` flag that the full-Schema +dispatchers use to gate full-Schema-only keywords (`readOnly`, +`discriminator`) with `CodeUnsupportedInSimpleSchema` and to +silently skip `required:` (parameters handle `required:` at the +parameter level via SimpleSchema dispatch; headers don't carry +`required:` at all). + +## §walker-payloads — Walker callback payload conventions + +- **Number / Integer / Bool** callbacks fire with a zero-value + payload when the lexer rejected the source value; the parser + has already emitted a `CodeInvalid{Number,Integer,Boolean}` + diagnostic. Consumers must gate on `pr.IsTyped()` before + writing — the helpers in this package do that internally. +- **String** fires with the raw value alongside `pr.Value`; for + `pattern:` consumers should read `pr.Value` (the regex source) + rather than the formatted string, so the regex reaches + `SetPattern` verbatim. +- **Raw** fires for `ShapeRawValue` keywords (`default:`, + `example:`, `enum:`) and reads `pr.Value` for the raw text. + +## §raw-errsink — `errSink` contract on `Raw` + +`Raw` accepts an `errSink func(error) bool` argument that controls +how coercion errors from `default:` / `example:` propagate: + +- `errSink == nil` → swallow silently. The response-header path + uses this posture: a malformed default/example on a header does + not fail the build. Both `DispatchHeaderLevel0` and + `DispatchItemsLevel` wire `errSink=nil`. +- `errSink != nil` → invoked with the first + `ParseValueFromSchema` error. Returning `true` short-circuits + subsequent `Raw` callbacks within the same Walker invocation + (the closure's `stopped` flag); returning `false` continues. + +`DispatchParamLevel0` wires a sink that captures the first error +and returns `true`, so `DispatchParamLevel0` bubbles a malformed +parameter `default:` / `example:` up to the caller as a hard +failure. See `TestMalformed_DefaultInt` / +`TestMalformed_ExampleInt` in the integration suite for the +end-to-end behaviour. + +## §collection-format-fallback — `collectionFormat:` accepts arbitrary strings + +`CollectionFormatString` tries the Walker-supplied typed string +first. When that is empty (the grammar's closed-vocab string-enum +rejected the source), it falls back to +`strings.TrimSpace(pr.Value)` and writes the raw value through. + +The OAS v2 spec defines a closed vocabulary +(`csv`/`ssv`/`tsv`/`pipes`/`multi`), but the codescan grammar is +intentionally permissive at this site: a typo such as `pipe` +instead of `pipes` round-trips verbatim onto the parameter or +items object. This preserves the source author's intent for +downstream tools that may surface validation errors against the +spec text directly. + +SimpleSchema-only — the full-Schema `Validations` adapter does +not expose `SetCollectionFormat` because `collectionFormat:` is +not a full-Schema keyword. + +## §simple-schema-keywords — allow-list and `required:` carve-out + +`simpleSchemaAllowed` in `keywords.go` enumerates the grammar +keyword names legal on an OAS v2 SimpleSchema site (parameter +with `in != body`, response header, and the items chain within +either). Source of truth: the OAS v2 Parameter Object and Header +Object allowed-keyword tables. + +Vendor extensions (`x-*`) are not listed in the table — they are +gated by `classify.IsAllowedExtension`, which runs by name-prefix. + +`required:` is included in the SimpleSchema allow-list because it +is valid on the parameter site (as a parameter-level boolean), but +it is NOT valid on response headers. Two consequences: + +- The parameters walker writes `required:` to `param.Required` + directly via `paramRequiredBool` — the value lands on the + parameter object, not on a schema. +- The full-Schema walker (`schemaBoolHandler`) silently skips + `required:` under `SimpleSchemaMode` because its full-Schema + target is `enclosing.Required[name]` — the object-level + required-array — which doesn't fit the SimpleSchema shape. + +`IsSimpleSchemaKeyword` returns `false` for full-Schema-only +keywords (`readOnly`, `discriminator`, `$ref`, `allOf`, ...) and +for unknown names. Consumers wired in SimpleSchema mode use this +predicate to gate writes and emit +`CodeUnsupportedInSimpleSchema` diagnostics on miss. + +## §extensions — vendor extensions land via `AddExtension` + +`ExtensionTarget` is the minimal surface a `Walker.Extension` +consumer needs to write a vendor extension. It is implemented by +every `oaispec` object that embeds `VendorExtensible` (`Schema`, +`Parameter`, `Header`, `Response`, `Operation`, ...) via the +`AddExtension` method promoted from the embed. + +`Extension` returns a callback that filters non-`x-*` names via +`classify.IsAllowedExtension` and writes the typed extension +value onto the target. + +User-authored extensions are not gated by the `SkipExtensions` +option — that flag suppresses scanner-derived `x-go-*` keys, not +author intent. Consumers that need additional side effects on a +successful write (e.g. the schema builder's +`refOverrideCollector` marking the collector) wrap this with +their own callback rather than reusing this helper. + +## §stale-enum-desc — field-level `enum:` strips inherited `x-go-enum-desc` + +`SchemaValidations.SetEnum` writes the parsed enum onto the +schema, then calls `clearStaleEnumDesc` to strip the +`x-go-enum-desc` extension (and the matching suffix from +`Description`) when present. + +The extension is set by the type-level `swagger:enum TypeName` +pass and carries per-value documentation text. When a field-level +`enum:` annotation overrides the inherited values, the per-value +text describes values that are no longer in the field-level +enum — it is stale and must be dropped to avoid misleading +documentation. + +## §quirks-open — deferred follow-ups + +- **`collectionFormat:` lax acceptance.** The fallback to the + raw string preserves typos by design. A future strict-mode + option could emit a diagnostic when the value is outside the + OAS v2 closed vocabulary, leaving the lax default in place + for compatibility. +- **`SchemaOptions.SimpleSchemaMode` on items dispatch.** The + option is accepted on `DispatchSchemaItemsLevel` for + symmetry but currently does not alter items-level behaviour. + Worth revisiting if items dispatch ever needs the same + gating as level-0 dispatch. diff --git a/internal/builders/handlers/dispatch_schema.go b/internal/builders/handlers/dispatch_schema.go new file mode 100644 index 0000000..467d8ba --- /dev/null +++ b/internal/builders/handlers/dispatch_schema.go @@ -0,0 +1,416 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +import ( + "regexp" + "strings" + + "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/builders/validations" + "github.com/go-openapi/codescan/internal/ifaces" + "github.com/go-openapi/codescan/internal/parsers/grammar" + oaispec "github.com/go-openapi/spec" +) + +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. +// +// 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. +// +// Exported because the schema package's refOverrideCollector also uses +// it directly when building $ref-override allOf compounds. +type SchemaValidations struct { + current *oaispec.Schema +} + +// NewSchemaValidations builds an adapter around ps. +func NewSchemaValidations(ps *oaispec.Schema) SchemaValidations { + return SchemaValidations{current: ps} +} + +func (sv SchemaValidations) SetMaximum(val float64, exclusive bool) { + sv.current.Maximum = &val + sv.current.ExclusiveMaximum = exclusive +} + +func (sv SchemaValidations) SetMinimum(val float64, exclusive bool) { + sv.current.Minimum = &val + sv.current.ExclusiveMinimum = exclusive +} +func (sv SchemaValidations) SetMultipleOf(val float64) { sv.current.MultipleOf = &val } +func (sv SchemaValidations) SetMinItems(val int64) { sv.current.MinItems = &val } +func (sv SchemaValidations) SetMaxItems(val int64) { sv.current.MaxItems = &val } +func (sv SchemaValidations) SetMinLength(val int64) { sv.current.MinLength = &val } +func (sv SchemaValidations) SetMaxLength(val int64) { sv.current.MaxLength = &val } +func (sv SchemaValidations) SetPattern(val string) { sv.current.Pattern = val } +func (sv SchemaValidations) SetUnique(val bool) { sv.current.UniqueItems = val } +func (sv SchemaValidations) SetDefault(val any) { sv.current.Default = val } +func (sv SchemaValidations) SetExample(val any) { sv.current.Example = val } + +// 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 { + typ = sv.current.Type[0] + } + sv.current.Enum = validations.ParseEnumValues(val, typ, sv.current.Format) + 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). +// +// 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, + diag func(grammar.Diagnostic), opts SchemaOptions, +) { + valid := NewSchemaValidations(ps) + + block.Walk(grammar.Walker{ + FilterDepth: 0, + Number: schemaNumberHandler(ps, valid, diag), + Integer: schemaIntegerHandler(ps, valid, diag), + Bool: schemaBoolHandler(enclosing, ps, name, valid, diag, opts), + String: schemaStringHandler(ps, valid, diag), + Raw: schemaRawHandler(ps, valid), + Extension: Extension(ps), + Diagnostic: diag, + }) +} + +// 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 accepted for symmetry but currently +// doesn't alter items-level behaviour. +// +// diag may be nil; when nil, all diagnostics are dropped. +func DispatchSchemaItemsLevel(block grammar.Block, target *oaispec.Schema, depth int, + diag func(grammar.Diagnostic), _ SchemaOptions, +) { + valid := NewSchemaValidations(target) + + block.Walk(grammar.Walker{ + FilterDepth: depth, + Number: schemaNumberHandler(target, valid, diag), + Integer: schemaIntegerHandler(target, valid, diag), + Bool: func(p grammar.Property, val bool) { + if !p.IsTyped() { + return + } + if !checkShape(p, target, diag) { + return + } + if p.Keyword.Name == grammar.KwUnique { + valid.SetUnique(val) + } + }, + String: schemaStringHandler(target, valid, diag), + Raw: schemaRawHandler(target, valid), + Diagnostic: diag, + }) +} + +// 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. +// +// 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)) { + valid.SetPattern(val) + if val == "" { + return + } + if _, err := regexp.Compile(val); err != nil && diag != nil { + diag(grammar.Diagnostic{ + Pos: p.Pos, + Severity: grammar.SeverityWarning, + Code: grammar.CodeInvalidAnnotation, + Message: strings.TrimSpace( + "pattern: " + val + " is not a valid Go RE2 regex (" + err.Error() + "); " + + "the value is preserved on the schema but downstream RE2 validators will fail", + ), + }) + } +} + +// SetRequired adds or removes name from the enclosing schema's Required slice. +// +// Exported because the schema package's refOverrideCollector reuses this when handling `required:` +// on a $ref-override field. +func SetRequired(enclosing *oaispec.Schema, name string, required bool) { + if enclosing == nil { + return + } + midx := -1 + for i, nm := range enclosing.Required { + if nm == name { + midx = i + break + } + } + if required { + if midx < 0 { + enclosing.Required = append(enclosing.Required, name) + } + return + } + if midx >= 0 { + enclosing.Required = append(enclosing.Required[:midx], enclosing.Required[midx+1:]...) + } +} + +// SetDiscriminator writes name to enclosing.Discriminator when +// required=true, or clears it when required=false and the current +// value matches. +func SetDiscriminator(enclosing *oaispec.Schema, name string, required bool) { + if enclosing == nil { + return + } + if required { + enclosing.Discriminator = name + return + } + if enclosing.Discriminator == name { + enclosing.Discriminator = "" + } +} + +// SchemaTypeOf returns the resolved Swagger type of ps, or "" when +// the schema has no Type set (typically a $ref-only schema, where +// type-aware coercion is not possible at this level). +func SchemaTypeOf(ps *oaispec.Schema) string { + if ps == nil || len(ps.Type) == 0 { + return "" + } + return ps.Type[0] +} + +// --- 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 +// (checkShape emits CodeShapeMismatch and the property is dropped). +func schemaNumberHandler(ps *oaispec.Schema, valid SchemaValidations, + diag func(grammar.Diagnostic), +) func(grammar.Property, float64, bool) { + return func(p grammar.Property, val float64, exclusive bool) { + if !p.IsTyped() { + return + } + if !checkShape(p, ps, diag) { + return + } + switch p.Keyword.Name { + case grammar.KwMaximum: + valid.SetMaximum(val, exclusive) + case grammar.KwMinimum: + valid.SetMinimum(val, exclusive) + case grammar.KwMultipleOf: + valid.SetMultipleOf(val) + } + } +} + +// schemaIntegerHandler returns a Walker.Integer callback bound to +// valid. Recognises minLength / maxLength / minItems / maxItems. +// Skips on typing failure or shape mismatch. +func schemaIntegerHandler(ps *oaispec.Schema, valid SchemaValidations, + diag func(grammar.Diagnostic), +) func(grammar.Property, int64) { + return func(p grammar.Property, val int64) { + if !p.IsTyped() { + return + } + if !checkShape(p, ps, diag) { + return + } + switch p.Keyword.Name { + case grammar.KwMinLength: + valid.SetMinLength(val) + case grammar.KwMaxLength: + valid.SetMaxLength(val) + case grammar.KwMinItems: + valid.SetMinItems(val) + case grammar.KwMaxItems: + valid.SetMaxItems(val) + } + } +} + +// schemaBoolHandler returns a Walker.Bool callback. 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 +// [§simple-schema-keywords](./README.md#simple-schema-keywords). +func schemaBoolHandler(enclosing, ps *oaispec.Schema, name string, valid SchemaValidations, + diag func(grammar.Diagnostic), opts SchemaOptions, +) func(grammar.Property, bool) { + return func(p grammar.Property, val bool) { + if !p.IsTyped() { + return + } + if !checkShape(p, ps, diag) { + return + } + if opts.SimpleSchemaMode && !IsSimpleSchemaKeyword(p.Keyword.Name) { + if diag != nil { + diag(grammar.Warnf( + p.Pos, + grammar.CodeUnsupportedInSimpleSchema, + "%q is a full-Schema-only keyword and is not allowed under SimpleSchema mode; ignored", + p.Keyword.Name, + )) + } + return + } + if opts.SimpleSchemaMode && p.Keyword.Name == grammar.KwRequired { + return + } + switch p.Keyword.Name { + case grammar.KwUnique: + valid.SetUnique(val) + case grammar.KwReadOnly: + ps.ReadOnly = val + case grammar.KwRequired: + if name != "" { + SetRequired(enclosing, name, val) + } + case grammar.KwDiscriminator: + if name != "" { + SetDiscriminator(enclosing, name, val) + } + } + } +} + +// schemaStringHandler returns a Walker.String callback. +// +// Recognises pattern (raw regex) and the enum keyword's pre-typed enum-option +// form (rare; comma-list / JSON-array forms travel through Raw). +// +// Shape-checks pattern (string-only); default/example/enum are +// type-independent (ParseDefault handles the type-specific coercion). +func schemaStringHandler(ps *oaispec.Schema, valid SchemaValidations, + diag func(grammar.Diagnostic), +) func(grammar.Property, string) { + return func(p grammar.Property, val string) { + if !checkShape(p, ps, diag) { + return + } + switch p.Keyword.Name { + case grammar.KwPattern: + ApplyPattern(p, valid, val, diag) + case grammar.KwDefault: + if v, err := validations.ParseDefault(val, SchemaTypeOf(ps), ps.Format); err == nil { + valid.SetDefault(v) + } + case grammar.KwExample: + if v, err := validations.ParseDefault(val, SchemaTypeOf(ps), ps.Format); err == nil { + valid.SetExample(v) + } + case grammar.KwEnum: + valid.SetEnum(val) + } + } +} + +// schemaRawHandler routes ShapeRawBlock / ShapeRawValue / ShapeNone / +// ShapeCommaList properties — default:, example:, enum: when +// expressed as raw bodies. Extensions travel through Walker.Extension +// with YAML-typed values, so the KwExtensions arm is absent here. +func schemaRawHandler(ps *oaispec.Schema, valid SchemaValidations) func(grammar.Property) { + return func(p grammar.Property) { + switch p.Keyword.Name { + case grammar.KwDefault: + if v, err := validations.ParseDefault(p.Value, SchemaTypeOf(ps), ps.Format); err == nil { + valid.SetDefault(v) + } + case grammar.KwExample: + if v, err := validations.ParseDefault(p.Value, SchemaTypeOf(ps), ps.Format); err == nil { + valid.SetExample(v) + } + case grammar.KwEnum: + valid.SetEnum(p.Value) + } + } +} + +// 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 { + schemaType = ps.Type[0] + } + ok, hint := validations.IsLegalForType(p.Keyword, schemaType) + if ok { + return true + } + if diag != nil { + diag(grammar.Diagnostic{ + Pos: p.Pos, + Severity: grammar.SeverityWarning, + Code: grammar.CodeShapeMismatch, + Message: hint, + }) + } + return false +} + +// 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 +// [§stale-enum-desc](./README.md#stale-enum-desc). +func clearStaleEnumDesc(ps *oaispec.Schema) { + enumDesc := resolvers.GetEnumDesc(ps.Extensions) + if enumDesc == "" { + return + } + delete(ps.Extensions, resolvers.ExtEnumDesc) + ps.Description = strings.TrimSuffix( + strings.TrimSuffix(ps.Description, enumDesc), + "\n", + ) +} diff --git a/internal/builders/handlers/dispatch_simple.go b/internal/builders/handlers/dispatch_simple.go new file mode 100644 index 0000000..1e94754 --- /dev/null +++ b/internal/builders/handlers/dispatch_simple.go @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +import ( + "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/builders/validations" + "github.com/go-openapi/codescan/internal/ifaces" + "github.com/go-openapi/codescan/internal/parsers/grammar" + oaispec "github.com/go-openapi/spec" +) + +var ( + _ ifaces.OperationValidationBuilder = ¶mValidations{} + _ 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. +// +// The adapter is unexported — DispatchParamLevel0 is the entry point. +type paramValidations struct { + current *oaispec.Parameter +} + +func (sv paramValidations) SetMaximum(val float64, exclusive bool) { + sv.current.Maximum = &val + sv.current.ExclusiveMaximum = exclusive +} + +func (sv paramValidations) SetMinimum(val float64, exclusive bool) { + sv.current.Minimum = &val + sv.current.ExclusiveMinimum = exclusive +} +func (sv paramValidations) SetMultipleOf(val float64) { sv.current.MultipleOf = &val } +func (sv paramValidations) SetMinItems(val int64) { sv.current.MinItems = &val } +func (sv paramValidations) SetMaxItems(val int64) { sv.current.MaxItems = &val } +func (sv paramValidations) SetMinLength(val int64) { sv.current.MinLength = &val } +func (sv paramValidations) SetMaxLength(val int64) { sv.current.MaxLength = &val } +func (sv paramValidations) SetPattern(val string) { sv.current.Pattern = val } +func (sv paramValidations) SetUnique(val bool) { sv.current.UniqueItems = val } +func (sv paramValidations) SetCollectionFormat(val string) { sv.current.CollectionFormat = val } +func (sv paramValidations) SetEnum(val string) { + sv.current.Enum = validations.ParseEnumValues(val, sv.current.Type, sv.current.Format) +} +func (sv paramValidations) SetDefault(val any) { sv.current.Default = val } +func (sv paramValidations) SetExample(val any) { sv.current.Example = val } + +// headerValidations adapts *oaispec.Header to ifaces.ValidationBuilder +// for the response-header SimpleSchema dispatch. +type headerValidations struct { + current *oaispec.Header +} + +func (sv headerValidations) SetMaximum(val float64, exclusive bool) { + sv.current.Maximum = &val + sv.current.ExclusiveMaximum = exclusive +} + +func (sv headerValidations) SetMinimum(val float64, exclusive bool) { + sv.current.Minimum = &val + sv.current.ExclusiveMinimum = exclusive +} +func (sv headerValidations) SetMultipleOf(val float64) { sv.current.MultipleOf = &val } +func (sv headerValidations) SetMinItems(val int64) { sv.current.MinItems = &val } +func (sv headerValidations) SetMaxItems(val int64) { sv.current.MaxItems = &val } +func (sv headerValidations) SetMinLength(val int64) { sv.current.MinLength = &val } +func (sv headerValidations) SetMaxLength(val int64) { sv.current.MaxLength = &val } +func (sv headerValidations) SetPattern(val string) { sv.current.Pattern = val } +func (sv headerValidations) SetUnique(val bool) { sv.current.UniqueItems = val } +func (sv headerValidations) SetCollectionFormat(val string) { sv.current.CollectionFormat = val } +func (sv headerValidations) SetEnum(val string) { + sv.current.Enum = validations.ParseEnumValues(val, sv.current.Type, sv.current.Format) +} +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. +func paramRequiredBool(param *oaispec.Parameter) func(grammar.Property, bool) { + return func(pr grammar.Property, val bool) { + if !pr.IsTyped() { + return + } + if pr.Keyword.Name == grammar.KwRequired { + param.Required = val + } + } +} + +// 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). +func DispatchParamLevel0(block grammar.Block, param *oaispec.Parameter, diag func(grammar.Diagnostic)) error { + valid := paramValidations{param} + scheme := ¶m.SimpleSchema + var firstErr error + + block.Walk(grammar.Walker{ + FilterDepth: 0, + Number: Number(valid), + Integer: Integer(valid), + Bool: ComposeBool( + UniqueBool(valid), + paramRequiredBool(param), + ), + String: ComposeString( + PatternString(valid), + CollectionFormatString(valid), + ), + Raw: Raw(valid, scheme, func(err error) bool { + firstErr = err + return true + }), + Extension: Extension(param), + Diagnostic: diag, + }) + + 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). +// +// diag may be nil; when nil, parser diagnostics are dropped. +func DispatchHeaderLevel0(block grammar.Block, header *oaispec.Header, diag func(grammar.Diagnostic)) { + valid := headerValidations{header} + scheme := &header.SimpleSchema + + block.Walk(grammar.Walker{ + FilterDepth: 0, + Number: Number(valid), + Integer: Integer(valid), + Bool: UniqueBool(valid), + String: ComposeString( + PatternString(valid), + CollectionFormatString(valid), + ), + Raw: Raw(valid, scheme, nil), + Extension: Extension(header), + Diagnostic: diag, + }) +} + +// 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 + + block.Walk(grammar.Walker{ + FilterDepth: depth, + Number: Number(valid), + Integer: Integer(valid), + Bool: UniqueBool(valid), + String: ComposeString( + PatternString(valid), + CollectionFormatString(valid), + ), + Raw: Raw(valid, scheme, nil), + Diagnostic: diag, + }) +} diff --git a/internal/builders/handlers/handlers.go b/internal/builders/handlers/handlers.go new file mode 100644 index 0000000..709fc52 --- /dev/null +++ b/internal/builders/handlers/handlers.go @@ -0,0 +1,224 @@ +// 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. +// +// # 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 +// conventions on each Walker callback. +package handlers + +import ( + "strings" + + "github.com/go-openapi/codescan/internal/builders/validations" + "github.com/go-openapi/codescan/internal/ifaces" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/scanner/classify" + oaispec "github.com/go-openapi/spec" +) + +// ExtensionTarget is the minimal surface a Walker.Extension consumer +// needs to write a vendor extension. Implemented by every +// oaispec object that embeds VendorExtensible (Schema, Parameter, +// Header, Response, Operation, …) via the AddExtension method +// promoted from the embed. +type ExtensionTarget interface { + AddExtension(key string, value any) +} + +// Extension returns a Walker.Extension callback that filters non-`x-*` +// names via classify.IsAllowedExtension and writes the typed +// extension value onto target. +// +// # Details +// +// 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) { + return + } + target.AddExtension(ext.Name, ext.Value) + } +} + +// Number returns a Walker.Number callback that routes +// `maximum:` / `minimum:` / `multipleOf:` onto v. +func Number(v ifaces.ValidationBuilder) func(grammar.Property, float64, bool) { + return func(pr grammar.Property, val float64, exclusive bool) { + if !pr.IsTyped() { + return + } + switch pr.Keyword.Name { + case grammar.KwMaximum: + v.SetMaximum(val, exclusive) + case grammar.KwMinimum: + v.SetMinimum(val, exclusive) + case grammar.KwMultipleOf: + v.SetMultipleOf(val) + } + } +} + +// Integer returns a Walker.Integer callback that routes +// `min/maxLength:` and `min/maxItems:` onto v. +func Integer(v ifaces.ValidationBuilder) func(grammar.Property, int64) { + return func(pr grammar.Property, val int64) { + if !pr.IsTyped() { + return + } + switch pr.Keyword.Name { + case grammar.KwMinLength: + v.SetMinLength(val) + case grammar.KwMaxLength: + v.SetMaxLength(val) + case grammar.KwMinItems: + v.SetMinItems(val) + case grammar.KwMaxItems: + v.SetMaxItems(val) + } + } +} + +// UniqueBool returns a Walker.Bool callback that handles only the +// `unique:` keyword. Consumers that also need to dispatch +// `required:` (parameter level) wrap this with a second callback +// via [ComposeBool], or write their own narrow handler that adds the +// parameter-target write next to a call into UniqueBool. +func UniqueBool(v ifaces.ValidationBuilder) func(grammar.Property, bool) { + return func(pr grammar.Property, val bool) { + if !pr.IsTyped() { + return + } + if pr.Keyword.Name == grammar.KwUnique { + v.SetUnique(val) + } + } +} + +// ComposeBool returns a Walker.Bool callback that fans the payload +// out to every non-nil handler in order. Useful when a consumer +// wants UniqueBool plus a context-specific extra (e.g. parameters' +// `required:` writes to param.Required directly). +func ComposeBool(hs ...func(grammar.Property, bool)) func(grammar.Property, bool) { + return func(pr grammar.Property, val bool) { + for _, h := range hs { + if h != nil { + h(pr, val) + } + } + } +} + +// PatternString returns a Walker.String callback for the `pattern:` +// keyword. The pattern is read from `pr.Value` so the regex source +// reaches v.SetPattern verbatim. +func PatternString(v ifaces.ValidationBuilder) func(grammar.Property, string) { + return func(pr grammar.Property, _ string) { + if pr.Keyword.Name == grammar.KwPattern { + v.SetPattern(pr.Value) + } + } +} + +// CollectionFormatString returns a Walker.String callback for the +// `collectionFormat:` keyword. Tries the Walker-supplied typed +// string first 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. +// +// # Details +// +// 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 { + return + } + x := val + if x == "" { + x = strings.TrimSpace(pr.Value) + } + if x != "" { + v.SetCollectionFormat(x) + } + } +} + +// ComposeString returns a Walker.String callback that fans the +// payload out to every non-nil handler in order. The canonical use +// is to combine PatternString + CollectionFormatString in one +// Walker.String slot. +func ComposeString(hs ...func(grammar.Property, string)) func(grammar.Property, string) { + return func(pr grammar.Property, val string) { + for _, h := range hs { + if h != nil { + h(pr, val) + } + } + } +} + +// Raw returns a Walker.Raw callback for `default:` / `example:` / +// `enum:` (Shape=ShapeRawValue per the lexer table). `default` and +// `example` coerce against scheme via validations.CoerceValue; +// `enum` is delegated to v.SetEnum which routes through +// validations.CoerceEnum inside the adapter. +// +// errSink controls coercion-error semantics: +// +// - errSink == nil → swallow silently. The response-header path +// uses this posture so that a malformed default/example on a +// header doesn't fail the build. +// - errSink != nil → invoked with the first ParseValueFromSchema +// error. Returning true short-circuits subsequent Raw +// callbacks within this Walker (the closure's `stopped` flag); +// returning false continues. Parameters use this to bubble the +// error up so the build surfaces a malformed default/example +// as a hard failure. +// +// # 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. +func Raw(v ifaces.ValidationBuilder, scheme *oaispec.SimpleSchema, errSink func(error) bool) func(grammar.Property) { + stopped := false + return func(pr grammar.Property) { + if stopped { + return + } + switch pr.Keyword.Name { + case grammar.KwDefault: + val, err := validations.CoerceValue(pr.Value, scheme) + if err != nil { + if errSink != nil && errSink(err) { + stopped = true + } + return + } + v.SetDefault(val) + case grammar.KwExample: + val, err := validations.CoerceValue(pr.Value, scheme) + if err != nil { + if errSink != nil && errSink(err) { + stopped = true + } + return + } + v.SetExample(val) + case grammar.KwEnum: + v.SetEnum(pr.Value) + } + } +} diff --git a/internal/builders/handlers/keywords.go b/internal/builders/handlers/keywords.go new file mode 100644 index 0000000..bc52560 --- /dev/null +++ b/internal/builders/handlers/keywords.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +import "github.com/go-openapi/codescan/internal/parsers/grammar" + +// simpleSchemaAllowed enumerates the grammar keyword names that +// can legally appear on an OAS v2 SimpleSchema site (parameter with +// `in != body`, response header, and the items chain within either). +// +// Source of truth: the OAS v2 Parameter Object and Header Object allowed-keyword tables. +// +// Vendor extensions (`x-*`) are NOT listed here — they are gated by +// classify.IsAllowedExtension, which runs by name-prefix. +// +// See [§simple-schema-keywords](./README.md#simple-schema-keywords) +// for the `required:` carve-out (valid on parameters, skipped on +// headers, silently dropped under SimpleSchema mode on the schema +// walker). +// +//nolint:gochecknoglobals // closed-vocabulary lookup table; one allocation, read-only. +var simpleSchemaAllowed = map[string]struct{}{ + grammar.KwMaximum: {}, + grammar.KwMinimum: {}, + grammar.KwMultipleOf: {}, + grammar.KwMinLength: {}, + grammar.KwMaxLength: {}, + grammar.KwPattern: {}, + grammar.KwMinItems: {}, + grammar.KwMaxItems: {}, + grammar.KwUnique: {}, + grammar.KwCollectionFormat: {}, + grammar.KwDefault: {}, + grammar.KwExample: {}, + grammar.KwEnum: {}, + grammar.KwRequired: {}, +} + +// IsSimpleSchemaKeyword reports whether keyword is legal on an OAS +// v2 SimpleSchema site. Returns false for full-Schema-only keywords +// (`readOnly`, `discriminator`, `$ref`, `allOf`, ...) and for unknown +// names. +// +// Consumers wired in SimpleSchema mode (the schema builder under +// WithSimpleSchema, the parameters dispatcher, the responses +// dispatcher) use this predicate to gate writes and emit +// CodeUnsupportedInSimpleSchema diagnostics on miss. +func IsSimpleSchemaKeyword(keyword string) bool { + _, ok := simpleSchemaAllowed[keyword] + return ok +} diff --git a/internal/builders/handlers/keywords_test.go b/internal/builders/handlers/keywords_test.go new file mode 100644 index 0000000..9491cd5 --- /dev/null +++ b/internal/builders/handlers/keywords_test.go @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +import ( + "testing" + + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/testify/v2/assert" +) + +// TestIsSimpleSchemaKeyword_AllowedSet pins the OAS v2 SimpleSchema +// allowed-keyword vocabulary in code. Any future change to the +// surface (a new SimpleSchema keyword or a removed one) must update +// this test alongside the package-level map and the README +// §simple-schema-mode entry — locking the contract down so it can't +// drift silently. +func TestIsSimpleSchemaKeyword_AllowedSet(t *testing.T) { + want := []string{ + grammar.KwMaximum, + grammar.KwMinimum, + grammar.KwMultipleOf, + grammar.KwMinLength, + grammar.KwMaxLength, + grammar.KwPattern, + grammar.KwMinItems, + grammar.KwMaxItems, + grammar.KwUnique, + grammar.KwCollectionFormat, + grammar.KwDefault, + grammar.KwExample, + grammar.KwEnum, + grammar.KwRequired, + } + for _, kw := range want { + assert.True(t, IsSimpleSchemaKeyword(kw), "keyword %q should be SimpleSchema-legal", kw) + } + assert.Len(t, simpleSchemaAllowed, len(want), "simpleSchemaAllowed must match the documented surface exactly") +} + +// TestIsSimpleSchemaKeyword_FullSchemaOnly pins the keywords that +// MUST be rejected as full-Schema-only. These are the keywords the +// schema Bool handler gates and emits CodeUnsupportedInSimpleSchema +// for under SimpleSchema mode. +func TestIsSimpleSchemaKeyword_FullSchemaOnly(t *testing.T) { + forbidden := []string{ + grammar.KwReadOnly, + grammar.KwDiscriminator, + } + for _, kw := range forbidden { + assert.False(t, IsSimpleSchemaKeyword(kw), "keyword %q must NOT be SimpleSchema-legal", kw) + } +} + +// TestIsSimpleSchemaKeyword_Unknown pins the predicate's behaviour +// on an unknown keyword name — returns false, no panic. +func TestIsSimpleSchemaKeyword_Unknown(t *testing.T) { + assert.False(t, IsSimpleSchemaKeyword("nosuchkeyword")) + assert.False(t, IsSimpleSchemaKeyword("")) +} From 3088343e2851fc10a0431e17c48b9044f5a2b2ce Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:37:32 +0200 Subject: [PATCH 09/22] feat(builders/validations): type-aware coercion and shape checks CoerceEnum, ParseDefault, IsLegalForType: convert grammar-level values to Go-typed Swagger payloads after checking the keyword is legal for the field's underlying Go type. Used by the Walker handlers to reject malformed input early with positioned diagnostics rather than silently producing invalid spec. Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- internal/builders/validations/README.md | 209 ++++++ internal/builders/validations/coerce.go | 153 ++++ internal/builders/validations/coerce_test.go | 100 +++ internal/builders/validations/shape.go | 69 ++ internal/builders/validations/shape_test.go | 112 +++ internal/parsers/validations.go | 610 --------------- internal/parsers/validations_test.go | 750 ------------------- 7 files changed, 643 insertions(+), 1360 deletions(-) create mode 100644 internal/builders/validations/README.md create mode 100644 internal/builders/validations/coerce.go create mode 100644 internal/builders/validations/coerce_test.go create mode 100644 internal/builders/validations/shape.go create mode 100644 internal/builders/validations/shape_test.go delete mode 100644 internal/parsers/validations.go delete mode 100644 internal/parsers/validations_test.go diff --git a/internal/builders/validations/README.md b/internal/builders/validations/README.md new file mode 100644 index 0000000..f886286 --- /dev/null +++ b/internal/builders/validations/README.md @@ -0,0 +1,209 @@ +# validations — maintainer notes + +This document is the long-form companion to the `validations` package +code. The source files keep godoc concise; complex invariants, +design trade-offs, and intentionally-deferred follow-ups live here. + +The `validations` package owns cross-builder validation and coercion +concerns shared by the `schema`, `parameters`, `responses`, and +items/headers code paths. Its two halves are: + +- **Value coercion** (`coerce.go`) — turns raw annotation text into + the Go value implied by the target schema's `type` + `format`, + for keywords whose payload is a primitive literal (`default:`, + `example:`, `enum:`). +- **Shape legality** (`shape.go`) — answers "is this keyword legal + on a schema of this type?" against the JSON-Schema draft-4 + domain rules that Swagger 2.0 inherits. + +--- + +## Table of contents + +- [§contract](#contract) — why these helpers live here and not in the grammar +- [§coercion-dispatch](#coercion-dispatch) — `CoerceValue` / `ParseDefault` / `ParseEnumValues` routing +- [§enum-shapes](#enum-shapes) — JSON-array form vs comma-list form +- [§format-axis](#format-axis) — why `Format` is reserved but not consulted today +- [§type-domain-table](#type-domain-table) — the keyword-vs-type legality table +- [§empty-type](#empty-type) — how an unknown schema type is treated +- [§quirks-open](#quirks-open) — deferred follow-ups + +--- + +## §contract — why these helpers live in the builder layer + +The grammar parser produces a typed annotation block but does not +know the resolved Swagger `type` / `format` of the field, parameter, +or header the block is decorating — that resolution is the +builder's job, and it depends on the surrounding Go type system. + +Two consequences: + +1. **Coercion of `default:` / `example:` / `enum:` payloads** cannot + happen at parse time. The grammar lexes `default: 3` as the raw + string `"3"`; only the builder knows whether the target is + `integer` (so the value should be `int(3)`), `string` + (so the value stays `"3"`), or `array` (so the value should + `json.Unmarshal` into `[]any`). +2. **Keyword-vs-type legality** (`pattern:` only on `string`, + `multipleOf:` only on `number`/`integer`, ...) similarly needs + the resolved type. The grammar accepts the keyword + syntactically; the builder applies the domain rule once the + target's `Type` is known. + +This package is the seam where those two concerns meet — small, +type-aware helpers that the builder layer calls from its Walker +callbacks. + +## §coercion-dispatch — `CoerceValue` / `ParseDefault` / `ParseEnumValues` + +`CoerceValue(s, schema *spec.SimpleSchema)` is the primitive +coercer. It dispatches on `schema.TypeName()` — a Swagger helper +that returns `Format` when set, falling back to `Type`. This +Format-wins behaviour is convenient at the items/parameter sites +(where the SimpleSchema is the authoritative source) but is the +wrong axis for the schema-builder paths that already hold a +resolved `(type, format)` pair. + +`ParseDefault(s, schemaType, schemaFormat)` is the explicit +two-axis entry point. It dispatches on `schemaType` only, +ignoring `schemaFormat` for routing. The `schemaFormat` +argument is reserved for future per-format paths (e.g. +size-bounded integer parsing for `int32` vs `int64`) but is +unused today. + +`ParseEnumValues(val, schemaType, schemaFormat)` mirrors +`ParseDefault` for enum payloads, delegating per-element typing +to `CoerceEnum`. + +Dispatch table (after stripping surrounding quotes from +`TypeName()`): + +| Source label | Dispatcher | Coercer | +|---|---|---| +| `integer`, `int`, `int64`, `int32`, `int16` | both | `strconv.Atoi` | +| `bool`, `boolean` | both | `strconv.ParseBool` | +| `number`, `float64`, `float32` | both | `strconv.ParseFloat` (bitSize=64) | +| `object` | both | `json.Unmarshal` into `map[string]any` | +| `array` | both | `json.Unmarshal` into `[]any` | +| anything else / `nil` schema | both | raw string unchanged | + +Numeric and boolean parse errors are surfaced to the caller so +the consumer can decide whether to emit a diagnostic. JSON +parse failures on `object` / `array` are absorbed and the raw +string is returned — the assumption is that an author who wrote +`default: notjson` against an object target intended a textual +placeholder rather than a machine-readable default. A future +strict-mode option could turn this into a diagnostic. + +## §enum-shapes — JSON-array form vs comma-list form + +`CoerceEnum` accepts two input shapes for the `enum:` annotation: + +- **JSON-array form** — `enum: ["a", "b", "c"]`. Detected by + attempting `json.Unmarshal` into `[]json.RawMessage`. Each + element is `strconv.Unquote`d (so the literal `"a"` becomes + `a` before per-value coercion) and then routed through + `CoerceValue`. +- **Comma-list form** — `enum: a, b, c`. Triggered when the + JSON-array unmarshal fails. Each comma-separated token is + `TrimSpace`d before per-value coercion so `enum: a, b` + produces `["a", "b"]`, not `["a", " b"]`. + +Per-element coercion is the same `CoerceValue` path as +`default:` / `example:`, so type-aware typing applies +uniformly across the three keywords. + +## §format-axis — `Format` is reserved but not routed + +`ParseDefault` and `ParseEnumValues` accept a `schemaFormat` +argument that is currently discarded — the helpers underscore- +assign it explicitly so the surface stays stable for callers +while the format-aware paths are deferred. + +Two paths could exercise it later: + +- **Size-bounded integer parsing.** `int32` could parse via + `strconv.ParseInt(s, 10, 32)` and surface overflow as a + diagnostic rather than the silent truncation `strconv.Atoi` + performs on `int32` targets. +- **Float precision.** `float32` could parse with `bitSize=32` + to match the target's range; today both float widths share + the `bitSize=64` path. + +Neither is strictly required for spec correctness — the +emitted Swagger document carries the value via `interface{}` +and downstream consumers re-validate against `(type, format)` +themselves. They are tagged here as straightforward refinements +once a concrete consumer asks for them. + +## §type-domain-table — keyword × Swagger type legality + +`keywordTypeRules` (in `shape.go`) carries the per-keyword +type-domain table sourced from JSON-Schema draft-4 (the +dialect Swagger 2.0 inherits): + +| Keyword family | Legal on | +|---|---| +| `pattern`, `minLength`, `maxLength` | `string` | +| `maximum`, `minimum`, `multipleOf` | `integer`, `number` | +| `minItems`, `maxItems`, `uniqueItems` | `array` | +| `minProperties`, `maxProperties` | `object` | + +Keywords intentionally absent from the table: + +- **`required`, `readOnly`, `deprecated`, `discriminator`** — the + rule is type-independent (or, in the case of `discriminator`, + the OAS-level legality check happens elsewhere). The table + returns "no rule" and `IsLegalForType` accepts the keyword + for any type. +- **`default`, `example`, `enum`** — coerced via `CoerceValue` / + `CoerceEnum`, so they are legal on any type and the value + conforms by construction. + +The table is returned by a function rather than held as a +package variable to keep the package `gochecknoglobals`-clean +and to leave room for a future `WithRules(...)` constructor +that lets callers extend the table for custom keywords. + +## §empty-type — `schemaType == ""` is accepted + +`IsLegalForType` treats an empty `schemaType` as "type unknown" +and returns `ok=true` with no hint. Two situations produce an +empty type at the call site: + +- The grammar parsed a keyword before the type has been + resolved (the typeless preamble case). +- The target's type is genuinely indeterminate — a + free-form schema such as `additionalProperties: true`. + +In both cases the caller — typically a Walker callback in the +schema or parameters builder — is responsible for deciding +whether to apply the keyword. The package-local rule is +"best-effort apply": never block on an unknown type from this +seam. + +`Format` is intentionally not consulted by `IsLegalForType`. +Format is a refinement of type (`int32` is an `integer`-typed +field with `format: int32`); the domain rules apply at the +type level. A future `IsLegalForFormat` sibling could add +format-specific constraints (e.g. `pattern:` only on +`format: regex` strings) without disturbing this surface. + +## §quirks-open — deferred follow-ups + +- **Format-aware numeric parsing.** `ParseDefault` ignores + `schemaFormat` today; per-bit-size integer parsing + (`int32` via `ParseInt(_, 10, 32)`) and per-bit-size float + parsing (`float32` via `ParseFloat(_, 32)`) are the + obvious next refinements once a consumer surfaces the need. +- **Strict JSON for `object` / `array` defaults.** Invalid + JSON on an `object`- or `array`-typed `default:` / + `example:` currently falls back to the raw string. A strict + mode could emit a diagnostic and drop the value rather + than silently retain it. +- **Custom keyword rule extension.** `keywordTypeRules` is + built per call so a `WithRules(...)` constructor (or a + `RegisterKeyword` hook) could let downstream tools extend + the legality table for vendor-extension keywords without + patching this package. diff --git a/internal/builders/validations/coerce.go b/internal/builders/validations/coerce.go new file mode 100644 index 0000000..7e3437a --- /dev/null +++ b/internal/builders/validations/coerce.go @@ -0,0 +1,153 @@ +// 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. +// +// The package has two halves: +// +// - Value coercion (this file) — turns raw annotation text into +// the Go value implied by the target schema's type+format, +// for keywords whose payload is a primitive literal +// (default:, example:, enum:). +// - Shape legality (shape.go) — answers whether a given keyword +// is legal on a schema of a given Swagger type. +// +// # Details +// +// See [§contract](./README.md#contract) — why these helpers live +// in the builder layer rather than in the grammar parser. +package validations + +import ( + "encoding/json" + "strconv" + "strings" + + "github.com/go-openapi/spec" +) + +// CoerceValue converts a raw annotation value to the Go representation +// implied by the target schema's Type/Format. Used by default:/example: +// setters where the annotation body is a primitive literal whose +// meaning depends on the target: `default: 3` becomes int(3) against +// `Type: "integer"`, "3" against `Type: "string"`, and so on. +// +// 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. +// +// # Details +// +// 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 + } + + switch strings.Trim(schema.TypeName(), "\"") { + case "integer", "int", "int64", "int32", "int16": + return strconv.Atoi(s) + case "bool", "boolean": + return strconv.ParseBool(s) + case "number", "float64", "float32": + return strconv.ParseFloat(s, 64) + case "object": + var obj map[string]any + if err := json.Unmarshal([]byte(s), &obj); err != nil { + return s, nil //nolint:nilerr // fallback: return raw string when JSON is invalid + } + return obj, nil + case "array": + var slice []any + if err := json.Unmarshal([]byte(s), &slice); err != nil { + return s, nil //nolint:nilerr // fallback: return raw string when JSON is invalid + } + return slice, nil + default: + return s, nil + } +} + +// ParseDefault is the 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. +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. +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. +// +// # 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. +func CoerceEnum(val string, s *spec.SimpleSchema) []any { + var rawElements []json.RawMessage + if err := json.Unmarshal([]byte(val), &rawElements); err != nil { + return coerceEnumCommaList(val, s) + } + + out := make([]any, len(rawElements)) + for i, d := range rawElements { + ds, err := strconv.Unquote(string(d)) + if err != nil { + ds = string(d) + } + + v, err := CoerceValue(ds, s) + if err != nil { + out[i] = ds + continue + } + out[i] = v + } + + return out +} + +// coerceEnumCommaList handles the comma-list `enum: a, b, c` form. +// Per-value whitespace is trimmed before coercion; per-value parse +// errors fall back to the raw string. +func coerceEnumCommaList(val string, s *spec.SimpleSchema) []any { + list := strings.Split(val, ",") + out := make([]any, len(list)) + + for i, d := range list { + d = strings.TrimSpace(d) + v, err := CoerceValue(d, s) + if err != nil { + out[i] = d + continue + } + out[i] = v + } + + return out +} diff --git a/internal/builders/validations/coerce_test.go b/internal/builders/validations/coerce_test.go new file mode 100644 index 0000000..cdb77c1 --- /dev/null +++ b/internal/builders/validations/coerce_test.go @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package validations_test + +import ( + "testing" + + "github.com/go-openapi/codescan/internal/builders/validations" + "github.com/go-openapi/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestCoerceValue_NilSchemaReturnsRaw(t *testing.T) { + got, err := validations.CoerceValue("hello", nil) + require.NoError(t, err) + assert.Equal(t, "hello", got) +} + +func TestCoerceValue_PrimitiveTypes(t *testing.T) { + cases := []struct { + name string + raw string + schema *spec.SimpleSchema + want any + wantErr bool + }{ + {"integer", "42", &spec.SimpleSchema{Type: "integer"}, 42, false}, + {"int64", "100", &spec.SimpleSchema{Type: "int64"}, 100, false}, + {"int32", "10", &spec.SimpleSchema{Type: "int32"}, 10, false}, + {"boolean true", "true", &spec.SimpleSchema{Type: "boolean"}, true, false}, + {"boolean false", "false", &spec.SimpleSchema{Type: "bool"}, false, false}, + {"number", "1.5", &spec.SimpleSchema{Type: "number"}, 1.5, false}, + {"float64", "3.14", &spec.SimpleSchema{Type: "float64"}, 3.14, false}, + {"unknown type returns raw", "anything", &spec.SimpleSchema{Type: "weird"}, "anything", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := validations.CoerceValue(tc.raw, tc.schema) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestCoerceValue_ObjectAndArrayUnmarshal(t *testing.T) { + obj, err := validations.CoerceValue(`{"k":1}`, &spec.SimpleSchema{Type: "object"}) + require.NoError(t, err) + assert.Equal(t, map[string]any{"k": float64(1)}, obj) + + arr, err := validations.CoerceValue(`[1,2,3]`, &spec.SimpleSchema{Type: "array"}) + require.NoError(t, err) + assert.Equal(t, []any{float64(1), float64(2), float64(3)}, arr) +} + +func TestCoerceValue_InvalidJSONFallsBackToRaw(t *testing.T) { + // v1 quirk preserved: object/array parse failure returns raw, + // not error. + got, err := validations.CoerceValue("not json", &spec.SimpleSchema{Type: "object"}) + require.NoError(t, err) + assert.Equal(t, "not json", got) +} + +func TestCoerceValue_FormatPrecedenceQuirk(t *testing.T) { + // v1 quirk: SimpleSchema.TypeName() returns Format when Format is + // non-empty. With Type="number" and Format="float", TypeName() + // returns "float" — NOT in the switch — so the value falls + // through to default and is returned as raw string. The schema + // builder's schemeFromPS deliberately drops Format to avoid this. + // This test pins the v1-bug behaviour for parity through S1–S6; + // S3 introduces a corrected path. + got, err := validations.CoerceValue("1.5", &spec.SimpleSchema{Type: "number", Format: "float"}) + require.NoError(t, err) + assert.Equal(t, "1.5", got, "v1 Format-wins quirk: raw string returned for unrecognized 'float'") +} + +func TestCoerceEnum_JSONArrayForm(t *testing.T) { + out := validations.CoerceEnum(`["low","medium","high"]`, &spec.SimpleSchema{Type: "string"}) + assert.Equal(t, []any{"low", "medium", "high"}, out) +} + +func TestCoerceEnum_CommaListForm(t *testing.T) { + out := validations.CoerceEnum(`a, b, c`, &spec.SimpleSchema{Type: "string"}) + assert.Equal(t, []any{"a", "b", "c"}, out) +} + +func TestCoerceEnum_PerItemTyping(t *testing.T) { + out := validations.CoerceEnum(`[1, 2, 3]`, &spec.SimpleSchema{Type: "integer"}) + assert.Equal(t, []any{1, 2, 3}, out) +} + +func TestCoerceEnum_CommaListWhitespaceTrimmed(t *testing.T) { + out := validations.CoerceEnum(` a , b ,c `, &spec.SimpleSchema{Type: "string"}) + assert.Equal(t, []any{"a", "b", "c"}, out) +} diff --git a/internal/builders/validations/shape.go b/internal/builders/validations/shape.go new file mode 100644 index 0000000..45a4c96 --- /dev/null +++ b/internal/builders/validations/shape.go @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package validations + +import ( + "fmt" + "slices" + + "github.com/go-openapi/codescan/internal/parsers/grammar" +) + +// keywordTypeRules returns the set of Swagger schema types each +// keyword is legal on. Keywords absent from the table are legal on +// any type (or the rule is type-independent — `required`, `readOnly`, +// `deprecated`, `discriminator`). +// +// # 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. +func keywordTypeRules() map[string][]string { + return map[string][]string{ + "pattern": {"string"}, + "minLength": {"string"}, + "maxLength": {"string"}, + "maximum": {"integer", "number"}, + "minimum": {"integer", "number"}, + "multipleOf": {"integer", "number"}, + "minItems": {"array"}, + "maxItems": {"array"}, + "uniqueItems": {"array"}, + "minProperties": {"object"}, + "maxProperties": {"object"}, + } +} + +// IsLegalForType reports whether keyword is legal on a schema with +// the given resolved Swagger type. Returns ok=true with empty hint +// when the keyword has no type constraint or the type matches. +// Returns ok=false with a human-readable hint when the type +// mismatches the keyword's domain. +// +// Empty schemaType is treated as "type unknown" and accepted; the +// caller 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. +func IsLegalForType(keyword grammar.Keyword, schemaType string) (ok bool, hint string) { + rules, hasRule := keywordTypeRules()[keyword.Name] + if !hasRule { + return true, "" + } + if schemaType == "" { + return true, "" + } + if slices.Contains(rules, schemaType) { + return true, "" + } + return false, fmt.Sprintf( + "keyword %q is only valid on schemas typed %v (got %q)", + keyword.Name, rules, schemaType, + ) +} diff --git a/internal/builders/validations/shape_test.go b/internal/builders/validations/shape_test.go new file mode 100644 index 0000000..b559596 --- /dev/null +++ b/internal/builders/validations/shape_test.go @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package validations_test + +import ( + "testing" + + "github.com/go-openapi/codescan/internal/builders/validations" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/testify/v2/assert" +) + +func TestIsLegalForType_PatternOnlyOnString(t *testing.T) { + kw := grammar.Keyword{Name: "pattern"} + + ok, _ := validations.IsLegalForType(kw, "string") + assert.True(t, ok, "pattern legal on string") + + ok, hint := validations.IsLegalForType(kw, "integer") + assert.False(t, ok) + assert.Contains(t, hint, "string") + assert.Contains(t, hint, "integer") +} + +func TestIsLegalForType_NumericOnNumberOrInteger(t *testing.T) { + for _, kwName := range []string{"maximum", "minimum", "multipleOf"} { + kw := grammar.Keyword{Name: kwName} + + okNum, _ := validations.IsLegalForType(kw, "number") + assert.True(t, okNum, "%s legal on number", kwName) + + okInt, _ := validations.IsLegalForType(kw, "integer") + assert.True(t, okInt, "%s legal on integer", kwName) + + okStr, _ := validations.IsLegalForType(kw, "string") + assert.False(t, okStr, "%s NOT legal on string", kwName) + } +} + +func TestIsLegalForType_StringLengthOnlyOnString(t *testing.T) { + for _, kwName := range []string{"minLength", "maxLength"} { + kw := grammar.Keyword{Name: kwName} + + ok, _ := validations.IsLegalForType(kw, "string") + assert.True(t, ok, "%s legal on string", kwName) + + ok, _ = validations.IsLegalForType(kw, "integer") + assert.False(t, ok, "%s NOT legal on integer", kwName) + } +} + +func TestIsLegalForType_ArrayConstraintsOnlyOnArray(t *testing.T) { + for _, kwName := range []string{"minItems", "maxItems", "uniqueItems"} { + kw := grammar.Keyword{Name: kwName} + + ok, _ := validations.IsLegalForType(kw, "array") + assert.True(t, ok, "%s legal on array", kwName) + + ok, _ = validations.IsLegalForType(kw, "string") + assert.False(t, ok, "%s NOT legal on string", kwName) + + ok, _ = validations.IsLegalForType(kw, "object") + assert.False(t, ok, "%s NOT legal on object", kwName) + } +} + +func TestIsLegalForType_ObjectConstraintsOnlyOnObject(t *testing.T) { + for _, kwName := range []string{"minProperties", "maxProperties"} { + kw := grammar.Keyword{Name: kwName} + + ok, _ := validations.IsLegalForType(kw, "object") + assert.True(t, ok, "%s legal on object", kwName) + + ok, _ = validations.IsLegalForType(kw, "array") + assert.False(t, ok, "%s NOT legal on array", kwName) + } +} + +func TestIsLegalForType_TypelessSchemaIsLenient(t *testing.T) { + // Empty schemaType ("type unknown") is accepted — the caller + // decides whether to apply. + kw := grammar.Keyword{Name: "pattern"} + ok, hint := validations.IsLegalForType(kw, "") + assert.True(t, ok) + assert.Empty(t, hint) +} + +func TestIsLegalForType_KeywordsWithoutRulesAlwaysLegal(t *testing.T) { + // required / readOnly / deprecated / discriminator have no type + // constraint — they apply to any type. + for _, kwName := range []string{"required", "readOnly", "deprecated", "discriminator"} { + kw := grammar.Keyword{Name: kwName} + for _, schemaType := range []string{"string", "integer", "number", "object", "array"} { + ok, _ := validations.IsLegalForType(kw, schemaType) + assert.True(t, ok, "%s should be legal on %s", kwName, schemaType) + } + } +} + +func TestIsLegalForType_DefaultExampleEnumLegalOnAnyType(t *testing.T) { + // default / example / enum coerce against the schema's + // type+format via CoerceValue / CoerceEnum, so they're always + // legal — type validation lives there, not here. + for _, kwName := range []string{"default", "example", "enum"} { + kw := grammar.Keyword{Name: kwName} + for _, schemaType := range []string{"string", "integer", "number", "object", "array"} { + ok, _ := validations.IsLegalForType(kw, schemaType) + assert.True(t, ok, "%s should be legal on %s", kwName, schemaType) + } + } +} diff --git a/internal/parsers/validations.go b/internal/parsers/validations.go deleted file mode 100644 index 1625d9e..0000000 --- a/internal/parsers/validations.go +++ /dev/null @@ -1,610 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "fmt" - "regexp" - "strconv" - - "github.com/go-openapi/codescan/internal/ifaces" - oaispec "github.com/go-openapi/spec" -) - -var ( - rxMaximum = regexp.MustCompile(fmt.Sprintf(rxMaximumFmt, "")) - rxMinimum = regexp.MustCompile(fmt.Sprintf(rxMinimumFmt, "")) - rxMultipleOf = regexp.MustCompile(fmt.Sprintf(rxMultipleOfFmt, "")) - rxMinItems = regexp.MustCompile(fmt.Sprintf(rxMinItemsFmt, "")) - rxMaxItems = regexp.MustCompile(fmt.Sprintf(rxMaxItemsFmt, "")) - rxMaxLength = regexp.MustCompile(fmt.Sprintf(rxMaxLengthFmt, "")) - rxMinLength = regexp.MustCompile(fmt.Sprintf(rxMinLengthFmt, "")) - rxPattern = regexp.MustCompile(fmt.Sprintf(rxPatternFmt, "")) - rxCollectionFormat = regexp.MustCompile(fmt.Sprintf(rxCollectionFormatFmt, "")) - rxUnique = regexp.MustCompile(fmt.Sprintf(rxUniqueFmt, "")) - rxEnumValidation = regexp.MustCompile(fmt.Sprintf(rxEnumFmt, "")) - rxDefaultValidation = regexp.MustCompile(fmt.Sprintf(rxDefaultFmt, "")) - rxExample = regexp.MustCompile(fmt.Sprintf(rxExampleFmt, "")) -) - -type PrefixRxOption func(string) *regexp.Regexp - -func WithItemsPrefixLevel(level int) PrefixRxOption { - // the expression is 1-index based not 0-index - itemsPrefix := fmt.Sprintf(rxItemsPrefixFmt, level+1) - return func(expr string) *regexp.Regexp { - return Rxf(expr, itemsPrefix) // Proposal for enhancement(fred): cache - } -} - -type SetMaximum struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetMaximum(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetMaximum { - rx := rxMaximum - for _, apply := range opts { - rx = apply(rxMaximumFmt) - } - - return &SetMaximum{ - builder: builder, - rx: rx, - } -} - -func (sm *SetMaximum) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := sm.rx.FindStringSubmatch(lines[0]) - if len(matches) > 2 && len(matches[2]) > 0 { - maximum, err := strconv.ParseFloat(matches[2], 64) - if err != nil { - return err - } - sm.builder.SetMaximum(maximum, matches[1] == "<") - } - return nil -} - -func (sm *SetMaximum) Matches(line string) bool { - return sm.rx.MatchString(line) -} - -type SetMinimum struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetMinimum(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetMinimum { - rx := rxMinimum - for _, apply := range opts { - rx = apply(rxMinimumFmt) - } - - return &SetMinimum{ - builder: builder, - rx: rx, - } -} - -func (sm *SetMinimum) Matches(line string) bool { - return sm.rx.MatchString(line) -} - -func (sm *SetMinimum) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := sm.rx.FindStringSubmatch(lines[0]) - if len(matches) > 2 && len(matches[2]) > 0 { - minimum, err := strconv.ParseFloat(matches[2], 64) - if err != nil { - return err - } - sm.builder.SetMinimum(minimum, matches[1] == ">") - } - return nil -} - -type SetMultipleOf struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetMultipleOf(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetMultipleOf { - rx := rxMultipleOf - for _, apply := range opts { - rx = apply(rxMultipleOfFmt) - } - - return &SetMultipleOf{ - builder: builder, - rx: rx, - } -} - -func (sm *SetMultipleOf) Matches(line string) bool { - return sm.rx.MatchString(line) -} - -func (sm *SetMultipleOf) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := sm.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - multipleOf, err := strconv.ParseFloat(matches[1], 64) - if err != nil { - return err - } - sm.builder.SetMultipleOf(multipleOf) - } - return nil -} - -type SetMaxItems struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetMaxItems(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetMaxItems { - rx := rxMaxItems - for _, apply := range opts { - rx = apply(rxMaxItemsFmt) - } - - return &SetMaxItems{ - builder: builder, - rx: rx, - } -} - -func (sm *SetMaxItems) Matches(line string) bool { - return sm.rx.MatchString(line) -} - -func (sm *SetMaxItems) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := sm.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - maxItems, err := strconv.ParseInt(matches[1], 10, 64) - if err != nil { - return err - } - sm.builder.SetMaxItems(maxItems) - } - return nil -} - -type SetMinItems struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetMinItems(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetMinItems { - rx := rxMinItems - for _, apply := range opts { - rx = apply(rxMinItemsFmt) - } - - return &SetMinItems{ - builder: builder, - rx: rx, - } -} - -func (sm *SetMinItems) Matches(line string) bool { - return sm.rx.MatchString(line) -} - -func (sm *SetMinItems) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := sm.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - minItems, err := strconv.ParseInt(matches[1], 10, 64) - if err != nil { - return err - } - sm.builder.SetMinItems(minItems) - } - return nil -} - -type SetMaxLength struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetMaxLength(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetMaxLength { - rx := rxMaxLength - for _, apply := range opts { - rx = apply(rxMaxLengthFmt) - } - - return &SetMaxLength{ - builder: builder, - rx: rx, - } -} - -func (sm *SetMaxLength) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := sm.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - maxLength, err := strconv.ParseInt(matches[1], 10, 64) - if err != nil { - return err - } - sm.builder.SetMaxLength(maxLength) - } - return nil -} - -func (sm *SetMaxLength) Matches(line string) bool { - return sm.rx.MatchString(line) -} - -type SetMinLength struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetMinLength(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetMinLength { - rx := rxMinLength - for _, apply := range opts { - rx = apply(rxMinLengthFmt) - } - - return &SetMinLength{ - builder: builder, - rx: rx, - } -} - -func (sm *SetMinLength) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := sm.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - minLength, err := strconv.ParseInt(matches[1], 10, 64) - if err != nil { - return err - } - sm.builder.SetMinLength(minLength) - } - return nil -} - -func (sm *SetMinLength) Matches(line string) bool { - return sm.rx.MatchString(line) -} - -type SetPattern struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetPattern(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetPattern { - rx := rxPattern - for _, apply := range opts { - rx = apply(rxPatternFmt) - } - - return &SetPattern{ - builder: builder, - rx: rx, - } -} - -func (sm *SetPattern) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := sm.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - sm.builder.SetPattern(matches[1]) - } - return nil -} - -func (sm *SetPattern) Matches(line string) bool { - return sm.rx.MatchString(line) -} - -type SetCollectionFormat struct { - builder ifaces.OperationValidationBuilder - rx *regexp.Regexp -} - -func NewSetCollectionFormat(builder ifaces.OperationValidationBuilder, opts ...PrefixRxOption) *SetCollectionFormat { - rx := rxCollectionFormat - for _, apply := range opts { - rx = apply(rxCollectionFormatFmt) - } - - return &SetCollectionFormat{ - builder: builder, - rx: rx, - } -} - -func (sm *SetCollectionFormat) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := sm.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - sm.builder.SetCollectionFormat(matches[1]) - } - return nil -} - -func (sm *SetCollectionFormat) Matches(line string) bool { - return sm.rx.MatchString(line) -} - -type SetUnique struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetUnique(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetUnique { - rx := rxUnique - for _, apply := range opts { - rx = apply(rxUniqueFmt) - } - - return &SetUnique{ - builder: builder, - rx: rx, - } -} - -func (su *SetUnique) Matches(line string) bool { - return su.rx.MatchString(line) -} - -func (su *SetUnique) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := su.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - req, err := strconv.ParseBool(matches[1]) - if err != nil { - return err - } - su.builder.SetUnique(req) - } - return nil -} - -type SetRequiredParam struct { - tgt *oaispec.Parameter -} - -func NewSetRequiredParam(param *oaispec.Parameter) *SetRequiredParam { - return &SetRequiredParam{ - tgt: param, - } -} - -func (su *SetRequiredParam) Matches(line string) bool { - return rxRequired.MatchString(line) -} - -func (su *SetRequiredParam) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := rxRequired.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - req, err := strconv.ParseBool(matches[1]) - if err != nil { - return err - } - su.tgt.Required = req - } - return nil -} - -type SetReadOnlySchema struct { - tgt *oaispec.Schema -} - -func NewSetReadOnlySchema(schema *oaispec.Schema) *SetReadOnlySchema { - return &SetReadOnlySchema{ - tgt: schema, - } -} - -func (su *SetReadOnlySchema) Matches(line string) bool { - return rxReadOnly.MatchString(line) -} - -func (su *SetReadOnlySchema) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := rxReadOnly.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - req, err := strconv.ParseBool(matches[1]) - if err != nil { - return err - } - su.tgt.ReadOnly = req - } - return nil -} - -type SetRequiredSchema struct { - Schema *oaispec.Schema - Field string -} - -func NewSetRequiredSchema(schema *oaispec.Schema, field string) *SetRequiredSchema { - return &SetRequiredSchema{ - Schema: schema, - Field: field, - } -} - -func (su *SetRequiredSchema) Matches(line string) bool { - return rxRequired.MatchString(line) -} - -func (su *SetRequiredSchema) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := rxRequired.FindStringSubmatch(lines[0]) - if len(matches) <= 1 || len(matches[1]) == 0 { - return nil - } - - req, err := strconv.ParseBool(matches[1]) - if err != nil { - return err - } - midx := -1 - for i, nm := range su.Schema.Required { - if nm == su.Field { - midx = i - break - } - } - if req { - if midx < 0 { - su.Schema.Required = append(su.Schema.Required, su.Field) - } - } else if midx >= 0 { - su.Schema.Required = append(su.Schema.Required[:midx], su.Schema.Required[midx+1:]...) - } - return nil -} - -type SetDefault struct { - scheme *oaispec.SimpleSchema - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetDefault(scheme *oaispec.SimpleSchema, builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetDefault { - rx := rxDefaultValidation - for _, apply := range opts { - rx = apply(rxDefaultFmt) - } - - return &SetDefault{ - scheme: scheme, - builder: builder, - rx: rx, - } -} - -func (sd *SetDefault) Matches(line string) bool { - return sd.rx.MatchString(line) -} - -func (sd *SetDefault) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - - matches := sd.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - d, err := parseValueFromSchema(matches[1], sd.scheme) - if err != nil { - return err - } - sd.builder.SetDefault(d) - } - - return nil -} - -type SetExample struct { - scheme *oaispec.SimpleSchema - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetExample(scheme *oaispec.SimpleSchema, builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetExample { - rx := rxExample - for _, apply := range opts { - rx = apply(rxExampleFmt) - } - - return &SetExample{ - scheme: scheme, - builder: builder, - rx: rx, - } -} - -func (se *SetExample) Matches(line string) bool { - return se.rx.MatchString(line) -} - -func (se *SetExample) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - - matches := se.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - d, err := parseValueFromSchema(matches[1], se.scheme) - if err != nil { - return err - } - se.builder.SetExample(d) - } - - return nil -} - -type SetDiscriminator struct { - Schema *oaispec.Schema - Field string -} - -func NewSetDiscriminator(schema *oaispec.Schema, field string) *SetDiscriminator { - return &SetDiscriminator{ - Schema: schema, - Field: field, - } -} - -func (su *SetDiscriminator) Matches(line string) bool { - return rxDiscriminator.MatchString(line) -} - -func (su *SetDiscriminator) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := rxDiscriminator.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - req, err := strconv.ParseBool(matches[1]) - if err != nil { - return err - } - if req { - su.Schema.Discriminator = su.Field - } else if su.Schema.Discriminator == su.Field { - su.Schema.Discriminator = "" - } - } - return nil -} diff --git a/internal/parsers/validations_test.go b/internal/parsers/validations_test.go deleted file mode 100644 index ed09c9d..0000000 --- a/internal/parsers/validations_test.go +++ /dev/null @@ -1,750 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "strings" - "testing" - - "github.com/go-openapi/codescan/internal/ifaces" - "github.com/go-openapi/codescan/internal/scantest/mocks" - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" - - oaispec "github.com/go-openapi/spec" -) - -// validationRecorder captures all calls made to a ValidationBuilder. -type validationRecorder struct { - maximum *float64 - exclusiveMaximum bool - minimum *float64 - exclusiveMinimum bool - multipleOf *float64 - minItems *int64 - maxItems *int64 - minLength *int64 - maxLength *int64 - pattern string - unique *bool - enum string - defaultVal any - exampleVal any - collectionFormat string -} - -func (r *validationRecorder) SetMaximum(v float64, exclusive bool) { - r.maximum = &v - r.exclusiveMaximum = exclusive -} - -func (r *validationRecorder) SetMinimum(v float64, exclusive bool) { - r.minimum = &v - r.exclusiveMinimum = exclusive -} -func (r *validationRecorder) SetMultipleOf(v float64) { r.multipleOf = &v } -func (r *validationRecorder) SetMinItems(v int64) { r.minItems = &v } -func (r *validationRecorder) SetMaxItems(v int64) { r.maxItems = &v } -func (r *validationRecorder) SetMinLength(v int64) { r.minLength = &v } -func (r *validationRecorder) SetMaxLength(v int64) { r.maxLength = &v } -func (r *validationRecorder) SetPattern(v string) { r.pattern = v } -func (r *validationRecorder) SetUnique(v bool) { r.unique = &v } -func (r *validationRecorder) SetEnum(v string) { r.enum = v } -func (r *validationRecorder) SetDefault(v any) { r.defaultVal = v } -func (r *validationRecorder) SetExample(v any) { r.exampleVal = v } -func (r *validationRecorder) SetCollectionFormat(v string) { r.collectionFormat = v } - -func TestSetMaximum(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - line string - wantMatch bool - wantVal float64 - exclusive bool - }{ - {"inclusive", "maximum: 100", true, 100, false}, - {"exclusive", "maximum: < 100", true, 100, true}, - {"decimal", "maximum: 99.5", true, 99.5, false}, - {"negative", "maximum: -10", true, -10, false}, - {"no match", "something else", false, 0, false}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMaximum(rec) - assert.EqualT(t, tc.wantMatch, sm.Matches(tc.line)) - if tc.wantMatch { - require.NoError(t, sm.Parse([]string{tc.line})) - require.NotNil(t, rec.maximum) - assert.EqualT(t, tc.wantVal, *rec.maximum) - assert.EqualT(t, tc.exclusive, rec.exclusiveMaximum) - } - }) - } - - t.Run("empty lines", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMaximum(rec) - require.NoError(t, sm.Parse(nil)) - require.NoError(t, sm.Parse([]string{})) - require.NoError(t, sm.Parse([]string{""})) - assert.Nil(t, rec.maximum) - }) - - t.Run("parse error", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMaximum(rec) - // Force a match with a non-numeric value via raw regex - require.NoError(t, sm.Parse([]string{"maximum: not-a-number"})) - assert.Nil(t, rec.maximum) // no match because regex won't capture non-numeric - }) -} - -func TestSetMinimum(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - line string - wantMatch bool - wantVal float64 - exclusive bool - }{ - {"inclusive", "minimum: 0", true, 0, false}, - {"exclusive", "minimum: > 0", true, 0, true}, - {"decimal", "min: 1.5", true, 1.5, false}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMinimum(rec) - assert.EqualT(t, tc.wantMatch, sm.Matches(tc.line)) - if tc.wantMatch { - require.NoError(t, sm.Parse([]string{tc.line})) - require.NotNil(t, rec.minimum) - assert.EqualT(t, tc.wantVal, *rec.minimum) - assert.EqualT(t, tc.exclusive, rec.exclusiveMinimum) - } - }) - } -} - -func TestSetMultipleOf(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - line string - wantMatch bool - wantVal float64 - }{ - {"integer", "multiple of: 5", true, 5}, - {"decimal", "Multiple Of: 0.5", true, 0.5}, - {"no match", "something else", false, 0}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMultipleOf(rec) - assert.EqualT(t, tc.wantMatch, sm.Matches(tc.line)) - if tc.wantMatch { - require.NoError(t, sm.Parse([]string{tc.line})) - require.NotNil(t, rec.multipleOf) - assert.EqualT(t, tc.wantVal, *rec.multipleOf) - } - }) - } -} - -func TestSetMaxItems(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - sm := NewSetMaxItems(rec) - assert.TrueT(t, sm.Matches("max items: 10")) - require.NoError(t, sm.Parse([]string{"max items: 10"})) - require.NotNil(t, rec.maxItems) - assert.EqualT(t, int64(10), *rec.maxItems) -} - -func TestSetMinItems(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - sm := NewSetMinItems(rec) - assert.TrueT(t, sm.Matches("min items: 1")) - require.NoError(t, sm.Parse([]string{"min items: 1"})) - require.NotNil(t, rec.minItems) - assert.EqualT(t, int64(1), *rec.minItems) -} - -func TestSetMaxLength(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - sm := NewSetMaxLength(rec) - assert.TrueT(t, sm.Matches("max length: 255")) - require.NoError(t, sm.Parse([]string{"max length: 255"})) - require.NotNil(t, rec.maxLength) - assert.EqualT(t, int64(255), *rec.maxLength) -} - -func TestSetMinLength(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - sm := NewSetMinLength(rec) - assert.TrueT(t, sm.Matches("min length: 1")) - require.NoError(t, sm.Parse([]string{"min length: 1"})) - require.NotNil(t, rec.minLength) - assert.EqualT(t, int64(1), *rec.minLength) -} - -func TestSetPattern(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - sm := NewSetPattern(rec) - assert.TrueT(t, sm.Matches("pattern: ^\\w+$")) - require.NoError(t, sm.Parse([]string{"pattern: ^\\w+$"})) - assert.EqualT(t, "^\\w+$", rec.pattern) -} - -func TestSetCollectionFormat(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - sm := NewSetCollectionFormat(rec) - assert.TrueT(t, sm.Matches("collection format: csv")) - require.NoError(t, sm.Parse([]string{"collection format: csv"})) - assert.EqualT(t, "csv", rec.collectionFormat) -} - -func TestSetUnique(t *testing.T) { - t.Parallel() - - tests := []struct { - line string - want bool - }{ - {"unique: true", true}, - {"unique: false", false}, - } - - for _, tc := range tests { - t.Run(tc.line, func(t *testing.T) { - rec := &validationRecorder{} - su := NewSetUnique(rec) - assert.TrueT(t, su.Matches(tc.line)) - require.NoError(t, su.Parse([]string{tc.line})) - require.NotNil(t, rec.unique) - assert.EqualT(t, tc.want, *rec.unique) - }) - } - - t.Run("parse error", func(t *testing.T) { - rec := &validationRecorder{} - su := NewSetUnique(rec) - // unique: accepts only true/false so non-bool won't match - assert.FalseT(t, su.Matches("unique: maybe")) - }) -} - -func TestSetRequiredParam(t *testing.T) { - t.Parallel() - - tests := []struct { - line string - want bool - }{ - {"required: true", true}, - {"required: false", false}, - } - - for _, tc := range tests { - t.Run(tc.line, func(t *testing.T) { - param := new(oaispec.Parameter) - su := NewSetRequiredParam(param) - assert.TrueT(t, su.Matches(tc.line)) - require.NoError(t, su.Parse([]string{tc.line})) - assert.EqualT(t, tc.want, param.Required) - }) - } - - t.Run("empty", func(t *testing.T) { - param := new(oaispec.Parameter) - su := NewSetRequiredParam(param) - require.NoError(t, su.Parse(nil)) - assert.FalseT(t, param.Required) - }) -} - -func TestSetReadOnlySchema(t *testing.T) { - t.Parallel() - - tests := []struct { - line string - want bool - }{ - {"read only: true", true}, - {"readOnly: true", true}, - {"read-only: false", false}, - } - - for _, tc := range tests { - t.Run(tc.line, func(t *testing.T) { - schema := new(oaispec.Schema) - su := NewSetReadOnlySchema(schema) - assert.TrueT(t, su.Matches(tc.line)) - require.NoError(t, su.Parse([]string{tc.line})) - assert.EqualT(t, tc.want, schema.ReadOnly) - }) - } -} - -func TestSetRequiredSchema(t *testing.T) { - t.Parallel() - - t.Run("set required true", func(t *testing.T) { - schema := new(oaispec.Schema) - su := NewSetRequiredSchema(schema, "name") - require.NoError(t, su.Parse([]string{"required: true"})) - assert.Equal(t, []string{"name"}, schema.Required) - }) - - t.Run("set required false removes", func(t *testing.T) { - schema := &oaispec.Schema{} - schema.Required = []string{"name", "age"} - su := NewSetRequiredSchema(schema, "name") - require.NoError(t, su.Parse([]string{"required: false"})) - assert.Equal(t, []string{"age"}, schema.Required) - }) - - t.Run("set required true idempotent", func(t *testing.T) { - schema := &oaispec.Schema{} - schema.Required = []string{"name"} - su := NewSetRequiredSchema(schema, "name") - require.NoError(t, su.Parse([]string{"required: true"})) - assert.Equal(t, []string{"name"}, schema.Required) - }) - - t.Run("set required false not present", func(t *testing.T) { - schema := new(oaispec.Schema) - su := NewSetRequiredSchema(schema, "name") - require.NoError(t, su.Parse([]string{"required: false"})) - assert.Empty(t, schema.Required) - }) - - t.Run("empty lines", func(t *testing.T) { - schema := new(oaispec.Schema) - su := NewSetRequiredSchema(schema, "name") - require.NoError(t, su.Parse(nil)) - require.NoError(t, su.Parse([]string{""})) - }) - - t.Run("no match in line", func(t *testing.T) { - schema := new(oaispec.Schema) - su := NewSetRequiredSchema(schema, "name") - require.NoError(t, su.Parse([]string{"something else"})) - assert.Empty(t, schema.Required) - }) -} - -func TestSetDefault(t *testing.T) { - t.Parallel() - - t.Run("string type", func(t *testing.T) { - rec := &validationRecorder{} - scheme := &oaispec.SimpleSchema{Type: "string"} - sd := NewSetDefault(scheme, rec) - assert.TrueT(t, sd.Matches("default: hello")) - require.NoError(t, sd.Parse([]string{"default: hello"})) - assert.EqualT(t, "hello", rec.defaultVal) - }) - - t.Run("integer type", func(t *testing.T) { - rec := &validationRecorder{} - scheme := &oaispec.SimpleSchema{Type: "integer"} - sd := NewSetDefault(scheme, rec) - require.NoError(t, sd.Parse([]string{"default: 42"})) - assert.EqualT(t, 42, rec.defaultVal) - }) - - t.Run("empty", func(t *testing.T) { - rec := &validationRecorder{} - scheme := &oaispec.SimpleSchema{Type: "string"} - sd := NewSetDefault(scheme, rec) - require.NoError(t, sd.Parse(nil)) - assert.Nil(t, rec.defaultVal) - }) -} - -func TestSetExample(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - scheme := &oaispec.SimpleSchema{Type: "string"} - se := NewSetExample(scheme, rec) - assert.TrueT(t, se.Matches("example: foobar")) - require.NoError(t, se.Parse([]string{"example: foobar"})) - assert.EqualT(t, "foobar", rec.exampleVal) -} - -func TestSetDiscriminator(t *testing.T) { - t.Parallel() - - t.Run("set true", func(t *testing.T) { - schema := new(oaispec.Schema) - sd := NewSetDiscriminator(schema, "kind") - assert.TrueT(t, sd.Matches("discriminator: true")) - require.NoError(t, sd.Parse([]string{"discriminator: true"})) - assert.EqualT(t, "kind", schema.Discriminator) - }) - - t.Run("set false clears", func(t *testing.T) { - schema := &oaispec.Schema{} - schema.Discriminator = "kind" - sd := NewSetDiscriminator(schema, "kind") - require.NoError(t, sd.Parse([]string{"discriminator: false"})) - assert.EqualT(t, "", schema.Discriminator) - }) - - t.Run("set false different field", func(t *testing.T) { - schema := &oaispec.Schema{} - schema.Discriminator = "type" - sd := NewSetDiscriminator(schema, "kind") - require.NoError(t, sd.Parse([]string{"discriminator: false"})) - assert.EqualT(t, "type", schema.Discriminator) // unchanged - }) -} - -func TestWithItemsPrefixLevel(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - sm := NewSetMaximum(rec, WithItemsPrefixLevel(0)) - line := "items.maximum: 100" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - require.NotNil(t, rec.maximum) - assert.EqualT(t, float64(100), *rec.maximum) - - // Level 1 requires "items.items." - rec2 := &validationRecorder{} - sm2 := NewSetMinimum(rec2, WithItemsPrefixLevel(1)) - line2 := "items.items.minimum: 5" - assert.TrueT(t, sm2.Matches(line2)) - require.NoError(t, sm2.Parse([]string{line2})) - require.NotNil(t, rec2.minimum) - assert.EqualT(t, float64(5), *rec2.minimum) -} - -func TestSetEnum(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - se := NewSetEnum(rec) - line := "enum: " + `["a","b","c"]` - assert.TrueT(t, se.Matches(line)) - require.NoError(t, se.Parse([]string{line})) - assert.EqualT(t, `["a","b","c"]`, rec.enum) - - t.Run("empty", func(t *testing.T) { - rec := &validationRecorder{} - se := NewSetEnum(rec) - require.NoError(t, se.Parse(nil)) - require.NoError(t, se.Parse([]string{""})) - assert.EqualT(t, "", rec.enum) - }) -} - -// TestPrefixRxOption_AllConstructors covers the WithItemsPrefixLevel loop body -// in every validation constructor that accepts PrefixRxOption. -func TestPrefixRxOption_AllConstructors(t *testing.T) { - t.Parallel() - - prefix := WithItemsPrefixLevel(0) - - t.Run("SetMultipleOf", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMultipleOf(rec, prefix) - line := "items.multiple of: 3" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - require.NotNil(t, rec.multipleOf) - assert.EqualT(t, float64(3), *rec.multipleOf) - }) - - t.Run("SetMaxItems", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMaxItems(rec, prefix) - line := "items.max items: 10" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - require.NotNil(t, rec.maxItems) - assert.EqualT(t, int64(10), *rec.maxItems) - }) - - t.Run("SetMinItems", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMinItems(rec, prefix) - line := "items.min items: 1" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - require.NotNil(t, rec.minItems) - assert.EqualT(t, int64(1), *rec.minItems) - }) - - t.Run("SetMaxLength", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMaxLength(rec, prefix) - line := "items.max length: 100" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - require.NotNil(t, rec.maxLength) - assert.EqualT(t, int64(100), *rec.maxLength) - }) - - t.Run("SetMinLength", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetMinLength(rec, prefix) - line := "items.min length: 1" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - require.NotNil(t, rec.minLength) - assert.EqualT(t, int64(1), *rec.minLength) - }) - - t.Run("SetPattern", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetPattern(rec, prefix) - line := "items.pattern: ^[a-z]+$" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - assert.EqualT(t, "^[a-z]+$", rec.pattern) - }) - - t.Run("SetCollectionFormat", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetCollectionFormat(rec, prefix) - line := "items.collection format: pipes" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - assert.EqualT(t, "pipes", rec.collectionFormat) - }) - - t.Run("SetUnique", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetUnique(rec, prefix) - line := "items.unique: true" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - require.NotNil(t, rec.unique) - assert.TrueT(t, *rec.unique) - }) - - t.Run("SetDefault", func(t *testing.T) { - rec := &validationRecorder{} - scheme := &oaispec.SimpleSchema{Type: "string"} - sm := NewSetDefault(scheme, rec, prefix) - line := "items.default: hello" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - assert.EqualT(t, "hello", rec.defaultVal) - }) - - t.Run("SetExample", func(t *testing.T) { - rec := &validationRecorder{} - scheme := &oaispec.SimpleSchema{Type: "string"} - sm := NewSetExample(scheme, rec, prefix) - line := "items.example: world" - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - assert.EqualT(t, "world", rec.exampleVal) - }) - - t.Run("SetEnum", func(t *testing.T) { - rec := &validationRecorder{} - sm := NewSetEnum(rec, prefix) - line := `items.enum: ["x","y"]` - assert.TrueT(t, sm.Matches(line)) - require.NoError(t, sm.Parse([]string{line})) - assert.EqualT(t, `["x","y"]`, rec.enum) - }) -} - -func TestSetDefault_ParseError(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - scheme := &oaispec.SimpleSchema{Type: "integer"} - sd := NewSetDefault(scheme, rec) - err := sd.Parse([]string{"default: not-a-number"}) - require.Error(t, err) - assert.Nil(t, rec.defaultVal) -} - -func TestSetExample_ParseError(t *testing.T) { - t.Parallel() - - rec := &validationRecorder{} - scheme := &oaispec.SimpleSchema{Type: "integer"} - se := NewSetExample(scheme, rec) - err := se.Parse([]string{"example: not-a-number"}) - require.Error(t, err) - assert.Nil(t, rec.exampleVal) -} - -func TestSetRequiredSchema_Matches(t *testing.T) { - t.Parallel() - - su := NewSetRequiredSchema(new(oaispec.Schema), "name") - assert.TrueT(t, su.Matches("required: true")) - assert.TrueT(t, su.Matches("Required: false")) - assert.FalseT(t, su.Matches("something else")) -} - -// strictMockValidationBuilder returns a MockValidationBuilder whose Set* methods -// fail the test if called. Use this in tests that assert no mutation happened -// (empty-input tolerance, overflow errors, etc.). -func strictMockValidationBuilder(t *testing.T) *mocks.MockValidationBuilder { - t.Helper() - fail := func(name string) func(...any) { - return func(args ...any) { t.Fatalf("%s should not be called (args: %v)", name, args) } - } - m := &mocks.MockValidationBuilder{} - m.SetMaximumFunc = func(v float64, exclusive bool) { fail("SetMaximum")(v, exclusive) } - m.SetMinimumFunc = func(v float64, exclusive bool) { fail("SetMinimum")(v, exclusive) } - m.SetMultipleOfFunc = func(v float64) { fail("SetMultipleOf")(v) } - m.SetMaxItemsFunc = func(v int64) { fail("SetMaxItems")(v) } - m.SetMinItemsFunc = func(v int64) { fail("SetMinItems")(v) } - m.SetMaxLengthFunc = func(v int64) { fail("SetMaxLength")(v) } - m.SetMinLengthFunc = func(v int64) { fail("SetMinLength")(v) } - m.SetPatternFunc = func(v string) { fail("SetPattern")(v) } - m.SetUniqueFunc = func(v bool) { fail("SetUnique")(v) } - m.SetEnumFunc = func(v string) { fail("SetEnum")(v) } - m.SetDefaultFunc = func(v any) { fail("SetDefault")(v) } - m.SetExampleFunc = func(v any) { fail("SetExample")(v) } - return m -} - -// TestValidationParsers_EmptyInputTolerance pins the defensive-guard -// contract documented in the D.5 post-mortem: every Parse(lines) tolerates -// nil / empty-slice / single-empty-string input without panic and without -// mutating its target. Uses MockValidationBuilder (Set* funcs fail on call) -// to prove no side effect. -func TestValidationParsers_EmptyInputTolerance(t *testing.T) { - t.Parallel() - - emptyInputs := [][]string{nil, {}, {""}} - - cases := []struct { - name string - factory func(*testing.T) ifaces.ValueParser - }{ - {"SetMaximum", func(t *testing.T) ifaces.ValueParser { return NewSetMaximum(strictMockValidationBuilder(t)) }}, - {"SetMinimum", func(t *testing.T) ifaces.ValueParser { return NewSetMinimum(strictMockValidationBuilder(t)) }}, - {"SetMultipleOf", func(t *testing.T) ifaces.ValueParser { return NewSetMultipleOf(strictMockValidationBuilder(t)) }}, - {"SetMaxItems", func(t *testing.T) ifaces.ValueParser { return NewSetMaxItems(strictMockValidationBuilder(t)) }}, - {"SetMinItems", func(t *testing.T) ifaces.ValueParser { return NewSetMinItems(strictMockValidationBuilder(t)) }}, - {"SetMaxLength", func(t *testing.T) ifaces.ValueParser { return NewSetMaxLength(strictMockValidationBuilder(t)) }}, - {"SetMinLength", func(t *testing.T) ifaces.ValueParser { return NewSetMinLength(strictMockValidationBuilder(t)) }}, - {"SetPattern", func(t *testing.T) ifaces.ValueParser { return NewSetPattern(strictMockValidationBuilder(t)) }}, - {"SetUnique", func(t *testing.T) ifaces.ValueParser { return NewSetUnique(strictMockValidationBuilder(t)) }}, - {"SetExample", func(t *testing.T) ifaces.ValueParser { - scheme := &oaispec.SimpleSchema{Type: "string"} - return NewSetExample(scheme, strictMockValidationBuilder(t)) - }}, - {"SetCollectionFormat", func(t *testing.T) ifaces.ValueParser { - // OperationValidationBuilder — use the op-variant mock, fail-all. - m := &mocks.MockOperationValidationBuilder{ - SetCollectionFormatFunc: func(v string) { t.Fatalf("SetCollectionFormat should not be called (arg: %s)", v) }, - } - return NewSetCollectionFormat(m) - }}, - {"SetReadOnlySchema", func(_ *testing.T) ifaces.ValueParser { return NewSetReadOnlySchema(new(oaispec.Schema)) }}, - {"SetDiscriminator", func(_ *testing.T) ifaces.ValueParser { return NewSetDiscriminator(new(oaispec.Schema), "kind") }}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - p := tc.factory(t) - for _, in := range emptyInputs { - require.NoError(t, p.Parse(in)) - } - }) - } -} - -// TestValidationParsers_NumericOverflow pins the overflow defence we kept -// in D.5: the regex captures \p{N}+ (any-length digit string), which matches -// values beyond int64 / float64 range. strconv.ParseInt / ParseFloat returns -// ErrRange in those cases, and the parser must propagate the error without -// invoking the target builder. See .claude/plans/dead-code-cleanup.md D.5 -// post-mortem for the rationale. -func TestValidationParsers_NumericOverflow(t *testing.T) { - t.Parallel() - - // int64 max is 9223372036854775807 (19 digits); 20+ 9's overflows. - intOverflow := strings.Repeat("9", 25) - // float64 max is ~1.8e308 in magnitude; 400 9's in decimal notation - // overflows ParseFloat (returns +Inf, ErrRange). - floatOverflow := strings.Repeat("9", 400) - - cases := []struct { - name string - line string - newP func(*testing.T) ifaces.ValueParser - }{ - { - name: "SetMaximum float overflow", - line: "maximum: " + floatOverflow, - newP: func(t *testing.T) ifaces.ValueParser { return NewSetMaximum(strictMockValidationBuilder(t)) }, - }, - { - name: "SetMinimum float overflow", - line: "minimum: " + floatOverflow, - newP: func(t *testing.T) ifaces.ValueParser { return NewSetMinimum(strictMockValidationBuilder(t)) }, - }, - { - name: "SetMultipleOf float overflow", - line: "multiple of: " + floatOverflow, - newP: func(t *testing.T) ifaces.ValueParser { return NewSetMultipleOf(strictMockValidationBuilder(t)) }, - }, - { - name: "SetMaxItems int overflow", - line: "max items: " + intOverflow, - newP: func(t *testing.T) ifaces.ValueParser { return NewSetMaxItems(strictMockValidationBuilder(t)) }, - }, - { - name: "SetMinItems int overflow", - line: "min items: " + intOverflow, - newP: func(t *testing.T) ifaces.ValueParser { return NewSetMinItems(strictMockValidationBuilder(t)) }, - }, - { - name: "SetMaxLength int overflow", - line: "max length: " + intOverflow, - newP: func(t *testing.T) ifaces.ValueParser { return NewSetMaxLength(strictMockValidationBuilder(t)) }, - }, - { - name: "SetMinLength int overflow", - line: "min length: " + intOverflow, - newP: func(t *testing.T) ifaces.ValueParser { return NewSetMinLength(strictMockValidationBuilder(t)) }, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - p := tc.newP(t) - require.TrueT(t, p.Matches(tc.line), "regex must match overflow input; otherwise the guard we're testing is dead") - err := p.Parse([]string{tc.line}) - require.Error(t, err, "expected ParseInt/ParseFloat ErrRange") - }) - } -} From d85ad29d31151e0fd77d16b02a4e1a1d9b0d50d8 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:37:40 +0200 Subject: [PATCH 10/22] feat(builders/resolvers): schema-for-type + items-chain adapters SwaggerSchemaForType resolves a go/types.Type to its Swagger representation (basic types, named types, slices, maps, pointers, interfaces). Identity and assertion helpers, plus the ItemsTypable / ItemsValidations ifaces adapters that let array-of-array descent share Walker handlers with the top-level schema builder. Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- internal/builders/items/errors.go | 15 --- internal/builders/items/taggers.go | 77 --------------- internal/builders/items/typable.go | 59 ------------ internal/builders/items/validations.go | 36 ------- internal/builders/resolvers/assertions.go | 46 +++++++-- internal/builders/resolvers/enum_desc.go | 28 ++++++ internal/builders/resolvers/items_adapters.go | 94 +++++++++++++++++++ internal/builders/resolvers/resolvers.go | 27 +++--- internal/builders/resolvers/resolvers_test.go | 12 +-- 9 files changed, 178 insertions(+), 216 deletions(-) delete mode 100644 internal/builders/items/errors.go delete mode 100644 internal/builders/items/taggers.go delete mode 100644 internal/builders/items/typable.go delete mode 100644 internal/builders/items/validations.go create mode 100644 internal/builders/resolvers/enum_desc.go create mode 100644 internal/builders/resolvers/items_adapters.go diff --git a/internal/builders/items/errors.go b/internal/builders/items/errors.go deleted file mode 100644 index 096703e..0000000 --- a/internal/builders/items/errors.go +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package items - -type itemsError string - -func (e itemsError) Error() string { - return string(e) -} - -const ( - // ErrItems is the sentinel error for all errors originating from the items package. - ErrItems itemsError = "builders:items" -) diff --git a/internal/builders/items/taggers.go b/internal/builders/items/taggers.go deleted file mode 100644 index bfb5a1e..0000000 --- a/internal/builders/items/taggers.go +++ /dev/null @@ -1,77 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package items - -import ( - "fmt" - "go/ast" - "slices" - - "github.com/go-openapi/codescan/internal/parsers" - "github.com/go-openapi/spec" -) - -// Taggers builds tag parsers for array items at a given nesting level. -func Taggers(items *spec.Items, level int) []parsers.TagParser { - return itemsTaggers(items, level) -} - -func itemsTaggers(items *spec.Items, level int) []parsers.TagParser { - opts := []parsers.PrefixRxOption{parsers.WithItemsPrefixLevel(level)} - - return []parsers.TagParser{ - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMaximum", level), parsers.NewSetMaximum(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMinimum", level), parsers.NewSetMinimum(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMultipleOf", level), parsers.NewSetMultipleOf(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMinLength", level), parsers.NewSetMinLength(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMaxLength", level), parsers.NewSetMaxLength(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dPattern", level), parsers.NewSetPattern(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dCollectionFormat", level), parsers.NewSetCollectionFormat(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMinItems", level), parsers.NewSetMinItems(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMaxItems", level), parsers.NewSetMaxItems(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dUnique", level), parsers.NewSetUnique(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dEnum", level), parsers.NewSetEnum(Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dDefault", level), parsers.NewSetDefault(&items.SimpleSchema, Validations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dExample", level), parsers.NewSetExample(&items.SimpleSchema, Validations{items}, opts...)), - } -} - -// ParseArrayTypes recursively builds tag parsers for nested array types. -func ParseArrayTypes(taggers []parsers.TagParser, name string, expr ast.Expr, items *spec.Items, level int) ([]parsers.TagParser, error) { - return parseArrayTypes(taggers, name, expr, items, level) -} - -func parseArrayTypes(taggers []parsers.TagParser, name string, expr ast.Expr, items *spec.Items, level int) ([]parsers.TagParser, error) { - if items == nil { - return taggers, nil - } - - switch iftpe := expr.(type) { - case *ast.ArrayType: - eleTaggers := itemsTaggers(items, level) - return parseArrayTypes(slices.Concat(eleTaggers, taggers), name, iftpe.Elt, items.Items, level+1) - - case *ast.SelectorExpr: - return parseArrayTypes(taggers, name, iftpe.Sel, items.Items, level+1) - - case *ast.Ident: - var identTaggers []parsers.TagParser - if iftpe.Obj == nil { - identTaggers = itemsTaggers(items, level) - } - - otherTaggers, err := parseArrayTypes(taggers, name, expr, items.Items, level+1) - if err != nil { - return nil, err - } - - return slices.Concat(identTaggers, otherTaggers), nil - - case *ast.StarExpr: - return parseArrayTypes(taggers, name, iftpe.X, items, level) - - default: - return nil, fmt.Errorf("unknown field type element for %q: %w", name, ErrItems) - } -} diff --git a/internal/builders/items/typable.go b/internal/builders/items/typable.go deleted file mode 100644 index 54b4d57..0000000 --- a/internal/builders/items/typable.go +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package items - -import ( - "github.com/go-openapi/codescan/internal/ifaces" - oaispec "github.com/go-openapi/spec" -) - -type Typable struct { - items *oaispec.Items - level int - in string -} - -func NewTypable(items *oaispec.Items, level int, in string) Typable { - return Typable{ - items: items, - level: level, - in: in, - } -} - -func (pt Typable) In() string { return pt.in } - -func (pt Typable) Level() int { return pt.level } - -func (pt Typable) Typed(tpe, format string) { - pt.items.Typed(tpe, format) -} - -func (pt Typable) SetRef(ref oaispec.Ref) { - pt.items.Ref = ref -} - -func (pt Typable) Schema() *oaispec.Schema { - return nil -} - -func (pt Typable) Items() ifaces.SwaggerTypable { //nolint:ireturn // polymorphic by design - if pt.items.Items == nil { - pt.items.Items = new(oaispec.Items) - } - pt.items.Type = "array" - return Typable{pt.items.Items, pt.level + 1, pt.in} -} - -func (pt Typable) AddExtension(key string, value any) { - pt.items.AddExtension(key, value) -} - -func (pt Typable) WithEnum(values ...any) { - pt.items.WithEnum(values...) -} - -func (pt Typable) WithEnumDescription(_ string) { - // no -} diff --git a/internal/builders/items/validations.go b/internal/builders/items/validations.go deleted file mode 100644 index 515ff34..0000000 --- a/internal/builders/items/validations.go +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package items - -import ( - "github.com/go-openapi/codescan/internal/parsers" - oaispec "github.com/go-openapi/spec" -) - -type Validations struct { - current *oaispec.Items -} - -func (sv Validations) SetMaximum(val float64, exclusive bool) { - sv.current.Maximum = &val - sv.current.ExclusiveMaximum = exclusive -} - -func (sv Validations) SetMinimum(val float64, exclusive bool) { - sv.current.Minimum = &val - sv.current.ExclusiveMinimum = exclusive -} -func (sv Validations) SetMultipleOf(val float64) { sv.current.MultipleOf = &val } -func (sv Validations) SetMinItems(val int64) { sv.current.MinItems = &val } -func (sv Validations) SetMaxItems(val int64) { sv.current.MaxItems = &val } -func (sv Validations) SetMinLength(val int64) { sv.current.MinLength = &val } -func (sv Validations) SetMaxLength(val int64) { sv.current.MaxLength = &val } -func (sv Validations) SetPattern(val string) { sv.current.Pattern = val } -func (sv Validations) SetUnique(val bool) { sv.current.UniqueItems = val } -func (sv Validations) SetCollectionFormat(val string) { sv.current.CollectionFormat = val } -func (sv Validations) SetEnum(val string) { - sv.current.Enum = parsers.ParseEnum(val, &oaispec.SimpleSchema{Type: sv.current.Type, Format: sv.current.Format}) -} -func (sv Validations) SetDefault(val any) { sv.current.Default = val } -func (sv Validations) SetExample(val any) { sv.current.Example = val } diff --git a/internal/builders/resolvers/assertions.go b/internal/builders/resolvers/assertions.go index f588ed7..d637fa3 100644 --- a/internal/builders/resolvers/assertions.go +++ b/internal/builders/resolvers/assertions.go @@ -25,11 +25,18 @@ const ( // code assertions to be explicit about the various expectations when entering a function func MustNotBeABuiltinType(o *types.TypeName) { - if o.Pkg() != nil { + if o != nil && o.Pkg() != nil { return } - panic(fmt.Errorf("type %q expected not to be a builtin: %w", o.Name(), ErrInternal)) + var name string + if o != nil { + name = o.Name() + } else { + name = "" + } + + panic(fmt.Errorf("type %q expected not to be a builtin: %w", name, ErrInternal)) } func MustHaveRightHandSide(a *types.Alias) { @@ -40,6 +47,14 @@ func MustHaveRightHandSide(a *types.Alias) { panic(fmt.Errorf("type alias %q expected to declare a right-hand-side: %w", a.Obj().Name(), ErrInternal)) } +func MustBeAType(tpe types.TypeAndValue) { + if tpe.IsType() { + return + } + + panic(fmt.Errorf("declaration is not a type: %v: %w", tpe, ErrInternal)) +} + // IsFieldStringable check if the field type is a scalar. If the field type is // *ast.StarExpr and is pointer type, check if it refers to a scalar. // Otherwise, the ",string" directive doesn't apply. @@ -60,21 +75,22 @@ func IsFieldStringable(tpe ast.Expr) bool { } func IsTextMarshaler(tpe types.Type) bool { - encodingPkg, err := importer.Default().Import("encoding") + encoding, err := importer.Default().Import("encoding") if err != nil { return false } - // Proposal for enhancement: there should be a better way to check this than hardcoding the TextMarshaler iface. - obj := encodingPkg.Scope().Lookup("TextMarshaler") - if obj == nil { + + iface := encoding.Scope().Lookup("TextMarshaler") + if iface == nil { return false } - ifc, ok := obj.Type().Underlying().(*types.Interface) + + asInterface, ok := iface.Type().Underlying().(*types.Interface) if !ok { return false } - return types.Implements(tpe, ifc) + return types.Implements(tpe, asInterface) } func IsStdTime(o *types.TypeName) bool { @@ -98,5 +114,19 @@ func AddExtension(ve *oaispec.VendorExtensible, key string, value any, skip bool return } + // handle synonyms + if (key != "x-nullable" && key != "x-isnullable") || len(ve.Extensions) == 0 { + ve.AddExtension(key, value) + + return + } + + if _, ok := ve.Extensions["x-nullable"]; ok { + return + } + if _, ok := ve.Extensions["x-isnullable"]; ok { + return + } + ve.AddExtension(key, value) } diff --git a/internal/builders/resolvers/enum_desc.go b/internal/builders/resolvers/enum_desc.go new file mode 100644 index 0000000..e41ac70 --- /dev/null +++ b/internal/builders/resolvers/enum_desc.go @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package resolvers + +import oaispec "github.com/go-openapi/spec" + +// ExtEnumDesc is the vendor-extension key the scanner uses to expose +// the per-enum-value documentation lines built from `swagger:enum` +// + const-block comments. It's a go-swagger concept (not part of +// the OpenAPI spec); the key lives in `resolvers` so every builder +// (schema, parameters, responses) reads and writes it through the +// same constant. +const ExtEnumDesc = "x-go-enum-desc" + +// GetEnumDesc reads the x-go-enum-desc extension off a Swagger +// extensions map. Empty when absent. +// +// Consumers typically check this after a build pass to know whether +// they should append the per-value docs to a Description (parameters +// + response headers do this for the parameter/header +// description; the schema builder folds it in differently — see +// `handlers/dispatch_schema.go:clearStaleEnumDesc` for the +// override-cleanup dance). +func GetEnumDesc(extensions oaispec.Extensions) string { + desc, _ := extensions.GetString(ExtEnumDesc) + return desc +} diff --git a/internal/builders/resolvers/items_adapters.go b/internal/builders/resolvers/items_adapters.go new file mode 100644 index 0000000..7a25279 --- /dev/null +++ b/internal/builders/resolvers/items_adapters.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package resolvers + +import ( + "github.com/go-openapi/codescan/internal/builders/validations" + "github.com/go-openapi/codescan/internal/ifaces" + oaispec "github.com/go-openapi/spec" +) + +// ItemsTypable adapts an *oaispec.Items target to +// ifaces.SwaggerTypable so the parameters/responses builders can +// recurse through nested array layers (SimpleSchema's items chain). +// +// One adapter, two callers (parameters, responses); lives here +// because it's pure ifaces-glue with no builder-specific logic. +// The schema builder uses its own *oaispec.Schema.Items chain and +// has no need for this adapter. +type ItemsTypable struct { + items *oaispec.Items + level int + in string +} + +func NewItemsTypable(items *oaispec.Items, level int, in string) ItemsTypable { + return ItemsTypable{items: items, level: level, in: in} +} + +func (pt ItemsTypable) In() string { return pt.in } + +func (pt ItemsTypable) Level() int { return pt.level } + +func (pt ItemsTypable) Typed(tpe, format string) { pt.items.Typed(tpe, format) } + +func (pt ItemsTypable) SetRef(ref oaispec.Ref) { pt.items.Ref = ref } + +func (pt ItemsTypable) Schema() *oaispec.Schema { return nil } + +func (pt ItemsTypable) Items() ifaces.SwaggerTypable { //nolint:ireturn // polymorphic by design + if pt.items.Items == nil { + pt.items.Items = new(oaispec.Items) + } + pt.items.Type = "array" + return ItemsTypable{pt.items.Items, pt.level + 1, pt.in} +} + +func (pt ItemsTypable) AddExtension(key string, value any) { + pt.items.AddExtension(key, value) +} + +func (pt ItemsTypable) WithEnum(values ...any) { + pt.items.WithEnum(values...) +} + +func (pt ItemsTypable) WithEnumDescription(_ string) { + // items levels carry no description channel; the enclosing + // parameter / header description already absorbs the enum doc. +} + +// ItemsValidations wraps an *oaispec.Items as a ValidationBuilder / +// OperationValidationBuilder target. Used by parameters' and +// responses' grammar items-level Walker dispatch. +type ItemsValidations struct { + current *oaispec.Items +} + +func NewItemsValidations(it *oaispec.Items) ItemsValidations { + return ItemsValidations{current: it} +} + +func (sv ItemsValidations) SetMaximum(val float64, exclusive bool) { + sv.current.Maximum = &val + sv.current.ExclusiveMaximum = exclusive +} + +func (sv ItemsValidations) SetMinimum(val float64, exclusive bool) { + sv.current.Minimum = &val + sv.current.ExclusiveMinimum = exclusive +} + +func (sv ItemsValidations) SetMultipleOf(val float64) { sv.current.MultipleOf = &val } +func (sv ItemsValidations) SetMinItems(val int64) { sv.current.MinItems = &val } +func (sv ItemsValidations) SetMaxItems(val int64) { sv.current.MaxItems = &val } +func (sv ItemsValidations) SetMinLength(val int64) { sv.current.MinLength = &val } +func (sv ItemsValidations) SetMaxLength(val int64) { sv.current.MaxLength = &val } +func (sv ItemsValidations) SetPattern(val string) { sv.current.Pattern = val } +func (sv ItemsValidations) SetUnique(val bool) { sv.current.UniqueItems = val } +func (sv ItemsValidations) SetCollectionFormat(val string) { sv.current.CollectionFormat = val } +func (sv ItemsValidations) SetEnum(val string) { + sv.current.Enum = validations.CoerceEnum(val, &oaispec.SimpleSchema{Type: sv.current.Type, Format: sv.current.Format}) +} +func (sv ItemsValidations) SetDefault(val any) { sv.current.Default = val } +func (sv ItemsValidations) SetExample(val any) { sv.current.Example = val } diff --git a/internal/builders/resolvers/resolvers.go b/internal/builders/resolvers/resolvers.go index c0a1e1f..3ffc0d4 100644 --- a/internal/builders/resolvers/resolvers.go +++ b/internal/builders/resolvers/resolvers.go @@ -104,23 +104,24 @@ func UnsupportedBuiltinType(tpe types.Type) bool { } } -func UnsupportedBuiltin(tpe ifaces.Objecter) bool { +// UnsupportedBuiltin returns true when tpe is unsafe.Pointer. +// +// Other "unsupported builtins" (complex64, complex128) cannot reach +// this function: they surface as *types.Basic, which does not satisfy +// [ifaces.Objecter]. Those are caught one layer down by +// [UnsupportedBasic] / [UnsupportedBuiltinType] when the *types.Basic +// surfaces directly. +// +// Supported builtins: +// +// - error +func UnsupportedBuiltin(tpe ifaces.Objecter) (skip bool) { o := tpe.Obj() - if o == nil { + if o == nil || o.Pkg() == nil { return false } - if o.Pkg() != nil { - if o.Pkg().Path() == "unsafe" { - return true - } - - return false // not a builtin type - } - - _, found := unsupportedTypes[o.Name()] - - return found + return o.Pkg().Path() == "unsafe" } func UnsupportedBasic(tpe *types.Basic) bool { diff --git a/internal/builders/resolvers/resolvers_test.go b/internal/builders/resolvers/resolvers_test.go index 5977e14..9aaa63a 100644 --- a/internal/builders/resolvers/resolvers_test.go +++ b/internal/builders/resolvers/resolvers_test.go @@ -134,14 +134,10 @@ func TestUnsupportedBuiltin(t *testing.T) { assert.FalseT(t, UnsupportedBuiltin(m)) }) - t.Run("builtin complex64 returns true", func(t *testing.T) { - tn := types.NewTypeName(token.NoPos, nil, "complex64", nil) - m := &mocks.MockObjecter{ObjFunc: func() *types.TypeName { return tn }} - assert.TrueT(t, UnsupportedBuiltin(m)) - }) - - t.Run("builtin int returns false", func(t *testing.T) { - tn := types.NewTypeName(token.NoPos, nil, "int", nil) + t.Run("universe-scope object returns false", func(t *testing.T) { + // Pkg()==nil objects (predeclared error, any, etc.) never + // represent unsafe.Pointer and must therefore not match. + tn := types.NewTypeName(token.NoPos, nil, "error", nil) m := &mocks.MockObjecter{ObjFunc: func() *types.TypeName { return tn }} assert.FalseT(t, UnsupportedBuiltin(m)) }) From fa3db67826162ef73b2e0fbc99c5e66f8148ce3d Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:37:55 +0200 Subject: [PATCH 11/22] feat(builders/schema): grammar-based schema builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds OAI2 Swagger schemas from discovered Go types, consuming grammar-parsed comment blocks. Supports the full Schema surface (allOf compounds for $ref overrides, struct / embedded field walks, named-type aliases, enum inlining, special-type recognition for `error` and `time.Time`) and the SimpleSchema mode — the cut-down keyword surface used by non-body parameters and response headers. Field walker dispatches per-call-site classifiers in a single pass over the doc; allOf $ref overrides are wrapped so vendor extensions surface at the outer compound; DescWithRef toggles the description-on-$ref behaviour; SkipExtensions suppresses x-go-* vendor extensions. Invalid constructs emit a diagnostic (not consumed for now). Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- internal/builders/schema/README.md | 1251 +++++++++++++ internal/builders/schema/allof.go | 149 ++ internal/builders/schema/embedded.go | 155 ++ internal/builders/schema/errors.go | 10 +- internal/builders/schema/fields.go | 258 +++ internal/builders/schema/interface.go | 160 ++ internal/builders/schema/options.go | 89 + internal/builders/schema/parsing_stuff.go | 58 + internal/builders/schema/ref.go | 37 + internal/builders/schema/schema.go | 1583 +++-------------- internal/builders/schema/schema_go118_test.go | 28 +- internal/builders/schema/schema_test.go | 658 ++++--- internal/builders/schema/simpleschema.go | 100 ++ internal/builders/schema/special_types.go | 136 ++ internal/builders/schema/struct.go | 74 + internal/builders/schema/taggers.go | 125 -- internal/builders/schema/typable.go | 79 +- internal/builders/schema/walker.go | 312 ++++ .../builders/schema/walker_classifiers.go | 317 ++++ 19 files changed, 3871 insertions(+), 1708 deletions(-) create mode 100644 internal/builders/schema/README.md create mode 100644 internal/builders/schema/allof.go create mode 100644 internal/builders/schema/embedded.go create mode 100644 internal/builders/schema/fields.go create mode 100644 internal/builders/schema/interface.go create mode 100644 internal/builders/schema/options.go create mode 100644 internal/builders/schema/parsing_stuff.go create mode 100644 internal/builders/schema/ref.go create mode 100644 internal/builders/schema/simpleschema.go create mode 100644 internal/builders/schema/special_types.go create mode 100644 internal/builders/schema/struct.go delete mode 100644 internal/builders/schema/taggers.go create mode 100644 internal/builders/schema/walker.go create mode 100644 internal/builders/schema/walker_classifiers.go diff --git a/internal/builders/schema/README.md b/internal/builders/schema/README.md new file mode 100644 index 0000000..8f4fdab --- /dev/null +++ b/internal/builders/schema/README.md @@ -0,0 +1,1251 @@ +# schema builder — maintainer notes + +This document is the long-form companion to the schema builder code. + +The source files keep godoc concise; complex invariants, design +trade-offs, and known quirks live here. + +--- + +## Table of contents + +- [§build-entry](#build-entry) — `Build` modes and dispatch entry points +- [§dispatch-table](#dispatch-table) — `buildNamedType`'s underlying-shape table +- [§dissolve-named](#dissolve-named) — when a named type is unwrapped instead of `$ref`'d +- [§special-types](#special-types) — `applyStdlibSpecials`, `applySpecialType`, UUID heuristic +- [§textmarshal-order](#textmarshal-order) — `buildFromTextMarshal` precedence +- [§aliases](#aliases) — `TransparentAliases` vs `RefAliases` vs default-expand +- [§discovery](#discovery) — `Models` / `ExtraModels` / `discovered`, dedup layers +- [§struct](#struct) — `buildFromStruct` two-pass shape +- [§allof](#allof) — `buildAllOf`, `buildNamedAllOf`, `scanEmbeddedFields` +- [§embedded](#embedded) — embed routing, struct/interface specials asymmetry +- [§embed-depth](#embed-depth) — ambiguous-embed diagnostic mechanism +- [§method-mangler](#method-mangler) — interface-method JSON-name derivation +- [§user-overrides](#user-overrides) — explicit user-driven type/format overrides at decl-site and field-site +- [§ref-override](#ref-override) — `applyToRefField`, the allOf-on-$ref shape, `refOverrideCollector`, `applyPattern` +- [§simple-schema-mode](#simple-schema-mode) — the SimpleSchema build mode for OAS v2 non-body params and response headers +- [§classifier-walkers](#classifier-walkers) — per-call-site classifier walkers and `findAnnotationArg`'s single-word filter +- [§quirks](#quirks) — known behavioural caveats + - [§quirks-resolved](#quirks-resolved) — ✅ fixed in this refactor + - [§quirks-open](#quirks-open) — 🟡 deferred / 🟦 documented behaviour + +--- + +## §build-entry — `Build` modes and dispatch entry points + +`Builder.Build` has three modes selected by [`Option`](options.go): + +| Option | Sets | Entry point | Output destination | +|---|---|---|---| +| `WithDefinitions(map)` | `s.definitions` | `buildFromDecl` | `map[s.Name]` | +| `WithType(tpe, tgt)` | `s.inputType`, `s.target` | `buildFromType(inputType, target)` | `tgt` (caller-owned, full Schema) | +| `WithSimpleSchema(tpe, tgt, in)` | `s.inputType`, `s.target`, `s.simpleSchema`, `s.paramIn` | `buildFromType(inputType, target)` + exit validator | `tgt` (caller-owned, SimpleSchema-shaped) | + +`WithDefinitions` and the `WithType`/`WithSimpleSchema` pair are +mutually exclusive; `Build` panics on misuse. `WithDefinitions` is +the spec-orchestrator entry point for top-level type declarations. +`WithType` produces a full OAS v2 Schema for body parameters, +response bodies, and any other site that owns a `Typable` and accepts +the full schema vocabulary. `WithSimpleSchema` produces an OAS v2 +SimpleSchema for non-body parameter sites and response headers — see +[§simple-schema-mode](#simple-schema-mode) for the contract and the +exit-validator's role. + +`buildFromDecl` does three things in order: + +1. Consume the decl's doc-comment block (may short-circuit on `swagger:ignore`). +2. Defer `annotateSchema` (`x-go-name`, `x-go-package` extensions). +3. Intercept stdlib special types ([§special-types](#special-types)) + before kind-dispatch. Necessary because stdlib decls can reach + `buildFromDecl` via the discovery chain (e.g. `type X = time.Time` + pulls `time.Time` itself into `discovered`). +4. Dispatch on `s.Decl.ObjType()`: `*types.Named` → `buildFromType(ti.Type, …)`; + `*types.Alias` → `buildDeclAlias`; otherwise warn-and-skip. + +--- + +## §dispatch-table — `buildNamedType`'s underlying-shape table + +`buildNamedType` handles a `*types.Named` reaching as a field/embed/etc. +type (not a top-level decl). The body is a table indexed by `Underlying()` +kind, with each arm following the same three-step shape: + +1. **shape-local pre-check** (e.g. `UnsupportedBuiltinType` for `*types.Basic`) +2. **classifier walk** — short-circuit on a match, may recurse on Underlying +3. **`FindModel` pivot** via `resolveRefOr` / `resolveRefOrErr` — hit ⇒ `makeRef`, miss ⇒ shape-specific fallback + +The arms: + +| Underlying | Classifier | On-miss fallback | +|---|---|---| +| `*types.Struct` | `classifierNamedStructStrfmt` | silent — `nil` | +| `*types.Interface` | — | `missingSource` error | +| `*types.Basic` | `classifierNamedBasic` (cascade) | `SwaggerSchemaForType(name)` | +| `*types.Array` / `*types.Slice` | `classifierNamedArrayLike(forSlice)` | inline element via `buildFromType` | +| `*types.Map` | — | silent — `nil` | + +`buildNamedArrayLike` is the unified `Array`/`Slice` helper; the slice +arm passes `forSlice=true` so `classifierNamedArrayLike` can honour +the `bsonobjectid` slice-only special case. + +--- + +## §dissolve-named — when a named type is unwrapped instead of `$ref`'d + +After the prelude but before the `Underlying()` switch, +`buildNamedType` has a short-circuit: + +```go +if s.Decl.Spec.Assign.IsValid() || (titpe.TypeArgs() != nil && titpe.TypeArgs().Len() > 0) { + return s.buildFromType(titpe.Underlying(), target) +} +``` + +Two disjuncts, both meaning "this named type has no source-level +`TypeSpec` of its own to `$ref` — emit its structural form inline." + +### Disjunct 1 — `s.Decl.Spec.Assign.IsValid()` + +This is **the TransparentAliases plumbing mechanism**. When the +outer Builder was constructed for an alias-syntax decl (`type X = Y`), +`Spec.Assign` is the position of the `=` token (valid). Every named +type reached during this Build is then inlined rather than `$ref`'d — +which is what `TransparentAliases` semantically means. + +It also covers the gotypesalias=0 legacy mode (`type X = Y` surfacing +as `*types.Named` directly), but that's an incidental side-benefit. + +Example: under `TransparentAliases=true`, a body parameter aliased as +`type AliasBody = Payload` causes `schema.Builder` to dissolve +`AliasBody` → walk `Payload`'s underlying struct → emit `{type:object, properties:{…}}` +inline on the body param. Without the disjunct, `buildNamedType` would +emit `{$ref: "#/definitions/Payload"}` — wrong for the dissolve intent. + +### Disjunct 2 — `titpe.TypeArgs().Len() > 0` + +Generic instantiations like `GenericSlice[int]`. The instantiated +`*types.Named` has no source-level `TypeSpec`; only the generic +declaration (`GenericSlice[T any] []T`) has one. Unwrapping to +`Underlying()` substitutes type params with concrete types +(`[]int`) so the schema reflects the substituted shape. + +Fixture: `fixtures/enhancements/generic-instantiation/`. + +--- + +## §special-types — `applyStdlibSpecials`, `applySpecialType`, UUID heuristic + +Three layers, all in `special_types.go`: + +- **`applySpecialType(obj, target, recognizers...)`** — the engine. + Iterates `recognizers` and applies the first match. Each recognizer + is identity-based (exact `Pkg.Path` + `Name` match) except + `recognizeUUID` which is **fuzzy** (case-insensitive name match). + +- **`applyStdlibSpecials(obj, target)`** — the canonical safe set + `{recognizeAny, recognizeTime, recognizeError, recognizeRawMessage}`. + All four are identity-based and cannot misfire on user types, + so this helper is **called uniformly at every site** that handles + a `*types.TypeName`. + +- **`recognizeUUID`** — opt-in via `applySpecialType`'s variadic. + Currently used **only by `buildFromTextMarshal`** because the + upstream `IsTextMarshaler` gate guarantees the type renders as + text, making the fuzzy name match safe. + +The seven call sites of `applyStdlibSpecials` (`buildFromDecl`, +`buildDeclAlias` RHS, `buildAlias`, `buildNamedType`, +`buildNamedEmbedded` interface arm, `processEmbeddedType` named arm, +`buildNamedAllOf` struct arm) **previously varied their recognizer +subsets**. Unification preserved goldens — the narrow subsets were +historical accumulation, not semantic. Identity checks cannot +misfire, so passing the full safe set everywhere is correct by +construction. + +--- + +## §textmarshal-order — `buildFromTextMarshal` precedence + +The function is entered from `buildFromType`'s shortcut +`hasNamedCore(tpe) && IsTextMarshaler(tpe)`. Pipeline: + +1. peel pointers (self-recurse) +2. route aliases through `buildAlias` (honour `TransparentAliases` / `RefAliases`) +3. type-assert to `*types.Named` (fallback: `{string, ""}`) +4. **classifier (`swagger:strfmt`) — explicit user intent wins** +5. **stdlib trio via `applySpecialType(recognizeError, recognizeTime, recognizeRawMessage, recognizeUUID)`** +6. `PkgForType`-miss bail (gates only the generic fallback below) +7. **generic fallback** — `{string, ""}` + `x-go-type: pkg.Name` + +The user-intent-first rule (step 4 before steps 5–6) is the same +shape applied in `buildNamedAllOf` and elsewhere. The order matters +because, e.g., a UUID-named type carrying `swagger:strfmt date` +should emit `{string, date}` — the classifier wins. + +Fixtures: `fixtures/enhancements/text-marshal/explicit_override/` +demonstrates classifier-beats-heuristic; `text-marshal/uuid_wrapping_time/` +demonstrates heuristic-still-fires when no override exists. + +### Why stdlib recognizers run **before** the `PkgForType` bail + +`PkgForType` looks `tpe` up in `s.app.AllPackages`. Stdlib packages +(`time`, `encoding/json`) often aren't there — the scanner only +registers packages it was asked to scan. Previously the stdlib +recognizers ran **after** the `PkgForType` bail, so stdlib types +reaching here when their package wasn't in `AllPackages` silently +emitted `{}`. The new order recognizes stdlib types via +`tio.Pkg()` alone (no `PkgForType` call needed) before the bail. + +--- + +## §aliases — `TransparentAliases` vs `RefAliases` vs default-expand + +`buildDeclAlias` handles top-level alias declarations. Independent +flags with deterministic precedence: + +| `TransparentAliases` | `RefAliases` | Outcome | +|---|---|---| +| true | (any) | **Dissolve** — `buildFromType(rhs, target)`. No LHS definition. Wins outright. | +| false | true | **`$ref`** — `makeRef` to the RHS's named target. | +| false | false (default) | **Expand** — `buildFromType(Underlying, target)`. LHS gets a structural definition. | + +The dissolve case propagates to nested named types via the +`Spec.Assign.IsValid()` disjunct in `buildNamedType` +([§dissolve-named](#dissolve-named)). + +### `Rhs()` vs `Underlying()` + +- `tpe.Rhs()` — immediate right-hand side of the alias declaration. + For `type X = Y`, `Rhs` is `Y` as-is (may itself be a `*types.Alias` + or `*types.Named`). +- `tpe.Underlying()` — peels through aliases **and** named types to + reach the structural form (`*types.Struct`, `*types.Basic`, …). + +Example: `type X = Y; type Y = Z; type Z = int` gives +`X.Rhs() == Y (*types.Alias)` and `X.Underlying() == int (*types.Basic)`. + +The two branches use them intentionally: + +- **Dissolve** uses `rhs` — dissolves one layer at a time; if the + RHS is itself a named/aliased type, build from it (may recurse). +- **Expand** uses `Underlying()` — fully expand the structural shape + onto the LHS definition. + +--- + +## §discovery — `Models` / `ExtraModels` / `discovered`, dedup layers + +The scanner exposes two model indexes (in `scanner.app`): + +- **`Models`** — decls carrying `swagger:model` (or response/parameter) + annotations. The orchestrator builds these unconditionally. +- **`ExtraModels`** — decls discovered transitively that aren't + annotated but need a top-level definition. Promoted to `Models` + at consumption (`joinExtraModels` calls `MoveExtraToModel`). + +The spec orchestrator also maintains an in-flight queue +`s.discovered` populated via `Builder.AppendPostDecl`: + +- **`makeRef(decl, …)`** appends `decl` to `Builder.postDecls`. +- `spec.Builder.buildDiscoveredSchema` runs schema Builds and harvests + each Builder's `PostDeclarations()` into `s.discovered`. +- `buildDiscovered` then drains `s.discovered` until empty. + +`ScanCtx.AddDiscoveredModel(decl)` is the explicit hook for the +`ExtraModels` side (replacing the now-deprecated `FindModel` +side effect). + +### Dedup layers + +Three layers prevent the same decl from being built twice: + +1. **`AppendPostDecl`** — per-Builder dedup keyed by `EntityDecl.Ident`. +2. **`AddDiscoveredModel`** — no-op when the decl is already in + `Models` (avoids `Models ↔ ExtraModels` bouncing). +3. **`spec.Builder.buildDiscovered`** — per-pass dedup keyed by + `decl.Names()` string. + +Without (3), the `TestCoverage_RefAliasChain` shape regression: the +same alias-target decl, appended via multiple `makeRef` call sites, +got queued twice in one pass, and the second `Build` read the +half-built schema and **appended another set of `allOf` entries** — +doubling them. + +--- + +## §struct — `buildFromStruct` two-pass shape + +`buildFromStruct` emits the schema for a named Go struct in two +passes over the same `*types.Struct`. The split is required because +`allOf` composition (driven by `swagger:allOf` on embedded fields) +can change *which* schema receives the property map. + +1. **Pass 1 — `scanEmbeddedFields`.** Walks every anonymous field. + Each embed is classified: + - **plain embed** (no `swagger:allOf`, embed is not an + `*types.Alias`): its fields are merged into the outer schema + directly via `buildEmbedded`. Returned `target == schema`. + - **allOf embed** (`swagger:allOf` present, or the embed is an + alias): the embedded type becomes an entry in `schema.AllOf`, + and a *fresh* schema is allocated as the target for the + property map. Returned `target == fresh schema`. +2. **Pass 2 — `buildStructFields`.** Iterates non-anonymous + exported fields and emits each via `applyFieldCarrier`. + +If `hasAllOf` is true and the fresh target ended up with +properties, the target itself is appended to `schema.AllOf` so the +inline properties live as their own compound member alongside the +embedded `$ref`s. + +### Why `target.Typed("object", "")` always fires + +The line runs unconditionally after target selection. Reason: a +struct with zero exported fields and zero embeds still emits as +`{type: object, properties: {}}` — distinguishable from a missing +schema. SimpleSchema (forthcoming with M1) introduces the only +path where this line would *not* fire; the current code is +Full-Schema only. + +### User-classifier short-circuit + +`classifierStructPreBuildType` runs at the very top of +`buildFromStruct`. It consumes only `swagger:type` on the decl's +own comment group. On match, the struct walk is skipped entirely +— the schema is whatever `swagger:type X` resolves to via +`SwaggerSchemaForType`. See [§user-overrides](#user-overrides) for +the cascade and the unknown-leaf fallthrough handled by the +caller (`buildFromDecl`), not here. + +--- + +## §allof — `buildAllOf`, `buildNamedAllOf`, `scanEmbeddedFields` + +OAS 2.0 allOf composition surfaces in three places in the schema +builder: `scanEmbeddedFields` (decides which embeds become allOf +members), `buildAllOf` (peels and dispatches an allOf member type), +and `buildNamedAllOf` (resolves a named-type allOf member through the +same user-classifier-first precedence the rest of the builder uses). + +### `scanEmbeddedFields` — embed classification + +Walks `*types.Struct`'s anonymous fields. Three signals decide +classification per embed: + +- `swagger:ignore` — embed skipped entirely. +- `json:"-"` — embed skipped (parity with v1's JSON tag handling). +- `swagger:allOf` (via `fieldDoc.IsAllOfMember`) **or** the + embedded type is `*types.Alias` — embed becomes an allOf member; + remaining properties land on a fresh target schema. +- otherwise — plain embed; properties merge into the outer schema. + +The `swagger:allOf` arg, when present, is recorded as +`x-class: ` on the outer schema (`fd.AllOfClass`). This is the +discriminator hint downstream go-swagger consumes. + +### `buildAllOf` — three-arm peel + +Strips pointers (recurses), routes `*types.Named` through +`buildNamedAllOf`, routes `*types.Alias` through `buildAlias`. Any +other input is dropped silently with a `logger.UnsupportedTypeKind` +warning — parity with v1, which had no surface for non-Named / +non-Alias allOf members. + +### `buildNamedAllOf` — symmetric arm dispatch + +Struct and interface underlyings share the same precedence shape: + +1. **classifier first** — `classifierAliasTargetStrfmt(ftpe, tgt)`. + On match the named type is emitted as `{string, }` and the + walk terminates. Same shape as + [§textmarshal-order](#textmarshal-order)'s step 4. +2. **stdlib specials** — `applyStdlibSpecials(tio, tgt, skipExt)`. + Identity-based, cannot misfire. Catches `time.Time`, `error`, + `json.RawMessage`, `any`/`interface{}` if any of them reach as an + allOf member. +3. **model lookup** — `Ctx.GetModel(pkg, name)`. Missing decl is a + build error (the allOf member must be resolvable). +4. **`HasModelAnnotation()` → `makeRef`** — annotated types become + `$ref` entries. The struct/interface body is built lazily via + `discovered`. +5. **inline build** — `buildFromStruct` / `buildFromInterface` on the + underlying. Used when the type is reachable but not + `swagger:model`-annotated. + +Both arms route through the same `tgt := NewTypable(schema, 0, skipExtensions)` +target, avoiding the v1 asymmetry where the struct arm used +`classifierAliasTargetStrfmt` (decl-fetched internally) and the +interface arm used a comment-group-keyed variant (`classifierAliasOwnDocStrfmt`, +since deleted) that pre-fetched the decl. The unified target lets +both arms run classifier-first without doing the model lookup +upfront — earlier classification means no orphan `ExtraModels` +side effect for strfmt-tagged interface types. + +### Error message on missing decl + +The missing-decl error is now uniform across arms: +`"can't find source for named allOf member %s: %w"`. Previous +phrasing differentiated struct vs interface; no test asserts on +the text, the change is golden-neutral. + +--- + +## §embedded — embed routing, struct/interface specials asymmetry + +`buildEmbedded` is the entry point for a struct's embedded fields +that the embed classifier +([§allof](#allof) §scanEmbeddedFields → plain-embed arm) routed for +inline merge into the outer schema. It splits three ways: pointers +peel (recurse), `*types.Named` descends into `buildNamedEmbedded`, +`*types.Alias` goes through `buildAlias` (so alias-resolution +honours `TransparentAliases` / `RefAliases`). + +### `buildNamedEmbedded` — the two-arm specials asymmetry + +The interface arm runs `applyStdlibSpecials(o, target, skipExt)` +**before** model lookup. The struct arm does *not*. + +The asymmetry is deliberate: + +- **Interface embed** is the common shape that surfaces `error` + promoted into a struct (`type Err struct { error }`). The stdlib + specials catch it and emit `{string}` with the `x-go-type: error` + hint, matching the field-level recognizer behaviour. +- **Struct embed of `time.Time`** is uncommon — the fixture corpus + has none. Adding `applyStdlibSpecials` to the struct arm would + change behaviour only for code paths we don't currently exercise, + and the change would surface as a golden delta on the day someone + adds such a fixture. Until then the conservative choice is to + preserve v1 parity: stdlib structs embedded as `*types.Struct` + reach `GetModel` and build through the normal `buildFromStruct` + path. If `time.Time` ends up embedded and the package isn't in + `AllPackages`, `missingSource` fires — same as v1. + +### `AddDiscoveredModel` pairing + +Both arms call `s.Ctx.AddDiscoveredModel(decl)` before recursing. +Reason: embedded user types appear as their **own** top-level +definition even when not annotated `swagger:model`. The +`discovered` queue picks them up on the next build pass. Parity +with v1. + +### `processEmbeddedType` — interface-side allOf composition + +Called from `buildNamedInterface` when walking the embedded +elements of an interface's underlying. Three shapes: + +- **`*types.Named`** — runs `applyStdlibSpecials` (dummy target + swallows the write so the recognizer can short-circuit without + contaminating `schema`), then routes through + `buildNamedInterface`. +- **`*types.Interface`** — anonymous embedded interface. Builds + into a side schema; only when the result is non-empty + (`Ref || Properties || AllOf`) is it appended to the outer + `schema.AllOf`. +- **`*types.Alias`** — same non-empty guard, builds via + `buildAlias` so alias modes are honoured. + +The non-empty guard is what makes `interface{}` and other +zero-content interfaces invisible at the allOf seam — they don't +contribute an `{}` entry to the outer schema. + +--- + +## §embed-depth — ambiguous-embed diagnostic mechanism + +`Builder.embedDepth` (incremented around `buildNamedEmbedded`'s +recursive descents via `defer s.enterEmbed()()`) tracks the depth +of embedded-type recursion in a single `buildFromStruct` / +`buildFromInterface` pass. + +`applyFieldCarrier` uses it to distinguish: + +- **`embedDepth == 0`** — legitimate explicit override (`S.Foo` + redefining the JSON name of an embedded `E.Foo`). No diagnostic. +- **`embedDepth > 0` + prior at deeper depth** — Go's depth rule + (shallower wins) already disambiguates. No diagnostic. +- **`embedDepth > 0` + prior at same-or-shallower depth + different + Go name** — peer ambiguity Go itself would refuse to promote. + Emits `CodeAmbiguousEmbed` (`SeverityWarning`). Last-write-wins + behaviour is preserved; only the signal is added. + +Fixture: `fixtures/enhancements/diagnostics/types.go` covers all +three cases. + +--- + +## §method-mangler — interface-method JSON-name derivation + +Interface methods can't carry struct tags. To pick a JSON property +name for a method, `Builder.methodMangler` (a +`mangling.NameMangler` from `go-openapi/swag`) applies the same +acronym-aware lower-first transform go-swagger uses for tag-less +struct fields: `CreatedAt → createdAt`, `ID → id`, +`ExternalID → externalId`. + +`swagger:name X` still takes precedence when present +([§dispatch-table](#dispatch-table) field-emission rules). + +The mangler is initialised in `NewBuilder`; `&Builder{…}` literals +that bypass the constructor will nil-panic in `interfaceJSONName`. + +--- + +## §user-overrides — explicit user-driven overrides + +Three classifier annotations let the author bend the type-driven +default. They live at two scopes — the **decl-site** (on the type +declaration's doc comment) and the **field-site** (on a struct +field or interface method's doc comment) — and the schema builder +applies each at a distinct point in the build pipeline. + +### Where each override is consumed + +| Annotation | Scope | Consumed by | Applied at | +|---|---|---|---| +| `swagger:type X` | decl-site | `classifierNamedTypeOverride(s.Decl.Comments, ps)` | `buildFromDecl` — **before** kind-dispatch | +| `swagger:type X` | decl-site, reached via field reference | same classifier, walked from the field's *types.Named decl | `buildNamedType` / `buildNamedArrayLike` / `classifierNamedBasic` arms | +| `swagger:type X` | field-site | `fieldDoc.TypeOverride` populated by `scanFieldDoc` | `applyFieldCarrier` — **after** `buildFromType` | +| `swagger:strfmt X` | decl-site | per-arm classifiers (`classifierTextMarshal`, `classifierNamedStructStrfmt`, …) | dispatch-table arms | +| `swagger:strfmt X` | field-site | `fieldDoc.StrfmtName` | `applyFieldCarrier` | +| json tag `,string` | field-site | `fieldCarrier.isString` | `applyFieldCarrier` | + +### Ordering inside `applyFieldCarrier` (last-write-wins) + +``` +buildFromType(propType, ps) + → isString sets {type: string, format: } + → StrfmtName sets {type: string, format: } + → TypeOverride resets ps; runs SwaggerSchemaForType(X) or falls back to Underlying() + → applyBlockToField consumes everything else (description, validations, extensions) +``` + +A field that picks up multiple overrides resolves by the last write. +Mixing `,string` and `swagger:strfmt` and `swagger:type` is misuse +in source — the precedence simply prevents accidental contradictions +from corrupting the schema mid-build. + +### Decl-site `swagger:type` fallthrough — known leaf vs unknown leaf + +`classifierNamedTypeOverride` tries `SwaggerSchemaForType(name, tgt)`: + +- **known leaf** (`object`, `string`, `integer`, `boolean`, `number`): + classifier returns `(handled=true, recurse=false)`. The override + terminates the dispatch — the schema is left typed as the user + asked. +- **unknown leaf** (`array`, `badvalue`, …): `SwaggerSchemaForType` + errors, classifier returns `(handled=true, recurse=true)`. The + caller falls back to `s.Decl.ObjType().Underlying()` so item + shapes are filled from the Go-level shape. For + `type X json.RawMessage // swagger:type array`, the Underlying is + `[]byte` and the result is `{type: array, items: {integer, uint8}}`. + +The same `(handled, recurse)` contract applies at field-reference +sites via `classifierNamedArrayLike` (the wrapper-decl path that +inlines into the field's schema) and at the decl-site via +`buildFromDecl` (the wrapper's own top-level definition). + +### Two scopes, two effects, independent precedence + +The same `swagger:type` annotation at the **decl-site** decides +what the type's `definitions` entry emits; at the **field-site** it +decides what one specific field emits, regardless of its Go type's +natural shape. + +A field referencing a wrapper-decl honours **both** overrides — the +wrapper's own definition reflects its decl-site override, and the +referring field reflects its own field-site override (which wins +locally by ordering). Both layers compose without mutating each +other. + +### `scanFieldDoc` and the `AnnType` filter + +`scanFieldDoc` is the field-level walker that pre-extracts every +classifier signal in one pass over `ParseBlocks(afld.Doc)`. Most +annotations (`ignore`, `name`, `strfmt`, `allOf`) go through the +lexer's `firstIdent` arg-classifier, which already produces a +single-token arg — no filter needed. + +`AnnType` is the exception: its arg-classifier `TrimSpaces` the +whole rest of the line, so prose noise like +`swagger:type so the scanner emits …` reaches `b.AnnotationArg()` +as a multi-word string. `scanFieldDoc` filters those out with an +inline `strings.ContainsAny(name, " \t")` check, mirroring the +filter inside `findAnnotationArg` (`walker_classifiers.go`). The +`enhancements/named-basic` fixture documents the v1 trap this +filter protects against. + +### Recognizer `skipExt` plumbing + +`applySpecialType` and `applyStdlibSpecials` take a `skipExt bool` +parameter that gates any vendor-extension writes the recognizers +would otherwise emit. Currently only `recognizeError` writes one +(`x-go-type: error`); the other recognizers +(`recognizeTime`, `recognizeAny`, `recognizeRawMessage`, +`recognizeUUID`) are purely type / format mutations and don't +consult `skipExt`. All eight schema-internal call sites pass +`s.skipExtensions` so the recognizer subsystem honours the same +`SkipExtensions` flag as the rest of the builder. + +--- + +## §ref-override — field-level overrides on a `$ref`'d field + +`applyToRefField` handles the case where a struct field's Go type +resolves to a named type whose schema lives in `definitions` +(`ps.Ref` set). Field-level sibling content — description, +`pattern`, `enum`, `example`, `x-*` extensions — cannot ride +alongside `$ref` per JSON-Schema-draft-4: the ref predates and +replaces siblings. The correct shape is an **allOf compound**: + +```json +{ + "description": "...", + "allOf": [ + { "$ref": "#/definitions/Parent" }, + { "...override validations and extensions..." } + ] +} +``` + +### Per-keyword landing rules + +- **`required:`** writes to `enclosing.Required` (it's a + parent-side concern, not a sibling of the `$ref`). +- **Description** rides the outer allOf compound when any + field-level content is present, including just the description + itself. The `DescWithRef` option (below) covers the only-description + edge case. +- **Validations** (`maximum`, `pattern`, `enum`, …) land on `allOf[1]` + — the override schema arm. +- **Vendor extensions** (`x-*` via the `extensions:` raw block) + are **lifted onto the outer compound**, not nested inside + `allOf[1]`. Reason: `x-*` siblings of `$ref` should live at the + same level as scanner-derived metadata (`x-go-name`, + `x-go-package`) for consistency. See `refOverrideCollector`'s + flag explanation below. + +### The `DescWithRef` toggle and the description-only case + +`scanner.Options.DescWithRef` controls how a description-only +override is emitted: + +- **DescWithRef=true**: a $ref'd field whose only field-level + decoration is a description produces a single-arm allOf + compound. The description rides the outer parent so JSON-Schema + consumers see it alongside the `$ref`. +- **DescWithRef=false** (the default — matches v1 strict + behaviour): the description is dropped and a bare `$ref` is + emitted. Users who want the description preserved via the + JSON-Schema-correct compound shape opt in explicitly. + +When validations or user-authored extensions are present, the +allOf wrap is mandatory regardless of the flag — the override +would be lost otherwise. + +### `refOverrideCollector` — accumulate-then-decide + +The collector accumulates field-level overrides into a scratch +schema so `applyToRefField` can pick the final shape after the +Walker has finished firing. Two flags track what was collected: + +- **`collectedValidation`** — a JSON-Schema validation keyword + fired (`maximum`, `pattern`, `enum`, …). When true, the override + arm (`allOf[1]`) is emitted carrying the validation. +- **`collectedExtension`** — a vendor extension fired. When true, + the collected extensions are **lifted onto the outer compound** + (not the override arm) so `x-*` keys live alongside the + scanner-derived `x-go-name` / `x-go-package`. + +Splitting the collector out of `applyToRefField` keeps the +per-shape Walker callbacks short and the orchestrator's cognitive +complexity in check. The Walker fires; the collector records; the +outer function shapes. + +### `applyPattern` — best-effort RE2 hint + +`applyPattern` stores a regex pattern unconditionally — JSON Schema +regex's grammar is broader than Go's RE2 (lookaheads, named +groups, etc.) and a user may rely on a downstream validator that +accepts the wider syntax. A best-effort `regexp.Compile` check +runs alongside: if the pattern is invalid against RE2, a +`SeverityWarning` diagnostic surfaces the issue without dropping +the value. + +The diagnostic rides on `CodeInvalidAnnotation` rather than a +dedicated `CodeInvalidPattern`. Reason: it's the closest existing +class for "the value is grammatically valid but semantically off." +A dedicated code can land alongside a broader pattern-hygiene pass +when one materialises. + +--- + +## §simple-schema-mode — SimpleSchema build mode for non-body params and response headers + +OAS v2 distinguishes a **full Schema** (body parameters, response +bodies, top-level definitions) from a **SimpleSchema** that applies +to non-body parameter sites and response headers: + +- A SimpleSchema is a parameter with `in: {query, path, header, + formData}` (anything except `in: body`) or a response header. +- SimpleSchema vocabulary is a restricted subset of Schema — `$ref`, + `allOf` / `oneOf` / `anyOf` / `not`, `properties` / + `additionalProperties`, object-level `required`, `discriminator`, + `readOnly`, `xml`, `externalDocs` are all forbidden. +- The notion disappears in OAS v3. + +References: +and . + +The schema builder offers `WithSimpleSchema(tpe, tgt, in)` as the +third Build mode (parallel to `WithType` / `WithDefinitions`). The +caller signals SimpleSchema-context explicitly via the option; the +builder no longer infers it from `tgt.In()`. See +[§build-entry](#build-entry) for the option table. + +### Allowed keyword surface + +Common between parameters and headers: + +| Keyword | Notes | +|---|---| +| `type` | `{string, number, integer, boolean, array}`; `file` for params only | +| `format` | same vocabulary as full Schema | +| `items` | required when `type: array`; nested SimpleSchema, recursive | +| `collectionFormat` | `{csv, ssv, tsv, pipes, multi}`, default `csv` — SimpleSchema-only | +| `default` | | +| `maximum`, `exclusiveMaximum`, `minimum`, `exclusiveMinimum`, `multipleOf` | | +| `maxLength`, `minLength`, `pattern` | | +| `maxItems`, `minItems`, `uniqueItems` | | +| `enum` | | +| `x-*` vendor extensions | | + +Parameter-only extras: `allowEmptyValue` (only when +`in ∈ {query, formData}` — forbidden on `path` and `header`); +`type: file` (only when `in: formData`). + +Response headers exclude `file` and `allowEmptyValue` entirely. + +### "Catch at exit" contract + +The schema builder does **not** pre-filter inputs in SimpleSchema +mode. `*types.Struct` and `*types.Interface` are allowed to enter +the resolution pipeline because they can legitimately resolve to a +SimpleSchema-legal primitive: + +- `time.Time` → `{string, date-time}` (stdlib recognizer) +- `TextMarshaler` → `{string, format}` (textmarshal shortcut) +- `json.RawMessage` → empty `{}` (any) +- user-driven overrides (`swagger:strfmt`, `swagger:type`, the + `swagger:alias` per-type opt-in via + [§classifier-walkers](#classifier-walkers)' + `classifierNamedBasic`) win the cascade as they always do + +The exit validator (`validateSimpleSchemaOutcome` in +`simpleschema.go`) inspects the resolved target after +`buildFromType` returns: + +- accept when `shape.Type` is in + `{"", string, number, integer, boolean, array}`, plus `file` if + `in == "formData"` (empty means "any" — `json.RawMessage` ends up + here). +- otherwise emit `CodeUnsupportedInSimpleSchema` (severity + `SeverityWarning`) and **reset** the target. + +The reset wipes the target back to empty `{}` (no `Type`, no +`Format`, no `Ref`) rather than degrading to `{type: string}` — +empty is honest about the failed resolution and avoids silently +mistyping a complex shape as a string. + +### `SimpleSchemaProbe` interface + +The target must expose three methods so the validator can inspect +the post-resolution shape and reset it on violation: + +```go +type SimpleSchemaProbe interface { + SimpleSchemaShape() *oaispec.SimpleSchema + HasRef() bool + ResetForViolation() +} +``` + +Implemented structurally by `paramTypable` in +`internal/builders/parameters` and (forthcoming with M2) +`headerTypable` in `internal/builders/responses`. Consumers don't +need to import the schema package — the interface is satisfied by +method set. + +A target that doesn't implement `SimpleSchemaProbe` is trusted: the +validator no-ops. A `nil` `SimpleSchemaShape` is also trusted — the +caller chose SimpleSchema mode for something that can't surface a +violation, by intent. + +### Knock-on cleanups this contract enables + +Once `WithSimpleSchema` carries the mode flag explicitly, the +builder can stop sniffing `tgt.In()`: + +- `classifierNamedBasic`'s primitive-inline arm (see + [§classifier-walkers](#classifier-walkers)) now keys on + `s.simpleSchema` instead of the v1-era `isAliasParam(tgt)` + predicate. That closes the `in: header` omission documented as a + resolved quirk below ([§quirks-resolved](#quirks-resolved)). +- The `swagger:alias` per-type author override stays orthogonal — + it bypasses the model-ref pipeline regardless of mode. + +### Walker keyword gating under SimpleSchema mode + +The single source of truth for the SimpleSchema vocabulary is +`internal/builders/handlers.IsSimpleSchemaKeyword(name)`. The +package-level `simpleSchemaAllowed` map enumerates every +keyword legal on a non-body parameter, response header, or +items chain within either, per OAS v2's Parameter Object and +Header Object tables. + +`schemaBoolHandler` consults this predicate when +`s.simpleSchema == true`: + +- **Full-Schema-only Bool keywords** (`readOnly`, `discriminator`) + trigger a `CodeUnsupportedInSimpleSchema` `SeverityWarning` + diagnostic and the write is skipped. Even if the path lands on + a throwaway scratch schema (the common case under SimpleSchema + mode — `paramTypable.Schema()` returns nil for non-body), the + diagnostic still surfaces the misuse to the author. +- **`required:`** is accepted by the predicate (it IS + SimpleSchema-legal as a parameter-level boolean) but the + schema's Bool handler skips it silently under SimpleSchema mode + anyway. The parameter-level write — `param.Required = val` — + lives in `parameters/walker.go:paramRequiredBool`. Headers + don't carry `required:` at all. The schema walker's full-Schema + semantics (object-level required-array via + `enclosing.Required[name]`) don't fit the SimpleSchema shape. +- Number / Integer / String / Raw / Extension dispatchers are + unchanged: all the keywords they handle are SimpleSchema-legal. + +The `IsSimpleSchemaKeyword` set is locked down by unit tests in +the handlers package — any future addition (or removal) of a +SimpleSchema keyword has to update the test alongside the map, +so the contract can't drift silently. + +--- + +## §classifier-walkers — per-call-site classifier walkers and `findAnnotationArg`'s single-word filter + +The schema builder dispatches user-classifier annotations +(`swagger:strfmt`, `swagger:type`, `swagger:enum`, +`swagger:default`, `swagger:allOf`, `swagger:alias`) via per-call- +site walker functions in `walker_classifiers.go`. The shape is +deliberate: + +- **One walker per call site.** Each walker is named for the call- + site context (e.g. `classifierTextMarshal`, + `classifierNamedBasic`, `classifierNamedArrayLike`). Its godoc + documents which `swagger:` classifier annotations it + consumes — explicit per-site contract instead of a single + catch-all dispatcher. +- **Reads through the ParseBlocks cache.** Each walker calls + `s.ParseBlocks(cg)` so a single CommentGroup is parsed exactly + once per build regardless of how many walkers inspect it. +- **Site-local writes.** The walker performs the call-site's writes + directly onto the typable target — both lookup and side effect + encapsulated. + +Correctness first; if a future re-read finds genuine redundancy, +the factorisation is mechanical and safer last. + +### `findAnnotationArg` and the single-word filter + +`findAnnotationArg(cg, kind)` returns the first positional argument +of the first `Block` of the given annotation kind, **filtered to +non-empty single-word arguments**: + +```go +if strings.ContainsAny(arg, " \t") { + continue +} +``` + +The single-word filter only matters for annotation kinds whose +lexer arg-classifier doesn't already split on whitespace — namely +`AnnType` (via `argTypeRef`) and `AnnDefaultName` (via +`argDefaultValue`). Kinds that go through `firstIdent` in the lexer +(`AnnStrfmt`, `AnnName`, `AnnAllOf`, `AnnModel`, `AnnResponse`, +`AnnEnum`) already produce single-token args, but the filter is +harmless on those. + +The check matches v1's de-facto `\S+`-anchored capture, which +silently rejected prose lines that happened to open with +`swagger:` followed by a sentence. The +`fixtures/enhancements/named-basic` fixture documents this trap +with a `swagger:type so the scanner emits ...` prose line preceding +the real `swagger:type string` annotation — without the filter, the +prose's "so" would pre-empt the real arg "string". + +`findAnnotationArg` reads through the per-Builder `ParseBlocks` +cache, so the lookup is parse-once per CommentGroup and the +`ParseAll`-aware multi-annotation case still surfaces every +annotation of interest. + +### Walker inventory + +| Walker | Call site | Consumes | +|---|---|---| +| `classifierTextMarshal` | `buildFromTextMarshal` end-of-pipe | `swagger:strfmt` | +| `classifierNamedTypeOverride` | `buildFromType` named fallback, `buildFromStruct` pre-pass | `swagger:type` | +| `classifierNamedBasic` | `buildNamedBasic` | cascade: `swagger:strfmt → swagger:enum → swagger:default → swagger:type → swagger:alias` (the alias arm doubles as the SimpleSchema-mode primitive-inline branch — see [§simple-schema-mode](#simple-schema-mode)) | +| `classifierNamedArrayLike` | `buildNamedArray` / `buildNamedSlice` | `swagger:strfmt`, `swagger:type` | +| `classifierAliasTargetStrfmt` | `buildNamedAllOf` (struct + interface arms) | `swagger:strfmt` | +| `classifierStructPreBuildType` | `buildFromStruct` top | `swagger:type` | +| `classifierNamedStructStrfmt` | `buildNamedStruct` strfmt-first branch | `swagger:strfmt` | +| `scanFieldDoc` | field-level FieldWalker (`applyFieldCarrier`) | `swagger:ignore`, `swagger:name`, `swagger:strfmt`, `swagger:type` (with the same single-word filter), `swagger:allOf` | + +--- + +## §quirks — known behavioural caveats + +Entries are split into two groups: **[Resolved in this refactor](#quirks-resolved)** lists +quirks fixed in the current pass with a short note on how, so a +future reader can find the change and the rationale. **[Still open](#quirks-open)** lists +quirks the refactor either deliberately did not touch (deferred to +v2 / design call required) or documents as intentional behaviour. + +--- + +## §quirks-resolved — fixed in this refactor + +### ✅ `recognizeRawMessage` now emits an empty schema (was `{type: object}`) + +`json.RawMessage` is `[]byte` underneath but JSON-marshals as +arbitrary JSON. The recognizer now emits an empty schema (`{}`), +which in Swagger 2.0 / JSON Schema means "any type" — the most +faithful representation of `RawMessage`'s contract. Previous +behaviour emitted `{type: object}` as a narrower approximation. + +**Fix:** `recognizeRawMessage` arm in `applySpecialType` rewritten to +call `_ = target.Schema()` (the "any" pattern used by `recognizeAny`) +instead of `target.Typed("object", "")`. Golden delta captured in +`go123_special_spec.json`'s `Message` property. + +### ✅ `recognizeError` x-go-type extension now honours `SkipExtensions` + +The `error` arm of `applySpecialType` writes `x-go-type: error` in +addition to typing the target as `{string, ""}`. This is for +downstream tooling that wants to detect the Go-error origin. The +write is now gated by the `skipExt` argument threaded into +`applySpecialType` / `applyStdlibSpecials` from `s.skipExtensions`, +so `SkipExtensions=true` suppresses it like any other vendor +extension. + +**Fix:** added `skipExt bool` parameter to `applySpecialType` and +`applyStdlibSpecials`; gated the `target.AddExtension(...)` call in +the `recognizeError` arm. Eight schema-internal call sites updated to +pass `s.skipExtensions`. Golden-neutral (no existing fixture combines +the `error` shape with `SkipExtensions=true`). + +### ✅ Field-level `swagger:type` on `json.RawMessage` fields + +A struct field of type `json.RawMessage` with a field-level +`swagger:type object` / `swagger:type array` annotation now produces +the user-specified shape instead of silently emitting the recognizer +default. Pre-fix: `scanFieldDoc` only consumed +`ignore` / `name` / `strfmt` / `allOf`, so field-level `swagger:type` +was dropped before `applyBlockToField` ran. + +**Fix:** added `TypeOverride` to `fieldDoc`; `scanFieldDoc` consumes +`AnnType` with a single-word filter (mirroring `findAnnotationArg`, +since `AnnType` uses TrimSpace and can carry prose on noise lines); +`applyFieldCarrier` applies the override after `buildFromType` — +tries `SwaggerSchemaForType(name, …)` first, falls back to +`buildFromType(c.propType.Underlying(), …)` on unknown leaves like +`"array"` so item shapes are computed from the Go type. Fixture +`fixtures/enhancements/raw-message-override/` (case C); golden +`enhancements_raw_message_override.json`. + +### ✅ Wrapper-decl `swagger:type` honoured at top-level definition + +A named wrapper of `json.RawMessage` (or any other type the recognizer +would otherwise short-circuit) decorated with `swagger:type` on the +decl now emits the user-specified shape at its **own** top-level +definition, not just at field reference sites. Pre-fix: only the +field-reference path consulted the wrapper's `swagger:type`; the +top-level definition emitted an empty schema because `buildFromDecl` +dispatched on `ti.Type` (the RHS) and the RHS recognizer fired +before any wrapper-side classifier could. + +**Fix:** `buildFromDecl` now calls `classifierNamedTypeOverride` on +`s.Decl.Comments` before the kind-dispatch. Known leaves (`object`, +`string`, …) terminate; unknown leaves (`array`) fall back to +`s.Decl.ObjType().Underlying()` so items / properties are filled +from the Go-level shape. Isolation fixture +`fixtures/enhancements/wrapper-decl-type-override/` — +`BareWrapperObject` / `BareWrapperArray`. + +### ✅ `in: header` parameters now inline named-basic types + +Pre-fix, `classifierNamedBasic`'s primitive-inline arm only fired +for `in: query` / `in: path` / `in: formData` — `header` was +silently omitted from the `isAliasParam` predicate (carried over +verbatim from v1's `parsers.IsAliasParam`). So a header parameter +typed as a named string `type SessionID string` resolved through +`FindModel → makeRef` and emitted `{$ref: "#/definitions/SessionID"}` +— invalid under OAS v2 SimpleSchema, which forbids `$ref` on +non-body parameter sites. + +**Fix:** the `isAliasParam(tgt)` `In()`-sniffing predicate replaced +with the M1 `s.simpleSchema` flag (set by `WithSimpleSchema`). The +parameter bridge now wires `WithSimpleSchema` for all four non-body +locations uniformly (`query` / `path` / `header` / `formData`), so +the primitive-inline arm covers `header` automatically. The +predicate function deletes (sole consumer). + +Two paths through the arm remain orthogonal: the SimpleSchema flag +is caller-driven (parameter/header build mode); `swagger:alias` on +the decl is a per-type author override. Either triggers +`SwaggerSchemaForType(underlying basic name)` on the typable. + +Isolation fixture `fixtures/enhancements/header-named-basic/` and +test `internal/integration/coverage_header_named_basic_test.go` +pin the post-fix shape. Responses won't pick up the same fix until +M2 wires `WithSimpleSchema` on header-field builds; the response +edges fixture covers a different (strfmt-tagged) shape already. + +--- + +## §quirks-open — still open + +### 🟡 Named-strfmt + `swagger:model` combo (deferred) + +When the author combines `swagger:strfmt` with `swagger:model` +on the same type, the FIELD reference inlines as `{string, format}` +(via the strfmt classifier) but the TOP-LEVEL definition body is +still emitted from walking the underlying struct. + +**Reproduction.** Fixture `fixtures/enhancements/named-struct-tags-ref/types.go` +declares `PhoneNumber` with both `swagger:strfmt phone` and +`swagger:model`, used by `Contact.Phone`. The golden +`enhancements_named_struct_tags-ref.json` captures the observable +inconsistency: + +- Field site: `{type: "string", format: "phone"}` — strfmt wins. +- Top-level definition: `{type: "object", properties: {CountryCode, Number}}` — + the struct walk wins; the strfmt annotation is ignored at decl time. + +The author asked for "named strfmt" (a reusable `PhoneNumber` +definition rendered as a formatted string) but gets an inconsistent +pair: the field says string, the definition says object. + +**Attempted fix and reasons it reverted.** The first attempt +(referred to as "Option 1") would have: + +1. Detected `swagger:strfmt` on the decl in `buildDeclNamed` and + emitted `{string, fmt}` instead of walking the struct body. +2. In `buildNamedStruct`, when the target also has `swagger:model`, + emitted `$ref` instead of inlining the strfmt. + +This was reverted before merge because: + +- Pre-existing fixtures in + `fixtures/goparsing/classification/transitive/mods/aliases.go` use + the same `swagger:strfmt + swagger:model` combination on + defined-from-`time.Time` types (e.g. `SomeTimeType time.Time`). + The existing tests (`TestAliasedTypes`, `TestAliasedModels`) + assert the *inline* baseline (`scantest.AssertProperty(..., "string", ...)`) + rather than a `$ref`. Option 1 flips these to `$ref`, requiring + coordinated test updates. +- The decl-level `StrfmtName` check also over-fires on slice / array / + map underlyings: `type SomeTimesType []time.Time` with + `swagger:strfmt date-time` should emit + `{array, items: {string, date-time}}`, not flatten to `{string}`. + A correct fix would gate the check on struct-underlying first, + then symmetrically consider whether `buildNamedSlice` / + `buildNamedArray` / `buildNamedMap` should also route through + `$ref` under the `swagger:model` combination. + +The surface area is wider than the Option 1 code change suggested, +and the existing test coverage of the combination is entangled with +the inconsistency itself. + +**Why deferred.** The combination is niche, the footgun is narrow +(you get what you asked for on one side of the indirection, not +both), and v2's annotation redesign can reshape the contract without +carrying this legacy. A focused decision on "named strfmt" semantics +belongs in the v2 design, not a bug-fix pass. + +The `named-struct-tags-ref` fixture and its golden are checked in as +a deliberate marker — the golden captures the observable +inconsistency (inline field + struct-body definition) so future work +on this decision has a failing test to anchor against. + +### 🟦 `interface{}` literals (documented behaviour) + +A bare `interface{}` field hits the `*types.Interface` arm of +`buildFromType` (anonymous, not Named). It produces an empty +schema. The user-named `type X interface{}` is a `*types.Named` +with empty `Underlying()` and emits as `$ref` to a definition +with no `properties` — JSON-equivalent to "any object" in v1. +Behavioural; changing it would break consumers. + +### 🟦 Generic declarations vs instantiations (documented behaviour) + +Generic **declarations** (e.g. `GenericSlice[T any] []T`) are +processed but their schemas are essentially empty — the type +parameter `T` is filtered out by `UnsupportedBuiltinType` as a +`*types.TypeParam`. Generic **instantiations** +(e.g. a field of type `GenericSlice[int]`) emit correctly with +the substituted underlying via the `TypeArgs` short-circuit +([§dissolve-named](#dissolve-named)). No bug — generic decls +without a concrete instantiation simply have no representable +schema. + +### 🟡 Cross-package definition-name collisions silently overwrite + +`buildFromDecl` writes the top-level schema as +`s.definitions[s.Name] = schema`, keyed only by the Go identifier +(`decl.Names()[0]`). When two packages in a single scan declare a type +with the same identifier — `pkg/a.User` and `pkg/b.User` — both map +to `definitions["User"]` and the second build silently overwrites the +first. The output spec carries only one `User`, with no record of the +collision and no signal of which package won. + +The existing `nameByJSON` (`propOwner`) map in field emission is **not** +a defense against this case: it tracks JSON property names within a +single struct's field set plus its embeds (for the ambiguous-embed +diagnostic), not type-level identifier conflicts across packages. + +#### Target shape + +A proper fix needs three pieces: + +1. **Detection** — at write time, recognise the case "definition key + already exists with non-empty schema and originates from a different + package" (use `x-go-package`, or stash origin in the `Builder`). +2. **Diagnostic** — emit `CodeNameConflict` (severity + `SeverityWarning` minimum, possibly `SeverityError` under strict + mode) carrying both `(pkg, name)` pairs. +3. **Policy** — open design call: + - **a. Rename** — prefix loser(s) with a stable short-package + (e.g. `a_User`, `b_User`). Stable but ugly; needs all `$ref`s + to follow the rename — cross-cutting. + - **b. Skip + warn** — keep the first writer, drop subsequent + ones, emit a warning. Predictable but lossy. + - **c. Fail the build** — under strict mode, treat as an error. + Forces the author to rename in source. Cleanest semantics, + most disruptive. + +#### Why deferred + +Each policy choice changes the contract for downstream code generators +(go-swagger, oapi-codegen, …) — they have assumptions about +`definitions` keys matching exported Go names. The "rename" path +additionally requires every `$ref` writer in the builders to consult a +rename map; the surface is wide. + +For multi-package scans where the author controls both packages, the +workaround today is to scope scans to one package per spec, or to +rename one of the colliding types at the source. A future strict-mode +flag (e.g. `Options.StrictNameConflicts`) could enable option (c) +without breaking existing scans. + +### 🟡 Stale `x-go-enum-desc` after a field-level enum override + +When a field uses a type marked `swagger:enum TypeName` **and** carries +its own `enum: …` override, v1 mutates the schema in place: it replaces +`Enum`, strips the inherited `x-go-enum-desc`, and trims the matching +description suffix. This is **lossy** — the per-value docs contributed +by `TypeName` are silently discarded. + +Concretely, given (fixture `fixtures/enhancements/enum-overrides/`, +case E): + +```go +// swagger:enum PriorityE +type PriorityE string + +const ( + PriorityELow PriorityE = "low" // low-priority requests + PriorityEMed PriorityE = "medium" // medium-priority requests + PriorityEHigh PriorityE = "high" // high-priority requests +) + +type NotificationE struct { + // Inline enum provides a narrower set than the const block. + // + // enum: urgent, normal + Priority PriorityE `json:"priority"` +} +``` + +v1 emits: + +```yaml +priority: + type: string + enum: [urgent, normal] # the override wins + description: "Inline enum provides a narrower set than the const block." + # x-go-enum-desc removed by clearStaleEnumDesc + # PriorityE's per-value doc lines silently dropped from description +``` + +The cleanup runs reactively from `schemaValidations.SetEnum` +([typable.go](typable.go#L128)) via `clearStaleEnumDesc` +([extensions.go](extensions.go#L42)). It treats any +`x-go-enum-desc` present at `SetEnum` time as inherited (and therefore +stale once `Enum` is replaced), deletes it, and trims the matching +suffix off `Description`. The `TrimSuffix` dance is fragile — it +relies on the enum-desc pipeline having appended the doc lines as a +literal suffix — but it works under v1's emission discipline. + +#### Target shape (allOf composition) + +OpenAPI 2.0 supports `allOf` for schema composition, so the cleaner +model does not have to wait for OAS 3. The replacement shape is: + +```yaml +# PriorityE promoted to a top-level definition: +definitions: + PriorityE: + type: string + enum: [low, medium, high] + description: | + low: low-priority requests + medium: medium-priority requests + high: high-priority requests + x-go-enum-desc: | + low: low-priority requests + medium: medium-priority requests + high: high-priority requests + + NotificationE: + type: object + properties: + priority: + description: "Inline enum provides a narrower set than the const block." + allOf: + - $ref: '#/definitions/PriorityE' # inherited enum + per-value docs + - enum: [urgent, normal] # the override +``` + +Each branch keeps its own concern: + +- the `$ref` branch carries `PriorityE`'s full schema (values + docs + + `x-go-enum-desc`), untouched and reusable by every field that + references `PriorityE`; +- the inline branch carries the narrowing override only. + +No mutation of the inherited schema, no `TrimSuffix` dance. Validator +semantics for enum-narrowing `allOf` aren't perfectly uniform across +tools, but for the documentation / code-gen use cases codescan feeds +(go-swagger, oapi-codegen, redoc, …) this composition preserves both +layers cleanly. + +#### Prerequisites for the migration (both currently missing) + +1. **Promote unannotated `swagger:enum` types to top-level definitions** + so the `$ref` branch has a target. Today they exist only as inlined + fragments on each referring field. +2. **Move override detection from `SetEnum` (validation hook) to the + field-emission path**, so the override is composed alongside the + inherited schema instead of mutating it after the fact. + +Until both land, `clearStaleEnumDesc` stays in place. The TODO in +`extensions.go` flags it as the replacement target. diff --git a/internal/builders/schema/allof.go b/internal/builders/schema/allof.go new file mode 100644 index 0000000..8ee404d --- /dev/null +++ b/internal/builders/schema/allof.go @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "fmt" + "go/types" + + "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/logger" + "github.com/go-openapi/codescan/internal/scanner" + oaispec "github.com/go-openapi/spec" +) + +// scanEmbeddedFields walks st's anonymous fields and decides whether +// each embed contributes properties to the outer schema directly or +// becomes an `allOf` compound member. +// +// # Details +// +// See [§allof](./README.md#allof) — embed classification rules, +// `IsAllOfMember` semantics, and how the returned target ties to +// `buildFromStruct`'s second pass. +// +// Returns: +// - target — the schema receiving properties; nil if no embed contributed, +// `schema` itself for plain embeds, a fresh schema when allOf is in play. +// - hasAllOf — true if at least one allOf member was appended. +func (s *Builder) scanEmbeddedFields( + decl *scanner.EntityDecl, st *types.Struct, schema *oaispec.Schema, nameByJSON map[string]propOwner, +) (target *oaispec.Schema, hasAllOf bool, err error) { + for fld := range st.Fields() { + if !fld.Anonymous() { + continue + } + + afld := resolvers.FindASTField(decl.File, fld.Pos()) + if afld == nil { + continue + } + + fd := s.scanFieldDoc(afld) + if fd.Ignored { + continue + } + + _, ignore, _, _, err := resolvers.ParseJSONTag(afld) + if err != nil { + return nil, false, err + } + if ignore { + continue + } + + _, isAliased := fld.Type().(*types.Alias) + + if !fd.IsAllOfMember && !isAliased { + if target == nil { + target = schema + } + if err := s.buildEmbedded(fld.Type(), target, nameByJSON); err != nil { + return nil, false, err + } + continue + } + + hasAllOf = true + if target == nil { + target = &oaispec.Schema{} + } + var newSch oaispec.Schema + if err := s.buildAllOf(fld.Type(), &newSch); err != nil { + return nil, false, err + } + + if fd.AllOfClass != "" { + schema.AddExtension("x-class", fd.AllOfClass) + } + schema.AllOf = append(schema.AllOf, newSch) + } + + return target, hasAllOf, nil +} + +// buildAllOf builds the schema for one allOf compound member. Peels +// pointers and routes named types and aliases to their dedicated +// helpers. +// +// # Details +// +// See [§allof](./README.md#allof) — the three-arm dispatch and why +// non-Named / non-Alias inputs are dropped silently with a logger +// warning rather than an error. +func (s *Builder) buildAllOf(tpe types.Type, schema *oaispec.Schema) error { + switch ftpe := tpe.(type) { + case *types.Pointer: + return s.buildAllOf(ftpe.Elem(), schema) + case *types.Named: + return s.buildNamedAllOf(ftpe, schema) + case *types.Alias: + tgt := NewTypable(schema, 0, s.skipExtensions) + return s.buildAlias(ftpe, tgt) + default: + logger.UnsupportedTypeKind("buildAllOf", ftpe) + return nil + } +} + +// buildNamedAllOf resolves a named type appearing as an allOf member. +// Struct and interface underlyings share the same precedence shape: +// user-classifier first, then stdlib specials, then model lookup, then +// inline build. +// +// # Details +// +// See [§allof](./README.md#allof) — arm symmetry rationale and why +// `classifierAliasTargetStrfmt` is preferred over a comment-group-keyed +// variant. +func (s *Builder) buildNamedAllOf(ftpe *types.Named, schema *oaispec.Schema) error { + tgt := NewTypable(schema, 0, s.skipExtensions) + tio := ftpe.Obj() + + if s.classifierAliasTargetStrfmt(ftpe, tgt) { + return nil + } + if applyStdlibSpecials(tio, tgt, s.skipExtensions) { + return nil + } + + decl, found := s.Ctx.GetModel(tio.Pkg().Path(), tio.Name()) + if !found { + return fmt.Errorf("can't find source for named allOf member %s: %w", ftpe.String(), ErrSchema) + } + + if decl.HasModelAnnotation() { + return s.MakeRef(decl, tgt) + } + + switch utpe := ftpe.Underlying().(type) { + case *types.Struct: + return s.buildFromStruct(decl, utpe, schema, make(map[string]propOwner)) + case *types.Interface: + return s.buildFromInterface(decl, utpe, schema, make(map[string]propOwner)) + default: + logger.UnsupportedTypeKind("buildNamedAllOf", utpe) + return nil + } +} diff --git a/internal/builders/schema/embedded.go b/internal/builders/schema/embedded.go new file mode 100644 index 0000000..5439075 --- /dev/null +++ b/internal/builders/schema/embedded.go @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/ast" + "go/types" + "log/slog" + + "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/logger" + "github.com/go-openapi/codescan/internal/scanner" + oaispec "github.com/go-openapi/spec" +) + +// enterEmbed bumps embedDepth and returns a func that restores it. +// Use as `defer s.enterEmbed()()` around any recursive build pass +// that descends into an embedded type. Pairs with +// `applyFieldCarrier`'s ambiguity diagnostic. +// +// # Details +// +// See [§embed-depth](./README.md#embed-depth) — depth-rule semantics +// and which writes count as ambiguous embeds. +func (s *Builder) enterEmbed() func() { + s.embedDepth++ + return func() { s.embedDepth-- } +} + +// buildEmbedded routes a struct's embedded-field type to the +// appropriate emitter: pointers are peeled, named types descend into +// `buildNamedEmbedded`, aliases go through `buildAlias`. +// +// # Details +// +// See [§embedded](./README.md#embedded) — the three-arm dispatch and +// the deliberate asymmetry with `buildAllOf` (embeds always inline +// properties, never $ref unless the embed is `swagger:allOf`-tagged). +func (s *Builder) buildEmbedded(tpe types.Type, schema *oaispec.Schema, nameByJSON map[string]propOwner) error { + switch ftpe := tpe.(type) { + case *types.Pointer: + return s.buildEmbedded(ftpe.Elem(), schema, nameByJSON) + case *types.Named: + return s.buildNamedEmbedded(ftpe, schema, nameByJSON) + case *types.Alias: + target := NewTypable(schema, 0, s.skipExtensions) + return s.buildAlias(ftpe, target) + default: + logger.UnsupportedTypeKind("buildEmbedded", ftpe) + return nil + } +} + +// buildNamedEmbedded inlines an embedded named struct or interface +// into the outer schema. The interface arm runs `applyStdlibSpecials` +// so `error` etc. recognize cleanly; the struct arm does not — the +// asymmetry is intentional, see README §embedded. +// +// # Details +// +// See [§embedded](./README.md#embedded) — `AddDiscoveredModel` +// pairing, struct-vs-interface specials asymmetry, and how +// `enterEmbed` interacts with `applyFieldCarrier`'s ambiguity +// diagnostic. +func (s *Builder) buildNamedEmbedded(tpe *types.Named, schema *oaispec.Schema, nameByJSON map[string]propOwner) error { + if resolvers.UnsupportedBuiltin(tpe) { + s.Warn("skipped unsupported builtin", slog.Any("type", tpe)) + + return nil + } + + switch utpe := tpe.Underlying().(type) { + case *types.Struct: + decl, found := s.Ctx.GetModel(tpe.Obj().Pkg().Path(), tpe.Obj().Name()) + if !found { + return missingSource(tpe) + } + s.Ctx.AddDiscoveredModel(decl) + + defer s.enterEmbed()() + return s.buildFromStruct(decl, utpe, schema, nameByJSON) + case *types.Interface: + if utpe.Empty() { + return nil + } + o := tpe.Obj() + target := NewTypable(schema, 0, s.skipExtensions) + if applyStdlibSpecials(o, target, s.skipExtensions) { + return nil + } + + resolvers.MustNotBeABuiltinType(o) + decl, found := s.Ctx.GetModel(o.Pkg().Path(), o.Name()) + if !found { + return missingSource(tpe) + } + s.Ctx.AddDiscoveredModel(decl) + + defer s.enterEmbed()() + return s.buildFromInterface(decl, utpe, schema, nameByJSON) + default: + logger.UnsupportedTypeKind("buildNamedEmbedded", utpe) + return nil + } +} + +// processEmbeddedType handles types reached during an interface's +// `swagger:allOf` walk: named, anonymous interface, and alias +// embeds. Each non-empty sub-schema becomes an `allOf` member on the +// outer schema. +// +// # Details +// +// See [§embedded](./README.md#embedded) — interface-side allOf +// composition rules and the `Ref.String() != "" || Properties >0 || +// AllOf >0` non-empty guard rationale. +func (s *Builder) processEmbeddedType(fld types.Type, flist []*ast.Field, decl *scanner.EntityDecl, schema *oaispec.Schema, + nameByJSON map[string]propOwner, +) (fieldHasAllOf bool, err error) { + switch ftpe := fld.(type) { + case *types.Named: + o := ftpe.Obj() + var dummySchema oaispec.Schema + ps := NewTypable(&dummySchema, 0, s.skipExtensions) + if applyStdlibSpecials(o, ps, s.skipExtensions) { + return false, nil + } + return s.buildNamedInterface(ftpe, flist, decl, schema, nameByJSON) + case *types.Interface: + var aliasedSchema oaispec.Schema + ps := NewTypable(&aliasedSchema, 0, s.skipExtensions) + if err = s.buildAnonymousInterface(ftpe, ps, decl); err != nil { + return false, err + } + if aliasedSchema.Ref.String() != "" || len(aliasedSchema.Properties) > 0 || len(aliasedSchema.AllOf) > 0 { + fieldHasAllOf = true + schema.AddToAllOf(aliasedSchema) + } + case *types.Alias: + var aliasedSchema oaispec.Schema + ps := NewTypable(&aliasedSchema, 0, s.skipExtensions) + if err = s.buildAlias(ftpe, ps); err != nil { + return false, err + } + if aliasedSchema.Ref.String() != "" || len(aliasedSchema.Properties) > 0 || len(aliasedSchema.AllOf) > 0 { + fieldHasAllOf = true + schema.AddToAllOf(aliasedSchema) + } + default: + logger.UnsupportedTypeKind("buildNamedInterface.allOf", ftpe) + } + + return fieldHasAllOf, nil +} diff --git a/internal/builders/schema/errors.go b/internal/builders/schema/errors.go index 2bb3cba..e9a4285 100644 --- a/internal/builders/schema/errors.go +++ b/internal/builders/schema/errors.go @@ -3,7 +3,15 @@ package schema -import "errors" +import ( + "errors" + "fmt" + "go/types" +) // ErrSchema is the sentinel error for all errors originating from the schema builder package. var ErrSchema = errors.New("codescan:builders:schema") + +func missingSource(tpe types.Type) error { + return fmt.Errorf("can't find source file for type: %v: %w", tpe, ErrSchema) +} diff --git a/internal/builders/schema/fields.go b/internal/builders/schema/fields.go new file mode 100644 index 0000000..535b38d --- /dev/null +++ b/internal/builders/schema/fields.go @@ -0,0 +1,258 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "fmt" + "go/ast" + "go/token" + "go/types" + + "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/scanner" + oaispec "github.com/go-openapi/spec" +) + +// fieldCarrier holds everything the unified field-emission pipeline needs. +// +// Per-source extractors (struct field, interface method) build it; +// applyFieldCarrier consumes it to emit one Swagger property. +type fieldCarrier struct { + name string // JSON property name (post-tag, post-mangler) + goName string // Go identifier (for x-go-name override) + propType types.Type // type to build the property schema from + afld *ast.Field // AST field, used for block-comment lookup + fd fieldDoc // pre-scanned doc-comment signals + isString bool // JSON tag's ",string" option (struct only) + omitEmpty bool // affects the nullable rule (struct only) +} + +// propOwner records the writer of a property: the Go field name and +// the embed-recursion depth at write time. +// +// Used by: +// - structFieldCarrier — reverse-lookup for `json:"-"` eviction +// (find the JSON name a given Go name was last bound to). +// - applyFieldCarrier — ambiguity detection (a later embed-side +// write at the same or shallower depth than the prior one and +// with a different Go name is the same-depth-ambiguity case Go +// itself would refuse to promote). +type propOwner struct { + goName string + depth int +} + +// nameByJSON maps a JSON property name to its most recent writer. +// The original implementation was map[string]string and drove a +// post-pass GC over target.Properties; that pass was dead under every +// traced path (applyFieldCarrier is the only writer to target.Properties +// and it always writes the matching nameByJSON entry, so +// `Properties.keys() ⊆ nameByJSON.keys()` is an invariant) and has +// been removed. + +// applyFieldCarrier emits one property of target from c. Common +// pipeline shared by processStructField, processInterfaceMethod and +// processAnonInterfaceMethod. `nameByJSON` is optional: nil for +// anonymous interfaces, which don't track duplicates. +// +// # Details +// +// See [§user-overrides](./README.md#user-overrides) — the +// last-write-wins ordering across `isString` / `StrfmtName` / +// `TypeOverride` / `applyBlockToField`, plus the +// `x-go-name` and pointer-nullable rules at the tail. +func (s *Builder) applyFieldCarrier(c fieldCarrier, target *oaispec.Schema, nameByJSON map[string]propOwner) error { + if target.Properties == nil { + target.Properties = make(map[string]oaispec.Schema) + } + + ps := target.Properties[c.name] + if err := s.buildFromType(c.propType, NewTypable(&ps, 0, s.skipExtensions)); err != nil { + return err + } + if c.isString { + ps.Typed("string", ps.Format) + ps.Ref = oaispec.Ref{} + ps.Items = nil + } + if c.fd.StrfmtName != "" { + ps.Typed("string", c.fd.StrfmtName) + ps.Ref = oaispec.Ref{} + ps.Items = nil + } + if c.fd.TypeOverride != "" { + // Field-site swagger:type override. See + // [§user-overrides](./README.md#user-overrides) for ordering + // and the Underlying() fallback rationale. + ps = oaispec.Schema{} + override := NewTypable(&ps, 0, s.skipExtensions) + if err := resolvers.SwaggerSchemaForType(c.fd.TypeOverride, override); err != nil { + if err := s.buildFromType(c.propType.Underlying(), override); err != nil { + return err + } + } + } + + s.applyBlockToField(c.afld, target, &ps, c.name) + + if ps.Ref.String() == "" && c.name != c.goName { + resolvers.AddExtension(&ps.VendorExtensible, "x-go-name", c.goName, s.skipExtensions) + } + if _, isPointer := c.propType.(*types.Pointer); isPointer && !c.omitEmpty { + s.applyNullable(&ps) + } + + if nameByJSON != nil { + s.diagnoseAmbiguousEmbed(c, nameByJSON) + nameByJSON[c.name] = propOwner{goName: c.goName, depth: s.embedDepth} + } + target.Properties[c.name] = ps + return nil +} + +// diagnoseAmbiguousEmbed fires a SeverityWarning Diagnostic on +// embed-side writes that would overwrite a prior entry whose write +// was at the same or shallower depth and bound to a different Go +// name. Last-write-wins is preserved; only the signal is added. +// +// # Details +// +// See [§embed-depth](./README.md#embed-depth) — depth-rule +// disambiguation and the three classification cases. +func (s *Builder) diagnoseAmbiguousEmbed(c fieldCarrier, nameByJSON map[string]propOwner) { + if s.embedDepth == 0 { + return + } + prior, found := nameByJSON[c.name] + if !found || prior.goName == c.goName { + return + } + if prior.depth > s.embedDepth { + // Legitimate depth-rule shadowing: current writer is closer + // to the parent struct than the prior, Go would prefer it. + return + } + var pos token.Position + if c.afld != nil { + pos = s.Ctx.PosOf(c.afld.Pos()) + } + s.RecordDiagnostic(grammar.Diagnostic{ + Pos: pos, + Severity: grammar.SeverityWarning, + Code: grammar.CodeAmbiguousEmbed, + Message: fmt.Sprintf( + "JSON property %q is promoted by Go field %q (depth %d) and Go field %q (depth %d); "+ + "Go would treat this as ambiguous and not promote it. The schema emits the later writer's shape.", + c.name, prior.goName, prior.depth, c.goName, s.embedDepth, + ), + }) +} + +// structFieldCarrier produces the carrier for a struct field, or +// returns ok=false when the field must be skipped silently (embedded, +// unexported, no AST, doc-ignored, JSON-tag-ignored). +// +// The JSON-tag-ignored case carries a side effect: it removes from +// target.Properties any entry whose owner Go name matches the current +// field's Go name — an embed-side property re-declared with +// `json:"-"` wins over the inherited one. +func (s *Builder) structFieldCarrier(fld *types.Var, decl *scanner.EntityDecl, target *oaispec.Schema, nameByJSON map[string]propOwner) (fieldCarrier, bool, error) { + if fld.Embedded() || !fld.Exported() { + return fieldCarrier{}, false, nil + } + + afld := resolvers.FindASTField(decl.File, fld.Pos()) + if afld == nil { + return fieldCarrier{}, false, nil + } + + fd := s.scanFieldDoc(afld) + if fd.Ignored { + return fieldCarrier{}, false, nil + } + + name, ignore, isString, omitEmpty, err := resolvers.ParseJSONTag(afld) + if err != nil { + return fieldCarrier{}, false, err + } + if ignore { + for jsonName, prior := range nameByJSON { + if prior.goName == fld.Name() { + delete(target.Properties, jsonName) + break + } + } + return fieldCarrier{}, false, nil + } + + return fieldCarrier{ + name: name, + goName: fld.Name(), + propType: fld.Type(), + afld: afld, + fd: fd, + isString: isString, + omitEmpty: omitEmpty, + }, true, nil +} + +// methodCarrier produces the carrier for an interface method, or +// returns ok=false when the method must be skipped silently +// (unexported, not a parameterless single-result signature, no AST, +// doc-ignored). +func (s *Builder) methodCarrier(fld *types.Func, decl *scanner.EntityDecl) (fieldCarrier, bool) { + if !fld.Exported() { + return fieldCarrier{}, false + } + + sig, isSignature := fld.Type().(*types.Signature) + if !isSignature { + return fieldCarrier{}, false + } + if sig.Params().Len() > 0 { + return fieldCarrier{}, false + } + if sig.Results() == nil || sig.Results().Len() != 1 { + return fieldCarrier{}, false + } + + afld := resolvers.FindASTField(decl.File, fld.Pos()) + if afld == nil { + return fieldCarrier{}, false + } + + fd := s.scanFieldDoc(afld) + if fd.Ignored { + return fieldCarrier{}, false + } + + name := fd.JSONName + if name == "" { + name = s.interfaceJSONName(fld.Name()) + } + + return fieldCarrier{ + name: name, + goName: fld.Name(), + propType: sig.Results().At(0).Type(), + afld: afld, + fd: fd, + }, true +} + +func (s *Builder) applyNullable(target *oaispec.Schema) { + if !s.Ctx.SetXNullableForPointers() { + return + } + + if target.Extensions == nil || (target.Extensions["x-nullable"] == nil && target.Extensions["x-isnullable"] == nil) { + target.AddExtension("x-nullable", true) + } +} + +// interfaceJSONName maps a Go interface-method name to its JSON property name via the Builder's mangler. +func (s *Builder) interfaceJSONName(goName string) string { + return s.methodMangler.ToJSONName(goName) +} diff --git a/internal/builders/schema/interface.go b/internal/builders/schema/interface.go new file mode 100644 index 0000000..5ecd216 --- /dev/null +++ b/internal/builders/schema/interface.go @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/ast" + "go/types" + + "github.com/go-openapi/codescan/internal/ifaces" + "github.com/go-openapi/codescan/internal/scanner" + oaispec "github.com/go-openapi/spec" +) + +func (s *Builder) buildFromInterface(decl *scanner.EntityDecl, it *types.Interface, schema *oaispec.Schema, nameByJSON map[string]propOwner) error { + if it.Empty() { + // return an empty schema for empty interfaces + return nil + } + + var ( + target *oaispec.Schema + hasAllOf bool + ) + + var flist []*ast.Field + if specType, ok := decl.Spec.Type.(*ast.InterfaceType); ok { + flist = make([]*ast.Field, it.NumEmbeddeds()+it.NumExplicitMethods()) + copy(flist, specType.Methods.List) + } + + // First collect the embedded interfaces + // create refs when: + // + // 1. the embedded interface is decorated with an allOf annotation + // 2. the embedded interface is an alias + for fld := range it.EmbeddedTypes() { + if target == nil { + target = &oaispec.Schema{} + } + + fieldHasAllOf, err := s.processEmbeddedType(fld, flist, decl, schema, nameByJSON) + if err != nil { + return err + } + hasAllOf = hasAllOf || fieldHasAllOf + } + + if target == nil { + target = schema + } + + // We can finally build the actual schema for the struct + if target.Properties == nil { + target.Properties = make(map[string]oaispec.Schema) + } + target.Typed("object", "") + + for fld := range it.ExplicitMethods() { + if err := s.processInterfaceMethod(fld, decl, target, nameByJSON); err != nil { + return err + } + } + + if target == nil { + return nil + } + if hasAllOf && len(target.Properties) > 0 { + schema.AllOf = append(schema.AllOf, *target) + } + + return nil +} + +func (s *Builder) processInterfaceMethod(fld *types.Func, decl *scanner.EntityDecl, target *oaispec.Schema, nameByJSON map[string]propOwner) error { + c, ok := s.methodCarrier(fld, decl) + if !ok { + return nil + } + return s.applyFieldCarrier(c, target, nameByJSON) +} + +func (s *Builder) buildNamedInterface( + ftpe *types.Named, flist []*ast.Field, decl *scanner.EntityDecl, schema *oaispec.Schema, nameByJSON map[string]propOwner, +) (hasAllOf bool, err error) { + o := ftpe.Obj() + var afld *ast.Field + + for _, an := range flist { + if len(an.Names) != 0 { + continue + } + + tpp := decl.Pkg.TypesInfo.Types[an.Type] + if tpp.Type.String() != o.Type().String() { + continue + } + + // decl. + afld = an + break + } + + if afld == nil { + return hasAllOf, nil + } + + fd := s.scanFieldDoc(afld) + if fd.Ignored { + return hasAllOf, nil + } + + if !fd.IsAllOfMember { + var newSch oaispec.Schema + if err = s.buildEmbedded(o.Type(), &newSch, nameByJSON); err != nil { + return hasAllOf, err + } + schema.AllOf = append(schema.AllOf, newSch) + hasAllOf = true + + return hasAllOf, nil + } + + hasAllOf = true + + var newSch oaispec.Schema + // when the embedded struct is annotated with swagger:allOf it will be used as allOf property + // otherwise the fields will just be included as normal properties + if err = s.buildAllOf(o.Type(), &newSch); err != nil { + return hasAllOf, err + } + + if fd.AllOfClass != "" { + schema.AddExtension("x-class", fd.AllOfClass) + } + + schema.AllOf = append(schema.AllOf, newSch) + + return hasAllOf, nil +} + +func (s *Builder) buildAnonymousInterface(it *types.Interface, target ifaces.SwaggerTypable, decl *scanner.EntityDecl) error { + target.Typed("object", "") + + for fld := range it.ExplicitMethods() { + if err := s.processAnonInterfaceMethod(fld, decl, target.Schema()); err != nil { + return err + } + } + + return nil +} + +func (s *Builder) processAnonInterfaceMethod(fld *types.Func, decl *scanner.EntityDecl, schema *oaispec.Schema) error { + c, ok := s.methodCarrier(fld, decl) + if !ok { + return nil + } + return s.applyFieldCarrier(c, schema, nil) +} diff --git a/internal/builders/schema/options.go b/internal/builders/schema/options.go new file mode 100644 index 0000000..1070c7c --- /dev/null +++ b/internal/builders/schema/options.go @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/types" + + "github.com/go-openapi/codescan/internal/ifaces" + oaispec "github.com/go-openapi/spec" +) + +// Option to build a schema. +type Option func(*options) + +type options struct { + definitions map[string]oaispec.Schema + inputType types.Type + target ifaces.SwaggerTypable + simpleSchema bool + paramIn string +} + +// WithDefinitions selects the "definitions" Build mode. The builder +// emits the top-level schema for the bound EntityDecl into the +// supplied map keyed by `s.Name`. +func WithDefinitions(definitions map[string]oaispec.Schema) Option { + return func(o *options) { + o.definitions = definitions + } +} + +// WithType selects the "typed target" Build mode (full Schema). The +// builder writes the schema for tpe into the caller-owned target. +// Used for body parameters, response bodies, and any other site that +// produces a full OAS v2 Schema. +func WithType(tpe types.Type, tgt ifaces.SwaggerTypable) Option { + return func(o *options) { + o.inputType = tpe + o.target = tgt + } +} + +// WithSimpleSchema selects the SimpleSchema Build mode for OAS v2 +// parameter / response-header sites where `in` is not `body`. tpe +// is the Go type; tgt is the caller-owned SimpleSchema-shaped +// target (typically paramTypable or headerTypable); in carries the +// parameter location string ("query" / "path" / "header" / +// "formData", or empty for response headers). +// +// # Details +// +// See [§simple-schema-mode](./README.md#simple-schema-mode) — the +// allowed keyword surface, the catch-at-exit contract, the +// SimpleSchemaProbe interface, and the rules that drive the +// file/allowEmptyValue special cases. +func WithSimpleSchema(tpe types.Type, tgt ifaces.SwaggerTypable, in string) Option { + return func(o *options) { + o.inputType = tpe + o.target = tgt + o.simpleSchema = true + o.paramIn = in + } +} + +// OptionFor picks the right Build mode based on the typable's +// location: WithType when the target is a body schema, WithSimpleSchema +// otherwise. The parameters and responses builders both call this +// at every field-build site; the body / non-body split is the +// single discriminator they rely on. +// +// Centralised here so the dispatch is uniform — adding a third mode +// or refining the gate becomes a one-place edit. +func OptionFor(tpe types.Type, tgt ifaces.SwaggerTypable) Option { + if tgt.In() == "body" { + return WithType(tpe, tgt) + } + return WithSimpleSchema(tpe, tgt, tgt.In()) +} + +func optionsWithDefaults(opts []Option) options { + var o options + + for _, apply := range opts { + apply(&o) + } + + return o +} diff --git a/internal/builders/schema/parsing_stuff.go b/internal/builders/schema/parsing_stuff.go new file mode 100644 index 0000000..877cbe3 --- /dev/null +++ b/internal/builders/schema/parsing_stuff.go @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/ast" + + "github.com/go-openapi/codescan/internal/parsers/grammar" +) + +func (s *Builder) InferNames() { + s.inferNames() +} + +// findAnnotation returns the first Block in the ParseBlocks slice +// matching kind, or nil. Used by discovery sites (e.g. inferNames) +// where the relevant annotation may not be the first in source +// order — multi-annotation comments like +// `swagger:type … / swagger:model objectStruct` need ParseAll's +// per-annotation Block to surface the model override. +// +//nolint:ireturn // Block interface is the documented return type. +func (s *Builder) findAnnotation(cg *ast.CommentGroup, kind grammar.AnnotationKind) grammar.Block { + for _, b := range s.ParseBlocks(cg) { + if b.AnnotationKind() == kind { + return b + } + } + return nil +} + +func (s *Builder) inferNames() { + if s.GoName != "" { + return + } + + goName := s.Decl.Ident.Name + s.GoName = goName + s.Name = goName + + // Read the model annotation directly off the cached parsed + // Blocks. findAnnotation walks every annotation in the comment + // group, so multi-annotation comments (e.g. `swagger:type` + + // `swagger:model objectStruct`) still surface the model + // override regardless of source order. AnnotationArg() carries + // the IDENT_NAME when one was given; bare `swagger:model` + // keeps the Go identifier as the schema name. + model := s.findAnnotation(s.Decl.Comments, grammar.AnnModel) + if model == nil { + return + } + + s.annotated = true + if override, ok := model.AnnotationArg(); ok { + s.Name = override + } +} diff --git a/internal/builders/schema/ref.go b/internal/builders/schema/ref.go new file mode 100644 index 0000000..77d75ed --- /dev/null +++ b/internal/builders/schema/ref.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/types" + + "github.com/go-openapi/codescan/internal/ifaces" +) + +// resolveRefOr looks tio up in the model index. On hit, emits a +// $ref to it via the inherited MakeRef. On miss, runs orElse (or +// returns nil when orElse is nil). Used by every named-shape leaf +// that follows the "FindModel → MakeRef, else fallback" pattern. +func (s *Builder) resolveRefOr(tio *types.TypeName, tgt ifaces.SwaggerTypable, orElse func() error) error { + if decl, ok := s.Ctx.GetModel(tio.Pkg().Path(), tio.Name()); ok { + return s.MakeRef(decl, tgt) + } + if orElse == nil { + return nil + } + return orElse() +} + +// resolveRefOrErr is the strict counterpart of resolveRefOr: +// the FindModel miss is treated as a missingSource error rather +// than a silent fallback. errTpe is the type to format into the +// error message (typically the underlying 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) + } + return missingSource(errTpe) +} diff --git a/internal/builders/schema/schema.go b/internal/builders/schema/schema.go index 0a5002d..388e966 100644 --- a/internal/builders/schema/schema.go +++ b/internal/builders/schema/schema.go @@ -4,1446 +4,443 @@ package schema import ( - "encoding/json" "fmt" "go/ast" "go/types" - "log" - "reflect" - "strings" + "log/slog" "github.com/go-openapi/swag/mangling" - "golang.org/x/tools/go/packages" + "github.com/go-openapi/codescan/internal/builders/common" "github.com/go-openapi/codescan/internal/builders/resolvers" "github.com/go-openapi/codescan/internal/ifaces" - "github.com/go-openapi/codescan/internal/logger" - "github.com/go-openapi/codescan/internal/parsers" "github.com/go-openapi/codescan/internal/scanner" oaispec "github.com/go-openapi/spec" ) +// Builder knows how to build a spec schema from go source. +// +// # Details +// +// See the maintainer notes in README.md (sibling file). type Builder struct { - ctx *scanner.ScanCtx - decl *scanner.EntityDecl - GoName string - Name string - annotated bool - discovered []*scanner.EntityDecl - postDecls []*scanner.EntityDecl + options + *common.Builder - // interfaceMethodMangler produces JSON-style property names from Go - // interface-method names. Interface methods cannot carry struct tags, so - // codescan can't read a per-field convention — instead it applies the - // same transform go-swagger uses for tag-less struct fields (acronym-aware - // lower-first, e.g. `CreatedAt → createdAt`, `ID → id`, - // `ExternalID → externalId`). `swagger:name` still takes precedence when - // present. NameMangler is thread-safe per its godoc. - // - // Pointer so that the zero value (nil) is safely detected and lazily - // initialized by interfaceJSONName — a zero mangling.NameMangler value - // panics on use, and tests that construct &Builder{…} directly bypass - // NewBuilder. - interfaceMethodMangler *mangling.NameMangler + GoName string + Name string + annotated bool + discovered []*scanner.EntityDecl + methodMangler mangling.NameMangler // see [§method-mangler](./README.md#method-mangler). + skipExtensions bool + + // Embed-recursion depth for ambiguous-embed diagnostics; + // see [§embed-depth](./README.md#embed-depth). + embedDepth int } +// NewBuilder constructs an initialized [Builder]. func NewBuilder(ctx *scanner.ScanCtx, decl *scanner.EntityDecl) *Builder { - m := mangling.NewNameMangler() return &Builder{ - ctx: ctx, - decl: decl, - interfaceMethodMangler: &m, + Builder: common.New(ctx, decl), + methodMangler: mangling.NewNameMangler(), + skipExtensions: ctx.SkipExtensions(), } } -func (s *Builder) Build(definitions map[string]oaispec.Schema) error { - s.inferNames() - - schema := definitions[s.Name] - err := s.buildFromDecl(s.decl, &schema) - if err != nil { - return err +// Build a schema spec. +// +// The output is either stored in the passed definitions map (when used with [WithDefinitions]), +// or in the target renderer (when used with [WithType]). +func (s *Builder) Build(opts ...Option) error { + s.options = optionsWithDefaults(opts) // set options for this build + if s.definitions == nil && s.inputType == nil || s.definitions != nil && s.inputType != nil { + panic("dev error: Build must be called with either a map of definitions or a single type") } - definitions[s.Name] = schema - - return nil -} - -func (s *Builder) SetDiscovered(discovered []*scanner.EntityDecl) { - s.discovered = discovered -} -func (s *Builder) PostDeclarations() []*scanner.EntityDecl { - return s.postDecls -} + if s.definitions != nil { + // top-level declarations + s.inferNames() -func (s *Builder) InferNames() { - s.inferNames() -} + schema := s.definitions[s.Name] // if not named, empty schema + err := s.buildFromDecl(&schema) + if err != nil { + return err + } -func (s *Builder) BuildFromType(tpe types.Type, tgt ifaces.SwaggerTypable) error { - return s.buildFromType(tpe, tgt) -} + s.definitions[s.Name] = schema -func (s *Builder) inferNames() { - if s.GoName != "" { - return + return nil } - goName := s.decl.Ident.Name - s.GoName = goName - s.Name = goName - - override, ok := parsers.ModelOverride(s.decl.Comments) - if !ok { - return + // build from parameter/response schema + if err := s.buildFromType(s.inputType, s.target); err != nil { + return err } - s.annotated = true - // Why: ModelOverride returns ("", true) for a bare `swagger:model` annotation - // without a name — in that case the Go identifier is the model name. - if override != "" { - s.Name = override + if s.simpleSchema { + s.validateSimpleSchemaOutcome() } + return nil } -// interfaceJSONName maps a Go interface-method name to its JSON property -// name via the Builder's mangler, lazily initializing the mangler on first -// use so a zero-value Builder remains usable. -func (s *Builder) interfaceJSONName(goName string) string { - if s.interfaceMethodMangler == nil { - m := mangling.NewNameMangler() - s.interfaceMethodMangler = &m - } - return s.interfaceMethodMangler.ToJSONName(goName) +func (s *Builder) SetDiscovered(discovered []*scanner.EntityDecl) { + s.discovered = discovered } -func (s *Builder) buildFromDecl(_ *scanner.EntityDecl, schema *oaispec.Schema) error { - // analyze doc comment for the model - // This includes parsing "example", "default" and other validation at the top-level declaration. - sp := s.createParser("", schema, schema, nil, - parsers.WithSetTitle(func(lines []string) { schema.Title = parsers.JoinDropLast(lines) }), - parsers.WithSetDescription(func(lines []string) { - schema.Description = parsers.JoinDropLast(lines) - enumDesc := parsers.GetEnumDesc(schema.Extensions) - if enumDesc != "" { - schema.Description += "\n" + enumDesc - } - }), - ) - - if err := sp.Parse(s.decl.Comments); err != nil { - return err +// buildFromDecl emits the schema for a top-level type declaration +// (named or alias). +// +// # Details +// +// See [§build-entry](./README.md#build-entry), [§special-types](./README.md#special-types) +// and [§user-overrides](./README.md#user-overrides). +func (s *Builder) buildFromDecl(schema *oaispec.Schema) error { + if s.applyDeclCommentBlock(schema) { + // swagger:ignore short-circuit. + return nil } + defer s.annotateSchema(schema)() - // if the type is marked to ignore, just return - if sp.Ignored() { + // Intercept stdlib specials before kind-dispatch (catches stdlib + // decls pulled in via the discovery chain). See + // [§special-types](./README.md#special-types). + ps := NewTypable(schema, 0, s.skipExtensions) + if applyStdlibSpecials(s.Decl.Obj(), ps, s.skipExtensions) { return nil } - defer func() { - if schema.Ref.String() == "" { - // unless this is a $ref, we add traceability of the origin of this schema in source - if s.Name != s.GoName { - resolvers.AddExtension(&schema.VendorExtensible, "x-go-name", s.GoName, s.ctx.SkipExtensions()) - } - resolvers.AddExtension(&schema.VendorExtensible, "x-go-package", s.decl.Obj().Pkg().Path(), s.ctx.SkipExtensions()) + // Decl-site swagger:type override wins over type-driven default. + // See [§user-overrides](./README.md#user-overrides) for the + // (handled, recurse) contract and the Underlying() fallback rationale. + if handled, recurse := s.classifierNamedTypeOverride(s.Decl.Comments, ps); handled { + if !recurse { + return nil } - }() + if named, ok := s.Decl.ObjType().(*types.Named); ok { + return s.buildFromType(named.Underlying(), ps) + } + } - switch tpe := s.decl.ObjType().(type) { + switch tpe := s.Decl.ObjType().(type) { case *types.Named: - logger.DebugLogf(s.ctx.Debug(), "named: %v", tpe) - return s.buildDeclNamed(tpe, schema) + if s.guardDecl(tpe) { + return nil + } + ti := s.Decl.Pkg.TypesInfo.Types[s.Decl.Spec.Type] + resolvers.MustBeAType(ti) // invariant + return s.buildFromType(ti.Type, NewTypable(schema, 0, s.skipExtensions)) case *types.Alias: - logger.DebugLogf(s.ctx.Debug(), "alias: %v -> %v", tpe, tpe.Rhs()) - tgt := Typable{schema, 0, s.ctx.SkipExtensions()} - - return s.buildDeclAlias(tpe, tgt) + if s.guardDecl(tpe) { + return nil + } + return s.buildDeclAlias(tpe, NewTypable(schema, 0, s.skipExtensions)) default: - logger.UnsupportedTypeKind("buildFromDecl", tpe) - return nil - } -} - -func (s *Builder) buildDeclNamed(tpe *types.Named, schema *oaispec.Schema) error { - if resolvers.UnsupportedBuiltin(tpe) { - log.Printf("WARNING: skipped unsupported builtin type: %v", tpe) - - return nil - } - o := tpe.Obj() - - resolvers.MustNotBeABuiltinType(o) - - logger.DebugLogf(s.ctx.Debug(), "got the named type object: %s.%s | isAlias: %t | exported: %t", o.Pkg().Path(), o.Name(), o.IsAlias(), o.Exported()) - if resolvers.IsStdTime(o) { - schema.Typed("string", "date-time") + s.Warn("unsupported Go type. Skipping", slog.Any("type", tpe)) return nil } - - ps := Typable{schema, 0, s.ctx.SkipExtensions()} - ti := s.decl.Pkg.TypesInfo.Types[s.decl.Spec.Type] - if !ti.IsType() { - return fmt.Errorf("declaration is not a type: %v: %w", o, ErrSchema) - } - - return s.buildFromType(ti.Type, ps) } -// buildFromTextMarshal renders a type that marshals as text as a string. -func (s *Builder) buildFromTextMarshal(tpe types.Type, tgt ifaces.SwaggerTypable) error { - if typePtr, ok := tpe.(*types.Pointer); ok { - return s.buildFromTextMarshal(typePtr.Elem(), tgt) - } - - // An alias surfaced under a pointer (e.g. *Timestamp where - // Timestamp = time.Time) — route through buildAlias so the alias - // indirection is honored per RefAliases/TransparentAliases, same as - // the non-pointer path in buildFromType. - if typeAlias, ok := tpe.(*types.Alias); ok { - return s.buildAlias(typeAlias, tgt) - } - - typeNamed, ok := tpe.(*types.Named) - if !ok { - tgt.Typed("string", "") - return nil - } - - tio := typeNamed.Obj() - if resolvers.IsStdError(tio) { - tgt.AddExtension("x-go-type", tio.Name()) - return resolvers.SwaggerSchemaForType(tio.Name(), tgt) - } +// buildDeclAlias emits the schema for a top-level alias declaration. +// Three modes — Dissolve / $ref / Expand — selected by TransparentAliases × RefAliases. +// +// # Details +// +// See [§aliases](./README.md#aliases) and [§discovery](./README.md#discovery). +func (s *Builder) buildDeclAlias(tpe *types.Alias, target ifaces.SwaggerTypable) error { + resolvers.MustHaveRightHandSide(tpe) - logger.DebugLogf(s.ctx.Debug(), "named refined type %s.%s", tio.Pkg().Path(), tio.Name()) - pkg, found := s.ctx.PkgForType(tpe) + rhs := tpe.Rhs() - if strings.ToLower(tio.Name()) == "uuid" { - tgt.Typed("string", "uuid") - return nil + // Dissolve: no LHS definition, build directly from the RHS. + if s.Ctx.TransparentAliases() { + return s.buildFromType(rhs, target) } - if !found { - // this must be a builtin - logger.DebugLogf(s.ctx.Debug(), "skipping because package is nil: %v", tpe) - return nil - } + // Register the LHS so the spec's definitions section includes it even when not swagger:model-annotated. + // s.Decl IS the alias's LHS decl by construction (see [§discovery](./README.md#discovery) + // for why we skip the GetModel round-trip here). + s.Ctx.AddDiscoveredModel(s.Decl) + s.AppendPostDecl(s.Decl) - if resolvers.IsStdTime(tio) { - tgt.Typed("string", "date-time") - return nil + // Expand: LHS gets a structural definition mirroring the + // underlying. tpe.Rhs() peels one alias layer; tpe.Underlying() + // peels through aliases AND named types to the structural form. + if !s.Ctx.RefAliases() { + return s.buildFromType(tpe.Underlying(), target) } - if resolvers.IsStdJSONRawMessage(tio) { - tgt.Typed("object", "") // TODO: this should actually be any type - return nil - } + // $ref to the RHS's target. + switch rtpe := rhs.(type) { + case *types.Named: + ro := rtpe.Obj() + rdecl, found := s.Ctx.GetModel(ro.Pkg().Path(), ro.Name()) + if !found { + return missingSource(rtpe) + } - cmt, hasComments := s.ctx.FindComments(pkg, tio.Name()) - if !hasComments { - cmt = new(ast.CommentGroup) - } + return s.MakeRef(rdecl, target) + case *types.Alias: + ro := rtpe.Obj() + if resolvers.UnsupportedBuiltin(rtpe) { + s.Warn("skipped unsupported builtin", slog.Any("type", rtpe)) - if sfnm, isf := parsers.StrfmtName(cmt); isf { - tgt.Typed("string", sfnm) - return nil - } + return nil + } - tgt.Typed("string", "") - tgt.AddExtension("x-go-type", tio.Pkg().Path()+"."+tio.Name()) + if applyStdlibSpecials(ro, target, s.skipExtensions) { + return nil + } - return nil -} + resolvers.MustNotBeABuiltinType(ro) -// hasNamedCore reports whether tpe is a *types.Named, or resolves to one -// by peeling one or more pointer layers. Used to gate content-based -// shortcuts (like the TextMarshaler check) to types whose name can be -// inspected — anonymous structural kinds cannot yield meaningful output -// from those shortcuts and should take the structural dispatch instead. -func hasNamedCore(tpe types.Type) bool { - for { - switch t := tpe.(type) { - case *types.Named: - return true - case *types.Pointer: - tpe = t.Elem() - default: - return false + rdecl, found := s.Ctx.GetModel(ro.Pkg().Path(), ro.Name()) + if !found { + return missingSource(rtpe) } + + return s.MakeRef(rdecl, target) + default: // alias to anonymous type + return s.buildFromType(rhs, target) } } -func (s *Builder) buildFromType(tpe types.Type, tgt ifaces.SwaggerTypable) error { - logger.DebugLogf(s.ctx.Debug(), "schema buildFromType %v (%T)", tpe, tpe) - - // Aliases are dispatched first, before any content-based shortcut, - // so the alias indirection is honored consistently with the caller's - // RefAliases/TransparentAliases intent. Without this, a text- - // marshalable alias (e.g. `type Timestamp = time.Time`) would be - // inlined as a plain string — losing both the alias semantics and - // (because buildFromTextMarshal only unwraps pointers) the target's - // format. +// buildFromType emits the schema for an arbitrary Go type into target. +// +// # Details +// +// See [§dispatch-table](./README.md#dispatch-table) for the named-type pivot; +// [§textmarshal-order](./README.md#textmarshal-order) for the TextMarshaler shortcut. +func (s *Builder) buildFromType(tpe types.Type, target ifaces.SwaggerTypable) error { + // Aliases first — honour RefAliases / TransparentAliases before + // any content-based shortcut (a TextMarshaler-implementing alias + // otherwise gets inlined and loses its alias indirection). if titpe, ok := tpe.(*types.Alias); ok { - logger.DebugLogf(s.ctx.Debug(), "alias(schema.buildFromType): got alias %v to %v", titpe, titpe.Rhs()) - return s.buildAlias(titpe, tgt) + return s.buildAlias(titpe, target) } - // Only shortcut to the TextMarshaler renderer when we can reach a - // *types.Named by peeling pointers — buildFromTextMarshal uses the - // name to map to known formats (time/uuid/json.RawMessage/strfmt) and - // falls back to {string, ""} otherwise. An anonymous struct that only - // satisfies TextMarshaler by embedding time.Time (method promotion) - // would otherwise be flattened to {string}, erasing its body and any - // allOf composition. See Q4 in .claude/plans/observed-quirks.md. + // TextMarshaler shortcut: only when reachable to a *types.Named by peeling pointers. + // Anonymous structs that merely satisfy TextMarshaler via embedded-method promotion don't qualify — + // they need the structural walk to preserve body / allOf shape. if hasNamedCore(tpe) && resolvers.IsTextMarshaler(tpe) { - return s.buildFromTextMarshal(tpe, tgt) + return s.buildFromTextMarshal(tpe, target) } switch titpe := tpe.(type) { case *types.Basic: if resolvers.UnsupportedBuiltinType(titpe) { - log.Printf("WARNING: skipped unsupported builtin type: %v", tpe) + s.Warn("skipped unsupported builtin", slog.Any("type", tpe)) return nil } - return resolvers.SwaggerSchemaForType(titpe.String(), tgt) + return resolvers.SwaggerSchemaForType(titpe.String(), target) case *types.Pointer: - return s.buildFromType(titpe.Elem(), tgt) + return s.buildFromType(titpe.Elem(), target) case *types.Struct: - return s.buildFromStruct(s.decl, titpe, tgt.Schema(), make(map[string]string)) + return s.buildFromStruct(s.Decl, titpe, target.Schema(), make(map[string]propOwner)) case *types.Interface: - return s.buildFromInterface(s.decl, titpe, tgt.Schema(), make(map[string]string)) + return s.buildFromInterface(s.Decl, titpe, target.Schema(), make(map[string]propOwner)) case *types.Slice: - // anonymous slice - return s.buildFromType(titpe.Elem(), tgt.Items()) + return s.buildFromType(titpe.Elem(), target.Items()) case *types.Array: - // anonymous array - return s.buildFromType(titpe.Elem(), tgt.Items()) + return s.buildFromType(titpe.Elem(), target.Items()) case *types.Map: - return s.buildFromMap(titpe, tgt) + return s.buildFromMap(titpe, target) case *types.Named: - // a named type, e.g. type X struct {} - return s.buildNamedType(titpe, tgt) + return s.buildNamedType(titpe, target) default: - // Warn-and-skip for unsupported kinds (TypeParam, Chan, Signature, - // Union, or future go/types additions). The scanner runs on user - // code in uncontrolled environments, so panicking here would be a - // worse experience than producing a partial spec. - logger.UnsupportedTypeKind("buildFromType", titpe) + // Unknown kind (TypeParam, Chan, Signature, Union, future additions). + // Warn-and-skip — panicking would degrade UX on user code we can't inspect. + s.Warn("unsupported Go type. Skipping", slog.Any("type", tpe)) return nil } } -func (s *Builder) buildNamedType(titpe *types.Named, tgt ifaces.SwaggerTypable) error { - tio := titpe.Obj() - if resolvers.UnsupportedBuiltin(titpe) { - log.Printf("WARNING: skipped unsupported builtin type: %v", titpe) +// buildAlias emits a schema for an alias reached as a field/element type (not a top-level decl). +// +// TransparentAliases dissolves the alias indirection; otherwise emit a $ref. +// +// # Details +// +// See [§aliases](./README.md#aliases). +func (s *Builder) buildAlias(tpe *types.Alias, target ifaces.SwaggerTypable) error { + if resolvers.UnsupportedBuiltinType(tpe) { + s.Warn("skipped unsupported builtin", slog.Any("type", tpe)) return nil } - if resolvers.IsAny(tio) { - // e.g type X any or type X interface{} - _ = tgt.Schema() - + o := tpe.Obj() + if applyStdlibSpecials(o, target, s.skipExtensions) { return nil } + resolvers.MustNotBeABuiltinType(o) + + if s.Ctx.TransparentAliases() { + return s.buildFromType(tpe.Rhs(), target) + } - // special case of the "error" interface. - if resolvers.IsStdError(tio) { - tgt.AddExtension("x-go-type", tio.Name()) - return resolvers.SwaggerSchemaForType(tio.Name(), tgt) + decl, ok := s.Ctx.GetModel(o.Pkg().Path(), o.Name()) + if !ok { + return fmt.Errorf("can't find source file for aliased type: %v: %w", tpe, ErrSchema) } + return s.MakeRef(decl, target) +} + +// buildNamedType emits the schema for a *types.Named reached as a field/element/embed type. +// +// # Details +// +// See [§dispatch-table](./README.md#dispatch-table) and [§dissolve-named](./README.md#dissolve-named). +func (s *Builder) buildNamedType(titpe *types.Named, target ifaces.SwaggerTypable) error { + if resolvers.UnsupportedBuiltin(titpe) { + s.Warn("skipped unsupported builtin", slog.Any("type", titpe)) - // special case of the "time.Time" type - if resolvers.IsStdTime(tio) { - tgt.Typed("string", "date-time") return nil } - // special case of the "json.RawMessage" type - if resolvers.IsStdJSONRawMessage(tio) { - tgt.Typed("object", "") // TODO: this should actually be any type + tio := titpe.Obj() + if applyStdlibSpecials(tio, target, s.skipExtensions) { return nil } - pkg, found := s.ctx.PkgForType(titpe) - logger.DebugLogf(s.ctx.Debug(), "named refined type %s.%s", pkg, tio.Name()) + // PkgForType-miss catches types we can't anchor to a scanned package + // (predeclared `comparable`, generic type params, compiler-internal shapes). + // + // Complementary to the UnsupportedBuiltin guard above; also yields `pkg` for the Basic classifier below. + pkg, found := s.Ctx.PkgForType(titpe) if !found { - // this must be a builtin - // - // This could happen for example when using unsupported types such as complex64, complex128, uintptr, - // or type constraints such as comparable. - logger.DebugLogf(s.ctx.Debug(), "skipping because package is nil (builtin type): %v", tio) - return nil } - cmt, hasComments := s.ctx.FindComments(pkg, tio.Name()) - if !hasComments { - cmt = new(ast.CommentGroup) + var cmt *ast.CommentGroup + if decl, ok := s.Ctx.DeclForType(titpe); ok && decl != nil { + cmt = decl.Comments } - if tn, ok := parsers.TypeName(cmt); ok { - if err := resolvers.SwaggerSchemaForType(tn, tgt); err == nil { - return nil + if handled, recurse := s.classifierNamedTypeOverride(cmt, target); handled { + if recurse { + return s.buildFromType(titpe.Underlying(), target) } - // For unsupported swagger:type values (e.g., "array"), fall through - // to underlying type resolution so the full schema (including items - // for slices) is properly built. Build directly from the underlying - // type to bypass the named-type $ref creation. - return s.buildFromType(titpe.Underlying(), tgt) - } - - if s.decl.Spec.Assign.IsValid() { - logger.DebugLogf(s.ctx.Debug(), "found assignment: %s.%s", tio.Pkg().Path(), tio.Name()) - return s.buildFromType(titpe.Underlying(), tgt) + return nil } - if titpe.TypeArgs() != nil && titpe.TypeArgs().Len() > 0 { - return s.buildFromType(titpe.Underlying(), tgt) + // Dissolve when there's no source-level TypeSpec to $ref: + // - outer decl is alias-syntax (TransparentAliases plumbing); + // - generic instantiation (Underlying carries substituted args). + // + // See [§dissolve-named](./README.md#dissolve-named). + if s.Decl.Spec.Assign.IsValid() || (titpe.TypeArgs() != nil && titpe.TypeArgs().Len() > 0) { + return s.buildFromType(titpe.Underlying(), target) } - // invariant: the Underlying cannot be an alias or named type + // Underlying-shape table. See [§dispatch-table](./README.md#dispatch-table). switch utitpe := titpe.Underlying().(type) { case *types.Struct: - return s.buildNamedStruct(tio, cmt, tgt) + if s.classifierNamedStructStrfmt(cmt, target) { + return nil + } + return s.resolveRefOr(tio, target, nil) + case *types.Interface: - logger.DebugLogf(s.ctx.Debug(), "found interface: %s.%s", tio.Pkg().Path(), tio.Name()) + return s.resolveRefOrErr(tio, target, utitpe) - decl, found := s.ctx.FindModel(tio.Pkg().Path(), tio.Name()) - if !found { - return fmt.Errorf("can't find source file for type: %v: %w", utitpe, ErrSchema) + case *types.Basic: + if resolvers.UnsupportedBuiltinType(utitpe) { + s.Warn("skipped unsupported builtin", slog.Any("type", tio)) + return nil + } + if s.classifierNamedBasic(cmt, pkg, utitpe, target) { + return nil } + return s.resolveRefOr(tio, target, func() error { + return resolvers.SwaggerSchemaForType(utitpe.String(), target) + }) - return s.makeRef(decl, tgt) - case *types.Basic: - return s.buildNamedBasic(tio, pkg, cmt, utitpe, tgt) case *types.Array: - return s.buildNamedArray(tio, cmt, utitpe.Elem(), tgt) + return s.buildNamedArrayLike(tio, cmt, utitpe.Elem(), target, false) case *types.Slice: - return s.buildNamedSlice(tio, cmt, utitpe.Elem(), tgt) + return s.buildNamedArrayLike(tio, cmt, utitpe.Elem(), target, true) + case *types.Map: - logger.DebugLogf(s.ctx.Debug(), "found map type: %s.%s", tio.Pkg().Path(), tio.Name()) + return s.resolveRefOr(tio, target, nil) - if decl, ok := s.ctx.FindModel(tio.Pkg().Path(), tio.Name()); ok { - return s.makeRef(decl, tgt) - } - return nil default: - logger.UnsupportedTypeKind("buildNamedType", utitpe) + s.Warn("unsupported Go type. Skipping", slog.Any("type", utitpe)) return nil } } -func (s *Builder) buildNamedBasic(tio *types.TypeName, pkg *packages.Package, cmt *ast.CommentGroup, utitpe *types.Basic, tgt ifaces.SwaggerTypable) error { - if resolvers.UnsupportedBuiltinType(utitpe) { - log.Printf("WARNING: skipped unsupported builtin type: %v", utitpe) - return nil - } - - logger.DebugLogf(s.ctx.Debug(), "found primitive type: %s.%s", tio.Pkg().Path(), tio.Name()) - - if sfnm, isf := parsers.StrfmtName(cmt); isf { - tgt.Typed("string", sfnm) - return nil - } - - if enumName, ok := parsers.EnumName(cmt); ok { - enumValues, enumDesces, _ := s.ctx.FindEnumValues(pkg, enumName) - if len(enumValues) > 0 { - tgt.WithEnum(enumValues...) - enumTypeName := reflect.TypeOf(enumValues[0]).String() - _ = resolvers.SwaggerSchemaForType(enumTypeName, tgt) - } - - if len(enumDesces) > 0 { - tgt.WithEnumDescription(strings.Join(enumDesces, "\n")) +// buildNamedArrayLike is the unified Array/Slice arm. +// forSlice toggles the slice-only "bsonobjectid" special case in classifierNamedArrayLike. +func (s *Builder) buildNamedArrayLike(tio *types.TypeName, cmt *ast.CommentGroup, elem types.Type, tgt ifaces.SwaggerTypable, forSlice bool) error { + if handled, recurse := s.classifierNamedArrayLike(cmt, tgt, forSlice); handled { + if recurse { + return s.buildFromType(elem, tgt.Items()) } - - return nil - } - - if defaultName, ok := parsers.DefaultName(cmt); ok { - logger.DebugLogf(s.ctx.Debug(), "default name: %s", defaultName) - return nil - } - - if typeName, ok := parsers.TypeName(cmt); ok { - _ = resolvers.SwaggerSchemaForType(typeName, tgt) return nil } - if parsers.IsAliasParam(tgt) || parsers.AliasParam(cmt) { - err := resolvers.SwaggerSchemaForType(utitpe.Name(), tgt) - if err == nil { - return nil - } - } - - if decl, ok := s.ctx.FindModel(tio.Pkg().Path(), tio.Name()); ok { - return s.makeRef(decl, tgt) - } - - return resolvers.SwaggerSchemaForType(utitpe.String(), tgt) + return s.resolveRefOr(tio, tgt, func() error { + return s.buildFromType(elem, tgt.Items()) + }) } -// buildNamedStruct emits a $ref to a named struct definition (or a strfmt -// override when `swagger:strfmt` is set on the type's doc). -// -// Preconditions established by the sole caller buildNamedType and not -// re-checked here: -// - tio is never time.Time — IsStdTime(tio) short-circuits upstream to -// {string, date-time} before the Underlying() switch runs. -// - parsers.TypeName(cmt) is never set — the upstream TypeName branch -// either resolves via SwaggerSchemaForType or delegates to -// buildFromType(Underlying), so neither outcome reaches this function. -// -// Re-adding either check here would be dead code. -func (s *Builder) buildNamedStruct(tio *types.TypeName, cmt *ast.CommentGroup, tgt ifaces.SwaggerTypable) error { - logger.DebugLogf(s.ctx.Debug(), "found struct: %s.%s", tio.Pkg().Path(), tio.Name()) - - // Run strfmt first, before FindModel, so a `swagger:strfmt` type is - // inlined as {string, format} *without* registering the struct in - // ExtraModels — FindModel has a side effect that would otherwise emit - // the struct as an orphan object definition no field references. See - // Q10 in .claude/plans/observed-quirks.md. - // - // A caveat remains: when the author combines `swagger:strfmt` with - // `swagger:model` (a "named strfmt" shape), the field still inlines - // here while the top-level definition body is emitted by walking the - // underlying struct. That inconsistency is documented in - // .claude/plans/deferred-quirks.md and left for v2. - if sfnm, isf := parsers.StrfmtName(cmt); isf { - tgt.Typed("string", sfnm) - return nil +// buildFromMap renders a Go map as a Swagger object with additionalProperties. +// Maps whose key type is neither string nor TextMarshaler are skipped — Swagger object keys are strings. +func (s *Builder) buildFromMap(titpe *types.Map, tgt ifaces.SwaggerTypable) error { + sch := tgt.Schema() + if sch == nil { + return fmt.Errorf("items doesn't support maps: %w", ErrSchema) } - decl, ok := s.ctx.FindModel(tio.Pkg().Path(), tio.Name()) - if !ok { - logger.DebugLogf(s.ctx.Debug(), "could not find model in index: %s.%s", tio.Pkg().Path(), tio.Name()) - return nil + eleProp := NewTypable(sch, tgt.Level(), s.skipExtensions) + key := titpe.Key() + if key.Underlying().String() == "string" || resolvers.IsTextMarshaler(key) { + return s.buildFromType(titpe.Elem(), eleProp.AdditionalProperties()) } - return s.makeRef(decl, tgt) -} - -func (s *Builder) buildNamedArray(tio *types.TypeName, cmt *ast.CommentGroup, elem types.Type, tgt ifaces.SwaggerTypable) error { - logger.DebugLogf(s.ctx.Debug(), "found array type: %s.%s", tio.Pkg().Path(), tio.Name()) - - if sfnm, isf := parsers.StrfmtName(cmt); isf { - if sfnm == "byte" { - tgt.Typed("string", sfnm) - return nil - } - if sfnm == "bsonobjectid" { - tgt.Typed("string", sfnm) - return nil - } - - tgt.Items().Typed("string", sfnm) - return nil - } - // When swagger:type is set to an unsupported value (e.g., "array"), - // skip the $ref and inline the array schema with proper items type. - if tn, ok := parsers.TypeName(cmt); ok { - if err := resolvers.SwaggerSchemaForType(tn, tgt); err != nil { - return s.buildFromType(elem, tgt.Items()) - } - return nil - } - if decl, ok := s.ctx.FindModel(tio.Pkg().Path(), tio.Name()); ok { - return s.makeRef(decl, tgt) - } - return s.buildFromType(elem, tgt.Items()) + return nil } -func (s *Builder) buildNamedSlice(tio *types.TypeName, cmt *ast.CommentGroup, elem types.Type, tgt ifaces.SwaggerTypable) error { - logger.DebugLogf(s.ctx.Debug(), "found slice type: %s.%s", tio.Pkg().Path(), tio.Name()) - - if sfnm, isf := parsers.StrfmtName(cmt); isf { - if sfnm == "byte" { - tgt.Typed("string", sfnm) - return nil +// annotateSchema returns a deferrable that decorates schema with x-go-name / x-go-package traceability extensions, +// unless the schema is just a $ref, in which case the source origin is the target's definition, +// not the reference site. +func (s *Builder) annotateSchema(schema *oaispec.Schema) func() { + return func() { + if schema.Ref.String() != "" { + return } - tgt.Items().Typed("string", sfnm) - return nil - } - // When swagger:type is set to an unsupported value (e.g., "array"), - // skip the $ref and inline the slice schema with proper items type. - // This preserves the field's description that would be lost with $ref. - if tn, ok := parsers.TypeName(cmt); ok { - if err := resolvers.SwaggerSchemaForType(tn, tgt); err != nil { - // Unsupported type name (e.g., "array") — build inline from element type. - return s.buildFromType(elem, tgt.Items()) + if s.Name != s.GoName { + resolvers.AddExtension(&schema.VendorExtensible, "x-go-name", s.GoName, s.skipExtensions) } - return nil + resolvers.AddExtension(&schema.VendorExtensible, "x-go-package", s.Decl.Obj().Pkg().Path(), s.skipExtensions) } - if decl, ok := s.ctx.FindModel(tio.Pkg().Path(), tio.Name()); ok { - return s.makeRef(decl, tgt) - } - return s.buildFromType(elem, tgt.Items()) } -// buildDeclAlias builds a top-level alias declaration. -// -// Note on LHS checks NOT performed here: IsAny(o) / IsStdError(o) / -// IsStdTime(o) on o := tpe.Obj() would all be false. o is the user's -// declared name (e.g. "X" in `type X = any`), so o.Pkg() is always the -// user's package and o.Name() is the user's identifier — neither -// matches the predeclared any/error (Pkg()==nil) nor stdlib time.Time -// (pkg "time", name "Time"). The live equivalents IsAny(ro) / -// IsStdError(ro) inside the `case *types.Alias:` branch of the RHS -// switch below do fire: they inspect the alias target, which for -// `type X = any` resolves to the predeclared any TypeName. -func (s *Builder) buildDeclAlias(tpe *types.Alias, tgt ifaces.SwaggerTypable) error { - if resolvers.UnsupportedBuiltinType(tpe) { - log.Printf("WARNING: skipped unsupported builtin type: %v", tpe) - return nil - } - - o := tpe.Obj() - resolvers.MustNotBeABuiltinType(o) - resolvers.MustHaveRightHandSide(tpe) - rhs := tpe.Rhs() - - // If transparent aliases are enabled, use the underlying type directly without creating a definition - if s.ctx.TransparentAliases() { - return s.buildFromType(rhs, tgt) - } - - decl, ok := s.ctx.FindModel(o.Pkg().Path(), o.Name()) - if !ok { - return fmt.Errorf("can't find source file for aliased type: %v -> %v: %w", tpe, rhs, ErrSchema) - } - - s.postDecls = append(s.postDecls, decl) // mark the left-hand side as discovered - - if !s.ctx.RefAliases() { - // expand alias - return s.buildFromType(tpe.Underlying(), tgt) - } - - // resolve alias to named type as $ref - switch rtpe := rhs.(type) { - // named declarations: we construct a $ref to the right-hand side target of the alias - case *types.Named: - ro := rtpe.Obj() - rdecl, found := s.ctx.FindModel(ro.Pkg().Path(), ro.Name()) - if !found { - return fmt.Errorf("can't find source file for target type of alias: %v -> %v: %w", tpe, rtpe, ErrSchema) - } - - return s.makeRef(rdecl, tgt) - case *types.Alias: - ro := rtpe.Obj() - if resolvers.UnsupportedBuiltin(rtpe) { - log.Printf("WARNING: skipped unsupported builtin type: %v", rtpe) - return nil - } - if resolvers.IsAny(ro) { - // e.g. type X = any - _ = tgt.Schema() // this is mutating tgt to create an empty schema - return nil - } - if resolvers.IsStdError(ro) { - // e.g. type X = error - tgt.AddExtension("x-go-type", o.Name()) - return resolvers.SwaggerSchemaForType(o.Name(), tgt) - } - resolvers.MustNotBeABuiltinType(ro) // TODO(fred): there are a few other cases - - rdecl, found := s.ctx.FindModel(ro.Pkg().Path(), ro.Name()) - if !found { - return fmt.Errorf("can't find source file for target type of alias: %v -> %v: %w", tpe, rtpe, ErrSchema) - } - - return s.makeRef(rdecl, tgt) +// guardDecl filters known-unsupported builtins (unsafe.Pointer) and asserts the scanner-side invariant +// that o.Pkg() is non-nil before downstream code dereferences it. +// Returns true if the caller should skip processing. +func (s *Builder) guardDecl(tpe ifaces.Objecter) (skip bool) { + if resolvers.UnsupportedBuiltin(tpe) { + s.Warn("skipped unsupported builtin", slog.Any("type", tpe)) + return true } - - // alias to anonymous type - return s.buildFromType(rhs, tgt) + resolvers.MustNotBeABuiltinType(tpe.Obj()) // invariant + return false } -func (s *Builder) buildAnonymousInterface(it *types.Interface, tgt ifaces.SwaggerTypable, decl *scanner.EntityDecl) error { - tgt.Typed("object", "") - - for fld := range it.ExplicitMethods() { - if err := s.processAnonInterfaceMethod(fld, it, decl, tgt.Schema()); err != nil { - return err +// hasNamedCore reports whether tpe is, or peels through pointers to reach, +// a *types.Named. Gates content-based checks (notably the TextMarshaler shortcut) +// to types whose name is inspectable. +// Anonymous structural kinds can satisfy TextMarshaler via embedded +// method promotion but must take the structural dispatch instead. +func hasNamedCore(tpe types.Type) bool { + for { + switch t := tpe.(type) { + case *types.Named: + return true + case *types.Pointer: + tpe = t.Elem() + default: + return false } } - - return nil -} - -func (s *Builder) processAnonInterfaceMethod(fld *types.Func, it *types.Interface, decl *scanner.EntityDecl, schema *oaispec.Schema) error { - if !fld.Exported() { - return nil - } - sig, isSignature := fld.Type().(*types.Signature) - if !isSignature { - return nil - } - if sig.Params().Len() > 0 { - return nil - } - if sig.Results() == nil || sig.Results().Len() != 1 { - return nil - } - - afld := resolvers.FindASTField(decl.File, fld.Pos()) - if afld == nil { - logger.DebugLogf(s.ctx.Debug(), "can't find source associated with %s for %s", fld.String(), it.String()) - return nil - } - - if parsers.Ignored(afld.Doc) { - return nil - } - - name, ok := parsers.NameOverride(afld.Doc) - if !ok { - name = s.interfaceJSONName(fld.Name()) - } - - if schema.Properties == nil { - schema.Properties = make(map[string]oaispec.Schema) - } - ps := schema.Properties[name] - if err := s.buildFromType(sig.Results().At(0).Type(), Typable{&ps, 0, s.ctx.SkipExtensions()}); err != nil { - return err - } - if sfName, isStrfmt := parsers.StrfmtName(afld.Doc); isStrfmt { - ps.Typed("string", sfName) - ps.Ref = oaispec.Ref{} - ps.Items = nil - } - - sp := s.createParser(name, schema, &ps, afld) - if err := sp.Parse(afld.Doc); err != nil { - return err - } - - if ps.Ref.String() == "" && name != fld.Name() { - ps.AddExtension("x-go-name", fld.Name()) - } - - if s.ctx.SetXNullableForPointers() { - _, isPointer := fld.Type().(*types.Signature).Results().At(0).Type().(*types.Pointer) - noNullableExt := ps.Extensions == nil || - (ps.Extensions["x-nullable"] == nil && ps.Extensions["x-isnullable"] == nil) - if isPointer && noNullableExt { - ps.AddExtension("x-nullable", true) - } - } - - schema.Properties[name] = ps - return nil -} - -// buildAlias builds a reference to an alias from another type. -func (s *Builder) buildAlias(tpe *types.Alias, tgt ifaces.SwaggerTypable) error { - if resolvers.UnsupportedBuiltinType(tpe) { - log.Printf("WARNING: skipped unsupported builtin type: %v", tpe) - - return nil - } - - o := tpe.Obj() - if resolvers.IsAny(o) { - _ = tgt.Schema() - return nil - } - resolvers.MustNotBeABuiltinType(o) - - // If transparent aliases are enabled, use the underlying type directly - if s.ctx.TransparentAliases() { - return s.buildFromType(tpe.Rhs(), tgt) - } - - decl, ok := s.ctx.FindModel(o.Pkg().Path(), o.Name()) - if !ok { - return fmt.Errorf("can't find source file for aliased type: %v: %w", tpe, ErrSchema) - } - - return s.makeRef(decl, tgt) -} - -func (s *Builder) buildFromMap(titpe *types.Map, tgt ifaces.SwaggerTypable) error { - // check if key is a string type, or knows how to marshall to text. - // If not, print a message and skip the map property. - // - // Only maps with string keys can go into additional properties - - sch := tgt.Schema() - if sch == nil { - return fmt.Errorf("items doesn't support maps: %w", ErrSchema) - } - - eleProp := Typable{sch, tgt.Level(), s.ctx.SkipExtensions()} - key := titpe.Key() - if key.Underlying().String() == "string" || resolvers.IsTextMarshaler(key) { - return s.buildFromType(titpe.Elem(), eleProp.AdditionalProperties()) - } - - return nil -} - -func (s *Builder) buildFromInterface(decl *scanner.EntityDecl, it *types.Interface, schema *oaispec.Schema, seen map[string]string) error { - if it.Empty() { - // return an empty schema for empty interfaces - return nil - } - - var ( - tgt *oaispec.Schema - hasAllOf bool - ) - - var flist []*ast.Field - if specType, ok := decl.Spec.Type.(*ast.InterfaceType); ok { - flist = make([]*ast.Field, it.NumEmbeddeds()+it.NumExplicitMethods()) - copy(flist, specType.Methods.List) - } - - // First collect the embedded interfaces - // create refs when: - // - // 1. the embedded interface is decorated with an allOf annotation - // 2. the embedded interface is an alias - for fld := range it.EmbeddedTypes() { - if tgt == nil { - tgt = &oaispec.Schema{} - } - - fieldHasAllOf, err := s.processEmbeddedType(fld, flist, decl, schema, seen) - if err != nil { - return err - } - hasAllOf = hasAllOf || fieldHasAllOf - } - - if tgt == nil { - tgt = schema - } - - // We can finally build the actual schema for the struct - if tgt.Properties == nil { - tgt.Properties = make(map[string]oaispec.Schema) - } - tgt.Typed("object", "") - - for fld := range it.ExplicitMethods() { - if err := s.processInterfaceMethod(fld, it, decl, tgt, seen); err != nil { - return err - } - } - - if tgt == nil { - return nil - } - if hasAllOf && len(tgt.Properties) > 0 { - schema.AllOf = append(schema.AllOf, *tgt) - } - - for k := range tgt.Properties { - if _, ok := seen[k]; !ok { - delete(tgt.Properties, k) - } - } - - return nil -} - -func (s *Builder) processEmbeddedType( - fld types.Type, - flist []*ast.Field, - decl *scanner.EntityDecl, - schema *oaispec.Schema, - seen map[string]string, -) (fieldHasAllOf bool, err error) { - logger.DebugLogf(s.ctx.Debug(), "inspecting embedded type in interface: %v", fld) - - switch ftpe := fld.(type) { - case *types.Named: - logger.DebugLogf(s.ctx.Debug(), "embedded named type (buildInterface): %v", ftpe) - o := ftpe.Obj() - if resolvers.IsAny(o) || resolvers.IsStdError(o) { - return false, nil - } - return s.buildNamedInterface(ftpe, flist, decl, schema, seen) - case *types.Interface: - logger.DebugLogf(s.ctx.Debug(), "embedded anonymous interface type (buildInterface): %v", ftpe) - var aliasedSchema oaispec.Schema - ps := Typable{schema: &aliasedSchema, skipExt: s.ctx.SkipExtensions()} - if err = s.buildAnonymousInterface(ftpe, ps, decl); err != nil { - return false, err - } - if aliasedSchema.Ref.String() != "" || len(aliasedSchema.Properties) > 0 || len(aliasedSchema.AllOf) > 0 { - fieldHasAllOf = true - schema.AddToAllOf(aliasedSchema) - } - case *types.Alias: - logger.DebugLogf(s.ctx.Debug(), "embedded alias (buildInterface): %v -> %v", ftpe, ftpe.Rhs()) - var aliasedSchema oaispec.Schema - ps := Typable{schema: &aliasedSchema, skipExt: s.ctx.SkipExtensions()} - if err = s.buildAlias(ftpe, ps); err != nil { - return false, err - } - if aliasedSchema.Ref.String() != "" || len(aliasedSchema.Properties) > 0 || len(aliasedSchema.AllOf) > 0 { - fieldHasAllOf = true - schema.AddToAllOf(aliasedSchema) - } - default: - logger.UnsupportedTypeKind("buildNamedInterface.allOf", ftpe) - } - - logger.DebugLogf(s.ctx.Debug(), "got embedded interface: %v {%T}, fieldHasAllOf: %t", fld, fld, fieldHasAllOf) - return fieldHasAllOf, nil -} - -func (s *Builder) processInterfaceMethod(fld *types.Func, it *types.Interface, decl *scanner.EntityDecl, tgt *oaispec.Schema, seen map[string]string) error { - if !fld.Exported() { - return nil - } - - sig, isSignature := fld.Type().(*types.Signature) - if !isSignature { - return nil - } - - if sig.Params().Len() > 0 { - return nil - } - - if sig.Results() == nil || sig.Results().Len() != 1 { - return nil - } - - afld := resolvers.FindASTField(decl.File, fld.Pos()) - if afld == nil { - logger.DebugLogf(s.ctx.Debug(), "can't find source associated with %s for %s", fld.String(), it.String()) - return nil - } - - // if the field is annotated with swagger:ignore, ignore it - if parsers.Ignored(afld.Doc) { - return nil - } - - name, ok := parsers.NameOverride(afld.Doc) - if !ok { - name = s.interfaceJSONName(fld.Name()) - } - - ps := tgt.Properties[name] - if err := s.buildFromType(sig.Results().At(0).Type(), Typable{&ps, 0, s.ctx.SkipExtensions()}); err != nil { - return err - } - - if sfName, isStrfmt := parsers.StrfmtName(afld.Doc); isStrfmt { - ps.Typed("string", sfName) - ps.Ref = oaispec.Ref{} - ps.Items = nil - } - - sp := s.createParser(name, tgt, &ps, afld) - if err := sp.Parse(afld.Doc); err != nil { - return err - } - - if ps.Ref.String() == "" && name != fld.Name() { - ps.AddExtension("x-go-name", fld.Name()) - } - - if s.ctx.SetXNullableForPointers() { - _, isPointer := fld.Type().(*types.Signature).Results().At(0).Type().(*types.Pointer) - noNullableExt := ps.Extensions == nil || - (ps.Extensions["x-nullable"] == nil && ps.Extensions["x-isnullable"] == nil) - if isPointer && noNullableExt { - ps.AddExtension("x-nullable", true) - } - } - - seen[name] = fld.Name() - tgt.Properties[name] = ps - - return nil -} - -func (s *Builder) buildNamedInterface(ftpe *types.Named, flist []*ast.Field, decl *scanner.EntityDecl, schema *oaispec.Schema, seen map[string]string) (hasAllOf bool, err error) { - o := ftpe.Obj() - var afld *ast.Field - - for _, an := range flist { - if len(an.Names) != 0 { - continue - } - - tpp := decl.Pkg.TypesInfo.Types[an.Type] - if tpp.Type.String() != o.Type().String() { - continue - } - - // decl. - logger.DebugLogf(s.ctx.Debug(), "maybe interface field %s: %s(%T)", o.Name(), o.Type().String(), o.Type()) - afld = an - break - } - - if afld == nil { - logger.DebugLogf(s.ctx.Debug(), "can't find source associated with %s", ftpe.String()) - return hasAllOf, nil - } - - // if the field is annotated with swagger:ignore, ignore it - if parsers.Ignored(afld.Doc) { - return hasAllOf, nil - } - - if !parsers.AllOfMember(afld.Doc) { - var newSch oaispec.Schema - if err = s.buildEmbedded(o.Type(), &newSch, seen); err != nil { - return hasAllOf, err - } - schema.AllOf = append(schema.AllOf, newSch) - hasAllOf = true - - return hasAllOf, nil - } - - hasAllOf = true - - var newSch oaispec.Schema - // when the embedded struct is annotated with swagger:allOf it will be used as allOf property - // otherwise the fields will just be included as normal properties - if err = s.buildAllOf(o.Type(), &newSch); err != nil { - return hasAllOf, err - } - - if afld.Doc != nil { - extractAllOfClass(afld.Doc, schema) - } - - schema.AllOf = append(schema.AllOf, newSch) - - return hasAllOf, nil -} - -func (s *Builder) buildFromStruct(decl *scanner.EntityDecl, st *types.Struct, schema *oaispec.Schema, seen map[string]string) error { - cmt, hasComments := s.ctx.FindComments(decl.Pkg, decl.Obj().Name()) - if !hasComments { - cmt = new(ast.CommentGroup) - } - name, ok := parsers.TypeName(cmt) - if ok { - _ = resolvers.SwaggerSchemaForType(name, Typable{schema: schema, skipExt: s.ctx.SkipExtensions()}) - return nil - } - // First pass: scan anonymous/embedded fields for allOf composition. - // Returns the target schema for properties (may differ from schema when allOf is used). - tgt, hasAllOf, err := s.scanEmbeddedFields(decl, st, schema, seen) - if err != nil { - return err - } - - if tgt == nil { - if schema != nil { - tgt = schema - } else { - tgt = &oaispec.Schema{} - } - } - if tgt.Properties == nil { - tgt.Properties = make(map[string]oaispec.Schema) - } - tgt.Typed("object", "") - - // Second pass: build properties from non-embedded exported fields. - if err := s.buildStructFields(decl, st, tgt, seen); err != nil { - return err - } - - if tgt == nil { - return nil - } - if hasAllOf && len(tgt.Properties) > 0 { - schema.AllOf = append(schema.AllOf, *tgt) - } - for k := range tgt.Properties { - if _, ok := seen[k]; !ok { - delete(tgt.Properties, k) - } - } - return nil -} - -// scanEmbeddedFields iterates over anonymous struct fields to detect allOf composition. -// It returns: -// - tgt: the schema that should receive properties (nil if no embedded fields were processed, -// schema itself for plain embeds, or a new schema when allOf is detected) -// - hasAllOf: whether any allOf member was found -func (s *Builder) scanEmbeddedFields(decl *scanner.EntityDecl, st *types.Struct, schema *oaispec.Schema, seen map[string]string) (tgt *oaispec.Schema, hasAllOf bool, err error) { - for i := range st.NumFields() { - fld := st.Field(i) - if !fld.Anonymous() { - logger.DebugLogf(s.ctx.Debug(), "skipping field %q for allOf scan because not anonymous", fld.Name()) - continue - } - tg := st.Tag(i) - - logger.DebugLogf(s.ctx.Debug(), - "maybe allof field(%t) %s: %s (%T) [%q](anon: %t, embedded: %t)", - fld.IsField(), fld.Name(), fld.Type().String(), fld.Type(), tg, fld.Anonymous(), fld.Embedded(), - ) - afld := resolvers.FindASTField(decl.File, fld.Pos()) - if afld == nil { - logger.DebugLogf(s.ctx.Debug(), "can't find source associated with %s for %s", fld.String(), st.String()) - continue - } - - if parsers.Ignored(afld.Doc) { - continue - } - - _, ignore, _, _, err := resolvers.ParseJSONTag(afld) - if err != nil { - return nil, false, err - } - if ignore { - continue - } - - _, isAliased := fld.Type().(*types.Alias) - - if !parsers.AllOfMember(afld.Doc) && !isAliased { - // Plain embed: merge fields into the main schema - if tgt == nil { - tgt = schema - } - if err := s.buildEmbedded(fld.Type(), tgt, seen); err != nil { - return nil, false, err - } - continue - } - - if isAliased { - logger.DebugLogf(s.ctx.Debug(), "alias member in struct: %v", fld) - } - - // allOf member: fields go into a separate schema, embedded struct becomes an allOf entry - hasAllOf = true - if tgt == nil { - tgt = &oaispec.Schema{} - } - var newSch oaispec.Schema - if err := s.buildAllOf(fld.Type(), &newSch); err != nil { - return nil, false, err - } - - extractAllOfClass(afld.Doc, schema) - schema.AllOf = append(schema.AllOf, newSch) - } - - return tgt, hasAllOf, nil -} - -func (s *Builder) buildStructFields(decl *scanner.EntityDecl, st *types.Struct, tgt *oaispec.Schema, seen map[string]string) error { - for fld := range st.Fields() { - if err := s.processStructField(fld, decl, tgt, seen); err != nil { - return err - } - } - return nil -} - -func (s *Builder) processStructField(fld *types.Var, decl *scanner.EntityDecl, tgt *oaispec.Schema, seen map[string]string) error { - if fld.Embedded() || !fld.Exported() { - return nil - } - - afld := resolvers.FindASTField(decl.File, fld.Pos()) - if afld == nil { - logger.DebugLogf(s.ctx.Debug(), "can't find source associated with %s", fld.String()) - return nil - } - - if parsers.Ignored(afld.Doc) { - return nil - } - - name, ignore, isString, omitEmpty, err := resolvers.ParseJSONTag(afld) - if err != nil { - return err - } - - if ignore { - for seenTagName, seenFieldName := range seen { - if seenFieldName == fld.Name() { - delete(tgt.Properties, seenTagName) - break - } - } - return nil - } - - ps := tgt.Properties[name] - if err = s.buildFromType(fld.Type(), Typable{&ps, 0, s.ctx.SkipExtensions()}); err != nil { - return err - } - if isString { - ps.Typed("string", ps.Format) - ps.Ref = oaispec.Ref{} - ps.Items = nil - } - - if sfName, isStrfmt := parsers.StrfmtName(afld.Doc); isStrfmt { - ps.Typed("string", sfName) - ps.Ref = oaispec.Ref{} - ps.Items = nil - } - - sp := s.createParser(name, tgt, &ps, afld) - if err := sp.Parse(afld.Doc); err != nil { - return err - } - - if ps.Ref.String() == "" && name != fld.Name() { - resolvers.AddExtension(&ps.VendorExtensible, "x-go-name", fld.Name(), s.ctx.SkipExtensions()) - } - - if s.ctx.SetXNullableForPointers() { - if _, isPointer := fld.Type().(*types.Pointer); isPointer && !omitEmpty && - (ps.Extensions == nil || (ps.Extensions["x-nullable"] == nil && ps.Extensions["x-isnullable"] == nil)) { - ps.AddExtension("x-nullable", true) - } - } - - // we have 2 cases: - // 1. field with different name override tag - // 2. field with different name removes tag - // so we need to save both tag&name - seen[name] = fld.Name() - tgt.Properties[name] = ps - return nil -} - -func (s *Builder) buildAllOf(tpe types.Type, schema *oaispec.Schema) error { - logger.DebugLogf(s.ctx.Debug(), "allOf %s", tpe.Underlying()) - - switch ftpe := tpe.(type) { - case *types.Pointer: - return s.buildAllOf(ftpe.Elem(), schema) - case *types.Named: - return s.buildNamedAllOf(ftpe, schema) - case *types.Alias: - logger.DebugLogf(s.ctx.Debug(), "allOf member is alias %v => %v", ftpe, ftpe.Rhs()) - tgt := Typable{schema: schema, skipExt: s.ctx.SkipExtensions()} - return s.buildAlias(ftpe, tgt) - default: - logger.UnsupportedTypeKind("buildAllOf", ftpe) - return nil - } -} - -func (s *Builder) buildNamedAllOf(ftpe *types.Named, schema *oaispec.Schema) error { - switch utpe := ftpe.Underlying().(type) { - case *types.Struct: - tio := ftpe.Obj() - - // Run inlining shortcuts (stdlib time, swagger:strfmt) before - // FindModel — FindModel registers the type in ExtraModels as a - // side effect, which would emit an orphan top-level definition - // for a type whose schema we've already inlined. See Q10 in - // .claude/plans/observed-quirks.md. - if resolvers.IsStdTime(tio) { - schema.Typed("string", "date-time") - return nil - } - - if pkg, ok := s.ctx.PkgForType(ftpe); ok { - if cmt, hasComments := s.ctx.FindComments(pkg, tio.Name()); hasComments { - if sfnm, isf := parsers.StrfmtName(cmt); isf { - schema.Typed("string", sfnm) - return nil - } - } - } - - decl, found := s.ctx.FindModel(tio.Pkg().Path(), tio.Name()) - if !found { - return fmt.Errorf("can't find source file for struct: %s: %w", ftpe.String(), ErrSchema) - } - - if decl.HasModelAnnotation() { - return s.makeRef(decl, Typable{schema, 0, s.ctx.SkipExtensions()}) - } - - return s.buildFromStruct(decl, utpe, schema, make(map[string]string)) - case *types.Interface: - decl, found := s.ctx.FindModel(ftpe.Obj().Pkg().Path(), ftpe.Obj().Name()) - if !found { - return fmt.Errorf("can't find source file for interface: %s: %w", ftpe.String(), ErrSchema) - } - - if sfnm, isf := parsers.StrfmtName(decl.Comments); isf { - schema.Typed("string", sfnm) - return nil - } - - if decl.HasModelAnnotation() { - return s.makeRef(decl, Typable{schema, 0, s.ctx.SkipExtensions()}) - } - - return s.buildFromInterface(decl, utpe, schema, make(map[string]string)) - default: - logger.UnsupportedTypeKind("buildNamedAllOf", utpe) - return nil - } -} - -func (s *Builder) buildEmbedded(tpe types.Type, schema *oaispec.Schema, seen map[string]string) error { - logger.DebugLogf(s.ctx.Debug(), "embedded %v", tpe.Underlying()) - - switch ftpe := tpe.(type) { - case *types.Pointer: - return s.buildEmbedded(ftpe.Elem(), schema, seen) - case *types.Named: - return s.buildNamedEmbedded(ftpe, schema, seen) - case *types.Alias: - logger.DebugLogf(s.ctx.Debug(), "embedded alias %v => %v", ftpe, ftpe.Rhs()) - tgt := Typable{schema, 0, s.ctx.SkipExtensions()} - return s.buildAlias(ftpe, tgt) - default: - logger.UnsupportedTypeKind("buildEmbedded", ftpe) - return nil - } -} - -func (s *Builder) buildNamedEmbedded(ftpe *types.Named, schema *oaispec.Schema, seen map[string]string) error { - logger.DebugLogf(s.ctx.Debug(), "embedded named type: %T", ftpe.Underlying()) - if resolvers.UnsupportedBuiltin(ftpe) { - log.Printf("WARNING: skipped unsupported builtin type: %v", ftpe) - - return nil - } - - switch utpe := ftpe.Underlying().(type) { - case *types.Struct: - decl, found := s.ctx.FindModel(ftpe.Obj().Pkg().Path(), ftpe.Obj().Name()) - if !found { - return fmt.Errorf("can't find source file for struct: %s: %w", ftpe.String(), ErrSchema) - } - - return s.buildFromStruct(decl, utpe, schema, seen) - case *types.Interface: - if utpe.Empty() { - return nil - } - o := ftpe.Obj() - if resolvers.IsAny(o) { - return nil - } - if resolvers.IsStdError(o) { - tgt := Typable{schema: schema, skipExt: s.ctx.SkipExtensions()} - tgt.AddExtension("x-go-type", o.Name()) - return resolvers.SwaggerSchemaForType(o.Name(), tgt) - } - resolvers.MustNotBeABuiltinType(o) - - decl, found := s.ctx.FindModel(o.Pkg().Path(), o.Name()) - if !found { - return fmt.Errorf("can't find source file for struct: %s: %w", ftpe.String(), ErrSchema) - } - return s.buildFromInterface(decl, utpe, schema, seen) - default: - logger.UnsupportedTypeKind("buildNamedEmbedded", utpe) - return nil - } -} - -func (s *Builder) makeRef(decl *scanner.EntityDecl, prop ifaces.SwaggerTypable) error { - nm, _ := decl.Names() - ref, err := oaispec.NewRef("#/definitions/" + nm) - if err != nil { - return err - } - - prop.SetRef(ref) - s.postDecls = append(s.postDecls, decl) - - return nil -} - -func (s *Builder) createParser(nm string, schema, ps *oaispec.Schema, fld *ast.Field, opts ...parsers.SectionedParserOption) *parsers.SectionedParser { - if ps.Ref.String() != "" && !s.ctx.DescWithRef() { - // if DescWithRef option is enabled, allow the tagged documentation to flow alongside the $ref - // otherwise behave as expected by jsonschema draft4: $ref predates all sibling keys. - opts = append( - opts, - parsers.WithTaggers(refSchemaTaggers(schema, nm)...), - ) - - return parsers.NewSectionedParser(opts...) - } - - taggers := schemaTaggers(schema, ps, nm) - - // the parser may be called outside the context of struct field. - // In that case, just return the outcome of the parsing now. - - if fld != nil { - // check if this is a primitive, if so parse the validations from the - // doc comments of the slice declaration. - if ftped, ok := fld.Type.(*ast.ArrayType); ok { - var err error - arrayTaggers, err := parseArrayTypes(taggers, ftped.Elt, ps.Items, 0) // NOTE: swallows error silently - if err == nil { - taggers = arrayTaggers - } - } - } - - opts = append( - opts, - parsers.WithSetDescription(func(lines []string) { - ps.Description = parsers.JoinDropLast(lines) - enumDesc := parsers.GetEnumDesc(ps.Extensions) - if enumDesc != "" { - ps.Description += "\n" + enumDesc - } - }), - parsers.WithTaggers(taggers...), - ) - - return parsers.NewSectionedParser(opts...) -} - -func schemaVendorExtensibleSetter(meta *oaispec.Schema) func(json.RawMessage) error { - return func(jsonValue json.RawMessage) error { - var jsonData oaispec.Extensions - err := json.Unmarshal(jsonValue, &jsonData) - if err != nil { - return err - } - - for k := range jsonData { - if !parsers.IsAllowedExtension(k) { - return fmt.Errorf("invalid schema extension name, should start from `x-`: %s: %w", k, ErrSchema) - } - } - - meta.Extensions = jsonData - - return nil - } -} - -func extractAllOfClass(doc *ast.CommentGroup, schema *oaispec.Schema) { - allOfClass, ok := parsers.AllOfName(doc) - if !ok { - return - } - - schema.AddExtension("x-class", allOfClass) } diff --git a/internal/builders/schema/schema_go118_test.go b/internal/builders/schema/schema_go118_test.go index 7b9f1d2..90d3d5c 100644 --- a/internal/builders/schema/schema_go118_test.go +++ b/internal/builders/schema/schema_go118_test.go @@ -26,12 +26,9 @@ func TestGo118SwaggerTypeNamed(t *testing.T) { sctx := scantest.LoadGo118ClassificationPkgsCtx(t) decl := getGo118ClassificationModel(sctx, "NamedWithType") require.NotNil(t, decl) - prs := &Builder{ - ctx: sctx, - decl: decl, - } + prs := NewBuilder(sctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["namedWithType"] scantest.AssertProperty(t, &schema, "object", "some_map", "", "SomeMap") @@ -51,11 +48,8 @@ func TestGo118AliasedModels(t *testing.T) { decl := getGo118ClassificationModel(sctx, nm) require.NotNil(t, decl) - prs := &Builder{ - decl: decl, - ctx: sctx, - } - require.NoError(t, prs.Build(defs)) + prs := NewBuilder(sctx, decl) + require.NoError(t, prs.Build(WithDefinitions(defs))) } for k := range defs { @@ -78,12 +72,9 @@ func TestGo118InterfaceField(t *testing.T) { sctx := scantest.LoadGo118ClassificationPkgsCtx(t) decl := getGo118ClassificationModel(sctx, "Interfaced") require.NotNil(t, decl) - prs := &Builder{ - ctx: sctx, - decl: decl, - } + prs := NewBuilder(sctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["Interfaced"] scantest.AssertProperty(t, &schema, "", "custom_data", "", "CustomData") @@ -95,12 +86,9 @@ func TestGo118_Issue2809(t *testing.T) { sctx := scantest.LoadGo118ClassificationPkgsCtx(t) decl := getGo118ClassificationModel(sctx, "transportErr") require.NotNil(t, decl) - prs := &Builder{ - ctx: sctx, - decl: decl, - } + prs := NewBuilder(sctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["transportErr"] scantest.AssertProperty(t, &schema, "", "data", "", "Data") diff --git a/internal/builders/schema/schema_test.go b/internal/builders/schema/schema_test.go index 8e8778f..9ab310c 100644 --- a/internal/builders/schema/schema_test.go +++ b/internal/builders/schema/schema_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "testing" + "github.com/go-openapi/codescan/internal/parsers/grammar" "github.com/go-openapi/codescan/internal/scanner" "github.com/go-openapi/codescan/internal/scantest" "github.com/go-openapi/testify/v2/assert" @@ -19,7 +20,10 @@ const ( epsilon = 1e-9 // fixturesModule is the module path of the fixtures nested module. - fixturesModule = "github.com/go-openapi/codescan/fixtures" + fixturesModule = "github.com/go-openapi/codescan/fixtures" + fixtureMinimal3125 = "bugs/3125/minimal" + sampleValue1 = "value1" + sampleValue2 = "value2" ) func TestBuilder_Struct_Tag(t *testing.T) { @@ -37,20 +41,14 @@ func TestBuilder_Struct_Tag(t *testing.T) { require.NotNil(t, td) }) - prs := &Builder{ - ctx: ctx, - decl: td, - } + prs := NewBuilder(ctx, td) result := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(result)) + require.NoError(t, prs.Build(WithDefinitions(result))) scantest.CompareOrDumpJSON(t, result, "petstore_schema_Tag.json") } func TestBuilder_Struct_Pet(t *testing.T) { - // Debug = true - // defer func() { Debug = false }() - ctx := scantest.LoadPetstorePkgsCtx(t, false) var td *scanner.EntityDecl for k, v := range ctx.Models() { @@ -62,20 +60,14 @@ func TestBuilder_Struct_Pet(t *testing.T) { } require.NotNil(t, td) - prs := &Builder{ - ctx: ctx, - decl: td, - } + prs := NewBuilder(ctx, td) result := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(result)) + require.NoError(t, prs.Build(WithDefinitions(result))) scantest.CompareOrDumpJSON(t, result, "petstore_schema_Pet.json") } func TestBuilder_Struct_Order(t *testing.T) { - // Debug = true - // defer func() { Debug = false }() - ctx := scantest.LoadPetstorePkgsCtx(t, false) var td *scanner.EntityDecl for k, v := range ctx.Models() { @@ -87,12 +79,9 @@ func TestBuilder_Struct_Order(t *testing.T) { } require.NotNil(t, td) - prs := &Builder{ - ctx: ctx, - decl: td, - } + prs := NewBuilder(ctx, td) result := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(result)) + require.NoError(t, prs.Build(WithDefinitions(result))) scantest.CompareOrDumpJSON(t, result, "petstore_schema_Order.json") } @@ -101,23 +90,26 @@ func TestBuilder(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "NoModel") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["NoModel"] assert.Equal(t, oaispec.StringOrArray([]string{"object"}), schema.Type) assert.EqualT(t, "NoModel is a struct without an annotation.", schema.Title) - assert.EqualT(t, "NoModel exists in a package\nbut is not annotated with the swagger model annotations\nso it should now show up in a test.", schema.Description) + assert.EqualT(t, + "NoModel exists in a package\nbut is not annotated with the swagger model annotations\nso it should now show up in a test.", + schema.Description, + ) assert.Len(t, schema.Required, 3) assert.Len(t, schema.Properties, 12) scantest.AssertProperty(t, &schema, "integer", "id", "int64", "ID") prop, ok := schema.Properties["id"] - assert.EqualT(t, "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", prop.Description) + assert.EqualT(t, + "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + prop.Description, + ) assert.TrueT(t, ok, "should have had an 'id' property") assert.InDeltaT(t, 1000.00, *prop.Maximum, epsilon) assert.TrueT(t, prop.ExclusiveMaximum, "'id' should have had an exclusive maximum") @@ -152,8 +144,8 @@ func TestBuilder(t *testing.T) { expectedNameExtensions := oaispec.Extensions{ "x-go-name": "Name", "x-property-array": []any{ - "value1", - "value2", + sampleValue1, + sampleValue2, }, "x-property-array-obj": []any{ map[string]any{ @@ -269,7 +261,10 @@ func TestBuilder(t *testing.T) { scantest.AssertProperty(t, itprop, "integer", "id", "int32", "ID") iprop, ok := itprop.Properties["id"] assert.TrueT(t, ok) - assert.EqualT(t, "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", iprop.Description) + assert.EqualT(t, + "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + iprop.Description, + ) require.NotNil(t, iprop.Maximum) assert.InDeltaT(t, 1000.00, *iprop.Maximum, epsilon) assert.TrueT(t, iprop.ExclusiveMaximum, "'id' should have had an exclusive maximum") @@ -282,7 +277,9 @@ func TestBuilder(t *testing.T) { iprop, ok = itprop.Properties["pet"] assert.TrueT(t, ok) if itprop.Ref.String() != "" { - assert.EqualT(t, "The Pet to add to this NoModel items bucket.\nPets can appear more than once in the bucket", iprop.Description) + assert.EqualT(t, "The Pet to add to this NoModel items bucket.\nPets can appear more than once in the bucket", + iprop.Description, + ) } scantest.AssertProperty(t, itprop, "integer", "quantity", "int16", "Quantity") @@ -304,7 +301,7 @@ func TestBuilder(t *testing.T) { decl2 := getClassificationModel(ctx, "StoreOrder") require.NotNil(t, decl2) - require.NoError(t, (&Builder{decl: decl2, ctx: ctx}).Build(models)) + require.NoError(t, NewBuilder(ctx, decl2).Build(WithDefinitions(models))) msch, ok := models["order"] pn := fixturesModule + "/goparsing/classification/models" assert.TrueT(t, ok) @@ -319,7 +316,7 @@ func TestBuilder_AddExtensions(t *testing.T) { models := make(map[string]oaispec.Schema) decl := getClassificationModel(ctx, "StoreOrder") require.NotNil(t, decl) - require.NoError(t, (&Builder{decl: decl, ctx: ctx}).Build(models)) + require.NoError(t, NewBuilder(ctx, decl).Build(WithDefinitions(models))) msch, ok := models["order"] pn := fixturesModule + "/goparsing/classification/models" @@ -333,12 +330,9 @@ func TestTextMarhalCustomType(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "TextMarshalModel") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["TextMarshalModel"] scantest.AssertProperty(t, &schema, "string", "id", "uuid", "ID") scantest.AssertArrayProperty(t, &schema, "string", "ids", "uuid", "IDs") @@ -356,12 +350,9 @@ func TestEmbeddedTypes(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "ComplexerOne") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["ComplexerOne"] scantest.AssertProperty(t, &schema, "integer", "age", "int32", "Age") scantest.AssertProperty(t, &schema, "integer", "id", "int64", "ID") @@ -375,12 +366,9 @@ func TestParsePrimitiveSchemaProperty(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "PrimateModel") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["PrimateModel"] scantest.AssertProperty(t, &schema, "boolean", "a", "", "A") @@ -406,12 +394,9 @@ func TestParseStringFormatSchemaProperty(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "FormattedModel") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["FormattedModel"] scantest.AssertProperty(t, &schema, "string", "a", "byte", "A") @@ -441,12 +426,9 @@ func TestStringStructTag(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "JSONString") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) sch := models["jsonString"] scantest.AssertProperty(t, &sch, "string", "someInt", "int64", "SomeInt") @@ -474,12 +456,9 @@ func TestPtrFieldStringStructTag(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "JSONPtrString") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) sch := models["jsonPtrString"] scantest.AssertProperty(t, &sch, "string", "someInt", "int64", "SomeInt") @@ -506,12 +485,9 @@ func TestIgnoredStructField(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "IgnoredFields") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) sch := models["ignoredFields"] scantest.AssertProperty(t, &sch, "string", "someIncludedField", "", "SomeIncludedField") @@ -523,12 +499,9 @@ func TestParseStructFields(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "SimpleComplexModel") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["SimpleComplexModel"] scantest.AssertProperty(t, &schema, "object", "emb", "", "Emb") @@ -544,12 +517,9 @@ func TestParsePointerFields(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "Pointdexter") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["Pointdexter"] @@ -569,12 +539,9 @@ func TestEmbeddedStarExpr(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "EmbeddedStarExpr") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["EmbeddedStarExpr"] @@ -586,12 +553,9 @@ func TestArrayOfPointers(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "Cars") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["cars"] scantest.AssertProperty(t, &schema, "array", "cars", "", "Cars") @@ -601,12 +565,9 @@ func TestOverridingOneIgnore(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "OverridingOneIgnore") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["OverridingOneIgnore"] @@ -630,12 +591,9 @@ func testParseCollectionFields( ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, modelName) require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models[modelName] @@ -682,12 +640,9 @@ func TestInterfaceField(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "Interfaced") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["Interfaced"] scantest.AssertProperty(t, &schema, "", "custom_data", "", "CustomData") @@ -697,12 +652,9 @@ func TestAliasedTypes(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "OtherTypes") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["OtherTypes"] scantest.AssertRef(t, &schema, "named", "Named", "#/definitions/SomeStringType") @@ -783,11 +735,8 @@ func TestAliasedModels(t *testing.T) { decl := getClassificationModel(ctx, nm) require.NotNil(t, decl) - prs := &Builder{ - decl: decl, - ctx: ctx, - } - require.NoError(t, prs.Build(defs)) + prs := NewBuilder(ctx, decl) + require.NoError(t, prs.Build(WithDefinitions(defs))) } for k := range defs { @@ -853,24 +802,21 @@ func TestAliasedTopLevelModels(t *testing.T) { t.Run("with schema builder", func(t *testing.T) { require.NotNil(t, decl) - builder := &Builder{ - ctx: ctx, - decl: decl, - } + builder := NewBuilder(ctx, decl) t.Run("should build model for Customer", func(t *testing.T) { models := make(map[string]oaispec.Schema) - require.NoError(t, builder.Build(models)) + require.NoError(t, builder.Build(WithDefinitions(models))) assertRefDefinition(t, models, "Customer", "#/definitions/User", "") }) t.Run("should have discovered models for User and Customer", func(t *testing.T) { - require.Len(t, builder.postDecls, 2) + require.Len(t, builder.PostDeclarations(), 2) foundUserIndex := -1 foundCustomerIndex := -1 - for i, discoveredDecl := range builder.postDecls { + for i, discoveredDecl := range builder.PostDeclarations() { switch discoveredDecl.Obj().Name() { case "User": foundUserIndex = i @@ -880,15 +826,14 @@ func TestAliasedTopLevelModels(t *testing.T) { } require.GreaterOrEqualT(t, foundUserIndex, 0) require.GreaterOrEqualT(t, foundCustomerIndex, 0) + postDecls := builder.PostDeclarations() + require.GreaterT(t, len(postDecls), foundUserIndex) - userBuilder := &Builder{ - ctx: ctx, - decl: builder.postDecls[foundUserIndex], - } + userBuilder := NewBuilder(ctx, postDecls[foundUserIndex]) t.Run("should build model for User", func(t *testing.T) { models := make(map[string]oaispec.Schema) - require.NoError(t, userBuilder.Build(models)) + require.NoError(t, userBuilder.Build(WithDefinitions(models))) require.MapContainsT(t, models, "User") @@ -929,14 +874,11 @@ func TestAliasedTopLevelModels(t *testing.T) { t.Run("with schema builder", func(t *testing.T) { require.NotNil(t, decl) - builder := &Builder{ - ctx: ctx, - decl: decl, - } + builder := NewBuilder(ctx, decl) t.Run("should build model for Customer", func(t *testing.T) { models := make(map[string]oaispec.Schema) - require.NoError(t, builder.Build(models)) + require.NoError(t, builder.Build(WithDefinitions(models))) require.MapContainsT(t, models, "Customer") customer := models["Customer"] @@ -950,8 +892,9 @@ func TestAliasedTopLevelModels(t *testing.T) { }) t.Run("should have discovered only Customer", func(t *testing.T) { - require.Len(t, builder.postDecls, 1) - discovered := builder.postDecls[0] + postDecls := builder.PostDeclarations() + require.Len(t, postDecls, 1) + discovered := postDecls[0] assert.EqualT(t, "Customer", discovered.Obj().Name()) }) }) @@ -963,12 +906,9 @@ func TestEmbeddedAllOf(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "AllOfModel") require.NotNil(t, decl) - prs := &Builder{ - ctx: ctx, - decl: decl, - } + prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["AllOfModel"] require.Len(t, schema.AllOf, 3) @@ -994,7 +934,7 @@ func TestPointersAreNullableByDefaultWhenSetXNullableForPointersIsSet(t *testing decl, _ := ctx.FindDecl(packagePath, modelName) require.NotNil(t, decl) prs := NewBuilder(ctx, decl) - require.NoError(t, prs.Build(allModels)) + require.NoError(t, prs.Build(WithDefinitions(allModels))) schema := allModels[modelName] require.Len(t, schema.Properties, 5) @@ -1028,11 +968,13 @@ func TestPointersAreNullableByDefaultWhenSetXNullableForPointersIsSet(t *testing } // valueKeys returns the five property keys expected for the fixtures -// Item (struct, Go names verbatim) and ItemInterface (interface methods, -// camelCased per Q9). +// Item (struct, Go names verbatim) and ItemInterface (interface +// methods, JSON-name-derived via the interface-method mangler — see +// [§method-mangler](./README.md#method-mangler) — so the keys are +// camelCased rather than Go-verbatim). func valueKeys(modelName string) (string, string, string, string, string) { if modelName == "ItemInterface" { - return "value1", "value2", "value3", "value4", "value5" + return sampleValue1, sampleValue2, "value3", "value4", "value5" } return "Value1", "Value2", "Value3", "Value4", "Value5" } @@ -1043,7 +985,7 @@ func TestPointersAreNotNullableByDefaultWhenSetXNullableForPointersIsNotSet(t *t decl, _ := ctx.FindDecl(packagePath, modelName) require.NotNil(t, decl) prs := NewBuilder(ctx, decl) - require.NoError(t, prs.Build(allModels)) + require.NoError(t, prs.Build(WithDefinitions(allModels))) schema := allModels[modelName] require.Len(t, schema.Properties, 5) @@ -1080,7 +1022,7 @@ func TestSwaggerTypeNamed(t *testing.T) { require.NotNil(t, decl) prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["namedWithType"] scantest.AssertProperty(t, &schema, "object", "some_map", "", "SomeMap") @@ -1126,7 +1068,7 @@ func TestSwaggerTypeNamedWithGenerics(t *testing.T) { require.NotNil(t, decl) prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) testFunc(t, models) }) } @@ -1138,7 +1080,7 @@ func TestSwaggerTypeStruct(t *testing.T) { require.NotNil(t, decl) prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["NullString"] assert.TrueT(t, schema.Type.Contains("string")) @@ -1154,7 +1096,7 @@ func TestStructDiscriminators(t *testing.T) { decl := getClassificationModel(ctx, tn) require.NotNil(t, decl) prs := NewBuilder(ctx, decl) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) } schema := models["animal"] @@ -1192,7 +1134,7 @@ func TestInterfaceDiscriminators(t *testing.T) { require.NotNil(t, decl) prs := NewBuilder(ctx, decl) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) } schema, ok := models["fish"] @@ -1390,40 +1332,355 @@ func assertMapRef(t *testing.T, schema *oaispec.Schema, jsonName, goName, fragme assert.EqualT(t, fragment, psch.Ref.String()) } +func TestBuilder_DiagnosticsOnInvalidNumeric(t *testing.T) { + packagePattern := "./enhancements/diagnostics" + packagePath := fixturesModule + "/enhancements/diagnostics" + + var collected []grammar.Diagnostic + ctx, err := scanner.NewScanCtx(&scanner.Options{ + Packages: []string{packagePattern}, + WorkDir: scantest.FixturesDir(), + OnDiagnostic: func(d grammar.Diagnostic) { + collected = append(collected, d) + }, + }) + require.NoError(t, err) + + decl, _ := ctx.FindDecl(packagePath, "BadMaximum") + require.NotNil(t, decl) + prs := NewBuilder(ctx, decl) + models := make(map[string]oaispec.Schema) + require.NoError(t, prs.Build(WithDefinitions(models))) + + schema := models["BadMaximum"] + require.Contains(t, schema.Properties, "count") + count := schema.Properties["count"] + // The invalid `maximum: notanumber` is silently dropped — Maximum + // stays nil on the property schema. + assert.Nil(t, count.Maximum, "invalid maximum: should be dropped from spec") + + // Builder.Diagnostics() and the OnDiagnostic callback both surface + // the parser's CodeInvalidNumber error. + bd := prs.Diagnostics() + require.NotEmpty(t, bd) + require.NotEmpty(t, collected) + + foundCallback := false + for _, d := range collected { + if d.Code == grammar.CodeInvalidNumber { + foundCallback = true + break + } + } + assert.True(t, foundCallback, "OnDiagnostic should fire with CodeInvalidNumber") + + foundBuilder := false + for _, d := range bd { + if d.Code == grammar.CodeInvalidNumber { + foundBuilder = true + break + } + } + assert.True(t, foundBuilder, "Builder.Diagnostics should contain CodeInvalidNumber") +} + +// TestBuilder_DiagnosticsOnAmbiguousEmbed exercises the +// embed-ambiguity diagnostic path. The fixture defines three +// shapes that all share a property JSON name across embeds: +// +// - AmbiguousEmbed — two sibling embeds at the same depth +// promote the same JSON name under different Go field names; +// the diagnostic must fire. +// - DepthShadowingEmbed — an inner embed at depth 2 is shadowed +// by a Go field at depth 1; Go's depth rule already disambiguates +// and the diagnostic must remain silent. +// - ExplicitOverride — a top-level field re-declares the +// embedded JSON name; the embed-side override is happening at +// depth 0 and the diagnostic must remain silent. +// +// The diagnostic carries CodeAmbiguousEmbed (SeverityWarning); the +// spec output remains last-write-wins regardless. Behaviour is not +// changed by this signal, only surfaced. +func TestBuilder_DiagnosticsOnAmbiguousEmbed(t *testing.T) { + packagePattern := "./enhancements/diagnostics" + packagePath := fixturesModule + "/enhancements/diagnostics" + + build := func(t *testing.T, name string) *Builder { + t.Helper() + ctx, err := scanner.NewScanCtx(&scanner.Options{ + Packages: []string{packagePattern}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + + decl, _ := ctx.FindDecl(packagePath, name) + require.NotNil(t, decl, "fixture decl %s not found", name) + prs := NewBuilder(ctx, decl) + models := make(map[string]oaispec.Schema) + require.NoError(t, prs.Build(WithDefinitions(models))) + return prs + } + + hasAmbig := func(ds []grammar.Diagnostic) bool { + for _, d := range ds { + if d.Code == grammar.CodeAmbiguousEmbed { + return true + } + } + return false + } + + t.Run("peer embeds at same depth fire the diagnostic", func(t *testing.T) { + prs := build(t, "AmbiguousEmbed") + ds := prs.Diagnostics() + require.NotEmpty(t, ds) + assert.True(t, hasAmbig(ds), "expected CodeAmbiguousEmbed in %+v", ds) + // Verify severity and message shape. + for _, d := range ds { + if d.Code != grammar.CodeAmbiguousEmbed { + continue + } + assert.Equal(t, grammar.SeverityWarning, d.Severity) + assert.Contains(t, d.Message, "shared") + assert.Contains(t, d.Message, "Foo") + assert.Contains(t, d.Message, "Bar") + } + }) + + t.Run("depth-rule shadowing stays silent", func(t *testing.T) { + prs := build(t, "DepthShadowingEmbed") + assert.False(t, hasAmbig(prs.Diagnostics()), + "depth shadowing must not be flagged as ambiguity") + }) + + t.Run("top-level explicit override stays silent", func(t *testing.T) { + prs := build(t, "ExplicitOverride") + assert.False(t, hasAmbig(prs.Diagnostics()), + "top-level explicit override must not be flagged as ambiguity") + }) +} + +// TestEmbeddedDescriptionAndTags verifies the allOf compound shape +// for $ref'd fields with field-level x-extensions and example. v1 +// rode them as siblings of $ref (rejecting JSON Schema draft-4); +// the current builder produces the principled allOf compound where +// the description lives on the outer parent and the override +// decorations live on the override arm — see +// `internal/builders/schema/walker.go#applyToRefField` for the +// shape rules and the DescWithRef toggle's role. func TestEmbeddedDescriptionAndTags(t *testing.T) { - packagePattern := "./bugs/3125/minimal" - packagePath := fixturesModule + "/bugs/3125/minimal" + packagePattern := "./" + fixtureMinimal3125 + packagePath := fixturesModule + "/" + fixtureMinimal3125 ctx, err := scanner.NewScanCtx(&scanner.Options{ - Packages: []string{packagePattern}, - WorkDir: scantest.FixturesDir(), - DescWithRef: true, + Packages: []string{packagePattern}, + WorkDir: scantest.FixturesDir(), }) require.NoError(t, err) decl, _ := ctx.FindDecl(packagePath, "Item") require.NotNil(t, decl) prs := NewBuilder(ctx, decl) models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + require.NoError(t, prs.Build(WithDefinitions(models))) schema := models["Item"] - assert.Equal(t, []string{"value1", "value2"}, schema.Required) + assert.Equal(t, []string{sampleValue1, sampleValue2}, schema.Required) require.Len(t, schema.Properties, 2) - require.MapContainsT(t, schema.Properties, "value1") - assert.EqualT(t, "Nullable value", schema.Properties["value1"].Description) - assert.Equal(t, true, schema.Properties["value1"].Extensions["x-nullable"]) - - require.MapContainsT(t, schema.Properties, "value2") - assert.EqualT(t, "Non-nullable value", schema.Properties["value2"].Description) - assert.MapNotContainsT(t, schema.Properties["value2"].Extensions, "x-nullable") - assert.Equal(t, `{"value": 42}`, schema.Properties["value2"].Example) + // Both Value1 and Value2 are typed *ValueStruct / ValueStruct + // (named) → $ref. Field-level decorations move to the override + // arm of an allOf compound; description rides the outer parent. + + // Vendor extensions ride the OUTER compound (alongside x-go-name) + // so the field carries all its x-* metadata at one level. + // Validations go on the override arm (AllOf[1]). + + require.MapContainsT(t, schema.Properties, sampleValue1) + v1 := schema.Properties[sampleValue1] + assert.EqualT(t, "Nullable value", v1.Description) + assert.Equal(t, true, v1.Extensions["x-nullable"], "x-nullable should be on the outer compound, not inside AllOf") + require.Len(t, v1.AllOf, 1, "value1 has an extension-only override → single-arm allOf") + assert.Equal(t, "#/definitions/ValueStruct", v1.AllOf[0].Ref.String()) + assert.Empty(t, v1.Ref.String(), "outer schema must NOT carry the ref directly") + + require.MapContainsT(t, schema.Properties, sampleValue2) + v2 := schema.Properties[sampleValue2] + assert.EqualT(t, "Non-nullable value", v2.Description) + assert.MapNotContainsT(t, v2.Extensions, "x-nullable") + require.Len(t, v2.AllOf, 2, "value2 has an example override → two-arm allOf") + assert.Equal(t, "#/definitions/ValueStruct", v2.AllOf[0].Ref.String()) + assert.Equal(t, `{"value": 42}`, v2.AllOf[1].Example) scantest.CompareOrDumpJSON(t, models, "bugs_3125_schema.json") } +// TestEmbeddedDescriptionAndTags_OptionVariants captures the +// (SkipExtensions, DescWithRef) option matrix on the bugs/3125 +// fixture into separately-named goldens. Verifies that: +// +// - Validation/extension overrides on a $ref'd field always wrap +// in allOf (Value1's x-nullable, Value2's example are both +// overrides). DescWithRef toggles description placement on the +// allOf parent vs. dropped in the description-only-no-overrides +// case (which doesn't apply here — both fields have overrides). +// - SkipExtensions suppresses scanner-derived x-go-name / +// x-go-package without affecting user-authored x-nullable or +// the allOf shape. +// +// The four goldens produce a complete trace of (skipExt, descRef) +// permutations and serve as regression locks for the option +// semantics described in scanner.Options. +func TestEmbeddedDescriptionAndTags_OptionVariants(t *testing.T) { + cases := []struct { + name string + skipExt bool + descRef bool + goldenFile string + }{ + {"default", false, false, "bugs_3125_schema.json"}, + {"DescWithRef", false, true, "bugs_3125_schema_descwithref.json"}, + {"SkipExt", true, false, "bugs_3125_schema_skipext.json"}, + {"SkipExt+DescWithRef", true, true, "bugs_3125_schema_skipext_descwithref.json"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, err := scanner.NewScanCtx(&scanner.Options{ + Packages: []string{"./" + fixtureMinimal3125}, + WorkDir: scantest.FixturesDir(), + SkipExtensions: tc.skipExt, + DescWithRef: tc.descRef, + }) + require.NoError(t, err) + decl, _ := ctx.FindDecl(fixturesModule+"/"+fixtureMinimal3125, "Item") + require.NotNil(t, decl) + prs := NewBuilder(ctx, decl) + models := make(map[string]oaispec.Schema) + require.NoError(t, prs.Build(WithDefinitions(models))) + scantest.CompareOrDumpJSON(t, models, tc.goldenFile) + }) + } +} + +// TestEmbeddedDescriptionAndTags_SkipExtensions verifies that with +// SkipExtensions=true, the allOf compound on a $ref'd field is NOT +// polluted by the scanner-derived metadata (x-go-name / x-go-package +// / x-nullable inferred from pointer-ness). User-authored +// `Extensions: x-foo` blocks would still flow (they're explicit), but +// nothing else should land alongside the $ref. +// +// This is a regression guard: in v1, $ref'd fields had ps.Ref non-empty +// throughout, so the schema.go x-go-name / x-go-package guards +// (`if ps.Ref.String() == ""`) silently skipped. Post-S7, the allOf +// rewrite clears ps.Ref — those guards now fire. Without +// SkipExtensions=true, x-go-name lands on the outer compound (visible +// in the regular TestEmbeddedDescriptionAndTags). With +// SkipExtensions=true, the metadata extension writers respect the +// option and the outer compound stays clean. +func TestEmbeddedDescriptionAndTags_SkipExtensions(t *testing.T) { + packagePattern := "./" + fixtureMinimal3125 + packagePath := fixturesModule + "/" + fixtureMinimal3125 + ctx, err := scanner.NewScanCtx(&scanner.Options{ + Packages: []string{packagePattern}, + WorkDir: scantest.FixturesDir(), + SkipExtensions: true, + }) + require.NoError(t, err) + decl, _ := ctx.FindDecl(packagePath, "Item") + require.NotNil(t, decl) + prs := NewBuilder(ctx, decl) + models := make(map[string]oaispec.Schema) + require.NoError(t, prs.Build(WithDefinitions(models))) + schema := models["Item"] + + require.Len(t, schema.Properties, 2) + + // User-authored x-nullable should still be present (`Extensions:` + // raw block in the source). Scanner-derived x-go-name, x-go-package + // should be skipped. + v1 := schema.Properties[sampleValue1] + assert.MapNotContainsT(t, v1.Extensions, "x-go-name", "x-go-name should be skipped under SkipExtensions=true") + assert.MapNotContainsT(t, v1.Extensions, "x-go-package", "x-go-package should be skipped under SkipExtensions=true") + // Note: x-nullable on Value1 is user-authored, not scanner-derived; + // it travels with the user's `Extensions:` block and SHOULD still + // be present even under SkipExtensions=true. + assert.Equal(t, true, v1.Extensions["x-nullable"], "user-authored x-nullable should survive SkipExtensions=true") + + v2 := schema.Properties[sampleValue2] + assert.MapNotContainsT(t, v2.Extensions, "x-go-name") + assert.MapNotContainsT(t, v2.Extensions, "x-go-package") + assert.MapNotContainsT(t, v2.Extensions, "x-nullable", "value2 has no x-nullable in source") +} + +// TestParamsShape_DescWithRef_BothModes covers the description-only +// $ref'd field case where the user toggles DescWithRef: +// +// - DescWithRef=false (default): the description is dropped and the +// field emits as a bare {$ref: ...}. +// - DescWithRef=true: the description rides a single-arm allOf +// compound — {description: ..., allOf: [{$ref}]}. +// +// Fixture: classification operations corpus' `pet` field of +// `items[]` in NoModel carries only a description plus a $ref to the +// pet model — no validations, no user-authored extensions. +// +// When the field carries validation or extension overrides, the +// allOf compound is mandatory regardless of DescWithRef — covered by +// TestEmbeddedDescriptionAndTags / TestEmbeddedDescriptionAndTags_SkipExtensions. +func TestParamsShape_DescWithRef_BothModes(t *testing.T) { + getPetField := func(t *testing.T, descWithRef bool) oaispec.Schema { + t.Helper() + ctx, err := scanner.NewScanCtx(&scanner.Options{ + Packages: []string{ + "./goparsing/classification", + "./goparsing/classification/models", + "./goparsing/classification/operations", + }, + WorkDir: scantest.FixturesDir(), + SkipExtensions: true, + DescWithRef: descWithRef, + }) + require.NoError(t, err) + decl, ok := ctx.FindDecl(fixturesModule+"/goparsing/classification/models", "NoModel") + require.True(t, ok) + require.NotNil(t, decl) + prs := NewBuilder(ctx, decl) + models := make(map[string]oaispec.Schema) + require.NoError(t, prs.Build(WithDefinitions(models))) + noModel := models["NoModel"] + require.Contains(t, noModel.Properties, "items") + itemsProp := noModel.Properties["items"] + require.NotNil(t, itemsProp.Items) + require.NotNil(t, itemsProp.Items.Schema) + itemSchema := itemsProp.Items.Schema + require.Contains(t, itemSchema.Properties, "pet") + return itemSchema.Properties["pet"] + } + + t.Run("DescWithRef=false → bare $ref", func(t *testing.T) { + pet := getPetField(t, false) + assert.Equal(t, "#/definitions/pet", pet.Ref.String()) + assert.Empty(t, pet.AllOf, "no allOf compound expected") + assert.Empty(t, pet.Description, "description dropped under DescWithRef=false") + assert.MapNotContainsT(t, pet.Extensions, "x-go-name") + }) + + t.Run("DescWithRef=true → single-arm allOf with description", func(t *testing.T) { + pet := getPetField(t, true) + assert.Empty(t, pet.Ref.String(), "outer schema must NOT carry the ref directly") + require.Len(t, pet.AllOf, 1, "single-arm allOf for description-only override") + assert.Equal(t, "#/definitions/pet", pet.AllOf[0].Ref.String()) + assert.Contains(t, pet.Description, "The Pet to add") + assert.MapNotContainsT(t, pet.Extensions, "x-go-name") + }) +} + +// TestIssue2540 verifies the JSON Schema draft-4 allOf compound shape +// for a $ref'd field (`Author Author`) carrying its own field-level +// `example:`. The example must travel on the override arm of the +// allOf compound, never as a sibling of $ref. The DescWithRef toggle +// does not change this case — when validations (here, `example`) +// are present, the allOf wrap is mandatory regardless of the flag. func TestIssue2540(t *testing.T) { - t.Run("should produce example and default for top level declaration only", - testIssue2540(false, `{ + const expectedJSON = `{ "Book": { "description": "At this moment, a book is only described by its publishing date\nand author.", "type": "object", @@ -1432,7 +1689,10 @@ func TestIssue2540(t *testing.T) { "default": "{ \"Published\": 1900, \"Author\": \"Unknown\" }", "properties": { "Author": { - "$ref": "#/definitions/Author" + "allOf": [ + {"$ref": "#/definitions/Author"}, + {"example": "{ \"Name\": \"Tolkien\" }"} + ] }, "Published": { "type": "integer", @@ -1442,53 +1702,23 @@ func TestIssue2540(t *testing.T) { } } } - }`), - ) - t.Run("should produce example and default for top level declaration and embedded $ref field", - testIssue2540(true, `{ - "Book": { - "description": "At this moment, a book is only described by its publishing date\nand author.", - "type": "object", - "title": "Book holds all relevant information about a book.", - "example": "{ \"Published\": 2026, \"Author\": \"Fred\" }", - "default": "{ \"Published\": 1900, \"Author\": \"Unknown\" }", - "properties": { - "Author": { - "$ref": "#/definitions/Author", - "example": "{ \"Name\": \"Tolkien\" }" - }, - "Published": { - "type": "integer", - "format": "int64", - "minimum": 0, - "example": 2021 - } - } - } - }`), - ) -} - -func testIssue2540(descWithRef bool, expectedJSON string) func(*testing.T) { - return func(t *testing.T) { - packagePattern := "./bugs/2540/foo" - packagePath := fixturesModule + "/bugs/2540/foo" - ctx, err := scanner.NewScanCtx(&scanner.Options{ - Packages: []string{packagePattern}, - WorkDir: scantest.FixturesDir(), - DescWithRef: descWithRef, - SkipExtensions: true, - }) - require.NoError(t, err) + }` + packagePattern := "./bugs/2540/foo" + packagePath := fixturesModule + "/bugs/2540/foo" + ctx, err := scanner.NewScanCtx(&scanner.Options{ + Packages: []string{packagePattern}, + WorkDir: scantest.FixturesDir(), + SkipExtensions: true, + }) + require.NoError(t, err) - decl, _ := ctx.FindDecl(packagePath, "Book") - require.NotNil(t, decl) - prs := NewBuilder(ctx, decl) - models := make(map[string]oaispec.Schema) - require.NoError(t, prs.Build(models)) + decl, _ := ctx.FindDecl(packagePath, "Book") + require.NotNil(t, decl) + prs := NewBuilder(ctx, decl) + models := make(map[string]oaispec.Schema) + require.NoError(t, prs.Build(WithDefinitions(models))) - b, err := json.Marshal(models) - require.NoError(t, err) - assert.JSONEqT(t, expectedJSON, string(b)) - } + b, err := json.Marshal(models) + require.NoError(t, err) + assert.JSONEqT(t, expectedJSON, string(b)) } diff --git a/internal/builders/schema/simpleschema.go b/internal/builders/schema/simpleschema.go new file mode 100644 index 0000000..3cb140c --- /dev/null +++ b/internal/builders/schema/simpleschema.go @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "github.com/go-openapi/codescan/internal/parsers/grammar" + oaispec "github.com/go-openapi/spec" +) + +// inFormData is the `in:` parameter-location value that enables the +// `file` type under SimpleSchema. Lifted into a constant to satisfy +// goconst across this file and walker_classifiers.go. +const inFormData = "formData" + +// SimpleSchemaProbe is the schema-builder-side contract a +// SimpleSchema target must satisfy. Implemented structurally by +// `paramTypable` and (M2) `headerTypable`. +// +// # Details +// +// See [§simple-schema-mode](./README.md#simple-schema-mode) — full +// interface contract, reset semantics, and the catch-at-exit +// validator's role. +type SimpleSchemaProbe interface { + SimpleSchemaShape() *oaispec.SimpleSchema + HasRef() bool + ResetForViolation() +} + +// validateSimpleSchemaOutcome runs the "catch at exit" contract: +// inspect the resolved target, accept SimpleSchema-legal outcomes, +// emit a diagnostic and reset on violation. +// +// # Details +// +// See [§simple-schema-mode](./README.md#simple-schema-mode) for the +// allowed-type set, the file/formData special case, and the +// honest-over-lossy reset rationale. +func (s *Builder) validateSimpleSchemaOutcome() { + probe, ok := s.target.(SimpleSchemaProbe) + if !ok { + // Target doesn't expose a SimpleSchema shape — caller chose + // the SimpleSchema mode for a target that can't surface a + // violation. Trust the caller; emit no diagnostic. + return + } + + shape := probe.SimpleSchemaShape() + if shape == nil { + return + } + + typeOK := isAllowedSimpleSchemaType(shape.Type, s.paramIn) + refViolation := probe.HasRef() + if typeOK && !refViolation { + return + } + + reason := simpleSchemaViolationReason(shape.Type, refViolation, s.paramIn) + s.RecordDiagnostic(grammar.Warnf( + s.Ctx.PosOf(s.Decl.Spec.Pos()), + grammar.CodeUnsupportedInSimpleSchema, + "non-body parameter / response header (in=%q) cannot be represented as an OAS v2 SimpleSchema: %s; target reset to empty {}", + s.paramIn, reason, + )) + probe.ResetForViolation() +} + +// isAllowedSimpleSchemaType reports whether t is a SimpleSchema-legal +// type given the caller's `in` location. Empty string means "any" and +// is accepted (e.g. json.RawMessage recognizer emits an empty schema). +// `file` is only valid for in == inFormData. +func isAllowedSimpleSchemaType(t, in string) bool { + switch t { + case "", "string", "number", "integer", "boolean", "array": + return true + case "file": + return in == inFormData + } + return false +} + +// simpleSchemaViolationReason produces a short human-readable cause +// for the diagnostic message. +func simpleSchemaViolationReason(t string, refViolation bool, in string) string { + switch { + case refViolation: + return "$ref / model reference is forbidden under SimpleSchema" + case t == "file" && in != inFormData: + return `type "file" is only valid when in: formData` + case t == "object": + return `type "object" is forbidden under SimpleSchema` + case t == "": + // Should never get here — empty type is accepted by + // isAllowedSimpleSchemaType. Defensive only. + return "empty type unexpectedly rejected" + } + return "type " + t + " is not in the allowed SimpleSchema set" +} diff --git a/internal/builders/schema/special_types.go b/internal/builders/schema/special_types.go new file mode 100644 index 0000000..4a742b0 --- /dev/null +++ b/internal/builders/schema/special_types.go @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/types" + "strings" + + "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/ifaces" +) + +// buildFromTextMarshal renders a TextMarshaler-implementing type as a string. +// Six-step pipeline; user-annotation first, implicit recognizers next, generic fallback last. +// +// # Details +// +// See [§textmarshal-order](./README.md#textmarshal-order). +func (s *Builder) buildFromTextMarshal(tpe types.Type, target ifaces.SwaggerTypable) error { + if typePtr, ok := tpe.(*types.Pointer); ok { + return s.buildFromTextMarshal(typePtr.Elem(), target) + } + // Aliases route through buildAlias so RefAliases / TransparentAliases + // stay in charge of the alias indirection. + if typeAlias, ok := tpe.(*types.Alias); ok { + return s.buildAlias(typeAlias, target) + } + + typeNamed, ok := tpe.(*types.Named) + if !ok { + target.Typed("string", "") + return nil + } + tio := typeNamed.Obj() + + // Explicit user annotation wins over implicit recognizers. + if s.classifierTextMarshal(typeNamed, target) { + return nil + } + // Implicit recognizers in priority order. + if applySpecialType(tio, target, s.skipExtensions, recognizeError, recognizeTime, recognizeRawMessage, recognizeUUID) { + return nil + } + // Generic fallback: x-go-type carries pkg.Name, so PkgForType-miss + // must bail (can't produce the extension without the package). + if _, found := s.Ctx.PkgForType(tpe); !found { + return nil + } + target.Typed("string", "") + target.AddExtension("x-go-type", tio.Pkg().Path()+"."+tio.Name()) + return nil +} + +type recognizeType uint8 + +const ( + recognizedNone recognizeType = iota + recognizeTime + recognizeAny + recognizeError + recognizeRawMessage + // recognizeUUID is a fuzzy name-only match (case-insensitive + // "uuid"). Caller-gated — opt in only where the type is + // guaranteed to render as text. See + // [§special-types](./README.md#special-types). + recognizeUUID +) + +// applyStdlibSpecials runs the canonical safe set of identity-based +// recognizers (any / time.Time / error / json.RawMessage). Safe at +// every call site that handles a *types.TypeName. +// +// # Details +// +// See [§special-types](./README.md#special-types). +func applyStdlibSpecials(obj *types.TypeName, target ifaces.SwaggerTypable, skipExt bool) bool { + return applySpecialType(obj, target, skipExt, + recognizeAny, recognizeTime, recognizeError, recognizeRawMessage) +} + +// applySpecialType iterates wanted recognizers in order and applies +// the first match to target, returning resolved=true. Recognizers are +// identity-based except recognizeUUID, which is fuzzy (caller-gated). +// skipExt gates vendor-extension writes. +// +// # Details +// +// See [§special-types](./README.md#special-types), +// [§user-overrides](./README.md#user-overrides) (skipExt plumbing) and +// [§quirks](./README.md#quirks) (per-recognizer rationale). +func applySpecialType(obj *types.TypeName, target ifaces.SwaggerTypable, skipExt bool, wanted ...recognizeType) (resolved bool) { + for _, typeKey := range wanted { + switch typeKey { + case recognizeTime: // special case of the "time.Time" type + if resolvers.IsStdTime(obj) { + target.Typed("string", "date-time") + + return true + } + + case recognizeAny: // e.g type X any or type X interface{} + if resolvers.IsAny(obj) { + _ = target.Schema() + + return true + } + + case recognizeError: // predeclared error; see [§quirks](./README.md#quirks) for x-go-type rationale. + if resolvers.IsStdError(obj) { + if !skipExt { + target.AddExtension("x-go-type", obj.Name()) + } + target.Typed("string", "") + return true + } + + case recognizeRawMessage: // json.RawMessage; see [§quirks](./README.md#quirks) for the "any" rationale. + if resolvers.IsStdJSONRawMessage(obj) { + _ = target.Schema() + return true + } + + case recognizeUUID: // fuzzy — see [§special-types](./README.md#special-types). + if obj != nil && strings.ToLower(obj.Name()) == "uuid" { + target.Typed("string", "uuid") + return true + } + + default: + // ignored + } + } + + return false +} diff --git a/internal/builders/schema/struct.go b/internal/builders/schema/struct.go new file mode 100644 index 0000000..fd3a056 --- /dev/null +++ b/internal/builders/schema/struct.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/types" + + "github.com/go-openapi/codescan/internal/scanner" + oaispec "github.com/go-openapi/spec" +) + +// buildFromStruct emits the schema for a named Go struct. A first +// pass scans anonymous embeds for allOf composition; the second pass +// fills the property map from exported fields. The user-classifier +// short-circuit (`swagger:type`) wins outright. +// +// # Details +// +// See [§struct](./README.md#struct) — two-pass shape, the +// target-vs-schema split when allOf is in play, and why the +// `target.Typed("object", "")` line always fires (no +// SimpleSchema-style early exit yet). +func (s *Builder) buildFromStruct(decl *scanner.EntityDecl, st *types.Struct, schema *oaispec.Schema, nameByJSON map[string]propOwner) error { + if s.classifierStructPreBuildType(decl.Comments, NewTypable(schema, 0, s.skipExtensions)) { + return nil + } + + target, hasAllOf, err := s.scanEmbeddedFields(decl, st, schema, nameByJSON) + if err != nil { + return err + } + + if target == nil { + if schema != nil { + target = schema + } else { + target = &oaispec.Schema{} + } + } + + if target.Properties == nil { + target.Properties = make(map[string]oaispec.Schema) + } + target.Typed("object", "") + + if err := s.buildStructFields(decl, st, target, nameByJSON); err != nil { + return err + } + + if hasAllOf && len(target.Properties) > 0 { + schema.AllOf = append(schema.AllOf, *target) + } + + return nil +} + +func (s *Builder) buildStructFields(decl *scanner.EntityDecl, st *types.Struct, target *oaispec.Schema, nameByJSON map[string]propOwner) error { + for fld := range st.Fields() { + if err := s.processStructField(fld, decl, target, nameByJSON); err != nil { + return err + } + } + + return nil +} + +func (s *Builder) processStructField(fld *types.Var, decl *scanner.EntityDecl, target *oaispec.Schema, nameByJSON map[string]propOwner) error { + c, ok, err := s.structFieldCarrier(fld, decl, target, nameByJSON) + if err != nil || !ok { + return err + } + return s.applyFieldCarrier(c, target, nameByJSON) +} diff --git a/internal/builders/schema/taggers.go b/internal/builders/schema/taggers.go deleted file mode 100644 index 60178d8..0000000 --- a/internal/builders/schema/taggers.go +++ /dev/null @@ -1,125 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package schema - -import ( - "fmt" - "go/ast" - "slices" - - "github.com/go-openapi/codescan/internal/parsers" - oaispec "github.com/go-openapi/spec" -) - -func schemaTaggers(schema, ps *oaispec.Schema, nm string) []parsers.TagParser { - schemeType, err := ps.Type.MarshalJSON() - if err != nil { - return nil - } - scheme := &oaispec.SimpleSchema{Type: string(schemeType)} - - return []parsers.TagParser{ - // Match-only: claim `in: ` lines so they do not leak into the - // schema description. `in:` only matters for parameter/response dispatch; - // if it reaches a schema field (e.g. via the alias-expand path), it is - // still metadata, not prose. - parsers.NewSingleLineTagParser("in", parsers.NewMatchIn()), - parsers.NewSingleLineTagParser("maximum", parsers.NewSetMaximum(schemaValidations{ps})), - parsers.NewSingleLineTagParser("minimum", parsers.NewSetMinimum(schemaValidations{ps})), - parsers.NewSingleLineTagParser("multipleOf", parsers.NewSetMultipleOf(schemaValidations{ps})), - parsers.NewSingleLineTagParser("minLength", parsers.NewSetMinLength(schemaValidations{ps})), - parsers.NewSingleLineTagParser("maxLength", parsers.NewSetMaxLength(schemaValidations{ps})), - parsers.NewSingleLineTagParser("pattern", parsers.NewSetPattern(schemaValidations{ps})), - parsers.NewSingleLineTagParser("minItems", parsers.NewSetMinItems(schemaValidations{ps})), - parsers.NewSingleLineTagParser("maxItems", parsers.NewSetMaxItems(schemaValidations{ps})), - parsers.NewSingleLineTagParser("unique", parsers.NewSetUnique(schemaValidations{ps})), - parsers.NewSingleLineTagParser("enum", parsers.NewSetEnum(schemaValidations{ps})), - parsers.NewSingleLineTagParser("default", parsers.NewSetDefault(scheme, schemaValidations{ps})), - parsers.NewSingleLineTagParser("type", parsers.NewSetDefault(scheme, schemaValidations{ps})), - parsers.NewSingleLineTagParser("example", parsers.NewSetExample(scheme, schemaValidations{ps})), - parsers.NewSingleLineTagParser("required", parsers.NewSetRequiredSchema(schema, nm)), - parsers.NewSingleLineTagParser("readOnly", parsers.NewSetReadOnlySchema(ps)), - parsers.NewSingleLineTagParser("discriminator", parsers.NewSetDiscriminator(schema, nm)), - parsers.NewMultiLineTagParser("YAMLExtensionsBlock", parsers.NewYAMLParser( - parsers.WithExtensionMatcher(), - parsers.WithSetter(schemaVendorExtensibleSetter(ps)), - ), true), - } -} - -func refSchemaTaggers(schema *oaispec.Schema, name string) []parsers.TagParser { - return []parsers.TagParser{ - parsers.NewSingleLineTagParser("required", parsers.NewSetRequiredSchema(schema, name)), - } -} - -func itemsTaggers(items *oaispec.Schema, level int) []parsers.TagParser { - schemeType, err := items.Type.MarshalJSON() - if err != nil { - return nil - } - - scheme := &oaispec.SimpleSchema{Type: string(schemeType)} - opts := []parsers.PrefixRxOption{parsers.WithItemsPrefixLevel(level)} - - return []parsers.TagParser{ - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMaximum", level), parsers.NewSetMaximum(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMinimum", level), parsers.NewSetMinimum(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMultipleOf", level), parsers.NewSetMultipleOf(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMinLength", level), parsers.NewSetMinLength(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMaxLength", level), parsers.NewSetMaxLength(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dPattern", level), parsers.NewSetPattern(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMinItems", level), parsers.NewSetMinItems(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dMaxItems", level), parsers.NewSetMaxItems(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dUnique", level), parsers.NewSetUnique(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dEnum", level), parsers.NewSetEnum(schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dDefault", level), parsers.NewSetDefault(scheme, schemaValidations{items}, opts...)), - parsers.NewSingleLineTagParser(fmt.Sprintf("items%dExample", level), parsers.NewSetExample(scheme, schemaValidations{items}, opts...)), - } -} - -func parseArrayTypes(taggers []parsers.TagParser, expr ast.Expr, items *oaispec.SchemaOrArray, level int) ([]parsers.TagParser, error) { - if items == nil || items.Schema == nil { - return taggers, nil - } - - switch iftpe := expr.(type) { - case *ast.ArrayType: - eleTaggers := itemsTaggers(items.Schema, level) - otherTaggers, err := parseArrayTypes(slices.Concat(eleTaggers, taggers), iftpe.Elt, items.Schema.Items, level+1) - if err != nil { - return nil, err - } - - return otherTaggers, nil - - case *ast.Ident: - var identTaggers []parsers.TagParser - if iftpe.Obj == nil { - identTaggers = itemsTaggers(items.Schema, level) - } - - otherTaggers, err := parseArrayTypes(taggers, expr, items.Schema.Items, level+1) - if err != nil { - return nil, err - } - - return slices.Concat(identTaggers, otherTaggers), nil - - case *ast.StarExpr: - return parseArrayTypes(taggers, iftpe.X, items, level) - - case *ast.SelectorExpr: - // qualified name (e.g. time.Time): terminal leaf, register items-level validations. - return slices.Concat(itemsTaggers(items.Schema, level), taggers), nil - - case *ast.StructType, *ast.InterfaceType, *ast.MapType: - // anonymous struct / interface / map element: no further items-level - // validations apply; the element type itself carries its schema. - return taggers, nil - - default: - return nil, fmt.Errorf("unknown field type element: %w", ErrSchema) - } -} diff --git a/internal/builders/schema/typable.go b/internal/builders/schema/typable.go index ab49562..623d140 100644 --- a/internal/builders/schema/typable.go +++ b/internal/builders/schema/typable.go @@ -6,20 +6,17 @@ package schema import ( "github.com/go-openapi/codescan/internal/builders/resolvers" "github.com/go-openapi/codescan/internal/ifaces" - "github.com/go-openapi/codescan/internal/parsers" oaispec "github.com/go-openapi/spec" ) -var _ ifaces.ValidationBuilder = &schemaValidations{} - type Typable struct { schema *oaispec.Schema level int skipExt bool } -func NewTypable(schema *oaispec.Schema, level int, skipExt bool) Typable { - return Typable{ +func NewTypable(schema *oaispec.Schema, level int, skipExt bool) *Typable { + return &Typable{ schema: schema, level: level, skipExt: skipExt, @@ -32,7 +29,7 @@ func (st Typable) Typed(tpe, format string) { st.schema.Typed(tpe, format) } -func (st Typable) SetRef(ref oaispec.Ref) { // TODO(fred/claude): isn't it a bug? Setter on non-pointer receiver? +func (st *Typable) SetRef(ref oaispec.Ref) { st.schema.Ref = ref } @@ -41,19 +38,19 @@ func (st Typable) Schema() *oaispec.Schema { } //nolint:ireturn // polymorphic by design -func (st Typable) Items() ifaces.SwaggerTypable { +func (st *Typable) Items() ifaces.SwaggerTypable { if st.schema.Items == nil { - st.schema.Items = new(oaispec.SchemaOrArray) // TODO(fred/claude): isn't it a bug? Setter on non-pointer receiver? + st.schema.Items = new(oaispec.SchemaOrArray) } if st.schema.Items.Schema == nil { - st.schema.Items.Schema = new(oaispec.Schema) // TODO(fred/claude): isn't it a bug? Setter on non-pointer receiver? + st.schema.Items.Schema = new(oaispec.Schema) } st.schema.Typed("array", "") - return Typable{st.schema.Items.Schema, st.level + 1, st.skipExt} + return &Typable{st.schema.Items.Schema, st.level + 1, st.skipExt} } -func (st Typable) AdditionalProperties() ifaces.SwaggerTypable { //nolint:ireturn // polymorphic by design +func (st Typable) AdditionalProperties() ifaces.SwaggerTypable { if st.schema.AdditionalProperties == nil { st.schema.AdditionalProperties = new(oaispec.SchemaOrBool) } @@ -62,7 +59,7 @@ func (st Typable) AdditionalProperties() ifaces.SwaggerTypable { //nolint:iretur } st.schema.Typed("object", "") - return Typable{st.schema.AdditionalProperties.Schema, st.level + 1, st.skipExt} + return &Typable{st.schema.AdditionalProperties.Schema, st.level + 1, st.skipExt} } func (st Typable) Level() int { return st.level } @@ -79,54 +76,26 @@ func (st Typable) WithEnumDescription(desc string) { if desc == "" { return } - st.AddExtension(parsers.EnumDescExtension(), desc) + st.AddExtension(resolvers.ExtEnumDesc, desc) } func BodyTypable(in string, schema *oaispec.Schema, skipExt bool) (ifaces.SwaggerTypable, *oaispec.Schema) { //nolint:ireturn // polymorphic by design - if in == "body" { - // get the schema for items on the schema property - if schema == nil { - schema = new(oaispec.Schema) - } - if schema.Items == nil { - schema.Items = new(oaispec.SchemaOrArray) - } - if schema.Items.Schema == nil { - schema.Items.Schema = new(oaispec.Schema) - } - schema.Typed("array", "") - return Typable{schema.Items.Schema, 1, skipExt}, schema + if in != "body" { + return nil, nil // notice that nil,nil does not correspond to a "nil error", but rather to a nil schema. } - return nil, nil -} - -type schemaValidations struct { - current *oaispec.Schema -} + // get the schema for items on the schema property + if schema == nil { + schema = new(oaispec.Schema) + } + if schema.Items == nil { + schema.Items = new(oaispec.SchemaOrArray) + } + if schema.Items.Schema == nil { + schema.Items.Schema = new(oaispec.Schema) + } + schema.Typed("array", "") -func (sv schemaValidations) SetMaximum(val float64, exclusive bool) { - sv.current.Maximum = &val - sv.current.ExclusiveMaximum = exclusive + return &Typable{schema.Items.Schema, 1, skipExt}, schema } -func (sv schemaValidations) SetMinimum(val float64, exclusive bool) { - sv.current.Minimum = &val - sv.current.ExclusiveMinimum = exclusive -} -func (sv schemaValidations) SetMultipleOf(val float64) { sv.current.MultipleOf = &val } -func (sv schemaValidations) SetMinItems(val int64) { sv.current.MinItems = &val } -func (sv schemaValidations) SetMaxItems(val int64) { sv.current.MaxItems = &val } -func (sv schemaValidations) SetMinLength(val int64) { sv.current.MinLength = &val } -func (sv schemaValidations) SetMaxLength(val int64) { sv.current.MaxLength = &val } -func (sv schemaValidations) SetPattern(val string) { sv.current.Pattern = val } -func (sv schemaValidations) SetUnique(val bool) { sv.current.UniqueItems = val } -func (sv schemaValidations) SetDefault(val any) { sv.current.Default = val } -func (sv schemaValidations) SetExample(val any) { sv.current.Example = val } -func (sv schemaValidations) SetEnum(val string) { - var typ string - if len(sv.current.Type) > 0 { - typ = sv.current.Type[0] - } - sv.current.Enum = parsers.ParseEnum(val, &oaispec.SimpleSchema{Format: sv.current.Format, Type: typ}) -} diff --git a/internal/builders/schema/walker.go b/internal/builders/schema/walker.go new file mode 100644 index 0000000..74f7af0 --- /dev/null +++ b/internal/builders/schema/walker.go @@ -0,0 +1,312 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/ast" + + "github.com/go-openapi/codescan/internal/builders/handlers" + "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/builders/validations" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/scanner/classify" + oaispec "github.com/go-openapi/spec" +) + +// applyBlockToDecl is the grammar entry point for a top-level model +// declaration. Parses the doc, short-circuits on swagger:ignore, writes +// title/description, then dispatches schema-level properties via the +// Walker. +// +// Returns true when the block's primary annotation is swagger:ignore; +// the caller short-circuits further building. +func (s *Builder) applyDeclCommentBlock(schema *oaispec.Schema) (skip bool) { + block := s.ParseBlock(s.Decl.Comments) + // `swagger:ignore` only short-circuits when it is the FIRST + // annotation on the comment group. 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() + if enumDesc := resolvers.GetEnumDesc(schema.Extensions); enumDesc != "" { + if schema.Description != "" { + schema.Description += "\n" + } + schema.Description += enumDesc + } + + handlers.DispatchSchemaLevel0(block, schema, schema, "", s.RecordDiagnostic, s.schemaOpts()) + + 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. +func (s *Builder) applyBlockToField(afld *ast.Field, enclosing *oaispec.Schema, ps *oaispec.Schema, name string) { + block := s.ParseBlock(afld.Doc) + + if ps.Ref.String() != "" { + s.applyToRefField(block, enclosing, ps, name) + return + } + + ps.Description = block.Prose() + if enumDesc := resolvers.GetEnumDesc(ps.Extensions); enumDesc != "" { + if ps.Description != "" { + ps.Description += "\n" + } + ps.Description += enumDesc + } + + handlers.DispatchSchemaLevel0(block, enclosing, ps, name, s.RecordDiagnostic, s.schemaOpts()) + + // 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 { + handlers.DispatchSchemaItemsLevel(block, target, depth+1, s.RecordDiagnostic, s.schemaOpts()) + } + } +} + +// 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. +// +// # Details +// +// See [§ref-override](./README.md#ref-override) — JSON-Schema-draft-4 +// shape, per-keyword landing rules, the DescWithRef toggle, and the +// description-only edge case. +func (s *Builder) applyToRefField(block grammar.Block, enclosing, ps *oaispec.Schema, name string) { + originalRef := ps.Ref + + c := &refOverrideCollector{builder: s, enclosing: enclosing, name: name} + c.valid = handlers.NewSchemaValidations(&c.override) + + block.Walk(grammar.Walker{ + FilterDepth: 0, + Number: c.onNumber, + Integer: c.onInteger, + Bool: c.onBool, + String: c.onString, + Raw: c.onRaw, + Extension: c.onExtension, + Diagnostic: s.RecordDiagnostic, + }) + + description := block.Prose() + + if !c.anyCollected() && description == "" { + return + } + if !c.anyCollected() && !s.Ctx.DescWithRef() { + return + } + + // Lift x-* siblings onto the outer compound (see §ref-override). + liftedExtensions := c.override.Extensions + c.override.Extensions = nil + + allOf := []oaispec.Schema{ + {SchemaProps: oaispec.SchemaProps{Ref: originalRef}}, + } + if c.collectedValidation { + allOf = append(allOf, c.override) + } + *ps = oaispec.Schema{ + VendorExtensible: oaispec.VendorExtensible{Extensions: liftedExtensions}, + SchemaProps: oaispec.SchemaProps{ + Description: description, + AllOf: allOf, + }, + } +} + +// refOverrideCollector accumulates field-level overrides into a +// scratch schema for the allOf compound rewrite. +// +// # Details +// +// See [§ref-override](./README.md#ref-override) — collector role, +// the two flags (`collectedValidation`, `collectedExtension`) and +// the lift-onto-outer behaviour for vendor extensions. +type refOverrideCollector struct { + builder *Builder + enclosing *oaispec.Schema + name string + override oaispec.Schema + valid handlers.SchemaValidations + collectedValidation bool + collectedExtension bool +} + +func (c *refOverrideCollector) anyCollected() bool { + return c.collectedValidation || c.collectedExtension +} + +func (c *refOverrideCollector) markValidation() { c.collectedValidation = true } +func (c *refOverrideCollector) markExtension() { c.collectedExtension = true } + +func (c *refOverrideCollector) onNumber(p grammar.Property, val float64, exclusive bool) { + if !p.IsTyped() { + return + } + switch p.Keyword.Name { + case grammar.KwMaximum: + c.valid.SetMaximum(val, exclusive) + c.markValidation() + case grammar.KwMinimum: + c.valid.SetMinimum(val, exclusive) + c.markValidation() + case grammar.KwMultipleOf: + c.valid.SetMultipleOf(val) + c.markValidation() + } +} + +func (c *refOverrideCollector) onInteger(p grammar.Property, val int64) { + if !p.IsTyped() { + return + } + switch p.Keyword.Name { + case grammar.KwMinLength: + c.valid.SetMinLength(val) + c.markValidation() + case grammar.KwMaxLength: + c.valid.SetMaxLength(val) + c.markValidation() + case grammar.KwMinItems: + c.valid.SetMinItems(val) + c.markValidation() + case grammar.KwMaxItems: + c.valid.SetMaxItems(val) + c.markValidation() + } +} + +func (c *refOverrideCollector) onBool(p grammar.Property, val bool) { + if !p.IsTyped() { + return + } + switch p.Keyword.Name { + case grammar.KwRequired: + if c.name != "" { + handlers.SetRequired(c.enclosing, c.name, val) + } + case grammar.KwReadOnly: + c.override.ReadOnly = val + c.markValidation() + case grammar.KwUnique: + c.valid.SetUnique(val) + c.markValidation() + } +} + +func (c *refOverrideCollector) onString(p grammar.Property, val string) { + switch p.Keyword.Name { + case grammar.KwPattern: + handlers.ApplyPattern(p, c.valid, val, c.builder.RecordDiagnostic) + c.markValidation() + case grammar.KwDefault: + if v, err := validations.ParseDefault(val, handlers.SchemaTypeOf(&c.override), c.override.Format); err == nil { + c.valid.SetDefault(v) + c.markValidation() + } + case grammar.KwExample: + if v, err := validations.ParseDefault(val, handlers.SchemaTypeOf(&c.override), c.override.Format); err == nil { + c.valid.SetExample(v) + c.markValidation() + } + case grammar.KwEnum: + c.valid.SetEnum(val) + c.markValidation() + } +} + +// onExtension applies one YAML-typed Extension entry onto the +// refOverride's compound and marks the collector so the outer caller +// emits an allOf wrap. Allowed-extension filtering matches the +// schema-level handler; user-authored extensions are not gated by +// SkipExtensions — 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 + } + c.override.AddExtension(ext.Name, ext.Value) + c.markExtension() +} + +func (c *refOverrideCollector) onRaw(p grammar.Property) { + switch p.Keyword.Name { + case grammar.KwDefault: + if v, err := validations.ParseDefault(p.Value, handlers.SchemaTypeOf(&c.override), c.override.Format); err == nil { + c.valid.SetDefault(v) + c.markValidation() + } + case grammar.KwExample: + if v, err := validations.ParseDefault(p.Value, handlers.SchemaTypeOf(&c.override), c.override.Format); err == nil { + c.valid.SetExample(v) + c.markValidation() + } + case grammar.KwEnum: + c.valid.SetEnum(p.Value) + c.markValidation() + } +} + +// 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 + for schemaItems != nil && schemaItems.Schema != nil { + out = append(out, schemaItems.Schema) + switch e := elt.(type) { + case *ast.ArrayType: + elt = e.Elt + schemaItems = schemaItems.Schema.Items + case *ast.Ident: + if e.Obj == nil { + schemaItems = schemaItems.Schema.Items + continue + } + return out + case *ast.StarExpr: + elt = e.X + case *ast.SelectorExpr: + return out + case *ast.StructType, *ast.InterfaceType, *ast.MapType: + out = out[:len(out)-1] + return out + default: + return out + } + } + return out +} diff --git a/internal/builders/schema/walker_classifiers.go b/internal/builders/schema/walker_classifiers.go new file mode 100644 index 0000000..c5215e0 --- /dev/null +++ b/internal/builders/schema/walker_classifiers.go @@ -0,0 +1,317 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/ast" + "go/types" + "log" + "reflect" + "strings" + + "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/ifaces" + "github.com/go-openapi/codescan/internal/logger" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "golang.org/x/tools/go/packages" +) + +// findAnnotationArg returns the first positional argument of the +// first Block of the given annotation kind in cg, filtered to +// non-empty single-word arguments and read through the ParseBlocks +// cache. +// +// # Details +// +// See [§classifier-walkers](./README.md#classifier-walkers) — the +// single-word filter's rationale and the named-basic prose-trap +// fixture it protects against. +func (s *Builder) findAnnotationArg(cg *ast.CommentGroup, kind grammar.AnnotationKind) (string, bool) { + for _, b := range s.ParseBlocks(cg) { + if b.AnnotationKind() != kind { + continue + } + arg, ok := b.AnnotationArg() + if !ok { + continue + } + if strings.ContainsAny(arg, " \t") { + continue + } + return arg, true + } + return "", false +} + +// Per-call-site classifier walkers. +// +// One walker per call site; each documents in its godoc which +// `swagger:` annotations it consumes. +// +// # Details +// +// See [§classifier-walkers](./README.md#classifier-walkers) — the +// design rationale, the walker inventory table, and the +// `findAnnotationArg` single-word filter contract that backs the +// kind-specific lookups. + +// classifierTextMarshal is the named-type walker fired at the +// text-marshal short-circuit (`buildFromTextMarshal` end-of-pipe). +// Consumes only `swagger:strfmt`. On match writes +// `{string, }` to tgt and returns true. +func (s *Builder) classifierTextMarshal(tpe types.Type, tgt ifaces.SwaggerTypable) (resolved bool) { + decl, ok := s.Ctx.DeclForType(tpe) + if !ok || decl == nil { + return false + } + + if name, ok := s.findAnnotationArg(decl.Comments, grammar.AnnStrfmt); ok { + tgt.Typed("string", name) + return true + } + + return false +} + +// classifierNamedTypeOverride is the named-type walker fired in +// `buildFromType`'s named-fallback and `buildFromStruct`'s pre- +// pass. Consumes only `swagger:type` (the explicit type-override +// annotation). On match attempts SwaggerSchemaForType and +// reports back via the (handled, fallthrough) tuple: +// +// - handled=true, fallthrough=false → caller returns nil +// - handled=false, fallthrough=false → no swagger:type present +// - handled=true, fallthrough=true → unrecognised type-ref +// value (caller should resolve via the underlying type) +func (s *Builder) classifierNamedTypeOverride(cg *ast.CommentGroup, tgt ifaces.SwaggerTypable) (handled, fallthroughUnderlying bool) { + name, ok := s.findAnnotationArg(cg, grammar.AnnType) + if !ok { + return false, false + } + if err := resolvers.SwaggerSchemaForType(name, tgt); err == nil { + return true, false + } + // Unsupported swagger:type value (e.g. "array") — caller falls + // through to the underlying type so the full schema (including + // items for slices) is properly built. + return true, true +} + +// classifierNamedBasic is the named-type walker for +// `buildNamedBasic`. Consumes a cascade of classifier +// annotations in source-priority order: +// +// swagger:strfmt → swagger:enum → swagger:default → +// swagger:type → swagger:alias +// +// The final arm is the "primitive-inline" branch: it fires when +// either (a) the builder is in SimpleSchema mode (the M1 contract +// for non-body parameters and response headers — `$ref` forbidden by +// OAS v2 so the underlying primitive ships inline) or (b) the user +// has explicitly opted in via `swagger:alias` on the decl. These two +// triggers are intentionally orthogonal — the SimpleSchema flag is +// caller-driven and covers query/path/header/formData uniformly, +// while `swagger:alias` is a per-type author override that bypasses +// the model-ref pipeline regardless of mode. +// +// Returns: +// - handled=true → caller returns nil (target written, terminal) +// - handled=false → no classifier matched; caller continues to +// FindModel / SwaggerSchemaForType fallback +func (s *Builder) classifierNamedBasic(cg *ast.CommentGroup, pkg *packages.Package, utitpe *types.Basic, tgt ifaces.SwaggerTypable) (resolved bool) { + if name, ok := s.findAnnotationArg(cg, grammar.AnnStrfmt); ok { + tgt.Typed("string", name) + return true + } + + if enumName, ok := s.findAnnotationArg(cg, grammar.AnnEnum); ok { + enumValues, enumDesces, _ := s.Ctx.FindEnumValues(pkg, enumName) + if len(enumValues) > 0 { + tgt.WithEnum(enumValues...) + enumTypeName := reflect.TypeOf(enumValues[0]).String() + _ = resolvers.SwaggerSchemaForType(enumTypeName, tgt) + if len(enumDesces) > 0 { + tgt.WithEnumDescription(strings.Join(enumDesces, "\n")) + } + return true + } + // swagger:enum with no matching const values. Fall through so + // the type-resolution engine can still decide what to do with + // the underlying Go type (it may be a model, an alias, a + // strfmt, …) rather than dropping the field entirely. + log.Printf("WARNING: swagger:enum %s: no matching const values found; dropping enum semantics", enumName) + } + + if defaultName, ok := s.findAnnotationArg(cg, grammar.AnnDefaultName); ok { + logger.DebugLogf(s.Ctx.Debug(), "default name: %s", defaultName) + return true + } + + if typeName, ok := s.findAnnotationArg(cg, grammar.AnnType); ok { + _ = resolvers.SwaggerSchemaForType(typeName, tgt) + return true + } + + if s.simpleSchema || s.findAnnotation(cg, grammar.AnnAlias) != nil { + if err := resolvers.SwaggerSchemaForType(utitpe.Name(), tgt); err == nil { + return true + } + } + + return false +} + +// classifierNamedArrayLike is the named-type walker shared +// between `buildNamedArray` and `buildNamedSlice`. Both have the +// same classifier surface — `swagger:strfmt` and `swagger:type` — +// with subtly different strfmt fall-throughs (array honors a +// "bsonobjectid" special case the slice doesn't). The boolean +// `forSlice` switches that arm; the rest is identical. +// +// Returns: +// - handled=true, err=nil → caller returns nil +// - handled=true, err!=nil → unrecognised swagger:type → caller +// should fall through to inline the element type +// - handled=false, err=nil → no classifier matched +func (s *Builder) classifierNamedArrayLike(cg *ast.CommentGroup, tgt ifaces.SwaggerTypable, forSlice bool) (handled bool, fallthroughElement bool) { + if sfnm, isf := s.findAnnotationArg(cg, grammar.AnnStrfmt); isf { + if sfnm == "byte" { + tgt.Typed("string", sfnm) + return true, false + } + if !forSlice && sfnm == "bsonobjectid" { + tgt.Typed("string", sfnm) + return true, false + } + tgt.Items().Typed("string", sfnm) + return true, false + } + + // When swagger:type is set to an unsupported value (e.g. "array"), + // skip the $ref and inline the array/slice schema with the proper + // items type. + if tn, ok := s.findAnnotationArg(cg, grammar.AnnType); ok { + if err := resolvers.SwaggerSchemaForType(tn, tgt); err != nil { + return true, true + } + return true, false + } + + return false, false +} + +// classifierAliasTargetStrfmt is the named-type walker fired +// from `buildNamedAllOf`'s struct branch — checks the alias's +// target type's docstring for `swagger:strfmt`. On match writes +// `{string, }` to schema and returns true. +func (s *Builder) classifierAliasTargetStrfmt(tpe types.Type, tgt ifaces.SwaggerTypable) bool { + decl, ok := s.Ctx.DeclForType(tpe) + if !ok || decl == nil { + return false + } + if name, ok := s.findAnnotationArg(decl.Comments, grammar.AnnStrfmt); ok { + tgt.Typed("string", name) + return true + } + return false +} + +// fieldDoc is the field-level FieldWalker output: every +// classifier signal a struct field / interface method might +// carry, pre-extracted in a single pass over the field's +// ParseBlocks slice. Consumed by the four field-level call +// patterns documented on scanFieldDoc. +type fieldDoc struct { + // Ignored — bare `swagger:ignore` presence (the field / + // method should be skipped entirely). + Ignored bool + // JSONName — argument of `swagger:name ` (rename the + // JSON property name). Empty when the annotation is absent + // or its arg is empty / multi-word. + JSONName string + // StrfmtName — argument of `swagger:strfmt ` (the + // string format to inline). Empty when the annotation is + // absent or its arg is empty / multi-word. + StrfmtName string + // TypeOverride — argument of `swagger:type ` at the + // field level. Empty when absent or multi-word. See + // [§user-overrides](./README.md#user-overrides). + TypeOverride string + // IsAllOfMember — bare `swagger:allOf` presence (treat + // this embedded type as an allOf compound member). + IsAllOfMember bool + // AllOfClass — argument of `swagger:allOf ` (the + // discriminator class for x-class). Empty for bare + // `swagger:allOf`. + AllOfClass string +} + +// scanFieldDoc inspects afld's docstring through the ParseBlocks +// cache and returns every field-level classifier signal in one pass. +// +// Consumes: swagger:ignore / swagger:name / swagger:strfmt / +// swagger:type / swagger:allOf. The AnnType arm carries an inline +// single-word filter — see [§user-overrides](./README.md#user-overrides) +// for why. +func (s *Builder) scanFieldDoc(afld *ast.Field) fieldDoc { + var fd fieldDoc + if afld == nil { + return fd + } + for _, b := range s.ParseBlocks(afld.Doc) { + switch b.AnnotationKind() { //nolint:exhaustive // field-level walker only consumes these five kinds + case grammar.AnnIgnore: + fd.Ignored = true + case grammar.AnnName: + if name, ok := b.AnnotationArg(); ok { + fd.JSONName = name + } + case grammar.AnnStrfmt: + if name, ok := b.AnnotationArg(); ok { + fd.StrfmtName = name + } + case grammar.AnnType: + if name, ok := b.AnnotationArg(); ok && !strings.ContainsAny(name, " \t") { + fd.TypeOverride = name + } + case grammar.AnnAllOf: + fd.IsAllOfMember = true + if name, ok := b.AnnotationArg(); ok { + fd.AllOfClass = name + } + } + } + return fd +} + +// classifierStructPreBuildType is the named-type walker fired +// at the top of `buildFromStruct`. Consumes only `swagger:type`. +// On match attempts SwaggerSchemaForType (errors are swallowed — +// 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 { + return false + } + _ = resolvers.SwaggerSchemaForType(name, tgt) + return true +} + +// classifierNamedStructStrfmt is the named-type walker fired +// from `buildNamedStruct`'s strfmt-first branch. Checks the +// struct's docstring for `swagger:strfmt`; on match writes +// `{string, }` to tgt and returns true. +// +// Pre-FindModel call site: matters because FindModel registers +// the type in ExtraModels as a side effect; running this walker +// first prevents an orphan top-level definition for a strfmt- +// inlined type. +func (s *Builder) classifierNamedStructStrfmt(cg *ast.CommentGroup, tgt ifaces.SwaggerTypable) (resolved bool) { + if name, ok := s.findAnnotationArg(cg, grammar.AnnStrfmt); ok { + tgt.Typed("string", name) + return true + } + return false +} From 3d6ac1116e74b56dbeb8d0850ba4fa2797fe50ac Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:38:16 +0200 Subject: [PATCH 12/22] feat(builders/parameters,responses): annotation builders Parses swagger:parameters and swagger:response struct declarations, dispatching each field through the schema builder in full or SimpleSchema mode based on the in: location. Parameters and responses share Walker dispatch, the Typable adapter, and the doc-signal classifier. Body parameters and body responses delegate fully to the schema builder; non-body parameters and response headers use SimpleSchema; swagger:file gates the file-upload form. Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- internal/builders/parameters/README.md | 220 ++++++++++++++ internal/builders/parameters/doc_signals.go | 127 ++++++++ internal/builders/parameters/parameters.go | 221 +++++++------- .../builders/parameters/parameters_test.go | 110 ++++--- internal/builders/parameters/taggers.go | 58 ---- internal/builders/parameters/typable.go | 43 ++- internal/builders/parameters/walker.go | 96 +++++++ internal/builders/parameters/walker_test.go | 179 ++++++++++++ internal/builders/responses/README.md | 271 ++++++++++++++++++ internal/builders/responses/doc_signals.go | 133 +++++++++ internal/builders/responses/responses.go | 267 +++++++++-------- internal/builders/responses/responses_test.go | 80 ++++-- internal/builders/responses/taggers.go | 48 ---- internal/builders/responses/typable.go | 84 ++---- internal/builders/responses/walker.go | 95 ++++++ internal/builders/responses/walker_test.go | 164 +++++++++++ internal/parsers/responses.go | 224 --------------- internal/parsers/responses_test.go | 264 ----------------- 18 files changed, 1709 insertions(+), 975 deletions(-) create mode 100644 internal/builders/parameters/README.md create mode 100644 internal/builders/parameters/doc_signals.go delete mode 100644 internal/builders/parameters/taggers.go create mode 100644 internal/builders/parameters/walker.go create mode 100644 internal/builders/parameters/walker_test.go create mode 100644 internal/builders/responses/README.md create mode 100644 internal/builders/responses/doc_signals.go delete mode 100644 internal/builders/responses/taggers.go create mode 100644 internal/builders/responses/walker.go create mode 100644 internal/builders/responses/walker_test.go delete mode 100644 internal/parsers/responses.go delete mode 100644 internal/parsers/responses_test.go diff --git a/internal/builders/parameters/README.md b/internal/builders/parameters/README.md new file mode 100644 index 0000000..eac8478 --- /dev/null +++ b/internal/builders/parameters/README.md @@ -0,0 +1,220 @@ +# `internal/builders/parameters` — maintainers' guide + +Builds OAS v2 parameter entries (`swagger:parameters`) and writes them +onto matching operations. One `Builder` per declaration; one +Walk pass per field doc-comment. + +## Sections + +- [§overview](#overview) — package shape and per-file responsibilities +- [§builder](#builder) — `Builder`, `Build`, the build chain +- [§in-discriminator](#in-discriminator) — how `in:` is read and what it gates +- [§dispatch](#dispatch) — Walker handlers wiring at level-0 and items level +- [§typable](#typable) — `paramTypable`, `SimpleSchemaProbe`, body vs non-body +- [§simple-schema-handoff](#simple-schema-handoff) — when the schema builder runs in SimpleSchema mode +- [§quirks-history](#quirks-history) — resolved quirks (Stream M) + +--- + +## §overview — files and responsibilities + +| File | Contents | +|------|----------| +| `parameters.go` | `Builder`, build chain (`Build` → `buildFromType` → `buildNamedType` / `buildAlias` → `buildFromStruct` → `processParamField` → `buildFromField*`), `buildOption` helper | +| `doc_signals.go` | `fieldDocSignals` + `scanFieldDocSignals`: pre-walk extraction of `in:`, `swagger:ignore`, `swagger:file`, `swagger:strfmt` from a field's doc comment | +| `walker.go` | `applyBlockToField` + `walkParamLevel` + `walkItemsLevel`: the grammar Walker dispatch for per-field validations / extensions | +| `typable.go` | `paramTypable` (the `ifaces.SwaggerTypable` adapter) + `paramValidations` (the `ifaces.OperationValidationBuilder` adapter) + `SimpleSchemaProbe` | +| `errors.go` | `ErrParameters` sentinel | + +The builder embeds `*common.Builder` (Ctx, Decl, PostDeclarations, +diagnostic sink, ParseBlocks cache, MakeRef). See +[../common](../common) for the common-builder rationale. + +## §builder — the build chain + +`Build(operations)` iterates over the declaration's `swagger:parameters +` arguments — one struct can attach to many operations — and +calls `buildFromType` for each. The chain unwraps pointers, dispatches +named types and aliases, and ultimately reaches `buildFromStruct` +which walks the struct fields. + +For each non-embedded exported field, `processParamField` runs the +following ordered steps: + +1. Find the AST field via `resolvers.FindASTField` (no AST → skip). +2. Pre-scan the doc-comment signals via `scanFieldDocSignals` (uses + the `common.Builder` parse cache so `applyBlockToField`'s later + walk hits the same parse result). +3. If `swagger:ignore` is present → skip. +4. Resolve the JSON tag name. If ignored (`json:"-"`) → skip. +5. Pick the `in:` location (default `query`; see [§in-discriminator](#in-discriminator)). +6. Build the field's type into the parameter via `buildFromField` — + the schema builder runs in SimpleSchema mode unless `in==body` + (see [§simple-schema-handoff](#simple-schema-handoff)). +7. Apply `swagger:strfmt ` override when set (collapses the + resolved shape to `string + format`). +8. Walk the doc-comment block to apply description, validations, + `required:`, vendor extensions, and items-level validations + (see [§dispatch](#dispatch)). +9. Force `required: true` for `in: path` (OAS-mandated). +10. Append `x-go-name` extension when the JSON tag differs from the + Go field name. + +The order matters: pre-scanning the doc signals BEFORE building lets +the dispatch pick the right `in:` and forces the SimpleSchema mode +correctly; applying the block AFTER the type build lets validations +override the resolved defaults. + +## §in-discriminator — reading `in:` and what it gates + +`in:` is the OAS v2 location discriminator — +`query | path | header | body | formData` (closed vocabulary, see +`validParamIn` in `doc_signals.go`). It drives three downstream +decisions: + +- **Schema vs SimpleSchema mode**: `in==body` ⇒ full Schema build; + any other `in` ⇒ SimpleSchema build (see [§simple-schema-handoff](#simple-schema-handoff)). +- **File handling**: `in==formData` + `swagger:file` ⇒ shape collapses + to `type: file` (no further build). +- **Path required**: `in==path` ⇒ `required: true` forced after + building, regardless of what the block said. + +### Why line-scan instead of property? + +`scanFieldDocSignals` reads `in:` by **scanning the doc text line by +line**, not by reading it as a grammar Property. Reason: grammar +attaches pre-annotation lines (e.g. `in: formData` preceding a +`swagger:file` annotation) to the annotation block's prose, not to +its property list. A direct line scan picks up `in:` regardless of +which side of an annotation it appears on. The line scan mirrors +v1's `rxIn` regex semantics: + +``` +[Ii]n\p{Zs}*:\p{Zs}*(query|path|header|body|formData)(?:\.)?$ +``` + +Default when `in:` is absent: `query` (OAS v2 convention). + +## §dispatch — Walker dispatch at level-0 and items level + +`applyBlockToField` is the per-field entry point. It runs three +phases on the parsed grammar block: + +1. **Prose** → `param.Description` (with `x-go-enum-desc` lift via + `resolvers.GetEnumDesc`). +2. **Level-0 dispatch** → `walkParamLevel` → `dispatchParamLevel0`, + which wires Walker callbacks via the `handlers` package: + - `Number` → `handlers.Number(valid)` (maximum / minimum / multipleOf) + - `Integer` → `handlers.Integer(valid)` (minLength / maxLength / minItems / maxItems) + - `Bool` → `ComposeBool(UniqueBool, paramRequiredBool)` — splits + `uniqueItems` (parameter-side validation) from `required:` + (writes straight onto `param.Required`) + - `String` → `ComposeString(PatternString, CollectionFormatString)` + — pattern + collectionFormat + - `Raw` → `handlers.Raw(valid, scheme, errSink)` — + `default:` / `example:` / `enum:` as raw bodies. `errSink` + captures the first parse error so malformed default/example + surface as a build error (see `TestMalformed_DefaultInt` / + `TestMalformed_ExampleInt` integration tests) + - `Extension` → `handlers.Extension(param)` — pre-typed YAML + extensions land directly on the parameter +3. **Items-level dispatch** → `walkItemsLevel` for each + `(level, items)` pair returned by `collectParamItemsLevels` + (1-indexed depths matching grammar's `Property.ItemsDepth`). + Named/aliased array types opt out — parity with v1. + +`dispatchParamLevel0` is standalone (not a method) so unit tests can +drive it without constructing a full `Builder`. + +### Why `required:` is parameter-specific + +Schema writes `required:` onto the **enclosing schema's** Required +slice keyed by name (because a struct field's required-ness belongs +to the parent type, not the field). Parameters write `required:` +straight onto `param.Required`. Headers don't carry `required:` at +all — the OAS v2 Header object simply doesn't have the field. + +## §typable — `paramTypable`, body vs non-body + +`paramTypable` adapts a `*spec.Parameter` to `ifaces.SwaggerTypable`. +Two shapes share the type: + +- **Body parameter** (`In == "body"`): `Schema()` returns a real + `*spec.Schema`; type writes go to `param.Schema`, not to the + parameter's SimpleSchema. `AddExtension` lands on the schema. +- **Non-body** (path / query / header / formData): `Schema()` + returns nil; type writes go to the parameter's embedded + `SimpleSchema`. `Items()` builds the items chain on the parameter + side (not on a body schema). + +`Items()` switches on `param.In`: under `body`, returns a +`schema.BodyTypable` that walks down `param.Schema.Items`; under +non-body, returns an `items.NewTypable` chain that walks +`param.Items` directly. The body / non-body split is the same +fundamental gate as everywhere else in this package. + +### `SimpleSchemaProbe` implementation + +`paramTypable` implements the `schema.SimpleSchemaProbe` interface so +the schema builder can validate the SimpleSchema outcome after its +internal build: + +- `SimpleSchemaShape() *oaispec.SimpleSchema` — returns the embedded + SimpleSchema so the exit validator can inspect Type / Format +- `HasRef()` — non-empty `Ref` is a violation (SimpleSchema forbids + `$ref`) +- `ResetForViolation()` — wipes SimpleSchema and Ref back to empty + so the resulting spec is honest about the failed resolution + +## §simple-schema-handoff — SimpleSchema mode delegation + +`buildOption(tpe, typable)` returns the `schema.Build` option matching +the typable's `In()`: + +- `In() == body` ⇒ `schema.WithType(tpe, typable)` — full Schema + build +- otherwise ⇒ `schema.WithSimpleSchema(tpe, typable, typable.In())` — + SimpleSchema build with the `in` carried for keyword gating + +Centralised in `buildOption` so every `buildFromFieldXxx` call site +picks the same shape uniformly. The schema builder enforces the +SimpleSchema vocabulary via `handlers.IsSimpleSchemaKeyword` (see +[../schema/README.md#simple-schema-mode](../schema/README.md#simple-schema-mode) +for the keyword surface). + +### Alias handling — when to `$ref` vs expand + +`buildFieldAlias` carries a gate: + +```go +if typable.In() != inBody || !p.Ctx.RefAliases() { + unaliased := types.Unalias(tpe) + return p.buildFromField(fld, unaliased, typable, seen) +} +``` + +Non-body parameters can't carry `$ref` (SimpleSchema forbids it), so +they always expand the alias. Body parameters honour `RefAliases`: +when set, the alias becomes a `$ref` to its target via +`common.Builder.MakeRef`; otherwise the underlying is expanded. + +## §quirks-history — resolved quirks + +The Stream M merge-readiness pass closed a handful of subtle quirks +in this builder. Recorded for archaeology — none should resurface +under the current dispatch. + +- **`x-go-name` parity**: only emit when the JSON tag differs from + the Go field name. Pre-Stream M this was sometimes emitted on + aliases even when names matched. +- **`required:` on path parameters**: forced post-build, after the + block walk. A user-authored `required: false` on a path parameter + is overridden — OAS v2 requires path params to be required. +- **`swagger:strfmt` collapse**: when set, the field's resolved + shape collapses to `string + format`, clearing `Ref` and `Items`. + This is the single point where strfmt overrides the resolved + build. +- **Pre-walk doc signal cache**: `scanFieldDocSignals` calls + `p.ParseBlocks(afld.Doc)` which hits the `common.Builder` cache; + the later `applyBlockToField` reads the same cache entry. One + parse per comment group. diff --git a/internal/builders/parameters/doc_signals.go b/internal/builders/parameters/doc_signals.go new file mode 100644 index 0000000..c87d6ff --- /dev/null +++ b/internal/builders/parameters/doc_signals.go @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package parameters + +import ( + "go/ast" + "strings" + + "github.com/go-openapi/codescan/internal/parsers/grammar" +) + +// fieldDocSignals carries the per-field doc-comment signals the +// parameter dispatcher reads upstream of the schema build: the +// `in:` location, presence of `swagger:ignore`, presence of +// `swagger:file`, and the `swagger:strfmt` argument when set. +// Replaces the four v1 regex helpers (parsers.ParamLocation / +// parsers.FileParam / parsers.StrfmtName / parsers.Ignored) with +// grammar lookups plus a small `in:` line scan. +type fieldDocSignals struct { + in string + inSet bool + ignored bool + file bool + strfmt string + strfmtSet bool +} + +// Parameter-location constants. The set matches v1's `rxIn` regex +// alternation and OAS v2's parameter-location vocabulary. +const ( + inQuery = "query" + inPath = "path" + inHeader = "header" + inFormData = "formData" +) + +// validParamIn enumerates the closed-vocabulary `in:` values the +// scanner accepts. +// +//nolint:gochecknoglobals // closed-vocabulary lookup table; one allocation, read-only. +var validParamIn = map[string]struct{}{ + inQuery: {}, + inPath: {}, + inHeader: {}, + inBody: {}, + inFormData: {}, +} + +// scanFieldDocSignals reads every signal the parameter dispatcher +// needs out of a pre-parsed block slice and the raw doc text. +// Callers should pass `p.ParseBlocks(afld.Doc)` so the +// common.Builder cache absorbs the parse cost. +// +// Returns the zero value when doc is nil. +// +// # Details +// +// See [§in-discriminator](./README.md#in-discriminator) — why `in:` +// is line-scanned rather than read as a grammar Property. +func scanFieldDocSignals(blocks []grammar.Block, doc *ast.CommentGroup) fieldDocSignals { + var pd fieldDocSignals + if doc == nil { + return pd + } + + for _, b := range blocks { + switch b.AnnotationKind() { //nolint:exhaustive // only ignore/file/strfmt are relevant here + case grammar.AnnIgnore: + pd.ignored = true + case grammar.AnnFile: + pd.file = true + case grammar.AnnStrfmt: + if arg, ok := b.AnnotationArg(); ok && !strings.ContainsAny(arg, " \t") { + pd.strfmt = arg + pd.strfmtSet = true + } + } + } + + if v, ok := scanInLocation(doc.Text()); ok { + pd.in = v + pd.inSet = true + } + + return pd +} + +// scanInLocation finds the first `in: X` (case-insensitive on `in`) +// line in text where X is one of the closed-vocabulary parameter +// locations. Mirrors v1's `rxIn` semantics: +// +// regexp: `[Ii]n\p{Zs}*:\p{Zs}*(query|path|header|body|formData)(?:\.)?$` +func scanInLocation(text string) (string, bool) { + for line := range strings.SplitSeq(text, "\n") { + line = strings.TrimSpace(line) + rest, ok := strings.CutPrefix(line, "in:") + if !ok { + rest, ok = strings.CutPrefix(line, "In:") + } + if !ok { + continue + } + v := strings.TrimSpace(rest) + v = strings.TrimSuffix(v, ".") + if _, ok := validParamIn[v]; ok { + return v, true + } + } + return "", false +} + +// strfmtFromDoc returns the argument of a `swagger:strfmt ` +// annotation present in blocks (the pre-parsed common.Builder cache +// slice for some CommentGroup). Single-word filter mirrors the +// schema package's `findAnnotationArg` rule. +func strfmtFromDoc(blocks []grammar.Block) (string, bool) { + for _, b := range blocks { + if b.AnnotationKind() != grammar.AnnStrfmt { + continue + } + if arg, ok := b.AnnotationArg(); ok && !strings.ContainsAny(arg, " \t") { + return arg, true + } + } + return "", false +} diff --git a/internal/builders/parameters/parameters.go b/internal/builders/parameters/parameters.go index 1ee8be6..b345334 100644 --- a/internal/builders/parameters/parameters.go +++ b/internal/builders/parameters/parameters.go @@ -7,43 +7,48 @@ import ( "fmt" "go/types" + "github.com/go-openapi/codescan/internal/builders/common" "github.com/go-openapi/codescan/internal/builders/resolvers" "github.com/go-openapi/codescan/internal/builders/schema" "github.com/go-openapi/codescan/internal/ifaces" "github.com/go-openapi/codescan/internal/logger" - "github.com/go-openapi/codescan/internal/parsers" "github.com/go-openapi/codescan/internal/scanner" oaispec "github.com/go-openapi/spec" ) const inBody = "body" -type ParameterBuilder struct { - ctx *scanner.ScanCtx - decl *scanner.EntityDecl - postDecls []*scanner.EntityDecl +// Builder constructs OAS v2 parameter entries for one +// `swagger:parameters` declaration and writes them onto the matching +// operations. Embeds *common.Builder for shared state (Ctx, Decl, +// PostDeclarations, diagnostics, ParseBlocks cache). +type Builder struct { + *common.Builder } -func NewBuilder(ctx *scanner.ScanCtx, decl *scanner.EntityDecl) *ParameterBuilder { - return &ParameterBuilder{ - ctx: ctx, - decl: decl, +// NewBuilder constructs an initialized [Builder] bound to +// ctx and decl. The embedded common.Builder owns the diagnostic +// sink, the post-declaration list, and the per-comment-group parse +// cache. +func NewBuilder(ctx *scanner.ScanCtx, decl *scanner.EntityDecl) *Builder { + return &Builder{ + Builder: common.New(ctx, decl), } } -func (p *ParameterBuilder) Build(operations map[string]*oaispec.Operation) error { +func (p *Builder) Build(operations map[string]*oaispec.Operation) error { // check if there is a swagger:parameters tag that is followed by one or more words, // these words are the ids of the operations this parameter struct applies to // once type name is found convert it to a schema, by looking up the schema in the // parameters dictionary that got passed into this parse method - for _, opid := range p.decl.OperationIDs() { + for _, opid := range p.Decl.OperationIDs() { operation, ok := operations[opid] if !ok { operation = new(oaispec.Operation) operations[opid] = operation operation.ID = opid } - logger.DebugLogf(p.ctx.Debug(), "building parameters for: %s", opid) + logger.DebugLogf(p.Ctx.Debug(), "building parameters for: %s", opid) // analyze struct body for fields etc // each exported struct field: @@ -52,7 +57,7 @@ func (p *ParameterBuilder) Build(operations map[string]*oaispec.Operation) error // * has to document the validations that apply for the type and the field // * when the struct field points to a model it becomes a ref: #/definitions/ModelName // * comments that aren't tags is used as the description - if err := p.buildFromType(p.decl.ObjType(), operation, make(map[string]oaispec.Parameter)); err != nil { + if err := p.buildFromType(p.Decl.ObjType(), operation, make(map[string]oaispec.Parameter)); err != nil { return err } } @@ -60,25 +65,21 @@ func (p *ParameterBuilder) Build(operations map[string]*oaispec.Operation) error return nil } -func (p *ParameterBuilder) PostDeclarations() []*scanner.EntityDecl { - return p.postDecls -} - -func (p *ParameterBuilder) buildFromType(otpe types.Type, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { +func (p *Builder) buildFromType(otpe types.Type, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { switch tpe := otpe.(type) { case *types.Pointer: return p.buildFromType(tpe.Elem(), op, seen) case *types.Named: return p.buildNamedType(tpe, op, seen) case *types.Alias: - logger.DebugLogf(p.ctx.Debug(), "alias(parameters.buildFromType): got alias %v to %v", tpe, tpe.Rhs()) + logger.DebugLogf(p.Ctx.Debug(), "alias(parameters.buildFromType): got alias %v to %v", tpe, tpe.Rhs()) return p.buildAlias(tpe, op, seen) default: return fmt.Errorf("unhandled type (%T): %s: %w", otpe, tpe.String(), ErrParameters) } } -func (p *ParameterBuilder) buildNamedType(tpe *types.Named, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { +func (p *Builder) buildNamedType(tpe *types.Named, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { o := tpe.Obj() if resolvers.IsAny(o) || resolvers.IsStdError(o) { return fmt.Errorf("%s type not supported in the context of a parameters section definition: %w", o.Name(), ErrParameters) @@ -87,18 +88,18 @@ func (p *ParameterBuilder) buildNamedType(tpe *types.Named, op *oaispec.Operatio switch stpe := o.Type().Underlying().(type) { case *types.Struct: - logger.DebugLogf(p.ctx.Debug(), "build from named type %s: %T", o.Name(), tpe) - if decl, found := p.ctx.DeclForType(o.Type()); found { + logger.DebugLogf(p.Ctx.Debug(), "build from named type %s: %T", o.Name(), tpe) + if decl, found := p.Ctx.DeclForType(o.Type()); found { return p.buildFromStruct(decl, stpe, op, seen) } - return p.buildFromStruct(p.decl, stpe, op, seen) + return p.buildFromStruct(p.Decl, stpe, op, seen) default: return fmt.Errorf("unhandled type (%T): %s: %w", stpe, o.Type().Underlying().String(), ErrParameters) } } -func (p *ParameterBuilder) buildAlias(tpe *types.Alias, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { +func (p *Builder) buildAlias(tpe *types.Alias, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { o := tpe.Obj() if resolvers.IsAny(o) || resolvers.IsStdError(o) { return fmt.Errorf("%s type not supported in the context of a parameters section definition: %w", o.Name(), ErrParameters) @@ -109,15 +110,15 @@ func (p *ParameterBuilder) buildAlias(tpe *types.Alias, op *oaispec.Operation, s rhs := tpe.Rhs() // If transparent aliases are enabled, use the underlying type directly without creating a definition - if p.ctx.TransparentAliases() { + if p.Ctx.TransparentAliases() { return p.buildFromType(rhs, op, seen) } - decl, ok := p.ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, ok := p.Ctx.FindModel(o.Pkg().Path(), o.Name()) if !ok { return fmt.Errorf("can't find source file for aliased type: %v -> %v: %w", tpe, rhs, ErrParameters) } - p.postDecls = append(p.postDecls, decl) // mark the left-hand side as discovered + p.AppendPostDecl(decl) // mark the left-hand side as discovered switch rtpe := rhs.(type) { // load declaration for named unaliased type @@ -126,28 +127,28 @@ func (p *ParameterBuilder) buildAlias(tpe *types.Alias, op *oaispec.Operation, s if o.Pkg() == nil { break // builtin } - decl, found := p.ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, found := p.Ctx.FindModel(o.Pkg().Path(), o.Name()) if !found { return fmt.Errorf("can't find source file for target type of alias: %v -> %v: %w", tpe, rtpe, ErrParameters) } - p.postDecls = append(p.postDecls, decl) + p.AppendPostDecl(decl) case *types.Alias: o := rtpe.Obj() if o.Pkg() == nil { break // builtin } - decl, found := p.ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, found := p.Ctx.FindModel(o.Pkg().Path(), o.Name()) if !found { return fmt.Errorf("can't find source file for target type of alias: %v -> %v: %w", tpe, rtpe, ErrParameters) } - p.postDecls = append(p.postDecls, decl) + p.AppendPostDecl(decl) } return p.buildFromType(rhs, op, seen) } -func (p *ParameterBuilder) buildFromField(fld *types.Var, tpe types.Type, typable ifaces.SwaggerTypable, seen map[string]oaispec.Parameter) error { - logger.DebugLogf(p.ctx.Debug(), "build from field %s: %T", fld.Name(), tpe) +func (p *Builder) buildFromField(fld *types.Var, tpe types.Type, typable ifaces.SwaggerTypable, seen map[string]oaispec.Parameter) error { + logger.DebugLogf(p.Ctx.Debug(), "build from field %s: %T", fld.Name(), tpe) switch ftpe := tpe.(type) { case *types.Basic: @@ -167,49 +168,69 @@ func (p *ParameterBuilder) buildFromField(fld *types.Var, tpe types.Type, typabl case *types.Named: return p.buildNamedField(ftpe, typable) case *types.Alias: - logger.DebugLogf(p.ctx.Debug(), "alias(parameters.buildFromField): got alias %v to %v", ftpe, ftpe.Rhs()) + logger.DebugLogf(p.Ctx.Debug(), "alias(parameters.buildFromField): got alias %v to %v", ftpe, ftpe.Rhs()) return p.buildFieldAlias(ftpe, typable, fld, seen) default: return fmt.Errorf("unknown type for %s: %T: %w", fld.String(), fld.Type(), ErrParameters) } } -func (p *ParameterBuilder) buildFromFieldStruct(tpe *types.Struct, typable ifaces.SwaggerTypable) error { - sb := schema.NewBuilder(p.ctx, p.decl) - if err := sb.BuildFromType(tpe, typable); err != nil { +func (p *Builder) buildFromFieldStruct(tpe *types.Struct, typable ifaces.SwaggerTypable) error { + sb := schema.NewBuilder(p.Ctx, p.Decl) + if err := sb.Build(schema.OptionFor(tpe, typable)); err != nil { return err } - p.postDecls = append(p.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + p.AppendPostDecl(d) + } return nil } -func (p *ParameterBuilder) buildFromFieldMap(ftpe *types.Map, typable ifaces.SwaggerTypable) error { +func (p *Builder) buildFromFieldMap(ftpe *types.Map, typable ifaces.SwaggerTypable) error { + // Map fields are only legal under in=body — paramTypable.Schema() + // returns nil for non-body. No SimpleSchema variant needed. sch := new(oaispec.Schema) typable.Schema().Typed("object", "").AdditionalProperties = &oaispec.SchemaOrBool{ Schema: sch, } - sb := schema.NewBuilder(p.ctx, p.decl) - if err := sb.BuildFromType(ftpe.Elem(), schema.NewTypable(sch, typable.Level()+1, p.ctx.SkipExtensions())); err != nil { + sb := schema.NewBuilder(p.Ctx, p.Decl) + if err := sb.Build(schema.WithType( + ftpe.Elem(), + schema.NewTypable(sch, typable.Level()+1, p.Ctx.SkipExtensions())), + ); err != nil { return err } + // Propagate the sub-builder's PostDeclarations so a model + // discovered only through the map's value type (no + // swagger:model annotation, no other reference site) makes it + // into the spec's definitions section. Every sibling + // buildFromFieldXxx method does the same; this loop went + // missing in M2.5's schema-builder factor-out — see the + // parameters-map-postdecl fixture. + for _, d := range sb.PostDeclarations() { + p.AppendPostDecl(d) + } + return nil } -func (p *ParameterBuilder) buildFromFieldInterface(tpe *types.Interface, typable ifaces.SwaggerTypable) error { - sb := schema.NewBuilder(p.ctx, p.decl) - if err := sb.BuildFromType(tpe, typable); err != nil { +func (p *Builder) buildFromFieldInterface(tpe *types.Interface, typable ifaces.SwaggerTypable) error { + sb := schema.NewBuilder(p.Ctx, p.Decl) + if err := sb.Build(schema.OptionFor(tpe, typable)); err != nil { return err } - p.postDecls = append(p.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + p.AppendPostDecl(d) + } return nil } -func (p *ParameterBuilder) buildNamedField(ftpe *types.Named, typable ifaces.SwaggerTypable) error { +func (p *Builder) buildNamedField(ftpe *types.Named, typable ifaces.SwaggerTypable) error { o := ftpe.Obj() if resolvers.IsAny(o) { // e.g. Field interface{} or Field any @@ -220,7 +241,7 @@ func (p *ParameterBuilder) buildNamedField(ftpe *types.Named, typable ifaces.Swa } resolvers.MustNotBeABuiltinType(o) - decl, found := p.ctx.DeclForType(o.Type()) + decl, found := p.Ctx.DeclForType(o.Type()) if !found { return fmt.Errorf("unable to find package and source file for: %s: %w", ftpe.String(), ErrParameters) } @@ -230,23 +251,25 @@ func (p *ParameterBuilder) buildNamedField(ftpe *types.Named, typable ifaces.Swa return nil } - if sfnm, isf := parsers.StrfmtName(decl.Comments); isf { + if sfnm, isf := strfmtFromDoc(p.ParseBlocks(decl.Comments)); isf { typable.Typed("string", sfnm) return nil } - sb := schema.NewBuilder(p.ctx, decl) + sb := schema.NewBuilder(p.Ctx, decl) sb.InferNames() - if err := sb.BuildFromType(decl.ObjType(), typable); err != nil { + if err := sb.Build(schema.OptionFor(decl.ObjType(), typable)); err != nil { return err } - p.postDecls = append(p.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + p.AppendPostDecl(d) + } return nil } -func (p *ParameterBuilder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypable, fld *types.Var, seen map[string]oaispec.Parameter) error { +func (p *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypable, fld *types.Var, seen map[string]oaispec.Parameter) error { o := tpe.Obj() if resolvers.IsAny(o) { // e.g. Field interface{} or Field any @@ -263,22 +286,24 @@ func (p *ParameterBuilder) buildFieldAlias(tpe *types.Alias, typable ifaces.Swag rhs := tpe.Rhs() // If transparent aliases are enabled, use the underlying type directly without creating a definition - if p.ctx.TransparentAliases() { - sb := schema.NewBuilder(p.ctx, p.decl) - if err := sb.BuildFromType(rhs, typable); err != nil { + if p.Ctx.TransparentAliases() { + sb := schema.NewBuilder(p.Ctx, p.Decl) + if err := sb.Build(schema.OptionFor(rhs, typable)); err != nil { return err } - p.postDecls = append(p.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + p.AppendPostDecl(d) + } return nil } - decl, ok := p.ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, ok := p.Ctx.FindModel(o.Pkg().Path(), o.Name()) if !ok { return fmt.Errorf("can't find source file for aliased type: %v -> %v: %w", tpe, rhs, ErrParameters) } - p.postDecls = append(p.postDecls, decl) // mark the left-hand side as discovered + p.AppendPostDecl(decl) // mark the left-hand side as discovered - if typable.In() != inBody || !p.ctx.RefAliases() { + if typable.In() != inBody || !p.Ctx.RefAliases() { // if ref option is disabled, and always for non-body parameters: just expand the alias unaliased := types.Unalias(tpe) return p.buildFromField(fld, unaliased, typable, seen) @@ -293,31 +318,31 @@ func (p *ParameterBuilder) buildFieldAlias(tpe *types.Alias, typable ifaces.Swag break // builtin } - decl, found := p.ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, found := p.Ctx.FindModel(o.Pkg().Path(), o.Name()) if !found { return fmt.Errorf("can't find source file for target type of alias: %v -> %v: %w", tpe, rtpe, ErrParameters) } - return p.makeRef(decl, typable) + return p.MakeRef(decl, typable) case *types.Alias: o := rtpe.Obj() if o.Pkg() == nil { break // builtin } - decl, found := p.ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, found := p.Ctx.FindModel(o.Pkg().Path(), o.Name()) if !found { return fmt.Errorf("can't find source file for target type of alias: %v -> %v: %w", tpe, rtpe, ErrParameters) } - return p.makeRef(decl, typable) + return p.MakeRef(decl, typable) } // anonymous type: just expand it return p.buildFromField(fld, rhs, typable, seen) } -func (p *ParameterBuilder) buildFromStruct(decl *scanner.EntityDecl, tpe *types.Struct, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { +func (p *Builder) buildFromStruct(decl *scanner.EntityDecl, tpe *types.Struct, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { numFields := tpe.NumFields() if numFields == 0 { @@ -359,19 +384,21 @@ func (p *ParameterBuilder) buildFromStruct(decl *scanner.EntityDecl, tpe *types. // processParamField processes a single non-embedded struct field for parameter building. // Returns the parameter name if the field was processed, or "" if it was skipped. -func (p *ParameterBuilder) processParamField(fld *types.Var, decl *scanner.EntityDecl, seen map[string]oaispec.Parameter) (string, error) { +func (p *Builder) processParamField(fld *types.Var, decl *scanner.EntityDecl, seen map[string]oaispec.Parameter) (string, error) { if !fld.Exported() { - logger.DebugLogf(p.ctx.Debug(), "skipping field %s because it's not exported", fld.Name()) + logger.DebugLogf(p.Ctx.Debug(), "skipping field %s because it's not exported", fld.Name()) return "", nil } afld := resolvers.FindASTField(decl.File, fld.Pos()) if afld == nil { - logger.DebugLogf(p.ctx.Debug(), "can't find source associated with %s", fld.String()) + logger.DebugLogf(p.Ctx.Debug(), "can't find source associated with %s", fld.String()) return "", nil } - if parsers.Ignored(afld.Doc) { + signals := scanFieldDocSignals(p.ParseBlocks(afld.Doc), afld.Doc) + + if signals.ignored { return "", nil } @@ -384,50 +411,30 @@ func (p *ParameterBuilder) processParamField(fld *types.Var, decl *scanner.Entit } in := "query" - // scan for param location first, this changes some behavior down the line - if afld.Doc != nil { - inOverride, ok := parsers.ParamLocation(afld.Doc) - if ok { - in = inOverride - } + if signals.inSet { + in = signals.in } ps := seen[name] ps.In = in - var pty ifaces.SwaggerTypable = paramTypable{&ps, p.ctx.SkipExtensions()} + var pty ifaces.SwaggerTypable = paramTypable{&ps, p.Ctx.SkipExtensions()} if in == inBody { - pty = schema.NewTypable(pty.Schema(), 0, p.ctx.SkipExtensions()) + pty = schema.NewTypable(pty.Schema(), 0, p.Ctx.SkipExtensions()) } - if in == "formData" && afld.Doc != nil && parsers.FileParam(afld.Doc) { + if in == "formData" && signals.file { pty.Typed("file", "") } else if err := p.buildFromField(fld, fld.Type(), pty, seen); err != nil { return "", err } - if strfmtName, ok := parsers.StrfmtName(afld.Doc); ok { - ps.Typed("string", strfmtName) + if signals.strfmtSet { + ps.Typed("string", signals.strfmt) ps.Ref = oaispec.Ref{} ps.Items = nil } - taggers, err := setupParamTaggers(&ps, name, afld, p.ctx.SkipExtensions(), p.ctx.Debug()) - if err != nil { - return "", err - } - - sp := parsers.NewSectionedParser( - parsers.WithSetDescription(func(lines []string) { - ps.Description = parsers.JoinDropLast(lines) - enumDesc := parsers.GetEnumDesc(ps.Extensions) - if enumDesc != "" { - ps.Description += "\n" + enumDesc - } - }), - parsers.WithTaggers(taggers...), - ) - - if err := sp.Parse(afld.Doc); err != nil { + if err := p.applyBlockToField(afld, &ps); err != nil { return "", err } if ps.In == "path" { @@ -439,30 +446,10 @@ func (p *ParameterBuilder) processParamField(fld *types.Var, decl *scanner.Entit } if name != fld.Name() { - resolvers.AddExtension(&ps.VendorExtensible, "x-go-name", fld.Name(), p.ctx.SkipExtensions()) + resolvers.AddExtension(&ps.VendorExtensible, "x-go-name", fld.Name(), p.Ctx.SkipExtensions()) } seen[name] = ps return name, nil } -func (p *ParameterBuilder) makeRef(decl *scanner.EntityDecl, prop ifaces.SwaggerTypable) error { - nm, _ := decl.Names() - ref, err := oaispec.NewRef("#/definitions/" + nm) - if err != nil { - return err - } - - prop.SetRef(ref) - p.postDecls = append(p.postDecls, decl) // mark the $ref target as discovered - - return nil -} - -func spExtensionsSetter(ps *oaispec.Parameter, skipExt bool) func(*oaispec.Extensions) { - return func(exts *oaispec.Extensions) { - for name, value := range *exts { - resolvers.AddExtension(&ps.VendorExtensible, name, value, skipExt) - } - } -} diff --git a/internal/builders/parameters/parameters_test.go b/internal/builders/parameters/parameters_test.go index 1e4fb95..31af113 100644 --- a/internal/builders/parameters/parameters_test.go +++ b/internal/builders/parameters/parameters_test.go @@ -11,7 +11,7 @@ import ( "github.com/go-openapi/testify/v2/assert" "github.com/go-openapi/testify/v2/require" - "github.com/go-openapi/spec" + oaispec "github.com/go-openapi/spec" ) const ( @@ -45,7 +45,7 @@ func getParameter(sctx *scanner.ScanCtx, nm string) *scanner.EntityDecl { func TestScanFileParam(t *testing.T) { sctx := scantest.LoadClassificationPkgsCtx(t) - operations := make(map[string]*spec.Operation) + operations := make(map[string]*oaispec.Operation) paramNames := []string{ "OrderBodyParams", "MultipleOrderParams", "ComplexerOneParams", "NoParams", "NoParamsAlias", "MyFileParams", "MyFuncFileParams", "EmbeddedFileParams", @@ -54,10 +54,7 @@ func TestScanFileParam(t *testing.T) { td := getParameter(sctx, rn) require.NotNil(t, td) - prs := &ParameterBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(operations)) } assert.Len(t, operations, 10) @@ -97,9 +94,65 @@ func TestScanFileParam(t *testing.T) { scantest.CompareOrDumpJSON(t, operations, "classification_params_file.json") } +// TestParamsParser_OptionVariants captures (SkipExtensions, +// DescWithRef) option permutations on the classification operations +// corpus into separately-named goldens. The same set of parameter +// names as TestParamsParser is built per combination, exercising the +// $ref'd-field shape on each option pair: +// +// - Default (false/false): description-only $ref'd fields render +// bare (matches v1 strict). +// - DescWithRef=true: description-only $ref'd fields render as +// single-arm allOf preserving the description. +// - SkipExtensions=true: scanner-derived x-go-name suppressed. +// +// The matching CompareOrDumpJSON files diverge wherever a $ref'd +// field carries field-level decoration. Validation overrides keep +// the allOf wrap regardless. +func TestParamsParser_OptionVariants(t *testing.T) { + cases := []struct { + name string + skipExt bool + descRef bool + goldenFile string + }{ + {"default", false, false, "classification_params.json"}, + {"DescWithRef", false, true, "classification_params_descwithref.json"}, + {"SkipExt", true, false, "classification_params_skipext.json"}, + {"SkipExt+DescWithRef", true, true, "classification_params_skipext_descwithref.json"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sctx, err := scanner.NewScanCtx(&scanner.Options{ + Packages: []string{ + "./goparsing/classification", + "./goparsing/classification/models", + "./goparsing/classification/operations", + }, + WorkDir: scantest.FixturesDir(), + SkipExtensions: tc.skipExt, + DescWithRef: tc.descRef, + }) + require.NoError(t, err) + operations := make(map[string]*oaispec.Operation) + paramNames := []string{ + "OrderBodyParams", "MultipleOrderParams", "ComplexerOneParams", "NoParams", + "NoParamsAlias", "MyFileParams", "MyFuncFileParams", "EmbeddedFileParams", + } + for _, rn := range paramNames { + td := getParameter(sctx, rn) + require.NotNil(t, td) + prs := NewBuilder(sctx, td) + require.NoError(t, prs.Build(operations)) + } + scantest.CompareOrDumpJSON(t, operations, tc.goldenFile) + }) + } +} + func TestParamsParser(t *testing.T) { sctx := scantest.LoadClassificationPkgsCtx(t) - operations := make(map[string]*spec.Operation) + operations := make(map[string]*oaispec.Operation) paramNames := []string{ "OrderBodyParams", "MultipleOrderParams", "ComplexerOneParams", "NoParams", "NoParamsAlias", "MyFileParams", "MyFuncFileParams", "EmbeddedFileParams", @@ -107,10 +160,7 @@ func TestParamsParser(t *testing.T) { for _, rn := range paramNames { td := getParameter(sctx, rn) - prs := &ParameterBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(operations)) } @@ -154,7 +204,7 @@ func TestParamsParser(t *testing.T) { scantest.CompareOrDumpJSON(t, operations, "classification_params.json") } -func assertYetAnotherOperationParams(t *testing.T, operations map[string]*spec.Operation) { +func assertYetAnotherOperationParams(t *testing.T, operations map[string]*oaispec.Operation) { t.Helper() cr, okParam := operations["yetAnotherOperation"] require.TrueT(t, okParam) @@ -191,7 +241,7 @@ func assertYetAnotherOperationParams(t *testing.T, operations map[string]*spec.O } } -func assertSomeOperationParams(t *testing.T, operations map[string]*spec.Operation) { +func assertSomeOperationParams(t *testing.T, operations map[string]*oaispec.Operation) { t.Helper() op, okParam := operations["someOperation"] assert.TrueT(t, okParam) @@ -410,7 +460,7 @@ func assertSomeOperationParams(t *testing.T, operations map[string]*spec.Operati } } -func assertAnotherOperationParamOrder(t *testing.T, operations map[string]*spec.Operation) { +func assertAnotherOperationParamOrder(t *testing.T, operations map[string]*oaispec.Operation) { t.Helper() order, ok := operations["anotherOperation"] @@ -451,7 +501,7 @@ func assertAnotherOperationParamOrder(t *testing.T, operations map[string]*spec. } } -func assertSomeAliasOperationParams(t *testing.T, operations map[string]*spec.Operation) { +func assertSomeAliasOperationParams(t *testing.T, operations map[string]*oaispec.Operation) { t.Helper() aliasOp, ok := operations["someAliasOperation"] @@ -498,18 +548,15 @@ func TestParamsParser_TransparentAliases(t *testing.T) { require.NotNil(t, td) // Build the operation map from the transparent alias fixtures. - operations := make(map[string]*spec.Operation) - prs := &ParameterBuilder{ - ctx: sctx, - decl: td, - } + operations := make(map[string]*oaispec.Operation) + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(operations)) op, ok := operations["transparentAlias"] require.TrueT(t, ok) require.Len(t, op.Parameters, 2) - var bodyParam, queryParam *spec.Parameter + var bodyParam, queryParam *oaispec.Parameter for i := range op.Parameters { p := &op.Parameters[i] switch p.In { @@ -543,12 +590,9 @@ func TestParamsParser_TransparentAliases(t *testing.T) { func TestParameterParser_Issue2007(t *testing.T) { sctx := scantest.LoadClassificationPkgsCtx(t) - operations := make(map[string]*spec.Operation) + operations := make(map[string]*oaispec.Operation) td := getParameter(sctx, "SetConfiguration") - prs := &ParameterBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(operations)) op := operations["getConfiguration"] @@ -567,12 +611,9 @@ func TestParameterParser_Issue2007(t *testing.T) { func TestParameterParser_Issue2011(t *testing.T) { sctx := scantest.LoadClassificationPkgsCtx(t) - operations := make(map[string]*spec.Operation) + operations := make(map[string]*oaispec.Operation) td := getParameter(sctx, "NumPlates") - prs := &ParameterBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(operations)) op := operations["putNumPlate"] @@ -586,12 +627,9 @@ func TestParameterParser_Issue2011(t *testing.T) { func TestGo118ParameterParser_Issue2011(t *testing.T) { sctx := scantest.LoadGo118ClassificationPkgsCtx(t) - operations := make(map[string]*spec.Operation) + operations := make(map[string]*oaispec.Operation) td := getParameter(sctx, "NumPlates") - prs := &ParameterBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(operations)) op := operations["putNumPlate"] diff --git a/internal/builders/parameters/taggers.go b/internal/builders/parameters/taggers.go deleted file mode 100644 index 273daa7..0000000 --- a/internal/builders/parameters/taggers.go +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parameters - -import ( - "go/ast" - "slices" - - "github.com/go-openapi/codescan/internal/builders/items" - "github.com/go-openapi/codescan/internal/parsers" - oaispec "github.com/go-openapi/spec" -) - -func setupParamTaggers(param *oaispec.Parameter, name string, afld *ast.Field, skipExt, debug bool) ([]parsers.TagParser, error) { - // Parameter-level $ref (e.g. {$ref: "#/parameters/X"}) is not emitted by - // the scanner today — named struct fields become body params with a - // schema-level ref (ps.Schema.Ref), never ps.Ref. To support - // operation-level parameter refs, branch here on - // `param.Ref.String() != ""` and dispatch to a narrower tagger set - // (in, required, extensions only). - return setupInlineParamTaggers(param, name, afld, skipExt, debug) -} - -// baseInlineParamTaggers configures taggers for a fully-defined inline parameter. -func baseInlineParamTaggers(param *oaispec.Parameter, skipExt, debug bool) []parsers.TagParser { - return []parsers.TagParser{ - parsers.NewSingleLineTagParser("in", parsers.NewMatchParamIn(param)), - parsers.NewSingleLineTagParser("maximum", parsers.NewSetMaximum(paramValidations{param})), - parsers.NewSingleLineTagParser("minimum", parsers.NewSetMinimum(paramValidations{param})), - parsers.NewSingleLineTagParser("multipleOf", parsers.NewSetMultipleOf(paramValidations{param})), - parsers.NewSingleLineTagParser("minLength", parsers.NewSetMinLength(paramValidations{param})), - parsers.NewSingleLineTagParser("maxLength", parsers.NewSetMaxLength(paramValidations{param})), - parsers.NewSingleLineTagParser("pattern", parsers.NewSetPattern(paramValidations{param})), - parsers.NewSingleLineTagParser("collectionFormat", parsers.NewSetCollectionFormat(paramValidations{param})), - parsers.NewSingleLineTagParser("minItems", parsers.NewSetMinItems(paramValidations{param})), - parsers.NewSingleLineTagParser("maxItems", parsers.NewSetMaxItems(paramValidations{param})), - parsers.NewSingleLineTagParser("unique", parsers.NewSetUnique(paramValidations{param})), - parsers.NewSingleLineTagParser("enum", parsers.NewSetEnum(paramValidations{param})), - parsers.NewSingleLineTagParser("default", parsers.NewSetDefault(¶m.SimpleSchema, paramValidations{param})), - parsers.NewSingleLineTagParser("example", parsers.NewSetExample(¶m.SimpleSchema, paramValidations{param})), - parsers.NewSingleLineTagParser("required", parsers.NewSetRequiredParam(param)), - parsers.NewMultiLineTagParser("Extensions", parsers.NewSetExtensions(spExtensionsSetter(param, skipExt), debug), true), - } -} - -func setupInlineParamTaggers(param *oaispec.Parameter, name string, afld *ast.Field, skipExt, debug bool) ([]parsers.TagParser, error) { - // TODO(claude): don't understand why we need this step. Isn't it handled by the recursion already? - if ftped, ok := afld.Type.(*ast.ArrayType); ok { - taggers, err := items.ParseArrayTypes([]parsers.TagParser{}, name, ftped.Elt, param.Items, 0) - if err != nil { - return nil, err - } - return slices.Concat(taggers, baseInlineParamTaggers(param, skipExt, debug)), nil - } - - return baseInlineParamTaggers(param, skipExt, debug), nil -} diff --git a/internal/builders/parameters/typable.go b/internal/builders/parameters/typable.go index cb27df5..d598369 100644 --- a/internal/builders/parameters/typable.go +++ b/internal/builders/parameters/typable.go @@ -4,15 +4,12 @@ package parameters import ( - "github.com/go-openapi/codescan/internal/builders/items" + "github.com/go-openapi/codescan/internal/builders/resolvers" "github.com/go-openapi/codescan/internal/builders/schema" "github.com/go-openapi/codescan/internal/ifaces" - "github.com/go-openapi/codescan/internal/parsers" oaispec "github.com/go-openapi/spec" ) -var _ ifaces.OperationValidationBuilder = ¶mValidations{} - type paramTypable struct { param *oaispec.Parameter skipExt bool @@ -41,7 +38,7 @@ func (pt paramTypable) Items() ifaces.SwaggerTypable { //nolint:ireturn // polym pt.param.Items = new(oaispec.Items) } pt.param.Type = "array" - return items.NewTypable(pt.param.Items, 1, pt.param.In) + return resolvers.NewItemsTypable(pt.param.Items, 1, pt.param.In) } func (pt paramTypable) Schema() *oaispec.Schema { @@ -70,32 +67,24 @@ func (pt paramTypable) WithEnumDescription(desc string) { if desc == "" { return } - pt.param.AddExtension(parsers.EnumDescExtension(), desc) + pt.param.AddExtension(resolvers.ExtEnumDesc, desc) } -type paramValidations struct { - current *oaispec.Parameter +// SimpleSchemaShape satisfies schema.SimpleSchemaProbe. See +// [§typable](./README.md#typable). +func (pt paramTypable) SimpleSchemaShape() *oaispec.SimpleSchema { + return &pt.param.SimpleSchema } -func (sv paramValidations) SetMaximum(val float64, exclusive bool) { - sv.current.Maximum = &val - sv.current.ExclusiveMaximum = exclusive +// HasRef satisfies schema.SimpleSchemaProbe. SimpleSchema forbids +// $ref; a non-empty Ref signals a violation. +func (pt paramTypable) HasRef() bool { + return pt.param.Ref.String() != "" } -func (sv paramValidations) SetMinimum(val float64, exclusive bool) { - sv.current.Minimum = &val - sv.current.ExclusiveMinimum = exclusive -} -func (sv paramValidations) SetMultipleOf(val float64) { sv.current.MultipleOf = &val } -func (sv paramValidations) SetMinItems(val int64) { sv.current.MinItems = &val } -func (sv paramValidations) SetMaxItems(val int64) { sv.current.MaxItems = &val } -func (sv paramValidations) SetMinLength(val int64) { sv.current.MinLength = &val } -func (sv paramValidations) SetMaxLength(val int64) { sv.current.MaxLength = &val } -func (sv paramValidations) SetPattern(val string) { sv.current.Pattern = val } -func (sv paramValidations) SetUnique(val bool) { sv.current.UniqueItems = val } -func (sv paramValidations) SetCollectionFormat(val string) { sv.current.CollectionFormat = val } -func (sv paramValidations) SetEnum(val string) { - sv.current.Enum = parsers.ParseEnum(val, &oaispec.SimpleSchema{Type: sv.current.Type, Format: sv.current.Format}) +// 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{} } -func (sv paramValidations) SetDefault(val any) { sv.current.Default = val } -func (sv paramValidations) SetExample(val any) { sv.current.Example = val } diff --git a/internal/builders/parameters/walker.go b/internal/builders/parameters/walker.go new file mode 100644 index 0000000..c43db95 --- /dev/null +++ b/internal/builders/parameters/walker.go @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package parameters + +import ( + "go/ast" + + "github.com/go-openapi/codescan/internal/builders/handlers" + "github.com/go-openapi/codescan/internal/builders/resolvers" + oaispec "github.com/go-openapi/spec" +) + +// paramItemsLevelTarget pairs a 1-indexed nesting depth (matching +// grammar.Property.ItemsDepth) with the *oaispec.Items target into +// which validations at that depth must be written. +type paramItemsLevelTarget struct { + level int + items *oaispec.Items +} + +// collectParamItemsLevels walks the AST array layers of a parameter +// field type and returns the (level, items) pairs reachable from +// the param's items chain. Mirrors items.ParseArrayTypes' shape on +// the grammar path; the schema builder handles the Schema-side +// equivalent internally. +// +// Starting level is 1 — `items.maximum:` has ItemsDepth=1 in the +// grammar lexer. Named/aliased array types opt out (parity with +// v1's tagger pipeline). +func collectParamItemsLevels(expr ast.Expr, it *oaispec.Items, level int) []paramItemsLevelTarget { + if it == nil { + return nil + } + + here := paramItemsLevelTarget{level: level, items: it} + + switch e := expr.(type) { + case *ast.ArrayType: + rest := collectParamItemsLevels(e.Elt, it.Items, level+1) + out := make([]paramItemsLevelTarget, 0, 1+len(rest)) + return append(append(out, here), rest...) + + case *ast.Ident: + rest := collectParamItemsLevels(expr, it.Items, level+1) + if e.Obj == nil { + out := make([]paramItemsLevelTarget, 0, 1+len(rest)) + return append(append(out, here), rest...) + } + return rest + + case *ast.StarExpr: + return collectParamItemsLevels(e.X, it, level) + + case *ast.SelectorExpr: + return []paramItemsLevelTarget{here} + + case *ast.StructType, *ast.InterfaceType, *ast.MapType: + return nil + + default: + return nil + } +} + +// applyBlockToField parses afld.Doc through grammar and dispatches +// description, level-0 validations, required flag, vendor extensions, +// and items-level validations into param. +// +// # Details +// +// See [§dispatch](./README.md#dispatch) — the three-phase Walker +// dispatch, why `in:` is resolved upstream, and the items-level +// chain. +func (p *Builder) applyBlockToField(afld *ast.Field, param *oaispec.Parameter) error { + block := p.ParseBlock(afld.Doc) + + param.Description = block.Prose() + if enumDesc := resolvers.GetEnumDesc(param.Extensions); enumDesc != "" { + if param.Description != "" { + param.Description += "\n" + } + param.Description += enumDesc + } + + if err := handlers.DispatchParamLevel0(block, param, p.RecordDiagnostic); err != nil { + return err + } + + if arrayType, ok := afld.Type.(*ast.ArrayType); ok { + for _, tgt := range collectParamItemsLevels(arrayType.Elt, param.Items, 1) { + handlers.DispatchItemsLevel(block, tgt.items, tgt.level, p.RecordDiagnostic) + } + } + return nil +} diff --git a/internal/builders/parameters/walker_test.go b/internal/builders/parameters/walker_test.go new file mode 100644 index 0000000..e9ae753 --- /dev/null +++ b/internal/builders/parameters/walker_test.go @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package parameters + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" + + "github.com/go-openapi/codescan/internal/builders/handlers" + "github.com/go-openapi/codescan/internal/parsers/grammar" + oaispec "github.com/go-openapi/spec" +) + +// ---------- collectParamItemsLevels ---------- + +func fieldType(t *testing.T, expr string) ast.Expr { + t.Helper() + e, err := parser.ParseExpr(expr) + if err != nil { + t.Fatalf("parseExpr %q: %v", expr, err) + } + return e +} + +func arrayTypeElt(t *testing.T, expr string) ast.Expr { + t.Helper() + at, ok := fieldType(t, expr).(*ast.ArrayType) + if !ok { + t.Fatalf("expected ArrayType for %q", expr) + } + return at.Elt +} + +func newItemsChain(depth int) *oaispec.Items { + if depth <= 0 { + return nil + } + root := new(oaispec.Items) + cur := root + for range depth - 1 { + cur.Items = new(oaispec.Items) + cur = cur.Items + } + return root +} + +func TestCollectParamItemsLevelsFlatSlice(t *testing.T) { + it := newItemsChain(1) + got := collectParamItemsLevels(arrayTypeElt(t, "[]string"), it, 1) + if len(got) != 1 || got[0].level != 1 || got[0].items != it { + t.Errorf("[]string: got %+v", got) + } +} + +func TestCollectParamItemsLevelsNestedSlice(t *testing.T) { + it := newItemsChain(2) + got := collectParamItemsLevels(arrayTypeElt(t, "[][]string"), it, 1) + if len(got) != 2 { + t.Fatalf("[][]string: got %d entries", len(got)) + } + if got[0].level != 1 || got[0].items != it { + t.Errorf("level 1: %+v", got[0]) + } + if got[1].level != 2 || got[1].items != it.Items { + t.Errorf("level 2: %+v", got[1]) + } +} + +func TestCollectParamItemsLevelsNilItems(t *testing.T) { + got := collectParamItemsLevels(arrayTypeElt(t, "[]string"), nil, 1) + if len(got) != 0 { + t.Errorf("nil items: got %+v", got) + } +} + +// ---------- dispatchParamLevel0 ---------- + +//nolint:ireturn // grammar.Block is the package's polymorphic return. +func parseParamBody(t *testing.T, body string) grammar.Block { + t.Helper() + p := grammar.NewParser(token.NewFileSet()) + return p.ParseAs(grammar.AnnParameters, body, token.Position{Line: 1}) +} + +func runDispatch(t *testing.T, param *oaispec.Parameter, body string) { + t.Helper() + b := parseParamBody(t, body) + if err := handlers.DispatchParamLevel0(b, param, nil); err != nil { + t.Fatalf("DispatchParamLevel0: %v", err) + } +} + +func TestDispatchParamKeywordNumeric(t *testing.T) { + param := &oaispec.Parameter{} + param.Type = "integer" + runDispatch(t, param, "maximum: <10\nminimum: >=0\nmultipleOf: 2") + + if param.Maximum == nil || *param.Maximum != 10 || !param.ExclusiveMaximum { + t.Errorf("maximum: got (%v, %v), want (10, true)", param.Maximum, param.ExclusiveMaximum) + } + if param.Minimum == nil || *param.Minimum != 0 || param.ExclusiveMinimum { + t.Errorf("minimum: got (%v, %v), want (0, false)", param.Minimum, param.ExclusiveMinimum) + } + if param.MultipleOf == nil || *param.MultipleOf != 2 { + t.Errorf("multipleOf: got %v", param.MultipleOf) + } +} + +func TestDispatchParamKeywordIntegerAndFlags(t *testing.T) { + param := &oaispec.Parameter{} + runDispatch(t, param, "minLength: 3\nmaxLength: 10\nminItems: 1\nmaxItems: 100\nunique: true\nrequired: true") + + if param.MinLength == nil || *param.MinLength != 3 { + t.Errorf("minLength: %v", param.MinLength) + } + if param.MaxLength == nil || *param.MaxLength != 10 { + t.Errorf("maxLength: %v", param.MaxLength) + } + if param.MinItems == nil || *param.MinItems != 1 { + t.Errorf("minItems: %v", param.MinItems) + } + if param.MaxItems == nil || *param.MaxItems != 100 { + t.Errorf("maxItems: %v", param.MaxItems) + } + if !param.UniqueItems { + t.Errorf("unique: want true") + } + if !param.Required { + t.Errorf("required: want true") + } +} + +func TestDispatchParamKeywordPatternAndEnum(t *testing.T) { + param := &oaispec.Parameter{} + param.Type = "string" + runDispatch(t, param, "pattern: ^[a-z]+$\nenum: red, green, blue") + + if param.Pattern != "^[a-z]+$" { + t.Errorf("pattern: %q", param.Pattern) + } + if len(param.Enum) != 3 || param.Enum[0] != "red" { + t.Errorf("enum: %v", param.Enum) + } +} + +func TestDispatchParamKeywordDefaultExampleScheme(t *testing.T) { + param := &oaispec.Parameter{} + param.Type = "integer" + runDispatch(t, param, "default: 42\nexample: 7") + + if param.Default != 42 { + t.Errorf("default: got %v (%T), want 42", param.Default, param.Default) + } + if param.Example != 7 { + t.Errorf("example: got %v (%T), want 7", param.Example, param.Example) + } +} + +func TestDispatchParamKeywordCollectionFormat(t *testing.T) { + param := &oaispec.Parameter{} + runDispatch(t, param, "collectionFormat: multi") + + if param.CollectionFormat != "multi" { + t.Errorf("collectionFormat: %q", param.CollectionFormat) + } +} + +func TestDispatchParamKeywordRequiredFalse(t *testing.T) { + param := &oaispec.Parameter{} + param.Required = true // simulate a prior path-param default + runDispatch(t, param, "required: false") + + if param.Required { + t.Errorf("required: want false after explicit override") + } +} diff --git a/internal/builders/responses/README.md b/internal/builders/responses/README.md new file mode 100644 index 0000000..695116e --- /dev/null +++ b/internal/builders/responses/README.md @@ -0,0 +1,271 @@ +# `internal/builders/responses` — maintainers' guide + +Builds OAS v2 response entries (`swagger:response`) including the +response body and any header fields. One `Builder` per +declaration; one Walk pass per field doc-comment. + +## Sections + +- [§overview](#overview) — package shape and per-file responsibilities +- [§builder](#builder) — `Builder`, `Build`, the build chain +- [§in-discriminator](#in-discriminator) — `in:` as body/header annotation switch +- [§file-body](#file-body) — `swagger:file` and the body-only gate +- [§dispatch](#dispatch) — Walker handlers wiring for headers +- [§typable](#typable) — `responseTypable`, the `refAttempted` mechanism, body vs header +- [§alias-handling](#alias-handling) — when to `$ref` vs expand +- [§quirks-history](#quirks-history) — resolved quirks (Stream M) + +--- + +## §overview — files and responsibilities + +| File | Contents | +|------|----------| +| `responses.go` | `Builder`, build chain (`Build` → `buildFromType` → `buildNamedType` / `buildAlias` → `buildFromStruct` → `processResponseField` → `buildFromField*`), `buildOption` helper | +| `doc_signals.go` | `fieldDocSignals` + `scanFieldDocSignals`: pre-walk extraction of `in:`, `swagger:ignore`, `swagger:file`, `swagger:strfmt` from a field's doc comment; closed-vocabulary `in:` validation | +| `walker.go` | `applyBlockToDecl` (top-level decl), `applyBlockToHeader` + `dispatchHeaderLevel0` + `walkHeaderItemsLevel`: the grammar Walker dispatch for per-header validations / extensions | +| `typable.go` | `responseTypable` (the `ifaces.SwaggerTypable` adapter) + `headerValidations` (the `ifaces.ValidationBuilder` adapter) + `SimpleSchemaProbe` | +| `errors.go` | `ErrResponses` sentinel | + +The builder embeds `*common.Builder` (Ctx, Decl, PostDeclarations, +diagnostic sink, ParseBlocks cache, MakeRef). See +[../common](../common) for the common-builder rationale. + +This package's shape closely mirrors +[../parameters](../parameters) — the chain is structurally the +same. Divergences are called out below; the +common-extraction list for Stream M6 lives in +`.claude/plans/stream-M-grammar-merge-readiness.md`. + +## §builder — the build chain + +`Build(responses)` looks up the response by name (from +`r.Decl.ResponseNames()`), runs `applyBlockToDecl` to capture the +top-level description, then calls `buildFromType` on the declared +type. `buildFromType` unwraps pointers, dispatches named types and +aliases. Unlike parameters, **anonymous types are rejected**: +`responses_test.go` documents the rationale — the top-level +response-as-alias case under default mode is deferred to v2. + +For each non-embedded exported field, `processResponseField` runs: + +1. Find the AST field via `resolvers.FindASTField` (no AST → skip). +2. Pre-scan the doc-comment signals via `scanFieldDocSignals` (uses + the `common.Builder` parse cache). +3. If `swagger:ignore` → skip. +4. Resolve JSON tag name. If `json:"-"` → skip. +5. Resolve `in:` (default `header`; see [§in-discriminator](#in-discriminator)). +6. **File-body gate**: if `swagger:file` AND `in==body` → set + `resp.Schema = {type:"file"}` and skip the field build (see + [§file-body](#file-body)). +7. Otherwise build the field's type into either `resp.Schema` + (body) or a header value (non-body), through `responseTypable`. +8. Apply `swagger:strfmt ` override when set. +9. Walk the doc-comment block via `applyBlockToHeader` (description, + validations, items, extensions — no `required:`). +10. If `in != body`, register the header on `resp.Headers[name]`. + +After all fields are processed, `buildFromStruct` deletes header +entries for fields that were skipped (the `seen` map). + +## §in-discriminator — `in:` as body/header annotation switch + +OAS v2 has **no `in` field on the Response Object** — the location +exists at the parameter level only. This package overloads `in:` on +response fields to tell apart "this field is the body" from "this +field is a header" within a single Go struct: + +- `in: body` → field's type populates `resp.Schema` +- `in: header` (or absent → defaults to `header`) → field becomes + one entry in `resp.Headers` +- `in: query | path | formData` → recognised but unusual; not a + response location per OAS v2. Treated as non-body (header-like) + with no special handling. + +### Default — implicit header + +Pre-Stream M the implicit case fell into the `in != "body"` branch +by accident: an empty string is not `"body"` and so behaved +identically to `header`. Q1 made this default **explicit** in code +— `inHeader` is assigned when `!signals.inSet`. Observable behaviour +is unchanged; the implicit fall-through is gone. + +### Invalid `in:` values + +An `in:` line with a non-vocabulary value (e.g. `in: cookie`) emits +a `CodeInvalidAnnotation` warning via `Warnf` and **defaults to +header**. Author misuse surfaces in diagnostics without breaking the +build. + +### Why line-scan instead of property + +Same reason as parameters — `in:` may appear on either side of an +annotation. See +[../parameters/README.md#in-discriminator](../parameters/README.md#in-discriminator). + +## §file-body — `swagger:file` annotation + +`swagger:file` on a response field marks the entire response body as +a file payload (image, PDF, raw bytes). Per OAS v2, the allowed +**header** types are `{string, number, integer, boolean, array}` — +`file` is **forbidden on a header**. The annotation must therefore +land on the body field; on a header it is misuse. + +### The Q3 gate + +Pre-Q3 the file branch fired unconditionally and rewrote +`resp.Schema = {file, ""}` even when `in != body`, silently +corrupting the body schema from a header-positioned annotation. +Q3 gates the branch on `in == inBody`: + +```go +useFileBody := signals.file && in == inBody +``` + +When `signals.file` fires under a non-body `in`, the dispatcher +emits a `CodeUnsupportedInSimpleSchema` warning and falls through +to the regular field build, treating the field like any other +header. The body schema is untouched. + +## §dispatch — Walker dispatch for headers + +`applyBlockToHeader` is the per-field entry point for header +fields. Three phases: + +1. **Prose** → `header.Description`. +2. **Level-0 dispatch** → `dispatchHeaderLevel0` wires Walker + callbacks via the `handlers` package. Shape mirrors parameters' + level-0 dispatcher with one omission: + - `Number`, `Integer`, `String` (Pattern + CollectionFormat), + `Raw` (default/example/enum) → identical to parameters + - `Bool` → `handlers.UniqueBool(valid)` only — **no `required:` + write**. The OAS v2 Header object simply doesn't carry + `required:`. + - `Extension` → `handlers.Extension(header)` — v1 had no + header-side extension support at all; the grammar migration + closes that gap. User-authored `Extensions:` block entries + land on the header. +3. **Items-level dispatch** → `walkHeaderItemsLevel` for each + `(level, items)` pair returned by `collectHeaderItemsLevels` + (1-indexed depths matching grammar's `Property.ItemsDepth`). + +`applyBlockToDecl` is the top-level (response-level) entry point. +It only writes `resp.Description` from prose — the v1 +`SectionedParser` only accepted description at the top level, no +taggers. Property-level keywords on the top-level decl are silently +ignored. + +### Header errSink semantics + +Unlike `dispatchParamLevel0`, the response `Raw` handler is called +with a nil errSink: malformed `default:` / `example:` for a header +produces a parser diagnostic but is **not promoted to a build +error**. Headers are non-critical metadata; failing the build over +a malformed example would be surprising. + +## §typable — `responseTypable`, body vs header + +`responseTypable` adapts a header or body slot to +`ifaces.SwaggerTypable`. Single struct, polymorphic by `ht.in`: + +- **Body** (`in == "body"`): `Schema()` materialises and returns + `resp.Schema`. `Typed()` writes to the header struct, but body + callers use `Schema()` directly. `Items()` walks `resp.Schema.Items`. +- **Header** (`in == "header"` or anything non-body): `Typed()` + writes to the embedded `SimpleSchema` on the header. + `Items()` builds the items chain on the header side. + +### The Q2 `refAttempted` mechanism + +OAS v2 response headers cannot carry `$ref`. Pre-Q2, `SetRef` wrote +the ref onto `response.Schema.Ref` unconditionally — which +corrupted the body schema with a **header field's reference** when a +header field aliased to a named type. The Q2 fix: + +```go +func (ht responseTypable) SetRef(ref oaispec.Ref) { + if ht.in == inBody { + ht.Schema().Ref = ref + return + } + if ht.refAttempted != nil { + *ht.refAttempted = true + } +} +``` + +Non-body `SetRef` calls no-op the ref write and flip a flag on a +caller-owned `bool`. `HasRef()` reads the flag for +`SimpleSchemaProbe` so the schema builder's exit validator can +detect the violation, emit `CodeUnsupportedInSimpleSchema`, and call +`ResetForViolation()` (which wipes the header's SimpleSchema back to +empty). + +The flag is **caller-owned** (passed by pointer) so a single +`responseTypable` value can be shared across recursion levels +without mutation through the `ifaces.SwaggerTypable` value-receiver +methods. + +### `SimpleSchemaProbe` implementation + +- `SimpleSchemaShape()` — returns the header's embedded SimpleSchema +- `HasRef()` — true if a non-body `SetRef` attempt was made +- `ResetForViolation()` — wipes the header's SimpleSchema back to `{}` + +## §alias-handling — when to `$ref` vs expand + +`buildFieldAlias` carries a gate identical to parameters': + +```go +if typable.In() != inBody || !r.Ctx.RefAliases() { + unaliased := types.Unalias(tpe) + return r.buildFromField(fld, unaliased, typable, seen) +} +``` + +Non-body fields can't carry `$ref` (SimpleSchema forbids it), so +they always expand. Body fields honour `RefAliases`: when set, the +alias becomes a `$ref` to its target via +`common.Builder.MakeRef`; otherwise the underlying is expanded. + +The top-level `buildAlias` (declaration-level, not field-level) +behaves slightly differently because the response-as-alias case is +always body-shaped: the gate collapses to `r.Ctx.RefAliases()` alone, +and the unaliased target is materialised via the schema builder. + +## §quirks-history — resolved quirks + +The Stream M merge-readiness pass closed a handful of subtle quirks +in this builder. Recorded for archaeology — none should resurface +under the current dispatch. + +- **Q1: implicit header default** — `in:` absent now resolves + explicitly to `inHeader`. Pre-Q1 the empty string fell through + `in != "body"` by accident. Observable behaviour unchanged. +- **Q2: `$ref` leaking into response body** — non-body `SetRef` + calls no longer write to `response.Schema.Ref`. The + `refAttempted` flag plumbs the attempt to the SimpleSchema exit + validator. See [§typable](#typable). +- **Q3: `swagger:file` on a header** — gated to `in == inBody` only; + misuse emits a diagnostic and falls through to the regular field + build. +- **Q4: alias expansion at field-level** — added the + `In() != inBody || !RefAliases()` gate to `buildFieldAlias` so + non-body header aliases expand instead of leaking a `$ref`. Aligns + with parameters' gate exactly. +- **Q5 (closed, no change)** — the previously-suspected TODO around + alias deduplication turned out to be obsolete; no code change + needed. +- **Q6: alignment with parameters** — `buildFieldAlias` and + `processResponseField` now mirror parameters' shape so the two + builders behave consistently at field-level alias sites and + during dispatch. + +### Deferred to v2 + +- **Top-level response-as-alias under default mode**: + `buildFromType`'s default branch rejects anonymous types with + `"anonymous types are currently not supported for responses"`. A + top-level alias to an anonymous struct under default mode crashes + here. Reproduces in `fixtures/enhancements/alias-response-shapes`. + Out of scope for v1. diff --git a/internal/builders/responses/doc_signals.go b/internal/builders/responses/doc_signals.go new file mode 100644 index 0000000..015ae6c --- /dev/null +++ b/internal/builders/responses/doc_signals.go @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package responses + +import ( + "go/ast" + "strings" + + "github.com/go-openapi/codescan/internal/parsers/grammar" +) + +// fieldDocSignals carries the per-field doc-comment signals the +// response dispatcher reads upstream of the schema build. Shape +// parallels parameters/doc_signals.go's; the two will fold into a +// shared resolvers helper in M6. +// +// # Details +// +// See [§in-discriminator](./README.md#in-discriminator) — the `in:` +// vocabulary, default-to-header behaviour, and the invalid-`in:` +// diagnostic path. +type fieldDocSignals struct { + in string + inSet bool + invalidIn string // raw value when an `in:` line was present but its value isn't in the closed vocabulary; empty otherwise. The caller emits a diagnostic. + ignored bool + file bool + strfmt string + strfmtSet bool +} + +// scanFieldDocSignals reads every signal the response dispatcher +// needs out of a pre-parsed block slice and the raw doc text. +// Callers should pass `r.ParseBlocks(afld.Doc)` so the +// common.Builder cache absorbs the parse cost. +// +// Returns the zero value when doc is nil. +// +// # Details +// +// See [§in-discriminator](./README.md#in-discriminator) — why `in:` +// is line-scanned rather than read as a grammar Property. +func scanFieldDocSignals(blocks []grammar.Block, doc *ast.CommentGroup) fieldDocSignals { + var pd fieldDocSignals + if doc == nil { + return pd + } + + for _, b := range blocks { + switch b.AnnotationKind() { //nolint:exhaustive // only ignore/file/strfmt are relevant here + case grammar.AnnIgnore: + pd.ignored = true + case grammar.AnnFile: + pd.file = true + case grammar.AnnStrfmt: + if arg, ok := b.AnnotationArg(); ok && !strings.ContainsAny(arg, " \t") { + pd.strfmt = arg + pd.strfmtSet = true + } + } + } + + v, ok, invalid := scanInLocation(doc.Text()) + switch { + case ok: + pd.in = v + pd.inSet = true + case invalid != "": + pd.invalidIn = invalid + } + + return pd +} + +// validResponseIn enumerates the closed-vocabulary `in:` values the +// response dispatcher accepts. Same vocabulary as parameters' — the +// in:body distinguishes the response-body / response-header split, +// the others are passed through verbatim. +// +//nolint:gochecknoglobals // closed-vocabulary lookup table; one allocation, read-only. +var validResponseIn = map[string]struct{}{ + "query": {}, + "path": {}, + "header": {}, + "body": {}, + "formData": {}, +} + +// scanInLocation finds the first `in: X` line in text and returns +// the value (when valid) or the raw candidate (when present but +// out-of-vocabulary). See [§in-discriminator](./README.md#in-discriminator). +func scanInLocation(text string) (value string, valid bool, invalid string) { + for line := range strings.SplitSeq(text, "\n") { + line = strings.TrimSpace(line) + rest, ok := strings.CutPrefix(line, "in:") + if !ok { + rest, ok = strings.CutPrefix(line, "In:") + } + if !ok { + continue + } + v := strings.TrimSpace(rest) + v = strings.TrimSuffix(v, ".") + if v == "" { + continue + } + if _, ok := validResponseIn[v]; ok { + return v, true, "" + } + // First `in:` line with a non-vocab value — record so the + // caller can diagnose. Don't keep scanning: a later valid + // `in:` after an invalid one would be a bizarre input we + // don't need to model. + return "", false, v + } + return "", false, "" +} + +// strfmtFromDoc returns the argument of a `swagger:strfmt ` +// annotation present in blocks. Single-word filter mirrors the +// schema package's `findAnnotationArg` rule. +func strfmtFromDoc(blocks []grammar.Block) (string, bool) { + for _, b := range blocks { + if b.AnnotationKind() != grammar.AnnStrfmt { + continue + } + if arg, ok := b.AnnotationArg(); ok && !strings.ContainsAny(arg, " \t") { + return arg, true + } + } + return "", false +} diff --git a/internal/builders/responses/responses.go b/internal/builders/responses/responses.go index e08d9eb..56d113e 100644 --- a/internal/builders/responses/responses.go +++ b/internal/builders/responses/responses.go @@ -7,47 +7,51 @@ import ( "fmt" "go/types" + "github.com/go-openapi/codescan/internal/builders/common" "github.com/go-openapi/codescan/internal/builders/resolvers" "github.com/go-openapi/codescan/internal/builders/schema" "github.com/go-openapi/codescan/internal/ifaces" "github.com/go-openapi/codescan/internal/logger" - "github.com/go-openapi/codescan/internal/parsers" + "github.com/go-openapi/codescan/internal/parsers/grammar" "github.com/go-openapi/codescan/internal/scanner" oaispec "github.com/go-openapi/spec" ) -type ResponseBuilder struct { - ctx *scanner.ScanCtx - decl *scanner.EntityDecl - postDecls []*scanner.EntityDecl +const ( + inBody = "body" + inHeader = "header" +) + +// Builder constructs OAS v2 response entries for one +// `swagger:response` declaration. Embeds *common.Builder for shared +// state (Ctx, Decl, PostDeclarations, diagnostics, ParseBlocks +// cache). +type Builder struct { + *common.Builder } -func NewBuilder(ctx *scanner.ScanCtx, decl *scanner.EntityDecl) *ResponseBuilder { - return &ResponseBuilder{ - ctx: ctx, - decl: decl, +// NewBuilder constructs an initialized [Builder] bound to +// ctx and decl. The embedded common.Builder owns the diagnostic +// sink, the post-declaration list, and the per-comment-group parse +// cache. +func NewBuilder(ctx *scanner.ScanCtx, decl *scanner.EntityDecl) *Builder { + return &Builder{ + Builder: common.New(ctx, decl), } } -func (r *ResponseBuilder) Build(responses map[string]oaispec.Response) error { +func (r *Builder) Build(responses map[string]oaispec.Response) error { // check if there is a swagger:response tag that is followed by one or more words, // these words are the ids of the operations this parameter struct applies to // once type name is found convert it to a schema, by looking up the schema in the // parameters dictionary that got passed into this parse method - name, _ := r.decl.ResponseNames() + name, _ := r.Decl.ResponseNames() response := responses[name] - logger.DebugLogf(r.ctx.Debug(), "building response: %s", name) + logger.DebugLogf(r.Ctx.Debug(), "building response: %s", name) // analyze doc comment for the model - sp := parsers.NewSectionedParser( - parsers.WithSetDescription(func(lines []string) { - response.Description = parsers.JoinDropLast(lines) - }), - ) - if err := sp.Parse(r.decl.Comments); err != nil { - return err - } + r.applyBlockToDecl(&response) // analyze struct body for fields etc // each exported struct field: @@ -56,7 +60,7 @@ func (r *ResponseBuilder) Build(responses map[string]oaispec.Response) error { // * has to document the validations that apply for the type and the field // * when the struct field points to a model it becomes a ref: #/definitions/ModelName // * comments that aren't tags is used as the description - if err := r.buildFromType(r.decl.ObjType(), &response, make(map[string]bool)); err != nil { + if err := r.buildFromType(r.Decl.ObjType(), &response, make(map[string]bool)); err != nil { return err } responses[name] = response @@ -64,12 +68,8 @@ func (r *ResponseBuilder) Build(responses map[string]oaispec.Response) error { return nil } -func (r *ResponseBuilder) PostDeclarations() []*scanner.EntityDecl { - return r.postDecls -} - -func (r *ResponseBuilder) buildFromField(fld *types.Var, tpe types.Type, typable ifaces.SwaggerTypable, seen map[string]bool) error { - logger.DebugLogf(r.ctx.Debug(), "build from field %s: %T", fld.Name(), tpe) +func (r *Builder) buildFromField(fld *types.Var, tpe types.Type, typable ifaces.SwaggerTypable, seen map[string]bool) error { + logger.DebugLogf(r.Ctx.Debug(), "build from field %s: %T", fld.Name(), tpe) switch ftpe := tpe.(type) { case *types.Basic: @@ -89,112 +89,121 @@ func (r *ResponseBuilder) buildFromField(fld *types.Var, tpe types.Type, typable case *types.Named: return r.buildNamedField(ftpe, typable) case *types.Alias: - logger.DebugLogf(r.ctx.Debug(), "alias(responses.buildFromField): got alias %v to %v", ftpe, ftpe.Rhs()) + logger.DebugLogf(r.Ctx.Debug(), "alias(responses.buildFromField): got alias %v to %v", ftpe, ftpe.Rhs()) return r.buildFieldAlias(ftpe, typable, fld, seen) default: return fmt.Errorf("unknown type for %s: %T: %w", fld.String(), fld.Type(), ErrResponses) } } -func (r *ResponseBuilder) buildFromFieldStruct(ftpe *types.Struct, typable ifaces.SwaggerTypable) error { - sb := schema.NewBuilder(r.ctx, r.decl) - if err := sb.BuildFromType(ftpe, typable); err != nil { +func (r *Builder) buildFromFieldStruct(ftpe *types.Struct, typable ifaces.SwaggerTypable) error { + sb := schema.NewBuilder(r.Ctx, r.Decl) + if err := sb.Build(schema.OptionFor(ftpe, typable)); err != nil { return err } - r.postDecls = append(r.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + r.AppendPostDecl(d) + } return nil } -func (r *ResponseBuilder) buildFromFieldMap(ftpe *types.Map, typable ifaces.SwaggerTypable) error { +func (r *Builder) buildFromFieldMap(ftpe *types.Map, typable ifaces.SwaggerTypable) error { sch := new(oaispec.Schema) typable.Schema().Typed("object", "").AdditionalProperties = &oaispec.SchemaOrBool{ Schema: sch, } - sb := schema.NewBuilder(r.ctx, r.decl) - if err := sb.BuildFromType(ftpe.Elem(), schema.NewTypable(sch, typable.Level()+1, r.ctx.SkipExtensions())); err != nil { + sb := schema.NewBuilder(r.Ctx, r.Decl) + if err := sb.Build( + schema.WithType(ftpe.Elem(), + schema.NewTypable(sch, typable.Level()+1, r.Ctx.SkipExtensions())), + ); err != nil { return err } - r.postDecls = append(r.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + r.AppendPostDecl(d) + } return nil } -func (r *ResponseBuilder) buildFromFieldInterface(tpe types.Type, typable ifaces.SwaggerTypable) error { - sb := schema.NewBuilder(r.ctx, r.decl) - if err := sb.BuildFromType(tpe, typable); err != nil { +func (r *Builder) buildFromFieldInterface(tpe *types.Interface, typable ifaces.SwaggerTypable) error { + sb := schema.NewBuilder(r.Ctx, r.Decl) + if err := sb.Build(schema.OptionFor(tpe, typable)); err != nil { return err } - r.postDecls = append(r.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + r.AppendPostDecl(d) + } return nil } -func (r *ResponseBuilder) buildFromType(otpe types.Type, resp *oaispec.Response, seen map[string]bool) error { +func (r *Builder) buildFromType(otpe types.Type, resp *oaispec.Response, seen map[string]bool) error { switch tpe := otpe.(type) { case *types.Pointer: return r.buildFromType(tpe.Elem(), resp, seen) case *types.Named: return r.buildNamedType(tpe, resp, seen) case *types.Alias: - logger.DebugLogf(r.ctx.Debug(), "alias(responses.buildFromType): got alias %v to %v", tpe, tpe.Rhs()) + logger.DebugLogf(r.Ctx.Debug(), "alias(responses.buildFromType): got alias %v to %v", tpe, tpe.Rhs()) return r.buildAlias(tpe, resp, seen) default: return fmt.Errorf("anonymous types are currently not supported for responses: %w", ErrResponses) } } -func (r *ResponseBuilder) buildNamedType(tpe *types.Named, resp *oaispec.Response, seen map[string]bool) error { +func (r *Builder) buildNamedType(tpe *types.Named, resp *oaispec.Response, seen map[string]bool) error { o := tpe.Obj() if resolvers.IsAny(o) || resolvers.IsStdError(o) { return fmt.Errorf("%s type not supported in the context of a responses section definition: %w", o.Name(), ErrResponses) } resolvers.MustNotBeABuiltinType(o) - switch stpe := o.Type().Underlying().(type) { // TODO(fred): this is wrong without checking for aliases? + switch stpe := o.Type().Underlying().(type) { case *types.Struct: - logger.DebugLogf(r.ctx.Debug(), "build from type %s: %T", o.Name(), tpe) - if decl, found := r.ctx.DeclForType(o.Type()); found { + logger.DebugLogf(r.Ctx.Debug(), "build from type %s: %T", o.Name(), tpe) + if decl, found := r.Ctx.DeclForType(o.Type()); found { return r.buildFromStruct(decl, stpe, resp, seen) } - return r.buildFromStruct(r.decl, stpe, resp, seen) + return r.buildFromStruct(r.Decl, stpe, resp, seen) default: - if decl, found := r.ctx.DeclForType(o.Type()); found { + if decl, found := r.Ctx.DeclForType(o.Type()); found { var sch oaispec.Schema - typable := schema.NewTypable(&sch, 0, r.ctx.SkipExtensions()) + typable := schema.NewTypable(&sch, 0, r.Ctx.SkipExtensions()) d := decl.Obj() if resolvers.IsStdTime(d) { typable.Typed("string", "date-time") return nil } - if sfnm, isf := parsers.StrfmtName(decl.Comments); isf { + if sfnm, isf := strfmtFromDoc(r.ParseBlocks(decl.Comments)); isf { typable.Typed("string", sfnm) return nil } - sb := schema.NewBuilder(r.ctx, decl) + sb := schema.NewBuilder(r.Ctx, decl) sb.InferNames() - if err := sb.BuildFromType(tpe.Underlying(), typable); err != nil { + if err := sb.Build(schema.OptionFor(tpe.Underlying(), typable)); err != nil { return err } resp.WithSchema(&sch) - r.postDecls = append(r.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + r.AppendPostDecl(d) + } return nil } return fmt.Errorf("responses can only be structs, did you mean for %s to be the response body?: %w", tpe.String(), ErrResponses) } } -func (r *ResponseBuilder) buildAlias(tpe *types.Alias, resp *oaispec.Response, seen map[string]bool) error { - // panic("yay") +func (r *Builder) buildAlias(tpe *types.Alias, resp *oaispec.Response, seen map[string]bool) error { o := tpe.Obj() if resolvers.IsAny(o) || resolvers.IsStdError(o) { - // wrong: TODO(fred): see what object exactly we want to build here - figure out with specific tests return fmt.Errorf("%s type not supported in the context of a responses section definition: %w", o.Name(), ErrResponses) } resolvers.MustNotBeABuiltinType(o) @@ -203,17 +212,17 @@ func (r *ResponseBuilder) buildAlias(tpe *types.Alias, resp *oaispec.Response, s rhs := tpe.Rhs() // If transparent aliases are enabled, use the underlying type directly without creating a definition - if r.ctx.TransparentAliases() { + if r.Ctx.TransparentAliases() { return r.buildFromType(rhs, resp, seen) } - decl, ok := r.ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, ok := r.Ctx.FindModel(o.Pkg().Path(), o.Name()) if !ok { return fmt.Errorf("can't find source file for aliased type: %v -> %v: %w", tpe, rhs, ErrResponses) } - r.postDecls = append(r.postDecls, decl) // mark the left-hand side as discovered + r.AppendPostDecl(decl) // mark the left-hand side as discovered - if !r.ctx.RefAliases() { + if !r.Ctx.RefAliases() { // expand alias unaliased := types.Unalias(tpe) return r.buildFromType(unaliased.Underlying(), resp, seen) @@ -227,24 +236,24 @@ func (r *ResponseBuilder) buildAlias(tpe *types.Alias, resp *oaispec.Response, s break // builtin } - typable := schema.NewTypable(&oaispec.Schema{}, 0, r.ctx.SkipExtensions()) - return r.makeRef(decl, typable) + typable := schema.NewTypable(&oaispec.Schema{}, 0, r.Ctx.SkipExtensions()) + return r.MakeRef(decl, typable) case *types.Alias: o := rtpe.Obj() if o.Pkg() == nil { break // builtin } - typable := schema.NewTypable(&oaispec.Schema{}, 0, r.ctx.SkipExtensions()) + typable := schema.NewTypable(&oaispec.Schema{}, 0, r.Ctx.SkipExtensions()) - return r.makeRef(decl, typable) + return r.MakeRef(decl, typable) } return r.buildFromType(rhs, resp, seen) } -func (r *ResponseBuilder) buildNamedField(ftpe *types.Named, typable ifaces.SwaggerTypable) error { - decl, found := r.ctx.DeclForType(ftpe.Obj().Type()) +func (r *Builder) buildNamedField(ftpe *types.Named, typable ifaces.SwaggerTypable) error { + decl, found := r.Ctx.DeclForType(ftpe.Obj().Type()) if !found { return fmt.Errorf("unable to find package and source file for: %s: %w", ftpe.String(), ErrResponses) } @@ -255,25 +264,25 @@ func (r *ResponseBuilder) buildNamedField(ftpe *types.Named, typable ifaces.Swag return nil } - if sfnm, isf := parsers.StrfmtName(decl.Comments); isf { + if sfnm, isf := strfmtFromDoc(r.ParseBlocks(decl.Comments)); isf { typable.Typed("string", sfnm) return nil } - sb := schema.NewBuilder(r.ctx, decl) + sb := schema.NewBuilder(r.Ctx, decl) sb.InferNames() - if err := sb.BuildFromType(decl.ObjType(), typable); err != nil { + if err := sb.Build(schema.OptionFor(decl.ObjType(), typable)); err != nil { return err } - r.postDecls = append(r.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + r.AppendPostDecl(d) + } return nil } -func (r *ResponseBuilder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypable, fld *types.Var, seen map[string]bool) error { - _ = fld - _ = seen +func (r *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypable, fld *types.Var, seen map[string]bool) error { o := tpe.Obj() if resolvers.IsAny(o) { // e.g. Field interface{} or Field any @@ -283,25 +292,34 @@ func (r *ResponseBuilder) buildFieldAlias(tpe *types.Alias, typable ifaces.Swagg } // If transparent aliases are enabled, use the underlying type directly without creating a definition - if r.ctx.TransparentAliases() { - sb := schema.NewBuilder(r.ctx, r.decl) - if err := sb.BuildFromType(tpe.Rhs(), typable); err != nil { + if r.Ctx.TransparentAliases() { + sb := schema.NewBuilder(r.Ctx, r.Decl) + if err := sb.Build(schema.OptionFor(tpe.Rhs(), typable)); err != nil { return err } - r.postDecls = append(r.postDecls, sb.PostDeclarations()...) + for _, d := range sb.PostDeclarations() { + r.AppendPostDecl(d) + } return nil } - decl, ok := r.ctx.FindModel(o.Pkg().Path(), o.Name()) + // Non-body or RefAliases-off ⇒ expand. See + // [§alias-handling](./README.md#alias-handling). + if typable.In() != inBody || !r.Ctx.RefAliases() { + unaliased := types.Unalias(tpe) + return r.buildFromField(fld, unaliased, typable, seen) + } + + decl, ok := r.Ctx.FindModel(o.Pkg().Path(), o.Name()) if !ok { return fmt.Errorf("can't find source file for aliased type: %v: %w", tpe, ErrResponses) } - r.postDecls = append(r.postDecls, decl) // mark the left-hand side as discovered + r.AppendPostDecl(decl) // mark the left-hand side as discovered - return r.makeRef(decl, typable) + return r.MakeRef(decl, typable) } -func (r *ResponseBuilder) buildFromStruct(decl *scanner.EntityDecl, tpe *types.Struct, resp *oaispec.Response, seen map[string]bool) error { +func (r *Builder) buildFromStruct(decl *scanner.EntityDecl, tpe *types.Struct, resp *oaispec.Response, seen map[string]bool) error { if tpe.NumFields() == 0 { return nil } @@ -314,7 +332,7 @@ func (r *ResponseBuilder) buildFromStruct(decl *scanner.EntityDecl, tpe *types.S continue } if fld.Anonymous() { - logger.DebugLogf(r.ctx.Debug(), "skipping anonymous field") + logger.DebugLogf(r.Ctx.Debug(), "skipping anonymous field") continue } @@ -331,19 +349,22 @@ func (r *ResponseBuilder) buildFromStruct(decl *scanner.EntityDecl, tpe *types.S return nil } -func (r *ResponseBuilder) processResponseField(fld *types.Var, decl *scanner.EntityDecl, resp *oaispec.Response, seen map[string]bool) error { +func (r *Builder) processResponseField(fld *types.Var, decl *scanner.EntityDecl, resp *oaispec.Response, seen map[string]bool) error { if !fld.Exported() { + logger.DebugLogf(r.Ctx.Debug(), "skipping field %s because it's not exported", fld.Name()) return nil } afld := resolvers.FindASTField(decl.File, fld.Pos()) if afld == nil { - logger.DebugLogf(r.ctx.Debug(), "can't find source associated with %s", fld.String()) + logger.DebugLogf(r.Ctx.Debug(), "can't find source associated with %s", fld.String()) return nil } - if parsers.Ignored(afld.Doc) { - logger.DebugLogf(r.ctx.Debug(), "field %v is deliberately ignored", fld) + signals := scanFieldDocSignals(r.ParseBlocks(afld.Doc), afld.Doc) + + if signals.ignored { + logger.DebugLogf(r.Ctx.Debug(), "field %v is deliberately ignored", fld) return nil } @@ -355,40 +376,56 @@ func (r *ResponseBuilder) processResponseField(fld *types.Var, decl *scanner.Ent return nil } - // scan for param location first, this changes some behavior down the line - in, _ := parsers.ParamLocation(afld.Doc) + // `in:` is the body/header annotation switch (Q1, default header). + // See [§in-discriminator](./README.md#in-discriminator). + in := signals.in + if !signals.inSet { + in = inHeader + } + if signals.invalidIn != "" { + r.RecordDiagnostic(grammar.Warnf( + r.Ctx.PosOf(afld.Pos()), + grammar.CodeInvalidAnnotation, + "unrecognised `in: %s` on response field %q (vocabulary: query/path/header/body/formData); defaulting to header", + signals.invalidIn, name, + )) + } ps := resp.Headers[name] - // support swagger:file for response - // An API operation can return a file, such as an image or PDF. In this case, - // define the response schema with type: file and specify the appropriate MIME types in the produces section. - if afld.Doc != nil && parsers.FileParam(afld.Doc) { + // `swagger:file` is body-only (Q3); on a header it would corrupt + // the body schema. See [§file-body](./README.md#file-body). + useFileBody := signals.file && in == inBody + if signals.file && !useFileBody { + r.RecordDiagnostic(grammar.Warnf( + r.Ctx.PosOf(afld.Pos()), + grammar.CodeUnsupportedInSimpleSchema, + "`swagger:file` is only valid on a body response field (in: body); ignored on response field %q (in=%q). Allowed header types: string/number/integer/boolean/array.", + name, in, + )) + } + + if useFileBody { resp.Schema = &oaispec.Schema{} resp.Schema.Typed("file", "") } else { - logger.DebugLogf(r.ctx.Debug(), "build response %v (%v) (not a file)", fld, fld.Type()) - if err := r.buildFromField(fld, fld.Type(), responseTypable{in, &ps, resp, r.ctx.SkipExtensions()}, seen); err != nil { + logger.DebugLogf(r.Ctx.Debug(), "build response %v (%v) (not a file)", fld, fld.Type()) + var refAttempted bool + if err := r.buildFromField(fld, fld.Type(), responseTypable{ + in: in, + header: &ps, + response: resp, + skipExt: r.Ctx.SkipExtensions(), + refAttempted: &refAttempted, + }, seen); err != nil { return err } } - if strfmtName, ok := parsers.StrfmtName(afld.Doc); ok { - ps.Typed("string", strfmtName) + if signals.strfmtSet { + ps.Typed("string", signals.strfmt) } - taggers, err := setupResponseHeaderTaggers(&ps, name, afld) - if err != nil { - return err - } - - sp := parsers.NewSectionedParser( - parsers.WithSetDescription(func(lines []string) { ps.Description = parsers.JoinDropLast(lines) }), - parsers.WithTaggers(taggers...), - ) - - if err := sp.Parse(afld.Doc); err != nil { - return err - } + r.applyBlockToHeader(afld, &ps) if in != "body" { seen[name] = true @@ -401,15 +438,3 @@ func (r *ResponseBuilder) processResponseField(fld *types.Var, decl *scanner.Ent return nil } -func (r *ResponseBuilder) makeRef(decl *scanner.EntityDecl, prop ifaces.SwaggerTypable) error { - nm, _ := decl.Names() - ref, err := oaispec.NewRef("#/definitions/" + nm) - if err != nil { - return err - } - - prop.SetRef(ref) - r.postDecls = append(r.postDecls, decl) // mark the $ref target as discovered - - return nil -} diff --git a/internal/builders/responses/responses_test.go b/internal/builders/responses/responses_test.go index 2117559..7769ac2 100644 --- a/internal/builders/responses/responses_test.go +++ b/internal/builders/responses/responses_test.go @@ -20,6 +20,54 @@ const ( paramID = "id" ) +// TestParseResponses_OptionVariants captures (SkipExtensions, +// DescWithRef) option permutations on the classification responses +// corpus into separately-named goldens. Same response set as +// TestParseResponses; the matrix verifies $ref'd-field shape under +// each option pair. See parameters/TestParamsParser_OptionVariants +// for the full matrix rationale. +func TestParseResponses_OptionVariants(t *testing.T) { + cases := []struct { + name string + skipExt bool + descRef bool + goldenFile string + }{ + {"default", false, false, "classification_responses.json"}, + {"DescWithRef", false, true, "classification_responses_descwithref.json"}, + {"SkipExt", true, false, "classification_responses_skipext.json"}, + {"SkipExt+DescWithRef", true, true, "classification_responses_skipext_descwithref.json"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sctx, err := scanner.NewScanCtx(&scanner.Options{ + Packages: []string{ + "./goparsing/classification", + "./goparsing/classification/models", + "./goparsing/classification/operations", + }, + WorkDir: scantest.FixturesDir(), + SkipExtensions: tc.skipExt, + DescWithRef: tc.descRef, + }) + require.NoError(t, err) + responses := make(map[string]spec.Response) + responseNames := []string{ + "ComplexerOne", "SimpleOnes", "SimpleOnesFunc", "ComplexerPointerOne", + "SomeResponse", "ValidationError", "Resp", "FileResponse", + "GenericError", "ValidationError", + } + for _, rn := range responseNames { + td := getResponse(sctx, rn) + require.NotNil(t, td) + prs := NewBuilder(sctx, td) + require.NoError(t, prs.Build(responses)) + } + scantest.CompareOrDumpJSON(t, responses, tc.goldenFile) + }) + } +} + func TestParseResponses(t *testing.T) { sctx := scantest.LoadClassificationPkgsCtx(t) responses := make(map[string]spec.Response) @@ -31,10 +79,7 @@ func TestParseResponses(t *testing.T) { for _, rn := range responseNames { td := getResponse(sctx, rn) require.NotNil(t, td) - prs := &ResponseBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(responses)) } @@ -301,10 +346,7 @@ func TestParseResponses_TransparentAliases(t *testing.T) { // Build the response map using the transparent alias fixtures. responses := make(map[string]spec.Response) - prs := &ResponseBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(responses)) resp, ok := responses["transparentAliasResponse"] @@ -327,10 +369,7 @@ func TestParseResponses_Issue2007(t *testing.T) { sctx := scantest.LoadClassificationPkgsCtx(t) responses := make(map[string]spec.Response) td := getResponse(sctx, "GetConfiguration") - prs := &ResponseBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(responses)) resp := responses["GetConfiguration"] @@ -350,10 +389,7 @@ func TestParseResponses_Issue2011(t *testing.T) { responses := make(map[string]spec.Response) td := getResponse(sctx, "NumPlatesResp") require.NotNil(t, td) - prs := &ResponseBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(responses)) resp := responses["NumPlatesResp"] @@ -371,16 +407,13 @@ func TestParseResponses_Issue2145(t *testing.T) { require.NoError(t, err) responses := make(map[string]spec.Response) td := getResponse(sctx, "GetProductsResponse") - prs := &ResponseBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(responses)) resp := responses["GetProductsResponse"] require.Empty(t, resp.Headers) require.NotNil(t, resp.Schema) - assert.NotEmpty(t, prs.postDecls) // should have Product + assert.NotEmpty(t, prs.PostDeclarations()) // should have Product scantest.CompareOrDumpJSON(t, responses, "product_responses.json") } @@ -398,10 +431,7 @@ func TestGo118ParseResponses_Issue2011(t *testing.T) { sctx := scantest.LoadGo118ClassificationPkgsCtx(t) responses := make(map[string]spec.Response) td := getResponse(sctx, "NumPlatesResp") - prs := &ResponseBuilder{ - ctx: sctx, - decl: td, - } + prs := NewBuilder(sctx, td) require.NoError(t, prs.Build(responses)) resp := responses["NumPlatesResp"] diff --git a/internal/builders/responses/taggers.go b/internal/builders/responses/taggers.go deleted file mode 100644 index 79e9b77..0000000 --- a/internal/builders/responses/taggers.go +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package responses - -import ( - "go/ast" - "slices" - - "github.com/go-openapi/codescan/internal/builders/items" - "github.com/go-openapi/codescan/internal/parsers" - oaispec "github.com/go-openapi/spec" -) - -// baseResponseHeaderTaggers configures taggers for a response header field. -func baseResponseHeaderTaggers(header *oaispec.Header) []parsers.TagParser { - return []parsers.TagParser{ - // Match-only: claim `in: header` so it does not leak into the header's description. - parsers.NewSingleLineTagParser("in", parsers.NewMatchIn()), - parsers.NewSingleLineTagParser("maximum", parsers.NewSetMaximum(headerValidations{header})), - parsers.NewSingleLineTagParser("minimum", parsers.NewSetMinimum(headerValidations{header})), - parsers.NewSingleLineTagParser("multipleOf", parsers.NewSetMultipleOf(headerValidations{header})), - parsers.NewSingleLineTagParser("minLength", parsers.NewSetMinLength(headerValidations{header})), - parsers.NewSingleLineTagParser("maxLength", parsers.NewSetMaxLength(headerValidations{header})), - parsers.NewSingleLineTagParser("pattern", parsers.NewSetPattern(headerValidations{header})), - parsers.NewSingleLineTagParser("collectionFormat", parsers.NewSetCollectionFormat(headerValidations{header})), - parsers.NewSingleLineTagParser("minItems", parsers.NewSetMinItems(headerValidations{header})), - parsers.NewSingleLineTagParser("maxItems", parsers.NewSetMaxItems(headerValidations{header})), - parsers.NewSingleLineTagParser("unique", parsers.NewSetUnique(headerValidations{header})), - parsers.NewSingleLineTagParser("enum", parsers.NewSetEnum(headerValidations{header})), - parsers.NewSingleLineTagParser("default", parsers.NewSetDefault(&header.SimpleSchema, headerValidations{header})), - parsers.NewSingleLineTagParser("example", parsers.NewSetExample(&header.SimpleSchema, headerValidations{header})), - } -} - -func setupResponseHeaderTaggers(header *oaispec.Header, name string, afld *ast.Field) ([]parsers.TagParser, error) { - // TODO(claude): don't understand why we need this step. Isn't it handled by the recursion already? - if ftped, ok := afld.Type.(*ast.ArrayType); ok { - taggers, err := items.ParseArrayTypes([]parsers.TagParser{}, name, ftped.Elt, header.Items, 0) - if err != nil { - return nil, err - } - - return slices.Concat(taggers, baseResponseHeaderTaggers(header)), nil - } - - return baseResponseHeaderTaggers(header), nil -} diff --git a/internal/builders/responses/typable.go b/internal/builders/responses/typable.go index f3feae3..efca510 100644 --- a/internal/builders/responses/typable.go +++ b/internal/builders/responses/typable.go @@ -4,20 +4,21 @@ package responses import ( - "github.com/go-openapi/codescan/internal/builders/items" + "github.com/go-openapi/codescan/internal/builders/resolvers" "github.com/go-openapi/codescan/internal/builders/schema" "github.com/go-openapi/codescan/internal/ifaces" - "github.com/go-openapi/codescan/internal/parsers" oaispec "github.com/go-openapi/spec" ) -var _ ifaces.ValidationBuilder = &headerValidations{} - type responseTypable struct { in string header *oaispec.Header response *oaispec.Response skipExt bool + + // refAttempted: caller-owned flag flipped when SetRef is called + // under non-body mode. See [§typable](./README.md#typable). + refAttempted *bool } func (ht responseTypable) In() string { return ht.in } @@ -41,12 +42,20 @@ func (ht responseTypable) Items() ifaces.SwaggerTypable { //nolint:ireturn // po ht.header.Type = "array" - return items.NewTypable(ht.header.Items, 1, "header") + return resolvers.NewItemsTypable(ht.header.Items, 1, "header") } +// SetRef writes the ref onto the body schema in body mode; under +// non-body it no-ops and flips refAttempted (Q2). See +// [§typable](./README.md#typable). func (ht responseTypable) SetRef(ref oaispec.Ref) { - // having trouble seeing the usefulness of this one here - ht.Schema().Ref = ref + if ht.in == inBody { + ht.Schema().Ref = ref + return + } + if ht.refAttempted != nil { + *ht.refAttempted = true + } } func (ht responseTypable) Schema() *oaispec.Schema { @@ -69,56 +78,21 @@ func (ht responseTypable) WithEnumDescription(_ string) { // no } -type headerValidations struct { - current *oaispec.Header -} - -func (sv headerValidations) SetMaximum(val float64, exclusive bool) { - sv.current.Maximum = &val - sv.current.ExclusiveMaximum = exclusive -} - -func (sv headerValidations) SetMinimum(val float64, exclusive bool) { - sv.current.Minimum = &val - sv.current.ExclusiveMinimum = exclusive -} - -func (sv headerValidations) SetMultipleOf(val float64) { - sv.current.MultipleOf = &val -} - -func (sv headerValidations) SetMinItems(val int64) { - sv.current.MinItems = &val +// 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 } -func (sv headerValidations) SetMaxItems(val int64) { - sv.current.MaxItems = &val +// 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 } -func (sv headerValidations) SetMinLength(val int64) { - sv.current.MinLength = &val +// ResetForViolation satisfies schema.SimpleSchemaProbe. Wipes the +// header's SimpleSchema back to `{}`. +func (ht responseTypable) ResetForViolation() { + ht.header.SimpleSchema = oaispec.SimpleSchema{} } - -func (sv headerValidations) SetMaxLength(val int64) { - sv.current.MaxLength = &val -} - -func (sv headerValidations) SetPattern(val string) { - sv.current.Pattern = val -} - -func (sv headerValidations) SetUnique(val bool) { - sv.current.UniqueItems = val -} - -func (sv headerValidations) SetCollectionFormat(val string) { - sv.current.CollectionFormat = val -} - -func (sv headerValidations) SetEnum(val string) { - sv.current.Enum = parsers.ParseEnum(val, &oaispec.SimpleSchema{Type: sv.current.Type, Format: sv.current.Format}) -} - -func (sv headerValidations) SetDefault(val any) { sv.current.Default = val } - -func (sv headerValidations) SetExample(val any) { sv.current.Example = val } diff --git a/internal/builders/responses/walker.go b/internal/builders/responses/walker.go new file mode 100644 index 0000000..f48e0de --- /dev/null +++ b/internal/builders/responses/walker.go @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package responses + +import ( + "go/ast" + + "github.com/go-openapi/codescan/internal/builders/handlers" + oaispec "github.com/go-openapi/spec" +) + +// headerItemsLevelTarget pairs a 1-indexed nesting depth (matching +// grammar.Property.ItemsDepth) with an *oaispec.Items target into +// which items-level validations at that depth must be written. +type headerItemsLevelTarget struct { + level int + items *oaispec.Items +} + +// collectHeaderItemsLevels walks the AST array layers of a response +// header field and returns the (level, items) pairs reachable from +// the header's items chain. Mirrors items.ParseArrayTypes' shape on +// the grammar path; identical recursion shape to parameters' +// equivalent helper. +// +// Starting level is 1 — `items.maximum:` has ItemsDepth=1 in the +// grammar lexer. Named/aliased array types opt out (parity with +// v1's tagger pipeline). +func collectHeaderItemsLevels(expr ast.Expr, it *oaispec.Items, level int) []headerItemsLevelTarget { + if it == nil { + return nil + } + + here := headerItemsLevelTarget{level: level, items: it} + + switch e := expr.(type) { + case *ast.ArrayType: + rest := collectHeaderItemsLevels(e.Elt, it.Items, level+1) + out := make([]headerItemsLevelTarget, 0, 1+len(rest)) + return append(append(out, here), rest...) + + case *ast.Ident: + rest := collectHeaderItemsLevels(expr, it.Items, level+1) + if e.Obj == nil { + out := make([]headerItemsLevelTarget, 0, 1+len(rest)) + return append(append(out, here), rest...) + } + return rest + + case *ast.StarExpr: + return collectHeaderItemsLevels(e.X, it, level) + + case *ast.SelectorExpr: + return []headerItemsLevelTarget{here} + + case *ast.StructType, *ast.InterfaceType, *ast.MapType: + return nil + + default: + return nil + } +} + +// applyBlockToDecl parses the top-level response doc through grammar +// and writes the description to resp.Description via the grammar +// parser's prose accumulator. Does not dispatch any property +// keywords — the v1 SectionedParser only accepted description at the +// top level, no taggers. +func (r *Builder) applyBlockToDecl(resp *oaispec.Response) { + block := r.ParseBlock(r.Decl.Comments) + resp.Description = block.Prose() +} + +// applyBlockToHeader parses afld.Doc through grammar and dispatches +// description, header validations, items-level validations, and +// user-authored vendor extensions into ps. +// +// # Details +// +// See [§dispatch](./README.md#dispatch) — the three-phase Walker +// dispatch for headers, the omitted `required:` write, and how +// items-level validations chain. +func (r *Builder) applyBlockToHeader(afld *ast.Field, header *oaispec.Header) { + block := r.ParseBlock(afld.Doc) + + header.Description = block.Prose() + handlers.DispatchHeaderLevel0(block, header, r.RecordDiagnostic) + + if arrayType, ok := afld.Type.(*ast.ArrayType); ok { + for _, tgt := range collectHeaderItemsLevels(arrayType.Elt, header.Items, 1) { + handlers.DispatchItemsLevel(block, tgt.items, tgt.level, r.RecordDiagnostic) + } + } +} diff --git a/internal/builders/responses/walker_test.go b/internal/builders/responses/walker_test.go new file mode 100644 index 0000000..cc4b778 --- /dev/null +++ b/internal/builders/responses/walker_test.go @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package responses + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" + + "github.com/go-openapi/codescan/internal/builders/handlers" + "github.com/go-openapi/codescan/internal/parsers/grammar" + oaispec "github.com/go-openapi/spec" +) + +// ---------- collectHeaderItemsLevels ---------- + +func fieldType(t *testing.T, expr string) ast.Expr { + t.Helper() + e, err := parser.ParseExpr(expr) + if err != nil { + t.Fatalf("parseExpr %q: %v", expr, err) + } + return e +} + +func arrayTypeElt(t *testing.T, expr string) ast.Expr { + t.Helper() + at, ok := fieldType(t, expr).(*ast.ArrayType) + if !ok { + t.Fatalf("expected ArrayType for %q", expr) + } + return at.Elt +} + +func newItemsChain(depth int) *oaispec.Items { + if depth <= 0 { + return nil + } + root := new(oaispec.Items) + cur := root + for range depth - 1 { + cur.Items = new(oaispec.Items) + cur = cur.Items + } + return root +} + +func TestCollectHeaderItemsLevelsFlatSlice(t *testing.T) { + it := newItemsChain(1) + got := collectHeaderItemsLevels(arrayTypeElt(t, "[]string"), it, 1) + if len(got) != 1 || got[0].level != 1 || got[0].items != it { + t.Errorf("[]string: got %+v", got) + } +} + +func TestCollectHeaderItemsLevelsNestedSlice(t *testing.T) { + it := newItemsChain(2) + got := collectHeaderItemsLevels(arrayTypeElt(t, "[][]string"), it, 1) + if len(got) != 2 { + t.Fatalf("[][]string: got %d entries", len(got)) + } + if got[0].level != 1 || got[0].items != it { + t.Errorf("level 1: %+v", got[0]) + } + if got[1].level != 2 || got[1].items != it.Items { + t.Errorf("level 2: %+v", got[1]) + } +} + +func TestCollectHeaderItemsLevelsNilItems(t *testing.T) { + got := collectHeaderItemsLevels(arrayTypeElt(t, "[]string"), nil, 1) + if len(got) != 0 { + t.Errorf("nil items: got %+v", got) + } +} + +// ---------- dispatchHeaderLevel0 ---------- + +//nolint:ireturn // grammar.Block is the package's polymorphic return. +func parseResponseBody(t *testing.T, body string) grammar.Block { + t.Helper() + p := grammar.NewParser(token.NewFileSet()) + return p.ParseAs(grammar.AnnResponse, body, token.Position{Line: 1}) +} + +func runDispatch(t *testing.T, header *oaispec.Header, body string) { + t.Helper() + b := parseResponseBody(t, body) + handlers.DispatchHeaderLevel0(b, header, nil) +} + +func TestDispatchHeaderKeywordNumeric(t *testing.T) { + h := &oaispec.Header{} + h.Type = "integer" + runDispatch(t, h, "maximum: <10\nminimum: >=0\nmultipleOf: 2") + + if h.Maximum == nil || *h.Maximum != 10 || !h.ExclusiveMaximum { + t.Errorf("maximum: got (%v, %v)", h.Maximum, h.ExclusiveMaximum) + } + if h.Minimum == nil || *h.Minimum != 0 || h.ExclusiveMinimum { + t.Errorf("minimum: got (%v, %v)", h.Minimum, h.ExclusiveMinimum) + } + if h.MultipleOf == nil || *h.MultipleOf != 2 { + t.Errorf("multipleOf: got %v", h.MultipleOf) + } +} + +func TestDispatchHeaderKeywordIntegerAndUnique(t *testing.T) { + h := &oaispec.Header{} + runDispatch(t, h, "minLength: 3\nmaxLength: 10\nminItems: 1\nmaxItems: 100\nunique: true") + + if h.MinLength == nil || *h.MinLength != 3 { + t.Errorf("minLength: %v", h.MinLength) + } + if h.MaxLength == nil || *h.MaxLength != 10 { + t.Errorf("maxLength: %v", h.MaxLength) + } + if h.MinItems == nil || *h.MinItems != 1 { + t.Errorf("minItems: %v", h.MinItems) + } + if h.MaxItems == nil || *h.MaxItems != 100 { + t.Errorf("maxItems: %v", h.MaxItems) + } + if !h.UniqueItems { + t.Errorf("unique: want true") + } +} + +func TestDispatchHeaderKeywordPatternAndEnum(t *testing.T) { + h := &oaispec.Header{} + h.Type = "string" + runDispatch(t, h, "pattern: ^[a-z]+$\nenum: red, green, blue") + + if h.Pattern != "^[a-z]+$" { + t.Errorf("pattern: %q", h.Pattern) + } + if len(h.Enum) != 3 || h.Enum[0] != "red" { + t.Errorf("enum: %v", h.Enum) + } +} + +func TestDispatchHeaderKeywordDefaultExampleScheme(t *testing.T) { + h := &oaispec.Header{} + h.Type = "integer" + runDispatch(t, h, "default: 42\nexample: 7") + + if h.Default != 42 { + t.Errorf("default: got %v (%T), want 42", h.Default, h.Default) + } + if h.Example != 7 { + t.Errorf("example: got %v (%T), want 7", h.Example, h.Example) + } +} + +func TestDispatchHeaderKeywordCollectionFormat(t *testing.T) { + h := &oaispec.Header{} + runDispatch(t, h, "collectionFormat: csv") + + if h.CollectionFormat != "csv" { + t.Errorf("collectionFormat: %q", h.CollectionFormat) + } +} diff --git a/internal/parsers/responses.go b/internal/parsers/responses.go deleted file mode 100644 index 53373e4..0000000 --- a/internal/parsers/responses.go +++ /dev/null @@ -1,224 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "fmt" - "regexp" - "strconv" - "strings" - - oaispec "github.com/go-openapi/spec" -) - -const ( - // r)sponseTag used when specifying a response to point to a defined swagger:response. - responseTag = "response" - - // bodyTag used when specifying a response to point to a model/schema. - bodyTag = "body" - - // descriptionTag used when specifying a response that gives a description of the response. - descriptionTag = "description" -) - -type SetOpResponses struct { - set func(*oaispec.Response, map[int]oaispec.Response) - rx *regexp.Regexp - definitions map[string]oaispec.Schema - responses map[string]oaispec.Response -} - -func NewSetResponses(definitions map[string]oaispec.Schema, responses map[string]oaispec.Response, setter func(*oaispec.Response, map[int]oaispec.Response)) *SetOpResponses { - return &SetOpResponses{ - set: setter, - rx: rxResponses, - definitions: definitions, - responses: responses, - } -} - -func (ss *SetOpResponses) Matches(line string) bool { - return ss.rx.MatchString(line) -} - -func (ss *SetOpResponses) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - - var def *oaispec.Response - var scr map[int]oaispec.Response - - for _, line := range lines { - var err error - def, scr, err = ss.parseResponseLine(line, def, scr) - if err != nil { - return err - } - } - - ss.set(def, scr) - - return nil -} - -func (ss *SetOpResponses) parseResponseLine(line string, def *oaispec.Response, scr map[int]oaispec.Response) (*oaispec.Response, map[int]oaispec.Response, error) { - kv := strings.SplitN(line, ":", kvParts) - if len(kv) <= 1 { - return def, scr, nil - } - - key := strings.TrimSpace(kv[0]) - if key == "" { - return def, scr, nil - } - - value := strings.TrimSpace(kv[1]) - if value == "" { - def, scr = assignResponse(key, oaispec.Response{}, def, scr) - return def, scr, nil - } - - refTarget, arrays, isDefinitionRef, description, err := parseTags(value) - if err != nil { - return def, scr, err - } - - // A possible exception for having a definition - if _, ok := ss.responses[refTarget]; !ok { - if _, ok := ss.definitions[refTarget]; ok { - isDefinitionRef = true - } - } - - var ref oaispec.Ref - if isDefinitionRef { - if description == "" { - description = refTarget - } - ref, err = oaispec.NewRef("#/definitions/" + refTarget) - } else { - ref, err = oaispec.NewRef("#/responses/" + refTarget) - } - if err != nil { - return def, scr, err - } - - // description should used on anyway. - resp := oaispec.Response{ResponseProps: oaispec.ResponseProps{Description: description}} - - if isDefinitionRef { - resp.Schema = new(oaispec.Schema) - resp.Description = description - if arrays == 0 { - resp.Schema.Ref = ref - } else { - cs := resp.Schema - for range arrays { - cs.Typed("array", "") - cs.Items = new(oaispec.SchemaOrArray) - cs.Items.Schema = new(oaispec.Schema) - cs = cs.Items.Schema - } - cs.Ref = ref - } - // ref. could be empty while use description tag - } else if len(refTarget) > 0 { - resp.Ref = ref - } - - def, scr = assignResponse(key, resp, def, scr) - return def, scr, nil -} - -func parseTags(line string) (modelOrResponse string, arrays int, isDefinitionRef bool, description string, err error) { - tags := strings.Split(line, " ") - parsedModelOrResponse := false - - for i, tagAndValue := range tags { - tagValList := strings.SplitN(tagAndValue, ":", kvParts) - var tag, value string - if len(tagValList) > 1 { - tag = tagValList[0] - value = tagValList[1] - } else { - // Proposal for enhancement: print a warning, and in the long term, do not support untagged values - // - // Add a default tag if none is supplied - if i == 0 { - tag = responseTag - } else { - tag = descriptionTag - } - value = tagValList[0] - } - - foundModelOrResponse := false - if !parsedModelOrResponse { - if tag == bodyTag { - foundModelOrResponse = true - isDefinitionRef = true - } - if tag == responseTag { - foundModelOrResponse = true - isDefinitionRef = false - } - } - if foundModelOrResponse { - // Read the model or response tag - parsedModelOrResponse = true - // Check for nested arrays - arrays = 0 - for strings.HasPrefix(value, "[]") { - arrays++ - value = value[2:] - } - // What's left over is the model name - modelOrResponse = value - continue - } - - if tag == descriptionTag { - // Descriptions are special, they read the rest of the line - descriptionWords := []string{value} - if i < len(tags)-1 { - descriptionWords = append(descriptionWords, tags[i+1:]...) - } - description = strings.Join(descriptionWords, " ") - break - } - - if tag == responseTag || tag == bodyTag { - err = fmt.Errorf("valid tag %s, but not in a valid position: %w", tag, ErrParser) - } else { - err = fmt.Errorf("invalid tag: %s: %w", tag, ErrParser) - } - - // Error case - return modelOrResponse, arrays, isDefinitionRef, description, err - } - - // Proposal for enhancement: maybe do, if !parsedModelOrResponse {return some error} - - return modelOrResponse, arrays, isDefinitionRef, description, err -} - -func assignResponse(key string, resp oaispec.Response, def *oaispec.Response, scr map[int]oaispec.Response) (*oaispec.Response, map[int]oaispec.Response) { - if strings.EqualFold("default", key) { - if def == nil { - def = &resp - } - return def, scr - } - - if sc, err := strconv.Atoi(key); err == nil { - if scr == nil { - scr = make(map[int]oaispec.Response) - } - scr[sc] = resp - } - - return def, scr -} diff --git a/internal/parsers/responses_test.go b/internal/parsers/responses_test.go deleted file mode 100644 index 7fca1f7..0000000 --- a/internal/parsers/responses_test.go +++ /dev/null @@ -1,264 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" - - oaispec "github.com/go-openapi/spec" -) - -func TestSetOpResponses_Matches(t *testing.T) { - t.Parallel() - - sr := NewSetResponses(nil, nil, nil) - assert.TrueT(t, sr.Matches("responses:")) - assert.TrueT(t, sr.Matches("Responses:")) - assert.FalseT(t, sr.Matches("something else")) -} - -func TestSetOpResponses_Parse(t *testing.T) { - t.Parallel() - - t.Run("empty", func(t *testing.T) { - var called bool - sr := NewSetResponses(nil, nil, func(_ *oaispec.Response, _ map[int]oaispec.Response) { called = true }) - require.NoError(t, sr.Parse(nil)) - assert.FalseT(t, called) - require.NoError(t, sr.Parse([]string{})) - require.NoError(t, sr.Parse([]string{""})) - }) - - t.Run("response ref", func(t *testing.T) { - responses := map[string]oaispec.Response{ - "notFound": {ResponseProps: oaispec.ResponseProps{Description: "not found"}}, - } - var gotDef *oaispec.Response - var gotScr map[int]oaispec.Response - - sr := NewSetResponses(nil, responses, func(def *oaispec.Response, scr map[int]oaispec.Response) { - gotDef = def - gotScr = scr - }) - - require.NoError(t, sr.Parse([]string{"404: notFound"})) - assert.Nil(t, gotDef) - require.NotNil(t, gotScr) - resp, ok := gotScr[404] - require.TrueT(t, ok) - assert.NotEmpty(t, resp.Ref.String()) - }) - - t.Run("default response", func(t *testing.T) { - responses := map[string]oaispec.Response{ - "genericError": {}, - } - var gotDef *oaispec.Response - - sr := NewSetResponses(nil, responses, func(def *oaispec.Response, _ map[int]oaispec.Response) { - gotDef = def - }) - - require.NoError(t, sr.Parse([]string{"default: genericError"})) - require.NotNil(t, gotDef) - }) - - t.Run("body ref", func(t *testing.T) { - definitions := map[string]oaispec.Schema{ - "Pet": {}, - } - var gotScr map[int]oaispec.Response - - sr := NewSetResponses(definitions, nil, func(_ *oaispec.Response, scr map[int]oaispec.Response) { - gotScr = scr - }) - - require.NoError(t, sr.Parse([]string{"200: body:Pet"})) - require.NotNil(t, gotScr) - resp := gotScr[200] - require.NotNil(t, resp.Schema) - assert.Contains(t, resp.Schema.Ref.String(), "definitions/Pet") - }) - - t.Run("body array ref", func(t *testing.T) { - definitions := map[string]oaispec.Schema{ - "Pet": {}, - } - var gotScr map[int]oaispec.Response - - sr := NewSetResponses(definitions, nil, func(_ *oaispec.Response, scr map[int]oaispec.Response) { - gotScr = scr - }) - - require.NoError(t, sr.Parse([]string{"200: body:[]Pet"})) - require.NotNil(t, gotScr) - resp := gotScr[200] - require.NotNil(t, resp.Schema) - assert.EqualT(t, "array", resp.Schema.Type[0]) - require.NotNil(t, resp.Schema.Items) - assert.Contains(t, resp.Schema.Items.Schema.Ref.String(), "definitions/Pet") - }) - - t.Run("with description tag", func(t *testing.T) { - responses := map[string]oaispec.Response{ - "notFound": {}, - } - var gotScr map[int]oaispec.Response - - sr := NewSetResponses(nil, responses, func(_ *oaispec.Response, scr map[int]oaispec.Response) { - gotScr = scr - }) - - require.NoError(t, sr.Parse([]string{"404: response:notFound description:Not Found"})) - require.NotNil(t, gotScr) - assert.EqualT(t, "Not Found", gotScr[404].Description) - }) -} - -func TestParseTags(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - line string - wantModel string - wantArrays int - wantIsDefRef bool - wantDesc string - wantErr bool - }{ - {"response ref", "notFound", "notFound", 0, false, "", false}, - {"body ref", "body:Pet", "Pet", 0, true, "", false}, - {"body array", "body:[]Pet", "Pet", 1, true, "", false}, - {"body nested array", "body:[][]Pet", "Pet", 2, true, "", false}, - {"with description", "notFound description:Resource not found", "notFound", 0, false, "Resource not found", false}, - {"invalid tag", "invalid:tag value:wrong", "", 0, false, "", true}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - model, arrays, isDefRef, desc, err := parseTags(tc.line) - if tc.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - assert.EqualT(t, tc.wantModel, model) - assert.EqualT(t, tc.wantArrays, arrays) - assert.EqualT(t, tc.wantIsDefRef, isDefRef) - assert.EqualT(t, tc.wantDesc, desc) - }) - } -} - -func TestAssignResponse(t *testing.T) { - t.Parallel() - - t.Run("default", func(t *testing.T) { - resp := oaispec.Response{ResponseProps: oaispec.ResponseProps{Description: "error"}} - def, scr := assignResponse("default", resp, nil, nil) - require.NotNil(t, def) - assert.EqualT(t, "error", def.Description) - assert.Nil(t, scr) - }) - - t.Run("default already set", func(t *testing.T) { - existing := &oaispec.Response{ResponseProps: oaispec.ResponseProps{Description: "existing"}} - def, _ := assignResponse("default", oaispec.Response{}, existing, nil) - assert.EqualT(t, "existing", def.Description) // not overwritten - }) - - t.Run("status code", func(t *testing.T) { - resp := oaispec.Response{ResponseProps: oaispec.ResponseProps{Description: "ok"}} - def, scr := assignResponse("200", resp, nil, nil) - assert.Nil(t, def) - require.NotNil(t, scr) - assert.EqualT(t, "ok", scr[200].Description) - }) - - t.Run("non-numeric key ignored", func(t *testing.T) { - def, scr := assignResponse("notANumber", oaispec.Response{}, nil, nil) - assert.Nil(t, def) - assert.Nil(t, scr) - }) -} - -func TestSetOpResponses_ParseEdgeCases(t *testing.T) { - t.Parallel() - - t.Run("line without colon", func(t *testing.T) { - var gotScr map[int]oaispec.Response - sr := NewSetResponses(nil, nil, func(_ *oaispec.Response, scr map[int]oaispec.Response) { - gotScr = scr - }) - require.NoError(t, sr.Parse([]string{"no-colon-here"})) - assert.Nil(t, gotScr) - }) - - t.Run("empty key after trim", func(t *testing.T) { - var gotScr map[int]oaispec.Response - sr := NewSetResponses(nil, nil, func(_ *oaispec.Response, scr map[int]oaispec.Response) { - gotScr = scr - }) - require.NoError(t, sr.Parse([]string{" : someValue"})) - assert.Nil(t, gotScr) - }) - - t.Run("empty value assigns empty response", func(t *testing.T) { - var gotScr map[int]oaispec.Response - sr := NewSetResponses(nil, nil, func(_ *oaispec.Response, scr map[int]oaispec.Response) { - gotScr = scr - }) - require.NoError(t, sr.Parse([]string{"200:"})) - require.NotNil(t, gotScr) - _, ok := gotScr[200] - assert.TrueT(t, ok) - }) - - t.Run("parse error propagated", func(t *testing.T) { - sr := NewSetResponses(nil, nil, func(_ *oaispec.Response, _ map[int]oaispec.Response) {}) - // "invalid:tag" is not response/body/description → error - err := sr.Parse([]string{"200: invalid:tag"}) - require.Error(t, err) - assert.ErrorIs(t, err, ErrParser) - }) - - t.Run("definition found by fallback lookup", func(t *testing.T) { - // refTarget is not in responses but IS in definitions → isDefinitionRef becomes true - definitions := map[string]oaispec.Schema{ - "ErrorModel": {}, - } - var gotScr map[int]oaispec.Response - sr := NewSetResponses(definitions, nil, func(_ *oaispec.Response, scr map[int]oaispec.Response) { - gotScr = scr - }) - require.NoError(t, sr.Parse([]string{"500: ErrorModel"})) - require.NotNil(t, gotScr) - resp := gotScr[500] - require.NotNil(t, resp.Schema) - assert.Contains(t, resp.Schema.Ref.String(), "definitions/ErrorModel") - }) -} - -func TestParseTags_UntaggedValues(t *testing.T) { - t.Parallel() - - t.Run("second value defaults to description tag", func(t *testing.T) { - // "notFound Something" → first untagged = responseTag, second untagged = descriptionTag - model, _, _, desc, err := parseTags("notFound Something here") - require.NoError(t, err) - assert.EqualT(t, "notFound", model) - assert.EqualT(t, "Something here", desc) - }) - - t.Run("response tag out of position", func(t *testing.T) { - // response: after first value already parsed - _, _, _, _, err := parseTags("body:Pet response:duplicate") - require.Error(t, err) - assert.ErrorIs(t, err, ErrParser) - }) -} From 70928f0385a314a56dc994725e1afa2d1e9efb9b Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:38:24 +0200 Subject: [PATCH 13/22] feat(builders/operations): operation annotation builder Parses swagger:operation annotations. The annotation body is YAML; the parser delegates to parsers/yaml for unmarshal then maps the resulting structure onto spec.Operation, surfacing parameter / response / consumer / producer / scheme / security properties to the orchestrator. Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- internal/builders/operations/README.md | 92 +++++++++++++++++++ internal/builders/operations/operations.go | 82 +++++++++++------ .../operations/operations_go119_test.go | 6 +- .../builders/operations/operations_test.go | 6 +- internal/builders/operations/walker.go | 30 ++++++ 5 files changed, 177 insertions(+), 39 deletions(-) create mode 100644 internal/builders/operations/README.md create mode 100644 internal/builders/operations/walker.go diff --git a/internal/builders/operations/README.md b/internal/builders/operations/README.md new file mode 100644 index 0000000..5d3ba20 --- /dev/null +++ b/internal/builders/operations/README.md @@ -0,0 +1,92 @@ +# `internal/builders/operations` — maintainers' guide + +Builds OAS v2 operation entries for `swagger:operation` annotations +— Summary, Description, and the YAML body content. One `Builder` +per annotation; one grammar parse per operation. + +## Sections + +- [§overview](#overview) — package shape and per-file responsibilities +- [§builder](#builder) — `Builder`, `Build`, the orchestrator entry +- [§path-operation-slot](#path-operation-slot) — `setPathOperation` reuse semantics +- [§walker](#walker) — `applyBlockToOperation` and the YAML body contract +- [§quirks-open](#quirks-open) — deferred follow-ups + +--- + +## §overview — files and responsibilities + +| File | Contents | +|------|----------| +| `operations.go` | `Builder` (embeds `*common.Builder`), `NewBuilder`, top-level `Build`, `setPathOperation` slot helper | +| `walker.go` | `applyBlockToOperation` — grammar Block → operation Summary/Description/YAML body | +| `errors.go` | `ErrOperations` sentinel | + +The builder embeds `*common.Builder` (Ctx, ParseBlocks cache, +diagnostic sink). `Decl` is nil — operations build off a path +annotation, not a declaration; `MakeRef` / Decl-anchored helpers +must not be called. + +Per-keyword body parsing for routes (`schemes`, `consumes`, +`security`, ...) lives in the `routes` package. The operations +package only handles the operation header (Summary/Description) and +the YAML body for `swagger:operation`. + +## §builder — top-level `Build` + +`Build(tgt *spec.Paths)` resolves the path-item slot on `tgt`, +allocates or reuses the operation for the HTTP verb via +`setPathOperation`, attaches the operation's `Tags`, then dispatches +into `applyBlockToOperation` for the header content and the YAML +body. + +The path-item slot may be missing on `tgt` (`tgt.Paths == nil`); the +builder lazily initialises the map before writing the result back. + +## §path-operation-slot — `setPathOperation` reuse + +`setPathOperation` lands an `*oaispec.Operation` on the HTTP-verb +slot of a `*oaispec.PathItem`. When the slot already holds an +operation with the same ID, the existing one is kept — so a +partially-built operation accumulates work across the scanner's two +passes (route discovery then operation discovery). Otherwise the +incoming operation replaces what was there. + +When the incoming operation is nil, a fresh operation is allocated +with the given ID. + +Unrecognised methods leave the path item untouched and return the +incoming operation verbatim. + +The public re-export `SetPathOperation` exists for consumers in +sibling packages (the `routes` builder uses it to allocate or reuse +the same operation slot from the route side). + +## §walker — `applyBlockToOperation` + +`applyBlockToOperation` parses `path.Remaining` through the grammar +parser and writes Summary / Description / YAML body content onto +the operation. + +The grammar lexer already classifies prose into `TokenTitle` / +`TokenDesc` and isolates `---` fenced bodies into `TokenOpaqueYaml`, +so the bridge collapses to three direct reads: + +1. `op.Summary` ← `block.Title()` — first title paragraph. +2. `op.Description` ← `block.Description()` — remaining prose. +3. The first body from `block.YAMLBlocks()` is fed through + `yaml.UnmarshalBody` → `op.UnmarshalJSON`. Exactly one fenced + body is consumed per operation; subsequent bodies are ignored. + +`path.Remaining` is the `*ast.CommentGroup` AFTER the +`swagger:operation` header line has been stripped by +`parsers.ParseOperationPathAnnotation`, so the grammar sees it as an +`UnboundBlock` whose `Title` / `Description` / `YAMLBlocks` all +behave identically to a properly-anchored block. + +## §quirks-open — deferred follow-ups + +- **single fenced body per operation.** The walker consumes the + first `YAMLBlocks` entry only. Multiple `---` blocks in a single + `swagger:operation` annotation are silently dropped beyond the + first; a future strict-mode option could emit a diagnostic. diff --git a/internal/builders/operations/operations.go b/internal/builders/operations/operations.go index 44011ac..76835b9 100644 --- a/internal/builders/operations/operations.go +++ b/internal/builders/operations/operations.go @@ -7,20 +7,32 @@ import ( "fmt" "strings" + "github.com/go-openapi/codescan/internal/builders/common" "github.com/go-openapi/codescan/internal/parsers" "github.com/go-openapi/codescan/internal/scanner" oaispec "github.com/go-openapi/spec" ) +// Builder constructs OAS v2 operation entries for one +// `swagger:operation` annotation. Embeds *common.Builder for shared +// state (Ctx, ParseBlocks cache, diagnostic sink). Decl is unused +// 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. type Builder struct { - ctx *scanner.ScanCtx + *common.Builder + path parsers.ParsedPathContent operations map[string]*oaispec.Operation } func NewBuilder(ctx *scanner.ScanCtx, pth parsers.ParsedPathContent, operations map[string]*oaispec.Operation) *Builder { return &Builder{ - ctx: ctx, + Builder: common.New(ctx, nil), path: pth, operations: operations, } @@ -33,15 +45,8 @@ func (o *Builder) Build(tgt *oaispec.Paths) error { o.path.Method, o.path.ID, &pthObj, o.operations[o.path.ID]) op.Tags = o.path.Tags - sp := parsers.NewYAMLSpecScanner( - func(lines []string) { op.Summary = parsers.JoinDropLast(lines) }, // setTitle - func(lines []string) { op.Description = parsers.JoinDropLast(lines) }, // setDescription - ) - if err := sp.Parse(o.path.Remaining); err != nil { - return fmt.Errorf("operation (%s): %w", op.ID, err) - } - if err := sp.UnmarshalSpec(op.UnmarshalJSON); err != nil { + if err := o.applyBlockToOperation(op); err != nil { return fmt.Errorf("operation (%s): %w", op.ID, err) } @@ -54,22 +59,20 @@ func (o *Builder) Build(tgt *oaispec.Paths) error { return nil } -// assignOrReuse either reuses an existing operation (if the ID matches) -// or assigns op to the slot. -// -// TODO(claude): rewrite without double indirection. -func assignOrReuse(slot **oaispec.Operation, op *oaispec.Operation, id string) *oaispec.Operation { - if *slot != nil && id == (*slot).ID { - return *slot - } - *slot = op - return op -} - func SetPathOperation(method, id string, pthObj *oaispec.PathItem, op *oaispec.Operation) *oaispec.Operation { return setPathOperation(method, id, pthObj, op) } +// setPathOperation lands op on the HTTP-verb slot of pthObj. 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. func setPathOperation(method, id string, pthObj *oaispec.PathItem, op *oaispec.Operation) *oaispec.Operation { if op == nil { op = new(oaispec.Operation) @@ -78,19 +81,40 @@ func setPathOperation(method, id string, pthObj *oaispec.PathItem, op *oaispec.O switch strings.ToUpper(method) { case "GET": - op = assignOrReuse(&pthObj.Get, op, id) + if pthObj.Get == nil || pthObj.Get.ID != id { + pthObj.Get = op + } + return pthObj.Get case "POST": - op = assignOrReuse(&pthObj.Post, op, id) + if pthObj.Post == nil || pthObj.Post.ID != id { + pthObj.Post = op + } + return pthObj.Post case "PUT": - op = assignOrReuse(&pthObj.Put, op, id) + if pthObj.Put == nil || pthObj.Put.ID != id { + pthObj.Put = op + } + return pthObj.Put case "PATCH": - op = assignOrReuse(&pthObj.Patch, op, id) + if pthObj.Patch == nil || pthObj.Patch.ID != id { + pthObj.Patch = op + } + return pthObj.Patch case "HEAD": - op = assignOrReuse(&pthObj.Head, op, id) + if pthObj.Head == nil || pthObj.Head.ID != id { + pthObj.Head = op + } + return pthObj.Head case "DELETE": - op = assignOrReuse(&pthObj.Delete, op, id) + if pthObj.Delete == nil || pthObj.Delete.ID != id { + pthObj.Delete = op + } + return pthObj.Delete case "OPTIONS": - op = assignOrReuse(&pthObj.Options, op, id) + if pthObj.Options == nil || pthObj.Options.ID != id { + pthObj.Options = op + } + return pthObj.Options } return op diff --git a/internal/builders/operations/operations_go119_test.go b/internal/builders/operations/operations_go119_test.go index 7a932a4..3dfca4b 100644 --- a/internal/builders/operations/operations_go119_test.go +++ b/internal/builders/operations/operations_go119_test.go @@ -25,11 +25,7 @@ func TestIndentedYAMLBlock(t *testing.T) { var ops spec.Paths for apiPath := range sctx.Operations() { - prs := &Builder{ - ctx: sctx, - path: apiPath, - operations: make(map[string]*spec.Operation), - } + prs := NewBuilder(sctx, apiPath, make(map[string]*spec.Operation)) require.NoError(t, prs.Build(&ops)) } diff --git a/internal/builders/operations/operations_test.go b/internal/builders/operations/operations_test.go index d16281e..f3651d9 100644 --- a/internal/builders/operations/operations_test.go +++ b/internal/builders/operations/operations_test.go @@ -28,11 +28,7 @@ func TestOperationsParser(t *testing.T) { require.NoError(t, err) var ops spec.Paths for apiPath := range sctx.Operations() { - prs := &Builder{ - ctx: sctx, - path: apiPath, - operations: make(map[string]*spec.Operation), - } + prs := NewBuilder(sctx, apiPath, make(map[string]*spec.Operation)) require.NoError(t, prs.Build(&ops)) } diff --git a/internal/builders/operations/walker.go b/internal/builders/operations/walker.go new file mode 100644 index 0000000..3035ac5 --- /dev/null +++ b/internal/builders/operations/walker.go @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package operations + +import ( + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/parsers/yaml" + oaispec "github.com/go-openapi/spec" +) + +// applyBlockToOperation parses path.Remaining through grammar and +// writes Summary / Description / YAML body content onto op. +// +// # Details +// +// 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()).Parse(o.path.Remaining) + + op.Summary = block.Title() + op.Description = block.Description() + + for y := range block.YAMLBlocks() { + return yaml.UnmarshalBody(y.Text, op.UnmarshalJSON) + } + return nil +} From 827972a0a5006819df6ea5f16d889e7ccad7613c Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:38:32 +0200 Subject: [PATCH 14/22] feat(builders/routes): route discovery + body parsing Parses swagger:route annotations: header line (method + path + operation ID + tags), then a multi-line body grammar consumed via parsers/routebody. Surfaces parameters, responses, consumers, producers, schemes, and security to the orchestrator. Synonym to swagger:operation but radically different in shape. Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- internal/builders/routes/README.md | 221 ++++++++++++ internal/builders/routes/routes.go | 19 +- internal/builders/routes/routes_test.go | 47 ++- internal/builders/routes/setters.go | 48 --- internal/builders/routes/taggers.go | 22 -- internal/builders/routes/walker.go | 441 ++++++++++++++++++++++++ internal/builders/routes/walker_test.go | 115 ++++++ internal/parsers/route_params.go | 267 -------------- internal/parsers/route_params_test.go | 250 -------------- 9 files changed, 817 insertions(+), 613 deletions(-) create mode 100644 internal/builders/routes/README.md delete mode 100644 internal/builders/routes/setters.go delete mode 100644 internal/builders/routes/taggers.go create mode 100644 internal/builders/routes/walker.go create mode 100644 internal/builders/routes/walker_test.go delete mode 100644 internal/parsers/route_params.go delete mode 100644 internal/parsers/route_params_test.go diff --git a/internal/builders/routes/README.md b/internal/builders/routes/README.md new file mode 100644 index 0000000..9988dbf --- /dev/null +++ b/internal/builders/routes/README.md @@ -0,0 +1,221 @@ +# routes — maintainer notes + +This document is the long-form companion to the `routes` builder +code. The source files keep godoc concise; complex invariants, +design trade-offs, and intentionally-deferred follow-ups live here. + +The `routes` package builds OAS v2 path entries from a single +`swagger:route` annotation — Summary, Description, schemes, +deprecated, consumes, produces, security, parameters, responses, +and vendor extensions. One `Builder` runs per route annotation; +one grammar parse runs per route. + +--- + +## Table of contents + +- [§overview](#overview) — files and per-file responsibilities +- [§builder](#builder) — `Builder`, `Build`, and the dispatch chain +- [§dispatch](#dispatch) — `dispatchRouteKeyword` per-keyword routing +- [§body-parsers](#body-parsers) — parameters / responses body lowering +- [§extensions](#extensions) — typed extensions surfaced by the lexer +- [§quirks](#quirks) — known behavioural caveats + +--- + +## §overview — files and responsibilities + +| File | Contents | +|------|----------| +| `routes.go` | `Builder` (embeds `*common.Builder`), `Inputs`, `NewBuilder`, top-level `Build` | +| `walker.go` | Grammar dispatch — `applyBlockToRoute`, `dispatchRouteKeyword`, parameter and response materialisation | +| `errors.go` | `ErrRoutes` sentinel | + +The body grammars for `parameters:` and `responses:` live in +`internal/parsers/routebody`. The routes builder calls +`routebody.ParseParameters` / `routebody.ParseResponses` to lower +the raw body to typed `ParamDecl` / `ResponseDecl` slices, then +walks each decl through the handlers seam to populate the +operation. See [§body-parsers](#body-parsers). + +Extensions are NOT parsed here — they ride grammar's typed-extensions +surface directly. See [§extensions](#extensions). + +The builder embeds `*common.Builder` (Ctx, ParseBlocks cache, +diagnostic sink). Its `Decl` field is nil — routes build off a +path annotation, not a declaration — so `MakeRef` and other +Decl-anchored helpers must not be called. + +## §builder — top-level `Build` + +`Build(tgt *spec.Paths)` looks up the path-item slot on `tgt`, +allocates or reuses the operation for the HTTP verb via +`operations.SetPathOperation`, attaches the route's `Tags`, then +dispatches into `applyBlockToRoute` for the header content and +per-keyword bodies. + +`applyBlockToRoute` parses `route.Remaining` (the +`*ast.CommentGroup` after the `swagger:route` header line has been +stripped by `parsers.ParseRoutePathAnnotation`) into a grammar +Block. `block.Title()` and `block.Description()` give the +lexer-classified prose; `block.Properties()` yields one entry per +recognised route-level keyword. Items-depth entries +(`items.maximum:` and friends) are skipped — they belong to a +nested schema, not the route header. + +After the property loop, `applyBlockToRoute` reads the typed +extension and security surfaces straight off the block. +The lexer routes their raw bodies through `yaml.TypedExtensions` +and the security sub-parser at lex time, so the dispatcher skips +them and the orchestrator picks up the typed values directly. + +## §dispatch — `dispatchRouteKeyword` + +One switch over `p.Keyword.Name`. Per-shape: + +- **List-shaped keywords** (`schemes`, `consumes`, `produces`) + flow through `Property.AsList`, which unifies inline comma-lists, + multi-line bare-line bodies, and YAML-style `-` markers. The + resulting `[]string` is assigned directly onto the operation. +- **Inline boolean** (`deprecated`) reads `p.Typed.Boolean` after + an `IsTyped()` guard, so malformed inputs (which leave + `ShapeNone`) are skipped silently. +- **Body-parser keywords** (`parameters`, `responses`) hand the raw + `Property.Body` and `Property.Pos` to `routebody`; the returned + decl slices fan into `buildRouteParam` / `buildRouteResponse`. + +`extensions:` and `security:` are not on the dispatcher — see +[§extensions](#extensions) and the security surface read at the +end of `applyBlockToRoute`. + +## §body-parsers — parameters and responses + +Two body shapes are too domain-specific to express through grammar's +keyword table and live in `internal/parsers/routebody`: + +- **Parameters** — the `+ name:` block syntax used to describe + route parameters inline. `routebody.ParseParameters` returns one + `ParamDecl` per parameter, each carrying head fields (`Name`, + `In`, `Required`, `Description`, `TypeRef`, `Format`, + `AllowEmpty`) and a sub-Block of validation properties. +- **Responses** — the `200: body Foo description text` + mini-language for status-code → response-or-definition mappings. + `routebody.ParseResponses` returns one `ResponseDecl` per entry, + each carrying `Code`, `BodyTypeRef` / `ResponseRef`, `Arrays`, + and `Description`. + +`buildRouteParam` and `buildRouteResponse` materialise each decl +into the corresponding `spec.Parameter` / `spec.Response`: + +- **Non-body params** dispatch through + `handlers.DispatchParamLevel0` (SimpleSchema). Validation + properties pass through `typeGateBlock` first, which drops any + keyword that is not legal for the declared type and emits a + `CodeShapeMismatch` diagnostic per dropped keyword so the author + sees the loss. +- **Body params** populate `param.Schema` from the type reference + (primitive type → typed schema; otherwise a `$ref` resolved + against `r.definitions` with optional array wrapping), then + dispatch through `handlers.DispatchSchemaLevel0`. The + description lives only on the parameter — the referenced model + owns the schema-level description. +- **Responses** assemble `op.Responses` from each `ResponseDecl`, + routing `"default"` to `Responses.Default` and integer codes to + `Responses.StatusCodeResponses`. Ref resolution follows the + definition-fallback rule documented in + [§quirks](#quirk-definition-fallback). + +`normaliseSimpleType` maps short type spellings (`bool` → +`boolean`) to their OAS v2 canonical forms before the parameter +type lands on the spec. + +### Format timing on SimpleSchema parameters + +`param.Format` is assigned **after** `DispatchParamLevel0` on +purpose. `spec.SimpleSchema.TypeName()` returns `Format` when it +is non-empty, so `validations.CoerceValue` would key +default/example coercion off the format string instead of the +type. Setting Format post-dispatch keeps `param.Type` stable +through coercion. + +## §extensions — typed via grammar's lexer + +Routes consumes vendor extensions through the same path schema, +parameters, and responses use: + +```go +for ext := range block.Extensions() { + op.AddExtension(ext.Name, ext.Value) +} +``` + +Grammar's lexer recognises `extensions:` (and `infoExtensions:`) +raw blocks and, at lex time, runs the body through +`yaml.TypedExtensions`: + +1. `normaliseExtensionBody` (dedent + tab→space conversion) +2. `yaml.Unmarshal` into `any` +3. `yamlutils.YAMLToJSON` to coerce to JSON-typed values +4. `json.Unmarshal` into `map[string]any` + +The result is exposed on the parsed Block as `[]Extension` with +each entry carrying `Name`, `Pos`, and the JSON-typed `Value` +(`bool` / `float64` / `string` / `[]any` / `map[string]any`). + +## §quirks — behavioural caveats + +### Block-comment prefix on Title / Description + +Route docs are most often `/* ... */` block comments. Each non-first +line of such a comment carries a leading tab / whitespace indent +that `//`-style line comments shed naturally (the preprocessor +strips the `// ` prefix per line). Grammar's lexer classifies the +prose correctly (Title vs Description, markdown ATX heading +stripping included) but preserves the raw source text. Consumers +that surface the prose verbatim need to shave the per-line +comment-marker noise (`space`, `tab`, `/`, `*`, `-`, optional `|`) +off the result themselves. + +The lexer deliberately does not do this stripping: its contract is +"preserve source verbatim, classify into tokens." Comment-marker +noise is a consumer-side concern, and stripping at the lexer would +break the LSP-diagnostics target (per-line `file:line:col` +positions must survive Preprocess). + +### Response definition-fallback + +A response whose ref name does not appear in `r.responses` but +does appear in `r.definitions` is silently promoted to a body ref +(`Schema: $ref: #/definitions/`) rather than emitting an +invalid `$ref: #/responses/`. This kindness exists because +authors commonly reference a model by name in a `responses:` block +without first declaring a `swagger:response` wrapper. + +Dangling refs (not in either map) emit a `CodeInvalidAnnotation` +diagnostic and the response is dropped — the author sees the loss +rather than discovering it as a malformed spec downstream. + +### SimpleSchema type-gating diagnostics + +`typeGateBlock` filters validation properties through +`validations.IsLegalForType` for the parameter's declared type and +emits `CodeShapeMismatch` per dropped keyword. A SimpleSchema +parameter with no declared `type:` drops every validation property +with a diagnostic explaining the loss — the author sees the +mismatch rather than a silently-empty validation surface. + +### Coercion errors surface as diagnostics + +`DispatchParamLevel0` may return an error when a `default:` or +`example:` value cannot be coerced to the declared type. The +router surfaces the first such error as a +`CodeInvalidAnnotation` diagnostic rather than dropping it silently +so the author sees the bad input. + +### Extensions are JSON-typed + +Vendor extensions ride `block.Extensions()` and surface as +JSON-typed values (`bool`, `float64`, `string`, `[]any`, +`map[string]any`). Goldens treat `x-some-flag: false` as `false` +(bool), not `"false"` (string). The full lex pipeline is documented +in [§extensions](#extensions). diff --git a/internal/builders/routes/routes.go b/internal/builders/routes/routes.go index 5d3687f..626b4b2 100644 --- a/internal/builders/routes/routes.go +++ b/internal/builders/routes/routes.go @@ -6,14 +6,21 @@ package routes import ( "fmt" + "github.com/go-openapi/codescan/internal/builders/common" "github.com/go-openapi/codescan/internal/builders/operations" "github.com/go-openapi/codescan/internal/parsers" "github.com/go-openapi/codescan/internal/scanner" oaispec "github.com/go-openapi/spec" ) +// Builder constructs OAS v2 path entries for one `swagger:route` +// annotation. 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 { - ctx *scanner.ScanCtx + *common.Builder + route parsers.ParsedPathContent responses map[string]oaispec.Response operations map[string]*oaispec.Operation @@ -29,7 +36,7 @@ type Inputs struct { func NewBuilder(ctx *scanner.ScanCtx, route parsers.ParsedPathContent, inputs Inputs) *Builder { return &Builder{ - ctx: ctx, + Builder: common.New(ctx, nil), route: route, responses: inputs.Responses, operations: inputs.Operations, @@ -45,13 +52,7 @@ func (r *Builder) Build(tgt *oaispec.Paths) error { ) op.Tags = r.route.Tags - sp := parsers.NewSectionedParser( - parsers.WithSetTitle(func(lines []string) { op.Summary = parsers.JoinDropLast(lines) }), - parsers.WithSetDescription(func(lines []string) { op.Description = parsers.JoinDropLast(lines) }), - parsers.WithTaggers(r.routeTaggers(op)...), - ) - - if err := sp.Parse(r.route.Remaining); err != nil { + if err := r.applyBlockToRoute(op); err != nil { return fmt.Errorf("operation (%s): %w", op.ID, err) } diff --git a/internal/builders/routes/routes_test.go b/internal/builders/routes/routes_test.go index 5c99d75..cd39f65 100644 --- a/internal/builders/routes/routes_test.go +++ b/internal/builders/routes/routes_test.go @@ -18,13 +18,24 @@ 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. + responses := map[string]oaispec.Response{ + "genericError": {}, + "someResponse": {}, + "validationError": {}, + "fileResponse": {}, + "resp": {}, + } var ops oaispec.Paths for apiPath := range sctx.Routes() { - prs := &Builder{ - ctx: sctx, - route: apiPath, - operations: make(map[string]*oaispec.Operation), - } + prs := NewBuilder(sctx, apiPath, Inputs{ + Operations: make(map[string]*oaispec.Operation), + Responses: responses, + }) require.NoError(t, prs.Build(&ops)) } @@ -32,7 +43,7 @@ func TestRoutesParser(t *testing.T) { po, ok := ops.Paths["/pets"] ext := make(oaispec.Extensions) - ext.Add("x-some-flag", "true") + ext.Add("x-some-flag", true) assert.TrueT(t, ok) assert.NotNil(t, po.Get) assertOperation(t, @@ -56,8 +67,8 @@ func TestRoutesParser(t *testing.T) { po, ok = ops.Paths["/orders"] ext = make(oaispec.Extensions) - ext.Add("x-some-flag", "false") - ext.Add("x-some-list", []string{"item1", "item2", "item3"}) + ext.Add("x-some-flag", false) + ext.Add("x-some-list", []any{"item1", "item2", "item3"}) ext.Add("x-some-object", map[string]any{ "key1": "value1", "key2": "value2", @@ -143,11 +154,9 @@ func TestRoutesParserBody(t *testing.T) { require.NoError(t, err) var ops oaispec.Paths for apiPath := range sctx.Routes() { - prs := &Builder{ - ctx: sctx, - route: apiPath, - operations: make(map[string]*oaispec.Operation), - } + prs := NewBuilder(sctx, apiPath, Inputs{ + Operations: make(map[string]*oaispec.Operation), + }) require.NoError(t, prs.Build(&ops)) } @@ -282,16 +291,20 @@ func validateRoutesParameters(t *testing.T, ops oaispec.Paths) { assert.InDelta(t, def, p.Default, epsilon) assert.Nil(t, p.Schema) - // Testing array param provided as query string. Testing "minLength" and "maxLength" constraints for "array" types + // 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) assert.EqualT(t, "query", p.In) assert.FalseT(t, p.Required) assert.EqualT(t, "array", p.Type) - minLen, maxLen := int64(5), int64(20) - assert.Equal(t, &maxLen, p.MaxLength) - assert.Equal(t, &minLen, p.MinLength) + assert.Nil(t, p.MaxLength) + assert.Nil(t, p.MinLength) assert.Nil(t, p.Schema) // Testing boolean param with default value diff --git a/internal/builders/routes/setters.go b/internal/builders/routes/setters.go deleted file mode 100644 index ab6b191..0000000 --- a/internal/builders/routes/setters.go +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package routes - -import "github.com/go-openapi/spec" - -func opConsumesSetter(op *spec.Operation) func([]string) { - return func(consumes []string) { op.Consumes = consumes } -} - -func opProducesSetter(op *spec.Operation) func([]string) { - return func(produces []string) { op.Produces = produces } -} - -func opSchemeSetter(op *spec.Operation) func([]string) { - return func(schemes []string) { op.Schemes = schemes } -} - -func opSecurityDefsSetter(op *spec.Operation) func([]map[string][]string) { - return func(securityDefs []map[string][]string) { op.Security = securityDefs } -} - -func opResponsesSetter(op *spec.Operation) func(*spec.Response, map[int]spec.Response) { - return func(def *spec.Response, scr map[int]spec.Response) { - if op.Responses == nil { - op.Responses = new(spec.Responses) - } - op.Responses.Default = def - op.Responses.StatusCodeResponses = scr - } -} - -func opParamSetter(op *spec.Operation) func([]*spec.Parameter) { - return func(params []*spec.Parameter) { - for _, v := range params { - op.AddParam(v) - } - } -} - -func opExtensionsSetter(op *spec.Operation) func(*spec.Extensions) { - return func(exts *spec.Extensions) { - for name, value := range *exts { - op.AddExtension(name, value) - } - } -} diff --git a/internal/builders/routes/taggers.go b/internal/builders/routes/taggers.go deleted file mode 100644 index 440f717..0000000 --- a/internal/builders/routes/taggers.go +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package routes - -import ( - "github.com/go-openapi/codescan/internal/parsers" - oaispec "github.com/go-openapi/spec" -) - -func (r *Builder) routeTaggers(op *oaispec.Operation) []parsers.TagParser { - return []parsers.TagParser{ - parsers.NewMultiLineTagParser("Consumes", parsers.NewConsumesDropEmptyParser(opConsumesSetter(op)), false), - parsers.NewMultiLineTagParser("Produces", parsers.NewProducesDropEmptyParser(opProducesSetter(op)), false), - parsers.NewSingleLineTagParser("Schemes", parsers.NewSetSchemes(opSchemeSetter(op))), - parsers.NewMultiLineTagParser("Security", parsers.NewSetSecurityScheme(opSecurityDefsSetter(op)), false), - parsers.NewMultiLineTagParser("Parameters", parsers.NewSetParams(r.parameters, opParamSetter(op)), false), - parsers.NewMultiLineTagParser("Responses", parsers.NewSetResponses(r.definitions, r.responses, opResponsesSetter(op)), false), - parsers.NewSingleLineTagParser("Deprecated", parsers.NewSetDeprecatedOp(op)), - parsers.NewMultiLineTagParser("Extensions", parsers.NewSetExtensions(opExtensionsSetter(op), r.ctx.Debug()), true), - } -} diff --git a/internal/builders/routes/walker.go b/internal/builders/routes/walker.go new file mode 100644 index 0000000..bc75306 --- /dev/null +++ b/internal/builders/routes/walker.go @@ -0,0 +1,441 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package routes + +import ( + "fmt" + "go/token" + "strconv" + "strings" + + "github.com/go-openapi/codescan/internal/builders/handlers" + "github.com/go-openapi/codescan/internal/builders/validations" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/parsers/routebody" + oaispec "github.com/go-openapi/spec" +) + +// primitiveTypes lists the OAS v2 primitive type spellings routebody +// accepts on `type:` for body parameters. A body type matching this +// set lands on param.Schema.Type as a typed schema; anything else is +// treated as a model reference and resolved via $ref. +// +//nolint:gochecknoglobals // immutable lookup table; read-only. +var primitiveTypes = map[string]struct{}{ + "string": {}, + "integer": {}, + "number": {}, + "boolean": {}, + "array": {}, + "object": {}, +} + +// 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). +// +// route.Remaining is the *ast.CommentGroup AFTER the swagger:route +// header line has been stripped by parsers.ParseRoutePathAnnotation; +// grammar sees it as an UnboundBlock whose Title / Description / +// Properties behave identically to a properly-anchored block. +func (r *Builder) applyBlockToRoute(op *oaispec.Operation) error { + block := grammar.NewParser(r.Ctx.FileSet()).Parse(r.route.Remaining) + + op.Summary = block.Title() + op.Description = block.Description() + + for prop := range block.Properties() { + if prop.ItemsDepth != 0 { + continue + } + if err := r.dispatchRouteKeyword(prop, op); err != nil { + return err + } + } + + // Extensions and security are read straight off the block — + // grammar's lexer routes their raw bodies through typed + // sub-parsers (yaml.TypedExtensions, security.Parse) at lex + // time, so the dispatcher above skips them and we read the + // typed surface here. See [§extensions](./README.md#extensions). + for ext := range block.Extensions() { + op.AddExtension(ext.Name, ext.Value) + } + if reqs := block.SecurityRequirements(); reqs != nil { + op.Security = reqs + } + + return nil +} + +// dispatchRouteKeyword routes one grammar Property to the matching +// body parser. 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: + if v := p.AsList(); len(v) > 0 { + op.Schemes = v + } + case grammar.KwDeprecated: + if p.IsTyped() { + op.Deprecated = p.Typed.Boolean + } + case grammar.KwConsumes: + op.Consumes = p.AsList() + case grammar.KwProduces: + op.Produces = p.AsList() + case grammar.KwParameters: + return r.dispatchParameters(p, op) + case grammar.KwResponses: + return r.dispatchResponses(p, op) + } + return nil +} + +// 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 { + decl := &decls[i] + param := r.buildRouteParam(decl) + if param == nil { + continue + } + op.AddParam(param) + } + 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). +func (r *Builder) buildRouteParam(decl *routebody.ParamDecl) *oaispec.Parameter { + if decl.Name == "" && decl.In == "" { + return nil + } + param := &oaispec.Parameter{ + ParamProps: oaispec.ParamProps{ + Name: decl.Name, + In: decl.In, + Description: decl.Description, + Required: decl.Required, + AllowEmptyValue: decl.AllowEmpty, + }, + } + + 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. + 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. + 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. + r.RecordDiagnostic(grammar.Diagnostic{ + Pos: decl.Pos, + Severity: grammar.SeverityWarning, + Code: grammar.CodeInvalidAnnotation, + Message: err.Error(), + }) + } + param.Format = decl.Format + 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 +// wrapping). +func (r *Builder) buildBodySchema(decl *routebody.ParamDecl) *oaispec.Schema { + if decl.TypeRef == "" { + return new(oaispec.Schema) + } + if _, prim := primitiveTypes[decl.TypeRef]; prim { + schema := &oaispec.Schema{ + SchemaProps: oaispec.SchemaProps{ + Type: oaispec.StringOrArray{decl.TypeRef}, + }, + } + if decl.Format != "" { + schema.Format = decl.Format + } + return schema + } + schema := r.resolveBodySchema(decl.TypeRef, 0) + if schema == nil { + return new(oaispec.Schema) + } + if decl.Format != "" { + schema.Format = decl.Format + } + 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). +// +// 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. + if schemaType == "" { + had := false + for p := range in.Properties() { + had = true + r.RecordDiagnostic(grammar.Diagnostic{ + Pos: p.Pos, + Severity: grammar.SeverityWarning, + Code: grammar.CodeShapeMismatch, + Message: fmt.Sprintf( + "validation %q dropped: parameter has no declared `type:` to validate against", + p.Keyword.Name, + ), + }) + } + if had { + return grammar.NewSyntheticBlock(pos, in.Title(), in.Description(), nil) + } + return in + } + var filtered []grammar.Property + for p := range in.Properties() { + ok, hint := validations.IsLegalForType(p.Keyword, schemaType) + if !ok { + r.RecordDiagnostic(grammar.Diagnostic{ + Pos: p.Pos, + Severity: grammar.SeverityWarning, + Code: grammar.CodeShapeMismatch, + Message: hint, + }) + continue + } + filtered = append(filtered, 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. +func normaliseSimpleType(t string) string { + if t == "bool" { + return "boolean" + } + return t +} + +// 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. +func (r *Builder) resolveBodySchema(ref string, arrayLayer int) *oaispec.Schema { + if ref == "" { + return nil + } + // Strip any remaining [] prefixes the caller didn't consume. + for strings.HasPrefix(ref, "[]") { + arrayLayer++ + ref = ref[2:] + } + if ref == "" { + return nil + } + target, err := oaispec.NewRef("#/definitions/" + ref) + if err != nil { + return nil + } + // 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{ + SchemaProps: oaispec.SchemaProps{ + Type: oaispec.StringOrArray{"array"}, + Items: &oaispec.SchemaOrArray{ + Schema: leaf, + }, + }, + } + } + return leaf +} + +// 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 { + return nil + } + if op.Responses == nil { + op.Responses = new(oaispec.Responses) + } + + for i := range decls { + decl := &decls[i] + resp, ok := r.buildRouteResponse(decl) + if !ok { + continue + } + if strings.EqualFold(decl.Code, "default") { + if op.Responses.Default == nil { + cp := resp + op.Responses.Default = &cp + } + continue + } + code, err := strconv.Atoi(decl.Code) + if err != nil { + r.RecordDiagnostic(grammar.Diagnostic{ + Pos: decl.Pos, + Severity: grammar.SeverityWarning, + Code: grammar.CodeInvalidAnnotation, + Message: "response code " + decl.Code + " is not a valid integer", + }) + continue + } + if op.Responses.StatusCodeResponses == nil { + op.Responses.StatusCodeResponses = make(map[int]oaispec.Response) + } + op.Responses.StatusCodeResponses[code] = resp + } + return nil +} + +// 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 != "": + schema := r.resolveBodySchema(decl.BodyTypeRef, decl.Arrays) + if schema == nil { + r.RecordDiagnostic(grammar.Diagnostic{ + Pos: decl.Pos, + Severity: grammar.SeverityWarning, + Code: grammar.CodeInvalidAnnotation, + Message: "response body ref " + decl.BodyTypeRef + " did not resolve", + }) + return oaispec.Response{}, false + } + desc := decl.Description + if desc == "" { + desc = decl.BodyTypeRef + } + return oaispec.Response{ + ResponseProps: oaispec.ResponseProps{ + Description: desc, + Schema: schema, + }, + }, 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). + if _, ok := r.responses[decl.ResponseRef]; !ok { + if _, ok := r.definitions[decl.ResponseRef]; ok { + schema := r.resolveBodySchema(decl.ResponseRef, decl.Arrays) + desc := decl.Description + if desc == "" { + desc = decl.ResponseRef + } + return oaispec.Response{ + ResponseProps: oaispec.ResponseProps{ + Description: desc, + Schema: schema, + }, + }, true + } + // Dangling refs (not in responses, not in definitions) + // emit a diagnostic and are dropped rather than silently + // emitting an invalid $ref. + r.RecordDiagnostic(grammar.Diagnostic{ + Pos: decl.Pos, + Severity: grammar.SeverityWarning, + Code: grammar.CodeInvalidAnnotation, + Message: "response ref " + decl.ResponseRef + " not found in responses or definitions; dropped", + }) + return oaispec.Response{}, false + } + ref, err := oaispec.NewRef("#/responses/" + decl.ResponseRef) + if err != nil { + return oaispec.Response{}, false + } + 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. + 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 new file mode 100644 index 0000000..590d8ff --- /dev/null +++ b/internal/builders/routes/walker_test.go @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package routes + +import ( + "go/token" + "strings" + "testing" + + "github.com/go-openapi/codescan/internal/parsers/grammar" + oaispec "github.com/go-openapi/spec" +) + +// parseRouteBody parses the given body as a swagger:route block using +// grammar's ParseAs hook — the lexer prepends a `swagger:route` +// annotation header so the keywords' CtxRoute context applies. +// +//nolint:ireturn // grammar.Block is the package's polymorphic return. +func parseRouteBody(t *testing.T, body string) grammar.Block { + t.Helper() + p := grammar.NewParser(token.NewFileSet()) + return p.ParseAs(grammar.AnnRoute, body, token.Position{Line: 1}) +} + +func TestDispatchRouteSchemes(t *testing.T) { + var b Builder + op := &oaispec.Operation{} + + block := parseRouteBody(t, "schemes: http, https, ws") + for prop := range block.Properties() { + if err := b.dispatchRouteKeyword(prop, op); err != nil { + t.Fatalf("dispatch: %v", err) + } + } + + want := []string{"http", "https", "ws"} //nolint:goconst // test fixture literals; consts would hurt readability. + if len(op.Schemes) != len(want) { + t.Fatalf("Schemes len: got %d, want %d", len(op.Schemes), len(want)) + } + for i, s := range want { + if op.Schemes[i] != s { + t.Errorf("Schemes[%d]: got %q, want %q", i, op.Schemes[i], s) + } + } +} + +func TestDispatchRouteKeywordDeprecated(t *testing.T) { + var b Builder + op := &oaispec.Operation{} + + block := parseRouteBody(t, "deprecated: true") + for prop := range block.Properties() { + if err := b.dispatchRouteKeyword(prop, op); err != nil { + t.Fatalf("dispatch: %v", err) + } + } + + if !op.Deprecated { + t.Errorf("Deprecated: want true") + } +} + +// TestRawBlockAbsorbsSubContextKeywords verifies the grammar-level +// behaviour that lets a Parameters or Responses body contain lines +// whose first word reads as a keyword from a sub-context +// (Param / Schema / Items): they're absorbed as body text rather +// than terminating the multi-line block. Without this, `default:`, +// `in:`, `required:`, `max:` inside a Parameters body would +// prematurely stop the collection and produce a malformed spec. +func TestRawBlockAbsorbsSubContextKeywords(t *testing.T) { + body := `Parameters: ++ name: someNumber + in: path + required: true + type: number + max: 20 + min: 10 + default: 15 ++ name: flag + in: query + type: boolean +` + block := parseRouteBody(t, body) + + var params grammar.Property + for p := range block.Properties() { + if p.Keyword.Name == grammar.KwParameters { + params = p + break + } + } + if params.Keyword.Name != grammar.KwParameters { + t.Fatalf("parameters property not found") + } + + // Body must retain every source line, absorbed verbatim. We don't + // pin the exact whitespace — the lexer is free to keep or + // normalise inter-token spacing — but every value must survive + // the absorption. + for _, expected := range []string{ + "someNumber", + "path", + "required", + "max", + "default", + "flag", + "query", + "boolean", + } { + if !strings.Contains(params.Body, expected) { + t.Errorf("Body missing %q in:\n%s", expected, params.Body) + } + } +} diff --git a/internal/parsers/route_params.go b/internal/parsers/route_params.go deleted file mode 100644 index 4354ab8..0000000 --- a/internal/parsers/route_params.go +++ /dev/null @@ -1,267 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "fmt" - "slices" - "strconv" - "strings" - - oaispec "github.com/go-openapi/spec" -) - -const ( - // paramDescriptionKey indicates the tag used to define a parameter description in swagger:route. - paramDescriptionKey = "description" - // paramNameKey indicates the tag used to define a parameter name in swagger:route. - paramNameKey = "name" - // paramInKey indicates the tag used to define a parameter location in swagger:route. - paramInKey = "in" - // paramRequiredKey indicates the tag used to declare whether a parameter is required in swagger:route. - paramRequiredKey = "required" - // paramTypeKey indicates the tag used to define the parameter type in swagger:route. - paramTypeKey = "type" - // paramAllowEmptyKey indicates the tag used to indicate whether a parameter allows empty values in swagger:route. - paramAllowEmptyKey = "allowempty" - - // schemaMinKey indicates the tag used to indicate the minimum value allowed for this type in swagger:route. - schemaMinKey = "min" - // schemaMaxKey indicates the tag used to indicate the maximum value allowed for this type in swagger:route. - schemaMaxKey = "max" - // schemaEnumKey indicates the tag used to specify the allowed values for this type in swagger:route. - schemaEnumKey = "enum" - // schemaFormatKey indicates the expected format for this field in swagger:route. - schemaFormatKey = "format" - // schemaDefaultKey indicates the default value for this field in swagger:route. - schemaDefaultKey = "default" - // schemaMinLenKey indicates the minimum length this field in swagger:route. - schemaMinLenKey = "minlength" - // schemaMaxLenKey indicates the minimum length this field in swagger:route. - schemaMaxLenKey = "maxlength" - - // typeArray is the identifier for an array type in swagger:route. - typeArray = "array" - // typeNumber is the identifier for a number type in swagger:route. - typeNumber = "number" - // typeInteger is the identifier for an integer type in swagger:route. - typeInteger = "integer" - // typeBoolean is the identifier for a boolean type in swagger:route. - typeBoolean = "boolean" - // typeBool is the identifier for a boolean type in swagger:route. - typeBool = "bool" - // typeObject is the identifier for an object type in swagger:route. - typeObject = "object" - // typeString is the identifier for a string type in swagger:route. - typeString = "string" -) - -var ( - validIn = []string{"path", "query", "header", "body", "form"} //nolint:gochecknoglobals // immutable lookup table - basicTypes = []string{typeInteger, typeNumber, typeString, typeBoolean, typeBool, typeArray} //nolint:gochecknoglobals // immutable lookup table -) - -type SetOpParams struct { - set func([]*oaispec.Parameter) - parameters []*oaispec.Parameter -} - -func NewSetParams(params []*oaispec.Parameter, setter func([]*oaispec.Parameter)) *SetOpParams { - return &SetOpParams{ - set: setter, - parameters: params, - } -} - -func (s *SetOpParams) Matches(line string) bool { - return rxParameters.MatchString(line) -} - -func (s *SetOpParams) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - - var current *oaispec.Parameter - var extraData map[string]string - - for _, line := range lines { - l := strings.TrimSpace(line) - - if strings.HasPrefix(l, "+") { - s.finalizeParam(current, extraData) - current = new(oaispec.Parameter) - extraData = make(map[string]string) - l = strings.TrimPrefix(l, "+") - } - - kv := strings.SplitN(l, ":", kvParts) - - if len(kv) <= 1 { - continue - } - - key := strings.ToLower(strings.TrimSpace(kv[0])) - value := strings.TrimSpace(kv[1]) - - if current == nil { - return fmt.Errorf("invalid route/operation schema provided: %w", ErrParser) - } - - applyParamField(current, extraData, key, value) - } - - s.finalizeParam(current, extraData) - s.set(s.parameters) - - return nil -} - -func applyParamField(current *oaispec.Parameter, extraData map[string]string, key, value string) { - switch key { - case paramDescriptionKey: - current.Description = value - case paramNameKey: - current.Name = value - case paramInKey: - v := strings.ToLower(value) - if contains(validIn, v) { - current.In = v - } - case paramRequiredKey: - if v, err := strconv.ParseBool(value); err == nil { - current.Required = v - } - case paramTypeKey: - if current.Schema == nil { - current.Schema = new(oaispec.Schema) - } - if contains(basicTypes, value) { - current.Type = strings.ToLower(value) - if current.Type == typeBool { - current.Type = typeBoolean - } - } else if ref, err := oaispec.NewRef("#/definitions/" + value); err == nil { - current.Type = typeObject - current.Schema.Ref = ref - } - current.Schema.Type = oaispec.StringOrArray{current.Type} - case paramAllowEmptyKey: - if v, err := strconv.ParseBool(value); err == nil { - current.AllowEmptyValue = v - } - default: - extraData[key] = value - } -} - -func (s *SetOpParams) finalizeParam(param *oaispec.Parameter, data map[string]string) { - if param == nil { - return - } - - processSchema(data, param) - - // schema is only allowed for parameters in "body" - // see https://swagger.io/specification/v2/#parameterObject - switch { - case param.In == "body": - param.Type = "" - - case param.Schema != nil: - // convert schema into validations - param.SetValidations(param.Schema.Validations()) - param.Default = param.Schema.Default - param.Format = param.Schema.Format - param.Schema = nil - } - - s.parameters = append(s.parameters, param) -} - -func processSchema(data map[string]string, param *oaispec.Parameter) { - if param.Schema == nil { - return - } - - var enumValues []string - - for key, value := range data { - switch key { - case schemaMinKey: - if t := getType(param.Schema); t == typeNumber || t == typeInteger { - v, _ := strconv.ParseFloat(value, 64) - param.Schema.Minimum = &v - } - case schemaMaxKey: - if t := getType(param.Schema); t == typeNumber || t == typeInteger { - v, _ := strconv.ParseFloat(value, 64) - param.Schema.Maximum = &v - } - case schemaMinLenKey: - if getType(param.Schema) == typeArray { - v, _ := strconv.ParseInt(value, 10, 64) - param.Schema.MinLength = &v - } - case schemaMaxLenKey: - if getType(param.Schema) == typeArray { - v, _ := strconv.ParseInt(value, 10, 64) - param.Schema.MaxLength = &v - } - case schemaEnumKey: - enumValues = strings.Split(value, ",") - case schemaFormatKey: - param.Schema.Format = value - case schemaDefaultKey: - param.Schema.Default = convert(param.Type, value) - } - } - - if param.Description != "" { - param.Schema.Description = param.Description - } - - convertEnum(param.Schema, enumValues) -} - -func convertEnum(schema *oaispec.Schema, enumValues []string) { - if len(enumValues) == 0 { - return - } - - finalEnum := make([]any, 0, len(enumValues)) - for _, v := range enumValues { - finalEnum = append(finalEnum, convert(schema.Type[0], strings.TrimSpace(v))) - } - schema.Enum = finalEnum -} - -func convert(typeStr, valueStr string) any { - switch typeStr { - case typeInteger: - fallthrough - case typeNumber: - if num, err := strconv.ParseFloat(valueStr, 64); err == nil { - return num - } - case typeBoolean: - fallthrough - case typeBool: - if b, err := strconv.ParseBool(valueStr); err == nil { - return b - } - } - return valueStr -} - -func getType(schema *oaispec.Schema) string { - if len(schema.Type) == 0 { - return "" - } - return schema.Type[0] -} - -func contains(arr []string, obj string) bool { - return slices.Contains(arr, obj) -} diff --git a/internal/parsers/route_params_test.go b/internal/parsers/route_params_test.go deleted file mode 100644 index da68209..0000000 --- a/internal/parsers/route_params_test.go +++ /dev/null @@ -1,250 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" - - oaispec "github.com/go-openapi/spec" -) - -func TestSetOpParams_Matches(t *testing.T) { - t.Parallel() - - sp := NewSetParams(nil, nil) - assert.TrueT(t, sp.Matches("parameters:")) - assert.TrueT(t, sp.Matches("Parameters:")) - assert.FalseT(t, sp.Matches("something else")) -} - -func TestSetOpParams_Parse(t *testing.T) { - t.Parallel() - - t.Run("empty", func(t *testing.T) { - sp := NewSetParams(nil, func(_ []*oaispec.Parameter) {}) - require.NoError(t, sp.Parse(nil)) - require.NoError(t, sp.Parse([]string{})) - require.NoError(t, sp.Parse([]string{""})) - }) - - t.Run("single param", func(t *testing.T) { - var got []*oaispec.Parameter - sp := NewSetParams(nil, func(params []*oaispec.Parameter) { got = params }) - - lines := []string{ - "+ name: id", - " in: path", - " type: integer", - " required: true", - " description: The pet ID", - } - require.NoError(t, sp.Parse(lines)) - require.Len(t, got, 1) - assert.EqualT(t, "id", got[0].Name) - assert.EqualT(t, "path", got[0].In) - assert.TrueT(t, got[0].Required) - assert.EqualT(t, "The pet ID", got[0].Description) - assert.EqualT(t, "integer", got[0].Type) - }) - - t.Run("multiple params", func(t *testing.T) { - var got []*oaispec.Parameter - sp := NewSetParams(nil, func(params []*oaispec.Parameter) { got = params }) - - lines := []string{ - "+ name: limit", - " in: query", - " type: integer", - "+ name: offset", - " in: query", - " type: integer", - } - require.NoError(t, sp.Parse(lines)) - require.Len(t, got, 2) - assert.EqualT(t, "limit", got[0].Name) - assert.EqualT(t, "offset", got[1].Name) - }) - - t.Run("body param", func(t *testing.T) { - var got []*oaispec.Parameter - sp := NewSetParams(nil, func(params []*oaispec.Parameter) { got = params }) - - lines := []string{ - "+ name: body", - " in: body", - " type: Pet", - } - require.NoError(t, sp.Parse(lines)) - require.Len(t, got, 1) - assert.EqualT(t, "body", got[0].In) - assert.EqualT(t, "", got[0].Type) // body params clear type - require.NotNil(t, got[0].Schema) - }) - - t.Run("boolean type", func(t *testing.T) { - var got []*oaispec.Parameter - sp := NewSetParams(nil, func(params []*oaispec.Parameter) { got = params }) - - lines := []string{ - "+ name: active", - " in: query", - " type: bool", - } - require.NoError(t, sp.Parse(lines)) - require.Len(t, got, 1) - assert.EqualT(t, "boolean", got[0].Type) // "bool" → "boolean" - }) - - t.Run("allow empty value", func(t *testing.T) { - var got []*oaispec.Parameter - sp := NewSetParams(nil, func(params []*oaispec.Parameter) { got = params }) - - lines := []string{ - "+ name: q", - " in: query", - " type: string", - " allowempty: true", - } - require.NoError(t, sp.Parse(lines)) - require.Len(t, got, 1) - assert.TrueT(t, got[0].AllowEmptyValue) - }) - - t.Run("error no leading +", func(t *testing.T) { - sp := NewSetParams(nil, func(_ []*oaispec.Parameter) {}) - err := sp.Parse([]string{"name: id"}) - require.Error(t, err) - assert.ErrorIs(t, err, ErrParser) - }) - - t.Run("with schema extras", func(t *testing.T) { - var got []*oaispec.Parameter - sp := NewSetParams(nil, func(params []*oaispec.Parameter) { got = params }) - - lines := []string{ - "+ name: age", - " in: query", - " type: integer", - " min: 0", - " max: 150", - " default: 25", - " enum: 18,25,30,65", - " format: int32", - } - require.NoError(t, sp.Parse(lines)) - require.Len(t, got, 1) - assert.EqualT(t, "int32", got[0].Format) - assert.Equal(t, float64(25), got[0].Default) - }) -} - -func TestConvert(t *testing.T) { - t.Parallel() - - tests := []struct { - typeStr string - valueStr string - want any - }{ - {"integer", "42", float64(42)}, - {"number", "3.14", float64(3.14)}, - {"boolean", "true", true}, - {"bool", "false", false}, - {"string", "hello", "hello"}, - {"integer", "not-a-number", "not-a-number"}, - {"boolean", "maybe", "maybe"}, - {"", "raw", "raw"}, - } - - for _, tc := range tests { - t.Run(tc.typeStr+"/"+tc.valueStr, func(t *testing.T) { - assert.Equal(t, tc.want, convert(tc.typeStr, tc.valueStr)) - }) - } -} - -func TestGetType(t *testing.T) { - t.Parallel() - - t.Run("empty type", func(t *testing.T) { - s := &oaispec.Schema{} - assert.EqualT(t, "", getType(s)) - }) - - t.Run("with type", func(t *testing.T) { - s := &oaispec.Schema{} - s.Type = oaispec.StringOrArray{"string"} - assert.EqualT(t, "string", getType(s)) - }) -} - -func TestSetOpParams_ParseLineWithoutColon(t *testing.T) { - t.Parallel() - - var got []*oaispec.Parameter - sp := NewSetParams(nil, func(params []*oaispec.Parameter) { got = params }) - // A line after "+" that has no colon is silently skipped - lines := []string{ - "+ name: test", - " no-colon-here", - " in: query", - " type: string", - } - require.NoError(t, sp.Parse(lines)) - require.Len(t, got, 1) - assert.EqualT(t, "test", got[0].Name) -} - -func TestSetOpParams_ArraySchemaExtras(t *testing.T) { - t.Parallel() - - var got []*oaispec.Parameter - sp := NewSetParams(nil, func(params []*oaispec.Parameter) { got = params }) - lines := []string{ - "+ name: tags", - " in: query", - " type: array", - " minlength: 1", - " maxlength: 10", - " enum: a,b,c", - " description: A list of tags", - } - require.NoError(t, sp.Parse(lines)) - require.Len(t, got, 1) - // For non-body params, schema is converted to validations - assert.EqualT(t, "array", got[0].Type) -} - -func TestSetOpParams_NilSchemaGuard(t *testing.T) { - t.Parallel() - - // A param with no type: field won't have a schema - var got []*oaispec.Parameter - sp := NewSetParams(nil, func(params []*oaispec.Parameter) { got = params }) - lines := []string{ - "+ name: simple", - " in: query", - } - require.NoError(t, sp.Parse(lines)) - require.Len(t, got, 1) - assert.Nil(t, got[0].Schema) -} - -func TestSetOpParams_InvalidIn(t *testing.T) { - t.Parallel() - - var got []*oaispec.Parameter - sp := NewSetParams(nil, func(params []*oaispec.Parameter) { got = params }) - lines := []string{ - "+ name: test", - " in: cookie", - " type: string", - } - require.NoError(t, sp.Parse(lines)) - require.Len(t, got, 1) - assert.EqualT(t, "", got[0].In) // "cookie" is not in validIn -} From 2b2cc096fb3d2b8da8a457e105ba2959663840b7 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:38:41 +0200 Subject: [PATCH 15/22] feat(builders/spec): meta + orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-level orchestrator: parses swagger:meta to seed Info / Schemes / Host / BasePath / Consumes / Produces / Security / SecurityDefinitions / Tags / extensions. Then drives the discovery loop — visiting each per-decl Builder's post-decl queue, re-deduping at consumption time, and assembling the final *spec.Swagger document returned by the public Run entry point. Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- internal/builders/spec/spec.go | 26 +- internal/builders/spec/walker.go | 189 ++++++++++ internal/parsers/meta.go | 242 ------------- internal/parsers/meta_test.go | 270 --------------- internal/parsers/yaml_spec_parser.go | 202 ----------- internal/parsers/yaml_spec_parser_test.go | 402 ---------------------- 6 files changed, 208 insertions(+), 1123 deletions(-) create mode 100644 internal/builders/spec/walker.go delete mode 100644 internal/parsers/meta.go delete mode 100644 internal/parsers/meta_test.go delete mode 100644 internal/parsers/yaml_spec_parser.go delete mode 100644 internal/parsers/yaml_spec_parser_test.go diff --git a/internal/builders/spec/spec.go b/internal/builders/spec/spec.go index 1e49ee8..bee35fa 100644 --- a/internal/builders/spec/spec.go +++ b/internal/builders/spec/spec.go @@ -11,7 +11,7 @@ import ( "github.com/go-openapi/codescan/internal/builders/responses" "github.com/go-openapi/codescan/internal/builders/routes" "github.com/go-openapi/codescan/internal/builders/schema" - "github.com/go-openapi/codescan/internal/parsers" + "github.com/go-openapi/codescan/internal/parsers/grammar" "github.com/go-openapi/codescan/internal/scanner" oaispec "github.com/go-openapi/spec" ) @@ -99,11 +99,22 @@ func (s *Builder) buildDiscovered() error { keepGoing := len(s.discovered) > 0 for keepGoing { var queue []*scanner.EntityDecl + // Dedupe by name within this pass. The same decl can appear + // multiple times in s.discovered (one entry per reference + // site that called AppendPostDecl); without this, both copies + // get queued and Build runs twice, each appending to the + // existing schema's AllOf and producing doubled entries. + queued := make(map[string]struct{}) for _, d := range s.discovered { nm, _ := d.Names() - if _, ok := s.definitions[nm]; !ok { - queue = append(queue, d) + if _, alreadyDone := s.definitions[nm]; alreadyDone { + continue } + if _, dupInPass := queued[nm]; dupInPass { + continue + } + queued[nm] = struct{}{} + queue = append(queue, d) } s.discovered = nil for _, sd := range queue { @@ -120,7 +131,7 @@ func (s *Builder) buildDiscovered() error { func (s *Builder) buildDiscoveredSchema(decl *scanner.EntityDecl) error { sb := schema.NewBuilder(s.ctx, decl) sb.SetDiscovered(s.discovered) - if err := sb.Build(s.definitions); err != nil { + if err := sb.Build(schema.WithDefinitions(s.definitions)); err != nil { return err } @@ -130,9 +141,10 @@ func (s *Builder) buildDiscoveredSchema(decl *scanner.EntityDecl) error { } func (s *Builder) buildMeta() error { - // build swagger object - for decl := range s.ctx.Meta() { - if err := parsers.NewMetaParser(s.input).Parse(decl.Comments); err != nil { + parser := grammar.NewParser(s.ctx.FileSet()) + for cg := range s.ctx.Meta() { + block := parser.Parse(cg) + if err := applyMetaBlock(s.input, block); err != nil { return err } } diff --git a/internal/builders/spec/walker.go b/internal/builders/spec/walker.go new file mode 100644 index 0000000..bf17105 --- /dev/null +++ b/internal/builders/spec/walker.go @@ -0,0 +1,189 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package spec + +import ( + "encoding/json" + "strings" + + "github.com/go-openapi/codescan/internal/parsers/grammar" + yamlparser "github.com/go-openapi/codescan/internal/parsers/yaml" + "github.com/go-openapi/spec" +) + +// applyMetaBlock dispatches one parsed swagger:meta block into the +// matching *spec.Swagger fields. Title and Description come from +// grammar's prose classifier; level-0 Property entries are +// routed by keyword name to the appropriate setter. +// +// swspec may have a nil Info field on entry; the helper allocates +// one before writing the first Info.* value. +func applyMetaBlock(swspec *spec.Swagger, block grammar.Block) error { + if swspec.Info == nil { + swspec.Info = new(spec.Info) + } + + swspec.Info.Title = stripPackagePrefix(block.Title()) + swspec.Info.Description = block.Description() + + for p := range block.Properties() { + if p.ItemsDepth != 0 { + continue + } + if err := dispatchMetaKeyword(p, swspec); err != nil { + return err + } + } + + if reqs := block.SecurityRequirements(); reqs != nil { + swspec.Security = reqs + } + for ext := range block.Extensions() { + switch ext.Source { + case grammar.KwInfoExtensions: + if swspec.Info.Extensions == nil { + swspec.Info.Extensions = spec.Extensions{} + } + swspec.Info.Extensions.Add(ext.Name, ext.Value) + default: + if swspec.Extensions == nil { + swspec.Extensions = spec.Extensions{} + } + swspec.Extensions.Add(ext.Name, ext.Value) + } + } + c, err := block.Contact() + if err != nil { + return err + } + if c != (grammar.Contact{}) { + swspec.Info.Contact = &spec.ContactInfo{ + ContactInfoProps: spec.ContactInfoProps{Name: c.Name, Email: c.Email, URL: c.URL}, + } + } + if l, ok := block.License(); ok { + swspec.Info.License = &spec.License{ + LicenseProps: spec.LicenseProps{Name: l.Name, URL: l.URL}, + } + } + + return nil +} + +// dispatchMetaKeyword routes one Property to the matching meta-side +// setter. Inline-value keywords (schemes, version, host, basePath, +// license, contact) read Property.Value; raw-block keywords (tos, +// consumes, produces, security, securityDefinitions, infoExtensions, +// extensions) split Property.Body and feed the body parsers. +func dispatchMetaKeyword(p grammar.Property, swspec *spec.Swagger) error { + if dispatchMetaSimple(p, swspec) { + return nil + } + return dispatchMetaYAMLBlock(p, swspec) +} + +// dispatchMetaSimple handles the keywords whose setters cannot fail. +func dispatchMetaSimple(p grammar.Property, swspec *spec.Swagger) bool { + switch p.Keyword.Name { + case grammar.KwTOS: + swspec.Info.TermsOfService = joinNonBlank(bodyLines(p.Body)) + case grammar.KwConsumes: + swspec.Consumes = p.AsList() + case grammar.KwProduces: + swspec.Produces = p.AsList() + case grammar.KwSchemes: + swspec.Schemes = p.AsList() + case grammar.KwVersion: + swspec.Info.Version = strings.TrimSpace(p.Value) + case grammar.KwHost: + host := strings.TrimSpace(p.Value) + if host == "" { + host = "localhost" + } + swspec.Host = host + case grammar.KwBasePath: + swspec.BasePath = strings.TrimSpace(p.Value) + default: + return false + } + return true +} + +// dispatchMetaYAMLBlock handles the keywords whose bodies are +// structurally YAML and not amenable to the flex-list union. Today +// only securityDefinitions falls here; extensions / infoExtensions +// ride grammar's typed Extensions surface (see applyMetaBlock — +// the block.Extensions() loop routes each entry by ext.Source). +func dispatchMetaYAMLBlock(p grammar.Property, swspec *spec.Swagger) error { + if p.Keyword.Name == grammar.KwSecurityDefinitions { + return yamlparser.UnmarshalBody(p.Body, func(data []byte) error { + var d spec.SecurityDefinitions + if err := json.Unmarshal(data, &d); err != nil { + return err + } + swspec.SecurityDefinitions = d + return nil + }) + } + return nil +} + +// bodyLines splits a grammar raw-block body into the []string shape +// the meta body parsers expect. +func bodyLines(body string) []string { + if body == "" { + return nil + } + lines := strings.Split(body, "\n") + if n := len(lines); n > 0 && lines[n-1] == "" { + lines = lines[:n-1] + } + return lines +} + +// joinNonBlank joins lines with "\n" after dropping whitespace-only +// entries. Used for the `Terms Of Service:` body — author free-form +// prose that should land as a single multi-line string on +// Info.TermsOfService. +func joinNonBlank(lines []string) string { + out := make([]string, 0, len(lines)) + for _, l := range lines { + if strings.TrimSpace(l) != "" { + out = append(out, l) + } + } + return strings.Join(out, "\n") +} + +// stripPackagePrefix shaves a leading `Package ` 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. +func stripPackagePrefix(s string) string { + rest, ok := strings.CutPrefix(strings.TrimLeft(s, " \t"), "Package ") + if !ok { + return s + } + rest = strings.TrimLeft(rest, " \t") + if rest == "" { + return s + } + 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. + return s + } + return strings.TrimLeft(rest[idx:], " \t") +} + + diff --git a/internal/parsers/meta.go b/internal/parsers/meta.go deleted file mode 100644 index 2d357ca..0000000 --- a/internal/parsers/meta.go +++ /dev/null @@ -1,242 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "encoding/json" - "fmt" - "go/ast" - "net/mail" - "regexp" - "strings" - - "github.com/go-openapi/spec" -) - -type MetaSection struct { - Comments *ast.CommentGroup -} - -func metaTOSSetter(meta *spec.Info) func([]string) { - return func(lines []string) { - meta.TermsOfService = JoinDropLast(lines) - } -} - -func metaConsumesSetter(meta *spec.Swagger) func([]string) { - return func(consumes []string) { meta.Consumes = consumes } -} - -func metaProducesSetter(meta *spec.Swagger) func([]string) { - return func(produces []string) { meta.Produces = produces } -} - -func metaSchemeSetter(meta *spec.Swagger) func([]string) { - return func(schemes []string) { meta.Schemes = schemes } -} - -func metaSecuritySetter(meta *spec.Swagger) func([]map[string][]string) { - return func(secDefs []map[string][]string) { meta.Security = secDefs } -} - -func metaSecurityDefinitionsSetter(meta *spec.Swagger) func(json.RawMessage) error { - return func(jsonValue json.RawMessage) error { - var jsonData spec.SecurityDefinitions - err := json.Unmarshal(jsonValue, &jsonData) - if err != nil { - return err - } - meta.SecurityDefinitions = jsonData - return nil - } -} - -func metaVendorExtensibleSetter(meta *spec.Swagger) func(json.RawMessage) error { - return func(jsonValue json.RawMessage) error { - var jsonData spec.Extensions - err := json.Unmarshal(jsonValue, &jsonData) - if err != nil { - return err - } - for k := range jsonData { - if !rxAllowedExtensions.MatchString(k) { - return fmt.Errorf("invalid schema extension name, should start from `x-`: %s: %w", k, ErrParser) - } - } - meta.Extensions = jsonData - return nil - } -} - -func infoVendorExtensibleSetter(meta *spec.Swagger) func(json.RawMessage) error { - return func(jsonValue json.RawMessage) error { - var jsonData spec.Extensions - err := json.Unmarshal(jsonValue, &jsonData) - if err != nil { - return err - } - for k := range jsonData { - if !rxAllowedExtensions.MatchString(k) { - return fmt.Errorf("invalid schema extension name, should start from `x-`: %s: %w", k, ErrParser) - } - } - meta.Info.Extensions = jsonData - return nil - } -} - -func NewMetaParser(swspec *spec.Swagger) *SectionedParser { - sp := new(SectionedParser) - if swspec.Info == nil { - swspec.Info = new(spec.Info) - } - info := swspec.Info - sp.setTitle = func(lines []string) { - tosave := JoinDropLast(lines) - if len(tosave) > 0 { - tosave = rxStripTitleComments.ReplaceAllString(tosave, "") - } - info.Title = tosave - } - sp.setDescription = func(lines []string) { info.Description = JoinDropLast(lines) } - sp.taggers = []TagParser{ - NewMultiLineTagParser("TOS", newMultilineDropEmptyParser(rxTOS, metaTOSSetter(info)), false), - NewMultiLineTagParser("Consumes", newMultilineDropEmptyParser(rxConsumes, metaConsumesSetter(swspec)), false), - NewMultiLineTagParser("Produces", newMultilineDropEmptyParser(rxProduces, metaProducesSetter(swspec)), false), - NewSingleLineTagParser("Schemes", NewSetSchemes(metaSchemeSetter(swspec))), - NewMultiLineTagParser("Security", newSetSecurity(rxSecuritySchemes, metaSecuritySetter(swspec)), false), - NewMultiLineTagParser("SecurityDefinitions", NewYAMLParser(WithMatcher(rxSecurity), WithSetter(metaSecurityDefinitionsSetter(swspec))), true), - NewSingleLineTagParser("Version", &setMetaSingle{Spec: swspec, Rx: rxVersion, Set: setInfoVersion}), - NewSingleLineTagParser("Host", &setMetaSingle{Spec: swspec, Rx: rxHost, Set: setSwaggerHost}), - NewSingleLineTagParser("BasePath", &setMetaSingle{swspec, rxBasePath, setSwaggerBasePath}), - NewSingleLineTagParser("Contact", &setMetaSingle{Spec: swspec, Rx: rxContact, Set: setInfoContact}), - NewSingleLineTagParser("License", &setMetaSingle{Spec: swspec, Rx: rxLicense, Set: setInfoLicense}), - NewMultiLineTagParser("YAMLInfoExtensionsBlock", NewYAMLParser(WithMatcher(rxInfoExtensions), WithSetter(infoVendorExtensibleSetter(swspec))), true), - NewMultiLineTagParser("YAMLExtensionsBlock", NewYAMLParser(WithExtensionMatcher(), WithSetter(metaVendorExtensibleSetter(swspec))), true), - } - - return sp -} - -type setMetaSingle struct { - Spec *spec.Swagger - Rx *regexp.Regexp - Set func(spec *spec.Swagger, lines []string) error -} - -func (s *setMetaSingle) Matches(line string) bool { - return s.Rx.MatchString(line) -} - -func (s *setMetaSingle) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - matches := s.Rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - return s.Set(s.Spec, []string{matches[1]}) - } - return nil -} - -func setSwaggerHost(swspec *spec.Swagger, lines []string) error { - lns := lines - if len(lns) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - lns = []string{"localhost"} - } - swspec.Host = lns[0] - return nil -} - -func setSwaggerBasePath(swspec *spec.Swagger, lines []string) error { - var ln string - if len(lines) > 0 { - ln = lines[0] - } - swspec.BasePath = ln - return nil -} - -func setInfoVersion(swspec *spec.Swagger, lines []string) error { - if len(lines) == 0 { - return nil - } - info := safeInfo(swspec) - info.Version = strings.TrimSpace(lines[0]) - return nil -} - -func setInfoContact(swspec *spec.Swagger, lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - contact, err := parseContactInfo(lines[0]) - if err != nil { - return err - } - info := safeInfo(swspec) - info.Contact = contact - return nil -} - -func parseContactInfo(line string) (*spec.ContactInfo, error) { - nameEmail, url := splitURL(line) - var name, email string - if len(nameEmail) > 0 { - addr, err := mail.ParseAddress(nameEmail) - if err != nil { - return nil, err - } - name, email = addr.Name, addr.Address - } - return &spec.ContactInfo{ - ContactInfoProps: spec.ContactInfoProps{ - URL: url, - Name: name, - Email: email, - }, - }, nil -} - -func setInfoLicense(swspec *spec.Swagger, lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - info := safeInfo(swspec) - line := lines[0] - name, url := splitURL(line) - info.License = &spec.License{ - LicenseProps: spec.LicenseProps{ - Name: name, - URL: url, - }, - } - return nil -} - -func safeInfo(swspec *spec.Swagger) *spec.Info { - if swspec.Info == nil { - swspec.Info = new(spec.Info) - } - return swspec.Info -} - -// httpFTPScheme matches http://, https://, ws://, wss://. -var httpFTPScheme = regexp.MustCompile("(?:(?:ht|f)tp|ws)s?://") - -func splitURL(line string) (notURL, url string) { - str := strings.TrimSpace(line) - parts := httpFTPScheme.FindStringIndex(str) - if len(parts) == 0 { - if len(str) > 0 { - notURL = str - } - return notURL, "" - } - if len(parts) > 0 { - notURL = strings.TrimSpace(str[:parts[0]]) - url = strings.TrimSpace(str[parts[0]:]) - } - return notURL, url -} diff --git a/internal/parsers/meta_test.go b/internal/parsers/meta_test.go deleted file mode 100644 index 8b8d391..0000000 --- a/internal/parsers/meta_test.go +++ /dev/null @@ -1,270 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - goparser "go/parser" - "go/token" - "testing" - - "github.com/go-openapi/codescan/internal/scantest/classification" - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" - - oaispec "github.com/go-openapi/spec" -) - -func TestSetInfoVersion(t *testing.T) { - info := new(oaispec.Swagger) - err := setInfoVersion(info, []string{"0.0.1"}) - require.NoError(t, err) - assert.EqualT(t, "0.0.1", info.Info.Version) -} - -func TestSetInfoLicense(t *testing.T) { - info := new(oaispec.Swagger) - err := setInfoLicense(info, []string{"MIT http://license.org/MIT"}) - require.NoError(t, err) - assert.EqualT(t, "MIT", info.Info.License.Name) - assert.EqualT(t, "http://license.org/MIT", info.Info.License.URL) -} - -func TestSetInfoContact(t *testing.T) { - info := new(oaispec.Swagger) - err := setInfoContact(info, []string{"Homer J. Simpson http://simpsons.com"}) - require.NoError(t, err) - assert.EqualT(t, "Homer J. Simpson", info.Info.Contact.Name) - assert.EqualT(t, "homer@simpsons.com", info.Info.Contact.Email) - assert.EqualT(t, "http://simpsons.com", info.Info.Contact.URL) -} - -func TestParseInfo(t *testing.T) { - swspec := new(oaispec.Swagger) - parser := NewMetaParser(swspec) - docFile := "../../fixtures/goparsing/classification/doc.go" - fileSet := token.NewFileSet() - fileTree, err := goparser.ParseFile(fileSet, docFile, nil, goparser.ParseComments) - if err != nil { - t.FailNow() - } - - err = parser.Parse(fileTree.Doc) - - require.NoError(t, err) - classification.VerifyInfo(t, swspec.Info) -} - -func TestParseSwagger(t *testing.T) { - swspec := new(oaispec.Swagger) - parser := NewMetaParser(swspec) - docFile := "../../fixtures/goparsing/classification/doc.go" - fileSet := token.NewFileSet() - fileTree, err := goparser.ParseFile(fileSet, docFile, nil, goparser.ParseComments) - if err != nil { - t.FailNow() - } - - err = parser.Parse(fileTree.Doc) - verifyMeta(t, swspec) - - require.NoError(t, err) -} - -func verifyMeta(t *testing.T, doc *oaispec.Swagger) { - assert.NotNil(t, doc) - classification.VerifyInfo(t, doc.Info) - assert.Equal(t, []string{"application/json", "application/xml"}, doc.Consumes) - assert.Equal(t, []string{"application/json", "application/xml"}, doc.Produces) - assert.Equal(t, []string{"http", "https"}, doc.Schemes) - assert.Equal(t, []map[string][]string{{"api_key": {}}}, doc.Security) - expectedSecuritySchemaKey := oaispec.SecurityScheme{ - SecuritySchemeProps: oaispec.SecuritySchemeProps{ - Type: "apiKey", - In: "header", - Name: "KEY", - }, - } - expectedSecuritySchemaOAuth := oaispec.SecurityScheme{ - SecuritySchemeProps: oaispec.SecuritySchemeProps{ //nolint:gosec // G101: false positive, test fixture not real credentials - Type: "oauth2", - In: "header", - AuthorizationURL: "/oauth2/auth", - TokenURL: "/oauth2/token", - Flow: "accessCode", - Scopes: map[string]string{ - "bla1": "foo1", - "bla2": "foo2", - }, - }, - } - expectedExtensions := oaispec.Extensions{ - "x-meta-array": []any{ - "value1", - "value2", - }, - "x-meta-array-obj": []any{ - map[string]any{ - "name": "obj", - "value": "field", - }, - }, - "x-meta-value": "value", - } - expectedInfoExtensions := oaispec.Extensions{ - "x-info-array": []any{ - "value1", - "value2", - }, - "x-info-array-obj": []any{ - map[string]any{ - "name": "obj", - "value": "field", - }, - }, - "x-info-value": "value", - } - assert.NotNil(t, doc.SecurityDefinitions["api_key"]) - assert.NotNil(t, doc.SecurityDefinitions["oauth2"]) - assert.Equal(t, oaispec.SecurityDefinitions{"api_key": &expectedSecuritySchemaKey, "oauth2": &expectedSecuritySchemaOAuth}, doc.SecurityDefinitions) - assert.Equal(t, expectedExtensions, doc.Extensions) - assert.Equal(t, expectedInfoExtensions, doc.Info.Extensions) - assert.EqualT(t, "localhost", doc.Host) - assert.EqualT(t, "/v2", doc.BasePath) -} - -func TestMoreParseMeta(t *testing.T) { - for _, docFile := range []string{ - "../../fixtures/goparsing/meta/v1/doc.go", - "../../fixtures/goparsing/meta/v2/doc.go", - "../../fixtures/goparsing/meta/v3/doc.go", - "../../fixtures/goparsing/meta/v4/doc.go", - } { - swspec := new(oaispec.Swagger) - parser := NewMetaParser(swspec) - fileSet := token.NewFileSet() - fileTree, err := goparser.ParseFile(fileSet, docFile, nil, goparser.ParseComments) - if err != nil { - t.FailNow() - } - - err = parser.Parse(fileTree.Doc) - require.NoError(t, err) - assert.EqualT(t, "there are no TOS at this moment, use at your own risk we take no responsibility", swspec.Info.TermsOfService) - /* - jazon, err := json.MarshalIndent(swoaispec.Info, "", " ") - require.NoError(t, err) - t.Logf("%v", string(jazon)) - */ - } -} - -func TestSetInfoVersion_Empty(t *testing.T) { - swspec := new(oaispec.Swagger) - require.NoError(t, setInfoVersion(swspec, nil)) - assert.Nil(t, swspec.Info) -} - -func TestSetSwaggerHost_Empty(t *testing.T) { - swspec := new(oaispec.Swagger) - require.NoError(t, setSwaggerHost(swspec, nil)) - assert.EqualT(t, "localhost", swspec.Host) // fallback - swspec2 := new(oaispec.Swagger) - require.NoError(t, setSwaggerHost(swspec2, []string{""})) - assert.EqualT(t, "localhost", swspec2.Host) // fallback -} - -func TestSetInfoContact_Empty(t *testing.T) { - swspec := new(oaispec.Swagger) - require.NoError(t, setInfoContact(swspec, nil)) - assert.Nil(t, swspec.Info) - require.NoError(t, setInfoContact(swspec, []string{""})) -} - -func TestSetInfoContact_BadEmail(t *testing.T) { - swspec := new(oaispec.Swagger) - err := setInfoContact(swspec, []string{"not-a-valid-email-address <<<"}) - require.Error(t, err) -} - -func TestSetInfoLicense_Empty(t *testing.T) { - swspec := new(oaispec.Swagger) - require.NoError(t, setInfoLicense(swspec, nil)) - assert.Nil(t, swspec.Info) - require.NoError(t, setInfoLicense(swspec, []string{""})) -} - -func TestSetMetaSingle_Parse_Empty(t *testing.T) { - swspec := new(oaispec.Swagger) - s := &setMetaSingle{Spec: swspec, Rx: rxVersion, Set: setInfoVersion} - require.NoError(t, s.Parse(nil)) - require.NoError(t, s.Parse([]string{""})) - // Line that doesn't match the regex - require.NoError(t, s.Parse([]string{"no match here"})) -} - -func TestSplitURL(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - line string - wantNot string - wantURL string - }{ - {"with http url", "MIT http://example.com", "MIT", "http://example.com"}, - {"with https url", "MIT https://example.com", "MIT", "https://example.com"}, - {"url only", "http://example.com", "", "http://example.com"}, - {"no url", "just text", "just text", ""}, - {"empty", "", "", ""}, - {"ws url", "live ws://example.com/ws", "live", "ws://example.com/ws"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - notURL, url := splitURL(tc.line) - assert.EqualT(t, tc.wantNot, notURL) - assert.EqualT(t, tc.wantURL, url) - }) - } -} - -func TestMetaVendorExtensibleSetter_InvalidKey(t *testing.T) { - swspec := new(oaispec.Swagger) - setter := metaVendorExtensibleSetter(swspec) - // Extension key that doesn't start with x- - err := setter([]byte(`{"not-x-key": "value"}`)) - require.Error(t, err) - require.ErrorIs(t, err, ErrParser) -} - -func TestMetaVendorExtensibleSetter_BadJSON(t *testing.T) { - swspec := new(oaispec.Swagger) - setter := metaVendorExtensibleSetter(swspec) - err := setter([]byte(`{bad json`)) - require.Error(t, err) -} - -func TestInfoVendorExtensibleSetter_InvalidKey(t *testing.T) { - swspec := &oaispec.Swagger{} - swspec.Info = new(oaispec.Info) - setter := infoVendorExtensibleSetter(swspec) - err := setter([]byte(`{"invalid-key": "value"}`)) - require.Error(t, err) - require.ErrorIs(t, err, ErrParser) -} - -func TestInfoVendorExtensibleSetter_BadJSON(t *testing.T) { - swspec := &oaispec.Swagger{} - swspec.Info = new(oaispec.Info) - setter := infoVendorExtensibleSetter(swspec) - err := setter([]byte(`{bad json`)) - require.Error(t, err) -} - -func TestMetaSecurityDefinitionsSetter_BadJSON(t *testing.T) { - swspec := new(oaispec.Swagger) - setter := metaSecurityDefinitionsSetter(swspec) - err := setter([]byte(`{bad json`)) - require.Error(t, err) -} diff --git a/internal/parsers/yaml_spec_parser.go b/internal/parsers/yaml_spec_parser.go deleted file mode 100644 index 1ebe8c7..0000000 --- a/internal/parsers/yaml_spec_parser.go +++ /dev/null @@ -1,202 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "encoding/json" - "fmt" - "go/ast" - "regexp" - "strings" - - "github.com/go-openapi/loads/fmts" - "go.yaml.in/yaml/v3" -) - -// YAMLSpecScanner aggregates lines in header until it sees `---`, -// the beginning of a YAML spec. -type YAMLSpecScanner struct { - header []string - yamlSpec []string - setTitle func([]string) - setDescription func([]string) - workedOutTitle bool - title []string - skipHeader bool -} - -func NewYAMLSpecScanner(setTitle func([]string), setDescription func([]string)) *YAMLSpecScanner { - return &YAMLSpecScanner{ - setTitle: setTitle, - setDescription: setDescription, - } -} - -func (sp *YAMLSpecScanner) Title() []string { - sp.collectTitleDescription() - return sp.title -} - -func (sp *YAMLSpecScanner) Description() []string { - sp.collectTitleDescription() - return sp.header -} - -func (sp *YAMLSpecScanner) Parse(doc *ast.CommentGroup) error { - if doc == nil { - return nil - } - var startedYAMLSpec bool -COMMENTS: - for _, c := range doc.List { - for line := range strings.SplitSeq(c.Text, "\n") { - if HasAnnotation(line) { - break COMMENTS // a new swagger: annotation terminates this parser - } - - if !startedYAMLSpec { - if rxBeginYAMLSpec.MatchString(line) { - startedYAMLSpec = true - sp.yamlSpec = append(sp.yamlSpec, line) - continue - } - - if !sp.skipHeader { - sp.header = append(sp.header, line) - } - - // no YAML spec yet, moving on - continue - } - - sp.yamlSpec = append(sp.yamlSpec, line) - } - } - if sp.setTitle != nil { - sp.setTitle(sp.Title()) - } - if sp.setDescription != nil { - sp.setDescription(sp.Description()) - } - return nil -} - -func (sp *YAMLSpecScanner) UnmarshalSpec(u func([]byte) error) (err error) { - specYaml := cleanupScannerLines(sp.yamlSpec, rxUncommentYAML) - if len(specYaml) == 0 { - return fmt.Errorf("no spec available to unmarshal: %w", ErrParser) - } - - if !strings.Contains(specYaml[0], "---") { - return fmt.Errorf("yaml spec has to start with `---`: %w", ErrParser) - } - - // remove indentation - specYaml = removeIndent(specYaml) - - // 1. parse yaml lines - yamlValue := make(map[any]any) - - yamlContent := strings.Join(specYaml, "\n") - err = yaml.Unmarshal([]byte(yamlContent), &yamlValue) - if err != nil { - return err - } - - // 2. convert to json - var jsonValue json.RawMessage - jsonValue, err = fmts.YAMLToJSON(yamlValue) - if err != nil { - return err - } - - // 3. unmarshal the json into an interface - var data []byte - data, err = jsonValue.MarshalJSON() - if err != nil { - return err - } - err = u(data) - if err != nil { - return err - } - - // all parsed, returning... - sp.yamlSpec = nil // spec is now consumed, so let's erase the parsed lines - - return nil -} - -func (sp *YAMLSpecScanner) collectTitleDescription() { - if sp.workedOutTitle { - return - } - if sp.setTitle == nil { - sp.header = cleanupScannerLines(sp.header, rxUncommentHeaders) - return - } - - sp.workedOutTitle = true - sp.title, sp.header = collectScannerTitleDescription(sp.header) -} - -// removes indent based on the first line. -func removeIndent(spec []string) []string { - if len(spec) == 0 { - return spec - } - - loc := rxIndent.FindStringIndex(spec[0]) - if len(loc) < 2 || loc[1] <= 1 { - return spec - } - - s := make([]string, len(spec)) - copy(s, spec) - - for i := range s { - if len(s[i]) < loc[1] { - continue - } - - s[i] = spec[i][loc[1]-1:] //nolint:gosec // G602: bounds already checked on line 445 - start := rxNotIndent.FindStringIndex(s[i]) - if len(start) < 2 || start[1] == 0 { - continue - } - - s[i] = strings.Replace(s[i], "\t", " ", start[1]) - } - - return s -} - -func cleanupScannerLines(lines []string, ur *regexp.Regexp) []string { - // bail early when there is nothing to parse - if len(lines) == 0 { - return lines - } - - seenLine := -1 - var lastContent int - - uncommented := make([]string, 0, len(lines)) - for i, v := range lines { - str := ur.ReplaceAllString(v, "") - uncommented = append(uncommented, str) - if str != "" { - if seenLine < 0 { - seenLine = i - } - lastContent = i - } - } - - // fixes issue #50 - if seenLine == -1 { - return nil - } - - return uncommented[seenLine : lastContent+1] -} diff --git a/internal/parsers/yaml_spec_parser_test.go b/internal/parsers/yaml_spec_parser_test.go deleted file mode 100644 index d870bd8..0000000 --- a/internal/parsers/yaml_spec_parser_test.go +++ /dev/null @@ -1,402 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "errors" - "go/ast" - "testing" - - "github.com/go-openapi/testify/v2/require" -) - -var errCallback = errors.New("callback error") - -func TestYamlSpecScanner(t *testing.T) { - t.Parallel() - - t.Run("with happy path", func(t *testing.T) { - t.Run("should parse operation definition object as YAML", func(t *testing.T) { - parser := new(YAMLSpecScanner) - var title, description []string - parser.setTitle = func(lines []string) { title = lines } - parser.setDescription = func(lines []string) { description = lines } - - lines := []string{ - // from issue #3225, reindented - // `swagger:operation POST /v1/example-endpoint addExampleConfig`, - `title for this operation`, - ``, // blank line elided - `description of this operation`, - ``, // blank line preserved - `continuation of the description`, - `---`, // YAML block - `summary: Adds a new configuration entry`, - `description: |-`, - ` Creates and validates a new configuration request.`, - ``, - `security:`, - `- AuthToken: []`, - `consumes:`, - `- application/json`, - `tags:`, - `- Example|Configuration`, - `responses:`, - ` 201:`, - ` $ref: "#/responses/createdResponse"`, - ` 400:`, - ` $ref: "#/responses/badRequestResponse"`, - ` 412:`, - ` $ref: "#/responses/preconditionFailedResponse"`, - ` 500:`, - ` $ref: "#/responses/internalServerErrorResponse"`, - } - - doc := buildRawTestComments(lines) - require.NoError(t, parser.Parse(doc)) - require.Equal(t, title, parser.Title()) - require.Equal(t, []string{"title for this operation"}, parser.Title()) - require.Equal(t, description, parser.Description()) - require.Equal(t, []string{"description of this operation", "", "continuation of the description"}, parser.Description()) - - var receivedJSON string - yamlReceiver := func(b []byte) error { - receivedJSON = string(b) - return nil - } - - require.NoError(t, parser.UnmarshalSpec(yamlReceiver)) - - const expectedJSON = `{ - "summary":"Adds a new configuration entry", - "description":"Creates and validates a new configuration request.", - "security":[ - {"AuthToken":[]} - ], - "consumes":["application/json"], - "tags":["Example|Configuration"], - "responses":{ - "201":{"$ref":"#/responses/createdResponse"}, - "400":{"$ref":"#/responses/badRequestResponse"}, - "412":{"$ref":"#/responses/preconditionFailedResponse"}, - "500":{"$ref":"#/responses/internalServerErrorResponse"} - } - }` - - require.JSONEqT(t, expectedJSON, receivedJSON) - }) - - t.Run("should stop yaml operation block when new tag is found", func(t *testing.T) { - parser := new(YAMLSpecScanner) - var title, description []string - parser.setTitle = func(lines []string) { title = lines } - parser.setDescription = func(lines []string) { description = lines } - - lines := []string{ - `title for this operation`, - ``, // blank line elided - `description of this operation`, - `---`, // YAML block - `summary: Adds a new configuration entry`, - ``, - `swagger:enum`, // yaml block ended at this tag. Rest is ignored - `security:`, - `- AuthToken: []`, - } - - doc := buildRawTestComments(lines) - require.NoError(t, parser.Parse(doc)) - require.Equal(t, title, parser.Title()) - require.Equal(t, []string{"title for this operation"}, parser.Title()) - require.Equal(t, description, parser.Description()) - require.Equal(t, []string{"description of this operation"}, parser.Description()) - - var receivedJSON string - yamlReceiver := func(b []byte) error { - receivedJSON = string(b) - return nil - } - - require.NoError(t, parser.UnmarshalSpec(yamlReceiver)) - - const expectedJSON = `{ - "summary":"Adds a new configuration entry" - }` - - require.JSONEqT(t, expectedJSON, receivedJSON) - }) - - t.Run("should stop yaml operation block when new yaml document separator is found", func(t *testing.T) { - parser := new(YAMLSpecScanner) - var title, description []string - parser.setTitle = func(lines []string) { title = lines } - parser.setDescription = func(lines []string) { description = lines } - - lines := []string{ - `title for this operation`, - ``, // blank line elided - `description of this operation`, - `---`, // YAML block - `summary: Adds a new configuration entry`, - ``, - `---`, // yaml block ended at mark. Rest is ignored - `security:`, - `- AuthToken: []`, - } - - doc := buildRawTestComments(lines) - require.NoError(t, parser.Parse(doc)) - require.Equal(t, title, parser.Title()) - require.Equal(t, []string{"title for this operation"}, parser.Title()) - require.Equal(t, description, parser.Description()) - require.Equal(t, []string{"description of this operation"}, parser.Description()) - - var receivedJSON string - yamlReceiver := func(b []byte) error { - receivedJSON = string(b) - return nil - } - - require.NoError(t, parser.UnmarshalSpec(yamlReceiver)) - - const expectedJSON = `{ - "summary":"Adds a new configuration entry" - }` - - require.JSONEqT(t, expectedJSON, receivedJSON) - }) - }) - - t.Run("with edge cases", func(t *testing.T) { - t.Run("with empty comment block", func(t *testing.T) { - parser := new(YAMLSpecScanner) - var title, description []string - parser.setTitle = func(lines []string) { title = lines } - parser.setDescription = func(lines []string) { description = lines } - doc := buildRawTestComments(nil) - require.NoError(t, parser.Parse(doc)) - require.Empty(t, title) - require.Empty(t, description) - }) - - t.Run("with nil comment block", func(t *testing.T) { - parser := new(YAMLSpecScanner) - var title, description []string - parser.setTitle = func(lines []string) { title = lines } - parser.setDescription = func(lines []string) { description = lines } - require.NoError(t, parser.Parse(nil)) - require.Empty(t, title) - require.Empty(t, description) - }) - - t.Run("without setTitle", func(t *testing.T) { - parser := new(YAMLSpecScanner) - var description []string - parser.setDescription = func(lines []string) { description = lines } - - lines := []string{ - `title for this operation`, - ``, // blank line preserved - `description of this operation`, - `---`, // YAML block - } - - doc := buildRawTestComments(lines) - require.NoError(t, parser.Parse(doc)) - require.Nil(t, parser.Title()) - require.Equal(t, description, parser.Description()) - require.Equal(t, []string{"title for this operation", "", "description of this operation"}, parser.Description()) - - var receivedJSON string - yamlReceiver := func(b []byte) error { - receivedJSON = string(b) - return nil - } - require.NoError(t, parser.UnmarshalSpec(yamlReceiver)) - require.JSONEqT(t, `{}`, receivedJSON) - }) - }) -} - -func TestYAMLSpecScanner_UnmarshalSpec_Errors(t *testing.T) { - t.Parallel() - - t.Run("no spec available", func(t *testing.T) { - parser := new(YAMLSpecScanner) - parser.setTitle = func(_ []string) {} - parser.setDescription = func(_ []string) {} - // Parse with no --- marker → no yamlSpec collected - doc := buildRawTestComments([]string{"just text, no yaml"}) - require.NoError(t, parser.Parse(doc)) - - err := parser.UnmarshalSpec(func(_ []byte) error { return nil }) - require.Error(t, err) - require.ErrorIs(t, err, ErrParser) - }) - - t.Run("spec doesnt start with ---", func(t *testing.T) { - parser := new(YAMLSpecScanner) - // Manually inject yamlSpec without the --- marker - parser.yamlSpec = []string{"summary: test"} - - err := parser.UnmarshalSpec(func(_ []byte) error { return nil }) - require.Error(t, err) - require.ErrorIs(t, err, ErrParser) - }) - - t.Run("invalid yaml", func(t *testing.T) { - parser := new(YAMLSpecScanner) - parser.yamlSpec = []string{"// ---", "// \tbad:", "// yaml"} - - err := parser.UnmarshalSpec(func(_ []byte) error { return nil }) - require.Error(t, err) - }) - - t.Run("unmarshal callback error", func(t *testing.T) { - parser := new(YAMLSpecScanner) - parser.setTitle = func(_ []string) {} - parser.setDescription = func(_ []string) {} - - lines := []string{ - "title", - "---", - "summary: test", - } - doc := buildRawTestComments(lines) - require.NoError(t, parser.Parse(doc)) - - err := parser.UnmarshalSpec(func(_ []byte) error { return errCallback }) - require.Error(t, err) - require.ErrorIs(t, err, errCallback) - }) -} - -func TestNewYAMLSpecScanner(t *testing.T) { - t.Parallel() - - var title, desc []string - scanner := NewYAMLSpecScanner( - func(lines []string) { title = lines }, - func(lines []string) { desc = lines }, - ) - - lines := []string{ - "My Title.", - "", - "My description.", - "---", - "summary: test", - } - doc := buildRawTestComments(lines) - require.NoError(t, scanner.Parse(doc)) - require.Equal(t, []string{"My Title."}, title) - require.Equal(t, []string{"My description."}, desc) -} - -func TestRemoveIndent(t *testing.T) { - t.Parallel() - - t.Run("with removeIndent", func(t *testing.T) { - t.Run("should tolerate empty input", func(t *testing.T) { - res := removeIndent([]string{}) - require.Empty(t, res) - require.NotNil(t, res) - }) - - t.Run("should tolerate nil input", func(t *testing.T) { - res := removeIndent(nil) - require.Empty(t, res) - require.Nil(t, res) - }) - - t.Run("should support headline without indentation", func(t *testing.T) { - lines := []string{ - "xyz", - " abc", - } - res := removeIndent(lines) - require.Equal(t, lines, res) - }) - - t.Run("should tolerate lines with only indents", func(t *testing.T) { - lines := []string{ - " xyz", - "", - " ", - " ", - } - res := removeIndent(lines) - - expected := []string{ - "xyz", - "", // empty line preserved - " ", // blank lines unindented - " ", - } - require.Equal(t, expected, res) - }) - - t.Run("should replace tabs with spaces in indentation", func(t *testing.T) { - lines := []string{ - "\t\txyz", - "", - " ", - "\t \t", - } - res := removeIndent(lines) - - expected := []string{ - "xyz", - "", // empty line preserved - " ", // blank lines unindented - " \t", - } - require.Equal(t, expected, res) - }) - }) - - t.Run("with removeYamlIndent", func(t *testing.T) { - t.Run("should tolerate empty input", func(t *testing.T) { - res := removeYamlIndent([]string{}) - require.Empty(t, res) - require.NotNil(t, res) - }) - - t.Run("should tolerate nil input", func(t *testing.T) { - res := removeYamlIndent(nil) - require.Empty(t, res) - require.Nil(t, res) - }) - - t.Run("should support headline without indentation", func(t *testing.T) { - lines := []string{ - "xyz", - " abc", - } - res := removeYamlIndent(lines) - require.Equal(t, lines, res) - }) - - t.Run("should support headline without indentation", func(t *testing.T) { - lines := []string{ - "xyz", - " abc", - } - res := removeYamlIndent(lines) - require.Equal(t, lines, res) - }) - }) -} - -func buildRawTestComments(lines []string) *ast.CommentGroup { - // build raw doc comments like ast provides - doc := &ast.CommentGroup{ - List: make([]*ast.Comment, 0, len(lines)), - } - for _, line := range lines { - doc.List = append(doc.List, &ast.Comment{Text: "// " + line}) - } - - return doc -} From badef4ced440f2b9abff3965d5d4cd9b26de24c0 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:38:58 +0200 Subject: [PATCH 16/22] feat(scanner): grammar seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapts the existing scanner to the grammar surface with minimal change. Wires Options.OnDiagnostic into the per-decl Builders, exposes the ScanCtx FileSet so grammar parsers produce position-stable diagnostics, and surfaces the scanner-level annotation classifiers (ExtractAnnotation, ModelOverride, ResponseOverride, ParametersOverride) under internal/parsers/. Note: a handful of regular expressions remain in the scanner — annotation discovery, model / response / parameters override matching, route / operation path-annotation tokenisation. Removing these is deferred to a forthcoming scanner-focused change. Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- internal/parsers/enum.go | 149 ------- internal/parsers/enum_test.go | 145 ------- internal/parsers/errors.go | 9 - internal/parsers/lines.go | 43 -- internal/parsers/lines_test.go | 66 --- internal/parsers/matchers.go | 118 +----- internal/parsers/matchers_test.go | 189 --------- internal/parsers/parsed_path_content.go | 25 +- internal/parsers/parsers.go | 142 ------- internal/parsers/parsers_helpers.go | 51 --- internal/parsers/parsers_helpers_test.go | 73 ---- internal/parsers/parsers_test.go | 99 ----- internal/parsers/regexprs.go | 119 ++---- internal/parsers/regexprs_test.go | 210 +--------- internal/parsers/sectioned_parser.go | 289 ------------- .../parsers/sectioned_parser_go119_test.go | 47 --- internal/parsers/sectioned_parser_test.go | 382 ------------------ internal/parsers/tag_parsers.go | 86 ---- internal/scanner/README.md | 189 +++++++++ internal/scanner/classify/extension.go | 22 + internal/scanner/classify/extension_test.go | 32 ++ internal/scanner/enum_value.go | 34 ++ internal/scanner/index.go | 17 +- internal/scanner/options.go | 51 ++- internal/scanner/scan_context.go | 108 ++++- 25 files changed, 514 insertions(+), 2181 deletions(-) delete mode 100644 internal/parsers/enum.go delete mode 100644 internal/parsers/enum_test.go delete mode 100644 internal/parsers/errors.go delete mode 100644 internal/parsers/lines.go delete mode 100644 internal/parsers/lines_test.go delete mode 100644 internal/parsers/parsers.go delete mode 100644 internal/parsers/parsers_helpers.go delete mode 100644 internal/parsers/parsers_helpers_test.go delete mode 100644 internal/parsers/parsers_test.go delete mode 100644 internal/parsers/sectioned_parser.go delete mode 100644 internal/parsers/sectioned_parser_go119_test.go delete mode 100644 internal/parsers/sectioned_parser_test.go delete mode 100644 internal/parsers/tag_parsers.go create mode 100644 internal/scanner/README.md create mode 100644 internal/scanner/classify/extension.go create mode 100644 internal/scanner/classify/extension_test.go create mode 100644 internal/scanner/enum_value.go diff --git a/internal/parsers/enum.go b/internal/parsers/enum.go deleted file mode 100644 index 0b4e62f..0000000 --- a/internal/parsers/enum.go +++ /dev/null @@ -1,149 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "encoding/json" - "go/ast" - "log" - "regexp" - "strconv" - "strings" - - "github.com/go-openapi/codescan/internal/ifaces" - "github.com/go-openapi/spec" -) - -type SetEnum struct { - builder ifaces.ValidationBuilder - rx *regexp.Regexp -} - -func NewSetEnum(builder ifaces.ValidationBuilder, opts ...PrefixRxOption) *SetEnum { - rx := rxEnumValidation - for _, apply := range opts { - rx = apply(rxEnumFmt) - } - - return &SetEnum{ - builder: builder, - rx: rx, - } -} - -func (se *SetEnum) Matches(line string) bool { - return se.rx.MatchString(line) -} - -func (se *SetEnum) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - - matches := se.rx.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - se.builder.SetEnum(matches[1]) - } - - return nil -} - -func parseValueFromSchema(s string, schema *spec.SimpleSchema) (any, error) { - if schema == nil { - return s, nil - } - - switch strings.Trim(schema.TypeName(), "\"") { - case "integer", "int", "int64", "int32", "int16": - return strconv.Atoi(s) - case "bool", "boolean": - return strconv.ParseBool(s) - case "number", "float64", "float32": - return strconv.ParseFloat(s, 64) - case "object": - var obj map[string]any - if err := json.Unmarshal([]byte(s), &obj); err != nil { - return s, nil //nolint:nilerr // fallback: return raw string when JSON is invalid - } - return obj, nil - case "array": - var slice []any - if err := json.Unmarshal([]byte(s), &slice); err != nil { - return s, nil //nolint:nilerr // fallback: return raw string when JSON is invalid - } - return slice, nil - default: - return s, nil - } -} - -func parseEnumOld(val string, s *spec.SimpleSchema) []any { - list := strings.Split(val, ",") - interfaceSlice := make([]any, len(list)) - for i, d := range list { - v, err := parseValueFromSchema(d, s) - if err != nil { - interfaceSlice[i] = d - continue - } - - interfaceSlice[i] = v - } - return interfaceSlice -} - -func ParseEnum(val string, s *spec.SimpleSchema) []any { - // obtain the raw elements of the list to latter process them with the parseValueFromSchema - var rawElements []json.RawMessage - if err := json.Unmarshal([]byte(val), &rawElements); err != nil { - log.Print("WARNING: item list for enum is not a valid JSON array, using the old deprecated format") - return parseEnumOld(val, s) - } - - interfaceSlice := make([]any, len(rawElements)) - - for i, d := range rawElements { - ds, err := strconv.Unquote(string(d)) - if err != nil { - ds = string(d) - } - - v, err := parseValueFromSchema(ds, s) - if err != nil { - interfaceSlice[i] = ds - continue - } - - interfaceSlice[i] = v - } - - return interfaceSlice -} - -func GetEnumBasicLitValue(basicLit *ast.BasicLit) any { - switch basicLit.Kind.String() { - case "INT": - if result, err := strconv.ParseInt(basicLit.Value, 10, 64); err == nil { - return result - } - case "FLOAT": - if result, err := strconv.ParseFloat(basicLit.Value, 64); err == nil { - return result - } - default: - return strings.Trim(basicLit.Value, "\"") - } - return nil -} - -const extEnumDesc = "x-go-enum-desc" - -func GetEnumDesc(extensions spec.Extensions) (desc string) { - desc, _ = extensions.GetString(extEnumDesc) - return desc -} - -func EnumDescExtension() string { - return extEnumDesc -} diff --git a/internal/parsers/enum_test.go b/internal/parsers/enum_test.go deleted file mode 100644 index 14f9288..0000000 --- a/internal/parsers/enum_test.go +++ /dev/null @@ -1,145 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "go/ast" - "go/token" - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" - - "github.com/go-openapi/spec" -) - -func Test_getEnumBasicLitValue(t *testing.T) { - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.INT, Value: "0"}, int64(0)) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.INT, Value: "-1"}, int64(-1)) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.INT, Value: "42"}, int64(42)) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.INT, Value: ""}, nil) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.INT, Value: "word"}, nil) - - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.FLOAT, Value: "0"}, float64(0)) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.FLOAT, Value: "-1"}, float64(-1)) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.FLOAT, Value: "42"}, float64(42)) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.FLOAT, Value: "1.1234"}, float64(1.1234)) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.FLOAT, Value: "1.9876"}, float64(1.9876)) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.FLOAT, Value: ""}, nil) - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.FLOAT, Value: "word"}, nil) - - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.STRING, Value: "Foo"}, "Foo") - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.STRING, Value: ""}, "") - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.STRING, Value: "0"}, "0") - verifyGetEnumBasicLitValue(t, ast.BasicLit{Kind: token.STRING, Value: "1.1"}, "1.1") -} - -func verifyGetEnumBasicLitValue(t *testing.T, basicLit ast.BasicLit, expected any) { - actual := GetEnumBasicLitValue(&basicLit) - - assert.Equal(t, expected, actual) -} - -func TestParseValueFromSchema(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - schema *spec.SimpleSchema - want any - }{ - {"nil schema", "hello", nil, "hello"}, - {"string", "hello", &spec.SimpleSchema{Type: "string"}, "hello"}, - {"integer", "42", &spec.SimpleSchema{Type: "integer"}, 42}, - {"int64", "100", &spec.SimpleSchema{Type: "int64"}, 100}, - {"bool true", "true", &spec.SimpleSchema{Type: "bool"}, true}, - {"boolean false", "false", &spec.SimpleSchema{Type: "boolean"}, false}, - {"float64", "3.14", &spec.SimpleSchema{Type: "float64"}, float64(3.14)}, - {"number", "2.5", &spec.SimpleSchema{Type: "number"}, float64(2.5)}, - {"object valid", `{"a":"b"}`, &spec.SimpleSchema{Type: "object"}, map[string]any{"a": "b"}}, - {"object invalid json", `not-json`, &spec.SimpleSchema{Type: "object"}, "not-json"}, - {"array valid", `[1,2,3]`, &spec.SimpleSchema{Type: "array"}, []any{float64(1), float64(2), float64(3)}}, - {"array invalid json", `not-json`, &spec.SimpleSchema{Type: "array"}, "not-json"}, - {"unknown type", "raw", &spec.SimpleSchema{Type: "custom"}, "raw"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got, err := parseValueFromSchema(tc.input, tc.schema) - require.NoError(t, err) - assert.Equal(t, tc.want, got) - }) - } - - t.Run("integer parse error", func(t *testing.T) { - _, err := parseValueFromSchema("not-a-number", &spec.SimpleSchema{Type: "integer"}) - require.Error(t, err) - }) - - t.Run("bool parse error", func(t *testing.T) { - _, err := parseValueFromSchema("maybe", &spec.SimpleSchema{Type: "bool"}) - require.Error(t, err) - }) -} - -func TestParseEnum(t *testing.T) { - t.Parallel() - - t.Run("JSON format strings", func(t *testing.T) { - result := ParseEnum(`["a","b","c"]`, &spec.SimpleSchema{Type: "string"}) - assert.Equal(t, []any{"a", "b", "c"}, result) - }) - - t.Run("JSON format integers", func(t *testing.T) { - result := ParseEnum(`[1,2,3]`, &spec.SimpleSchema{Type: "integer"}) - assert.Equal(t, []any{1, 2, 3}, result) - }) - - t.Run("old comma-separated format", func(t *testing.T) { - result := ParseEnum("a,b,c", &spec.SimpleSchema{Type: "string"}) - assert.Equal(t, []any{"a", "b", "c"}, result) - }) - - t.Run("old format integers", func(t *testing.T) { - result := ParseEnum("1,2,3", &spec.SimpleSchema{Type: "integer"}) - assert.Equal(t, []any{1, 2, 3}, result) - }) - - t.Run("old format with parse error fallback", func(t *testing.T) { - // "abc" cannot be parsed as integer → fallback to raw string - result := ParseEnum("abc,2,xyz", &spec.SimpleSchema{Type: "integer"}) - assert.Equal(t, []any{"abc", 2, "xyz"}, result) - }) - - t.Run("JSON format with parse error fallback", func(t *testing.T) { - // JSON array of integers, but "abc" can't parse as integer → fallback - result := ParseEnum(`["abc",2,"xyz"]`, &spec.SimpleSchema{Type: "integer"}) - assert.Equal(t, []any{"abc", 2, "xyz"}, result) - }) -} - -func TestGetEnumDesc(t *testing.T) { - t.Parallel() - - t.Run("with extension", func(t *testing.T) { - ext := spec.Extensions{"x-go-enum-desc": "Active - active state\nInactive - inactive state"} - assert.EqualT(t, "Active - active state\nInactive - inactive state", GetEnumDesc(ext)) - }) - - t.Run("without extension", func(t *testing.T) { - ext := spec.Extensions{} - assert.EqualT(t, "", GetEnumDesc(ext)) - }) - - t.Run("nil extensions", func(t *testing.T) { - assert.EqualT(t, "", GetEnumDesc(nil)) - }) -} - -func TestEnumDescExtension(t *testing.T) { - t.Parallel() - - assert.EqualT(t, "x-go-enum-desc", EnumDescExtension()) -} diff --git a/internal/parsers/errors.go b/internal/parsers/errors.go deleted file mode 100644 index 0fb4e79..0000000 --- a/internal/parsers/errors.go +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import "errors" - -// ErrParser is the sentinel error for all errors originating from the parsers package. -var ErrParser = errors.New("codescan:parsers") diff --git a/internal/parsers/lines.go b/internal/parsers/lines.go deleted file mode 100644 index e79c041..0000000 --- a/internal/parsers/lines.go +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import "strings" - -func JoinDropLast(lines []string) string { - l := len(lines) - lns := lines - if l > 0 && len(strings.TrimSpace(lines[l-1])) == 0 { - lns = lines[:l-1] - } - return strings.Join(lns, "\n") -} - -// Setter sets a string field from a multi lines comment. -// -// Usage: -// -// Setter(&op.Description) -// Setter(&op.Summary) -// -// Replaces this idiom: -// -// parsers.WithSetDescription(func(lines []string) { op.Description = parsers.JoinDropLast(lines) }), -func Setter(target *string) func([]string) { - return func(lines []string) { - *target = JoinDropLast(lines) - } -} - -func removeEmptyLines(lines []string) []string { - notEmpty := make([]string, 0, len(lines)) - - for _, l := range lines { - if len(strings.TrimSpace(l)) > 0 { - notEmpty = append(notEmpty, l) - } - } - - return notEmpty -} diff --git a/internal/parsers/lines_test.go b/internal/parsers/lines_test.go deleted file mode 100644 index ead2970..0000000 --- a/internal/parsers/lines_test.go +++ /dev/null @@ -1,66 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "testing" - - "github.com/go-openapi/testify/v2/assert" -) - -func TestJoinDropLast(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - lines []string - want string - }{ - {"nil", nil, ""}, - {"empty slice", []string{}, ""}, - {"single line", []string{"hello"}, "hello"}, - {"trailing blank dropped", []string{"hello", "world", " "}, "hello\nworld"}, - {"trailing empty dropped", []string{"hello", "world", ""}, "hello\nworld"}, - {"no trailing blank", []string{"hello", "world"}, "hello\nworld"}, - {"only blank", []string{" "}, ""}, - {"only empty", []string{""}, ""}, - {"middle blank preserved", []string{"hello", "", "world"}, "hello\n\nworld"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - assert.EqualT(t, tc.want, JoinDropLast(tc.lines)) - }) - } -} - -func TestSetter(t *testing.T) { - t.Parallel() - - var target string - set := Setter(&target) - set([]string{"line1", "line2", ""}) - assert.EqualT(t, "line1\nline2", target) -} - -func TestRemoveEmptyLines(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input []string - want []string - }{ - {"nil", nil, []string{}}, - {"all empty", []string{"", " ", "\t"}, []string{}}, - {"mixed", []string{"hello", "", "world", " "}, []string{"hello", "world"}}, - {"no empty", []string{"a", "b"}, []string{"a", "b"}}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, removeEmptyLines(tc.input)) - }) - } -} diff --git a/internal/parsers/matchers.go b/internal/parsers/matchers.go index 79dfc85..3000e31 100644 --- a/internal/parsers/matchers.go +++ b/internal/parsers/matchers.go @@ -6,27 +6,14 @@ package parsers import ( "go/ast" "regexp" - "slices" "strings" - - "github.com/go-openapi/codescan/internal/ifaces" ) const minMatchCount = 2 -func HasAnnotation(line string) bool { - return rxSwaggerAnnotation.MatchString(line) -} - -func IsAliasParam(prop ifaces.SwaggerTypable) bool { - in := prop.In() - return in == "query" || in == "path" || in == "formData" -} - -func IsAllowedExtension(ext string) bool { - return rxAllowedExtensions.MatchString(ext) -} - +// ExtractAnnotation returns the trailing identifier of a `swagger:` +// marker found anywhere on line, or ("", false) if no marker is +// present. Used by the scanner's annotation-classification index. func ExtractAnnotation(line string) (string, bool) { matches := rxSwaggerAnnotation.FindStringSubmatch(line) if len(matches) < minMatchCount { @@ -36,100 +23,32 @@ func ExtractAnnotation(line string) (string, bool) { return matches[1], true } -func AllOfMember(comments *ast.CommentGroup) bool { - return commentMatcher(rxAllOf)(comments) -} - -func FileParam(comments *ast.CommentGroup) bool { - return commentMatcher(rxFileUpload)(comments) -} - -func Ignored(comments *ast.CommentGroup) bool { - return commentMatcher(rxIgnoreOverride)(comments) -} - -func AliasParam(comments *ast.CommentGroup) bool { - return commentMatcher(rxAlias)(comments) -} - -func StrfmtName(comments *ast.CommentGroup) (string, bool) { - return commentSubMatcher(rxStrFmt)(comments) -} - -func ParamLocation(comments *ast.CommentGroup) (string, bool) { - return commentSubMatcher(rxIn)(comments) -} - -func EnumName(comments *ast.CommentGroup) (string, bool) { - return commentSubMatcher(rxEnum)(comments) -} - -func AllOfName(comments *ast.CommentGroup) (string, bool) { - return commentSubMatcher(rxAllOf)(comments) -} - -func NameOverride(comments *ast.CommentGroup) (string, bool) { - return commentSubMatcher(rxName)(comments) -} - -func DefaultName(comments *ast.CommentGroup) (string, bool) { - return commentSubMatcher(rxDefault)(comments) -} - -func TypeName(comments *ast.CommentGroup) (string, bool) { - return commentSubMatcher(rxType)(comments) -} - +// ModelOverride returns the name argument of a `swagger:model ` +// marker found anywhere in comments, or ("", true) when the marker +// is present without an argument (bare `swagger:model`). Returns +// ("", false) when no marker is found. func ModelOverride(comments *ast.CommentGroup) (string, bool) { return commentBlankSubMatcher(rxModelOverride)(comments) } +// ResponseOverride returns the name argument of a `swagger:response +// ` marker, matching the bare-marker semantics of ModelOverride. func ResponseOverride(comments *ast.CommentGroup) (string, bool) { return commentBlankSubMatcher(rxResponseOverride)(comments) } +// ParametersOverride returns every operation-id reference attached to +// a `swagger:parameters` marker. One marker can carry several +// operation ids; multiple markers across comments accumulate. func ParametersOverride(comments *ast.CommentGroup) ([]string, bool) { return commentMultipleSubMatcher(rxParametersOverride)(comments) } -func commentMatcher(rx *regexp.Regexp) func(*ast.CommentGroup) bool { - return func(comments *ast.CommentGroup) bool { - if comments == nil { - return false - } - - return slices.ContainsFunc(comments.List, func(cmt *ast.Comment) bool { - for ln := range strings.SplitSeq(cmt.Text, "\n") { - if rx.MatchString(ln) { - return true - } - } - - return false - }) - } -} - -func commentSubMatcher(rx *regexp.Regexp) func(*ast.CommentGroup) (string, bool) { - return func(comments *ast.CommentGroup) (string, bool) { - if comments == nil { - return "", false - } - - for _, cmt := range comments.List { - for ln := range strings.SplitSeq(cmt.Text, "\n") { - matches := rx.FindStringSubmatch(ln) - if len(matches) > 1 && len(strings.TrimSpace(matches[1])) > 0 { - return strings.TrimSpace(matches[1]), true - } - } - } - - return "", false - } -} - -// same as commentSubMatcher but returns true if a bare annotation is found, even without an empty submatch. +// commentBlankSubMatcher returns a matcher that searches comments for +// any line matching rx and returns the first non-blank submatch. +// When the marker is present but carries no argument, returns +// ("", true) so callers can distinguish "no marker" from "bare +// marker." See ModelOverride / ResponseOverride. func commentBlankSubMatcher(rx *regexp.Regexp) func(*ast.CommentGroup) (string, bool) { return func(comments *ast.CommentGroup) (string, bool) { if comments == nil { @@ -153,6 +72,9 @@ func commentBlankSubMatcher(rx *regexp.Regexp) func(*ast.CommentGroup) (string, } } +// commentMultipleSubMatcher returns a matcher that collects every +// non-blank submatch from comments, splitting whitespace-separated +// arguments into individual entries. See ParametersOverride. func commentMultipleSubMatcher(rx *regexp.Regexp) func(*ast.CommentGroup) ([]string, bool) { return func(comments *ast.CommentGroup) ([]string, bool) { if comments == nil { diff --git a/internal/parsers/matchers_test.go b/internal/parsers/matchers_test.go index 630daf8..36411b3 100644 --- a/internal/parsers/matchers_test.go +++ b/internal/parsers/matchers_test.go @@ -9,98 +9,8 @@ import ( "github.com/go-openapi/testify/v2/assert" "github.com/go-openapi/testify/v2/require" - - "github.com/go-openapi/codescan/internal/ifaces" - oaispec "github.com/go-openapi/spec" ) -// stubTypable is a minimal SwaggerTypable for testing IsAliasParam. -type stubTypable struct { - in string -} - -func (s stubTypable) In() string { return s.in } -func (s stubTypable) Typed(string, string) {} -func (s stubTypable) SetRef(oaispec.Ref) {} - -func (s stubTypable) Items() ifaces.SwaggerTypable { //nolint:ireturn // test stub - return s -} -func (s stubTypable) Schema() *oaispec.Schema { return nil } -func (s stubTypable) Level() int { return 0 } -func (s stubTypable) AddExtension(string, any) {} -func (s stubTypable) WithEnum(...any) {} -func (s stubTypable) WithEnumDescription(string) {} - -func TestHasAnnotation(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - line string - want bool - }{ - {"swagger:model", "// swagger:model Foo", true}, - {"swagger:route", "swagger:route GET /foo tags fooOp", true}, - {"swagger:parameters", "// swagger:parameters addFoo", true}, - {"swagger:response", "swagger:response notFound", true}, - {"swagger:operation", "// swagger:operation POST /bar tags barOp", true}, - {"no annotation", "// this is just a comment", false}, - {"empty", "", false}, - {"partial", "swagger:", false}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - assert.EqualT(t, tc.want, HasAnnotation(tc.line)) - }) - } -} - -func TestIsAliasParam(t *testing.T) { - t.Parallel() - - tests := []struct { - in string - want bool - }{ - {"query", true}, - {"path", true}, - {"formData", true}, - {"body", false}, - {"header", false}, - {"", false}, - } - - for _, tc := range tests { - t.Run(tc.in, func(t *testing.T) { - assert.EqualT(t, tc.want, IsAliasParam(stubTypable{in: tc.in})) - }) - } -} - -func TestIsAllowedExtension(t *testing.T) { - t.Parallel() - - tests := []struct { - ext string - want bool - }{ - {"x-foo", true}, - {"X-bar", true}, - {"x-", true}, - {"y-foo", false}, - {"foo", false}, - {"", false}, - } - - for _, tc := range tests { - t.Run(tc.ext, func(t *testing.T) { - assert.EqualT(t, tc.want, IsAllowedExtension(tc.ext)) - }) - } -} - func TestExtractAnnotation(t *testing.T) { t.Parallel() @@ -138,105 +48,6 @@ func makeCommentGroup(lines ...string) *ast.CommentGroup { return cg } -func TestCommentMatcher(t *testing.T) { - t.Parallel() - - t.Run("AllOfMember", func(t *testing.T) { - assert.TrueT(t, AllOfMember(makeCommentGroup("// swagger:allOf"))) - assert.TrueT(t, AllOfMember(makeCommentGroup("// swagger:allOf MyParent"))) - assert.FalseT(t, AllOfMember(makeCommentGroup("// just a comment"))) - assert.FalseT(t, AllOfMember(nil)) - }) - - t.Run("FileParam", func(t *testing.T) { - assert.TrueT(t, FileParam(makeCommentGroup("// swagger:file"))) - assert.FalseT(t, FileParam(makeCommentGroup("// swagger:model"))) - assert.FalseT(t, FileParam(nil)) - }) - - t.Run("Ignored", func(t *testing.T) { - assert.TrueT(t, Ignored(makeCommentGroup("// swagger:ignore"))) - assert.TrueT(t, Ignored(makeCommentGroup("// swagger:ignore Foo"))) - assert.FalseT(t, Ignored(makeCommentGroup("// swagger:model"))) - assert.FalseT(t, Ignored(nil)) - }) - - t.Run("AliasParam", func(t *testing.T) { - assert.TrueT(t, AliasParam(makeCommentGroup("// swagger:alias"))) - assert.FalseT(t, AliasParam(makeCommentGroup("// swagger:model"))) - assert.FalseT(t, AliasParam(nil)) - }) -} - -func TestCommentSubMatcher(t *testing.T) { - t.Parallel() - - t.Run("StrfmtName", func(t *testing.T) { - name, ok := StrfmtName(makeCommentGroup("// swagger:strfmt date-time")) - require.TrueT(t, ok) - assert.EqualT(t, "date-time", name) - - _, ok = StrfmtName(makeCommentGroup("// swagger:model Foo")) - assert.FalseT(t, ok) - - _, ok = StrfmtName(nil) - assert.FalseT(t, ok) - }) - - t.Run("ParamLocation", func(t *testing.T) { - loc, ok := ParamLocation(makeCommentGroup("// In: query")) - require.TrueT(t, ok) - assert.EqualT(t, "query", loc) - - loc, ok = ParamLocation(makeCommentGroup("// in: body")) - require.TrueT(t, ok) - assert.EqualT(t, "body", loc) - - _, ok = ParamLocation(makeCommentGroup("// no location")) - assert.FalseT(t, ok) - }) - - t.Run("EnumName", func(t *testing.T) { - name, ok := EnumName(makeCommentGroup("// swagger:enum Status")) - require.TrueT(t, ok) - assert.EqualT(t, "Status", name) - - _, ok = EnumName(nil) - assert.FalseT(t, ok) - }) - - t.Run("AllOfName", func(t *testing.T) { - name, ok := AllOfName(makeCommentGroup("// swagger:allOf MyParent")) - require.TrueT(t, ok) - assert.EqualT(t, "MyParent", name) - - // bare annotation: no submatch → returns false - _, ok = AllOfName(makeCommentGroup("// swagger:allOf")) - assert.FalseT(t, ok) - }) - - t.Run("NameOverride", func(t *testing.T) { - name, ok := NameOverride(makeCommentGroup("// swagger:name MyName")) - require.TrueT(t, ok) - assert.EqualT(t, "MyName", name) - - _, ok = NameOverride(nil) - assert.FalseT(t, ok) - }) - - t.Run("DefaultName", func(t *testing.T) { - name, ok := DefaultName(makeCommentGroup("// swagger:default MyDefault")) - require.TrueT(t, ok) - assert.EqualT(t, "MyDefault", name) - }) - - t.Run("TypeName", func(t *testing.T) { - name, ok := TypeName(makeCommentGroup("// swagger:type string")) - require.TrueT(t, ok) - assert.EqualT(t, "string", name) - }) -} - func TestCommentBlankSubMatcher(t *testing.T) { t.Parallel() diff --git a/internal/parsers/parsed_path_content.go b/internal/parsers/parsed_path_content.go index 5d69591..54e6e28 100644 --- a/internal/parsers/parsed_path_content.go +++ b/internal/parsers/parsed_path_content.go @@ -28,6 +28,18 @@ 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. +func ensureCommentMarker(line string) string { + if strings.HasPrefix(line, "//") || strings.HasPrefix(line, "/*") { + return line + } + return "// " + line +} + func parsePathAnnotation(annotation *regexp.Regexp, lines []*ast.Comment) (cnt ParsedPathContent) { const routeTagsIndex = 3 // routeTagsIndex is the regex submatch index where route tags begin. var justMatched bool @@ -58,7 +70,18 @@ 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 - cc.Text = line + // 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/parsers.go b/internal/parsers/parsers.go deleted file mode 100644 index b7708a5..0000000 --- a/internal/parsers/parsers.go +++ /dev/null @@ -1,142 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "regexp" - "strconv" - - oaispec "github.com/go-openapi/spec" -) - -const ( - // kvParts is the number of parts when splitting key:value pairs. - kvParts = 2 -) - -// Many thanks go to https://github.com/yvasiyarov/swagger -// this is loosely based on that implementation but for swagger 2.0 - -type matchOnlyParam struct { - rx *regexp.Regexp -} - -func (mo *matchOnlyParam) Matches(line string) bool { - return mo.rx.MatchString(line) -} - -func (mo *matchOnlyParam) Parse(_ []string) error { - return nil -} - -type MatchParamIn struct { - *matchOnlyParam -} - -func NewMatchParamIn(_ *oaispec.Parameter) *MatchParamIn { - return NewMatchIn() -} - -// NewMatchIn returns a match-only tagger that claims `in: ` -// lines. The `in:` directive is extracted separately via -// parsers.ParamLocation; this tagger only prevents the line from -// being absorbed into the surrounding description by a SectionedParser. -func NewMatchIn() *MatchParamIn { - return &MatchParamIn{ - matchOnlyParam: &matchOnlyParam{ - rx: rxIn, - }, - } -} - -type MatchParamRequired struct { - *matchOnlyParam -} - -func NewMatchParamRequired(_ *oaispec.Parameter) *MatchParamRequired { - return &MatchParamRequired{ - matchOnlyParam: &matchOnlyParam{ - rx: rxRequired, - }, - } -} - -type SetDeprecatedOp struct { - tgt *oaispec.Operation -} - -func NewSetDeprecatedOp(operation *oaispec.Operation) *SetDeprecatedOp { - return &SetDeprecatedOp{ - tgt: operation, - } -} - -func (su *SetDeprecatedOp) Matches(line string) bool { - return rxDeprecated.MatchString(line) -} - -func (su *SetDeprecatedOp) Parse(lines []string) error { - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { - return nil - } - - matches := rxDeprecated.FindStringSubmatch(lines[0]) - if len(matches) > 1 && len(matches[1]) > 0 { - req, err := strconv.ParseBool(matches[1]) - if err != nil { - return err - } - su.tgt.Deprecated = req - } - - return nil -} - -type ConsumesDropEmptyParser struct { - *multilineDropEmptyParser -} - -func NewConsumesDropEmptyParser(set func([]string)) *ConsumesDropEmptyParser { - return &ConsumesDropEmptyParser{ - multilineDropEmptyParser: &multilineDropEmptyParser{ - set: set, - rx: rxConsumes, - }, - } -} - -type ProducesDropEmptyParser struct { - *multilineDropEmptyParser -} - -func NewProducesDropEmptyParser(set func([]string)) *ProducesDropEmptyParser { - return &ProducesDropEmptyParser{ - multilineDropEmptyParser: &multilineDropEmptyParser{ - set: set, - rx: rxProduces, - }, - } -} - -type multilineDropEmptyParser struct { - set func([]string) - rx *regexp.Regexp -} - -func newMultilineDropEmptyParser(rx *regexp.Regexp, set func([]string)) *multilineDropEmptyParser { - return &multilineDropEmptyParser{ - set: set, - rx: rx, - } -} - -func (m *multilineDropEmptyParser) Matches(line string) bool { - return m.rx.MatchString(line) -} - -func (m *multilineDropEmptyParser) Parse(lines []string) error { - m.set(removeEmptyLines(lines)) - - return nil -} diff --git a/internal/parsers/parsers_helpers.go b/internal/parsers/parsers_helpers.go deleted file mode 100644 index 3295c41..0000000 --- a/internal/parsers/parsers_helpers.go +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "strings" -) - -// a shared function that can be used to split given headers -// into a title and description. -func collectScannerTitleDescription(headers []string) (title, desc []string) { - hdrs := cleanupScannerLines(headers, rxUncommentHeaders) - - idx := -1 - for i, line := range hdrs { - if strings.TrimSpace(line) == "" { - idx = i - break - } - } - - if idx > -1 { - title = hdrs[:idx] - if len(title) > 0 { - title[0] = rxTitleStart.ReplaceAllString(title[0], "") - } - if len(hdrs) > idx+1 { - desc = hdrs[idx+1:] - } else { - desc = nil - } - return title, desc - } - - if len(hdrs) > 0 { - line := hdrs[0] - switch { - case rxPunctuationEnd.MatchString(line): - title = []string{line} - desc = hdrs[1:] - case rxTitleStart.MatchString(line): - title = []string{rxTitleStart.ReplaceAllString(line, "")} - desc = hdrs[1:] - default: - desc = hdrs - } - } - - return title, desc -} diff --git a/internal/parsers/parsers_helpers_test.go b/internal/parsers/parsers_helpers_test.go deleted file mode 100644 index bc5f5c7..0000000 --- a/internal/parsers/parsers_helpers_test.go +++ /dev/null @@ -1,73 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "testing" - - "github.com/go-openapi/testify/v2/assert" -) - -func TestCollectScannerTitleDescription(t *testing.T) { - t.Parallel() - - t.Run("title and description separated by blank", func(t *testing.T) { - headers := []string{ - "// This is the title.", - "//", - "// This is the description.", - "// More description.", - } - title, desc := collectScannerTitleDescription(headers) - assert.Equal(t, []string{"This is the title."}, title) - assert.Equal(t, []string{"This is the description.", "More description."}, desc) - }) - - t.Run("title only with punctuation", func(t *testing.T) { - headers := []string{ - "// A single title line.", - "// And some description.", - } - title, desc := collectScannerTitleDescription(headers) - assert.Equal(t, []string{"A single title line."}, title) - assert.Equal(t, []string{"And some description."}, desc) - }) - - t.Run("title with markdown header prefix", func(t *testing.T) { - headers := []string{ - "// # My Title", - "// Description here.", - } - title, desc := collectScannerTitleDescription(headers) - assert.Equal(t, []string{"My Title"}, title) - assert.Equal(t, []string{"Description here."}, desc) - }) - - t.Run("no title, all description", func(t *testing.T) { - headers := []string{ - "// no punctuation at end means no title", - "// more text", - } - title, desc := collectScannerTitleDescription(headers) - assert.Empty(t, title) - assert.Equal(t, []string{"no punctuation at end means no title", "more text"}, desc) - }) - - t.Run("empty", func(t *testing.T) { - title, desc := collectScannerTitleDescription(nil) - assert.Empty(t, title) - assert.Empty(t, desc) - }) - - t.Run("blank line only", func(t *testing.T) { - headers := []string{"//"} - title, desc := collectScannerTitleDescription(headers) - assert.Empty(t, title) - assert.Nil(t, desc) - }) - - // Note: the branch at line 31-32 (desc = nil when blank is last line) - // is unreachable because cleanupScannerLines always trims trailing blanks - // before collectScannerTitleDescription processes the slice. -} diff --git a/internal/parsers/parsers_test.go b/internal/parsers/parsers_test.go deleted file mode 100644 index 02841d4..0000000 --- a/internal/parsers/parsers_test.go +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" - - oaispec "github.com/go-openapi/spec" -) - -func TestMatchParamIn(t *testing.T) { - t.Parallel() - - mp := NewMatchParamIn(nil) - assert.TrueT(t, mp.Matches("In: query")) - assert.TrueT(t, mp.Matches("in: body")) - assert.TrueT(t, mp.Matches("in: path")) - assert.TrueT(t, mp.Matches("in: header")) - assert.TrueT(t, mp.Matches("in: formData")) - assert.FalseT(t, mp.Matches("in: cookie")) // not a valid swagger 2.0 location - assert.FalseT(t, mp.Matches("something else")) - - // Parse is a no-op - require.NoError(t, mp.Parse(nil)) -} - -func TestMatchParamRequired(t *testing.T) { - t.Parallel() - - mp := NewMatchParamRequired(nil) - assert.TrueT(t, mp.Matches("required: true")) - assert.TrueT(t, mp.Matches("Required: false")) - assert.FalseT(t, mp.Matches("something else")) - - // Parse is a no-op - require.NoError(t, mp.Parse(nil)) -} - -func TestSetDeprecatedOp(t *testing.T) { - t.Parallel() - - t.Run("true", func(t *testing.T) { - op := new(oaispec.Operation) - sd := NewSetDeprecatedOp(op) - assert.TrueT(t, sd.Matches("deprecated: true")) - require.NoError(t, sd.Parse([]string{"deprecated: true"})) - assert.TrueT(t, op.Deprecated) - }) - - t.Run("false", func(t *testing.T) { - op := new(oaispec.Operation) - sd := NewSetDeprecatedOp(op) - require.NoError(t, sd.Parse([]string{"deprecated: false"})) - assert.FalseT(t, op.Deprecated) - }) - - t.Run("empty", func(t *testing.T) { - op := new(oaispec.Operation) - sd := NewSetDeprecatedOp(op) - require.NoError(t, sd.Parse(nil)) - require.NoError(t, sd.Parse([]string{})) - require.NoError(t, sd.Parse([]string{""})) - assert.FalseT(t, op.Deprecated) - }) - - t.Run("no match", func(t *testing.T) { - sd := NewSetDeprecatedOp(new(oaispec.Operation)) - assert.FalseT(t, sd.Matches("something else")) - }) -} - -func TestConsumesDropEmptyParser(t *testing.T) { - t.Parallel() - - var got []string - cp := NewConsumesDropEmptyParser(func(v []string) { got = v }) - assert.TrueT(t, cp.Matches("consumes:")) - assert.TrueT(t, cp.Matches("Consumes:")) - assert.FalseT(t, cp.Matches("other")) - - require.NoError(t, cp.Parse([]string{"application/json", "", "application/xml", " "})) - assert.Equal(t, []string{"application/json", "application/xml"}, got) -} - -func TestProducesDropEmptyParser(t *testing.T) { - t.Parallel() - - var got []string - pp := NewProducesDropEmptyParser(func(v []string) { got = v }) - assert.TrueT(t, pp.Matches("produces:")) - assert.TrueT(t, pp.Matches("Produces:")) - - require.NoError(t, pp.Parse([]string{"text/plain", "", "text/html"})) - assert.Equal(t, []string{"text/plain", "text/html"}, got) -} diff --git a/internal/parsers/regexprs.go b/internal/parsers/regexprs.go index 1efc912..20dad76 100644 --- a/internal/parsers/regexprs.go +++ b/internal/parsers/regexprs.go @@ -3,82 +3,55 @@ package parsers -import ( - "fmt" - "regexp" -) +import "regexp" const ( // rxCommentPrefix matches the leading comment noise that precedes an // annotation keyword on a raw comment line: whitespace, tabs, slashes, - // asterisks, dashes, optional markdown table pipe, then any trailing - // spaces. Mirrors the prefix class used by rxUncommentHeaders so - // Matches() can still see through the `//` / `*` / ` * ` comment - // prefixes on raw lines. + // asterisks, dashes, optional markdown table pipe, then trailing + // spaces. // // Annotations must START the comment line — any prose before the - // swagger:xxx keyword disqualifies the line: an annotation buried in prose is ignored. - // - // Example: - // `swagger:strfmt` buried inside the sentence - // `// MAC is a text-marshalable ... swagger:strfmt so ...` is ignored and no longer captures - // "so" instead of the intended strfmt name. + // `swagger:xxx` keyword disqualifies the line, so an annotation + // buried in prose is ignored. // - // The sole documented-by-example exception is `swagger:route`, which is - // allowed to follow a single godoc identifier (see rxRoutePrefix). + // The sole documented exception is `swagger:route`, which is allowed + // to follow a single godoc identifier (see rxRoutePrefix). rxCommentPrefix = `^[\p{Zs}\t/\*-]*\|?\p{Zs}*` - // rxRoutePrefix extends rxCommentPrefix with an OPTIONAL single leading - // identifier. Godoc convention places the function/type name before the - // annotation body, e.g. `// DoBad swagger:route GET /path`. Without - // this allowance we would reject every `swagger:route` annotation - // attached to a documented handler. The allowance is intentionally - // narrow — ONE identifier, then whitespace — so multi-word prose - // prefixes still fail. + // rxRoutePrefix extends rxCommentPrefix with an OPTIONAL single + // leading identifier. Godoc convention places the function/type name + // before the annotation body, e.g. `// DoBad swagger:route GET + // /path`. The allowance is intentionally narrow — ONE identifier, + // then whitespace — so multi-word prose prefixes still fail. // - // This exception is reserved for `swagger:route`. All other annotations - // must start the comment line, per rxCommentPrefix. + // This exception is reserved for `swagger:route`. All other + // annotations must start the comment line, per rxCommentPrefix. rxRoutePrefix = rxCommentPrefix + `(?:\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]*\p{Zs}+)?` rxMethod = "(\\p{L}+)" rxPath = "((?:/[\\p{L}\\p{N}\\p{Pd}\\p{Pc}{}\\-\\.\\?_~%!$&'()*+,;=:@/]*)+/?)" rxOpTags = "(\\p{L}[\\p{L}\\p{N}\\p{Pd}\\.\\p{Pc}\\p{Zs}]+)" rxOpID = "((?:\\p{L}[\\p{L}\\p{N}\\p{Pd}\\p{Pc}]+)+)" - - rxMaximumFmt = rxCommentPrefix + "%s[Mm]ax(?:imum)?\\p{Zs}*:\\p{Zs}*([\\<=])?\\p{Zs}*([\\+-]?(?:\\p{N}+\\.)?\\p{N}+)(?:\\.)?$" - rxMinimumFmt = rxCommentPrefix + "%s[Mm]in(?:imum)?\\p{Zs}*:\\p{Zs}*([\\>=])?\\p{Zs}*([\\+-]?(?:\\p{N}+\\.)?\\p{N}+)(?:\\.)?$" - rxMultipleOfFmt = rxCommentPrefix + "%s[Mm]ultiple\\p{Zs}*[Oo]f\\p{Zs}*:\\p{Zs}*([\\+-]?(?:\\p{N}+\\.)?\\p{N}+)(?:\\.)?$" - - rxMaxLengthFmt = rxCommentPrefix + "%s[Mm]ax(?:imum)?(?:\\p{Zs}*[\\p{Pd}\\p{Pc}]?[Ll]en(?:gth)?)\\p{Zs}*:\\p{Zs}*(\\p{N}+)(?:\\.)?$" - rxMinLengthFmt = rxCommentPrefix + "%s[Mm]in(?:imum)?(?:\\p{Zs}*[\\p{Pd}\\p{Pc}]?[Ll]en(?:gth)?)\\p{Zs}*:\\p{Zs}*(\\p{N}+)(?:\\.)?$" - rxPatternFmt = rxCommentPrefix + "%s[Pp]attern\\p{Zs}*:\\p{Zs}*(.*)$" - rxCollectionFormatFmt = rxCommentPrefix + "%s[Cc]ollection(?:\\p{Zs}*[\\p{Pd}\\p{Pc}]?[Ff]ormat)\\p{Zs}*:\\p{Zs}*(.*)$" - rxEnumFmt = rxCommentPrefix + "%s[Ee]num\\p{Zs}*:\\p{Zs}*(.*)$" - rxDefaultFmt = rxCommentPrefix + "%s[Dd]efault\\p{Zs}*:\\p{Zs}*(.*)$" - rxExampleFmt = rxCommentPrefix + "%s[Ee]xample\\p{Zs}*:\\p{Zs}*(.*)$" - - rxMaxItemsFmt = rxCommentPrefix + "%s[Mm]ax(?:imum)?(?:\\p{Zs}*|[\\p{Pd}\\p{Pc}]|\\.)?[Ii]tems\\p{Zs}*:\\p{Zs}*(\\p{N}+)(?:\\.)?$" - rxMinItemsFmt = rxCommentPrefix + "%s[Mm]in(?:imum)?(?:\\p{Zs}*|[\\p{Pd}\\p{Pc}]|\\.)?[Ii]tems\\p{Zs}*:\\p{Zs}*(\\p{N}+)(?:\\.)?$" - rxUniqueFmt = rxCommentPrefix + "%s[Uu]nique\\p{Zs}*:\\p{Zs}*(true|false)(?:\\.)?$" - - rxItemsPrefixFmt = "(?:[Ii]tems[\\.\\p{Zs}]*){%d}" ) +//nolint:gochecknoglobals // compile-once regexes; read-only. var ( - rxSwaggerAnnotation = regexp.MustCompile(`(?:^|[\s/])swagger:([\p{L}\p{N}\p{Pd}\p{Pc}]+)`) - rxFileUpload = regexp.MustCompile(rxCommentPrefix + `swagger:file`) - rxStrFmt = regexp.MustCompile(rxCommentPrefix + `swagger:strfmt\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)(?:\.)?$`) - rxAlias = regexp.MustCompile(rxCommentPrefix + `swagger:alias`) - rxName = regexp.MustCompile(rxCommentPrefix + `swagger:name\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}\.]+)(?:\.)?$`) - rxAllOf = regexp.MustCompile(rxCommentPrefix + `swagger:allOf\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}\.]+)?(?:\.)?$`) + // rxSwaggerAnnotation matches `swagger:` anywhere on a comment + // line where it is preceded by whitespace, `/`, or start-of-line. + // Kept loose because it is the classification regex consumed by + // scanner.index.ExtractAnnotation; `swagger:route` is allowed to + // follow a godoc-style identifier per rxRoutePrefix. + // + // Do NOT use this regex as a block terminator — it triggers on + // mid-prose mentions and would truncate descriptions. + rxSwaggerAnnotation = regexp.MustCompile(`(?:^|[\s/])swagger:([\p{L}\p{N}\p{Pd}\p{Pc}]+)`) + rxModelOverride = regexp.MustCompile(rxCommentPrefix + `swagger:model\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)?(?:\.)?$`) rxResponseOverride = regexp.MustCompile(rxCommentPrefix + `swagger:response\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)?(?:\.)?$`) rxParametersOverride = regexp.MustCompile(rxCommentPrefix + `swagger:parameters\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}\p{Zs}]+)(?:\.)?$`) - rxEnum = regexp.MustCompile(rxCommentPrefix + `swagger:enum\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)(?:\.)?$`) - rxIgnoreOverride = regexp.MustCompile(rxCommentPrefix + `swagger:ignore\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)?(?:\.)?$`) - rxDefault = regexp.MustCompile(rxCommentPrefix + `swagger:default\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)(?:\.)?$`) - rxType = regexp.MustCompile(rxCommentPrefix + `swagger:type\p{Zs}*(\p{L}[\p{L}\p{N}\p{Pd}\p{Pc}]+)(?:\.)?$`) - rxRoute = regexp.MustCompile( + + rxRoute = regexp.MustCompile( rxRoutePrefix + "swagger:route\\p{Zs}*" + rxMethod + @@ -88,10 +61,7 @@ var ( rxOpTags + ")?\\p{Zs}+" + rxOpID + "\\p{Zs}*$") - rxBeginYAMLSpec = regexp.MustCompile(rxCommentPrefix + `---\p{Zs}*$`) - rxUncommentHeaders = regexp.MustCompile(`^[\p{Zs}\t/\*-]*\|?`) - rxUncommentYAML = regexp.MustCompile(`^[\p{Zs}\t]*/*`) - rxOperation = regexp.MustCompile( + rxOperation = regexp.MustCompile( rxCommentPrefix + "swagger:operation\\p{Zs}*" + rxMethod + @@ -101,37 +71,4 @@ var ( rxOpTags + ")?\\p{Zs}+" + rxOpID + "\\p{Zs}*$") - - rxIndent = regexp.MustCompile(`[\p{Zs}\t]*/*[\p{Zs}\t]*[^\p{Zs}\t]`) - rxNotIndent = regexp.MustCompile(`[^\p{Zs}\t]`) - rxPunctuationEnd = regexp.MustCompile(`\p{Po}$`) - rxTitleStart = regexp.MustCompile(`^[#]+\p{Zs}+`) - rxStripTitleComments = regexp.MustCompile(`^[^\p{L}]*[Pp]ackage\p{Zs}+[^\p{Zs}]+\p{Zs}*`) - rxAllowedExtensions = regexp.MustCompile(`^[Xx]-`) - - rxIn = regexp.MustCompile(rxCommentPrefix + `[Ii]n\p{Zs}*:\p{Zs}*(query|path|header|body|formData)(?:\.)?$`) - rxRequired = regexp.MustCompile(rxCommentPrefix + `[Rr]equired\p{Zs}*:\p{Zs}*(true|false)(?:\.)?$`) - rxDiscriminator = regexp.MustCompile(rxCommentPrefix + `[Dd]iscriminator\p{Zs}*:\p{Zs}*(true|false)(?:\.)?$`) - rxReadOnly = regexp.MustCompile(rxCommentPrefix + `[Rr]ead(?:\p{Zs}*|[\p{Pd}\p{Pc}])?[Oo]nly\p{Zs}*:\p{Zs}*(true|false)(?:\.)?$`) - rxConsumes = regexp.MustCompile(rxCommentPrefix + `[Cc]onsumes\p{Zs}*:`) - rxProduces = regexp.MustCompile(rxCommentPrefix + `[Pp]roduces\p{Zs}*:`) - rxSecuritySchemes = regexp.MustCompile(rxCommentPrefix + `[Ss]ecurity\p{Zs}*:`) - rxSecurity = regexp.MustCompile(rxCommentPrefix + `[Ss]ecurity\p{Zs}*[Dd]efinitions:`) - rxResponses = regexp.MustCompile(rxCommentPrefix + `[Rr]esponses\p{Zs}*:`) - rxParameters = regexp.MustCompile(rxCommentPrefix + `[Pp]arameters\p{Zs}*:`) - rxSchemes = regexp.MustCompile(rxCommentPrefix + `[Ss]chemes\p{Zs}*:\p{Zs}*((?:(?:https?|HTTPS?|wss?|WSS?)[\p{Zs},]*)+)(?:\.)?$`) - rxVersion = regexp.MustCompile(rxCommentPrefix + `[Vv]ersion\p{Zs}*:\p{Zs}*(.+)$`) - rxHost = regexp.MustCompile(rxCommentPrefix + `[Hh]ost\p{Zs}*:\p{Zs}*(.+)$`) - rxBasePath = regexp.MustCompile(rxCommentPrefix + `[Bb]ase\p{Zs}*-*[Pp]ath\p{Zs}*:\p{Zs}*` + rxPath + "(?:\\.)?$") - rxLicense = regexp.MustCompile(rxCommentPrefix + `[Ll]icense\p{Zs}*:\p{Zs}*(.+)$`) - rxContact = regexp.MustCompile(rxCommentPrefix + `[Cc]ontact\p{Zs}*-?(?:[Ii]info\p{Zs}*)?:\p{Zs}*(.+)$`) - rxTOS = regexp.MustCompile(rxCommentPrefix + `[Tt](:?erms)?\p{Zs}*-?[Oo]f?\p{Zs}*-?[Ss](?:ervice)?\p{Zs}*:`) - rxExtensions = regexp.MustCompile(rxCommentPrefix + `[Ee]xtensions\p{Zs}*:`) - rxInfoExtensions = regexp.MustCompile(rxCommentPrefix + `[In]nfo\p{Zs}*[Ee]xtensions:`) - rxDeprecated = regexp.MustCompile(rxCommentPrefix + `[Dd]eprecated\p{Zs}*:\p{Zs}*(true|false)(?:\.)?$`) - // currently unused: rxExample = regexp.MustCompile(`[Ex]ample\p{Zs}*:\p{Zs}*(.*)$`). ) - -func Rxf(rxp, ar string) *regexp.Regexp { - return regexp.MustCompile(fmt.Sprintf(rxp, ar)) -} diff --git a/internal/parsers/regexprs_test.go b/internal/parsers/regexprs_test.go index cb5a71c..92b882f 100644 --- a/internal/parsers/regexprs_test.go +++ b/internal/parsers/regexprs_test.go @@ -4,7 +4,6 @@ package parsers import ( - "fmt" "regexp" "strings" "testing" @@ -23,18 +22,6 @@ func TestOperationExpression(t *testing.T) { } func TestSchemaValueExtractors(t *testing.T) { - strfmts := []string{ - "// swagger:strfmt ", - "* swagger:strfmt ", - "* swagger:strfmt ", - " swagger:strfmt ", - "swagger:strfmt ", - "// swagger:strfmt ", - "* swagger:strfmt ", - "* swagger:strfmt ", - " swagger:strfmt ", - "swagger:strfmt ", - } models := []string{ "// swagger:model ", "* swagger:model ", @@ -48,19 +35,6 @@ func TestSchemaValueExtractors(t *testing.T) { "swagger:model ", } - allOf := []string{ - "// swagger:allOf ", - "* swagger:allOf ", - "* swagger:allOf ", - " swagger:allOf ", - "swagger:allOf ", - "// swagger:allOf ", - "* swagger:allOf ", - "* swagger:allOf ", - " swagger:allOf ", - "swagger:allOf ", - } - parameters := []string{ "// swagger:parameters ", "* swagger:parameters ", @@ -80,197 +54,17 @@ func TestSchemaValueExtractors(t *testing.T) { "date-time", "long-combo-1-with-combo-2-and-a-3rd-one-too", } - invalidParams := make([]string, 0, 9) - invalidParams = append(invalidParams, + invalidParams := []string{ "1-yada-3", "1-2-3", "-yada-3", "-2-3", "*blah", "blah*", - ) + } - verifySwaggerOneArgSwaggerTag(t, rxStrFmt, strfmts, validParams, append(invalidParams, "", " ", " ")) verifySwaggerOneArgSwaggerTag(t, rxModelOverride, models, append(validParams, "", " ", " "), invalidParams) - - verifySwaggerOneArgSwaggerTag(t, rxAllOf, allOf, append(validParams, "", " ", " "), invalidParams) - verifySwaggerMultiArgSwaggerTag(t, rxParametersOverride, parameters, validParams, invalidParams) - - verifyMinMax(t, Rxf(rxMinimumFmt, ""), "min", []string{"", ">", "="}) - verifyMinMax(t, Rxf(rxMinimumFmt, fmt.Sprintf(rxItemsPrefixFmt, 1)), "items.min", []string{"", ">", "="}) - verifyMinMax(t, Rxf(rxMaximumFmt, ""), "max", []string{"", "<", "="}) - verifyMinMax(t, Rxf(rxMaximumFmt, fmt.Sprintf(rxItemsPrefixFmt, 1)), "items.max", []string{"", "<", "="}) - verifyNumeric2Words(t, Rxf(rxMultipleOfFmt, ""), "multiple", "of") - verifyNumeric2Words(t, Rxf(rxMultipleOfFmt, fmt.Sprintf(rxItemsPrefixFmt, 1)), "items.multiple", "of") - - verifyIntegerMinMaxManyWords(t, Rxf(rxMinLengthFmt, ""), "min", []string{"len", "length"}) - // pattern - patPrefixes := cartesianJoin( - []string{"//", "*", ""}, - []string{"", " ", " ", " "}, - []string{"pattern", "Pattern"}, - []string{"", " ", " ", " "}, - []string{":"}, - []string{"", " ", " ", " "}, - ) - verifyRegexpArgs(t, Rxf(rxPatternFmt, ""), patPrefixes, []string{"^\\w+$", "[A-Za-z0-9-.]*"}, nil, 2, 1) - - verifyIntegerMinMaxManyWords(t, Rxf(rxMinItemsFmt, ""), "min", []string{"items"}) - verifyBoolean(t, Rxf(rxUniqueFmt, ""), []string{"unique"}, nil) - - verifyBoolean(t, rxReadOnly, []string{"read"}, []string{"only"}) - verifyBoolean(t, rxRequired, []string{"required"}, nil) -} - -func makeMinMax(lower string) (res []string) { - for _, a := range []string{"", "imum"} { - res = append(res, lower+a, strings.Title(lower)+a) //nolint:staticcheck // Title is deprecated, yet still useful here. The replacement is bit heavy for just this test - } - - return res -} - -// cartesianJoin returns all concatenations formed by picking one element from each slot. -func cartesianJoin(slots ...[]string) []string { - result := []string{""} - for _, slot := range slots { - next := make([]string, 0, len(result)*len(slot)) - for _, prefix := range result { - for _, s := range slot { - next = append(next, prefix+s) - } - } - result = next - } - - return result -} - -// titleCaseVariants returns each name paired with its Title-cased form. -func titleCaseVariants(names []string) []string { - result := make([]string, 0, len(names)*2) - for _, nm := range names { - result = append(result, nm, strings.Title(nm)) //nolint:staticcheck // Title is deprecated, yet still useful here - } - - return result -} - -// verifyRegexpArgs tests that matcher matches lines formed by each prefix+validArg -// (expecting expectedMatchLen matches with the value at matchIdx) and rejects prefix+invalidArg. -func verifyRegexpArgs(t *testing.T, matcher *regexp.Regexp, prefixes, validArgs, invalidArgs []string, expectedMatchLen, matchIdx int) int { - t.Helper() - cnt := 0 - for _, prefix := range prefixes { - for _, vv := range validArgs { - matches := matcher.FindStringSubmatch(prefix + vv) - assert.Len(t, matches, expectedMatchLen) - assert.EqualT(t, vv, matches[matchIdx]) - cnt++ - } - - for _, iv := range invalidArgs { - matches := matcher.FindStringSubmatch(prefix + iv) - assert.Empty(t, matches) - cnt++ - } - } - - return cnt -} - -func verifyBoolean(t *testing.T, matcher *regexp.Regexp, names, names2 []string) { - t.Helper() - - extraSpaces := []string{"", " ", " ", " "} - prefixes := []string{"//", "*", ""} - validArgs := []string{"true", "false"} - invalidArgs := []string{"TRUE", "FALSE", "t", "f", "1", "0", "True", "False", "true*", "false*"} - - nms := titleCaseVariants(names) - - var rnms []string - if len(names2) > 0 { - nms2 := titleCaseVariants(names2) - spacesAndDash := []string{"", " ", " ", " ", "-"} - for _, nm := range nms { - for _, sep := range spacesAndDash { - for _, nm2 := range nms2 { - rnms = append(rnms, nm+sep+nm2) - } - } - } - } else { - rnms = nms - } - - linePrefixes := cartesianJoin(prefixes, extraSpaces, rnms, extraSpaces, []string{":"}, extraSpaces) - cnt := verifyRegexpArgs(t, matcher, linePrefixes, validArgs, invalidArgs, 2, 1) - - var nm2 string - if len(names2) > 0 { - nm2 = " " + names2[0] - } - t.Logf("tested %d %s%s combinations\n", cnt, names[0], nm2) -} - -func verifyIntegerMinMaxManyWords(t *testing.T, matcher *regexp.Regexp, name1 string, words []string) { - t.Helper() - - extraSpaces := []string{"", " ", " ", " "} - prefixes := []string{"//", "*", ""} - validArgs := []string{"0", "1234"} - invalidArgs := []string{"1A3F", "2e10", "*12", "12*", "-1235", "0.0", "1234.0394", "-2948.484"} - - wordVariants := titleCaseVariants(words) - spacesAndDash := []string{"", " ", " ", " ", "-"} - linePrefixes := cartesianJoin(prefixes, extraSpaces, makeMinMax(name1), spacesAndDash, wordVariants, extraSpaces, []string{":"}, extraSpaces) - cnt := verifyRegexpArgs(t, matcher, linePrefixes, validArgs, invalidArgs, 2, 1) - - var nm2 string - if len(words) > 0 { - nm2 = " " + words[0] - } - t.Logf("tested %d %s%s combinations\n", cnt, name1, nm2) -} - -func verifyNumeric2Words(t *testing.T, matcher *regexp.Regexp, name1, name2 string) { - t.Helper() - - extraSpaces := []string{"", " ", " ", " "} - prefixes := []string{"//", "*", ""} - validArgs := []string{"0", "1234", "-1235", "0.0", "1234.0394", "-2948.484"} - invalidArgs := []string{"1A3F", "2e10", "*12", "12*"} - - titleName1 := strings.Title(name1) //nolint:staticcheck // Title is deprecated, yet still useful here - titleName2 := strings.Title(name2) //nolint:staticcheck // Title is deprecated, yet still useful here - nameVariants := make([]string, 0, 4*len(extraSpaces)) - for _, es := range extraSpaces { - nameVariants = append(nameVariants, - name1+es+name2, - titleName1+es+titleName2, - titleName1+es+name2, - name1+es+titleName2, - ) - } - - linePrefixes := cartesianJoin(prefixes, extraSpaces, nameVariants, extraSpaces, []string{":"}, extraSpaces) - cnt := verifyRegexpArgs(t, matcher, linePrefixes, validArgs, invalidArgs, 2, 1) - t.Logf("tested %d %s %s combinations\n", cnt, name1, name2) -} - -func verifyMinMax(t *testing.T, matcher *regexp.Regexp, name string, operators []string) { - t.Helper() - - extraSpaces := []string{"", " ", " ", " "} - prefixes := []string{"//", "*", ""} - validArgs := []string{"0", "1234", "-1235", "0.0", "1234.0394", "-2948.484"} - invalidArgs := []string{"1A3F", "2e10", "*12", "12*"} - - linePrefixes := cartesianJoin(prefixes, extraSpaces, makeMinMax(name), extraSpaces, []string{":"}, extraSpaces, operators, extraSpaces) - cnt := verifyRegexpArgs(t, matcher, linePrefixes, validArgs, invalidArgs, 3, 2) - t.Logf("tested %d %s combinations\n", cnt, name) } func verifySwaggerOneArgSwaggerTag(t *testing.T, matcher *regexp.Regexp, prefixes, validParams, invalidParams []string) { diff --git a/internal/parsers/sectioned_parser.go b/internal/parsers/sectioned_parser.go deleted file mode 100644 index c87fb1d..0000000 --- a/internal/parsers/sectioned_parser.go +++ /dev/null @@ -1,289 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "go/ast" - "strings" - - "github.com/go-openapi/codescan/internal/ifaces" -) - -// SectionedParserOption configures a [SectionedParser] via [NewSectionedParser]. -type SectionedParserOption func(*SectionedParser) - -// WithSetTitle provides a callback that receives the extracted title lines -// after parsing completes. If no title callback is set, the parser does not -// attempt to separate the title from the description. -func WithSetTitle(setTitle func([]string)) SectionedParserOption { - return func(p *SectionedParser) { - p.setTitle = setTitle - } -} - -// WithSetDescription provides a callback that receives the extracted -// description lines after parsing completes. -func WithSetDescription(setDescription func([]string)) SectionedParserOption { - return func(p *SectionedParser) { - p.setDescription = setDescription - } -} - -// WithTaggers registers the [TagParser] instances that this SectionedParser -// will try to match against each line after the header section ends. -func WithTaggers(taggers ...TagParser) SectionedParserOption { - return func(p *SectionedParser) { - p.taggers = taggers - } -} - -// SectionedParser is the core comment-block parser for go-swagger annotations. -// It processes an [ast.CommentGroup] and splits its content into three sections: -// -// 1. Header — free-form text at the top of the comment block, later split -// into a title and description. -// 2. Tags — structured key:value lines (e.g. "minimum: 10", "consumes:", -// "schemes: http, https") recognized by registered [TagParser] instances. -// 3. Annotation — an optional swagger:* annotation line (e.g. "swagger:model -// Foo") handled by a dedicated [ifaces.ValueParser]. -// -// # Parsing algorithm -// -// Parse walks each line of the comment block in order. For every line: -// -// 1. If the line contains a swagger:* annotation: -// - "swagger:ignore" → mark as ignored, stop parsing. -// - If an annotation parser is registered and matches → delegate to it. -// - Otherwise → stop parsing (the annotation belongs to a different parser). -// -// 2. If any registered [TagParser] matches the line: -// - For a single-line tagger: collect the line, then reset the current -// tagger so the next line can match a different tag. -// - For a multi-line tagger: the matching (header) line is consumed but NOT -// collected; all subsequent lines are collected into that tagger until a -// different tagger matches or the block ends. -// -// 3. Otherwise, if no tag has been seen yet, the line is appended to the -// header (free-form text). -// -// After the line walk completes, three things happen: -// -// 1. The header is split into title + description (see [collectScannerTitleDescription]). -// 2. For each matched tagger, its collected lines are cleaned up (comment -// prefixes stripped, unless SkipCleanUp is set) and passed to the -// tagger's Parse method, which writes the extracted value into the target -// spec object. -// 3. Title and description callbacks are invoked. -// -// # Example: Swagger meta block -// -// Given the comment block on a package doc.go: -// -// // Petstore API. -// // -// // The purpose of this application is to provide an API for pets. -// // -// // Schemes: http, https -// // Host: petstore.example.com -// // BasePath: /v2 -// // Version: 1.0.0 -// // License: MIT http://opensource.org/licenses/MIT -// // Contact: John Doe http://john.example.com -// // -// // Consumes: -// // - application/json -// // - application/xml -// // -// // swagger:meta -// -// The SectionedParser (configured by [NewMetaParser]) will: -// -// - Collect "Petstore API." as the title, and the next paragraph as the -// description (header section, lines 1-3). -// - Match "Schemes: http, https" via the single-line "Schemes" tagger. -// - Match "Host: ...", "BasePath: ...", etc. via their respective single-line taggers. -// - Match "Consumes:" via the multi-line "Consumes" tagger, collecting -// "- application/json" and "- application/xml" as its body. -// - Stop at "swagger:meta" (an annotation that doesn't match any registered -// annotation parser, so it terminates the block). -type SectionedParser struct { - header []string - matched map[string]TagParser - annotation ifaces.ValueParser - - seenTag bool - skipHeader bool - setTitle func([]string) - setDescription func([]string) - workedOutTitle bool - taggers []TagParser - currentTagger *TagParser - title []string - ignored bool -} - -// NewSectionedParser creates a SectionedParser configured by the given options. -// -// At minimum, callers should provide [WithSetTitle] and [WithTaggers]: -// -// sp := NewSectionedParser( -// WithSetTitle(func(lines []string) { op.Summary = JoinDropLast(lines) }), -// WithSetDescription(func(lines []string) { op.Description = JoinDropLast(lines) }), -// WithTaggers( -// NewSingleLineTagParser("maximum", NewSetMaximum(builder)), -// NewMultiLineTagParser("consumes", NewConsumesDropEmptyParser(setter), false), -// ), -// ) -func NewSectionedParser(opts ...SectionedParserOption) *SectionedParser { - var p SectionedParser - - for _, apply := range opts { - apply(&p) - } - - return &p -} - -// Title returns the title lines extracted from the header. The title is -// separated from the description by the first blank line, or inferred from -// punctuation and markdown heading prefixes when there is no blank line. -// -// Title triggers lazy title/description splitting on first call. -func (st *SectionedParser) Title() []string { - st.collectTitleDescription() - return st.title -} - -// Description returns the description lines extracted from the header (everything -// after the title). Like [SectionedParser.Title], it triggers lazy splitting on first call. -func (st *SectionedParser) Description() []string { - st.collectTitleDescription() - return st.header -} - -// Ignored reports whether a "swagger:ignore" annotation was encountered. -func (st *SectionedParser) Ignored() bool { - return st.ignored -} - -// Parse processes an [ast.CommentGroup] through the sectioned parsing algorithm -// described in the type documentation. Returns an error if any matched tagger's -// Parse method fails. -func (st *SectionedParser) Parse(doc *ast.CommentGroup) error { - if doc == nil { - return nil - } - -COMMENTS: - for _, c := range doc.List { - for line := range strings.SplitSeq(c.Text, "\n") { - if st.parseLine(line) { - break COMMENTS - } - } - } - - if st.setTitle != nil { - st.setTitle(st.Title()) - } - - if st.setDescription != nil { - st.setDescription(st.Description()) - } - - for _, mt := range st.matched { - if !mt.SkipCleanUp { - mt.Lines = cleanupScannerLines(mt.Lines, rxUncommentHeaders) - } - if err := mt.Parse(mt.Lines); err != nil { - return err - } - } - - return nil -} - -// parseLine processes a single comment line. It returns true when the -// caller should stop processing further comments (a swagger: annotation -// that doesn't belong to this parser, or swagger:ignore). -func (st *SectionedParser) parseLine(line string) (stop bool) { - // Step 1: check for swagger:* annotations. - if rxSwaggerAnnotation.MatchString(line) { - if rxIgnoreOverride.MatchString(line) { - st.ignored = true - return true // an explicit ignore terminates this parser - } - if st.annotation == nil || !st.annotation.Matches(line) { - return true // a new swagger: annotation terminates this parser - } - - _ = st.annotation.Parse([]string{line}) - if len(st.header) > 0 { - st.seenTag = true - } - return false - } - - // Step 2: try to match a registered tagger. - var matched bool - for _, tg := range st.taggers { - tagger := tg - if tagger.Matches(line) { - st.seenTag = true - st.currentTagger = &tagger - matched = true - break - } - } - - // Step 3: no tagger active → accumulate as header (free-form text). - if st.currentTagger == nil { - if !st.skipHeader && !st.seenTag { - st.header = append(st.header, line) - } - return false - } - - // For multi-line taggers, the header line (the one that matched) is - // consumed but not collected — only subsequent lines are body. - if st.currentTagger.MultiLine && matched { - return false - } - - // Collect the line into the matched tagger's line buffer. - ts, ok := st.matched[st.currentTagger.Name] - if !ok { - ts = *st.currentTagger - } - ts.Lines = append(ts.Lines, line) - if st.matched == nil { - st.matched = make(map[string]TagParser) - } - st.matched[st.currentTagger.Name] = ts - - // Single-line taggers reset immediately; multi-line taggers stay active. - if !st.currentTagger.MultiLine { - st.currentTagger = nil - } - return false -} - -// collectTitleDescription lazily splits the accumulated header lines into -// title and description. The split is performed at most once. -// -// When setTitle is nil (no title callback registered), the header is only -// cleaned up (comment prefixes removed) but not split — everything stays -// in the description. -func (st *SectionedParser) collectTitleDescription() { - if st.workedOutTitle { - return - } - if st.setTitle == nil { - st.header = cleanupScannerLines(st.header, rxUncommentHeaders) - return - } - - st.workedOutTitle = true - st.title, st.header = collectScannerTitleDescription(st.header) -} diff --git a/internal/parsers/sectioned_parser_go119_test.go b/internal/parsers/sectioned_parser_go119_test.go deleted file mode 100644 index 729e047..0000000 --- a/internal/parsers/sectioned_parser_go119_test.go +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" -) - -func TestSectionedParser_TitleDescriptionGo119(t *testing.T) { - text := `# This has a title that starts with a hash tag - -The punctuation here does indeed matter. But it won't for go. -` - - text2 := `This has a title without whitespace. - -The punctuation here does indeed matter. But it won't for go. - -# There is an inline header here that doesn't count for finding a title - -` - - var err error - - st := &SectionedParser{} - st.setTitle = func(_ []string) {} - err = st.Parse(ascg(text)) - require.NoError(t, err) - - assert.Equal(t, []string{"This has a title that starts with a hash tag"}, st.Title()) - assert.Equal(t, []string{"The punctuation here does indeed matter. But it won't for go."}, st.Description()) - - st = &SectionedParser{} - st.setTitle = func(_ []string) {} - err = st.Parse(ascg(text2)) - require.NoError(t, err) - - assert.Equal(t, []string{"This has a title without whitespace."}, st.Title()) - assert.Equal(t, []string{ - "The punctuation here does indeed matter. But it won't for go.", "", - "# There is an inline header here that doesn't count for finding a title", - }, st.Description()) -} diff --git a/internal/parsers/sectioned_parser_test.go b/internal/parsers/sectioned_parser_test.go deleted file mode 100644 index d28d82c..0000000 --- a/internal/parsers/sectioned_parser_test.go +++ /dev/null @@ -1,382 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import ( - "errors" - "fmt" - "go/ast" - "regexp" - "strings" - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" - - "github.com/go-openapi/spec" -) - -// only used within this group of tests but never used within actual code base. -func newSchemaAnnotationParser(goName string) *schemaAnnotationParser { - return &schemaAnnotationParser{GoName: goName, rx: rxModelOverride} -} - -type schemaAnnotationParser struct { - GoName string - Name string - rx *regexp.Regexp -} - -func (sap *schemaAnnotationParser) Matches(line string) bool { - return sap.rx.MatchString(line) -} - -func (sap *schemaAnnotationParser) Parse(lines []string) error { - if sap.Name != "" { - return nil - } - - if len(lines) > 0 { - for _, line := range lines { - matches := sap.rx.FindStringSubmatch(line) - if len(matches) > 1 && len(matches[1]) > 0 { - sap.Name = matches[1] - return nil - } - } - } - return nil -} - -func TestSectionedParser_TitleDescription(t *testing.T) { - const ( - text = `This has a title, separated by a whitespace line - -In this example the punctuation for the title should not matter for swagger. -For go it will still make a difference though. -` - text2 = `This has a title without whitespace. -The punctuation here does indeed matter. But it won't for go. -` - - text3 = `This has a title, and markdown in the description - -See how markdown works now, we can have lists: - -+ first item -+ second item -+ third item - -[Links works too](http://localhost) -` - - text4 = `This has whitespace sensitive markdown in the description - -|+ first item -| + nested item -| + also nested item - -Sample code block: - -| fmt.Println("Hello World!") - -` - ) - - var err error - - st := &SectionedParser{} - st.setTitle = func(_ []string) {} - err = st.Parse(ascg(text)) - require.NoError(t, err) - - assert.Equal(t, []string{"This has a title, separated by a whitespace line"}, st.Title()) - assert.Equal(t, []string{"In this example the punctuation for the title should not matter for swagger.", "For go it will still make a difference though."}, st.Description()) - - st = &SectionedParser{} - st.setTitle = func(_ []string) {} - err = st.Parse(ascg(text2)) - require.NoError(t, err) - - assert.Equal(t, []string{"This has a title without whitespace."}, st.Title()) - assert.Equal(t, []string{"The punctuation here does indeed matter. But it won't for go."}, st.Description()) - - st = &SectionedParser{} - st.setTitle = func(_ []string) {} - err = st.Parse(ascg(text3)) - require.NoError(t, err) - - assert.Equal(t, []string{"This has a title, and markdown in the description"}, st.Title()) - assert.Equal(t, []string{ - "See how markdown works now, we can have lists:", "", - "+ first item", "+ second item", "+ third item", "", - "[Links works too](http://localhost)", - }, st.Description()) - - st = &SectionedParser{} - st.setTitle = func(_ []string) {} - err = st.Parse(ascg(text4)) - require.NoError(t, err) - - assert.Equal(t, []string{"This has whitespace sensitive markdown in the description"}, st.Title()) - assert.Equal(t, []string{"+ first item", " + nested item", " + also nested item", "", "Sample code block:", "", " fmt.Println(\"Hello World!\")"}, st.Description()) -} - -type schemaValidations struct { - current *spec.Schema -} - -func (sv schemaValidations) SetMaximum(val float64, exclusive bool) { - sv.current.Maximum = &val - sv.current.ExclusiveMaximum = exclusive -} - -func (sv schemaValidations) SetMinimum(val float64, exclusive bool) { - sv.current.Minimum = &val - sv.current.ExclusiveMinimum = exclusive -} -func (sv schemaValidations) SetMultipleOf(val float64) { sv.current.MultipleOf = &val } -func (sv schemaValidations) SetMinItems(val int64) { sv.current.MinItems = &val } -func (sv schemaValidations) SetMaxItems(val int64) { sv.current.MaxItems = &val } -func (sv schemaValidations) SetMinLength(val int64) { sv.current.MinLength = &val } -func (sv schemaValidations) SetMaxLength(val int64) { sv.current.MaxLength = &val } -func (sv schemaValidations) SetPattern(val string) { sv.current.Pattern = val } -func (sv schemaValidations) SetUnique(val bool) { sv.current.UniqueItems = val } -func (sv schemaValidations) SetDefault(val any) { sv.current.Default = val } -func (sv schemaValidations) SetExample(val any) { sv.current.Example = val } -func (sv schemaValidations) SetEnum(val string) { - var typ string - if len(sv.current.Type) > 0 { - typ = sv.current.Type[0] - } - sv.current.Enum = ParseEnum(val, &spec.SimpleSchema{Format: sv.current.Format, Type: typ}) -} - -func dummybuilder() schemaValidations { - return schemaValidations{new(spec.Schema)} -} - -func TestSectionedParser_TagsDescription(t *testing.T) { - const ( - block = `This has a title without whitespace. -The punctuation here does indeed matter. But it won't for go. -minimum: 10 -maximum: 20 -` - block2 = `This has a title without whitespace. -The punctuation here does indeed matter. But it won't for go. - -minimum: 10 -maximum: 20 -` - ) - - var err error - - st := &SectionedParser{} - st.setTitle = func(_ []string) {} - st.taggers = []TagParser{ - {"Maximum", false, false, nil, &SetMaximum{builder: dummybuilder(), rx: regexp.MustCompile(fmt.Sprintf(rxMaximumFmt, ""))}}, - {"Minimum", false, false, nil, &SetMinimum{builder: dummybuilder(), rx: regexp.MustCompile(fmt.Sprintf(rxMinimumFmt, ""))}}, - {"MultipleOf", false, false, nil, &SetMultipleOf{builder: dummybuilder(), rx: regexp.MustCompile(fmt.Sprintf(rxMultipleOfFmt, ""))}}, - } - - err = st.Parse(ascg(block)) - require.NoError(t, err) - assert.Equal(t, []string{"This has a title without whitespace."}, st.Title()) - assert.Equal(t, []string{"The punctuation here does indeed matter. But it won't for go."}, st.Description()) - assert.Len(t, st.matched, 2) - _, ok := st.matched["Maximum"] - assert.TrueT(t, ok) - _, ok = st.matched["Minimum"] - assert.TrueT(t, ok) - - st = &SectionedParser{} - st.setTitle = func(_ []string) {} - st.taggers = []TagParser{ - {"Maximum", false, false, nil, &SetMaximum{builder: dummybuilder(), rx: regexp.MustCompile(fmt.Sprintf(rxMaximumFmt, ""))}}, - {"Minimum", false, false, nil, &SetMinimum{builder: dummybuilder(), rx: regexp.MustCompile(fmt.Sprintf(rxMinimumFmt, ""))}}, - {"MultipleOf", false, false, nil, &SetMultipleOf{builder: dummybuilder(), rx: regexp.MustCompile(fmt.Sprintf(rxMultipleOfFmt, ""))}}, - } - - err = st.Parse(ascg(block2)) - require.NoError(t, err) - assert.Equal(t, []string{"This has a title without whitespace."}, st.Title()) - assert.Equal(t, []string{"The punctuation here does indeed matter. But it won't for go."}, st.Description()) - assert.Len(t, st.matched, 2) - _, ok = st.matched["Maximum"] - assert.TrueT(t, ok) - _, ok = st.matched["Minimum"] - assert.TrueT(t, ok) -} - -func TestSectionedParser_Empty(t *testing.T) { - const block = `swagger:response someResponse` - - var err error - - st := &SectionedParser{} - st.setTitle = func(_ []string) {} - ap := newSchemaAnnotationParser("SomeResponse") - ap.rx = rxResponseOverride - st.annotation = ap - - err = st.Parse(ascg(block)) - require.NoError(t, err) - assert.Empty(t, st.Title()) - assert.Empty(t, st.Description()) - assert.Empty(t, st.taggers) - assert.EqualT(t, "SomeResponse", ap.GoName) - assert.EqualT(t, "someResponse", ap.Name) -} - -func testSectionedParserWithBlock( - t *testing.T, - block string, - expectedMatchedCount int, - maximumExpected bool, -) { - t.Helper() - - st := &SectionedParser{} - st.setTitle = func(_ []string) {} - ap := newSchemaAnnotationParser("SomeModel") - st.annotation = ap - st.taggers = []TagParser{ - {"Maximum", false, false, nil, &SetMaximum{builder: dummybuilder(), rx: regexp.MustCompile(fmt.Sprintf(rxMaximumFmt, ""))}}, - {"Minimum", false, false, nil, &SetMinimum{builder: dummybuilder(), rx: regexp.MustCompile(fmt.Sprintf(rxMinimumFmt, ""))}}, - {"MultipleOf", false, false, nil, &SetMultipleOf{builder: dummybuilder(), rx: regexp.MustCompile(fmt.Sprintf(rxMultipleOfFmt, ""))}}, - } - - err := st.Parse(ascg(block)) - require.NoError(t, err) - assert.Equal(t, []string{"This has a title without whitespace."}, st.Title()) - assert.Equal(t, []string{"The punctuation here does indeed matter. But it won't for go."}, st.Description()) - assert.Len(t, st.matched, expectedMatchedCount) - _, ok := st.matched["Maximum"] - assert.EqualT(t, maximumExpected, ok) - _, ok = st.matched["Minimum"] - assert.TrueT(t, ok) - assert.EqualT(t, "SomeModel", ap.GoName) - assert.EqualT(t, "someModel", ap.Name) -} - -func TestSectionedParser_SkipSectionAnnotation(t *testing.T) { - const block = `swagger:model someModel - -This has a title without whitespace. -The punctuation here does indeed matter. But it won't for go. - -minimum: 10 -maximum: 20 -` - testSectionedParserWithBlock(t, block, 2, true) -} - -func TestSectionedParser_TerminateOnNewAnnotation(t *testing.T) { - const block = `swagger:model someModel - -This has a title without whitespace. -The punctuation here does indeed matter. But it won't for go. - -minimum: 10 -swagger:meta -maximum: 20 -` - testSectionedParserWithBlock(t, block, 1, false) -} - -func TestSectionedParser_NilDoc(t *testing.T) { - st := NewSectionedParser( - WithSetTitle(func(_ []string) {}), - WithSetDescription(func(_ []string) {}), - ) - require.NoError(t, st.Parse(nil)) - assert.Empty(t, st.Title()) - assert.Empty(t, st.Description()) - assert.FalseT(t, st.Ignored()) -} - -func TestSectionedParser_IgnoredAnnotation(t *testing.T) { - const block = `swagger:ignore SomeType - -This should not matter. -` - st := NewSectionedParser( - WithSetTitle(func(_ []string) {}), - ) - err := st.Parse(ascg(block)) - require.NoError(t, err) - assert.TrueT(t, st.Ignored()) -} - -func TestSectionedParser_WithoutSetTitle(t *testing.T) { - // When setTitle is nil, collectTitleDescription cleans up headers - // but does not split title from description. - const block = `Just a description line. -Another line. -` - st := &SectionedParser{} - err := st.Parse(ascg(block)) - require.NoError(t, err) - assert.Nil(t, st.Title()) - assert.Equal(t, []string{"Just a description line.", "Another line."}, st.Description()) -} - -func TestSectionedParser_TagParseError(t *testing.T) { - // When a matched tagger's Parse returns an error, SectionedParser.Parse propagates it. - errParser := &failingParser{} - st := NewSectionedParser( - WithSetTitle(func(_ []string) {}), - WithTaggers( - NewSingleLineTagParser("Failing", errParser), - ), - ) - - const block = `Title. - -minimum: 10 -` - err := st.Parse(ascg(block)) - require.Error(t, err) - assert.ErrorIs(t, err, errForced) -} - -type failingParser struct{} - -var errForced = errors.New("forced error") - -func (f *failingParser) Matches(line string) bool { return rxMinimum.MatchString(line) } -func (f *failingParser) Parse(_ []string) error { return errForced } - -func TestSectionedParser_AnnotationMatchWithHeader(t *testing.T) { - // When the annotation matches and headers have been collected, - // seenTag is set to true — further non-tag lines are skipped. - const block = `swagger:model someModel - -Title. -Description. - -swagger:model anotherModel -This line after a re-match should still be part of the description. -` - ap := newSchemaAnnotationParser("SomeModel") - st := &SectionedParser{} - st.setTitle = func(_ []string) {} - st.annotation = ap - - err := st.Parse(ascg(block)) - require.NoError(t, err) - assert.EqualT(t, "someModel", ap.Name) -} - -func ascg(txt string) *ast.CommentGroup { - var cg ast.CommentGroup - for line := range strings.SplitSeq(txt, "\n") { - var cmt ast.Comment - cmt.Text = "// " + line - cg.List = append(cg.List, &cmt) - } - return &cg -} diff --git a/internal/parsers/tag_parsers.go b/internal/parsers/tag_parsers.go deleted file mode 100644 index 16d8ad7..0000000 --- a/internal/parsers/tag_parsers.go +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package parsers - -import "github.com/go-openapi/codescan/internal/ifaces" - -// TagParser pairs a named tag with a [ifaces.ValueParser] that recognizes and -// extracts its value from comment lines. -// -// A TagParser operates in one of two modes: -// -// - Single-line: the tag matches exactly one line (e.g. "maximum: 10"). -// The [SectionedParser] resets its current tagger after every single-line -// match, so the next line is free to match a different tagger. -// -// - Multi-line: the tag's first matching line is a header (e.g. "consumes:") -// and all subsequent lines are collected as its body until a different -// tagger matches or the comment block ends. The header line itself is NOT -// included in Lines — only the body lines that follow it. -// -// SkipCleanUp controls whether the [SectionedParser] strips comment prefixes -// (// , *, etc.) from the collected Lines before calling Parse. YAML-based -// taggers set this to true because they need the original indentation intact. -// -// Lines is populated by the [SectionedParser] during its scan; after the scan -// completes, Parse is called with those lines to extract the value. -type TagParser struct { - Name string - MultiLine bool - SkipCleanUp bool - Lines []string - Parser ifaces.ValueParser -} - -// NewMultiLineTagParser creates a TagParser that collects all lines following -// the matching header until a different tag or annotation is encountered. -// -// Example usage (from [NewMetaParser]): -// -// NewMultiLineTagParser("TOS", -// newMultilineDropEmptyParser(rxTOS, metaTOSSetter(info)), -// false, // clean up comment prefixes before parsing -// ) -// -// This creates a tagger that recognizes "Terms of Service:" and collects every -// subsequent line into the TOS field, stripping comment prefixes. -func NewMultiLineTagParser(name string, parser ifaces.ValueParser, skipCleanUp bool) TagParser { - return TagParser{ - Name: name, - MultiLine: true, - SkipCleanUp: skipCleanUp, - Parser: parser, - } -} - -// NewSingleLineTagParser creates a TagParser that matches and parses exactly -// one line. After the match, the [SectionedParser] resets its current tagger -// so subsequent lines can match other taggers. -// -// Example usage (from [NewMetaParser]): -// -// NewSingleLineTagParser("Version", -// &setMetaSingle{Spec: swspec, Rx: rxVersion, Set: setInfoVersion}, -// ) -// -// This creates a tagger that recognizes "Version: 1.0.0" and writes the -// captured value into swspec.Info.Version. -func NewSingleLineTagParser(name string, parser ifaces.ValueParser) TagParser { - return TagParser{ - Name: name, - MultiLine: false, - SkipCleanUp: false, - Parser: parser, - } -} - -// Matches delegates to the underlying Parser. -func (st *TagParser) Matches(line string) bool { - return st.Parser.Matches(line) -} - -// Parse delegates to the underlying Parser. -func (st *TagParser) Parse(lines []string) error { - return st.Parser.Parse(lines) -} diff --git a/internal/scanner/README.md b/internal/scanner/README.md new file mode 100644 index 0000000..2958111 --- /dev/null +++ b/internal/scanner/README.md @@ -0,0 +1,189 @@ +# scanner — maintainer notes + +This document is the long-form companion to the scanner package code. +The source files keep godoc concise; complex invariants, design +trade-offs, and known quirks live here. + +The `scanner` package owns package loading and entity discovery. It +turns a set of Go package patterns into a `ScanCtx` that exposes the +classified per-decl inventory (meta, routes, operations, models, +parameters, responses) consumed by the builder layer. + +--- + +## Table of contents + +- [§options](#options) — `Options.DescWithRef` shape and rationale +- [§descwithref](#descwithref) — the description-only-decoration + $ref shape and why it has a flag +- [§diagnostics](#diagnostics) — `OnDiagnostic` contract and + experimental-API caveat +- [§model-lookup](#model-lookup) — `GetModel` vs `FindModel` — + pure read vs implicit registration +- [§classifier](#classifier) — `detectNodes` bitmask semantics and + struct-annotation exclusivity +- [§quirks-open](#quirks-open) — deferred follow-ups + +--- + +## §options — `Options` overview + +`Options` is the externally-visible configuration struct. It is +re-exported from the package root as `codescan.Options`. The default +zero value is a valid configuration: every flag defaults to false and +every slice/map defaults to nil. + +Most fields are simple toggles (scope inclusion, debug, vendor +extension suppression). Two fields carry non-trivial semantics that +warrant the inline godoc and the deeper notes below: + +- `DescWithRef` — controls the `$ref` shape used when a struct field + resolves to a named type and its only decoration is a description. + See [§descwithref](#descwithref). +- `OnDiagnostic` — diagnostic callback hook. See + [§diagnostics](#diagnostics). + +## §descwithref — description-only-decoration $ref shape + +When a struct field's Go type resolves to a named type (so the spec +emits a `$ref` to its definition) and its only field-level +decoration is a description (no validations, no user-authored +vendor extensions), the spec has two possible shapes: + +1. **Bare $ref** — `{$ref: ...}`. The field's description is + dropped. This is the conservative default when `DescWithRef` is + false. +2. **Single-arm allOf** — `{description: "...", allOf: [{$ref}]}`. + The description is preserved by wrapping the `$ref` in a + single-arm `allOf` compound. This is JSON-Schema-draft-4 correct + for sibling description. + +`DescWithRef=true` opts into the second shape. The default is false +because the bare-`$ref` shape interoperates more broadly with +Swagger 2.0 tooling that does not implement the `allOf` compound. + +When the field also carries validation overrides (pattern, enum, +example, etc.) or user-authored vendor extensions, the `allOf` +compound is mandatory regardless of `DescWithRef` — the override +would be lost otherwise. + +## §diagnostics — `OnDiagnostic` callback + +`Options.OnDiagnostic`, when non-nil, is invoked for every +`grammar.Diagnostic` the builder layer records: lexer/parser +warnings, semantic-validation failures from the validations package, +and any future diagnostic class wired into the builder pipeline. + +Contract: + +- The callback fires **once per diagnostic, in source order**. +- Diagnostics **never block the build**. An invalid construct is + silently dropped from the output spec; the explanation flows + through this channel instead. +- The callback may be called from any per-decl builder; it is the + caller's responsibility to make it goroutine-safe if the consumer + ever drives `codescan.Run` concurrently (today it is single- + goroutine, but the callback contract makes no such guarantee). + +The diagnostic surface is **experimental**. Once the LSP integration +matures the shape is expected to grow: typed severity classes, +structural deduplication, per-position provenance. Callers that +adopt `OnDiagnostic` today should treat the signature as subject to +breaking change in a future minor release. + +`ScanCtx.OnDiagnostic` returns the user-supplied callback verbatim; +builders pipe diagnostics through it via `common.Builder.RecordDiagnostic`. + +## §model-lookup — `GetModel` vs `FindModel` + +`ScanCtx` exposes two lookup helpers with similar signatures but +different side-effect contracts. The choice between them is +load-bearing for the shape of the emitted spec. + +### `GetModel(pkgPath, name)` — pure read + +Looks up a model decl across three sources, in order: + +1. `Models` — decls annotated with `swagger:model`. Always emitted + as top-level definitions regardless of lookup. +2. `ExtraModels` — decls discovered as dependencies of other + emitted shapes. Already enqueued for top-level emission. +3. `FindDecl` — fall through to a syntactic search over the + loaded packages. + +No side effect. A `FindDecl` hit through `GetModel` does **not** +register the decl in `ExtraModels`. Callers that want the lookup +to also surface the decl as a top-level definition must follow up +with `AddDiscoveredModel` explicitly. + +### `FindModel(pkgPath, name)` — implicit registration + +The older sibling of `GetModel`. It does the same three-source +lookup, but a `FindDecl` hit also writes the decl into `ExtraModels` +as a side effect. + +`FindModel` is deprecated. The implicit registration 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. Builders that need the +registration should use the explicit `GetModel` + `AddDiscoveredModel` +pair. + +### `AddDiscoveredModel` — explicit registration + +Registers a decl in `ExtraModels`. No-op for decls already in +`Models` (annotated decls are emitted unconditionally — registering +them as discovered would create a Models↔ExtraModels bouncing loop +in the spec orchestrator's `joinExtraModels` pass). Nil and +Ident-less decls are silently ignored, which is defensive against +the scanner emitting partial decls during error recovery. + +## §classifier — `detectNodes` bitmask + +`TypeIndex.detectNodes` scans every comment group in a file and +returns a bitmask of detected annotation kinds. Each kind drives +downstream processing: + +| Bit | Annotation | Downstream | +|---|---|---| +| `metaNode` | `swagger:meta` | file-level meta block | +| `routeNode` | `swagger:route` | path-level route annotations | +| `operationNode` | `swagger:operation` | path-level operation annotations | +| `modelNode` | `swagger:model` | per-decl model registration | +| `parametersNode` | `swagger:parameters` | per-decl parameter registration | +| `responseNode` | `swagger:response` | per-decl response registration | + +`route`, `operation`, and `meta` accumulate freely across comment +groups in a file. The three struct-level annotations (`model`, +`parameters`, `response`) are **mutually exclusive within a single +comment group** — a struct cannot simultaneously be a model and a +parameters bag, for instance. `checkStructConflict` enforces the +rule per comment group and returns an error if the constraint is +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. + +## §quirks-open — deferred follow-ups + +- **`FindModel` deprecation.** The deprecated alias is still on the + `ScanCtx` surface for in-tree callers. Once every builder has been + audited and migrated to the `GetModel` + `AddDiscoveredModel` pair, + the deprecated method can be removed in a future major release. +- **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 + builders skip whole files that carry no decorations — an + optimisation, not a correctness change. +- **`shouldAcceptTag` precedence.** When both `includeTags` and + `excludeTags` are populated, `includeTags` wins (a tag in + `includeTags` admits the operation even if it also appears in + `excludeTags`). This is deliberate but easy to mis-read; an + explicit "the include list takes precedence" doc on `Options` + would help callers, but the field-level prose is already dense. diff --git a/internal/scanner/classify/extension.go b/internal/scanner/classify/extension.go new file mode 100644 index 0000000..5ae7a61 --- /dev/null +++ b/internal/scanner/classify/extension.go @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package classify provides small classification predicates used by +// the scanner and by builders to decide whether a given name or +// comment line belongs to a particular Swagger-annotation family. +// +// The package lives beneath internal/scanner/ because classification +// is fundamentally a scanner concern: "does this string denote a +// swagger:xxx construct?" is the same kind of question the scanner +// asks when indexing packages. Builders that need the same predicate +// (vendor-extension key filtering, for instance) import from here +// rather than reaching back into internal/parsers/. +package classify + +// IsAllowedExtension reports whether key is a valid Swagger +// vendor-extension key — opens with `x-` or `X-`. Mirrors the +// previous regex `^[Xx]-`: the suffix may be empty (the spec +// itself rejects empty-suffix keys at a higher level). +func IsAllowedExtension(key string) bool { + return len(key) >= 2 && (key[0] == 'x' || key[0] == 'X') && key[1] == '-' +} diff --git a/internal/scanner/classify/extension_test.go b/internal/scanner/classify/extension_test.go new file mode 100644 index 0000000..b669b84 --- /dev/null +++ b/internal/scanner/classify/extension_test.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package classify + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" +) + +func TestIsAllowedExtension(t *testing.T) { + t.Parallel() + + tests := []struct { + ext string + want bool + }{ + {"x-foo", true}, + {"X-bar", true}, + {"x-", true}, + {"y-foo", false}, + {"foo", false}, + {"", false}, + } + + for _, tc := range tests { + t.Run(tc.ext, func(t *testing.T) { + assert.EqualT(t, tc.want, IsAllowedExtension(tc.ext)) + }) + } +} diff --git a/internal/scanner/enum_value.go b/internal/scanner/enum_value.go new file mode 100644 index 0000000..9975d67 --- /dev/null +++ b/internal/scanner/enum_value.go @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package scanner + +import ( + "go/ast" + "strconv" + "strings" +) + +// enumBasicLitValue converts the RHS of a `const Foo Kind = "bar"` +// declaration into its runtime value — int64 / float64 / unquoted +// string — for emission as an enum entry on the Swagger schema the +// scanner is building. +// +// Returns nil when the literal kind is INT or FLOAT but the textual +// value fails to parse (rare — Go's own parser would have caught it +// upstream, but the safety net is cheap). +func enumBasicLitValue(basicLit *ast.BasicLit) any { + switch basicLit.Kind.String() { + case "INT": + if result, err := strconv.ParseInt(basicLit.Value, 10, 64); err == nil { + return result + } + case "FLOAT": + if result, err := strconv.ParseFloat(basicLit.Value, 64); err == nil { + return result + } + default: + return strings.Trim(basicLit.Value, "\"") + } + return nil +} diff --git a/internal/scanner/index.go b/internal/scanner/index.go index 4a68828..39a08c2 100644 --- a/internal/scanner/index.go +++ b/internal/scanner/index.go @@ -74,7 +74,7 @@ type TypeIndex struct { AllPackages map[string]*packages.Package Models map[*ast.Ident]*EntityDecl ExtraModels map[*ast.Ident]*EntityDecl - Meta []parsers.MetaSection + Meta []*ast.CommentGroup Routes []parsers.ParsedPathContent Operations []parsers.ParsedPathContent Parameters []*EntityDecl @@ -145,7 +145,7 @@ func (a *TypeIndex) processFile(pkg *packages.Package, file *ast.File) error { } if n&metaNode != 0 { - a.Meta = append(a.Meta, parsers.MetaSection{Comments: file.Doc}) + a.Meta = append(a.Meta, file.Doc) } if n&operationNode != 0 { @@ -294,11 +294,14 @@ 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 types. Node types like route, operation, and -// meta accumulate freely across comment groups. Struct-level annotations -// (model, parameters, response) are mutually exclusive Within a single -// comment group — mixing them is an error. +// 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. func (a *TypeIndex) detectNodes(file *ast.File) (node, error) { var n node for _, comments := range file.Comments { diff --git a/internal/scanner/options.go b/internal/scanner/options.go index 987b18a..65dc137 100644 --- a/internal/scanner/options.go +++ b/internal/scanner/options.go @@ -3,8 +3,20 @@ package scanner -import "github.com/go-openapi/spec" +import ( + "github.com/go-openapi/codescan/internal/parsers/grammar" + "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. +// +// # 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). type Options struct { Packages []string InputSpec *spec.Swagger @@ -19,7 +31,38 @@ type Options struct { SetXNullableForPointers bool RefAliases bool // aliases result in $ref, otherwise aliases are expanded TransparentAliases bool // aliases are completely transparent, never creating definitions - DescWithRef bool // allow overloaded descriptions together with $ref, otherwise jsonschema draft4 $ref predates everything - SkipExtensions bool // skip generating x-go-* vendor extensions in the spec - Debug bool // enable verbose debug logging during scanning + // DescWithRef controls description preservation on $ref'd fields + // in the description-only-decoration case: when a struct field's + // Go type resolves to a named type ($ref) and its only + // field-level decoration is a description (no validations, no + // user-authored extensions). + // + // - false (default): the description is dropped and the field + // emits as a bare `{$ref: ...}`. + // - true: the description is preserved by wrapping the $ref in + // a single-arm `allOf` compound — `{description: "...", + // 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. + // + // See [§descwithref](./README.md#descwithref). + DescWithRef bool + SkipExtensions bool // skip generating x-go-* vendor extensions in the spec + Debug bool // enable verbose debug logging during scanning + + // OnDiagnostic, when non-nil, is invoked for every diagnostic the + // builder layer records (lexer/parser warnings, semantic-validation + // failures from the validations package, etc.). The callback fires + // once per diagnostic in source order; diagnostics never block the + // build — invalid constructs are silently dropped from the output + // spec while their explanation flows through this channel. + // + // Experimental: the public API surface for diagnostics is subject + // to change while LSP integration matures. See + // [§diagnostics](./README.md#diagnostics). + OnDiagnostic func(grammar.Diagnostic) } diff --git a/internal/scanner/scan_context.go b/internal/scanner/scan_context.go index 24eec84..693a1bc 100644 --- a/internal/scanner/scan_context.go +++ b/internal/scanner/scan_context.go @@ -16,6 +16,7 @@ import ( "github.com/go-openapi/codescan/internal/logger" "github.com/go-openapi/codescan/internal/parsers" + "github.com/go-openapi/codescan/internal/parsers/grammar" "golang.org/x/tools/go/packages" ) @@ -98,11 +99,52 @@ func (s *ScanCtx) RefAliases() bool { return s.opts.RefAliases } +// 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. +func (s *ScanCtx) FileSet() *token.FileSet { + if len(s.pkgs) == 0 { + return nil + } + return s.pkgs[0].Fset +} + +// PosOf resolves p to a token.Position via the active FileSet. Returns +// the zero token.Position when p is invalid or no FileSet is available. +// Useful for attaching a source location to a Diagnostic without each +// caller re-deriving the FileSet. +func (s *ScanCtx) PosOf(p token.Pos) token.Position { + if !p.IsValid() { + return token.Position{} + } + fset := s.FileSet() + if fset == nil { + return token.Position{} + } + return fset.Position(p) +} + func (s *ScanCtx) Debug() bool { return s.debug } -func (s *ScanCtx) Meta() iter.Seq[parsers.MetaSection] { +// OnDiagnostic returns the user-supplied diagnostic sink, or nil when +// the consumer has not opted into diagnostic delivery. +// +// # Details +// +// See [§diagnostics](./README.md#diagnostics) — callback contract, +// ordering guarantee, experimental-API caveat. +func (s *ScanCtx) OnDiagnostic() func(grammar.Diagnostic) { + return s.opts.OnDiagnostic +} + +func (s *ScanCtx) Meta() iter.Seq[*ast.CommentGroup] { if s.app == nil { return nil } @@ -229,6 +271,68 @@ 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. +// +// # Details +// +// 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. +func (s *ScanCtx) GetModel(pkgPath, name string) (*EntityDecl, bool) { + for _, cand := range s.app.Models { + ct := cand.Obj() + if ct.Name() == name && ct.Pkg().Path() == pkgPath { + return cand, true + } + } + + for _, cand := range s.app.ExtraModels { + ct := cand.Obj() + if ct.Name() == name && ct.Pkg().Path() == pkgPath { + return cand, true + } + } + + return s.FindDecl(pkgPath, name) +} + +// AddDiscoveredModel registers decl in the ExtraModels index so the +// spec orchestrator emits a top-level definition for it. +// +// No-op when decl is already an annotated swagger:model (in Models); +// 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). +func (s *ScanCtx) AddDiscoveredModel(decl *EntityDecl) { + if decl == nil || decl.Ident == nil { + return + } + if _, alreadyModel := s.app.Models[decl.Ident]; alreadyModel { + return + } + s.app.ExtraModels[decl.Ident] = decl +} + +// FindModel returns the model decl for (pkgPath, name) and, when the +// hit comes from FindDecl fallback, registers it in ExtraModels as a +// side effect. +// +// Deprecated: prefer the explicit pair GetModel (pure read) and +// AddDiscoveredModel (explicit registration). The implicit +// registration side effect surprises readers and pulls stdlib types +// (notably time.Time, json.RawMessage) into the spec's top-level +// definitions when they should be inlined where referenced. 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() @@ -361,7 +465,7 @@ func (s *ScanCtx) findEnumValue(spec ast.Spec, enumName string) (values []any, d continue } - literalValue := parsers.GetEnumBasicLitValue(bl) + literalValue := enumBasicLitValue(bl) var desc strings.Builder fmt.Fprintf(&desc, "%v %s", literalValue, nameIdent.Name) From 46be15325841dbf5f8ab1d7d4bcdb1bec66aee0f Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:39:16 +0200 Subject: [PATCH 17/22] test(integration): golden-file test harness + quirk story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keystone that warrants this refactor: every change is validated by comparing produced spec JSON against captured golden fixtures, regenerable in bulk via UPDATE_GOLDEN=1 go test ./... internal/scantest — load helpers and CompareOrDumpJSON internal/integration — black-box scanner runs across fixture trees fixtures/goparsing — historic corpus (classification, petstore, go118/go119/go123 variants, invalid inputs) fixtures/enhancements — one sub-tree per isolated branch-coverage scenario (swagger-type-array, alias-expand, allof-edges, named-basic, interface-methods, …) fixtures/bugs — minimised repros for specific upstream IDs fixtures/integration/golden — captured spec.Swagger JSON Quirks surfaced and fixed during the refactor, now reflected in the goldens: - Enum/const detection: comma-list whitespace trim; no-matching- const warning; stale x-go-enum-desc cleared on inline override. - Parameter/response parity: explicit `in: header` default; diagnostic on invalid `in:` values; $ref on response headers suppressed (SetRef no-ops under non-body mode); swagger:file gated to in:body with diagnostic on misuse; buildFieldAlias gate brought to parity with parameters; unexported-field skip logged for parity with parameters. - Multi-line bodies: YAML-list sub-parser handles nested route body blocks; annotation terminator must start at line-start. - Description accumulation: leading-space artifact stripped on routebody response descriptions when description: leads a multi-token line. - $ref / description coexistence: DescWithRef revived as the description-on-$ref toggle; $ref-with-overrides wrapped as allOf compounds so vendor extensions surface on the outer compound; SkipExtensions=true produces bare $ref for description-only $ref'd fields. - Schema validation gating: type-aware shape check rejects keywords illegal for the field's underlying Go type, with positioned diagnostics rather than silent invalid spec. - Post-decl propagation: map-body sub-build propagates its PostDeclarations back to the outer Builder so referenced types reach the orchestrator's discovery loop. Remaining quirks tracked as known issues for follow-up. Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- .../enhancements/alias-response-shapes/api.go | 72 ++ fixtures/enhancements/diagnostics/types.go | 96 +++ fixtures/enhancements/enum-overrides/types.go | 112 +++ .../generic-instantiation/types.go | 42 ++ .../enhancements/header-extensions/api.go | 43 ++ .../enhancements/header-named-basic/api.go | 44 ++ .../meta-lists-flex-forms/handlers.go | 31 + .../parameters-map-postdecl/api.go | 53 ++ .../raw-message-override/types.go | 81 ++ .../enhancements/response-file-types/api.go | 67 ++ .../response-header-ref-leak/api.go | 70 ++ .../response-implicit-header/api.go | 77 ++ .../routes-description-dash-list/handlers.go | 26 + .../handlers.go | 35 + .../routes-full-petstore-shape/handlers.go | 66 ++ .../routes-lists-flex-forms/handlers.go | 75 ++ .../routes-multi-method-same-path/handlers.go | 49 ++ .../handlers.go | 29 + .../routes-params-body-array/handlers.go | 30 + .../routes-params-body-ref/handlers.go | 30 + .../handlers.go | 35 + .../routes-params-empty-chunk/handlers.go | 19 + .../routes-params-form-string/handlers.go | 22 + .../routes-params-header-string/handlers.go | 24 + .../routes-params-multiple/handlers.go | 40 + .../routes-params-path/handlers.go | 22 + .../routes-params-query-array/handlers.go | 24 + .../routes-params-query-boolean/handlers.go | 23 + .../routes-params-query-number/handlers.go | 24 + .../routes-params-query-string/handlers.go | 23 + .../handlers.go | 29 + .../routes-params-unknown-key/handlers.go | 25 + .../routes-responses-array/handlers.go | 31 + .../routes-responses-default/handlers.go | 25 + .../handlers.go | 26 + .../handlers.go | 17 + .../routes-responses-empty-value/handlers.go | 17 + .../routes-responses-mixed-bodies/handlers.go | 34 + .../handlers.go | 36 + .../routes-responses-positional/handlers.go | 25 + .../handlers.go | 22 + .../handlers.go | 22 + .../routes-responses-tagged-body/handlers.go | 24 + .../handlers.go | 26 + .../simple-schema-readonly/api.go | 48 ++ .../simple-schema-violation/api.go | 42 ++ .../text-marshal/explicit_override/types.go | 33 + .../text-marshal/uuid_wrapping_time/types.go | 33 + .../wrapper-decl-type-override/types.go | 33 + fixtures/goparsing/bookings/api.go | 4 +- .../operations/todo_operation.go | 48 +- .../operations_body/todo_operation_body.go | 56 +- fixtures/goparsing/spec/api.go | 4 +- .../integration/golden/api_spec_go111.json | 144 ++-- .../golden/api_spec_go111_ref.json | 148 ++-- .../golden/api_spec_go111_transparent.json | 2 +- .../integration/golden/bugs_3125_schema.json | 20 +- .../golden/bugs_3125_schema_descwithref.json | 34 + .../golden/bugs_3125_schema_skipext.json | 31 + .../bugs_3125_schema_skipext_descwithref.json | 31 + .../classification_params_descwithref.json | 689 ++++++++++++++++++ .../golden/classification_params_skipext.json | 625 ++++++++++++++++ ...sification_params_skipext_descwithref.json | 635 ++++++++++++++++ .../classification_responses_descwithref.json | 256 +++++++ .../classification_responses_skipext.json | 247 +++++++ ...ication_responses_skipext_descwithref.json | 252 +++++++ .../golden/classification_routes.json | 4 +- .../golden/classification_routes_body.json | 14 +- .../golden/classification_schema_NoModel.json | 1 - .../golden/enhancements_alias_expand.json | 13 +- .../golden/enhancements_alias_ref.json | 1 + ...cements_alias_response_shapes_default.json | 47 ++ ...nhancements_alias_response_shapes_ref.json | 55 ++ ...nts_alias_response_shapes_transparent.json | 47 ++ .../golden/enhancements_all_http_methods.json | 14 +- .../golden/enhancements_allof_edges.json | 1 + .../golden/enhancements_enum_overrides.json | 97 +++ .../enhancements_generic_instantiation.json | 41 ++ .../enhancements_interface_methods.json | 1 + ...ancements_interface_methods_xnullable.json | 1 + .../enhancements_meta_lists_flex_forms.json | 19 + .../enhancements_parameters_map_postdecl.json | 48 ++ .../enhancements_raw_message_override.json | 73 ++ .../enhancements_response_file_types.json | 30 + ...enhancements_response_header_ref_leak.json | 41 ++ ...enhancements_response_implicit_header.json | 55 ++ ...ncements_routes_description_dash_list.json | 20 + ..._routes_description_yaml_fence_absorb.json | 15 + ...hancements_routes_full_petstore_shape.json | 99 +++ .../enhancements_routes_lists_flex_forms.json | 94 +++ ...cements_routes_multi_method_same_path.json | 79 ++ ...enhancements_routes_params_body_array.json | 51 ++ ...ments_routes_params_body_array_nested.json | 49 ++ .../enhancements_routes_params_body_ref.json | 48 ++ ...s_params_body_with_schema_validations.json | 51 ++ ...nhancements_routes_params_empty_chunk.json | 19 + ...nhancements_routes_params_form_string.json | 28 + ...ancements_routes_params_header_string.json | 30 + .../enhancements_routes_params_multiple.json | 62 ++ .../enhancements_routes_params_path.json | 28 + ...nhancements_routes_params_query_array.json | 32 + ...ancements_routes_params_query_boolean.json | 28 + ...hancements_routes_params_query_number.json | 30 + ...hancements_routes_params_query_string.json | 29 + ...ments_routes_params_query_validations.json | 41 ++ ...nhancements_routes_params_unknown_key.json | 28 + .../enhancements_routes_responses_array.json | 66 ++ ...enhancements_routes_responses_default.json | 33 + ..._routes_responses_definition_fallback.json | 40 + ...nts_routes_responses_description_only.json | 25 + ...ncements_routes_responses_empty_value.json | 22 + ...cements_routes_responses_mixed_bodies.json | 57 ++ ...ments_routes_responses_multiple_codes.json | 63 ++ ...ancements_routes_responses_positional.json | 33 + ...ements_routes_responses_ref_not_found.json | 15 + ...nts_routes_responses_space_body_quirk.json | 14 + ...ncements_routes_responses_tagged_body.json | 43 ++ ...ents_routes_responses_tagged_response.json | 36 + .../enhancements_swagger_type_array.json | 3 + .../golden/enhancements_text_marshal.json | 32 + .../golden/enhancements_top_level_kinds.json | 2 + ...hancements_wrapper_decl_type_override.json | 21 + .../golden/go123_aliased_spec.json | 12 +- .../golden/go123_special_spec.json | 4 +- .../golden/malformed_bad_response_tag.json | 13 + .../golden/malformed_duplicate_body_tag.json | 13 + .../golden/malformed_info_bad_ext_key.json | 14 + .../golden/malformed_meta_bad_ext_key.json | 14 + .../coverage_alias_response_shapes_test.go | 89 +++ .../integration/coverage_enhancements_test.go | 534 ++++++++++++++ .../coverage_header_extensions_test.go | 47 ++ .../coverage_header_named_basic_test.go | 43 ++ .../integration/coverage_malformed_test.go | 47 +- .../coverage_response_file_types_test.go | 95 +++ .../coverage_response_header_ref_leak_test.go | 83 +++ .../coverage_response_implicit_header_test.go | 95 +++ .../coverage_simple_schema_readonly_test.go | 60 ++ .../coverage_simple_schema_test.go | 62 ++ internal/integration/schema_special_test.go | 5 +- internal/scantest/property.go | 13 +- 140 files changed, 8118 insertions(+), 252 deletions(-) create mode 100644 fixtures/enhancements/alias-response-shapes/api.go create mode 100644 fixtures/enhancements/diagnostics/types.go create mode 100644 fixtures/enhancements/enum-overrides/types.go create mode 100644 fixtures/enhancements/generic-instantiation/types.go create mode 100644 fixtures/enhancements/header-extensions/api.go create mode 100644 fixtures/enhancements/header-named-basic/api.go create mode 100644 fixtures/enhancements/meta-lists-flex-forms/handlers.go create mode 100644 fixtures/enhancements/parameters-map-postdecl/api.go create mode 100644 fixtures/enhancements/raw-message-override/types.go create mode 100644 fixtures/enhancements/response-file-types/api.go create mode 100644 fixtures/enhancements/response-header-ref-leak/api.go create mode 100644 fixtures/enhancements/response-implicit-header/api.go create mode 100644 fixtures/enhancements/routes-description-dash-list/handlers.go create mode 100644 fixtures/enhancements/routes-description-yaml-fence-absorb/handlers.go create mode 100644 fixtures/enhancements/routes-full-petstore-shape/handlers.go create mode 100644 fixtures/enhancements/routes-lists-flex-forms/handlers.go create mode 100644 fixtures/enhancements/routes-multi-method-same-path/handlers.go create mode 100644 fixtures/enhancements/routes-params-body-array-nested/handlers.go create mode 100644 fixtures/enhancements/routes-params-body-array/handlers.go create mode 100644 fixtures/enhancements/routes-params-body-ref/handlers.go create mode 100644 fixtures/enhancements/routes-params-body-with-schema-validations/handlers.go create mode 100644 fixtures/enhancements/routes-params-empty-chunk/handlers.go create mode 100644 fixtures/enhancements/routes-params-form-string/handlers.go create mode 100644 fixtures/enhancements/routes-params-header-string/handlers.go create mode 100644 fixtures/enhancements/routes-params-multiple/handlers.go create mode 100644 fixtures/enhancements/routes-params-path/handlers.go create mode 100644 fixtures/enhancements/routes-params-query-array/handlers.go create mode 100644 fixtures/enhancements/routes-params-query-boolean/handlers.go create mode 100644 fixtures/enhancements/routes-params-query-number/handlers.go create mode 100644 fixtures/enhancements/routes-params-query-string/handlers.go create mode 100644 fixtures/enhancements/routes-params-query-validations/handlers.go create mode 100644 fixtures/enhancements/routes-params-unknown-key/handlers.go create mode 100644 fixtures/enhancements/routes-responses-array/handlers.go create mode 100644 fixtures/enhancements/routes-responses-default/handlers.go create mode 100644 fixtures/enhancements/routes-responses-definition-fallback/handlers.go create mode 100644 fixtures/enhancements/routes-responses-description-only/handlers.go create mode 100644 fixtures/enhancements/routes-responses-empty-value/handlers.go create mode 100644 fixtures/enhancements/routes-responses-mixed-bodies/handlers.go create mode 100644 fixtures/enhancements/routes-responses-multiple-codes/handlers.go create mode 100644 fixtures/enhancements/routes-responses-positional/handlers.go create mode 100644 fixtures/enhancements/routes-responses-ref-not-found/handlers.go create mode 100644 fixtures/enhancements/routes-responses-space-body-quirk/handlers.go create mode 100644 fixtures/enhancements/routes-responses-tagged-body/handlers.go create mode 100644 fixtures/enhancements/routes-responses-tagged-response/handlers.go create mode 100644 fixtures/enhancements/simple-schema-readonly/api.go create mode 100644 fixtures/enhancements/simple-schema-violation/api.go create mode 100644 fixtures/enhancements/text-marshal/explicit_override/types.go create mode 100644 fixtures/enhancements/text-marshal/uuid_wrapping_time/types.go create mode 100644 fixtures/enhancements/wrapper-decl-type-override/types.go create mode 100644 fixtures/integration/golden/bugs_3125_schema_descwithref.json create mode 100644 fixtures/integration/golden/bugs_3125_schema_skipext.json create mode 100644 fixtures/integration/golden/bugs_3125_schema_skipext_descwithref.json create mode 100644 fixtures/integration/golden/classification_params_descwithref.json create mode 100644 fixtures/integration/golden/classification_params_skipext.json create mode 100644 fixtures/integration/golden/classification_params_skipext_descwithref.json create mode 100644 fixtures/integration/golden/classification_responses_descwithref.json create mode 100644 fixtures/integration/golden/classification_responses_skipext.json create mode 100644 fixtures/integration/golden/classification_responses_skipext_descwithref.json create mode 100644 fixtures/integration/golden/enhancements_alias_response_shapes_default.json create mode 100644 fixtures/integration/golden/enhancements_alias_response_shapes_ref.json create mode 100644 fixtures/integration/golden/enhancements_alias_response_shapes_transparent.json create mode 100644 fixtures/integration/golden/enhancements_enum_overrides.json create mode 100644 fixtures/integration/golden/enhancements_generic_instantiation.json create mode 100644 fixtures/integration/golden/enhancements_meta_lists_flex_forms.json create mode 100644 fixtures/integration/golden/enhancements_parameters_map_postdecl.json create mode 100644 fixtures/integration/golden/enhancements_raw_message_override.json create mode 100644 fixtures/integration/golden/enhancements_response_file_types.json create mode 100644 fixtures/integration/golden/enhancements_response_header_ref_leak.json create mode 100644 fixtures/integration/golden/enhancements_response_implicit_header.json create mode 100644 fixtures/integration/golden/enhancements_routes_description_dash_list.json create mode 100644 fixtures/integration/golden/enhancements_routes_description_yaml_fence_absorb.json create mode 100644 fixtures/integration/golden/enhancements_routes_full_petstore_shape.json create mode 100644 fixtures/integration/golden/enhancements_routes_lists_flex_forms.json create mode 100644 fixtures/integration/golden/enhancements_routes_multi_method_same_path.json create mode 100644 fixtures/integration/golden/enhancements_routes_params_body_array.json create mode 100644 fixtures/integration/golden/enhancements_routes_params_body_array_nested.json create mode 100644 fixtures/integration/golden/enhancements_routes_params_body_ref.json create mode 100644 fixtures/integration/golden/enhancements_routes_params_body_with_schema_validations.json create mode 100644 fixtures/integration/golden/enhancements_routes_params_empty_chunk.json create mode 100644 fixtures/integration/golden/enhancements_routes_params_form_string.json create mode 100644 fixtures/integration/golden/enhancements_routes_params_header_string.json create mode 100644 fixtures/integration/golden/enhancements_routes_params_multiple.json create mode 100644 fixtures/integration/golden/enhancements_routes_params_path.json create mode 100644 fixtures/integration/golden/enhancements_routes_params_query_array.json create mode 100644 fixtures/integration/golden/enhancements_routes_params_query_boolean.json create mode 100644 fixtures/integration/golden/enhancements_routes_params_query_number.json create mode 100644 fixtures/integration/golden/enhancements_routes_params_query_string.json create mode 100644 fixtures/integration/golden/enhancements_routes_params_query_validations.json create mode 100644 fixtures/integration/golden/enhancements_routes_params_unknown_key.json create mode 100644 fixtures/integration/golden/enhancements_routes_responses_array.json create mode 100644 fixtures/integration/golden/enhancements_routes_responses_default.json create mode 100644 fixtures/integration/golden/enhancements_routes_responses_definition_fallback.json create mode 100644 fixtures/integration/golden/enhancements_routes_responses_description_only.json create mode 100644 fixtures/integration/golden/enhancements_routes_responses_empty_value.json create mode 100644 fixtures/integration/golden/enhancements_routes_responses_mixed_bodies.json create mode 100644 fixtures/integration/golden/enhancements_routes_responses_multiple_codes.json create mode 100644 fixtures/integration/golden/enhancements_routes_responses_positional.json create mode 100644 fixtures/integration/golden/enhancements_routes_responses_ref_not_found.json create mode 100644 fixtures/integration/golden/enhancements_routes_responses_space_body_quirk.json create mode 100644 fixtures/integration/golden/enhancements_routes_responses_tagged_body.json create mode 100644 fixtures/integration/golden/enhancements_routes_responses_tagged_response.json create mode 100644 fixtures/integration/golden/enhancements_wrapper_decl_type_override.json create mode 100644 fixtures/integration/golden/malformed_bad_response_tag.json create mode 100644 fixtures/integration/golden/malformed_duplicate_body_tag.json create mode 100644 fixtures/integration/golden/malformed_info_bad_ext_key.json create mode 100644 fixtures/integration/golden/malformed_meta_bad_ext_key.json create mode 100644 internal/integration/coverage_alias_response_shapes_test.go create mode 100644 internal/integration/coverage_header_extensions_test.go create mode 100644 internal/integration/coverage_header_named_basic_test.go create mode 100644 internal/integration/coverage_response_file_types_test.go create mode 100644 internal/integration/coverage_response_header_ref_leak_test.go create mode 100644 internal/integration/coverage_response_implicit_header_test.go create mode 100644 internal/integration/coverage_simple_schema_readonly_test.go create mode 100644 internal/integration/coverage_simple_schema_test.go diff --git a/fixtures/enhancements/alias-response-shapes/api.go b/fixtures/enhancements/alias-response-shapes/api.go new file mode 100644 index 0000000..37a8a90 --- /dev/null +++ b/fixtures/enhancements/alias-response-shapes/api.go @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package alias_response_shapes captures Q4-exploration goldens +// for the response builder's alias handling on MEMBER fields: +// +// - body field whose Go type is an alias chain +// - header field whose Go type is an alias of a named primitive +// - header field whose Go type is an alias of a named struct +// +// Top-level response-as-alias is intentionally NOT in this package +// because the default (non-Transparent, non-Ref) buildAlias path +// for swagger:response on an alias crashes with "anonymous types +// are currently not supported for responses" (the existing +// alias-response/ fixture documents that gap by running only under +// RefAliases=true). The split lets default mode run on this +// package — the member-alias cases work — and the goldens +// captured side-by-side reveal what's broken vs working. +package alias_response_shapes + +// Envelope is the canonical named struct. +// +// swagger:model Envelope +type Envelope struct { + // required: true + ID int64 `json:"id"` + + Name string `json:"name"` +} + +// EnvelopeAlias is an alias of Envelope. +type EnvelopeAlias = Envelope + +// EnvelopeAlias2 is an alias of EnvelopeAlias (alias-of-alias). +type EnvelopeAlias2 = EnvelopeAlias + +// SessionID is a named string used in alias scenarios. +type SessionID string + +// SessionIDAlias is an alias of SessionID. +type SessionIDAlias = SessionID + +// BodyAliasResponse — body field uses an alias-of-alias chain. +// +// swagger:response bodyAliasResponse +type BodyAliasResponse struct { + // in: body + Body EnvelopeAlias2 `json:"body"` +} + +// HeaderAliasedBasicResponse — header field is an alias of a +// named string. Expected emission: primitive inline +// {string, ""}; no $ref under any mode (headers can't carry +// $ref). +// +// swagger:response headerAliasedBasicResponse +type HeaderAliasedBasicResponse struct { + // in: header + Session SessionIDAlias `json:"X-Session"` +} + +// HeaderAliasedStructResponse — header field is an alias of a +// named struct. Expected emission: NO body schema corruption +// (Q2 fix); header surfaces empty (struct can't reduce to a +// SimpleSchema primitive); CodeUnsupportedInSimpleSchema +// diagnostic fires for the underlying ref attempt. +// +// swagger:response headerAliasedStructResponse +type HeaderAliasedStructResponse struct { + // in: header + Detail EnvelopeAlias `json:"X-Detail"` +} diff --git a/fixtures/enhancements/diagnostics/types.go b/fixtures/enhancements/diagnostics/types.go new file mode 100644 index 0000000..c4bdc03 --- /dev/null +++ b/fixtures/enhancements/diagnostics/types.go @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package diagnostics carries fixtures that exercise the schema +// builder's diagnostic emission path. Each type is annotated with +// at least one deliberately invalid construct that the grammar +// parser rejects with a non-fatal diagnostic; the offending value +// is dropped from the output spec while the diagnostic flows +// through Builder.Diagnostics() / Options.OnDiagnostic. +package diagnostics + +// BadMaximum has an invalid maximum: value. The parser emits a +// CodeInvalidNumber diagnostic and the maximum keyword is dropped +// from the output schema. +// +// swagger:model BadMaximum +type BadMaximum struct { + // Count holds an arbitrary count. + // + // maximum: notanumber + Count int `json:"count"` +} + +// Helper types for the AmbiguousEmbed fixture: two unrelated structs +// that each carry a property whose JSON name is "shared", but whose +// Go field names differ. Embedding both into a single parent triggers +// the same-depth ambiguity case Go itself would refuse to promote. + +// SharedFoo carries a `shared` JSON property under Go name Foo. +// +// swagger:model SharedFoo +type SharedFoo struct { + Foo string `json:"shared"` +} + +// SharedBar carries a `shared` JSON property under Go name Bar. +// +// swagger:model SharedBar +type SharedBar struct { + Bar string `json:"shared"` +} + +// AmbiguousEmbed embeds two unrelated types that both promote the +// `shared` JSON property under different Go names. The schema +// builder emits a CodeAmbiguousEmbed diagnostic; the resulting +// spec is last-write-wins (Bar overrides Foo because struct fields +// iterate in source order — SharedBar is the later embed). +// +// swagger:model AmbiguousEmbed +type AmbiguousEmbed struct { + SharedFoo + SharedBar +} + +// Helper types for the DepthShadowingEmbed fixture: an inner struct +// shadowed by an outer struct's own explicit field of the same JSON +// name. Go's depth rule prefers the shallower field; codescan's +// last-write-wins produces the same outcome (the explicit field +// processes after the embed pass). The diagnostic must NOT fire. + +// DepthInner carries `shared` under Go name Foo at the deeper layer. +// +// swagger:model DepthInner +type DepthInner struct { + Foo string `json:"shared"` +} + +// DepthMiddle re-declares `shared` under Go name Bar at its own +// (shallower) depth, on top of an embed of DepthInner. +// +// swagger:model DepthMiddle +type DepthMiddle struct { + DepthInner + Bar string `json:"shared"` +} + +// DepthShadowingEmbed exercises legitimate Go-depth-rule shadowing +// across an embed chain: DepthMiddle.Bar (depth 1 from the parent +// after embedding) shadows DepthInner.Foo (depth 2). The diagnostic +// must remain silent. +// +// swagger:model DepthShadowingEmbed +type DepthShadowingEmbed struct { + DepthMiddle +} + +// ExplicitOverride exercises the explicit-override case: a top-level +// struct embeds a type carrying `shared`, then re-declares `shared` +// as its own field. The explicit field wins at embedDepth = 0; the +// diagnostic must remain silent. +// +// swagger:model ExplicitOverride +type ExplicitOverride struct { + SharedFoo + Bar string `json:"shared"` +} diff --git a/fixtures/enhancements/enum-overrides/types.go b/fixtures/enhancements/enum-overrides/types.go new file mode 100644 index 0000000..a02e70b --- /dev/null +++ b/fixtures/enhancements/enum-overrides/types.go @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package enum_overrides isolates the v1 behavior of `enum:` when it +// coexists with (or replaces) `swagger:enum TypeName` const-value +// inference. The golden output of TestCoverage_EnumOverrides is the +// factual reference for what the v2 parser migration must preserve +// — or consciously diverge from — under W2's override semantics +// (`.claude/plans/workshops/w2-enum.md` §2.6). +// +// Five cases, one per model in this file: +// +// A. swagger:enum + matching consts, no inline enum on field → consts +// B. inline comma-list on field, no swagger:enum, no consts → inline +// C. inline JSON array on field, no swagger:enum, no consts → inline +// D. swagger:enum but NO matching consts in package → ? +// E. swagger:enum + matching consts + inline enum on field → ? +package enum_overrides + +// --- Case A: swagger:enum + matching consts --- + +// PriorityA is a classic linked-const enum. +// +// swagger:enum PriorityA +type PriorityA string + +const ( + PriorityALow PriorityA = "low" + PriorityAMed PriorityA = "medium" + PriorityAHigh PriorityA = "high" +) + +// NotificationA exercises case A: field uses PriorityA, no inline +// enum override. +// +// swagger:model NotificationA +type NotificationA struct { + // required: true + ID int64 `json:"id"` + + // The priority level. Enum values come from PriorityA's consts. + Priority PriorityA `json:"priority"` +} + +// --- Case B: inline comma-list on field, no swagger:enum --- + +// NotificationB exercises case B: plain string field with inline +// comma-list enum. No swagger:enum on the type, no consts in code. +// +// swagger:model NotificationB +type NotificationB struct { + // The priority level. + // + // enum: low, medium, high + Priority string `json:"priority"` +} + +// --- Case C: inline JSON-array on field, no swagger:enum --- + +// NotificationC exercises case C: inline JSON-array enum. +// +// swagger:model NotificationC +type NotificationC struct { + // The priority level. + // + // enum: ["low","medium","high"] + Priority string `json:"priority"` +} + +// --- Case D: swagger:enum with no matching consts --- + +// PriorityD has a swagger:enum annotation but no corresponding +// const declarations in this package. The builder's FindEnumValues +// call returns an empty slice; the test captures how the spec +// renders in that case. +// +// swagger:enum PriorityD +type PriorityD string + +// NotificationD exercises case D. +// +// swagger:model NotificationD +type NotificationD struct { + // The priority level. + Priority PriorityD `json:"priority"` +} + +// --- Case E: swagger:enum + matching consts + inline override --- + +// PriorityE has both a linked-const set AND fields will provide an +// inline override. +// +// swagger:enum PriorityE +type PriorityE string + +const ( + PriorityELow PriorityE = "low" + PriorityEMed PriorityE = "medium" + PriorityEHigh PriorityE = "high" +) + +// NotificationE exercises case E: the inline enum on the field +// competes with the const-derived enum from PriorityE. The golden +// output captures which one wins in v1. +// +// swagger:model NotificationE +type NotificationE struct { + // Inline enum provides a narrower set than the const block. + // + // enum: urgent, normal + Priority PriorityE `json:"priority"` +} diff --git a/fixtures/enhancements/generic-instantiation/types.go b/fixtures/enhancements/generic-instantiation/types.go new file mode 100644 index 0000000..2fe4632 --- /dev/null +++ b/fixtures/enhancements/generic-instantiation/types.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package generic_instantiation exercises the generic-type +// instantiation short-circuit inside [Builder.buildNamedType]: +// when a named type has TypeArgs (i.e. is an instantiation of a +// generic declaration like `GenericSlice[int]`), we unwrap to its +// parameterised Underlying instead of trying to $ref to it. +// +// Without the short-circuit, an instantiated field type would route +// through resolveRefOr → makeRef → $ref to the generic declaration, +// which itself emits as an empty schema (its type parameters are +// filtered as UnsupportedBuiltinType). With the short-circuit, the +// field correctly reflects the substituted underlying. +package generic_instantiation + +// GenericSlice is the generic declaration. Its own emitted schema +// is essentially empty because the type parameter `T` is filtered +// out by UnsupportedBuiltinType. +type GenericSlice[T any] []T + +// GenericMap is a two-parameter generic declaration. +type GenericMap[K comparable, V any] map[K]V + +// Container holds fields whose types are generic INSTANTIATIONS. +// These should emit with the substituted underlying shape, not as +// $ref to the (empty) generic declaration. +// +// swagger:model generic_container +type Container struct { + // Items is an instantiation of GenericSlice with T=int. + // Expected schema: {array, items:{integer, int64}}. + Items GenericSlice[int] `json:"items"` + + // Names is an instantiation of GenericSlice with T=string. + // Expected schema: {array, items:{string}}. + Names GenericSlice[string] `json:"names"` + + // Counts is an instantiation of GenericMap with K=string, V=int. + // Expected schema: {object, additionalProperties:{integer, int64}}. + Counts GenericMap[string, int] `json:"counts"` +} diff --git a/fixtures/enhancements/header-extensions/api.go b/fixtures/enhancements/header-extensions/api.go new file mode 100644 index 0000000..a04c4e0 --- /dev/null +++ b/fixtures/enhancements/header-extensions/api.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package header_extensions exercises M2's new Walker.Extension wiring +// on the response-header path. Pre-M2, the responses bridge had no +// extension handling on headers at all — a user-authored +// `Extensions:` block on a header field was silently dropped. +// Post-M2, the bridge runs the same Walker.Extension callback the +// parameter bridge uses, so `x-*` entries land on the +// header.Extensions map. +package header_extensions + +// ExtendedResponse demonstrates a response header carrying a +// user-authored vendor extension via the `Extensions:` block. M2's +// bridge dispatches the typed extension value onto the header. +// +// swagger:response extendedResponse +type ExtendedResponse struct { + // RateLimit advertises the remaining quota. + // + // in: header + // Extensions: + // x-rate-window: 60s + // x-burst-allowed: true + RateLimit int `json:"X-Rate-Limit"` + + // Body is the canonical response payload. + // + // in: body + Body struct { + // Message carries the response message. + Message string `json:"message"` + } `json:"body"` +} + +// DoExtended handles the route. +// +// swagger:route GET /extended ext extendedResponse +// +// Responses: +// +// 200: extendedResponse +func DoExtended() {} diff --git a/fixtures/enhancements/header-named-basic/api.go b/fixtures/enhancements/header-named-basic/api.go new file mode 100644 index 0000000..5369f55 --- /dev/null +++ b/fixtures/enhancements/header-named-basic/api.go @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package header_named_basic pins the M1-follow-up fix that brought +// `in: header` parameters into the SimpleSchema-aware primitive- +// inline path. Pre-fix, classifierNamedBasic only short-circuited +// to inline-emission for query / path / formData (the v1 +// `isAliasParam` predicate omitted `header`), so a `type SessionID +// string` field appearing as `in: header` silently fell through to +// FindModel → makeRef and emitted a `$ref`-typed parameter — invalid +// under OAS v2 SimpleSchema. +// +// With the fix, all four non-body locations route through the same +// SimpleSchema-driven primitive-inline arm and the header parameter +// emits `{type: string, format: ""}` inline. +package header_named_basic + +// SessionID is a named string used as a header parameter. The +// schema builder used to emit a $ref to a top-level definition for +// it; under SimpleSchema mode for `in: header` it now inlines as +// `{string, ""}`. +type SessionID string + +// HeaderParams demonstrates a header parameter typed as a named +// string. +// +// swagger:parameters headerNamedBasicOp +type HeaderParams struct { + // Session is the offending parameter — pre-fix it emitted as a + // $ref to definitions/SessionID, post-fix it inlines as a + // primitive string. + // + // in: header + Session SessionID `json:"X-Session"` +} + +// DoHeader handles the route. +// +// swagger:route GET /header-named-basic hdr headerNamedBasicOp +// +// Responses: +// +// 200: description: OK +func DoHeader() {} diff --git a/fixtures/enhancements/meta-lists-flex-forms/handlers.go b/fixtures/enhancements/meta-lists-flex-forms/handlers.go new file mode 100644 index 0000000..bdb3d23 --- /dev/null +++ b/fixtures/enhancements/meta-lists-flex-forms/handlers.go @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package meta_lists_flex_forms witnesses Property.AsList covering +// the meta (swagger:meta) annotation surface for list-shaped +// keywords. The meta dispatcher in builders/spec/walker.go reads +// schemes / consumes / produces via the same Property.AsList seam +// the routes dispatcher uses, so the accepted forms are identical. +// +// swagger:meta +// +// Title: +// +// Lists Flex Forms (meta) +// +// Description: +// +// Witnesses inline + multi-line list forms on the meta surface. +// +// Schemes: +// - http +// - https +// - ws +// +// Consumes: application/json +// +// Produces: +// +// application/json +// application/xml +package meta_lists_flex_forms diff --git a/fixtures/enhancements/parameters-map-postdecl/api.go b/fixtures/enhancements/parameters-map-postdecl/api.go new file mode 100644 index 0000000..1de928e --- /dev/null +++ b/fixtures/enhancements/parameters-map-postdecl/api.go @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package parameters_map_postdecl witnesses a bug in the parameters +// builder's buildFromFieldMap path. +// +// LocalItem below is deliberately NOT annotated with swagger:model. Its +// only reachable site is the map-valued body parameter on MapParams. +// The parameters builder walks into buildFromFieldMap, which spins up a +// fresh schema sub-builder for the map's value type. The sub-builder +// discovers LocalItem and registers it on its own PostDeclarations +// slice. The parameters builder is supposed to propagate that +// registration to its own AppendPostDecl chain — but +// buildFromFieldMap omits that loop (every other buildFromFieldXxx +// method has it). +// +// Without the propagation, spec.Builder.buildDiscovered never sees +// LocalItem; the resulting definitions section is missing the schema, +// and the spec is internally inconsistent (the map's value shape +// vanishes silently). +// +// The pre-fix golden captures this buggy state. The fix commit +// regenerates the golden to show LocalItem appearing in the +// definitions section. +package parameters_map_postdecl + +// LocalItem — NOT annotated; reachable only via the map field on +// MapParams below. +type LocalItem struct { + Name string `json:"name"` + Tag string `json:"tag"` +} + +// MapParams sends a map body parameter keyed by id with LocalItem values. +// +// swagger:parameters mapBody +type MapParams struct { + // Items is a body parameter of type map[string]LocalItem. + // + // in: body + // required: true + Items map[string]LocalItem `json:"items"` +} + +// swagger:operation POST /items mapBody +// +// Send a map body. +// +// --- +// responses: +// "200": +// description: OK +func _() {} diff --git a/fixtures/enhancements/raw-message-override/types.go b/fixtures/enhancements/raw-message-override/types.go new file mode 100644 index 0000000..7d0297f --- /dev/null +++ b/fixtures/enhancements/raw-message-override/types.go @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package raw_message_override exercises user-classifier overrides on +// json.RawMessage. By default a json.RawMessage value emits an empty +// schema (`{}`, "any type") via recognizeRawMessage. This fixture +// captures the three scopes where a user might want to constrain that +// to a typed schema: +// +// A. Plain field — emits `{}` (the recognizer baseline). +// B. Named wrapper type with `swagger:type` on the decl — +// honoured at both field-reference sites (classifierNamedArrayLike) +// AND at the wrapper's own top-level definition (buildFromDecl +// runs classifierNamedTypeOverride before kind-dispatch, falling +// back to Underlying() on unknown-leaf values like "array"). +// C. Field-level `swagger:type` on a json.RawMessage field — +// consumed by scanFieldDoc and applied in applyFieldCarrier +// after buildFromType. The recognizer's empty-schema default +// is replaced by the user-specified type. +package raw_message_override + +import "encoding/json" + +// PlainContainer (case A) — bare json.RawMessage field, no overrides. +// +// swagger:model PlainContainer +type PlainContainer struct { + // Should emit an empty schema (`{}`). + Payload json.RawMessage `json:"payload"` +} + +// AsObject (case B.1) — named wrapper of json.RawMessage with +// `swagger:type object`. The classifier overrides the recognizer +// because IsStdJSONRawMessage checks for encoding/json.RawMessage +// identity; the wrapper has its own package path and does NOT +// match. The classifier on the slice arm fires instead. +// +// swagger:model AsObject +// swagger:type object +type AsObject json.RawMessage + +// AsArray (case B.2) — named wrapper with `swagger:type array`. +// +// swagger:model AsArray +// swagger:type array +type AsArray json.RawMessage + +// TypedContainer (case B field reference) — references the wrappers. +// +// swagger:model TypedContainer +type TypedContainer struct { + // Wrapped via AsObject — expect `{$ref: AsObject}` whose definition + // resolves to `{type: object}` (the classifier wins on the decl). + Obj AsObject `json:"obj"` + + // Wrapped via AsArray — expect `{$ref: AsArray}` whose definition + // resolves to a typed array shape. + Arr AsArray `json:"arr"` +} + +// FieldLevelContainer (case C) — exercises field-level +// `swagger:type` on plain json.RawMessage fields. The field-level +// walker (scanFieldDoc) consumes swagger:type and applyFieldCarrier +// applies it after buildFromType, so the user override beats the +// RawMessage recognizer's empty-schema default. +// +// swagger:model FieldLevelContainer +type FieldLevelContainer struct { + // Overridden to {type: object} via field-level swagger:type. + // + // swagger:type object + Obj json.RawMessage `json:"obj"` + + // Overridden to {type: array, items: {integer, uint8}} via + // field-level swagger:type. "array" isn't a known SwaggerSchemaForType + // value, so the override falls back to building from Underlying() + // (= []byte) which yields the byte-typed array shape. + // + // swagger:type array + Arr json.RawMessage `json:"arr"` +} diff --git a/fixtures/enhancements/response-file-types/api.go b/fixtures/enhancements/response-file-types/api.go new file mode 100644 index 0000000..4bca07c --- /dev/null +++ b/fixtures/enhancements/response-file-types/api.go @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package response_file_types pins Q3's response-side fix: the +// `swagger:file` annotation on a `swagger:response` field is gated +// on `in: body`. Pre-Q3 the file branch fired unconditionally and +// rewrote `resp.Schema` to `{file, ""}` even on header-positioned +// fields — silently corrupting the body schema from a misplaced +// annotation. Per OAS v2 the allowed header types are +// {string, number, integer, boolean, array}; `file` is forbidden +// on headers. +// +// Three response shapes pinned by the golden: +// +// - FileBodyResponse — legitimate file body: `swagger:file` + +// `in: body`. resp.Schema is {file, ""}; no headers. +// - FileOnHeaderResponse — misuse: `swagger:file` + `in: header` +// (or implicit default). Diagnostic emitted; the file branch +// is skipped; the field falls through to the normal header +// build and surfaces as a regular header. +// - FileOnImplicitDefaultResponse — same misuse without the +// `in:` line at all (Q1's implicit-header default applies). +// Diagnostic still fires; field becomes a header. +package response_file_types + +// FileBodyResponse is the legitimate case: the response IS a file. +// `swagger:file` on the Body field with `in: body` rewrites +// resp.Schema to {file, ""} and skips the field build. +// +// swagger:response fileBodyResponse +type FileBodyResponse struct { + // File marks the response body as a file payload. Field shape + // mirrors the canonical v1 file response: a `[]byte` field with + // `in: body` + `swagger:file`. + // + // in: body + // swagger:file + File []byte +} + +// FileOnHeaderResponse exercises the Q3 misuse: `swagger:file` on +// a header-positioned field. Post-fix the diagnostic fires and +// the field falls through to the normal build, surfacing as a +// regular header (typed as string here). +// +// swagger:response fileOnHeaderResponse +type FileOnHeaderResponse struct { + // Misplaced has both `in: header` and `swagger:file`. The + // scanner diagnoses and treats it as a normal header. + // + // in: header + // swagger:file + Misplaced string `json:"X-Misplaced"` +} + +// FileOnImplicitDefaultResponse — `swagger:file` on a field with +// no `in:` line. After Q1 the implicit default is header, so this +// is also a misuse. Same diagnostic fires; field becomes a header. +// +// swagger:response fileOnImplicitDefaultResponse +type FileOnImplicitDefaultResponse struct { + // Implicit has only `swagger:file` — no `in:` line at all. + // Q1's implicit-header default applies; Q3's gate diagnoses. + // + // swagger:file + Implicit string `json:"X-Implicit"` +} diff --git a/fixtures/enhancements/response-header-ref-leak/api.go b/fixtures/enhancements/response-header-ref-leak/api.go new file mode 100644 index 0000000..47c1df8 --- /dev/null +++ b/fixtures/enhancements/response-header-ref-leak/api.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package response_header_ref_leak pins Q2's fix on the response +// side: a response-header field whose Go type triggers the schema +// builder's makeRef path no longer corrupts `response.Schema` with +// a $ref. OAS v2 forbids $ref on response headers entirely +// (headers are SimpleSchema, no model references allowed); the +// pre-Q2 `responseTypable.SetRef` blindly wrote `response.Schema.Ref` +// regardless of the typable's `in`. Q2 changes SetRef to no-op +// under non-body mode and flags the attempt via a `refAttempted` +// state so the SimpleSchema exit validator's HasRef probe catches +// it and emits CodeUnsupportedInSimpleSchema. +// +// Two response shapes captured by the integration test golden: +// +// - LeakResponse — header typed as a named struct, no strfmt. +// Post-fix the response carries only the header entry (still +// empty, since the named struct can't reduce to a SimpleSchema +// primitive); no body Schema; diagnostic fires. +// +// - LeakWithStrfmtResponse — same shape plus `swagger:strfmt +// uuid` on the field. Post-fix the header is {string, uuid} +// (the strfmt override fires after the exit-validator reset); +// no body Schema; the diagnostic still surfaces for the +// underlying ref attempt. +package response_header_ref_leak + +// Tag is a named struct intended to be referenceable as a model +// from body schemas. Using it as the type of a header field is the +// author misuse this fixture captures. +// +// swagger:model +type Tag struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// LeakResponse pins the post-fix shape: the header field is +// surfaced, the body schema is NOT corrupted, and the diagnostic +// signals the misuse. +// +// swagger:response leakResponse +type LeakResponse struct { + // TagHeader is a header field typed as a named struct. Pre-Q2 + // this leaked `$ref: "#/definitions/Tag"` onto resp.Schema and + // left the header empty. Post-fix resp.Schema stays nil; the + // header surfaces empty (no Type — the named struct can't + // reduce to a SimpleSchema primitive); diagnostic fires. + // + // in: header + TagHeader Tag `json:"X-Tag"` +} + +// LeakWithStrfmtResponse pins the post-fix shape under the strfmt +// override: the header gets {string, uuid}, the body schema stays +// nil, and the diagnostic still fires for the underlying ref +// attempt. +// +// swagger:response leakWithStrfmtResponse +type LeakWithStrfmtResponse struct { + // TagID is a header field with strfmt override on a named-struct + // type. The strfmt override runs after the exit-validator's + // reset, so the header surfaces as {string, uuid}. No body-schema + // leak. + // + // in: header + // swagger:strfmt uuid + TagID Tag `json:"X-Tag-ID"` +} diff --git a/fixtures/enhancements/response-implicit-header/api.go b/fixtures/enhancements/response-implicit-header/api.go new file mode 100644 index 0000000..22c82ec --- /dev/null +++ b/fixtures/enhancements/response-implicit-header/api.go @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package response_implicit_header pins Q1's response-side fix: the +// `in:` annotation on a `swagger:response` Go struct's field is a +// scanner-side discriminator (OAS v2's wire format has no `in` on +// response headers) and an omitted line now explicitly defaults to +// "header" rather than falling through an incidental +// `in != "body"` check. +// +// The package exercises four variants captured by the integration +// test golden: +// +// - empty struct → response with description only +// - all-header struct (no `in: body`) → every field a header, +// mixing implicit and explicit +// - mixed struct → body + headers (implicit + explicit) +// - invalid-`in:` struct → diagnostic emitted, field still becomes +// a header (not silently ignored) +package response_implicit_header + +// EmptyResponse — empty Go struct with a swagger:response +// annotation. Produces a response with description only; no Headers +// map, no Schema. +// +// swagger:response emptyResponse +type EmptyResponse struct{} + +// AllHeadersResponse — every field becomes a header. Etag carries +// no `in:` line at all (default); RateLimit carries an explicit +// `in: header` (parity with existing fixtures). +// +// swagger:response allHeadersResponse +type AllHeadersResponse struct { + // Etag carries an HTTP entity tag for cache validation. + // No `in:` line — defaults to header (Q1 fix). + Etag string `json:"ETag"` + + // RateLimit advertises the remaining quota. + // + // in: header + RateLimit int `json:"X-Rate-Limit"` +} + +// MixedResponse — body field plus headers (implicit + explicit). +// +// swagger:response mixedResponse +type MixedResponse struct { + // Tag is a tracing tag. Implicit header (no `in:`). + Tag string `json:"X-Tag"` + + // Limit advertises the remaining quota. + // + // in: header + Limit int `json:"X-Limit"` + + // Body is the response payload. + // + // in: body + Body struct { + Message string `json:"message"` + } `json:"body"` +} + +// InvalidInResponse — non-vocabulary `in:` value. The scanner emits +// a CodeInvalidAnnotation warning naming the bad value, then falls +// back to the default (header). +// +// swagger:response invalidInResponse +type InvalidInResponse struct { + // Cookie carries a session cookie. The `in: cookie` line is + // not in the OAS v2 vocabulary; the scanner warns and treats + // the field as a header anyway. + // + // in: cookie + Cookie string `json:"Cookie"` +} diff --git a/fixtures/enhancements/routes-description-dash-list/handlers.go b/fixtures/enhancements/routes-description-dash-list/handlers.go new file mode 100644 index 0000000..b46c5d0 --- /dev/null +++ b/fixtures/enhancements/routes-description-dash-list/handlers.go @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_description_dash_list witnesses the post-M6.5-C +// behaviour for route description lines that begin with `-` (a +// markdown list item). Before the parsePathAnnotation / +// trimCommentPrefix cleanup, routes' description handler would +// silently strip the leading `-` from every prose line. Now grammar's +// lexer's `//` strip path (trimContentPrefix) runs over each +// synthetic per-line comment, and only ` \t*/|` are stripped — `-` +// survives, matching how every other annotation handles prose. +package routes_description_dash_list + +/* CreateThing swagger:route POST /things things createThing + +Create a thing. + +This endpoint: +- accepts a thing payload +- returns 201 on success +- returns a problem document on failure + +Responses: + 200: description: OK +*/ +func CreateThing() {} diff --git a/fixtures/enhancements/routes-description-yaml-fence-absorb/handlers.go b/fixtures/enhancements/routes-description-yaml-fence-absorb/handlers.go new file mode 100644 index 0000000..5eceae2 --- /dev/null +++ b/fixtures/enhancements/routes-description-yaml-fence-absorb/handlers.go @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_description_yaml_fence_absorb witnesses the +// post-M6.5-C behaviour when a route's prose carries a stray `---` +// line. Grammar's lexer treats `---` as a YAML fence opener +// regardless of which annotation is being parsed — so lines AFTER +// the fence are captured as raw YAML body until the matching +// closing `---`, vanishing from Title / Description. +// +// QUIRK: routes don't support YAML blocks (the convention is to use +// markdown HRs sparingly and lean on blank-line paragraph breaks for +// structure). The legacy routes parser silently stripped `---` to +// empty via trimCommentPrefix, making this corner case invisible; +// the new path treats routes consistently with every other annotation +// family. Authors who want a horizontal rule should use another form +// (e.g., a long em-dash run) or accept the loss of subsequent prose +// to the YAML capture. +package routes_description_yaml_fence_absorb + +/* GetThing swagger:route GET /things/{id} things getThing + +Get a thing. + +Some intro prose here. + +--- + +Hidden behind the YAML fence — this prose is absorbed as YAML body +and never reaches the Description. + +Responses: + 200: description: OK +*/ +func GetThing() {} diff --git a/fixtures/enhancements/routes-full-petstore-shape/handlers.go b/fixtures/enhancements/routes-full-petstore-shape/handlers.go new file mode 100644 index 0000000..db77aea --- /dev/null +++ b/fixtures/enhancements/routes-full-petstore-shape/handlers.go @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_full_petstore_shape exercises a swagger:route block +// using the full petstore-style metadata surface (Consumes / Produces / +// Schemes / Security / Tags / Parameters / Responses / Extensions) +// alongside the `+ name:` parameter sub-grammar. +package routes_full_petstore_shape + +// Pet is a pet on offer. +// +// swagger:model +type Pet struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// GenericError is the catch-all error response. +// +// swagger:response genericError +type GenericError struct { + // in: body + Body struct { + Message string `json:"message"` + } +} + +// CreatePet swagger:route POST /pets pets users createPetFull +// +// Create a pet with the full metadata surface. +// +// Consumes: +// - application/json +// - application/x-protobuf +// +// Produces: +// - application/json +// +// Schemes: http, https +// +// Security: +// +// api_key: +// oauth: read, write +// +// Parameters: +// + name: trace +// in: query +// description: enable tracing +// type: boolean +// default: false +// + name: body +// in: body +// description: pet to create +// required: true +// type: Pet +// +// Responses: +// +// 201: body:Pet the created pet +// default: response:genericError +// +// Extensions: +// +// x-route-flag: true +func CreatePet() {} diff --git a/fixtures/enhancements/routes-lists-flex-forms/handlers.go b/fixtures/enhancements/routes-lists-flex-forms/handlers.go new file mode 100644 index 0000000..052e213 --- /dev/null +++ b/fixtures/enhancements/routes-lists-flex-forms/handlers.go @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_lists_flex_forms witnesses the unified +// Property.AsList accepted surface forms for list-shaped keywords +// (schemes / consumes / produces). M6.5-D widened KwSchemes from +// asCommaList() to asRawBlock(), added inline-value capture to +// collectRawBlock, and centralised list parsing in Block.GetList +// → Property.AsList. Every form below should produce the same +// underlying token list. +package routes_lists_flex_forms + +// CommaInline swagger:route GET /alpha lists commaInline +// +// Inline comma-separated form. +// +// Schemes: http, https +// Consumes: application/json +// Produces: application/json +// +// Responses: +// +// 200: description: OK +func CommaInline() {} + +// YAMLDash swagger:route GET /beta lists yamlDash +// +// Multi-line YAML-dash form. +// +// Schemes: +// - http +// - https +// +// Consumes: +// - application/json +// - application/xml +// +// Produces: +// - application/json +// +// Responses: +// +// 200: description: OK +func YAMLDash() {} + +// BareLines swagger:route GET /gamma lists bareLines +// +// Multi-line indented bare-lines form (no `-` markers). +// +// Consumes: +// +// application/json +// application/xml +// +// Produces: +// +// application/json +// +// Responses: +// +// 200: description: OK +func BareLines() {} + +// MixedInlineAndYAML swagger:route GET /delta lists mixedInlineYAML +// +// Inline-plus-indented continuation. +// +// Schemes: http +// - https +// - ws +// +// Responses: +// +// 200: description: OK +func MixedInlineAndYAML() {} diff --git a/fixtures/enhancements/routes-multi-method-same-path/handlers.go b/fixtures/enhancements/routes-multi-method-same-path/handlers.go new file mode 100644 index 0000000..b39f8a0 --- /dev/null +++ b/fixtures/enhancements/routes-multi-method-same-path/handlers.go @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_multi_method_same_path exercises two handlers on the +// same path (GET + POST) with different parameter/response shapes — +// confirms the routes builder merges multiple operations under one +// path entry without cross-contamination. +package routes_multi_method_same_path + +// Pet is a pet on offer. +// +// swagger:model +type Pet struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// ListPets swagger:route GET /pets pets listPetsMulti +// +// List pets. +// +// Parameters: +// + name: limit +// in: query +// description: page size +// type: integer +// min: 1 +// max: 100 +// +// Responses: +// +// 200: body:[]Pet pets +func ListPets() {} + +// CreatePet swagger:route POST /pets pets createPetMulti +// +// Create a pet. +// +// Parameters: +// + name: body +// in: body +// description: pet to create +// required: true +// type: Pet +// +// Responses: +// +// 201: body:Pet the created pet +func CreatePet() {} diff --git a/fixtures/enhancements/routes-params-body-array-nested/handlers.go b/fixtures/enhancements/routes-params-body-array-nested/handlers.go new file mode 100644 index 0000000..e70fefd --- /dev/null +++ b/fixtures/enhancements/routes-params-body-array-nested/handlers.go @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_params_body_array_nested exercises nested-array body +// types: `type: [][]Item`. +package routes_params_body_array_nested + +// Item is one item. +// +// swagger:model +type Item struct { + Name string `json:"name"` +} + +// SubmitMatrix swagger:route POST /matrix items submitMatrix +// +// Submit a matrix of items. +// +// Parameters: +// + name: body +// in: body +// description: nested grid of items +// required: true +// type: [][]Item +// +// Responses: +// +// 200: description: OK +func SubmitMatrix() {} diff --git a/fixtures/enhancements/routes-params-body-array/handlers.go b/fixtures/enhancements/routes-params-body-array/handlers.go new file mode 100644 index 0000000..095f706 --- /dev/null +++ b/fixtures/enhancements/routes-params-body-array/handlers.go @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_params_body_array exercises a body parameter that is +// an array of a referenced model: `type: []Pet`. +package routes_params_body_array + +// Pet is a pet on offer. +// +// swagger:model +type Pet struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// CreatePets swagger:route POST /pets pets createPets +// +// Create several pets. +// +// Parameters: +// + name: body +// in: body +// description: pets to create +// required: true +// type: []Pet +// +// Responses: +// +// 201: description: created +func CreatePets() {} diff --git a/fixtures/enhancements/routes-params-body-ref/handlers.go b/fixtures/enhancements/routes-params-body-ref/handlers.go new file mode 100644 index 0000000..11a7292 --- /dev/null +++ b/fixtures/enhancements/routes-params-body-ref/handlers.go @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_params_body_ref exercises a body parameter referencing +// a swagger:model. +package routes_params_body_ref + +// Pet is a pet on offer. +// +// swagger:model +type Pet struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// CreatePet swagger:route POST /pets pets createPet +// +// Create a new pet. +// +// Parameters: +// + name: body +// in: body +// description: pet to create +// required: true +// type: Pet +// +// Responses: +// +// 201: description: created +func CreatePet() {} diff --git a/fixtures/enhancements/routes-params-body-with-schema-validations/handlers.go b/fixtures/enhancements/routes-params-body-with-schema-validations/handlers.go new file mode 100644 index 0000000..6912517 --- /dev/null +++ b/fixtures/enhancements/routes-params-body-with-schema-validations/handlers.go @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_params_body_with_schema_validations exercises the +// "force-the-spec" case: a body ref to a known model plus inline +// schema validations that land on param.Schema.{Minimum,Maximum,...} +// per legacy processSchema behavior. +package routes_params_body_with_schema_validations + +// Pet is a pet on offer. +// +// swagger:model +type Pet struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// CreatePet swagger:route POST /pets pets createPetWithOverrides +// +// Create a pet with author-asserted schema constraints. +// +// Parameters: +// + name: body +// in: body +// description: pet to create +// required: true +// type: Pet +// min: 0 +// max: 999 +// format: special +// +// Responses: +// +// 201: description: created +func CreatePet() {} diff --git a/fixtures/enhancements/routes-params-empty-chunk/handlers.go b/fixtures/enhancements/routes-params-empty-chunk/handlers.go new file mode 100644 index 0000000..51b7fc3 --- /dev/null +++ b/fixtures/enhancements/routes-params-empty-chunk/handlers.go @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_params_empty_chunk exercises a bare `+` chunk with +// no following key:value lines. Legacy SetOpParams.Parse appends an +// empty parameter object. +package routes_params_empty_chunk + +// EmptyChunk swagger:route GET /empty items emptyChunkOp +// +// Endpoint with an empty parameter chunk. +// +// Parameters: +// + +// +// Responses: +// +// 200: description: OK +func EmptyChunk() {} diff --git a/fixtures/enhancements/routes-params-form-string/handlers.go b/fixtures/enhancements/routes-params-form-string/handlers.go new file mode 100644 index 0000000..267a50b --- /dev/null +++ b/fixtures/enhancements/routes-params-form-string/handlers.go @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_params_form_string exercises a form string parameter +// with allowempty: true. +package routes_params_form_string + +// SubmitForm swagger:route POST /forms forms submitForm +// +// Submit a form. +// +// Parameters: +// + name: comment +// in: form +// description: optional comment +// type: string +// allowempty: true +// +// Responses: +// +// 200: description: OK +func SubmitForm() {} diff --git a/fixtures/enhancements/routes-params-header-string/handlers.go b/fixtures/enhancements/routes-params-header-string/handlers.go new file mode 100644 index 0000000..0e0ab67 --- /dev/null +++ b/fixtures/enhancements/routes-params-header-string/handlers.go @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_params_header_string exercises a header string +// parameter with pattern and format. +package routes_params_header_string + +// SecureItems swagger:route GET /items items secureItems +// +// List items with auth header. +// +// Parameters: +// + name: X-Request-ID +// in: header +// description: request correlation id +// type: string +// pattern: ^[0-9a-fA-F-]+$ +// format: uuid +// required: true +// +// Responses: +// +// 200: description: OK +func SecureItems() {} diff --git a/fixtures/enhancements/routes-params-multiple/handlers.go b/fixtures/enhancements/routes-params-multiple/handlers.go new file mode 100644 index 0000000..5ff6e83 --- /dev/null +++ b/fixtures/enhancements/routes-params-multiple/handlers.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_params_multiple exercises three chained parameters +// mixing in: values (path, query, body). +package routes_params_multiple + +// Pet is a pet on offer. +// +// swagger:model +type Pet struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// UpdatePet swagger:route PUT /pets/{id} pets updatePet +// +// Update a pet identified by id. +// +// Parameters: +// + name: id +// in: path +// description: pet identifier +// required: true +// type: integer +// + name: dryRun +// in: query +// description: validate without persisting +// type: boolean +// default: false +// + name: body +// in: body +// description: updated pet +// required: true +// type: Pet +// +// Responses: +// +// 200: description: OK +func UpdatePet() {} diff --git a/fixtures/enhancements/routes-params-path/handlers.go b/fixtures/enhancements/routes-params-path/handlers.go new file mode 100644 index 0000000..11d9791 --- /dev/null +++ b/fixtures/enhancements/routes-params-path/handlers.go @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_params_path exercises a single path parameter declared +// via the swagger:route `+ name:` form. +package routes_params_path + +// GetItem swagger:route GET /items/{id} items getItem +// +// Get an item by id. +// +// Parameters: +// + name: id +// in: path +// description: item identifier +// required: true +// type: integer +// +// Responses: +// +// 200: description: OK +func GetItem() {} diff --git a/fixtures/enhancements/routes-params-query-array/handlers.go b/fixtures/enhancements/routes-params-query-array/handlers.go new file mode 100644 index 0000000..64a5854 --- /dev/null +++ b/fixtures/enhancements/routes-params-query-array/handlers.go @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_params_query_array exercises a query array parameter +// with enum + minlength/maxlength validations. +package routes_params_query_array + +// FilterByTags swagger:route GET /items items filterByTags +// +// Filter items by tags. +// +// Parameters: +// + name: tags +// in: query +// description: tags filter +// type: array +// enum: red,green,blue +// minlength: 1 +// maxlength: 5 +// +// Responses: +// +// 200: description: OK +func FilterByTags() {} diff --git a/fixtures/enhancements/routes-params-query-boolean/handlers.go b/fixtures/enhancements/routes-params-query-boolean/handlers.go new file mode 100644 index 0000000..6c4d4c8 --- /dev/null +++ b/fixtures/enhancements/routes-params-query-boolean/handlers.go @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_params_query_boolean exercises a query boolean +// parameter with a default value. Also covers the `bool` -> `boolean` +// type lowering quirk. +package routes_params_query_boolean + +// ListItems swagger:route GET /items items listItemsBool +// +// List items, optionally including archived. +// +// Parameters: +// + name: includeArchived +// in: query +// description: include archived items +// type: bool +// default: false +// +// Responses: +// +// 200: description: OK +func ListItems() {} diff --git a/fixtures/enhancements/routes-params-query-number/handlers.go b/fixtures/enhancements/routes-params-query-number/handlers.go new file mode 100644 index 0000000..5b5f8c1 --- /dev/null +++ b/fixtures/enhancements/routes-params-query-number/handlers.go @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_params_query_number exercises a query number parameter +// with min/max/default landed via legacy short-form keys. +package routes_params_query_number + +// FilterItems swagger:route GET /items items filterItems +// +// Filter items by price. +// +// Parameters: +// + name: price +// in: query +// description: maximum price +// type: number +// min: 0.0 +// max: 999.99 +// default: 100.0 +// +// Responses: +// +// 200: description: OK +func FilterItems() {} diff --git a/fixtures/enhancements/routes-params-query-string/handlers.go b/fixtures/enhancements/routes-params-query-string/handlers.go new file mode 100644 index 0000000..6994285 --- /dev/null +++ b/fixtures/enhancements/routes-params-query-string/handlers.go @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_params_query_string exercises a query string parameter +// with pattern and format validations. +package routes_params_query_string + +// SearchItems swagger:route GET /items items searchItems +// +// Search items by tag. +// +// Parameters: +// + name: tag +// in: query +// description: tag to filter by +// type: string +// pattern: ^[a-z]+$ +// format: byte +// +// Responses: +// +// 200: description: OK +func SearchItems() {} diff --git a/fixtures/enhancements/routes-params-query-validations/handlers.go b/fixtures/enhancements/routes-params-query-validations/handlers.go new file mode 100644 index 0000000..6256873 --- /dev/null +++ b/fixtures/enhancements/routes-params-query-validations/handlers.go @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_params_query_validations exercises query parameters +// carrying SimpleSchema validations (min, max, default, enum). +package routes_params_query_validations + +// ListItems swagger:route GET /items items listItems +// +// List items filtered by parameters. +// +// Parameters: +// + name: limit +// in: query +// description: maximum number of items returned +// type: integer +// min: 1 +// max: 100 +// default: 20 +// + name: status +// in: query +// description: filter by status +// type: string +// enum: pending,active,archived +// +// Responses: +// +// 200: description: OK +func ListItems() {} diff --git a/fixtures/enhancements/routes-params-unknown-key/handlers.go b/fixtures/enhancements/routes-params-unknown-key/handlers.go new file mode 100644 index 0000000..6e16c38 --- /dev/null +++ b/fixtures/enhancements/routes-params-unknown-key/handlers.go @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_params_unknown_key exercises silent-drop of unknown +// param keys: `colour: blue` is unknown to applyParamField and is +// stashed in extraData; processSchema only reads known schema keys +// (min/max/etc.) so `colour` is silently dropped. +package routes_params_unknown_key + +// ListItems swagger:route GET /items items listItemsUnknown +// +// List items. +// +// Parameters: +// + name: limit +// in: query +// description: max results +// type: integer +// colour: blue +// min: 1 +// +// Responses: +// +// 200: description: OK +func ListItems() {} diff --git a/fixtures/enhancements/routes-responses-array/handlers.go b/fixtures/enhancements/routes-responses-array/handlers.go new file mode 100644 index 0000000..6a9da9b --- /dev/null +++ b/fixtures/enhancements/routes-responses-array/handlers.go @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_responses_array exercises array-shaped body responses, +// including the nested `[][]` form. +package routes_responses_array + +// Pet is a pet on offer. +// +// swagger:model +type Pet struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// Item is one item. +// +// swagger:model +type Item struct { + Name string `json:"name"` +} + +// ListPets swagger:route GET /pets pets listPetsArray +// +// List pets and a matrix of items. +// +// Responses: +// +// 200: body:[]Pet the pet list +// 202: body:[][]Item items grid +func ListPets() {} diff --git a/fixtures/enhancements/routes-responses-default/handlers.go b/fixtures/enhancements/routes-responses-default/handlers.go new file mode 100644 index 0000000..17013cf --- /dev/null +++ b/fixtures/enhancements/routes-responses-default/handlers.go @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_responses_default exercises a route with only a +// `default:` response. +package routes_responses_default + +// GenericError is the catch-all error response. +// +// swagger:response genericError +type GenericError struct { + // in: body + Body struct { + Message string `json:"message"` + } +} + +// Health swagger:route GET /health ops health +// +// Health probe. +// +// Responses: +// +// default: genericError +func Health() {} diff --git a/fixtures/enhancements/routes-responses-definition-fallback/handlers.go b/fixtures/enhancements/routes-responses-definition-fallback/handlers.go new file mode 100644 index 0000000..1fde825 --- /dev/null +++ b/fixtures/enhancements/routes-responses-definition-fallback/handlers.go @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_responses_definition_fallback exercises the legacy +// silent flip from response-ref to body-ref when the untagged name is +// not in the operation's responses map but IS in definitions +// (parseResponseLine lines 83-87). Pet is a model, not a response, so +// `200: Pet` should resolve as a body ref to #/definitions/Pet. +package routes_responses_definition_fallback + +// Pet is a pet on offer. +// +// swagger:model +type Pet struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// GetPet swagger:route GET /pets/{id} pets getPetFallback +// +// Get a pet. +// +// Responses: +// +// 200: Pet +func GetPet() {} diff --git a/fixtures/enhancements/routes-responses-description-only/handlers.go b/fixtures/enhancements/routes-responses-description-only/handlers.go new file mode 100644 index 0000000..daaf79e --- /dev/null +++ b/fixtures/enhancements/routes-responses-description-only/handlers.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_responses_description_only exercises response lines +// carrying only a description: tag with no body/response ref. +package routes_responses_description_only + +// GetItem swagger:route GET /items/{id} items getItemDescOnly +// +// Get an item. +// +// Responses: +// +// 200: description: OK +// 404: description: not found +// 500: description: server error +func GetItem() {} diff --git a/fixtures/enhancements/routes-responses-empty-value/handlers.go b/fixtures/enhancements/routes-responses-empty-value/handlers.go new file mode 100644 index 0000000..f1d681f --- /dev/null +++ b/fixtures/enhancements/routes-responses-empty-value/handlers.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_responses_empty_value exercises an empty response +// value: `204:` with nothing after the colon — legacy produces an +// empty Response{} under the code. +package routes_responses_empty_value + +// DeleteItem swagger:route DELETE /items/{id} items deleteItemEmpty +// +// Delete an item. +// +// Responses: +// +// 204: +// 404: description: not found +func DeleteItem() {} diff --git a/fixtures/enhancements/routes-responses-mixed-bodies/handlers.go b/fixtures/enhancements/routes-responses-mixed-bodies/handlers.go new file mode 100644 index 0000000..97fffc9 --- /dev/null +++ b/fixtures/enhancements/routes-responses-mixed-bodies/handlers.go @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_responses_mixed_bodies exercises a single route mixing +// body refs (schema) and response refs across status codes. +package routes_responses_mixed_bodies + +// Pet is a pet on offer. +// +// swagger:model +type Pet struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// GenericError is the catch-all error response. +// +// swagger:response genericError +type GenericError struct { + // in: body + Body struct { + Message string `json:"message"` + } +} + +// GetPet swagger:route GET /pets/{id} pets getPetMixed +// +// Get a pet. +// +// Responses: +// +// 200: body:Pet the pet +// default: response:genericError +func GetPet() {} diff --git a/fixtures/enhancements/routes-responses-multiple-codes/handlers.go b/fixtures/enhancements/routes-responses-multiple-codes/handlers.go new file mode 100644 index 0000000..83064c3 --- /dev/null +++ b/fixtures/enhancements/routes-responses-multiple-codes/handlers.go @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_responses_multiple_codes exercises a route with +// default + 2xx + 4xx + 5xx response codes in one block. +package routes_responses_multiple_codes + +// Pet is a pet on offer. +// +// swagger:model +type Pet struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// GenericError is the catch-all error response. +// +// swagger:response genericError +type GenericError struct { + // in: body + Body struct { + Message string `json:"message"` + } +} + +// GetPet swagger:route GET /pets/{id} pets getPetMultiCode +// +// Get a pet. +// +// Responses: +// +// 200: body:Pet the pet +// 404: description: not found +// 500: description: server error +// default: response:genericError +func GetPet() {} diff --git a/fixtures/enhancements/routes-responses-positional/handlers.go b/fixtures/enhancements/routes-responses-positional/handlers.go new file mode 100644 index 0000000..4fa44d1 --- /dev/null +++ b/fixtures/enhancements/routes-responses-positional/handlers.go @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_responses_positional exercises the untagged-first-token +// response form: `200: someResponse` is treated as response="someResponse". +package routes_responses_positional + +// SomeResponse is a top-level response object. +// +// swagger:response someResponse +type SomeResponse struct { + // in: body + Body struct { + Status string `json:"status"` + } +} + +// ListItems swagger:route GET /items items listItemsPositional +// +// List items. +// +// Responses: +// +// 200: someResponse +func ListItems() {} diff --git a/fixtures/enhancements/routes-responses-ref-not-found/handlers.go b/fixtures/enhancements/routes-responses-ref-not-found/handlers.go new file mode 100644 index 0000000..dbf76aa --- /dev/null +++ b/fixtures/enhancements/routes-responses-ref-not-found/handlers.go @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_responses_ref_not_found exercises the not-found branch +// of the legacy parseResponseLine fallback (lines 83-87). When an +// untagged response name is missing from BOTH the responses map AND +// the definitions map, the legacy parser silently emits a dangling +// `$ref: #/responses/` that points at nothing. +// +// QUIRK: dangling refs are not flagged. Companion of +// routes-responses-definition-fallback which exercises the found +// branch. Together they witness both halves of the fallback logic. +package routes_responses_ref_not_found + +// ListItems swagger:route GET /items items listItemsRefNotFound +// +// List items. +// +// Responses: +// +// 200: doesNotExistAnywhere +func ListItems() {} diff --git a/fixtures/enhancements/routes-responses-space-body-quirk/handlers.go b/fixtures/enhancements/routes-responses-space-body-quirk/handlers.go new file mode 100644 index 0000000..8286d9d --- /dev/null +++ b/fixtures/enhancements/routes-responses-space-body-quirk/handlers.go @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_responses_space_body_quirk locks the legacy parser's +// interpretation of `200: body Pet ...` (space-separated, NOT +// colon-attached `body:Pet`). Legacy parseTags treats the first +// untagged token as a response name — so "body" becomes the response +// ref and "Pet the pet" becomes the description. +// +// This is malformed input that the parser silently mishandles; the +// fixture locks the misinterpretation so any post-refactor change in +// behaviour shows up in the golden diff for explicit review. +package routes_responses_space_body_quirk + +// GetPet swagger:route GET /pets/{id} pets getPetSpaceQuirk +// +// Get a pet. +// +// Responses: +// +// 200: body Pet the pet +func GetPet() {} diff --git a/fixtures/enhancements/routes-responses-tagged-body/handlers.go b/fixtures/enhancements/routes-responses-tagged-body/handlers.go new file mode 100644 index 0000000..5fb6edd --- /dev/null +++ b/fixtures/enhancements/routes-responses-tagged-body/handlers.go @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_responses_tagged_body exercises response lines carrying +// the `body:` tag with a positional description tail. +package routes_responses_tagged_body + +// Pet is a pet on offer. +// +// swagger:model +type Pet struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// GetPet swagger:route GET /pets/{id} pets getPet +// +// Get a pet by id. +// +// Responses: +// +// 200: body:Pet the pet as returned +// 404: description: not found +func GetPet() {} diff --git a/fixtures/enhancements/routes-responses-tagged-response/handlers.go b/fixtures/enhancements/routes-responses-tagged-response/handlers.go new file mode 100644 index 0000000..4c80a1b --- /dev/null +++ b/fixtures/enhancements/routes-responses-tagged-response/handlers.go @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package routes_responses_tagged_response exercises the explicit +// `response:someName` tag form. +package routes_responses_tagged_response + +// GenericError is the catch-all error response. +// +// swagger:response genericError +type GenericError struct { + // in: body + Body struct { + Message string `json:"message"` + } +} + +// ListItems swagger:route GET /items items listItemsTaggedResp +// +// List items. +// +// Responses: +// +// default: response:genericError +// 200: description: OK +func ListItems() {} diff --git a/fixtures/enhancements/simple-schema-readonly/api.go b/fixtures/enhancements/simple-schema-readonly/api.go new file mode 100644 index 0000000..6827333 --- /dev/null +++ b/fixtures/enhancements/simple-schema-readonly/api.go @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package simple_schema_readonly exercises the schema builder's +// SimpleSchema-mode gate on the full-Schema-only `readOnly:` +// keyword. The fixture writes `readOnly: true` on a struct property +// reached during a non-body parameter resolution; the schema +// builder's Bool dispatcher emits CodeUnsupportedInSimpleSchema and +// skips the write. +// +// The Filter field must be an **anonymous** struct so the schema +// builder walks it inline rather than emitting a $ref (named +// structs short-circuit via resolveRefOr in buildNamedType and +// never reach walkSchemaLevel under SimpleSchema mode). +package simple_schema_readonly + +// ReadOnlyParams demonstrates a non-body parameter typed as an +// anonymous struct, triggering the SimpleSchema-mode descent +// through the schema builder's struct walker. +// +// swagger:parameters readOnlyOp +type ReadOnlyParams struct { + // Filter is the offending parameter — typed as an anonymous + // struct under `in: query` is itself outside SimpleSchema's + // allowed type set, but the schema builder walks it inline + // (rather than $ref'ing it) and so reaches the inner + // `readOnly:` annotation. The gate emits + // CodeUnsupportedInSimpleSchema and skips the write. + // + // in: query + Filter struct { + // Hidden is meant to be read-only — but `readOnly:` is a + // full-Schema-only keyword and cannot appear under + // SimpleSchema mode. + // + // readOnly: true + Hidden bool `json:"hidden"` + } `json:"filter"` +} + +// DoReadOnly handles the violating route. +// +// swagger:route GET /readonly ro readOnlyOp +// +// Responses: +// +// 200: description: OK +func DoReadOnly() {} diff --git a/fixtures/enhancements/simple-schema-violation/api.go b/fixtures/enhancements/simple-schema-violation/api.go new file mode 100644 index 0000000..0ff8d17 --- /dev/null +++ b/fixtures/enhancements/simple-schema-violation/api.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package simple_schema_violation exercises the M1 exit validator on +// the parameter SimpleSchema path. A non-body parameter typed as a +// named string with a `swagger:type object` decl-level override +// resolves to a schema with `Type == "object"` — invalid under OAS v2 +// SimpleSchema. The exit validator emits +// CodeUnsupportedInSimpleSchema and resets the target to empty `{}`. +package simple_schema_violation + +// ObjectOverride is a named string carrying a decl-level +// `swagger:type object` override. The override is honoured by the +// schema builder (classifierNamedBasic arm); under SimpleSchema mode +// the exit validator catches the resulting `Type == "object"` and +// resets the parameter back to empty `{}`. +// +// swagger:type object +type ObjectOverride string + +// ViolatingParams demonstrates a query parameter whose Go type +// resolves to an object-shaped schema — invalid under SimpleSchema. +// +// swagger:parameters violationOp +type ViolatingParams struct { + // Bad is the offending parameter — its type carries a + // decl-level override that the schema builder honours, producing + // an object-typed SimpleSchema. The M1 exit validator emits a + // CodeUnsupportedInSimpleSchema diagnostic and wipes the target. + // + // in: query + Bad ObjectOverride `json:"bad"` +} + +// DoViolation handles the violating route. +// +// swagger:route GET /violation viol violationOp +// +// Responses: +// +// 200: description: OK +func DoViolation() {} diff --git a/fixtures/enhancements/text-marshal/explicit_override/types.go b/fixtures/enhancements/text-marshal/explicit_override/types.go new file mode 100644 index 0000000..b9503b9 --- /dev/null +++ b/fixtures/enhancements/text-marshal/explicit_override/types.go @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package explicit_override exercises the precedence rule inside +// buildFromTextMarshal: an explicit `swagger:strfmt` annotation on a +// UUID-named TextMarshaler wins over the implicit UUID heuristic. +// +// Under the old order the heuristic ran first; this `UUID` would have +// been emitted as `{string, uuid}` even though the user explicitly +// annotated it as a `date`. The new order runs the classifier first. +package explicit_override + +// UUID is a TextMarshaler whose name matches the case-insensitive +// UUID heuristic but carries an explicit `swagger:strfmt date` +// override. Expected output: `{string, date}` — not `{string, uuid}`. +// +// swagger:strfmt date +type UUID [16]byte + +// MarshalText renders the UUID as text. +func (u UUID) MarshalText() ([]byte, error) { return nil, nil } + +// UnmarshalText parses the UUID from text. +func (u *UUID) UnmarshalText([]byte) error { return nil } + +// OverrideContainer references UUID so the schema builder walks +// the override path. +// +// swagger:model override_container +type OverrideContainer struct { + // required: true + ID UUID `json:"id"` +} diff --git a/fixtures/enhancements/text-marshal/uuid_wrapping_time/types.go b/fixtures/enhancements/text-marshal/uuid_wrapping_time/types.go new file mode 100644 index 0000000..839e9fc --- /dev/null +++ b/fixtures/enhancements/text-marshal/uuid_wrapping_time/types.go @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package uuid_wrapping_time documents the asymmetry between *being* +// `time.Time` and *wrapping* it. A user type named `UUID` that embeds +// time.Time inherits MarshalText via method promotion (it does +// implement TextMarshaler), but the stdlib trio recognizer +// (`IsStdTime`) only matches the literal stdlib `time.Time` TypeName. +// Wrapping does not count. +// +// The UUID heuristic still matches by name though, so this type emits +// as `{string, uuid}` — not as a `date-time` string and not as a +// struct expansion. +package uuid_wrapping_time + +import "time" + +// UUID embeds time.Time and inherits its MarshalText method. The +// stdlib trio doesn't fire (UUID's Obj is in this package, not +// "time"), the user provided no `swagger:strfmt`, so the UUID name +// heuristic wins: emits as `{string, uuid}`. +type UUID struct { + time.Time +} + +// WrappingTimeContainer references UUID so the schema builder walks +// the heuristic path. +// +// swagger:model wrapping_time_container +type WrappingTimeContainer struct { + // required: true + ID UUID `json:"id"` +} diff --git a/fixtures/enhancements/wrapper-decl-type-override/types.go b/fixtures/enhancements/wrapper-decl-type-override/types.go new file mode 100644 index 0000000..974b6b2 --- /dev/null +++ b/fixtures/enhancements/wrapper-decl-type-override/types.go @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package wrapper_decl_type_override isolates Gap B' — the +// wrapper-type's top-level definition emits an empty schema even +// though the same wrapper carries `swagger:type` decoration that +// the classifier honours at field reference sites. +// +// No referencing struct is declared here: the wrapper itself is the +// only top-level model, so the golden makes the gap unambiguous. +// +// Expected (target shape): the top-level `BareWrapperObject` +// definition should be `{type: object}`; `BareWrapperArray` should +// be `{type: array, items: {integer, uint8}}`. Today both come out +// empty (`x-go-package` + description only). +package wrapper_decl_type_override + +import "encoding/json" + +// BareWrapperObject — named wrapper of json.RawMessage with +// `swagger:type object`. No field references it; the only schema +// the scanner emits for this package is the top-level definition. +// +// swagger:model BareWrapperObject +// swagger:type object +type BareWrapperObject json.RawMessage + +// BareWrapperArray — named wrapper with `swagger:type array`. +// No field references. +// +// swagger:model BareWrapperArray +// swagger:type array +type BareWrapperArray json.RawMessage diff --git a/fixtures/goparsing/bookings/api.go b/fixtures/goparsing/bookings/api.go index 8d1522e..0dec00a 100644 --- a/fixtures/goparsing/bookings/api.go +++ b/fixtures/goparsing/bookings/api.go @@ -64,12 +64,12 @@ type BookingResponse struct { // Bookings lists all the appointments that have been made on the site. // // Consumes: -// application/json +// - application/json // // Schemes: http, https // // Produces: -// application/json +// - application/json // // Responses: // 200: BookingResponse diff --git a/fixtures/goparsing/classification/operations/todo_operation.go b/fixtures/goparsing/classification/operations/todo_operation.go index a12c6b5..0b4af29 100644 --- a/fixtures/goparsing/classification/operations/todo_operation.go +++ b/fixtures/goparsing/classification/operations/todo_operation.go @@ -30,12 +30,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // You can get the pets that are out of stock // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -81,12 +81,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // lists orders filtered by some parameters. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -119,12 +119,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // create an order based on the parameters. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -143,12 +143,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // gets the details for an order. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -170,12 +170,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // When the order doesn't exist this will return an error. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -194,12 +194,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // delete a particular order. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // diff --git a/fixtures/goparsing/classification/operations_body/todo_operation_body.go b/fixtures/goparsing/classification/operations_body/todo_operation_body.go index beb9139..a407201 100644 --- a/fixtures/goparsing/classification/operations_body/todo_operation_body.go +++ b/fixtures/goparsing/classification/operations_body/todo_operation_body.go @@ -30,12 +30,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // You can get the pets that are out of stock // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -93,12 +93,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // lists orders filtered by some parameters. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -133,12 +133,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // create an order based on the parameters. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -169,12 +169,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // gets the details for an order. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -195,12 +195,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // When the order doesn't exist this will return an error. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -219,12 +219,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // delete a particular order. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // @@ -243,12 +243,12 @@ func ServeAPI(host, basePath string, schemes []string) error { // Allow some params with constraints. // // Consumes: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Produces: - // application/json - // application/x-protobuf + // - application/json + // - application/x-protobuf // // Schemes: http, https, ws, wss // diff --git a/fixtures/goparsing/spec/api.go b/fixtures/goparsing/spec/api.go index 5234744..abfe8ff 100644 --- a/fixtures/goparsing/spec/api.go +++ b/fixtures/goparsing/spec/api.go @@ -70,14 +70,14 @@ type BookingResponse struct { // Bookings lists all the appointments that have been made on the site. // // Consumes: -// application/json +// - application/json // // Deprecated: true // // Schemes: http, https // // Produces: -// application/json +// - application/json // // Responses: // 200: BookingResponse diff --git a/fixtures/integration/golden/api_spec_go111.json b/fixtures/integration/golden/api_spec_go111.json index 4d11762..663ee27 100644 --- a/fixtures/integration/golden/api_spec_go111.json +++ b/fixtures/integration/golden/api_spec_go111.json @@ -1,119 +1,121 @@ { - "consumes":[ + "consumes": [ "application/json" ], - "produces":[ + "produces": [ "application/json" ], - "schemes":[ + "schemes": [ "https" ], - "swagger":"2.0", - "info":{ - "description":"the purpose of this application is to provide an application\nthat is using plain go code to define an API", - "title":"API.", - "version":"0.0.1" + "swagger": "2.0", + "info": { + "description": "the purpose of this application is to provide an application\nthat is using plain go code to define an API", + "title": "API.", + "version": "0.0.1" }, - "host":"localhost", - "paths":{ - "/admin/bookings/":{ - "get":{ - "consumes":[ + "host": "localhost", + "paths": { + "/admin/bookings/": { + "get": { + "consumes": [ "application/json" ], - "produces":[ + "produces": [ "application/json" ], - "schemes":[ + "schemes": [ "http", "https" ], - "tags":[ + "tags": [ "booking" ], - "summary":"Bookings lists all the appointments that have been made on the site.", + "summary": "Bookings lists all the appointments that have been made on the site.", + "operationId": "Bookings", "deprecated": true, - "operationId":"Bookings", - "responses":{ - "200":{ - "$ref":"#/responses/BookingResponse" + "responses": { + "200": { + "$ref": "#/responses/BookingResponse" } } } } }, - "definitions":{ - "Booking":{ - "description":"A Booking in the system", - "type":"object", - "required":[ + "definitions": { + "Booking": { + "description": "A Booking in the system", + "type": "object", + "required": [ "id", "Subject" ], - "properties":{ - "Subject":{ - "description":"Subject the subject of this booking", - "type":"string" + "properties": { + "Subject": { + "description": "Subject the subject of this booking", + "type": "string" }, - "id":{ - "description":"ID the id of the booking", - "type":"integer", - "format":"int64", - "x-go-name":"ID", - "readOnly":true + "id": { + "description": "ID the id of the booking", + "type": "integer", + "format": "int64", + "x-go-name": "ID", + "readOnly": true } }, - "x-go-package":"github.com/go-swagger/scan-repo-boundary/makeplans" + "x-go-package": "github.com/go-swagger/scan-repo-boundary/makeplans" }, - "Customer":{ - "type":"object", - "title":"Customer of the site.", - "properties":{ - "name":{ - "type":"string", - "x-go-name":"Name" + "Customer": { + "type": "object", + "title": "Customer of the site.", + "properties": { + "name": { + "type": "string", + "x-go-name": "Name" } }, - "x-go-package":"github.com/go-openapi/codescan/fixtures/goparsing/spec" + "x-go-package": "github.com/go-openapi/codescan/fixtures/goparsing/spec" }, - "DateRange":{ - "description":"DateRange represents a scheduled appointments time\nDateRange should be in definitions since it's being used in a response", - "type":"object", - "properties":{ - "end":{ - "type":"string", - "x-go-name":"End" + "DateRange": { + "description": "DateRange represents a scheduled appointments time\nDateRange should be in definitions since it's being used in a response", + "type": "object", + "properties": { + "end": { + "type": "string", + "x-go-name": "End" }, - "start":{ - "type":"string", - "x-go-name":"Start" + "start": { + "type": "string", + "x-go-name": "Start" } }, - "x-go-package":"github.com/go-openapi/codescan/fixtures/goparsing/spec" + "x-go-package": "github.com/go-openapi/codescan/fixtures/goparsing/spec" } }, - "responses":{ - "BookingResponse":{ - "description":"BookingResponse represents a scheduled appointment", - "schema":{ - "type":"object", - "properties":{ - "booking":{ - "$ref":"#/definitions/Booking" + "responses": { + "BookingResponse": { + "description": "BookingResponse represents a scheduled appointment", + "schema": { + "type": "object", + "properties": { + "booking": { + "$ref": "#/definitions/Booking" }, - "customer":{ - "$ref":"#/definitions/Customer" + "customer": { + "$ref": "#/definitions/Customer" }, - "dates":{ - "$ref":"#/definitions/DateRange" + "dates": { + "$ref": "#/definitions/DateRange" }, "map": { "type": "object", "additionalProperties": { "type": "string" }, - "example": {"key": "value"}, - "x-go-name": "Map" + "x-go-name": "Map", + "example": { + "key": "value" + } }, "slice": { "type": "array", @@ -131,4 +133,4 @@ } } } -} +} \ No newline at end of file diff --git a/fixtures/integration/golden/api_spec_go111_ref.json b/fixtures/integration/golden/api_spec_go111_ref.json index 353b13c..7b3ef8a 100644 --- a/fixtures/integration/golden/api_spec_go111_ref.json +++ b/fixtures/integration/golden/api_spec_go111_ref.json @@ -1,122 +1,124 @@ { - "consumes":[ + "consumes": [ "application/json" ], - "produces":[ + "produces": [ "application/json" ], - "schemes":[ + "schemes": [ "https" ], - "swagger":"2.0", - "info":{ - "description":"the purpose of this application is to provide an application\nthat is using plain go code to define an API", - "title":"API.", - "version":"0.0.1" + "swagger": "2.0", + "info": { + "description": "the purpose of this application is to provide an application\nthat is using plain go code to define an API", + "title": "API.", + "version": "0.0.1" }, - "host":"localhost", - "paths":{ - "/admin/bookings/":{ - "get":{ - "consumes":[ + "host": "localhost", + "paths": { + "/admin/bookings/": { + "get": { + "consumes": [ "application/json" ], - "produces":[ + "produces": [ "application/json" ], - "schemes":[ + "schemes": [ "http", "https" ], - "tags":[ + "tags": [ "booking" ], - "summary":"Bookings lists all the appointments that have been made on the site.", + "summary": "Bookings lists all the appointments that have been made on the site.", + "operationId": "Bookings", "deprecated": true, - "operationId":"Bookings", - "responses":{ - "200":{ - "$ref":"#/responses/BookingResponse" + "responses": { + "200": { + "$ref": "#/responses/BookingResponse" } } } } }, - "definitions":{ - "Booking":{ - "description":"A Booking in the system", - "type":"object", - "required":[ + "definitions": { + "Booking": { + "description": "A Booking in the system", + "type": "object", + "required": [ "id", "Subject" ], - "properties":{ - "Subject":{ - "description":"Subject the subject of this booking", - "type":"string" + "properties": { + "Subject": { + "description": "Subject the subject of this booking", + "type": "string" }, - "id":{ - "description":"ID the id of the booking", - "type":"integer", - "format":"int64", - "x-go-name":"ID", - "readOnly":true + "id": { + "description": "ID the id of the booking", + "type": "integer", + "format": "int64", + "x-go-name": "ID", + "readOnly": true } }, - "x-go-package":"github.com/go-swagger/scan-repo-boundary/makeplans" + "x-go-package": "github.com/go-swagger/scan-repo-boundary/makeplans" }, - "Customer":{ - "title":"Customer of the site.", + "Customer": { + "title": "Customer of the site.", "$ref": "#/definitions/User" }, - "User":{ - "type":"object", - "properties":{ - "name":{ - "type":"string", - "x-go-name":"Name" + "DateRange": { + "description": "DateRange represents a scheduled appointments time\nDateRange should be in definitions since it's being used in a response", + "type": "object", + "properties": { + "end": { + "type": "string", + "x-go-name": "End" + }, + "start": { + "type": "string", + "x-go-name": "Start" } }, - "x-go-package":"github.com/go-openapi/codescan/fixtures/goparsing/spec" + "x-go-package": "github.com/go-openapi/codescan/fixtures/goparsing/spec" }, - "DateRange":{ - "description":"DateRange represents a scheduled appointments time\nDateRange should be in definitions since it's being used in a response", - "type":"object", - "properties":{ - "end":{ - "type":"string", - "x-go-name":"End" - }, - "start":{ - "type":"string", - "x-go-name":"Start" + "User": { + "type": "object", + "properties": { + "name": { + "type": "string", + "x-go-name": "Name" } }, - "x-go-package":"github.com/go-openapi/codescan/fixtures/goparsing/spec" + "x-go-package": "github.com/go-openapi/codescan/fixtures/goparsing/spec" } }, - "responses":{ - "BookingResponse":{ - "description":"BookingResponse represents a scheduled appointment", - "schema":{ - "type":"object", - "properties":{ - "booking":{ - "$ref":"#/definitions/Booking" + "responses": { + "BookingResponse": { + "description": "BookingResponse represents a scheduled appointment", + "schema": { + "type": "object", + "properties": { + "booking": { + "$ref": "#/definitions/Booking" }, - "customer":{ - "$ref":"#/definitions/Customer" + "customer": { + "$ref": "#/definitions/Customer" }, - "dates":{ - "$ref":"#/definitions/DateRange" + "dates": { + "$ref": "#/definitions/DateRange" }, "map": { "type": "object", "additionalProperties": { "type": "string" }, - "example": {"key": "value"}, - "x-go-name": "Map" + "x-go-name": "Map", + "example": { + "key": "value" + } }, "slice": { "type": "array", @@ -134,4 +136,4 @@ } } } -} +} \ No newline at end of file diff --git a/fixtures/integration/golden/api_spec_go111_transparent.json b/fixtures/integration/golden/api_spec_go111_transparent.json index d9aa1e9..bf84106 100644 --- a/fixtures/integration/golden/api_spec_go111_transparent.json +++ b/fixtures/integration/golden/api_spec_go111_transparent.json @@ -132,4 +132,4 @@ } } } -} +} \ No newline at end of file diff --git a/fixtures/integration/golden/bugs_3125_schema.json b/fixtures/integration/golden/bugs_3125_schema.json index 291ecea..36bf2f3 100644 --- a/fixtures/integration/golden/bugs_3125_schema.json +++ b/fixtures/integration/golden/bugs_3125_schema.json @@ -8,13 +8,25 @@ "properties": { "value1": { "description": "Nullable value", - "x-nullable": true, - "$ref": "#/definitions/ValueStruct" + "allOf": [ + { + "$ref": "#/definitions/ValueStruct" + } + ], + "x-go-name": "Value1", + "x-nullable": true }, "value2": { "description": "Non-nullable value", - "$ref": "#/definitions/ValueStruct", - "example": "{\"value\": 42}" + "allOf": [ + { + "$ref": "#/definitions/ValueStruct" + }, + { + "example": "{\"value\": 42}" + } + ], + "x-go-name": "Value2" } }, "x-go-package": "github.com/go-openapi/codescan/fixtures/bugs/3125/minimal" diff --git a/fixtures/integration/golden/bugs_3125_schema_descwithref.json b/fixtures/integration/golden/bugs_3125_schema_descwithref.json new file mode 100644 index 0000000..36bf2f3 --- /dev/null +++ b/fixtures/integration/golden/bugs_3125_schema_descwithref.json @@ -0,0 +1,34 @@ +{ + "Item": { + "type": "object", + "required": [ + "value1", + "value2" + ], + "properties": { + "value1": { + "description": "Nullable value", + "allOf": [ + { + "$ref": "#/definitions/ValueStruct" + } + ], + "x-go-name": "Value1", + "x-nullable": true + }, + "value2": { + "description": "Non-nullable value", + "allOf": [ + { + "$ref": "#/definitions/ValueStruct" + }, + { + "example": "{\"value\": 42}" + } + ], + "x-go-name": "Value2" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/bugs/3125/minimal" + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/bugs_3125_schema_skipext.json b/fixtures/integration/golden/bugs_3125_schema_skipext.json new file mode 100644 index 0000000..a2d6595 --- /dev/null +++ b/fixtures/integration/golden/bugs_3125_schema_skipext.json @@ -0,0 +1,31 @@ +{ + "Item": { + "type": "object", + "required": [ + "value1", + "value2" + ], + "properties": { + "value1": { + "description": "Nullable value", + "allOf": [ + { + "$ref": "#/definitions/ValueStruct" + } + ], + "x-nullable": true + }, + "value2": { + "description": "Non-nullable value", + "allOf": [ + { + "$ref": "#/definitions/ValueStruct" + }, + { + "example": "{\"value\": 42}" + } + ] + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/bugs_3125_schema_skipext_descwithref.json b/fixtures/integration/golden/bugs_3125_schema_skipext_descwithref.json new file mode 100644 index 0000000..a2d6595 --- /dev/null +++ b/fixtures/integration/golden/bugs_3125_schema_skipext_descwithref.json @@ -0,0 +1,31 @@ +{ + "Item": { + "type": "object", + "required": [ + "value1", + "value2" + ], + "properties": { + "value1": { + "description": "Nullable value", + "allOf": [ + { + "$ref": "#/definitions/ValueStruct" + } + ], + "x-nullable": true + }, + "value2": { + "description": "Non-nullable value", + "allOf": [ + { + "$ref": "#/definitions/ValueStruct" + }, + { + "example": "{\"value\": 42}" + } + ] + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/classification_params_descwithref.json b/fixtures/integration/golden/classification_params_descwithref.json new file mode 100644 index 0000000..7e05ab3 --- /dev/null +++ b/fixtures/integration/golden/classification_params_descwithref.json @@ -0,0 +1,689 @@ +{ + "anotherOperation": { + "operationId": "anotherOperation", + "parameters": [ + { + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "type": "integer", + "format": "int64", + "default": 1, + "x-go-name": "ID", + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "name": "id", + "in": "path", + "required": true + }, + { + "maximum": 45, + "minimum": 3, + "multipleOf": 3, + "type": "integer", + "format": "int32", + "default": 2, + "example": 27, + "x-go-name": "Score", + "description": "The Score of this model", + "name": "score", + "in": "query", + "required": true + }, + { + "maxLength": 50, + "minLength": 4, + "pattern": "[A-Za-z0-9-.]*", + "type": "string", + "x-go-name": "Name", + "description": "Name of this no model instance", + "name": "x-hdr-name", + "in": "header", + "required": true + }, + { + "type": "string", + "format": "date-time", + "x-go-name": "Created", + "description": "Created holds the time when this entry was created", + "name": "created", + "in": "query" + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "x-go-name": "CategoryOld", + "description": "The Category of this model (old enum format)", + "name": "category_old", + "in": "query", + "required": true + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "x-go-name": "Category", + "description": "The Category of this model", + "name": "category", + "in": "query", + "required": true + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "x-go-name": "TypeOld", + "description": "Type of this model (old enum format)", + "name": "type_old", + "in": "query" + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "x-go-name": "Type", + "description": "Type of this model", + "name": "type", + "in": "query" + }, + { + "enum": [ + 1, + "rsq", + "qaz" + ], + "type": "integer", + "format": "int64", + "x-go-name": "BadEnum", + "description": "This is mix in enum. And actually on output should be valid form where int will be int and\nstring will also be presented.", + "name": "bad_enum", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string", + "default": "bar" + }, + "collectionFormat": "pipe", + "x-go-name": "FooSlice", + "description": "a FooSlice has foos which are strings", + "name": "foo_slice", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxItems": 9, + "minItems": 4, + "enum": [ + "bar1", + "bar2", + "bar3" + ], + "type": "array", + "items": { + "maxItems": 8, + "minItems": 5, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string" + } + }, + "default": "bar2" + }, + "collectionFormat": "pipe", + "x-go-name": "BarSlice", + "description": "a BarSlice has bars which are strings", + "name": "bar_slice", + "in": "query" + }, + { + "maxItems": 20, + "minItems": 1, + "type": "array", + "items": { + "maximum": 100, + "minimum": 5, + "uniqueItems": true, + "multipleOf": 5, + "type": "integer", + "format": "int32", + "collectionFormat": "csv" + }, + "x-go-name": "NumSlice", + "description": "a NumSlice has numeric items with item-level validation", + "name": "num_slice", + "in": "query" + }, + { + "x-go-name": "Items", + "description": "the items for this order", + "name": "items", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pet", + "quantity" + ], + "properties": { + "id": { + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "type": "integer", + "format": "int32", + "default": 3, + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "x-go-name": "ID" + }, + "notes": { + "description": "Notes to add to this item.\nThis can be used to add special instructions.", + "type": "string", + "x-go-name": "Notes" + }, + "pet": { + "description": "The Pet to add to this NoModel items bucket.\nPets can appear more than once in the bucket", + "allOf": [ + { + "$ref": "#/definitions/pet" + } + ], + "x-go-name": "Pet" + }, + "quantity": { + "description": "The amount of pets to add to this bucket.", + "type": "integer", + "format": "int16", + "maximum": 10, + "minimum": 1, + "x-go-name": "Quantity" + } + } + } + } + } + ] + }, + "createOrder": { + "operationId": "createOrder", + "parameters": [ + { + "x-go-name": "Order", + "description": "The order to submit.", + "name": "order", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/order" + } + } + ] + }, + "getOrders": { + "operationId": "getOrders", + "parameters": [ + { + "type": "array", + "items": { + "$ref": "#/definitions/OrderBodyParams" + }, + "x-go-name": "Orders", + "description": "The orders", + "name": "orders", + "in": "query", + "required": true + }, + { + "x-go-name": "Another", + "description": "And another thing", + "name": "another", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "that": { + "type": "string", + "x-go-name": "That" + } + } + } + } + } + ] + }, + "myFuncOperation": { + "operationId": "myFuncOperation", + "parameters": [ + { + "type": "file", + "x-go-name": "MyFormFile", + "description": "MyFormFile desc.", + "name": "myFormFile", + "in": "formData" + } + ] + }, + "myOperation": { + "operationId": "myOperation", + "parameters": [ + { + "type": "file", + "x-go-name": "MyFormFile", + "description": "MyFormFile desc.", + "name": "myFormFile", + "in": "formData" + } + ] + }, + "myOtherOperation": { + "operationId": "myOtherOperation", + "parameters": [ + { + "type": "file", + "x-go-name": "MyFormFile", + "description": "MyFormFile desc.", + "name": "myFormFile", + "in": "formData" + }, + { + "type": "integer", + "format": "int64", + "x-go-name": "ExtraParam", + "description": "ExtraParam desc.", + "name": "extraParam", + "in": "formData", + "required": true + } + ] + }, + "someAliasOperation": { + "operationId": "someAliasOperation", + "parameters": [ + { + "maximum": 10, + "minimum": 1, + "type": "integer", + "format": "int64", + "x-go-name": "IntAlias", + "description": "default \"in\" is \"query\" =\u003e this params should be aliased", + "name": "intAlias", + "in": "query", + "required": true + }, + { + "type": "string", + "x-go-name": "StringAlias", + "name": "stringAlias", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "x-go-name": "IntAliasPath", + "name": "intAliasPath", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "x-go-name": "IntAliasForm", + "name": "intAliasForm", + "in": "formData" + } + ] + }, + "someOperation": { + "operationId": "someOperation", + "parameters": [ + { + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "type": "integer", + "format": "int64", + "default": 1, + "x-go-name": "ID", + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "name": "id", + "in": "path", + "required": true + }, + { + "maximum": 45, + "minimum": 3, + "multipleOf": 3, + "type": "integer", + "format": "int32", + "default": 2, + "example": 27, + "x-go-name": "Score", + "description": "The Score of this model", + "name": "score", + "in": "query", + "required": true + }, + { + "maxLength": 50, + "minLength": 4, + "pattern": "[A-Za-z0-9-.]*", + "type": "string", + "x-go-name": "Name", + "description": "Name of this no model instance", + "name": "x-hdr-name", + "in": "header", + "required": true + }, + { + "type": "string", + "format": "date-time", + "x-go-name": "Created", + "description": "Created holds the time when this entry was created", + "name": "created", + "in": "query" + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "x-go-name": "CategoryOld", + "description": "The Category of this model (old enum format)", + "name": "category_old", + "in": "query", + "required": true + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "x-go-name": "Category", + "description": "The Category of this model", + "name": "category", + "in": "query", + "required": true + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "x-go-name": "TypeOld", + "description": "Type of this model (old enum format)", + "name": "type_old", + "in": "query" + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "x-go-name": "Type", + "description": "Type of this model", + "name": "type", + "in": "query" + }, + { + "enum": [ + 1, + "rsq", + "qaz" + ], + "type": "integer", + "format": "int64", + "x-go-name": "BadEnum", + "description": "This is mix in enum. And actually on output should be valid form where int will be int and\nstring will also be presented.", + "name": "bad_enum", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string", + "default": "bar" + }, + "collectionFormat": "pipe", + "x-go-name": "FooSlice", + "description": "a FooSlice has foos which are strings", + "name": "foo_slice", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxItems": 9, + "minItems": 4, + "enum": [ + "bar1", + "bar2", + "bar3" + ], + "type": "array", + "items": { + "maxItems": 8, + "minItems": 5, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string" + } + }, + "default": "bar2" + }, + "collectionFormat": "pipe", + "x-go-name": "BarSlice", + "description": "a BarSlice has bars which are strings", + "name": "bar_slice", + "in": "query" + }, + { + "maxItems": 20, + "minItems": 1, + "type": "array", + "items": { + "maximum": 100, + "minimum": 5, + "uniqueItems": true, + "multipleOf": 5, + "type": "integer", + "format": "int32", + "collectionFormat": "csv" + }, + "x-go-name": "NumSlice", + "description": "a NumSlice has numeric items with item-level validation", + "name": "num_slice", + "in": "query" + }, + { + "x-go-name": "Items", + "description": "the items for this order", + "name": "items", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pet", + "quantity" + ], + "properties": { + "id": { + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "type": "integer", + "format": "int32", + "default": 3, + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "x-go-name": "ID" + }, + "notes": { + "description": "Notes to add to this item.\nThis can be used to add special instructions.", + "type": "string", + "x-go-name": "Notes" + }, + "pet": { + "description": "The Pet to add to this NoModel items bucket.\nPets can appear more than once in the bucket", + "allOf": [ + { + "$ref": "#/definitions/pet" + } + ], + "x-go-name": "Pet" + }, + "quantity": { + "description": "The amount of pets to add to this bucket.", + "type": "integer", + "format": "int16", + "maximum": 10, + "minimum": 1, + "x-go-name": "Quantity" + } + } + } + } + } + ] + }, + "updateOrder": { + "operationId": "updateOrder", + "parameters": [ + { + "x-go-name": "Order", + "description": "The order to submit.", + "name": "order", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/order" + } + } + ] + }, + "yetAnotherOperation": { + "operationId": "yetAnotherOperation", + "parameters": [ + { + "type": "integer", + "format": "int32", + "x-go-name": "Age", + "name": "age", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "x-go-name": "ID", + "description": "ID the id of this not selected model", + "name": "id", + "in": "query" + }, + { + "type": "string", + "x-go-name": "Name", + "description": "Name the name of this not selected model", + "name": "name", + "in": "query" + }, + { + "type": "string", + "x-go-name": "Notes", + "name": "notes", + "in": "query" + }, + { + "type": "string", + "x-go-name": "Extra", + "name": "extra", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "x-go-name": "CreatedAt", + "name": "createdAt", + "in": "query" + }, + { + "type": "string", + "x-go-name": "Informity", + "name": "informity", + "in": "formData" + }, + { + "type": "string", + "name": "NoTagName", + "in": "query" + } + ] + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/classification_params_skipext.json b/fixtures/integration/golden/classification_params_skipext.json new file mode 100644 index 0000000..93ed8be --- /dev/null +++ b/fixtures/integration/golden/classification_params_skipext.json @@ -0,0 +1,625 @@ +{ + "anotherOperation": { + "operationId": "anotherOperation", + "parameters": [ + { + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "type": "integer", + "format": "int64", + "default": 1, + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "name": "id", + "in": "path", + "required": true + }, + { + "maximum": 45, + "minimum": 3, + "multipleOf": 3, + "type": "integer", + "format": "int32", + "default": 2, + "example": 27, + "description": "The Score of this model", + "name": "score", + "in": "query", + "required": true + }, + { + "maxLength": 50, + "minLength": 4, + "pattern": "[A-Za-z0-9-.]*", + "type": "string", + "description": "Name of this no model instance", + "name": "x-hdr-name", + "in": "header", + "required": true + }, + { + "type": "string", + "format": "date-time", + "description": "Created holds the time when this entry was created", + "name": "created", + "in": "query" + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "description": "The Category of this model (old enum format)", + "name": "category_old", + "in": "query", + "required": true + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "description": "The Category of this model", + "name": "category", + "in": "query", + "required": true + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "description": "Type of this model (old enum format)", + "name": "type_old", + "in": "query" + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "description": "Type of this model", + "name": "type", + "in": "query" + }, + { + "enum": [ + 1, + "rsq", + "qaz" + ], + "type": "integer", + "format": "int64", + "description": "This is mix in enum. And actually on output should be valid form where int will be int and\nstring will also be presented.", + "name": "bad_enum", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string", + "default": "bar" + }, + "collectionFormat": "pipe", + "description": "a FooSlice has foos which are strings", + "name": "foo_slice", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxItems": 9, + "minItems": 4, + "enum": [ + "bar1", + "bar2", + "bar3" + ], + "type": "array", + "items": { + "maxItems": 8, + "minItems": 5, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string" + } + }, + "default": "bar2" + }, + "collectionFormat": "pipe", + "description": "a BarSlice has bars which are strings", + "name": "bar_slice", + "in": "query" + }, + { + "maxItems": 20, + "minItems": 1, + "type": "array", + "items": { + "maximum": 100, + "minimum": 5, + "uniqueItems": true, + "multipleOf": 5, + "type": "integer", + "format": "int32", + "collectionFormat": "csv" + }, + "description": "a NumSlice has numeric items with item-level validation", + "name": "num_slice", + "in": "query" + }, + { + "description": "the items for this order", + "name": "items", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pet", + "quantity" + ], + "properties": { + "id": { + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "type": "integer", + "format": "int32", + "default": 3, + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true + }, + "notes": { + "description": "Notes to add to this item.\nThis can be used to add special instructions.", + "type": "string" + }, + "pet": { + "$ref": "#/definitions/pet" + }, + "quantity": { + "description": "The amount of pets to add to this bucket.", + "type": "integer", + "format": "int16", + "maximum": 10, + "minimum": 1 + } + } + } + } + } + ] + }, + "createOrder": { + "operationId": "createOrder", + "parameters": [ + { + "description": "The order to submit.", + "name": "order", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/order" + } + } + ] + }, + "getOrders": { + "operationId": "getOrders", + "parameters": [ + { + "type": "array", + "items": { + "$ref": "#/definitions/OrderBodyParams" + }, + "description": "The orders", + "name": "orders", + "in": "query", + "required": true + }, + { + "description": "And another thing", + "name": "another", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "that": { + "type": "string" + } + } + } + } + } + ] + }, + "myFuncOperation": { + "operationId": "myFuncOperation", + "parameters": [ + { + "type": "file", + "description": "MyFormFile desc.", + "name": "myFormFile", + "in": "formData" + } + ] + }, + "myOperation": { + "operationId": "myOperation", + "parameters": [ + { + "type": "file", + "description": "MyFormFile desc.", + "name": "myFormFile", + "in": "formData" + } + ] + }, + "myOtherOperation": { + "operationId": "myOtherOperation", + "parameters": [ + { + "type": "file", + "description": "MyFormFile desc.", + "name": "myFormFile", + "in": "formData" + }, + { + "type": "integer", + "format": "int64", + "description": "ExtraParam desc.", + "name": "extraParam", + "in": "formData", + "required": true + } + ] + }, + "someAliasOperation": { + "operationId": "someAliasOperation", + "parameters": [ + { + "maximum": 10, + "minimum": 1, + "type": "integer", + "format": "int64", + "description": "default \"in\" is \"query\" =\u003e this params should be aliased", + "name": "intAlias", + "in": "query", + "required": true + }, + { + "type": "string", + "name": "stringAlias", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "name": "intAliasPath", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "name": "intAliasForm", + "in": "formData" + } + ] + }, + "someOperation": { + "operationId": "someOperation", + "parameters": [ + { + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "type": "integer", + "format": "int64", + "default": 1, + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "name": "id", + "in": "path", + "required": true + }, + { + "maximum": 45, + "minimum": 3, + "multipleOf": 3, + "type": "integer", + "format": "int32", + "default": 2, + "example": 27, + "description": "The Score of this model", + "name": "score", + "in": "query", + "required": true + }, + { + "maxLength": 50, + "minLength": 4, + "pattern": "[A-Za-z0-9-.]*", + "type": "string", + "description": "Name of this no model instance", + "name": "x-hdr-name", + "in": "header", + "required": true + }, + { + "type": "string", + "format": "date-time", + "description": "Created holds the time when this entry was created", + "name": "created", + "in": "query" + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "description": "The Category of this model (old enum format)", + "name": "category_old", + "in": "query", + "required": true + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "description": "The Category of this model", + "name": "category", + "in": "query", + "required": true + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "description": "Type of this model (old enum format)", + "name": "type_old", + "in": "query" + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "description": "Type of this model", + "name": "type", + "in": "query" + }, + { + "enum": [ + 1, + "rsq", + "qaz" + ], + "type": "integer", + "format": "int64", + "description": "This is mix in enum. And actually on output should be valid form where int will be int and\nstring will also be presented.", + "name": "bad_enum", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string", + "default": "bar" + }, + "collectionFormat": "pipe", + "description": "a FooSlice has foos which are strings", + "name": "foo_slice", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxItems": 9, + "minItems": 4, + "enum": [ + "bar1", + "bar2", + "bar3" + ], + "type": "array", + "items": { + "maxItems": 8, + "minItems": 5, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string" + } + }, + "default": "bar2" + }, + "collectionFormat": "pipe", + "description": "a BarSlice has bars which are strings", + "name": "bar_slice", + "in": "query" + }, + { + "maxItems": 20, + "minItems": 1, + "type": "array", + "items": { + "maximum": 100, + "minimum": 5, + "uniqueItems": true, + "multipleOf": 5, + "type": "integer", + "format": "int32", + "collectionFormat": "csv" + }, + "description": "a NumSlice has numeric items with item-level validation", + "name": "num_slice", + "in": "query" + }, + { + "description": "the items for this order", + "name": "items", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pet", + "quantity" + ], + "properties": { + "id": { + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "type": "integer", + "format": "int32", + "default": 3, + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true + }, + "notes": { + "description": "Notes to add to this item.\nThis can be used to add special instructions.", + "type": "string" + }, + "pet": { + "$ref": "#/definitions/pet" + }, + "quantity": { + "description": "The amount of pets to add to this bucket.", + "type": "integer", + "format": "int16", + "maximum": 10, + "minimum": 1 + } + } + } + } + } + ] + }, + "updateOrder": { + "operationId": "updateOrder", + "parameters": [ + { + "description": "The order to submit.", + "name": "order", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/order" + } + } + ] + }, + "yetAnotherOperation": { + "operationId": "yetAnotherOperation", + "parameters": [ + { + "type": "integer", + "format": "int32", + "name": "age", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "ID the id of this not selected model", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "Name the name of this not selected model", + "name": "name", + "in": "query" + }, + { + "type": "string", + "name": "notes", + "in": "query" + }, + { + "type": "string", + "name": "extra", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "name": "createdAt", + "in": "query" + }, + { + "type": "string", + "name": "informity", + "in": "formData" + }, + { + "type": "string", + "name": "NoTagName", + "in": "query" + } + ] + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/classification_params_skipext_descwithref.json b/fixtures/integration/golden/classification_params_skipext_descwithref.json new file mode 100644 index 0000000..66734db --- /dev/null +++ b/fixtures/integration/golden/classification_params_skipext_descwithref.json @@ -0,0 +1,635 @@ +{ + "anotherOperation": { + "operationId": "anotherOperation", + "parameters": [ + { + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "type": "integer", + "format": "int64", + "default": 1, + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "name": "id", + "in": "path", + "required": true + }, + { + "maximum": 45, + "minimum": 3, + "multipleOf": 3, + "type": "integer", + "format": "int32", + "default": 2, + "example": 27, + "description": "The Score of this model", + "name": "score", + "in": "query", + "required": true + }, + { + "maxLength": 50, + "minLength": 4, + "pattern": "[A-Za-z0-9-.]*", + "type": "string", + "description": "Name of this no model instance", + "name": "x-hdr-name", + "in": "header", + "required": true + }, + { + "type": "string", + "format": "date-time", + "description": "Created holds the time when this entry was created", + "name": "created", + "in": "query" + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "description": "The Category of this model (old enum format)", + "name": "category_old", + "in": "query", + "required": true + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "description": "The Category of this model", + "name": "category", + "in": "query", + "required": true + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "description": "Type of this model (old enum format)", + "name": "type_old", + "in": "query" + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "description": "Type of this model", + "name": "type", + "in": "query" + }, + { + "enum": [ + 1, + "rsq", + "qaz" + ], + "type": "integer", + "format": "int64", + "description": "This is mix in enum. And actually on output should be valid form where int will be int and\nstring will also be presented.", + "name": "bad_enum", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string", + "default": "bar" + }, + "collectionFormat": "pipe", + "description": "a FooSlice has foos which are strings", + "name": "foo_slice", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxItems": 9, + "minItems": 4, + "enum": [ + "bar1", + "bar2", + "bar3" + ], + "type": "array", + "items": { + "maxItems": 8, + "minItems": 5, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string" + } + }, + "default": "bar2" + }, + "collectionFormat": "pipe", + "description": "a BarSlice has bars which are strings", + "name": "bar_slice", + "in": "query" + }, + { + "maxItems": 20, + "minItems": 1, + "type": "array", + "items": { + "maximum": 100, + "minimum": 5, + "uniqueItems": true, + "multipleOf": 5, + "type": "integer", + "format": "int32", + "collectionFormat": "csv" + }, + "description": "a NumSlice has numeric items with item-level validation", + "name": "num_slice", + "in": "query" + }, + { + "description": "the items for this order", + "name": "items", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pet", + "quantity" + ], + "properties": { + "id": { + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "type": "integer", + "format": "int32", + "default": 3, + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true + }, + "notes": { + "description": "Notes to add to this item.\nThis can be used to add special instructions.", + "type": "string" + }, + "pet": { + "description": "The Pet to add to this NoModel items bucket.\nPets can appear more than once in the bucket", + "allOf": [ + { + "$ref": "#/definitions/pet" + } + ] + }, + "quantity": { + "description": "The amount of pets to add to this bucket.", + "type": "integer", + "format": "int16", + "maximum": 10, + "minimum": 1 + } + } + } + } + } + ] + }, + "createOrder": { + "operationId": "createOrder", + "parameters": [ + { + "description": "The order to submit.", + "name": "order", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/order" + } + } + ] + }, + "getOrders": { + "operationId": "getOrders", + "parameters": [ + { + "type": "array", + "items": { + "$ref": "#/definitions/OrderBodyParams" + }, + "description": "The orders", + "name": "orders", + "in": "query", + "required": true + }, + { + "description": "And another thing", + "name": "another", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "that": { + "type": "string" + } + } + } + } + } + ] + }, + "myFuncOperation": { + "operationId": "myFuncOperation", + "parameters": [ + { + "type": "file", + "description": "MyFormFile desc.", + "name": "myFormFile", + "in": "formData" + } + ] + }, + "myOperation": { + "operationId": "myOperation", + "parameters": [ + { + "type": "file", + "description": "MyFormFile desc.", + "name": "myFormFile", + "in": "formData" + } + ] + }, + "myOtherOperation": { + "operationId": "myOtherOperation", + "parameters": [ + { + "type": "file", + "description": "MyFormFile desc.", + "name": "myFormFile", + "in": "formData" + }, + { + "type": "integer", + "format": "int64", + "description": "ExtraParam desc.", + "name": "extraParam", + "in": "formData", + "required": true + } + ] + }, + "someAliasOperation": { + "operationId": "someAliasOperation", + "parameters": [ + { + "maximum": 10, + "minimum": 1, + "type": "integer", + "format": "int64", + "description": "default \"in\" is \"query\" =\u003e this params should be aliased", + "name": "intAlias", + "in": "query", + "required": true + }, + { + "type": "string", + "name": "stringAlias", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "name": "intAliasPath", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "name": "intAliasForm", + "in": "formData" + } + ] + }, + "someOperation": { + "operationId": "someOperation", + "parameters": [ + { + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "type": "integer", + "format": "int64", + "default": 1, + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "name": "id", + "in": "path", + "required": true + }, + { + "maximum": 45, + "minimum": 3, + "multipleOf": 3, + "type": "integer", + "format": "int32", + "default": 2, + "example": 27, + "description": "The Score of this model", + "name": "score", + "in": "query", + "required": true + }, + { + "maxLength": 50, + "minLength": 4, + "pattern": "[A-Za-z0-9-.]*", + "type": "string", + "description": "Name of this no model instance", + "name": "x-hdr-name", + "in": "header", + "required": true + }, + { + "type": "string", + "format": "date-time", + "description": "Created holds the time when this entry was created", + "name": "created", + "in": "query" + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "description": "The Category of this model (old enum format)", + "name": "category_old", + "in": "query", + "required": true + }, + { + "enum": [ + "foo", + "bar", + "none" + ], + "type": "string", + "default": "bar", + "description": "The Category of this model", + "name": "category", + "in": "query", + "required": true + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "description": "Type of this model (old enum format)", + "name": "type_old", + "in": "query" + }, + { + "enum": [ + 1, + 3, + 5 + ], + "type": "integer", + "format": "int64", + "default": 1, + "description": "Type of this model", + "name": "type", + "in": "query" + }, + { + "enum": [ + 1, + "rsq", + "qaz" + ], + "type": "integer", + "format": "int64", + "description": "This is mix in enum. And actually on output should be valid form where int will be int and\nstring will also be presented.", + "name": "bad_enum", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string", + "default": "bar" + }, + "collectionFormat": "pipe", + "description": "a FooSlice has foos which are strings", + "name": "foo_slice", + "in": "query" + }, + { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxItems": 9, + "minItems": 4, + "enum": [ + "bar1", + "bar2", + "bar3" + ], + "type": "array", + "items": { + "maxItems": 8, + "minItems": 5, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string" + } + }, + "default": "bar2" + }, + "collectionFormat": "pipe", + "description": "a BarSlice has bars which are strings", + "name": "bar_slice", + "in": "query" + }, + { + "maxItems": 20, + "minItems": 1, + "type": "array", + "items": { + "maximum": 100, + "minimum": 5, + "uniqueItems": true, + "multipleOf": 5, + "type": "integer", + "format": "int32", + "collectionFormat": "csv" + }, + "description": "a NumSlice has numeric items with item-level validation", + "name": "num_slice", + "in": "query" + }, + { + "description": "the items for this order", + "name": "items", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pet", + "quantity" + ], + "properties": { + "id": { + "description": "ID of this no model instance.\nids in this application start at 11 and are smaller than 1000", + "type": "integer", + "format": "int32", + "default": 3, + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true + }, + "notes": { + "description": "Notes to add to this item.\nThis can be used to add special instructions.", + "type": "string" + }, + "pet": { + "description": "The Pet to add to this NoModel items bucket.\nPets can appear more than once in the bucket", + "allOf": [ + { + "$ref": "#/definitions/pet" + } + ] + }, + "quantity": { + "description": "The amount of pets to add to this bucket.", + "type": "integer", + "format": "int16", + "maximum": 10, + "minimum": 1 + } + } + } + } + } + ] + }, + "updateOrder": { + "operationId": "updateOrder", + "parameters": [ + { + "description": "The order to submit.", + "name": "order", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/order" + } + } + ] + }, + "yetAnotherOperation": { + "operationId": "yetAnotherOperation", + "parameters": [ + { + "type": "integer", + "format": "int32", + "name": "age", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "ID the id of this not selected model", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "Name the name of this not selected model", + "name": "name", + "in": "query" + }, + { + "type": "string", + "name": "notes", + "in": "query" + }, + { + "type": "string", + "name": "extra", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "name": "createdAt", + "in": "query" + }, + { + "type": "string", + "name": "informity", + "in": "formData" + }, + { + "type": "string", + "name": "NoTagName", + "in": "query" + } + ] + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/classification_responses_descwithref.json b/fixtures/integration/golden/classification_responses_descwithref.json new file mode 100644 index 0000000..e25fb89 --- /dev/null +++ b/fixtures/integration/golden/classification_responses_descwithref.json @@ -0,0 +1,256 @@ +{ + "complexerOne": { + "description": "A ComplexerOne is composed of a SimpleOne and some extra fields.", + "headers": { + "NoTagName": { + "type": "string" + }, + "age": { + "type": "integer", + "format": "int32" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "extra": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64", + "description": "ID the id of this not selected model" + }, + "name": { + "type": "string", + "description": "Name the name of this not selected model" + }, + "notes": { + "type": "string" + } + } + }, + "complexerPointerOne": { + "description": "A ComplexerPointerOne is composed of a *SimpleOne and some extra fields.", + "headers": { + "age": { + "type": "integer", + "format": "int32" + }, + "extra": { + "type": "integer", + "format": "int64" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "fileResponse": { + "description": "File response", + "schema": { + "type": "file" + } + }, + "genericError": { + "description": "A GenericError is an error that is used when no other error is appropriate", + "schema": { + "type": "object", + "properties": { + "Message": { + "type": "string" + } + } + } + }, + "resp": { + "description": "Resp a response for testing", + "schema": { + "$ref": "#/definitions/user" + }, + "headers": { + "UUID": { + "type": "string", + "format": "uuid" + } + } + }, + "simpleOnes": { + "description": "SimpleOnes is a collection of SimpleOne", + "headers": { + "ones": { + "type": "array", + "items": { + "$ref": "#/definitions/SimpleOne" + } + } + } + }, + "simpleOnesFunc": { + "description": "SimpleOnesFunc is a collection of SimpleOne", + "headers": { + "ones": { + "type": "array", + "items": { + "$ref": "#/definitions/SimpleOne" + } + } + } + }, + "someResponse": { + "description": "A SomeResponse is a dummy response object to test parsing.\n\nThe properties are the same as the other structs used to test parsing.", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pet", + "quantity" + ], + "properties": { + "id": { + "description": "ID of this some response instance.\nids in this application start at 11 and are smaller than 1000", + "type": "integer", + "format": "int32", + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "x-go-name": "ID" + }, + "notes": { + "description": "Notes to add to this item.\nThis can be used to add special instructions.", + "type": "string", + "x-go-name": "Notes" + }, + "pet": { + "description": "The Pet to add to this NoModel items bucket.\nPets can appear more than once in the bucket", + "allOf": [ + { + "$ref": "#/definitions/pet" + } + ], + "x-go-name": "Pet" + }, + "quantity": { + "description": "The amount of pets to add to this bucket.", + "type": "integer", + "format": "int16", + "maximum": 10, + "minimum": 1, + "x-go-name": "Quantity" + } + } + } + }, + "headers": { + "active": { + "type": "boolean", + "default": true, + "description": "Active state of the record" + }, + "bar_slice": { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxItems": 9, + "minItems": 4, + "type": "array", + "items": { + "maxItems": 8, + "minItems": 5, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string" + } + } + }, + "collectionFormat": "pipe", + "description": "a BarSlice has bars which are strings" + }, + "created": { + "type": "string", + "format": "date-time", + "description": "Created holds the time when this entry was created" + }, + "foo_slice": { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string", + "example": "foo" + }, + "collectionFormat": "pipe", + "description": "a FooSlice has foos which are strings" + }, + "id": { + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "type": "integer", + "format": "int64", + "default": 11, + "description": "ID of this some response instance.\nids in this application start at 11 and are smaller than 1000" + }, + "score": { + "maximum": 45, + "minimum": 3, + "multipleOf": 3, + "type": "integer", + "format": "int32", + "example": 27, + "description": "The Score of this model" + }, + "x-hdr-name": { + "maxLength": 50, + "minLength": 4, + "pattern": "[A-Za-z0-9-.]*", + "type": "string", + "description": "Name of this some response instance" + } + } + }, + "validationError": { + "description": "A ValidationError is an error that is used when the required input fails validation.", + "schema": { + "type": "object", + "properties": { + "FieldName": { + "description": "An optional field name to which this validation applies", + "type": "string" + }, + "Message": { + "description": "The validation message", + "type": "string" + } + } + }, + "headers": { + "code": { + "enum": [ + "foo", + "bar" + ], + "type": "integer", + "format": "int64", + "default": 400 + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/classification_responses_skipext.json b/fixtures/integration/golden/classification_responses_skipext.json new file mode 100644 index 0000000..bb1f2df --- /dev/null +++ b/fixtures/integration/golden/classification_responses_skipext.json @@ -0,0 +1,247 @@ +{ + "complexerOne": { + "description": "A ComplexerOne is composed of a SimpleOne and some extra fields.", + "headers": { + "NoTagName": { + "type": "string" + }, + "age": { + "type": "integer", + "format": "int32" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "extra": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64", + "description": "ID the id of this not selected model" + }, + "name": { + "type": "string", + "description": "Name the name of this not selected model" + }, + "notes": { + "type": "string" + } + } + }, + "complexerPointerOne": { + "description": "A ComplexerPointerOne is composed of a *SimpleOne and some extra fields.", + "headers": { + "age": { + "type": "integer", + "format": "int32" + }, + "extra": { + "type": "integer", + "format": "int64" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "fileResponse": { + "description": "File response", + "schema": { + "type": "file" + } + }, + "genericError": { + "description": "A GenericError is an error that is used when no other error is appropriate", + "schema": { + "type": "object", + "properties": { + "Message": { + "type": "string" + } + } + } + }, + "resp": { + "description": "Resp a response for testing", + "schema": { + "$ref": "#/definitions/user" + }, + "headers": { + "UUID": { + "type": "string", + "format": "uuid" + } + } + }, + "simpleOnes": { + "description": "SimpleOnes is a collection of SimpleOne", + "headers": { + "ones": { + "type": "array", + "items": { + "$ref": "#/definitions/SimpleOne" + } + } + } + }, + "simpleOnesFunc": { + "description": "SimpleOnesFunc is a collection of SimpleOne", + "headers": { + "ones": { + "type": "array", + "items": { + "$ref": "#/definitions/SimpleOne" + } + } + } + }, + "someResponse": { + "description": "A SomeResponse is a dummy response object to test parsing.\n\nThe properties are the same as the other structs used to test parsing.", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pet", + "quantity" + ], + "properties": { + "id": { + "description": "ID of this some response instance.\nids in this application start at 11 and are smaller than 1000", + "type": "integer", + "format": "int32", + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true + }, + "notes": { + "description": "Notes to add to this item.\nThis can be used to add special instructions.", + "type": "string" + }, + "pet": { + "$ref": "#/definitions/pet" + }, + "quantity": { + "description": "The amount of pets to add to this bucket.", + "type": "integer", + "format": "int16", + "maximum": 10, + "minimum": 1 + } + } + } + }, + "headers": { + "active": { + "type": "boolean", + "default": true, + "description": "Active state of the record" + }, + "bar_slice": { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxItems": 9, + "minItems": 4, + "type": "array", + "items": { + "maxItems": 8, + "minItems": 5, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string" + } + } + }, + "collectionFormat": "pipe", + "description": "a BarSlice has bars which are strings" + }, + "created": { + "type": "string", + "format": "date-time", + "description": "Created holds the time when this entry was created" + }, + "foo_slice": { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string", + "example": "foo" + }, + "collectionFormat": "pipe", + "description": "a FooSlice has foos which are strings" + }, + "id": { + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "type": "integer", + "format": "int64", + "default": 11, + "description": "ID of this some response instance.\nids in this application start at 11 and are smaller than 1000" + }, + "score": { + "maximum": 45, + "minimum": 3, + "multipleOf": 3, + "type": "integer", + "format": "int32", + "example": 27, + "description": "The Score of this model" + }, + "x-hdr-name": { + "maxLength": 50, + "minLength": 4, + "pattern": "[A-Za-z0-9-.]*", + "type": "string", + "description": "Name of this some response instance" + } + } + }, + "validationError": { + "description": "A ValidationError is an error that is used when the required input fails validation.", + "schema": { + "type": "object", + "properties": { + "FieldName": { + "description": "An optional field name to which this validation applies", + "type": "string" + }, + "Message": { + "description": "The validation message", + "type": "string" + } + } + }, + "headers": { + "code": { + "enum": [ + "foo", + "bar" + ], + "type": "integer", + "format": "int64", + "default": 400 + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/classification_responses_skipext_descwithref.json b/fixtures/integration/golden/classification_responses_skipext_descwithref.json new file mode 100644 index 0000000..0f30415 --- /dev/null +++ b/fixtures/integration/golden/classification_responses_skipext_descwithref.json @@ -0,0 +1,252 @@ +{ + "complexerOne": { + "description": "A ComplexerOne is composed of a SimpleOne and some extra fields.", + "headers": { + "NoTagName": { + "type": "string" + }, + "age": { + "type": "integer", + "format": "int32" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "extra": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64", + "description": "ID the id of this not selected model" + }, + "name": { + "type": "string", + "description": "Name the name of this not selected model" + }, + "notes": { + "type": "string" + } + } + }, + "complexerPointerOne": { + "description": "A ComplexerPointerOne is composed of a *SimpleOne and some extra fields.", + "headers": { + "age": { + "type": "integer", + "format": "int32" + }, + "extra": { + "type": "integer", + "format": "int64" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "fileResponse": { + "description": "File response", + "schema": { + "type": "file" + } + }, + "genericError": { + "description": "A GenericError is an error that is used when no other error is appropriate", + "schema": { + "type": "object", + "properties": { + "Message": { + "type": "string" + } + } + } + }, + "resp": { + "description": "Resp a response for testing", + "schema": { + "$ref": "#/definitions/user" + }, + "headers": { + "UUID": { + "type": "string", + "format": "uuid" + } + } + }, + "simpleOnes": { + "description": "SimpleOnes is a collection of SimpleOne", + "headers": { + "ones": { + "type": "array", + "items": { + "$ref": "#/definitions/SimpleOne" + } + } + } + }, + "simpleOnesFunc": { + "description": "SimpleOnesFunc is a collection of SimpleOne", + "headers": { + "ones": { + "type": "array", + "items": { + "$ref": "#/definitions/SimpleOne" + } + } + } + }, + "someResponse": { + "description": "A SomeResponse is a dummy response object to test parsing.\n\nThe properties are the same as the other structs used to test parsing.", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "pet", + "quantity" + ], + "properties": { + "id": { + "description": "ID of this some response instance.\nids in this application start at 11 and are smaller than 1000", + "type": "integer", + "format": "int32", + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true + }, + "notes": { + "description": "Notes to add to this item.\nThis can be used to add special instructions.", + "type": "string" + }, + "pet": { + "description": "The Pet to add to this NoModel items bucket.\nPets can appear more than once in the bucket", + "allOf": [ + { + "$ref": "#/definitions/pet" + } + ] + }, + "quantity": { + "description": "The amount of pets to add to this bucket.", + "type": "integer", + "format": "int16", + "maximum": 10, + "minimum": 1 + } + } + } + }, + "headers": { + "active": { + "type": "boolean", + "default": true, + "description": "Active state of the record" + }, + "bar_slice": { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxItems": 9, + "minItems": 4, + "type": "array", + "items": { + "maxItems": 8, + "minItems": 5, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string" + } + } + }, + "collectionFormat": "pipe", + "description": "a BarSlice has bars which are strings" + }, + "created": { + "type": "string", + "format": "date-time", + "description": "Created holds the time when this entry was created" + }, + "foo_slice": { + "maxItems": 10, + "minItems": 3, + "uniqueItems": true, + "type": "array", + "items": { + "maxLength": 10, + "minLength": 3, + "pattern": "\\w+", + "type": "string", + "example": "foo" + }, + "collectionFormat": "pipe", + "description": "a FooSlice has foos which are strings" + }, + "id": { + "maximum": 1000, + "exclusiveMaximum": true, + "minimum": 10, + "exclusiveMinimum": true, + "type": "integer", + "format": "int64", + "default": 11, + "description": "ID of this some response instance.\nids in this application start at 11 and are smaller than 1000" + }, + "score": { + "maximum": 45, + "minimum": 3, + "multipleOf": 3, + "type": "integer", + "format": "int32", + "example": 27, + "description": "The Score of this model" + }, + "x-hdr-name": { + "maxLength": 50, + "minLength": 4, + "pattern": "[A-Za-z0-9-.]*", + "type": "string", + "description": "Name of this some response instance" + } + } + }, + "validationError": { + "description": "A ValidationError is an error that is used when the required input fails validation.", + "schema": { + "type": "object", + "properties": { + "FieldName": { + "description": "An optional field name to which this validation applies", + "type": "string" + }, + "Message": { + "description": "The validation message", + "type": "string" + } + } + }, + "headers": { + "code": { + "enum": [ + "foo", + "bar" + ], + "type": "integer", + "format": "int64", + "default": 400 + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/classification_routes.json b/fixtures/integration/golden/classification_routes.json index 7746e7b..1517db8 100644 --- a/fixtures/integration/golden/classification_routes.json +++ b/fixtures/integration/golden/classification_routes.json @@ -42,7 +42,7 @@ ] } ], - "x-some-flag": "false", + "x-some-flag": false, "x-some-list": [ "item1", "item2", @@ -282,7 +282,7 @@ ] } ], - "x-some-flag": "true" + "x-some-flag": true }, "post": { "consumes": [ diff --git a/fixtures/integration/golden/classification_routes_body.json b/fixtures/integration/golden/classification_routes_body.json index b14aa22..589b27b 100644 --- a/fixtures/integration/golden/classification_routes_body.json +++ b/fixtures/integration/golden/classification_routes_body.json @@ -51,7 +51,7 @@ ] } ], - "x-some-flag": "false", + "x-some-flag": false, "x-some-list": [ "item1", "item2", @@ -98,8 +98,6 @@ "name": "request", "in": "body", "schema": { - "description": "The request model.", - "type": "object", "$ref": "#/definitions/orderModel" } } @@ -267,6 +265,9 @@ "$ref": "#/definitions/someResponse" } }, + "202": { + "description": "Some description" + }, "422": { "description": "validationError", "schema": { @@ -327,8 +328,6 @@ "allowEmptyValue": true }, { - "maxLength": 20, - "minLength": 5, "type": "array", "description": "some query values", "name": "someQuery", @@ -358,7 +357,6 @@ "name": "request", "in": "body", "schema": { - "description": "The request model.", "type": "string", "default": "orange", "enum": [ @@ -458,7 +456,7 @@ ] } ], - "x-some-flag": "true" + "x-some-flag": true }, "post": { "consumes": [ @@ -487,8 +485,6 @@ "name": "request", "in": "body", "schema": { - "description": "The request model.", - "type": "object", "$ref": "#/definitions/petModel" } }, diff --git a/fixtures/integration/golden/classification_schema_NoModel.json b/fixtures/integration/golden/classification_schema_NoModel.json index eee3651..b17b0e4 100644 --- a/fixtures/integration/golden/classification_schema_NoModel.json +++ b/fixtures/integration/golden/classification_schema_NoModel.json @@ -251,7 +251,6 @@ "description": "the name for this user", "type": "integer", "format": "int64", - "minLength": 3, "x-go-name": "UserID" } }, diff --git a/fixtures/integration/golden/enhancements_alias_expand.json b/fixtures/integration/golden/enhancements_alias_expand.json index 2fe6aca..5c05c74 100644 --- a/fixtures/integration/golden/enhancements_alias_expand.json +++ b/fixtures/integration/golden/enhancements_alias_expand.json @@ -66,16 +66,6 @@ }, "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/alias-expand" }, - "EnvelopeAlias2": { - "type": "object", - "title": "EnvelopeAlias2 aliases EnvelopeAlias (alias-of-alias).", - "properties": { - "payload": { - "$ref": "#/definitions/PayloadAlias" - } - }, - "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/alias-expand" - }, "Payload": { "type": "object", "title": "Payload is the canonical struct referenced by aliases.", @@ -150,6 +140,7 @@ }, "exportedParams": { "type": "object", + "title": "exportedParams is the backing struct for an aliased swagger:parameters.", "required": [ "data" ], @@ -169,7 +160,7 @@ "aliasedResponse": { "description": "AliasedResponse has a body field whose type is an alias chain.", "schema": { - "$ref": "#/definitions/EnvelopeAlias2" + "$ref": "#/definitions/ResponseEnvelope" } }, "namedTopResponse": { diff --git a/fixtures/integration/golden/enhancements_alias_ref.json b/fixtures/integration/golden/enhancements_alias_ref.json index e92e4ad..eaee663 100644 --- a/fixtures/integration/golden/enhancements_alias_ref.json +++ b/fixtures/integration/golden/enhancements_alias_ref.json @@ -66,6 +66,7 @@ }, "exportedParams": { "type": "object", + "title": "exportedParams is the backing struct for an aliased swagger:parameters.", "required": [ "data" ], diff --git a/fixtures/integration/golden/enhancements_alias_response_shapes_default.json b/fixtures/integration/golden/enhancements_alias_response_shapes_default.json new file mode 100644 index 0000000..b645b10 --- /dev/null +++ b/fixtures/integration/golden/enhancements_alias_response_shapes_default.json @@ -0,0 +1,47 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Envelope": { + "type": "object", + "title": "Envelope is the canonical named struct.", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/alias-response-shapes" + } + }, + "responses": { + "bodyAliasResponse": { + "description": "BodyAliasResponse — body field uses an alias-of-alias chain.", + "schema": { + "$ref": "#/definitions/Envelope" + } + }, + "headerAliasedBasicResponse": { + "description": "HeaderAliasedBasicResponse — header field is an alias of a\nnamed string. Expected emission: primitive inline\n{string, \"\"}; no $ref under any mode (headers can't carry\n$ref).", + "headers": { + "X-Session": { + "type": "string" + } + } + }, + "headerAliasedStructResponse": { + "description": "HeaderAliasedStructResponse — header field is an alias of a\nnamed struct. Expected emission: NO body schema corruption\n(Q2 fix); header surfaces empty (struct can't reduce to a\nSimpleSchema primitive); CodeUnsupportedInSimpleSchema\ndiagnostic fires for the underlying ref attempt.", + "headers": { + "X-Detail": {} + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_alias_response_shapes_ref.json b/fixtures/integration/golden/enhancements_alias_response_shapes_ref.json new file mode 100644 index 0000000..133c139 --- /dev/null +++ b/fixtures/integration/golden/enhancements_alias_response_shapes_ref.json @@ -0,0 +1,55 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Envelope": { + "type": "object", + "title": "Envelope is the canonical named struct.", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/alias-response-shapes" + }, + "EnvelopeAlias": { + "title": "EnvelopeAlias is an alias of Envelope.", + "$ref": "#/definitions/Envelope" + }, + "EnvelopeAlias2": { + "title": "EnvelopeAlias2 is an alias of EnvelopeAlias (alias-of-alias).", + "$ref": "#/definitions/EnvelopeAlias" + } + }, + "responses": { + "bodyAliasResponse": { + "description": "BodyAliasResponse — body field uses an alias-of-alias chain.", + "schema": { + "$ref": "#/definitions/EnvelopeAlias2" + } + }, + "headerAliasedBasicResponse": { + "description": "HeaderAliasedBasicResponse — header field is an alias of a\nnamed string. Expected emission: primitive inline\n{string, \"\"}; no $ref under any mode (headers can't carry\n$ref).", + "headers": { + "X-Session": { + "type": "string" + } + } + }, + "headerAliasedStructResponse": { + "description": "HeaderAliasedStructResponse — header field is an alias of a\nnamed struct. Expected emission: NO body schema corruption\n(Q2 fix); header surfaces empty (struct can't reduce to a\nSimpleSchema primitive); CodeUnsupportedInSimpleSchema\ndiagnostic fires for the underlying ref attempt.", + "headers": { + "X-Detail": {} + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_alias_response_shapes_transparent.json b/fixtures/integration/golden/enhancements_alias_response_shapes_transparent.json new file mode 100644 index 0000000..b645b10 --- /dev/null +++ b/fixtures/integration/golden/enhancements_alias_response_shapes_transparent.json @@ -0,0 +1,47 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Envelope": { + "type": "object", + "title": "Envelope is the canonical named struct.", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/alias-response-shapes" + } + }, + "responses": { + "bodyAliasResponse": { + "description": "BodyAliasResponse — body field uses an alias-of-alias chain.", + "schema": { + "$ref": "#/definitions/Envelope" + } + }, + "headerAliasedBasicResponse": { + "description": "HeaderAliasedBasicResponse — header field is an alias of a\nnamed string. Expected emission: primitive inline\n{string, \"\"}; no $ref under any mode (headers can't carry\n$ref).", + "headers": { + "X-Session": { + "type": "string" + } + } + }, + "headerAliasedStructResponse": { + "description": "HeaderAliasedStructResponse — header field is an alias of a\nnamed struct. Expected emission: NO body schema corruption\n(Q2 fix); header surfaces empty (struct can't reduce to a\nSimpleSchema primitive); CodeUnsupportedInSimpleSchema\ndiagnostic fires for the underlying ref attempt.", + "headers": { + "X-Detail": {} + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_all_http_methods.json b/fixtures/integration/golden/enhancements_all_http_methods.json index 4231faa..a6eec0b 100644 --- a/fixtures/integration/golden/enhancements_all_http_methods.json +++ b/fixtures/integration/golden/enhancements_all_http_methods.json @@ -10,7 +10,7 @@ "operationId": "getItem", "responses": { "200": { - "description": " OK" + "description": "OK" } } }, @@ -22,7 +22,7 @@ "operationId": "putItem", "responses": { "200": { - "description": " OK" + "description": "OK" } } }, @@ -34,7 +34,7 @@ "operationId": "postItem", "responses": { "201": { - "description": " Created" + "description": "Created" } } }, @@ -46,7 +46,7 @@ "operationId": "deleteItem", "responses": { "204": { - "description": " No Content" + "description": "No Content" } } }, @@ -58,7 +58,7 @@ "operationId": "optionsItem", "responses": { "200": { - "description": " OK" + "description": "OK" } } }, @@ -70,7 +70,7 @@ "operationId": "headItem", "responses": { "200": { - "description": " OK" + "description": "OK" } } }, @@ -82,7 +82,7 @@ "operationId": "patchItem", "responses": { "200": { - "description": " OK" + "description": "OK" } } } diff --git a/fixtures/integration/golden/enhancements_allof_edges.json b/fixtures/integration/golden/enhancements_allof_edges.json index 8f512b0..5f763c6 100644 --- a/fixtures/integration/golden/enhancements_allof_edges.json +++ b/fixtures/integration/golden/enhancements_allof_edges.json @@ -59,6 +59,7 @@ "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/allof-edges" }, "AllOfStrfmt": { + "title": "AllOfStrfmt composes an allOf member that carries a swagger:strfmt tag.", "allOf": [ { "type": "string", diff --git a/fixtures/integration/golden/enhancements_enum_overrides.json b/fixtures/integration/golden/enhancements_enum_overrides.json new file mode 100644 index 0000000..ddd4a68 --- /dev/null +++ b/fixtures/integration/golden/enhancements_enum_overrides.json @@ -0,0 +1,97 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "NotificationA": { + "description": "NotificationA exercises case A: field uses PriorityA, no inline\nenum override.", + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "priority": { + "description": "The priority level. Enum values come from PriorityA's consts.\nlow PriorityALow\nmedium PriorityAMed\nhigh PriorityAHigh", + "type": "string", + "enum": [ + "low", + "medium", + "high" + ], + "x-go-enum-desc": "low PriorityALow\nmedium PriorityAMed\nhigh PriorityAHigh", + "x-go-name": "Priority" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/enum-overrides" + }, + "NotificationB": { + "description": "NotificationB exercises case B: plain string field with inline\ncomma-list enum. No swagger:enum on the type, no consts in code.", + "type": "object", + "properties": { + "priority": { + "description": "The priority level.", + "type": "string", + "enum": [ + "low", + "medium", + "high" + ], + "x-go-name": "Priority" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/enum-overrides" + }, + "NotificationC": { + "type": "object", + "title": "NotificationC exercises case C: inline JSON-array enum.", + "properties": { + "priority": { + "description": "The priority level.", + "type": "string", + "enum": [ + "low", + "medium", + "high" + ], + "x-go-name": "Priority" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/enum-overrides" + }, + "NotificationD": { + "type": "object", + "title": "NotificationD exercises case D.", + "properties": { + "priority": { + "$ref": "#/definitions/PriorityD" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/enum-overrides" + }, + "NotificationE": { + "description": "NotificationE exercises case E: the inline enum on the field\ncompetes with the const-derived enum from PriorityE. The golden\noutput captures which one wins in v1.", + "type": "object", + "properties": { + "priority": { + "description": "Inline enum provides a narrower set than the const block.", + "type": "string", + "enum": [ + "urgent", + "normal" + ], + "x-go-name": "Priority" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/enum-overrides" + }, + "PriorityD": { + "description": "PriorityD has a swagger:enum annotation but no corresponding\nconst declarations in this package. The builder's FindEnumValues\ncall returns an empty slice; the test captures how the spec\nrenders in that case.", + "type": "string", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/enum-overrides" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_generic_instantiation.json b/fixtures/integration/golden/enhancements_generic_instantiation.json new file mode 100644 index 0000000..b303cce --- /dev/null +++ b/fixtures/integration/golden/enhancements_generic_instantiation.json @@ -0,0 +1,41 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "generic_container": { + "description": "These should emit with the substituted underlying shape, not as\n$ref to the (empty) generic declaration.", + "type": "object", + "title": "Container holds fields whose types are generic INSTANTIATIONS.", + "properties": { + "counts": { + "description": "Counts is an instantiation of GenericMap with K=string, V=int.\nExpected schema: {object, additionalProperties:{integer, int64}}.", + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + }, + "x-go-name": "Counts" + }, + "items": { + "description": "Items is an instantiation of GenericSlice with T=int.\nExpected schema: {array, items:{integer, int64}}.", + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "x-go-name": "Items" + }, + "names": { + "description": "Names is an instantiation of GenericSlice with T=string.\nExpected schema: {array, items:{string}}.", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "Names" + } + }, + "x-go-name": "Container", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/generic-instantiation" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_interface_methods.json b/fixtures/integration/golden/enhancements_interface_methods.json index 6ef3996..fa45fb6 100644 --- a/fixtures/integration/golden/enhancements_interface_methods.json +++ b/fixtures/integration/golden/enhancements_interface_methods.json @@ -3,6 +3,7 @@ "paths": {}, "definitions": { "Audited": { + "description": "Audited is a small named interface that is embedded with swagger:allOf\ninto richer interfaces below.", "type": "object", "properties": { "createdAt": { diff --git a/fixtures/integration/golden/enhancements_interface_methods_xnullable.json b/fixtures/integration/golden/enhancements_interface_methods_xnullable.json index 3c677c4..6d6fe00 100644 --- a/fixtures/integration/golden/enhancements_interface_methods_xnullable.json +++ b/fixtures/integration/golden/enhancements_interface_methods_xnullable.json @@ -3,6 +3,7 @@ "paths": {}, "definitions": { "Audited": { + "description": "Audited is a small named interface that is embedded with swagger:allOf\ninto richer interfaces below.", "type": "object", "properties": { "createdAt": { diff --git a/fixtures/integration/golden/enhancements_meta_lists_flex_forms.json b/fixtures/integration/golden/enhancements_meta_lists_flex_forms.json new file mode 100644 index 0000000..27e1c95 --- /dev/null +++ b/fixtures/integration/golden/enhancements_meta_lists_flex_forms.json @@ -0,0 +1,19 @@ +{ + "consumes": [ + "application/json" + ], + "produces": [ + "application/json", + "application/xml" + ], + "schemes": [ + "http", + "https", + "ws" + ], + "swagger": "2.0", + "info": { + "description": "Package meta_lists_flex_forms witnesses Property.AsList covering\nthe meta (swagger:meta) annotation surface for list-shaped\nkeywords. The meta dispatcher in builders/spec/walker.go reads\nschemes / consumes / produces via the same Property.AsList seam\nthe routes dispatcher uses, so the accepted forms are identical.\n\n\nTitle:\n\nLists Flex Forms (meta)\n\nDescription:\n\nWitnesses inline + multi-line list forms on the meta surface." + }, + "paths": {} +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_parameters_map_postdecl.json b/fixtures/integration/golden/enhancements_parameters_map_postdecl.json new file mode 100644 index 0000000..0e335eb --- /dev/null +++ b/fixtures/integration/golden/enhancements_parameters_map_postdecl.json @@ -0,0 +1,48 @@ +{ + "swagger": "2.0", + "paths": { + "/items": { + "post": { + "summary": "Send a map body.", + "operationId": "mapBody", + "parameters": [ + { + "x-go-name": "Items", + "description": "Items is a body parameter of type map[string]LocalItem.", + "name": "items", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/LocalItem" + } + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + } + }, + "definitions": { + "LocalItem": { + "description": "LocalItem — NOT annotated; reachable only via the map field on\nMapParams below.", + "type": "object", + "properties": { + "name": { + "type": "string", + "x-go-name": "Name" + }, + "tag": { + "type": "string", + "x-go-name": "Tag" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/parameters-map-postdecl" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_raw_message_override.json b/fixtures/integration/golden/enhancements_raw_message_override.json new file mode 100644 index 0000000..c0162dc --- /dev/null +++ b/fixtures/integration/golden/enhancements_raw_message_override.json @@ -0,0 +1,73 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "AsArray": { + "type": "array", + "title": "AsArray (case B.2) — named wrapper with `swagger:type array`.", + "items": { + "type": "integer", + "format": "uint8" + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/raw-message-override" + }, + "AsObject": { + "description": "AsObject (case B.1) — named wrapper of json.RawMessage with\n`swagger:type object`. The classifier overrides the recognizer\nbecause IsStdJSONRawMessage checks for encoding/json.RawMessage\nidentity; the wrapper has its own package path and does NOT\nmatch. The classifier on the slice arm fires instead.", + "type": "object", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/raw-message-override" + }, + "FieldLevelContainer": { + "description": "FieldLevelContainer (case C) — exercises field-level\n`swagger:type` on plain json.RawMessage fields. The field-level\nwalker (scanFieldDoc) consumes swagger:type and applyFieldCarrier\napplies it after buildFromType, so the user override beats the\nRawMessage recognizer's empty-schema default.", + "type": "object", + "properties": { + "arr": { + "description": "Overridden to {type: array, items: {integer, uint8}} via\nfield-level swagger:type. \"array\" isn't a known SwaggerSchemaForType\nvalue, so the override falls back to building from Underlying()\n(= []byte) which yields the byte-typed array shape.", + "type": "array", + "items": { + "type": "integer", + "format": "uint8" + }, + "x-go-name": "Arr" + }, + "obj": { + "description": "Overridden to {type: object} via field-level swagger:type.", + "type": "object", + "x-go-name": "Obj" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/raw-message-override" + }, + "PlainContainer": { + "type": "object", + "title": "PlainContainer (case A) — bare json.RawMessage field, no overrides.", + "properties": { + "payload": { + "description": "Should emit an empty schema (`{}`).", + "x-go-name": "Payload" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/raw-message-override" + }, + "TypedContainer": { + "type": "object", + "title": "TypedContainer (case B field reference) — references the wrappers.", + "properties": { + "arr": { + "description": "Wrapped via AsArray — expect `{$ref: AsArray}` whose definition\nresolves to a typed array shape.", + "type": "array", + "items": { + "type": "integer", + "format": "uint8" + }, + "x-go-name": "Arr" + }, + "obj": { + "description": "Wrapped via AsObject — expect `{$ref: AsObject}` whose definition\nresolves to `{type: object}` (the classifier wins on the decl).", + "type": "object", + "x-go-name": "Obj" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/raw-message-override" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_response_file_types.json b/fixtures/integration/golden/enhancements_response_file_types.json new file mode 100644 index 0000000..8de73cd --- /dev/null +++ b/fixtures/integration/golden/enhancements_response_file_types.json @@ -0,0 +1,30 @@ +{ + "swagger": "2.0", + "paths": {}, + "responses": { + "fileBodyResponse": { + "description": "FileBodyResponse is the legitimate case: the response IS a file.\n`swagger:file` on the Body field with `in: body` rewrites\nresp.Schema to {file, \"\"} and skips the field build.", + "schema": { + "type": "file" + } + }, + "fileOnHeaderResponse": { + "description": "FileOnHeaderResponse exercises the Q3 misuse: `swagger:file` on\na header-positioned field. Post-fix the diagnostic fires and\nthe field falls through to the normal build, surfacing as a\nregular header (typed as string here).", + "headers": { + "X-Misplaced": { + "type": "string", + "description": "Misplaced has both `in: header` and `swagger:file`. The\nscanner diagnoses and treats it as a normal header." + } + } + }, + "fileOnImplicitDefaultResponse": { + "description": "FileOnImplicitDefaultResponse — `swagger:file` on a field with\nno `in:` line. After Q1 the implicit default is header, so this\nis also a misuse. Same diagnostic fires; field becomes a header.", + "headers": { + "X-Implicit": { + "type": "string", + "description": "Implicit has only `swagger:file` — no `in:` line at all.\nQ1's implicit-header default applies; Q3's gate diagnoses." + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_response_header_ref_leak.json b/fixtures/integration/golden/enhancements_response_header_ref_leak.json new file mode 100644 index 0000000..20d26ec --- /dev/null +++ b/fixtures/integration/golden/enhancements_response_header_ref_leak.json @@ -0,0 +1,41 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Tag": { + "description": "Tag is a named struct intended to be referenceable as a model\nfrom body schemas. Using it as the type of a header field is the\nauthor misuse this fixture captures.", + "type": "object", + "properties": { + "name": { + "type": "string", + "x-go-name": "Name" + }, + "value": { + "type": "string", + "x-go-name": "Value" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/response-header-ref-leak" + } + }, + "responses": { + "leakResponse": { + "description": "LeakResponse pins the post-fix shape: the header field is\nsurfaced, the body schema is NOT corrupted, and the diagnostic\nsignals the misuse.", + "headers": { + "X-Tag": { + "description": "TagHeader is a header field typed as a named struct. Pre-Q2\nthis leaked `$ref: \"#/definitions/Tag\"` onto resp.Schema and\nleft the header empty. Post-fix resp.Schema stays nil; the\nheader surfaces empty (no Type — the named struct can't\nreduce to a SimpleSchema primitive); diagnostic fires." + } + } + }, + "leakWithStrfmtResponse": { + "description": "LeakWithStrfmtResponse pins the post-fix shape under the strfmt\noverride: the header gets {string, uuid}, the body schema stays\nnil, and the diagnostic still fires for the underlying ref\nattempt.", + "headers": { + "X-Tag-ID": { + "type": "string", + "format": "uuid", + "description": "TagID is a header field with strfmt override on a named-struct\ntype. The strfmt override runs after the exit-validator's\nreset, so the header surfaces as {string, uuid}. No body-schema\nleak." + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_response_implicit_header.json b/fixtures/integration/golden/enhancements_response_implicit_header.json new file mode 100644 index 0000000..6d5f8b4 --- /dev/null +++ b/fixtures/integration/golden/enhancements_response_implicit_header.json @@ -0,0 +1,55 @@ +{ + "swagger": "2.0", + "paths": {}, + "responses": { + "allHeadersResponse": { + "description": "AllHeadersResponse — every field becomes a header. Etag carries\nno `in:` line at all (default); RateLimit carries an explicit\n`in: header` (parity with existing fixtures).", + "headers": { + "ETag": { + "type": "string", + "description": "Etag carries an HTTP entity tag for cache validation.\nNo `in:` line — defaults to header (Q1 fix)." + }, + "X-Rate-Limit": { + "type": "integer", + "format": "int64", + "description": "RateLimit advertises the remaining quota." + } + } + }, + "emptyResponse": { + "description": "EmptyResponse — empty Go struct with a swagger:response\nannotation. Produces a response with description only; no Headers\nmap, no Schema." + }, + "invalidInResponse": { + "description": "InvalidInResponse — non-vocabulary `in:` value. The scanner emits\na CodeInvalidAnnotation warning naming the bad value, then falls\nback to the default (header).", + "headers": { + "Cookie": { + "type": "string", + "description": "Cookie carries a session cookie. The `in: cookie` line is\nnot in the OAS v2 vocabulary; the scanner warns and treats\nthe field as a header anyway." + } + } + }, + "mixedResponse": { + "description": "MixedResponse — body field plus headers (implicit + explicit).", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "x-go-name": "Message" + } + } + }, + "headers": { + "X-Limit": { + "type": "integer", + "format": "int64", + "description": "Limit advertises the remaining quota." + }, + "X-Tag": { + "type": "string", + "description": "Tag is a tracing tag. Implicit header (no `in:`)." + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_description_dash_list.json b/fixtures/integration/golden/enhancements_routes_description_dash_list.json new file mode 100644 index 0000000..b6089ac --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_description_dash_list.json @@ -0,0 +1,20 @@ +{ + "swagger": "2.0", + "paths": { + "/things": { + "post": { + "description": "This endpoint:\n- accepts a thing payload\n- returns 201 on success\n- returns a problem document on failure", + "tags": [ + "things" + ], + "summary": "Create a thing.", + "operationId": "createThing", + "responses": { + "200": { + "description": "OK" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_description_yaml_fence_absorb.json b/fixtures/integration/golden/enhancements_routes_description_yaml_fence_absorb.json new file mode 100644 index 0000000..2b946c4 --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_description_yaml_fence_absorb.json @@ -0,0 +1,15 @@ +{ + "swagger": "2.0", + "paths": { + "/things/{id}": { + "get": { + "description": "Some intro prose here.", + "tags": [ + "things" + ], + "summary": "Get a thing.", + "operationId": "getThing" + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_full_petstore_shape.json b/fixtures/integration/golden/enhancements_routes_full_petstore_shape.json new file mode 100644 index 0000000..9ca6faa --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_full_petstore_shape.json @@ -0,0 +1,99 @@ +{ + "swagger": "2.0", + "paths": { + "/pets": { + "post": { + "consumes": [ + "application/json", + "application/x-protobuf" + ], + "produces": [ + "application/json" + ], + "schemes": [ + "http", + "https" + ], + "tags": [ + "pets", + "users" + ], + "summary": "Create a pet with the full metadata surface.", + "operationId": "createPetFull", + "parameters": [ + { + "type": "boolean", + "default": false, + "description": "enable tracing", + "name": "trace", + "in": "query" + }, + { + "description": "pet to create", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Pet" + } + } + ], + "responses": { + "201": { + "description": "the created pet", + "schema": { + "$ref": "#/definitions/Pet" + } + }, + "default": { + "$ref": "#/responses/genericError" + } + }, + "security": [ + { + "api_key": [] + }, + { + "oauth": [ + "read", + "write" + ] + } + ], + "x-route-flag": true + } + } + }, + "definitions": { + "Pet": { + "type": "object", + "title": "Pet is a pet on offer.", + "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/routes-full-petstore-shape" + } + }, + "responses": { + "genericError": { + "description": "GenericError is the catch-all error response.", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "x-go-name": "Message" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_lists_flex_forms.json b/fixtures/integration/golden/enhancements_routes_lists_flex_forms.json new file mode 100644 index 0000000..eb6fc12 --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_lists_flex_forms.json @@ -0,0 +1,94 @@ +{ + "swagger": "2.0", + "paths": { + "/alpha": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "schemes": [ + "http", + "https" + ], + "tags": [ + "lists" + ], + "summary": "Inline comma-separated form.", + "operationId": "commaInline", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/beta": { + "get": { + "consumes": [ + "application/json", + "application/xml" + ], + "produces": [ + "application/json" + ], + "schemes": [ + "http", + "https" + ], + "tags": [ + "lists" + ], + "summary": "Multi-line YAML-dash form.", + "operationId": "yamlDash", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/delta": { + "get": { + "schemes": [ + "http", + "https", + "ws" + ], + "tags": [ + "lists" + ], + "summary": "Inline-plus-indented continuation.", + "operationId": "mixedInlineYAML", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/gamma": { + "get": { + "consumes": [ + "application/json", + "application/xml" + ], + "produces": [ + "application/json" + ], + "tags": [ + "lists" + ], + "summary": "Multi-line indented bare-lines form (no `-` markers).", + "operationId": "bareLines", + "responses": { + "200": { + "description": "OK" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_multi_method_same_path.json b/fixtures/integration/golden/enhancements_routes_multi_method_same_path.json new file mode 100644 index 0000000..8bd4e35 --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_multi_method_same_path.json @@ -0,0 +1,79 @@ +{ + "swagger": "2.0", + "paths": { + "/pets": { + "get": { + "tags": [ + "pets" + ], + "summary": "List pets.", + "operationId": "listPetsMulti", + "parameters": [ + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "description": "page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "pets", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Pet" + } + } + } + } + }, + "post": { + "tags": [ + "pets" + ], + "summary": "Create a pet.", + "operationId": "createPetMulti", + "parameters": [ + { + "description": "pet to create", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Pet" + } + } + ], + "responses": { + "201": { + "description": "the created pet", + "schema": { + "$ref": "#/definitions/Pet" + } + } + } + } + } + }, + "definitions": { + "Pet": { + "type": "object", + "title": "Pet is a pet on offer.", + "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/routes-multi-method-same-path" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_params_body_array.json b/fixtures/integration/golden/enhancements_routes_params_body_array.json new file mode 100644 index 0000000..a44d4d6 --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_params_body_array.json @@ -0,0 +1,51 @@ +{ + "swagger": "2.0", + "paths": { + "/pets": { + "post": { + "tags": [ + "pets" + ], + "summary": "Create several pets.", + "operationId": "createPets", + "parameters": [ + { + "description": "pets to create", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Pet" + } + } + } + ], + "responses": { + "201": { + "description": "created" + } + } + } + } + }, + "definitions": { + "Pet": { + "type": "object", + "title": "Pet is a pet on offer.", + "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/routes-params-body-array" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_params_body_array_nested.json b/fixtures/integration/golden/enhancements_routes_params_body_array_nested.json new file mode 100644 index 0000000..52633b7 --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_params_body_array_nested.json @@ -0,0 +1,49 @@ +{ + "swagger": "2.0", + "paths": { + "/matrix": { + "post": { + "tags": [ + "items" + ], + "summary": "Submit a matrix of items.", + "operationId": "submitMatrix", + "parameters": [ + { + "description": "nested grid of items", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/Item" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + } + }, + "definitions": { + "Item": { + "type": "object", + "title": "Item is one item.", + "properties": { + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/routes-params-body-array-nested" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_params_body_ref.json b/fixtures/integration/golden/enhancements_routes_params_body_ref.json new file mode 100644 index 0000000..284c877 --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_params_body_ref.json @@ -0,0 +1,48 @@ +{ + "swagger": "2.0", + "paths": { + "/pets": { + "post": { + "tags": [ + "pets" + ], + "summary": "Create a new pet.", + "operationId": "createPet", + "parameters": [ + { + "description": "pet to create", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Pet" + } + } + ], + "responses": { + "201": { + "description": "created" + } + } + } + } + }, + "definitions": { + "Pet": { + "type": "object", + "title": "Pet is a pet on offer.", + "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/routes-params-body-ref" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_params_body_with_schema_validations.json b/fixtures/integration/golden/enhancements_routes_params_body_with_schema_validations.json new file mode 100644 index 0000000..742ff20 --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_params_body_with_schema_validations.json @@ -0,0 +1,51 @@ +{ + "swagger": "2.0", + "paths": { + "/pets": { + "post": { + "tags": [ + "pets" + ], + "summary": "Create a pet with author-asserted schema constraints.", + "operationId": "createPetWithOverrides", + "parameters": [ + { + "description": "pet to create", + "name": "body", + "in": "body", + "required": true, + "schema": { + "format": "special", + "maximum": 999, + "minimum": 0, + "$ref": "#/definitions/Pet" + } + } + ], + "responses": { + "201": { + "description": "created" + } + } + } + } + }, + "definitions": { + "Pet": { + "type": "object", + "title": "Pet is a pet on offer.", + "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/routes-params-body-with-schema-validations" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_params_empty_chunk.json b/fixtures/integration/golden/enhancements_routes_params_empty_chunk.json new file mode 100644 index 0000000..c09e63a --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_params_empty_chunk.json @@ -0,0 +1,19 @@ +{ + "swagger": "2.0", + "paths": { + "/empty": { + "get": { + "tags": [ + "items" + ], + "summary": "Endpoint with an empty parameter chunk.", + "operationId": "emptyChunkOp", + "responses": { + "200": { + "description": "OK" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_params_form_string.json b/fixtures/integration/golden/enhancements_routes_params_form_string.json new file mode 100644 index 0000000..587aa7f --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_params_form_string.json @@ -0,0 +1,28 @@ +{ + "swagger": "2.0", + "paths": { + "/forms": { + "post": { + "tags": [ + "forms" + ], + "summary": "Submit a form.", + "operationId": "submitForm", + "parameters": [ + { + "type": "string", + "description": "optional comment", + "name": "comment", + "in": "formData", + "allowEmptyValue": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_params_header_string.json b/fixtures/integration/golden/enhancements_routes_params_header_string.json new file mode 100644 index 0000000..69755be --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_params_header_string.json @@ -0,0 +1,30 @@ +{ + "swagger": "2.0", + "paths": { + "/items": { + "get": { + "tags": [ + "items" + ], + "summary": "List items with auth header.", + "operationId": "secureItems", + "parameters": [ + { + "pattern": "^[0-9a-fA-F-]+$", + "type": "string", + "format": "uuid", + "description": "request correlation id", + "name": "X-Request-ID", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_params_multiple.json b/fixtures/integration/golden/enhancements_routes_params_multiple.json new file mode 100644 index 0000000..a503f31 --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_params_multiple.json @@ -0,0 +1,62 @@ +{ + "swagger": "2.0", + "paths": { + "/pets/{id}": { + "put": { + "tags": [ + "pets" + ], + "summary": "Update a pet identified by id.", + "operationId": "updatePet", + "parameters": [ + { + "type": "integer", + "description": "pet identifier", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "boolean", + "default": false, + "description": "validate without persisting", + "name": "dryRun", + "in": "query" + }, + { + "description": "updated pet", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Pet" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + } + }, + "definitions": { + "Pet": { + "type": "object", + "title": "Pet is a pet on offer.", + "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/routes-params-multiple" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_params_path.json b/fixtures/integration/golden/enhancements_routes_params_path.json new file mode 100644 index 0000000..901b2a4 --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_params_path.json @@ -0,0 +1,28 @@ +{ + "swagger": "2.0", + "paths": { + "/items/{id}": { + "get": { + "tags": [ + "items" + ], + "summary": "Get an item by id.", + "operationId": "getItem", + "parameters": [ + { + "type": "integer", + "description": "item identifier", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_params_query_array.json b/fixtures/integration/golden/enhancements_routes_params_query_array.json new file mode 100644 index 0000000..2a01cac --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_params_query_array.json @@ -0,0 +1,32 @@ +{ + "swagger": "2.0", + "paths": { + "/items": { + "get": { + "tags": [ + "items" + ], + "summary": "Filter items by tags.", + "operationId": "filterByTags", + "parameters": [ + { + "enum": [ + "red", + "green", + "blue" + ], + "type": "array", + "description": "tags filter", + "name": "tags", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_params_query_boolean.json b/fixtures/integration/golden/enhancements_routes_params_query_boolean.json new file mode 100644 index 0000000..4ac2467 --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_params_query_boolean.json @@ -0,0 +1,28 @@ +{ + "swagger": "2.0", + "paths": { + "/items": { + "get": { + "tags": [ + "items" + ], + "summary": "List items, optionally including archived.", + "operationId": "listItemsBool", + "parameters": [ + { + "type": "boolean", + "default": false, + "description": "include archived items", + "name": "includeArchived", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_params_query_number.json b/fixtures/integration/golden/enhancements_routes_params_query_number.json new file mode 100644 index 0000000..bb3bc9f --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_params_query_number.json @@ -0,0 +1,30 @@ +{ + "swagger": "2.0", + "paths": { + "/items": { + "get": { + "tags": [ + "items" + ], + "summary": "Filter items by price.", + "operationId": "filterItems", + "parameters": [ + { + "maximum": 999.99, + "minimum": 0, + "type": "number", + "default": 100, + "description": "maximum price", + "name": "price", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_params_query_string.json b/fixtures/integration/golden/enhancements_routes_params_query_string.json new file mode 100644 index 0000000..60631ee --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_params_query_string.json @@ -0,0 +1,29 @@ +{ + "swagger": "2.0", + "paths": { + "/items": { + "get": { + "tags": [ + "items" + ], + "summary": "Search items by tag.", + "operationId": "searchItems", + "parameters": [ + { + "pattern": "^[a-z]+$", + "type": "string", + "format": "byte", + "description": "tag to filter by", + "name": "tag", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_params_query_validations.json b/fixtures/integration/golden/enhancements_routes_params_query_validations.json new file mode 100644 index 0000000..05c5ca6 --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_params_query_validations.json @@ -0,0 +1,41 @@ +{ + "swagger": "2.0", + "paths": { + "/items": { + "get": { + "tags": [ + "items" + ], + "summary": "List items filtered by parameters.", + "operationId": "listItems", + "parameters": [ + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "default": 20, + "description": "maximum number of items returned", + "name": "limit", + "in": "query" + }, + { + "enum": [ + "pending", + "active", + "archived" + ], + "type": "string", + "description": "filter by status", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_params_unknown_key.json b/fixtures/integration/golden/enhancements_routes_params_unknown_key.json new file mode 100644 index 0000000..bbec80b --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_params_unknown_key.json @@ -0,0 +1,28 @@ +{ + "swagger": "2.0", + "paths": { + "/items": { + "get": { + "tags": [ + "items" + ], + "summary": "List items.", + "operationId": "listItemsUnknown", + "parameters": [ + { + "minimum": 1, + "type": "integer", + "description": "max results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_responses_array.json b/fixtures/integration/golden/enhancements_routes_responses_array.json new file mode 100644 index 0000000..5245de1 --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_responses_array.json @@ -0,0 +1,66 @@ +{ + "swagger": "2.0", + "paths": { + "/pets": { + "get": { + "tags": [ + "pets" + ], + "summary": "List pets and a matrix of items.", + "operationId": "listPetsArray", + "responses": { + "200": { + "description": "the pet list", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Pet" + } + } + }, + "202": { + "description": "items grid", + "schema": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/Item" + } + } + } + } + } + } + } + }, + "definitions": { + "Item": { + "type": "object", + "title": "Item is one item.", + "properties": { + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/routes-responses-array" + }, + "Pet": { + "type": "object", + "title": "Pet is a pet on offer.", + "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/routes-responses-array" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_responses_default.json b/fixtures/integration/golden/enhancements_routes_responses_default.json new file mode 100644 index 0000000..c29ef9b --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_responses_default.json @@ -0,0 +1,33 @@ +{ + "swagger": "2.0", + "paths": { + "/health": { + "get": { + "tags": [ + "ops" + ], + "summary": "Health probe.", + "operationId": "health", + "responses": { + "default": { + "$ref": "#/responses/genericError" + } + } + } + } + }, + "responses": { + "genericError": { + "description": "GenericError is the catch-all error response.", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "x-go-name": "Message" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_responses_definition_fallback.json b/fixtures/integration/golden/enhancements_routes_responses_definition_fallback.json new file mode 100644 index 0000000..da438f6 --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_responses_definition_fallback.json @@ -0,0 +1,40 @@ +{ + "swagger": "2.0", + "paths": { + "/pets/{id}": { + "get": { + "tags": [ + "pets" + ], + "summary": "Get a pet.", + "operationId": "getPetFallback", + "responses": { + "200": { + "description": "Pet", + "schema": { + "$ref": "#/definitions/Pet" + } + } + } + } + } + }, + "definitions": { + "Pet": { + "type": "object", + "title": "Pet is a pet on offer.", + "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/routes-responses-definition-fallback" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_responses_description_only.json b/fixtures/integration/golden/enhancements_routes_responses_description_only.json new file mode 100644 index 0000000..04d6984 --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_responses_description_only.json @@ -0,0 +1,25 @@ +{ + "swagger": "2.0", + "paths": { + "/items/{id}": { + "get": { + "tags": [ + "items" + ], + "summary": "Get an item.", + "operationId": "getItemDescOnly", + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "not found" + }, + "500": { + "description": "server error" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_responses_empty_value.json b/fixtures/integration/golden/enhancements_routes_responses_empty_value.json new file mode 100644 index 0000000..ca35df2 --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_responses_empty_value.json @@ -0,0 +1,22 @@ +{ + "swagger": "2.0", + "paths": { + "/items/{id}": { + "delete": { + "tags": [ + "items" + ], + "summary": "Delete an item.", + "operationId": "deleteItemEmpty", + "responses": { + "204": { + "description": "" + }, + "404": { + "description": "not found" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_responses_mixed_bodies.json b/fixtures/integration/golden/enhancements_routes_responses_mixed_bodies.json new file mode 100644 index 0000000..3a5a2f3 --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_responses_mixed_bodies.json @@ -0,0 +1,57 @@ +{ + "swagger": "2.0", + "paths": { + "/pets/{id}": { + "get": { + "tags": [ + "pets" + ], + "summary": "Get a pet.", + "operationId": "getPetMixed", + "responses": { + "200": { + "description": "the pet", + "schema": { + "$ref": "#/definitions/Pet" + } + }, + "default": { + "$ref": "#/responses/genericError" + } + } + } + } + }, + "definitions": { + "Pet": { + "type": "object", + "title": "Pet is a pet on offer.", + "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/routes-responses-mixed-bodies" + } + }, + "responses": { + "genericError": { + "description": "GenericError is the catch-all error response.", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "x-go-name": "Message" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_responses_multiple_codes.json b/fixtures/integration/golden/enhancements_routes_responses_multiple_codes.json new file mode 100644 index 0000000..65b8f6d --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_responses_multiple_codes.json @@ -0,0 +1,63 @@ +{ + "swagger": "2.0", + "paths": { + "/pets/{id}": { + "get": { + "tags": [ + "pets" + ], + "summary": "Get a pet.", + "operationId": "getPetMultiCode", + "responses": { + "200": { + "description": "the pet", + "schema": { + "$ref": "#/definitions/Pet" + } + }, + "404": { + "description": "not found" + }, + "500": { + "description": "server error" + }, + "default": { + "$ref": "#/responses/genericError" + } + } + } + } + }, + "definitions": { + "Pet": { + "type": "object", + "title": "Pet is a pet on offer.", + "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/routes-responses-multiple-codes" + } + }, + "responses": { + "genericError": { + "description": "GenericError is the catch-all error response.", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "x-go-name": "Message" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_responses_positional.json b/fixtures/integration/golden/enhancements_routes_responses_positional.json new file mode 100644 index 0000000..0846b70 --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_responses_positional.json @@ -0,0 +1,33 @@ +{ + "swagger": "2.0", + "paths": { + "/items": { + "get": { + "tags": [ + "items" + ], + "summary": "List items.", + "operationId": "listItemsPositional", + "responses": { + "200": { + "$ref": "#/responses/someResponse" + } + } + } + } + }, + "responses": { + "someResponse": { + "description": "SomeResponse is a top-level response object.", + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "x-go-name": "Status" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_responses_ref_not_found.json b/fixtures/integration/golden/enhancements_routes_responses_ref_not_found.json new file mode 100644 index 0000000..17faabe --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_responses_ref_not_found.json @@ -0,0 +1,15 @@ +{ + "swagger": "2.0", + "paths": { + "/items": { + "get": { + "tags": [ + "items" + ], + "summary": "List items.", + "operationId": "listItemsRefNotFound", + "responses": {} + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_responses_space_body_quirk.json b/fixtures/integration/golden/enhancements_routes_responses_space_body_quirk.json new file mode 100644 index 0000000..d4a407c --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_responses_space_body_quirk.json @@ -0,0 +1,14 @@ +{ + "swagger": "2.0", + "paths": { + "/pets/{id}": { + "get": { + "tags": [ + "pets" + ], + "summary": "Get a pet.", + "operationId": "getPetSpaceQuirk" + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_responses_tagged_body.json b/fixtures/integration/golden/enhancements_routes_responses_tagged_body.json new file mode 100644 index 0000000..900ab3e --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_responses_tagged_body.json @@ -0,0 +1,43 @@ +{ + "swagger": "2.0", + "paths": { + "/pets/{id}": { + "get": { + "tags": [ + "pets" + ], + "summary": "Get a pet by id.", + "operationId": "getPet", + "responses": { + "200": { + "description": "the pet as returned", + "schema": { + "$ref": "#/definitions/Pet" + } + }, + "404": { + "description": "not found" + } + } + } + } + }, + "definitions": { + "Pet": { + "type": "object", + "title": "Pet is a pet on offer.", + "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/routes-responses-tagged-body" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_routes_responses_tagged_response.json b/fixtures/integration/golden/enhancements_routes_responses_tagged_response.json new file mode 100644 index 0000000..8307510 --- /dev/null +++ b/fixtures/integration/golden/enhancements_routes_responses_tagged_response.json @@ -0,0 +1,36 @@ +{ + "swagger": "2.0", + "paths": { + "/items": { + "get": { + "tags": [ + "items" + ], + "summary": "List items.", + "operationId": "listItemsTaggedResp", + "responses": { + "200": { + "description": "OK" + }, + "default": { + "$ref": "#/responses/genericError" + } + } + } + } + }, + "responses": { + "genericError": { + "description": "GenericError is the catch-all error response.", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "x-go-name": "Message" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_swagger_type_array.json b/fixtures/integration/golden/enhancements_swagger_type_array.json index b9d971a..b9d75e2 100644 --- a/fixtures/integration/golden/enhancements_swagger_type_array.json +++ b/fixtures/integration/golden/enhancements_swagger_type_array.json @@ -3,6 +3,7 @@ "paths": {}, "definitions": { "objectStruct": { + "description": "ObjectStruct carries swagger:type object (unsupported by\nswaggerSchemaForType for structs). The fix inlines the struct as\ntype:object rather than producing an empty schema.", "type": "object", "x-go-name": "ObjectStruct", "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/swagger-type-array" @@ -25,6 +26,7 @@ "x-go-name": "Labels" }, "nested": { + "description": "The nested struct with an unsupported swagger:type.", "type": "object", "properties": { "name": { @@ -47,6 +49,7 @@ "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/swagger-type-array" }, "structWithBadType": { + "description": "StructWithBadType is a struct whose swagger:type is set to an\nunrecognised value. The fix ensures buildNamedStruct falls through to\nmakeRef so the property is still serialisable — the key assertion is\nthat the referenced schema is not empty.", "x-go-name": "StructWithBadType", "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/swagger-type-array" } diff --git a/fixtures/integration/golden/enhancements_text_marshal.json b/fixtures/integration/golden/enhancements_text_marshal.json index 1cee1ce..2c82bdf 100644 --- a/fixtures/integration/golden/enhancements_text_marshal.json +++ b/fixtures/integration/golden/enhancements_text_marshal.json @@ -26,6 +26,38 @@ } }, "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/text-marshal" + }, + "override_container": { + "description": "OverrideContainer references UUID so the schema builder walks\nthe override path.", + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "format": "date", + "x-go-name": "ID" + } + }, + "x-go-name": "OverrideContainer", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/text-marshal/explicit_override" + }, + "wrapping_time_container": { + "description": "WrappingTimeContainer references UUID so the schema builder walks\nthe heuristic path.", + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "x-go-name": "ID" + } + }, + "x-go-name": "WrappingTimeContainer", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/text-marshal/uuid_wrapping_time" } } } \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_top_level_kinds.json b/fixtures/integration/golden/enhancements_top_level_kinds.json index 11d6792..c9dca4c 100644 --- a/fixtures/integration/golden/enhancements_top_level_kinds.json +++ b/fixtures/integration/golden/enhancements_top_level_kinds.json @@ -3,7 +3,9 @@ "paths": {}, "definitions": { "IgnoredModel": { + "description": "so the sectionedParser flags it as ignored and buildFromDecl returns\nearly via its `sp.ignored` branch.", "type": "object", + "title": "IgnoredModel is annotated as a model but also carries swagger:ignore,", "properties": { "value": { "type": "integer", diff --git a/fixtures/integration/golden/enhancements_wrapper_decl_type_override.json b/fixtures/integration/golden/enhancements_wrapper_decl_type_override.json new file mode 100644 index 0000000..16dc0e0 --- /dev/null +++ b/fixtures/integration/golden/enhancements_wrapper_decl_type_override.json @@ -0,0 +1,21 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "BareWrapperArray": { + "description": "No field references.", + "type": "array", + "title": "BareWrapperArray — named wrapper with `swagger:type array`.", + "items": { + "type": "integer", + "format": "uint8" + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/wrapper-decl-type-override" + }, + "BareWrapperObject": { + "description": "BareWrapperObject — named wrapper of json.RawMessage with\n`swagger:type object`. No field references it; the only schema\nthe scanner emits for this package is the top-level definition.", + "type": "object", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/wrapper-decl-type-override" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/go123_aliased_spec.json b/fixtures/integration/golden/go123_aliased_spec.json index e6fa7d9..b605b07 100644 --- a/fixtures/integration/golden/go123_aliased_spec.json +++ b/fixtures/integration/golden/go123_aliased_spec.json @@ -236,7 +236,16 @@ "$ref": "#/definitions/ExtendedID" }, "id": { - "$ref": "#/definitions/UUID" + "description": "the id for this order", + "allOf": [ + { + "$ref": "#/definitions/UUID" + }, + { + "minimum": 1 + } + ], + "x-go-name": "ID" }, "items": { "description": "the items for this order", @@ -265,7 +274,6 @@ "description": "the name for this user", "type": "integer", "format": "int64", - "minLength": 3, "x-go-name": "UserID" } }, diff --git a/fixtures/integration/golden/go123_special_spec.json b/fixtures/integration/golden/go123_special_spec.json index 3cce724..4cf947d 100644 --- a/fixtures/integration/golden/go123_special_spec.json +++ b/fixtures/integration/golden/go123_special_spec.json @@ -124,9 +124,7 @@ "type": "string", "x-go-type": "github.com/go-openapi/codescan/fixtures/goparsing/go123/special.IsATextMarshaler" }, - "Message": { - "type": "object" - }, + "Message": {}, "NamedArray": { "$ref": "#/definitions/go_array" }, diff --git a/fixtures/integration/golden/malformed_bad_response_tag.json b/fixtures/integration/golden/malformed_bad_response_tag.json new file mode 100644 index 0000000..cf52674 --- /dev/null +++ b/fixtures/integration/golden/malformed_bad_response_tag.json @@ -0,0 +1,13 @@ +{ + "swagger": "2.0", + "paths": { + "/bad-response": { + "get": { + "tags": [ + "bad" + ], + "operationId": "doBadResp" + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/malformed_duplicate_body_tag.json b/fixtures/integration/golden/malformed_duplicate_body_tag.json new file mode 100644 index 0000000..2aaaaa0 --- /dev/null +++ b/fixtures/integration/golden/malformed_duplicate_body_tag.json @@ -0,0 +1,13 @@ +{ + "swagger": "2.0", + "paths": { + "/duplicate-body": { + "get": { + "tags": [ + "bad" + ], + "operationId": "doDupBody" + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/malformed_info_bad_ext_key.json b/fixtures/integration/golden/malformed_info_bad_ext_key.json new file mode 100644 index 0000000..3749132 --- /dev/null +++ b/fixtures/integration/golden/malformed_info_bad_ext_key.json @@ -0,0 +1,14 @@ +{ + "schemes": [ + "http" + ], + "swagger": "2.0", + "info": { + "description": "Probe description.", + "title": "Probe API.", + "version": "0.0.1" + }, + "host": "localhost", + "basePath": "/", + "paths": {} +} \ No newline at end of file diff --git a/fixtures/integration/golden/malformed_meta_bad_ext_key.json b/fixtures/integration/golden/malformed_meta_bad_ext_key.json new file mode 100644 index 0000000..3749132 --- /dev/null +++ b/fixtures/integration/golden/malformed_meta_bad_ext_key.json @@ -0,0 +1,14 @@ +{ + "schemes": [ + "http" + ], + "swagger": "2.0", + "info": { + "description": "Probe description.", + "title": "Probe API.", + "version": "0.0.1" + }, + "host": "localhost", + "basePath": "/", + "paths": {} +} \ No newline at end of file diff --git a/internal/integration/coverage_alias_response_shapes_test.go b/internal/integration/coverage_alias_response_shapes_test.go new file mode 100644 index 0000000..b10d547 --- /dev/null +++ b/internal/integration/coverage_alias_response_shapes_test.go @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestCoverage_AliasResponseShapes_Default — Q4 / Q5 exploration +// goldens. After Q4's fix to responses.buildFieldAlias (parity with +// parameters), default mode dissolves an alias chain on a body +// field down to the canonical decl ($ref → Envelope, no +// EnvelopeAlias{,2} pollution in definitions). Headers typed as an +// alias of a named primitive now also surface correctly as +// SimpleSchema primitives ({string, ""}); pre-Q4 they emitted +// empty headers because the response builder's buildFieldAlias +// fell through to makeRef even on header targets. +func TestCoverage_AliasResponseShapes_Default(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/alias-response-shapes/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + // Header aliased to a named string: surfaces as a primitive. + hdr := doc.Responses["headerAliasedBasicResponse"].Headers["X-Session"] + assert.Equal(t, "string", hdr.Type, "alias-of-named-string header should be {string, ''} post-Q4") + + // Body aliased through a chain: dissolves to canonical Envelope + // under default mode (no per-alias definition pollution). + bodyRef := doc.Responses["bodyAliasResponse"].Schema.Ref.String() + assert.Equal(t, "#/definitions/Envelope", bodyRef, "body alias-chain dissolves to canonical decl") + assert.NotContains(t, doc.Definitions, "EnvelopeAlias", "EnvelopeAlias should not pollute definitions under default mode") + assert.NotContains(t, doc.Definitions, "EnvelopeAlias2", "EnvelopeAlias2 should not pollute definitions under default mode") + assert.NotContains(t, doc.Definitions, "SessionIDAlias", "SessionIDAlias should not pollute definitions under default mode") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_alias_response_shapes_default.json") +} + +// TestCoverage_AliasResponseShapes_Ref — under RefAliases the body +// $ref chains through EnvelopeAlias2 → EnvelopeAlias → Envelope; +// headers STILL expand inline (no $ref) because $ref is not legal +// on a SimpleSchema target. The Q4 gate's +// "in != body OR !RefAliases" branch covers both legs. +func TestCoverage_AliasResponseShapes_Ref(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/alias-response-shapes/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + RefAliases: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + hdr := doc.Responses["headerAliasedBasicResponse"].Headers["X-Session"] + assert.Equal(t, "string", hdr.Type, "header still expands inline under RefAliases") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_alias_response_shapes_ref.json") +} + +// TestCoverage_AliasResponseShapes_Transparent — TransparentAliases +// dissolves every layer up front; the body $ref points at +// Envelope, headers carry their primitive type, and the +// alias-of-struct case on a header surfaces empty (the struct +// can't reduce to a SimpleSchema primitive — same as Q2's +// post-fix behaviour). +func TestCoverage_AliasResponseShapes_Transparent(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/alias-response-shapes/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + TransparentAliases: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + hdr := doc.Responses["headerAliasedBasicResponse"].Headers["X-Session"] + assert.Equal(t, "string", hdr.Type, "header expands inline under Transparent") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_alias_response_shapes_transparent.json") +} diff --git a/internal/integration/coverage_enhancements_test.go b/internal/integration/coverage_enhancements_test.go index 083ae09..65b3cd1 100644 --- a/internal/integration/coverage_enhancements_test.go +++ b/internal/integration/coverage_enhancements_test.go @@ -8,6 +8,7 @@ import ( "github.com/go-openapi/codescan" "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" "github.com/go-openapi/testify/v2/require" oaispec "github.com/go-openapi/spec" @@ -164,6 +165,121 @@ func TestCoverage_NamedBasic(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_named_basic.json") } +// TestCoverage_WrapperDeclTypeOverride isolates Gap B' — the wrapper's +// own top-level definition does not honour `swagger:type` on the decl, +// even though the same annotation works at field reference sites. +// Pins today's broken behavior so the gap is visible until it's fixed. +func TestCoverage_WrapperDeclTypeOverride(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/wrapper-decl-type-override/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_wrapper_decl_type_override.json") + + defs := doc.Definitions + + t.Run("BareWrapperObject top-level definition is typed object", func(t *testing.T) { + def, ok := defs["BareWrapperObject"] + require.TrueT(t, ok) + assert.TrueT(t, def.Type.Contains("object"), "wrapper-decl swagger:type object should produce a typed object schema") + }) + + t.Run("BareWrapperArray top-level definition is typed array", func(t *testing.T) { + def, ok := defs["BareWrapperArray"] + require.TrueT(t, ok) + assert.TrueT(t, def.Type.Contains("array"), "wrapper-decl swagger:type array should produce a typed array schema") + require.NotNil(t, def.Items) + require.NotNil(t, def.Items.Schema) + assert.TrueT(t, def.Items.Schema.Type.Contains("integer"), "array items reflect []byte → uint8") + }) +} + +// TestCoverage_RawMessageOverride captures the user-classifier-override +// precedence for json.RawMessage. The recognizer emits an empty schema +// (`{}`, "any type") as the baseline. A user-declared wrapping type +// carrying `swagger:type` decoration overrides via the +// classifierNamedArrayLike path (RawMessage underlying is []byte). +// A field-level `swagger:type` is now consumed by scanFieldDoc and +// applied in applyFieldCarrier after buildFromType (Gap C — closed). +func TestCoverage_RawMessageOverride(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/raw-message-override/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_raw_message_override.json") + + defs := doc.Definitions + + t.Run("case A: plain json.RawMessage field emits empty schema", func(t *testing.T) { + def, ok := defs["PlainContainer"] + require.TrueT(t, ok) + payload, ok := def.Properties["payload"] + require.TrueT(t, ok) + assert.EqualT(t, 0, len(payload.Type), "payload should have no type — empty schema") + assert.EqualT(t, "", payload.Format) + }) + + t.Run("case B: wrapper-type swagger:type overrides at field reference sites", func(t *testing.T) { + def, ok := defs["TypedContainer"] + require.TrueT(t, ok) + + obj, ok := def.Properties["obj"] + require.TrueT(t, ok) + assert.TrueT(t, obj.Type.Contains("object"), "obj should be typed object via swagger:type on AsObject") + + arr, ok := def.Properties["arr"] + require.TrueT(t, ok) + assert.TrueT(t, arr.Type.Contains("array"), "arr should be typed array via swagger:type on AsArray") + require.NotNil(t, arr.Items) + require.NotNil(t, arr.Items.Schema) + assert.TrueT(t, arr.Items.Schema.Type.Contains("integer"), "array items reflect []byte → uint8") + }) + + t.Run("case B': wrapper-type top-level definitions honour swagger:type", func(t *testing.T) { + // buildFromDecl now applies classifierNamedTypeOverride from + // s.Decl.Comments before the kind-dispatch, so the wrapper's + // own definition reflects the decl-level swagger:type override + // (Gap B' — closed). + asObject, ok := defs["AsObject"] + require.TrueT(t, ok) + assert.TrueT(t, asObject.Type.Contains("object"), "AsObject def is typed object") + + asArray, ok := defs["AsArray"] + require.TrueT(t, ok) + assert.TrueT(t, asArray.Type.Contains("array"), "AsArray def is typed array") + require.NotNil(t, asArray.Items) + require.NotNil(t, asArray.Items.Schema) + assert.TrueT(t, asArray.Items.Schema.Type.Contains("integer"), "AsArray items reflect underlying []byte") + }) + + t.Run("case C: field-level swagger:type overrides on json.RawMessage", func(t *testing.T) { + // scanFieldDoc now consumes swagger:type at the field level; + // applyFieldCarrier applies it after buildFromType so the + // override beats the RawMessage recognizer's empty-schema default. + def, ok := defs["FieldLevelContainer"] + require.TrueT(t, ok) + + obj, ok := def.Properties["obj"] + require.TrueT(t, ok) + assert.TrueT(t, obj.Type.Contains("object"), "field-level swagger:type object overrides RawMessage default") + + arr, ok := def.Properties["arr"] + require.TrueT(t, ok) + assert.TrueT(t, arr.Type.Contains("array"), "field-level swagger:type array overrides RawMessage default") + require.NotNil(t, arr.Items) + require.NotNil(t, arr.Items.Schema) + assert.TrueT(t, arr.Items.Schema.Type.Contains("integer"), "fallback to Underlying() yields []byte → integer/uint8 items") + }) +} + // TestCoverage_SwaggerTypeArray exercises the fallthrough introduced by // upstream #11: when swagger:type is set to a value not recognised by // SwaggerSchemaForType (e.g. "array"), the builder resolves the underlying @@ -206,6 +322,33 @@ func TestCoverage_EnumDocs(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_enum_docs.json") } +// TestCoverage_EnumOverrides captures the v1 behavior for five +// enum-related cases that W2 needs to pin down before the P5.1 +// schema-builder migration: +// +// A. `swagger:enum` with matching consts — const inference +// B. inline `enum: a,b,c` only — inline only +// C. inline `enum: ["a","b","c"]` JSON form only — JSON inline only +// D. `swagger:enum` with NO matching consts — empty/??? case +// E. `swagger:enum` + matching consts + inline on — override question +// the field +// +// See `.claude/plans/workshops/w2-enum.md` §2.6 and +// `fixtures/enhancements/enum-overrides/types.go` for the fixture. +// The golden snapshot becomes the v1-behavior contract the v2 +// migration either preserves or consciously diverges from. +func TestCoverage_EnumOverrides(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/enum-overrides/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_enum_overrides.json") +} + func TestCoverage_TextMarshal(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/text-marshal/..."}, @@ -218,6 +361,24 @@ func TestCoverage_TextMarshal(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_text_marshal.json") } +// TestCoverage_GenericInstantiation exercises buildNamedType's +// generic-instantiation short-circuit. A field whose type is an +// instantiation (e.g. `GenericSlice[int]`) must emit with the +// substituted underlying shape, not as a $ref to the generic +// declaration (whose own schema is empty because type parameters +// are filtered as UnsupportedBuiltinType). +func TestCoverage_GenericInstantiation(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/generic-instantiation/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_generic_instantiation.json") +} + func TestCoverage_AllHTTPMethods(t *testing.T) { doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/all-http-methods/..."}, @@ -318,3 +479,376 @@ func TestCoverage_InputOverlay(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_input_overlay.json") } + +// TestCoverage_ParametersMapPostDecl scans a fixture that witnesses a bug +// in parameters.buildFromFieldMap: the schema sub-builder's +// PostDeclarations are not propagated to the parent parameters +// builder, so a map's value-type model registered during the build +// never reaches the spec's definitions section. +// +// The pre-fix golden shows the buggy state (LocalItem missing from +// definitions). The fix commit regenerates the golden to show LocalItem +// appearing, witnessing the resolution. +func TestCoverage_ParametersMapPostDecl(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/parameters-map-postdecl/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_parameters_map_postdecl.json") +} + +// 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{ + Packages: []string{"./enhancements/routes-params-path/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_params_path.json") +} + +func TestCoverage_RoutesParamsQueryValidations(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-params-query-validations/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_params_query_validations.json") +} + +func TestCoverage_RoutesParamsBodyRef(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-params-body-ref/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_params_body_ref.json") +} + +func TestCoverage_RoutesResponsesTaggedBody(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-responses-tagged-body/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_responses_tagged_body.json") +} + +func TestCoverage_RoutesParamsQueryString(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-params-query-string/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_params_query_string.json") +} + +func TestCoverage_RoutesParamsQueryNumber(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-params-query-number/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_params_query_number.json") +} + +func TestCoverage_RoutesParamsQueryBoolean(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-params-query-boolean/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_params_query_boolean.json") +} + +func TestCoverage_RoutesParamsQueryArray(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-params-query-array/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_params_query_array.json") +} + +func TestCoverage_RoutesParamsHeaderString(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-params-header-string/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_params_header_string.json") +} + +func TestCoverage_RoutesParamsFormString(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-params-form-string/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_params_form_string.json") +} + +func TestCoverage_RoutesParamsBodyArray(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-params-body-array/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_params_body_array.json") +} + +func TestCoverage_RoutesParamsBodyArrayNested(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-params-body-array-nested/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_params_body_array_nested.json") +} + +func TestCoverage_RoutesParamsBodyWithSchemaValidations(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-params-body-with-schema-validations/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_params_body_with_schema_validations.json") +} + +func TestCoverage_RoutesParamsMultiple(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-params-multiple/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_params_multiple.json") +} + +func TestCoverage_RoutesParamsUnknownKey(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-params-unknown-key/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_params_unknown_key.json") +} + +func TestCoverage_RoutesParamsEmptyChunk(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-params-empty-chunk/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_params_empty_chunk.json") +} + +func TestCoverage_RoutesResponsesPositional(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-responses-positional/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_responses_positional.json") +} + +func TestCoverage_RoutesResponsesTaggedResponse(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-responses-tagged-response/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_responses_tagged_response.json") +} + +func TestCoverage_RoutesResponsesMixedBodies(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-responses-mixed-bodies/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_responses_mixed_bodies.json") +} + +func TestCoverage_RoutesResponsesDescriptionOnly(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-responses-description-only/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_responses_description_only.json") +} + +func TestCoverage_RoutesResponsesDefault(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-responses-default/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_responses_default.json") +} + +func TestCoverage_RoutesResponsesArray(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-responses-array/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_responses_array.json") +} + +func TestCoverage_RoutesResponsesEmptyValue(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-responses-empty-value/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_responses_empty_value.json") +} + +func TestCoverage_RoutesResponsesDefinitionFallback(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-responses-definition-fallback/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_responses_definition_fallback.json") +} + +func TestCoverage_RoutesResponsesMultipleCodes(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-responses-multiple-codes/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_responses_multiple_codes.json") +} + +func TestCoverage_RoutesFullPetstoreShape(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-full-petstore-shape/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_full_petstore_shape.json") +} + +func TestCoverage_RoutesMultiMethodSamePath(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-multi-method-same-path/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_multi_method_same_path.json") +} + +func TestCoverage_RoutesResponsesSpaceBodyQuirk(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-responses-space-body-quirk/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_responses_space_body_quirk.json") +} + +func TestCoverage_RoutesResponsesRefNotFound(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-responses-ref-not-found/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_responses_ref_not_found.json") +} + +func TestCoverage_RoutesDescriptionDashList(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-description-dash-list/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_description_dash_list.json") +} + +func TestCoverage_RoutesDescriptionYAMLFenceAbsorb(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-description-yaml-fence-absorb/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_description_yaml_fence_absorb.json") +} + +func TestCoverage_RoutesListsFlexForms(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/routes-lists-flex-forms/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_routes_lists_flex_forms.json") +} + +func TestCoverage_MetaListsFlexForms(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/meta-lists-flex-forms/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + scantest.CompareOrDumpJSON(t, doc, "enhancements_meta_lists_flex_forms.json") +} diff --git a/internal/integration/coverage_header_extensions_test.go b/internal/integration/coverage_header_extensions_test.go new file mode 100644 index 0000000..673a2ac --- /dev/null +++ b/internal/integration/coverage_header_extensions_test.go @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "testing" + + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + + "github.com/go-openapi/codescan" +) + +// TestCoverage_HeaderExtensions pins M2's Walker.Extension wiring on +// the response-header path. Pre-M2, user-authored `Extensions:` +// blocks on header fields were silently dropped; post-M2 they land +// on the header's Extensions map with grammar-typed values. +func TestCoverage_HeaderExtensions(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/header-extensions/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + + // Response is registered at the doc level and the operation + // references it via $ref — the path-level operation has no + // inline headers, so inspect the top-level definition. + require.Contains(t, doc.Responses, "extendedResponse") + resp := doc.Responses["extendedResponse"] + + require.Contains(t, resp.Headers, "X-Rate-Limit") + hdr := resp.Headers["X-Rate-Limit"] + + // Extensions present on the header. The YAML body of the + // Extensions block was parsed via grammar's typed-extensions + // pipeline, so the values arrive pre-typed (string + bool). + val, ok := hdr.Extensions["x-rate-window"] + require.True(t, ok, "x-rate-window extension should be present on header") + assert.Equal(t, "60s", val) + + val, ok = hdr.Extensions["x-burst-allowed"] + require.True(t, ok, "x-burst-allowed extension should be present on header") + assert.Equal(t, true, val) +} diff --git a/internal/integration/coverage_header_named_basic_test.go b/internal/integration/coverage_header_named_basic_test.go new file mode 100644 index 0000000..29cfd25 --- /dev/null +++ b/internal/integration/coverage_header_named_basic_test.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestCoverage_HeaderNamedBasic pins the M1-follow-up fix that +// brought `in: header` into the SimpleSchema-aware primitive-inline +// arm of classifierNamedBasic. Pre-fix, a header parameter typed as +// a named string emitted a `$ref` (invalid under OAS v2 +// SimpleSchema); post-fix it inlines as `{type: string}`. +func TestCoverage_HeaderNamedBasic(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/header-named-basic/..."}, + WorkDir: scantest.FixturesDir(), + }) + require.NoError(t, err) + require.NotNil(t, doc) + + require.Contains(t, doc.Paths.Paths, "/header-named-basic") + op := doc.Paths.Paths["/header-named-basic"].Get + require.NotNil(t, op) + require.Len(t, op.Parameters, 1) + + session := op.Parameters[0] + assert.Equal(t, "X-Session", session.Name) + assert.Equal(t, "header", session.In) + assert.Equal(t, "string", session.Type, "named basic should inline as primitive under SimpleSchema") + assert.Empty(t, session.Ref.String(), "no $ref should be emitted") + + // The SessionID type should NOT appear as a top-level definition + // — the primitive-inline arm bypasses FindModel. + _, defined := doc.Definitions["SessionID"] + assert.False(t, defined, "SessionID should not become a top-level definition") +} diff --git a/internal/integration/coverage_malformed_test.go b/internal/integration/coverage_malformed_test.go index 3abe2d9..6289f1f 100644 --- a/internal/integration/coverage_malformed_test.go +++ b/internal/integration/coverage_malformed_test.go @@ -32,20 +32,35 @@ 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. func TestMalformed_MetaBadExtensionKey(t *testing.T) { - _, err := codescan.Run(&codescan.Options{ + doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/malformed/meta-bad-ext-key/..."}, WorkDir: scantest.FixturesDir(), }) - require.Error(t, err) + require.NoError(t, err) + require.NotNil(t, doc) + + 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. func TestMalformed_InfoBadExtensionKey(t *testing.T) { - _, err := codescan.Run(&codescan.Options{ + doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/malformed/info-bad-ext-key/..."}, WorkDir: scantest.FixturesDir(), }) - require.Error(t, err) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "malformed_info_bad_ext_key.json") } func TestMalformed_BadContact(t *testing.T) { @@ -56,20 +71,36 @@ 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. func TestMalformed_DuplicateBodyTag(t *testing.T) { - _, err := codescan.Run(&codescan.Options{ + doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/malformed/duplicate-body-tag/..."}, WorkDir: scantest.FixturesDir(), }) - require.Error(t, err) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "malformed_duplicate_body_tag.json") } +// TestMalformed_BadResponseTag — see TestMalformed_DuplicateBodyTag. +// Unknown tag prefixes emit a diagnostic and drop the response line; +// the rest of the route builds normally. func TestMalformed_BadResponseTag(t *testing.T) { - _, err := codescan.Run(&codescan.Options{ + doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/malformed/bad-response-tag/..."}, WorkDir: scantest.FixturesDir(), }) - require.Error(t, err) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "malformed_bad_response_tag.json") } func TestMalformed_BadSecurityDefinitions(t *testing.T) { diff --git a/internal/integration/coverage_response_file_types_test.go b/internal/integration/coverage_response_file_types_test.go new file mode 100644 index 0000000..0597e4a --- /dev/null +++ b/internal/integration/coverage_response_file_types_test.go @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestCoverage_ResponseFileTypes pins Q3's three variants: +// +// 1. FileBodyResponse — `swagger:file` + `in: body` → +// resp.Schema = {file, ""}; no headers; no diagnostic. +// +// 2. FileOnHeaderResponse — `swagger:file` + explicit +// `in: header` → diagnostic fires; file branch skipped; field +// surfaces as a regular header. +// +// 3. FileOnImplicitDefaultResponse — `swagger:file` with no +// `in:` (Q1 implicit-header default) → same diagnostic; +// field surfaces as a regular header. +// +// Diagnostic capture asserts CodeUnsupportedInSimpleSchema fires +// for variants (2) and (3) but not for (1). +func TestCoverage_ResponseFileTypes(t *testing.T) { + var got []grammar.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/response-file-types/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + OnDiagnostic: func(d grammar.Diagnostic) { + got = append(got, d) + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + // (1) Legitimate file body. + require.Contains(t, doc.Responses, "fileBodyResponse") + body := doc.Responses["fileBodyResponse"] + require.NotNil(t, body.Schema, "FileBodyResponse should produce a body schema") + assert.Equal(t, "file", body.Schema.Type[0], "body schema must be {file, ...}") + assert.Empty(t, body.Headers, "file-body response carries no headers") + + // (2) Misuse with explicit `in: header`. + require.Contains(t, doc.Responses, "fileOnHeaderResponse") + headerMisuse := doc.Responses["fileOnHeaderResponse"] + assert.Nil(t, headerMisuse.Schema, "no body schema corruption from header-positioned swagger:file") + require.Contains(t, headerMisuse.Headers, "X-Misplaced", "field falls through to the normal header build") + assert.Equal(t, "string", headerMisuse.Headers["X-Misplaced"].Type) + + // (3) Misuse with implicit default (no `in:` line). + require.Contains(t, doc.Responses, "fileOnImplicitDefaultResponse") + implicitMisuse := doc.Responses["fileOnImplicitDefaultResponse"] + assert.Nil(t, implicitMisuse.Schema, "no body schema corruption from swagger:file at implicit-header default") + require.Contains(t, implicitMisuse.Headers, "X-Implicit") + assert.Equal(t, "string", implicitMisuse.Headers["X-Implicit"].Type) + + // Diagnostic captures: (2) and (3) each emit one + // CodeUnsupportedInSimpleSchema warning naming `swagger:file`. + var seenExplicit, seenImplicit bool + for _, d := range got { + if d.Code != grammar.CodeUnsupportedInSimpleSchema { + continue + } + if !strings.Contains(d.Message, "swagger:file") { + continue + } + switch { + case strings.Contains(d.Message, `"X-Misplaced"`): + seenExplicit = true + assert.Equal(t, grammar.SeverityWarning, d.Severity) + case strings.Contains(d.Message, `"X-Implicit"`): + seenImplicit = true + assert.Equal(t, grammar.SeverityWarning, d.Severity) + } + } + if !seenExplicit || !seenImplicit { + for i, d := range got { + t.Logf("diag[%d] code=%s severity=%s msg=%q", i, d.Code, d.Severity, d.Message) + } + } + assert.True(t, seenExplicit, "expected diagnostic naming `swagger:file` on field X-Misplaced (explicit in: header)") + assert.True(t, seenImplicit, "expected diagnostic naming `swagger:file` on field X-Implicit (implicit default)") + + // Golden pins the full shape. + scantest.CompareOrDumpJSON(t, doc, "enhancements_response_file_types.json") +} diff --git a/internal/integration/coverage_response_header_ref_leak_test.go b/internal/integration/coverage_response_header_ref_leak_test.go new file mode 100644 index 0000000..49ea71f --- /dev/null +++ b/internal/integration/coverage_response_header_ref_leak_test.go @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestCoverage_ResponseHeaderRefLeak pins the Q2 fix on the +// response side: when a header field's Go type resolves through the +// schema builder's makeRef path (e.g. typed as a named struct), +// `responseTypable.SetRef` no-ops instead of writing `response.Schema.Ref`. +// The body-schema leak that used to land in the pre-fix emission +// is gone; the SimpleSchema exit validator catches the attempt via +// the probe's HasRef and emits CodeUnsupportedInSimpleSchema. +// +// Two variants pinned: +// +// 1. LeakResponse — TagHeader typed as named struct Tag, no +// strfmt override. Post-fix: header stays empty (the named +// struct can't be expressed as OAS v2 SimpleSchema); body +// schema is NOT set; diagnostic fires. +// +// 2. LeakWithStrfmtResponse — same shape plus +// `// swagger:strfmt uuid` on the field. Post-fix: header +// gets {string, uuid} from the strfmt override (which runs +// after the exit validator's reset); body schema is NOT +// set; diagnostic still fires for the underlying ref attempt. +func TestCoverage_ResponseHeaderRefLeak(t *testing.T) { + var got []grammar.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/response-header-ref-leak/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + OnDiagnostic: func(d grammar.Diagnostic) { + got = append(got, d) + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + // Body-schema leak is gone for both responses. + require.Contains(t, doc.Responses, "leakResponse") + leak := doc.Responses["leakResponse"] + assert.Nil(t, leak.Schema, "leakResponse.Schema must be nil — no body leak") + require.Contains(t, leak.Headers, "X-Tag", "the header field must still surface as a header entry") + + require.Contains(t, doc.Responses, "leakWithStrfmtResponse") + leakStrfmt := doc.Responses["leakWithStrfmtResponse"] + assert.Nil(t, leakStrfmt.Schema, "leakWithStrfmtResponse.Schema must be nil — no body leak") + require.Contains(t, leakStrfmt.Headers, "X-Tag-ID") + xTagID := leakStrfmt.Headers["X-Tag-ID"] + assert.Equal(t, "string", xTagID.Type, "strfmt override fires after the exit-validator reset") + assert.Equal(t, "uuid", xTagID.Format) + + // SimpleSchema-mode diagnostic fires at least once. + var seenForbiddenRef bool + for _, d := range got { + if d.Code == grammar.CodeUnsupportedInSimpleSchema && + strings.Contains(d.Message, "$ref") { + seenForbiddenRef = true + assert.Equal(t, grammar.SeverityWarning, d.Severity) + break + } + } + if !seenForbiddenRef { + for i, d := range got { + t.Logf("diag[%d] code=%s severity=%s msg=%q", i, d.Code, d.Severity, d.Message) + } + } + assert.True(t, seenForbiddenRef, "expected CodeUnsupportedInSimpleSchema diagnostic naming `$ref` for the header-as-named-struct attempt") + + // Golden pins the full shape (no body Schema on either response). + scantest.CompareOrDumpJSON(t, doc, "enhancements_response_header_ref_leak.json") +} diff --git a/internal/integration/coverage_response_implicit_header_test.go b/internal/integration/coverage_response_implicit_header_test.go new file mode 100644 index 0000000..c554f54 --- /dev/null +++ b/internal/integration/coverage_response_implicit_header_test.go @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestCoverage_ResponseImplicitHeader exercises the four Q1 variants +// on the response side: +// +// 1. EmptyResponse — empty struct under swagger:response → +// description-only response, no Headers map, no Schema. +// 2. AllHeadersResponse — fields default to header (Etag with no +// `in:` line; RateLimit with explicit `in: header`). The body +// schema stays nil. +// 3. MixedResponse — Tag (implicit header), Limit (explicit +// header), Body (explicit `in: body`). +// 4. InvalidInResponse — `in: cookie` emits a +// CodeInvalidAnnotation warning and defaults to header anyway +// (diagnosed but not silently ignored). +// +// The full document is golden-captured so the shape of each +// response — Headers map presence, Schema presence, primitive types +// — locks down side-by-side. +func TestCoverage_ResponseImplicitHeader(t *testing.T) { + var got []grammar.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/response-implicit-header/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + OnDiagnostic: func(d grammar.Diagnostic) { + got = append(got, d) + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + // --- Variant 1: EmptyResponse --- + require.Contains(t, doc.Responses, "emptyResponse") + empty := doc.Responses["emptyResponse"] + assert.Empty(t, empty.Headers, "empty struct should not emit a Headers map") + assert.Nil(t, empty.Schema, "empty struct should not emit a body Schema") + + // --- Variant 2: AllHeadersResponse --- + require.Contains(t, doc.Responses, "allHeadersResponse") + allHeaders := doc.Responses["allHeadersResponse"] + assert.Contains(t, allHeaders.Headers, "ETag", "Etag (no `in:`) should default to a header") + assert.Contains(t, allHeaders.Headers, "X-Rate-Limit", "RateLimit (explicit `in: header`) should be a header") + assert.Nil(t, allHeaders.Schema, "no `in: body` field → no body Schema") + assert.Equal(t, "string", allHeaders.Headers["ETag"].Type) + assert.Equal(t, "integer", allHeaders.Headers["X-Rate-Limit"].Type) + + // --- Variant 3: MixedResponse --- + require.Contains(t, doc.Responses, "mixedResponse") + mixed := doc.Responses["mixedResponse"] + assert.Contains(t, mixed.Headers, "X-Tag", "Tag (implicit) should be a header") + assert.Contains(t, mixed.Headers, "X-Limit", "Limit (explicit) should be a header") + assert.NotContains(t, mixed.Headers, "body", "Body (explicit `in: body`) should not appear in Headers") + require.NotNil(t, mixed.Schema, "Body field should populate resp.Schema") + + // --- Variant 4: InvalidInResponse --- + require.Contains(t, doc.Responses, "invalidInResponse") + invalidResp := doc.Responses["invalidInResponse"] + assert.Contains(t, invalidResp.Headers, "Cookie", "`in: cookie` → still treated as header (Q1: not silently ignored)") + + var seenInvalid bool + for _, d := range got { + if d.Code == grammar.CodeInvalidAnnotation && + strings.Contains(d.Message, "in: cookie") && + strings.Contains(d.Message, "Cookie") { + seenInvalid = true + assert.Equal(t, grammar.SeverityWarning, d.Severity) + break + } + } + if !seenInvalid { + for i, d := range got { + t.Logf("diag[%d] code=%s severity=%s msg=%q", i, d.Code, d.Severity, d.Message) + } + } + assert.True(t, seenInvalid, "expected a CodeInvalidAnnotation diagnostic naming `in: cookie` on field Cookie") + + // Golden of the full doc — shape-level pin (Headers maps, + // Schema presence, primitive types per response). + scantest.CompareOrDumpJSON(t, doc, "enhancements_response_implicit_header.json") +} diff --git a/internal/integration/coverage_simple_schema_readonly_test.go b/internal/integration/coverage_simple_schema_readonly_test.go new file mode 100644 index 0000000..28b91ad --- /dev/null +++ b/internal/integration/coverage_simple_schema_readonly_test.go @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestCoverage_SimpleSchemaReadOnlyGate pins the schema builder's +// SimpleSchema-mode gate on the full-Schema-only `readOnly:` +// keyword. When the schema builder is invoked via WithSimpleSchema +// and walks an anonymous struct that carries a `readOnly: true` +// sub-field annotation, the Bool handler emits a +// CodeUnsupportedInSimpleSchema diagnostic naming `readOnly` and +// skips the write. +// +// The exit validator does NOT additionally reset the parameter here. +// The struct-walking dance writes through a throwaway scratch schema +// (paramTypable.Schema() returns nil for non-body), so the +// parameter's SimpleSchema stays at Type="" — the validator's "any" +// branch accepts that. The observable signal is the gate diagnostic +// itself. +func TestCoverage_SimpleSchemaReadOnlyGate(t *testing.T) { + var got []grammar.Diagnostic + _, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/simple-schema-readonly/..."}, + WorkDir: scantest.FixturesDir(), + OnDiagnostic: func(d grammar.Diagnostic) { + got = append(got, d) + }, + }) + require.NoError(t, err) + + var seen bool + for _, d := range got { + if d.Code != grammar.CodeUnsupportedInSimpleSchema { + continue + } + if strings.Contains(d.Message, "readOnly") { + seen = true + assert.Equal(t, grammar.SeverityWarning, d.Severity) + assert.Contains(t, d.Message, "full-Schema-only") + break + } + } + if !seen { + for i, d := range got { + t.Logf("diag[%d] code=%s severity=%s msg=%q", i, d.Code, d.Severity, d.Message) + } + } + assert.True(t, seen, "expected a CodeUnsupportedInSimpleSchema diagnostic naming `readOnly`") +} diff --git a/internal/integration/coverage_simple_schema_test.go b/internal/integration/coverage_simple_schema_test.go new file mode 100644 index 0000000..53c6de0 --- /dev/null +++ b/internal/integration/coverage_simple_schema_test.go @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/parsers/grammar" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestCoverage_SimpleSchemaViolation exercises M1's exit validator: +// a query parameter whose Go type resolves to an object-typed +// SimpleSchema fires CodeUnsupportedInSimpleSchema and the target is +// reset to empty `{}`. +// +// Plumbing tested: +// - schema.WithSimpleSchema option carries the `in` value to the builder +// - exit validator detects Type=="object" as a violation +// - paramTypable.ResetForViolation wipes the SimpleSchema-shape +// - OnDiagnostic callback fires with the new code +func TestCoverage_SimpleSchemaViolation(t *testing.T) { + var got []grammar.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/simple-schema-violation/..."}, + WorkDir: scantest.FixturesDir(), + OnDiagnostic: func(d grammar.Diagnostic) { + got = append(got, d) + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + // 1. Diagnostic surface. + var seen bool + for _, d := range got { + if d.Code == grammar.CodeUnsupportedInSimpleSchema { + seen = true + assert.Equal(t, grammar.SeverityWarning, d.Severity, "CodeUnsupportedInSimpleSchema severity") + assert.Contains(t, d.Message, "SimpleSchema", "message should mention SimpleSchema") + break + } + } + assert.True(t, seen, "expected CodeUnsupportedInSimpleSchema diagnostic") + + // 2. Target reset. The offending parameter should have an empty + // SimpleSchema (no Type, no Format, no Ref) — honest over lossy. + require.Contains(t, doc.Paths.Paths, "/violation") + op := doc.Paths.Paths["/violation"].Get + require.NotNil(t, op) + require.Len(t, op.Parameters, 1) + bad := op.Parameters[0] + assert.Equal(t, "bad", bad.Name) + assert.Equal(t, "query", bad.In, "in: query preserved") + assert.Empty(t, bad.Type, "Type should be wiped to empty") + assert.Empty(t, bad.Format, "Format should be wiped to empty") + assert.Empty(t, bad.Ref.String(), "Ref should be wiped to empty") +} diff --git a/internal/integration/schema_special_test.go b/internal/integration/schema_special_test.go index 55303ce..ebd3524 100644 --- a/internal/integration/schema_special_test.go +++ b/internal/integration/schema_special_test.go @@ -255,10 +255,11 @@ func testSpecialTypesStruct(t *testing.T, sp *oaispec.Swagger) { }) }) - t.Run("a json.RawMessage should be recognized and render as an object (yes this is wrong)", func(t *testing.T) { + t.Run("a json.RawMessage should be recognized and render as an empty schema (any type)", func(t *testing.T) { m, ok := props["Message"] require.TrueT(t, ok) - require.TrueT(t, m.Type.Contains("object")) + assert.EqualT(t, 0, len(m.Type), "Message should have no type — empty schema models 'any'") + assert.EqualT(t, "", m.Format) }) t.Run("type time.Duration is not recognized as a special type and should just render as a ref", func(t *testing.T) { diff --git a/internal/scantest/property.go b/internal/scantest/property.go index f761901..6e89201 100644 --- a/internal/scantest/property.go +++ b/internal/scantest/property.go @@ -59,10 +59,19 @@ func AssertArrayRef(t *testing.T, schema *oaispec.Schema, jsonName, goName, frag assert.EqualT(t, fragment, psch.Ref.String()) } +// AssertRef checks that the named property is a $ref to fragment. +// Accepts both shapes: a bare $ref schema, and the P7/S7 allOf +// compound where the $ref rides arm[0] (with description and +// optional override siblings on the parent / arm[1]). func AssertRef(t *testing.T, schema *oaispec.Schema, jsonName, _, fragment string) { t.Helper() - assert.Empty(t, schema.Properties[jsonName].Type) psch := schema.Properties[jsonName] - assert.EqualT(t, fragment, psch.Ref.String()) + assert.Empty(t, psch.Type) + if psch.Ref.String() != "" { + assert.EqualT(t, fragment, psch.Ref.String()) + return + } + require.NotEmpty(t, psch.AllOf, "property %q has neither $ref nor allOf", jsonName) + assert.EqualT(t, fragment, psch.AllOf[0].Ref.String()) } From cd41e55850def0dd8cc13ef08611336022aed1c9 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:40:19 +0200 Subject: [PATCH 18/22] docs: annotation + grammar reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four documents under ./docs: annotations.md — author cheatsheet: every swagger:* annotation with argument shape, Go code samples, fixture pointers, and a compatibility matrix. keywords.md — per-keyword reference card with value shapes, annotation contexts, and aliases. sub-languages.md — embedded mini-languages: flex-list, route body, response body, YAML extensions, security requirements, contact/license inline forms. grammar.md — formal EBNF: preprocessor, lexer, parser, Walker, diagnostics, and what the grammar does not cover. Hugo frontmatter (title + weight) is present in anticipation of a future docs site; the documents render fine as plain markdown today. Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- docs/annotations.md | 876 ++++++++++++++++++++++++++++++++++++++++++ docs/grammar.md | 559 +++++++++++++++++++++++++++ docs/keywords.md | 645 +++++++++++++++++++++++++++++++ docs/sub-languages.md | 491 +++++++++++++++++++++++ 4 files changed, 2571 insertions(+) create mode 100644 docs/annotations.md create mode 100644 docs/grammar.md create mode 100644 docs/keywords.md create mode 100644 docs/sub-languages.md diff --git a/docs/annotations.md b/docs/annotations.md new file mode 100644 index 0000000..c804aa1 --- /dev/null +++ b/docs/annotations.md @@ -0,0 +1,876 @@ +--- +title: "Annotations" +weight: 10 +--- + +# Annotations + +Annotations are the `swagger:` markers the scanner recognises in +Go doc comments. Each annotation classifies the surrounding +declaration — telling the scanner "this is a model definition", "this +is a route handler", "this is meta-information about the API" — and +opens the door for [keywords](./keywords.md) inside the same comment +block. + +There are twelve annotations. They divide cleanly by what they +attach to: + +- **Spec-level**: `swagger:meta`. +- **Model declarations**: `swagger:model`, `swagger:strfmt`, + `swagger:enum`, `swagger:allOf`, `swagger:alias`. +- **Operation declarations**: `swagger:route`, `swagger:operation`. +- **Companion declarations**: `swagger:parameters`, `swagger:response`. +- **Local hints**: `swagger:ignore`, `swagger:name`, `swagger:type`, + `swagger:file`, `swagger:default`. + +This file is the **author-first reference**. Each entry covers: + +- What the annotation does and what it produces in the spec. +- Where in the Go source it goes (package doc, type doc, field doc). +- The shape of any argument the annotation accepts. +- A short Go sample. +- A pointer to the keywords that are legal inside the block. +- A pointer to a real fixture in this repo for the full executable + example. + +For the per-keyword reference, see [keywords.md](./keywords.md). +For the embedded sub-languages (`Parameters:` and `Responses:` body +grammars, YAML extensions, etc.), see +[sub-languages.md](./sub-languages.md). For the formal grammar, +see [grammar.md](./grammar.md). + +--- + +## Table of contents + +- [How annotations attach](#how-annotations-attach) +- [Annotation argument shapes](#annotation-argument-shapes) +- [`swagger:meta`](#swaggermeta) +- [`swagger:model`](#swaggermodel) +- [`swagger:strfmt`](#swaggerstrfmt) +- [`swagger:enum`](#swaggerenum) +- [`swagger:allOf`](#swaggerallof) +- [`swagger:alias`](#swaggeralias) +- [`swagger:route`](#swaggerroute) +- [`swagger:operation`](#swaggeroperation) +- [`swagger:parameters`](#swaggerparameters) +- [`swagger:response`](#swaggerresponse) +- [`swagger:ignore`](#swaggerignore) +- [`swagger:name`](#swaggername) +- [`swagger:type`](#swaggertype) +- [`swagger:file`](#swaggerfile) +- [`swagger:default`](#swaggerdefault) + +--- + +## How annotations attach + +An annotation is recognised when it appears at the start of a comment +line in a doc comment. Leading whitespace, the `//` marker, and any +`/* */` block-comment continuation noise are stripped — the lexer +applies the same content-prefix-trim that every other godoc-aware +tool does. + +Annotations attach to whichever Go declaration owns the comment +group: + +- **Package doc** (`// Package foo …` followed by `package foo`) — + carries `swagger:meta`. +- **Type declaration** (`type T struct { … }`, `type T int`, + `type T = Other`) — carries `swagger:model`, `swagger:strfmt`, + `swagger:enum`, `swagger:allOf`, `swagger:alias`, `swagger:ignore`, + `swagger:type`. +- **Function or variable declaration** (`func ServeAPI() { … }`, + `var DoIt = func() { … }`) — carries `swagger:route`, + `swagger:operation`. +- **Struct field doc** — carries `swagger:name`, `swagger:type`, + `swagger:ignore`, plus any of the [keyword reference](./keywords.md) + entries legal in `schema` / `param` / `header` context. + +One comment group may carry MORE than one annotation when the +combinations are semantically compatible — e.g. `swagger:model` + +`swagger:type` together overrides the auto-detected Go type while +still publishing the model. The grammar parses both and the builder +honours both. + +The **first** annotation in source order wins as the "primary" +classifier — for example, a comment carrying `swagger:model` followed +by `swagger:ignore` produces a model (the ignore is silently +overridden because only the source-order-first annotation drives the +short-circuit). Subsequent annotations are still parsed and visible +via `Block.AnnotationKind()`-iteration, but the primary classifier +determines which builder owns the decl. + +## Annotation argument shapes + +After the `swagger:` head, an annotation may carry positional +arguments. The shapes: + +- **No args**: `swagger:meta`, `swagger:ignore`, `swagger:enum`, + `swagger:allOf`, `swagger:file`, `swagger:default` — bare + annotation, the surrounding decl supplies the entity name. +- **One IDENT arg**: `swagger:model Pet`, `swagger:response + errorResponse`, `swagger:strfmt uuid`, `swagger:name fullName`, + `swagger:type integer`, `swagger:alias TimestampAlias` — the + argument overrides or names the entity. +- **One IDENT arg, optional**: `swagger:model` (bare — derives the + name from the Go decl) vs `swagger:model Pet` (overrides). +- **List of IDENT args**: `swagger:parameters listItems createItem` + — declares the parameters group as legal for multiple operations. +- **Header line**: `swagger:route GET /pets pets users listPets` and + `swagger:operation GET /pets users listPets` — a structured header + carrying method, path, tags, and operation ID. See the + per-annotation entries for the exact rules. + +--- + +## `swagger:meta` + +**What it does.** Declares the package as the OpenAPI spec +container. The scanner reads the package doc comment for top-level +spec fields: title (via [stripPackagePrefix](./grammar.md#prose) of +the doc's first line), description, license, contact, host, +basePath, version, schemes, consumes, produces, securityDefinitions, +extensions, and the rest of the meta keyword surface. + +**Where it goes.** On the package doc comment. + +**Argument shape.** No args. Bare annotation. + +**Sample.** + +```go +// Package petstore Petstore API. +// +// The purpose of this application is to provide an application +// that is using plain Go code to define an API. +// +// Schemes: http, https +// Host: petstore.swagger.io +// BasePath: /v2 +// Version: 1.0.0 +// +// Consumes: +// - application/json +// +// Produces: +// - application/json +// +// swagger:meta +package petstore +``` + +**Legal keywords.** All [meta single-line keywords](./keywords.md#meta-single-line-keywords) +(`schemes`, `version`, `host`, `basePath`, `license`, `contact`) plus +the meta-scope [body keywords](./keywords.md#body-keywords) +(`consumes`, `produces`, `security`, `securityDefinitions`, +`extensions`, `infoExtensions`, `tos`, `externalDocs`). + +**Full example.** `fixtures/goparsing/spec/api.go`. + +--- + +## `swagger:model` + +**What it does.** Declares a Go type as a published model. The +scanner walks the type, emits a schema into the spec's `definitions` +map, and resolves cross-references between models. + +**Where it goes.** On a type declaration (`type T struct { … }`, +`type T int`, `type T = Other`, …). + +**Argument shape.** Optional IDENT — the name the model takes in +`definitions`. Default: the Go type's name. + +**Sample.** + +```go +// Pet is the petstore's primary entity. +// +// swagger:model +type Pet struct { + // ID is the unique identifier. + ID int64 `json:"id"` + + // Name is the pet's display name. + Name string `json:"name"` + + // Tags categorise the pet. + Tags []string `json:"tags,omitempty"` +} +``` + +With a name override: + +```go +// swagger:model PetWithExtras +type DetailedPet struct { … } +``` + +The type is published as `#/definitions/PetWithExtras`. + +**Legal keywords.** All [schema](./keywords.md#schema-decorators) +keywords plus the +[length / array / numeric validations](./keywords.md#numeric-validations) +on field doc comments. + +**Full example.** `fixtures/enhancements/named-struct-tags-ref/types.go`. + +--- + +## `swagger:strfmt` + +**What it does.** Marks a named type as a custom string format. +Wherever the type appears as a field, the emitted schema is +`{type: string, format: }`. Useful for `UUID`, `Email`, +`URL`-style types that have a Go type but should serialise as a +JSON string with a known format. + +**Where it goes.** On a type declaration whose underlying form is a +string-marshalable type (typically implementing `encoding.TextMarshaler` +or `encoding.TextUnmarshaler`). + +**Argument shape.** Required IDENT — the format name (`uuid`, `email`, +`mac`, etc.). + +**Sample.** + +```go +// MAC is a hardware address rendered as a colon-separated hex string. +// +// swagger:strfmt mac +type MAC string + +func (m MAC) MarshalText() ([]byte, error) { return []byte(m), nil } +func (m *MAC) UnmarshalText(b []byte) error { *m = MAC(b); return nil } +``` + +A field typed `MAC` emits as `{type: string, format: mac}`. The +underlying `MAC` type does NOT appear as a top-level model definition +(strfmt-tagged structs are replaced by their format at every +reference). + +**Legal keywords.** None at the type level beyond `swagger:strfmt` +itself; the format name is the entire surface. + +**Full example.** `fixtures/enhancements/text-marshal/types.go`. + +--- + +## `swagger:enum` + +**What it does.** Marks a string-typed (or integer-typed) named type +as an enum. The scanner collects the type's `const` declarations, +emits each value into the schema's `enum` array, and produces an +`x-go-enum-desc` extension carrying the per-value godoc descriptions +in ` ` shape — useful for downstream tooling that +renders enum option labels. + +**Where it goes.** On a named type declaration. The type's `const` +values are discovered via Go's type-system traversal; they do not +need to live in the same file. + +**Argument shape.** Optional IDENT — when omitted, the surrounding +type's name is used. + +**Sample.** + +```go +// Priority is the urgency level on a task. +// +// swagger:enum +type Priority string + +const ( + // PriorityLow is for tasks that can wait. + PriorityLow Priority = "low" + + // PriorityMedium is the default. + PriorityMedium Priority = "medium" + + // PriorityHigh is for tasks that must run soon. + PriorityHigh Priority = "high" +) +``` + +Produces (extract): + +```json +{ + "Priority": { + "type": "string", + "enum": ["low", "medium", "high"], + "x-go-enum-desc": "low PriorityLow is for tasks that can wait.\nmedium PriorityMedium is the default.\nhigh PriorityHigh is for tasks that must run soon." + } +} +``` + +**Legal keywords.** Schema-context keywords. The `enum:` keyword can +ALSO be used inline on the type doc to force a value set; when present, +it overrides the const-derived values and the `x-go-enum-desc` is +recomputed (or dropped) accordingly. + +**Full example.** `fixtures/enhancements/enum-overrides/types.go`. + +--- + +## `swagger:allOf` + +**What it does.** Marks a struct as participating in an `allOf` +composition. The struct's fields plus any embedded +`swagger:model`-tagged base produce an `allOf: [$ref base, {inline +fields}]` schema. The companion convention is to embed the base +type as an anonymous field with this annotation on the embedding's +doc comment (or on the embedded type itself). + +**Where it goes.** On a struct field that embeds another type, or on +a struct type that has at least one embedded base. + +**Argument shape.** No args. + +**Sample.** + +```go +// Animal is the abstract base. +// +// swagger:model +type Animal struct { + Kind string `json:"kind"` +} + +// Dog is an Animal with a breed. +// +// swagger:model +type Dog struct { + // swagger:allOf + Animal + + Breed string `json:"breed"` +} +``` + +Produces: + +```json +"Dog": { + "allOf": [ + {"$ref": "#/definitions/Animal"}, + { + "type": "object", + "properties": { + "breed": {"type": "string", "x-go-name": "Breed"} + } + } + ] +} +``` + +**Legal keywords.** Schema-context keywords on the inline-object +member (the second `allOf` element). + +**Full example.** `fixtures/enhancements/allof-edges/types.go`. + +--- + +## `swagger:alias` + +**What it does.** Marks a Go alias declaration (`type T = Other`) as +a model that should publish as a `$ref` to `Other`'s definition +rather than as a duplicate of Other's schema. + +The scanner also honours `RefAliases` and `TransparentAliases` +top-level options, which can globally enable alias-as-ref behaviour +without per-decl annotation. `swagger:alias` is the per-decl override +for cases where the global mode isn't appropriate. + +**Where it goes.** On a type alias declaration. + +**Argument shape.** Optional IDENT — the published name. Default: +the alias's Go name. + +**Sample.** + +```go +// Timestamp aliases time.Time. The published model carries +// format: date-time via the time.Time → strfmt resolution. +// +// swagger:alias +type Timestamp = time.Time +``` + +Without the annotation (and without global `RefAliases`), the alias +either expands the target's full schema or is silently ignored +depending on context. + +**Legal keywords.** Schema-context keywords. + +**Full example.** `fixtures/enhancements/ref-alias-chain/types.go`. + +--- + +## `swagger:route` + +**What it does.** Declares an HTTP route + operation in one +annotation. The header line carries the method, path, optional tags, +and the operation ID; the comment body carries the operation's +metadata (consumes / produces / schemes / security / parameters / +responses / extensions). + +This is the **terser of the two operation-declaration annotations**. +Most go-swagger projects use `swagger:route` for hand-written +operations. + +**Where it goes.** On a function or variable declaration whose doc +comment carries the annotation. The Go entity itself doesn't have to +be a handler — the annotation publishes a path/operation independent +of the carrier. + +**Argument shape.** Header line: + +``` +swagger:route [tag1 tag2 …] +``` + +- `` — `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, + `OPTIONS`. Case insensitive. +- `` — starts with `/`. Supports path-parameter braces: + `/items/{id}`. +- `[tag1 tag2 …]` — optional whitespace-separated list of tags. At + least two characters each. +- `` — the unique operation identifier. + +A godoc-style identifier may precede the annotation on the same +comment line: + +```go +// ListPets swagger:route GET /pets pets users listPets +``` + +That leading identifier is recognised as a godoc convention and is +not part of the annotation surface. + +**Sample.** + +```go +// ListPets swagger:route GET /pets pets users listPets +// +// List pets filtered by some parameters. +// +// Consumes: +// - application/json +// +// Produces: +// - application/json +// +// Schemes: http, https +// +// Security: +// api_key: +// oauth: read, write +// +// Parameters: +// + name: limit +// in: query +// type: integer +// minimum: 1 +// maximum: 100 +// +// Responses: +// 200: body:[]Pet the pet list +// default: response:genericError +func ListPets() {} +``` + +**Legal keywords.** All +[body keywords](./keywords.md#body-keywords) legal in route context +(`consumes`, `produces`, `schemes`, `security`, `parameters`, +`responses`, `extensions`) plus inline `deprecated:`. + +The `Parameters:` and `Responses:` sub-languages are documented in +[sub-languages.md §parameters](./sub-languages.md#parameters) and +[sub-languages.md §responses](./sub-languages.md#responses). + +**Full example.** `fixtures/enhancements/routes-full-petstore-shape/handlers.go`. + +--- + +## `swagger:operation` + +**What it does.** Same payload as `swagger:route` but with a +different body shape: instead of the structured `Parameters:` / +`Responses:` keyword surface, `swagger:operation`'s body is a +single YAML document spelling out the OpenAPI operation object +directly. + +Use `swagger:operation` when you want to author the operation in +YAML (closer to the OpenAPI spec text) or when the operation has +shapes the keyword surface doesn't cover. + +**Where it goes.** Same as `swagger:route` — function or variable +doc comment. + +**Argument shape.** Same header shape as `swagger:route`: + +``` +swagger:operation [tag1 tag2 …] +``` + +**Sample.** + +```go +// swagger:operation GET /items/{id} items getItem +// +// --- +// summary: Get item by ID +// parameters: +// - name: id +// in: path +// required: true +// type: integer +// responses: +// '200': +// description: the requested item +// schema: +// $ref: '#/definitions/Item' +// default: +// $ref: '#/responses/genericError' +func GetItem() {} +``` + +The `---` delimits the YAML body; everything between the fences is +parsed as an OpenAPI 2.0 operation object. + +**Legal keywords.** None inside the YAML body (it's structurally +YAML, not the keyword grammar). The header line is the entire +annotation surface. + +**Full example.** `fixtures/enhancements/parameters-map-postdecl/api.go`. + +--- + +## `swagger:parameters` + +**What it does.** Declares a Go struct as the parameters set for one +or more operations. Each field of the struct becomes one parameter +on the named operation(s). The field's doc comment carries the +parameter's `in:`, `required:`, validation, and description. + +**Where it goes.** On a struct declaration. + +**Argument shape.** Required IDENTs — the operation IDs this +parameters set applies to. At least one. The same operation ID may +appear in multiple `swagger:parameters` annotations to compose a +parameter set from several structs. + +**Sample.** + +```go +// ListItemsParams declares pagination + filter parameters for the +// listItems operation. +// +// swagger:parameters listItems +type ListItemsParams struct { + // Offset is the page offset. + // + // in: query + // minimum: 0 + // default: 0 + Offset int `json:"offset"` + + // Limit is the page size. + // + // in: query + // minimum: 1 + // maximum: 100 + // default: 20 + Limit int `json:"limit"` + + // Tag is the filter tag. + // + // in: query + // required: false + Tag string `json:"tag,omitempty"` +} +``` + +**Legal keywords on fields.** [param-context keywords](./keywords.md#parameter-location) +(`in`, `required`, the numeric / length / format validations, +`default`, `example`, `enum`, `allowEmptyValue`, `collectionFormat`). + +**Full example.** `fixtures/enhancements/simple-schema-violation/api.go`. + +--- + +## `swagger:response` + +**What it does.** Declares a Go struct as a named response object, +emitted into the spec's top-level `responses` map. Routes / operations +reference it by name via the response sub-language (`Responses:` +body in `swagger:route`, or the YAML `$ref` form in +`swagger:operation`). + +The struct's fields contribute the response shape: + +- A field named `Body` (or carrying `in: body`) becomes the response + body schema. +- Other fields carrying `in: header` become response headers. + +**Where it goes.** On a struct declaration. + +**Argument shape.** Optional IDENT — the published response name. +Default: the Go type's name. + +**Sample.** + +```go +// GenericError is the catch-all error response. +// +// swagger:response genericError +type GenericError struct { + // in: body + Body struct { + // Message is the human-readable error message. + Message string `json:"message"` + + // Code is the machine-readable error category. + Code string `json:"code,omitempty"` + } + + // X-Request-ID echoes the request correlation header. + // + // in: header + XRequestID string `json:"X-Request-ID"` +} +``` + +Routes can then reference it via `response:genericError` in their +`Responses:` body. + +**Legal keywords on body field.** Schema-context keywords. +**Legal keywords on header field.** Header-context keywords — +numeric / length / format validations, `pattern`, `enum`, `default`, +`example`, `collectionFormat`. `required:` is silently dropped on +headers (the OAS v2 Header object does not carry a `required` field). + +**Full example.** `fixtures/enhancements/routes-full-petstore-shape/handlers.go`. + +--- + +## `swagger:ignore` + +**What it does.** Excludes the surrounding declaration from the +generated spec. The scanner sees the decl and the doc, classifies +it, then drops it. + +**Where it goes.** On a type declaration to exclude the whole type, +or on a struct field doc to exclude that one field. + +**Argument shape.** No args. + +**Sample (type):** + +```go +// Internal is not exposed. +// +// swagger:ignore +type Internal struct { + SecretField string +} +``` + +**Sample (field):** + +```go +type User struct { + Name string `json:"name"` + + // PasswordHash is internal. + // + // swagger:ignore + PasswordHash string `json:"-"` +} +``` + +**Interaction:** when `swagger:ignore` appears AFTER another +classifier on the same comment block (e.g., `swagger:model` first, +then `swagger:ignore`), the first annotation wins and the ignore is +silently overridden. Place `swagger:ignore` first if you genuinely +want the decl excluded. + +**Full example.** `fixtures/enhancements/top-level-kinds/types.go`. + +--- + +## `swagger:name` + +**What it does.** Overrides the JSON property name that a struct +field or interface method renders as. By default the scanner derives +names from `json:"…"` struct tags (or the Go identifier for fields / +methods with no tag); `swagger:name` is the per-field override when +the tag-based shape isn't appropriate — typically on **interface +methods**, which cannot carry struct tags. + +**Where it goes.** On a struct field doc OR an interface method doc. + +**Argument shape.** Required IDENT — the JSON property name to use. + +**Sample (interface method):** + +```go +// UserProfile is the user's profile interface. +// +// swagger:model +type UserProfile interface { + // ID is the user identifier. + ID() string + + // FullName is the user's display name. + // + // swagger:name fullName + FullName() string +} +``` + +Without `swagger:name`, the method `FullName()` would publish as +property `FullName` (PascalCase). The annotation renames it to +`fullName`. + +**Legal keywords.** None — the override name is the entire surface. + +**Full example.** `fixtures/enhancements/interface-methods/types.go`. + +--- + +## `swagger:type` + +**What it does.** Overrides the inferred Swagger type for a named +type or struct field. The Go type's natural inference (struct → +object, named string → string, `time.Time` → date-time, …) is +replaced with the annotation's argument. + +**Where it goes.** On a type declaration OR a struct field doc. + +**Argument shape.** Required IDENT — the Swagger type name (`string`, +`integer`, `number`, `boolean`, `array`, `object`). + +**Sample (type-level override):** + +```go +// ULID is a Crockford-base32 unique identifier rendered as a string. +// +// swagger:type string +type ULID [16]byte +``` + +Fields typed `ULID` emit as `{type: string}` regardless of the +underlying `[16]byte` shape. + +**Sample (field-level override):** + +```go +type Document struct { + // Body is an opaque payload published as a string blob. + // + // swagger:type string + Body json.RawMessage `json:"body"` +} +``` + +**Interaction:** when combined with `swagger:strfmt` on the same +type, both apply — the strfmt format goes onto the published +`{type: string, format: …}`. + +**Full example.** `fixtures/enhancements/named-struct-tags-ref/types.go`. + +--- + +## `swagger:file` + +**What it does.** Marks a parameter or response body as a binary file +(`{type: file}`). The scanner emits the file-type marker without +further introspection of the Go type. + +**Where it goes.** On a struct field doc inside a +`swagger:parameters` (multipart file upload) or `swagger:response` +(file download) struct. + +**Argument shape.** No args. + +**Sample.** + +```go +// UploadParams declares a multipart file upload. +// +// swagger:parameters uploadFile +type UploadParams struct { + // File is the uploaded asset. + // + // in: formData + // swagger:file + File io.ReadCloser `json:"file"` +} +``` + +**Legal keywords.** Standard parameter / response keywords; the file +marker stacks with `in:` and other parameter shape keywords. + +--- + +## `swagger:default` + +**What it does.** Marks the surrounding declaration as the spec's +default value for the corresponding shape. Used in narrow contexts +where the scanner expects an explicit anchor for a default. + +This annotation is **value-only** — there's no exported entity it +publishes; it's a classifier hint the scanner consumes during +discovery. + +**Where it goes.** On a value declaration (`var`, `const`) or a +struct field. + +**Argument shape.** No args. + +**Sample.** + +```go +// DefaultLimit is the default page size used wherever Limit is not +// supplied by the caller. +// +// swagger:default +var DefaultLimit = 20 +``` + +This annotation has a narrow surface and is not commonly authored +directly. Most spec defaults are carried by the `default:` keyword on +the relevant field. + +--- + +## Annotation × keyword compatibility matrix + +A quick orientation for which annotations can carry which keyword +families. See [keywords.md](./keywords.md) for the per-keyword +contracts. + +| Annotation | Numeric/length validations | Schema decorators | `in:` | Meta keywords | `Parameters:` body | `Responses:` body | YAML body | +|------------|----------------------------|-------------------|-------|---------------|--------------------|-------------------|-----------| +| `swagger:meta` | — | — | — | ✅ | — | — | ✅ (security defs, extensions) | +| `swagger:model` | ✅ (on fields) | ✅ | — | — | — | — | — | +| `swagger:strfmt` | — | — | — | — | — | — | — | +| `swagger:enum` | — | (enum keyword via const) | — | — | — | — | — | +| `swagger:allOf` | ✅ (on member fields) | ✅ | — | — | — | — | — | +| `swagger:alias` | — | — | — | — | — | — | — | +| `swagger:route` | — | (deprecated only) | — | (schemes/consumes/produces/security) | ✅ | ✅ | (extensions) | +| `swagger:operation` | — | — | — | — | — | — | ✅ (full op as YAML) | +| `swagger:parameters` | ✅ (on fields) | ✅ (on fields) | ✅ | — | — | — | — | +| `swagger:response` | ✅ (on header fields) | ✅ (on body field) | ✅ (body/header) | — | — | — | — | +| `swagger:ignore` | — | — | — | — | — | — | — | +| `swagger:name` | — | — | — | — | — | — | — | +| `swagger:type` | — | — | — | — | — | — | — | +| `swagger:file` | — | — | — | — | — | — | — | +| `swagger:default` | — | — | — | — | — | — | — | + +A blank cell means the keyword family is not legal in that context; +attempting to use it emits `CodeContextInvalid` and the keyword is +dropped. diff --git a/docs/grammar.md b/docs/grammar.md new file mode 100644 index 0000000..09f4f3d --- /dev/null +++ b/docs/grammar.md @@ -0,0 +1,559 @@ +--- +title: "Grammar" +weight: 40 +--- + +# Grammar + +The formal grammar of the codescan annotation surface. This document +specifies the language a Go comment must conform to so that the +scanner classifies it, dispatches it to the right builder, and +populates the OpenAPI spec deterministically. + +**Audience.** Implementers — anyone porting, extending, or debugging +the parser. Annotation authors typically need +[annotations.md](./annotations.md) and [keywords.md](./keywords.md) +instead. + +The grammar is layered: + +1. **Preprocess** — comment-marker stripping (see + [§preprocess](#preprocess)). +2. **Lex** — terminal token emission, including multi-line body + accumulation (see [§lexer](#lexer)). +3. **Parse** — block construction, family dispatch, keyword + classification (see [§parser](#parser)). +4. **Walk** — typed dispatch through `grammar.Walker` callbacks to + the builders (see [§walker](#walker)). + +The productions below operate on the **lexer's terminal alphabet**, +not raw text. Per-terminal lexical detail (how the lexer recognises +a number, a string, an annotation, …) is described in §lexer; the +EBNF that follows consumes pre-classified terminals. + +The grammar is rigorous ISO-14977 EBNF. Required vs. optional +arguments, value typing, and family membership are +**grammar-visible** — every legality constraint expressible by token +sequencing is expressed that way. + +--- + +## Table of contents + +- [Preprocess](#preprocess) +- [Lexer](#lexer) + - [Terminal vocabulary](#terminal-vocabulary) + - [Body accumulation](#body-accumulation) + - [YAML fence handling](#yaml-fence-handling) + - [Prose classification](#prose-classification) +- [Parser](#parser) + - [Top-level dispatch](#top-level-dispatch) + - [Schema family](#schema-family) + - [Operation family](#operation-family) + - [Meta family](#meta-family) + - [Classifier family](#classifier-family) +- [Cross-cutting productions](#cross-cutting-productions) +- [Walker](#walker) +- [Diagnostics](#diagnostics) +- [What this grammar does not describe](#what-this-grammar-does-not-describe) + +--- + +## Preprocess + +Input is a `*ast.CommentGroup` from `go/parser`. Each `*ast.Comment` +in the group is one source-level comment node (either `// …` or +`/* … */`). The preprocessor produces a flat sequence of `Line` +structs, each one source line with: + +- **`Line.Text`** — content after comment-marker stripping and + leading content-prefix trim. +- **`Line.Raw`** — content after comment-marker stripping only + (preserves leading whitespace). +- **`Line.Pos`** — `token.Position` of the first content byte. + +Stripping rules: + +- For `//` comments: drop the `//` marker. `Line.Text` runs + `trimContentPrefix` (strips leading ` \t*/|`); `Line.Raw` keeps + the post-marker spacing. +- For `/* */` block comments: split body on newlines. + - First line: drop the `/*` marker. + - Continuation lines: run `stripBlockContinuation` (strips + leading whitespace + optional `*` continuation marker + one + following space), then `trimContentPrefix`. + - Last line: drop the trailing `*/`. + +`trimContentPrefix` strips ` \t*/` and a single trailing `|` from +the line head. It does NOT strip `-` (so YAML list markers and +markdown dash items survive intact). + +For synthetic per-line comments produced by upstream tooling +(notably `parsers.ParseRoutePathAnnotation`), a `// ` prefix is +prepended before stripping so the `//` branch fires and the leading +whitespace gets shed correctly. + +--- + +## Lexer + +The lexer turns a `[]Line` into a `[]Token` ending in `TokenEOF`. +Pipeline: + +1. **Line classifier** — emit one preliminary token per line + (annotation / keyword / fence / blank / text). +2. **Body accumulator** — fold multi-line bodies (OPAQUE_YAML, + RAW_BLOCK_*, RAW_VALUE_*) into single body tokens. +3. **Prose classifier** — re-type surviving text tokens as + `TokenTitle` / `TokenDesc`. + +### Terminal vocabulary + +#### Annotation name terminals (`TokenAnnotation`) + +Each recognises an annotation **name** only — positional arguments +are emitted as separate terminals. + +| Terminal | Annotation | +|----------|------------| +| `ANN_MODEL` | `swagger:model` | +| `ANN_RESPONSE` | `swagger:response` | +| `ANN_PARAMETERS` | `swagger:parameters` | +| `ANN_ROUTE` | `swagger:route` | +| `ANN_OPERATION` | `swagger:operation` | +| `ANN_META` | `swagger:meta` | +| `ANN_STRFMT` | `swagger:strfmt` | +| `ANN_ALIAS` | `swagger:alias` | +| `ANN_NAME` | `swagger:name` | +| `ANN_ALLOF` | `swagger:allOf` | +| `ANN_ENUM` | `swagger:enum` | +| `ANN_IGNORE` | `swagger:ignore` | +| `ANN_DEFAULT` | `swagger:default` | +| `ANN_TYPE` | `swagger:type` | +| `ANN_FILE` | `swagger:file` | + +#### Argument terminals + +| Terminal | Recognises | +|----------|------------| +| `IDENT_NAME` | Identifier-shaped token. Used for every named arg and reference. | +| `JSON_VALUE` | RFC-8259 JSON literal (string / number / boolean / null / array / object). | +| `RAW_VALUE` | Verbatim non-LF text — fallback when `JSON_VALUE` recognition fails. | +| `TYPE_REF` | Closed vocab: `string` / `integer` / `number` / `boolean` / `array` / `object` / `file` / `null`. | +| `HTTP_METHOD` | `GET` / `POST` / `PUT` / `PATCH` / `HEAD` / `DELETE` / `OPTIONS` / `TRACE` (case-insensitive). | +| `URL_PATH` | RFC-3986 URL path token (used as the second positional arg of `OperationArgs`). | + +#### Keyword head terminals (`TokenKeyword`) + +Each recognises the keyword **name** only. See +[keywords.md](./keywords.md) for the complete keyword surface. + +#### Inline value terminals + +The lexer types values per their lexical shape; semantic coercion +against the Go target happens in the analyzer. + +| Terminal | Recognises | +|----------|------------| +| `NUMBER_VALUE` | Signed decimal literal (integer or fractional). | +| `INT_VALUE` | Unsigned decimal integer. | +| `BOOL_VALUE` | `true` / `false` (case-insensitive). | +| `STRING_VALUE` | Verbatim non-LF text. | +| `COMMA_LIST_VALUE` | Comma-separated list of strings, trim-stripped. | +| `ENUM_OPTION_VALUE` | One of a closed token set declared per keyword (`query`/`path`/… for `in:`, `csv`/`ssv`/… for `collectionFormat`). | + +When the lexer fails to type a value against its keyword's expected +shape, the property reaches the analyzer with `Property.Typed.Type == +ShapeNone` and a `CodeInvalidNumber` / `CodeInvalidInteger` / +`CodeInvalidBoolean` diagnostic is emitted. + +#### Multi-line body terminals + +Single tokens spanning multiple source lines. The lexer absorbs the +head and the body lines. + +| Terminal | Parent keyword | Body shape | +|----------|----------------|------------| +| `RAW_BLOCK_CONSUMES` | `consumes` | Flat token list (see [sub-languages §flex-list](./sub-languages.md#flex-list)) | +| `RAW_BLOCK_PRODUCES` | `produces` | Flat token list | +| `RAW_BLOCK_SCHEMES` | `schemes` | Flat token list | +| `RAW_BLOCK_SECURITY` | `security` | Security requirements (see [sub-languages §security-requirements](./sub-languages.md#security-requirements)) | +| `RAW_BLOCK_SECURITY_DEFINITIONS` | `securityDefinitions` | YAML map | +| `RAW_BLOCK_RESPONSES` | `responses` | Response sub-language (see [sub-languages §responses](./sub-languages.md#responses)) | +| `RAW_BLOCK_PARAMETERS` | `parameters` | Parameter chunk sub-language (see [sub-languages §parameters](./sub-languages.md#parameters)) | +| `RAW_BLOCK_EXTENSIONS` | `extensions` | YAML map of `x-*` entries | +| `RAW_BLOCK_INFO_EXTENSIONS` | `infoExtensions` | YAML map of `x-*` entries | +| `RAW_BLOCK_TOS` | `tos` | Free-form prose paragraph | +| `RAW_BLOCK_EXTERNAL_DOCS` | `externalDocs` | YAML map | +| `RAW_VALUE_DEFAULT` | `default` | Raw value text | +| `RAW_VALUE_EXAMPLE` | `example` | Raw value text | +| `RAW_VALUE_ENUM` | `enum` | Comma list, JSON array, or YAML dash list | + +### Body accumulation + +A raw-block / raw-value keyword opens a body. The body terminates at +the **next sibling structural token** in the same family — either +another `TokenAnnotation`, another body-keyword head whose context +makes it a sibling, or `TokenEOF`. + +Blank lines do NOT terminate the body. They are absorbed as visual +separators inside list-shaped bodies. + +For raw-block heads, the inline post-colon value (when non-empty) +is prepended to the body as its first line. This means +`Consumes: application/json` (inline single value) and +`Consumes:\n - application/json` (multi-line body) both yield the +same body content; consumers don't need to special-case the inline +form. + +### YAML fence handling + +A line whose trimmed content is exactly `---` opens (or closes) a +YAML fence. While the cursor sits between matching fences: + +- Annotation and keyword recognition is suspended; every line emits + as `tokenRawLine` carrying the verbatim source text. +- The body accumulator captures the fenced region as a single + `OPAQUE_YAML` token attached to the surrounding annotation + (typically `swagger:operation` or a fenced extensions body). +- A missing closing fence emits a `CodeUnterminatedFence` + diagnostic; the `OPAQUE_YAML` token is marked truncated and the + builder degrades gracefully. + +### Prose classification + +Surviving `tokenText` tokens (not consumed by a body, not an +annotation or keyword head) re-type as either `TokenTitle` or +`TokenDesc` per three heuristics evaluated in order: + +1. **Blank-line split** — a blank line inside the prose run ends + the title and starts the description. +2. **Closing punctuation** — if the first prose line ends with + Unicode punctuation, the title is just that one line. +3. **Markdown ATX heading** — if the first prose line matches + markdown's `# Heading` shape, the `#` markers are stripped and + the line becomes the title. + +When no heuristic fires, the entire prose run is title. + +See [sub-languages.md §prose-classification](./sub-languages.md#prose-classification) +for the author-facing description. + +--- + +## Parser + +The parser consumes the lexer's terminal stream and produces typed +`Block` values, one per `*ast.CommentGroup`. A single comment group +may produce MORE than one Block when multiple annotations appear +(each annotation closes the preceding Block and opens a fresh one). + +### Top-level dispatch + +```ebnf +CommentBlock = AnnotatedBlock | UnboundBlock ; + +AnnotatedBlock = SchemaBlock + | OperationFamilyBlock + | MetaBlock + | ClassifierBlock ; + +UnboundBlock = [ Description ] , UnboundBlockBody ; +``` + +The dispatcher reads the first `ANN_*` terminal; its identity +selects the family. If no annotation appears, the input is an +`UnboundBlock` — typically a Go struct field with description-only +documentation. + +`Block.AnnotationKind()` returns the family discriminator. +`Block.AnnotationArg()` returns the leading IDENT argument (if any) +without requiring the caller to type-assert on the typed Block +kind. + +### Schema family + +Bodies of `swagger:model`, `swagger:parameters`, `swagger:response`, +`swagger:name`. + +```ebnf +SchemaBlock = SchemaAnnotation + , [ Title ] + , [ Description ] + , SchemaAnnotationBody ; + +SchemaAnnotation = ModelAnnotation + | ResponseAnnotation + | ParametersAnnotation + | NameAnnotation ; + +ModelAnnotation = ANN_MODEL , [ IDENT_NAME ] ; +ResponseAnnotation = ANN_RESPONSE , [ IDENT_NAME ] ; +ParametersAnnotation = ANN_PARAMETERS , IDENT_NAME , { IDENT_NAME } ; +NameAnnotation = ANN_NAME , IDENT_NAME ; + +SchemaAnnotationBody = { SchemaBodyItem } ; +UnboundBlockBody = { SchemaBodyItem } ; + +SchemaBodyItem = Validation + | SchemaDecorator + | ExtensionsBlock + | ExternalDocsBlock + | BLANK ; + +Validation = NumericValidation + | StringValidation + | ArrayValidation + | EnumValidation + | RequiredLine + | ReadOnlyLine ; + +NumericValidation = NumericKw , NUMBER_VALUE ; +NumericKw = KW_MAXIMUM | KW_MINIMUM | KW_MULTIPLE_OF ; + +StringValidation = KW_PATTERN , STRING_VALUE + | StringLengthKw , INT_VALUE ; +StringLengthKw = KW_MAX_LENGTH | KW_MIN_LENGTH ; + +ArrayValidation = ArrayCountKw , INT_VALUE + | KW_UNIQUE , BOOL_VALUE + | KW_COLLECTION_FORMAT , ENUM_OPTION_VALUE ; +ArrayCountKw = KW_MAX_ITEMS | KW_MIN_ITEMS ; + +EnumValidation = RAW_VALUE_ENUM ; +RequiredLine = KW_REQUIRED , BOOL_VALUE ; +ReadOnlyLine = KW_READ_ONLY , BOOL_VALUE ; + +SchemaDecorator = RAW_VALUE_DEFAULT + | RAW_VALUE_EXAMPLE + | DiscriminatorLine + | DeprecatedLine ; + +DiscriminatorLine = KW_DISCRIMINATOR , BOOL_VALUE ; +DeprecatedLine = KW_DEPRECATED , BOOL_VALUE ; +``` + +### Operation family + +`swagger:route` and `swagger:operation` are distinct block productions +because their bodies differ structurally — `swagger:route` accepts +the structured keyword surface; `swagger:operation` accepts an +`OPAQUE_YAML` body. + +```ebnf +OperationFamilyBlock = RouteBlock | InlineOperationBlock ; + +RouteBlock = ANN_ROUTE , OperationArgs + , [ Title ] + , [ Description ] + , RouteBody ; + +InlineOperationBlock = ANN_OPERATION , OperationArgs + , [ Title ] + , [ Description ] + , InlineOperationBody ; + +OperationArgs = HTTP_METHOD , URL_PATH , { IDENT_NAME } , IDENT_NAME ; + (* Trailing IDENT_NAME is the OperationID; + the run between URL_PATH and the OpID is + the tag list. *) + +RouteBody = { CommonOperationBodyItem | BLANK } ; + +InlineOperationBody = { CommonOperationBodyItem + | OPAQUE_YAML + | BLANK } ; + +CommonOperationBodyItem = OperationKeyword + | OperationDecorator + | OperationRawBlock + | ExtensionsBlock + | ExternalDocsBlock ; + +OperationKeyword = KW_SCHEMES , COMMA_LIST_VALUE ; + +OperationDecorator = DeprecatedLine ; + +OperationRawBlock = RAW_BLOCK_CONSUMES + | RAW_BLOCK_PRODUCES + | RAW_BLOCK_SECURITY + | RAW_BLOCK_RESPONSES + | RAW_BLOCK_PARAMETERS ; +``` + +The ` swagger:route ...` godoc-prefix exception (which +allows a leading Go identifier on the route annotation line) is +absorbed by the lexer; the EBNF sees a plain `ANN_ROUTE`. + +### Meta family + +`swagger:meta` defines top-of-spec metadata. + +```ebnf +MetaBlock = ANN_META + , [ Title ] + , [ Description ] + , MetaBody ; + +MetaBody = { MetaBodyItem | BLANK } ; + +MetaBodyItem = MetaKeyword + | MetaRawBlock + | ExtensionsBlock + | InfoExtensionsBlock + | ExternalDocsBlock ; + +MetaKeyword = KW_VERSION , STRING_VALUE + | KW_HOST , STRING_VALUE + | KW_BASE_PATH , STRING_VALUE + | KW_LICENSE , STRING_VALUE + | KW_CONTACT , STRING_VALUE + | KW_SCHEMES , COMMA_LIST_VALUE ; + +MetaRawBlock = RAW_BLOCK_CONSUMES + | RAW_BLOCK_PRODUCES + | RAW_BLOCK_SCHEMES + | RAW_BLOCK_SECURITY + | RAW_BLOCK_SECURITY_DEFINITIONS + | RAW_BLOCK_TOS ; +``` + +### Classifier family + +Single-purpose annotations that classify the surrounding declaration +without carrying their own body. + +```ebnf +ClassifierBlock = StrfmtBlock + | AliasBlock + | AllOfBlock + | EnumBlock + | IgnoreBlock + | DefaultClassifierBlock + | TypeBlock + | FileBlock ; + +StrfmtBlock = ANN_STRFMT , IDENT_NAME , [ Title ] , [ Description ] ; +AliasBlock = ANN_ALIAS , [ IDENT_NAME ] , [ Title ] , [ Description ] ; +AllOfBlock = ANN_ALLOF , [ Title ] , [ Description ] ; +EnumBlock = ANN_ENUM , [ IDENT_NAME ] , [ Title ] , [ Description ] ; +IgnoreBlock = ANN_IGNORE , [ Title ] , [ Description ] ; +DefaultClassifierBlock = ANN_DEFAULT , [ Title ] , [ Description ] ; +TypeBlock = ANN_TYPE , TYPE_REF , [ Title ] , [ Description ] ; +FileBlock = ANN_FILE , [ Title ] , [ Description ] ; +``` + +Classifiers are stateless markers — they carry no validation body +of their own. The surrounding declaration's other annotations (or +the absence thereof) determine where the classification lands. + +--- + +## Cross-cutting productions + +These appear in multiple families and share a single production. + +```ebnf +ExtensionsBlock = RAW_BLOCK_EXTENSIONS ; +InfoExtensionsBlock = RAW_BLOCK_INFO_EXTENSIONS ; +ExternalDocsBlock = RAW_BLOCK_EXTERNAL_DOCS ; + +Title = TokenTitle ; +Description = TokenDesc , { TokenDesc | BLANK , TokenDesc } ; +BLANK = TokenBlank ; +``` + +Vendor extensions (`ExtensionsBlock`, `InfoExtensionsBlock`) accept +YAML map bodies; non-`x-*` keys emit `CodeInvalidAnnotation` and +drop. The lexer additionally surfaces them via +`Block.Extensions()` with an `Extension.Source` discriminator +(`KwExtensions` vs `KwInfoExtensions`) so consumers can route to +the correct spec field (`spec.extensions` vs `info.extensions`). + +--- + +## Walker + +`Block.Walk(grammar.Walker{...})` dispatches Properties through +typed callbacks. The Walker maps a Property to a callback by +`Keyword.Shape`: + +| Shape | Callback | Payload | +|-------|----------|---------| +| `ShapeNumber` | `Number` | `(p, float64, exclusive bool)` | +| `ShapeInt` | `Integer` | `(p, int64)` | +| `ShapeBool` | `Bool` | `(p, bool)` | +| `ShapeString` | `String` | `(p, string)` — value on `p.Value` | +| `ShapeEnumOption` | `String` | `(p, string)` — closed-vocab token on `p.Typed.String` | +| `ShapeRawBlock` | `Raw` | `(p)` — caller reads `p.Body` / `p.Raw` | +| `ShapeRawValue` | `Raw` | `(p)` | +| `ShapeCommaList` | `Raw` | `(p)` — caller splits via `Property.AsList` | +| `ShapeNone` (failed typing) | `Raw` | `(p)` — diagnostic fired separately | + +Additional callbacks fire outside the per-Property dispatch: + +- `Title(s string)` — once, before any property, if non-empty. +- `Description(s string)` — once, before any property, if non-empty. +- `Extension(ext grammar.Extension)` — once per typed extension. +- `Diagnostic(d grammar.Diagnostic)` — block-level diagnostics fire + before Title; per-property diagnostics fire immediately before + the property's main callback. + +`Walker.FilterDepth` gates property callbacks by `Property.ItemsDepth`. +Pass `0` for level-0 properties (default); pass `N` for items-level +N; pass `AllDepths` (-1) for every depth. + +For full Walker contract see the +[`grammar` package README](../internal/parsers/grammar/README.md#walker-contract). + +--- + +## Diagnostics + +The grammar emits typed diagnostics for malformed input, recovered +where possible: + +| Code | Severity | Trigger | +|------|----------|---------| +| `CodeInvalidAnnotation` | Warning | Unknown tag, malformed annotation arg, dropped malformed property | +| `CodeInvalidNumber` | Warning | Number-typed value failed lexical parse | +| `CodeInvalidInteger` | Warning | Integer-typed value failed lexical parse | +| `CodeInvalidBoolean` | Warning | Boolean-typed value failed lexical parse | +| `CodeShapeMismatch` | Warning | Keyword applied to a schema type that doesn't accept it (e.g. `minLength` on a number) | +| `CodeContextInvalid` | Warning | Keyword used outside its legal annotation context | +| `CodeUnsupportedInSimpleSchema` | Warning | Full-schema-only keyword used in SimpleSchema (non-body param, header) | +| `CodeInvalidYAMLExtensions` | Warning | YAML parse failed inside an extensions body | +| `CodeUnterminatedFence` | Warning | YAML fence opened but not closed before EOF | + +All diagnostics drop the offending property / annotation / +extension and continue the build. The accumulator on +`common.Builder` collects them in source order; the consumer's +`OnDiagnostic` callback (if wired) fires inline. + +--- + +## What this grammar does not describe + +The grammar's job ends at producing typed `Property` and `Block` +values. The analyzer (builders / spec orchestrator) owns: + +- **Type coercion** — `default: 1.5` against an `integer` schema is + a lexical success and an analyzer rejection. `validations.CoerceValue` + and `validations.ParseDefault` apply the schema-type-aware + coercion at write time. +- **Cross-reference resolution** — `$ref` targets, alias-chain + resolution, post-decl discovery. The grammar emits the names; the + analyzer resolves them. +- **Schema-shape gating** — `validations.IsLegalForType` decides + whether `minLength` applies to the resolved schema type. The + grammar always emits the property; the handler dispatch decides + whether to write it. +- **Ordering & merging across multiple comment groups** — when + several `swagger:parameters Foo Bar Baz` declarations contribute + to the same operation, the spec builder merges them. + +The grammar is also deliberately **single-pass** — it never +revisits a `*ast.CommentGroup` after `Parse(cg)` returns. The +`common.Builder` blockCache memoises results across the analyzer's +recursive type descent (see +[`common` README §blockcache](../internal/builders/common/README.md#blockcache)). diff --git a/docs/keywords.md b/docs/keywords.md new file mode 100644 index 0000000..81fdc54 --- /dev/null +++ b/docs/keywords.md @@ -0,0 +1,645 @@ +--- +title: "Keyword reference" +weight: 20 +--- + +# Keyword reference + +This document catalogs the `keyword: value` forms recognised inside +annotation blocks. The keywords come in two flavours: + +- **Inline keywords** — one line, `keyword: value` shape, with the + value classified by a [value shape](#value-shapes) (number, integer, + boolean, string, …). +- **Body keywords** — a header line followed by indented continuation + lines. The body's interpretation depends on the keyword (a flat + token list for `Consumes:`, a YAML map for `SecurityDefinitions:`, + a per-line sub-language for `Parameters:` / `Responses:` on + `swagger:route`). + +The reader-friendly orientation is in [annotations.md](./annotations.md) +(which annotation accepts which keywords, with examples); this file +is the **per-keyword reference card**. Implementers wanting the +formal productions should read [grammar.md](./grammar.md). + +--- + +## Table of contents + +- [Reading the tables](#reading-the-tables) +- [Value shapes](#value-shapes) +- [Annotation contexts](#annotation-contexts) +- [Summary table](#summary-table) +- [Numeric validations](#numeric-validations) — `maximum`, `minimum`, `multipleOf` +- [Length / array validations](#length--array-validations) — `maxLength`, `minLength`, `maxItems`, `minItems` +- [Format validations](#format-validations) — `pattern`, `unique`, `collectionFormat` +- [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` + +--- + +## Reading the tables + +Each keyword entry carries: + +- **Name** — canonical spelling. This is what `Property.Keyword.Name` + compares equal to. Comparisons are case-insensitive on the + canonical spelling and on every alias. +- **Aliases** — alternate spellings the lexer accepts. They map to + the canonical name at lex time; consumers never see alias values. +- **Value shape** — the lexical category of the value. See + [value shapes](#value-shapes) for what each one means and how it + surfaces to consumers. +- **Contexts** — the family-level scopes where the keyword is legal. + Using a keyword outside its legal contexts emits a + `CodeContextInvalid` diagnostic and the keyword is dropped from + the affected block. + +## Value shapes + +The grammar's lexer classifies every value into one of these +shapes. The shape determines which Walker callback fires for the +property and which field of `Property.Typed` carries the parsed +value. + +| Shape | Typed payload | Example value forms | +|-------|---------------|---------------------| +| `number` | `float64` (with optional `<`/`<=`/`>`/`>=`/`=` prefix) | `5`, `1.5`, `<10`, `>=0`, `=42` | +| `integer` | `int64` | `5`, `100` | +| `boolean` | `bool` | `true`, `false`, `1`, `0` | +| `string` | raw `string` | `^[a-z]+$`, `date-time`, `multipart/form-data` | +| `comma-list` | raw `string`; split on `,` by `Property.AsList()` | `http, https`, `a,b,c` | +| `enum-option` | typed `string` (closed-vocab match) | `csv`, `pipes` for `collectionFormat:` | +| `raw-block` | accumulated body lines on `Property.Body` | multi-line YAML, indented token lists | +| `raw-value` | the verbatim post-colon text on `Property.Value` | `42`, `"orange"`, `[1, 2, 3]` | + +When typing fails (e.g. `maximum: notanumber`) the lexer emits a +`CodeInvalidNumber` / `CodeInvalidInteger` / `CodeInvalidBoolean` +diagnostic and the property reaches the Walker with a zero-value +payload. Consumers gate on `Property.IsTyped()` to skip +malformed-typed values; the corresponding builder field stays +unwritten. + +## Annotation contexts + +The closed set of contexts a keyword can legally appear in. A keyword +table entry's `Contexts` field combines these: + +| Context | Meaning | +|---------|---------| +| `param` | Parameter doc on a `swagger:parameters` struct field, or a `+ name:` chunk inside `swagger:route Parameters:` | +| `header` | Header field on a `swagger:response` struct | +| `schema` | Top-level model or struct field on a `swagger:model` | +| `items` | Items-level (array element) validation on either parameter or schema | +| `route` | Route-level metadata under `swagger:route` | +| `operation` | Inline operation metadata under `swagger:operation` | +| `meta` | Package-level metadata under `swagger:meta` | +| `response` | Response-level decorations | + +--- + +## Summary table + +The full keyword surface, in the order the keyword table declares +them. Detailed entries follow this table. + +| Keyword | Aliases | Shape | Contexts | +|---------|---------|-------|----------| +| `maximum` | `max` | number | param, header, schema, items | +| `minimum` | `min` | number | param, header, schema, items | +| `multipleOf` | `multiple of`, `multiple-of` | number | param, header, schema, items | +| `maxLength` | `max length`, `max-length`, `maxLen`, `max len`, `max-len`, `maximum length`, `maximum-length`, `maximumLength`, `maximum len`, `maximum-len` | integer | param, header, schema, items | +| `minLength` | `min length`, `min-length`, `minLen`, `min len`, `min-len`, `minimum length`, `minimum-length`, `minimumLength`, `minimum len`, `minimum-len` | integer | param, header, schema, items | +| `pattern` | — | string | param, header, schema, items | +| `maxItems` | `max items`, `max-items`, `max.items`, `maximum items`, `maximum-items`, `maximumItems` | integer | param, header, schema, items | +| `minItems` | `min items`, `min-items`, `min.items`, `minimum items`, `minimum-items`, `minimumItems` | integer | param, header, schema, items | +| `unique` | — | boolean | param, header, schema, items | +| `collectionFormat` | `collection format`, `collection-format` | enum-option (`csv`, `ssv`, `tsv`, `pipes`, `multi`) | param, header, items | +| `default` | — | raw-value | param, header, schema, items | +| `example` | — | raw-value | param, header, schema, items | +| `enum` | — | raw-value | param, header, schema, items | +| `required` | — | boolean | param, schema | +| `readOnly` | `read only`, `read-only` | boolean | schema | +| `discriminator` | — | boolean | schema | +| `deprecated` | — | boolean | operation, route, schema | +| `in` | — | enum-option (`query`, `path`, `header`, `body`, `formData`) | param | +| `schemes` | — | raw-block (token list) | meta, route, operation | +| `version` | — | string | meta | +| `host` | — | string | meta | +| `basePath` | `base path`, `base-path` | string | meta | +| `license` | — | string | meta | +| `contact` | `contact info`, `contact-info` | string | meta | +| `consumes` | — | raw-block (token list) | meta, route, operation | +| `produces` | — | raw-block (token list) | meta, route, operation | +| `security` | — | raw-block (security requirements) | meta, route, operation | +| `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 | +| `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 | +| `externalDocs` | `external docs`, `external-docs` | raw-block (YAML map) | meta, route, operation, schema | + +--- + +## Numeric validations + +Apply to numeric schema types (`integer`, `number`). On a typed +schema with a non-numeric type, these keywords emit +`CodeShapeMismatch` and drop. On a typeless schema (no `type:` +declared upstream), they apply best-effort. + +### `maximum` + +Upper bound on a numeric value. Alias: `max`. + +The value may carry a leading comparison operator that becomes the +exclusive/inclusive bound: + +- `maximum: 10` — inclusive (≤ 10). +- `maximum: <10` — exclusive (< 10). +- `maximum: <=10` — inclusive (same as no prefix). +- `maximum: =10` — inclusive. + +Maps to `schema.maximum` and `schema.exclusiveMaximum`. + +```go +// Limit is the cap on items per page. +// +// maximum: 100 +// minimum: 1 +type Limit int +``` + +— from `fixtures/enhancements/...` (any numeric-validation fixture). + +### `minimum` + +Lower bound on a numeric value. Alias: `min`. Same operator-prefix +shape as `maximum`. Maps to `schema.minimum` and +`schema.exclusiveMinimum`. + +### `multipleOf` + +Divisibility constraint. The value must be a positive number. +Aliases: `multiple of`, `multiple-of`. Maps to `schema.multipleOf`. + +```go +// AllowedStep enforces increments of 5. +// +// multipleOf: 5 +type AllowedStep int +``` + +--- + +## Length / array validations + +`maxLength` / `minLength` apply only to **string-typed** schemas; +`maxItems` / `minItems` apply only to **array-typed** schemas. Using +the wrong pairing emits `CodeShapeMismatch` and drops the keyword. + +### `maxLength` + +Maximum string length. Many aliases for ergonomic spelling: +`max length`, `max-length`, `maxLen`, `max len`, `max-len`, +`maximum length`, `maximum-length`, `maximumLength`, `maximum len`, +`maximum-len`. Maps to `schema.maxLength`. + +### `minLength` + +Minimum string length. Same alias set as `maxLength` with `min` in +place of `max`. Maps to `schema.minLength`. + +### `maxItems` + +Maximum array length. Aliases: `max items`, `max-items`, +`max.items`, `maximum items`, `maximum-items`, `maximumItems`. Maps +to `schema.maxItems`. + +### `minItems` + +Minimum array length. Same alias shape as `maxItems` with `min` in +place of `max`. Maps to `schema.minItems`. + +```go +// Tags is a non-empty, bounded list. +// +// minItems: 1 +// maxItems: 20 +// unique: true +type Tags []string +``` + +--- + +## Format validations + +### `pattern` + +A regex constraint on a string value. The pattern is preserved +verbatim on `schema.pattern`. The grammar runs a best-effort RE2 +compile (Go's regex engine) on the value; if it fails, a +`CodeInvalidAnnotation` diagnostic surfaces with the compile error. +The value still lands on the schema — downstream tools may use +JSON Schema's wider regex dialect. + +```go +// Slug is a URL-friendly identifier. +// +// pattern: ^[a-z0-9-]+$ +type Slug string +``` + +### `unique` + +Marks an array-typed schema as set-valued (no duplicates). Boolean. +Maps to `schema.uniqueItems`. + +### `collectionFormat` + +How an array value is serialised on the wire. Closed-vocab: + +- `csv` — comma-separated (default). +- `ssv` — space-separated. +- `tsv` — tab-separated. +- `pipes` — pipe-separated. +- `multi` — repeated `?key=val&key=val2` (query params only). + +Aliases: `collection format`, `collection-format`. Maps to +`parameter.collectionFormat` / `items.collectionFormat`. Schema-level +contexts ignore this keyword (it's a SimpleSchema concept; schemas +serialise via `application/json`). + +When the source value doesn't match the closed vocab, the raw value +is preserved verbatim on the parameter (matches the original +behaviour where `pipe` as a typo for `pipes` round-trips). + +```go +// Tags is the form-data array of label tokens. +// +// in: query +// type: array +// collectionFormat: csv +// items.type: string +type TagsParam []string +``` + +--- + +## Schema decorators + +### `default` + +Default value for a schema or simple-schema field. Raw-value shape — +the post-colon text is captured verbatim and coerced against the +resolved schema type at write time (`ParseDefault` / +`CoerceValue`). + +Multi-line bodies are accepted for complex literals: + +```go +// Limits is the throughput envelope. +// +// default: +// { +// "rps": 100, +// "burst": 200 +// } +type Limits struct { ... } +``` + +Single-line form for primitives: + +```go +// Page is the page number. +// +// in: query +// type: integer +// default: 1 +type PageParam int +``` + +### `example` + +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). + +### `enum` + +A closed set of allowed values. Three accepted surface forms: + +- **Comma list**: `enum: red, green, blue` — split on `,` and + trimmed. +- **JSON array**: `enum: ["red", "green", "blue"]` — parsed via + YAML/JSON. +- **Multi-line list with `-` markers**: + ``` + enum: + - red + - green + - blue + ``` + +Each element is coerced against the resolved schema type. Maps to +`schema.enum`. + +For string-typed enums driven by Go `const` declarations the +`swagger:enum` annotation is the more idiomatic surface — it picks +up the constant names AND their godoc descriptions and produces an +`x-go-enum-desc` extension alongside the enum values. The +`enum:` keyword is the manual override. + +### `required` + +Marks a field as required. Boolean. + +- On a `swagger:model` struct field: adds the field's name to the + enclosing schema's `required` array. +- On a `swagger:parameters` struct field: sets `parameter.required`. +- On a `swagger:response` header: not applicable; the keyword is + silently dropped (response headers don't carry `required`). + +### `readOnly` + +Marks a schema property as read-only. Aliases: `read only`, +`read-only`. Maps to `schema.readOnly`. + +Schema-only — emitting `readOnly:` inside a SimpleSchema context +(non-body parameter, response header) emits +`CodeUnsupportedInSimpleSchema` and drops the keyword. + +### `discriminator` + +Marks the property as the discriminator for an `allOf` polymorphic +schema. Boolean. Writes the property's name onto the enclosing +schema's `discriminator` field. Schema-only. + +### `deprecated` + +Marks the carrying entity as deprecated. Boolean. Legal on operations +(`operation.deprecated`), routes (`operation.deprecated` on the +synthesised op), and schemas (some downstream tools render this). + +--- + +## Parameter location + +### `in` + +Where the parameter value comes from. Closed-vocab: + +- `query` — query string parameter. +- `path` — path-parameter substitution. +- `header` — request header. +- `body` — request body (JSON, etc.). +- `formData` — form-data body field (note: `form` accepted as an + alias inside `swagger:route Parameters:` chunks; the lexer + normalises to `formData` at the canonical surface). + +A non-matching value emits a context-invalid diagnostic; the +parameter loses its `in` and may end up incorrectly classified +downstream. + +```go +// PageParams declares pagination query parameters. +// +// swagger:parameters listItems +type PageParams struct { + // in: query + // minimum: 0 + Offset int `json:"offset"` + + // in: query + // minimum: 1 + // maximum: 100 + // default: 20 + Limit int `json:"limit"` +} +``` + +--- + +## Meta single-line keywords + +Single-line keywords under `swagger:meta`. Values are taken as-is +from the post-colon string. + +### `schemes` + +Accepted URL schemes. Flexible list — all forms below produce the +same `["http", "https"]` output: + +``` +Schemes: http, https +Schemes: + - http + - https +Schemes: http + - https +``` + +Maps to `spec.schemes`. See [sub-languages.md](./sub-languages.md) +§flex-lists for the unified rule. + +### `version` + +API version string. Maps to `info.version`. + +### `host` + +Default host. Defaults to `localhost` when empty. Maps to +`spec.host`. + +### `basePath` + +URL base path. Maps to `spec.basePath`. Aliases: `base path`, +`base-path`. + +### `license` + +License declaration. Two forms accepted: + +``` +License: Apache 2.0 http://www.apache.org/licenses/LICENSE-2.0.html +``` + +…where the trailing token starting with a URL scheme becomes +`license.url` and the prefix becomes `license.name`. A bare name +with no URL is accepted too. + +### `contact` + +Contact declaration. Author writes a `Name URL` triple, in +any order. The grammar recognises: + +- `Name ` — Go's `net/mail.ParseAddress` form. +- `Name http://example.com` — same + trailing + URL. +- Just a URL, no name. + +Aliases: `contact info`, `contact-info`. Maps to `info.contact`. + +--- + +## Body keywords + +Body keywords have a header line ending in `:` and indented +continuation lines. The body's structure depends on the keyword. +See [sub-languages.md](./sub-languages.md) for the full +sub-language specifications; this section covers the keyword +shape. + +### `consumes` / `produces` + +Media-type lists. Same flex-list rule as `schemes:` — comma +inline, multi-line bare, YAML `-` markers, or any combination. +Maps to `consumes` / `produces` on the surrounding scope (spec, +operation). + +``` +Consumes: + - application/json + - application/xml + +Produces: application/json +``` + +### `security` + +Security-requirements list. Each line is one requirement of shape +`schemeName: scope1, scope2`. An empty scope list (`schemeName:`) +means "no scopes required, but the scheme must be active." + +``` +Security: + api_key: + oauth2: read, write +``` + +Maps to `security` (array of single-key maps). + +### `securityDefinitions` + +YAML map declaring security schemes. The body is parsed as YAML +directly into the `spec.securityDefinitions` shape — see +[OAS v2 §5.2.16](https://swagger.io/specification/v2/#securityDefinitionsObject). + +``` +SecurityDefinitions: + api_key: + type: apiKey + in: header + name: X-API-Key + oauth2: + type: oauth2 + flow: implicit + authorizationUrl: https://example.com/auth + scopes: + read: read access + write: write access +``` + +Aliases: `security definitions`, `security-definitions`. Meta-only. + +### `responses` + +Per-route / per-operation response declarations. Each line is one +response in the form `: `. See +[sub-languages.md §responses](./sub-languages.md#responses) for the +full per-line grammar. + +``` +Responses: + 200: body:User the requested user + 404: description: not found + default: response:genericError +``` + +### `parameters` + +Per-route / per-operation parameter declarations. Body is a sequence +of `+ name:` chunks (the `+` is the chunk-start sigil; `-` is +accepted as an alias). See +[sub-languages.md §parameters](./sub-languages.md#parameters) for +the full per-chunk grammar. + +``` +Parameters: + + name: id + in: path + type: integer + required: true + + name: limit + in: query + type: integer + default: 20 + minimum: 1 + maximum: 100 +``` + +### `extensions` / `infoExtensions` + +Vendor extension declarations as a YAML map. Keys must start with +`x-` or `X-`; non-`x-*` keys emit `CodeInvalidAnnotation` and drop. + +- `extensions:` lands the entries on the surrounding scope + (`spec.extensions`, `operation.extensions`, `schema.extensions`, + …). +- `infoExtensions:` is meta-only; entries land on `info.extensions`. + +``` +Extensions: + x-internal-id: 42 + x-feature-flags: + - alpha + - beta + x-nested: + enabled: true + rate: 0.5 +``` + +Aliases: `info extensions`, `info-extensions` (for `infoExtensions`). + +### `tos` + +Terms-of-service prose paragraph. Multi-line body is joined with +`\n` after dropping whitespace-only lines. Aliases: +`terms of service`, `terms-of-service`, `termsOfService`. Maps to +`info.termsOfService`. Meta-only. + +### `externalDocs` + +External documentation pointer as a YAML map with `description` and +`url` keys. Aliases: `external docs`, `external-docs`. Multi-context +(meta, route, operation, schema). + +``` +ExternalDocs: + description: Reference documentation + url: https://example.com/docs +``` + +--- + +## Cross-keyword interactions + +A handful of keyword interactions are worth flagging: + +- **`default` + `example` + `enum`** on the same field: all three + may co-occur. The values are coerced against the resolved schema + type independently. If `enum` is declared and `default` is not a + member of it, no diagnostic fires today — downstream JSON Schema + validation catches it. +- **`type` + numeric validations + `format`** on a body parameter: + the schema dispatcher's `checkShape` gates numeric / length + validations against the resolved type. `format` is type-blind + (any format string lands). +- **`required` on a `$ref'd` field**: writes to the enclosing + schema's `required` array (the standard JSON-Schema-draft-4 + shape). If the field has sibling overrides, the `$ref` rewrites + into an `allOf` compound — see + [grammar.md](./grammar.md) §refoverride. diff --git a/docs/sub-languages.md b/docs/sub-languages.md new file mode 100644 index 0000000..bfa342a --- /dev/null +++ b/docs/sub-languages.md @@ -0,0 +1,491 @@ +--- +title: "Sub-languages" +weight: 30 +--- + +# Sub-languages + +The annotation body grammar is not a single language — it's a +top-level keyword grammar that embeds several smaller languages +inside specific body keywords. Each embedded language has its own +shape rules. + +This document catalogs the embedded languages and how they fit +together. For the per-keyword surface, see +[keywords.md](./keywords.md); for the formal grammar that hosts +them, see [grammar.md](./grammar.md). + +--- + +## Table of contents + +- [Prose classification (TITLE / DESC)](#prose-classification) +- [Flex-list (Property.AsList)](#flex-list) +- [Parameters body grammar](#parameters) +- [Responses body grammar](#responses) +- [YAML extensions surface](#yaml-extensions) +- [Security requirements](#security-requirements) +- [Contact / License inline forms](#contact-license) + +--- + +## Prose classification + +Comment lines that don't match any keyword head OR YAML fence OR +annotation marker are classified as **prose** — free-form text. The +lexer splits prose into two token kinds: + +- **TITLE** — the first paragraph of prose, expected to fit on a + short summary line. +- **DESC** — every prose paragraph after the title (or following a + blank line within the first paragraph). + +Three heuristics decide the title-vs-desc boundary, evaluated in +order. The first to fire wins: + +1. **Blank-line split.** Any blank line inside the prose run ends + the title paragraph and starts the description. +2. **Closing punctuation.** If the first prose line ends with + Unicode punctuation (`.`, `?`, `!`, `…`, `:`, …), the title is + just that one line; everything after becomes description. +3. **Markdown ATX heading.** If the first prose line matches + markdown's `# Heading` shape, the `#` markers are stripped and + the remaining text becomes the title. + +When no heuristic fires, the entire prose run is title (the schema +builder later collapses to a description-only schema when +appropriate). + +### `Package ` prefix strip + +The `swagger:meta` annotation's title comes from the package doc +comment, which by Go convention starts with `Package `. The +spec builder strips that prefix before publishing: + +```go +// Package petstore Petstore API. +// +// Description of the petstore service. +// +// swagger:meta +package petstore +``` + +Produces `info.title = "Petstore API."` (the `Package petstore` +prefix stripped) and `info.description = "Description of the +petstore service."` + +Only the capital-P `Package` form is recognised — author prose like +"package this carefully" is not chopped. + +### Comment-marker noise stripping + +Block-comment routes (`/* swagger:route … */`) typically carry +indented continuation lines: + +```go +/* swagger:route POST /pets pets createPet + + Create a pet based on the parameters. + + Consumes: + - application/json +*/ +func CreatePet() {} +``` + +The lexer strips the leading whitespace (`\t`, `*`, `/`, `|`) per +line via [`trimContentPrefix`](./grammar.md#preprocess) before +classification. + +### Markdown semantics that survive + +- **Dash lists** in descriptions are preserved verbatim. A line + starting with `- foo` lands in the description as + `"- foo"` (not `"foo"`). +- **`---` lines** open a YAML fence — see + [YAML extensions](#yaml-extensions) below. + +--- + +## Flex-list + +Body keywords that publish a flat list of tokens (`schemes:`, +`consumes:`, `produces:`) accept multiple surface forms uniformly. +The unified reader is `Property.AsList()`. + +### Accepted forms + +``` +# Inline, comma-separated +Schemes: http, https + +# Multi-line, indented bare lines +Schemes: + http + https + +# Multi-line, YAML-style dash markers +Schemes: + - http + - https + +# Inline value plus indented continuation +Schemes: http + - https + +# All combinations of the above +Consumes: application/json, application/xml + - application/protobuf +``` + +All five forms produce the same `["http", "https"]` (or +`["application/json", "application/xml", "application/protobuf"]`) +output. + +### Algorithm + +For each input line — `Property.Value` first (if non-empty), then +each line of `Property.Body`: + +1. Trim surrounding whitespace. +2. Drop a leading `- ` YAML marker if present. +3. Re-trim whitespace. +4. Comma-split. +5. Trim each token; drop empties. + +Aggregate into a single slice in source order. + +### What flex-list does NOT touch + +- **Enum values** (`enum: ...`) — their elements may themselves be + complex (JSON arrays, quoted strings with commas). `enum:` keeps + its raw-value path; the value coercion layer handles array / + comma-list / multi-line shapes per the schema type. +- **Parameters chunks** — the `+ name:` chunk grammar is not a + simple token list; see [§parameters](#parameters). +- **YAML structural bodies** — `securityDefinitions:`, + `extensions:`, `infoExtensions:` parse the body as YAML directly; + their structure isn't a flat list. See + [§yaml-extensions](#yaml-extensions). + +--- + +## Parameters + +The `Parameters:` body in `swagger:route` and `swagger:operation` +carries a sequence of parameter declarations separated by `+ name:` +chunks (the `+` is the chunk-start sigil; `-` is accepted as an +alias for forward compatibility with proper YAML). + +### Chunk shape + +``` +Parameters: + + name: id + in: path + type: integer + description: the item identifier + required: true + + name: limit + in: query + type: integer + minimum: 1 + maximum: 100 + default: 20 + + name: body + in: body + type: User + required: true +``` + +### Per-chunk fields + +The fields are classified into **head fields** (consumed by the +orchestrator to populate the `*spec.Parameter` shell) and +**validation fields** (lowered to grammar properties and dispatched +through the standard validation pipeline). + +**Head fields:** + +| Field | Lands on | Notes | +|-------|----------|-------| +| `name:` | `parameter.name` | Required. Identifies the parameter. | +| `in:` | `parameter.in` | One of `path` / `query` / `header` / `body` / `formData`. `form` accepted as an alias for `formData`. | +| `type:` | `parameter.type` (for SimpleSchema) or determines the body $ref | For non-body: one of `string` / `integer` / `number` / `boolean` / `array`. For body: a Go ident referring to a `swagger:model`-declared type, optionally with `[]` array prefixes (`[][]Pet`). `bool` accepted as an alias for `boolean`. | +| `format:` | `parameter.format` or `parameter.schema.format` | Free-form string. Applied after validation dispatch so it doesn't interfere with default/example coercion. | +| `description:` | `parameter.description` | Free-form prose. | +| `required:` | `parameter.required` | Boolean. | +| `allowempty:` / `allowemptyvalue:` | `parameter.allowEmptyValue` | Boolean. | + +**Validation fields:** any other recognised +[keyword](./keywords.md) — `min`, `max`, `minLength`, `maxLength`, +`minItems`, `maxItems`, `pattern`, `unique`, `collectionFormat`, +`default`, `example`, `enum`. These are looked up via +`grammar.Lookup` (which accepts canonical names + aliases) and +dispatched through the standard handlers seam. + +### Empty chunks and unknown keys + +- A bare `+` (or `-`) sigil with no follow-up content emits a + `CodeInvalidAnnotation` diagnostic and is dropped. The legacy + parser silently emitted an empty `Parameter{}` object — current + behaviour rejects it. +- Unknown keys (typos like `defualt:`) emit + `CodeInvalidAnnotation` and drop. The legacy parser silently + discarded them. + +### Body parameters + +When `in: body`, the orchestrator looks up `type:` as either: + +- A primitive (`string`, `integer`, `number`, `boolean`, `array`, + `object`) — emits a typed schema with the primitive on + `parameter.schema.type`. +- A Go ident — emits a `$ref` to `#/definitions/`. With `[]` + prefixes, wraps the ref in nested array schemas. + +Validation properties on a body chunk apply to the schema, gated by +the schema's resolved type via `checkShape`. A `min: 0` on a body +chunk with `type: Pet` (object) emits `CodeShapeMismatch` and drops; +a `min: 0` with `type: integer` lands on the schema's `minimum`. + +### Validation on SimpleSchema (non-body) parameters + +For `in:` other than `body`, validation properties apply directly to +the parameter (not to a sub-schema). Type-gating still applies: +`minLength` on `type: integer` emits a diagnostic and drops. + +--- + +## Responses + +The `Responses:` body in `swagger:route` carries one response +declaration per line. Each line has the shape: + +``` +: * +``` + +where `` is `default` (case-insensitive) or a decimal HTTP +status code, and `` is either a `tag:value` form or an +untagged token. + +### Recognised tags + +| Tag | Value shape | Lands on | +|-----|-------------|----------| +| `body:` | Go ident with optional `[]` prefixes (`body:[]Pet`) | A `$ref` to `#/definitions/`, array-wrapped per `[]` count | +| `response:` | Go ident referring to a `swagger:response`-declared type | A `$ref` to `#/responses/` | +| `description:` | Free-form prose (rest of line) | `response.description` | + +### Untagged token rules + +- The **first untagged token** defaults to a response ref. The + orchestrator resolves it against the operation's `responses` map + first, then falls back to `definitions` — if found in definitions + (not responses), it's silently promoted to a body ref. +- **Subsequent untagged tokens** accumulate into the description. + +### Examples + +``` +Responses: + 200: User the user as returned # untagged → response="User", desc="the user as returned" + 200: body:User the user # body ref + description + 200: response:userResponse the user # named response ref + 201: body:Pet the created pet + 404: description: not found + default: response:genericError + default: body:[]ErrorList the error list # array-wrapped body ref +``` + +### Diagnostics + +- **Unknown tag** (`200: weird:value`) — emits + `CodeInvalidAnnotation` and drops the line. +- **Duplicate body/response tags** on one line + (`200: body:Pet response:errors`) — emits + `CodeInvalidAnnotation`; the line drops. +- **Space-separated `body Foo`** (instead of `body:Foo`) — detected + as a likely typo and dropped with diagnostic. The legacy parser + silently treated it as `response="body"` (a dangling ref to a + non-existent response). +- **Unresolvable response ref** — when a response name appears in + neither `responses` nor `definitions`, the line drops with + diagnostic. The legacy parser emitted a dangling `$ref`. + +### Empty value lines + +A line like `204:` with nothing after the colon produces a Response +with the code and an empty description. This is intentional — some +authors want a `204 No Content` with no body and no description. + +--- + +## YAML extensions + +Several body keywords parse their body as YAML directly: + +- `extensions:` and `infoExtensions:` — a YAML map of `x-*` entries. +- `securityDefinitions:` — a YAML map matching OAS v2's + `securityDefinitions` shape. +- `externalDocs:` — a YAML map with `description` and `url` keys. + +### Extension typing + +Extension values are NOT coerced to strings — they preserve their +YAML-typed form: `bool`, `float64`, `string`, `[]any`, or +`map[string]any` for nested structures. + +``` +Extensions: + x-feature-flags: + - alpha + - beta + x-rate-limit: + requests: 100 + window: 60 + x-internal: true + x-version: 0.5 +``` + +Produces (extract): + +```json +"x-feature-flags": ["alpha", "beta"], +"x-rate-limit": {"requests": 100, "window": 60}, +"x-internal": true, +"x-version": 0.5 +``` + +### `x-*` name gating + +Keys that don't start with `x-` or `X-` emit a +`CodeInvalidAnnotation` diagnostic and drop. The build still +succeeds. Authors who relied on the legacy "hard error on non-x-*" +behaviour see a diagnostic + a clean spec missing the typo'd key. + +``` +Extensions: + x-good: 1 + not-good: 2 # → diagnostic, dropped +``` + +### YAML body delimitation + +The YAML extension bodies use **indentation** to delimit. A line +that returns to the indentation level of the keyword head — or +introduces a sibling keyword — terminates the body. The grammar +also recognises `---` fence pairs around the body (matching the +`swagger:operation` YAML shape) and absorbs them silently. + +--- + +## Security requirements + +The `security:` body (in `swagger:meta`, `swagger:route`, and +`swagger:operation`) carries OAuth-style security requirements +where each line is one requirement. + +### Shape + +Each line: `schemeName: scope1, scope2, …` + +- `schemeName` matches a scheme declared in + `securityDefinitions`. +- Scope list is comma-separated; trimmed; empties dropped. +- An empty scope list (`schemeName:`) means "this scheme is + required, no scopes." Common for `apiKey` and `basic`. + +### Example + +``` +Security: + api_key: + oauth2: read, write + oauth2: admin +``` + +Produces: + +```json +"security": [ + {"api_key": []}, + {"oauth2": ["read", "write"]}, + {"oauth2": ["admin"]} +] +``` + +Each requirement is a single-key map; the array is an OR +relationship (the request satisfies security if it matches ANY +entry). + +--- + +## Contact / License + +Inline single-line meta keywords with structured value +parsing. + +### Contact + +The `contact:` value carries up to three components: name, email, +URL. Recognised forms: + +``` +Contact: Name https://example.com +Contact: Name +Contact: https://example.com +Contact: +``` + +The grammar splits the value on the first URL prefix it finds +(`https://`, `http://`, `ftps://`, `ftp://`, `wss://`, `ws://`), +then parses the prefix portion as `Name ` via Go's +`net/mail.ParseAddress`. + +- A malformed `Name ` head (e.g., unbalanced angle + brackets) surfaces as an error from `Block.Contact()`; the + meta builder propagates it as a build failure. +- An empty contact line produces an empty `Contact` value (no + error, no diagnostic — equivalent to omitting the keyword). + +Aliases: `contact info`, `contact-info`. + +### License + +The `license:` value is split similarly: + +``` +License: Apache 2.0 https://www.apache.org/licenses/LICENSE-2.0 +License: MIT +License: https://opensource.org/licenses/Custom +``` + +Same URL-prefix detection. Everything before the URL is the +license name; the URL (when present) is the license URL. +Either part may be empty. + +License does NOT use `mail.ParseAddress` — the name is taken as +raw text up to the URL boundary. + +--- + +## Sub-language interactions + +Two interaction points worth flagging: + +- **Block-comment continuation lines and the + parameters/responses sub-languages.** A + `/* swagger:route … */` block with `Parameters:` inside requires + the chunk-start sigils (`+ ` / `- `) to be at the start of the + trimmed line. Block-comment continuation noise (`\t`, `*`) is + stripped first; if your editor inserts a `*` continuation marker, + the lexer handles it transparently. +- **Flex-list and `description:`** on a parameter chunk. + `description:` is a head field, not a list — it does NOT comma-split. + Authors who write `description: foo, bar` get a single description + `"foo, bar"`, not two descriptions. (This was a real ambiguity + in older versions of go-swagger; the current grammar resolves + it cleanly.) From 65b35cf5321e62183cd298d47e254aa4c3dace1e Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Tue, 2 Jun 2026 23:40:27 +0200 Subject: [PATCH 19/22] chore: loose ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catch-all for non-code housekeeping that lands with this branch: go.mod / go.sum — version bumps; no new dependencies introduced. .golangci.yml — linter config updates. .gitattributes — line-ending normalisation hints. .claude/CLAUDE.md — project notes refreshed to reflect the new package layout. internal/parsers/grammar/README.md — long-form maintainer notes for the grammar package (parallels the per-package READMEs added under builders/). Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- .claude/CLAUDE.md | 41 +- .gitattributes | 6 + .github/copilot-instructions.md | 30 +- .golangci.yml | 3 + go.mod | 15 +- go.sum | 38 +- internal/parsers/grammar/README.md | 1001 ++++++++++++++++++++++++++++ 7 files changed, 1081 insertions(+), 53 deletions(-) create mode 100644 .gitattributes create mode 100644 internal/parsers/grammar/README.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1bd7e09..16f1498 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -36,26 +36,33 @@ to builders without direct coupling. | `scan_context.go` | `ScanCtx` / `NewScanCtx` — loads Go packages via `golang.org/x/tools/go/packages` | | `index.go` | `TypeIndex` — node classification (meta/route/operation/model/parameters/response) | | `declaration.go` | `EntityDecl` — wraps a type/value declaration with its enclosing file/package | +| `classify/` | Classification predicates usable from both scanner and builders (e.g. `IsAllowedExtension`) | -### `internal/parsers/` — comment-block parsing engine +### `internal/parsers/` — scanner classification + helpers + +Post grammar-migration (P6.3), `parsers/` is intentionally scanner-only. The +old regex-based comment-block parsing engine is gone; what remains are +classification helpers used by the scanner and builders, plus subpackages +for the grammar parser and its satellite helpers. + +**Root — scanner classification** | File | Contents | |------|----------| -| `sectioned_parser.go` | The section-driven parser that walks title/description/annotation blocks | -| `parsers.go`, `parsers_helpers.go` | Dispatch + helpers for tag/package filtering, value extraction | -| `tag_parsers.go`, `matchers.go` | Tag recognisers (`TypeName`, `Model`, etc.) | -| `regexprs.go` | Shared regular expressions for annotation parsing | -| `meta.go` | Swagger info-block parsing (title, version, license, contact) | -| `responses.go`, `route_params.go` | Response / route-parameter annotation parsing | -| `validations.go`, `extensions.go` | Validation directives, `x-*` extensions | -| `enum.go`, `security.go` | Enum extraction from Go constants, security-definition blocks | -| `yaml_parser.go`, `yaml_spec_parser.go` | Embedded-YAML parsing for `swagger:operation` bodies | -| `lines.go`, `parsed_path_content.go` | Comment-line and path-content helpers | -| `errors.go` | Sentinel errors | +| `matchers.go` | `ExtractAnnotation`, `ModelOverride`, `ResponseOverride`, `ParametersOverride` — the scanner-level annotation classifiers | +| `regexprs.go` | Regex definitions backing the matchers + `rxRoute` / `rxOperation` for the path-annotation parsers | +| `parsed_path_content.go` | `ParsedPathContent` + `ParseOperationPathAnnotation` / `ParseRoutePathAnnotation` | + +**Subpackages** + +| Package | Role | +|---------|------| +| `grammar/` | The grammar parser — `NewParser`, `Block`, `Property`, keyword tables | +| `yaml/` | YAML sub-parser used by grammar's typed-extensions surface and by operation / meta body unmarshal | ### `internal/builders/` — Swagger object construction -Each sub-package owns one concern and a `taggers.go` file wiring parsers to its targets. +Each sub-package owns one concern; `walker.go` carries the per-block grammar dispatch. | Package | Contents | |---------|----------| @@ -64,9 +71,11 @@ Each sub-package owns one concern and a `taggers.go` file wiring parsers to its | `operations` | Operation (route handler) annotation parsing | | `parameters` | Parameter annotation parsing | | `responses` | Response annotation parsing | -| `routes` | Route/path discovery and matching | -| `items` | Array-item targets (typable + validations, no own annotations) | -| `resolvers` | `SwaggerSchemaForType`, identity/assertion helpers shared by builders | +| `routes` | Route/path discovery + body parsers (`body_params.go`, `body_responses.go`) | +| `common` | `*common.Builder` embedded by every per-decl builder; `SchemesList` + `SecurityRequirements` shared by routes/spec | +| `handlers` | Walker callback factories shared across schema/parameters/responses (`Number`, `Integer`, `UniqueBool`, `PatternString`, …) | +| `resolvers` | `SwaggerSchemaForType`, identity/assertion helpers, items-chain ifaces adapters (`ItemsTypable` / `ItemsValidations`) shared by builders | +| `validations` | Type-aware coercion / shape-check primitives (`CoerceEnum`, `ParseDefault`, `IsLegalForType`) | ### `internal/ifaces/` — cross-package interfaces diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..edb53f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Golden test fixtures must keep LF line endings on every platform +# so byte-level comparison doesn't trip on Windows checkouts that +# default to core.autocrlf=true. +*.json text eol=lf +internal/parsers/grammar/grammar_test/testdata/golden/* text eol=lf +fixtures/integration/golden/* text eol=lf diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 21d4545..b0ed16f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -8,7 +8,10 @@ and extracts API metadata — routes, parameters, responses, schemas, and more `spec.Swagger` document. Supports Go modules (go1.11+). Single module: `github.com/go-openapi/codescan`. Public API is a thin facade at the root; the -implementation lives under `internal/`. +implementation lives under `internal/` and is split into three layers: **scanner** (package / +AST ingestion), **parsers** (annotation grammar + sub-language parsers), and **builders** +(emitting Swagger objects). A thin `ifaces` package glues parsers to builders without direct +coupling. ### Layout @@ -25,12 +28,20 @@ Internal tree: | Package | Role | |---------|------| | `internal/scanner` | Package loading via `golang.org/x/tools/go/packages`, entity discovery, `ScanCtx`, `TypeIndex`, `Options` | -| `internal/parsers` | Comment-block parsing engine: sectioned parser, meta, responses, route params, validations, extensions, YAML body parser, enum extraction, security definitions | -| `internal/builders/spec` | Top-level `Builder` orchestrating the final `*spec.Swagger` | -| `internal/builders/schema` | Go type → Swagger schema conversion (largest builder) | -| `internal/builders/{operations,parameters,responses,routes,items}` | Per-concern builders | -| `internal/builders/resolvers` | `SwaggerSchemaForType` and shared assertion helpers | -| `internal/ifaces` | `SwaggerTypable`, `ValidationBuilder`, `ValueParser`, `Objecter` — decouples parsers from builders | +| `internal/scanner/classify` | Classification predicates shared with builders (e.g. `IsAllowedExtension`) | +| `internal/parsers` | Scanner-level annotation classifiers (`ExtractAnnotation`, `ModelOverride`, …) and route/operation path-annotation parsing | +| `internal/parsers/grammar` | Annotation grammar parser: preprocessor, lexer, recursive-descent parser, typed `Block` family, `Walker` visitor, diagnostics | +| `internal/parsers/yaml` | YAML sub-parser used by the grammar's typed-extensions surface and by operation / meta body unmarshal | +| `internal/parsers/routebody` | Sub-parser for the multi-line body grammar nested under `swagger:route` | +| `internal/parsers/security` | Inline security-requirement line parser shared by routes and meta | +| `internal/builders/spec` | Top-level orchestrator producing the final `*spec.Swagger` | +| `internal/builders/schema` | Go type → Swagger schema conversion (largest builder); supports full Schema and SimpleSchema modes | +| `internal/builders/{operations,parameters,responses,routes}` | Per-annotation builders | +| `internal/builders/common` | `*common.Builder` embedded by every per-decl builder; parsed-block cache, post-decl queue, diagnostic accumulator, `MakeRef` | +| `internal/builders/handlers` | Reusable Walker callback factories (`Number`, `Integer`, `UniqueBool`, `Extension`, parameter/schema dispatch) | +| `internal/builders/validations` | Type-aware coercion (`CoerceEnum`, `ParseDefault`) + shape checks (`IsLegalForType`) | +| `internal/builders/resolvers` | `SwaggerSchemaForType`, identity / assertion helpers, items-chain ifaces adapters | +| `internal/ifaces` | `SwaggerTypable`, `ValidationBuilder`, `OperationValidationBuilder`, `ValueParser`, `Objecter` — decouples parsers from builders | | `internal/logger` | Debug logging (gated on `Options.Debug`) | | `internal/scantest` | Test utilities: golden compare, fixture loading, mocks, classification helpers | | `internal/integration` | Black-box integration tests against `fixtures/integration/golden/*.json` | @@ -47,7 +58,7 @@ Fixtures: - `Run(*Options) (*spec.Swagger, error)` — scan Go packages and produce a Swagger spec. - `Options` — packages, work dir, build tags, include/exclude filters, `ScanModels`, `InputSpec` overlay, plus feature flags (`RefAliases`, `TransparentAliases`, `DescWithRef`, - `SetXNullableForPointers`, `SkipExtensions`, `Debug`). + `SetXNullableForPointers`, `SkipExtensions`, `OnDiagnostic`, `Debug`). ### Dependencies @@ -82,7 +93,8 @@ Coding conventions are found beneath `.github/copilot` (symlinked to `.claude/ru - Tests: `go test ./...`. CI runs on `{ubuntu, macos, windows} x {stable, oldstable}` with `-race`. - Test framework: `github.com/go-openapi/testify/v2` (not `stretchr/testify`; `testifylint` does not work). - Parsers never import builders — write into the interfaces in `internal/ifaces`. When adding a new - annotation, extend the relevant builder's `taggers.go` rather than reaching into parser internals. + annotation keyword, extend the grammar keyword table and wire a matching Walker handler in the + relevant builder rather than reaching across the parser/builder boundary. - Test helpers live in `internal/scantest`; do not widen production API to satisfy a test. See `.github/copilot/` for detailed rules on Go conventions, linting, testing, and contributions. diff --git a/.golangci.yml b/.golangci.yml index d987d15..62950c4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -4,8 +4,11 @@ linters: disable: - depguard - funlen + - goconst # disabled, perhaps temporarily as this linter has become way too pick and noisy - godox + - gomodguard_v2 - exhaustruct + - ireturn # not suited to the design of this repo - nlreturn - nonamedreturns - noinlineerr diff --git a/go.mod b/go.mod index e91176b..1566885 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,9 @@ go 1.25.0 toolchain go1.26.1 require ( - github.com/go-openapi/loads v0.23.3 github.com/go-openapi/spec v0.22.4 github.com/go-openapi/swag/mangling v0.26.0 + github.com/go-openapi/swag/yamlutils v0.26.0 github.com/go-openapi/testify/v2 v2.5.1 go.yaml.in/yaml/v3 v3.0.4 golang.org/x/tools v0.45.0 @@ -16,13 +16,12 @@ require ( require ( github.com/go-openapi/jsonpointer v0.22.5 // indirect github.com/go-openapi/jsonreference v0.21.5 // indirect - github.com/go-openapi/swag/conv v0.25.5 // indirect - github.com/go-openapi/swag/jsonname v0.25.5 // indirect - github.com/go-openapi/swag/jsonutils v0.25.5 // indirect - github.com/go-openapi/swag/loading v0.25.5 // indirect - github.com/go-openapi/swag/stringutils v0.25.5 // indirect - github.com/go-openapi/swag/typeutils v0.25.5 // indirect - github.com/go-openapi/swag/yamlutils v0.25.5 // indirect + github.com/go-openapi/swag/conv v0.26.0 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/go-openapi/swag/jsonutils v0.26.0 // indirect + github.com/go-openapi/swag/loading v0.26.0 // indirect + github.com/go-openapi/swag/stringutils v0.26.0 // indirect + github.com/go-openapi/swag/typeutils v0.26.0 // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect ) diff --git a/go.sum b/go.sum index 7a64be3..b1d78d8 100644 --- a/go.sum +++ b/go.sum @@ -2,30 +2,28 @@ github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+r github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= -github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= -github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= -github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= -github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= -github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= -github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= -github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= -github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= -github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= -github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= +github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= +github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= +github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= +github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= -github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= -github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= -github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= -github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= -github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= -github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.1 h1:NZOrZmIb6PTv5LTFxr5/mKV/FjbUzGE7E6gLz7vFoOQ= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.1/go.mod h1:r7dwsujEHawapMsxA69i+XMGZrQ5tRauhLAjV/sxg3Q= +github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= +github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= +github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= +github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= +github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= +github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= diff --git a/internal/parsers/grammar/README.md b/internal/parsers/grammar/README.md new file mode 100644 index 0000000..3f4d9c6 --- /dev/null +++ b/internal/parsers/grammar/README.md @@ -0,0 +1,1001 @@ +# grammar — maintainer notes + +This document is the long-form companion to the grammar package code. + +The source files keep godoc concise; the full grammar contract, +pipeline rules, keyword tables, body-termination rules, and quirks +live here. + +--- + +## Table of contents + +- [§overview](#overview) — what the package parses and what it emits +- [§pipeline](#pipeline) — Preprocess → Lex → Parse stages +- [§preprocess-contract](#preprocess-contract) — comment-marker stripping rules +- [§lexer-contract](#lexer-contract) — line classifier, body accumulator, prose classifier +- [§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 +- [§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 +- [§property-shape](#property-shape) — Property, TypedValue, IsTyped +- [§walker-contract](#walker-contract) — functional-visitor dispatch table +- [§keyword-table](#keyword-table) — closed-vocabulary keyword classification +- [§context-legality](#context-legality) — per-annotation keyword legality +- [§annotation-args](#annotation-args) — per-annotation argument terminals +- [§typed-extensions](#typed-extensions) — `extensions:` body → typed map +- [§security-requirements](#security-requirements) — typed Requirements parsing +- [§contact-license](#contact-license) — typed Contact / License accessors +- [§diagnostics](#diagnostics) — Diagnostic / Code / Severity model +- [§synthetic-block](#synthetic-block) — sub-parser construction factory +- [§quirks-open](#quirks-open) — deferred follow-ups + +--- + +## §overview — what the package parses + +`grammar` is the annotation parser for codescan. It consumes one +Go comment group (`*ast.CommentGroup`) at a time and produces a +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`); +- `Property` entries for every recognised body keyword (`maximum:`, + `pattern:`, `consumes:`, …) carrying the keyword's lexer-typed + value or raw body bytes; +- prose lines split into `Title()` / `Description()` using + line-shape heuristics; +- diagnostics for malformed inputs (the parser never aborts — it + emits warnings/errors and continues). + +Comment groups without a `swagger:` line surface as an +`UnboundBlock` so the schema builder can still consume the prose +and any field-level annotations. + +The annotation vocabulary is the go-swagger convention: +`swagger:model`, `swagger:response`, `swagger:parameters`, +`swagger:route`, `swagger:operation`, `swagger:meta`, plus the +classifier annotations `swagger:strfmt`, `swagger:alias`, +`swagger:name`, `swagger:allOf`, `swagger:enum`, `swagger:ignore`, +`swagger:default`, `swagger:type`, `swagger:file`. + +`AnnotationPrefix` is the literal `"swagger:"`. It is a constant +rather than configurable today. + +--- + +## §pipeline — Preprocess → Lex → Parse + +``` +*ast.CommentGroup + │ + ▼ + Preprocess → []Line (comment-marker stripping) + │ + ▼ + Lex → []Token (line classifier + body accumulator + prose classifier) + │ + ▼ + Parse → Block (dispatch by annotation family) +``` + +Three stages, each a pure function: + +1. **Preprocess** strips comment markers and normalises line endings + ([§preprocess-contract](#preprocess-contract)). +2. **Lex** runs three sub-stages — line classifier, body accumulator, + prose classifier — producing the terminal token stream + ([§lexer-contract](#lexer-contract)). +3. **Parse** dispatches the stream to a family-specific parser per + the recognised `AnnotationKind` + ([§parser-contract](#parser-contract)). + +The Token vocabulary is defined in `token.go` and matches the +documented terminal alphabet for the four annotation families +(schema / operation / meta / classifier) plus the family-shared +keyword vocabulary. + +--- + +## §preprocess-contract — comment-marker stripping + +`Preprocess` turns a `*ast.CommentGroup` into a position-tagged +`[]Line`. Per line: + +- `Text` has comment markers (`//`, `/*`, `*/`) stripped along with + godoc continuation decoration (leading whitespace, asterisks, + slashes, optional markdown table pipe). This is the surface the + keyword/annotation classifiers consume. +- `Raw` is the same source line with **only** the comment marker + removed — content whitespace, indentation, and list markers + survive. The body accumulator reads `Raw` for YAML / nested-map + indentation fidelity. +- `Pos` points to the first character of `Text` in the source file + so diagnostics can pinpoint the offending line. + +Line endings are normalised before line splitting (`\r\n → \n`, +lone `\r → \n`) so the lexer never sees `\r`. + +The `/* */` block-comment form yields one Line per physical source +line; the godoc continuation decoration (`\s*\*\s?`) is stripped +from each line. + +Leading `-` is preserved on Text so the YAML fence `---` survives +intact. + +--- + +## §lexer-contract — token production + +`Lex` runs three stages: + +1. **Line classifier** (`classifyLines` / `lexLine`). Pure function + on a single line plus an in-fence flag carried between lines. + Emits one preliminary token per line — `TokenBlank`, + `tokenYAMLFence`, `tokenRawLine` (inside an active fence), + `TokenAnnotation`, `tokenKeywordPre`, or `tokenText`. + +2. **Body accumulator** (`accumulateBodies`). State machine over + the line stream, folding multi-line bodies (`OPAQUE_YAML`, + `RAW_BLOCK_*`, `RAW_VALUE_*`) into single body tokens and + finalising inline-value keywords with their typed payload. The + output stream contains only tokens the parser actually consumes; + internal kinds (`tokenYAMLFence`, `tokenText`, `tokenKeywordPre`, + `tokenRawLine`, `tokenDirective`) are stripped here. + +3. **Prose classifier** (`classifyProse`). Re-types surviving + `tokenText` tokens as `TokenTitle` or `TokenDesc` per the + line-shape heuristics in [§prose-classification](#prose-classification). + +The output stream terminates with a single `TokenEOF`. + +### Recognised annotation prefix + +`hasSwaggerPrefix` matches `AnnotationPrefix` with **only the first +character** case-permissive (`[Ss]wagger:`). The rest of the prefix +is verbatim, matching authorial convention. + +### Godoc-prefix exception for `swagger:route` + +A line of the form `swagger:route ` has the +leading `` stripped before annotation lexing. +`matchGodocRoutePrefix` implements the check; only `swagger:route` +is granted this exception (the form is a long-standing godoc +convenience — a function or constant identifier on the same line +as the annotation). Other annotation names do not get the +heuristic. + +### Go directives are dropped + +Lines like `//go:generate`, `//nolint:foo`, `//lint:ignore` are +recognised by `isGoDirective` (lowercase-word + `:` + immediate +non-whitespace argument, no leading whitespace) and dropped from +the prose surface so they never land in TITLE / DESC. The +swagger-prefix check runs first, so `//swagger:model` (legal but +non-idiomatic, no leading space) is not mistaken for a directive. + +### First-character case insensitivity on keywords + +`Consumes:` and `consumes:` both lex as the `consumes` keyword. +Only the leading character is lowercased before `Lookup(name)`; +the rest of the keyword name is matched verbatim. So `Consumes` → +`consumes` matches, but `CONSUMES` does not. + +### `items.` prefix runs + +Repeated `items.` segments before a keyword (e.g. +`items.items.maxLength:`) are stripped and counted; `ItemsDepth` +records the depth on the emitted keyword token. Bare `items:` +(no separator) is not stripped — it is a legitimate keyword on its +own (the items chain head). `stripItemsPrefix` implements the +peel. + +### Trailing-dot elision + +After extracting `Value` (Keyword) or `Args` (Annotation), a +single trailing `.` is stripped. Source preservation lives in +`Raw`. The rule allows authors to write keyword and annotation +lines as English sentences without leaking the period into the +parsed value. + +--- + +## §prose-classification — TITLE / DESC split + +`classifyProse` re-types `tokenText` tokens as `TokenTitle` or +`TokenDesc` using four heuristics applied to the first contiguous +prose run: + +1. **Blank inside the run splits title/desc.** A blank line inside + the run with text after it ends the title and starts the + description. +2. **First line ends with Unicode punctuation** (`\p{Po}`) — first + line is title, the rest is description. +3. **First line is a markdown ATX heading** (`#`+ followed by + whitespace) — strip the marker, first line is title, the rest + is description. +4. **Otherwise the entire run is description**, title empty. + +Later prose runs (after body / keyword tokens) become description +unconditionally. + +Heuristic 1 only fires when there is text after the blank — +trailing blanks are separators between the prose run and the +following non-prose token (annotation / EOF), not an internal +title/desc divide. On a heuristic-1 split, an ATX marker on the +first title line is also stripped so the rendered title doesn't +carry the `#`+ prefix. + +The classification fires regardless of whether the block carries +an annotation — `UnboundBlock`-style comments (struct-field +docstrings) still need title/description so the schema builder +can consume them when the type is reached indirectly (e.g. an +embedded interface in a `swagger:model` parent). + +The state-machine in `classifyTitleDescRun` walks the run once +and re-types text tokens; blanks stay as `TokenBlank` so consumers +can reproduce paragraph structure. + +--- + +## §raw-block-terminators — sibling-terminator rule + +Multi-line keyword bodies (`consumes:`, `produces:`, `security:`, +`responses:`, `parameters:`, `extensions:`, `default:`, +`example:`, `enum:`, …) end at the next **sibling structural +item** or EOF. Blank lines never terminate a body — they are +absorbed verbatim into the body content. + +A "sibling structural item" is any of: + +- another `TokenAnnotation`; +- a `tokenKeywordPre` whose canonical name shares a family + context with the opening keyword + ([§context-legality](#context-legality)); +- a `tokenYAMLFence` outside an extensions body (the extensions + body absorbs decorative fences silently — see + [§yaml-fence-handling](#yaml-fence-handling)). + +The family-overlap rule lives in `isSiblingTerminatorFor` and +`familyOf`: + +- bodies opened under a **meta/route/operation context** keyword + (`consumes`, `produces`, `security`, `securityDefinitions`, + `responses`, `parameters`, `extensions`, `externalDocs`, + `infoExtensions`, `tos`, `schemes`) terminate on any sibling + whose family overlaps with the meta/route/operation set; +- bodies opened under a **schema-context** body keyword + (`default`, `example`, `enum`) terminate on any sibling whose + family overlaps with the schema set. + +A keyword whose name is recognisable but whose family does not +overlap is absorbed as body text — this matches the permissive +shape of nested YAML-like content under e.g. `security:`. + +### Inline-value capture on raw-block heads + +`Consumes: application/json` on a single line carries its value +on the head token (`head.Text`). `collectRawBlock` prepends that +value as the first body line so mixed inline-plus-indented forms +work uniformly. Without this prepend, the post-colon payload +would be silently lost. + +### Per-body indentation handling + +- `extensions:` and `infoExtensions:` bodies are YAML-parsed + downstream (`yaml.TypedExtensions`), so every body line + preserves its original indentation — `collectRawBlock` reads + the `Raw` view (right-trimmed only). +- Flat raw blocks (`consumes:`, `produces:`, `security:`, …) use + the `Text` view (leading whitespace dropped, keyword lines + reformatted via `formatKeywordLine`). + +Both branches converge on the same `bodyText` slice. + +### Single-line raw-value path + +`collectRawValue` has a trivial single-line path: when the head +token carries an inline value, one `TokenRawValueBody` is emitted +immediately with the inline value as the whole body. The +multi-line path is reserved for the block-head case (head with +empty inline value). + +--- + +## §yaml-fence-handling — opaque YAML bodies + +`collectFencedYAML` scans from a `---` opener and emits one +`TokenOpaqueYaml`: + +- the body is joined with `\n` into `Body`; +- `Raw` carries the verbatim content (indentation preserved); +- `Truncated = true` is set when EOF is reached without a closing + `---` — `parser.consumeBodyToken` then emits a + `CodeUnterminatedYAML` error diagnostic. + +Decorative `---` fences inside an `extensions:` body are +**dropped silently** — authors decorate extensions blocks with +fences as a visual separator; the lexer absorbs the fence into +the body and discards the fence markers themselves via +`absorbDecorativeFenceInto`. + +`swagger:route` does not allow `OPAQUE_YAML` bodies — only +`swagger:operation` does. The parser flags an OPAQUE_YAML under +route with `CodeUnexpectedToken`. + +--- + +## §disambiguation — value-shape dispatch + +`disambiguate.go` centralises the value-shape rules so the lexer +emits already-disambiguated typed tokens. The parser never +re-decides. + +### `swagger:default` value + +`classifyDefaultValue` tries `JSON_VALUE` first (full JSON +validation via the stdlib decoder), falling back to `RAW_VALUE`. +A leading quote / bracket / brace / sign / digit is the quick +discriminant; `true` / `false` / `null` are also JSON-valid. + +### `swagger:enum` arguments + +`classifyEnumArgs` implements the four-way dispatch on the +post-name remainder: + +- empty → `enumFormEmpty` (multi-line body may follow); +- leading `[` → `enumFormBracketedOnly` (one `JSON_VALUE` arg, no name); +- leading identifier + no rest → `enumFormNameOnly`; +- leading identifier + leading `[` rest → `enumFormNamePlusBracketed`; +- otherwise → `enumFormPlainOnly` or `enumFormNamePlusPlain`. + +Bracketed lists are emitted as a single `TokenJSONValue`; plain +lists as a single `TokenCommaListValue`; the name (when present) +as a separate `TokenIdentName`. Downstream parsing of the list +items lives in the analyzer. + +### `swagger:type` argument + +`isTypeRef` matches the closed type-reference vocabulary +(`string`, `integer`, `number`, `boolean`, `array`, `object`, +`file`, `null`). Anything else falls back to `TokenIdentName`, +letting the parser diagnose `CodeInvalidTypeRef`. + +### HTTP method recognition + +`classifyHTTPMethod` matches the closed HTTP-method vocabulary +(`GET` / `POST` / `PUT` / `PATCH` / `HEAD` / `DELETE` / +`OPTIONS` / `TRACE`) case-insensitively, emitting the canonical +upper-case form on `TokenHTTPMethod`. + +### URL-path recognition + +`looksLikeURLPath` is a coarse check (leading `/`). Full RFC 3986 +conformance is left to the analyzer. + +--- + +## §parser-contract — Block construction + +`DefaultParser.Parse` consumes a comment group end-to-end: +preprocess → lex → parse. `ParseAll` returns one Block per +annotation in source order; the partition rule splits at each +`TokenAnnotation` index. The first annotation owns the +pre-annotation prose; later annotations partition from one +annotation header to the next. + +`ParseText` and `ParseAs` are entry points for non-CommentGroup +inputs (raw text from sub-parsers like routebody, or synthesised +annotation headers for tests). + +The parser interface is a stable seam (`Parser`) so tests can +substitute mocks; the package ships `*DefaultParser` as the only +production implementation. + +### Dispatch by family + +`parseTokens` finds the first `TokenAnnotation`, looks up its +`AnnotationKind`, and dispatches by family: + +| Family | Annotations | Parser entry | +|---|---|---| +| `familySchema` | `swagger:model`, `swagger:response`, `swagger:parameters`, `swagger:name` | `parseSchemaBlock` | +| `familyOperation` | `swagger:route`, `swagger:operation` | `parseOperationBlock` | +| `familyMeta` | `swagger:meta` | `parseMetaBlock` | +| `familyClassifier` | `swagger:strfmt`, `swagger:alias`, `swagger:allOf`, `swagger:enum`, `swagger:ignore`, `swagger:default`, `swagger:type`, `swagger:file` | `parseClassifierBlock` | +| `familyUnknown` | unrecognised | `parseUnboundBlock` | + +`swagger:name` dispatches through the schema family because its +body accepts the same validation-keyword vocabulary as a schema +field (min length, pattern, required, etc.). Surfacing those body +keywords as Properties — rather than rejecting them as +context-invalid under a classifier block — keeps the field-level +walker uniform. + +### Body-token consumption + +`consumeBodyToken` is the per-token sink shared across families: + +- `TokenKeyword` → typed Property via `emitInlineKeyword` + (validation against the keyword's shape). +- `TokenRawBlockBody` → raw Property via `emitRawBlock`. For + `extensions:` / `infoExtensions:` the body is also fed through + `yaml.TypedExtensions` to populate per-entry typed values + ([§typed-extensions](#typed-extensions)). For `security:` the + body is parsed into typed Requirements + ([§security-requirements](#security-requirements)). +- `TokenRawValueBody` → raw Property via `emitRawValue`. +- `TokenOpaqueYaml` → `RawYAML` entry on the Block; emits + `CodeUnterminatedYAML` if `Truncated`. +- Stray value-only tokens (`TokenIdentName` outside an owning + keyword) emit `CodeUnexpectedToken`. + +### Context-legality warnings + +`contextLegal` reports whether a keyword may legally appear under +the given annotation kind. A mismatch is non-fatal — the parser +emits a `CodeContextInvalid` warning and still records the +property. See [§context-legality](#context-legality). + +### parseState scaffolding + +`parseState.peek` / `parseState.advance` and the `pos` cursor are +scaffolding for future order-sensitive productions (LSP partial +parses, strict positional checks on EnumDeclBlock's annotation +header → RAW_VALUE_ENUM body). Today's family parsers walk +`s.tokens` via range loops because the token classifier already +serialises the body — order between annotation header and body +items is flat. When order-sensitive productions land, the +per-family parsers will switch to peek/advance. + +--- + +## §block-shapes — typed Block kinds + +Every Block implements the `Block` interface (one consumer +contract for builders + LSP): + +- `Pos()`, `Title()`, `Description()`, `Diagnostics()`, + `AnnotationKind()`; +- `Properties()`, `YAMLBlocks()`, `Extensions()`, + `SecurityRequirements()`, `Contact()`, `License()`; +- `Walk(w Walker)` — the functional-visitor surface; +- `ProseLines()`, `PreambleLines()`, `PreambleTitle()`, + `PreambleDescription()`, `Prose()`; +- `Has(name)`, `GetFloat`, `GetInt`, `GetBool`, `GetString`, + `GetList`; +- `AnnotationArg()` — single-word convergence accessor for the + annotation's primary positional argument. + +Typed Block kinds embed `*baseBlock` and add per-annotation +fields: + +| Block | Annotation | Extra fields | +|---|---|---| +| `ModelBlock` | `swagger:model [Name]` | `Name string` | +| `ResponseBlock` | `swagger:response [Name]` | `Name string` | +| `ParametersBlock` | `swagger:parameters T1 T2 …` | `OperationIDs []string` | +| `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` | +| `MetaBlock` | `swagger:meta` | — | +| `ClassifierBlock` | `swagger:strfmt`, `swagger:type`, … | `Args []Token` | +| `EnumDeclBlock` | `swagger:enum [name] [values…]` | `Name string; InlineForm enumArgsForm; InlineArgs []Token; BodyValues string` | +| `UnboundBlock` | no annotation | — | + +### Preamble vs full prose + +`PreambleTitle` / `PreambleDescription` / `PreambleLines` cover +only the prose appearing **before** the block's annotation. +Schema's top-level model builder consumes the preamble so +post-annotation text reads as body content rather than as part of +the title/description. Routes / operations / meta consult the +full `Title()` / `Description()` (whole-block prose). + +### Prose() — single-string description + +`Prose()` returns the entire prose surface (TITLE + DESC tokens +in source order) joined with `\n`, internal blanks preserved as +paragraph breaks, a single trailing blank dropped. Used by +field-level callers (struct-field / interface-method docs) where +the whole prose is the description. + +### AnnotationArg — convergence accessor + +Returns the first single-word positional identifier argument of +the block's primary annotation, or `("", false)` for bare +annotations / multi-word args. Replaces type-asserting on each +typed Block kind to read its `Name` field. Used by Walker +callbacks that don't care which classifier flavour they are +looking at — only what its `IDENT_NAME`-style argument is. + +`ClassifierBlock.AnnotationArg` filters to a single non-empty +word, mirroring the legacy single-word capture: prose lines that +happen to open with `swagger:` followed by a sentence are +rejected at this layer. + +--- + +## §property-shape — Property and TypedValue + +`Property` is one keyword:value (or keyword body) attached to a +Block. Field population varies by shape: + +- **Inline-value keywords** (Number / Integer / Bool / String / + EnumOption / CommaList): `Value` carries the raw string, + `Typed` carries the lexically-typed form. +- **Body keywords** (RawBlock / RawValue): `Body` holds the + accumulated body content (joined with `\n`), `Raw` holds the + verbatim source content (indentation preserved), and + `Typed.Type` is `ShapeRawBlock` / `ShapeRawValue`. + +`ItemsDepth` records the leading `items.*` depth from the keyword +head — `0` for level-0 keywords, `N` for `items.…N` chain depth. + +### TypedValue.Op for comparison-bound numbers + +A NumberValue may carry a leading comparison operator (`<`, `<=`, +`>`, `>=`, `=`); the lexer strips it to `TypedValue.Op` so the +analyzer can decide inclusive vs exclusive semantics (`maximum: +<5` is exclusive max; `maximum: <=5` is inclusive). The Walker +collapses `<` / `>` to an `exclusive bool` on the Number callback. + +### IsTyped — primitive-typed shortcut + +`Property.IsTyped()` returns true when `Typed.Type` is one of the +primitive shapes (Number / Integer / Bool / EnumOption) — i.e. a +case where the matching `Typed.` is populated and +authoritative. Returns false otherwise (raw shapes, comma-list, +string, ShapeNone). Consumers use it as a switch shortcut: + +```go +if p.IsTyped() { + // read p.Typed. matching p.Keyword.Shape +} else { + // coerce p.Value against the resolved schema type +} +``` + +### AsList — unified list extraction + +`Property.AsList()` (also reachable via `Block.GetList(name)`) +unifies every list-shaped surface form: + +``` +Schemes: http, https # inline, comma-separated +Schemes: # multi-line, indented bare + http + https +Schemes: # multi-line, YAML `- ` markers + - http + - https +Schemes: http, https # inline + indented continuation + - ws +``` + +Algorithm: treat `Value` (if non-empty) as one input line, then +each line of `Body`. For each line: trim, drop a leading `- ` +YAML marker if present, re-trim, comma-split, trim each token, +drop empties. Aggregate. + +The helper stops at "simple token lists" — it does **not** handle +enum values (whose elements may be JSON arrays), the `+ name:` +Parameters chunk grammar (routebody-owned), or raw bodies that +need YAML structural parsing (`securityDefinitions`, +`extensions`, `infoExtensions` — those travel through +`yaml.TypedExtensions` / `json.Unmarshal` directly). + +--- + +## §walker-contract — functional-visitor dispatch + +`Walker` is the functional-visitor surface a Block exposes for +bulk dispatch. Consumers wire only the callbacks they care about; +nil callbacks are silent no-ops. + +### Dispatch order + +1. Block-level diagnostics fire first (before Title) so consumers + see them regardless of which property callbacks they wired. +2. `Title` fires once if non-empty. +3. `Description` fires once if non-empty. +4. Properties fire in source order — one callback per Property + selected by `Keyword.Shape`: + + | Keyword.Shape | Callback | Payload | + |-----------------|------------|------------------------------------------| + | `ShapeNumber` | `Number` | `(p, p.Typed.Number, exclusive)` | + | `ShapeInt` | `Integer` | `(p, p.Typed.Integer)` | + | `ShapeBool` | `Bool` | `(p, p.Typed.Boolean)` | + | `ShapeString` | `String` | `(p, p.Value)` | + | `ShapeEnumOption` | `String` | `(p, p.Typed.String)` | + | `ShapeRawBlock` | `Raw` | `(p)` — caller reads `p.Body` / `p.Raw` | + | `ShapeRawValue` | `Raw` | `(p)` | + | `ShapeCommaList` | `Raw` | `(p)` — caller splits via `b.GetList` | + | `ShapeNone` | `Raw` | `(p)` — fallback | + + An **unknown** keyword (Property.Keyword.Name empty) fires the + `Unknown` callback instead. + +5. Extensions fire in source order, one callback per Extension entry. + +### Iteration scope + +Walker walks one Block per call; ordering across blocks (multiple +declarations, file order, discovery order) is the builder's +concern, not the walker's. + +### Shape-based dispatch, not Typed.Type + +When the lexer rejects an invalid value (e.g. `maximum: +notanumber`) the parser leaves `Typed.Type` at `ShapeNone` and +emits a `CodeInvalidNumber` diagnostic. Walker still dispatches +based on `Keyword.Shape` — `Number/Integer/Bool` callbacks fire +with the zero value of the payload. Consumers treat the +`Diagnostic` callback as authoritative for malformed values +rather than re-validating. + +### FilterDepth — items-chain gating + +`FilterDepth` gates property callbacks (Number / Integer / Bool / +String / Raw / Unknown). Title / Description / Extension / +Diagnostic are unaffected. + +- `AllDepths` (`-1`) admits every depth — use this explicitly for + "fire every property" rather than `-1` so the intent reads at + the call site. +- `0` admits level-0 only — the schema-side default. +- `N` admits depth N only — used by items-chain walkers. + +**Zero-value gotcha:** the Go zero value of `FilterDepth` is `0`, +which means "level-0 only". Items callers must explicitly set +`FilterDepth` to the wanted depth; they cannot leave it at the +zero value. Schema-side level-0 walkers can leave it at zero by +accident-and-design. + +### Concurrency + +`Walk` reads only from the Block — 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. + +--- + +## §keyword-table — closed-vocabulary keywords + +`keywords.go` defines the authoritative keyword table. Each entry +declares a canonical name, optional aliases, a `ValueShape`, and +the family contexts where it is legal +([§context-legality](#context-legality)). + +`Kw*` constants (`KwMaximum`, `KwSchemes`, …) 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 the constants rather than +re-declaring the strings — the schema walker and the bridge +dispatchers in routes / parameters / responses / operations / +items / spec all dispatch on these names. + +### ValueShape vocabulary + +| Shape | Terminal | Notes | +|---|---|---| +| `ShapeNumber` | NUMBER_VALUE | signed decimal, optional leading comparison operator | +| `ShapeInt` | INT_VALUE | unsigned decimal integer | +| `ShapeBool` | BOOL_VALUE | `true` / `false` (case-insensitive) | +| `ShapeString` | STRING_VALUE | verbatim non-LF text | +| `ShapeCommaList` | COMMA_LIST_VALUE | comma-separated list of strings (trim-stripped) | +| `ShapeEnumOption` | ENUM_OPTION_VALUE | closed-vocab choice (Values lists the allowed set) | +| `ShapeRawBlock` | RAW_BLOCK_\ | multi-line body terminal — caller reads Body/Raw | +| `ShapeRawValue` | RAW_VALUE_\ | multi-line OR single-line body terminal | +| `ShapeNone` | — | no value shape (rare; ShapeNone keywords reach Walker's Raw callback) | + +`ValueShape.IsBody()` reports whether the shape is a multi-line +body terminal (RawBlock or RawValue) — the lexer's body +accumulator triggers on body shapes. + +### Lookup + +`Lookup(name)` matches the canonical name or any alias, +case-insensitively. Aliases cover common variants (`max length`, +`max-length`, `maxLen`, `maximum length`, … all match +`KwMaxLength`). The lexer applies first-character case +folding before lookup; alias matching is fully case-insensitive. + +`Keywords()` returns a defensive copy of the authoritative table +for tooling that needs to enumerate it. + +### Multi-line raw-block keywords + +`KwConsumes`, `KwProduces`, `KwSecurity`, `KwSecurityDefinitions`, +`KwResponses`, `KwParameters`, `KwExtensions`, `KwInfoExtensions`, +`KwTOS`, `KwExternalDocs` are all `ShapeRawBlock`. Their bodies +travel through the lexer's body accumulator and surface on the +Block as raw Properties; downstream sub-parsers (yaml, +routebody, security) consume the body content. + +### `in:` is a parameter-location directive + +`KwIn` is declared as `ShapeEnumOption("query", "path", "header", +"body", "formData")` in `CtxParam`. It is not part of the formal +schema-body grammar; the keyword table recognises it 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. + +### `Schemes:` accepts both inline and multi-line + +`KwSchemes` uses `ShapeRawBlock` 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` ([§raw-block-terminators](#raw-block-terminators)). +`Block.GetList` unifies both surfaces. + +--- + +## §context-legality — per-annotation keyword legality + +`KeywordContext` enumerates the family-level contexts where a +keyword may appear: `CtxParam`, `CtxHeader`, `CtxSchema`, +`CtxItems`, `CtxRoute`, `CtxOperation`, `CtxMeta`, `CtxResponse`. +Each `Keyword.Contexts` lists the contexts the keyword is legal in. + +`parser.allowedContexts(kind)` maps each `AnnotationKind` to the +context set legal under it: + +| AnnotationKind | Allowed contexts | +|---|---| +| `AnnModel` | `CtxSchema`, `CtxItems` | +| `AnnParameters` | `CtxParam`, `CtxSchema`, `CtxItems` | +| `AnnResponse` | `CtxResponse`, `CtxSchema`, `CtxHeader`, `CtxItems` | +| `AnnOperation` | `CtxOperation`, `CtxParam`, `CtxSchema`, `CtxHeader`, `CtxItems`, `CtxResponse` | +| `AnnRoute` | `CtxRoute`, `CtxParam`, `CtxSchema`, `CtxHeader`, `CtxItems`, `CtxResponse` | +| `AnnMeta` | `CtxMeta`, `CtxSchema` | +| Classifier kinds & `AnnUnknown` | nil (no parser-layer policy) | + +`contextLegal(kw, kind)` returns true when the keyword's contexts +overlap with the kind's allowed contexts. A missing overlap is a +`CodeContextInvalid` warning — the property is still recorded so +the builder can decide policy. + +--- + +## §annotation-args — argument terminals + +Per-annotation argument shapes are classified by +`classifyAnnotationArgs` and emitted as typed `Token`s on +`TokenAnnotation.Args`: + +| Kind | Argument tokens | +|---|---| +| `AnnRoute`, `AnnOperation` | `TokenHTTPMethod` + `TokenURLPath` + `TokenIdentName`* (tags + trailing OpID) | +| `AnnDefaultName` | one `TokenJSONValue` or `TokenRawValue` per `classifyDefaultValue` | +| `AnnType` | one `TokenTypeRef` (or fallback `TokenIdentName`) per `isTypeRef` | +| `AnnEnum` | per `classifyEnumArgs` — `TokenIdentName` (name) + `TokenJSONValue` / `TokenCommaListValue` (values), in source order | +| `AnnParameters` | `TokenIdentName`* (operation IDs) | +| `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 | + +### Operation arg extraction + +`parseOperationArgs` extracts `METHOD`, `/path`, `[tags…]`, +`OperationID`. The trailing `TokenIdentName` is the OpID; any +preceding `TokenIdentName`s are tags. Missing or invalid pieces +emit `CodeMalformedOperation`. + +### Schema-family arg validation + +- `AnnParameters` requires at least one IDENT_NAME (operation id) + — empty emits `CodeMissingRequiredArg`. +- `AnnName` requires a single IDENT_NAME — empty emits + `CodeMissingRequiredArg`. + +### Classifier-family arg validation + +- `AnnStrfmt` requires a name; empty emits `CodeMissingRequiredArg`. +- `AnnDefaultName` requires a value; missing emits `CodeMissingRequiredArg`. +- `AnnType` requires a `TokenTypeRef`; a non-closed-vocab value + emits `CodeInvalidTypeRef`. +- `AnnEnum` requires a name and/or value list and/or a body; + empty across all three emits `CodeMissingRequiredArg`. +- `AnnAllOf`, `AnnIgnore`, `AnnAlias`, `AnnFile` accept optional / + no args. + +--- + +## §typed-extensions — `extensions:` body → typed map + +`collectExtensionsFromBody` parses the body of an `extensions:` +or `infoExtensions:` raw block through `yaml.TypedExtensions` and +registers one `Extension` per top-level `x-*` entry, carrying its +YAML-typed value (`bool` / `float64` / `string` / `[]any` / +`map[string]any`). + +`Extension.Source` carries the keyword that produced the entry: +`KwExtensions` (top-level vendor extensions) vs +`KwInfoExtensions` (Info-scoped, meta-only). Consumers that 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. + +### Drop policy + +- Non-`x-*` keys are dropped with a `CodeInvalidAnnotation` + warning, so authors who typo a vendor-extension key (e.g. + `invalid-key:` under `Extensions:`) get a signal rather than + silent loss. +- A YAML parse failure emits a `CodeInvalidYAMLExtensions` + warning and the block is skipped (no Extension entries + registered). + +### Position fidelity + +Every Extension currently shares the `extensions:` keyword's +position. Per-entry positions require `*yaml.Node` walking and +can be added when LSP-grade diagnostics need them — see the YAML +sub-parser package. + +### `isExtensionName` + +A well-formed extension name starts with `x-` or `X-`, length +≥ 3. The check is local to this package; the JSON encoder layer +applies its own validation. + +--- + +## §security-requirements — typed Requirements + +A `security:` raw block in a meta / route / operation context is +parsed at lex time into a `[]security.Requirement` and made +available via `Block.SecurityRequirements()`. Each entry is a +single-key map from scheme name → scope list, mirroring the shape +OAS v2 expects on `spec.Operation.Security`. + +`parser.emitRawBlock` calls `security.Parse(body)` when the +keyword name is `KwSecurity`. Returns `nil` when no `security:` +keyword appeared on the block. + +The companion accessor — `Block.Contact()` / `Block.License()` — +exposes the typed shapes parsed from inline `contact:` / +`license:` values ([§contact-license](#contact-license)). + +--- + +## §contact-license — typed Contact / License + +`Contact` is the typed shape of a `contact:` inline value on a +swagger:meta block: + +``` +contact: +``` + +Each part is optional in the order written: `parseContact` +recognises a `Name ` head (Go's `net/mail.ParseAddress` +form) followed by an optional URL. A bare email without a name is +accepted. Empty / unrecognised input returns `(Contact{}, nil)`. +A malformed `Name ` head returns `(Contact{}, err)` — the +caller decides whether to fail or warn. + +`License` is the typed shape of a `license:` inline value: + +``` +license: +``` + +`parseLicense` splits on the URL prefix; Name may be empty when +the line starts with the URL. Empty input returns +`(License{}, false)`. + +`splitURL` recognises the leading URL prefix from a closed set: +`https://`, `http://`, `ftps://`, `ftp://`, `wss://`, `ws://`. + +--- + +## §diagnostics — Code / Severity model + +`Diagnostic` is one observation about a comment block: + +- `Pos` — source position; +- `Severity` — `SeverityError`, `SeverityWarning`, or + `SeverityHint`; +- `Code` — stable identifier (`parse.invalid-number`, + `validate.shape-mismatch`, …); +- `Message` — human-readable text. + +`Errorf` / `Warnf` / `Hintf` are convenience constructors. +`Diagnostic.String()` renders compiler-style one-line form. + +### Code prefixes + +- `parse.*` — lexer / parser-level observations emitted by the + grammar package itself. +- `validate.*` — semantic-validation observations emitted by the + builder layer (typically through `internal/builders/validations`). + +### Parser never aborts + +The parser emits diagnostics and continues. Callers (analyzers, +LSP, the CLI) decide policy by severity. The parser layer never +returns an error to the consumer; diagnostics are observable on +the returned Block (`Block.Diagnostics()`) and via the +diagnostic-sink option (`WithDiagnosticSink`) for streaming. + +### Defined codes + +| Code | Description | +|---|---| +| `CodeInvalidNumber` | malformed number value | +| `CodeInvalidInteger` | malformed integer value | +| `CodeInvalidBoolean` | not `true`/`false` | +| `CodeInvalidEnumOption` | not in the closed set | +| `CodeContextInvalid` | keyword not legal under the current annotation | +| `CodeInvalidExtension` | malformed extension name | +| `CodeInvalidYAMLExtensions` | YAML parse failure on extensions body | +| `CodeUnterminatedYAML` | `---` opened, not closed | +| `CodeInvalidAnnotation` | malformed annotation surface | +| `CodeInvalidTypeRef` | not in the closed type-reference vocab | +| `CodeUnexpectedToken` | stray token at body level | +| `CodeMalformedOperation` | missing/invalid HTTP method / path / OpID | +| `CodeMissingRequiredArg` | annotation requires an argument | +| `CodeShapeMismatch` | builder-layer keyword vs schema-type mismatch | +| `CodeAmbiguousEmbed` | builder-layer embed disambiguation diagnostic | +| `CodeUnsupportedInSimpleSchema` | builder-layer SimpleSchema-exit violation | + +--- + +## §synthetic-block — sub-parser construction + +`NewSyntheticBlock(pos, title, description, props)` 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. + +`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. + +--- + +## §quirks-open — deferred follow-ups + +### Body-shape choices retained as-is + +`Body` is a single string with embedded `\n`; `Raw` carries +verbatim source indentation. Consumers that prefer a `[]string` +shape call `strings.Split(body, "\n")` themselves. Switching to +`[]string` at the Property level would force every consumer to +re-join; the single-string form pays the cost where it is +needed. + +### Position fidelity on multi-entry bodies + +Extensions and security requirements share the parent keyword's +position. Per-entry positions require walking the `*yaml.Node` +tree from the YAML parser; LSP-grade diagnostics may want this +in a later pass. + +### Closed-vocab annotation prefix + +`AnnotationPrefix` is fixed at `"swagger:"`. A configurable +prefix would interact with the first-character case-insensitive +fallback (which is tied to ASCII letter casing). A non-letter +prefix character would not need the fallback. No current +consumer asks for this; the constant is the minimal scaffolding +for a future Option promotion. From 21148c1630ef817c5ba1b305d232fad661518417 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Wed, 3 Jun 2026 00:14:45 +0200 Subject: [PATCH 20/22] refactor(parameters,responses): replace deprecated FindModel with GetModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical migration of the eight remaining FindModel call sites in the parameters and responses builders to the explicit pair GetModel (pure read) + AppendPostDecl (queueing for the orchestrator's discovery loop). FindModel's implicit registration in ExtraModels — which fires on the FindDecl-fallback path — surprises readers and would pull stdlib types like time.Time / json.RawMessage into top-level definitions when they should be inlined where referenced. No golden impact: the current fixture corpus reaches these paths only through schema.applyStdlibSpecials, which short-circuits stdlib types before any model lookup. The change closes the hole defensively rather than as a regression fix. Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- internal/builders/parameters/parameters.go | 13 ++++++------- internal/builders/responses/responses.go | 5 ++--- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/internal/builders/parameters/parameters.go b/internal/builders/parameters/parameters.go index b345334..9925eb4 100644 --- a/internal/builders/parameters/parameters.go +++ b/internal/builders/parameters/parameters.go @@ -114,7 +114,7 @@ func (p *Builder) buildAlias(tpe *types.Alias, op *oaispec.Operation, seen map[s return p.buildFromType(rhs, op, seen) } - decl, ok := p.Ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, ok := p.Ctx.GetModel(o.Pkg().Path(), o.Name()) if !ok { return fmt.Errorf("can't find source file for aliased type: %v -> %v: %w", tpe, rhs, ErrParameters) } @@ -127,7 +127,7 @@ func (p *Builder) buildAlias(tpe *types.Alias, op *oaispec.Operation, seen map[s if o.Pkg() == nil { break // builtin } - decl, found := p.Ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, found := p.Ctx.GetModel(o.Pkg().Path(), o.Name()) if !found { return fmt.Errorf("can't find source file for target type of alias: %v -> %v: %w", tpe, rtpe, ErrParameters) } @@ -137,7 +137,7 @@ func (p *Builder) buildAlias(tpe *types.Alias, op *oaispec.Operation, seen map[s if o.Pkg() == nil { break // builtin } - decl, found := p.Ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, found := p.Ctx.GetModel(o.Pkg().Path(), o.Name()) if !found { return fmt.Errorf("can't find source file for target type of alias: %v -> %v: %w", tpe, rtpe, ErrParameters) } @@ -297,7 +297,7 @@ func (p *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl return nil } - decl, ok := p.Ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, ok := p.Ctx.GetModel(o.Pkg().Path(), o.Name()) if !ok { return fmt.Errorf("can't find source file for aliased type: %v -> %v: %w", tpe, rhs, ErrParameters) } @@ -318,7 +318,7 @@ func (p *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl break // builtin } - decl, found := p.Ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, found := p.Ctx.GetModel(o.Pkg().Path(), o.Name()) if !found { return fmt.Errorf("can't find source file for target type of alias: %v -> %v: %w", tpe, rtpe, ErrParameters) } @@ -330,7 +330,7 @@ func (p *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl break // builtin } - decl, found := p.Ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, found := p.Ctx.GetModel(o.Pkg().Path(), o.Name()) if !found { return fmt.Errorf("can't find source file for target type of alias: %v -> %v: %w", tpe, rtpe, ErrParameters) } @@ -452,4 +452,3 @@ func (p *Builder) processParamField(fld *types.Var, decl *scanner.EntityDecl, se seen[name] = ps return name, nil } - diff --git a/internal/builders/responses/responses.go b/internal/builders/responses/responses.go index 56d113e..d40961a 100644 --- a/internal/builders/responses/responses.go +++ b/internal/builders/responses/responses.go @@ -216,7 +216,7 @@ func (r *Builder) buildAlias(tpe *types.Alias, resp *oaispec.Response, seen map[ return r.buildFromType(rhs, resp, seen) } - decl, ok := r.Ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, ok := r.Ctx.GetModel(o.Pkg().Path(), o.Name()) if !ok { return fmt.Errorf("can't find source file for aliased type: %v -> %v: %w", tpe, rhs, ErrResponses) } @@ -310,7 +310,7 @@ func (r *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl return r.buildFromField(fld, unaliased, typable, seen) } - decl, ok := r.Ctx.FindModel(o.Pkg().Path(), o.Name()) + decl, ok := r.Ctx.GetModel(o.Pkg().Path(), o.Name()) if !ok { return fmt.Errorf("can't find source file for aliased type: %v: %w", tpe, ErrResponses) } @@ -437,4 +437,3 @@ func (r *Builder) processResponseField(fld *types.Var, decl *scanner.EntityDecl, return nil } - From 996f3366b407982598d6f4d088da3561e0ff7cd3 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Wed, 3 Jun 2026 00:26:14 +0200 Subject: [PATCH 21/22] =?UTF-8?q?test(integration):=20witness=20fixture=20?= =?UTF-8?q?for=20FindModel=E2=86=92GetModel=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an alias-to-unannotated-target fixture exercised under RefAliases=true. The body of WitnessParams and WitnessResponse is a Go alias whose RHS is an unannotated struct — exactly the shape that, under the old FindModel, would have triggered the implicit ExtraModels registration on the FindDecl-fallback path. Verified A/B against 21148c1^: the golden is byte-identical under the deprecated FindModel and the explicit GetModel + AppendPostDecl pair. PlainTarget reaches spec.definitions through the orchestrator's discovery loop in both cases. The fixture locks the equivalence and serves as institutional memory. Co-Authored-By: Claude Signed-off-by: Frederic BIDON --- .../alias-findmodel-witness/api.go | 53 +++++++++++++++++++ .../enhancements_alias_findmodel_witness.json | 38 +++++++++++++ .../coverage_alias_findmodel_witness_test.go | 38 +++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 fixtures/enhancements/alias-findmodel-witness/api.go create mode 100644 fixtures/integration/golden/enhancements_alias_findmodel_witness.json create mode 100644 internal/integration/coverage_alias_findmodel_witness_test.go diff --git a/fixtures/enhancements/alias-findmodel-witness/api.go b/fixtures/enhancements/alias-findmodel-witness/api.go new file mode 100644 index 0000000..f7a393e --- /dev/null +++ b/fixtures/enhancements/alias-findmodel-witness/api.go @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package alias_findmodel_witness exercises the parameters and +// responses builders' GetModel calls on alias targets that are +// not annotated with swagger:model — the FindDecl-fallback path. +// +// Pre-migration FindModel registered such targets in ExtraModels +// as an implicit side effect of the lookup. The explicit GetModel +// + AppendPostDecl pair leaves the registration to the spec +// orchestrator's discovery loop, which visits the queued decl and +// produces the same top-level definition. +// +// The golden captured here locks the two paths to identical +// output, witnessing the safety of the FindModel → GetModel +// migration on the parameters and responses builders. +package alias_findmodel_witness + +// PlainTarget is a user struct with no swagger:model annotation. +// It must end up in spec.definitions only via the orchestrator's +// discovery of the alias's RHS — not via any implicit lookup side +// effect at scan time. +type PlainTarget struct { + // required: true + ID int64 `json:"id"` + + Note string `json:"note"` +} + +// AliasOfPlain is an alias pointing at the unannotated target. +type AliasOfPlain = PlainTarget + +// WitnessParams has a body parameter whose Go type is an alias +// of an unannotated struct. Triggers buildFieldAlias on the Body +// field; under RefAliases the GetModel(RHS) lookup at the Named +// switch arm is the relevant call. +// +// swagger:parameters witnessRequest +type WitnessParams struct { + // in: body + // required: true + Body AliasOfPlain `json:"body"` +} + +// WitnessResponse has a response body whose Go type is the same +// alias — mirror witness on the response builder's +// buildFieldAlias path. +// +// swagger:response witnessResponse +type WitnessResponse struct { + // in: body + Body AliasOfPlain `json:"body"` +} diff --git a/fixtures/integration/golden/enhancements_alias_findmodel_witness.json b/fixtures/integration/golden/enhancements_alias_findmodel_witness.json new file mode 100644 index 0000000..1d713aa --- /dev/null +++ b/fixtures/integration/golden/enhancements_alias_findmodel_witness.json @@ -0,0 +1,38 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "AliasOfPlain": { + "title": "AliasOfPlain is an alias pointing at the unannotated target.", + "$ref": "#/definitions/PlainTarget" + }, + "PlainTarget": { + "description": "It must end up in spec.definitions only via the orchestrator's\ndiscovery of the alias's RHS — not via any implicit lookup side\neffect at scan time.", + "type": "object", + "title": "PlainTarget is a user struct with no swagger:model annotation.", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "note": { + "type": "string", + "x-go-name": "Note" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/alias-findmodel-witness" + } + }, + "responses": { + "witnessResponse": { + "description": "WitnessResponse has a response body whose Go type is the same\nalias — mirror witness on the response builder's\nbuildFieldAlias path.", + "schema": { + "$ref": "#/definitions/AliasOfPlain" + } + } + } +} \ No newline at end of file diff --git a/internal/integration/coverage_alias_findmodel_witness_test.go b/internal/integration/coverage_alias_findmodel_witness_test.go new file mode 100644 index 0000000..8aecff6 --- /dev/null +++ b/internal/integration/coverage_alias_findmodel_witness_test.go @@ -0,0 +1,38 @@ +// 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_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. +// Both paths produce identical output; the golden is the witness. +func TestCoverage_AliasFindModelWitness(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/alias-findmodel-witness/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + RefAliases: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + assert.Contains(t, doc.Definitions, "PlainTarget", + "unannotated alias target must reach definitions via the orchestrator's discovery path") + + scantest.CompareOrDumpJSON(t, doc, "enhancements_alias_findmodel_witness.json") +} From b19aeca3751b00ef106b3a7da25ee63bf206bf04 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Wed, 3 Jun 2026 00:29:12 +0200 Subject: [PATCH 22/22] chore: fixed linting (fixtures excluded from auto-fix by formatter Signed-off-by: Frederic BIDON --- .golangci.yml | 2 ++ internal/builders/routes/routes.go | 8 ++++---- internal/builders/schema/typable.go | 1 - internal/builders/spec/walker.go | 2 -- internal/parsers/grammar/keywords.go | 13 ++++++------- internal/parsers/regexprs.go | 2 +- internal/parsers/regexprs_test.go | 8 +++++--- internal/parsers/routebody/parameters.go | 16 +++++++--------- internal/parsers/routebody/responses.go | 14 +++++++------- 9 files changed, 32 insertions(+), 34 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 62950c4..97cdebf 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -6,6 +6,7 @@ linters: - funlen - goconst # disabled, perhaps temporarily as this linter has become way too pick and noisy - godox + - gomodguard - gomodguard_v2 - exhaustruct - ireturn # not suited to the design of this repo @@ -58,6 +59,7 @@ formatters: exclusions: generated: lax paths: + - fixtures - third_party$ - builtin$ - examples$ diff --git a/internal/builders/routes/routes.go b/internal/builders/routes/routes.go index 626b4b2..e4967a1 100644 --- a/internal/builders/routes/routes.go +++ b/internal/builders/routes/routes.go @@ -21,10 +21,10 @@ import ( type Builder struct { *common.Builder - route parsers.ParsedPathContent - responses map[string]oaispec.Response - operations map[string]*oaispec.Operation - parameters []*oaispec.Parameter + route parsers.ParsedPathContent + responses map[string]oaispec.Response + operations map[string]*oaispec.Operation + // parameters []*oaispec.Parameter // shared parameters - unsupported for now definitions map[string]oaispec.Schema } diff --git a/internal/builders/schema/typable.go b/internal/builders/schema/typable.go index 623d140..7914745 100644 --- a/internal/builders/schema/typable.go +++ b/internal/builders/schema/typable.go @@ -98,4 +98,3 @@ func BodyTypable(in string, schema *oaispec.Schema, skipExt bool) (ifaces.Swagge return &Typable{schema.Items.Schema, 1, skipExt}, schema } - diff --git a/internal/builders/spec/walker.go b/internal/builders/spec/walker.go index bf17105..e207625 100644 --- a/internal/builders/spec/walker.go +++ b/internal/builders/spec/walker.go @@ -185,5 +185,3 @@ func stripPackagePrefix(s string) string { } return strings.TrimLeft(rest[idx:], " \t") } - - diff --git a/internal/parsers/grammar/keywords.go b/internal/parsers/grammar/keywords.go index 606dd8b..b063e3a 100644 --- a/internal/parsers/grammar/keywords.go +++ b/internal/parsers/grammar/keywords.go @@ -132,13 +132,12 @@ func aka(names ...string) keywordOpt { return func(kw *Keyword) { kw.Aliases = append(kw.Aliases, names...) } } -func asNumber() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeNumber } } -func asInt() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeInt } } -func asBool() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeBool } } -func asString() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeString } } -func asCommaList() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeCommaList } } -func asRawBlock() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeRawBlock } } -func asRawValue() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeRawValue } } +func asNumber() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeNumber } } +func asInt() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeInt } } +func asBool() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeBool } } +func asString() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeString } } +func asRawBlock() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeRawBlock } } +func asRawValue() keywordOpt { return func(kw *Keyword) { kw.Shape = ShapeRawValue } } func asEnumOption(values ...string) keywordOpt { return func(kw *Keyword) { diff --git a/internal/parsers/regexprs.go b/internal/parsers/regexprs.go index 20dad76..64b32c6 100644 --- a/internal/parsers/regexprs.go +++ b/internal/parsers/regexprs.go @@ -35,7 +35,7 @@ const ( rxOpID = "((?:\\p{L}[\\p{L}\\p{N}\\p{Pd}\\p{Pc}]+)+)" ) -//nolint:gochecknoglobals // compile-once regexes; read-only. +// compile-once regexes; read-only. var ( // rxSwaggerAnnotation matches `swagger:` anywhere on a comment // line where it is preceded by whitespace, `/`, or start-of-line. diff --git a/internal/parsers/regexprs_test.go b/internal/parsers/regexprs_test.go index 92b882f..2627378 100644 --- a/internal/parsers/regexprs_test.go +++ b/internal/parsers/regexprs_test.go @@ -48,12 +48,14 @@ func TestSchemaValueExtractors(t *testing.T) { "swagger:parameters ", } - validParams := []string{ + const numValid = 4 + 3 // +3 extra space + validParams := make([]string, 0, numValid) + validParams = append(validParams, "yada123", "date", "date-time", "long-combo-1-with-combo-2-and-a-3rd-one-too", - } + ) invalidParams := []string{ "1-yada-3", "1-2-3", @@ -98,7 +100,7 @@ func verifySwaggerMultiArgSwaggerTag(t *testing.T, matcher *regexp.Regexp, prefi for i := range validParams { vp = vp[:0] for j := range i + 1 { - vp = append(vp, validParams[j]) //nolint:gosec // G602: j is bounded by i+1 which is bounded by len(validParams) + 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, " ")) diff --git a/internal/parsers/routebody/parameters.go b/internal/parsers/routebody/parameters.go index 4e4e8c9..1d126c0 100644 --- a/internal/parsers/routebody/parameters.go +++ b/internal/parsers/routebody/parameters.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "go/token" + "slices" "strconv" "strings" @@ -138,12 +139,12 @@ func isChunkSigil(line string) bool { // Returns (key, value, true) when both halves are non-empty after // trimming; (_, _, false) otherwise. func splitKeyValue(line string) (key, value string, ok bool) { - idx := strings.Index(line, ":") - if idx < 0 { + before, after, ok := strings.Cut(line, ":") + if !ok { return "", "", false } - key = strings.TrimSpace(line[:idx]) - value = strings.TrimSpace(line[idx+1:]) + key = strings.TrimSpace(before) + value = strings.TrimSpace(after) if key == "" { return "", "", false } @@ -278,11 +279,8 @@ func buildProperty(kw grammar.Keyword, raw string, pos token.Position) (grammar. // write while the handler-side string fallback recovers the // raw value where supported (handlers.CollectionFormatString // reads pr.Value when Typed is empty). - for _, allowed := range kw.Values { - if raw == allowed { - p.Typed = grammar.TypedValue{Type: grammar.ShapeEnumOption, String: raw} - break - } + 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: diff --git a/internal/parsers/routebody/responses.go b/internal/parsers/routebody/responses.go index 8fc30df..e823718 100644 --- a/internal/parsers/routebody/responses.go +++ b/internal/parsers/routebody/responses.go @@ -55,21 +55,21 @@ func ParseResponses(body string, basePos token.Position, diag func(grammar.Diagn } pos := offsetPos(basePos, lineNo) - idx := strings.Index(line, ":") - if idx < 0 { + before, after, ok0 := strings.Cut(line, ":") + if !ok0 { emitDiagf(diag, pos, "response line %q has no `:` separator", line) continue } - code := strings.TrimSpace(line[:idx]) + code := strings.TrimSpace(before) if code == "" { emitDiagf(diag, pos, "response line missing status code before `:`") continue } - value := strings.TrimSpace(line[idx+1:]) + value := strings.TrimSpace(after) decl, ok := parseResponseValue(code, value, pos, diag) if !ok { continue @@ -168,11 +168,11 @@ func parseResponseValue(code, value string, pos token.Position, diag func(gramma // otherwise. The split takes only the FIRST colon — anything // after it is the value. func splitTagToken(tok string) (tag, value string, ok bool) { - idx := strings.Index(tok, ":") - if idx < 0 { + before, after, ok := strings.Cut(tok, ":") + if !ok { return "", "", false } - return tok[:idx], tok[idx+1:], true + return before, after, true } // stripArrayPrefixes counts leading `[]` prefixes on a body/response