initial commit
This commit is contained in:
commit
23816d00f7
71 changed files with 14497 additions and 0 deletions
728
backend/docgen/docgen.go
Normal file
728
backend/docgen/docgen.go
Normal file
|
|
@ -0,0 +1,728 @@
|
|||
// Package docgen analyses a Go project and renders audience-segmented
|
||||
// documentation for go-doc.
|
||||
//
|
||||
// The pipeline is deliberately mechanical: Analyze walks a project tree and
|
||||
// extracts facts that can be read off the filesystem (module metadata, HTTP
|
||||
// routes, CLI flags, environment variables, Make targets, container services,
|
||||
// Helm charts, existing Markdown, language breakdown), and Render turns those
|
||||
// facts into the same three-audience layout go-doc uses natively:
|
||||
//
|
||||
// ## For Users what the thing does and how to use it
|
||||
// ## For Operators running, configuring and deploying it
|
||||
// ## For Developers contracts, layout and dependencies
|
||||
//
|
||||
// Import wraps the rendered page in generated markers so re-running the tool
|
||||
// refreshes only the machine-written region and never clobbers prose a human
|
||||
// added outside it.
|
||||
package docgen
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxReadBytes caps how much of any single file is read into memory.
|
||||
const maxReadBytes = 2 << 20
|
||||
|
||||
// maxWalkFiles caps the walk so a mis-pointed root cannot spin forever.
|
||||
const maxWalkFiles = 200000
|
||||
|
||||
// Route is one HTTP endpoint discovered in the source.
|
||||
type Route struct {
|
||||
Method string
|
||||
Path string
|
||||
Handler string
|
||||
}
|
||||
|
||||
// String renders the route the way it is registered.
|
||||
func (r Route) String() string {
|
||||
if r.Method == "" {
|
||||
return r.Path
|
||||
}
|
||||
return r.Method + " " + r.Path
|
||||
}
|
||||
|
||||
// SortKey orders routes by path, then method, so output is stable.
|
||||
func (r Route) SortKey() string { return r.Path + "\x00" + r.Method + "\x00" + r.Handler }
|
||||
|
||||
// Flag is a command-line flag declared with the flag package.
|
||||
type Flag struct {
|
||||
Name string
|
||||
Type string
|
||||
Default string
|
||||
Usage string
|
||||
}
|
||||
|
||||
// EnvVar is an environment variable the code reads, plus a note when it was
|
||||
// discovered in a .env.example file rather than in source.
|
||||
type EnvVar struct {
|
||||
Name string
|
||||
Example string
|
||||
FromEnv bool
|
||||
}
|
||||
|
||||
// Dependency is one requirement line from a go.mod.
|
||||
type Dependency struct {
|
||||
Path string
|
||||
Version string
|
||||
Indirect bool
|
||||
}
|
||||
|
||||
// Module is one go.mod file found in the tree.
|
||||
type Module struct {
|
||||
Path string
|
||||
File string
|
||||
GoVersion string
|
||||
Direct []Dependency
|
||||
Indirect int
|
||||
}
|
||||
|
||||
// ComposeService is a service declared in a docker-compose file.
|
||||
type ComposeService struct {
|
||||
Name string
|
||||
Image string
|
||||
Ports string
|
||||
File string
|
||||
}
|
||||
|
||||
// DockerInfo summarises the project's Dockerfile.
|
||||
type DockerInfo struct {
|
||||
File string
|
||||
Stages []string
|
||||
Bases []string
|
||||
Exposed []string
|
||||
}
|
||||
|
||||
// DocFile is an existing Markdown document that ships with the project.
|
||||
type DocFile struct {
|
||||
Path string
|
||||
Title string
|
||||
Bytes int
|
||||
}
|
||||
|
||||
// Dir is a first-level source directory with a purpose inferred from its name.
|
||||
type Dir struct {
|
||||
Name string
|
||||
Files int
|
||||
Lines int
|
||||
}
|
||||
|
||||
// Language is a per-extension line count.
|
||||
type Language struct {
|
||||
Name string
|
||||
Files int
|
||||
Lines int
|
||||
}
|
||||
|
||||
// Stats are the headline counters shown in the generated page.
|
||||
type Stats struct {
|
||||
GoFiles int
|
||||
TestFiles int
|
||||
TestFuncs int
|
||||
Components int
|
||||
TotalLines int
|
||||
OldestCommit string
|
||||
}
|
||||
|
||||
// Project is the full analysis result for one repository.
|
||||
type Project struct {
|
||||
Name string
|
||||
Path string
|
||||
Summary string
|
||||
Readme string
|
||||
|
||||
Modules []Module
|
||||
Routes []Route
|
||||
Flags []Flag
|
||||
EnvVars []EnvVar
|
||||
MakeTargets []string
|
||||
Compose []ComposeService
|
||||
Docker *DockerInfo
|
||||
HelmCharts []string
|
||||
Docs []DocFile
|
||||
Dirs []Dir
|
||||
Languages []Language
|
||||
Ports []string
|
||||
Stats Stats
|
||||
|
||||
// Signature changes whenever any analysed input changes. Watch mode uses it
|
||||
// to decide whether a regeneration is needed.
|
||||
Signature string
|
||||
}
|
||||
|
||||
// Analyze walks root and extracts everything Render needs.
|
||||
//
|
||||
// A missing or partial project is not an error: a repository without a go.mod
|
||||
// or without a README still yields a usable page describing whatever was found.
|
||||
func Analyze(name, root string) (*Project, error) {
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docgen: stat %s: %w", root, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("docgen: %s is not a directory", root)
|
||||
}
|
||||
|
||||
p := &Project{Name: name, Path: root}
|
||||
|
||||
files, err := collectFiles(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Signature = signature(files)
|
||||
|
||||
for _, file := range files {
|
||||
rel := file.rel
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(rel, ".go"):
|
||||
p.Stats.GoFiles++
|
||||
p.Stats.TotalLines += file.lines
|
||||
if strings.HasSuffix(rel, "_test.go") {
|
||||
p.Stats.TestFiles++
|
||||
p.Stats.TestFuncs += countTestFuncs(file.text())
|
||||
}
|
||||
if isComponentPath(rel) {
|
||||
p.Stats.Components++
|
||||
}
|
||||
analyzeGoFile(p, file)
|
||||
|
||||
case filepath.Base(rel) == "go.mod":
|
||||
p.Modules = append(p.Modules, parseGoMod(rel, file.text()))
|
||||
|
||||
case isMarkdown(rel):
|
||||
p.Docs = append(p.Docs, DocFile{
|
||||
Path: rel,
|
||||
Title: markdownTitle(file.text()),
|
||||
Bytes: int(file.size),
|
||||
})
|
||||
|
||||
case filepath.Base(rel) == "Makefile" || filepath.Base(rel) == "makefile":
|
||||
p.MakeTargets = append(p.MakeTargets, makeTargets(file.text())...)
|
||||
|
||||
case isDockerfile(rel):
|
||||
if p.Docker == nil {
|
||||
p.Docker = parseDockerfile(rel, file.text())
|
||||
}
|
||||
|
||||
case isComposeFile(rel):
|
||||
p.Compose = append(p.Compose, parseCompose(rel, file.text())...)
|
||||
|
||||
case strings.HasPrefix(filepath.Base(rel), ".env"):
|
||||
p.EnvVars = append(p.EnvVars, envFromExample(file.text())...)
|
||||
|
||||
case isHelmFile(rel):
|
||||
p.HelmCharts = append(p.HelmCharts, chartRoot(rel))
|
||||
}
|
||||
|
||||
p.Languages = addLanguage(p.Languages, file)
|
||||
if portBearingFiles(rel) {
|
||||
p.Ports = append(p.Ports, portsIn(file.text())...)
|
||||
}
|
||||
}
|
||||
|
||||
finish(p)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func finish(p *Project) {
|
||||
p.Readme = findReadme(p)
|
||||
p.Summary = summarize(p)
|
||||
|
||||
p.Modules = dedupeModules(p.Modules)
|
||||
p.Routes = dedupeRoutes(p.Routes)
|
||||
p.Flags = dedupeFlags(p.Flags)
|
||||
p.EnvVars = dedupeEnv(p.EnvVars)
|
||||
p.MakeTargets = dedupeStrings(p.MakeTargets)
|
||||
p.HelmCharts = dedupeStrings(p.HelmCharts)
|
||||
p.Ports = dedupeStrings(p.Ports)
|
||||
p.Dirs = topDirs(p)
|
||||
p.Languages = rankLanguages(p.Languages)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Walking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type scannedFile struct {
|
||||
path string
|
||||
rel string
|
||||
size int64
|
||||
lines int
|
||||
}
|
||||
|
||||
func (f scannedFile) text() string {
|
||||
data, err := readCapped(f.path, maxReadBytes)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// ignoredDirs never contain first-party source worth documenting.
|
||||
var ignoredDirs = map[string]struct{}{
|
||||
"node_modules": {}, ".git": {}, ".idea": {}, ".vscode": {}, "dist": {},
|
||||
"build": {}, "bin": {}, "vendor": {}, "coverage": {}, "tmp": {},
|
||||
".cache": {}, ".next": {}, "target": {}, "__pycache__": {}, ".venv": {},
|
||||
}
|
||||
|
||||
func collectFiles(root string) ([]scannedFile, error) {
|
||||
var files []scannedFile
|
||||
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// An unreadable subtree must not abort the whole analysis.
|
||||
if entry != nil && entry.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(files) >= maxWalkFiles {
|
||||
return fs.SkipAll
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
if entry.IsDir() {
|
||||
if _, skip := ignoredDirs[name]; skip && path != root {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !entry.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
if isNoise(name) {
|
||||
return nil
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
rel = name
|
||||
}
|
||||
files = append(files, scannedFile{
|
||||
path: path,
|
||||
rel: filepath.ToSlash(rel),
|
||||
size: info.Size(),
|
||||
lines: countLinesFast(path),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docgen: walk %s: %w", root, err)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// isNoise filters artefacts that would pollute the analysis.
|
||||
func isNoise(name string) bool {
|
||||
switch filepath.Ext(name) {
|
||||
case ".log", ".lock", ".sum", ".pem", ".crt", ".key", ".png", ".jpg", ".jpeg",
|
||||
".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".zip", ".tar",
|
||||
".gz", ".mp4", ".sql.gz", ".db", ".sqlite", ".pyc":
|
||||
return true
|
||||
}
|
||||
// Certificates and keys are frequently committed without extensions.
|
||||
switch {
|
||||
case strings.HasSuffix(name, ".crt"), strings.HasSuffix(name, ".key"):
|
||||
return true
|
||||
case strings.HasSuffix(name, "-chain.crt"), strings.HasSuffix(name, "-root-ca.crt"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isMarkdown(rel string) bool {
|
||||
return strings.EqualFold(filepath.Ext(rel), ".md")
|
||||
}
|
||||
|
||||
func isDockerfile(rel string) bool {
|
||||
base := filepath.Base(rel)
|
||||
return base == "Dockerfile" || strings.HasPrefix(base, "Dockerfile.")
|
||||
}
|
||||
|
||||
func isComposeFile(rel string) bool {
|
||||
base := strings.ToLower(filepath.Base(rel))
|
||||
if !strings.HasPrefix(base, "docker-compose") && base != "compose.yaml" && base != "compose.yml" {
|
||||
return false
|
||||
}
|
||||
return strings.HasSuffix(base, ".yml") || strings.HasSuffix(base, ".yaml")
|
||||
}
|
||||
|
||||
func isHelmChart(rel string) bool {
|
||||
parts := strings.Split(rel, "/")
|
||||
for i, part := range parts {
|
||||
if part == "helm" || part == "charts" {
|
||||
// A chart is a directory that owns a values.yaml or Chart.yaml.
|
||||
if i+1 < len(parts) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// chartRoot collapses a chart file path to the directory that owns the chart,
|
||||
// so the page lists one entry per chart rather than one per template.
|
||||
func chartRoot(rel string) string {
|
||||
dir := path.Dir(rel)
|
||||
if dir == "." {
|
||||
return "./ (repository root)"
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func isComponentPath(rel string) bool {
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
switch parts[len(parts)-2] {
|
||||
case "internal", "pkg", "cmd", "api", "services", "web", "ui", "frontend", "backend":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readCapped(path string, limit int64) ([]byte, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
return io.ReadAll(io.LimitReader(file, limit))
|
||||
}
|
||||
|
||||
func countLinesFast(path string) int {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Skip .md / .json / .lock style files where line counts are meaningless.
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".md", ".json", ".yaml", ".yml", ".txt", ".lock", ".css", ".html":
|
||||
return 0
|
||||
}
|
||||
|
||||
buf := make([]byte, 32<<10)
|
||||
lines := 0
|
||||
for {
|
||||
n, err := file.Read(buf)
|
||||
if n > 0 {
|
||||
for _, b := range buf[:n] {
|
||||
if b == '\n' {
|
||||
lines++
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// signature hashes the identity of every analysed file. It deliberately uses
|
||||
// size and mtime rather than content so watch mode can poll large trees cheaply.
|
||||
func signature(files []scannedFile) string {
|
||||
hash := sha256.New()
|
||||
for _, file := range files {
|
||||
info, err := os.Stat(file.path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(hash, "%s|%d|%d\n", file.rel, file.size, info.ModTime().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil))[:32]
|
||||
}
|
||||
|
||||
func countTestFuncs(source string) int {
|
||||
return strings.Count(source, "func Test") + strings.Count(source, "func Fuzz") +
|
||||
strings.Count(source, "func Benchmark")
|
||||
}
|
||||
|
||||
// noiseExtensions are file types that carry no language signal: build
|
||||
// metadata, ignore lists and editor configuration all have extensions that would
|
||||
// otherwise show up as their own "language".
|
||||
var noiseExtensions = map[string]struct{}{
|
||||
"mod": {}, "sum": {}, "lock": {}, "gitignore": {}, "dockerignore": {},
|
||||
"helmignore": {}, "npmignore": {}, "eslintignore": {}, "gitattributes": {},
|
||||
"env": {},
|
||||
"editorconfig": {}, "prettierignore": {}, "example": {}, "template": {},
|
||||
}
|
||||
|
||||
func addLanguage(langs []Language, file scannedFile) []Language {
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(file.path), "."))
|
||||
if ext == "" {
|
||||
return langs
|
||||
}
|
||||
if _, skip := noiseExtensions[ext]; skip {
|
||||
return langs
|
||||
}
|
||||
name := languageName(ext)
|
||||
lines := file.lines
|
||||
if lines == 0 {
|
||||
lines = countAllLinesFast(file.path)
|
||||
}
|
||||
for i := range langs {
|
||||
if langs[i].Name == name {
|
||||
langs[i].Files++
|
||||
langs[i].Lines += lines
|
||||
return langs
|
||||
}
|
||||
}
|
||||
return append(langs, Language{Name: name, Files: 1, Lines: lines})
|
||||
}
|
||||
|
||||
func countAllLinesFast(path string) int {
|
||||
data, err := readCapped(path, maxReadBytes)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
lines := strings.Count(string(data), "\n")
|
||||
if len(data) > 0 && !strings.HasSuffix(string(data), "\n") {
|
||||
lines++
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func languageName(ext string) string {
|
||||
switch ext {
|
||||
case "go":
|
||||
return "Go"
|
||||
case "js", "mjs", "cjs":
|
||||
return "JavaScript"
|
||||
case "jsx":
|
||||
return "JSX"
|
||||
case "ts", "tsx":
|
||||
return "TypeScript"
|
||||
case "css":
|
||||
return "CSS"
|
||||
case "html", "htm":
|
||||
return "HTML"
|
||||
case "sh", "bash":
|
||||
return "Shell"
|
||||
case "sql":
|
||||
return "SQL"
|
||||
case "yml", "yaml":
|
||||
return "YAML"
|
||||
case "json":
|
||||
return "JSON"
|
||||
case "md":
|
||||
return "Markdown"
|
||||
case "tf":
|
||||
return "Terraform"
|
||||
case "proto":
|
||||
return "Protobuf"
|
||||
case "tpl", "gotmpl":
|
||||
return "Templates"
|
||||
case "py":
|
||||
return "Python"
|
||||
default:
|
||||
return ext
|
||||
}
|
||||
}
|
||||
|
||||
func rankLanguages(langs []Language) []Language {
|
||||
sort.Slice(langs, func(i, j int) bool {
|
||||
if langs[i].Lines == langs[j].Lines {
|
||||
return langs[i].Name < langs[j].Name
|
||||
}
|
||||
return langs[i].Lines > langs[j].Lines
|
||||
})
|
||||
// A page does not benefit from a tail of one-file languages.
|
||||
if len(langs) > maxLanguages {
|
||||
langs = langs[:maxLanguages]
|
||||
}
|
||||
return langs
|
||||
}
|
||||
|
||||
// maxLanguages caps the language table on a generated page.
|
||||
const maxLanguages = 10
|
||||
|
||||
func topDirs(p *Project) []Dir {
|
||||
byName := map[string]*Dir{}
|
||||
err := filepath.WalkDir(p.Path, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if path != p.Path {
|
||||
if _, skip := ignoredDirs[entry.Name()]; skip {
|
||||
return fs.SkipDir
|
||||
}
|
||||
// Dot directories hold tooling, not first-party source.
|
||||
if strings.HasPrefix(entry.Name(), ".") {
|
||||
return fs.SkipDir
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(p.Path, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(rel), "/")
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
dir := byName[parts[0]]
|
||||
if dir == nil {
|
||||
dir = &Dir{Name: parts[0]}
|
||||
byName[parts[0]] = dir
|
||||
}
|
||||
dir.Files++
|
||||
dir.Lines += countLinesFast(path)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
dirs := make([]Dir, 0, len(byName))
|
||||
for _, dir := range byName {
|
||||
dirs = append(dirs, *dir)
|
||||
}
|
||||
sort.Slice(dirs, func(i, j int) bool {
|
||||
if dirs[i].Files == dirs[j].Files {
|
||||
return dirs[i].Name < dirs[j].Name
|
||||
}
|
||||
return dirs[i].Files > dirs[j].Files
|
||||
})
|
||||
if len(dirs) > 14 {
|
||||
dirs = dirs[:14]
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func dedupeStrings(in []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(in))
|
||||
for _, value := range in {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[value]; dup {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupeInOrder removes duplicates while preserving first-seen order, for lists
|
||||
// whose order is meaningful (Docker build stages, published ports).
|
||||
func dedupeInOrder(in []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(in))
|
||||
for _, value := range in {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[value]; dup {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dedupeModules(mods []Module) []Module {
|
||||
sort.Slice(mods, func(i, j int) bool { return mods[i].File < mods[j].File })
|
||||
return mods
|
||||
}
|
||||
|
||||
// dedupeRoutes collapses registrations that describe the same endpoint. The
|
||||
// key is method plus path, because one endpoint is often registered in several
|
||||
// places (a group helper plus the router); when several carry a handler name,
|
||||
// the one with a handler wins over the one without.
|
||||
func dedupeRoutes(routes []Route) []Route {
|
||||
best := make(map[string]Route, len(routes))
|
||||
for _, route := range routes {
|
||||
if route.Path == "" {
|
||||
continue
|
||||
}
|
||||
key := route.Method + " " + route.Path
|
||||
existing, seen := best[key]
|
||||
switch {
|
||||
case !seen:
|
||||
best[key] = route
|
||||
case existing.Handler == "" && route.Handler != "":
|
||||
best[key] = route
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]Route, 0, len(best))
|
||||
for _, route := range best {
|
||||
out = append(out, route)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Path == out[j].Path {
|
||||
return out[i].Method < out[j].Method
|
||||
}
|
||||
return out[i].Path < out[j].Path
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func dedupeFlags(flags []Flag) []Flag {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]Flag, 0, len(flags))
|
||||
for _, flag := range flags {
|
||||
if _, dup := seen[flag.Name]; dup {
|
||||
continue
|
||||
}
|
||||
seen[flag.Name] = struct{}{}
|
||||
out = append(out, flag)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
func dedupeEnv(vars []EnvVar) []EnvVar {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]EnvVar, 0, len(vars))
|
||||
for _, v := range vars {
|
||||
if _, dup := seen[v.Name]; dup {
|
||||
continue
|
||||
}
|
||||
seen[v.Name] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// Timestamp is used inside generated markers so a reader can tell how fresh a
|
||||
// machine-written region is.
|
||||
func Timestamp() string { return time.Now().UTC().Format("2006-01-02 15:04 UTC") }
|
||||
Loading…
Add table
Add a link
Reference in a new issue