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 }