Files
seanime/codegen/internal/generate_structs.go
2024-07-23 12:37:46 -04:00

545 lines
17 KiB
Go

package codegen
import (
"encoding/json"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"reflect"
"strings"
)
type GoStruct struct {
Filepath string `json:"filepath"`
Filename string `json:"filename"`
Name string `json:"name"`
FormattedName string `json:"formattedName"` // name with package prefix e.g. models.User => Models_User
Package string `json:"package"`
Fields []*GoStructField `json:"fields"`
AliasOf *GoAlias `json:"aliasOf,omitempty"`
Comments []string `json:"comments"`
EmbeddedStructTypes []string `json:"embeddedStructNames,omitempty"`
}
type GoAlias struct {
GoType string `json:"goType"`
TypescriptType string `json:"typescriptType"`
DeclaredValues []string `json:"declaredValues"`
UsedStructType string `json:"usedStructName,omitempty"`
}
type GoStructField struct {
Name string `json:"name"`
JsonName string `json:"jsonName"`
// e.g. map[string]models.User
GoType string `json:"goType"`
// e.g. User
TypescriptType string `json:"typescriptType"`
// e.g. GoType = map[string]models.User => TypescriptType = User => UsedStructType = models.User
UsedStructType string `json:"usedStructName,omitempty"`
// If no 'omitempty' and not a pointer
Required bool `json:"required"`
Public bool `json:"public"`
Comments []string `json:"comments"`
}
var typePrefixesByPackage = map[string]string{
"anilist": "AL_",
"auto_downloader": "AutoDownloader_",
"entities": "",
"db": "DB_",
"db_bridge": "DB_",
"models": "Models_",
"playbackmanager": "PlaybackManager_",
"torrent_client": "TorrentClient_",
"events": "Events_",
"torrent": "Torrent_",
"manga": "Manga_",
"autoscanner": "AutoScanner_",
"listsync": "ListSync_",
"util": "Util_",
"scanner": "Scanner_",
"offline": "Offline_",
"discordrpc": "DiscordRPC_",
"discordrpc_presence": "DiscordRPC_",
"anizip": "Anizip_",
"onlinestream": "Onlinestream_",
"onlinestream_providers": "Onlinestream_",
"onlinestream_sources": "Onlinestream_",
"manga_providers": "Manga_",
"chapter_downloader": "ChapterDownloader_",
"manga_downloader": "MangaDownloader_",
"docs": "INTERNAL_",
"tvdb": "TVDB_",
"metadata": "Metadata_",
"mappings": "Mappings_",
"mal": "MAL_",
"handlers": "",
"animetosho": "AnimeTosho_",
"updater": "Updater_",
"anime": "Anime_",
"summary": "Summary_",
"filesystem": "Filesystem_",
"filecache": "Filecache_",
"core": "INTERNAL_",
"comparison": "Comparison_",
"mediastream": "Mediastream_",
"torrentstream": "Torrentstream_",
"extension": "Extension_",
"extension_repo": "ExtensionRepo_",
"vendor_hibike_manga": "HibikeManga_",
"vendor_hibike_onlinestream": "HibikeOnlinestream_",
"vendor_hibike_torrent": "HibikeTorrent_",
"hibikemanga": "HibikeManga_",
"hibikeonlinestream": "HibikeOnlinestream_",
"hibiketorrent": "HibikeTorrent_",
}
func getTypePrefix(packageName string) string {
if prefix, ok := typePrefixesByPackage[packageName]; ok {
return prefix
}
return ""
}
func ExtractStructs(dir string, outDir string) {
structs := make([]*GoStruct, 0)
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".go") {
// Parse the Go file
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ParseComments)
if err != nil {
return err
}
packageName := file.Name.Name
// Extract public structs
for _, decl := range file.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.TYPE {
continue
}
//
// Go through each type declaration
//
for _, spec := range genDecl.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
if !typeSpec.Name.IsExported() {
continue
}
//
// The type declaration is an alias
// e.g. alias.Name: string, typeSpec.Name.Name: MediaListStatus
//
alias, ok := typeSpec.Type.(*ast.Ident)
if ok {
if alias.Name == typeSpec.Name.Name {
continue
}
// Get comments
comments := make([]string, 0)
if genDecl.Doc != nil && genDecl.Doc.List != nil && len(genDecl.Doc.List) > 0 {
for _, comment := range genDecl.Doc.List {
comments = append(comments, strings.TrimPrefix(comment.Text, "//"))
}
}
usedStructType, usedStructPkgName := getUsedStructType(typeSpec.Type, packageName)
goStruct := &GoStruct{
Filepath: filepath.ToSlash(path),
Filename: info.Name(),
Name: typeSpec.Name.Name,
Package: packageName,
FormattedName: getTypePrefix(packageName) + typeSpec.Name.Name,
Fields: make([]*GoStructField, 0),
Comments: comments,
AliasOf: &GoAlias{
GoType: alias.Name,
TypescriptType: fieldTypeToTypescriptType(typeSpec.Type, usedStructPkgName),
UsedStructType: usedStructType,
},
}
// Get declared values - useful for building enums or union types
// e.g. const Something AliasType = "something"
goStruct.AliasOf.DeclaredValues = make([]string, 0)
for _, decl := range file.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.CONST {
continue
}
for _, spec := range genDecl.Specs {
valueSpec, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
valueSpecType := fieldTypeString(valueSpec.Type)
if len(valueSpec.Names) == 1 && valueSpec.Names[0].IsExported() && valueSpecType == typeSpec.Name.Name {
for _, value := range valueSpec.Values {
name, ok := value.(*ast.BasicLit)
if !ok {
continue
}
goStruct.AliasOf.DeclaredValues = append(goStruct.AliasOf.DeclaredValues, name.Value)
}
}
}
}
structs = append(structs, goStruct)
continue
}
//
// The type declaration is a struct
//
structType, ok := typeSpec.Type.(*ast.StructType)
if ok {
// Get comments
comments := make([]string, 0)
if genDecl.Doc != nil && genDecl.Doc.List != nil && len(genDecl.Doc.List) > 0 {
for _, comment := range genDecl.Doc.List {
comments = append(comments, strings.TrimPrefix(comment.Text, "//"))
}
}
goStruct := &GoStruct{
Filepath: filepath.ToSlash(path),
Filename: info.Name(),
Name: typeSpec.Name.Name,
FormattedName: getTypePrefix(packageName) + typeSpec.Name.Name,
Package: packageName,
Fields: make([]*GoStructField, 0),
EmbeddedStructTypes: make([]string, 0),
Comments: comments,
}
// Get fields
for _, field := range structType.Fields.List {
if field.Names == nil || len(field.Names) == 0 {
if len(field.Names) == 0 {
switch field.Type.(type) {
case *ast.Ident, *ast.StarExpr, *ast.SelectorExpr:
usedStructType, _ := getUsedStructType(field.Type, packageName)
goStruct.EmbeddedStructTypes = append(goStruct.EmbeddedStructTypes, usedStructType)
}
}
continue
}
// Get fields comments
comments := make([]string, 0)
if field.Comment != nil && field.Comment.List != nil && len(field.Comment.List) > 0 {
for _, comment := range field.Comment.List {
comments = append(comments, strings.TrimPrefix(comment.Text, "//"))
}
}
required := true
if field.Tag != nil {
tag := reflect.StructTag(field.Tag.Value[1 : len(field.Tag.Value)-1])
jsonTag := tag.Get("json")
if jsonTag != "" {
jsonParts := strings.Split(jsonTag, ",")
if len(jsonParts) > 1 && jsonParts[1] == "omitempty" {
required = false
}
}
}
switch field.Type.(type) {
case *ast.StarExpr:
required = false
case *ast.ArrayType:
required = false
case *ast.MapType:
required = false
case *ast.SelectorExpr:
required = false
}
fieldName := field.Names[0].Name
usedStructType, usedStructPkgName := getUsedStructType(field.Type, packageName)
goStructField := &GoStructField{
Name: fieldName,
JsonName: jsonFieldName(field),
GoType: fieldTypeString(field.Type),
TypescriptType: fieldTypeToTypescriptType(field.Type, usedStructPkgName),
Required: required, // might need to parse struct tags to determine this
Public: field.Names[0].IsExported(),
UsedStructType: usedStructType,
Comments: comments,
}
goStruct.Fields = append(goStruct.Fields, goStructField)
}
structs = append(structs, goStruct)
continue
}
mapType, ok := typeSpec.Type.(*ast.MapType)
if ok {
goStruct := &GoStruct{
Filepath: path,
Filename: info.Name(),
Name: typeSpec.Name.Name,
FormattedName: getTypePrefix(packageName) + typeSpec.Name.Name,
Package: packageName,
Fields: make([]*GoStructField, 0),
}
usedStructType, usedStructPkgName := getUsedStructType(mapType, packageName)
goStruct.AliasOf = &GoAlias{
GoType: fieldTypeString(mapType),
TypescriptType: fieldTypeToTypescriptType(mapType, usedStructPkgName),
UsedStructType: usedStructType,
}
structs = append(structs, goStruct)
continue
}
}
}
}
return nil
})
if err != nil {
fmt.Println("Error:", err)
return
}
// Write structs to file
_ = os.MkdirAll(outDir, os.ModePerm)
file, err := os.Create(outDir + "/public_structs.json")
if err != nil {
fmt.Println("Error:", err)
return
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(structs); err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Public structs extracted and saved to public_structs.json")
}
// getUsedStructType returns the used struct type for a given type declaration.
// For example, if the type declaration is `map[string]models.User`, the used struct type is `models.User`.
// If the type declaration is `[]User`, the used struct type is `{packageName}.User`.
func getUsedStructType(expr ast.Expr, packageName string) (string, string) {
usedStructType := fieldTypeToUsedStructType(expr)
switch usedStructType {
case "string", "bool", "byte", "uint", "uint8", "uint16", "uint32", "uint64", "int", "int8", "int16", "int32", "int64", "float", "float32", "float64":
return "", ""
}
if usedStructType != "" && !strings.Contains(usedStructType, ".") {
usedStructType = packageName + "." + usedStructType
}
pkgName := strings.Split(usedStructType, ".")[0]
return usedStructType, pkgName
}
// fieldTypeString returns the field type as a string.
// For example, if the field type is `[]*models.User`, the return value is `[]models.User`.
// If the field type is `[]InternalStruct`, the return value is `[]InternalStruct`.
func fieldTypeString(fieldType ast.Expr) string {
switch t := fieldType.(type) {
case *ast.Ident:
return t.Name
case *ast.StarExpr:
//return "*" + fieldTypeString(t.X)
return fieldTypeString(t.X)
case *ast.ArrayType:
if fieldTypeString(t.Elt) == "byte" {
return "string"
}
return "[]" + fieldTypeString(t.Elt)
case *ast.MapType:
return "map[" + fieldTypeString(t.Key) + "]" + fieldTypeString(t.Value)
case *ast.SelectorExpr:
return fieldTypeString(t.X) + "." + t.Sel.Name
default:
return ""
}
}
// fieldTypeToTypescriptType returns the field type as a string in TypeScript format.
// For example, if the field type is `[]*models.User`, the return value is `Array<Models_User>`.
func fieldTypeToTypescriptType(fieldType ast.Expr, usedStructPkgName string) string {
switch t := fieldType.(type) {
case *ast.Ident:
switch t.Name {
case "string":
return "string"
case "uint", "uint8", "uint16", "uint32", "uint64", "int", "int8", "int16", "int32", "int64", "float", "float32", "float64":
return "number"
case "bool":
return "boolean"
case "nil":
return "null"
default:
return getTypePrefix(usedStructPkgName) + t.Name
}
case *ast.StarExpr:
return fieldTypeToTypescriptType(t.X, usedStructPkgName)
case *ast.ArrayType:
if fieldTypeToTypescriptType(t.Elt, usedStructPkgName) == "byte" {
return "string"
}
return "Array<" + fieldTypeToTypescriptType(t.Elt, usedStructPkgName) + ">"
case *ast.MapType:
return "Record<" + fieldTypeToTypescriptType(t.Key, usedStructPkgName) + ", " + fieldTypeToTypescriptType(t.Value, usedStructPkgName) + ">"
case *ast.SelectorExpr:
if t.Sel.Name == "Time" {
return "string"
}
return getTypePrefix(usedStructPkgName) + t.Sel.Name
default:
return "any"
}
}
func stringGoTypeToTypescriptType(goType string) string {
switch goType {
case "string":
return "string"
case "uint", "uint8", "uint16", "uint32", "uint64", "int", "int8", "int16", "int32", "int64", "float", "float32", "float64":
return "number"
case "nil":
return "null"
case "bool":
return "boolean"
}
if strings.HasPrefix(goType, "[]") {
return "Array<" + stringGoTypeToTypescriptType(goType[2:]) + ">"
}
if strings.HasPrefix(goType, "*") {
return stringGoTypeToTypescriptType(goType[1:])
}
if strings.HasPrefix(goType, "map[") {
s := strings.TrimPrefix(goType, "map[")
key := ""
value := ""
for i, c := range s {
if c == ']' {
key = s[:i]
value = s[i+1:]
break
}
}
return "Record<" + stringGoTypeToTypescriptType(key) + ", " + stringGoTypeToTypescriptType(value) + ">"
}
if strings.Contains(goType, ".") {
parts := strings.Split(goType, ".")
return getTypePrefix(parts[0]) + parts[1]
}
return goType
}
func goTypeToTypescriptType(goType string) string {
switch goType {
case "string":
return "string"
case "uint", "uint8", "uint16", "uint32", "uint64", "int", "int8", "int16", "int32", "int64", "float", "float32", "float64":
return "number"
case "bool":
return "boolean"
case "nil":
return "null"
default:
return "unknown"
}
}
// fieldTypeUnformattedString returns the field type as a string without formatting.
// For example, if the field type is `[]*models.User`, the return value is `models.User`.
// /!\ Caveat: this assumes that the map key is always a string.
func fieldTypeUnformattedString(fieldType ast.Expr) string {
switch t := fieldType.(type) {
case *ast.Ident:
return t.Name
case *ast.StarExpr:
//return "*" + fieldTypeString(t.X)
return fieldTypeUnformattedString(t.X)
case *ast.ArrayType:
return fieldTypeUnformattedString(t.Elt)
case *ast.MapType:
return fieldTypeUnformattedString(t.Value)
case *ast.SelectorExpr:
return fieldTypeString(t.X) + "." + t.Sel.Name
default:
return ""
}
}
// fieldTypeToUsedStructType returns the used struct type for a given field type.
// For example, if the field type is `[]*models.User`, the return value is `models.User`.
func fieldTypeToUsedStructType(fieldType ast.Expr) string {
switch t := fieldType.(type) {
case *ast.StarExpr:
return fieldTypeString(t.X)
case *ast.ArrayType:
return fieldTypeString(t.Elt)
case *ast.MapType:
return fieldTypeUnformattedString(t.Value)
case *ast.SelectorExpr:
return fieldTypeString(t)
case *ast.Ident:
return t.Name
default:
return ""
}
}
func jsonFieldName(field *ast.Field) string {
if field.Tag != nil {
tag := reflect.StructTag(strings.ReplaceAll(field.Tag.Value[1:len(field.Tag.Value)-1], "\\\"", "\""))
jsonTag := tag.Get("json")
if jsonTag != "" {
jsonParts := strings.Split(jsonTag, ",")
return jsonParts[0]
}
}
return field.Names[0].Name
}
func jsonFieldOmitEmpty(field *ast.Field) bool {
if field.Tag != nil {
tag := reflect.StructTag(strings.ReplaceAll(field.Tag.Value[1:len(field.Tag.Value)-1], "\\\"", "\""))
jsonTag := tag.Get("json")
if jsonTag != "" {
jsonParts := strings.Split(jsonTag, ",")
return len(jsonParts) > 1 && jsonParts[1] == "omitempty"
}
}
return false
}