package main import ( "context" "fmt" "go/types" "os" "path/filepath" "reflect" "regexp" "sort" "strings" "golang.org/x/tools/go/packages" "golang.org/x/xerrors" "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" ) const ( baseDir = "./codersdk" indent = " " ) func main() { ctx := context.Background() log := slog.Make(sloghuman.Sink(os.Stderr)) codeBlocks, err := GenerateFromDirectory(ctx, log, baseDir) if err != nil { log.Fatal(ctx, err.Error()) } // Just cat the output to a file to capture it _, _ = fmt.Println(codeBlocks.String()) } // TypescriptTypes holds all the code blocks created. type TypescriptTypes struct { // Each entry is the type name, and it's typescript code block. Types map[string]string Enums map[string]string } // String just combines all the codeblocks. func (t TypescriptTypes) String() string { var s strings.Builder _, _ = s.WriteString("// Code generated by 'make coder/scripts/apitypings/main.go'. DO NOT EDIT.\n\n") sortedTypes := make([]string, 0, len(t.Types)) sortedEnums := make([]string, 0, len(t.Enums)) for k := range t.Types { sortedTypes = append(sortedTypes, k) } for k := range t.Enums { sortedEnums = append(sortedEnums, k) } sort.Strings(sortedTypes) sort.Strings(sortedEnums) for _, k := range sortedTypes { v := t.Types[k] _, _ = s.WriteString(v) _, _ = s.WriteRune('\n') } for _, k := range sortedEnums { v := t.Enums[k] _, _ = s.WriteString(v) _, _ = s.WriteRune('\n') } return strings.TrimRight(s.String(), "\n") } // GenerateFromDirectory will return all the typescript code blocks for a directory func GenerateFromDirectory(ctx context.Context, log slog.Logger, directory string) (*TypescriptTypes, error) { g := Generator{ log: log, } err := g.parsePackage(ctx, directory) if err != nil { return nil, xerrors.Errorf("parse package %q: %w", directory, err) } codeBlocks, err := g.generateAll() if err != nil { return nil, xerrors.Errorf("parse package %q: %w", directory, err) } return codeBlocks, nil } type Generator struct { // Package we are scanning. pkg *packages.Package log slog.Logger } // parsePackage takes a list of patterns such as a directory, and parses them. func (g *Generator) parsePackage(ctx context.Context, patterns ...string) error { cfg := &packages.Config{ // Just accept the fact we need these flags for what we want. Feel free to add // more, it'll just increase the time it takes to parse. Mode: packages.NeedTypes | packages.NeedName | packages.NeedTypesInfo | packages.NeedTypesSizes | packages.NeedSyntax, Tests: false, Context: ctx, } pkgs, err := packages.Load(cfg, patterns...) if err != nil { return xerrors.Errorf("load package: %w", err) } // Only support 1 package for now. We can expand it if we need later, we // just need to hook up multiple packages in the generator. if len(pkgs) != 1 { return xerrors.Errorf("expected 1 package, found %d", len(pkgs)) } g.pkg = pkgs[0] return nil } // generateAll will generate for all types found in the pkg func (g *Generator) generateAll() (*TypescriptTypes, error) { structs := make(map[string]string) enums := make(map[string]types.Object) enumConsts := make(map[string][]*types.Const) // Look for comments that indicate to ignore a type for typescript generation. ignoredTypes := make(map[string]struct{}) ignoreRegex := regexp.MustCompile("@typescript-ignore[:]?(?P.*)") for _, file := range g.pkg.Syntax { for _, comment := range file.Comments { for _, line := range comment.List { text := line.Text matches := ignoreRegex.FindStringSubmatch(text) ignored := ignoreRegex.SubexpIndex("ignored_types") if len(matches) >= ignored && matches[ignored] != "" { arr := strings.Split(matches[ignored], ",") for _, s := range arr { ignoredTypes[strings.TrimSpace(s)] = struct{}{} } } } } } for _, n := range g.pkg.Types.Scope().Names() { obj := g.pkg.Types.Scope().Lookup(n) if obj == nil || obj.Type() == nil { // This would be weird, but it is if the package does not have the type def. continue } // Exclude ignored types if _, ok := ignoredTypes[obj.Name()]; ok { continue } switch obj := obj.(type) { // All named types are type declarations case *types.TypeName: named, ok := obj.Type().(*types.Named) if !ok { panic("all typename should be named types") } switch named.Underlying().(type) { case *types.Struct: // type struct // Structs are obvious. st, _ := obj.Type().Underlying().(*types.Struct) codeBlock, err := g.buildStruct(obj, st) if err != nil { return nil, xerrors.Errorf("generate %q: %w", obj.Name(), err) } structs[obj.Name()] = codeBlock case *types.Basic: // type string // These are enums. Store to expand later. enums[obj.Name()] = obj case *types.Map: // Declared maps that are not structs are still valid codersdk objects. // Handle them custom by calling 'typescriptType' directly instead of // iterating through each struct field. // These types support no json/typescript tags. // These are **NOT** enums, as a map in Go would never be used for an enum. ts, err := g.typescriptType(obj.Type().Underlying()) if err != nil { return nil, xerrors.Errorf("(map) generate %q: %w", obj.Name(), err) } var str strings.Builder _, _ = str.WriteString(g.posLine(obj)) if ts.AboveTypeLine != "" { str.WriteString(ts.AboveTypeLine) str.WriteRune('\n') } // Use similar output syntax to enums. str.WriteString(fmt.Sprintf("export type %s = %s\n", obj.Name(), ts.ValueType)) structs[obj.Name()] = str.String() case *types.Array, *types.Slice: // TODO: @emyrk if you need this, follow the same design as "*types.Map" case. } case *types.Var: // TODO: Are any enums var declarations? This is also codersdk.Me. case *types.Const: // We only care about named constant types, since they are enums if named, ok := obj.Type().(*types.Named); ok { name := named.Obj().Name() enumConsts[name] = append(enumConsts[name], obj) } case *types.Func: // Noop default: fmt.Println(obj.Name()) } } // Write all enums enumCodeBlocks := make(map[string]string) for name, v := range enums { var values []string for _, elem := range enumConsts[name] { // TODO: If we have non string constants, we need to handle that // here. values = append(values, elem.Val().String()) } sort.Strings(values) var s strings.Builder _, _ = s.WriteString(g.posLine(v)) _, _ = s.WriteString(fmt.Sprintf("export type %s = %s\n", name, strings.Join(values, " | "), )) enumCodeBlocks[name] = s.String() } return &TypescriptTypes{ Types: structs, Enums: enumCodeBlocks, }, nil } func (g *Generator) posLine(obj types.Object) string { file := g.pkg.Fset.File(obj.Pos()) return fmt.Sprintf("// From %s\n", filepath.Join("codersdk", filepath.Base(file.Name()))) } // buildStruct just prints the typescript def for a type. func (g *Generator) buildStruct(obj types.Object, st *types.Struct) (string, error) { var s strings.Builder _, _ = s.WriteString(g.posLine(obj)) _, _ = s.WriteString(fmt.Sprintf("export interface %s ", obj.Name())) // Handle named embedded structs in the codersdk package via extension. var extends []string extendedFields := make(map[int]bool) for i := 0; i < st.NumFields(); i++ { field := st.Field(i) tag := reflect.StructTag(st.Tag(i)) // Adding a json struct tag causes the json package to consider // the field unembedded. if field.Embedded() && tag.Get("json") == "" && field.Pkg().Name() == "codersdk" { extendedFields[i] = true extends = append(extends, field.Name()) } } if len(extends) > 0 { _, _ = s.WriteString(fmt.Sprintf("extends %s ", strings.Join(extends, ", "))) } _, _ = s.WriteString("{\n") // For each field in the struct, we print 1 line of the typescript interface for i := 0; i < st.NumFields(); i++ { if extendedFields[i] { continue } field := st.Field(i) tag := reflect.StructTag(st.Tag(i)) // Use the json name if present jsonName := tag.Get("json") arr := strings.Split(jsonName, ",") jsonName = arr[0] if jsonName == "" { jsonName = field.Name() } jsonOptional := false if len(arr) > 1 && arr[1] == "omitempty" { jsonOptional = true } var tsType TypescriptType // If a `typescript:"string"` exists, we take this, and do not try to infer. typescriptTag := tag.Get("typescript") if typescriptTag == "-" { // Ignore this field continue } else if typescriptTag != "" { tsType.ValueType = typescriptTag } else { var err error tsType, err = g.typescriptType(field.Type()) if err != nil { return "", xerrors.Errorf("typescript type: %w", err) } } if tsType.AboveTypeLine != "" { _, _ = s.WriteString(tsType.AboveTypeLine) _, _ = s.WriteRune('\n') } optional := "" if jsonOptional || tsType.Optional { optional = "?" } _, _ = s.WriteString(fmt.Sprintf("%sreadonly %s%s: %s\n", indent, jsonName, optional, tsType.ValueType)) } _, _ = s.WriteString("}\n") return s.String(), nil } type TypescriptType struct { ValueType string // AboveTypeLine lets you put whatever text you want above the typescript // type line. AboveTypeLine string // Optional indicates the value is an optional field in typescript. Optional bool } // typescriptType this function returns a typescript type for a given // golang type. // Eg: // // []byte returns "string" func (g *Generator) typescriptType(ty types.Type) (TypescriptType, error) { switch ty := ty.(type) { case *types.Basic: bs := ty // All basic literals (string, bool, int, etc). switch { case bs.Info()&types.IsNumeric > 0: return TypescriptType{ValueType: "number"}, nil case bs.Info()&types.IsBoolean > 0: return TypescriptType{ValueType: "boolean"}, nil case bs.Kind() == types.Byte: // TODO: @emyrk What is a byte for typescript? A string? A uint8? return TypescriptType{ValueType: "number", AboveTypeLine: indentedComment("This is a byte in golang")}, nil default: return TypescriptType{ValueType: bs.Name()}, nil } case *types.Struct: // This handles anonymous structs. This should never happen really. // Such as: // type Name struct { // Embedded struct { // Field string `json:"field"` // } // } return TypescriptType{ ValueType: "any", AboveTypeLine: fmt.Sprintf("%s\n%s", indentedComment("Embedded anonymous struct, please fix by naming it"), indentedComment("eslint-disable-next-line @typescript-eslint/no-explicit-any"), ), }, nil case *types.Map: // map[string][string] -> Record m := ty keyType, err := g.typescriptType(m.Key()) if err != nil { return TypescriptType{}, xerrors.Errorf("map key: %w", err) } valueType, err := g.typescriptType(m.Elem()) if err != nil { return TypescriptType{}, xerrors.Errorf("map key: %w", err) } return TypescriptType{ ValueType: fmt.Sprintf("Record<%s, %s>", keyType.ValueType, valueType.ValueType), }, nil case *types.Slice, *types.Array: // Slice/Arrays are pretty much the same. type hasElem interface { Elem() types.Type } arr, _ := ty.(hasElem) switch { // When type checking here, just use the string. You can cast it // to a types.Basic and get the kind if you want too :shrug: case arr.Elem().String() == "byte": // All byte arrays are strings on the typescript. // Is this ok? return TypescriptType{ValueType: "string"}, nil default: // By default, just do an array of the underlying type. underlying, err := g.typescriptType(arr.Elem()) if err != nil { return TypescriptType{}, xerrors.Errorf("array: %w", err) } return TypescriptType{ValueType: underlying.ValueType + "[]", AboveTypeLine: underlying.AboveTypeLine}, nil } case *types.Named: n := ty // First see if the type is defined elsewhere. If it is, we can just // put the name as it will be defined in the typescript codeblock // we generate. name := n.Obj().Name() if obj := g.pkg.Types.Scope().Lookup(name); obj != nil { // Sweet! Using other typescript types as fields. This could be an // enum or another struct return TypescriptType{ValueType: name}, nil } // These are external named types that we handle uniquely. switch n.String() { case "net/url.URL": return TypescriptType{ValueType: "string"}, nil case "time.Time": // We really should come up with a standard for time. return TypescriptType{ValueType: "string"}, nil case "database/sql.NullTime": return TypescriptType{ValueType: "string", Optional: true}, nil case "github.com/google/uuid.NullUUID": return TypescriptType{ValueType: "string", Optional: true}, nil case "github.com/google/uuid.UUID": return TypescriptType{ValueType: "string"}, nil } // If it's a struct, just use the name of the struct type if _, ok := n.Underlying().(*types.Struct); ok { return TypescriptType{ValueType: "any", AboveTypeLine: fmt.Sprintf("%s\n%s", indentedComment(fmt.Sprintf("Named type %q unknown, using \"any\"", n.String())), indentedComment("eslint-disable-next-line @typescript-eslint/no-explicit-any"), )}, nil } // Defer to the underlying type. ts, err := g.typescriptType(ty.Underlying()) if err != nil { return TypescriptType{}, xerrors.Errorf("named underlying: %w", err) } ts.AboveTypeLine = indentedComment(fmt.Sprintf("This is likely an enum in an external package (%q)", n.String())) return ts, nil case *types.Pointer: // Dereference pointers. pt := ty resp, err := g.typescriptType(pt.Elem()) if err != nil { return TypescriptType{}, xerrors.Errorf("pointer: %w", err) } resp.Optional = true return resp, nil } // These are all the other types we need to support. // time.Time, uuid, etc. return TypescriptType{}, xerrors.Errorf("unknown type: %s", ty.String()) } func indentedComment(comment string) string { return fmt.Sprintf("%s// %s", indent, comment) }