go-forge/cmd/forgegen/generator.go
Virgil 4b33c1b71c
Some checks are pending
Test / test (push) Waiting to run
Security Scan / security (push) Successful in 13s
docs(forgegen): expand generated usage examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 08:37:32 +00:00

418 lines
11 KiB
Go

package main
import (
"bytes"
"cmp"
"maps"
"slices"
"strconv"
"strings"
"text/template"
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
)
// typeGrouping maps type name prefixes to output file names.
var typeGrouping = map[string]string{
"Repository": "repo",
"Repo": "repo",
"Issue": "issue",
"PullRequest": "pr",
"Pull": "pr",
"User": "user",
"Organization": "org",
"Org": "org",
"Team": "team",
"Label": "label",
"Milestone": "milestone",
"Release": "release",
"Tag": "tag",
"Branch": "branch",
"Hook": "hook",
"Deploy": "key",
"PublicKey": "key",
"GPGKey": "key",
"Key": "key",
"Notification": "notification",
"Package": "package",
"Action": "action",
"Commit": "commit",
"Git": "git",
"Contents": "content",
"File": "content",
"Wiki": "wiki",
"Comment": "comment",
"Review": "review",
"Reaction": "reaction",
"Topic": "topic",
"Status": "status",
"Combined": "status",
"Cron": "admin",
"Quota": "quota",
"OAuth2": "oauth",
"AccessToken": "oauth",
"API": "error",
"Forbidden": "error",
"NotFound": "error",
"NodeInfo": "federation",
"Activity": "activity",
"Feed": "activity",
"StopWatch": "time_tracking",
"TrackedTime": "time_tracking",
"Blocked": "user",
"Email": "user",
"Settings": "settings",
"GeneralAPI": "settings",
"GeneralAttachment": "settings",
"GeneralRepo": "settings",
"GeneralUI": "settings",
"Markdown": "misc",
"Markup": "misc",
"License": "misc",
"Gitignore": "misc",
"Annotated": "git",
"Note": "git",
"ChangedFile": "git",
"ExternalTracker": "repo",
"ExternalWiki": "repo",
"InternalTracker": "repo",
"Permission": "common",
"RepoTransfer": "repo",
"PayloadCommit": "hook",
"Dispatch": "action",
"Secret": "action",
"Variable": "action",
"Push": "repo",
"Mirror": "repo",
"Attachment": "common",
"EditDeadline": "issue",
"IssueDeadline": "issue",
"IssueLabels": "issue",
"IssueMeta": "issue",
"IssueTemplate": "issue",
"StateType": "common",
"TimeStamp": "common",
"Rename": "admin",
"Unadopted": "admin",
}
// classifyType determines which output file a type belongs to.
// It checks for a direct match first, then tries prefix matching,
// then strips Create/Edit/Delete/Update prefixes and Option suffix
// to find the base type's grouping.
func classifyType(name string) string {
// Direct match.
if group, ok := typeGrouping[name]; ok {
return group
}
// Prefix match — longest prefix wins.
bestKey := ""
bestGroup := ""
for key, group := range typeGrouping {
if core.HasPrefix(name, key) && len(key) > len(bestKey) {
bestKey = key
bestGroup = group
}
}
if bestGroup != "" {
return bestGroup
}
// Strip CRUD prefixes and Option suffix, then retry.
base := name
for _, prefix := range []string{"Create", "Edit", "Delete", "Update", "Add", "Submit", "Replace", "Set", "Transfer"} {
base = core.TrimPrefix(base, prefix)
}
base = core.TrimSuffix(base, "Option")
base = core.TrimSuffix(base, "Options")
if base != name && base != "" {
if group, ok := typeGrouping[base]; ok {
return group
}
// Prefix match on the stripped base.
bestKey = ""
bestGroup = ""
for key, group := range typeGrouping {
if core.HasPrefix(base, key) && len(key) > len(bestKey) {
bestKey = key
bestGroup = group
}
}
if bestGroup != "" {
return bestGroup
}
}
return "misc"
}
// sanitiseLine collapses a multi-line string into a single line,
// replacing newlines and consecutive whitespace with a single space.
func sanitiseLine(s string) string {
return core.Join(" ", splitFields(s)...)
}
// enumConstName generates a Go constant name for an enum value.
func enumConstName(typeName, value string) string {
return typeName + pascalCase(value)
}
// templateFuncs provides helper functions for the file template.
var templateFuncs = template.FuncMap{
"sanitise": sanitiseLine,
"enumConstName": enumConstName,
}
// fileHeader is the template for generating a single Go source file.
var fileHeader = template.Must(template.New("file").Funcs(templateFuncs).Parse(`// Code generated by forgegen from swagger.v1.json — DO NOT EDIT.
package types
{{if .NeedTime}}
import "time"
{{end}}
{{range .Types}}{{$t := .}}
{{- if .Description}}
// {{.Name}}{{sanitise .Description}}
{{- end}}
{{- if .Usage}}
//
// Usage:
//
// opts := {{.Usage}}
{{- end}}
{{- if .IsEnum}}
type {{.Name}} string
const (
{{- range .EnumValues}}
{{enumConstName $t.Name .}} {{$t.Name}} = "{{.}}"
{{- end}}
)
{{- else if .IsAlias}}
type {{.Name}} {{.AliasType}}
{{- else if (eq (len .Fields) 0)}}
// {{.Name}} has no fields in the swagger spec.
type {{.Name}} struct{}
{{- else}}
type {{.Name}} struct {
{{- range .Fields}}
{{.GoName}} {{.GoType}} ` + "`" + `json:"{{.JSONName}}{{if not .Required}},omitempty{{end}}"` + "`" + `{{if .Comment}} // {{sanitise .Comment}}{{end}}
{{- end}}
}
{{- end}}
{{end}}
`))
// templateData is the data passed to the file template.
type templateData struct {
NeedTime bool
Types []*GoType
}
// Generate writes Go source files for the extracted types, grouped by logical domain.
//
// Usage:
//
// err := Generate(types, pairs, "types")
// _ = err
func Generate(types map[string]*GoType, pairs []CRUDPair, outDir string) error {
if err := coreio.Local.EnsureDir(outDir); err != nil {
return core.E("Generate", "create output directory", err)
}
populateUsageExamples(types)
// Group types by output file.
groups := make(map[string][]*GoType)
for _, gt := range types {
file := classifyType(gt.Name)
groups[file] = append(groups[file], gt)
}
// Sort types within each group for deterministic output.
for file := range groups {
slices.SortFunc(groups[file], func(a, b *GoType) int {
return cmp.Compare(a.Name, b.Name)
})
}
// Write each group to its own file.
fileNames := slices.Collect(maps.Keys(groups))
slices.Sort(fileNames)
for _, file := range fileNames {
outPath := core.JoinPath(outDir, file+".go")
if err := writeFile(outPath, groups[file]); err != nil {
return core.E("Generate", "write "+outPath, err)
}
}
return nil
}
func populateUsageExamples(types map[string]*GoType) {
for _, gt := range types {
gt.Usage = usageExample(gt)
}
}
func usageExample(gt *GoType) string {
switch {
case gt.IsEnum && len(gt.EnumValues) > 0:
return enumConstName(gt.Name, gt.EnumValues[0])
case gt.IsAlias:
return gt.Name + "(" + exampleTypeExpression(gt.AliasType) + ")"
default:
example := exampleTypeLiteral(gt)
if example == "" {
example = gt.Name + "{}"
}
return example
}
}
func exampleTypeLiteral(gt *GoType) string {
if len(gt.Fields) == 0 {
return gt.Name + "{}"
}
field := chooseUsageField(gt.Fields)
if field.GoName == "" {
return gt.Name + "{}"
}
return gt.Name + "{" + field.GoName + ": " + exampleValue(field) + "}"
}
func exampleTypeExpression(typeName string) string {
switch {
case typeName == "string":
return strconv.Quote("example")
case typeName == "bool":
return "true"
case typeName == "int", typeName == "int32", typeName == "int64", typeName == "uint", typeName == "uint32", typeName == "uint64":
return "1"
case typeName == "float32", typeName == "float64":
return "1.0"
case typeName == "time.Time":
return "time.Now()"
case core.HasPrefix(typeName, "[]string"):
return "[]string{\"example\"}"
case core.HasPrefix(typeName, "[]int64"):
return "[]int64{1}"
case core.HasPrefix(typeName, "[]int"):
return "[]int{1}"
case core.HasPrefix(typeName, "map["):
return typeName + "{\"key\": \"value\"}"
default:
return typeName + "{}"
}
}
func chooseUsageField(fields []GoField) GoField {
best := fields[0]
bestScore := usageFieldScore(best)
for _, field := range fields[1:] {
score := usageFieldScore(field)
if score < bestScore || (score == bestScore && field.GoName < best.GoName) {
best = field
bestScore = score
}
}
return best
}
func usageFieldScore(field GoField) int {
score := 100
if field.Required {
score -= 50
}
switch {
case core.HasSuffix(field.GoType, "string"):
score -= 30
case core.Contains(field.GoType, "time.Time"):
score -= 25
case core.HasSuffix(field.GoType, "bool"):
score -= 20
case core.Contains(field.GoType, "int"):
score -= 15
case core.HasPrefix(field.GoType, "[]"):
score -= 10
}
if core.Contains(field.GoName, "Name") || core.Contains(field.GoName, "Title") || core.Contains(field.GoName, "Body") || core.Contains(field.GoName, "Description") {
score -= 10
}
return score
}
func exampleValue(field GoField) string {
switch {
case core.HasPrefix(field.GoType, "*"):
return "&" + core.TrimPrefix(field.GoType, "*") + "{}"
case field.GoType == "string":
return exampleStringValue(field.GoName)
case field.GoType == "time.Time":
return "time.Now()"
case field.GoType == "bool":
return "true"
case core.HasSuffix(field.GoType, "int64"), core.HasSuffix(field.GoType, "int"), core.HasSuffix(field.GoType, "uint64"), core.HasSuffix(field.GoType, "uint"):
return "1"
case core.HasPrefix(field.GoType, "[]string"):
return "[]string{\"example\"}"
case core.HasPrefix(field.GoType, "[]int64"):
return "[]int64{1}"
case core.HasPrefix(field.GoType, "[]int"):
return "[]int{1}"
case core.HasPrefix(field.GoType, "map["):
return field.GoType + "{\"key\": \"value\"}"
default:
return "{}"
}
}
func exampleStringValue(fieldName string) string {
switch {
case core.Contains(fieldName, "URL"):
return "\"https://example.com\""
case core.Contains(fieldName, "Email"):
return "\"alice@example.com\""
case core.Contains(fieldName, "Tag"):
return "\"v1.0.0\""
case core.Contains(fieldName, "Branch"), core.Contains(fieldName, "Ref"):
return "\"main\""
default:
return "\"example\""
}
}
// writeFile renders and writes a single Go source file for the given types.
func writeFile(path string, types []*GoType) error {
needTime := slices.ContainsFunc(types, func(gt *GoType) bool {
if core.Contains(gt.AliasType, "time.Time") {
return true
}
return slices.ContainsFunc(gt.Fields, func(f GoField) bool {
return core.Contains(f.GoType, "time.Time")
})
})
data := templateData{
NeedTime: needTime,
Types: types,
}
var buf bytes.Buffer
if err := fileHeader.Execute(&buf, data); err != nil {
return core.E("writeFile", "execute template", err)
}
content := strings.TrimRight(buf.String(), "\n") + "\n"
if err := coreio.Local.Write(path, content); err != nil {
return core.E("writeFile", "write file", err)
}
return nil
}