diff --git a/.claude/rules/technical-writing.md b/.claude/rules/technical-writing.md new file mode 100644 index 0000000..3f0ae36 --- /dev/null +++ b/.claude/rules/technical-writing.md @@ -0,0 +1,67 @@ +--- +paths: + - "**/*.go" + - "**/*.md" +--- + +# Technical writing style (go-openapi) + +Applies to every committed comment, commit message, README and doc-site page. + +The standard is Ernest Gowers, *Plain Words*: **be short, be simple, be human.** +His worked example is the whole rule: + + DON'T Was this the realisation of an anticipated liability? + DO Did you expect to have to do this? + +The abstract nouns carry no information; the concrete verb carries all of it. + +## Two tests + +**The grep test.** Does the sentence contain something a reader can search for — an +identifier, a file, a flag, an error, a number with a unit? Prose that names nothing has +described the code without pointing at it. + +**The quotability test.** A sentence that would survive being quoted on its own is too +pleased with itself. Rewrite it until it merely sounds true. + +## Never define by inversion + +The worst and most frequent fault. A copula whose subject or predicate is a wh-clause +promises a definition and delivers a metaphor. Both directions are banned: + + DON'T Coverage is what says which templates a suite never reaches. + DON'T What is lost is the doc comment. + DO Coverage records which templates the suite never executed. + DO A synthesized type loses its doc comment. + +The rewrite is mechanical: find the verb hiding inside the wh-clause and make it the main +verb of the sentence. + +`which is why` pointing back at a fact just stated is legitimate, and rationed — one per +comment is plenty. + +## The rest + +- **Name the thing.** `WithRoots`, not "the option that scopes a repository". Name the + error, the file, the flag, the upstream package, the constant. +- **Statement, not aphorism.** State mechanism and effect. Never close a paragraph on a + maxim: the reflex lands hardest on a closing sentence. +- **Keep a subject.** "New returns an error if the source is unreadable", not "What a + source leaves out is settled where it is declared". +- **Plain verbs.** add, fix, return, parse, reject, cap, prune, record. Code does not say, + judge, grant, refuse, know, mean to, or reach for. `report` is fine when something + genuinely reports. +- **Keep the numbers.** Sizes with units, counts, ratios, advisory ids. `286 -> 178 KiB`, + `GHSA-v2xp-g8xf-22pf`. Dropping them for a smoother sentence loses information. +- **Be human.** Address the reader where there is advice: "Use `WithRoot` to confine local + loading." Admit the awkward thing rather than smoothing it over. + +## Self-check + + # definition by inversion, both directions + grep -rnE '\b(is|are) (what|where) [a-z]' --include='*.go' --include='*.md' . + grep -rnE '(^|\. )What [a-z][a-z ,-]{3,50} (is|are) ' --include='*.go' --include='*.md' . + +Subtract the legitimate `which/that/this/it is what` before judging the first one. +Neither grep is a verdict — they find one fault out of six. The others need reading. diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 414541e..a6c45e3 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -23,7 +23,7 @@ updates: # - golang.org (e.g. golang.org/x/... packages) # - other dependencies (direct or indirect) # - # * All groups are checked once a week and each produce at most 1 PR. + # * All groups are checked twice a week and each produce at most 1 PR. # * All dependabot PRs are auto-approved # # Auto-merging policy, when requirements are met: @@ -34,9 +34,10 @@ updates: directories: - "**/*" schedule: - interval: "weekly" - day: "friday" - open-pull-requests-limit: 4 + interval: "cron" + cronjob: "35 7 * * 3,6" # => update twice a week + timezone: CEST + open-pull-requests-limit: 16 # => {# modules} x {# groups} groups: development-dependencies: patterns: @@ -53,6 +54,7 @@ updates: exclude-patterns: - "github.com/go-openapi/testify" + # not auto-merge: require review and manual merge action other-dependencies: exclude-patterns: - "github.com/go-openapi/*" diff --git a/.golangci.yml b/.golangci.yml index 38de600..1886180 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -8,6 +8,7 @@ linters: - errchkjson - errorlint - exhaustruct + - exhaustruct_v5 - forcetypeassert - funlen - gochecknoglobals diff --git a/README.md b/README.md index b0908e3..30b3ffc 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ -[![Release][release-badge]][release-url] [![Go Report Card][gocard-badge]][gocard-url] [![CodeFactor Grade][codefactor-badge]][codefactor-url] [![License][license-badge]][license-url] +[![Release][release-badge]][release-url] [![CodeFactor Grade][codefactor-badge]][codefactor-url] [![License][license-badge]][license-url] [![GoDoc][godoc-badge]][godoc-url] [![Discord Channel][discord-badge]][discord-url] [![go version][goversion-badge]][goversion-url] ![Top language][top-badge] ![Commits since latest release][commits-badge] @@ -101,13 +101,9 @@ Maintainers can cut a new release by running: [gomod-badge]: https://badge.fury.io/go/github.com%2Fgo-openapi%2Fcodegen.svg [gomod-url]: https://badge.fury.io/go/github.com%2Fgo-openapi%2Fcodegen -[gocard-badge]: https://goreportcard.com/badge/github.com/go-openapi/codegen -[gocard-url]: https://goreportcard.com/report/github.com/go-openapi/codegen [codefactor-badge]: https://img.shields.io/codefactor/grade/github/go-openapi/codegen [codefactor-url]: https://www.codefactor.io/repository/github/go-openapi/codegen -[doc-badge]: https://img.shields.io/badge/doc-site-blue?link=https%3A%2F%2Fgoswagger.io%2Fgo-openapi%2F -[doc-url]: https://goswagger.io/go-openapi [godoc-badge]: https://pkg.go.dev/badge/github.com/go-openapi/codegen [godoc-url]: http://pkg.go.dev/github.com/go-openapi/codegen [discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue diff --git a/funcmaps/doc.go b/funcmaps/doc.go new file mode 100644 index 0000000..7c1c28b --- /dev/null +++ b/funcmaps/doc.go @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package funcmaps exposes utilities to work with [template.FuncMap]. +// +// * funcmap merging, with guards against unwary overrides (coalesce, protecting builtins) +// * a default funcmap for golang codegen, with common mangling for go identifiers, go comments handling etc +package funcmaps diff --git a/funcmaps/golang/comments.go b/funcmaps/golang/comments.go new file mode 100644 index 0000000..9c9f4e3 --- /dev/null +++ b/funcmaps/golang/comments.go @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package golang + +import ( + "fmt" + "reflect" + "strings" +) + +// lineComment renders its arguments as a complete Go line-comment block, emitting +// the "//" markers itself so call sites only pass content. +// +// Arguments are stringified and concatenated with fmt.Sprint semantics (a space is +// inserted only between two non-string operands); nil arguments are skipped, so +// optional template values drop out cleanly. This lets a composed comment be built +// inline, e.g. {{ lineComment "MinProperties: " .MinProperties }}. +// +// Besides factoring out comment construction, it harmonizes the output: every line +// is prefixed with "// " (a single space), CR/CRLF are normalized, and per-line +// trailing whitespace is trimmed. +// +// Blank lines are preserved (they matter to godoc and to codescan). +// +// A blank line left trailing the whole comment block is dropped by the Go formatter. +// +// Splitting on embedded newlines keeps multi-line content fully commented so it cannot break out of the comment, +// and the guaranteed space after "//" keeps content from accidentally (or maliciously) +// forming a compiler directive such as //go:embed or //line. +// +// Blank input yields no output. +func lineComment(args ...any) string { + return renderLineComment("// ", args) +} + +// linePadComment renders its arguments as a Go line-comment block like [lineComment], +// but indents every rendered line by pad after the "//" marker, +// including the continuation lines produced by a multi-line argument. +// +// This preserves a fixed indentation across wrapped lines, as required by the +// indentation-significant swagger:meta package doc block. +// +// pad is the indentation that follows "//": a pad of " " yields "// text". A +// leading space is inserted when pad does not already start with whitespace, so +// the marker can never accidentally (or maliciously) form a compiler directive. +// Empty content yields no output. +func linePadComment(pad string, args ...any) string { + if pad == "" || (pad[0] != ' ' && pad[0] != '\t') { + pad = " " + pad + } + + return renderLineComment("//"+pad, args) +} + +// wrapBlockComment renders text as a complete Go block comment. +// +// It emits the "/*" and "*/" markers itself so call sites only pass the text. +// +// It neutralizes any inner "*/" so spec text cannot terminate the comment early, +// normalizes newlines and trims trailing whitespace. Single-line text stays +// inline ("/* text */"); multi-line text is wrapped on its own lines. +// +// Empty input yields no output. +func wrapBlockComment(str string) string { + str = strings.TrimRight(normalizeNewlines(str), " \t\n") + if str == "" { + return "" + } + + str = strings.ReplaceAll(str, "*/", "[*]/") + + if strings.ContainsRune(str, '\n') { + return "/*\n" + str + "\n*/" + } + + return "/* " + str + " */" +} + +// renderLineComment is the shared core of [lineComment] and [linePadComment]. +// +// It stringifies args (fmt.Sprint semantics, nil arguments skipped), normalizes CR/CRLF, +// trims trailing whitespace from each line, then prefixes every line with marker (blank lines become a bare "//"). +// +// Splitting on embedded newlines keeps multi-line content fully commented so it cannot break out of the comment. +// +// Blank lines are preserved, including a blank line trailing the content: +// they carry meaning for godoc paragraphs and for codescan. +// +// A blank line that ends up trailing the whole comment block is left to the Go formatter to drop: +// renderLineComment may be called several times and it cannot determine which trailing blank line will be the +// last block to trim. +// +// Content that is entirely blank yields no output. +func renderLineComment(marker string, args []any) string { + kept := make([]any, 0, len(args)) + for _, arg := range args { + if deref, ok := derefArg(arg); ok { + kept = append(kept, deref) + } + } + + str := normalizeNewlines(fmt.Sprint(kept...)) + if strings.TrimSpace(str) == "" { + return "" + } + + lines := strings.Split(str, "\n") + for i, line := range lines { + line = strings.TrimRight(line, " \t") + if line == "" { + lines[i] = "//" + + continue + } + + lines[i] = marker + line + } + + return strings.Join(lines, "\n") +} + +// derefArg unwraps pointer arguments so they stringify by value, mirroring how +// text/template prints a pointer field with {{ .X }}. +// +// A nil interface or a nil pointer is reported as absent (ok=false) so the caller can skip it. +// This keeps composed comments such as {{ lineComment "MinProperties: " .MinProperties }} +// printing the int64 value rather than the *int64 address. +func derefArg(arg any) (any, bool) { + const limit = 1000 // guard against malicious overflow + + if arg == nil { + return nil, false + } + + v := reflect.ValueOf(arg) + for i := 0; v.Kind() == reflect.Pointer; i++ { + if i > limit { + return nil, false + } + + if v.IsNil() { + return nil, false + } + + v = v.Elem() + } + + if v.CanInterface() { + return v.Interface(), true + } + + return nil, false +} + +// normalizeNewlines rewrites CRLF and lone CR to LF so comment helpers can split reliably on "\n". +func normalizeNewlines(str string) string { + if !strings.ContainsRune(str, '\r') { + return str + } + + str = strings.ReplaceAll(str, "\r\n", "\n") + + return strings.ReplaceAll(str, "\r", "\n") +} diff --git a/funcmaps/golang/comments_test.go b/funcmaps/golang/comments_test.go index a7d6d9c..5c41e77 100644 --- a/funcmaps/golang/comments_test.go +++ b/funcmaps/golang/comments_test.go @@ -115,7 +115,7 @@ func TestWrapBlockComment(t *testing.T) { // TestCommentHelpersRegistered ensures the helpers are wired into the funcmap // under their template-facing names. func TestCommentHelpersRegistered(t *testing.T) { - fm := testMap() + fm := testGoMap() assert.Contains(t, fm, "lineComment") assert.Contains(t, fm, "linePadComment") assert.Contains(t, fm, "blockComment") diff --git a/funcmaps/golang/doc.go b/funcmaps/golang/doc.go new file mode 100644 index 0000000..4d9bfbd --- /dev/null +++ b/funcmaps/golang/doc.go @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package golang provides the Go-specific template function map used by code generators. +package golang diff --git a/funcmaps/golang/funcmap.go b/funcmaps/golang/funcmap.go index fa8b0ea..62ea1b3 100644 --- a/funcmaps/golang/funcmap.go +++ b/funcmaps/golang/funcmap.go @@ -1,383 +1,94 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -// Package golang provides the Go-specific template function map used by the -// go-swagger code generator. Functions defined here are pure utilities with -// no dependency on the generator's own types (GenSchema, GenOperation, etc.). package golang import ( - "encoding/json" - "fmt" - "maps" - "math" "path" "path/filepath" - "reflect" "slices" - "strconv" "strings" "text/template" - "github.com/Masterminds/sprig/v3" - "github.com/kr/pretty" - + "github.com/go-openapi/codegen/funcmaps" "github.com/go-openapi/codegen/mangling" - "github.com/go-openapi/inflect" - "github.com/go-openapi/runtime" - "github.com/go-openapi/swag/stringutils" ) -var foldReplacer = strings.NewReplacer("\n", " ", "\r", "") - -// FuncMap returns a template.FuncMap containing all Go-specific template -// functions that are independent of generator types. Callers typically -// merge additional entries (e.g. LanguageOpts-dependent or type-dependent -// functions) on top. +// FuncMap returns a template.FuncMap containing common Go-specific template functions, +// plus a few other commonly utility functions to work with strings, maps and numbers. +// +// For advanced users, a "dict" function (similar to sprig.Dict) is provided. +// +// It injects a [mangling.GoMangler], which may be configured with options. +// +// Callers typically merge additional entries to suit the specific needs of a generator on top of this map. func FuncMap(mangler mangling.GoMangler) template.FuncMap { - f := sprig.TxtFuncMap() - pascalize := mangler.IdentExported + return funcmaps.Merge( + othersBase(), + stringsBase(), + numbersBase(), + goBase(mangler), + ) +} + +func goBase(mangler mangling.GoMangler) template.FuncMap { + goMap := template.FuncMap{ + "asFile": mangler.File, + "asModule": mangler.Module, + "asPackageName": func(pth string) string { + _, name := mangler.Package(pth) + + return name + }, + "asPackagePath": func(pth string) string { + mangled, _ := mangler.Package(pth) - extra := template.FuncMap{ - "pascalize": pascalize, + return mangled + }, "camelize": mangler.Camelize, - "humanize": mangler.Humanize, "dasherize": mangler.Kebabize, - "pluralizeFirstWord": pluralizeFirstWord, - "json": asJSON, - "prettyjson": asPrettyJSON, - "hasInsecure": func(arg []string) bool { - return stringutils.ContainsStringsCI(arg, "http") || stringutils.ContainsStringsCI(arg, "ws") - }, - "hasSecure": func(arg []string) bool { - return stringutils.ContainsStringsCI(arg, "https") || stringutils.ContainsStringsCI(arg, "wss") - }, - "dropPackage": dropPackage, - "containsPkgStr": containsPkgStr, - "contains": slices.Contains[[]string, string], - "padSurround": padSurround, - "joinFilePath": filepath.Join, - "joinPath": path.Join, + "enumName": mangler.ConstName, + "escapeBackticks": escapeBackticks, + "escapeDoubleQuoted": escapeDoubleQuoted, + "humanize": mangler.Humanize, + "import": printImports, + "jsonFieldTag": jsonFieldTag, + "pascalize": mangler.IdentExported, + "snakize": mangler.Snakize, + "varName": mangler.IdentUnexported, "lineComment": lineComment, "linePadComment": linePadComment, "blockComment": wrapBlockComment, - "inspect": pretty.Sprint, - "cleanPath": path.Clean, - "mediaTypeName": mediaMime, - "mediaGoName": mangler.ConstName, - "dict": dict, - "isInteger": isInteger, - "hasPrefix": strings.HasPrefix, - "stringContains": strings.Contains, - "trimSpace": strings.TrimSpace, - "mdBlock": markdownBlock, - "httpStatus": httpStatus, - "cleanupEnumVariant": mangler.ConstName, - "gt0": gt0, - "escapeBackticks": func(arg string) string { - return strings.ReplaceAll(arg, "`", "`+\"`\"+`") - }, - "flagNameVar": func(in string) string { - return fmt.Sprintf("flag%sName", pascalize(in)) - }, - "flagValueVar": func(in string) string { - return fmt.Sprintf("flag%sValue", pascalize(in)) - }, - "flagDefaultVar": func(in string) string { - return fmt.Sprintf("flag%sDefault", pascalize(in)) - }, - "flagModelVar": func(in string) string { - return fmt.Sprintf("flag%sModel", pascalize(in)) - }, - "flagDescriptionVar": func(in string) string { - return fmt.Sprintf("flag%sDescription", pascalize(in)) - }, - "printGoLiteral": func(in any) string { - return interfaceReplacer.Replace(fmt.Sprintf("%#v", in)) - }, - "fold": func(in string) string { - return foldReplacer.Replace(in) - }, + "printGoLiteral": printGoLiteral, } - maps.Copy(f, extra) - - return f + return goMap } -// asJSON marshals data to a compact JSON string. -func asJSON(data any) (string, error) { - b, err := json.Marshal(data) - if err != nil { - return "", err - } - - return string(b), nil -} - -// asPrettyJSON marshals data to an indented JSON string. -func asPrettyJSON(data any) (string, error) { - b, err := json.MarshalIndent(data, "", " ") - if err != nil { - return "", err - } - - return string(b), nil -} - -func pluralizeFirstWord(arg string) string { - sentence := strings.Split(arg, " ") - if len(sentence) == 1 { - return inflect.Pluralize(arg) - } - - return inflect.Pluralize(sentence[0]) + " " + strings.Join(sentence[1:], " ") -} - -// dropPackage returns the last component of a dot-separated name. -func dropPackage(str string) string { - parts := strings.Split(str, ".") - return parts[len(parts)-1] -} - -// containsPkgStr returns true if str contains a package qualifier (e.g. "model.MyType"). -func containsPkgStr(str string) bool { - dropped := dropPackage(str) - return dropped != str -} - -func padSurround(entry, padWith string, i, ln int) string { - res := make([]string, 0, i+max(ln-i-1, 0)+1) - - if i > 0 { - for range i { - res = append(res, padWith) - } - } - - res = append(res, entry) - - if ln > i { - tot := ln - i - 1 - for range tot { - res = append(res, padWith) - } - } - - return strings.Join(res, ",") -} - -// normalizeNewlines rewrites CRLF and lone CR to LF so comment helpers can split -// reliably on "\n". -func normalizeNewlines(str string) string { - if !strings.ContainsRune(str, '\r') { - return str - } - - str = strings.ReplaceAll(str, "\r\n", "\n") - - return strings.ReplaceAll(str, "\r", "\n") -} - -// lineComment renders its arguments as a complete Go line-comment block, emitting -// the "//" markers itself so call sites only pass content. -// -// Arguments are stringified and concatenated with fmt.Sprint semantics (a space is -// inserted only between two non-string operands); nil arguments are skipped, so -// optional template values drop out cleanly. This lets a composed comment be built -// inline, e.g. {{ lineComment "MinProperties: " .MinProperties }}. -// -// Besides factoring out comment construction, it harmonizes the output: every line -// is prefixed with "// " (a single space), CR/CRLF are normalized, and per-line -// trailing whitespace is trimmed. Blank lines are preserved (they matter to godoc -// and the spec re-scanner); a blank line left trailing the whole comment block is -// dropped by the Go formatter. Splitting on embedded newlines keeps multi-line -// content fully commented so it cannot break out of the comment, and the guaranteed -// space after "//" keeps content from accidentally (or maliciously) forming a -// compiler directive such as //go:embed or //line. Blank input yields no output. -func lineComment(args ...any) string { - return renderLineComment("// ", args) -} - -// linePadComment renders its arguments as a Go line-comment block like -// [lineComment], but indents every rendered line — including the continuation -// lines produced by a multi-line argument — by pad after the "//" marker. This -// preserves a fixed indentation across wrapped lines, as required by the -// indentation-significant swagger:meta package doc block. -// -// pad is the indentation that follows "//": a pad of " " yields "// text". A -// leading space is inserted when pad does not already start with whitespace, so -// the marker can never accidentally (or maliciously) form a compiler directive. -// Empty content yields no output. -func linePadComment(pad string, args ...any) string { - if pad == "" || (pad[0] != ' ' && pad[0] != '\t') { - pad = " " + pad - } - - return renderLineComment("//"+pad, args) -} - -// renderLineComment is the shared core of [lineComment] and [linePadComment]. -// -// It stringifies args (fmt.Sprint semantics, nil arguments skipped), normalizes -// CR/CRLF, trims trailing whitespace from each line, then prefixes every line with -// marker (blank lines become a bare "//"). Splitting on embedded newlines keeps -// multi-line content fully commented so it cannot break out of the comment. -// -// Blank lines are preserved, including a blank line trailing the content: they -// carry meaning for godoc paragraphs and for the spec re-scanner. A blank line that -// ends up trailing the whole comment block is left to the Go formatter to drop. -// Content that is entirely blank yields no output. -// derefArg unwraps pointer arguments so they stringify by value, mirroring how -// text/template prints a pointer field with {{ .X }}. A nil interface or a nil -// pointer is reported as absent (ok=false) so the caller can skip it. This keeps -// composed comments such as {{ lineComment "MinProperties: " .MinProperties }} -// printing the int64 value rather than the *int64 address. -func derefArg(arg any) (any, bool) { - if arg == nil { - return nil, false - } - - v := reflect.ValueOf(arg) - for v.Kind() == reflect.Pointer { - if v.IsNil() { - return nil, false - } - - v = v.Elem() - } - - return v.Interface(), true -} - -func renderLineComment(marker string, args []any) string { - kept := make([]any, 0, len(args)) - for _, arg := range args { - if deref, ok := derefArg(arg); ok { - kept = append(kept, deref) - } - } - - str := normalizeNewlines(fmt.Sprint(kept...)) - if strings.TrimSpace(str) == "" { - return "" - } - - lines := strings.Split(str, "\n") - for i, line := range lines { - line = strings.TrimRight(line, " \t") - if line == "" { - lines[i] = "//" - - continue - } - - lines[i] = marker + line - } - - return strings.Join(lines, "\n") -} - -// wrapBlockComment renders text as a complete Go block comment, emitting the -// "/*" and "*/" markers itself so call sites only pass the text. -// -// It neutralizes any inner "*/" so spec text cannot terminate the comment early, -// normalizes newlines and trims trailing whitespace. Single-line text stays -// inline ("/* text */"); multi-line text is wrapped on its own lines. Empty -// input yields no output. -func wrapBlockComment(str string) string { - str = strings.TrimRight(normalizeNewlines(str), " \t\n") - if str == "" { - return "" - } - - str = strings.ReplaceAll(str, "*/", "[*]/") - - if strings.ContainsRune(str, '\n') { - return "/*\n" + str + "\n*/" - } - - return "/* " + str + " */" -} - -func dict(values ...any) (map[string]any, error) { - const pair = 2 - - if len(values)%pair != 0 { - return nil, fmt.Errorf("expected even number of arguments, got %d", len(values)) - } - - dict := make(map[string]any, len(values)/pair) - for i := 0; i < len(values); i += 2 { - key, ok := values[i].(string) - if !ok { - return nil, fmt.Errorf("expected string key, got %+v", values[i]) - } - dict[key] = values[i+1] // bounds checked by the modulo guard above +func stringsBase() template.FuncMap { + return template.FuncMap{ + "cleanPath": path.Clean, + "contains": slices.Contains[[]string, string], + "hasPrefix": strings.HasPrefix, + "joinFilePath": filepath.Join, + "joinPath": path.Join, + "json": asJSON, + "pluralizeFirstWord": pluralizeFirstWord, + "prettyjson": asPrettyJSON, + "stringContains": strings.Contains, + "trimSpace": strings.TrimSpace, } - - return dict, nil } -func isInteger(arg any) bool { - switch val := arg.(type) { - case int8, int16, int32, int, int64, uint8, uint16, uint32, uint, uint64: - return true - case *int8, *int16, *int32, *int, *int64, *uint8, *uint16, *uint32, *uint, *uint64: - v := reflect.ValueOf(arg) - return !v.IsNil() - case float64: - return math.Round(val) == val - case *float64: - return val != nil && math.Round(*val) == *val - case float32: - return math.Round(float64(val)) == float64(val) - case *float32: - return val != nil && math.Round(float64(*val)) == float64(*val) - case string: - _, err := strconv.ParseInt(val, 10, 64) - return err == nil - case *string: - if val == nil { - return false - } - _, err := strconv.ParseInt(*val, 10, 64) - return err == nil - default: - return false +func numbersBase() template.FuncMap { + return template.FuncMap{ + "isInteger": isInteger, + "gt0": gt0, } } -func httpStatus(code int) string { - if name, ok := runtime.Statuses[code]; ok { - return name +func othersBase() template.FuncMap { + return template.FuncMap{ + "dict": dict, } - - return fmt.Sprintf("Status %d", code) -} - -func gt0(in *int64) bool { - return in != nil && *in > 0 -} - -const ( - mdNewLine = "
" - mimeParamParts = 2 -) - -var ( - mdNewLineReplacer = strings.NewReplacer("\r\n", mdNewLine, "\n", mdNewLine, "\r", mdNewLine) - interfaceReplacer = strings.NewReplacer("interface {}", "any") -) - -func markdownBlock(in string) string { - in = strings.TrimSpace(in) - - return mdNewLineReplacer.Replace(in) -} - -// mediaMime extracts the MIME type from a media type string, stripping -// any parameters after the first semicolon. -func mediaMime(orig string) string { - return strings.SplitN(orig, ";", mimeParamParts)[0] } diff --git a/funcmaps/golang/funcmap_test.go b/funcmaps/golang/funcmap_test.go index 822c872..f56ae3e 100644 --- a/funcmaps/golang/funcmap_test.go +++ b/funcmaps/golang/funcmap_test.go @@ -4,414 +4,70 @@ package golang import ( + "maps" "testing" "text/template" "github.com/go-openapi/codegen/mangling" - "github.com/go-openapi/swag/conv" "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" ) -func TestFuncMap(t *testing.T) { //nolint:maintidx // false positive +func TestFuncMap(t *testing.T) { t.Parallel() - fm := testMap() - const helloTitle = "Hello" - - t.Run("All expected keys should be present", func(t *testing.T) { - t.Parallel() - - for _, key := range []string{ - "pascalize", "camelize", "humanize", "dasherize", - "pluralizeFirstWord", "json", "prettyjson", - "hasInsecure", "hasSecure", - "dropPackage", "containsPkgStr", "contains", - "padSurround", "joinFilePath", "joinPath", - "lineComment", "linePadComment", "blockComment", "inspect", - "cleanPath", "mediaTypeName", "mediaGoName", - "dict", "isInteger", "hasPrefix", "stringContains", - "trimSpace", "mdBlock", "httpStatus", - "cleanupEnumVariant", "gt0", - "escapeBackticks", - "flagNameVar", "flagValueVar", "flagDefaultVar", "flagModelVar", "flagDescriptionVar", - "printGoLiteral", - } { - assert.MapContainsTf(t, fm, key, "expected funcmap key %q", key) - } - }) - - t.Run("dropPackage should only keep the base name", func(t *testing.T) { - t.Parallel() - - dropPackage, ok := fm["dropPackage"].(func(string) string) - require.TrueT(t, ok) - require.NotNil(t, dropPackage) - - assert.EqualT(t, "trail", dropPackage("base.trail")) - assert.EqualT(t, "trail", dropPackage("base.another.trail")) - assert.EqualT(t, "trail", dropPackage("trail")) - }) - - t.Run("pascalize should use custom prefix function", func(t *testing.T) { - t.Parallel() - - pascalize, ok := fm["pascalize"].(func(string) string) - require.TrueT(t, ok) - require.NotNil(t, pascalize) - - for _, tc := range []struct { - Input string - Expected string - }{ - {Expected: "One", Input: "+1"}, - {Expected: "Plus", Input: "+"}, - {Expected: "MinusOne", Input: "-1"}, - {Expected: "Empty", Input: "-"}, - {Expected: "Eight", Input: "8"}, - {Expected: "Star", Input: "*"}, - {Expected: "Slash", Input: "/"}, - {Expected: "Equal", Input: "="}, - {Expected: "PlusHello", Input: "+hello"}, - // other values from swag rules - {Expected: "At8", Input: "@8"}, - {Expected: "Bang8", Input: "!8"}, - {Expected: "At", Input: "@"}, - // # values - {Expected: "HashHello", Input: "#hello"}, - {Expected: "HashBangHello", Input: "#!hello"}, - {Expected: "Hash8", Input: "#8"}, - {Expected: "Hash", Input: "#"}, - // single '_' - {Expected: "Empty", Input: "_"}, - {Expected: helloTitle, Input: "_hello"}, - // remove spaces - {Expected: "HashHelloWorld", Input: "# hello world"}, - {Expected: "Hash8HelloWorld", Input: "# 8 hello world"}, - {Expected: "Empty", Input: ""}, - } { - result := pascalize(tc.Input) - assert.EqualTf(t, tc.Expected, result, "given %q, expected pascalize to yield %q, but got %q", tc.Input, tc.Expected, result) + t.Run("Specialized sub-maps should not overlap", func(t *testing.T) { + type crossMaps struct { + toCheck template.FuncMap + others []template.FuncMap } - }) - - t.Run("asJSON should jsonify anything that is serializable to JSON", func(t *testing.T) { - t.Parallel() - asJSON, ok := fm["json"].(func(any) (string, error)) - require.TrueT(t, ok) - require.NotNil(t, asJSON) - - asPrettyJSON, ok := fm["prettyjson"].(func(any) (string, error)) - require.TrueT(t, ok) - require.NotNil(t, asPrettyJSON) - - for _, jsonFunc := range []func(any) (string, error){ - asJSON, - asPrettyJSON, + for _, tc := range []crossMaps{ + { + toCheck: numbersBase(), + others: []template.FuncMap{ + stringsBase(), + testGoMap(), + othersBase(), + }, + }, + { + toCheck: stringsBase(), + others: []template.FuncMap{ + numbersBase(), + testGoMap(), + othersBase(), + }, + }, + { + toCheck: testGoMap(), + others: []template.FuncMap{ + stringsBase(), + numbersBase(), + othersBase(), + }, + }, } { - res, err := jsonFunc(struct { - A string `json:"a"` - B int - }{A: "good", B: 3}) - require.NoError(t, err) - assert.JSONEqT(t, `{"a":"good","B":3}`, res) - - _, err = jsonFunc(struct { - A string `json:"a"` - B func() string - }{A: "good", B: func() string { return "" }}) - require.Error(t, err) + for _, againstMap := range tc.others { + for key := range maps.Keys(againstMap) { + assert.MapNotContainsT(t, tc.toCheck, key) + } + } } }) - t.Run("dict should render values as a map", func(t *testing.T) { - t.Parallel() - - dict, ok := fm["dict"].(func(...any) (map[string]any, error)) - require.TrueT(t, ok) - require.NotNil(t, dict) - - d, err := dict("a", "b", "c", "d") - require.NoError(t, err) - assert.Equal(t, map[string]any{"a": "b", "c": "d"}, d) - - // odd number of arguments - _, err = dict("a", "b", "c") - require.Error(t, err) - - // none-string key - _, err = dict("a", "b", 3, "d") - require.Error(t, err) - }) - - t.Run("isInteger should detect integer values", func(t *testing.T) { + t.Run("Each submap should be merged", func(t *testing.T) { t.Parallel() - isInteger, ok := fm["isInteger"].(func(any) bool) - require.TrueT(t, ok) - require.NotNil(t, isInteger) - - var ( - nilString *string - nilInt *int - nilFloat *float32 - ) - - for _, anInteger := range []any{ - int8(4), - int16(4), - int32(4), - int64(4), - int(4), - conv.Pointer(int(4)), - conv.Pointer(int32(4)), - conv.Pointer(int64(4)), - conv.Pointer(uint(4)), - conv.Pointer(uint32(4)), - conv.Pointer(uint64(4)), - float32(12), - float64(12), - conv.Pointer(float32(12)), - conv.Pointer(float64(12)), - "12", - conv.Pointer("12"), - } { - val := anInteger - require.Truef(t, isInteger(val), "expected %#v to be detected an integer value", val) - } - - for _, notAnInteger := range []any{ - float32(12.5), - float64(12.5), - conv.Pointer(float32(12.5)), - conv.Pointer(float64(12.5)), - []string{"a"}, - struct{}{}, - nil, - map[string]int{"a": 1}, - "abc", - "2.34", - conv.Pointer("2.34"), - nilString, - nilInt, - nilFloat, + mangler := mangling.MakeGoMangler() + fm := FuncMap(mangler) + for _, key := range []string{ + // pick just one representative from each + "pascalize", + "dict", + "gt0", + "contains", } { - val := notAnInteger - require.Falsef(t, isInteger(val), "did not expect %#v to be detected an integer value", val) + assert.MapContainsTf(t, fm, key, "expected funcmap key %q", key) } }) - - t.Run("gt0 should work with any *int64", func(t *testing.T) { - t.Parallel() - - gt0, ok := fm["gt0"].(func(*int64) bool) - require.TrueT(t, ok) - require.NotNil(t, gt0) - - require.TrueT(t, gt0(conv.Pointer(int64(1)))) - require.FalseT(t, gt0(conv.Pointer(int64(0)))) - require.FalseT(t, gt0(nil)) - }) - - t.Run("mediaMime should return mime type with parameters stripped", func(t *testing.T) { - t.Parallel() - - mediaMime, ok := fm["mediaTypeName"].(func(string) string) - require.TrueT(t, ok) - require.NotNil(t, mediaMime) - - assert.EqualT(t, "application/json", mediaMime("application/json")) - assert.EqualT(t, "application/json", mediaMime("application/json;param=1;param=2")) - }) - - t.Run("mediaGoMime should return buid a go name from any mime", func(t *testing.T) { - t.Parallel() - - mediaGoName, ok := fm["mediaGoName"].(func(string) string) - require.TrueT(t, ok) - require.NotNil(t, mediaGoName) - - assert.EqualT(t, "StarSlashStar", mediaGoName("*/*")) - }) - - t.Run("mediaGoMime should return buid a go name from any mime", func(t *testing.T) { - t.Parallel() - - containsPkgStr, ok := fm["containsPkgStr"].(func(string) bool) - require.TrueT(t, ok) - require.NotNil(t, containsPkgStr) - - assert.TrueT(t, containsPkgStr("models.MyType")) - assert.FalseT(t, containsPkgStr("MyType")) - assert.FalseT(t, containsPkgStr("")) - }) - - t.Run("httpStatus should return the string of well-known codes", func(t *testing.T) { - t.Parallel() - - httpStatus, ok := fm["httpStatus"].(func(int) string) - require.TrueT(t, ok) - require.NotNil(t, httpStatus) - - assert.EqualT(t, "OK", httpStatus(200)) - assert.EqualT(t, "Not Found", httpStatus(404)) - assert.EqualT(t, "Status 999", httpStatus(999)) - }) - - t.Run("markdownBlock should trim space and handle new line", func(t *testing.T) { - t.Parallel() - - markdownBlock, ok := fm["mdBlock"].(func(string) string) - require.TrueT(t, ok) - require.NotNil(t, markdownBlock) - - assert.EqualT(t, "line1
line2", markdownBlock("line1\nline2")) - assert.EqualT(t, "line1
line2", markdownBlock("line1\r\nline2")) - assert.EqualT(t, "trimmed", markdownBlock(" trimmed ")) - }) - - t.Run("pluralizeFirstWord should plurarize a word using inflect", func(t *testing.T) { - t.Parallel() - - pluralizeFirstWord, ok := fm["pluralizeFirstWord"].(func(string) string) - require.TrueT(t, ok) - require.NotNil(t, pluralizeFirstWord) - - assert.EqualT(t, "ponies of the round table", pluralizeFirstWord("pony of the round table")) - assert.EqualT(t, "dwarves", pluralizeFirstWord("dwarf")) - assert.EqualT(t, "", pluralizeFirstWord("")) - }) - - t.Run("padSurround should plurarize a word using inflect", func(t *testing.T) { - t.Parallel() - - padSurround, ok := fm["padSurround"].(func(string, string, int, int) string) - require.TrueT(t, ok) - require.NotNil(t, padSurround) - - assert.EqualT(t, "-,-,-,padme,-,-,-,-,-,-,-,-", padSurround("padme", "-", 3, 12)) - assert.EqualT(t, "padme,-,-,-,-,-,-,-,-,-,-,-", padSurround("padme", "-", 0, 12)) - assert.EqualT(t, "only", padSurround("only", "-", 0, 1)) - }) - - t.Run("cleanupEnumVariant should transliterate special characters", func(t *testing.T) { - t.Parallel() - - cleanupEnumVariant, ok := fm["cleanupEnumVariant"].(func(string) string) - require.TrueT(t, ok) - require.NotNil(t, cleanupEnumVariant) - - assert.EqualT(t, "TwoDotFourGhz", cleanupEnumVariant("2.4Ghz")) - assert.EqualT(t, "One", cleanupEnumVariant("+1")) - assert.EqualT(t, "ABHashC", cleanupEnumVariant("a-b#c")) - assert.EqualT(t, "Plain", cleanupEnumVariant("plain")) - assert.EqualT(t, "Equal", cleanupEnumVariant("==")) - assert.EqualT(t, "Match", cleanupEnumVariant("=~")) - assert.EqualT(t, "GreaterOrEqual", cleanupEnumVariant(">=")) - assert.EqualT(t, "LessOrEqual", cleanupEnumVariant("<=")) - assert.EqualT(t, "NotEqual", cleanupEnumVariant("!=")) - assert.EqualT(t, "NotMatch", cleanupEnumVariant("!~")) - }) - - t.Run("hasInsecure should detect the http scheme as insecure", func(t *testing.T) { - t.Parallel() - - hasInsecure, ok := fm["hasInsecure"].(func([]string) bool) - require.TrueT(t, ok) - require.NotNil(t, hasInsecure) - - assert.TrueT(t, hasInsecure([]string{"http"})) - assert.TrueT(t, hasInsecure([]string{"ws"})) - assert.FalseT(t, hasInsecure([]string{"https"})) - assert.FalseT(t, hasInsecure([]string{"wss"})) - }) - - t.Run("hasSecure should detect the https scheme as secure", func(t *testing.T) { - t.Parallel() - - hasSecure, ok := fm["hasSecure"].(func([]string) bool) - require.TrueT(t, ok) - require.NotNil(t, hasSecure) - - assert.TrueT(t, hasSecure([]string{"https"})) - assert.TrueT(t, hasSecure([]string{"wss"})) - assert.FalseT(t, hasSecure([]string{"http"})) - assert.FalseT(t, hasSecure([]string{"ws"})) - }) - - t.Run("escapeBackicks should escape backticks in strings", func(t *testing.T) { - t.Parallel() - - escapeBackticks, ok := fm["escapeBackticks"].(func(string) string) - require.TrueT(t, ok) - require.NotNil(t, escapeBackticks) - - assert.EqualT(t, "no ticks", escapeBackticks("no ticks")) - assert.EqualT(t, "has`+\"`\"+`tick", escapeBackticks("has`tick")) - }) -} - -func TestFuncMap_FlagVars(t *testing.T) { - fm := testMap() - const ( - flagNameVar = "flagNameVar" - flagValueVar = "flagValueVar" - flagDefaultVar = "flagDefaultVar" - flagModelVar = "flagModelVar" - flagDescriptionVar = "flagDescriptionVar" - ) - - for _, tc := range []struct { - key string - expected string - }{ - {flagNameVar, "flagMyFieldName"}, - {flagValueVar, "flagMyFieldValue"}, - {flagDefaultVar, "flagMyFieldDefault"}, - {flagModelVar, "flagMyFieldModel"}, - {flagDescriptionVar, "flagMyFieldDescription"}, - } { - fn, ok := fm[tc.key].(func(string) string) - require.TrueT(t, ok) - assert.EqualT(t, tc.expected, fn("myField")) - } -} - -func TestFuncMap_PrintGoLiteral(t *testing.T) { - fm := testMap() - - fn, ok := fm["printGoLiteral"].(func(any) string) - require.TrueT(t, ok) - - assert.EqualT(t, `"hello"`, fn("hello")) - assert.EqualT(t, "42", fn(42)) -} - -/* -func TestPrefixForName_Letter(t *testing.T) { - // unicode.IsLetter branch: returns "" - assert.EqualT(t, "", PrefixForName("hello")) -} - -func TestReplaceSpecialChar(t *testing.T) { - assert.EqualT(t, "-Plus-", replaceSpecialChar('+')) - assert.EqualT(t, "-Dash-", replaceSpecialChar('-')) - assert.EqualT(t, "-Hashtag-", replaceSpecialChar('#')) - assert.EqualT(t, "-Dot-", replaceSpecialChar('.')) - assert.EqualT(t, "-Equal-", replaceSpecialChar('=')) - assert.EqualT(t, "-Bang-", replaceSpecialChar('!')) - assert.EqualT(t, "-Tilde-", replaceSpecialChar('~')) - assert.EqualT(t, "-GreaterOrEqual-", replaceSpecialChar('>')) - assert.EqualT(t, "-LessThan-", replaceSpecialChar('<')) - assert.EqualT(t, "-Star-", replaceSpecialChar('*')) - assert.EqualT(t, "-Slash-", replaceSpecialChar('/')) - assert.EqualT(t, "x", replaceSpecialChar('x')) -} -*/ - -func testMap() template.FuncMap { - m := mangling.MakeGoMangler() - - return FuncMap(m) } diff --git a/funcmaps/golang/go.go b/funcmaps/golang/go.go new file mode 100644 index 0000000..fc83f47 --- /dev/null +++ b/funcmaps/golang/go.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package golang + +import ( + "fmt" + "path" + "sort" + "strconv" + "strings" +) + +// printImports takes a map of imports: keys are aliases and values are the target packages to import. +// +// The layout is current ordered by key. +func printImports(imports map[string]string) string { + if len(imports) == 0 { + return "" + } + + result := make([]string, 0, len(imports)) + for k, v := range imports { + _, name := path.Split(v) + if name != k { + result = append(result, fmt.Sprintf("\t%s %q", k, v)) + } else { + result = append(result, fmt.Sprintf("\t%q", v)) + } + } + sort.Strings(result) + return strings.Join(result, "\n") +} + +var backticksReplacer = strings.NewReplacer("`", "`+\"`\"+`") + +func escapeBackticks(arg string) string { + return backticksReplacer.Replace(arg) +} + +// escapeDoubleQuoted escapes arg so it can be safely interpolated inside a double-quoted Go string literal ("..."). +// +// strconv.Quote produces a fully escaped literal. +// Stripping its surrounding quotes yields the inner form, so an embedded '"', '\' or newline in spec-derived text +// (e.g. an operation path baked into a diagnostic message) cannot break out of the literal. +// +// For text without special characters this is a no-op. +func escapeDoubleQuoted(arg string) string { + quoted := strconv.Quote(arg) + + return quoted[1 : len(quoted)-1] +} + +// jsonFieldTag renders a complete `json:"..."` struct tag from a field name. +// +// Templates that hand-write the backtick tag inline (server response headers, allOf / discriminator serializers) bypass +// PrintTags, so a backtick in the name would otherwise close the raw +// string early and inject arbitrary top-level Go. +// +// strconv.Quote escapes the name into the tag value; if the assembled tag can be backquoted it +// is emitted as a raw literal (byte-identical to the previous output for clean names), +// otherwise the whole tag is rendered as a double-quoted literal so no breakout is possible. +func jsonFieldTag(name string, omitEmpty, asString bool) string { + value := name + if omitEmpty { + value += ",omitempty" + } + if asString { + value += ",string" + } + + tag := "json:" + strconv.Quote(value) + if strconv.CanBackquote(tag) { + return "`" + tag + "`" + } + + return strconv.Quote(tag) +} + +/* + f["arrayInitializer"] = lang.ArrayInitializer +*/ + +var interfaceReplacer = strings.NewReplacer("interface {}", "any") + +func printGoLiteral(arg any) string { + return interfaceReplacer.Replace(fmt.Sprintf("%#v", arg)) +} diff --git a/funcmaps/golang/go_test.go b/funcmaps/golang/go_test.go new file mode 100644 index 0000000..53e3de1 --- /dev/null +++ b/funcmaps/golang/go_test.go @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package golang + +import ( + "testing" + "text/template" + + "github.com/go-openapi/codegen/mangling" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestGoMap(t *testing.T) { + const helloTitle = "Hello" + t.Run("pascalize should use verbalise prefix", func(t *testing.T) { + t.Parallel() + + fm := testGoMap() + pascalize, ok := fm["pascalize"].(func(string) string) + require.TrueT(t, ok) + require.NotNil(t, pascalize) + + for _, tc := range []struct { + Input string + Expected string + }{ + {Expected: "One", Input: "+1"}, + {Expected: "Plus", Input: "+"}, + {Expected: "MinusOne", Input: "-1"}, + {Expected: "Empty", Input: "-"}, + {Expected: "Eight", Input: "8"}, + {Expected: "Star", Input: "*"}, + {Expected: "Slash", Input: "/"}, + {Expected: "Equal", Input: "="}, + {Expected: "PlusHello", Input: "+hello"}, + // other values from swag rules + {Expected: "At8", Input: "@8"}, + {Expected: "Bang8", Input: "!8"}, + {Expected: "At", Input: "@"}, + // # values + {Expected: "HashHello", Input: "#hello"}, + {Expected: "HashBangHello", Input: "#!hello"}, + {Expected: "Hash8", Input: "#8"}, + {Expected: "Hash", Input: "#"}, + // single '_' + {Expected: "Empty", Input: "_"}, + {Expected: helloTitle, Input: "_hello"}, + // remove spaces + {Expected: "HashHelloWorld", Input: "# hello world"}, + {Expected: "Hash8HelloWorld", Input: "# 8 hello world"}, + {Expected: "Empty", Input: ""}, + } { + result := pascalize(tc.Input) + assert.EqualTf(t, tc.Expected, result, "given %q, expected pascalize to yield %q, but got %q", tc.Input, tc.Expected, result) + } + }) + + t.Run("enumName should transliterate special characters", func(t *testing.T) { + t.Parallel() + + gomap := testGoMap() + enumName, ok := gomap["enumName"].(func(string) string) + require.TrueT(t, ok) + require.NotNil(t, enumName) + + assert.EqualT(t, "TwoDotFourGhz", enumName("2.4Ghz")) + assert.EqualT(t, "One", enumName("+1")) + assert.EqualT(t, "ABHashC", enumName("a-b#c")) + assert.EqualT(t, "Plain", enumName("plain")) + assert.EqualT(t, "Equal", enumName("==")) + assert.EqualT(t, "Match", enumName("=~")) + assert.EqualT(t, "GreaterOrEqual", enumName(">=")) + assert.EqualT(t, "LessOrEqual", enumName("<=")) + assert.EqualT(t, "NotEqual", enumName("!=")) + assert.EqualT(t, "NotMatch", enumName("!~")) + }) + + t.Run("escapeBackicks should escape backticks in strings", func(t *testing.T) { + t.Parallel() + + fm := testGoMap() + escapeBackticks, ok := fm["escapeBackticks"].(func(string) string) + require.TrueT(t, ok) + require.NotNil(t, escapeBackticks) + + assert.EqualT(t, "no ticks", escapeBackticks("no ticks")) + assert.EqualT(t, "has`+\"`\"+`tick", escapeBackticks("has`tick")) + }) + + t.Run("go literal", func(t *testing.T) { + fm := testGoMap() + + fn, ok := fm["printGoLiteral"].(func(any) string) + require.TrueT(t, ok) + + assert.EqualT(t, `"hello"`, fn("hello")) + assert.EqualT(t, "42", fn(42)) + }) + + t.Run("printImports should render imports", func(t *testing.T) { + // empty map: returns "" + assert.Empty(t, printImports(map[string]string{})) + + // unaliased import (name matches last path component) + res := printImports(map[string]string{"fmt": "fmt"}) + assert.StringContainsT(t, res, `"fmt"`) + + // aliased import (name differs from last path component) + res = printImports(map[string]string{"myalias": "github.com/example/pkg"}) + assert.StringContainsT(t, res, `myalias "github.com/example/pkg"`) + }) + + t.Run("jsonFieldTag should render a safe struct tag", func(t *testing.T) { + t.Parallel() + + // clean names render as a raw backtick literal, byte-identical to the + // previous hand-written template output. + assert.EqualT(t, "`json:\"name\"`", jsonFieldTag("name", false, false)) + assert.EqualT(t, "`json:\"name,omitempty\"`", jsonFieldTag("name", true, false)) + assert.EqualT(t, "`json:\"name,omitempty,string\"`", jsonFieldTag("name", true, true)) + assert.EqualT(t, "`json:\"name,string\"`", jsonFieldTag("name", false, true)) + + // a backtick in the name cannot be represented in a raw literal, so the + // whole tag falls back to a double-quoted literal: the injected payload + // can no longer close the tag and inject top-level Go. + got := jsonFieldTag("evil` }; func init(){ println(\"pwned\") }; var _ = `", false, false) + assert.EqualT(t, `"json:\"evil`+"`"+` }; func init(){ println(\\\"pwned\\\") }; var _ = `+"`"+`\""`, got) + }) + + t.Run("escapeDoubleQuoted should render the inner part of the quoted string", func(t *testing.T) { + assert.EqualT(t, "fi`\\\"xed", escapeDoubleQuoted("fi`\"xed")) + }) +} + +func testGoMap() template.FuncMap { + mangler := mangling.MakeGoMangler() + + return goBase(mangler) +} diff --git a/funcmaps/golang/numbers.go b/funcmaps/golang/numbers.go new file mode 100644 index 0000000..8e9d166 --- /dev/null +++ b/funcmaps/golang/numbers.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package golang + +import ( + "cmp" + "math" + "math/big" + "strconv" +) + +// isInteger takes any type that may have an integer representation (including []byte, string, etc.) and +// returns true if it is in fact an integer. +// +// Pointers are resolved and nil is not considered an integer value. +func isInteger(arg any) bool { + in, ok := derefArg(arg) + if !ok { + return false + } + + switch val := in.(type) { + case int8, int16, int32, int, int64, uint8, uint16, uint32, uint, uint64, big.Int: + return true + case float64: + return math.Round(val) == val + case float32: + return math.Round(float64(val)) == float64(val) + case string: + _, err := strconv.ParseInt(val, 10, 64) + return err == nil + case []byte: + _, err := strconv.ParseInt(string(val), 10, 64) + return err == nil + case big.Rat: + return val.IsInt() + case big.Float: + return val.IsInt() + default: + return false + } +} + +// gt0 is like the builtin "gt 0". +// +// Its arg is de-dereferenced if this is a pointer. +// +// It may return true only for numerical values. +func gt0(arg any) bool { + in, ok := derefArg(arg) + if !ok { + return false + } + + switch val := in.(type) { + case int8: + return cmp.Compare(val, 0) > 0 + case int16: + return cmp.Compare(val, 0) > 0 + case int32: + return cmp.Compare(val, 0) > 0 + case int: + return cmp.Compare(val, 0) > 0 + case int64: + return cmp.Compare(val, 0) > 0 + case uint8: + return cmp.Compare(val, 0) > 0 + case uint16: + return cmp.Compare(val, 0) > 0 + case uint32: + return cmp.Compare(val, 0) > 0 + case uint: + return cmp.Compare(val, 0) > 0 + case uint64: + return cmp.Compare(val, 0) > 0 + case float64: + return cmp.Compare(val, 0) > 0 + case float32: + return cmp.Compare(val, 0) > 0 + case big.Rat: + return val.IsInt() + case big.Float: + return val.IsInt() + default: + return false + } +} diff --git a/funcmaps/golang/numbers_test.go b/funcmaps/golang/numbers_test.go new file mode 100644 index 0000000..b10e919 --- /dev/null +++ b/funcmaps/golang/numbers_test.go @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package golang + +import ( + "fmt" + "math/big" + "testing" + + "github.com/go-openapi/swag/conv" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestNumbersMap(t *testing.T) { + t.Parallel() + + m := numbersBase() + + const expectedSymbols = 2 + t.Run(fmt.Sprintf("numbersBase should contain %d functions", expectedSymbols), func(*testing.T) { + require.Len(t, m, expectedSymbols) + }) + + t.Run("numberBase should contain the expected functions", func(*testing.T) { + symbols := []string{ + "isInteger", + "gt0", + } + for _, symbol := range symbols { + assert.MapContainsT(t, m, symbol) + } + }) +} + +func TestIsInteger(t *testing.T) { + t.Parallel() + + t.Run("isInteger should expose signature func(any) bool", func(t *testing.T) { + t.Parallel() + + m := numbersBase() + isInteger, ok := m["isInteger"].(func(any) bool) + require.TrueT(t, ok) + require.NotNil(t, isInteger) + }) + + t.Run("isInteger should detect integer values", func(t *testing.T) { + t.Parallel() + + for _, anInteger := range []any{ + int8(4), + int16(4), + int32(4), + int64(4), + int(4), + conv.Pointer(int(4)), + conv.Pointer(int32(4)), + conv.Pointer(int64(4)), + conv.Pointer(uint(4)), + conv.Pointer(uint32(4)), + conv.Pointer(uint64(4)), + float32(12), + float64(12), + conv.Pointer(float32(12)), + conv.Pointer(float64(12)), + "12", + conv.Pointer("12"), + big.NewInt(12), + big.NewFloat(12), + big.NewRat(12, 1), + } { + val := anInteger + require.Truef(t, isInteger(val), "expected %#v to be detected an integer value", val) + } + }) + + t.Run("isInteger should detect non-integer values", func(t *testing.T) { + t.Parallel() + + var ( + nilString *string + nilInt *int + nilFloat *float32 + ) + + for _, notAnInteger := range []any{ + float32(12.5), + float64(12.5), + conv.Pointer(float32(12.5)), + conv.Pointer(float64(12.5)), + []string{"a"}, + struct{}{}, + nil, + map[string]int{"a": 1}, + "abc", + "2.34", + conv.Pointer("2.34"), + nilString, + nilInt, + nilFloat, + big.NewFloat(12.5), + big.NewRat(12, 5), + } { + val := notAnInteger + require.Falsef(t, isInteger(val), "did not expect %#v to be detected an integer value", val) + } + }) +} + +func TestGt0(t *testing.T) { + t.Parallel() + + t.Run("gt0 should expose signature func(any) bool", func(t *testing.T) { + t.Parallel() + + m := numbersBase() + gt0, ok := m["isInteger"].(func(any) bool) + require.TrueT(t, ok) + require.NotNil(t, gt0) + }) + + t.Run("gt0 should work with any numeral", func(t *testing.T) { + t.Parallel() + + // TODO more cases + require.TrueT(t, gt0(conv.Pointer(int64(1)))) + require.FalseT(t, gt0(conv.Pointer(int64(0)))) + require.FalseT(t, gt0(nil)) + }) +} diff --git a/funcmaps/golang/others.go b/funcmaps/golang/others.go new file mode 100644 index 0000000..e42d21c --- /dev/null +++ b/funcmaps/golang/others.go @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package golang + +import "fmt" + +func dict(values ...any) (map[string]any, error) { + const pair = 2 + + if len(values)%pair != 0 { + return nil, fmt.Errorf("expected even number of arguments, got %d", len(values)) + } + + dict := make(map[string]any, len(values)/pair) + for i := 0; i < len(values)-1; i += 2 { + key, ok := values[i].(string) + if !ok { + return nil, fmt.Errorf("expected string key, got %+v", values[i]) + } + dict[key] = values[i+1] // bounds checked by the modulo guard above + } + + return dict, nil +} diff --git a/funcmaps/golang/others_test.go b/funcmaps/golang/others_test.go new file mode 100644 index 0000000..9c89067 --- /dev/null +++ b/funcmaps/golang/others_test.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package golang + +import ( + "fmt" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestOthersMap(t *testing.T) { + m := othersBase() + const expectedSymbols = 1 + + t.Run(fmt.Sprintf("othersBase should contain %d functions", expectedSymbols), func(*testing.T) { + require.Len(t, m, expectedSymbols) + }) + + t.Run("dict should expose signature func(any) bool", func(t *testing.T) { + t.Parallel() + + d, ok := m["dict"].(func(...any) (map[string]any, error)) + require.TrueT(t, ok) + require.NotNil(t, d) + }) + + t.Run("dict should render values as a map", func(t *testing.T) { + t.Parallel() + + d, err := dict("a", "b", "c", "d") + require.NoError(t, err) + assert.Equal(t, map[string]any{"a": "b", "c": "d"}, d) + + // odd number of arguments + _, err = dict("a", "b", "c") + require.Error(t, err) + + // none-string key + _, err = dict("a", "b", 3, "d") + require.Error(t, err) + }) +} diff --git a/funcmaps/golang/strings.go b/funcmaps/golang/strings.go new file mode 100644 index 0000000..99ce737 --- /dev/null +++ b/funcmaps/golang/strings.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package golang + +import ( + "encoding/json" + "strings" + + "github.com/go-openapi/inflect" +) + +// asJSON marshals data to a compact JSON string. +func asJSON(data any) (string, error) { + b, err := json.Marshal(data) + if err != nil { + return "", err + } + + return string(b), nil +} + +// asPrettyJSON marshals data to an indented JSON string. +func asPrettyJSON(data any) (string, error) { + b, err := json.MarshalIndent(data, "", " ") + if err != nil { + return "", err + } + + return string(b), nil +} + +func pluralizeFirstWord(arg string) string { + sentence := strings.Split(arg, " ") + if len(sentence) == 1 { + return inflect.Pluralize(arg) + } + + return inflect.Pluralize(sentence[0]) + " " + strings.Join(sentence[1:], " ") +} diff --git a/funcmaps/golang/strings_test.go b/funcmaps/golang/strings_test.go new file mode 100644 index 0000000..2d25245 --- /dev/null +++ b/funcmaps/golang/strings_test.go @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package golang + +import ( + "fmt" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestStringsMap(t *testing.T) { + t.Parallel() + + m := stringsBase() + + const expectedSymbols = 10 + t.Run(fmt.Sprintf("stringsBase should contain %d functions", expectedSymbols), func(*testing.T) { + require.Len(t, m, expectedSymbols) + }) + + t.Run("stringsBase should contain the expected functions", func(*testing.T) { + symbols := []string{ + "cleanPath", + "contains", + "hasPrefix", + "joinFilePath", + "joinPath", + "json", + "pluralizeFirstWord", + "prettyjson", + "stringContains", + "trimSpace", + } + for _, symbol := range symbols { + assert.MapContainsT(t, m, symbol) + } + }) +} + +func TestAsJSON(t *testing.T) { + t.Parallel() + + t.Run("asJSON and prettyJSON should expose signature func(any) (string,error)", func(t *testing.T) { + t.Parallel() + + m := stringsBase() + + asJSON, ok := m["json"].(func(any) (string, error)) + require.TrueT(t, ok) + require.NotNil(t, asJSON) + + asPrettyJSON, ok := m["prettyjson"].(func(any) (string, error)) + require.TrueT(t, ok) + require.NotNil(t, asPrettyJSON) + }) + + t.Run("asJSON and prettyJSON should jsonify anything that is serializable to JSON", func(t *testing.T) { + t.Parallel() + + for _, jsonFunc := range []func(any) (string, error){ + asJSON, + asPrettyJSON, + } { + res, err := jsonFunc(struct { + A string `json:"a"` + B int + }{A: "good", B: 3}) + require.NoError(t, err) + assert.JSONEqT(t, `{"a":"good","B":3}`, res) + + _, err = jsonFunc(struct { + A string `json:"a"` + B func() string + }{A: "good", B: func() string { return "" }}) + require.Error(t, err) + } + }) +} + +func TestPluralizeFirstWord(t *testing.T) { + t.Parallel() + + t.Run("pluralizeFirstWord should expose signature func(string) string", func(t *testing.T) { + t.Parallel() + + m := stringsBase() + + pluralize, ok := m["pluralizeFirstWord"].(func(string) string) + require.True(t, ok) + require.NotNil(t, pluralize) + }) + + t.Run("pluralizeFirstWord should plurarize an English word using inflect", func(t *testing.T) { + t.Parallel() + + assert.EqualT(t, "ponies of the round table", pluralizeFirstWord("pony of the round table")) + assert.EqualT(t, "dwarves", pluralizeFirstWord("dwarf")) + assert.EqualT(t, "", pluralizeFirstWord("")) + }) +} diff --git a/funcmaps/merge.go b/funcmaps/merge.go new file mode 100644 index 0000000..1e855f7 --- /dev/null +++ b/funcmaps/merge.go @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package funcmaps + +import ( + "maps" + "text/template" +) + +// Merge [template.FuncMap] s into the target [template.FuncMap]. +// +// Merge is performed with last wins, overwriting keys. +// +// Built-in template functions are always protected from an override. +func Merge(target template.FuncMap, merged ...template.FuncMap) template.FuncMap { + m := mergeMaps(target, merged...) + + return omitBuiltins(m) +} + +// Coalesce [template.FuncMap] s into the target [template.FuncMap]. +// +// Coalesce is a merge with first wins, never overwriting keys. +// +// Built-in template functions are implied and are therefore always protected from an override. +func Coalesce(target template.FuncMap, coalesced ...template.FuncMap) template.FuncMap { + m := coalesceMaps(target, coalesced...) + + return omitBuiltins(m) +} + +// Pick cherrypicks a list of functions from a [template.FuncMap] and yields a clone with only +// the selected ones. Non-existent symbols are silently ignored. +func Pick(source template.FuncMap, picked ...string) template.FuncMap { + m := make(template.FuncMap, len(source)) + + for _, selected := range picked { + reexported, ok := source[selected] + if !ok { + continue + } + + m[selected] = reexported + } + + return m +} + +func omitBuiltins(m template.FuncMap) template.FuncMap { + for _, builtin := range builtinFuncMap { + delete(m, builtin) + } + + return m +} + +// builtins holds the name of all built-in functions. +// +// These are provided by the text/templates package. +// +// See https://pkg.go.dev/text/template@go1.26.5#hdr-Functions +var builtinFuncMap = []string{ + "and", "not", "or", + "eq", "ge", "gt", "le", "lt", "ne", + "call", + "index", "slice", "len", + "print", "printf", "println", + "html", "js", "urlquery", +} + +// mergeMaps merges maps into the target. +// +// If the target is nil, a new merged map is created. +// +// Merge semantics are: overwrite, last win. +func mergeMaps[M ~map[K]V, K comparable, V any](target M, merged ...M) M { + if target == nil { + var c int + for _, m := range merged { + c += len(m) + } + target = make(map[K]V, c) + } + + for _, m := range merged { + maps.Copy(target, m) + } + + return target +} + +// coalesceMaps merges maps into the target. +// +// If the target is nil, a new merged map is created. +// +// Merge semantics are: coalesce without overwrite, first win. +func coalesceMaps[M ~map[K]V, K comparable, V any](target M, coalesced ...M) M { + if target == nil { + var c int + for _, m := range coalesced { + c += len(m) + } + target = make(map[K]V, c) + } + + for _, co := range coalesced { + for k, v := range co { + _, found := target[k] + if found { + continue + } + target[k] = v + } + } + + return target +} diff --git a/funcmaps/merge_test.go b/funcmaps/merge_test.go new file mode 100644 index 0000000..0ed54d5 --- /dev/null +++ b/funcmaps/merge_test.go @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package funcmaps + +import ( + "testing" + "text/template" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func key(k string) func() string { + return func() string { + return k + } +} + +func buildTestCase() (template.FuncMap, template.FuncMap) { + a := template.FuncMap{ + "1": key("1"), + "2": key("2"), + } + b := template.FuncMap{ + "2": key("x"), + "3": key("3"), + } + + return a, b +} + +func TestMerge(t *testing.T) { + t.Run("should merge maps, with overwrite", func(t *testing.T) { + a, b := buildTestCase() + m := Merge(a, b) + assert.MapContainsT(t, m, "1") + require.MapContainsT(t, m, "2") + assert.MapContainsT(t, m, "3") + + fn, ok := m["2"].(func() string) + require.TrueT(t, ok) + assert.Equalf(t, "x", fn(), "value for key %q should have been overwritten", "2") + }) + + t.Run("should merge maps, a builtin is not overwritten", func(t *testing.T) { + a, b := buildTestCase() + a["eq"] = key("eq") + b["slice"] = key("slice") + + m := Merge(a, b) + + assert.MapContainsT(t, m, "1") + require.MapContainsT(t, m, "2") + assert.MapContainsT(t, m, "3") + assert.MapNotContainsT(t, m, "eq") + assert.MapNotContainsT(t, m, "slice") + + fn, ok := m["2"].(func() string) + require.TrueT(t, ok) + assert.Equalf(t, "x", fn(), "value for key %q should have been overwritten", "2") + }) +} + +func TestCoalesce(t *testing.T) { + t.Run("should merge maps, with overwrite", func(t *testing.T) { + a, b := buildTestCase() + + m := Coalesce(a, b) + assert.MapContainsT(t, m, "1") + require.MapContainsT(t, m, "2") + assert.MapContainsT(t, m, "3") + + fn, ok := m["2"].(func() string) + require.TrueT(t, ok) + assert.Equalf(t, "2", fn(), "value for key %q should NOT have been overwritten", "2") + }) + + t.Run("should coalesce maps, a builtin is not overwritten", func(t *testing.T) { + a, b := buildTestCase() + a["eq"] = key("eq") + b["slice"] = key("slice") + + m := Merge(a, b) + + assert.MapContainsT(t, m, "1") + require.MapContainsT(t, m, "2") + assert.MapNotContainsT(t, m, "eq") + assert.MapNotContainsT(t, m, "slice") + + fn, ok := m["2"].(func() string) + require.TrueT(t, ok) + + assert.Equalf(t, "x", fn(), "value for key %q should have been overwritten", "2") + }) +} diff --git a/go.mod b/go.mod index da0d656..6657419 100644 --- a/go.mod +++ b/go.mod @@ -3,46 +3,18 @@ module github.com/go-openapi/codegen go 1.25.0 require ( - github.com/Masterminds/sprig/v3 v3.3.0 github.com/go-openapi/codegen/mangling v0.0.0 github.com/go-openapi/inflect v1.0.0 - github.com/go-openapi/runtime v0.33.0 - github.com/go-openapi/swag/conv v0.28.0 - github.com/go-openapi/swag/stringutils v0.28.0 + github.com/go-openapi/swag/conv v0.29.0 github.com/go-openapi/testify/v2 v2.6.1 - github.com/kr/pretty v0.3.1 golang.org/x/tools v0.49.0 ) replace github.com/go-openapi/codegen/mangling => ./mangling require ( - dario.cat/mergo v1.0.2 // indirect - github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/semver/v3 v3.5.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/go-openapi/errors v0.22.8 // indirect - github.com/go-openapi/strfmt v0.27.0 // indirect - github.com/go-openapi/swag/fileutils v0.28.0 // indirect - github.com/go-openapi/swag/jsonutils v0.28.0 // indirect - github.com/go-openapi/swag/pools v0.28.0 // indirect - github.com/go-openapi/swag/typeutils v0.28.0 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-openapi/swag/pools v0.29.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/huandu/xstrings v1.5.0 // indirect - github.com/kr/text v0.2.0 // indirect - github.com/mitchellh/copystructure v1.2.0 // indirect - github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/oklog/ulid/v2 v2.1.2 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rogpeppe/go-internal v1.16.0 // indirect - github.com/shopspring/decimal v1.4.0 // indirect - github.com/spf13/cast v1.10.0 // indirect - golang.org/x/crypto v0.55.0 // indirect golang.org/x/mod v0.40.0 // indirect - golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect - golang.org/x/text v0.41.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index c984e40..190e71b 100644 --- a/go.sum +++ b/go.sum @@ -1,105 +1,18 @@ -dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= -dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= -github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= -github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= -github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/go-openapi/analysis v0.25.5 h1:xPYEvTb90o1y0epuiOPAoG4QqahjP3cdp5xNlHeKJRI= -github.com/go-openapi/analysis v0.25.5/go.mod h1:d3UGtQC5uq5Kqqqis2VH09Km/v3vwsWrYkbp4gdm+Rc= -github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= -github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= github.com/go-openapi/inflect v1.0.0 h1:IzG7K5YBu97odaCXhjODGGt25WaNtEYLJc9NfUcW4AI= github.com/go-openapi/inflect v1.0.0/go.mod h1:ksYcnLD7j24H79hdqOMmWaLXjFXd0LTkRoBA0UazLW8= -github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= -github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= -github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= -github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= -github.com/go-openapi/loads v0.25.0 h1:74Bc2snfaVlsHzwdQj/3gsA9XJz3daXTJVs+4ZaK7jI= -github.com/go-openapi/loads v0.25.0/go.mod h1:JFBw4SIB9+PTIFHDfcXuSSy5h6aWzjtUCrPYyx3qWU8= -github.com/go-openapi/runtime v0.33.0 h1:Dd3Oj2ig+WH8ckK95l0Wn2V8a4bH/UqWPRZVT0vc8yU= -github.com/go-openapi/runtime v0.33.0/go.mod h1:+rsupH3+TFKqmFysqkmgBOTxpVJV8eV+j9myvvea2Xw= -github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= -github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= -github.com/go-openapi/strfmt v0.27.0 h1:kbcTeaD9TXuXD0hhMXzuYa1sdTo6+dWGvwjW93E80IM= -github.com/go-openapi/strfmt v0.27.0/go.mod h1:s/qhDqfY72irigXUGJmtgid2Rm+3tnz3k8hZaRmvWYc= -github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8= -github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k= -github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU= -github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= -github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= -github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= -github.com/go-openapi/swag/loading v0.27.3 h1:L9nQkEgzU7QgFQL+pLEMfGUKxeM4pWwGwbET9Z3weW0= -github.com/go-openapi/swag/loading v0.27.3/go.mod h1:rJ0NeaKsF4CVPnMGjPQl7JlSHzvD0bc2DKXLss1hiuE= -github.com/go-openapi/swag/mangling v0.27.3 h1:gRzzD1PAUoLTtGMgI3KpBmCSOlTuLTFWnviLxLcTnyg= -github.com/go-openapi/swag/mangling v0.27.3/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= -github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= -github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= -github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU= -github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= -github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY= -github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= -github.com/go-openapi/swag/yamlutils v0.27.3 h1:cRFCAoYtslYn9L9T0xWryHy1t7c1MACC+DMj3CLvwvs= -github.com/go-openapi/swag/yamlutils v0.27.3/go.mod h1:6JYBGj8sw/NawMllyZY+cTA8Mzk2etS3ZBASdcyPsiU= -github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= -github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= +github.com/go-openapi/swag/conv v0.29.0 h1:4+1TogWpOIzMPzVKrvx1BfqBYlApB7D7DW3EAWpwmp4= +github.com/go-openapi/swag/conv v0.29.0/go.mod h1:ch1l7V87F6zQXuLs5s0RFvrro6aFvrVcfVXn2PTZnu8= +github.com/go-openapi/swag/pools v0.29.0 h1:uMQcoJeHJ8fWkdfEXJZMMpqk6hpfW8qTL5Q/IoRFFII= +github.com/go-openapi/swag/pools v0.29.0/go.mod h1:leDcaghjkRAhCuCRv9NfJU5f0mjoU3cT/XZObhMk3pc= +github.com/go-openapi/swag/typeutils v0.29.0 h1:HrWCYZeXVVNDo/7QQPRaYk33XeIDxksbxpalID3bWR8= +github.com/go-openapi/swag/typeutils v0.29.0/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE= github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7seO/y7Q= github.com/go-openapi/testify/v2 v2.6.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= -github.com/go-openapi/validate v0.26.1 h1:pZSbvtRO8G2R2FpWTYRn3w8LrsNwbtaVhP2dWiBa0Us= -github.com/go-openapi/validate v0.26.1/go.mod h1:B8UMgXiQiwwQWIbmuROlwJZDPGlikPuh7iHV1vPX9Oo= -github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= -github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= -github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= -github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= -github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= -github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= -github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= -github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= -github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= -github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/mangling/docs/design.md b/mangling/docs/design.md index 74406e1..f8d4678 100644 --- a/mangling/docs/design.md +++ b/mangling/docs/design.md @@ -130,7 +130,7 @@ Go-specific naming rules live: Repair is applied only where a collision can actually occur, for **minimum distortion**: `type` unexported becomes `typeVar`, but `type` exported stays `Type` — a perfectly good name that needs no -mangling. The explicit `IdentExported` / `IdentUnexported` split is what earns this; every generated +mangling. The explicit `IdentExported` / `IdentUnexported` split earns this; every generated symbol calls the mangler once and lands with the least distortion for its role. Path-returning targets (`Package`, `Module`) preprocess the path themselves — split on the last `/`, @@ -143,7 +143,7 @@ A ruleset carries its dictionaries (initialisms, reserved words, repair tokens) targets together. Targets hold the recipe only; the dictionaries live on the mangler and bind to the stages at run time, so the same target degrades gracefully across manglers — a `GoMangler` target run on a bare `Mangler` simply finds no initialisms. Go is the only concrete ruleset today, but the -seam is where another language would plug in. +seam is the point another language would plug in at. ## Immutability and concurrency diff --git a/mangling/go.mod b/mangling/go.mod index 553af63..188f289 100644 --- a/mangling/go.mod +++ b/mangling/go.mod @@ -3,6 +3,6 @@ module github.com/go-openapi/codegen/mangling go 1.25.0 require ( - github.com/go-openapi/swag/pools v0.28.0 - github.com/go-openapi/testify/v2 v2.6.0 + github.com/go-openapi/swag/pools v0.29.0 + github.com/go-openapi/testify/v2 v2.6.1 ) diff --git a/mangling/go.sum b/mangling/go.sum index 5e878b7..22dae3c 100644 --- a/mangling/go.sum +++ b/mangling/go.sum @@ -1,4 +1,4 @@ -github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= -github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= -github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= -github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/swag/pools v0.29.0 h1:uMQcoJeHJ8fWkdfEXJZMMpqk6hpfW8qTL5Q/IoRFFII= +github.com/go-openapi/swag/pools v0.29.0/go.mod h1:leDcaghjkRAhCuCRv9NfJU5f0mjoU3cT/XZObhMk3pc= +github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7seO/y7Q= +github.com/go-openapi/testify/v2 v2.6.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= diff --git a/mangling/mangler_test.go b/mangling/mangler_test.go index 577665a..2d0bde0 100644 --- a/mangling/mangler_test.go +++ b/mangling/mangler_test.go @@ -36,6 +36,21 @@ func TestMangler(t *testing.T) { t.Run(tc.name, testMangler(m, testModeASCIIMangler, tc)) } }) + + t.Run("with extra separator", func(t *testing.T) { + t.Parallel() + + const pth = "a:/folder/file" + t.Run("default should verbalize / (not a default separator)", func(t *testing.T) { + b := MakeMangler() // default + assert.Equal(t, "aSlashFolderSlashFile", b.Camelize(pth)) + }) + + t.Run("with custom separators should split the path as desired", func(t *testing.T) { + m := MakeMangler(WithTokenOptions(WithExtraTokenSeparator('/', '-'))) // "-" is already there + assert.Equal(t, "aFolderFile", m.Camelize(pth)) + }) + }) } func TestGoMangler(t *testing.T) { diff --git a/mangling/options.go b/mangling/options.go index ec4908d..a6c012d 100644 --- a/mangling/options.go +++ b/mangling/options.go @@ -155,6 +155,15 @@ func WithTokenSeparator(separator func(rune) bool) TokenOption { } } +// WithExtraTokenSeparator appends new separators to the default list. +func WithExtraTokenSeparator(extras ...rune) TokenOption { + return func(o tokenOptions) tokenOptions { + o.separator = customTokenSeparatorFunc(extras) + + return o + } +} + // WithTokenOptions bundles token-level options into a single mangler [Option]. // // Use it to pass tokenizer settings (such as [WithTokenSeparator]) when configuring a [Mangler]. @@ -176,7 +185,7 @@ func WithTokenOptions(opts ...TokenOption) Option { // - segmentation: a rune in the set is a single-rune *symbol token*; a rune not in the set falls to the category // rules and is typically a separator. So adding ',' makes "a,b" tokenize as [a , b] (→ verbalizable), while removing // '@' makes "a@b" tokenize as [a b] (the '@' becomes a separator, dropped). -// - verbalization: under a target's verbalize policy, the word is what the assembler emits ("@" → "at"). +// - verbalization: under a target's verbalize policy, the assembler emits the word ("@" → "at"). // // Repeated calls accumulate. Applies to the base [Mangler] and, via [WithManglerOptions], to the [GoMangler]. func WithSymbolWords(words map[rune]string) Option { diff --git a/mangling/separator.go b/mangling/separator.go index d76e5b6..357a26d 100644 --- a/mangling/separator.go +++ b/mangling/separator.go @@ -45,6 +45,25 @@ func defaultTokenSeparator(r rune) bool { return separatorForRune(r, defaultSymbolWords) } +func customTokenSeparatorFunc(extraSeparators []rune) func(rune) bool { + onlyNewSeparators := make(map[rune]struct{}, len(extraSeparators)) + for _, r := range extraSeparators { + if defaultTokenSeparator(r) { + continue + } + onlyNewSeparators[r] = struct{}{} + } + + return func(r rune) bool { + if defaultTokenSeparator(r) { + return true + } + _, ok := onlyNewSeparators[r] + + return ok + } +} + // asciiSeparator caches the default-set separator decision for every ASCII rune, filled in init. var asciiSeparator [utf8.RuneSelf]bool diff --git a/templates-repo/README.md b/templates-repo/README.md new file mode 100644 index 0000000..443ed7b --- /dev/null +++ b/templates-repo/README.md @@ -0,0 +1,469 @@ +# templates-repo + +The templates repository is a cache for collecting golang text templates. + +It compiles a set of Go text templates into an immutable repository. It reads assets from an +`io/fs.FS`, a directory or a `[]byte`, parses them in a single pass so that every template can +call every other, and returns them ready to execute from `Get`. + +A code generator ships a default set of templates and may allow its users to override some of them. +The repository holds that set, and takes overlay options to override from further sources. + +## Features + +* expose a namespace for the whole tree of templates, including `{{ define }}` macros +* safe for a concurrent use +* automatic check and dependencies resolution +* support for composition and overrides with overlays +* cache compiled templates from assets on a file system, possibly embedded + +**Experimental features** + +* self-check audit: unused or empty templates, likely errors +* generates documentation for your templates from data introspection and comments in source +* instruments templates for test coverage reporting + +The types these three produce live in the [`reports`](reports/README.md) sub-package, so the main API stays +to what executing templates needs. + +## Getting started + +```cmd +go get github.com/go-openapi/codegen +``` + +```go +import repo "github.com/go-openapi/codegen/templates-repo" +``` + +Add the sub-package only to describe a repository, never to run one: + +```go +import "github.com/go-openapi/codegen/templates-repo/reports" +``` + +## Building + +A repository is built once, from sources declared as options, and is sealed from then on. + +All dependencies and templates are resolved eagerly: any compilation or dependency error is known at build time. + +```go +templates, err := repo.New( + repo.FromFS(assets, ""), // load from an io/fs.FS + repo.FromDir("./mytemplates", ""), // load from local disk + repo.FromTemplate("addon", []byte("{{ printf \"%#v\" . }}")), // load from []byte + repo.WithFuncMap(funcs), +) + +tpl, err := templates.Get("validationPrimitive") +err = tpl.Execute(w, data) +... +``` + +Sources are read in the order they are declared, so a template declared twice comes from the last +one. Use `Audit` to report on overridden templates. + +Each source decides what it reads. `SkipDirectories` leaves a directory unread, along with +everything below it: + +```go +repo.FromFS(assets, "", repo.SkipDirectories("contrib")) // the alternate sets are stacked, not loaded wholesale +``` + +It matches a directory by its own name, the last segment of its path, at any depth: `contrib` at +the root and `server/legacy/contrib` alike. It matches neither a path nor a template name, since +it decides what a source reads before anything is named. Nothing is skipped by default, and what +one source skips says nothing about any other. + +`New` reports an error when it cannot build the set of templates. +This may be because of an unreadable source, a template that does not parse, a reference that +reaches nothing, a root that no source declares, or an override that would be silently ignored. + +> That last one needs a word. `text/template.Template.AddParseTree` keeps the existing definition +> when the new parse tree is empty, so an override holding nothing but white space and comments +> would leave the earlier definition in place. +> `New` rejects it rather than let the override pass unnoticed. +> +> To override a template with one that renders nothing, give it an action to run: +> +> ```gotmpl +> {{ "" }} +> ``` + +## Concurrency + +A `Repository` is immutable and lock-free. It is safe for concurrent use, as are the returned templates. + +`Clone` is the only way to derive a new `Repository` from one already built. It only reads the +repository it derives from, so cloning is safe while that repository is in use. + +New sources may be added at cloning time. The clone rebuilds the entire set, and may error. + +```go +patched, err := repo.Clone(repository, repo.FromTemplate("model.gotmpl", mine)) +``` + +**The test coverage counters are the exception to immutability**: the templates are frozen, the +counters are not. They are atomic, so rendering in parallel needs no lock. + +## A namespace for your templates + +### Addresses and names + +A template is known by three strings: the asset path it was read from, the address it was declared +at, and the name it answers to. Only the name executes it, and the address reaches the same +template through `Lookup`, so pick whichever your caller already holds. + +``` +asset path server/parameter.gotmpl the file it came from +address server/parameter where its author declared it +name serverParameter what it answers to +``` + +--- + +The **address** is the original path to the template source, where its author declared it. + +An asset is addressed at its own path, extension trimmed. Separators are normalized to `/`, so a +caller may write `server\parameter` on Windows and get the same address as everyone else. +A `define` statement is addressed under it like so: + +``` +server/parameter.gotmpl -> server/parameter +{{ define "bind-primitive" }} within it -> server/parameter/bind-primitive +``` + +`Lookup` and `MustLookup` retrieve a template by address: + +```go +tpl, err := templates.Lookup("server/parameter") +``` + +--- + +The **name** is the identity `Get` takes. It camel-cases the address, `/` counting as a word +boundary like any other: + +``` +server/parameter -> serverParameter +server/parameter/bindPrimitive -> serverParameterBindPrimitive +``` + +```go +tpl, err := templates.Get("serverParameter") +``` + +Four methods translate between the three: + +| | | +|---|---| +| `NameOf("server/parameter")` | the name an address answers to | +| `AddressOf("serverParameter")` | the address behind a name | +| `AssetOf("serverParameter")` | the file a name was read from | +| `Addresses()` | every address and name the repository holds | + +`TemplateName` computes a name without a repository, for a caller that has to choose its sources +by name before there is a repository to ask. + +The godoc carries a runnable example for each of these, over a two-template repository. Start +there if the three words have not settled yet. + +## Resolving relative references + +`{{ template "x" }}` means something relative to where an author wrote it. Two sets of templates +can therefore each have a `body` macro, and each reaches its own. + +Take this tree: + +``` +server/fred.gotmpl declares {{ define "inner-macro" }} +server/claude.gotmpl +server/operations/operation.gotmpl +client/swagger.gotmpl +``` + +A reference is looked up outward from the template holding it. Starting at `server/claude.gotmpl`, +that means: templates under `server/claude` itself, then under `server/`, then under the root. At +each step two things can match, and the first match wins: + +* a template **addressed** under that step, named by recasing its address relative to that step +* a **define** declared by an asset sitting directly in that step, named as its author wrote it + +So `server/claude.gotmpl` reaches four things four ways: + +```gotmpl +{{ template "inner-macro" }} {{/* a define of a neighbour, by its own name */}} +{{ template "fred" }} {{/* server/fred, relative to server/ */}} +{{ template "operationsOperation" }} {{/* server/operations/operation, relative */}} +{{ template "serverFredInnerMacro" }} {{/* anything at all, by its name */}} +``` + +From `client/swagger.gotmpl`, only the last one works. `client/` encloses none of those templates, +so nothing there is in reach except by name. A define never travels beyond the directory holding +it, so one set cannot capture another set's macro. + +The repository reports an error for two situations rather than guessing: + +* one name matching both a template addressed under a level and a define of that level +* two assets of a directory declaring the same bare name + +The build resolves every reference once, writing the name it addresses into the parse tree, so +nothing is resolved again while a template runs. + +### Scoping a run + +A generator ships every template it may ever need, and a single run might need only a part of them. + +Scope the repository so that a run carries only the templates it executes. + +```go +client, err := repo.Clone(repository, repo.WithRoots("clientClient", "model")) // resolves dependencies from these roots +``` + +The repository then holds the roots and whatever they reach, and nothing else. + +A root is a **name**, the identity `Get` takes, and never the address a template was declared at. +Scoping is the one place that accepts names alone: `Lookup` takes either, so convert with `NameOf` +if your caller holds addresses. + +```go +scoped, err := repo.Clone(repository, repo.WithRoots(repository.NameOf("client/client"))) +``` + +> A root that no source declares is an error: +> a filter naming a template that does not exist would build a repository that quietly generates nothing. +> Naming an address instead of a name reports that same error, and says so. + +`WithRoots` sets the scope. `WithExtraRoots` widens it. Both take names. + +> `WithExtraRoots` changes nothing on a repository that already keeps everything, +> so a caller adding a template writes the same call either way. + +```go +client, _ := repo.Clone(repository, repo.WithRoots("clientClient")) + +// "mine" is reachable from no root, so the scope has to admit it +mine, err := repo.Clone(client, + repo.FromTemplate("mine.gotmpl", body), + repo.WithExtraRoots("mine"), +) +``` + +Against a repository that keeps everything, the second call changes nothing, since `mine` is +already there. Write it the same way either way, without checking which kind of repository you +hold. + +`Roots` returns the current scope, and is empty when the repository kept everything it read. + +## Composition + +A package shipping templates publishes **sources**, not a repository. + +> A repository can be built only when everything it refers to is there. + + + +```go +// assembling two repos exposed by other packages +templates, err := repo.New( + genmodels.Sources(repo.Rebased("models")), + genclient.Sources(repo.Rebased("client")), + repo.FromDir("./mine", ""), +) +``` + +`Rebased` mounts a source under a base, on top of wherever it already mounts, so the package that +ships it chooses none of that. Mounting moves every address under the mount point, and the +references between templates move with them: a set resolves the same wherever it lands, and two +sets that each declare a `body` macro no longer collide. + +One caveat. A template that calls into another set names the address that set was mounted at, so a +package whose templates do that has an expected mount point. Document it alongside the root +templates the package exports and the data they are executed on. + +`Rebase`, `Merge` and `Coalesce` do the same to repositories that are already built. `Merge` lets +the last repository to declare an address win, `Coalesce` the first, and each combines the func +maps the same way. + +## Experimental features + +These describe a repository rather than run it, and their types live in the +[`reports`](reports/README.md) sub-package: + +```go +import ( + repo "github.com/go-openapi/codegen/templates-repo" + "github.com/go-openapi/codegen/templates-repo/reports" +) +``` + +Ten types describe a repository; executing its templates needs none of them. A program that only +renders imports `repo` alone. + +### Self-healthcheck + +`Audit` reads the assets again and returns a `reports.Audit`, listing what compiles and runs but +still deserves a look: + +* templates that more than one asset declared, and which definition stands +* templates that no other template calls +* templates that render nothing +* templates that call a function carried by their data +* funcmap entries that no template calls + +None of it is an error. `New` rejects what it cannot resolve, so everything the audit reports +already compiles and runs. + +#### Overrides and shadowed templates + +Stacking sources is how a set replaces what it needs to, so an override is intended far more often +than not and is never an error. It is still worth seeing: + +```go +report, err := repository.Audit() +if err != nil { + return err +} + +for _, override := range report.Overridden { + log.Printf("%s comes from %s, replacing %v", + override.Name, override.Standing, override.Replaced) +} +``` + +Nothing else reveals a set that replaced a template by accident. + +#### Unused templates and functions + +`Unused` lists the templates that no other template calls. `UnusedFuncs` lists the func map +entries that no template calls. + +Neither is a verdict. Nothing calls a generator's entry points either, so a repository that keeps +every template it read cannot distinguish an entry point from a template that outlived its +callers. + +Scope the repository with `WithRoots` and `Unused` comes back empty, since everything left is a +root or is reached from one. To find dead templates, audit the unscoped repository and subtract +the entry points you know about; what remains is worth a look. + +`UnusedFuncs` reads the same way. A generator that gives its templates a general-purpose library +will find most of it unused, which is expected rather than a defect. + +#### Spot dynamic calls + +The `call` builtin invokes a function carried by the data, and only at execution time. A +repository resolves everything else before a run starts, so neither the audit nor the +documentation can report what these calls reach. + +`Dynamic` lists the templates that use `call`, which at least bounds the blind spot. + +A function that no funcmap provides never reaches the audit: templates are parsed against the +funcmap, so calling a function nothing provides fails the build. + +### Self-documentation + +Templates are part of the interface a generator exposes, so a repository can document them: the +comments on each template, the data paths it reads, the functions it calls, and the templates it +calls with the data passed to each. + +```go +err = repository.Dump(w) // markdown, the common way to ask +documentation, err := repository.Documentation() // or the reports.Documentation behind it +``` + +`reports.Dump` renders a documentation on its own, which suits a document built once and laid out +several ways: + +```go +err = reports.Dump(w, documentation, reports.WithTemplate(myLayout)) +``` + +The analysis runs on demand rather than when the repository is built, so a caller that only +executes templates does not pay for it. The output is ordered throughout, so the same templates +produce the same document every time, and that document can be committed and checked in CI. + +It reports the data as a closure over every branch: what the data must be able to answer, not a +list of what it must hold. + +### Test coverage + +```go +counting, err := repo.Clone(repository, repo.WithCoverage("example.com/gen/templates")) +... +err = counting.Coverage().Flush(profile) +``` + +`go tool cover -html` renders the result. A line that never ran appears in the profile at zero, +and a line holding nothing but a `define`, an `end` or an `else` is left out, so it greys out the +way a Go declaration does. Instrumentation has to be set when the repository is built, because the +templates that execute must be the ones holding the counters. + +Two branches on one line share one counter: `{{if .A}}x{{else}}y{{end}}` reports the line covered +when either ran. Telling them apart needs column positions, which the template parser does not +report. + +## Design notes + +A record of the decisions that were not obvious. + +### Why a repository retains its sources rather than its compiled state + +`Clone` re-parses everything and copies no compiled object. An override therefore reaches the +templates that already referred to it, which earlier designs got wrong in both directions: one +mutated a shared cache and contaminated every holder, the other isolated so thoroughly that the +override never took effect. + +The price is a full parse per derivation. Derive for the settings of a program, decided once, not +per operation. + +### Why references are rewritten into the parse trees + +Authors write names relative to where they are. `text/template` executes against one flat +namespace. The build reconciles the two by resolving each reference once and writing the resolved +name into the node. + +The alternative, a namespace per template with its dependencies grafted in, costs a copy per +dependency. Rewriting costs one pass: about 0.1 ms over the go-swagger set, against about 22 ms to +parse it. + +This works only because the scope is lexical. A reference resolves by where its template was +declared, never by who invoked it. Dynamic scope would force namespaces back. + +### Why a doubly answered reference is refused + +One name can match both a template addressed under a scope and a define of that scope. Picking +either silently sends the reference somewhere the author did not mean. + +We tried precedence first, addressed-before-define. It sent a call to the wrong template and the +test suite hung on the recursion that followed. The repository now reports the ambiguity and +leaves the author to rename one of the two. + +### Why skipping directories belongs to the source + +Which directories to skip describes the file system being walked, not the repository. As a +repository-wide setting it also skipped the same directory name in template sets brought by users, +and an override placed there did nothing at all, silently. + +### Why the analysis re-parses the assets + +Execution trees drop comments, and their references have already been rewritten to names. Neither +the docstrings nor the names an author typed survive there. Assets are retained anyway, so the +analysis reads them again and maps what it finds back onto the addresses the repository holds. + +### Why composition rebuilds + +Re-addressing compiled trees would run about six times faster than parsing again. `Rebase` and +`Merge` still rebuild from the retained assets, because relative resolution then costs nothing +extra: a set that resolved on its own resolves identically once rebased, and no reference needs +recomputing. + +Take the faster path only if assembling ever lands on a hot path. diff --git a/templates-repo/audit.go b/templates-repo/audit.go new file mode 100644 index 0000000..bec0f8d --- /dev/null +++ b/templates-repo/audit.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package repo + +import ( + "maps" + "slices" + + "github.com/go-openapi/codegen/templates-repo/reports" +) + +// Audit reports what a repository holds that is worth a second look. +// +// It reads the assets again, as [Repository.Documentation] does, so a caller pays for it only by +// asking. Run it where a build can fail: it reveals a contrib set that replaced a template by +// accident, or a macro that has outlived its callers. +// +// Example: +// +// report, err := repository.Audit() +// if err != nil { +// return err +// } +// +// for _, override := range report.Overridden { +// log.Printf("%s comes from %s, replacing %v", override.Name, override.Standing, override.Replaced) +// } +func (r *Repository) Audit() (reports.Audit, error) { + documentation, err := r.Documentation() + if err != nil { + return reports.Audit{}, err + } + + report := reports.Audit{Overridden: slices.Clone(r.overrides)} + roots := r.Roots() + called := make(map[string]struct{}, len(r.settings.funcs)) + + for _, asset := range documentation.Assets { + for _, tpl := range asset.Templates { + for _, function := range tpl.Funcs { + called[function] = struct{}{} + } + + if tpl.Empty { + report.Empty = append(report.Empty, tpl.Name) + } + + if tpl.Dynamic { + report.Dynamic = append(report.Dynamic, tpl.Name) + } + + if len(tpl.UsedBy) == 0 && !slices.Contains(roots, tpl.Name) { + report.Unused = append(report.Unused, tpl.Name) + } + } + } + + for _, function := range slices.Sorted(maps.Keys(r.settings.funcs)) { + if _, reached := called[function]; !reached { + report.UnusedFuncs = append(report.UnusedFuncs, function) + } + } + + slices.Sort(report.Unused) + slices.Sort(report.Empty) + slices.Sort(report.Dynamic) + + return report, nil +} diff --git a/templates-repo/compose.go b/templates-repo/compose.go new file mode 100644 index 0000000..3418e9c --- /dev/null +++ b/templates-repo/compose.go @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package repo + +import ( + "fmt" + "path" + "slices" + "text/template" + + "github.com/go-openapi/codegen/funcmaps" +) + +// Rebase derives a repository holding the templates of another one, addressed under a base. +// +// Every address moves under base, so a repository built from server/parameter.gotmpl rebased under +// "v2" holds v2/server/parameter, answering to v2ServerParameter. +// +// What the templates refer to moves with them. A reference is resolved outward from where it was +// written, and everything it could reach is still there, one level further in, so a set that +// resolved on its own resolves the same rebased, so a repository may be assembled rather than +// only built. +// +// The repository it derives from is untouched. +// +// Example: +// +// models, err := repo.Rebase(modelTemplates, "models") +func Rebase(source *Repository, base string) (*Repository, error) { + if source == nil { + return nil, fmt.Errorf("cannot rebase a nil repository: %w", ErrTemplateRepo) + } + + under, err := cleanMountPoint(base) + if err != nil { + return nil, err + } + + if under == "" { + return nil, fmt.Errorf("rebasing needs a base to address the templates under: %w", ErrTemplateRepo) + } + + moved := make([]asset, 0, len(source.assets)) + for _, item := range source.assets { + item.path = path.Join(under, item.path) + moved = append(moved, item) + } + + return build(moved, source.layers, source.settings.derive()) +} + +// Merge derives a repository holding the templates of several, the last to declare an address +// winning. +// +// A merge exists in order to override, so a template declared twice is not an error: +// [Repository.Audit] reports which definition stands and which it replaced. Func maps are merged +// the same way, by [github.com/go-openapi/codegen/funcmaps.Merge]. +// +// Assembling sets that were written apart usually means [Rebase] first, which is what keeps their +// addresses from meeting at all. +// +// Example: +// +// templates, err := repo.Merge(scaffolding, +// must(repo.Rebase(modelTemplates, "models")), +// must(repo.Rebase(serverTemplates, "server")), +// ) +func Merge(source *Repository, merged ...*Repository) (*Repository, error) { + return compose(source, merged, "merge", func(assets []asset, _ map[string]struct{}) []asset { + return assets + }, funcmaps.Merge) +} + +// Coalesce derives a repository holding the templates of several, the first to declare an address +// winning. +// +// It is [Merge] the other way round: what a later repository declares at an address another one +// already holds is dropped rather than taking its place. Func maps are coalesced the same way, by +// [github.com/go-openapi/codegen/funcmaps.Coalesce], which also leaves the builtins alone. +// +// This is for assembling a set out of parts where the first one named is the one in charge, and a +// later one only fills what is missing. +func Coalesce(source *Repository, coalesced ...*Repository) (*Repository, error) { + return compose(source, coalesced, "coalesce", func(assets []asset, taken map[string]struct{}) []asset { + kept := make([]asset, 0, len(assets)) + for _, item := range assets { + if _, held := taken[item.path]; held { + continue // the repository named first holds this address + } + + kept = append(kept, item) + } + + return kept + }, funcmaps.Coalesce) +} + +// compose assembles the assets of several repositories into one, however the operation resolves +// what they both declare. +func compose( + source *Repository, + others []*Repository, + operation string, + keep func([]asset, map[string]struct{}) []asset, + combine func(template.FuncMap, ...template.FuncMap) template.FuncMap, +) (*Repository, error) { + if source == nil { + return nil, fmt.Errorf("cannot %s a nil repository: %w", operation, ErrTemplateRepo) + } + + settings := source.settings.derive() + assets := slices.Clone(source.assets) + layers := source.layers + taken := addressesOf(assets) + maps := make([]template.FuncMap, 0, len(others)) + + for at, other := range others { + if other == nil { + return nil, fmt.Errorf("cannot %s a nil repository, at position %d: %w", operation, at+1, ErrTemplateRepo) + } + + // layers say which source read an asset, and two repositories know nothing of each other's, + // so those of each one carry on where the last left off + moved := make([]asset, 0, len(other.assets)) + for _, item := range other.assets { + item.layer += layers + moved = append(moved, item) + } + + for _, item := range keep(moved, taken) { + assets = append(assets, item) + taken[item.path] = struct{}{} + } + + layers += other.layers + maps = append(maps, other.settings.funcs) + } + + settings.funcs = combine(settings.funcs, maps...) + + return build(assets, layers, settings) +} + +// addressesOf lists the asset paths a set of assets holds. +func addressesOf(assets []asset) map[string]struct{} { + held := make(map[string]struct{}, len(assets)) + for _, item := range assets { + held[item.path] = struct{}{} + } + + return held +} diff --git a/templates-repo/compose_test.go b/templates-repo/compose_test.go new file mode 100644 index 0000000..a4342aa --- /dev/null +++ b/templates-repo/compose_test.go @@ -0,0 +1,379 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package repo + +import ( + "slices" + "strings" + "testing" + "testing/fstest" + "text/template" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// modelSet is a self-contained set: it declares a macro and calls it, knowing nothing of any other. +func modelSet(t *testing.T) *Repository { + t.Helper() + + r, err := New(FromFS(fstest.MapFS{ + "schema.gotmpl": {Data: []byte(`{{define "body"}}MODEL-BODY{{end}}schema=[{{template "body"}}]`)}, + "model.gotmpl": {Data: []byte(`model=[{{template "schema"}}]`)}, + }, "")) + require.NoError(t, err) + + return r +} + +// serverSet declares a macro under the same name as modelSet, which is what rebasing keeps apart. +func serverSet(t *testing.T) *Repository { + t.Helper() + + r, err := New(FromFS(fstest.MapFS{ + "handler.gotmpl": {Data: []byte(`{{define "body"}}SERVER-BODY{{end}}handler=[{{template "body"}}]`)}, + }, "")) + require.NoError(t, err) + + return r +} + +func TestRebase(t *testing.T) { + t.Run("should address every template under the base", func(t *testing.T) { + moved, err := Rebase(modelSet(t), "models") + require.NoError(t, err) + + assert.Equal(t, + []string{"modelsModel", "modelsSchema", "modelsSchemaBody"}, + slices.Collect(moved.Names()), + ) + + address, found := moved.AddressOf("modelsSchemaBody") + assert.True(t, found) + assert.Equal(t, "models/schema/body", address) + }) + + t.Run("should keep what the templates refer to", func(t *testing.T) { + // everything moved together, so a reference written before the move still finds its target + moved, err := Rebase(modelSet(t), "models") + require.NoError(t, err) + + assert.Equal(t, "model=[schema=[MODEL-BODY]]", render(t, moved, "modelsModel")) + }) + + t.Run("should leave the repository it derives from alone", func(t *testing.T) { + origin := modelSet(t) + _, err := Rebase(origin, "models") + require.NoError(t, err) + + assert.Equal(t, "model=[schema=[MODEL-BODY]]", render(t, origin, "model")) + }) + + t.Run("should refuse a base that addresses nothing", func(t *testing.T) { + _, err := Rebase(modelSet(t), "") + require.ErrorIs(t, err, ErrTemplateRepo) + }) + + t.Run("should refuse a nil repository", func(t *testing.T) { + _, err := Rebase(nil, "models") + require.ErrorIs(t, err, ErrTemplateRepo) + }) +} + +func TestMerge(t *testing.T) { + t.Run("should assemble sets rebased apart", func(t *testing.T) { + models, err := Rebase(modelSet(t), "models") + require.NoError(t, err) + servers, err := Rebase(serverSet(t), "server") + require.NoError(t, err) + + all, err := Merge(models, servers) + require.NoError(t, err) + + // both sets declare a "body" macro, and each still reaches its own + assert.Equal(t, "model=[schema=[MODEL-BODY]]", render(t, all, "modelsModel")) + assert.Equal(t, "handler=[SERVER-BODY]", render(t, all, "serverHandler")) + }) + + t.Run("should let the last to declare an address win, and report it", func(t *testing.T) { + first, err := New(FromTemplate("leaf.gotmpl", []byte("FIRST"))) + require.NoError(t, err) + second, err := New(FromTemplate("leaf.gotmpl", []byte("SECOND"))) + require.NoError(t, err) + + all, err := Merge(first, second) + require.NoError(t, err) + + assert.Equal(t, "SECOND", render(t, all, "leaf")) + + report, err := all.Audit() + require.NoError(t, err) + require.Len(t, report.Overridden, 1) + assert.Equal(t, "leaf", report.Overridden[0].Name) + }) + + t.Run("should merge the functions the templates may call", func(t *testing.T) { + first, err := New( + FromTemplate("leaf.gotmpl", []byte(`{{ shout "x" }}{{ mine }}`)), + WithFuncMap(template.FuncMap{ + "shout": func(string) string { return "FIRST" }, + "mine": func() string { return "MINE" }, + }), + ) + require.NoError(t, err) + + second, err := New(WithFuncMap(template.FuncMap{"shout": func(string) string { return "SECOND" }})) + require.NoError(t, err) + + all, err := Merge(first, second) + require.NoError(t, err) + + assert.Equal(t, "SECONDMINE", render(t, all, "leaf")) + }) + + t.Run("should refuse a nil repository among those it assembles", func(t *testing.T) { + _, err := Merge(modelSet(t), nil) + require.ErrorIs(t, err, ErrTemplateRepo) + }) +} + +// A scaffolding that calls into the sets it is assembled with cannot stand on its own, so the sets +// are declared as sources of the same build rather than merged after the fact. +func TestFromRepository(t *testing.T) { + t.Run("should assemble a scaffolding with the sets it calls into", func(t *testing.T) { + all, err := New( + FromTemplate("app.gotmpl", []byte(`app=[{{template "modelsModel"}} {{template "serverHandler"}}]`)), + FromRepository(modelSet(t), "models"), + FromRepository(serverSet(t), "server"), + ) + require.NoError(t, err) + + assert.Equal(t, "app=[model=[schema=[MODEL-BODY]] handler=[SERVER-BODY]]", render(t, all, "app")) + }) + + t.Run("should keep each set reaching its own macros", func(t *testing.T) { + all, err := New( + FromRepository(modelSet(t), "models"), + FromRepository(serverSet(t), "server"), + ) + require.NoError(t, err) + + // both declare "body", and neither captures the other + assert.Equal(t, "schema=[MODEL-BODY]", render(t, all, "modelsSchema")) + assert.Equal(t, "handler=[SERVER-BODY]", render(t, all, "serverHandler")) + }) + + t.Run("should refuse a nil repository", func(t *testing.T) { + _, err := New(FromRepository(nil, "models")) + require.ErrorIs(t, err, ErrTemplateRepo) + }) + + t.Run("should leave the repository it reads alone", func(t *testing.T) { + origin := modelSet(t) + _, err := New(FromRepository(origin, "models")) + require.NoError(t, err) + + assert.Equal(t, "model=[schema=[MODEL-BODY]]", render(t, origin, "model")) + }) +} + +func TestCoalesce(t *testing.T) { + t.Run("should let the first to declare an address win", func(t *testing.T) { + first, err := New(FromTemplate("leaf.gotmpl", []byte("FIRST"))) + require.NoError(t, err) + second, err := New( + FromTemplate("leaf.gotmpl", []byte("SECOND")), + FromTemplate("other.gotmpl", []byte("OTHER")), + ) + require.NoError(t, err) + + all, err := Coalesce(first, second) + require.NoError(t, err) + + assert.Equal(t, "FIRST", render(t, all, "leaf"), "what the first holds stands") + assert.Equal(t, "OTHER", render(t, all, "other"), "and the rest fills in") + report, err := all.Audit() + require.NoError(t, err) + assert.Empty(t, report.Overridden, "nothing was replaced, so nothing is reported") + }) + + t.Run("should coalesce the functions the templates may call", func(t *testing.T) { + first, err := New( + FromTemplate("leaf.gotmpl", []byte(`{{ shout "x" }}`)), + WithFuncMap(template.FuncMap{"shout": func(string) string { return "FIRST" }}), + ) + require.NoError(t, err) + + second, err := New(WithFuncMap(template.FuncMap{"shout": func(string) string { return "SECOND" }})) + require.NoError(t, err) + + all, err := Coalesce(first, second) + require.NoError(t, err) + + assert.Equal(t, "FIRST", render(t, all, "leaf")) + }) +} + +// The shape a package publishing templates takes: it hands out sources, not a repository, and +// whoever assembles them says where each one lands. +// +// genmodels ships model templates; genclient ships client templates that call into the models and +// cannot stand on their own. Neither knows where the other will be mounted. +func genmodelsSources(opts ...SourceOption) Option { + return Sources( + FromFS(fstest.MapFS{ + "schema.gotmpl": {Data: []byte(`{{define "body"}}MODEL-BODY{{end}}schema=[{{template "body"}}]`)}, + "goModel.gotmpl": {Data: []byte(`goModel=[{{template "schema"}}]`)}, + }, "", opts...), + FromFS(fstest.MapFS{ + "goModel/target.gotmpl": {Data: []byte(`models`)}, + }, "paths", opts...), + ) +} + +func genclientSources(opts ...SourceOption) Option { + return Sources( + FromFS(fstest.MapFS{ + // its own macro, under the name the model set also uses + "operation.gotmpl": {Data: []byte(`{{define "body"}}OP-BODY{{end}}operation=[{{template "body"}}]`)}, + // and a call into the model set, which only resolves once both are assembled + "client.gotmpl": {Data: []byte(`client=[{{template "operation"}} {{template "modelsGoModel"}}]`)}, + }, "", opts...), + ) +} + +func TestPublishedSources(t *testing.T) { + t.Run("should assemble packages that know nothing of each other", func(t *testing.T) { + templates, err := New( + genmodelsSources(Rebased("models")), + genclientSources(Rebased("client")), + ) + require.NoError(t, err) + + assert.Equal(t, + "client=[operation=[OP-BODY] goModel=[schema=[MODEL-BODY]]]", + render(t, templates, "clientClient"), + ) + }) + + t.Run("should keep each package reaching its own macros", func(t *testing.T) { + templates, err := New( + genmodelsSources(Rebased("models")), + genclientSources(Rebased("client")), + ) + require.NoError(t, err) + + // both declare "body", and each still gets its own + assert.Equal(t, "schema=[MODEL-BODY]", render(t, templates, "modelsSchema")) + assert.Equal(t, "operation=[OP-BODY]", render(t, templates, "clientOperation")) + }) + + t.Run("should carry a rebase into every source a package publishes", func(t *testing.T) { + templates, err := New(genmodelsSources(Rebased("models"))) + require.NoError(t, err) + + // the second source of the package mounts at "paths", under the base the caller asked for + assert.True(t, templates.Has("modelsPathsGoModelTarget")) + }) + + t.Run("should let the assembler add sources of its own", func(t *testing.T) { + templates, err := New( + genmodelsSources(Rebased("models")), + genclientSources(Rebased("client")), + FromTemplate("app.gotmpl", []byte(`app=[{{template "clientClient"}}]`)), + ) + require.NoError(t, err) + + assert.Equal(t, + "app=[client=[operation=[OP-BODY] goModel=[schema=[MODEL-BODY]]]]", + render(t, templates, "app"), + ) + }) + + t.Run("should mount a package wherever the assembler says", func(t *testing.T) { + // the same packages, laid out differently: only the assembler's call changes + templates, err := New( + genmodelsSources(Rebased("v2/models")), + FromTemplate("app.gotmpl", []byte(`app=[{{template "v2ModelsGoModel"}}]`)), + ) + require.NoError(t, err) + + assert.Equal(t, "app=[goModel=[schema=[MODEL-BODY]]]", render(t, templates, "app")) + }) +} + +func TestLookup(t *testing.T) { + r, err := New(FromFS(fstest.MapFS{ + "server/parameter.gotmpl": {Data: []byte(`{{define "bind-primitive"}}BIND{{end}}param=[{{template "bind-primitive"}}]`)}, + }, "")) + require.NoError(t, err) + + t.Run("should find a template by the address it was declared at", func(t *testing.T) { + tpl, err := r.Lookup("server/parameter") + require.NoError(t, err) + + var out strings.Builder + require.NoError(t, tpl.Execute(&out, nil)) + assert.Equal(t, "param=[BIND]", out.String()) + }) + + t.Run("should find a define under the asset declaring it", func(t *testing.T) { + tpl, err := r.Lookup("server/parameter/bind-primitive") + require.NoError(t, err) + + var out strings.Builder + require.NoError(t, tpl.Execute(&out, nil)) + assert.Equal(t, "BIND", out.String()) + }) + + t.Run("should take the extension the address may carry", func(t *testing.T) { + _, err := r.Lookup("server/parameter.gotmpl") + require.NoError(t, err) + }) + + t.Run("should read a backslash as a separator", func(t *testing.T) { + _, err := r.Lookup(`server\parameter`) + require.NoError(t, err) + }) + + t.Run("should report an address nothing is declared at", func(t *testing.T) { + _, err := r.Lookup("server/nowhere") + require.ErrorIs(t, err, ErrTemplateRepo) + }) + + t.Run("should panic on an address nothing is declared at", func(t *testing.T) { + assert.Panics(t, func() { r.MustLookup("server/nowhere") }) + }) + + t.Run("should agree with Get", func(t *testing.T) { + for address, key := range r.Addresses() { + byAddress, err := r.Lookup(address) + require.NoError(t, err) + byKey, err := r.Get(key) + require.NoError(t, err) + + assert.Equal(t, byKey.Name(), byAddress.Name()) + } + }) +} + +func TestSeparators(t *testing.T) { + t.Run("should hold the same address whatever separator a caller writes", func(t *testing.T) { + r, err := New( + FromTemplate(`server\parameter.gotmpl`, []byte("PARAM")), + FromTemplate("server/other.gotmpl", []byte(`other=[{{template "parameter"}}]`)), + ) + require.NoError(t, err) + + assert.True(t, r.Has("serverParameter")) + assert.Equal(t, "other=[PARAM]", render(t, r, "serverOther")) + }) + + t.Run("should read a backslash in a mount point", func(t *testing.T) { + r, err := New(FromFS(fstest.MapFS{"leaf.gotmpl": {Data: []byte("LEAF")}}, `a\b`)) + require.NoError(t, err) + + assert.True(t, r.Has("aBLeaf")) + }) +} diff --git a/templates-repo/coverage_test.go b/templates-repo/coverage_test.go new file mode 100644 index 0000000..e285e90 --- /dev/null +++ b/templates-repo/coverage_test.go @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package repo + +import ( + "strings" + "sync" + "testing" + "testing/fstest" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// coveredAssets hold every shape instrumentation has to leave alone: trim markers, a define, a +// branch never taken, a range, and a template calling another. +func coveredAssets() fstest.MapFS { + return fstest.MapFS{ + "page.gotmpl": {Data: []byte( + "{{- define \"row\" -}}\n" + + "row: {{ .Name }}\n" + + "{{- end -}}\n" + + "{{ if .Show }}\n" + + "shown\n" + + "{{- range .Items }}\n" + + "{{ template \"row\" . }}\n" + + "{{- end }}\n" + + "{{- else }}\n" + + "hidden\n" + + "{{- end }}\n", + )}, + } +} + +type coverItem struct{ Name string } + +type coverData struct { + Show bool + Items []coverItem +} + +func renderPage(t *testing.T, r *Repository, data coverData) string { + t.Helper() + + tpl, err := r.Get("page") + require.NoError(t, err) + + var out strings.Builder + require.NoError(t, tpl.Execute(&out, data)) + + return out.String() +} + +// TestCoverageLeavesOutputAlone guards the property everything else rests on: a repository that +// counts renders exactly what a repository that does not renders. +func TestCoverageLeavesOutputAlone(t *testing.T) { + for _, data := range []coverData{ + {Show: true, Items: []coverItem{{"a"}, {"b"}}}, + {Show: true}, + {Show: false}, + } { + plain, err := New(FromFS(coveredAssets(), "")) + require.NoError(t, err) + + counting, err := New(FromFS(coveredAssets(), ""), WithCoverage("example.com/templates")) + require.NoError(t, err) + + assert.Equal(t, renderPage(t, plain, data), renderPage(t, counting, data), + "instrumenting must not change what a template renders") + } +} + +func TestCoverageProfile(t *testing.T) { + r, err := New(FromFS(coveredAssets(), ""), WithCoverage("example.com/templates")) + require.NoError(t, err) + + require.NotNil(t, r.Coverage()) + _ = renderPage(t, r, coverData{Show: true, Items: []coverItem{{"a"}, {"b"}}}) + + var out strings.Builder + require.NoError(t, r.Coverage().Flush(&out)) + profile := out.String() + + t.Logf("profile:\n%s", profile) + + t.Run("should open with the mode the counts are in", func(t *testing.T) { + assert.True(t, strings.HasPrefix(profile, "mode: count\n")) + }) + + t.Run("should hold a line that never ran, at zero", func(t *testing.T) { + assert.Contains(t, profile, "example.com/templates/page.gotmpl:10.1,10.7 1 0", + "the else branch never runs, and has to be in the profile all the same") + }) + + t.Run("should count a line as many times as it ran", func(t *testing.T) { + assert.Contains(t, profile, "example.com/templates/page.gotmpl:2.1,2.17 1 2", + "the row template runs once per item") + }) + + t.Run("should cover a whole line, never a block of no width", func(t *testing.T) { + for _, line := range strings.Split(strings.TrimSpace(profile), "\n")[1:] { + assert.NotContains(t, line, ".0,", "a column of zero makes go tool cover write broken html") + } + }) + + t.Run("should leave a line holding only a define, an end or an else out", func(t *testing.T) { + for _, line := range []string{":1.", ":3.", ":9.", ":11."} { + assert.NotContains(t, profile, "page.gotmpl"+line, + "a line that runs nothing renders as plain text, the way a go declaration does") + } + }) + + t.Run("should report what it counts", func(t *testing.T) { + counted, reached := r.Coverage().Lines() + assert.Positive(t, counted) + assert.Less(t, reached, counted, "the else branch is not reached") + }) + + t.Run("should write the same profile twice", func(t *testing.T) { + var again strings.Builder + require.NoError(t, r.Coverage().Flush(&again)) + assert.Equal(t, profile, again.String()) + }) +} + +func TestCoverageOptions(t *testing.T) { + t.Run("should leave a plain repository without a profile", func(t *testing.T) { + r, err := New(FromFS(coveredAssets(), "")) + require.NoError(t, err) + + assert.Nil(t, r.Coverage()) + }) + + t.Run("should refuse coverage without the path the templates live under", func(t *testing.T) { + _, err := New(FromFS(coveredAssets(), ""), WithCoverage(" ")) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrTemplateRepo) + }) + + t.Run("should yield an instrumented twin of a plain repository", func(t *testing.T) { + plain, err := New(FromFS(coveredAssets(), "")) + require.NoError(t, err) + + counting, err := Clone(plain, WithCoverage("example.com/templates")) + require.NoError(t, err) + + assert.Nil(t, plain.Coverage()) + require.NotNil(t, counting.Coverage()) + + _ = renderPage(t, counting, coverData{Show: true}) + _, reached := counting.Coverage().Lines() + assert.Positive(t, reached) + }) +} + +// TestCoverageConcurrent runs the counters the way a generator would, under -race. +func TestCoverageConcurrent(t *testing.T) { + r, err := New(FromFS(coveredAssets(), ""), WithCoverage("example.com/templates")) + require.NoError(t, err) + + const runners, runs = 16, 25 + + var wg sync.WaitGroup + wg.Add(runners) + + for range runners { + go func() { + defer wg.Done() + + for range runs { + _ = renderPage(t, r, coverData{Show: true, Items: []coverItem{{"a"}}}) + } + }() + } + + wg.Wait() + + var out strings.Builder + require.NoError(t, r.Coverage().Flush(&out)) + assert.Contains(t, out.String(), "page.gotmpl:5.1,5.6 1 400", + "every run of every runner is counted") +} diff --git a/templates-repo/dependencies.go b/templates-repo/dependencies.go new file mode 100644 index 0000000..f0a46c7 --- /dev/null +++ b/templates-repo/dependencies.go @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package repo + +import ( + "fmt" + "slices" + "strconv" + "strings" + "text/template/parse" +) + +// retainedNames selects the templates a repository keeps, from the roots the caller named. +// +// A repository asked for no root keeps everything. Otherwise it keeps the roots and whatever they +// reach: a template referring to another one keeps it, all the way down, and a loop of templates +// stops at the ones already kept. +// +// A root no source declares is an error. Nothing else would report it, since a filter naming a +// template that does not exist builds a repository that quietly generates nothing. +func retainedNames(byKey map[string]*declared, roots []string) (map[string]struct{}, error) { + retained := make(map[string]struct{}, len(byKey)) + + if len(roots) == 0 { + for name := range byKey { + retained[name] = struct{}{} + } + + return retained, nil + } + + var undeclared []string + for _, root := range roots { + if _, found := byKey[root]; !found { + undeclared = append(undeclared, strconv.Quote(root)) + } + } + + if len(undeclared) > 0 { + slices.Sort(undeclared) + + return nil, fmt.Errorf( + "no source declares the root template %s: a root is a name, the identity Get takes, "+ + "not the address it was declared at: %w", + strings.Join(undeclared, ", "), ErrTemplateRepo) + } + + pending := slices.Clone(roots) + for len(pending) > 0 { + name := pending[len(pending)-1] + pending = pending[:len(pending)-1] + + item, found := byKey[name] + if !found { + continue // a reference that resolved to nothing, reported once the scope is known + } + + if _, kept := retained[name]; kept { + continue + } + + retained[name] = struct{}{} + if item.tree != nil { + pending = append(pending, dependenciesOf(item.tree.Root)...) + } + } + + return retained, nil +} + +// dependenciesOf collects the names a template refers to, sorted and deduplicated. +// +// Only the nodes that may hold a template invocation are walked. An action holds an expression, +// never an invocation, so it has no child worth visiting. +func dependenciesOf(node parse.Node) []string { + found := make(map[string]struct{}) + collectDependencies(node, found) + + dependencies := make([]string, 0, len(found)) + for name := range found { + if name == "" { + continue + } + + dependencies = append(dependencies, name) + } + + slices.Sort(dependencies) + + return dependencies +} + +// collectDependencies walks a parse tree and records every template invocation it holds. +func collectDependencies(node parse.Node, found map[string]struct{}) { + switch typed := node.(type) { + case *parse.ListNode: + if typed == nil { + return + } + + for _, child := range typed.Nodes { + collectDependencies(child, found) + } + + case *parse.IfNode: + collectDependencies(typed.List, found) + collectDependencies(typed.ElseList, found) + + case *parse.RangeNode: + collectDependencies(typed.List, found) + collectDependencies(typed.ElseList, found) + + case *parse.WithNode: + collectDependencies(typed.List, found) + collectDependencies(typed.ElseList, found) + + case *parse.TemplateNode: + found[typed.Name] = struct{}{} + } +} diff --git a/templates-repo/doc.go b/templates-repo/doc.go new file mode 100644 index 0000000..450fcf7 --- /dev/null +++ b/templates-repo/doc.go @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package repo compiles a set of Go text templates into an immutable repository. +// +// A code generator ships a default set of templates and lets its users override some of them. +// This package holds that set. It reads assets from an [io/fs.FS], a directory or a byte slice, +// parses them in a single pass so that every template can call every other, +// and returns them ready to execute from [Repository.Get]. +// +// # Usage +// +// A repository is built once, from sources declared as options: +// +// repository, err := repo.New( +// repo.FromFS(assets, ""), +// repo.WithFuncMap(funcs), +// ) +// if err != nil { +// return err +// } +// +// tpl, err := repository.Get("validationPrimitive") +// if err != nil { +// return err +// } +// +// err = tpl.Execute(w, data) +// +// A repository is immutable once [New] returns. To change the set, derive a new repository +// with [Clone]: +// +// patched, err := repo.Clone(repository, repo.FromTemplate("validation/primitive", mine)) +// +// [Clone] re-parses every asset, so a template that calls the overridden one calls the new +// definition. The two repositories share no state. +// +// Every error this package reports matches [ErrTemplateRepo] and wraps its cause, +// so a caller may also match a parse error or an [io/fs] error with [errors.Is] and [errors.As]. +// +// # Naming +// +// A template is known by three strings: the asset path it was read from, the address it was +// declared at, and the name it answers to. The address is the asset path with the extension +// trimmed, slash-separated and otherwise untouched. The name is that address recased, "/" counting +// as a word boundary like any other. +// +// validation/primitive.gotmpl -> address validation/primitive, name validationPrimitive +// server/parameter.gotmpl -> address server/parameter, name serverParameter +// model.gotmpl -> address model, name model +// +// [Repository.Get] takes a name and [Repository.Lookup] takes an address. [Repository.NameOf] maps +// an address to a name, [Repository.AddressOf] maps it back, and [Repository.Addresses] iterates +// over both. [TemplateName] computes a name before any repository exists, which a caller needs +// when the templates it is about to declare are themselves chosen by name. +// +// A "define" statement declares a further template, addressed under the asset that holds it: +// +// server/fred.gotmpl holding {{define "inner-macro"}} +// -> address server/fred/inner-macro, name serverFredInnerMacro +// +// Paths are slash-separated whatever the platform, so a caller may write "server\parameter" on +// Windows and reach the same template as everyone else. +// +// Two assets of the same source declaring one name is an error, since no source is read after the +// other. Across sources, the last declaration wins. +// +// # Overriding +// +// Sources are read in the order they are declared, and the last declaration of a name wins. +// There is no other precedence rule: a source cannot mark a template as final. +// +// Stacking whole sets of templates is a file system concern rather than a repository one. +// Merge the sets into one [io/fs.FS] with [github.com/go-openapi/swag/fileutils.NewOverlayFS], +// then pass the result to [FromFS]. +// +// [SkipDirectories] attaches to one source, not to the build. Skipping "internal" in your own +// assets leaves an "internal" directory in a set someone else brings fully readable. +// +// It matches a directory by its own name, the last segment of its path, at any depth. It matches +// neither a path nor a template name: it decides what a source reads, before anything is named. +// +// [Repository.Audit] lists every name that more than one asset declared, with the definition that +// stands and the ones it replaced. Run it where a build can fail, so that a contrib set which +// shadows a template by accident is caught before it ships. It returns a +// [github.com/go-openapi/codegen/templates-repo/reports.Audit], which covers more than overrides. +// +// # Scoping +// +// A generator ships every template it may ever need, and one run uses a fraction of them. +// [WithRoots] keeps the named templates and everything they call, and prunes the rest: +// +// client, err := repo.Clone(repository, repo.WithRoots("clientClient", "model")) +// +// A root is a name, the identity [Repository.Get] takes, and never the address a template was +// declared at. Scoping is the one place that accepts names alone: [Repository.Lookup] takes +// either, so a caller holding addresses converts them with [Repository.NameOf] first. +// Naming an address reports an error rather than building an empty repository. +// +// A pruned template is gone from the repository: [Repository.Names] does not list it, +// [Repository.Documentation] does not describe it, and [Repository.Coverage] does not count it. +// Every asset is still read and parsed, because a template only announces its name once parsed. +// The assets are retained whole, so a later [Clone] with [WithExtraRoots] widens the scope again. +// +// [Repository.Roots] returns the current scope, and is empty when the repository kept everything +// it read. +// +// # Assembling +// +// A repository may itself be a source. [FromRepository] reads what one holds and mounts it at a +// chosen point, so two sets written independently are assembled without either knowing about the +// other: +// +// templates, err := repo.New( +// repo.FromDir("./scaffolding", ""), +// repo.FromRepository(modelTemplates, "models"), +// repo.FromRepository(serverTemplates, "server"), +// ) +// +// Mounting moves every address under the mount point, and the references between templates move +// with them, so a set that resolved on its own resolves the same mounted. Two sets that each +// define a macro called "header" no longer collide, because their addresses now differ. +// +// A package that ships templates should therefore export sources rather than a repository. +// A scaffolding that calls into the parts it is assembled with cannot be built on its own, +// so those parts have to be sources of the same build: +// +// // what the package exports, knowing nothing of where it lands +// func Sources(opts ...repo.SourceOption) repo.Option { +// return repo.Sources( +// repo.FromFS(templates, "", opts...), +// repo.FromFS(filepaths, "paths", opts...), +// ) +// } +// +// One caveat is worth stating. A template that calls into another set names the address that set +// was mounted at, so a package whose templates do that has an expected mount point. +// Document it alongside the templates the package exports and the data they are executed on. +// A set that calls into nothing may be mounted anywhere. +// +// [Rebase], [Merge] and [Coalesce] do the same to repositories that are already built. +// [Rebase] moves every address under a base. [Merge] lets the last repository to declare an +// address win, and [Coalesce] lets the first. All three need each part to build on its own. +// +// # Documentation and audit +// +// Templates are part of the interface a generator exposes, so a repository can document them. +// [Repository.Documentation] reads the assets again, comments included, and reports: +// +// - the comments documenting each template, attached as a Go author expects, a comment group +// placed immediately before a "define" statement documenting that template +// - the data paths each template reads, and which of them it reaches through the root +// - the functions it calls, and the templates it calls, with the data passed to each +// +// [Repository.Dump] renders that model as markdown: +// +// err = repository.Dump(w) +// +// The analysis runs on demand rather than when the repository is built, so a caller that only +// executes templates does not pay for it. The output is ordered throughout, so the same templates +// produce the same document every time, and that document can be committed and checked in CI. +// +// The types these methods return live in a package of their own, +// [github.com/go-openapi/codegen/templates-repo/reports]. Describing a repository takes ten types; +// executing its templates takes none of them, so a program that only renders imports neither the +// documentation model nor the audit report. That package also renders a document on its own, +// through reports.Dump, which suits a document built once and laid out several ways. +// +// # Coverage +// +// [WithCoverage] instruments a repository to count the lines of its templates that execute. +// It has to be set when the repository is built, because the templates that run must be the ones +// holding the counters. [Clone] carries the setting over, so a plain repository clones into an +// instrumented one: +// +// counting, err := repo.Clone(repository, repo.WithCoverage("example.com/gen/templates")) +// +// The prefix is prepended to the path of every asset in the profile. go tool cover resolves the +// file a profile names by asking go list, so the paths have to read as an import path of a +// package that exists. That is why the prefix is required. +// +// [Repository.Coverage] returns a profile in the format go test writes, which go tool cover +// renders as html. A line that never ran appears at zero rather than being absent. A line holding +// nothing but a define, an end or an else is left out, so it greys out the way a Go declaration +// does. +// +// # Concurrency +// +// A repository is immutable and safe for concurrent use, as are the [Template] values it returns. +// [Clone] only reads the repository it derives from. +// +// The coverage counters are the one part that changes after a build. They are atomic, so a +// generator rendering templates in parallel needs no lock. +package repo diff --git a/templates-repo/documentation.go b/templates-repo/documentation.go new file mode 100644 index 0000000..0590512 --- /dev/null +++ b/templates-repo/documentation.go @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package repo + +import ( + "fmt" + "io" + "maps" + "slices" + "strings" + + "github.com/go-openapi/codegen/templates-repo/internal/document" + "github.com/go-openapi/codegen/templates-repo/reports" +) + +// Documentation returns the structure of the repository and the documentation of its templates. +// +// The analysis runs here rather than while the repository is built: comments are dropped from the +// trees a template executes, and the data a template reads is of no use to executing it. The +// repository holds its sources, so both are recovered by reading them again, which only a caller +// asking for documentation pays for. +// +// The result is built on demand and shared with nobody, so a caller may hold it, walk it, or +// render it in whatever format. +func (r *Repository) Documentation() (reports.Documentation, error) { + analysed, err := r.analyse() + if err != nil { + return reports.Documentation{}, err + } + + usedBy := r.reverseDependencies(analysed) + + contracts := make(map[string]document.Contract, len(analysed)) + for name, found := range analysed { + contracts[name] = found.contract + } + closed := document.Closure(contracts) + + byAsset := make(map[string][]reports.Template, len(r.declarations)) + for _, name := range r.names { + declared := r.declarations[name] + contract := analysed[name].contract + + byAsset[declared.assetPath] = append(byAsset[declared.assetPath], reports.Template{ + Name: name, + Doc: analysed[name].doc, + Reads: contract.Reads, + RootReads: contract.RootReads, + Funcs: contract.Funcs, + Dependencies: dependenciesOfContract(contract, closed), + UsedBy: usedBy[name], + Inner: name != r.settings.templateName(declared.assetPath), + Empty: contract.Empty, + Unresolved: contract.Unresolved, + Dynamic: contract.Dynamic, + Transitive: transitiveOf(closed[name]), + }) + } + + documentation := reports.Documentation{Assets: make([]reports.Asset, 0, len(byAsset))} + for _, path := range slices.Sorted(maps.Keys(byAsset)) { + templates := byAsset[path] + + // the template named after the asset comes first, the "define" statements follow by name + slices.SortFunc(templates, func(a, b reports.Template) int { + if a.Inner != b.Inner { + if a.Inner { + return 1 + } + + return -1 + } + + return strings.Compare(a.Name, b.Name) + }) + + documentation.Assets = append(documentation.Assets, reports.Asset{Path: path, Templates: templates}) + } + + return documentation, nil +} + +// analysed holds the result of re-reading an asset, for one template name. +type analysed struct { + doc []string + contract document.Contract +} + +// analyse reads the assets again and keeps, per name, what the asset that declares it reported. +// +// An asset overridden by a later one still gets analysed, and its findings are then replaced, so +// the documentation stays aligned with the templates the repository actually holds. +func (r *Repository) analyse() (map[string]analysed, error) { + found := make(map[string]analysed, len(r.names)) + + for _, item := range r.assets { + owner := r.settings.trimmedPath(item.path) + + analysis, err := document.Analyze(item.path, owner, item.data, r.settings.funcs) + if err != nil { + return nil, fmt.Errorf("%w: %w", err, ErrTemplateRepo) + } + + // the analysis reads the source again, so it sees what an author declared rather than the + // address it landed at: each is placed back where the repository holds it + for declaredName, contract := range analysis.Contracts { + key := TemplateName(addressOf(owner, declaredName)) + if r.declarations[key].assetPath != item.path { + continue // another asset declares this address + } + + found[key] = analysed{ + doc: analysis.Docstrings[declaredName], + contract: r.resolvedContract(key, contract), + } + } + } + + return found, nil +} + +// resolvedContract names the templates a contract calls the way the repository holds them. +// +// A template refers to another one the way its author saw the tree, and the repository settled +// what that addresses when it was built. The analysis reads the source again, so it sees the +// references as written and needs the same answer. +func (r *Repository) resolvedContract(name string, contract document.Contract) document.Contract { + resolved := r.resolutions[name] + if len(resolved) == 0 { + return contract + } + + calls := make([]document.Call, 0, len(contract.Calls)) + for _, call := range contract.Calls { + if key, found := resolved[call.Name]; found { + call.Name = key + } + + calls = append(calls, call) + } + + contract.Calls = calls + + return contract +} + +// transitiveOf maps a folded contract onto the exported model. +func transitiveOf(folded document.Transitive) reports.Transitive { + return reports.Transitive{ + Reads: folded.Reads, + Funcs: folded.Funcs, + Reaches: folded.Reaches, + Unresolved: folded.Unresolved, + Recursive: folded.Recursive, + } +} + +// dependenciesOfContract turns the calls a template makes into its dependencies. +func dependenciesOfContract(contract document.Contract, closed map[string]document.Transitive) []reports.Dependency { + dependencies := make([]reports.Dependency, 0, len(contract.Calls)) + for _, call := range contract.Calls { + dependencies = append(dependencies, reports.Dependency{ + Name: call.Name, + Data: call.Data, + Folded: len(closed[call.Name].Reads), + }) + } + + return dependencies +} + +// reverseDependencies inverts the call graph, so that every template lists its callers. +func (r *Repository) reverseDependencies(analysed map[string]analysed) map[string][]string { + usedBy := make(map[string][]string, len(r.names)) + + for _, name := range r.names { + for _, call := range analysed[name].contract.Calls { + if !slices.Contains(usedBy[call.Name], name) { + usedBy[call.Name] = append(usedBy[call.Name], name) + } + } + } + + for dependency := range usedBy { + slices.Sort(usedBy[dependency]) + } + + return usedBy +} + +// Dump writes the documentation of the repository, as markdown by default. +// +// It is [reports.Dump] over what [Repository.Documentation] returns, which is the common way to +// ask. Use reports.Dump directly to lay out a document built once and rendered several ways. +func (r *Repository) Dump(w io.Writer, opts ...reports.DumpOption) error { + documentation, err := r.Documentation() + if err != nil { + return err + } + + if err := reports.Dump(w, documentation, opts...); err != nil { + // a caller of this method matches the error of this package, whichever one reports it + return fmt.Errorf("%w: %w", err, ErrTemplateRepo) + } + + return nil +} diff --git a/templates-repo/documentation_test.go b/templates-repo/documentation_test.go new file mode 100644 index 0000000..43f6062 --- /dev/null +++ b/templates-repo/documentation_test.go @@ -0,0 +1,348 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package repo + +import ( + "strings" + "testing" + "testing/fstest" + "text/template" + + "github.com/go-openapi/codegen/templates-repo/reports" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// documentedAssets exercise every placement a comment may have. +func documentedAssets() fstest.MapFS { + return fstest.MapFS{ + "folder/documented.gotmpl": {Data: []byte( + "{{/* documented renders a model. */}}\n" + + "{{/* It has a second line of doc. */}}\n" + + "\n" + + "{{/* macro expands the body of a schema. */}}\n" + + `{{define "macro"}}{{/* a note, not a docstring */}}body{{end}}` + "\n" + + "\n" + + "{{/* other does something else. */}}\n" + + `{{define "other"}}other{{end}}` + "\n" + + `content {{template "macro"}} {{template "other"}}` + "\n" + + "{{/* a trailing note, documenting nothing */}}\n", + )}, + "caller.gotmpl": {Data: []byte("{{/* caller uses the macro. */}}\n" + `{{template "folderDocumentedMacro"}}`)}, + } +} + +func documentedRepository(t *testing.T) *Repository { + t.Helper() + + r, err := New(FromFS(documentedAssets(), "")) + require.NoError(t, err) + + return r +} + +// docsOf collects the docstrings of a repository, by template name. +func docsOf(t *testing.T, r *Repository) map[string][]string { + t.Helper() + + documentation, err := r.Documentation() + require.NoError(t, err) + + docs := make(map[string][]string) + for _, asset := range documentation.Assets { + for _, tpl := range asset.Templates { + if len(tpl.Doc) > 0 { + docs[tpl.Name] = tpl.Doc + } + } + } + + return docs +} + +func TestDocstrings(t *testing.T) { + r := documentedRepository(t) + docstrings := docsOf(t, r) + + t.Run("should document an asset with the comments it opens with", func(t *testing.T) { + assert.Equal(t, + []string{"documented renders a model.", "It has a second line of doc."}, + docstrings["folderDocumented"], + ) + }) + + t.Run("should document a define with the comments right before it", func(t *testing.T) { + assert.Equal(t, []string{"macro expands the body of a schema."}, docstrings["folderDocumentedMacro"]) + assert.Equal(t, []string{"other does something else."}, docstrings["folderDocumentedOther"]) + }) + + t.Run("should ignore a comment that documents nothing", func(t *testing.T) { + for _, docs := range docstrings { + for _, doc := range docs { + assert.NotContains(t, doc, "not a docstring") + assert.NotContains(t, doc, "documenting nothing") + } + } + }) + + t.Run("should record a docstring once", func(t *testing.T) { + for name, docs := range docstrings { + seen := make(map[string]bool, len(docs)) + for _, doc := range docs { + assert.Falsef(t, seen[doc], "%q holds %q twice", name, doc) + seen[doc] = true + } + } + }) + + t.Run("should strip the comment marks and the space around them", func(t *testing.T) { + assert.Equal(t, "caller uses the macro.", docstrings["caller"][0]) + }) + + t.Run("should not document an asset whose comment comes after content", func(t *testing.T) { + plain, err := New( + FromFS(fstest.MapFS{"a.gotmpl": {Data: []byte("content\n{{/* too late to be a docstring */}}")}}, ""), + ) + require.NoError(t, err) + + assert.Empty(t, docsOf(t, plain)["a"]) + }) +} + +func TestDocumentation(t *testing.T) { + r := documentedRepository(t) + + documentation, err := r.Documentation() + require.NoError(t, err) + + t.Run("should group templates by asset, in order", func(t *testing.T) { + require.Len(t, documentation.Assets, 2) + assert.Equal(t, "caller.gotmpl", documentation.Assets[0].Path) + assert.Equal(t, "folder/documented.gotmpl", documentation.Assets[1].Path) + }) + + t.Run("should list the template named after the asset first", func(t *testing.T) { + templates := documentation.Assets[1].Templates + require.Len(t, templates, 3) + + assert.Equal(t, "folderDocumented", templates[0].Name) + assert.False(t, templates[0].Inner) + assert.Equal(t, + []string{"folderDocumentedMacro", "folderDocumentedOther"}, + []string{templates[1].Name, templates[2].Name}, + ) + assert.True(t, templates[1].Inner) + }) + + t.Run("should report direct dependencies and callers", func(t *testing.T) { + templates := documentation.Assets[1].Templates + + assert.Equal(t, + []reports.Dependency{{Name: "folderDocumentedMacro", Data: "."}, {Name: "folderDocumentedOther", Data: "."}}, + templates[0].Dependencies, + ) + assert.Equal(t, []string{"caller", "folderDocumented"}, templates[1].UsedBy) + assert.Empty(t, templates[1].Dependencies) + }) + + t.Run("should recompute the documentation of a clone", func(t *testing.T) { + clone, err := Clone(r, FromTemplate("caller.gotmpl", []byte( + "{{/* caller has been replaced. */}}\n"+`{{template "folderDocumentedOther"}}`, + ))) + require.NoError(t, err) + + documentation, err := clone.Documentation() + require.NoError(t, err) + + assert.Equal(t, []string{"caller has been replaced."}, documentation.Assets[0].Templates[0].Doc) + assert.Equal(t, + []reports.Dependency{{Name: "folderDocumentedOther", Data: "."}}, + documentation.Assets[0].Templates[0].Dependencies, + ) + }) +} + +func TestDump(t *testing.T) { + r := documentedRepository(t) + + t.Run("should produce the same document every time", func(t *testing.T) { + seen := make(map[string]struct{}) + for range 20 { + var out strings.Builder + require.NoError(t, r.Dump(&out)) + seen[out.String()] = struct{}{} + } + + assert.Len(t, seen, 1, "a document generated twice must be the same document twice") + }) + + t.Run("should hold an index, the docstrings and the graph", func(t *testing.T) { + var out strings.Builder + require.NoError(t, r.Dump(&out)) + document := out.String() + + assert.Contains(t, document, "- [folder/documented.gotmpl](#folderdocumentedgotmpl)") + assert.Contains(t, document, "documented renders a model.") + assert.Contains(t, document, "- `folderDocumentedMacro`, with `.`") + assert.Contains(t, document, "**Called by** [caller](#caller), [folderDocumented](#folderdocumented)") + assert.NotContains(t, document, "not a docstring") + assert.NotContains(t, document, "**Folded** 0 paths", "a fold holding nothing is not reported") + }) + + t.Run("should lay the document out as the caller asks", func(t *testing.T) { + var out strings.Builder + require.NoError(t, r.Dump(&out, + reports.WithTemplate(`{{ range .Assets }}{{ range .Templates }}{{ shout .Name }} {{ end }}{{ end }}`), + reports.WithFuncMap(template.FuncMap{"shout": strings.ToUpper}), + )) + + assert.Equal(t, "CALLER FOLDERDOCUMENTED FOLDERDOCUMENTEDMACRO FOLDERDOCUMENTEDOTHER ", out.String()) + }) + + t.Run("should report a dump template that does not parse", func(t *testing.T) { + err := r.Dump(&strings.Builder{}, reports.WithTemplate(`{{ if }}`)) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrTemplateRepo) + assert.ErrorContains(t, err, "dump template") + }) + + t.Run("should report an empty dump template", func(t *testing.T) { + err := r.Dump(&strings.Builder{}, reports.WithTemplate(" ")) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrTemplateRepo) + }) + +} + +// TestEmptyBody covers an asset made of define statements alone: it declares a template under its +// own name, reachable like any other, whose body holds nothing. +func TestEmptyBody(t *testing.T) { + r, err := New( + FromFS(fstest.MapFS{ + "serializers/schema.gotmpl": {Data: []byte( + "{{/* schemaSerializer writes a serializer. */}}\n" + + `{{define "schemaSerializer"}}serializer{{end}}` + "\n", + )}, + "model.gotmpl": {Data: []byte(`model {{template "serializersSchemaSchemaSerializer"}}`)}, + }, ""), + ) + require.NoError(t, err) + + documentation, err := r.Documentation() + require.NoError(t, err) + + t.Run("should still declare the template named after the asset", func(t *testing.T) { + assert.True(t, r.Has("serializersSchema")) + assert.Empty(t, strings.TrimSpace(render(t, r, "serializersSchema")), + "an empty body is white space and comments, so it renders nothing of substance") + }) + + t.Run("should report the empty body of that template", func(t *testing.T) { + templates := documentation.Assets[1].Templates + require.Len(t, templates, 2) + + assert.Equal(t, "serializersSchema", templates[0].Name) + assert.True(t, templates[0].Empty) + assert.False(t, templates[0].Inner) + + assert.Equal(t, "serializersSchemaSchemaSerializer", templates[1].Name) + assert.False(t, templates[1].Empty, "the define holds the body") + }) + + t.Run("should report a template with a body as not empty", func(t *testing.T) { + assert.False(t, documentation.Assets[0].Templates[0].Empty) + }) + + t.Run("should say so in the document", func(t *testing.T) { + var out strings.Builder + require.NoError(t, r.Dump(&out)) + + assert.Contains(t, out.String(), + "This asset declares define statements only, so the template named after it renders nothing.") + }) +} + +// TestTransitive covers what a template reads once the templates it calls are folded into it, +// which is what a reader wanting to feed it data actually needs. +func TestTransitive(t *testing.T) { + r, err := New(FromFS(fstest.MapFS{ + "model.gotmpl": {Data: []byte( + `{{ .GoName }}{{ range .Properties }}{{ template "field" . }}{{ end }}`, + )}, + "field.gotmpl": {Data: []byte( + `{{ define "field" }}{{ pascalize .Name }}{{ template "validation" .Schema }}{{ end }}`, + )}, + "validation.gotmpl": {Data: []byte(`{{ define "validation" }}{{ .Maximum }}{{ end }}`)}, + }, ""), WithFuncMap(template.FuncMap{"pascalize": strings.ToUpper})) + require.NoError(t, err) + + documentation, err := r.Documentation() + require.NoError(t, err) + + var model reports.Template + for _, asset := range documentation.Assets { + for _, tpl := range asset.Templates { + if tpl.Name == "model" { + model = tpl + } + } + } + + t.Run("should rebase what the templates it calls read", func(t *testing.T) { + assert.Equal(t, + []string{ + ".GoName", + ".Properties", + ".Properties[].Name", + ".Properties[].Schema", + ".Properties[].Schema.Maximum", + }, + model.Transitive.Reads, + ) + }) + + t.Run("should keep the direct reads to what the template itself does", func(t *testing.T) { + assert.Equal(t, []string{".GoName", ".Properties"}, model.Reads) + }) + + t.Run("should report the templates it reaches, directly or not", func(t *testing.T) { + assert.Equal(t, []string{"field", "validation"}, model.Transitive.Reaches) + }) + + t.Run("should gather the functions reached through the calls", func(t *testing.T) { + assert.Equal(t, []string{"pascalize"}, model.Transitive.Funcs) + assert.Empty(t, model.Funcs, "the template itself calls none") + }) + + t.Run("should report a set of templates that loops", func(t *testing.T) { + looping, err := New(FromFS(fstest.MapFS{ + "schema.gotmpl": {Data: []byte( + `{{ .Name }}{{ range .Properties }}{{ template "schema" . }}{{ end }}`, + )}, + }, "")) + require.NoError(t, err) + + documentation, err := looping.Documentation() + require.NoError(t, err) + + assert.True(t, documentation.Assets[0].Templates[0].Transitive.Recursive) + }) + + t.Run("should summarise the fold in the document", func(t *testing.T) { + var out strings.Builder + require.NoError(t, r.Dump(&out)) + + document := out.String() + + assert.Contains(t, document, "**Folded** 5 paths, through 2 templates.") + assert.Contains(t, document, "Mostly under `.Properties` (4).", + "a field a subtree hangs from is reported with its weight") + assert.Contains(t, document, "Read once: `.GoName`.", + "a field read once is reported without a count of one") + assert.Contains(t, document, "- `field`, with `.Properties[]` (3 paths)", + "a call says how much of the fold it accounts for") + }) +} diff --git a/templates-repo/errors.go b/templates-repo/errors.go new file mode 100644 index 0000000..a871a39 --- /dev/null +++ b/templates-repo/errors.go @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package repo + +// repoError is the type of the sentinel errors this package declares. +// +// A string type keeps a sentinel a constant, so nothing may reassign it. It compares by value, +// which is what [errors.Is] needs to find it in a chain of wrapped errors. +type repoError string + +// Error implements the error interface. +func (e repoError) Error() string { return string(e) } + +// ErrTemplateRepo is matched by every error this package reports. +// +// Errors wrap the cause as well, so a caller may match on a parse error or on an [io/fs] error +// with [errors.Is] and [errors.As] all the same. +const ErrTemplateRepo repoError = "template repository" diff --git a/templates-repo/errors_test.go b/templates-repo/errors_test.go new file mode 100644 index 0000000..3606c48 --- /dev/null +++ b/templates-repo/errors_test.go @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package repo + +import ( + "errors" + "fmt" + "testing" + "testing/fstest" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestSentinel(t *testing.T) { + t.Run("should read as the text it is declared with", func(t *testing.T) { + assert.Equal(t, "template repository", ErrTemplateRepo.Error()) + }) + + t.Run("should be found through a chain of wrapped errors", func(t *testing.T) { + wrapped := fmt.Errorf("outer: %w", fmt.Errorf("inner: %w", ErrTemplateRepo)) + + require.ErrorIs(t, wrapped, ErrTemplateRepo) + }) + + t.Run("should compare by value, so a copy is the same sentinel", func(t *testing.T) { + // this is what lets it be a constant: nothing holds an address anyone could rebind + copied := ErrTemplateRepo + + assert.Equal(t, ErrTemplateRepo, copied) + require.ErrorIs(t, fmt.Errorf("wrapped: %w", copied), ErrTemplateRepo) + }) + + t.Run("should leave the cause reachable alongside it", func(t *testing.T) { + _, err := New(FromFS(fstest.MapFS{ + "broken.gotmpl": {Data: []byte(`{{ this does not parse`)}, + }, "")) + + require.ErrorIs(t, err, ErrTemplateRepo) + assert.ErrorContains(t, err, `function "this" not defined`) + }) + + t.Run("should not match an error of another package", func(t *testing.T) { + assert.False(t, errors.Is(errors.New("template repository"), ErrTemplateRepo), + "the text alone does not make a sentinel") + }) +} diff --git a/templates-repo/example_naming_test.go b/templates-repo/example_naming_test.go new file mode 100644 index 0000000..4283fff --- /dev/null +++ b/templates-repo/example_naming_test.go @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package repo_test + +import ( + "fmt" + "os" + "slices" + "testing/fstest" + + repo "github.com/go-openapi/codegen/templates-repo" +) + +// oneAsset declares two templates: the asset itself, and the "define" it holds. +func oneAsset() repo.Option { + return repo.FromTemplate("server/parameter.gotmpl", []byte( + `{{ define "bind" }}bound{{ end }}param[{{ template "bind" }}]`, + )) +} + +// A template is known by three strings, and only the last one executes it. +func Example_vocabulary() { + repository, err := repo.New(oneAsset()) + if err != nil { + panic(err) + } + + for address, name := range repository.Addresses() { + asset, _ := repository.AssetOf(name) + fmt.Printf("asset %-24s address %-24s name %s\n", asset, address, name) + } + + // Output: + // asset server/parameter.gotmpl address server/parameter name serverParameter + // asset server/parameter.gotmpl address server/parameter/bind name serverParameterBind +} + +// Names identify the templates a repository holds, and Get takes one. +func ExampleRepository_Names() { + repository, err := repo.New(oneAsset()) + if err != nil { + panic(err) + } + + for name := range repository.Names() { + fmt.Println(name) + } + + // Output: + // serverParameter + // serverParameterBind +} + +// Get takes a name. +func ExampleRepository_Get() { + repository, err := repo.New(oneAsset()) + if err != nil { + panic(err) + } + + tpl, err := repository.Get("serverParameter") + if err != nil { + panic(err) + } + + _ = tpl.Execute(os.Stdout, nil) + + // Output: param[bound] +} + +// Lookup takes an address, and reaches the same template as Get does by name. +func ExampleRepository_Lookup() { + repository, err := repo.New(oneAsset()) + if err != nil { + panic(err) + } + + tpl, err := repository.Lookup("server/parameter/bind") + if err != nil { + panic(err) + } + + _ = tpl.Execute(os.Stdout, nil) + + // Output: bound +} + +// NameOf recases an address into the name it answers to. It computes, and does not look up: an +// address nothing declares still yields the name it would have. +func ExampleRepository_NameOf() { + repository, err := repo.New(oneAsset()) + if err != nil { + panic(err) + } + + fmt.Println(repository.NameOf("server/parameter/bind")) + fmt.Println(repository.NameOf("server/parameter.gotmpl")) + fmt.Println(repository.NameOf("nowhere/at/all"), repository.Has("nowhereAtAll")) + + // Output: + // serverParameterBind + // serverParameter + // nowhereAtAll false +} + +// AddressOf goes back, and reports whether the name is declared at all. +func ExampleRepository_AddressOf() { + repository, err := repo.New(oneAsset()) + if err != nil { + panic(err) + } + + fmt.Println(repository.AddressOf("serverParameterBind")) + fmt.Println(repository.AddressOf("nowhereAtAll")) + + // Output: + // server/parameter/bind true + // false +} + +// AssetOf names the file a template was read from, extension and all. +func ExampleRepository_AssetOf() { + repository, err := repo.New(oneAsset()) + if err != nil { + panic(err) + } + + fmt.Println(repository.AssetOf("serverParameterBind")) + + // Output: server/parameter.gotmpl true +} + +// Roots are names, the identity Get takes, and never addresses. +func ExampleWithRoots() { + repository, err := repo.New(oneAsset(), repo.WithRoots("serverParameter")) + if err != nil { + panic(err) + } + + fmt.Println(repository.Roots()) + + // an address is not a name, and saying so is an error rather than an empty repository + _, err = repo.New(oneAsset(), repo.WithRoots("server/parameter")) + fmt.Println(err != nil) + + // a caller holding addresses converts them first + naming, err := repo.New(oneAsset()) + if err != nil { + panic(err) + } + + scoped, err := repo.Clone(naming, repo.WithRoots(naming.NameOf("server/parameter"))) + if err != nil { + panic(err) + } + + fmt.Println(scoped.Roots()) + + // Output: + // [serverParameter] + // true + // [serverParameter] +} + +// Scoping takes names alone, where Lookup takes either identity. +func ExampleRepository_NameOf_scoping() { + repository, err := repo.New(oneAsset()) + if err != nil { + panic(err) + } + + // a caller holding an address converts it, then scopes + scoped, err := repo.Clone(repository, repo.WithRoots(repository.NameOf("server/parameter"))) + if err != nil { + panic(err) + } + + for name := range scoped.Names() { + fmt.Println(name) + } + + // Output: + // serverParameter + // serverParameterBind +} + +// SkipDirectories matches a directory's own name, at any depth, and never a path or a template +// name. +func ExampleSkipDirectories() { + assets := fstest.MapFS{ + "model.gotmpl": {Data: []byte("model")}, + "contrib/mine/model.gotmpl": {Data: []byte("mine")}, + "server/legacy/contrib/x.gotmpl": {Data: []byte("legacy")}, + } + + skipped, err := repo.New(repo.FromFS(assets, "", repo.SkipDirectories("contrib"))) + if err != nil { + panic(err) + } + + fmt.Println(slices.Collect(skipped.Names())) + + // a path matches no directory name, so nothing is skipped + byPath, err := repo.New(repo.FromFS(assets, "", repo.SkipDirectories("server/legacy"))) + if err != nil { + panic(err) + } + + fmt.Println(slices.Collect(byPath.Names())) + + // Output: + // [model] + // [contribMineModel model serverLegacyContribX] +} diff --git a/templates-repo/internal/cover/cover_test.go b/templates-repo/internal/cover/cover_test.go new file mode 100644 index 0000000..528a74a --- /dev/null +++ b/templates-repo/internal/cover/cover_test.go @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package cover + +import ( + "strings" + "testing" + "text/template" + "text/template/parse" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestLineTable(t *testing.T) { + source := []byte("one\ntwo\n\nfour") + table := newLineTable(source) + + t.Run("should place an offset on its line", func(t *testing.T) { + assert.Equal(t, 1, table.at(0)) + assert.Equal(t, 1, table.at(2)) + assert.Equal(t, 2, table.at(4)) + assert.Equal(t, 3, table.at(8)) + assert.Equal(t, 4, table.at(9)) + }) + + t.Run("should report how long a line is, its break left out", func(t *testing.T) { + assert.Equal(t, 3, table.length(1)) + assert.Equal(t, 3, table.length(2)) + assert.Equal(t, 0, table.length(3), "an empty line holds nothing") + assert.Equal(t, 4, table.length(4)) + }) + + t.Run("should report nothing for a line that is not there", func(t *testing.T) { + assert.Equal(t, 0, table.length(0)) + assert.Equal(t, 0, table.length(9)) + }) + + t.Run("should leave a carriage return out of the length", func(t *testing.T) { + windows := newLineTable([]byte("one\r\ntwo\r\n")) + + assert.Equal(t, 3, windows.length(1)) + assert.Equal(t, 2, windows.at(5), "the offsets follow the bytes of the source") + }) + + t.Run("should move an offset past the white space it opens with", func(t *testing.T) { + spaced := newLineTable([]byte("{{ if . }}\n shown\n")) + + assert.Equal(t, 1, spaced.at(10), "the text node opens with the line break of the line before") + assert.Equal(t, 2, spaced.at(spaced.skipSpace(10)), "moved past it, the text is on its own line") + }) +} + +func TestProfile(t *testing.T) { + t.Run("should count a line it was told about", func(t *testing.T) { + profile := NewProfile("example.com/x/") + profile.register("a.gotmpl", 3, 10) + + counter := profile.counterFor("a.gotmpl", 3) + require.NotNil(t, counter) + counter.Add(2) + + var out strings.Builder + require.NoError(t, profile.Flush(&out)) + assert.Equal(t, "mode: count\nexample.com/x/a.gotmpl:3.1,3.11 1 2\n", out.String()) + }) + + t.Run("should know nothing of a line it was not told about", func(t *testing.T) { + profile := NewProfile("") + + assert.Nil(t, profile.counterFor("nowhere.gotmpl", 1)) + assert.Nil(t, profile.counterFor("a.gotmpl", 4)) + }) + + t.Run("should keep the first length it was given for a line", func(t *testing.T) { + profile := NewProfile("") + profile.register("a.gotmpl", 1, 5) + profile.register("a.gotmpl", 1, 99) + + var out strings.Builder + require.NoError(t, profile.Flush(&out)) + assert.Contains(t, out.String(), "a.gotmpl:1.1,1.6 1 0") + }) + + t.Run("should report what it counts", func(t *testing.T) { + profile := NewProfile("") + profile.register("a.gotmpl", 1, 5) + profile.register("a.gotmpl", 2, 5) + profile.counterFor("a.gotmpl", 1).Add(1) + + counted, reached := profile.Lines() + assert.Equal(t, 2, counted) + assert.Equal(t, 1, reached) + }) + + t.Run("should order a profile by asset then by line", func(t *testing.T) { + profile := NewProfile("") + profile.register("b.gotmpl", 1, 1) + profile.register("a.gotmpl", 9, 1) + profile.register("a.gotmpl", 2, 1) + + var out grabWriter + require.NoError(t, profile.Flush(&out)) + assert.Equal(t, + []string{"mode: count", "a.gotmpl:2.1,2.2 1 0", "a.gotmpl:9.1,9.2 1 0", "b.gotmpl:1.1,1.2 1 0"}, + out.lines(), + ) + }) +} + +func TestCallbackName(t *testing.T) { + seen := make(map[string]struct{}, 100) + for range 100 { + name := callbackName() + + assert.True(t, strings.HasPrefix(name, "cover")) + seen[name] = struct{}{} + } + + assert.Greater(t, len(seen), 90, "a name drawn at random rarely repeats") +} + +func TestInstrument(t *testing.T) { + const source = "{{ if . }}\nshown\n{{- end }}\n" + + parse := func(t *testing.T) map[string]*parse.Tree { + t.Helper() + + parsed, err := template.New("page").Parse(source) + require.NoError(t, err) + + return map[string]*parse.Tree{"page": parsed.Tree} + } + + t.Run("should leave the trees it was given alone", func(t *testing.T) { + trees := parse(t) + before := trees["page"].Root.String() + + NewProfile("").Instrument("page.gotmpl", []byte(source), trees) + + assert.Equal(t, before, trees["page"].Root.String(), "the caller keeps what it passed") + }) + + t.Run("should bind the function the trees call", func(t *testing.T) { + instrumented := NewProfile("").Instrument("page.gotmpl", []byte(source), parse(t)) + + bound := instrumented.Bind() + require.Len(t, bound, 1) + assert.Contains(t, bound, instrumented.FuncName) + assert.Contains(t, instrumented.Trees["page"].Root.String(), instrumented.FuncName) + }) + + t.Run("should render the counters it inserted", func(t *testing.T) { + instrumented := NewProfile("").Instrument("page.gotmpl", []byte(source), parse(t)) + root := instrumented.Trees["page"].Root + + assert.Contains(t, root.String(), "{{"+instrumented.FuncName+" 1}}", + "a counter renders as a call carrying its line") + assert.Equal(t, root.String(), root.Copy().String(), + "a copy of an instrumented tree renders the same") + }) + + t.Run("should record the lines that run, before any of them does", func(t *testing.T) { + profile := NewProfile("") + profile.Instrument("page.gotmpl", []byte(source), parse(t)) + + counted, reached := profile.Lines() + assert.Equal(t, 2, counted, "the if and the text it guards") + assert.Zero(t, reached, "nothing has run yet") + }) +} + +// grabWriter keeps what was written to it, line by line. +type grabWriter struct { + written strings.Builder +} + +func (w *grabWriter) Write(p []byte) (int, error) { return w.written.Write(p) } + +func (w *grabWriter) lines() []string { + return strings.Split(strings.TrimSpace(w.written.String()), "\n") +} diff --git a/templates-repo/internal/cover/doc.go b/templates-repo/internal/cover/doc.go new file mode 100644 index 0000000..f1c24a0 --- /dev/null +++ b/templates-repo/internal/cover/doc.go @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package cover measures which lines of a template a program reaches when it runs. +// +// It works the way the go toolchain does: a counter is injected before every statement while the +// templates are compiled, and executing them counts. The result is a profile in the format of +// go test, which go tool cover renders. +// +// # Granularity +// +// Counting is per line. The parser gives the offset of a token and not its extent, so a block +// covers the whole line it starts on, and two branches written on one line share a counter. +// +// A line holding nothing but a define, an end or an else leaves no node in the parse tree, so +// nothing counts it and nothing is written for it. It renders as plain text, the way a go +// declaration does. +// +// # Reading the profile +// +// go tool cover finds the file a profile names by asking go list, so the paths have to read as an +// import path of a package that exists. The prefix a caller gives [NewProfile] is prepended to +// every asset path for that reason. +// +// Only the html output is worth aiming at: go tool cover -func reads the file as go source and +// stops at the first template action. +package cover diff --git a/templates-repo/internal/cover/instrument.go b/templates-repo/internal/cover/instrument.go new file mode 100644 index 0000000..6db0d02 --- /dev/null +++ b/templates-repo/internal/cover/instrument.go @@ -0,0 +1,211 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package cover + +import ( + "strconv" + "sync" + "text/template" + "text/template/parse" +) + +// Instrumented is a set of trees that count the lines they run, with the function to bind for them +// to count. +type Instrumented struct { + // Trees holds the rewritten tree of every template the asset declares, by name. + Trees map[string]*parse.Tree + + // FuncName is the name the trees call to count a line. + FuncName string + + // Func is the function that name must be bound to before the trees run. + Func func(int) string +} + +// Instrument rewrites the trees of an asset so that running them counts the lines they reach. +// +// Every line a tree holds is recorded, whether it ever runs or not, so the profile distinguishes +// a line that was not reached from one that does not exist. +// +// The trees are rewritten rather than altered: what the caller passed is left as it was. +func (p *Profile) Instrument(assetPath string, source []byte, trees map[string]*parse.Tree) Instrumented { + lines := newLineTable(source) + name := callbackName() + + instrumented := Instrumented{ + Trees: make(map[string]*parse.Tree, len(trees)), + FuncName: name, + Func: func(line int) string { + if counter := p.counterFor(assetPath, line); counter != nil { + counter.Add(1) + } + + return "" + }, + } + + writer := &rewriter{profile: p, assetPath: assetPath, lines: lines, funcName: name} + for declared, tree := range trees { + rewritten := tree.Copy() + rewritten.Root.Nodes = writer.nodes(rewritten.Root) + instrumented.Trees[declared] = rewritten + } + + return instrumented +} + +// rewriter walks a tree and puts a counter before everything that runs. +type rewriter struct { + profile *Profile + assetPath string + lines *lineTable + funcName string +} + +// nodes rewrites the children of a list, counting each of them. +func (w *rewriter) nodes(list *parse.ListNode) []parse.Node { + if list == nil { + return nil + } + + rewritten := make([]parse.Node, 0, 2*len(list.Nodes)) //nolint:mnd // a counter before each node + counted := 0 + + for _, node := range list.Nodes { + w.descend(node) + + // a line is counted once per list, so that text followed by an action on one line counts + // once. A branch is a list of its own, and keeps a counter of its own + if line := w.lineOf(node); line != 0 && line != counted { + counted = line + rewritten = append(rewritten, w.counter(line)) + } + + rewritten = append(rewritten, node) + } + + return rewritten +} + +// descend rewrites the bodies a node holds, so that a branch counts on its own. +func (w *rewriter) descend(node parse.Node) { + switch typed := node.(type) { + case *parse.IfNode: + w.branch(typed.List, typed.ElseList) + case *parse.RangeNode: + w.branch(typed.List, typed.ElseList) + case *parse.WithNode: + w.branch(typed.List, typed.ElseList) + } +} + +// branch rewrites the two bodies of a branching node. +func (w *rewriter) branch(list, elseList *parse.ListNode) { + if list != nil { + list.Nodes = w.nodes(list) + } + + if elseList != nil { + elseList.Nodes = w.nodes(elseList) + } +} + +// lineOf reports the line a node is counted on, or zero when it is counted on none. +// +// A node rendering nothing of its own, such as the white space between two actions, is counted on +// no line: a mark on it would claim a line that holds no output. Nor is a line holding nothing, +// which would make a block of no width. +func (w *rewriter) lineOf(node parse.Node) int { + if !runs(node) { + return 0 + } + + line := w.lines.at(w.lines.skipSpace(int(node.Position()))) + if w.lines.length(line) == 0 { + return 0 + } + + return line +} + +// counter builds the node counting a line, and records that line so that the profile holds it +// whether it ever runs or not. +func (w *rewriter) counter(line int) parse.Node { + w.profile.register(w.assetPath, line, w.lines.length(line)) + + action := counterProto().Copy().(*parse.ActionNode) + args := action.Pipe.Cmds[0].Args + args[0].(*parse.IdentifierNode).Ident = w.funcName + + number := args[1].(*parse.NumberNode) + number.Int64 = int64(line) + number.Text = strconv.Itoa(line) + + return action +} + +// counterPlaceholder is the identifier the prototype calls; [rewriter.counter] renames each copy +// to the callback the asset was instrumented with. +const counterPlaceholder = "coverPlaceholder" + +// counterProto returns the action every counter is copied from. +// +// The nodes cannot be assembled from struct literals. A node carries an unexported pointer to the +// tree that parsed it, and since Go 1.27 [parse.ActionNode.String] reads the delimiters off that +// pointer and [parse.ActionNode.Copy] allocates through it, so a hand-built node panics on both. +// Parsing one action and copying it gives every counter a tree to point at. +// +// A copy keeps the position it was parsed at rather than the position of the node it counts, so +// that [parse.Tree.ErrorContext] indexes the prototype's own source and stays in range. +var counterProto = sync.OnceValue(func() *parse.ActionNode { + tree := parse.New("cover") + tree.Mode = parse.SkipFuncCheck // the callback is bound at run time, not known here + + parsed, err := tree.Parse("{{"+counterPlaceholder+" 0}}", "", "", make(map[string]*parse.Tree)) + if err != nil { + panic(err) // a constant template: parsing it can only fail if this file is wrong + } + + return parsed.Root.Nodes[0].(*parse.ActionNode) +}) + +// runs reports whether a node emits output of its own, or controls whether something else does. +func runs(node parse.Node) bool { + switch typed := node.(type) { + case *parse.TextNode: + // the white space between two actions renders, but belongs to no line of its own + return len(trimSpace(typed.Text)) > 0 + + case *parse.ActionNode, *parse.IfNode, *parse.RangeNode, *parse.WithNode, + *parse.TemplateNode, *parse.BreakNode, *parse.ContinueNode: + return true + + default: + return false + } +} + +// trimSpace is [bytes.TrimSpace], kept here to say what it is used for. +func trimSpace(text []byte) []byte { + start, end := 0, len(text) + for start < end && isSpace(text[start]) { + start++ + } + + for end > start && isSpace(text[end-1]) { + end-- + } + + return text[start:end] +} + +// isSpace reports whether a byte is white space, as the template lexer defines it. +func isSpace(char byte) bool { + return char == ' ' || char == '\t' || char == '\r' || char == '\n' +} + +// Bind returns the func map a set of instrumented trees needs to run. +func (i Instrumented) Bind() template.FuncMap { + return template.FuncMap{i.FuncName: i.Func} +} diff --git a/templates-repo/internal/cover/lines.go b/templates-repo/internal/cover/lines.go new file mode 100644 index 0000000..5ed980c --- /dev/null +++ b/templates-repo/internal/cover/lines.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package cover + +import ( + "slices" + "unicode" +) + +// lineTable places an offset in a source, and reports how long a line is. +type lineTable struct { + source []byte + + // starts holds the offset each line begins at, so a binary search places an offset. + starts []int +} + +// newLineTable indexes the lines of a source. +func newLineTable(source []byte) *lineTable { + starts := []int{0} + for offset, char := range source { + if char == '\n' { + starts = append(starts, offset+1) + } + } + + return &lineTable{source: source, starts: starts} +} + +// at reports the line an offset falls on, counting from one. +func (t *lineTable) at(offset int) int { + found, exact := slices.BinarySearch(t.starts, offset) + if exact { + return found + 1 + } + + return found +} + +// length reports how many bytes a line holds, its line break left out. +func (t *lineTable) length(line int) int { + if line < 1 || line > len(t.starts) { + return 0 + } + + start := t.starts[line-1] + end := len(t.source) + if line < len(t.starts) { + end = t.starts[line] - 1 + } + + if end > start && t.source[end-1] == '\r' { + end-- + } + + return end - start +} + +// skipSpace moves an offset forward to the first character that renders. +// +// A text node holds the line break that precedes it, so it is placed on the line of whatever came +// before unless its offset is moved past the white space it opens with. +func (t *lineTable) skipSpace(offset int) int { + for offset < len(t.source) && unicode.IsSpace(rune(t.source[offset])) { + offset++ + } + + return offset +} diff --git a/templates-repo/internal/cover/profile.go b/templates-repo/internal/cover/profile.go new file mode 100644 index 0000000..0730a54 --- /dev/null +++ b/templates-repo/internal/cover/profile.go @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package cover + +import ( + "fmt" + "io" + "maps" + "math/rand/v2" + "slices" + "sync" + "sync/atomic" +) + +// Profile counts the lines the templates of one repository reach when they run. +// +// It is built while the repository is, and counts from then on. Counting is safe for concurrent +// use: a counter is an [sync/atomic.Int64], and the lines a template holds are known before any of +// it runs, so nothing is written to the index once the repository is sealed. +type Profile struct { + prefix string + + // mu guards the index while templates are instrumented, never while they run. + mu sync.Mutex + files map[string]*fileCounters +} + +// fileCounters holds one counter per line of an asset. +type fileCounters struct { + lengths map[int]int + counts map[int]*atomic.Int64 +} + +// NewProfile builds a profile of the templates of a repository. +// +// prefix is prepended to the path of every asset, so that the profile reads as an import path go +// list resolves, so go tool cover finds the templates. +func NewProfile(prefix string) *Profile { + return &Profile{ + prefix: prefix, + files: make(map[string]*fileCounters), + } +} + +// Flush writes the profile, in the format go test produces and go tool cover reads. +// +// Counters are left as they are, so a caller may write the profile more than once, and go on +// running templates afterwards. +func (p *Profile) Flush(w io.Writer) error { + p.mu.Lock() + defer p.mu.Unlock() + + if _, err := fmt.Fprintln(w, "mode: count"); err != nil { + return err + } + + for _, assetPath := range slices.Sorted(maps.Keys(p.files)) { + counters := p.files[assetPath] + + for _, line := range slices.Sorted(maps.Keys(counters.counts)) { + // a block covers the whole line: the parser gives where a token starts, never how far + // it reaches, and a block of no width makes go tool cover write broken html + _, err := fmt.Fprintf(w, "%s:%d.%d,%d.%d %d %d\n", + p.prefix+assetPath, + line, 1, + line, counters.lengths[line]+1, + 1, + counters.counts[line].Load(), + ) + if err != nil { + return err + } + } + } + + return nil +} + +// Lines reports how many lines are counted, and how many of them ran. +func (p *Profile) Lines() (counted, reached int) { + p.mu.Lock() + defer p.mu.Unlock() + + for _, counters := range p.files { + for _, count := range counters.counts { + counted++ + if count.Load() > 0 { + reached++ + } + } + } + + return counted, reached +} + +// register records a line of an asset, so that it is reported whether it runs or not. +func (p *Profile) register(assetPath string, line, length int) { + p.mu.Lock() + defer p.mu.Unlock() + + counters, known := p.files[assetPath] + if !known { + counters = &fileCounters{lengths: make(map[int]int), counts: make(map[int]*atomic.Int64)} + p.files[assetPath] = counters + } + + if _, counted := counters.counts[line]; counted { + return + } + + counters.lengths[line] = length + counters.counts[line] = new(atomic.Int64) +} + +// counterFor returns the counter of a line, once the templates are instrumented. +func (p *Profile) counterFor(assetPath string, line int) *atomic.Int64 { + p.mu.Lock() + defer p.mu.Unlock() + + counters, known := p.files[assetPath] + if !known { + return nil + } + + return counters.counts[line] +} + +// callbackName draws the name of the function a template calls to count a line. +// +// It is drawn at random so that it cannot collide with a function of the map a caller supplies. +// Nothing reads the name once the template holds it. +func callbackName() string { + const ( + alphabet = "abcdefghijklmnopqrstuvwxyz" + length = 12 + ) + + name := make([]byte, 0, len("cover")+length) + name = append(name, "cover"...) + + for range length { + name = append(name, alphabet[rand.IntN(len(alphabet))]) //nolint:gosec // a name, not a secret + } + + return string(name) +} diff --git a/templates-repo/internal/document/analysis.go b/templates-repo/internal/document/analysis.go new file mode 100644 index 0000000..85e7868 --- /dev/null +++ b/templates-repo/internal/document/analysis.go @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package document + +import ( + "fmt" + "text/template" + "text/template/parse" +) + +// Analysis holds the result of reading an asset, per template it declares. +type Analysis struct { + // Docstrings holds the comments documenting a template, by template name. + Docstrings map[string][]string + + // Contracts holds what a template reads and calls, by template name. + Contracts map[string]Contract +} + +// Contract holds the data a template reads, and the data it passes to the templates it calls. +// +// Paths are rooted at the data the template itself is executed on, which is the argument of the +// call that reached it, not the data of whoever called that caller. +// +// A contract closes over every branch, so it holds what the template may read rather than what any +// one execution of it reads. +type Contract struct { + // Reads lists the data paths the template may read, sorted, across every branch. + Reads []string + + // RootReads lists the paths read through "$", sorted. + // + // Inside a range or a with, "$" still stands for the data the template was executed on, so a + // path listed here is a reach past the current dot, back to the top of the template. + RootReads []string + + // Funcs lists the func map functions the template calls, sorted. + // + // The builtins of [text/template] are left out: a caller replacing a template supplies the + // func map, never those. + Funcs []string + + // Calls lists the templates it invokes, with the data handed to each, sorted by name. + Calls []Call + + // Unresolved counts the accesses the analysis could not place. + Unresolved int + + // Empty reports whether the template holds nothing but white space and comments, so that running + // it renders nothing. + Empty bool + + // Dynamic reports whether the template invokes a function held by the data, with "call". + // + // Such a function is resolved only when the template runs, so a contract reported for a template + // that uses one is incomplete by construction. + Dynamic bool +} + +// Call is the invocation of one template by another. +type Call struct { + // Name is the template invoked. + Name string + + // Data is the path handed to it, rooted like the paths of the calling template. + // + // It is "." when the caller hands over its own data, and empty when the analysis could not + // place it. + Data string +} + +// Analyze reads an asset and reports what the templates it declares document and do. +// +// name is the template name the asset itself is registered under, which the parser also uses for +// the tree holding whatever lies outside the define statements. +// +// funcs is the map the templates are bound to. It decides which calls are worth reporting: a +// builtin of [text/template] is left out, unless the map defines a function of that name, in which +// case calling it is a dependency on the map like any other. +func Analyze(path, name string, data []byte, funcs template.FuncMap) (Analysis, error) { + tree := parse.New(name) + tree.Mode = parse.ParseComments | parse.SkipFuncCheck + + declared := make(map[string]*parse.Tree) + root, err := tree.Parse(string(data), "", "", declared, nil) + if err != nil { + return Analysis{}, fmt.Errorf("could not analyse asset %q: %w", path, err) + } + + analysis := Analysis{ + Docstrings: docstringsOf(root, declared, name), + Contracts: make(map[string]Contract, len(declared)), + } + + for declaredName, declaredTree := range declared { + contract := contractOf(declaredTree.Root, funcs) + contract.Empty = parse.IsEmptyTree(declaredTree.Root) + analysis.Contracts[declaredName] = contract + } + + return analysis, nil +} diff --git a/templates-repo/internal/document/closure.go b/templates-repo/internal/document/closure.go new file mode 100644 index 0000000..1f1dcee --- /dev/null +++ b/templates-repo/internal/document/closure.go @@ -0,0 +1,282 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package document + +import "slices" + +// Transitive holds the data a template reads once the templates it calls are folded into it. +// +// Paths are rooted at the data the template itself is executed on, like those of a [Contract]: the +// paths a called template reads are rebased onto the data handed to it, so a template reading +// ".GoName" that is called with ".Properties[]" contributes ".Properties[].GoName". +type Transitive struct { + // Reads lists the data paths the template may read, itself or through the templates it calls. + Reads []string + + // Funcs lists the func map functions reached the same way. + Funcs []string + + // Reaches lists the templates it calls, directly or not, sorted. + // + // Unlike the paths, this follows a loop all the way round: a template in a loop reaches every + // other template of that loop. + Reaches []string + + // Unresolved counts what could not be folded in: the accesses the template itself could not + // place, and the contract of a template called with data that could not be placed. + Unresolved int + + // Recursive reports whether the template is in a loop, calling itself directly or through + // others. + // + // The paths of a template in the loop are left out of the fold: rebasing them would hang them + // from themselves without end. Every template of a loop is treated alike, so what the fold + // holds does not depend on which template it started from. + Recursive bool +} + +// Closure folds every template into the templates it calls. +// +// A template is folded once, against the data it is executed on, and its result is rebased wherever +// it is called. A template no other one calls is folded all the same, since what it reads is +// answered by whatever data reaches it. +// +// Templates calling one another in a loop are folded without following the loop. Which templates +// those are is a property of the call graph, not of the order the folding happens in, so the result +// is the same however this is called. +func Closure(contracts map[string]Contract) map[string]Transitive { + c := &closer{ + contracts: contracts, + loops: loopsOf(contracts), + folded: make(map[string]Transitive, len(contracts)), + } + + for name := range contracts { + c.fold(name) + } + + return c.folded +} + +// closer folds templates into one another, remembering what it has already folded. +type closer struct { + contracts map[string]Contract + loops map[string]int + folded map[string]Transitive +} + +// fold gathers what a template reads and what the templates it calls read. +// +// Skipping a call that lands in the same loop leaves what is left a graph without loops, so a +// template needs folding only once, whichever other one is folded first. +func (c *closer) fold(name string) Transitive { + if done, found := c.folded[name]; found { + return done + } + + contract, declared := c.contracts[name] + if !declared { + return Transitive{} + } + + reads := setOf(contract.Reads) + funcs := setOf(contract.Funcs) + result := Transitive{Unresolved: contract.Unresolved} + + for _, call := range contract.Calls { + if c.sameLoop(name, call.Name) { + result.Recursive = true + + continue + } + + called := c.fold(call.Name) + result.Recursive = result.Recursive || called.Recursive + + for _, function := range called.Funcs { + funcs[function] = struct{}{} + } + + if call.Data == "" { + // the called template reads against data this analysis could not name + result.Unresolved += len(called.Reads) + called.Unresolved + + continue + } + + result.Unresolved += called.Unresolved + for _, path := range called.Reads { + reads[rebase(path, call.Data)] = struct{}{} + } + } + + result.Reads = sortedKeys(reads) + result.Funcs = sortedKeys(funcs) + result.Reaches = c.reaches(name) + c.folded[name] = result + + return result +} + +// sameLoop reports whether two templates call one another, directly or through others. +func (c *closer) sameLoop(caller, called string) bool { + loop, inALoop := c.loops[caller] + + return inALoop && loop == c.loops[called] +} + +// reaches lists every template reached from one, following loops all the way round. +func (c *closer) reaches(name string) []string { + found := make(map[string]struct{}) + c.walkCalls(name, found) + delete(found, name) + + return sortedKeys(found) +} + +// walkCalls collects the templates reached from one. +func (c *closer) walkCalls(name string, found map[string]struct{}) { + for _, call := range c.contracts[name].Calls { + if _, seen := found[call.Name]; seen { + continue + } + + found[call.Name] = struct{}{} + c.walkCalls(call.Name, found) + } +} + +// loopsOf finds the templates that call one another, directly or through others. +// +// It is Tarjan's algorithm for strongly connected components, keeping only the components holding +// a loop: every template of one is given the same number, and a template in no loop is given none. +func loopsOf(contracts map[string]Contract) map[string]int { + t := &tarjan{ + contracts: contracts, + index: make(map[string]int, len(contracts)), + low: make(map[string]int, len(contracts)), + onStack: make(map[string]struct{}, len(contracts)), + loops: make(map[string]int), + } + + for _, name := range sortedKeys(setOf(namesOf(contracts))) { + if _, visited := t.index[name]; !visited { + t.visit(name) + } + } + + return t.loops +} + +// tarjan holds the state of the search for loops. +type tarjan struct { + contracts map[string]Contract + index map[string]int + low map[string]int + onStack map[string]struct{} + stack []string + loops map[string]int + next int + found int +} + +// visit explores one template and the templates it calls. +func (t *tarjan) visit(name string) { + t.index[name] = t.next + t.low[name] = t.next + t.next++ + t.stack = append(t.stack, name) + t.onStack[name] = struct{}{} + + selfCalling := false + for _, call := range t.contracts[name].Calls { + if call.Name == name { + selfCalling = true + } + + switch _, visited := t.index[call.Name]; { + case !visited: + if _, declared := t.contracts[call.Name]; !declared { + continue // a template no source declares, reported by the repository itself + } + + t.visit(call.Name) + t.low[name] = min(t.low[name], t.low[call.Name]) + + default: + if _, stacked := t.onStack[call.Name]; stacked { + t.low[name] = min(t.low[name], t.index[call.Name]) + } + } + } + + if t.low[name] != t.index[name] { + return + } + + t.close(name, selfCalling) +} + +// close pops the component a template roots, and records it when it holds a loop. +func (t *tarjan) close(root string, selfCalling bool) { + var component []string + + for { + last := len(t.stack) - 1 + name := t.stack[last] + t.stack = t.stack[:last] + delete(t.onStack, name) + component = append(component, name) + + if name == root { + break + } + } + + // a component of one template is a loop only when that template calls itself + if len(component) == 1 && !selfCalling { + return + } + + t.found++ + for _, name := range component { + t.loops[name] = t.found + } +} + +// rebase hangs a path read by a called template from the data it was handed. +func rebase(path, data string) string { + if data == currentData { + return path + } + + if path == currentData { + return data + } + + // a path read by the called template already opens with the dot, which is the separator here + return data + path +} + +// namesOf lists the templates a set of contracts covers. +func namesOf(contracts map[string]Contract) []string { + names := make([]string, 0, len(contracts)) + for name := range contracts { + names = append(names, name) + } + + slices.Sort(names) + + return names +} + +// setOf turns a list into a set. +func setOf(list []string) map[string]struct{} { + set := make(map[string]struct{}, len(list)) + for _, item := range list { + set[item] = struct{}{} + } + + return set +} diff --git a/templates-repo/internal/document/closure_test.go b/templates-repo/internal/document/closure_test.go new file mode 100644 index 0000000..3e7b1c6 --- /dev/null +++ b/templates-repo/internal/document/closure_test.go @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package document + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" +) + +// contracts builds the contracts of a set of sources, keyed by template name, the way a repository +// hands them to the closure. +func contracts(t *testing.T, sources map[string]string) map[string]Contract { + t.Helper() + + all := make(map[string]Contract) + for name, source := range sources { + analysis, err := Analyze(name+".gotmpl", name, []byte(source), nil) + if err != nil { + t.Fatalf("analysing %q: %v", name, err) + } + + for declared, contract := range analysis.Contracts { + all[declared] = contract + } + } + + return all +} + +func TestClosure(t *testing.T) { + t.Run("should rebase what a called template reads onto the data it is handed", func(t *testing.T) { + closed := Closure(contracts(t, map[string]string{ + "caller": `{{ range .AllOf }}{{ template "field" .Properties }}{{ end }}`, + "field": `{{ define "field" }}{{ .GoName }}{{ .Required }}{{ end }}`, + })) + + assert.Equal(t, + []string{ + ".AllOf", + ".AllOf[].Properties", // the caller reads it to hand it over + ".AllOf[].Properties.GoName", + ".AllOf[].Properties.Required", + }, + closed["caller"].Reads, + ) + assert.Equal(t, []string{"field"}, closed["caller"].Reaches) + }) + + t.Run("should leave a template handed the current data as it is", func(t *testing.T) { + closed := Closure(contracts(t, map[string]string{ + "caller": `{{ .Own }}{{ template "macro" . }}`, + "macro": `{{ define "macro" }}{{ .Shared }}{{ end }}`, + })) + + assert.Equal(t, []string{".Own", ".Shared"}, closed["caller"].Reads) + }) + + t.Run("should fold a chain of calls", func(t *testing.T) { + closed := Closure(contracts(t, map[string]string{ + "top": `{{ template "middle" .Schema }}`, + "middle": `{{ define "middle" }}{{ template "leaf" .Items }}{{ end }}`, + "leaf": `{{ define "leaf" }}{{ .Name }}{{ end }}`, + })) + + assert.Equal(t, []string{".Schema", ".Schema.Items", ".Schema.Items.Name"}, closed["top"].Reads) + assert.Equal(t, []string{"leaf", "middle"}, closed["top"].Reaches) + }) + + t.Run("should gather the functions of the templates it calls", func(t *testing.T) { + closed := Closure(contracts(t, map[string]string{ + "caller": `{{ pascalize .Name }}{{ template "macro" .Schema }}`, + "macro": `{{ define "macro" }}{{ humanize .Title }}{{ end }}`, + })) + + assert.Equal(t, []string{"humanize", "pascalize"}, closed["caller"].Funcs) + }) + + t.Run("should stop at a template that calls itself", func(t *testing.T) { + closed := Closure(contracts(t, map[string]string{ + "schema": `{{ .Name }}{{ range .Properties }}{{ template "schema" . }}{{ end }}`, + })) + + assert.True(t, closed["schema"].Recursive) + assert.Equal(t, []string{".Name", ".Properties"}, closed["schema"].Reads) + }) + + t.Run("should not follow a loop between two templates", func(t *testing.T) { + closed := Closure(contracts(t, map[string]string{ + "first": `{{ .A }}{{ template "second" .Down }}`, + "second": `{{ define "second" }}{{ .B }}{{ template "first" .Up }}{{ end }}`, + })) + + assert.True(t, closed["first"].Recursive) + assert.True(t, closed["second"].Recursive) + + assert.Equal(t, []string{".A", ".Down"}, closed["first"].Reads, + "what the other template of the loop reads is left out") + assert.Equal(t, []string{".B", ".Up"}, closed["second"].Reads) + + assert.Equal(t, []string{"second"}, closed["first"].Reaches, + "the call graph still follows the loop all the way round") + }) + + t.Run("should fold a template calling into a loop from outside it", func(t *testing.T) { + closed := Closure(contracts(t, map[string]string{ + "first": `{{ .A }}{{ template "second" .Down }}`, + "second": `{{ define "second" }}{{ .B }}{{ template "first" .Up }}{{ end }}`, + "third": `{{ .C }}{{ template "first" .Into }}`, + })) + + assert.Equal(t, []string{".C", ".Into", ".Into.A", ".Into.Down"}, closed["third"].Reads) + assert.True(t, closed["third"].Recursive, "it reaches a loop, so its fold is cut short too") + assert.Equal(t, []string{"first", "second"}, closed["third"].Reaches) + }) + + t.Run("should fold the same way whichever template it starts from", func(t *testing.T) { + sources := map[string]string{ + "first": `{{ .A }}{{ template "second" .Down }}`, + "second": `{{ define "second" }}{{ .B }}{{ template "first" .Up }}{{ end }}`, + "third": `{{ .C }}{{ template "first" .Into }}`, + } + + // the templates are held in a map, so the fold starts wherever the range happens to begin + reference := Closure(contracts(t, sources)) + for range 40 { + assert.Equal(t, reference, Closure(contracts(t, sources))) + } + }) + + t.Run("should count what a template called with unplaceable data reads", func(t *testing.T) { + closed := Closure(contracts(t, map[string]string{ + "caller": `{{ template "macro" (index .Items 0) }}`, + "macro": `{{ define "macro" }}{{ .A }}{{ .B }}{{ end }}`, + })) + + assert.NotContains(t, closed["caller"].Reads, ".A") + assert.Equal(t, 2, closed["caller"].Unresolved, "the two paths the macro reads cannot be rebased") + assert.Equal(t, []string{"macro"}, closed["caller"].Reaches) + }) + + t.Run("should carry the unresolved accesses of a template it calls", func(t *testing.T) { + closed := Closure(contracts(t, map[string]string{ + "caller": `{{ template "macro" .Schema }}`, + "macro": `{{ define "macro" }}{{ (index .Items 0).Name }}{{ end }}`, + })) + + assert.Equal(t, 1, closed["caller"].Unresolved) + }) + + t.Run("should close a template no other one calls", func(t *testing.T) { + closed := Closure(contracts(t, map[string]string{ + "lonely": `{{ .Name }}`, + })) + + assert.Equal(t, []string{".Name"}, closed["lonely"].Reads) + assert.Empty(t, closed["lonely"].Reaches) + }) + + t.Run("should ignore a call to a template no source declares", func(t *testing.T) { + closed := Closure(contracts(t, map[string]string{ + "caller": `{{ .Own }}{{ template "nowhere" .Schema }}`, + })) + + assert.Equal(t, []string{".Own", ".Schema"}, closed["caller"].Reads, + "the caller still reads the data it hands over") + assert.Equal(t, []string{"nowhere"}, closed["caller"].Reaches) + }) +} diff --git a/templates-repo/internal/document/comments.go b/templates-repo/internal/document/comments.go new file mode 100644 index 0000000..1770773 --- /dev/null +++ b/templates-repo/internal/document/comments.go @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package document + +import ( + "bytes" + "slices" + "strings" + "text/template/parse" +) + +// commentGroup is a run of comments that documents whatever follows it. +// +// Comments are grouped the way a Go author expects: a blank line starts a new group, a single line +// break does not. The index is that of the last comment of the group in the nodes of the tree it +// was found in, and locates a template close enough after it to claim it. +type commentGroup struct { + comments []string + index int + position parse.Pos +} + +// docstringsOf extracts the documentation an asset holds, per template it declares. +// +// A template declared by a "define" statement is documented by the comment group that precedes it, +// separated from it by nothing but white space. What is left of the leading group documents the +// asset itself. +// +// Comments anywhere else are comments: a note in the middle of a template, or one nested inside a +// branch, documents nothing and is ignored. +func docstringsOf(root *parse.Tree, declared map[string]*parse.Tree, name string) map[string][]string { + nodes := root.Root.Nodes + groups := commentGroupsOf(nodes) + docstrings := make(map[string][]string, len(declared)) + + // a "define" statement leaves no node behind, so it is placed by the position of its body + for _, defined := range declaredInOrder(declared, name) { + group := lastGroupBefore(groups, declared[defined].Root.Position()) + if group < 0 || !onlySpaceUntil(nodes, groups[group].index, declared[defined].Root.Position()) { + continue + } + + docstrings[defined] = groups[group].comments + groups = slices.Delete(groups, group, group+1) + } + + // the comment opening the asset documents the asset, and no "define" statement has claimed it + if opening, found := openingComment(nodes); found && len(groups) > 0 && groups[0].position == opening { + docstrings[name] = groups[0].comments + } + + return docstrings +} + +// commentGroupsOf collects the comment groups found at the top level of a tree. +func commentGroupsOf(nodes []parse.Node) []commentGroup { + var ( + groups []commentGroup + current *commentGroup + ) + + closeGroup := func() { + if current != nil { + groups = append(groups, *current) + current = nil + } + } + + for index, node := range nodes { + switch typed := node.(type) { + case *parse.CommentNode: + if current == nil { + current = &commentGroup{position: typed.Position()} + } + + current.comments = append(current.comments, commentText(typed.Text)) + current.index = index + + case *parse.TextNode: + // a blank line starts a new group, and anything that is not white space ends one + if len(bytes.TrimSpace(typed.Text)) > 0 || bytes.Count(typed.Text, []byte("\n")) > 1 { + closeGroup() + } + + default: + closeGroup() + } + } + closeGroup() + + return groups +} + +// lastGroupBefore returns the group closest to a position, or -1 when every group comes after it. +func lastGroupBefore(groups []commentGroup, position parse.Pos) int { + found := -1 + for index, group := range groups { + if group.position >= position { + break + } + + found = index + } + + return found +} + +// onlySpaceUntil reports whether nothing but white space stands between a node and a position. +func onlySpaceUntil(nodes []parse.Node, from int, until parse.Pos) bool { + for _, node := range nodes[from+1:] { + if node.Position() >= until { + return true + } + + text, isText := node.(*parse.TextNode) + if !isText || len(bytes.TrimSpace(text.Text)) > 0 { + return false + } + } + + return true +} + +// openingComment returns the position of the comment a tree opens with, when it opens with one. +// +// A comment that content comes before documents nothing, so the leading group is the documentation +// of the asset only when it opens it. +func openingComment(nodes []parse.Node) (parse.Pos, bool) { + for _, node := range nodes { + switch typed := node.(type) { + case *parse.CommentNode: + return typed.Position(), true + + case *parse.TextNode: + if len(bytes.TrimSpace(typed.Text)) > 0 { + return 0, false + } + + default: + return 0, false + } + } + + return 0, false +} + +// declaredInOrder returns the names an asset declares with a "define" statement, by position. +func declaredInOrder(declared map[string]*parse.Tree, assetName string) []string { + names := make([]string, 0, len(declared)) + for name := range declared { + if name == assetName { + continue // the tree of the asset itself, which the parser adds to the set + } + + names = append(names, name) + } + + slices.SortFunc(names, func(a, b string) int { + return int(declared[a].Root.Position()) - int(declared[b].Root.Position()) + }) + + return names +} + +// commentText strips the marks of a template comment and trims the white space around it. +func commentText(text string) string { + return strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(text, "/*"), "*/")) +} diff --git a/templates-repo/internal/document/comments_test.go b/templates-repo/internal/document/comments_test.go new file mode 100644 index 0000000..51cd74e --- /dev/null +++ b/templates-repo/internal/document/comments_test.go @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package document + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" +) + +func TestDocstringsOf(t *testing.T) { + analysis := analyze(t, + "{{/* asset renders a model. */}}\n"+ + "{{/* It has a second line. */}}\n"+ + "\n"+ + "{{/* macro expands a schema. */}}\n"+ + `{{define "macro"}}{{/* a note, not a docstring */}}body{{end}}`+"\n"+ + "\n"+ + "{{/* other does something else. */}}\n"+ + `{{define "other"}}other{{end}}`+"\n"+ + "content\n"+ + "{{/* a trailing note */}}\n", + ) + + t.Run("should document the asset with the group it opens with", func(t *testing.T) { + assert.Equal(t, + []string{"asset renders a model.", "It has a second line."}, + analysis.Docstrings["asset"], + ) + }) + + t.Run("should document a define with the group right before it", func(t *testing.T) { + assert.Equal(t, []string{"macro expands a schema."}, analysis.Docstrings["macro"]) + assert.Equal(t, []string{"other does something else."}, analysis.Docstrings["other"]) + }) + + t.Run("should document nothing with a note placed elsewhere", func(t *testing.T) { + assert.Len(t, analysis.Docstrings, 3) + }) + + t.Run("should keep a blank line from joining two groups", func(t *testing.T) { + analysis := analyze(t, + "{{/* first group. */}}\n\n{{/* second group. */}}\n"+`{{define "macro"}}body{{end}}`, + ) + + assert.Equal(t, []string{"first group."}, analysis.Docstrings["asset"]) + assert.Equal(t, []string{"second group."}, analysis.Docstrings["macro"]) + }) + + t.Run("should not document an asset whose comment comes after content", func(t *testing.T) { + analysis := analyze(t, "content\n{{/* too late */}}") + + assert.Empty(t, analysis.Docstrings) + }) + + t.Run("should let white space stand between a group and the define it documents", func(t *testing.T) { + analysis := analyze(t, "{{/* macro expands a schema. */}}\n\n\n"+`{{define "macro"}}body{{end}}`) + + assert.Equal(t, []string{"macro expands a schema."}, analysis.Docstrings["macro"]) + }) +} diff --git a/templates-repo/internal/document/contract.go b/templates-repo/internal/document/contract.go new file mode 100644 index 0000000..a9948e2 --- /dev/null +++ b/templates-repo/internal/document/contract.go @@ -0,0 +1,356 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package document + +import ( + "maps" + "slices" + "strings" + "text/template" + "text/template/parse" +) + +// currentData is the path the dot stands for at the top of a template. +const currentData = "." + +// rootVariable is the variable holding the data a template was executed on. +const rootVariable = "$" + +// builtins are the functions [text/template] always provides. +// +// They are left out of what a template reports calling: a caller replacing a template has to +// supply the functions of the func map, never these. A func map defining one of these names +// shadows the builtin, and calling it is then a dependency on the map after all. +var builtins = map[string]struct{}{ + "and": {}, "call": {}, "eq": {}, "ge": {}, "gt": {}, "html": {}, "index": {}, "js": {}, + "le": {}, "len": {}, "lt": {}, "ne": {}, "not": {}, "or": {}, "print": {}, "printf": {}, + "println": {}, "slice": {}, "urlquery": {}, +} + +// scope holds the path each variable in view stands for. +// +// A variable whose path could not be placed is held with an empty one, so that reading through it +// is counted rather than mistaken for a variable that was never declared. +type scope map[string]string + +// walker follows the dot through a parse tree and records what the template does with it. +type walker struct { + reads map[string]struct{} + rootReads map[string]struct{} + funcs map[string]struct{} + calls map[Call]struct{} + funcMap template.FuncMap + unresolved int + dynamic bool +} + +// contractOf reports what a template reads and calls. +func contractOf(root parse.Node, funcs template.FuncMap) Contract { + w := &walker{ + reads: make(map[string]struct{}), + rootReads: make(map[string]struct{}), + funcs: make(map[string]struct{}), + calls: make(map[Call]struct{}), + funcMap: funcs, + } + w.walk(root, currentData, make(scope)) + + contract := Contract{ + Reads: sortedKeys(w.reads), + RootReads: sortedKeys(w.rootReads), + Funcs: sortedKeys(w.funcs), + Calls: make([]Call, 0, len(w.calls)), + Unresolved: w.unresolved, + Dynamic: w.dynamic, + } + + for call := range w.calls { + contract.Calls = append(contract.Calls, call) + } + + slices.SortFunc(contract.Calls, func(a, b Call) int { + if a.Name != b.Name { + return strings.Compare(a.Name, b.Name) + } + + return strings.Compare(a.Data, b.Data) + }) + + return contract +} + +// walk visits a node, knowing what the dot stands for where it sits and what the variables in view +// stand for. +func (w *walker) walk(node parse.Node, dot string, vars scope) { + switch typed := node.(type) { + case *parse.ListNode: + if typed == nil { + return + } + + // a variable declared in a list is in view for the rest of it, and no further + inner := maps.Clone(vars) + for _, child := range typed.Nodes { + w.walk(child, dot, inner) + } + + case *parse.ActionNode: + w.pipe(typed.Pipe, dot, vars) + w.declare(typed.Pipe, w.selected(typed.Pipe, dot, vars), vars) + + case *parse.IfNode: + w.pipe(typed.Pipe, dot, vars) + w.branch(typed.List, dot, w.selected(typed.Pipe, dot, vars), typed.Pipe, vars) + w.walk(typed.ElseList, dot, vars) + + case *parse.WithNode: + // a with rebinds the dot to whatever it selects, and binds its variable to the same + w.pipe(typed.Pipe, dot, vars) + selected := w.selected(typed.Pipe, dot, vars) + w.branch(typed.List, selected, selected, typed.Pipe, vars) + w.walk(typed.ElseList, dot, vars) + + case *parse.RangeNode: + // a range rebinds the dot to an element of whatever it selects + w.pipe(typed.Pipe, dot, vars) + item := element(w.selected(typed.Pipe, dot, vars)) + w.rangeBranch(typed, item, vars) + w.walk(typed.ElseList, dot, vars) + + case *parse.TemplateNode: + handed := currentData + if typed.Pipe != nil { + handed = w.selected(typed.Pipe, dot, vars) + } + + w.calls[Call{Name: typed.Name, Data: handed}] = struct{}{} + w.pipe(typed.Pipe, dot, vars) + } +} + +// branch walks the body of an if or a with, with the variable the statement declares in view. +func (w *walker) branch(body parse.Node, dot, declared string, pipeline *parse.PipeNode, vars scope) { + inner := maps.Clone(vars) + w.declare(pipeline, declared, inner) + w.walk(body, dot, inner) +} + +// rangeBranch walks the body of a range, with the variables it declares in view. +// +// A range declaring one variable binds it to the element, and a range declaring two binds the +// first to the index, which stands for no path at all. +func (w *walker) rangeBranch(node *parse.RangeNode, item string, vars scope) { + inner := maps.Clone(vars) + + if node.Pipe != nil { + switch declarations := node.Pipe.Decl; len(declarations) { + case 1: + inner[declarations[0].Ident[0]] = item + case 2: //nolint:mnd // an index and an element + inner[declarations[0].Ident[0]] = "" + inner[declarations[1].Ident[0]] = item + } + } + + w.walk(node.List, item, inner) +} + +// declare binds the variables a pipeline declares to the path it selects. +func (w *walker) declare(pipeline *parse.PipeNode, selected string, vars scope) { + if pipeline == nil { + return + } + + for _, declared := range pipeline.Decl { + if len(declared.Ident) > 0 { + vars[declared.Ident[0]] = selected + } + } +} + +// pipe records what a pipeline reads and which functions it calls. +func (w *walker) pipe(pipeline *parse.PipeNode, dot string, vars scope) { + if pipeline == nil { + return + } + + for _, command := range pipeline.Cmds { + for _, argument := range command.Args { + w.argument(argument, dot, vars) + } + } +} + +// argument records a single term of a command. +func (w *walker) argument(argument parse.Node, dot string, vars scope) { + switch typed := argument.(type) { + case *parse.FieldNode: + w.read(dot, typed.Ident) + + case *parse.ChainNode: + w.chain(typed, dot, vars) + + case *parse.VariableNode: + w.variable(typed, vars) + + case *parse.IdentifierNode: + w.function(typed.Ident) + + case *parse.PipeNode: + w.pipe(typed, dot, vars) + } +} + +// function records a call to a function of the func map. +// +// A builtin is not recorded, since it is always there, unless the func map shadows it. "call" is +// never recorded, and marks the template dynamic instead: it invokes a function held by the data, +// which the analysis cannot follow into, so what it reads stays unknown. +func (w *walker) function(name string) { + if name == "call" { + w.dynamic = true + + return + } + + _, isBuiltin := builtins[name] + _, shadowed := w.funcMap[name] + + if isBuiltin && !shadowed { + return + } + + w.funcs[name] = struct{}{} +} + +// chain records a path read from the result of an expression, as in (index .Items 0).Name. +// +// The base resolves to a path of its own when it is a field, the dot or a variable. Otherwise it +// comes out of a function call, and the fields hanging from it belong to no path this analysis can +// name, so they are counted rather than reported under the current dot. +func (w *walker) chain(node *parse.ChainNode, dot string, vars scope) { + if path := w.path(node, dot, vars); path != "" { + w.reads[path] = struct{}{} + + return + } + + w.argument(node.Node, dot, vars) + w.unresolved++ +} + +// variable records what is read through a variable. +// +// "$" stands for the data the template was executed on, wherever it is read from: a range or a +// with rebinds the dot, never "$". Any other variable stands for the path it was declared with. +func (w *walker) variable(node *parse.VariableNode, vars scope) { + if len(node.Ident) < 2 { //nolint:mnd // a lone variable reads no path of its own + return + } + + if node.Ident[0] == rootVariable { + path := join(currentData, node.Ident[1:]) + w.reads[path] = struct{}{} + w.rootReads[path] = struct{}{} + + return + } + + w.read(vars[node.Ident[0]], node.Ident[1:]) +} + +// read records a path, unless the dot it hangs from could not be placed. +func (w *walker) read(dot string, idents []string) { + if dot == "" { + w.unresolved++ + + return + } + + w.reads[join(dot, idents)] = struct{}{} +} + +// selected reports the path a range, a with or a template statement selects. +// +// It is empty when the selection is anything more involved than a field, the dot itself or a path +// from the root, since the analysis cannot follow a value through a function. +func (w *walker) selected(pipeline *parse.PipeNode, dot string, vars scope) string { + if pipeline == nil || len(pipeline.Cmds) != 1 || len(pipeline.Cmds[0].Args) != 1 { + return "" + } + + return w.path(pipeline.Cmds[0].Args[0], dot, vars) +} + +// path reports the data path a term stands for, empty when it stands for none the analysis can name. +func (w *walker) path(node parse.Node, dot string, vars scope) string { + switch typed := node.(type) { + case *parse.FieldNode: + return join(dot, typed.Ident) + + case *parse.DotNode: + return dot + + case *parse.ChainNode: + base := w.path(typed.Node, dot, vars) + if base == "" { + return "" + } + + return join(base, typed.Field) + + case *parse.PipeNode: + // a term in parentheses, which stands for a path when it holds nothing but one + return w.selected(typed, dot, vars) + + case *parse.VariableNode: + if len(typed.Ident) == 0 { + return "" + } + + if typed.Ident[0] == rootVariable { + return join(currentData, typed.Ident[1:]) + } + + if base, declared := vars[typed.Ident[0]]; declared && base != "" { + return join(base, typed.Ident[1:]) + } + } + + return "" +} + +// join hangs a chain of field names from the path the dot stands for. +func join(dot string, idents []string) string { + if len(idents) == 0 { + return dot + } + + if dot == currentData { + return currentData + strings.Join(idents, ".") + } + + return dot + "." + strings.Join(idents, ".") +} + +// element turns the path of a collection into the path of one of its elements. +func element(path string) string { + if path == "" { + return "" + } + + return path + "[]" +} + +// sortedKeys returns the keys of a set, in order. +func sortedKeys(set map[string]struct{}) []string { + keys := make([]string, 0, len(set)) + for key := range set { + keys = append(keys, key) + } + + slices.Sort(keys) + + return keys +} diff --git a/templates-repo/internal/document/doc.go b/templates-repo/internal/document/doc.go new file mode 100644 index 0000000..9e46c4a --- /dev/null +++ b/templates-repo/internal/document/doc.go @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package document analyses template sources, rather than compiling them. +// +// A go template declares nothing about the data it runs on: which data model fits which template is +// knowledge that lives outside the language. The parse tree, however, records every access, and the +// data the current dot stands for can be followed through range, with and template statements. What +// a template expects is therefore recoverable, as the set of paths it reads. +// +// [Analyze] reads one asset and reports, per template it declares, the comments documenting it and +// the contract it works to. +// +// # What the result means +// +// The result closes over every branch, so it holds everything a template may read, not what one +// execution of it does read. A path guarded by a condition is reported like any other, and the +// tree cannot tell which of them a given run will reach. +// +// It is therefore what the data has to be able to answer, and not a list of what it must hold. +// +// # Limits +// +// An access the analysis cannot place is counted rather than dropped, so an incomplete contract +// is visible as such. A dot or a variable that comes out of a function call produces one, +// since a value cannot be followed through a function. +// +// A template invoking a function held by its data, with the builtin "call", is reported as +// dynamic: what such a function reads is decided when the template runs. Nothing else invokes a +// template or reads data by a name computed at run time, and a func map that did would be +// invisible here. +// +// A variable assigned inside a block keeps, for the rest of the block, the path it is assigned. +// Past the end of that block the analysis reports the path it was declared with, so an assignment +// intended to outlive its block is not followed. +package document diff --git a/templates-repo/internal/document/document_test.go b/templates-repo/internal/document/document_test.go new file mode 100644 index 0000000..a7add71 --- /dev/null +++ b/templates-repo/internal/document/document_test.go @@ -0,0 +1,284 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package document + +import ( + "testing" + "text/template" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func analyze(t *testing.T, source string) Analysis { + t.Helper() + + analysis, err := Analyze("asset.gotmpl", "asset", []byte(source), nil) + require.NoError(t, err) + + return analysis +} + +func TestContractReads(t *testing.T) { + t.Run("should read a field of the data it runs on", func(t *testing.T) { + contract := analyze(t, `{{ .Name }} {{ .Schema.GoType }}`).Contracts["asset"] + + assert.Equal(t, []string{".Name", ".Schema.GoType"}, contract.Reads) + }) + + t.Run("should follow the dot into a range", func(t *testing.T) { + contract := analyze(t, `{{ range .Properties }}{{ .Name }}{{ end }}`).Contracts["asset"] + + assert.Equal(t, []string{".Properties", ".Properties[].Name"}, contract.Reads) + }) + + t.Run("should follow the dot into a with", func(t *testing.T) { + contract := analyze(t, `{{ with .Schema }}{{ .GoType }}{{ end }}`).Contracts["asset"] + + assert.Equal(t, []string{".Schema", ".Schema.GoType"}, contract.Reads) + }) + + t.Run("should follow the dot through nested ranges", func(t *testing.T) { + contract := analyze(t, + `{{ range .AllOf }}{{ range .Properties }}{{ .Required }}{{ end }}{{ end }}`, + ).Contracts["asset"] + + assert.Contains(t, contract.Reads, ".AllOf[].Properties[].Required") + }) + + t.Run("should keep the else branch at the outer dot", func(t *testing.T) { + contract := analyze(t, `{{ range .Items }}{{ .Inner }}{{ else }}{{ .Fallback }}{{ end }}`).Contracts["asset"] + + assert.Equal(t, []string{".Fallback", ".Items", ".Items[].Inner"}, contract.Reads) + }) + + t.Run("should record the functions it calls", func(t *testing.T) { + contract := analyze(t, `{{ pascalize (humanize .Name) }}`).Contracts["asset"] + + assert.Equal(t, []string{"humanize", "pascalize"}, contract.Funcs) + }) +} + +func TestContractRootReads(t *testing.T) { + t.Run("should record a reach back to the root from inside a range", func(t *testing.T) { + contract := analyze(t, `{{ range .Properties }}{{ $.Package }}{{ .Name }}{{ end }}`).Contracts["asset"] + + assert.Equal(t, []string{".Package"}, contract.RootReads) + assert.Contains(t, contract.Reads, ".Package", "a root read is a read like any other") + assert.Contains(t, contract.Reads, ".Properties[].Name") + }) + + t.Run("should keep the root of a with at the template's own data", func(t *testing.T) { + contract := analyze(t, `{{ with .Schema }}{{ $.Package }}{{ end }}`).Contracts["asset"] + + assert.Equal(t, []string{".Package"}, contract.RootReads) + }) +} + +func TestContractCalls(t *testing.T) { + t.Run("should record the data handed to a template", func(t *testing.T) { + contract := analyze(t, `{{ template "docstring" . }}{{ template "schema" .Schema }}`).Contracts["asset"] + + assert.Equal(t, + []Call{{Name: "docstring", Data: "."}, {Name: "schema", Data: ".Schema"}}, + contract.Calls, + ) + }) + + t.Run("should record the data handed from inside a range", func(t *testing.T) { + contract := analyze(t, + `{{ range .AllOf }}{{ template "structfield" .Properties }}{{ end }}`, + ).Contracts["asset"] + + assert.Equal(t, []Call{{Name: "structfield", Data: ".AllOf[].Properties"}}, contract.Calls) + }) + + t.Run("should record a call with no argument as handing over its own data", func(t *testing.T) { + contract := analyze(t, `{{ template "docstring" }}`).Contracts["asset"] + + assert.Equal(t, []Call{{Name: "docstring", Data: "."}}, contract.Calls) + }) + + t.Run("should analyse a define on its own data", func(t *testing.T) { + analysis := analyze(t, `{{ define "macro" }}{{ .GoName }}{{ end }}{{ .Package }}`) + + assert.Equal(t, []string{".GoName"}, analysis.Contracts["macro"].Reads) + assert.Equal(t, []string{".Package"}, analysis.Contracts["asset"].Reads) + }) +} + +func TestContractUnresolved(t *testing.T) { + t.Run("should count a dot that comes out of a function", func(t *testing.T) { + contract := analyze(t, `{{ with pick .Schema }}{{ .GoType }}{{ end }}`).Contracts["asset"] + + assert.Equal(t, 1, contract.Unresolved) + assert.NotContains(t, contract.Reads, ".GoType", "the path has no known root") + }) + + t.Run("should count a read through a variable holding an unplaceable value", func(t *testing.T) { + contract := analyze(t, `{{ $item := pick .Schema }}{{ $item.GoType }}`).Contracts["asset"] + + assert.Equal(t, 1, contract.Unresolved) + }) + + t.Run("should count a read through the index of a range", func(t *testing.T) { + contract := analyze(t, `{{ range $index, $item := .Items }}{{ $index.Whatever }}{{ end }}`).Contracts["asset"] + + assert.Equal(t, 1, contract.Unresolved) + }) + + t.Run("should leave a resolved template with nothing unresolved", func(t *testing.T) { + contract := analyze(t, `{{ range .Items }}{{ .Name }}{{ end }}`).Contracts["asset"] + + assert.Zero(t, contract.Unresolved) + }) +} + +func TestAnalyzeErrors(t *testing.T) { + _, err := Analyze("broken.gotmpl", "broken", []byte(`{{ if }}`), nil) + + require.Error(t, err) + assert.ErrorContains(t, err, "broken.gotmpl") +} + +func TestContractVariables(t *testing.T) { + t.Run("should follow a variable to the path it holds", func(t *testing.T) { + contract := analyze(t, `{{ $ctx := .Ctx }}{{ $ctx.Title }} {{ $ctx.Description }}`).Contracts["asset"] + + assert.Equal(t, []string{".Ctx", ".Ctx.Description", ".Ctx.Title"}, contract.Reads) + assert.Zero(t, contract.Unresolved) + }) + + t.Run("should follow a variable declared from another one", func(t *testing.T) { + contract := analyze(t, `{{ $ctx := .Ctx }}{{ $inner := $ctx.Schema }}{{ $inner.GoType }}`).Contracts["asset"] + + assert.Contains(t, contract.Reads, ".Ctx.Schema.GoType") + }) + + t.Run("should bind the element of a range to its variable", func(t *testing.T) { + contract := analyze(t, `{{ range $prop := .Properties }}{{ $prop.Name }}{{ end }}`).Contracts["asset"] + + assert.Equal(t, []string{".Properties", ".Properties[].Name"}, contract.Reads) + }) + + t.Run("should bind the element of a range declaring an index too", func(t *testing.T) { + contract := analyze(t, + `{{ range $index, $prop := .Properties }}{{ $prop.Name }}{{ end }}`, + ).Contracts["asset"] + + assert.Contains(t, contract.Reads, ".Properties[].Name") + }) + + t.Run("should bind the variable a with declares", func(t *testing.T) { + contract := analyze(t, `{{ with $schema := .Schema }}{{ $schema.GoType }}{{ .Name }}{{ end }}`).Contracts["asset"] + + assert.Equal(t, []string{".Schema", ".Schema.GoType", ".Schema.Name"}, contract.Reads) + }) + + t.Run("should bind the variable an if declares", func(t *testing.T) { + contract := analyze(t, `{{ if $schema := .Schema }}{{ $schema.GoType }}{{ end }}`).Contracts["asset"] + + assert.Contains(t, contract.Reads, ".Schema.GoType") + }) + + t.Run("should hand the path a variable holds to a template it calls", func(t *testing.T) { + contract := analyze(t, `{{ $ctx := .Ctx }}{{ template "docstring" $ctx }}`).Contracts["asset"] + + assert.Equal(t, []Call{{Name: "docstring", Data: ".Ctx"}}, contract.Calls) + }) + + t.Run("should range over the path a variable holds", func(t *testing.T) { + contract := analyze(t, `{{ $all := .AllOf }}{{ range $all }}{{ .GoType }}{{ end }}`).Contracts["asset"] + + assert.Contains(t, contract.Reads, ".AllOf[].GoType") + }) + + t.Run("should let a block shadow a variable without losing the outer one", func(t *testing.T) { + contract := analyze(t, + `{{ $x := .Outer }}{{ if . }}{{ $x := .Inner }}{{ $x.Leaf }}{{ end }}{{ $x.Leaf }}`, + ).Contracts["asset"] + + assert.Contains(t, contract.Reads, ".Inner.Leaf", "the inner block reads the shadowing one") + assert.Contains(t, contract.Reads, ".Outer.Leaf", "past the block, the outer one is back") + }) + + t.Run("should follow a variable reassigned to another path", func(t *testing.T) { + contract := analyze(t, `{{ $x := .First }}{{ $x = .Second }}{{ $x.Leaf }}`).Contracts["asset"] + + assert.Contains(t, contract.Reads, ".Second.Leaf") + }) + + t.Run("should keep $ bound to the data of the template", func(t *testing.T) { + contract := analyze(t, + `{{ range $item := .Items }}{{ $.Package }}{{ $item.Name }}{{ end }}`, + ).Contracts["asset"] + + assert.Equal(t, []string{".Package"}, contract.RootReads) + assert.Contains(t, contract.Reads, ".Items[].Name") + }) +} + +func TestContractFuncs(t *testing.T) { + t.Run("should leave the builtins out", func(t *testing.T) { + contract := analyze(t, + `{{ if and (not .A) (or .B .C) }}{{ len .D }}{{ printf "%v" .E }}{{ eq .F 1 }}{{ end }}`, + ).Contracts["asset"] + + assert.Empty(t, contract.Funcs, "a caller supplies the func map, never the builtins") + assert.Equal(t, []string{".A", ".B", ".C", ".D", ".E", ".F"}, contract.Reads) + }) + + t.Run("should record a builtin the func map shadows", func(t *testing.T) { + const source = `{{ printf "%v" .A }}{{ len .B }}` + + plain, err := Analyze("a.gotmpl", "asset", []byte(source), nil) + require.NoError(t, err) + assert.Empty(t, plain.Contracts["asset"].Funcs) + + shadowing, err := Analyze("a.gotmpl", "asset", []byte(source), template.FuncMap{"printf": nil}) + require.NoError(t, err) + assert.Equal(t, []string{"printf"}, shadowing.Contracts["asset"].Funcs, + "a func map defining a builtin name makes calling it a dependency on the map") + }) + + t.Run("should record the functions of the func map", func(t *testing.T) { + contract := analyze(t, `{{ printf "%q" .Field | myFunc }}`).Contracts["asset"] + + assert.Equal(t, []string{"myFunc"}, contract.Funcs) + assert.Equal(t, []string{".Field"}, contract.Reads, "a pipeline is walked through") + }) + + t.Run("should report a template invoking a function held by its data", func(t *testing.T) { + contract := analyze(t, `{{ call .Fn .Arg }}`).Contracts["asset"] + + assert.True(t, contract.Dynamic) + assert.Equal(t, []string{".Arg", ".Fn"}, contract.Reads) + }) + + t.Run("should leave an ordinary template not dynamic", func(t *testing.T) { + assert.False(t, analyze(t, `{{ .Name }}`).Contracts["asset"].Dynamic) + }) +} + +func TestContractChains(t *testing.T) { + t.Run("should read a chain hanging from a field", func(t *testing.T) { + contract := analyze(t, `{{ (.Schema).GoType }}`).Contracts["asset"] + + assert.Equal(t, []string{".Schema.GoType"}, contract.Reads) + }) + + t.Run("should read a chain hanging from a variable", func(t *testing.T) { + contract := analyze(t, `{{ $ctx := .Ctx }}{{ ($ctx).Title }}`).Contracts["asset"] + + assert.Contains(t, contract.Reads, ".Ctx.Title") + }) + + t.Run("should not root a chain hanging from a function at the current dot", func(t *testing.T) { + contract := analyze(t, `{{ (index .Items 0).Name }}`).Contracts["asset"] + + assert.Equal(t, []string{".Items"}, contract.Reads, "the base is read, the chain is not a path") + assert.NotContains(t, contract.Reads, ".Name") + assert.Equal(t, 1, contract.Unresolved) + }) +} diff --git a/templates-repo/naming.go b/templates-repo/naming.go new file mode 100644 index 0000000..008171b --- /dev/null +++ b/templates-repo/naming.go @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package repo + +import ( + "strings" + + "github.com/go-openapi/codegen/mangling" +) + +// nameMangler recases an asset path into the name a template answers to. +// +// It reads "/" as a token separator on top of the usual ones, so a whole address is recased in one +// pass and a directory carries no more weight than any other word of a name. +// +// A [mangling.Mangler] is an immutable value, safe for concurrent use, so one instance serves +// every repository. +var nameMangler = mangling.MakeMangler( + mangling.WithTokenOptions(mangling.WithExtraTokenSeparator('/')), +) + +// DefaultExtension is the file extension a repository recognizes as a template when the caller +// declares no other with [WithExtensions]. +const DefaultExtension = ".gotmpl" + +// TemplateName returns the name a repository gives an asset at a path. +// +// It computes the name before a repository exists, for a caller that has to choose its sources by +// name: the name decides what to build, so it cannot wait for the build. +// [Repository.NameOf] answers the same question for a repository already built. +// +// The extensions are those the repository recognizes, [DefaultExtension] when none is given. +// +// Example: +// +// repo.TemplateName("validation/primitive.gotmpl") -> validationPrimitive +func TemplateName(assetPath string, extensions ...string) string { + if len(extensions) == 0 { + extensions = []string{DefaultExtension} + } + + return templateName(assetPath, extensions) +} + +// templateName derives the name of a template from the path of the asset that declares it. +// +// The path is expected to be slash-separated and cleaned, which every source guarantees. The +// supported extension is trimmed, and the rest is camel-cased, "/" counting as a word boundary +// like any other. +// +// Example: +// +// validation/primitive.gotmpl -> validationPrimitive +// swagger_json_embed.gotmpl -> swaggerJsonEmbed +func templateName(assetPath string, extensions []string) string { + return nameMangler.Camelize(trimExtension(assetPath, extensions)) +} + +// templateName derives the name of a template with the extensions this repository recognizes. +func (o options) templateName(assetPath string) string { + return templateName(assetPath, o.extensions) +} + +// trimmedPath is the address an asset declares its own template at. +// +// It is the asset path with the extension trimmed, and nothing else: an address is never mangled, +// which is what lets a reference be written the way its author sees the tree. +func (o options) trimmedPath(assetPath string) string { + return trimExtension(assetPath, o.extensions) +} + +// trimExtension removes from a name the first supported extension it ends with. +func trimExtension(name string, extensions []string) string { + for _, extension := range extensions { + if trimmed, found := strings.CutSuffix(name, extension); found { + return trimmed + } + } + + return name +} + +// hasSupportedExtension reports whether an asset is recognized as a template. +func (o options) hasSupportedExtension(name string) bool { + for _, extension := range o.extensions { + if strings.HasSuffix(name, extension) { + return true + } + } + + return false +} diff --git a/templates-repo/options.go b/templates-repo/options.go new file mode 100644 index 0000000..96386a8 --- /dev/null +++ b/templates-repo/options.go @@ -0,0 +1,238 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package repo + +import ( + "fmt" + "maps" + "slices" + "strings" + "text/template" +) + +// Option configures a [Repository] built with [New] or derived with [Clone]. +// +// An option that cannot be honoured reports an error from [New] or [Clone], rather than at the +// point where it is constructed: a repository built from settings the caller did not ask for is +// worse than one that fails to build. +// +// # Usage +// +// Options come in two kinds. Sources declare where templates are read from, and are applied in +// order: [FromFS], [FromDir] and [FromTemplate]. Settings shape how they are read, whatever the +// order: [WithFuncMap], [WithExtensions], [WithRoots], [WithExtraRoots] and [WithCoverage]. +// How one source is read is settled where it is declared, with a [SourceOption]. +type Option func(*options) error + +// options holds the settings of a repository, and the sources it is yet to read. +type options struct { + sources []source + funcs template.FuncMap + extensions []string + roots []string + coverPrefix string + coverage bool +} + +// makeOptions applies opts on top of the defaults. +// +// The defaults are built afresh on every call, so that no repository shares a map or a slice +// with another one. +func makeOptions(opts []Option) (options, error) { + o := options{ + funcs: make(template.FuncMap), + extensions: []string{DefaultExtension}, + } + + if err := o.apply(opts); err != nil { + return options{}, err + } + + return o, nil +} + +// apply runs a list of options over these settings. +func (o *options) apply(opts []Option) error { + for _, option := range opts { + if option == nil { + continue + } + + if err := option(o); err != nil { + return err + } + } + + return nil +} + +// derive copies the settings of a repository for a [Clone], with no source left to read. +// +// The sources of the original have already been read into assets, which the clone inherits +// directly, so carrying them over would read them twice. +func (o options) derive() options { + return options{ + funcs: maps.Clone(o.funcs), + extensions: slices.Clone(o.extensions), + roots: slices.Clone(o.roots), + coverPrefix: o.coverPrefix, + coverage: o.coverage, + } +} + +// WithFuncMap adds functions that templates may call. +// +// By default a repository binds no function, so templates only have the builtins of +// [text/template]. The map is copied, and repeated calls merge, the last definition of a name +// winning. +// +// Functions are bound when templates are parsed, which is why they cannot be changed afterwards. +// Adding a function to an existing repository is [Clone] with this option: the clone re-parses +// its templates, so the new function reaches all of them. +func WithFuncMap(funcs template.FuncMap) Option { + return func(o *options) error { + maps.Copy(o.funcs, funcs) + + return nil + } +} + +// WithExtensions sets the file extensions recognized as templates when reading a file system. +// +// The default is ".gotmpl" alone. An asset whose name ends with none of them is ignored by +// [FromFS] and [FromDir], while [FromTemplate] registers its content whatever its name. +// +// The extension is trimmed from the asset path before its name is derived, so +// "validation/primitive.gotmpl" is named validationPrimitive. +func WithExtensions(extensions ...string) Option { + return func(o *options) error { + if len(extensions) == 0 { + return fmt.Errorf("at least one extension is required: %w", ErrTemplateRepo) + } + + o.extensions = slices.Clone(extensions) + + return nil + } +} + +// WithRoots keeps the templates named, and the templates they reach, and prunes the rest. +// +// A generator ships every template it may ever need, and a single run needs a part of them: the +// templates a client needs are not those a server needs. Naming the roots of a run keeps the +// repository to what that run executes, and lets a template set that is incomplete for the other +// runs build all the same. +// +// Roots are names, the identity [Repository.Get] takes, never the address a template was declared +// at. [Repository.NameOf] converts one to the other for a caller holding addresses. +// +// A root no source declares is an error: a filter naming a template that does not exist builds a +// repository that generates nothing, which is worse than a build that fails. +// +// Everything is still read and parsed, since a template only names itself once parsed, so a source +// that does not parse is an error whether it is pruned away or not. Pruning decides only which +// templates the repository holds, and therefore what [Repository.Names] lists, what its +// documentation covers, and what its coverage counts. +// +// This sets the scope rather than adding to it: a [Clone] naming roots of its own is scoped to +// those alone, whatever the repository it derives from was scoped to. [WithExtraRoots] is the one +// that widens a scope. A repository with no root at all keeps every template it reads, which is +// the default. +// +// Example: +// +// // the templates a client generation executes, and nothing else +// client, err := repo.Clone(repository, repo.WithRoots("clientClient", "clientParameter", "model")) +func WithRoots(names ...string) Option { + return func(o *options) error { + scope, err := scopeOf(names) + if err != nil { + return err + } + + o.roots = scope + + return nil + } +} + +// WithExtraRoots widens the scope of a repository with roots of its own. +// +// It adds to whatever [WithRoots] settled, and changes nothing on a repository that keeps every +// template it reads, since that scope already holds them. It takes names, as [WithRoots] does. +// +// Use it to add a template to a repository the caller did not build. [WithRoots] would be wrong +// either way: on an unscoped repository it prunes everything else away, and on a scoped one it +// discards the scope already set. +// +// Example: +// +// // one more template, reachable whether or not the repository is scoped +// mine, err := repo.Clone(repository, repo.FromTemplate("mine", body), repo.WithExtraRoots("mine")) +func WithExtraRoots(names ...string) Option { + return func(o *options) error { + scope, err := scopeOf(names) + if err != nil { + return err + } + + if len(o.roots) == 0 { + return nil // every template is kept already, these among them + } + + for _, name := range scope { + if !slices.Contains(o.roots, name) { + o.roots = append(o.roots, name) + } + } + + return nil + } +} + +// scopeOf checks the roots a caller names, and drops the repetitions. +func scopeOf(names []string) ([]string, error) { + if len(names) == 0 { + return nil, fmt.Errorf("at least one root template is required: %w", ErrTemplateRepo) + } + + scope := make([]string, 0, len(names)) + for _, name := range names { + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("a root template cannot be unnamed: %w", ErrTemplateRepo) + } + + if !slices.Contains(scope, name) { + scope = append(scope, name) + } + } + + return scope, nil +} + +// WithCoverage counts the lines of the templates that run. +// +// Counting is decided here rather than later: the templates that execute have to be the ones +// holding the counters, so a repository either is instrumented or is not. [Clone] carries the +// setting over, and a clone of a plain repository asking for it yields an instrumented twin. +// +// prefix is prepended to the path of every asset in the profile [Repository.Coverage] writes. +// go tool cover resolves the file a profile names by asking go list, so the paths have to read as +// an import path of a package that exists, so prefix is required. +// +// Example: +// +// repo.WithCoverage("github.com/go-swagger/go-swagger/generator/templates") +func WithCoverage(prefix string) Option { + return func(o *options) error { + if strings.TrimSpace(prefix) == "" { + return fmt.Errorf("coverage needs the import path the templates live under: %w", ErrTemplateRepo) + } + + o.coverage = true + o.coverPrefix = strings.TrimSuffix(prefix, "/") + "/" + + return nil + } +} diff --git a/templates-repo/reports/README.md b/templates-repo/reports/README.md new file mode 100644 index 0000000..1f7a50e --- /dev/null +++ b/templates-repo/reports/README.md @@ -0,0 +1,73 @@ +# reports + +What a [templates repository](../README.md) says about itself: the documentation of its templates, and the +audit of what it holds. + +```go +import "github.com/go-openapi/codegen/templates-repo/reports" +``` + +A repository executes templates. Describing them is a separate job, and the types that describe +them outnumber the ones that run them. They live here so that a program which only renders +templates imports none of it. + +## Documentation + +`Repository.Documentation` returns a `Documentation`, grouped by asset so it follows the tree an +author edits, and ordered throughout so the same templates produce the same document every time. + +Each `Template` in it carries the comments documenting it, the data paths it reads, the functions +it calls, and the templates it calls with the data passed to each. `Transitive` holds the same +once the templates it calls are folded in, with their paths rebased onto the data handed to them. + +`Dump` renders a documentation as markdown: + +```go +documentation, err := repository.Documentation() +if err != nil { + return err +} + +err = reports.Dump(w, documentation) +``` + +`WithTemplate` lays it out otherwise, and `WithFuncMap` adds functions such a layout may call. +Walk the `Documentation` directly for anything a text template cannot produce. + +```go +err = reports.Dump(w, documentation, reports.WithTemplate(myLayout)) +``` + +`Repository.Dump` is the same thing in one call, for the common case of rendering markdown once. + +## Audit + +`Repository.Audit` returns an `Audit`, listing what compiles and runs but still deserves a look: + +| | | +|---|---| +| `Overridden` | templates more than one asset declared, and which definition stands | +| `Unused` | templates no other template calls | +| `Empty` | templates that render nothing | +| `Dynamic` | templates calling a function carried by their data | +| `UnusedFuncs` | funcmap entries no template calls | + +None of it is an error. The repository rejects what it cannot resolve when it is built, so +everything reported here already works. A function no funcmap provides never reaches the audit +either: templates are parsed against the funcmap, so that fails the build instead. + +`Unused` and `UnusedFuncs` are observations rather than verdicts. Nothing calls a generator's +entry points either, and a general-purpose funcmap is mostly unused by design. Scope the +repository to its roots and `Unused` answers for itself. + +## Errors + +Everything this package reports matches `ErrReport` and wraps its cause, so a caller may match the +underlying template error with `errors.Is` and `errors.As` all the same. + +## Tests + +```sh +go test ./... +golangci-lint run +``` diff --git a/templates-repo/reports/audit.go b/templates-repo/reports/audit.go new file mode 100644 index 0000000..aecf15a --- /dev/null +++ b/templates-repo/reports/audit.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package reports + +// Audit lists what a repository holds that deserves a second look. +// +// None of it is an error. [New] rejects what it cannot resolve, so everything here compiles and +// runs. These are the things a set of templates gets wrong quietly: +// a macro replaced by accident, a template nobody calls any more, a name that renders nothing. +// +// A function no template can resolve is not among them. Templates are parsed against the func map, +// so calling a function nothing provides fails the build. +type Audit struct { + // Overridden lists the templates that more than one asset declared, with the definition that + // stands and the ones it replaced. + Overridden []Override + + // Unused lists the templates nothing else calls, ordered by name. + // + // A repository scoped with [WithRoots] leaves its roots out of this, since a run starts there. + // One that keeps every template it read cannot tell an entry point from a dead template, so + // its entry points are listed too. + Unused []string + + // Empty lists the templates that render nothing, ordered by name. An asset holding only + // "define" statements declares one, under its own name. + Empty []string + + // Dynamic lists the templates invoking a function held by their data, with the "call" builtin. + // + // Nothing settles such a call before the template runs, so what it reaches is unknown to the + // repository and to the documentation alike. + Dynamic []string + + // UnusedFuncs lists the func map entries no template calls, ordered by name. + UnusedFuncs []string +} + +// Override is a template a source declared and a later one replaced. +// +// Stacking sources exists in order to override, so this is not an error. It is worth reporting +// all the same: nothing else reveals a template set that replaced a definition by accident. +type Override struct { + // Name is the template that was declared more than once. + Name string + + // Standing is the path of the asset whose definition the repository holds. + Standing string + + // Replaced holds the paths of the assets whose definitions it replaced, in the order they + // were read. + Replaced []string +} diff --git a/templates-repo/reports/doc.go b/templates-repo/reports/doc.go new file mode 100644 index 0000000..db31705 --- /dev/null +++ b/templates-repo/reports/doc.go @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package reports holds what a templates repository says about itself. +// +// A repository executes templates. Describing them is a separate job, and the types that describe +// them are the bulk of what a caller would otherwise import without ever executing anything. They +// live here so that a program rendering templates imports none of it. +// +// # Usage +// +// [github.com/go-openapi/codegen/templates-repo.Repository] builds these values, and this package +// declares them and renders them: +// +// documentation, err := repository.Documentation() +// if err != nil { +// return err +// } +// +// err = reports.Dump(w, documentation) +// +// [Dump] writes markdown by default. Pass [WithTemplate] to lay a document out otherwise, or walk +// the [Documentation] and render it however a text template cannot. +// +// # What the reports cover +// +// [Documentation] describes the templates a repository holds: the comments on each one, the data +// paths it reads, the functions it calls, and the templates it calls with the data handed to each. +// It is grouped by asset, so it follows the tree an author edits, and it is ordered throughout, so +// a document generated twice from the same templates is the same document twice. +// +// [Audit] lists what compiles and runs but still deserves a look: a template two assets declared, +// a template nothing calls, a template that renders nothing, a template calling a function carried +// by its data, and a func map entry no template calls. +package reports diff --git a/templates-repo/reports/documentation.go b/templates-repo/reports/documentation.go new file mode 100644 index 0000000..fc6b9fc --- /dev/null +++ b/templates-repo/reports/documentation.go @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package reports + +// Documentation is the structure of a repository, as far as a reader of its templates cares. +// +// It is grouped by asset rather than by template, so that it follows the tree its author edits, +// and it is ordered, so that a document generated from it twice is the same document twice. +type Documentation struct { + // Assets holds one entry per asset that declares a template, ordered by path. + Assets []Asset +} + +// Asset is the documentation of a single template asset. +type Asset struct { + // Path is the asset path, as mounted. + Path string + + // Templates holds the templates the asset declares, the one named after the asset first, + // then those declared by a "define" statement, ordered by name. + Templates []Template +} + +// Template is the documentation of a single template. +type Template struct { + // Name is the name the template is registered under. + Name string + + // Doc holds the comments documenting the template, one entry per comment. + Doc []string + + // Reads lists the data paths the template may read, rooted at the data it is executed on. + // + // It closes over every branch, so it describes what the data must be able to answer, not a + // list of what it must hold: a path guarded by a condition may never be reached. + Reads []string + + // RootReads lists the paths read through "$", a reach past the current dot back to the data + // the template was executed on. + RootReads []string + + // Funcs lists the func map functions the template calls, sorted. Builtins are left out. + Funcs []string + + // Dependencies lists the templates this one calls, with the data handed to each. + Dependencies []Dependency + + // UsedBy lists the templates that refer to this one directly, sorted. + UsedBy []string + + // Inner reports whether the template is declared by a "define" statement rather than by an + // asset of its own. + Inner bool + + // Empty reports whether the template holds nothing but white space and comments, so that + // executing it renders nothing. + // + // An asset made of "define" statements alone declares such a template under its own name, + // which is reachable like any other and renders nothing. + Empty bool + + // Unresolved counts the data accesses the analysis could not place, because the value they + // hang from comes out of a function call. + Unresolved int + + // Dynamic reports whether the template invokes a function held by its data, with the builtin + // "call", which makes its contract incomplete by construction. + Dynamic bool + + // Transitive holds the data the template reads once the templates it calls are folded into it. + Transitive Transitive +} + +// Transitive holds the data paths a template reads once the templates it calls are folded +// into it. +// +// The paths a called template reads are rebased onto the data handed to it, so a template reading +// ".GoName" called with ".Properties[]" contributes ".Properties[].GoName" to its caller. +type Transitive struct { + // Reads lists the data paths the template may read, itself or through the templates it calls. + Reads []string + + // Funcs lists the func map functions reached the same way. + Funcs []string + + // Reaches lists the templates it calls, directly or not, sorted. + Reaches []string + + // Unresolved counts what could not be folded in. + Unresolved int + + // Recursive reports whether the closure ran into a template that calls itself, directly or + // not, and stopped there. + Recursive bool +} + +// Dependency is a template called by another one. +type Dependency struct { + // Name is the template called. + Name string + + // Data is the path handed to it, rooted like the paths of the calling template. + // + // It is "." when the caller hands over its own data, and empty when the analysis could not + // place it. + Data string + + // Folded is the number of paths the called template reads, itself and through its own calls. + // + // It locates the weight of a caller's own fold, so a reader can see which of its calls to + // follow first. + Folded int +} diff --git a/templates-repo/reports/dump.go b/templates-repo/reports/dump.go new file mode 100644 index 0000000..fde8bde --- /dev/null +++ b/templates-repo/reports/dump.go @@ -0,0 +1,219 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package reports + +import ( + "fmt" + "io" + "maps" + "slices" + "strconv" + "strings" + "text/template" +) + +// DumpOption configures a single call to [Repository.Dump]. +// +// Rendering settings belong to the call rather than to the repository: how a document looks is the +// business of whoever asks for it, and the template that lays it out is compiled when it is used. +type DumpOption func(*dumpOptions) error + +type dumpOptions struct { + layout string + funcs template.FuncMap +} + +// WithTemplate lays the document out with a template of the caller's own. +// +// The template is executed against a [Documentation]. It is compiled when [Repository.Dump] runs, +// so a template that does not parse is reported by that call. +func WithTemplate(text string) DumpOption { + return func(o *dumpOptions) error { + if strings.TrimSpace(text) == "" { + return fmt.Errorf("the dump template is empty: %w", ErrReport) + } + + o.layout = text + + return nil + } +} + +// WithFuncMap adds functions a dump template of the caller's own may call. +func WithFuncMap(funcs template.FuncMap) DumpOption { + return func(o *dumpOptions) error { + maps.Copy(o.funcs, funcs) + + return nil + } +} + +// Dump writes a documentation, as markdown by default. +// +// Use [WithTemplate] to lay the document out otherwise, or walk the [Documentation] directly for a +// format a text template cannot produce. +// +// Example: +// +// documentation, err := repository.Documentation() +// if err != nil { +// return err +// } +// +// err = reports.Dump(w, documentation) +func Dump(w io.Writer, documentation Documentation, opts ...DumpOption) error { + settings := dumpOptions{ + layout: markdownLayout, + funcs: template.FuncMap{"anchor": anchor, "weigh": weigh, "plural": plural}, + } + for _, apply := range opts { + if err := apply(&settings); err != nil { + return err + } + } + + layout, err := template.New("dump").Funcs(settings.funcs).Parse(settings.layout) + if err != nil { + return fmt.Errorf("could not parse the dump template: %w: %w", err, ErrReport) + } + + if err := layout.Execute(w, documentation); err != nil { + return fmt.Errorf("could not write the documentation: %w: %w", err, ErrReport) + } + + return nil +} + +// anchor turns a heading into the fragment a markdown renderer derives from it. +// +// The rule is the one github applies: lower case, spaces to dashes, everything else that is not a +// letter, a digit, a dash or an underscore dropped. +func anchor(heading string) string { + var fragment strings.Builder + + for _, char := range strings.ToLower(heading) { + switch { + case char >= 'a' && char <= 'z', char >= '0' && char <= '9', char == '-', char == '_': + fragment.WriteRune(char) + case char == ' ': + fragment.WriteRune('-') + } + } + + return fragment.String() +} + +// plural writes a count with its noun, singular or not. +func plural(count int, noun string) string { + if count == 1 { + return "1 " + noun + } + + if strings.HasSuffix(noun, "s") { + return strconv.Itoa(count) + " " + noun + "es" + } + + return strconv.Itoa(count) + " " + noun + "s" +} + +// Root is a field of the data, with the number of paths that hang from it. +type Root struct { + // Field is the field the paths start at. + Field string + + // Paths is how many of them there are. + Paths int +} + +// Weights groups the paths of a fold by the fields they hang from. +// +// They spread very unevenly: a handful of fields hold a subtree the templates walk into, and +// everything else is read once. Separating the two shows which part of the data a template works +// on, without a list of counts that are all one. +type Weights struct { + // Heavy holds the fields more than one path hangs from, heaviest first. + Heavy []Root + + // Single holds the fields read once, sorted. + Single []string +} + +// weigh reduces a list of data paths to the fields they hang from, heaviest first. +// +// A folded contract holds thousands of paths for a template at the top of a call graph, which no +// reader gets through. The fields they start at are few, and their weight shows which part of +// the data a template mostly works on. +func weigh(paths []string) Weights { + counted := make(map[string]int, len(paths)) + for _, path := range paths { + field, _, _ := strings.Cut(strings.TrimPrefix(path, "."), ".") + field, _, _ = strings.Cut(field, "[") + + if field != "" { + counted["."+field]++ + } + } + + var weights Weights + for field, count := range counted { + if count == 1 { + weights.Single = append(weights.Single, field) + + continue + } + + weights.Heavy = append(weights.Heavy, Root{Field: field, Paths: count}) + } + + slices.SortFunc(weights.Heavy, func(a, b Root) int { + if a.Paths != b.Paths { + return b.Paths - a.Paths + } + + return strings.Compare(a.Field, b.Field) + }) + slices.Sort(weights.Single) + + return weights +} + +// markdownLayout is the document [Repository.Dump] produces when the caller asks for no other. +// +// It reports a fold by its weight rather than by its paths. A template at the top of a call graph +// reads thousands of them, and the detail of each is one hop away, on the page of the template +// that reads it. +const markdownLayout = `# Templates +{{ range .Assets }} +- [{{ .Path }}](#{{ anchor .Path }}){{ range .Templates }} + - [{{ .Name }}](#{{ anchor .Name }}){{ end }}{{ end }} +{{ range $asset := .Assets }} +## {{ $asset.Path }} +{{ range $tpl := .Templates }} +### {{ .Name }} +{{ if .Inner }} +Declared by a define statement in {{ $asset.Path }}. +{{ end }}{{ if .Empty }} +{{ if .Inner }}This template renders nothing.{{ else }}This asset declares define statements only, so the template named after it renders nothing.{{ end }} +{{ end }}{{ range .Doc }} +{{ . }} +{{ end }}{{ if .Reads }} +**Reads** {{ range $index, $path := .Reads }}{{ if $index }}, {{ end }}` + "`" + `{{ $path }}` + "`" + `{{ end }} +{{ end }}{{ if .RootReads }} +**Reaches back to the root of its own data** for {{ range $index, $path := .RootReads }}{{ if $index }}, {{ end }}` + "`" + `{{ $path }}` + "`" + `{{ end }} +{{ end }}{{ if .Dependencies }} +**Calls** +{{ range .Dependencies }}- ` + "`" + `{{ .Name }}` + "`" + `, with {{ if .Data }}` + "`" + `{{ .Data }}` + "`" + `{{ else }}a value built at the call site{{ end }}{{ if .Folded }} ({{ plural .Folded "path" }}){{ end }} +{{ end }}{{ end }}{{ if .Transitive.Funcs }} +**Needs from the func map** {{ range $index, $name := .Transitive.Funcs }}{{ if $index }}, {{ end }}` + "`" + `{{ $name }}` + "`" + `{{ end }} +{{ end }}{{ if .UsedBy }} +**Called by** {{ range $index, $name := .UsedBy }}{{ if $index }}, {{ end }}[{{ $name }}](#{{ anchor $name }}){{ end }} +{{ end }}{{ if and .Transitive.Reaches .Transitive.Reads }}{{ with weigh .Transitive.Reads }} +**Folded** {{ plural (len $tpl.Transitive.Reads) "path" }}, through {{ plural (len $tpl.Transitive.Reaches) "template" }}. +{{ if .Heavy }} +Mostly under {{ range $index, $root := .Heavy }}{{ if $index }}, {{ end }}` + "`" + `{{ $root.Field }}` + "`" + ` ({{ $root.Paths }}){{ end }}. +{{ end }}{{ if .Single }} +Read once: {{ range $index, $field := .Single }}{{ if $index }}, {{ end }}` + "`" + `{{ $field }}` + "`" + `{{ end }}. +{{ end }}{{ end }}{{ end }}{{ if or .Transitive.Recursive .Dynamic .Unresolved }} +> {{ if .Transitive.Recursive }}Some of the templates it calls loop back, so the fold stops at the loop. {{ end }}{{ if .Dynamic }}It invokes a function held by its data, so what it reads is not fully known. {{ end }}{{ if .Unresolved }}{{ plural .Unresolved "access" }} hang from a value that could not be placed.{{ end }} +{{ end }}{{ end }}{{ end }}` diff --git a/templates-repo/reports/dump_test.go b/templates-repo/reports/dump_test.go new file mode 100644 index 0000000..1f111d2 --- /dev/null +++ b/templates-repo/reports/dump_test.go @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package reports + +import ( + "strings" + "testing" + "text/template" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func documented() Documentation { + return Documentation{Assets: []Asset{{ + Path: "schema.gotmpl", + Templates: []Template{{ + Name: "schema", + Doc: []string{"schema renders a model."}, + Reads: []string{".GoName", ".Properties[].Name"}, + Funcs: []string{"pascalize"}, + Dependencies: []Dependency{{Name: "schemaBody", Data: ".", Folded: 3}}, + UsedBy: []string{"model"}, + }, { + Name: "schemaBody", + Inner: true, + Empty: true, + }}, + }}} +} + +func TestDump(t *testing.T) { + t.Run("should write markdown by default", func(t *testing.T) { + var out strings.Builder + require.NoError(t, Dump(&out, documented())) + + document := out.String() + assert.Contains(t, document, "## schema.gotmpl") + assert.Contains(t, document, "### schema") + assert.Contains(t, document, "schema renders a model.") + assert.Contains(t, document, "`.GoName`") + assert.Contains(t, document, "**Called by** [model]") + assert.Contains(t, document, "This template renders nothing.") + }) + + t.Run("should lay a document out as the caller asks", func(t *testing.T) { + var out strings.Builder + require.NoError(t, Dump(&out, documented(), + WithTemplate(`{{ range .Assets }}{{ range .Templates }}{{ upper .Name }} {{ end }}{{ end }}`), + WithFuncMap(template.FuncMap{"upper": strings.ToUpper}), + )) + + assert.Equal(t, "SCHEMA SCHEMABODY ", out.String()) + }) + + t.Run("should report a layout that does not parse", func(t *testing.T) { + err := Dump(&strings.Builder{}, documented(), WithTemplate(`{{ if }}`)) + + require.ErrorIs(t, err, ErrReport) + assert.ErrorContains(t, err, "could not parse") + }) + + t.Run("should report a layout naming what a document does not hold", func(t *testing.T) { + err := Dump(&strings.Builder{}, documented(), WithTemplate(`{{ .Nowhere }}`)) + + require.ErrorIs(t, err, ErrReport) + assert.ErrorContains(t, err, "could not write") + }) + + t.Run("should refuse an empty layout", func(t *testing.T) { + err := Dump(&strings.Builder{}, documented(), WithTemplate(" ")) + + require.ErrorIs(t, err, ErrReport) + }) +} + +func TestWeigh(t *testing.T) { + t.Run("should tell the fields a fold hangs from apart by weight", func(t *testing.T) { + weights := weigh([]string{ + ".Properties[].Name", ".Properties[].Type", ".Properties[].GoName", + ".Items.Name", ".Items.Type", + ".Name", + }) + + assert.Equal(t, []Root{{Field: ".Properties", Paths: 3}, {Field: ".Items", Paths: 2}}, weights.Heavy) + assert.Equal(t, []string{".Name"}, weights.Single) + }) + + t.Run("should hold nothing for no path at all", func(t *testing.T) { + weights := weigh(nil) + + assert.Empty(t, weights.Heavy) + assert.Empty(t, weights.Single) + }) +} + +func TestAnchor(t *testing.T) { + assert.Equal(t, "schemagotmpl", anchor("schema.gotmpl")) + assert.Equal(t, "server-parameter", anchor("Server Parameter")) + assert.Equal(t, "a_b-1", anchor("A_B 1")) +} + +func TestPlural(t *testing.T) { + assert.Equal(t, "1 path", plural(1, "path")) + assert.Equal(t, "2 paths", plural(2, "path")) + assert.Equal(t, "0 accesses", plural(0, "access")) +} diff --git a/templates-repo/reports/errors.go b/templates-repo/reports/errors.go new file mode 100644 index 0000000..ab1272d --- /dev/null +++ b/templates-repo/reports/errors.go @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package reports + +// reportError is the type of the sentinel errors this package declares. +// +// A string type keeps a sentinel a constant, so nothing may reassign it. It compares by value, +// which is what [errors.Is] needs to find it in a chain of wrapped errors. +type reportError string + +// Error implements the error interface. +func (e reportError) Error() string { return string(e) } + +// ErrReport is matched by every error this package reports. +// +// Errors wrap the cause as well, so a caller may match on a template parse error with +// [errors.Is] and [errors.As] all the same. +const ErrReport reportError = "templates report" diff --git a/templates-repo/reports/errors_test.go b/templates-repo/reports/errors_test.go new file mode 100644 index 0000000..a52b2f9 --- /dev/null +++ b/templates-repo/reports/errors_test.go @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package reports + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestSentinel(t *testing.T) { + t.Run("should read as the text it is declared with", func(t *testing.T) { + assert.Equal(t, "templates report", ErrReport.Error()) + }) + + t.Run("should be found through a chain of wrapped errors", func(t *testing.T) { + wrapped := fmt.Errorf("outer: %w", fmt.Errorf("inner: %w", ErrReport)) + + require.ErrorIs(t, wrapped, ErrReport) + }) + + t.Run("should compare by value, so a copy is the same sentinel", func(t *testing.T) { + // this is what lets it be a constant: nothing holds an address anyone could rebind + copied := ErrReport + + assert.Equal(t, ErrReport, copied) + require.ErrorIs(t, fmt.Errorf("wrapped: %w", copied), ErrReport) + }) + + t.Run("should leave the cause reachable alongside it", func(t *testing.T) { + err := Dump(&strings.Builder{}, Documentation{}, WithTemplate(`{{ if }}`)) + + require.ErrorIs(t, err, ErrReport) + assert.ErrorContains(t, err, "missing value for if") + }) + + t.Run("should not match an error of another package", func(t *testing.T) { + assert.False(t, errors.Is(errors.New("templates report"), ErrReport), + "the text alone does not make a sentinel") + }) +} diff --git a/templates-repo/repository.go b/templates-repo/repository.go index c4d0bde..eb539fd 100644 --- a/templates-repo/repository.go +++ b/templates-repo/repository.go @@ -4,332 +4,565 @@ package repo import ( - "bytes" "fmt" - "log" + "iter" "maps" - "os" - "path/filepath" + "slices" "strings" - "sync" "text/template" "text/template/parse" - "github.com/go-openapi/codegen/mangling" + "github.com/go-openapi/codegen/templates-repo/internal/cover" + "github.com/go-openapi/codegen/templates-repo/reports" ) -// AssetProvider provides access to embedded template assets. -type AssetProvider interface { - AssetNames() []string - MustAsset(name string) []byte -} +// namespaceName is the name of the template holding the namespace shared by a repository. +// +// It is not a name any asset may produce, since a template name is made of recased path +// segments, so no asset can take the place of the namespace. +const namespaceName = "" -// Repository is the repository for the generator templates. +// Repository is a set of compiled templates, resolved against one another and sealed. +// +// A repository is built by [New] from the sources given as options, and derived by [Clone]. +// It has no other constructor, and nothing alters it once built. +// +// # Usage +// +// A repository reads its sources once, when it is built, and retains their content. Everything +// else follows from that: +// +// - templates share a single namespace, so any of them may refer to any other by name +// - [Clone] re-reads nothing and re-parses everything, so a template added by a clone is seen +// by the templates that were already there +// - the file system a repository was built from is not retained, and is never read again +// +// A clone therefore costs a full rebuild. This is meant for the settings of a program, decided +// once, and not for a per-operation derivation. +// +// # Concurrency +// +// A repository is immutable, holds no lock, and is safe for concurrent use. [Clone] only reads +// its source, so cloning a repository other goroutines are using is safe as well. type Repository struct { - files map[string]string - templates map[string]*template.Template - funcs template.FuncMap - protectedTemplates map[string]bool - allowOverride bool - mux sync.Mutex - mangler mangling.Mangler + namespace *template.Template + declarations map[string]declaration + coverage *cover.Profile + names []string + assets []asset + overrides []reports.Override + resolutions map[string]map[string]string + layers int + settings options } -// NewRepository creates a new template repository with the provided functions defined. -func NewRepository(funcs template.FuncMap) *Repository { - repo := Repository{ - files: make(map[string]string), - templates: make(map[string]*template.Template), - funcs: funcs, - mangler: mangling.MakeMangler(), // default is good enough for template management +// declaration records where a template comes from. +type declaration struct { + address string + assetPath string + layer int +} + +// New builds a repository from the sources and settings given as options. +// +// Sources are read in the order they are declared, and a template declared by several of them +// comes from the last one. Reporting an error rather than a repository covers an unreadable +// source, a template that fails to parse, a template that refers to one no source declares, and +// an override that would silently not replace what it overrides. +// +// Building a repository with no source at all yields an empty one, which is not an error. +// +// Example: +// +// repository, err := repo.New( +// repo.FromFS(assets, ""), +// repo.FromDir("./mytemplates", ""), +// repo.WithFuncMap(funcs), +// ) +func New(opts ...Option) (*Repository, error) { + settings, err := makeOptions(opts) + if err != nil { + return nil, err } - if repo.funcs == nil { - repo.funcs = make(template.FuncMap) + assets, layers, err := settings.resolveSources(0) + if err != nil { + return nil, err } - return &repo + return build(assets, layers, settings) } -// SetProtectedTemplates sets the map of template names that cannot be overridden -// by user-provided templates. -func (t *Repository) SetProtectedTemplates(m map[string]bool) { - t.protectedTemplates = m -} +// Clone builds a repository from the assets and settings of another one, with opts applied on top. +// +// The sources of source are not read again: their content, retained when source was built, is +// carried over and the sources declared by opts are appended to it. Everything is parsed and +// resolved afresh, so a template that opts overrides is picked up by every template referring to +// it, and the two repositories share nothing. +// +// source is left untouched, and may be used by other goroutines while [Clone] runs. A nil source +// reports an error. +// +// Example: +// +// // the same templates, with one of them replaced +// patched, err := repo.Clone(repository, repo.FromTemplate("model", myModel)) +func Clone(source *Repository, opts ...Option) (*Repository, error) { + if source == nil { + return nil, fmt.Errorf("cannot clone a nil repository: %w", ErrTemplateRepo) + } -// ShallowClone a repository. -// -// Clones the maps of files and templates, so as to be able to use -// the cloned repo concurrently. -func (t *Repository) ShallowClone() *Repository { - clone := &Repository{ - files: make(map[string]string, len(t.files)), - templates: make(map[string]*template.Template, len(t.templates)), - funcs: t.funcs, - protectedTemplates: t.protectedTemplates, - allowOverride: t.allowOverride, - mangler: t.mangler, + settings := source.settings.derive() + if err := settings.apply(opts); err != nil { + return nil, err } - t.mux.Lock() - defer t.mux.Unlock() + added, layers, err := settings.resolveSources(source.layers) + if err != nil { + return nil, err + } - maps.Copy(clone.files, t.files) - maps.Copy(clone.templates, t.templates) + return build(slices.Concat(source.assets, added), layers, settings) +} - return clone +// Get returns the template registered under a name. +// +// A template is known by three strings, and this takes the third: +// +// asset path server/parameter.gotmpl the file it was read from +// address server/parameter where it was declared, never recased +// name serverParameter what it answers to, and what Get takes +// +// It reports an error when no source declares that name. The returned [Template] resolves the +// templates it refers to in this repository. +func (r *Repository) Get(name string) (Template, error) { + if _, declared := r.declarations[name]; !declared { + return Template{}, fmt.Errorf("template %q is not declared in this repository: %w", name, ErrTemplateRepo) + } + + return Template{tpl: r.namespace.Lookup(name)}, nil } -// LoadDefaults loads templates from the given asset map. -func (t *Repository) LoadDefaults(assets map[string][]byte) error { - for name, asset := range assets { - if err := t.addFile(name, string(asset), true); err != nil { - return err - } +// Lookup returns the template declared at an address. +// +// An address is the path a template was declared at, slash-separated and never recased. This method +// takes one, [Repository.Get] takes a name. Use whichever a caller already holds. +// +// The extension may be left on, so the asset path a template was read from addresses it too. +// +// Example: +// +// tpl, err := repository.Lookup("server/parameter") +func (r *Repository) Lookup(address string) (Template, error) { + clean, err := cleanAssetName(address) + if err != nil { + return Template{}, err } - return nil + key := TemplateName(clean, r.settings.extensions...) + if _, declared := r.declarations[key]; !declared { + return Template{}, fmt.Errorf("no template is declared at %q: %w", address, ErrTemplateRepo) + } + + return Template{tpl: r.namespace.Lookup(key)}, nil } -// LoadDir will walk the specified path and add each .gotmpl file it finds to the repository. -func (t *Repository) LoadDir(templatePath string) error { - err := filepath.Walk(templatePath, func(path string, _ os.FileInfo, err error) error { - if strings.HasSuffix(path, ".gotmpl") { - if assetName, e := filepath.Rel(templatePath, path); e == nil { - if data, e := os.ReadFile(path); e == nil { //nolint:gosec // pre-existing: template loading from user-specified directory - if ee := t.AddFile(assetName, string(data)); ee != nil { - return fmt.Errorf("could not add template: %w", ee) - } - } - // Non-readable files are skipped - } - } +// MustLookup returns the template declared at an address, and panics when there is none. +// +// Use it for an address hardcoded in the program, and [Repository.Lookup] for one coming from +// the outside. +func (r *Repository) MustLookup(address string) Template { + tpl, err := r.Lookup(address) + if err != nil { + panic(err) + } - if err != nil { - return err - } + return tpl +} - // Non-template files are skipped - return nil - }) +// MustGet returns the template registered under a name, and panics when there is none. +// +// Use it for a name hardcoded in the program, and [Repository.Get] for one coming from the +// outside. +func (r *Repository) MustGet(name string) Template { + tpl, err := r.Get(name) if err != nil { - return fmt.Errorf("could not complete template processing in directory %q: %w", templatePath, err) + panic(err) } - return nil + + return tpl } -// LoadContrib loads template from contrib directory using the given asset provider. -func (t *Repository) LoadContrib(name string, provider AssetProvider) error { - log.Printf("loading contrib %s", name) - const pathPrefix = "templates/contrib/" - basePath := pathPrefix + name - filesAdded := 0 - for _, aname := range provider.AssetNames() { - if !strings.HasSuffix(aname, ".gotmpl") { - continue - } - if strings.HasPrefix(aname, basePath) { - target := aname[len(basePath)+1:] - err := t.addFile(target, string(provider.MustAsset(aname)), true) - if err != nil { - return err +// Has reports whether a name is declared in this repository. +func (r *Repository) Has(name string) bool { + _, declared := r.declarations[name] + + return declared +} + +// Names iterates over the names declared in this repository, in lexical order. +func (r *Repository) Names() iter.Seq[string] { + return slices.Values(r.names) +} + +// Addresses iterates over what this repository declares, address first, name second, ordered by +// name. +func (r *Repository) Addresses() iter.Seq2[string, string] { + return func(yield func(string, string) bool) { + for _, name := range r.names { + if !yield(r.declarations[name].address, name) { + return } - log.Printf("added contributed template %s from %s", target, aname) - filesAdded++ } } - if filesAdded == 0 { - return fmt.Errorf("no files added from template: %s", name) - } - return nil } -// MustGet a template by name, panics when fails. -func (t *Repository) MustGet(name string) *template.Template { - tpl, err := t.Get(name) - if err != nil { - panic(err) - } - return tpl +// AddressOf returns the address a name is declared at, and whether it is declared at all. +// +// It reverses [Repository.NameOf]. +func (r *Repository) AddressOf(name string) (string, bool) { + declared, found := r.declarations[name] + + return declared.address, found } -// AddFile adds a file to the repository. It will create a new template based on the filename. -// It trims the .gotmpl from the end and converts the name using swag.ToJSONName. This will strip -// directory separators and Camelcase the next letter. -// e.g validation/primitive.gotmpl will become validationPrimitive +// Roots returns the names this repository is scoped to, in the order they were given to +// [WithRoots]. // -// If the file contains a definition for a template that is protected the whole file will not be added. -func (t *Repository) AddFile(name, data string) error { - return t.addFile(name, data, false) +// It is empty when the repository holds every template it read, which is a repository built +// without [WithRoots]. This reports a scope rather than deciding anything with it: a caller adding +// a template to a repository it did not build wants [WithExtraRoots], which does the right thing +// whether there is a scope to widen or not. +func (r *Repository) Roots() []string { + return slices.Clone(r.settings.roots) } -// SetAllowOverride allows setting allowOverride after the Repository was initialized. -func (t *Repository) SetAllowOverride(value bool) { - t.allowOverride = value +// AssetOf returns the path of the asset that declares a name, and whether it is declared at all. +// +// The path is the one the asset has once mounted, and the name was derived from it. +func (r *Repository) AssetOf(name string) (string, bool) { + declared, found := r.declarations[name] + + return declared.assetPath, found } -// Get will return the named template from the repository, ensuring that all dependent templates are loaded. -// It will return an error if a dependent template is not defined in the repository. -func (t *Repository) Get(name string) (*template.Template, error) { - templ, found := t.templates[name] +// NameOf returns the name a template declared at an address answers to. +// +// It recases rather than looks up, so an address nothing declares still yields the name it would +// have. Ask [Repository.Has] whether that name is declared. The asset path addresses a template +// too, the extension being trimmed either way: NameOf("server/parameter.gotmpl") and +// NameOf("server/parameter") are both serverParameter. +// +// Which extensions are trimmed is a setting of the repository, which is why this is a method. +// [TemplateName] answers the same question before a repository exists. +// +// It reverses [Repository.AddressOf], and it is idempotent on the names it produces, so a name may +// be handed back to it: NameOf("serverParameter") is serverParameter. A name an address never +// produced is not covered by that, an inner "define" being addressed under the asset that holds it. +func (r *Repository) NameOf(address string) string { + return r.settings.templateName(address) +} - if !found { - return templ, fmt.Errorf("template doesn't exist %s", name) +// build compiles a set of assets into a sealed repository. +// +// Assets are read in order, so that a template declared twice keeps its last definition. They are +// all parsed before any of them is registered, because which templates the repository keeps is +// decided on the call graph they form, and only the templates it keeps are instrumented. +func build(assets []asset, layers int, settings options) (*Repository, error) { + parsed, err := parseAssets(assets, settings) + if err != nil { + return nil, err } - return t.addDependencies(templ) -} + space, err := newAddressSpace(parsed.declared) + if err != nil { + return nil, err + } -// DumpTemplates prints out a dump of all the defined templates, where they are defined and what their dependencies are. -func (t *Repository) DumpTemplates() { - buf := bytes.NewBuffer(nil) - fmt.Fprintln(buf, "\n# Templates") - for name, templ := range t.templates { - fmt.Fprintf(buf, "## %s\n", name) - fmt.Fprintf(buf, "Defined in `%s`\n", t.files[name]) + // what an author wrote is relative to where they wrote it, and a namespace is flat: every + // reference is settled here, once, and never looked at again while a template runs + unresolved, resolutions, err := space.rewrite() + if err != nil { + return nil, err + } - if deps := findDependencies(templ.Root); len(deps) > 0 { - fmt.Fprintf(buf, "####requires \n - %v\n\n\n", strings.Join(deps, "\n - ")) + byKey := keyedDeclarations(parsed.declared) + + retained, err := retainedNames(byKey, settings.roots) + if err != nil { + return nil, err + } + + if err := reportUnresolved(unresolved, retained); err != nil { + return nil, err + } + + namespace := template.New(namespaceName).Funcs(settings.funcs) + + var profile *cover.Profile + if settings.coverage { + profile = cover.NewProfile(settings.coverPrefix) + } + + for _, item := range parsed.assets { + if err := register(namespace, item, retained, profile); err != nil { + return nil, err } - fmt.Fprintln(buf, "\n---") } - log.Println(buf.String()) + + declarations := parsed.declarationsOf(retained) + names := slices.Sorted(maps.Keys(declarations)) + + return &Repository{ + namespace: namespace, + declarations: declarations, + coverage: profile, + names: names, + assets: assets, + overrides: parsed.overridesOf(names), + resolutions: resolutions, + layers: layers, + settings: settings, + }, nil } -// Funcs returns the template function map, allowing callers to add or modify functions. -func (t *Repository) Funcs() template.FuncMap { - return t.funcs +// keyedDeclarations indexes what a repository declares by the name it answers to. +func keyedDeclarations(byPath map[string]*declared) map[string]*declared { + byKey := make(map[string]*declared, len(byPath)) + for _, item := range byPath { + byKey[item.key] = item + } + + return byKey } -func (t *Repository) addFile(name, data string, allowOverride bool) error { - fileName := name - name = t.mangler.Camelize(strings.TrimSuffix(name, ".gotmpl")) +// reportUnresolved rejects a repository holding a template that refers to one it cannot address. +// +// Only the templates it keeps are checked, so a set that is incomplete for the runs this one is +// not scoped to builds all the same. That is the point of scoping. +func reportUnresolved(unresolved map[string][]string, retained map[string]struct{}) error { + var missing []string - templ, err := template.New(name).Funcs(t.funcs).Parse(data) - if err != nil { - return fmt.Errorf("failed to load template %s: %w", name, err) - } + for _, name := range slices.Sorted(maps.Keys(unresolved)) { + if _, keep := retained[name]; !keep { + continue + } - // check if any protected templates are defined - if !allowOverride && !t.allowOverride { - for _, template := range templ.Templates() { - if t.protectedTemplates[template.Name()] { - return fmt.Errorf("cannot overwrite protected template %s", template.Name()) - } + for _, reference := range unresolved[name] { + missing = append(missing, fmt.Sprintf("%q refers to %q, which it cannot reach", name, reference)) } } - // Add each defined template into the cache - for _, template := range templ.Templates() { - t.files[template.Name()] = fileName - t.templates[template.Name()] = template.Lookup(template.Name()) + if len(missing) > 0 { + return fmt.Errorf("unresolved template references: %s: %w", strings.Join(missing, ", "), ErrTemplateRepo) } return nil } -func (t *Repository) flattenDependencies(templ *template.Template, dependencies map[string]bool) map[string]bool { - if dependencies == nil { - dependencies = make(map[string]bool) - } +// parsedAsset is an asset with the templates it declares, before any of them is registered. +type parsedAsset struct { + item asset + declared map[string]*declared +} - deps := findDependencies(templ.Root) +// parsedAssets holds the result of parsing every asset of a repository. +type parsedAssets struct { + // assets holds the parsed assets, in the order they were read. + assets []parsedAsset - for _, d := range deps { - if _, found := dependencies[d]; !found { - dependencies[d] = true + // declarations records, per name, the asset the definition that stands comes from. + declarations map[string]declaration - if tt := t.templates[d]; tt != nil { - dependencies = t.flattenDependencies(tt, dependencies) - } + // declared holds, per address, the definition that stands there. + declared map[string]*declared + + // declaring records every asset that declared an address, in the order they were read. + declaring map[string][]string +} + +// overridesOf reports the names a later source redeclared, among those a repository retains. +func (p parsedAssets) overridesOf(names []string) []reports.Override { + var overrides []reports.Override + + for _, name := range names { + declaring := p.declaring[p.declarations[name].address] + if len(declaring) < 2 { //nolint:mnd // one asset declaring a name overrides nothing + continue } - dependencies[d] = true + last := len(declaring) - 1 + overrides = append(overrides, reports.Override{ + Name: name, + Standing: declaring[last], + Replaced: slices.Clone(declaring[:last]), + }) } - return dependencies + return overrides } -func (t *Repository) addDependencies(templ *template.Template) (*template.Template, error) { - name := templ.Name() +// declarationsOf keeps the declarations of the names a repository retains. +func (p parsedAssets) declarationsOf(retained map[string]struct{}) map[string]declaration { + declarations := make(map[string]declaration, len(retained)) + for name := range retained { + declarations[name] = p.declarations[name] + } - deps := t.flattenDependencies(templ, nil) + return declarations +} - for dep := range deps { - if dep == "" { - continue +// parseAssets parses every asset, and records which definition stands at each address. +// +// An asset declares a template at its own path, plus one per inner "define" statement, addressed +// under it. Each is parsed on its own, so that what it declares is known, and checked, before any +// of it is registered. +func parseAssets(assets []asset, settings options) (parsedAssets, error) { + parsed := parsedAssets{ + assets: make([]parsedAsset, 0, len(assets)), + declarations: make(map[string]declaration, len(assets)), + declared: make(map[string]*declared, len(assets)), + declaring: make(map[string][]string, len(assets)), + } + + for _, item := range assets { + owner := settings.trimmedPath(item.path) + + tpl, err := template.New(owner).Funcs(settings.funcs).Parse(string(item.data)) + if err != nil { + return parsedAssets{}, + fmt.Errorf("could not parse template %q from asset %q: %w: %w", owner, item.path, err, ErrTemplateRepo) } - tt := templ.Lookup(dep) + declaredHere := make(map[string]*declared, len(tpl.Templates())) + for _, found := range tpl.Templates() { + if found.Tree == nil { + continue + } + + bare := found.Name() + address := addressOf(owner, bare) + if bare == owner { + bare = "" + } - // Check if we have it - if tt == nil { - tt = t.templates[dep] + if err := checkCollision(parsed.declarations, address, item); err != nil { + return parsedAssets{}, err + } - // Still don't have it, return an error - if tt == nil { - return templ, fmt.Errorf("could not find template %s", dep) + if err := checkOverride(parsed.declared, address, found.Tree, item.path); err != nil { + return parsedAssets{}, err } - var err error - // Add it to the parse tree - templ, err = templ.AddParseTree(dep, tt.Tree) - if err != nil { - return templ, fmt.Errorf("dependency error: %w", err) + declaredHere[address] = &declared{ + address: address, + key: TemplateName(address), + owner: owner, + bare: bare, + assetPath: item.path, + layer: item.layer, + tree: found.Tree, } } + + for address, found := range declaredHere { + parsed.declared[address] = found + parsed.declarations[found.key] = declaration{ + address: address, assetPath: item.path, layer: item.layer, + } + parsed.declaring[address] = append(parsed.declaring[address], item.path) + } + + parsed.assets = append(parsed.assets, parsedAsset{item: item, declared: declaredHere}) } - return templ.Lookup(name), nil + + return parsed, nil } -func findDependencies(n parse.Node) []string { - depMap := make(map[string]bool) +// register adds the retained templates of one asset to the namespace. +// +// An asset all of whose templates were pruned away contributes nothing, counters included: a +// template a repository does not hold is not a template its coverage has an opinion on. +func register( + namespace *template.Template, + parsed parsedAsset, + retained map[string]struct{}, + profile *cover.Profile, +) error { + trees := make(map[string]*parse.Tree, len(parsed.declared)) + for _, item := range parsed.declared { + if _, keep := retained[item.key]; keep { + trees[item.key] = item.tree + } + } - if n == nil { + if len(trees) == 0 { return nil } - switch node := n.(type) { - case *parse.ListNode: - if node != nil && node.Nodes != nil { - for _, nn := range node.Nodes { - for _, dep := range findDependencies(nn) { - depMap[dep] = true - } - } - } - case *parse.IfNode: - for _, dep := range findDependencies(node.List) { - depMap[dep] = true - } - for _, dep := range findDependencies(node.ElseList) { - depMap[dep] = true - } + // the trees that run hold the counters, and the emptiness of a template is judged before they do + if profile != nil { + instrumented := profile.Instrument(parsed.item.path, parsed.item.data, trees) + trees = instrumented.Trees + namespace.Funcs(instrumented.Bind()) + } - case *parse.RangeNode: - for _, dep := range findDependencies(node.List) { - depMap[dep] = true - } - for _, dep := range findDependencies(node.ElseList) { - depMap[dep] = true + for name, tree := range trees { + if _, err := namespace.AddParseTree(name, tree); err != nil { + return fmt.Errorf("could not register template %q from asset %q: %w: %w", + name, parsed.item.path, err, ErrTemplateRepo) } + } - case *parse.WithNode: - for _, dep := range findDependencies(node.List) { - depMap[dep] = true - } - for _, dep := range findDependencies(node.ElseList) { - depMap[dep] = true - } + return nil +} - case *parse.TemplateNode: - depMap[node.Name] = true +// checkCollision reports two assets of a single source declaring the same template. +// +// Names are flat, so a template declared twice keeps one definition and loses the other. Which +// one that is depends on the order the assets are read in, which a caller stacking sources +// chooses, and which a caller pointing at a directory does not. +// +// A redeclaration is therefore taken as intended when it crosses sources, and as a mistake when +// it happens within one: an override is something a caller asks for by declaring a further +// source, never something a directory listing decides. +func checkCollision(declarations map[string]declaration, address string, item asset) error { + previous, found := declarations[TemplateName(address)] + if !found || previous.layer != item.layer { + return nil } - deps := make([]string, 0, len(depMap)) - for dep := range depMap { - deps = append(deps, dep) + return fmt.Errorf( + "template %q is declared by assets %q and %q, which come from the same source: "+ + "rename one of them, or declare the overriding one as a further source: %w", + address, previous.assetPath, item.path, ErrTemplateRepo, + ) +} + +// checkOverride rejects an override that would be silently ignored. +// +// [text/template.Template.AddParseTree] keeps the older definition when the new parse tree is +// empty, so a tree holding nothing but white space and comments does not replace one that holds +// something. To override a template with one that renders nothing, give it an action to run, +// such as an empty string. +func checkOverride(overriding map[string]*declared, address string, tree *parse.Tree, assetPath string) error { + overridden := overriding[address] + if overridden != nil && overridden.tree != nil && + parse.IsEmptyTree(tree.Root) && !parse.IsEmptyTree(overridden.tree.Root) { + return fmt.Errorf( + "template %q declared by asset %q is empty and would not replace the definition it overrides: %w", + address, assetPath, ErrTemplateRepo, + ) } - return deps + return nil +} + +// Coverage returns the counters of the templates, or nil when the repository was not built with +// [WithCoverage]. +// +// The templates of a repository are frozen, their counters are not. The counters record what a +// run reaches, and are the one part of a repository that changes. +func (r *Repository) Coverage() *cover.Profile { + return r.coverage } diff --git a/templates-repo/repository_nonwin.go b/templates-repo/repository_nonwin.go deleted file mode 100644 index b9c8527..0000000 --- a/templates-repo/repository_nonwin.go +++ /dev/null @@ -1,42 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -//go:build !windows - -package repo - -import ( - "fmt" - "log" - "plugin" - "text/template" -) - -// LoadPlugin will load the named plugin and inject its functions into the funcMap -// -// The plugin must implement a function matching the signature: -// `func AddFuncs(f template.FuncMap)` -// which can add any number of functions to the template repository funcMap. -// Any existing sprig or go-swagger templates with the same name will be overridden. -func (t *Repository) LoadPlugin(pluginPath string) error { - log.Printf("Attempting to load template plugin: %s", pluginPath) - - p, err := plugin.Open(pluginPath) - if err != nil { - return err - } - - f, err := p.Lookup("AddFuncs") - if err != nil { - return err - } - - funcmap, ok := f.(func(template.FuncMap)) - if !ok { - return fmt.Errorf("invalid plugin: AddFuncs is of an unexpected type: %T", f) - } - - funcmap(t.funcs) - - return nil -} diff --git a/templates-repo/repository_test.go b/templates-repo/repository_test.go index 4f42cf6..837370f 100644 --- a/templates-repo/repository_test.go +++ b/templates-repo/repository_test.go @@ -4,512 +4,635 @@ package repo import ( - "bytes" - "os" - "path/filepath" + "slices" + "strings" + "sync" "testing" + "testing/fstest" + "text/template" - gentest "github.com/go-openapi/codegen/gentesting" "github.com/go-openapi/testify/v2/assert" "github.com/go-openapi/testify/v2/require" ) -func TestLoadDir_EmptyPath(t *testing.T) { - repo := NewRepository(nil) +// dependentAssets declare a template that refers to another one, which is the shape every +// override has to work through. +func dependentAssets() fstest.MapFS { + return fstest.MapFS{ + "root.gotmpl": {Data: []byte(`root=[{{template "leaf"}}]`)}, + "leaf.gotmpl": {Data: []byte(`DEFAULT`)}, + } +} + +func render(t *testing.T, r *Repository, name string) string { + t.Helper() - err := repo.LoadDir("") - require.Error(t, err) - assert.ErrorContains(t, err, "could not complete") + tpl, err := r.Get(name) + require.NoErrorf(t, err, "expected %q to be declared", name) + + var out strings.Builder + require.NoError(t, tpl.Execute(&out, nil)) + + return out.String() } -func TestLoadDir_ProtectedTemplateBlocks(t *testing.T) { - repo := NewRepository(nil) - repo.SetProtectedTemplates(map[string]bool{ - "myProtected": true, +func TestNew(t *testing.T) { + t.Run("should declare a template per asset", func(t *testing.T) { + r, err := New(FromFS(fstest.MapFS{ + "model.gotmpl": {Data: []byte("model")}, + "validation/primitive.gotmpl": {Data: []byte("primitive")}, + "server/parameter.gotmpl": {Data: []byte("parameter")}, + }, "")) + require.NoError(t, err) + + assert.Equal(t, + []string{"model", "serverParameter", "validationPrimitive"}, + slices.Collect(r.Names()), + ) }) - // Create a temp dir with a .gotmpl that defines a protected template - dir := t.TempDir() - err := os.WriteFile( - filepath.Join(dir, "test.gotmpl"), - []byte(`{{ define "myProtected" }}hello{{ end }}`), - 0o600, - ) - require.NoError(t, err) + t.Run("should declare the inner templates of an asset", func(t *testing.T) { + r, err := New(FromFS(fstest.MapFS{ + "schema.gotmpl": {Data: []byte(`{{define "schemaBody"}}body{{end}}{{define "schemaType"}}type{{end}}`)}, + }, "")) + require.NoError(t, err) + + assert.Equal(t, + []string{"schema", "schemaSchemaBody", "schemaSchemaType"}, + slices.Collect(r.Names()), + ) + assert.Equal(t, "body", render(t, r, "schemaSchemaBody")) + }) - err = repo.LoadDir(dir) - require.Error(t, err) - assert.StringContainsT(t, err.Error(), "cannot overwrite protected template") -} + t.Run("should ignore an asset with an unsupported extension", func(t *testing.T) { + r, err := New(FromFS(fstest.MapFS{ + "model.gotmpl": {Data: []byte("model")}, + "README.md": {Data: []byte("not a template")}, + }, "")) + require.NoError(t, err) -func TestLoadDir_Success(t *testing.T) { - repo := NewRepository(nil) + assert.Equal(t, []string{"model"}, slices.Collect(r.Names())) + }) - dir := t.TempDir() - err := os.WriteFile( - filepath.Join(dir, "greeting.gotmpl"), - []byte(`hello world`), - 0o600, - ) - require.NoError(t, err) + t.Run("should read every directory when the source skips none", func(t *testing.T) { + assets := fstest.MapFS{ + "model.gotmpl": {Data: []byte("model")}, + "contrib/mine/model.gotmpl": {Data: []byte("mine")}, + "nested/contrib/one.gotmpl": {Data: []byte("nested")}, + "kept/keeper.gotmpl": {Data: []byte("kept")}, + } - err = repo.LoadDir(dir) - require.NoError(t, err) + all, err := New(FromFS(assets, "")) + require.NoError(t, err) + assert.Equal(t, + []string{"contribMineModel", "keptKeeper", "model", "nestedContribOne"}, + slices.Collect(all.Names()), + ) + }) - tmpl, err := repo.Get("greeting") - require.NoError(t, err) - require.NotNil(t, tmpl) -} + t.Run("should skip the directories a source declares as skipped", func(t *testing.T) { + assets := fstest.MapFS{ + "model.gotmpl": {Data: []byte("model")}, + "contrib/mine/model.gotmpl": {Data: []byte("mine")}, + "nested/contrib/one.gotmpl": {Data: []byte("nested")}, + "kept/keeper.gotmpl": {Data: []byte("kept")}, + } -func TestSetAllowOverride(t *testing.T) { - repo := NewRepository(nil) - repo.SetProtectedTemplates(map[string]bool{ - "secret": true, + r, err := New(FromFS(assets, "", SkipDirectories("contrib"))) + require.NoError(t, err) + assert.Equal(t, []string{"keptKeeper", "model"}, slices.Collect(r.Names())) }) - // Seed the repo with the protected template - err := repo.addFile("secret.gotmpl", "original", true) - require.NoError(t, err) + t.Run("should skip for the source that says so, and no other", func(t *testing.T) { + // what one source leaves out says nothing about a set someone else brings + shipped := fstest.MapFS{"contrib/mine/model.gotmpl": {Data: []byte("shipped")}} + brought := fstest.MapFS{"contrib/theirs/model.gotmpl": {Data: []byte("brought")}} - // Without allowOverride, adding a file that redefines "secret" fails - repo.SetAllowOverride(false) - err = repo.AddFile("other.gotmpl", `{{ define "secret" }}replaced{{ end }}`) - require.Error(t, err) - assert.StringContainsT(t, err.Error(), "cannot overwrite protected template secret") + r, err := New( + FromFS(shipped, "", SkipDirectories("contrib")), + FromFS(brought, ""), + ) + require.NoError(t, err) - // With allowOverride, it succeeds - repo.SetAllowOverride(true) - err = repo.AddFile("other.gotmpl", `{{ define "secret" }}replaced{{ end }}`) - require.NoError(t, err) -} + assert.Equal(t, []string{"contribTheirsModel"}, slices.Collect(r.Names())) + }) -func TestShallowClone(t *testing.T) { - repo := NewRepository(nil) - err := repo.AddFile("hello", "world") - require.NoError(t, err) - repo.SetProtectedTemplates(map[string]bool{"hello": true}) - repo.SetAllowOverride(true) + t.Run("should build an empty repository from no source at all", func(t *testing.T) { + r, err := New() + require.NoError(t, err) - clone := repo.ShallowClone() + assert.Empty(t, slices.Collect(r.Names())) + assert.False(t, r.Has("anything")) + }) - // clone has the same template - tmpl, err := clone.Get("hello") - require.NoError(t, err) - require.NotNil(t, tmpl) + t.Run("should not declare the namespace itself", func(t *testing.T) { + r, err := New(FromFS(dependentAssets(), "")) + require.NoError(t, err) - // adding to clone doesn't affect original - err = clone.AddFile("extra", "data") - require.NoError(t, err) - _, err = repo.Get("extra") - require.Error(t, err) + assert.False(t, r.Has(namespaceName)) + _, err = r.Get(namespaceName) + assert.ErrorIs(t, err, ErrTemplateRepo) + }) + + t.Run("should build the same repository from the same sources", func(t *testing.T) { + assets := fstest.MapFS{ + "a.gotmpl": {Data: []byte(`a=[{{template "b"}}]`)}, + "b.gotmpl": {Data: []byte(`b`)}, + "c.gotmpl": {Data: []byte(`c`)}, + } + + first, err := New(FromFS(assets, "")) + require.NoError(t, err) + second, err := New(FromFS(assets, "")) + require.NoError(t, err) + + assert.Equal(t, slices.Collect(first.Names()), slices.Collect(second.Names())) + assert.Equal(t, render(t, first, "a"), render(t, second, "a")) + }) } -func TestLoadDefaults(t *testing.T) { - repo := NewRepository(nil) +// TestOverride covers the failure both earlier implementations had, in opposite ways: an +// override that reaches the templates depending on it. +func TestOverride(t *testing.T) { + t.Run("should reach the templates depending on it", func(t *testing.T) { + r, err := New( + FromFS(dependentAssets(), ""), + FromTemplate("leaf.gotmpl", []byte("OVERRIDDEN")), + ) + require.NoError(t, err) - assets := map[string][]byte{ - "greeting.gotmpl": []byte("hello {{ . }}"), - "farewell.gotmpl": []byte("goodbye {{ . }}"), - } - err := repo.LoadDefaults(assets) - require.NoError(t, err) + assert.Equal(t, "OVERRIDDEN", render(t, r, "leaf")) + assert.Equal(t, "root=[OVERRIDDEN]", render(t, r, "root")) + }) - tmpl, err := repo.Get("greeting") - require.NoError(t, err) - require.NotNil(t, tmpl) + t.Run("should reach them when the override comes from a clone", func(t *testing.T) { + r, err := New(FromFS(dependentAssets(), "")) + require.NoError(t, err) + require.Equal(t, "root=[DEFAULT]", render(t, r, "root")) - tmpl, err = repo.Get("farewell") - require.NoError(t, err) - require.NotNil(t, tmpl) -} + clone, err := Clone(r, FromTemplate("leaf.gotmpl", []byte("OVERRIDDEN"))) + require.NoError(t, err) -func TestLoadDefaults_ParseError(t *testing.T) { - repo := NewRepository(nil) + assert.Equal(t, "root=[OVERRIDDEN]", render(t, clone, "root")) + }) - assets := map[string][]byte{ - "bad.gotmpl": []byte("{{ .Broken"), - } - err := repo.LoadDefaults(assets) - require.Error(t, err) -} + t.Run("should let the last source win", func(t *testing.T) { + r, err := New( + FromFS(fstest.MapFS{"leaf.gotmpl": {Data: []byte("FIRST")}}, ""), + FromFS(fstest.MapFS{"leaf.gotmpl": {Data: []byte("SECOND")}}, ""), + FromFS(fstest.MapFS{"leaf.gotmpl": {Data: []byte("THIRD")}}, ""), + ) + require.NoError(t, err) -type mockAssetProvider struct { - assets map[string][]byte -} + assert.Equal(t, "THIRD", render(t, r, "leaf")) + }) -func (m mockAssetProvider) AssetNames() []string { - names := make([]string, 0, len(m.assets)) - for k := range m.assets { - names = append(names, k) - } - return names -} + t.Run("should override a define at the address it is declared at", func(t *testing.T) { + // a define lives under the asset declaring it, so replacing one means declaring that asset + r, err := New( + FromFS(fstest.MapFS{ + "schema.gotmpl": {Data: []byte(`{{define "schemaBody"}}DEFAULT{{end}}`)}, + "user.gotmpl": {Data: []byte(`user=[{{template "schemaBody"}}]`)}, + }, ""), + FromTemplate("schema.gotmpl", []byte(`{{define "schemaBody"}}OVERRIDDEN{{end}}`)), + ) + require.NoError(t, err) + + assert.Equal(t, "user=[OVERRIDDEN]", render(t, r, "user")) + }) -func (m mockAssetProvider) MustAsset(name string) []byte { - return m.assets[name] -} + t.Run("should refuse an override that would silently not replace", func(t *testing.T) { + _, err := New( + FromFS(fstest.MapFS{"leaf.gotmpl": {Data: []byte("DEFAULT")}}, ""), + FromTemplate("leaf.gotmpl", []byte(" {{/* nothing at all */}} ")), + ) -func TestLoadContrib(t *testing.T) { - repo := NewRepository(nil) - provider := mockAssetProvider{ - assets: map[string][]byte{ - "templates/contrib/mycontrib/model.gotmpl": []byte("model template"), - "templates/contrib/mycontrib/server.gotmpl": []byte("server template"), - "templates/contrib/other/skip.gotmpl": []byte("should be skipped"), - "templates/contrib/mycontrib/readme.md": []byte("not a template"), - }, - } + require.Error(t, err) + assert.ErrorIs(t, err, ErrTemplateRepo) + assert.ErrorContains(t, err, "would not replace") + }) + + t.Run("should accept an override that renders nothing on purpose", func(t *testing.T) { + r, err := New( + FromFS(fstest.MapFS{"leaf.gotmpl": {Data: []byte("DEFAULT")}}, ""), + FromTemplate("leaf.gotmpl", []byte(`{{""}}`)), + ) + require.NoError(t, err) + + assert.Empty(t, render(t, r, "leaf")) + }) +} - err := repo.LoadContrib("mycontrib", provider) +// TestCloneIsolation covers the other half of the mutation problem: what a clone changes must +// not reach the repository it was derived from, nor a sibling clone. +func TestCloneIsolation(t *testing.T) { + origin, err := New(FromFS(dependentAssets(), "")) require.NoError(t, err) - _, err = repo.Get("model") + first, err := Clone(origin, FromTemplate("leaf.gotmpl", []byte("FROM-FIRST"))) require.NoError(t, err) - _, err = repo.Get("server") + + second, err := Clone(origin, FromTemplate("leaf.gotmpl", []byte("FROM-SECOND"))) require.NoError(t, err) - // "skip" from another contrib should not be loaded - _, err = repo.Get("skip") - require.Error(t, err) -} + t.Run("should isolate a clone from its origin", func(t *testing.T) { + assert.Equal(t, "root=[DEFAULT]", render(t, origin, "root")) + }) -func TestLoadContrib_NoFiles(t *testing.T) { - repo := NewRepository(nil) - provider := mockAssetProvider{ - assets: map[string][]byte{ - "templates/contrib/other/model.gotmpl": []byte("wrong contrib"), - }, - } + t.Run("should isolate sibling clones from one another", func(t *testing.T) { + assert.Equal(t, "root=[FROM-FIRST]", render(t, first, "root")) + assert.Equal(t, "root=[FROM-SECOND]", render(t, second, "root")) + }) - err := repo.LoadContrib("nonexistent", provider) - require.Error(t, err) - assert.ErrorContains(t, err, "no files added") -} + t.Run("should isolate a clone of a clone", func(t *testing.T) { + third, err := Clone(first, FromTemplate("leaf.gotmpl", []byte("FROM-THIRD"))) + require.NoError(t, err) -func TestMustGet(t *testing.T) { - repo := NewRepository(nil) - err := repo.AddFile("present", "content") - require.NoError(t, err) + assert.Equal(t, "root=[FROM-THIRD]", render(t, third, "root")) + assert.Equal(t, "root=[FROM-FIRST]", render(t, first, "root")) + assert.Equal(t, "root=[DEFAULT]", render(t, origin, "root")) + }) - // success - tmpl := repo.MustGet("present") - require.NotNil(t, tmpl) + t.Run("should carry the settings of the origin over", func(t *testing.T) { + origin, err := New( + FromFS(fstest.MapFS{"a.tmpl": {Data: []byte("a")}}, ""), + WithExtensions(".tmpl"), + ) + require.NoError(t, err) - // panic on missing - assert.Panics(t, func() { - repo.MustGet("missing") + clone, err := Clone(origin, FromFS(fstest.MapFS{"b.tmpl": {Data: []byte("b")}}, "")) + require.NoError(t, err) + + assert.Equal(t, []string{"a", "b"}, slices.Collect(clone.Names())) }) -} -func TestGet_NotFound(t *testing.T) { - repo := NewRepository(nil) - _, err := repo.Get("nonexistent") - require.Error(t, err) - assert.ErrorContains(t, err, "template doesn't exist") -} + t.Run("should report a nil origin", func(t *testing.T) { + _, err := Clone(nil) -func TestAddFile_ParseError(t *testing.T) { - repo := NewRepository(nil) - err := repo.AddFile("bad", "{{ .Broken") - require.Error(t, err) - assert.ErrorContains(t, err, "failed to load template") + require.Error(t, err) + assert.ErrorIs(t, err, ErrTemplateRepo) + }) } -func TestDumpTemplates(t *testing.T) { - repo := NewRepository(nil) - err := repo.AddFile("base", `base content {{ template "sub" }}`) - require.NoError(t, err) +func TestFuncMap(t *testing.T) { + shout := template.FuncMap{"shout": strings.ToUpper} - err = repo.AddFile("sub", `sub content`) - require.NoError(t, err) + t.Run("should bind functions to every template", func(t *testing.T) { + r, err := New( + FromFS(fstest.MapFS{"a.gotmpl": {Data: []byte(`{{ shout "hi" }}`)}}, ""), + WithFuncMap(shout), + ) + require.NoError(t, err) - defer gentest.DiscardOutput()() - require.NotPanics(t, func() { - repo.DumpTemplates() + assert.Equal(t, "HI", render(t, r, "a")) }) -} -func TestFuncs(t *testing.T) { - fm := make(map[string]any) - fm["myfunc"] = func() string { return "hi" } - repo := NewRepository(fm) + t.Run("should report a template calling an unknown function", func(t *testing.T) { + _, err := New(FromFS(fstest.MapFS{"a.gotmpl": {Data: []byte(`{{ shout "hi" }}`)}}, "")) - funcs := repo.Funcs() - require.NotNil(t, funcs["myfunc"]) -} + require.Error(t, err) + assert.ErrorIs(t, err, ErrTemplateRepo) + }) -func TestFuncs_NilInit(t *testing.T) { - repo := NewRepository(nil) - funcs := repo.Funcs() - require.NotNil(t, funcs) -} + t.Run("should let a clone add a function that reaches the templates already there", func(t *testing.T) { + origin, err := New( + FromFS(fstest.MapFS{"a.gotmpl": {Data: []byte(`{{ shout "hi" }}`)}}, ""), + WithFuncMap(shout), + ) + require.NoError(t, err) -func TestDependencies_TemplateNode(t *testing.T) { - repo := NewRepository(nil) - err := repo.AddFile("child", "child content") - require.NoError(t, err) - err = repo.AddFile("parent", `parent calls {{ template "child" }}`) - require.NoError(t, err) + clone, err := Clone(origin, WithFuncMap(template.FuncMap{"shout": strings.ToLower})) + require.NoError(t, err) - tmpl, err := repo.Get("parent") - require.NoError(t, err) + assert.Equal(t, "hi", render(t, clone, "a"), "the clone re-parses, so the new function applies") + assert.Equal(t, "HI", render(t, origin, "a"), "the origin keeps the function it was built with") + }) - // executing should work since dependency is resolved - var buf bytes.Buffer - err = tmpl.Execute(&buf, nil) - require.NoError(t, err) - assert.StringContainsT(t, buf.String(), "child content") -} + t.Run("should not share a function map between repositories", func(t *testing.T) { + funcs := template.FuncMap{"shout": strings.ToUpper} -func TestDependencies_MissingDep(t *testing.T) { - repo := NewRepository(nil) - err := repo.AddFile("orphan", `calls {{ template "missing" }}`) - require.NoError(t, err) + first, err := New(FromFS(fstest.MapFS{"a.gotmpl": {Data: []byte(`{{ shout "hi" }}`)}}, ""), WithFuncMap(funcs)) + require.NoError(t, err) - _, err = repo.Get("orphan") - require.Error(t, err) - assert.ErrorContains(t, err, "could not find template missing") + // a caller mutating the map it passed must not reach the repository it built with it + funcs["shout"] = strings.ToLower + assert.Equal(t, "HI", render(t, first, "a")) + + second, err := New(FromFS(fstest.MapFS{"b.gotmpl": {Data: []byte(`{{ shout "hi" }}`)}}, "")) + require.Error(t, err, "the defaults of the first repository must not have leaked into the second") + assert.Nil(t, second) + }) } -func TestDependencies_IfNode(t *testing.T) { - repo := NewRepository(nil) - err := repo.AddFile("ifchild", "if-child") - require.NoError(t, err) - err = repo.AddFile("elsechild", "else-child") - require.NoError(t, err) - err = repo.AddFile("iftpl", `{{ if . }}{{ template "ifchild" }}{{ else }}{{ template "elsechild" }}{{ end }}`) - require.NoError(t, err) +func TestDependencies(t *testing.T) { + t.Run("should report a template referring to an undeclared one", func(t *testing.T) { + _, err := New(FromFS(fstest.MapFS{ + "root.gotmpl": {Data: []byte(`{{template "nowhere"}}`)}, + }, "")) - tmpl, err := repo.Get("iftpl") - require.NoError(t, err) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTemplateRepo) + assert.ErrorContains(t, err, `"root" refers to "nowhere", which it cannot reach`) + }) - var buf bytes.Buffer - err = tmpl.Execute(&buf, true) - require.NoError(t, err) - assert.StringContainsT(t, buf.String(), "if-child") + t.Run("should report every missing dependency at once", func(t *testing.T) { + _, err := New(FromFS(fstest.MapFS{ + "a.gotmpl": {Data: []byte(`{{template "missingOne"}}`)}, + "b.gotmpl": {Data: []byte(`{{template "missingTwo"}}`)}, + }, "")) - buf.Reset() - err = tmpl.Execute(&buf, false) - require.NoError(t, err) - assert.StringContainsT(t, buf.String(), "else-child") -} + require.Error(t, err) + assert.ErrorContains(t, err, "missingOne") + assert.ErrorContains(t, err, "missingTwo") + }) -func TestDependencies_RangeNode(t *testing.T) { - repo := NewRepository(nil) - err := repo.AddFile("item", "item:{{ . }}") - require.NoError(t, err) - err = repo.AddFile("rangetpl", `{{ range . }}{{ template "item" . }}{{ end }}`) - require.NoError(t, err) + t.Run("should find a dependency nested in a control structure", func(t *testing.T) { + _, err := New(FromFS(fstest.MapFS{ + "root.gotmpl": {Data: []byte( + `{{if .}}{{range .}}{{with .}}{{template "deep"}}{{end}}{{end}}{{else}}{{template "otherwise"}}{{end}}`, + )}, + }, "")) - tmpl, err := repo.Get("rangetpl") - require.NoError(t, err) + require.Error(t, err) + assert.ErrorContains(t, err, "deep") + assert.ErrorContains(t, err, "otherwise") + }) - var buf bytes.Buffer - err = tmpl.Execute(&buf, []string{"a", "b"}) - require.NoError(t, err) - assert.StringContainsT(t, buf.String(), "item:a") - assert.StringContainsT(t, buf.String(), "item:b") + t.Run("should accept a dependency declared by another asset", func(t *testing.T) { + r, err := New(FromFS(dependentAssets(), "")) + require.NoError(t, err) + + assert.Equal(t, "root=[DEFAULT]", render(t, r, "root")) + }) } -func TestDependencies_WithNode(t *testing.T) { - repo := NewRepository(nil) - err := repo.AddFile("withchild", "with-child:{{ . }}") - require.NoError(t, err) - err = repo.AddFile("withtpl", `{{ with . }}{{ template "withchild" . }}{{ end }}`) +func TestGet(t *testing.T) { + r, err := New(FromFS(dependentAssets(), "")) require.NoError(t, err) - tmpl, err := repo.Get("withtpl") - require.NoError(t, err) + t.Run("should report an undeclared name", func(t *testing.T) { + _, err := r.Get("nowhere") - var buf bytes.Buffer - err = tmpl.Execute(&buf, "data") - require.NoError(t, err) - assert.StringContainsT(t, buf.String(), "with-child:data") + require.Error(t, err) + assert.ErrorIs(t, err, ErrTemplateRepo) + }) + + t.Run("should report the asset a template comes from", func(t *testing.T) { + assetPath, declared := r.AssetOf("leaf") + assert.True(t, declared) + assert.Equal(t, "leaf.gotmpl", assetPath) + + _, declared = r.AssetOf("nowhere") + assert.False(t, declared) + }) + + t.Run("should panic on MustGet of an undeclared name", func(t *testing.T) { + assert.Panics(t, func() { _ = r.MustGet("nowhere") }) + assert.NotPanics(t, func() { _ = r.MustGet("leaf") }) + }) + + t.Run("should report the name of the template it returns", func(t *testing.T) { + tpl, err := r.Get("leaf") + require.NoError(t, err) + assert.Equal(t, "leaf", tpl.Name()) + }) + + t.Run("should report the zero template as unusable", func(t *testing.T) { + var zero Template + + assert.Empty(t, zero.Name()) + assert.ErrorIs(t, zero.Execute(&strings.Builder{}, nil), ErrTemplateRepo) + }) + + t.Run("should report an execution failure", func(t *testing.T) { + failing, err := New(FromFS(fstest.MapFS{"a.gotmpl": {Data: []byte(`{{ .Missing }}`)}}, "")) + require.NoError(t, err) + + tpl, err := failing.Get("a") + require.NoError(t, err) + + err = tpl.Execute(&strings.Builder{}, struct{ Present string }{}) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTemplateRepo) + assert.ErrorContains(t, err, `executing template "a"`) + }) } -func TestDependencies_Transitive(t *testing.T) { - repo := NewRepository(nil) - err := repo.AddFile("leaf", "leaf") - require.NoError(t, err) - err = repo.AddFile("mid", `mid->{{ template "leaf" }}`) - require.NoError(t, err) - err = repo.AddFile("root", `root->{{ template "mid" }}`) +// TestConcurrency exercises the contract the type advertises: a sealed repository is read +// concurrently, and cloning one reads it without disturbing it. Run under -race. +func TestConcurrency(t *testing.T) { + origin, err := New(FromFS(dependentAssets(), "")) require.NoError(t, err) - tmpl, err := repo.Get("root") - require.NoError(t, err) + const readers, cloners = 16, 8 - var buf bytes.Buffer - err = tmpl.Execute(&buf, nil) - require.NoError(t, err) - assert.StringContainsT(t, buf.String(), "root->mid->leaf") -} + var wg sync.WaitGroup + wg.Add(readers + cloners) -func TestFindDependencies_Nil(t *testing.T) { - deps := findDependencies(nil) - assert.Nil(t, deps) -} + for range readers { + go func() { + defer wg.Done() + + for range 50 { + tpl, err := origin.Get("root") + assert.NoError(t, err) -func TestLoadContrib_ParseError(t *testing.T) { - repo := NewRepository(nil) - provider := mockAssetProvider{ - assets: map[string][]byte{ - "templates/contrib/bad/broken.gotmpl": []byte("{{ .Broken"), - }, + var out strings.Builder + assert.NoError(t, tpl.Execute(&out, nil)) + assert.Equal(t, "root=[DEFAULT]", out.String()) + } + }() } - err := repo.LoadContrib("bad", provider) - require.Error(t, err) - assert.ErrorContains(t, err, "failed to load template") + for i := range cloners { + go func() { + defer wg.Done() + + for range 10 { + clone, err := Clone(origin, FromTemplate("leaf.gotmpl", []byte(strings.Repeat("X", i+1)))) + assert.NoError(t, err) + + var out strings.Builder + tpl, err := clone.Get("root") + assert.NoError(t, err) + assert.NoError(t, tpl.Execute(&out, nil)) + assert.Equal(t, "root=["+strings.Repeat("X", i+1)+"]", out.String()) + } + }() + } + + wg.Wait() + + assert.Equal(t, "root=[DEFAULT]", render(t, origin, "root")) } -func TestDependencies_RangeWithElse(t *testing.T) { - repo := NewRepository(nil) - err := repo.AddFile("rangeitem", "item") - require.NoError(t, err) - err = repo.AddFile("rangeempty", "empty") - require.NoError(t, err) - err = repo.AddFile("rangeelse", `{{ range . }}{{ template "rangeitem" }}{{ else }}{{ template "rangeempty" }}{{ end }}`) - require.NoError(t, err) +// TestScoping documents how a reference is resolved: mangled to a key, then looked for outward +// from the template holding it - its own children, its directory, each enclosing directory, the +// root. The nearest match wins, and a "define" reaches no further than the directory it sits in. +func TestScoping(t *testing.T) { + assets := fstest.MapFS{ + "server/fred.gotmpl": {Data: []byte(`{{define "inner-macro"}}MACRO{{end}}fred=[{{template "inner-macro" .}}]`)}, + "server/operations/operation.gotmpl": {Data: []byte(`{{define "deep"}}DEEP{{end}}op`)}, + "server/claude.gotmpl": {Data: []byte(`claude`)}, + "client/swagger.gotmpl": {Data: []byte(`swagger`)}, + } - tmpl, err := repo.Get("rangeelse") + r, err := New(FromFS(assets, "")) require.NoError(t, err) - var buf bytes.Buffer - err = tmpl.Execute(&buf, []string{}) - require.NoError(t, err) - assert.StringContainsT(t, buf.String(), "empty") -} + t.Run("should address a define under the asset declaring it", func(t *testing.T) { + assert.Equal(t, []string{ + "clientSwagger", "serverClaude", "serverFred", "serverFredInnerMacro", + "serverOperationsOperation", "serverOperationsOperationDeep", + }, slices.Collect(r.Names())) + }) -func TestDependencies_WithElse(t *testing.T) { - repo := NewRepository(nil) - err := repo.AddFile("withpresent", "present") - require.NoError(t, err) - err = repo.AddFile("withnil", "nil") - require.NoError(t, err) - err = repo.AddFile("withelse", `{{ with . }}{{ template "withpresent" }}{{ else }}{{ template "withnil" }}{{ end }}`) - require.NoError(t, err) + t.Run("should map an address onto the name it answers to", func(t *testing.T) { + name := r.NameOf("server/fred/inner-macro") + assert.Equal(t, "serverFredInnerMacro", name) + assert.True(t, r.Has(name)) - tmpl, err := repo.Get("withelse") - require.NoError(t, err) + address, found := r.AddressOf("serverFredInnerMacro") + assert.True(t, found) + assert.Equal(t, "server/fred/inner-macro", address) - var buf bytes.Buffer - err = tmpl.Execute(&buf, nil) - require.NoError(t, err) - assert.StringContainsT(t, buf.String(), "nil") -} + assert.False(t, r.Has(r.NameOf("inner-macro")), "a define is not addressed at the root") + }) -const ( -// Test template environment. -) + t.Run("should let a template reach its own define by the name it gave it", func(t *testing.T) { + assert.Equal(t, "fred=[MACRO]", render(t, r, "serverFred")) + }) -func TestRepoLoadingTemplates(t *testing.T) { - const singleTemplate = `test` + for _, reference := range []string{ + "serverFred", // by key, from the root + "serverFredInnerMacro", // a define, by key + "serverOperationsOperationDeep", // a define of a deeper directory, by key + "fred", // relative to the caller's own directory + "fredInnerMacro", // a define of a sibling, relative + "operationsOperation", // relative, through a directory + "inner-macro", // the bare name of a sibling's define + } { + t.Run("should resolve "+reference+" from a sibling", func(t *testing.T) { + reaching, err := Clone(r, FromTemplate("server/reaching.gotmpl", + []byte(`[{{template "`+reference+`" .}}]`))) + require.NoErrorf(t, err, "expected %q to resolve from server/", reference) + assert.NotEmpty(t, render(t, reaching, "serverReaching")) + }) + } - repo := NewRepository(nil) - require.NoError(t, repo.AddFile("simple", singleTemplate)) - templ, err := repo.Get("simple") - require.NoError(t, err) + for _, reference := range []string{ + "fred", // a sibling of server/, not of client/ + "fredInnerMacro", // likewise + "inner-macro", // a define reaches no further than its own directory + "operation", // no deep search: server/operation does not exist + } { + t.Run("should refuse "+reference+" from another directory", func(t *testing.T) { + _, err := Clone(r, FromTemplate("client/reaching.gotmpl", + []byte(`[{{template "`+reference+`" .}}]`))) + require.Errorf(t, err, "expected %q not to resolve from client/", reference) + assert.ErrorIs(t, err, ErrTemplateRepo) + }) + } + + t.Run("should let the nearest scope shadow the ones outside it", func(t *testing.T) { + shadowing, err := Clone(r, + FromTemplate("swagger.gotmpl", []byte(`ROOT`)), + FromTemplate("client/reaching.gotmpl", []byte(`[{{template "swagger" .}}]`)), + ) + require.NoError(t, err) - var b bytes.Buffer - require.NoError(t, templ.Execute(&b, nil)) - assert.EqualT(t, "test", b.String()) + // client/swagger stands nearer than the swagger at the root + assert.Equal(t, "[swagger]", render(t, shadowing, "clientReaching")) + }) } -const ( - multipleDefinitions = `{{ define "T1" }}T1{{end}}{{ define "T2" }}T2{{end}}` - dependantTemplate = `{{ template "T1" }}D1` -) +// TestCollision covers what a repository cannot tell apart, and what it takes as an override. +// Overriding is declaring the same address again; two addresses answering to one reference is not. +func TestCollision(t *testing.T) { + t.Run("should refuse two assets of a directory declaring the same define", func(t *testing.T) { + _, err := New(FromFS(fstest.MapFS{ + "one.gotmpl": {Data: []byte(`{{define "shared"}}FROM-ONE{{end}}`)}, + "two.gotmpl": {Data: []byte(`{{define "shared"}}FROM-TWO{{end}}`)}, + }, "")) -func TestRepoLoadsAllTemplatesDefined(t *testing.T) { - var b bytes.Buffer - repo := NewRepository(nil) - require.NoError(t, repo.AddFile("multiple", multipleDefinitions)) - templ, err := repo.Get("multiple") - require.NoError(t, err) - require.NoError(t, templ.Execute(&b, nil)) - assert.Empty(t, b.String()) - templ, err = repo.Get("T1") - require.NoError(t, err) - require.NotNil(t, templ) - require.NoError(t, templ.Execute(&b, nil)) - assert.EqualT(t, "T1", b.String()) -} + require.Error(t, err) + assert.ErrorIs(t, err, ErrTemplateRepo) + assert.ErrorContains(t, err, `both declare "shared"`) + }) -type testData struct { - Children []testData - Name string - Recurse bool -} + t.Run("should refuse it across sources too, an address being no layer's to take", func(t *testing.T) { + _, err := New( + FromFS(fstest.MapFS{"one.gotmpl": {Data: []byte(`{{define "shared"}}FROM-ONE{{end}}`)}}, ""), + FromFS(fstest.MapFS{"two.gotmpl": {Data: []byte(`{{define "shared"}}FROM-TWO{{end}}`)}}, ""), + ) -func TestRepoLoadsAllDependantTemplates(t *testing.T) { - var b bytes.Buffer - repo := NewRepository(nil) - require.NoError(t, repo.AddFile("multiple", multipleDefinitions)) - require.NoError(t, repo.AddFile("dependant", dependantTemplate)) - templ, err := repo.Get("dependant") - require.NoError(t, err) - require.NotNil(t, templ) - require.NoError(t, templ.Execute(&b, nil)) - assert.EqualT(t, "T1D1", b.String()) -} + require.Error(t, err) + assert.ErrorContains(t, err, `both declare "shared"`) + }) -func TestTemplates_RepoRecursiveTemplates(t *testing.T) { - const ( - cirularDeps1 = `{{ define "T1" }}{{ .Name }}: {{ range .Children }}{{ template "T2" . }}{{end}}{{end}}{{template "T1" . }}` - cirularDeps2 = `{{ define "T2" }}{{if .Recurse }}{{ template "T1" . }}{{ else }}Children{{end}}{{end}}` - root = "Root" - ) - - repo := NewRepository(nil) - require.NoError(t, repo.AddFile("c1", cirularDeps1)) - require.NoError(t, repo.AddFile("c2", cirularDeps2)) - templ, err := repo.Get("c1") - require.NoError(t, err) - require.NotNil(t, templ) - - t.Run("should not recurse", func(t *testing.T) { - var b bytes.Buffer - data := testData{ - Name: root, - Children: []testData{ - {Recurse: false}, - }, - } + t.Run("should let a define of another directory carry the same name", func(t *testing.T) { + r, err := New(FromFS(fstest.MapFS{ + "one.gotmpl": {Data: []byte(`{{define "shared"}}FROM-ONE{{end}}one=[{{template "shared"}}]`)}, + "deep/two.gotmpl": {Data: []byte(`{{define "shared"}}FROM-TWO{{end}}two=[{{template "shared"}}]`)}, + }, "")) + require.NoError(t, err) - require.NoError(t, templ.Execute(&b, data)) + assert.Equal(t, "one=[FROM-ONE]", render(t, r, "one")) + assert.Equal(t, "two=[FROM-TWO]", render(t, r, "deepTwo")) + }) - const expected = `Root: Children` - assert.EqualT(t, expected, b.String()) + t.Run("should report two asset paths that yield the same key", func(t *testing.T) { + _, err := New(FromFS(fstest.MapFS{ + "some-name.gotmpl": {Data: []byte("kebab")}, + "some_name.gotmpl": {Data: []byte("snake")}, + }, "")) + + require.Error(t, err) + assert.ErrorContains(t, err, `template "some_name" is declared by assets`) }) - t.Run("should recurse", func(t *testing.T) { - var b bytes.Buffer - data := testData{ - Name: root, - Children: []testData{ - {Name: "Child1", Recurse: true, Children: []testData{{Name: "Child2"}}}, - }, - } + t.Run("should let a caller's own define outrank an asset of the same name", func(t *testing.T) { + r, err := New(FromFS(fstest.MapFS{ + "model.gotmpl": {Data: []byte("ASSET")}, + "other.gotmpl": {Data: []byte(`{{define "model"}}DEFINE{{end}}other=[{{template "model"}}]`)}, + }, "")) + require.NoError(t, err) - require.NoError(t, templ.Execute(&b, data)) + // the nearest scope wins, and a template's own define is as near as it gets + assert.Equal(t, "other=[DEFINE]", render(t, r, "other")) + }) - const expected = `Root: Child1: Children` - assert.EqualT(t, expected, b.String()) + t.Run("should refuse a reference an asset and a define both answer", func(t *testing.T) { + // from a third template the two stand at the same distance, and nothing says which is meant + _, err := New(FromFS(fstest.MapFS{ + "model.gotmpl": {Data: []byte("ASSET")}, + "other.gotmpl": {Data: []byte(`{{define "model"}}DEFINE{{end}}other`)}, + "third.gotmpl": {Data: []byte(`third=[{{template "model"}}]`)}, + }, "")) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrTemplateRepo) + assert.ErrorContains(t, err, "which is both the template addressed") }) - t.Run("should not recurse", func(t *testing.T) { - var b bytes.Buffer - data := testData{ - Name: root, - Children: []testData{ - {Name: "Child1", Recurse: false, Children: []testData{{Name: "Child2"}}}, - }, - } + t.Run("should take a further source declaring the same address as an override", func(t *testing.T) { + r, err := New( + FromFS(fstest.MapFS{"one.gotmpl": {Data: []byte(`{{define "shared"}}FROM-ONE{{end}}`)}}, ""), + FromTemplate("one.gotmpl", []byte(`{{define "shared"}}FROM-TWO{{end}}`)), + ) + require.NoError(t, err) + + assert.Equal(t, "FROM-TWO", render(t, r, "oneShared")) + }) + + t.Run("should take a clone declaring the same address as an override", func(t *testing.T) { + origin, err := New(FromFS(fstest.MapFS{"one.gotmpl": {Data: []byte(`{{define "shared"}}ORIGIN{{end}}`)}}, "")) + require.NoError(t, err) - require.NoError(t, templ.Execute(&b, data)) + clone, err := Clone(origin, FromTemplate("one.gotmpl", []byte(`{{define "shared"}}CLONE{{end}}`))) + require.NoError(t, err) - const expected = `Root: Children` - assert.EqualT(t, expected, b.String()) + assert.Equal(t, "CLONE", render(t, clone, "oneShared")) + assert.Equal(t, "ORIGIN", render(t, origin, "oneShared")) }) } diff --git a/templates-repo/repository_win.go b/templates-repo/repository_win.go deleted file mode 100644 index 43ca536..0000000 --- a/templates-repo/repository_win.go +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -//go:build windows - -package repo - -// LoadPlugin is a no-op on Windows. -// -// Go plugins (the "plugin" package) are not supported on Windows, so the -// template-plugin option cannot be honoured on this platform and is ignored. -func (t *Repository) LoadPlugin(_ string) error { - return nil -} diff --git a/templates-repo/resolve.go b/templates-repo/resolve.go new file mode 100644 index 0000000..aa3c3bc --- /dev/null +++ b/templates-repo/resolve.go @@ -0,0 +1,282 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package repo + +import ( + "fmt" + "maps" + "path" + "slices" + "strings" + "text/template/parse" +) + +// declared is a template the repository holds, at the address it was declared under. +type declared struct { + // address is the path the template was declared at, never mangled. + // + // An asset declares a template at its own path, extension trimmed, and one per "define" + // statement at that path followed by the name the statement gives: + // + // server/fred.gotmpl -> server/fred + // {{ define "inner-macro" }} within it -> server/fred/inner-macro + address string + + // key is the name [Repository.Get] takes, mangled from the path. + key string + + // owner is the path of the asset template that declares this one, which is the path itself + // for a template named after its asset. + owner string + + // bare is the name a "define" statement gave, empty for a template named after its asset. + bare string + + // assetPath is the path of the asset it was read from, extension and all. + assetPath string + + layer int + tree *parse.Tree +} + +// addressOf returns the address an asset declares one of its templates at. +// +// An asset declares a template at its own address, and one per "define" statement under it. +func addressOf(owner, declaredName string) string { + if declaredName == owner { + return owner + } + + return owner + "/" + declaredName +} + +// isDefine reports whether a "define" statement declared this template. +func (d declared) isDefine() bool { return d.bare != "" } + +// addressSpace resolves what a template refers to, from where it was declared. +// +// A reference is mangled to a key, then looked for outward from the template holding it: its own +// children, its directory, each enclosing directory, the root. At every level a reference matches +// either a template addressed under that level, by the key of the path relative to it, or a +// "define" of an asset sitting directly in it, by its bare name. The first match wins, so the +// nearest scope shadows the ones outside it. +type addressSpace struct { + // byPath holds every template the repository declares. + byPath map[string]*declared + + // relative maps, per scope, the key of a path relative to that scope onto the key of the + // template it addresses. + relative map[string]map[string]string + + // siblings maps, per directory, the bare name of a "define" onto the template it declares. + // Only the assets sitting directly in that directory contribute to it. + siblings map[string]map[string]*declared +} + +// newAddressSpace indexes what a set of declarations addresses, and reports what it cannot tell +// apart. +func newAddressSpace(declarations map[string]*declared) (addressSpace, error) { + space := addressSpace{ + byPath: declarations, + relative: make(map[string]map[string]string, len(declarations)), + siblings: make(map[string]map[string]*declared, len(declarations)), + } + + var ambiguous []string + + for _, address := range sortedPaths(declarations) { + item := declarations[address] + + for _, scope := range ancestorsOf(address) { + relative := TemplateName(strings.TrimPrefix(strings.TrimPrefix(address, scope), "/")) + + if taken, found := space.relative[scope][relative]; found && taken != item.key { + ambiguous = append(ambiguous, fmt.Sprintf( + "%q and %q are both %q under %s", taken, item.key, relative, scopeName(scope))) + + continue + } + + if space.relative[scope] == nil { + space.relative[scope] = make(map[string]string) + } + space.relative[scope][relative] = item.key + } + + if !item.isDefine() { + continue + } + + directory := path.Dir(item.owner) + if directory == "." { + directory = "" + } + + bare := TemplateName(item.bare) + if taken, found := space.siblings[directory][bare]; found && taken.key != item.key { + ambiguous = append(ambiguous, fmt.Sprintf( + "%q and %q both declare %q in %s", taken.owner, item.owner, item.bare, + scopeName(directory))) + + continue + } + + if space.siblings[directory] == nil { + space.siblings[directory] = make(map[string]*declared) + } + space.siblings[directory][bare] = item + } + + if len(ambiguous) > 0 { + return addressSpace{}, fmt.Errorf( + "templates that cannot be told apart: %s: %w", strings.Join(ambiguous, ", "), ErrTemplateRepo) + } + + return space, nil +} + +// resolve names the template a reference held by another one addresses. +// +// A scope may answer a reference in two ways at once. That is an ambiguous reference rather than +// a precedence to settle, so it is reported instead of resolved either way. +func (a addressSpace) resolve(from, reference string) (string, bool, error) { + wanted := TemplateName(reference) + + for _, scope := range scopesOf(from) { + addressed, byAddress := a.relative[scope][wanted] + sibling, byName := a.siblings[scope][wanted] + + switch { + case byAddress && byName && addressed != sibling.key: + return "", false, fmt.Errorf( + "%q refers to %q, which is both the template addressed %q and the %q declared by %q, in %s: %w", + from, reference, addressed, sibling.bare, sibling.owner, scopeName(scope), ErrTemplateRepo) + + case byAddress: + return addressed, true, nil + + case byName: + return sibling.key, true, nil + } + } + + return "", false, nil +} + +// rewrite resolves every reference a template holds, and writes the key it addresses in its place. +// +// The names an author writes are relative to where they wrote them, and the namespace a template +// executes in is flat. Resolving every reference here reconciles the two, and nothing is resolved +// again while a template runs. +func (a addressSpace) rewrite() (map[string][]string, map[string]map[string]string, error) { + unresolved := make(map[string][]string) + resolutions := make(map[string]map[string]string) + + for _, address := range sortedPaths(a.byPath) { + item := a.byPath[address] + if item.tree == nil { + continue + } + + for _, node := range referencesIn(item.tree.Root) { + key, found, err := a.resolve(address, node.Name) + if err != nil { + return nil, nil, err + } + + if !found { + unresolved[item.key] = append(unresolved[item.key], node.Name) + + continue + } + + if resolutions[item.key] == nil { + resolutions[item.key] = make(map[string]string) + } + resolutions[item.key][node.Name] = key + + node.Name = key + } + } + + return unresolved, resolutions, nil +} + +// referencesIn collects the nodes invoking another template, which are the ones to resolve. +func referencesIn(node parse.Node) []*parse.TemplateNode { + var found []*parse.TemplateNode + collectReferences(node, &found) + + return found +} + +// collectReferences walks a parse tree and gathers every template invocation it holds. +func collectReferences(node parse.Node, found *[]*parse.TemplateNode) { + switch typed := node.(type) { + case *parse.ListNode: + if typed == nil { + return + } + + for _, child := range typed.Nodes { + collectReferences(child, found) + } + + case *parse.IfNode: + collectReferences(typed.List, found) + collectReferences(typed.ElseList, found) + + case *parse.RangeNode: + collectReferences(typed.List, found) + collectReferences(typed.ElseList, found) + + case *parse.WithNode: + collectReferences(typed.List, found) + collectReferences(typed.ElseList, found) + + case *parse.TemplateNode: + *found = append(*found, typed) + } +} + +// scopesOf lists where a template looks for what it refers to, nearest first. +func scopesOf(address string) []string { + scopes := []string{address} + + for { + cut := strings.LastIndex(address, "/") + if cut < 0 { + break + } + + address = address[:cut] + scopes = append(scopes, address) + } + + if address != "" { + scopes = append(scopes, "") + } + + return scopes +} + +// ancestorsOf lists the scopes a template is addressable under, nearest first. +func ancestorsOf(address string) []string { + return scopesOf(address)[1:] +} + +// scopeName renders a scope for a diagnostic. +func scopeName(scope string) string { + if scope == "" { + return "the root" + } + + return scope +} + +// sortedPaths lists the addresses of a set of declarations, so that what is built from them does +// not depend on the order a map hands them over. +func sortedPaths(declarations map[string]*declared) []string { + return slices.Sorted(maps.Keys(declarations)) +} diff --git a/templates-repo/roots_test.go b/templates-repo/roots_test.go new file mode 100644 index 0000000..d51dad3 --- /dev/null +++ b/templates-repo/roots_test.go @@ -0,0 +1,550 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package repo + +import ( + "slices" + "strings" + "testing" + "testing/fstest" + "text/template" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// targetedAssets declare two generation targets sharing a template, plus one no target reaches. +func targetedAssets() fstest.MapFS { + return fstest.MapFS{ + "client.gotmpl": {Data: []byte(`client=[{{template "shared" .}}{{template "clientCall"}}]`)}, + "client/call.gotmpl": {Data: []byte(`call`)}, + "server.gotmpl": {Data: []byte(`server=[{{template "shared" .}}]`)}, + "shared.gotmpl": {Data: []byte(`shared`)}, + "orphan.gotmpl": {Data: []byte(`orphan`)}, + } +} + +func TestWithRoots(t *testing.T) { + t.Run("should keep the roots and what they reach", func(t *testing.T) { + r, err := New(FromFS(targetedAssets(), ""), WithRoots("client")) + require.NoError(t, err) + + assert.Equal(t, []string{"client", "clientCall", "shared"}, slices.Collect(r.Names())) + assert.Equal(t, "client=[sharedcall]", render(t, r, "client")) + }) + + t.Run("should prune what no root reaches", func(t *testing.T) { + r, err := New(FromFS(targetedAssets(), ""), WithRoots("server")) + require.NoError(t, err) + + assert.Equal(t, []string{"server", "shared"}, slices.Collect(r.Names())) + + assert.False(t, r.Has("client")) + assert.False(t, r.Has("orphan")) + + _, err = r.Get("orphan") + require.ErrorIs(t, err, ErrTemplateRepo) + + _, declared := r.AssetOf("orphan") + assert.False(t, declared) + }) + + t.Run("should keep every template when no root is named", func(t *testing.T) { + r, err := New(FromFS(targetedAssets(), "")) + require.NoError(t, err) + + assert.Equal(t, + []string{"client", "clientCall", "orphan", "server", "shared"}, + slices.Collect(r.Names()), + ) + }) + + t.Run("should keep a root that reaches nothing", func(t *testing.T) { + r, err := New(FromFS(targetedAssets(), ""), WithRoots("orphan")) + require.NoError(t, err) + + assert.Equal(t, []string{"orphan"}, slices.Collect(r.Names())) + }) + + t.Run("should keep an inner template a root reaches", func(t *testing.T) { + r, err := New(FromFS(fstest.MapFS{ + "schema.gotmpl": {Data: []byte( + `{{define "schemaBody"}}body{{template "schemaType"}}{{end}}{{define "schemaType"}}type{{end}}`, + )}, + "other.gotmpl": {Data: []byte(`other`)}, + }, ""), WithRoots("schemaSchemaBody")) + require.NoError(t, err) + + assert.Equal(t, + []string{"schemaSchemaBody", "schemaSchemaType"}, + slices.Collect(r.Names()), + ) + assert.Equal(t, "bodytype", render(t, r, "schemaSchemaBody")) + }) + + t.Run("should follow a loop of templates without hanging", func(t *testing.T) { + r, err := New(FromFS(fstest.MapFS{ + "schema.gotmpl": {Data: []byte(`{{if .}}{{template "schemaBody" false}}{{end}}`)}, + "schema/body.gotmpl": {Data: []byte( + `body{{if .}}{{template "schema" false}}{{end}}`, + )}, + "orphan.gotmpl": {Data: []byte(`orphan`)}, + }, ""), WithRoots("schema")) + require.NoError(t, err) + + assert.Equal(t, []string{"schema", "schemaBody"}, slices.Collect(r.Names())) + assert.Equal(t, "body", render(t, r, "schemaBody")) + }) + + t.Run("should build although a pruned template refers to an undeclared one", func(t *testing.T) { + assets := fstest.MapFS{ + "client.gotmpl": {Data: []byte(`client`)}, + "server.gotmpl": {Data: []byte(`{{template "neverDeclared"}}`)}, + } + + _, err := New(FromFS(assets, "")) + require.ErrorIs(t, err, ErrTemplateRepo) + assert.Contains(t, err.Error(), "neverDeclared") + + r, err := New(FromFS(assets, ""), WithRoots("client")) + require.NoError(t, err) + + assert.Equal(t, []string{"client"}, slices.Collect(r.Names())) + }) + + t.Run("should still report an undeclared template a root reaches", func(t *testing.T) { + _, err := New(FromFS(fstest.MapFS{ + "client.gotmpl": {Data: []byte(`{{template "neverDeclared"}}`)}, + }, ""), WithRoots("client")) + + require.ErrorIs(t, err, ErrTemplateRepo) + assert.Contains(t, err.Error(), "neverDeclared") + }) + + t.Run("should still parse the assets it prunes away", func(t *testing.T) { + _, err := New(FromFS(fstest.MapFS{ + "client.gotmpl": {Data: []byte(`client`)}, + "broken.gotmpl": {Data: []byte(`{{ this does not parse`)}, + }, ""), WithRoots("client")) + + require.ErrorIs(t, err, ErrTemplateRepo) + assert.Contains(t, err.Error(), "broken.gotmpl") + }) + + t.Run("should report a root no source declares", func(t *testing.T) { + _, err := New(FromFS(targetedAssets(), ""), WithRoots("client", "typo", "alsoATypo")) + + require.ErrorIs(t, err, ErrTemplateRepo) + assert.Contains(t, err.Error(), `"alsoATypo"`) + assert.Contains(t, err.Error(), `"typo"`) + }) + + t.Run("should refuse a filter naming no root", func(t *testing.T) { + _, err := New(FromFS(targetedAssets(), ""), WithRoots()) + + require.ErrorIs(t, err, ErrTemplateRepo) + }) + + t.Run("should refuse an unnamed root", func(t *testing.T) { + _, err := New(FromFS(targetedAssets(), ""), WithRoots("client", " ")) + + require.ErrorIs(t, err, ErrTemplateRepo) + }) + + t.Run("should honour an override of a pruned template", func(t *testing.T) { + // the override is read, and stands, whether the template it replaces is retained or not + r, err := New( + FromFS(targetedAssets(), ""), + FromTemplate("shared", []byte("overridden")), + WithRoots("server"), + ) + require.NoError(t, err) + + assert.Equal(t, "server=[overridden]", render(t, r, "server")) + }) + + t.Run("should follow the calls of the definition that stands", func(t *testing.T) { + // the override calls a template the original does not, and pulls it in + r, err := New( + FromFS(targetedAssets(), ""), + FromTemplate("shared", []byte(`shared+{{template "orphan"}}`)), + WithRoots("server"), + ) + require.NoError(t, err) + + assert.Equal(t, []string{"orphan", "server", "shared"}, slices.Collect(r.Names())) + assert.Equal(t, "server=[shared+orphan]", render(t, r, "server")) + }) +} + +func TestWithExtraRoots(t *testing.T) { + t.Run("should widen the scope of a repository that has one", func(t *testing.T) { + client, err := New(FromFS(targetedAssets(), ""), WithRoots("client")) + require.NoError(t, err) + + both, err := Clone(client, WithExtraRoots("server")) + require.NoError(t, err) + + assert.Equal(t, + []string{"client", "clientCall", "server", "shared"}, + slices.Collect(both.Names()), + ) + }) + + t.Run("should leave a repository that keeps everything alone", func(t *testing.T) { + full, err := New(FromFS(targetedAssets(), "")) + require.NoError(t, err) + + still, err := Clone(full, WithExtraRoots("server")) + require.NoError(t, err) + + assert.Equal(t, + []string{"client", "clientCall", "orphan", "server", "shared"}, + slices.Collect(still.Names()), + ) + assert.Empty(t, still.Roots()) + }) + + t.Run("should keep a template added to a repository, scoped or not", func(t *testing.T) { + // the caller writes the same thing either way, which is the point of it + for _, scope := range [][]Option{{}, {WithRoots("server")}} { + base, err := New(append([]Option{FromFS(targetedAssets(), "")}, scope...)...) + require.NoError(t, err) + + mine, err := Clone(base, FromTemplate("mine", []byte("mine")), WithExtraRoots("mine")) + require.NoError(t, err) + + assert.TrueT(t, mine.Has("mine")) + assert.TrueT(t, mine.Has("server"), "what it was scoped to is still there") + } + }) + + t.Run("should refuse an unnamed root", func(t *testing.T) { + _, err := New(FromFS(targetedAssets(), ""), WithRoots("server"), WithExtraRoots(" ")) + + require.ErrorIs(t, err, ErrTemplateRepo) + }) +} + +func TestRoots(t *testing.T) { + t.Run("should report no root when the repository holds everything", func(t *testing.T) { + r, err := New(FromFS(targetedAssets(), "")) + require.NoError(t, err) + + assert.Empty(t, r.Roots()) + }) + + t.Run("should report the roots it is scoped to", func(t *testing.T) { + r, err := New(FromFS(targetedAssets(), ""), WithRoots("client"), WithRoots("server", "orphan")) + require.NoError(t, err) + + assert.Equal(t, []string{"server", "orphan"}, r.Roots()) + }) + + t.Run("should not let a caller alter the scope it reports", func(t *testing.T) { + r, err := New(FromFS(targetedAssets(), ""), WithRoots("server")) + require.NoError(t, err) + + r.Roots()[0] = "client" + + assert.Equal(t, []string{"server"}, r.Roots()) + }) +} + +func TestWithRootsOnClone(t *testing.T) { + t.Run("should prune a clone of a whole repository", func(t *testing.T) { + full, err := New(FromFS(targetedAssets(), "")) + require.NoError(t, err) + + client, err := Clone(full, WithRoots("client")) + require.NoError(t, err) + + assert.Equal(t, []string{"client", "clientCall", "shared"}, slices.Collect(client.Names())) + + // the repository it derives from is untouched + assert.True(t, full.Has("server")) + }) + + t.Run("should carry the roots over to a clone", func(t *testing.T) { + client, err := New(FromFS(targetedAssets(), ""), WithRoots("client")) + require.NoError(t, err) + + patched, err := Clone(client, FromTemplate("shared", []byte("patched"))) + require.NoError(t, err) + + assert.Equal(t, []string{"client", "clientCall", "shared"}, slices.Collect(patched.Names())) + assert.Equal(t, "client=[patchedcall]", render(t, patched, "client")) + }) + + t.Run("should rescope a clone to the roots it names", func(t *testing.T) { + client, err := New(FromFS(targetedAssets(), ""), WithRoots("client")) + require.NoError(t, err) + + server, err := Clone(client, WithRoots("server")) + require.NoError(t, err) + + assert.Equal(t, []string{"server", "shared"}, slices.Collect(server.Names())) + assert.Equal(t, []string{"server"}, server.Roots()) + }) + + t.Run("should keep the pruned assets available to a clone", func(t *testing.T) { + // pruning decides what a repository holds, not what it retains to build a clone from + client, err := New(FromFS(targetedAssets(), ""), WithRoots("client")) + require.NoError(t, err) + + widened, err := Clone(client, WithRoots("orphan")) + require.NoError(t, err) + + assert.True(t, widened.Has("orphan")) + assert.Equal(t, "orphan", render(t, widened, "orphan")) + }) +} + +func TestWithRootsAndCoverage(t *testing.T) { + t.Run("should count no line of a pruned template", func(t *testing.T) { + r, err := New( + FromFS(targetedAssets(), ""), + WithRoots("server"), + WithCoverage("example.com/gen/templates"), + ) + require.NoError(t, err) + + require.NoError(t, r.MustGet("server").Execute(&strings.Builder{}, nil)) + + var profile strings.Builder + require.NoError(t, r.Coverage().Flush(&profile)) + + assert.Contains(t, profile.String(), "server.gotmpl") + assert.Contains(t, profile.String(), "shared.gotmpl") + assert.NotContains(t, profile.String(), "orphan.gotmpl") + assert.NotContains(t, profile.String(), "client.gotmpl") + }) +} + +func TestWithRootsAndDocumentation(t *testing.T) { + t.Run("should document the retained templates only", func(t *testing.T) { + r, err := New(FromFS(targetedAssets(), ""), WithRoots("server")) + require.NoError(t, err) + + documentation, err := r.Documentation() + require.NoError(t, err) + + documented := make([]string, 0, len(documentation.Assets)) + for _, item := range documentation.Assets { + documented = append(documented, item.Path) + } + + assert.Equal(t, []string{"server.gotmpl", "shared.gotmpl"}, documented) + }) +} + +func TestNameOf(t *testing.T) { + r, err := New(FromFS(targetedAssets(), "")) + require.NoError(t, err) + + t.Run("should name an asset the way it declares one", func(t *testing.T) { + assert.Equal(t, "clientCall", r.NameOf("client/call.gotmpl")) + assert.Equal(t, "serverParameter", r.NameOf("server/parameter.gotmpl")) + assert.Equal(t, "swaggerJsonEmbed", r.NameOf("swagger_json_embed.gotmpl")) + }) + + t.Run("should name an asset no source declares", func(t *testing.T) { + assert.Equal(t, "neverThere", r.NameOf("never/there.gotmpl")) + }) + + t.Run("should take a name back unchanged", func(t *testing.T) { + for name := range r.Names() { + assert.Equalf(t, name, r.NameOf(name), "naming %q again changed it", name) + } + }) + + t.Run("should reverse AssetOf", func(t *testing.T) { + for name := range r.Names() { + path, declared := r.AssetOf(name) + require.True(t, declared) + + assert.Equal(t, name, r.NameOf(path)) + } + }) + + t.Run("should trim the extensions the repository recognizes", func(t *testing.T) { + other, err := New(FromFS(fstest.MapFS{"model.tmpl": {Data: []byte("model")}}, ""), WithExtensions(".tmpl")) + require.NoError(t, err) + + assert.Equal(t, "model", other.NameOf("model.tmpl")) + assert.Equal(t, "modelDotGotmpl", other.NameOf("model.gotmpl")) + }) +} + +func TestExportedTemplateName(t *testing.T) { + t.Run("should name an asset before any repository exists", func(t *testing.T) { + assert.Equal(t, "validationPrimitive", TemplateName("validation/primitive.gotmpl")) + assert.Equal(t, "swaggerJsonEmbed", TemplateName("swagger_json_embed.gotmpl")) + assert.Equal(t, "serverParameter", TemplateName("serverParameter")) + }) + + t.Run("should take the extensions it is given", func(t *testing.T) { + assert.Equal(t, "model", TemplateName("model.tmpl", ".tmpl")) + assert.Equal(t, "modelDotGotmpl", TemplateName("model.gotmpl", ".tmpl")) + }) + + t.Run("should agree with the repository it names for", func(t *testing.T) { + r, err := New(FromFS(targetedAssets(), "")) + require.NoError(t, err) + + for name := range r.Names() { + path, _ := r.AssetOf(name) + + assert.Equal(t, r.NameOf(path), TemplateName(path)) + } + }) +} + +func TestAudit(t *testing.T) { + t.Run("should report nothing when no name is declared twice", func(t *testing.T) { + r, err := New(FromFS(targetedAssets(), "")) + require.NoError(t, err) + + report, err := r.Audit() + require.NoError(t, err) + assert.Empty(t, report.Overridden) + }) + + t.Run("should report what a later source replaced", func(t *testing.T) { + r, err := New( + FromFS(targetedAssets(), ""), + FromFS(fstest.MapFS{"shared.gotmpl": {Data: []byte("theirs")}}, ""), + FromTemplate("shared", []byte("mine")), + ) + require.NoError(t, err) + + report, err := r.Audit() + require.NoError(t, err) + require.Len(t, report.Overridden, 1) + assert.Equal(t, "shared", report.Overridden[0].Name) + assert.Equal(t, "shared", report.Overridden[0].Standing) + assert.Equal(t, []string{"shared.gotmpl", "shared.gotmpl"}, report.Overridden[0].Replaced) + }) + + t.Run("should report an inner define a later source took over", func(t *testing.T) { + // a define is replaced at the address it lives at, which is under the asset declaring it + r, err := New( + FromFS(fstest.MapFS{ + "schema.gotmpl": {Data: []byte(`{{define "helper"}}shipped{{end}}{{template "helper"}}`)}, + }, ""), + FromFS(fstest.MapFS{ + "schema.gotmpl": {Data: []byte(`{{define "helper"}}mine{{end}}{{template "helper"}}`)}, + }, ""), + ) + require.NoError(t, err) + + report, err := r.Audit() + require.NoError(t, err) + require.Len(t, report.Overridden, 2) + assert.Equal(t, "schema", report.Overridden[0].Name) + assert.Equal(t, "schemaHelper", report.Overridden[1].Name) + assert.Equal(t, "schema.gotmpl", report.Overridden[1].Standing) + + // and it reaches the template that was calling the one replaced + assert.Equal(t, "mine", render(t, r, "schema")) + }) + + t.Run("should not let a caller alter what it reports", func(t *testing.T) { + r, err := New( + FromFS(targetedAssets(), ""), + FromTemplate("shared", []byte("mine")), + ) + require.NoError(t, err) + + report, err := r.Audit() + require.NoError(t, err) + report.Overridden[0].Name = "tampered" + + again, err := r.Audit() + require.NoError(t, err) + assert.Equal(t, "shared", again.Overridden[0].Name) + }) + + t.Run("should say nothing of a template pruned away", func(t *testing.T) { + r, err := New( + FromFS(targetedAssets(), ""), + FromTemplate("orphan", []byte("mine")), + WithRoots("server"), + ) + require.NoError(t, err) + + report, err := r.Audit() + require.NoError(t, err) + assert.Empty(t, report.Overridden) + }) +} + +func TestAuditReport(t *testing.T) { + t.Run("should report what renders nothing and what calls dynamically", func(t *testing.T) { + r, err := New(FromFS(fstest.MapFS{ + "root.gotmpl": {Data: []byte(`root=[{{template "used"}}]`)}, + "used.gotmpl": {Data: []byte(`used`)}, + "orphan.gotmpl": {Data: []byte(`orphan`)}, + "blank.gotmpl": {Data: []byte(`{{define "inner"}}inner{{end}}`)}, + "dynamic.gotmpl": {Data: []byte(`{{ call .Fn }}`)}, + }, "")) + require.NoError(t, err) + + report, err := r.Audit() + require.NoError(t, err) + + assert.Empty(t, report.Overridden) + assert.Equal(t, []string{"blank"}, report.Empty, "an asset of defines alone renders nothing") + assert.Equal(t, []string{"dynamic"}, report.Dynamic) + + // nothing calls these; whether that is a dead template or an entry point, only the caller knows + assert.Equal(t, []string{"blank", "blankInner", "dynamic", "orphan", "root"}, report.Unused) + }) + + t.Run("should find nothing unused in a repository scoped to its roots", func(t *testing.T) { + // scoping keeps the roots and whatever they reach, so every template left is one or the other + r, err := New(FromFS(fstest.MapFS{ + "root.gotmpl": {Data: []byte(`root=[{{template "used"}}]`)}, + "used.gotmpl": {Data: []byte(`used`)}, + "orphan.gotmpl": {Data: []byte(`orphan`)}, + }, ""), WithRoots("root")) + require.NoError(t, err) + + report, err := r.Audit() + require.NoError(t, err) + + assert.Empty(t, report.Unused) + }) + + t.Run("should report the func map entries no template calls", func(t *testing.T) { + r, err := New( + FromTemplate("leaf.gotmpl", []byte(`{{ shout "x" }}`)), + WithFuncMap(template.FuncMap{ + "shout": func(string) string { return "X" }, + "unused": func() string { return "" }, + "also": func() string { return "" }, + }), + ) + require.NoError(t, err) + + report, err := r.Audit() + require.NoError(t, err) + + assert.Equal(t, []string{"also", "unused"}, report.UnusedFuncs) + }) + + t.Run("should count an entry point of an unscoped repository as unused", func(t *testing.T) { + // with no root declared, nothing tells an entry point from a template that outlived its callers + r, err := New(FromFS(fstest.MapFS{ + "root.gotmpl": {Data: []byte(`root=[{{template "used"}}]`)}, + "used.gotmpl": {Data: []byte(`used`)}, + }, "")) + require.NoError(t, err) + + report, err := r.Audit() + require.NoError(t, err) + + assert.Equal(t, []string{"root"}, report.Unused) + }) +} diff --git a/templates-repo/sources.go b/templates-repo/sources.go new file mode 100644 index 0000000..fc6f197 --- /dev/null +++ b/templates-repo/sources.go @@ -0,0 +1,403 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package repo + +import ( + "fmt" + "io/fs" + "os" + "path" + "slices" + "strings" +) + +// asset is a template file read from a source, held for as long as the repository lives. +// +// The path is the one the asset has once mounted, slash-separated and cleaned. The name of the +// template is derived from it, so the path is retained rather than the name: a [Clone] that +// changes the recognized extensions renames the templates accordingly. +// +// The layer records which source read the asset. Layers are numbered in the order the sources are +// declared, and a [Clone] carries on where the repository it derives from left off, so two assets +// share a layer only when a single source read them both. +type asset struct { + path string + data []byte + layer int +} + +// source reads a set of assets, once, when the repository is built. +type source func(options) ([]asset, error) + +// FromFS reads every supported asset of fsys, and mounts them at mountPoint. +// +// fsys is read from its root, so a caller serving a subtree re-roots it beforehand with +// [io/fs.Sub]. An empty mountPoint, or ".", mounts the assets at the top of the template tree, +// which is the usual case. +// +// Overriding is not this option's business: a caller that wants one set of files to take +// precedence over another stacks them into a single [io/fs.FS] first, and passes the result. +// +// Example: +// +// // the assets of an embed.FS, patched by an alternate set living elsewhere in the same tree +// repo.New(repo.FromFS(fileutils.NewOverlayFS( +// fileutils.MustSub(assets, "templates"), +// fileutils.MustSub(assets, "templates/contrib/mine"), +// ), "")) +func FromFS(fsys fs.FS, mountPoint string, opts ...SourceOption) Option { + return func(o *options) error { + mount, err := cleanMountPoint(mountPoint) + if err != nil { + return err + } + + reading, err := makeSourceOptions(opts) + if err != nil { + return err + } + + o.sources = append(o.sources, func(settings options) ([]asset, error) { + if fsys == nil { + return nil, fmt.Errorf("cannot read templates from a nil fs.FS: %w", ErrTemplateRepo) + } + + return readFS(fsys, reading.mount(mount), reading.skipDirectories, settings) + }) + + return nil + } +} + +// Sources bundles several options into one, so that a package exports everything its templates +// need as a single option. +// +// A set of templates is rarely one source: the templates themselves, the ones that place each +// generated section, and whatever else a package ships. The caller assembling them does not have +// to know how many there are, nor in what order they go. +// +// Example: +// +// // the package publishes this, and nothing of what is inside it +// func Sources(opts ...repo.SourceOption) repo.Option { +// return repo.Sources( +// repo.FromFS(templates, "", opts...), +// repo.FromFS(filepaths, "paths", opts...), +// ) +// } +func Sources(opts ...Option) Option { + return func(o *options) error { + return o.apply(opts) + } +} + +// SourceOption configures how one source is read. +// +// Which directories to skip describes the file system being walked, not the repository. Skipping a +// directory of the assets one source ships leaves a directory of the same name fully readable in +// a template set someone else brings. +type SourceOption func(*sourceOptions) error + +// sourceOptions holds the settings of a single source. +type sourceOptions struct { + skipDirectories []string + rebase string +} + +// Rebased mounts a source under a base, on top of wherever it already mounts. +// +// Use it to publish templates without knowing where they land: the package exports sources, and +// the caller assembling them chooses the mount point of each. +// +// Example: +// +// // the package publishes this +// func Sources(opts ...repo.SourceOption) repo.Option { +// return repo.FromFS(templates, "", opts...) +// } +// +// // and whoever assembles decides where it lands +// repo.New( +// genmodels.Sources(repo.Rebased("models")), +// genclient.Sources(repo.Rebased("client")), +// ) +func Rebased(base string) SourceOption { + return func(o *sourceOptions) error { + under, err := cleanMountPoint(base) + if err != nil { + return err + } + + o.rebase = path.Join(o.rebase, under) + + return nil + } +} + +// mount joins the mount point a source declared with the base the caller asked for. +func (o sourceOptions) mount(declared string) string { + return path.Join(o.rebase, declared) +} + +// makeSourceOptions applies opts on top of the defaults, which skip nothing. +func makeSourceOptions(opts []SourceOption) (sourceOptions, error) { + var o sourceOptions + + for _, option := range opts { + if option == nil { + continue + } + + if err := option(&o); err != nil { + return sourceOptions{}, err + } + } + + return o, nil +} + +// SkipDirectories walks past the directories named, wherever they are in the tree read. +// +// Directories are matched on their name, at any depth. Nothing is skipped by default. Use this +// to stack a set of alternate templates without reading all of it, on the source that holds +// them. +// +// Example: +// +// // the assets shipped, leaving the alternate sets to be stacked explicitly +// repo.FromFS(assets, "", repo.SkipDirectories("contrib")) +func SkipDirectories(names ...string) SourceOption { + return func(o *sourceOptions) error { + o.skipDirectories = append(o.skipDirectories, names...) + + return nil + } +} + +// FromDir reads every supported asset of a local directory, and mounts them at mountPoint. +// +// dir is a path in the os file system, and the assets are named relative to it. It is the +// shorthand for [FromFS] over an [os.DirFS], and it reports an error when dir is not a readable +// directory. +// +// The directory is read once, when the repository is built. Editing a template on disk +// afterwards has no effect until a repository is built again. +func FromDir(dir, mountPoint string, opts ...SourceOption) Option { + return func(o *options) error { + mount, err := cleanMountPoint(mountPoint) + if err != nil { + return err + } + + reading, err := makeSourceOptions(opts) + if err != nil { + return err + } + + o.sources = append(o.sources, func(settings options) ([]asset, error) { + info, err := os.Stat(dir) + if err != nil { + return nil, fmt.Errorf("cannot read templates from %q: %w: %w", dir, err, ErrTemplateRepo) + } + + if !info.IsDir() { + return nil, fmt.Errorf("%q is not a directory: %w", dir, ErrTemplateRepo) + } + + return readFS(os.DirFS(dir), reading.mount(mount), reading.skipDirectories, settings) + }) + + return nil + } +} + +// FromRepository reads the templates another repository holds, and mounts them at mountPoint. +// +// This is how a set assembled out of parts is built in one pass. A repository is only built when +// everything it refers to is there, so a scaffolding that calls into the parts it is assembled +// with cannot stand on its own. Declare the parts as sources of the same build, and each may be +// written apart and resolved together. +// +// The templates of the repository are read as they were declared, and mounting them somewhere +// moves their addresses the way [Rebase] does. What they refer to moves with them, so a set that +// resolved on its own resolves the same mounted. +// +// Nothing of the repository is read again: it retained the content of its own sources, and that is +// what is carried over. It is left untouched. +// +// Example: +// +// // a scaffolding of one's own, with the sets it calls into +// repo.New( +// repo.FromDir("./scaffolding", ""), +// repo.FromRepository(modelTemplates, "models"), +// repo.FromRepository(serverTemplates, "server"), +// ) +func FromRepository(source *Repository, mountPoint string, opts ...SourceOption) Option { + return func(o *options) error { + mount, err := cleanMountPoint(mountPoint) + if err != nil { + return err + } + + reading, err := makeSourceOptions(opts) + if err != nil { + return err + } + + mount = reading.mount(mount) + + o.sources = append(o.sources, func(options) ([]asset, error) { + if source == nil { + return nil, fmt.Errorf("cannot read templates from a nil repository: %w", ErrTemplateRepo) + } + + read := make([]asset, 0, len(source.assets)) + for _, item := range source.assets { + item.path = path.Join(mount, item.path) + read = append(read, item) + } + + return read, nil + }) + + return nil + } +} + +// FromTemplate registers a single template held in memory, at the address given. +// +// The address locates the template, exactly as written, so it may hold directories and it +// is never mangled: overriding a template declared elsewhere means naming the address it was +// declared at. The key it answers to is derived from the address like any other. +// +// Unlike an asset read from a file system, it is registered whatever its extension. +// +// This is the way to declare a template that no file holds, such as one a configuration +// provides. A caller holding several of them is better served by an in-memory [io/fs.FS] passed +// to [FromFS], which keeps every override going through the same mechanism. +// +// The content is retained, not copied. +func FromTemplate(name string, content []byte, opts ...SourceOption) Option { + return func(o *options) error { + clean, err := cleanAssetName(name) + if err != nil { + return err + } + + reading, err := makeSourceOptions(opts) + if err != nil { + return err + } + + clean = reading.mount(clean) + + o.sources = append(o.sources, func(options) ([]asset, error) { + return []asset{{path: clean, data: content}}, nil + }) + + return nil + } +} + +// resolveSources reads every source declared, in the order it was declared. +// +// Layers are numbered from baseLayer on, so that the assets a [Clone] adds are never mistaken for +// the ones its origin already held. The number of the next free layer is returned along with the +// assets. +func (o options) resolveSources(baseLayer int) ([]asset, int, error) { + var assets []asset + + layer := baseLayer + for _, read := range o.sources { + found, err := read(o) + if err != nil { + return nil, 0, err + } + + for _, item := range found { + item.layer = layer + assets = append(assets, item) + } + + layer++ + } + + return assets, layer, nil +} + +// readFS walks a file system and reads the assets it holds that are recognized as templates. +func readFS(fsys fs.FS, mount string, skipped []string, settings options) ([]asset, error) { + var assets []asset + + err := fs.WalkDir(fsys, ".", func(name string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + + if entry.IsDir() { + if name != "." && slices.Contains(skipped, path.Base(name)) { + return fs.SkipDir + } + + return nil + } + + if !settings.hasSupportedExtension(name) { + return nil + } + + data, err := fs.ReadFile(fsys, name) + if err != nil { + return err + } + + assets = append(assets, asset{path: path.Join(mount, name), data: data}) + + return nil + }) + if err != nil { + return nil, fmt.Errorf("could not read templates: %w: %w", err, ErrTemplateRepo) + } + + return assets, nil +} + +// slashed reads a caller's path the way an address is written, whatever separator they typed. +// +// An [io/fs.FS] always hands over slash-separated names, so only a path written by a caller can +// carry a backslash. Reading it as a separator keeps a repository holding the same addresses on +// every platform, so a template refers to another one the same way everywhere. +func slashed(p string) string { + return strings.ReplaceAll(p, `\`, "/") +} + +// cleanMountPoint validates the place a source is mounted at in the template tree. +// +// An empty mount point, or ".", mounts at the top. Separators are not translated: a mount point +// is a slash-separated path, so that the same declaration yields the same names on every +// platform. +func cleanMountPoint(mountPoint string) (string, error) { + trimmed := strings.Trim(slashed(mountPoint), "/") + if trimmed == "" || trimmed == "." { + return "", nil + } + + clean := path.Clean(trimmed) + if !fs.ValidPath(clean) { + return "", fmt.Errorf("invalid mount point %q: %w", mountPoint, ErrTemplateRepo) + } + + return clean, nil +} + +// cleanAssetName validates the name a template is registered under by [FromTemplate]. +func cleanAssetName(name string) (string, error) { + clean := path.Clean(strings.TrimPrefix(slashed(name), "/")) + if clean == "." || !fs.ValidPath(clean) { + return "", fmt.Errorf("invalid template name %q: %w", name, ErrTemplateRepo) + } + + return clean, nil +} diff --git a/templates-repo/sources_test.go b/templates-repo/sources_test.go new file mode 100644 index 0000000..596e9c8 --- /dev/null +++ b/templates-repo/sources_test.go @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package repo + +import ( + "os" + "path/filepath" + "slices" + "testing" + "testing/fstest" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// makeTemplateDir writes a few templates in a temporary directory and returns its path. +func makeTemplateDir(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "validation"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "model.gotmpl"), []byte("model"), 0o600)) + require.NoError(t, + os.WriteFile(filepath.Join(dir, "validation", "primitive.gotmpl"), []byte("primitive"), 0o600), + ) + + return dir +} + +func TestFromFS(t *testing.T) { + assets := fstest.MapFS{ + "primitive.gotmpl": {Data: []byte("primitive")}, + "format.gotmpl": {Data: []byte("format")}, + } + + t.Run("should mount at the top of the tree", func(t *testing.T) { + for _, mount := range []string{"", ".", "/"} { + r, err := New(FromFS(assets, mount)) + require.NoErrorf(t, err, "mount point %q", mount) + + assert.Equal(t, []string{"format", "primitive"}, slices.Collect(r.Names())) + } + }) + + t.Run("should mount under a directory of the tree", func(t *testing.T) { + r, err := New(FromFS(assets, "validation")) + require.NoError(t, err) + + assert.Equal(t, []string{"validationFormat", "validationPrimitive"}, slices.Collect(r.Names())) + }) + + t.Run("should mount several sources at different places", func(t *testing.T) { + r, err := New( + FromFS(assets, "validation"), + FromFS(fstest.MapFS{"primitive.gotmpl": {Data: []byte("client")}}, "client"), + ) + require.NoError(t, err) + + assert.Equal(t, + []string{"clientPrimitive", "validationFormat", "validationPrimitive"}, + slices.Collect(r.Names()), + ) + assert.Equal(t, "client", render(t, r, "clientPrimitive")) + assert.Equal(t, "primitive", render(t, r, "validationPrimitive")) + }) + + t.Run("should report a nil file system", func(t *testing.T) { + _, err := New(FromFS(nil, "")) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrTemplateRepo) + }) + + t.Run("should report an invalid mount point", func(t *testing.T) { + _, err := New(FromFS(assets, "../escape")) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrTemplateRepo) + }) +} + +func TestFromDir(t *testing.T) { + dir := makeTemplateDir(t) + + t.Run("should read a local directory", func(t *testing.T) { + r, err := New(FromDir(dir, "")) + require.NoError(t, err) + + assert.Equal(t, []string{"model", "validationPrimitive"}, slices.Collect(r.Names())) + assert.Equal(t, "primitive", render(t, r, "validationPrimitive")) + }) + + t.Run("should mount a local directory under the tree", func(t *testing.T) { + r, err := New(FromDir(dir, "local")) + require.NoError(t, err) + + assert.Equal(t, []string{"localModel", "localValidationPrimitive"}, slices.Collect(r.Names())) + }) + + t.Run("should read the directory once, when the repository is built", func(t *testing.T) { + r, err := New(FromDir(dir, "")) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "model.gotmpl"), []byte("edited"), 0o600)) + + assert.Equal(t, "model", render(t, r, "model"), "the repository holds what it read") + + fresh, err := New(FromDir(dir, "")) + require.NoError(t, err) + assert.Equal(t, "edited", render(t, fresh, "model"), "a new repository reads the directory again") + }) + + t.Run("should report a directory it cannot read", func(t *testing.T) { + _, err := New(FromDir(filepath.Join(dir, "nowhere"), "")) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrTemplateRepo) + }) + + t.Run("should report a path that is not a directory", func(t *testing.T) { + _, err := New(FromDir(filepath.Join(dir, "model.gotmpl"), "")) + + require.Error(t, err) + assert.ErrorContains(t, err, "is not a directory") + }) +} + +func TestFromTemplate(t *testing.T) { + t.Run("should register a template held in memory", func(t *testing.T) { + r, err := New(FromTemplate("model.gotmpl", []byte("model"))) + require.NoError(t, err) + + assert.Equal(t, []string{"model"}, slices.Collect(r.Names())) + }) + + t.Run("should name a path like any other asset", func(t *testing.T) { + r, err := New(FromTemplate("validation/primitive.gotmpl", []byte("primitive"))) + require.NoError(t, err) + + assert.Equal(t, []string{"validationPrimitive"}, slices.Collect(r.Names())) + }) + + t.Run("should register whatever the extension", func(t *testing.T) { + r, err := New(FromTemplate("model", []byte("model"))) + require.NoError(t, err) + + assert.Equal(t, []string{"model"}, slices.Collect(r.Names())) + }) + + t.Run("should report an invalid name", func(t *testing.T) { + for _, name := range []string{"", "/", "../escape", "."} { + _, err := New(FromTemplate(name, []byte("nope"))) + + require.Errorf(t, err, "name %q", name) + assert.ErrorIs(t, err, ErrTemplateRepo) + } + }) + + t.Run("should report a template that does not parse", func(t *testing.T) { + _, err := New(FromTemplate("broken.gotmpl", []byte(`{{ if }}`))) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrTemplateRepo) + assert.ErrorContains(t, err, "broken.gotmpl") + }) +} + +func TestWithExtensions(t *testing.T) { + assets := fstest.MapFS{ + "model.gotmpl": {Data: []byte("gotmpl")}, + "other.tmpl": {Data: []byte("tmpl")}, + } + + t.Run("should recognize only the declared extensions", func(t *testing.T) { + r, err := New(FromFS(assets, ""), WithExtensions(".tmpl")) + require.NoError(t, err) + + assert.Equal(t, []string{"other"}, slices.Collect(r.Names())) + }) + + t.Run("should recognize several extensions", func(t *testing.T) { + r, err := New(FromFS(assets, ""), WithExtensions(".gotmpl", ".tmpl")) + require.NoError(t, err) + + assert.Equal(t, []string{"model", "other"}, slices.Collect(r.Names())) + }) + + t.Run("should report an empty list of extensions", func(t *testing.T) { + _, err := New(FromFS(assets, ""), WithExtensions()) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrTemplateRepo) + }) +} + +func TestTemplateName(t *testing.T) { + settings, err := makeOptions(nil) + require.NoError(t, err) + + // the paths of the template set this package was extracted for, plus a few shapes around them + for _, testCase := range []struct { + assetPath string + expected string + }{ + {"model.gotmpl", "model"}, + {"validation/primitive.gotmpl", "validationPrimitive"}, + {"server/parameter.gotmpl", "serverParameter"}, + {"validation/multipleOf.gotmpl", "validationMultipleOf"}, + {"swagger_json_embed.gotmpl", "swaggerJsonEmbed"}, + {"contrib/stratoscale/client/client.gotmpl", "contribStratoscaleClientClient"}, + {"serializers/additionalpropertiesserializer.gotmpl", "serializersAdditionalpropertiesserializer"}, + {"simpleschema/defaultsinit.gotmpl", "simpleschemaDefaultsinit"}, + {"some-kebab/name.gotmpl", "someKebabName"}, + {"http/api_v2.gotmpl", "httpApiV2"}, + {"no-extension", "noExtension"}, + } { + assert.Equalf(t, testCase.expected, settings.templateName(testCase.assetPath), + "naming %q", testCase.assetPath) + } +} diff --git a/templates-repo/template.go b/templates-repo/template.go new file mode 100644 index 0000000..bfbe8dc --- /dev/null +++ b/templates-repo/template.go @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package repo + +import ( + "fmt" + "io" + "text/template" +) + +// Template is a compiled template, resolved against every other template of its [Repository]. +// +// It is obtained from [Repository.Get] and cannot be built otherwise. The zero value reports an +// empty name and fails to execute. +// +// A [Template] exposes execution and nothing else, on purpose: the methods of a +// [text/template.Template] that alter a template would alter what the repository serves, for +// every holder of it. There is no ExecuteTemplate either, since resolving a name is the job of +// [Repository.Get]. +// +// # Concurrency +// +// A [Template] is immutable and may be executed concurrently. Concurrent executions sharing a +// single [io.Writer] interleave their output, as they do with [text/template.Template]. +type Template struct { + tpl *template.Template +} + +// Name returns the name the template is registered under, or an empty string for the zero value. +func (t Template) Name() string { + if t.tpl == nil { + return "" + } + + return t.tpl.Name() +} + +// Execute applies the template to data and writes the result to w. +// +// A template that refers to another one resolves it in the repository the template comes from. +// The zero [Template] reports an error. +func (t Template) Execute(w io.Writer, data any) error { + if t.tpl == nil { + return fmt.Errorf("zero template cannot be executed: %w", ErrTemplateRepo) + } + + if err := t.tpl.Execute(w, data); err != nil { + return fmt.Errorf("executing template %q: %w: %w", t.tpl.Name(), err, ErrTemplateRepo) + } + + return nil +}