chore: Add generics to typescript generator (#4664)

* feat: Support generating generics in interfaces
* Switch struct to a template
* Support generics in apitypings
This commit is contained in:
Steven Masley
2022-10-20 08:15:24 -05:00
committed by GitHub
parent d0b1c36d51
commit 369b5d1c2d
9 changed files with 598 additions and 91 deletions

View File

@ -22,6 +22,21 @@ func Overlap[T comparable](a []T, b []T) bool {
}) })
} }
// Unique returns a new slice with all duplicate elements removed.
// This is a slow function on large lists.
// TODO: Sort elements and implement a faster search algorithm if we
// really start to use this.
func Unique[T comparable](a []T) []T {
cpy := make([]T, 0, len(a))
for _, v := range a {
v := v
if !Contains(cpy, v) {
cpy = append(cpy, v)
}
}
return cpy
}
func OverlapCompare[T any](a []T, b []T, equal func(a, b T) bool) bool { func OverlapCompare[T any](a []T, b []T, equal func(a, b T) bool) bool {
// For each element in b, if at least 1 is contained in 'a', // For each element in b, if at least 1 is contained in 'a',
// return true. // return true.

View File

@ -9,6 +9,22 @@ import (
"github.com/coder/coder/coderd/util/slice" "github.com/coder/coder/coderd/util/slice"
) )
func TestUnique(t *testing.T) {
t.Parallel()
require.ElementsMatch(t,
[]int{1, 2, 3, 4, 5},
slice.Unique([]int{
1, 2, 3, 4, 5, 1, 2, 3, 4, 5,
}))
require.ElementsMatch(t,
[]string{"a"},
slice.Unique([]string{
"a", "a", "a",
}))
}
func TestContains(t *testing.T) { func TestContains(t *testing.T) {
t.Parallel() t.Parallel()

View File

@ -1,15 +1,18 @@
package main package main
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"go/types" "go/types"
"os" "os"
"path"
"path/filepath" "path/filepath"
"reflect" "reflect"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
"text/template"
"github.com/fatih/structtag" "github.com/fatih/structtag"
"golang.org/x/tools/go/packages" "golang.org/x/tools/go/packages"
@ -17,6 +20,7 @@ import (
"cdr.dev/slog" "cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman" "cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/coderd/util/slice"
) )
const ( const (
@ -27,20 +31,33 @@ const (
func main() { func main() {
ctx := context.Background() ctx := context.Background()
log := slog.Make(sloghuman.Sink(os.Stderr)) log := slog.Make(sloghuman.Sink(os.Stderr))
codeBlocks, err := GenerateFromDirectory(ctx, log, baseDir) output, err := Generate(baseDir)
if err != nil { if err != nil {
log.Fatal(ctx, err.Error()) log.Fatal(ctx, err.Error())
} }
// Just cat the output to a file to capture it // Just cat the output to a file to capture it
_, _ = fmt.Println(codeBlocks.String()) fmt.Println(output)
}
func Generate(directory string) (string, error) {
ctx := context.Background()
log := slog.Make(sloghuman.Sink(os.Stderr))
codeBlocks, err := GenerateFromDirectory(ctx, log, directory)
if err != nil {
return "", err
}
// Just cat the output to a file to capture it
return codeBlocks.String(), nil
} }
// TypescriptTypes holds all the code blocks created. // TypescriptTypes holds all the code blocks created.
type TypescriptTypes struct { type TypescriptTypes struct {
// Each entry is the type name, and it's typescript code block. // Each entry is the type name, and it's typescript code block.
Types map[string]string Types map[string]string
Enums map[string]string Enums map[string]string
Generics map[string]string
} }
// String just combines all the codeblocks. // String just combines all the codeblocks.
@ -50,6 +67,7 @@ func (t TypescriptTypes) String() string {
sortedTypes := make([]string, 0, len(t.Types)) sortedTypes := make([]string, 0, len(t.Types))
sortedEnums := make([]string, 0, len(t.Enums)) sortedEnums := make([]string, 0, len(t.Enums))
sortedGenerics := make([]string, 0, len(t.Generics))
for k := range t.Types { for k := range t.Types {
sortedTypes = append(sortedTypes, k) sortedTypes = append(sortedTypes, k)
@ -57,9 +75,13 @@ func (t TypescriptTypes) String() string {
for k := range t.Enums { for k := range t.Enums {
sortedEnums = append(sortedEnums, k) sortedEnums = append(sortedEnums, k)
} }
for k := range t.Generics {
sortedGenerics = append(sortedGenerics, k)
}
sort.Strings(sortedTypes) sort.Strings(sortedTypes)
sort.Strings(sortedEnums) sort.Strings(sortedEnums)
sort.Strings(sortedGenerics)
for _, k := range sortedTypes { for _, k := range sortedTypes {
v := t.Types[k] v := t.Types[k]
@ -73,13 +95,20 @@ func (t TypescriptTypes) String() string {
_, _ = s.WriteRune('\n') _, _ = s.WriteRune('\n')
} }
for _, k := range sortedGenerics {
v := t.Generics[k]
_, _ = s.WriteString(v)
_, _ = s.WriteRune('\n')
}
return strings.TrimRight(s.String(), "\n") return strings.TrimRight(s.String(), "\n")
} }
// GenerateFromDirectory will return all the typescript code blocks for a directory // GenerateFromDirectory will return all the typescript code blocks for a directory
func GenerateFromDirectory(ctx context.Context, log slog.Logger, directory string) (*TypescriptTypes, error) { func GenerateFromDirectory(ctx context.Context, log slog.Logger, directory string) (*TypescriptTypes, error) {
g := Generator{ g := Generator{
log: log, log: log,
builtins: make(map[string]string),
} }
err := g.parsePackage(ctx, directory) err := g.parsePackage(ctx, directory)
if err != nil { if err != nil {
@ -98,6 +127,15 @@ type Generator struct {
// Package we are scanning. // Package we are scanning.
pkg *packages.Package pkg *packages.Package
log slog.Logger log slog.Logger
// builtins is kinda a hack to get around the fact that using builtin
// generic constraints is common. We want to support them even though
// they are external to our package.
// It is also a string because the builtins are not proper go types. Meaning
// if you inspect the types, they are not "correct". Things like "comparable"
// cannot be implemented in go. So they are a first class thing that we just
// have to make a static string for ¯\_(ツ)_/¯
builtins map[string]string
} }
// parsePackage takes a list of patterns such as a directory, and parses them. // parsePackage takes a list of patterns such as a directory, and parses them.
@ -128,12 +166,15 @@ func (g *Generator) parsePackage(ctx context.Context, patterns ...string) error
// generateAll will generate for all types found in the pkg // generateAll will generate for all types found in the pkg
func (g *Generator) generateAll() (*TypescriptTypes, error) { func (g *Generator) generateAll() (*TypescriptTypes, error) {
structs := make(map[string]string) m := &Maps{
enums := make(map[string]types.Object) Structs: make(map[string]string),
enumConsts := make(map[string][]*types.Const) Generics: make(map[string]string),
Enums: make(map[string]types.Object),
EnumConsts: make(map[string][]*types.Const),
IgnoredTypes: make(map[string]struct{}),
}
// Look for comments that indicate to ignore a type for typescript generation. // Look for comments that indicate to ignore a type for typescript generation.
ignoredTypes := make(map[string]struct{})
ignoreRegex := regexp.MustCompile("@typescript-ignore[:]?(?P<ignored_types>.*)") ignoreRegex := regexp.MustCompile("@typescript-ignore[:]?(?P<ignored_types>.*)")
for _, file := range g.pkg.Syntax { for _, file := range g.pkg.Syntax {
for _, comment := range file.Comments { for _, comment := range file.Comments {
@ -144,7 +185,7 @@ func (g *Generator) generateAll() (*TypescriptTypes, error) {
if len(matches) >= ignored && matches[ignored] != "" { if len(matches) >= ignored && matches[ignored] != "" {
arr := strings.Split(matches[ignored], ",") arr := strings.Split(matches[ignored], ",")
for _, s := range arr { for _, s := range arr {
ignoredTypes[strings.TrimSpace(s)] = struct{}{} m.IgnoredTypes[strings.TrimSpace(s)] = struct{}{}
} }
} }
} }
@ -153,80 +194,24 @@ func (g *Generator) generateAll() (*TypescriptTypes, error) {
for _, n := range g.pkg.Types.Scope().Names() { for _, n := range g.pkg.Types.Scope().Names() {
obj := g.pkg.Types.Scope().Lookup(n) obj := g.pkg.Types.Scope().Lookup(n)
if obj == nil || obj.Type() == nil { err := g.generateOne(m, obj)
// This would be weird, but it is if the package does not have the type def. if err != nil {
continue return nil, xerrors.Errorf("%q: %w", n, err)
} }
}
// Exclude ignored types // Add the builtins
if _, ok := ignoredTypes[obj.Name()]; ok { for n, value := range g.builtins {
continue if value != "" {
} m.Generics[n] = value
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 <Name> 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 <Name> 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 // Write all enums
enumCodeBlocks := make(map[string]string) enumCodeBlocks := make(map[string]string)
for name, v := range enums { for name, v := range m.Enums {
var values []string var values []string
for _, elem := range enumConsts[name] { for _, elem := range m.EnumConsts[name] {
// TODO: If we have non string constants, we need to handle that // TODO: If we have non string constants, we need to handle that
// here. // here.
values = append(values, elem.Val().String()) values = append(values, elem.Val().String())
@ -242,21 +227,182 @@ func (g *Generator) generateAll() (*TypescriptTypes, error) {
} }
return &TypescriptTypes{ return &TypescriptTypes{
Types: structs, Types: m.Structs,
Enums: enumCodeBlocks, Enums: enumCodeBlocks,
Generics: m.Generics,
}, nil }, nil
} }
type Maps struct {
Structs map[string]string
Generics map[string]string
Enums map[string]types.Object
EnumConsts map[string][]*types.Const
IgnoredTypes map[string]struct{}
}
func (g *Generator) generateOne(m *Maps, obj types.Object) error {
if obj == nil || obj.Type() == nil {
// This would be weird, but it is if the package does not have the type def.
return nil
}
// Exclude ignored types
if _, ok := m.IgnoredTypes[obj.Name()]; ok {
return nil
}
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 underNamed := named.Underlying().(type) {
case *types.Struct:
// type <Name> struct
// Structs are obvious.
codeBlock, err := g.buildStruct(obj, underNamed)
if err != nil {
return xerrors.Errorf("generate %q: %w", obj.Name(), err)
}
m.Structs[obj.Name()] = codeBlock
case *types.Basic:
// type <Name> string
// These are enums. Store to expand later.
m.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 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))
m.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.Interface:
// Interfaces are used as generics. Non-generic interfaces are
// not supported.
if underNamed.NumEmbeddeds() == 1 {
union, ok := underNamed.EmbeddedType(0).(*types.Union)
if !ok {
// If the underlying is not a union, but has 1 type. It's
// just that one type.
union = types.NewUnion([]*types.Term{
// Set the tilde to true to support underlying.
// Doesn't actually affect our generation.
types.NewTerm(true, underNamed.EmbeddedType(0)),
})
}
block, err := g.buildUnion(obj, union)
if err != nil {
return xerrors.Errorf("generate union %q: %w", obj.Name(), err)
}
m.Generics[obj.Name()] = block
}
case *types.Signature:
// Ignore named functions.
default:
// If you hit this error, you added a new unsupported named type.
// The easiest way to solve this is add a new case above with
// your type and a TODO to implement it.
return xerrors.Errorf("unsupported named type %q", underNamed.String())
}
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()
m.EnumConsts[name] = append(m.EnumConsts[name], obj)
}
case *types.Func:
// Noop
default:
fmt.Println(obj.Name())
}
return nil
}
func (g *Generator) posLine(obj types.Object) string { func (g *Generator) posLine(obj types.Object) string {
file := g.pkg.Fset.File(obj.Pos()) file := g.pkg.Fset.File(obj.Pos())
return fmt.Sprintf("// From %s\n", filepath.Join("codersdk", filepath.Base(file.Name()))) // Do not use filepath, as that changes behavior based on OS
return fmt.Sprintf("// From %s\n", path.Join("codersdk", filepath.Base(file.Name())))
} }
// buildStruct just prints the typescript def for a type. // buildStruct just prints the typescript def for a type.
func (g *Generator) buildStruct(obj types.Object, st *types.Struct) (string, error) { func (g *Generator) buildUnion(obj types.Object, st *types.Union) (string, error) {
var s strings.Builder var s strings.Builder
_, _ = s.WriteString(g.posLine(obj)) _, _ = s.WriteString(g.posLine(obj))
_, _ = s.WriteString(fmt.Sprintf("export interface %s ", obj.Name()))
allTypes := make([]string, 0, st.Len())
var optional bool
for i := 0; i < st.Len(); i++ {
term := st.Term(i)
scriptType, err := g.typescriptType(term.Type())
if err != nil {
return "", xerrors.Errorf("union %q for %q failed to get type: %w", st.String(), obj.Name(), err)
}
allTypes = append(allTypes, scriptType.ValueType)
optional = optional || scriptType.Optional
}
if optional {
allTypes = append(allTypes, "null")
}
allTypes = slice.Unique(allTypes)
s.WriteString(fmt.Sprintf("export type %s = %s\n", obj.Name(), strings.Join(allTypes, " | ")))
return s.String(), nil
}
type structTemplateState struct {
PosLine string
Name string
Fields []string
Generics []string
Extends string
AboveLine string
}
const structTemplate = `{{ .PosLine -}}
{{ if .AboveLine }}{{ .AboveLine }}
{{ end }}export interface {{ .Name }}{{ if .Generics }}<{{ join .Generics ", " }}>{{ end }}{{ if .Extends }} extends {{ .Extends }}{{ end }} {
{{ join .Fields "\n"}}
}
`
// buildStruct just prints the typescript def for a type.
func (g *Generator) buildStruct(obj types.Object, st *types.Struct) (string, error) {
state := structTemplateState{}
tpl := template.New("struct")
tpl.Funcs(template.FuncMap{
"join": strings.Join,
})
tpl, err := tpl.Parse(structTemplate)
if err != nil {
return "", xerrors.Errorf("parse struct template: %w", err)
}
state.PosLine = g.posLine(obj)
state.Name = obj.Name()
// Handle named embedded structs in the codersdk package via extension. // Handle named embedded structs in the codersdk package via extension.
var extends []string var extends []string
@ -272,10 +418,10 @@ func (g *Generator) buildStruct(obj types.Object, st *types.Struct) (string, err
} }
} }
if len(extends) > 0 { if len(extends) > 0 {
_, _ = s.WriteString(fmt.Sprintf("extends %s ", strings.Join(extends, ", "))) state.Extends = strings.Join(extends, ", ")
} }
_, _ = s.WriteString("{\n") genericsUsed := make(map[string]string)
// For each field in the struct, we print 1 line of the typescript interface // For each field in the struct, we print 1 line of the typescript interface
for i := 0; i < st.NumFields(); i++ { for i := 0; i < st.NumFields(); i++ {
if extendedFields[i] { if extendedFields[i] {
@ -330,21 +476,109 @@ func (g *Generator) buildStruct(obj types.Object, st *types.Struct) (string, err
} }
} }
if tsType.AboveTypeLine != "" {
_, _ = s.WriteString(tsType.AboveTypeLine)
_, _ = s.WriteRune('\n')
}
optional := "" optional := ""
if jsonOptional || tsType.Optional { if jsonOptional || tsType.Optional {
optional = "?" optional = "?"
} }
_, _ = s.WriteString(fmt.Sprintf("%sreadonly %s%s: %s\n", indent, jsonName, optional, tsType.ValueType)) valueType := tsType.ValueType
if tsType.GenericValue != "" {
valueType = tsType.GenericValue
// This map we are building is just gathering all the generics used
// by our fields. We will use this map for our export type line.
// This isn't actually required since we can get it from the obj
// itself, but this ensures we actually use all the generic fields
// we place in the export line. If we are missing one from this map,
// that is a developer error. And we might as well catch it.
for name, constraint := range tsType.GenericTypes {
if _, ok := genericsUsed[name]; ok {
// Don't add a generic twice
// TODO: We should probably check that the generic mapping is
// not a different type. Like 'T' being referenced to 2 different
// constraints. I don't think this is possible though in valid
// go, so I'm going to ignore this for now.
continue
}
genericsUsed[name] = constraint
}
}
if tsType.AboveTypeLine != "" {
// Just append these as fields. We should fix this later.
state.Fields = append(state.Fields, tsType.AboveTypeLine)
}
state.Fields = append(state.Fields, fmt.Sprintf("%sreadonly %s%s: %s", indent, jsonName, optional, valueType))
} }
_, _ = s.WriteString("}\n")
return s.String(), nil // This is implemented to ensure the correct order of generics on the
// top level structure. Ordering of generic fields is important, and
// we want to match the same order as Golang. The gathering of generic types
// from our fields does not guarantee the order.
named, ok := obj.(*types.TypeName)
if !ok {
return "", xerrors.Errorf("generic param ordering undefined on %q", obj.Name())
}
namedType, ok := named.Type().(*types.Named)
if !ok {
return "", xerrors.Errorf("generic param %q unexpected type %q", obj.Name(), named.Type().String())
}
// Ensure proper generic param ordering
params := namedType.TypeParams()
for i := 0; i < params.Len(); i++ {
param := params.At(i)
name := param.String()
constraint, ok := genericsUsed[param.String()]
if !ok {
// If this error is thrown, it is because you have defined a
// generic field on a structure, but did not use it in your
// fields. If this happens, remove the unused generic on
// the top level structure. We **technically** can implement
// this still, but it's not a case we need to support.
// Example:
// type Foo[A any] struct {
// Bar string
// }
return "", xerrors.Errorf("generic param %q missing on %q, fix your data structure", name, obj.Name())
}
state.Generics = append(state.Generics, fmt.Sprintf("%s extends %s", name, constraint))
}
data := bytes.NewBuffer(make([]byte, 0))
err = tpl.Execute(data, state)
if err != nil {
return "", xerrors.Errorf("execute struct template: %w", err)
}
return data.String(), nil
} }
type TypescriptType struct { type TypescriptType struct {
// GenericTypes is a map of generic name to actual constraint.
// We return these, so we can bubble them up if we are recursively traversing
// a nested structure. We duplicate these at the top level.
// Example: 'C = comparable'.
GenericTypes map[string]string
// GenericValue is the value using the Generic name, rather than the constraint.
// This is only usedful if you can use the generic syntax. Things like maps
// don't currently support this, and will use the ValueType instead.
// Example:
// Given the Golang
// type Foo[C comparable] struct {
// Bar C
// }
// The field `Bar` will return:
// TypescriptType {
// ValueType: "comparable",
// GenericValue: "C",
// GenericTypes: map[string]string{
// "C":"comparable"
// }
// }
GenericValue string
// ValueType is the typescript value type. This is the actual type or
// generic constraint. This can **always** be used without special handling.
ValueType string ValueType string
// AboveTypeLine lets you put whatever text you want above the typescript // AboveTypeLine lets you put whatever text you want above the typescript
// type line. // type line.
@ -456,10 +690,40 @@ func (g *Generator) typescriptType(ty types.Type) (TypescriptType, error) {
// put the name as it will be defined in the typescript codeblock // put the name as it will be defined in the typescript codeblock
// we generate. // we generate.
name := n.Obj().Name() name := n.Obj().Name()
genericName := ""
genericTypes := make(map[string]string)
if obj := g.pkg.Types.Scope().Lookup(name); obj != nil { if obj := g.pkg.Types.Scope().Lookup(name); obj != nil {
// Sweet! Using other typescript types as fields. This could be an // Sweet! Using other typescript types as fields. This could be an
// enum or another struct // enum or another struct
return TypescriptType{ValueType: name}, nil if args := n.TypeArgs(); args != nil && args.Len() > 0 {
genericConstraints := make([]string, 0, args.Len())
genericNames := make([]string, 0, args.Len())
for i := 0; i < args.Len(); i++ {
genType, err := g.typescriptType(args.At(i))
if err != nil {
return TypescriptType{}, xerrors.Errorf("generic field %q<%q>: %w", name, args.At(i).String(), err)
}
if param, ok := args.At(i).(*types.TypeParam); ok {
// Using a generic defined by the parent.
gname := param.Obj().Name()
genericNames = append(genericNames, gname)
genericTypes[gname] = genType.ValueType
} else {
// Defining a generic
genericNames = append(genericNames, genType.ValueType)
}
genericConstraints = append(genericConstraints, genType.ValueType)
}
genericName = name + fmt.Sprintf("<%s>", strings.Join(genericNames, ", "))
name += fmt.Sprintf("<%s>", strings.Join(genericConstraints, ", "))
}
return TypescriptType{
GenericTypes: genericTypes,
GenericValue: genericName,
ValueType: name,
}, nil
} }
// If it's a struct, just use the name of the struct type // If it's a struct, just use the name of the struct type
@ -494,6 +758,50 @@ func (g *Generator) typescriptType(ty types.Type) (TypescriptType, error) {
AboveTypeLine: indentedComment("eslint-disable-next-line")}, nil AboveTypeLine: indentedComment("eslint-disable-next-line")}, nil
} }
return TypescriptType{}, xerrors.New("only empty interface types are supported") return TypescriptType{}, xerrors.New("only empty interface types are supported")
case *types.TypeParam:
_, ok := ty.Underlying().(*types.Interface)
if !ok {
// If it's not an interface, it is likely a usage of generics that
// we have not hit yet. Feel free to add support for it.
return TypescriptType{}, xerrors.New("type param must be an interface")
}
generic := ty.Constraint()
// We don't mess with multiple packages, so just trim the package path
// from the name.
pkgPath := ty.Obj().Pkg().Path()
name := strings.TrimPrefix(generic.String(), pkgPath+".")
referenced := g.pkg.Types.Scope().Lookup(name)
if referenced == nil {
include, builtinString := g.isBuiltIn(name)
if !include {
// If we don't have the type constraint defined somewhere in the package,
// then we have to resort to using any.
return TypescriptType{
GenericTypes: map[string]string{
ty.Obj().Name(): "any",
},
GenericValue: ty.Obj().Name(),
ValueType: "any",
AboveTypeLine: fmt.Sprintf("// %q is an external type, so we use any", name),
Optional: false,
}, nil
}
// Include the builtin for this type to reference
g.builtins[name] = builtinString
}
return TypescriptType{
GenericTypes: map[string]string{
ty.Obj().Name(): name,
},
GenericValue: ty.Obj().Name(),
ValueType: name,
AboveTypeLine: "",
Optional: false,
}, nil
} }
// These are all the other types we need to support. // These are all the other types we need to support.
@ -501,6 +809,26 @@ func (g *Generator) typescriptType(ty types.Type) (TypescriptType, error) {
return TypescriptType{}, xerrors.Errorf("unknown type: %s", ty.String()) return TypescriptType{}, xerrors.Errorf("unknown type: %s", ty.String())
} }
// isBuiltIn returns the string for a builtin type that we want to support
// if the name is a reserved builtin type. This is for types like 'comparable'.
// These types are not implemented in golang, so we just have to hardcode it.
func (Generator) isBuiltIn(name string) (bool, string) {
// Note: @emyrk If we use constraints like Ordered, we can pull those
// dynamically from their respective packages. This is a method on Generator
// so if someone wants to implement that, they can find the respective package
// and type.
switch name {
case "comparable":
// To be complete, we include "any". Kinda sucks :(
return true, "export type comparable = boolean | number | string | any"
case "any":
// This is supported in typescript, we don't need to write anything
return true, ""
default:
return false, ""
}
}
func indentedComment(comment string) string { func indentedComment(comment string) string {
return fmt.Sprintf("%s// %s", indent, comment) return fmt.Sprintf("%s// %s", indent, comment)
} }

View File

@ -0,0 +1,43 @@
//go:build !windows
// +build !windows
// Windows tests fail because the \n\r vs \n. It's not worth trying
// to replace newlines for os tests. If people start using this tool on windows
// and are seeing problems, then we can add build tags and figure it out.
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestGeneration(t *testing.T) {
t.Parallel()
files, err := os.ReadDir("testdata")
require.NoError(t, err, "read dir")
for _, f := range files {
if !f.IsDir() {
// Only test directories
continue
}
f := f
t.Run(f.Name(), func(t *testing.T) {
t.Parallel()
dir := filepath.Join(".", "testdata", f.Name())
output, err := Generate("./" + dir)
require.NoErrorf(t, err, "generate %q", dir)
golden := filepath.Join(dir, f.Name()+".ts")
expected, err := os.ReadFile(golden)
require.NoErrorf(t, err, "read file %s", golden)
expectedString := strings.TrimSpace(string(expected))
output = strings.TrimSpace(output)
require.Equal(t, expectedString, output, "matched output")
})
}
}

5
scripts/apitypings/testdata/README.md vendored Normal file
View File

@ -0,0 +1,5 @@
# How to add a unit test
1. Create a new directory in `testdata`
2. Name a go file `<directory_name>.go`. This file will generate the typescript.
3. Name the expected typescript file `<directory_name>.ts`. This is the unit test's expected output.

View File

@ -0,0 +1,10 @@
package enums
type Enum string
const (
EnumFoo Enum = "foo"
EnumBar Enum = "bar"
EnumBaz Enum = "baz"
EnumQux Enum = "qux"
)

View File

@ -0,0 +1,4 @@
// Code generated by 'make coder/scripts/apitypings/main.go'. DO NOT EDIT.
// From codersdk/enums.go
export type Enum = "bar" | "baz" | "foo" | "qux"

View File

@ -0,0 +1,43 @@
package generics
import "time"
type Single interface {
string
}
type Custom interface {
string | bool | int | time.Duration | []string | *int
}
// StaticGeneric has all generic fields defined in the field
type StaticGeneric struct {
Static GenericFields[string, int, time.Duration, string] `json:"static"`
}
// DynamicGeneric can has some dynamic fields
type DynamicGeneric[A any, S Single] struct {
Dynamic GenericFields[bool, A, string, S] `json:"dynamic"`
Comparable bool `json:"comparable"`
}
type ComplexGeneric[C comparable, S Single, T Custom] struct {
Dynamic GenericFields[C, bool, string, S] `json:"dynamic"`
Order GenericFieldsDiffOrder[C, string, S, T] `json:"order"`
Comparable C `json:"comparable"`
Single S `json:"single"`
Static StaticGeneric `json:"static"`
}
type GenericFields[C comparable, A any, T Custom, S Single] struct {
Comparable C `json:"comparable"`
Any A `json:"any"`
Custom T `json:"custom"`
Again T `json:"again"`
SingleConstraint S `json:"single_constraint"`
}
type GenericFieldsDiffOrder[A any, C comparable, S Single, T Custom] struct {
GenericFields[C, A, T, S]
}

View File

@ -0,0 +1,43 @@
// Code generated by 'make coder/scripts/apitypings/main.go'. DO NOT EDIT.
// From codersdk/generics.go
export interface ComplexGeneric<C extends comparable, S extends Single, T extends Custom> {
readonly dynamic: GenericFields<C, boolean, string, S>
readonly order: GenericFieldsDiffOrder<C, string, S, T>
readonly comparable: C
readonly single: S
readonly static: StaticGeneric
}
// From codersdk/generics.go
export interface DynamicGeneric<A extends any, S extends Single> {
readonly dynamic: GenericFields<boolean, A, string, S>
readonly comparable: boolean
}
// From codersdk/generics.go
export interface GenericFields<C extends comparable, A extends any, T extends Custom, S extends Single> {
readonly comparable: C
readonly any: A
readonly custom: T
readonly again: T
readonly single_constraint: S
}
// From codersdk/generics.go
export interface GenericFieldsDiffOrder<A extends any, C extends comparable, S extends Single, T extends Custom> {
readonly GenericFields: GenericFields<C, A, T, S>
}
// From codersdk/generics.go
export interface StaticGeneric {
readonly static: GenericFields<string, number, number, string>
}
// From codersdk/generics.go
export type Custom = string | boolean | number | string[] | null
// From codersdk/generics.go
export type Single = string
export type comparable = boolean | number | string | any