diff --git a/go.mod b/go.mod index 8965d4277..f0053e5de 100644 --- a/go.mod +++ b/go.mod @@ -113,6 +113,7 @@ require ( require ( github.com/dave/jennifer v1.6.0 github.com/ettle/strcase v0.1.1 + golang.org/x/tools v0.6.0 ) require ( @@ -290,7 +291,6 @@ require ( golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.6.0 // indirect golang.org/x/time v0.3.0 // indirect - golang.org/x/tools v0.6.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/grpc v1.48.0 // indirect google.golang.org/protobuf v1.28.1 // indirect diff --git a/references/cuegen/README.md b/references/cuegen/README.md new file mode 100644 index 000000000..86ef8b2b8 --- /dev/null +++ b/references/cuegen/README.md @@ -0,0 +1,60 @@ +## CUE Generator + +Auto generation of CUE schema and docs from Go struct + +## Type Conversion + +- All comments will be copied to CUE schema + +### Basic Types + +| Go Type | CUE Type | +|:------------------:|:---------:| +| `int` | `int` | +| `int8` | `int8` | +| `int16` | `int16` | +| `int32` | `int32` | +| `int64` | `int64` | +| `uint` | `uint` | +| `uint8` | `uint8` | +| `uint16` | `uint16` | +| `uint32` | `uint32` | +| `uint64` | `uint64` | +| `float32` | `float32` | +| `float64` | `float64` | +| `string` | `string` | +| `bool` | `bool` | +| `nil` | `null` | +| `byte` | `uint8` | +| `uintptr` | `uint64` | +| `[]byte` | `bytes` | +| `interface{}/any` | `{...}` | +| `interface{ ... }` | `_` | + +### Map Type + +- CUE only supports `map[string]T` type, which is converted to `[string]: T` in CUE schema +- All `map[string]any/map[string]interface{}` are converted to `{...}` in CUE schema + +### Struct Type + +- Fields will be expanded recursively in CUE schema +- All unexported fields will be ignored +- Do not support recursive struct type, which will cause infinite loop + +`json` Tag: + +- Fields with `json:"FIELD_NAME"` tag will be renamed to `FIELD_NAME` in CUE schema, otherwise the field name will be + used +- Fields with `json:"-"` tag will be ignored in generation +- Anonymous fields with `json:",inline"` tag will be expanded inlined in CUE schema +- Fields with `json:",omitempty"` tag will be marked as optional in CUE schema + +`cue` Tag: + +- Format: `cue:"key1:value1;key2:value2;boolValue1;boolValue2"` +- Fields with `cue:"enum:VALUE1,VALUE2"` tag will be set with enum values `VALUE1` and `VALUE2` in CUE schema +- Fields with `cue:"default:VALUE"` tag will be set with default value `VALUE` in CUE schema, and `VALUE` must be one of + go basic types, including `int`, `float`, `string`, `bool` +- Separators `';'`, `':'` and `','` can be escaped with `'\'`, e.g. `cue:"default:va\\;lue\\:;enum:e\\;num1,e\\:num2\\,enum3"` will + be parsed as `Default: "va;lue:", Enum: []string{"e;num1", "e:num2,enum3"}}` diff --git a/references/cuegen/convert.go b/references/cuegen/convert.go new file mode 100644 index 000000000..203071699 --- /dev/null +++ b/references/cuegen/convert.go @@ -0,0 +1,479 @@ +/* +Copyright 2023 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cuegen + +import ( + "fmt" + goast "go/ast" + gotoken "go/token" + gotypes "go/types" + "strconv" + "strings" + + cueast "cuelang.org/go/cue/ast" + cuetoken "cuelang.org/go/cue/token" +) + +func (g *Generator) convertDecls(x *goast.GenDecl) (decls []cueast.Decl, _ error) { + // TODO(iyear): currently only support 'type' + if x.Tok != gotoken.TYPE { + return + } + + for _, spec := range x.Specs { + typeSpec, ok := spec.(*goast.TypeSpec) + if !ok { + continue + } + + // only process struct + typ := g.pkg.TypesInfo.TypeOf(typeSpec.Name) + + if err := supportedType(nil, typ); err != nil { + return nil, fmt.Errorf("unsupported type %s: %w", typeSpec.Name.Name, err) + } + + named, ok := typ.(*gotypes.Named) + if !ok { + continue + } + st, ok := named.Underlying().(*gotypes.Struct) + if !ok { + continue + } + + lit, err := g.convert(st) + if err != nil { + return nil, err + } + + field := &cueast.Field{ + Label: cueast.NewString(typeSpec.Name.Name), + Value: lit, + } + // there is no doc for typeSpec, so we only add x.Doc + makeComments(field, &commentUnion{comment: nil, doc: x.Doc}) + + cueast.SetRelPos(field, cuetoken.Blank) + decls = append(decls, field) + } + + return decls, nil +} + +func (g *Generator) convert(typ gotypes.Type) (cueast.Expr, error) { + // if type is registered as any, return {...} + if _, ok := g.anyTypes[typ.String()]; ok { + return anyLit(), nil + } + + switch t := typ.(type) { + case *gotypes.Basic: + return basicType(t), nil + case *gotypes.Named: + return g.convert(t.Underlying()) + case *gotypes.Struct: + return g.makeStructLit(t) + case *gotypes.Pointer: + expr, err := g.convert(t.Elem()) + if err != nil { + return nil, err + } + return &cueast.BinaryExpr{ + X: cueast.NewNull(), + Op: cuetoken.OR, + Y: expr, + }, nil + case *gotypes.Slice: + if t.Elem().String() == "byte" { + return ident("bytes", false), nil + } + expr, err := g.convert(t.Elem()) + if err != nil { + return nil, err + } + return cueast.NewList(&cueast.Ellipsis{Type: expr}), nil + case *gotypes.Array: + if t.Elem().String() == "byte" { + // TODO: no way to constraint lengths of bytes for now, as regexps + // operate on Unicode, not bytes. So we need + // fmt.Fprint(e.w, fmt.Sprintf("=~ '^\C{%d}$'", x.Len())), + // but regexp does not support that. + // But translate to bytes, instead of [...byte] to be consistent. + return ident("bytes", false), nil + } + + expr, err := g.convert(t.Elem()) + if err != nil { + return nil, err + } + return &cueast.BinaryExpr{ + X: &cueast.BasicLit{ + Kind: cuetoken.INT, + Value: strconv.Itoa(int(t.Len())), + }, + Op: cuetoken.MUL, + Y: cueast.NewList(expr), + }, nil + case *gotypes.Map: + // cue map only support string as key + if b, ok := t.Key().Underlying().(*gotypes.Basic); !ok || b.Kind() != gotypes.String { + return nil, fmt.Errorf("unsupported map key type %s of %s", t.Key(), t) + } + + expr, err := g.convert(t.Elem()) + if err != nil { + return nil, err + } + + f := &cueast.Field{ + Label: cueast.NewList(ident("string", false)), + Value: expr, + } + return &cueast.StructLit{ + Elts: []cueast.Decl{f}, + }, nil + case *gotypes.Interface: + // we don't process interface + return ident("_", false), nil + } + + return nil, fmt.Errorf("unsupported type %s", typ) +} + +func (g *Generator) makeStructLit(x *gotypes.Struct) (*cueast.StructLit, error) { + st := &cueast.StructLit{ + Elts: make([]cueast.Decl, 0), + } + + // if num of fields is 1, we don't need braces. Keep it simple. + if x.NumFields() > 1 { + st.Lbrace = cuetoken.Blank.Pos() + st.Rbrace = cuetoken.Newline.Pos() + } + + err := g.addFields(st, x, map[string]struct{}{}) + if err != nil { + return nil, err + } + + return st, nil +} + +// addFields converts fields of go struct to CUE fields and add them to cue StructLit. +func (g *Generator) addFields(st *cueast.StructLit, x *gotypes.Struct, names map[string]struct{}) error { + comments := g.fieldComments(x) + + for i := 0; i < x.NumFields(); i++ { + field := x.Field(i) + + // skip unexported fields + if !field.Exported() { + continue + } + + // TODO(iyear): support more complex tags and usages + opts := g.parseTag(x.Tag(i)) + + // skip fields with "-" tag + if opts.Name == "-" { + continue + } + + // if field name tag is empty, use Go field name + if opts.Name == "" { + opts.Name = field.Name() + } + + // can't decl same field in the same scope + if _, ok := names[opts.Name]; ok { + return fmt.Errorf("field '%s' already exists, can not declare duplicate field name", opts.Name) + } + names[opts.Name] = struct{}{} + + // process anonymous field with inline tag + if field.Anonymous() && opts.Inline { + if t, ok := field.Type().Underlying().(*gotypes.Struct); ok { + if err := g.addFields(st, t, names); err != nil { + return err + } + } + continue + } + + var ( + expr cueast.Expr + err error + ) + switch { + // process field with enum tag + case opts.Enum != nil && len(opts.Enum) > 0: + expr, err = g.enumField(field.Type(), opts) + // process normal field + default: + expr, err = g.normalField(field.Type(), opts) + } + if err != nil { + return fmt.Errorf("field '%s': %w", opts.Name, err) + } + + f := &cueast.Field{ + Label: cueast.NewString(opts.Name), + Value: expr, + } + + // process field with optional tag(omitempty in json tag) + if opts.Optional { + f.Token = cuetoken.COLON + f.Optional = cuetoken.Blank.Pos() + } + + makeComments(f, comments[i]) + + st.Elts = append(st.Elts, f) + } + + return nil +} + +func (g *Generator) enumField(typ gotypes.Type, opts *tagOptions) (cueast.Expr, error) { + tt, ok := typ.(*gotypes.Basic) + if !ok { + // TODO(iyear): support more types + return nil, fmt.Errorf("enum value only support [int, float, string, bool]") + } + + expr, err := basicLabel(tt, opts.Enum[0]) + if err != nil { + return nil, err + } + + for _, v := range opts.Enum[1:] { + enumExpr, err := basicLabel(tt, v) + if err != nil { + return nil, err + } + + // default value should be marked with * + if opts.Default != nil && *opts.Default == v { + enumExpr = &cueast.UnaryExpr{Op: cuetoken.MUL, X: enumExpr} + } + + expr = &cueast.BinaryExpr{ + X: expr, + Op: cuetoken.OR, + Y: enumExpr, + } + } + + return expr, nil +} + +func (g *Generator) normalField(typ gotypes.Type, opts *tagOptions) (cueast.Expr, error) { + expr, err := g.convert(typ) + if err != nil { + return nil, err + } + + // process field with default tag + if opts.Default != nil { + tt, ok := typ.(*gotypes.Basic) + if !ok { + // TODO(iyear): support more types + return nil, fmt.Errorf("default value only support [int, float, string, bool]") + } + + defaultExpr, err := basicLabel(tt, *opts.Default) + if err != nil { + return nil, err + } + expr = &cueast.BinaryExpr{ + // default value should be marked with * + X: &cueast.UnaryExpr{Op: cuetoken.MUL, X: defaultExpr}, + Op: cuetoken.OR, + Y: expr, + } + } + + return expr, nil +} + +func supportedType(stack []gotypes.Type, t gotypes.Type) error { + // we expand structures recursively, so we can't support recursive types + for _, t0 := range stack { + if t0 == t { + return fmt.Errorf("recursive type %s", t) + } + } + stack = append(stack, t) + + t = t.Underlying() + switch x := t.(type) { + case *gotypes.Basic: + if x.String() != "invalid type" { + return nil + } + return fmt.Errorf("unsupported type %s", t) + case *gotypes.Named: + return nil + case *gotypes.Pointer: + return supportedType(stack, x.Elem()) + case *gotypes.Slice: + return supportedType(stack, x.Elem()) + case *gotypes.Array: + return supportedType(stack, x.Elem()) + case *gotypes.Map: + if b, ok := x.Key().Underlying().(*gotypes.Basic); !ok || b.Kind() != gotypes.String { + return fmt.Errorf("unsupported map key type %s of %s", x.Key(), t) + } + return supportedType(stack, x.Elem()) + case *gotypes.Struct: + // Eliminate structs with fields for which all fields are filtered. + if x.NumFields() == 0 { + return nil + } + for i := 0; i < x.NumFields(); i++ { + f := x.Field(i) + if f.Exported() { + if err := supportedType(stack, f.Type()); err != nil { + return err + } + } + } + return nil + case *gotypes.Interface: + return nil + } + return fmt.Errorf("unsupported type %s", t) +} + +// ----------comment---------- + +type commentUnion struct { + comment *goast.CommentGroup + doc *goast.CommentGroup +} + +// fieldComments returns the comments for each field in a go struct. +// +// The comments are same order as the fields. +func (g *Generator) fieldComments(x *gotypes.Struct) []*commentUnion { + comments := make([]*commentUnion, x.NumFields()) + + st, ok := g.types[x] + if !ok { + return comments + } + + for i, field := range st.Fields.List { + comments[i] = &commentUnion{comment: field.Comment, doc: field.Doc} + } + + return comments +} + +// makeComments adds comments to a cue node. +// +// go docs/comments are converted to cue comments. +func makeComments(node cueast.Node, c *commentUnion) { + if c == nil { + return + } + cg := make([]*cueast.Comment, 0) + + if comment := makeComment(c.comment); comment != nil && len(comment.List) > 0 { + cg = append(cg, comment.List...) + } + if doc := makeComment(c.doc); doc != nil && len(doc.List) > 0 { + cg = append(cg, doc.List...) + } + + // avoid nil comment groups which will cause panics + if len(cg) > 0 { + cueast.AddComment(node, &cueast.CommentGroup{List: cg}) + } +} + +// makeComment converts a go CommentGroup to a cue CommentGroup. +// +// All /*-style comments are converted to //-style comments. +func makeComment(cg *goast.CommentGroup) *cueast.CommentGroup { + if cg == nil { + return nil + } + + var comments []*cueast.Comment + + for _, comment := range cg.List { + c := comment.Text + + if len(c) < 2 { + continue + } + + // Remove comment markers. + // The parser has given us exactly the comment text. + switch c[1] { + case '/': + // -style comment (no newline at the end) + comments = append(comments, &cueast.Comment{Text: c}) + + case '*': + /*-style comment */ + c = c[2 : len(c)-2] + if len(c) > 0 && c[0] == '\n' { + c = c[1:] + } + + lines := strings.Split(c, "\n") + + // Find common space prefix + i := 0 + line := lines[0] + for ; i < len(line); i++ { + if c := line[i]; c != ' ' && c != '\t' { + break + } + } + + for _, l := range lines { + for j := 0; j < i && j < len(l); j++ { + if line[j] != l[j] { + i = j + break + } + } + } + + // Strip last line if empty. + if n := len(lines); n > 1 && len(lines[n-1]) < i { + lines = lines[:n-1] + } + + // Print lines. + for _, l := range lines { + if i >= len(l) { + comments = append(comments, &cueast.Comment{Text: "//"}) + continue + } + comments = append(comments, &cueast.Comment{Text: "// " + l[i:]}) + } + } + } + + return &cueast.CommentGroup{List: comments} +} diff --git a/references/cuegen/convert_test.go b/references/cuegen/convert_test.go new file mode 100644 index 000000000..5a489ef43 --- /dev/null +++ b/references/cuegen/convert_test.go @@ -0,0 +1,64 @@ +/* +Copyright 2023 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cuegen + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConvert(t *testing.T) { + g, err := NewGenerator("testdata/valid.go") + assert.NoError(t, err) + g.RegisterAny( + "*k8s.io/apimachinery/pkg/apis/meta/v1/unstructured.Unstructured", + ) + + got := &bytes.Buffer{} + assert.NoError(t, g.Generate(got)) + + want, err := os.ReadFile("testdata/valid.cue") + assert.NoError(t, err) + + assert.Equal(t, got.String(), string(want)) +} + +func TestConvertInvalid(t *testing.T) { + if err := filepath.Walk("testdata/invalid", func(path string, info os.FileInfo, e error) error { + if e != nil { + return e + } + + if info.IsDir() { + return nil + } + + g, err := NewGenerator(path) + assert.NoError(t, err) + + assert.Error(t, g.Generate(io.Discard), path) + + return nil + }); err != nil { + t.Error(err) + } +} diff --git a/references/cuegen/generator.go b/references/cuegen/generator.go new file mode 100644 index 000000000..f9c5d84d6 --- /dev/null +++ b/references/cuegen/generator.go @@ -0,0 +1,152 @@ +/* +Copyright 2023 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cuegen + +import ( + "fmt" + goast "go/ast" + gotypes "go/types" + "io" + "strings" + + cueast "cuelang.org/go/cue/ast" + "cuelang.org/go/cue/ast/astutil" + cueformat "cuelang.org/go/cue/format" + "golang.org/x/tools/go/packages" +) + +// Generator generates CUE schema from Go struct. +type Generator struct { + // immutable + pkg *packages.Package + types typeInfo + + anyTypes map[string]struct{} +} + +var defaultAnyTypes = []string{ + "map[string]interface{}", + "map[string]any", + "interface{}", + "any", +} + +// NewGenerator creates a new generator with given file or package path. +func NewGenerator(f string) (*Generator, error) { + pkg, err := loadPackage(f) + if err != nil { + return nil, err + } + + types := getTypeInfo(pkg) + + g := &Generator{ + pkg: pkg, + types: types, + anyTypes: make(map[string]struct{}), + } + + g.RegisterAny(defaultAnyTypes...) + + return g, nil +} + +// Generate generates CUE schema from Go struct and writes to w. +func (g *Generator) Generate(w io.Writer) error { + var decls []cueast.Decl + + for _, syntax := range g.pkg.Syntax { + for _, decl := range syntax.Decls { + if d, ok := decl.(*goast.GenDecl); ok { + t, err := g.convertDecls(d) + if err != nil { + return err + } + decls = append(decls, t...) + } + } + } + + pkg := &cueast.Package{Name: ident(g.pkg.Name, false)} + + f := &cueast.File{Decls: []cueast.Decl{pkg}} + f.Decls = append(f.Decls, decls...) + + return g.write(w, f) +} + +func (g *Generator) write(w io.Writer, f *cueast.File) error { + if err := astutil.Sanitize(f); err != nil { + return err + } + + b, err := cueformat.Node(f, cueformat.Simplify()) + if err != nil { + return err + } + + _, err = w.Write(b) + return err +} + +// loadPackage loads a package from given path. +func loadPackage(p string) (*packages.Package, error) { + cfg := &packages.Config{ + Mode: packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles | + packages.NeedImports | packages.NeedTypes | packages.NeedTypesSizes | + packages.NeedSyntax | packages.NeedTypesInfo | packages.NeedDeps | + packages.NeedModule, + } + + pkgs, err := packages.Load(cfg, []string{p}...) + if err != nil { + return nil, err + } + if len(pkgs) != 1 { + return nil, fmt.Errorf("expected one package, got %d", len(pkgs)) + } + + // only need to check the first package + pkg := pkgs[0] + if pkg.Errors != nil { + errs := make([]string, 0, len(pkg.Errors)) + for _, e := range pkg.Errors { + errs = append(errs, fmt.Sprintf("\t%s: %v", pkg.PkgPath, e)) + } + return nil, fmt.Errorf("could not load Go packages:\n%s", strings.Join(errs, "\n")) + } + + return pkg, nil +} + +type typeInfo map[gotypes.Type]*goast.StructType + +func getTypeInfo(p *packages.Package) typeInfo { + m := make(typeInfo) + + for _, f := range p.Syntax { + goast.Inspect(f, func(n goast.Node) bool { + // record all struct types + if t, ok := n.(*goast.StructType); ok { + m[p.TypesInfo.TypeOf(t)] = t + } + return true + }) + } + + return m +} diff --git a/references/cuegen/generator_test.go b/references/cuegen/generator_test.go new file mode 100644 index 000000000..4de22ab62 --- /dev/null +++ b/references/cuegen/generator_test.go @@ -0,0 +1,65 @@ +/* +Copyright 2023 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cuegen + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testGenerator(t *testing.T) *Generator { + g, err := NewGenerator("testdata/valid.go") + require.NoError(t, err) + require.NotNil(t, g) + require.Len(t, g.pkg.Errors, 0) + + return g +} + +func TestNewGenerator(t *testing.T) { + g := testGenerator(t) + + assert.NotNil(t, g.pkg) + assert.NotNil(t, g.types) + assert.NotNil(t, g.anyTypes) + + assert.Equal(t, len(defaultAnyTypes), len(g.anyTypes)) + assert.Greater(t, len(g.types), 0) +} + +func TestGeneratorGenerate(t *testing.T) { + g := testGenerator(t) + + require.NoError(t, g.Generate(io.Discard)) +} + +func TestLoadPackage(t *testing.T) { + pkg, err := loadPackage("testdata/valid.go") + require.NoError(t, err) + require.NotNil(t, pkg) + require.Len(t, pkg.Errors, 0) +} + +func TestGetTypeInfo(t *testing.T) { + pkg, err := loadPackage("testdata/valid.go") + require.NoError(t, err) + + require.Greater(t, len(getTypeInfo(pkg)), 0) +} diff --git a/references/cuegen/registry.go b/references/cuegen/registry.go new file mode 100644 index 000000000..b132ed66b --- /dev/null +++ b/references/cuegen/registry.go @@ -0,0 +1,28 @@ +/* +Copyright 2023 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cuegen + +// RegisterAny registers go types' package+name as any type({...} in CUE) +// +// Example:RegisterAny("*k8s.io/apimachinery/pkg/apis/meta/v1/unstructured.Unstructured") +// +// Default any types are: map[string]interface{}, map[string]any, interface{}, any +func (g *Generator) RegisterAny(types ...string) { + for _, t := range types { + g.anyTypes[t] = struct{}{} + } +} diff --git a/references/cuegen/registry_test.go b/references/cuegen/registry_test.go new file mode 100644 index 000000000..a3213b8c8 --- /dev/null +++ b/references/cuegen/registry_test.go @@ -0,0 +1,61 @@ +/* +Copyright 2023 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cuegen + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRegisterAny(t *testing.T) { + tests := []struct { + name string + typ []string + want map[string]struct{} + }{ + { + name: "builtin", + typ: []string{"int", "string", "bool"}, + want: map[string]struct{}{"int": {}, "string": {}, "bool": {}}, + }, + { + name: "map", + typ: []string{"map[string]interface{}"}, + want: map[string]struct{}{"map[string]interface{}": {}}, + }, + { + name: "external", + typ: []string{"http.Header"}, + want: map[string]struct{}{"http.Header": {}}, + }, + { + name: "external2", + typ: []string{"*k8s.io/apimachinery/pkg/apis/meta/v1/unstructured.Unstructured", "http.Header"}, + want: map[string]struct{}{ + "*k8s.io/apimachinery/pkg/apis/meta/v1/unstructured.Unstructured": {}, + "http.Header": {}, + }, + }, + } + + for _, tt := range tests { + g := Generator{anyTypes: map[string]struct{}{}} + g.RegisterAny(tt.typ...) + assert.Equal(t, tt.want, g.anyTypes, tt.name) + } +} diff --git a/references/cuegen/tag.go b/references/cuegen/tag.go new file mode 100644 index 000000000..cd2d8b3b5 --- /dev/null +++ b/references/cuegen/tag.go @@ -0,0 +1,146 @@ +/* +Copyright 2023 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cuegen + +import ( + "reflect" + "strings" +) + +type tagOptions struct { + // basic + Name string + Inline bool + Optional bool + + // extended + Default *string // nil means no default value + Enum []string +} + +// TODO(iyear): be customizable +const ( + basicTag = "json" // same as json tag + extTag = "cue" // format: cue:"key1:value1;key2:value2;boolValue1;boolValue2" +) + +func (g *Generator) parseTag(tag string) *tagOptions { + if tag == "" { + return &tagOptions{} + } + + name, opts := parseTag(reflect.StructTag(tag).Get(basicTag)) + ext := parseExtTag(reflect.StructTag(tag).Get(extTag)) + + return &tagOptions{ + Name: name, + Inline: opts.Has("inline"), + Optional: opts.Has("omitempty"), + + Default: ext.GetX("default"), + Enum: unescapeSplit(ext.Get("enum"), ","), + } +} + +type basicTagOptions string + +func parseTag(tag string) (string, basicTagOptions) { + tag, opt, _ := strings.Cut(tag, ",") + return tag, basicTagOptions(opt) +} + +func (o basicTagOptions) Has(opt string) bool { + if len(o) == 0 { + return false + } + s := string(o) + for s != "" { + var name string + name, s, _ = strings.Cut(s, ",") + if name == opt { + return true + } + } + return false +} + +func parseExtTag(str string) extTagOptions { + settings := map[string]string{} + if str == "" { + return settings + } + + pairs := unescapeSplit(str, ";") + for _, pair := range pairs { + switch kv := unescapeSplit(pair, ":"); len(kv) { + case 1: + settings[kv[0]] = "" + case 2: + settings[kv[0]] = kv[1] + default: + // ignore invalid pair + } + } + + return settings +} + +func unescapeSplit(str string, sep string) []string { + if str == "" { + return []string{} + } + + ss := strings.Split(str, sep) + for i := 0; i < len(ss); i++ { + j := i + if len(ss[j]) > 0 { + for { + if ss[j][len(ss[j])-1] == '\\' && i+1 < len(ss) { + i++ + ss[j] = ss[j][0:len(ss[j])-1] + sep + ss[i] + ss[i] = "" + } else { + break + } + } + } + } + + // filter empty strings + res := make([]string, 0, len(ss)) + for _, s := range ss { + if s != "" { + res = append(res, s) + } + } + return res +} + +type extTagOptions map[string]string + +// GetX returns the value of the key if it exists, otherwise nil. +func (e extTagOptions) GetX(key string) *string { + if v, ok := e[key]; ok { + return &v + } + return nil +} + +// Get returns the value of the key if it exists, otherwise "". +func (e extTagOptions) Get(key string) string { + return e[key] +} diff --git a/references/cuegen/tag_test.go b/references/cuegen/tag_test.go new file mode 100644 index 000000000..501c398de --- /dev/null +++ b/references/cuegen/tag_test.go @@ -0,0 +1,232 @@ +/* +Copyright 2023 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cuegen + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func str(s string) *string { + return &s +} + +func TestGeneratorParseTag(t *testing.T) { + tests := []struct { + name string + tag string + opts *tagOptions + }{ + {"empty", "", &tagOptions{}}, + {"only_name", `json:"name"`, &tagOptions{Name: "name", Enum: []string{}}}, + {"only_name_2", `json:""`, &tagOptions{Name: "", Enum: []string{}}}, + {"only_name_3", `json:"-"`, &tagOptions{Name: "-", Enum: []string{}}}, + {"json_omitempty", `json:"name,omitempty"`, &tagOptions{Name: "name", Optional: true, Enum: []string{}}}, + {"json_omitempty_2", `json:"name,omitempty,omitempty"`, &tagOptions{Name: "name", Optional: true, Enum: []string{}}}, + {"json_omitempty_3", `json:",omitempty"`, &tagOptions{Name: "", Optional: true, Enum: []string{}}}, + {"json_inline", `json:",inline"`, &tagOptions{Name: "", Inline: true, Enum: []string{}}}, + {"json_inline_2", `json:"name,inline"`, &tagOptions{Name: "name", Inline: true, Enum: []string{}}}, + {"json_inline_3", `json:"name,inline,inline"`, &tagOptions{Name: "name", Inline: true, Enum: []string{}}}, + {"json_omitempty_inline", `json:"name,omitempty,inline"`, &tagOptions{Name: "name", Optional: true, Inline: true, Enum: []string{}}}, + {"json_omitempty_inline_2", `json:",omitempty,inline"`, &tagOptions{Name: "", Optional: true, Inline: true, Enum: []string{}}}, + {"cue_default", `cue:"default:default_value"`, &tagOptions{Default: str("default_value"), Enum: []string{}}}, + {"cue_default_2", `cue:"default:default_value;default:default_value2"`, &tagOptions{Default: str("default_value2"), Enum: []string{}}}, + {"cue_default_3", `cue:"default:1.11"`, &tagOptions{Default: str("1.11"), Enum: []string{}}}, + {"cue_default_4", `cue:"default:va,lue"`, &tagOptions{Default: str(`va,lue`), Enum: []string{}}}, + {"cue_enum", `cue:"enum:enum1,enum2"`, &tagOptions{Enum: []string{"enum1", "enum2"}}}, + {"cue_enum_2", `cue:"enum:enum1,enum2;enum:enum3,enum4"`, &tagOptions{Enum: []string{"enum3", "enum4"}}}, + {"cue_enum_empty", `cue:""`, &tagOptions{Enum: []string{}}}, + {"cue_enum_empty_2", `cue:"enum:"`, &tagOptions{Enum: []string{}}}, + {"cue_escape", `cue:"default:\"default_value\""`, &tagOptions{Default: str(`"default_value"`), Enum: []string{}}}, + {"cue_escape_2", `cue:"default:\"default_value\\\"\""`, &tagOptions{Default: str(`"default_value\""`), Enum: []string{}}}, + {"cue_escape_3", `cue:"default:value\\;vv;enum:enum1,enum2"`, &tagOptions{Default: str(`value;vv`), Enum: []string{"enum1", "enum2"}}}, + {"cue_escape_default_semicolon", `cue:"default:va\\;lue\\"`, &tagOptions{Default: str(`va;lue\`), Enum: []string{}}}, + {"cue_escape_default_colon", `cue:"default:va\\:lue\\"`, &tagOptions{Default: str(`va:lue\`), Enum: []string{}}}, + {"cue_escape_enum_semicolon", `cue:"enum:e\\;num1,enum2"`, &tagOptions{Enum: []string{`e;num1`, "enum2"}}}, + {"cue_escape_enum_colon", `cue:"enum:e\\:num1,enum2"`, &tagOptions{Enum: []string{`e:num1`, "enum2"}}}, + {"cue_escape_enum_colon_2", `cue:"enum:enum1\\,enum2"`, &tagOptions{Enum: []string{"enum1,enum2"}}}, + {"cue_escape_all", `cue:"default:va\\;lue\\:;enum:e\\;num1,e\\:num2\\,enum3"`, &tagOptions{Default: str(`va;lue:`), Enum: []string{`e;num1`, `e:num2,enum3`}}}, + {"json_cue", `json:"name" cue:"default:default_value"`, &tagOptions{Name: "name", Default: str("default_value"), Enum: []string{}}}, + {"json_cue_2", `json:"name" cue:"default:default_value;enum:enum1,enum2"`, &tagOptions{Name: "name", Default: str("default_value"), Enum: []string{"enum1", "enum2"}}}, + {"json_cue_3", `json:",omitempty" cue:"default:default_value"`, &tagOptions{Name: "", Optional: true, Default: str("default_value"), Enum: []string{}}}, + {"json_cue_4", `json:",inline" cue:"default:default_value"`, &tagOptions{Name: "", Inline: true, Default: str("default_value"), Enum: []string{}}}, + {"json_cue_5", `json:"name,omitempty,inline" cue:"default:default_value"`, &tagOptions{Name: "name", Optional: true, Inline: true, Default: str("default_value"), Enum: []string{}}}, + {"json_cue_6", `json:",omitempty,inline" cue:"default:default_value;enum:enum1,enum2"`, &tagOptions{Name: "", Optional: true, Inline: true, Default: str("default_value"), Enum: []string{"enum1", "enum2"}}}, + } + + g := &Generator{} + for _, tt := range tests { + got := g.parseTag(tt.tag) + assert.Equal(t, tt.opts, got, tt.name) + } +} + +func TestParseBasicTag(t *testing.T) { + tests := []struct { + name string + tag string + wantName string + wantOpt string + }{ + {"empty", "", "", ""}, + {"only_name", "name", "name", ""}, + {"name_and_opt", "name,opt,opt2", "name", "opt,opt2"}, + {"name_and_opt_2", "name,opt1;opt2.opt3", "name", "opt1;opt2.opt3"}, + {"name_and_opt_with_space", "name, opt", "name", " opt"}, + {"only_opt", ",opt,opt2", "", "opt,opt2"}, + } + + for _, tt := range tests { + gotName, gotOpt := parseTag(tt.tag) + assert.EqualValues(t, tt.wantName, gotName, tt.name) + assert.EqualValues(t, tt.wantOpt, gotOpt, tt.name) + } +} + +func TestBasicTagOptHas(t *testing.T) { + tests := []struct { + name string + tag string + opt string + want bool + }{ + {"empty", "", "", false}, + {"empty_opt", "name", "", false}, + {"empty_tag", "", "opt", false}, + {"single_opt", "name,opt", "opt", true}, + {"multi_opt", "name,opt1,opt2,opt3", "opt1", true}, + {"multi_opt_2", "name,opt1,opt2,opt3", "opt2", true}, + {"multi_opt_3", "name,opt1,opt2,opt3", "opt3", true}, + {"only_multi_opt", ",opt1,opt2,opt3", "opt1", true}, + {"only_multi_opt_2", ",opt1,opt2,opt3", "opt2", true}, + {"only_multi_opt_3", ",opt1,opt2,opt3", "opt3", true}, + } + + for _, tt := range tests { + _, opt := parseTag(tt.tag) + assert.Equal(t, tt.want, opt.Has(tt.opt), tt.name) + } +} + +func TestParseExtTag(t *testing.T) { + tests := []struct { + name string + tag string + want map[string]string + }{ + {"empty", "", map[string]string{}}, + {"only_key", "key", map[string]string{"key": ""}}, + {"one_kv", "key:value", map[string]string{"key": "value"}}, + {"multi_kv", "key1:value1;key2:value2", map[string]string{"key1": "value1", "key2": "value2"}}, + {"bool", "key1;key2", map[string]string{"key1": "", "key2": ""}}, + {"bool_and_kv", "key1;key2:value2", map[string]string{"key1": "", "key2": "value2"}}, + {"kv_and_bool", "key1:value1;key2", map[string]string{"key1": "value1", "key2": ""}}, + {"multi_kv_and_bool", "key1:value1;key2:value2;key3", map[string]string{"key1": "value1", "key2": "value2", "key3": ""}}, + {"escape_semicolon_value", `key1:value\;1`, map[string]string{"key1": "value;1"}}, + {"escape_semicolon_key", `key\;1:value1`, map[string]string{"key;1": "value1"}}, + {"escape_semicolon_pairs", `key\;1:value\;1;key3:va\lue3\`, map[string]string{"key;1": "value;1", "key3": `va\lue3\`}}, + {"escape_semicolon_last", `key\1:value1\`, map[string]string{`key\1`: `value1\`}}, + {"escape_semicolon_last_2", `k\ey1:va\lue1\1`, map[string]string{`k\ey1`: `va\lue1\1`}}, + {"escape_semicolon_last_3", `key1:value1\;`, map[string]string{"key1": `value1;`}}, + {"escape_colon_value", `key1:value\:1`, map[string]string{"key1": "value:1"}}, + {"escape_colon_key", `key\:1:value1`, map[string]string{"key:1": "value1"}}, + {"escape_colon_pairs", `key\:1:value\:1;key3:va\lue3\`, map[string]string{"key:1": "value:1", "key3": `va\lue3\`}}, + {"escape_colon_last", `key\1:value1\:`, map[string]string{`key\1`: `value1:`}}, + {"escape_colon_last_2", `k\ey1:va\lue1\:1`, map[string]string{`k\ey1`: `va\lue1:1`}}, + {"escape_colon_last_3", `key1:value1\:`, map[string]string{"key1": `value1:`}}, + {"invalid_pair", `key1:value1:invalid;key2:value2`, map[string]string{"key2": "value2"}}, + } + + for _, tt := range tests { + got := parseExtTag(tt.tag) + assert.EqualValues(t, tt.want, got, tt.name) + } +} + +func TestExtTagGet(t *testing.T) { + tests := []struct { + name string + tag string + key string + want string + }{ + {"empty", "", "", ""}, + {"empty_key", "key", "", ""}, + {"empty_tag", "", "key", ""}, + {"one_kv", "key:value", "key", "value"}, + {"multi_kv", "key1:value1;key2:value2", "key1", "value1"}, + {"multi_kv_2", "key1:value1;key2:value2", "key2", "value2"}, + {"bool", "key1;key2", "key1", ""}, + {"bool2", "key1;key2", "key2", ""}, + {"escape", "key1:value1\\;vv", "key1", "value1;vv"}, + {"escape_2", "key1:\"value2\"", "key1", `"value2"`}, + } + + for _, tt := range tests { + got := parseExtTag(tt.tag) + assert.EqualValues(t, tt.want, got.Get(tt.key), tt.name) + } +} + +func TestExtTagGetX(t *testing.T) { + tests := []struct { + name string + tag string + key string + want *string + }{ + {"empty", "", "", nil}, + {"empty_key", "key", "", nil}, + {"empty_tag", "", "key", nil}, + {"one_kv", "key:value", "key", str("value")}, + {"multi_kv", "key1:value1;key2:value2", "key1", str("value1")}, + {"multi_kv_2", "key1:value1;key2:value2", "key2", str("value2")}, + {"bool", "key1;key2", "key1", str("")}, + {"bool2", "key1;key2", "key2", str("")}, + {"escape", "key1:value1\\;vv", "key1", str("value1;vv")}, + {"escape_2", "key1:\"value2\"", "key1", str(`"value2"`)}, + } + + for _, tt := range tests { + got := parseExtTag(tt.tag) + assert.EqualValues(t, tt.want, got.GetX(tt.key), tt.name) + } +} + +func TestUnescapeSplit(t *testing.T) { + tests := []struct { + name string + s string + sep string + want []string + }{ + {"empty", "", "", []string{}}, + {"empty_sep", "key:value", "", []string{"k", "e", "y", ":", "v", "a", "l", "u", "e"}}, + {"one", "key:value", ":", []string{"key", "value"}}, + {"escape_sep", `key\:value`, ":", []string{"key:value"}}, + {"escape_sep_2", `key\:value\:`, ":", []string{"key:value:"}}, + {"escape_last", `key\:value\`, ":", []string{`key:value\`}}, + {"escape_multi", `key\:value\:;key2:value2`, ":", []string{"key:value:;key2", "value2"}}, + {"escape_multi_2", `key\:value\:;key2:value2`, ";", []string{`key\:value\:`, "key2:value2"}}, + } + + for _, tt := range tests { + got := unescapeSplit(tt.s, tt.sep) + assert.EqualValues(t, tt.want, got, tt.name) + } +} diff --git a/references/cuegen/testdata/invalid/default.go b/references/cuegen/testdata/invalid/default.go new file mode 100644 index 000000000..3f88b67f2 --- /dev/null +++ b/references/cuegen/testdata/invalid/default.go @@ -0,0 +1,22 @@ +/* +Copyright 2023 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package invalid + +type Default struct { + Field1 chan int `json:"field1"` + Field2 int `json:"field2" cue:"default:a"` +} diff --git a/references/cuegen/testdata/invalid/enum.go b/references/cuegen/testdata/invalid/enum.go new file mode 100644 index 000000000..b35d0e964 --- /dev/null +++ b/references/cuegen/testdata/invalid/enum.go @@ -0,0 +1,22 @@ +/* +Copyright 2023 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package invalid + +type Enum struct { + Field1 map[string]string `json:"field1" cue:"enum:1,2,3"` + Field2 int `json:"field2" cue:"enum:a,b,c"` +} diff --git a/references/cuegen/testdata/invalid/inline.go b/references/cuegen/testdata/invalid/inline.go new file mode 100644 index 000000000..d269ebb1b --- /dev/null +++ b/references/cuegen/testdata/invalid/inline.go @@ -0,0 +1,26 @@ +/* +Copyright 2023 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package invalid + +type InlineStruct struct { + Field1 string `json:"field1"` +} + +type SameNameInlined struct { + Field1 string `json:"field1"` + InlineStruct `json:",inline"` +} diff --git a/references/cuegen/testdata/invalid/non_string_map_key.go b/references/cuegen/testdata/invalid/non_string_map_key.go new file mode 100644 index 000000000..49f880598 --- /dev/null +++ b/references/cuegen/testdata/invalid/non_string_map_key.go @@ -0,0 +1,21 @@ +/* +Copyright 2023 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package invalid + +type NonStringMapKey struct { + Field1 map[int]string `json:"field1"` +} diff --git a/references/cuegen/testdata/invalid/recursive_struct.go b/references/cuegen/testdata/invalid/recursive_struct.go new file mode 100644 index 000000000..5a60668ae --- /dev/null +++ b/references/cuegen/testdata/invalid/recursive_struct.go @@ -0,0 +1,21 @@ +/* +Copyright 2023 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package invalid + +type RecursiveStruct struct { + Field1 *RecursiveStruct `json:"field1"` +} diff --git a/references/cuegen/testdata/valid.cue b/references/cuegen/testdata/valid.cue new file mode 100644 index 000000000..a41601b88 --- /dev/null +++ b/references/cuegen/testdata/valid.cue @@ -0,0 +1,321 @@ +package testdata + +BasicType: { + field1: string + field2: int + field3: bool + field4: float32 + field5: float64 + field6: int8 + field7: int16 + field8: int32 + field9: int64 + field10: uint + field11: uint8 + field12: uint16 + field13: uint32 + field14: uint64 + field15: uint64 + field16: uint8 + field17: rune + field18: { + ... + } + field19: { + ... + } +} +TagName: { + f1: string + f2: string + f3: string +} +SliceAndArray: { + field1: [...string] + field2: 3 * [string] + field3: [...int] + field4: 3 * [int] + field5: [...bool] + field6: 3 * [bool] + field7: [...float32] + field8: 3 * [float32] + field9: [...float64] + field10: 3 * [float64] + field11: bytes + field12: bytes +} +SmallStruct: { + field1: string + field2: string +} +AnonymousField: SmallStruct: { + field1: string + field2: string +} +ReferenceField: field1: null | { + field1: string + field2: string +} +StructField: { + field1: { + field1: string + field2: string + } + field2: null | { + field1: string + field2: string + } +} +EmbedStruct: { + field1: { + field1: string + field2: string + } + field2: { + field1: string + field2: string + field3: { + field1: string + field2: string + field3: { + field1: string + field2: string + field3: { + field1: string + field2: string + } + } + } + } + field3: [string]: [...string] + field4: uint +} +MapField: { + field1: [string]: string + field2: [string]: int + field3: { + ... + } + field4: [string]: { + field1: string + field2: string + } + field5: { + ... + } +} +EmptyStruct: {} +// Comment is a test struct1 +// Struct is a test struct2 +// Struct is a test struct3 +// Struct is a test struct4 +Comment: { + // Field1 comment + // Field1 doc + field1: string + // Field2 comment + // Field2 doc + field2: string + // Field3 comment + // Field3 doc + field3: string + // Field4 comment + field4: string + // Field5 doc + field5: { + // Field5.Field1 comment + field1: string + // Field5.Field2 doc + field2: string + // Field5.Field3 doc + field3: string + // Field5.Field4 doc + field4: string + // Field5.Field5 doc + field5: { + // Field5.Field5.Field1 comment + field1: string + // Field5.Field5.Field2 doc + field2: string + // Field5.Field5.Field3 doc + field3: string + // Field5.Field5.Field4 doc + field4: string + } + } + // Field6 doc + field6: [string]: [...string] + // Field7 comment + field7: [string]: [...string] + // Field8 comment + // Field8 doc + field8: [string]: [...string] + field9: [string]: [...string] + // Field10 comment + // Field10 doc1 + // Field10 doc2 + // Field10 doc3 + field10: [string]: [...string] + // Field11 doc + field11: { + // Field11.Field1 comment + field1: string + } + // Field12 doc + field12: { + // Field12.Field1 doc + field1: string + } + // Field13 doc + field13: field1: string + // Field14 doc + field14: [string]: string +} +Default: { + a1: *"abc" | string + // empty string + a2: *"" | string + b1: *true | bool + b2: *false | bool + c1: *123 | int + c2: *123 | int8 + c3: *123 | int16 + c4: *123 | int32 + c5: *123 | int64 + d1: *123 | uint + d2: *123 | uint8 + d3: *123 | uint16 + d4: *123 | uint32 + d5: *123 | uint64 + e1: *123.456 | float32 + e2: *123.456 | float64 +} +Enum: { + a: "abc" | "def" | "ghi" + b: 1 | 2 | 3 + c: true | false + d: 1.1 | 2.2 | 3.3 + e: "abc" | "def" | *"ghi" + f: 1 | *2 | 3 + g: true | *false + // if default value is first enum, '*' will not be added + h: 1.1 | 2.2 | 3.3 + i: "abc" +} +Unexported: { + field1: string + field2: { + field1: string + field3: { + field1: string + field3: string + } + } +} +// RequestVars is the vars for http request +// TODO: support timeout & tls +RequestVars: { + method: string + url: string + request: { + body: string + header: [string]: [...string] + trailer: [string]: [...string] + } +} +// ResponseVars is the vars for http response +ResponseVars: { + body: string + header: [string]: [...string] + trailer: [string]: [...string] + statusCode: int +} +// DoParams is the params for http request +DoParams: $params: { + method: string + url: string + request: { + body: string + header: [string]: [...string] + trailer: [string]: [...string] + } +} +// DoReturns returned struct for http response +DoReturns: $returns: { + body: string + header: [string]: [...string] + trailer: [string]: [...string] + statusCode: int +} +ResourceReturns: $returns: { + ... +} +InlineStruct1: { + // Field1 comment + // Field1 doc + field11: string + field12: string + // Field3 doc + field13: { + // Field3.Field1 comment + // Field3.Field1 doc + field11: string + // Field3.Field2 comment + field12: string + } +} +InlineStruct2: { + // Field1 doc + field21: string + // Field1 comment + // Field1 doc + field11: string + field12: string + // Field3 doc + field13: { + // Field3.Field1 comment + // Field3.Field1 doc + field11: string + // Field3.Field2 comment + field12: string + } +} +InlineStruct3: { + field31: string + // Field1 doc + field21: string + // Field1 comment + // Field1 doc + field11: string + field12: string + // Field3 doc + field13: { + // Field3.Field1 comment + // Field3.Field1 doc + field11: string + // Field3.Field2 comment + field12: string + } +} +Optional: { + field1?: string + field2: string + field3?: string + field4: string + field5: { + field1?: string + field2: string + } + field6?: { + field1?: string + field2: string + field3?: { + field1?: string + field2: string + } + } +} +Skip: { + field2: string + field4: string +} diff --git a/references/cuegen/testdata/valid.go b/references/cuegen/testdata/valid.go new file mode 100644 index 000000000..bbfc4f222 --- /dev/null +++ b/references/cuegen/testdata/valid.go @@ -0,0 +1,323 @@ +/* +Copyright 2023 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package testdata + +import ( + "crypto" + "net/http" + + "github.com/kubevela/pkg/cue/cuex/providers" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +type BasicType struct { + Field1 string `json:"field1"` + Field2 int `json:"field2"` + Field3 bool `json:"field3"` + Field4 float32 `json:"field4"` + Field5 float64 `json:"field5"` + Field6 int8 `json:"field6"` + Field7 int16 `json:"field7"` + Field8 int32 `json:"field8"` + Field9 int64 `json:"field9"` + Field10 uint `json:"field10"` + Field11 uint8 `json:"field11"` + Field12 uint16 `json:"field12"` + Field13 uint32 `json:"field13"` + Field14 uint64 `json:"field14"` + Field15 uintptr `json:"field15"` + Field16 byte `json:"field16"` + Field17 rune `json:"field17"` + Field18 interface{} `json:"field18"` + Field19 any `json:"field19"` +} + +type TagName struct { + Field1 string `json:"f1"` + Field2 string `json:"f2"` + Field3 string `json:"f3"` +} + +type SliceAndArray struct { + Field1 []string `json:"field1"` + Field2 [3]string `json:"field2"` + Field3 []int `json:"field3"` + Field4 [3]int `json:"field4"` + Field5 []bool `json:"field5"` + Field6 [3]bool `json:"field6"` + Field7 []float32 `json:"field7"` + Field8 [3]float32 `json:"field8"` + Field9 []float64 `json:"field9"` + Field10 [3]float64 `json:"field10"` + Field11 [3]byte `json:"field11"` + Field12 []byte `json:"field12"` +} + +type SmallStruct struct { + Field1 string `json:"field1"` + Field2 string `json:"field2"` +} + +type AnonymousField struct { + SmallStruct +} + +type ReferenceField struct { + Field1 *SmallStruct `json:"field1"` +} + +type StructField struct { + Field1 SmallStruct `json:"field1"` + Field2 *SmallStruct `json:"field2"` +} + +type EmbedStruct struct { + Field1 struct { + Field1 string `json:"field1"` + Field2 string `json:"field2"` + } `json:"field1"` + Field2 struct { + Field1 string `json:"field1"` + Field2 string `json:"field2"` + Field3 struct { + Field1 string `json:"field1"` + Field2 string `json:"field2"` + Field3 struct { + Field1 string `json:"field1"` + Field2 string `json:"field2"` + Field3 struct { + Field1 string `json:"field1"` + Field2 string `json:"field2"` + } `json:"field3"` + } `json:"field3"` + } `json:"field3"` + } `json:"field2"` + Field3 http.Header `json:"field3"` + Field4 crypto.Hash `json:"field4"` +} + +type MapField struct { + Field1 map[string]string `json:"field1"` + Field2 map[string]int `json:"field2"` + Field3 map[string]interface{} `json:"field3"` + Field4 map[string]SmallStruct `json:"field4"` + Field5 map[string]any `json:"field5"` +} + +type EmptyStruct struct{} + +type Interface interface { + Foo() +} + +// Comment is a test struct1 +/* Struct is a test struct2 */ +/* + Struct is a test struct3 + Struct is a test struct4 +*/ +type Comment struct { + // Field1 doc + Field1 string `json:"field1"` // Field1 comment + /* Field2 doc */ + Field2 string `json:"field2"` // Field2 comment + /* + Field3 doc + */ + Field3 string `json:"field3"` // Field3 comment + Field4 string `json:"field4"` // Field4 comment + + // Field5 doc + Field5 struct { + Field1 string `json:"field1"` // Field5.Field1 comment + // Field5.Field2 doc + Field2 string `json:"field2"` + /* Field5.Field3 doc */ + Field3 string `json:"field3"` + /* + Field5.Field4 doc + */ + Field4 string `json:"field4"` + // Field5.Field5 doc + Field5 struct { + Field1 string `json:"field1"` // Field5.Field5.Field1 comment + // Field5.Field5.Field2 doc + Field2 string `json:"field2"` + /* Field5.Field5.Field3 doc */ + Field3 string `json:"field3"` + /* + Field5.Field5.Field4 doc + */ + Field4 string `json:"field4"` + } `json:"field5"` + } `json:"field5"` + + // Field6 doc + Field6 http.Header `json:"field6"` + Field7 http.Header `json:"field7"` // Field7 comment + // Field8 doc + Field8 http.Header `json:"field8"` // Field8 comment + Field9 http.Header `json:"field9"` + + /* + Field10 doc1 + Field10 doc2 + Field10 doc3 + */ + Field10 http.Header `json:"field10"` // Field10 comment + + // Field11 doc + Field11 struct { + Field1 string `json:"field1"` // Field11.Field1 comment + } `json:"field11"` + + // Field12 doc + Field12 struct { + // Field12.Field1 doc + Field1 string `json:"field1"` + } `json:"field12"` + + // Field13 doc + Field13 struct { + Field1 string `json:"field1"` + } `json:"field13"` + + // Field14 doc + Field14 map[string]string `json:"field14"` +} + +type Default struct { + A1 string `json:"a1" cue:"default:abc"` + A2 string `json:"a2" cue:"default:"` // empty string + B1 bool `json:"b1" cue:"default:true"` + B2 bool `json:"b2" cue:"default:false"` + C1 int `json:"c1" cue:"default:123"` + C2 int8 `json:"c2" cue:"default:123"` + C3 int16 `json:"c3" cue:"default:123"` + C4 int32 `json:"c4" cue:"default:123"` + C5 int64 `json:"c5" cue:"default:123"` + D1 uint `json:"d1" cue:"default:123"` + D2 uint8 `json:"d2" cue:"default:123"` + D3 uint16 `json:"d3" cue:"default:123"` + D4 uint32 `json:"d4" cue:"default:123"` + D5 uint64 `json:"d5" cue:"default:123"` + E1 float32 `json:"e1" cue:"default:123.456"` + E2 float64 `json:"e2" cue:"default:123.456"` +} + +type Enum struct { + A string `json:"a" cue:"enum:abc,def,ghi"` + B int `json:"b" cue:"enum:1,2,3"` + C bool `json:"c" cue:"enum:true,false"` + D float64 `json:"d" cue:"enum:1.1,2.2,3.3"` + E string `json:"e" cue:"enum:abc,def,ghi;default:ghi"` + F int `json:"f" cue:"enum:1,2,3;default:2"` + G bool `json:"g" cue:"enum:true,false;default:false"` + H float64 `json:"h" cue:"enum:1.1,2.2,3.3;default:1.1"` // if default value is first enum, '*' will not be added + I string `json:"i" cue:"enum:abc"` +} + +type Unexported struct { + Field1 string `json:"field1"` + Field2 struct { + Field1 string `json:"field1"` + field2 string // unexported field will be ignored + Field3 struct { + Field1 string `json:"field1"` + field2 string // unexported field will be ignored + Field3 string `json:"field3"` + } `json:"field3"` + } `json:"field2"` + field3 string // unexported field will be ignored +} + +// RequestVars is the vars for http request +// TODO: support timeout & tls +type RequestVars struct { + Method string `json:"method"` + URL string `json:"url"` + Request struct { + Body string `json:"body"` + Header http.Header `json:"header"` + Trailer http.Header `json:"trailer"` + } `json:"request"` +} + +// ResponseVars is the vars for http response +type ResponseVars struct { + Body string `json:"body"` + Header http.Header `json:"header"` + Trailer http.Header `json:"trailer"` + StatusCode int `json:"statusCode"` +} + +// DoParams is the params for http request +type DoParams providers.Params[RequestVars] + +// DoReturns returned struct for http response +type DoReturns providers.Returns[ResponseVars] + +type ResourceReturns providers.Returns[*unstructured.Unstructured] + +type InlineStruct1 struct { + // Field1 doc + Field1 string `json:"field11"` // Field1 comment + Field2 string `json:"field12"` + // Field3 doc + Field3 struct { + // Field3.Field1 doc + Field1 string `json:"field11"` // Field3.Field1 comment + Field2 string `json:"field12"` // Field3.Field2 comment + } `json:"field13"` +} + +type InlineStruct2 struct { + // Field1 doc + Field1 string `json:"field21"` + InlineStruct1 `json:",inline"` // Field1 comment +} + +type InlineStruct3 struct { + Field1 string `json:"field31"` + InlineStruct2 `json:",inline"` +} + +type Optional struct { + Field1 string `json:"field1,omitempty"` + Field2 string `json:"field2"` + Field3 string `json:"field3,omitempty"` + Field4 string `json:"field4"` + Field5 struct { + Field1 string `json:"field1,omitempty"` + Field2 string `json:"field2"` + } `json:"field5"` + Field6 struct { + Field1 string `json:"field1,omitempty"` + Field2 string `json:"field2"` + Field3 struct { + Field1 string `json:"field1,omitempty"` + Field2 string `json:"field2"` + } `json:"field3,omitempty"` + } `json:"field6,omitempty"` +} + +type Skip struct { + Field1 string `json:"-"` + Field2 string `json:"field2"` + Field3 string `json:"-"` + Field4 string `json:"field4"` +} diff --git a/references/cuegen/util.go b/references/cuegen/util.go new file mode 100644 index 000000000..95bb5e6c3 --- /dev/null +++ b/references/cuegen/util.go @@ -0,0 +1,75 @@ +/* +Copyright 2023 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cuegen + +import ( + "fmt" + gotypes "go/types" + "strconv" + + cueast "cuelang.org/go/cue/ast" + cuetoken "cuelang.org/go/cue/token" +) + +func ident(name string, isDef bool) *cueast.Ident { + if isDef { + name = "#" + name + } + return cueast.NewIdent(name) +} + +func basicType(x *gotypes.Basic) cueast.Expr { + // byte is an alias for uint8 in go/types + + switch t := x.String(); t { + case "uintptr": + return ident("uint64", false) + case "byte": + return ident("uint8", false) + default: + return ident(t, false) + } +} + +func anyLit() cueast.Expr { + return &cueast.StructLit{Elts: []cueast.Decl{&cueast.Ellipsis{}}} +} + +func basicLabel(t *gotypes.Basic, v string) (cueast.Expr, error) { + switch { + case t.Info()&gotypes.IsInteger != 0: + if _, err := strconv.ParseInt(v, 10, 64); err != nil { + return nil, err + } + return &cueast.BasicLit{Kind: cuetoken.INT, Value: v}, nil + case t.Info()&gotypes.IsFloat != 0: + if _, err := strconv.ParseFloat(v, 64); err != nil { + return nil, err + } + return &cueast.BasicLit{Kind: cuetoken.FLOAT, Value: v}, nil + case t.Info()&gotypes.IsBoolean != 0: + b, err := strconv.ParseBool(v) + if err != nil { + return nil, err + } + return cueast.NewBool(b), nil + case t.Info()&gotypes.IsString != 0: + return cueast.NewString(v), nil + default: + return nil, fmt.Errorf("unsupported basic type %s", t) + } +} diff --git a/references/cuegen/util_test.go b/references/cuegen/util_test.go new file mode 100644 index 000000000..ddcab9205 --- /dev/null +++ b/references/cuegen/util_test.go @@ -0,0 +1,128 @@ +/* +Copyright 2023 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cuegen + +import ( + gotypes "go/types" + "math" + "strconv" + "testing" + + cueast "cuelang.org/go/cue/ast" + cuetoken "cuelang.org/go/cue/token" + "github.com/stretchr/testify/assert" +) + +func TestIdent(t *testing.T) { + tests := []struct { + name string + isDef bool + want string + }{ + {name: "test", isDef: true, want: "#test"}, + {name: "test", isDef: false, want: "test"}, + {name: "Test", isDef: true, want: "#Test"}, + {name: "Test", isDef: false, want: "Test"}, + } + + for _, tt := range tests { + assert.Equal(t, tt.want, ident(tt.name, tt.isDef).String()) + } +} + +func TestBasicType(t *testing.T) { + tests := []struct { + name string + typ *gotypes.Basic + want string + }{ + {name: "uintptr", typ: gotypes.Typ[gotypes.Uintptr], want: "uint64"}, + {name: "byte", typ: gotypes.Typ[gotypes.Byte], want: "uint8"}, + {name: "int", typ: gotypes.Typ[gotypes.Int], want: "int"}, + {name: "int8", typ: gotypes.Typ[gotypes.Int8], want: "int8"}, + {name: "int16", typ: gotypes.Typ[gotypes.Int16], want: "int16"}, + {name: "int32", typ: gotypes.Typ[gotypes.Int32], want: "int32"}, + {name: "int64", typ: gotypes.Typ[gotypes.Int64], want: "int64"}, + {name: "uint", typ: gotypes.Typ[gotypes.Uint], want: "uint"}, + {name: "uint8", typ: gotypes.Typ[gotypes.Uint8], want: "uint8"}, + {name: "uint16", typ: gotypes.Typ[gotypes.Uint16], want: "uint16"}, + {name: "uint32", typ: gotypes.Typ[gotypes.Uint32], want: "uint32"}, + {name: "uint64", typ: gotypes.Typ[gotypes.Uint64], want: "uint64"}, + {name: "float32", typ: gotypes.Typ[gotypes.Float32], want: "float32"}, + {name: "float64", typ: gotypes.Typ[gotypes.Float64], want: "float64"}, + {name: "string", typ: gotypes.Typ[gotypes.String], want: "string"}, + {name: "bool", typ: gotypes.Typ[gotypes.Bool], want: "bool"}, + } + + for _, tt := range tests { + assert.Equal(t, tt.want, basicType(tt.typ).(*cueast.Ident).String(), tt.name) + } +} + +func TestAnyLit(t *testing.T) { + assert.Equal(t, anyLit(), &cueast.StructLit{Elts: []cueast.Decl{&cueast.Ellipsis{}}}) +} + +func TestBasicLabel(t *testing.T) { + overflowInt64 := strconv.FormatInt(math.MaxInt64, 10) + "0" + overflowUint64 := strconv.FormatUint(math.MaxUint64, 10) + "0" + overflowFloat64 := strconv.FormatFloat(math.MaxFloat64, 'f', -1, 64) + "0" + + tests := []struct { + name string + typ *gotypes.Basic + v string + wantErr bool + want *cueast.BasicLit + }{ + {name: "int", typ: gotypes.Typ[gotypes.Int], v: "123", wantErr: false, want: &cueast.BasicLit{Kind: cuetoken.INT, Value: "123"}}, + {name: "int8", typ: gotypes.Typ[gotypes.Int8], v: "123", wantErr: false, want: &cueast.BasicLit{Kind: cuetoken.INT, Value: "123"}}, + {name: "int16", typ: gotypes.Typ[gotypes.Int16], v: "123", wantErr: false, want: &cueast.BasicLit{Kind: cuetoken.INT, Value: "123"}}, + {name: "int32", typ: gotypes.Typ[gotypes.Int32], v: "123", wantErr: false, want: &cueast.BasicLit{Kind: cuetoken.INT, Value: "123"}}, + {name: "int64", typ: gotypes.Typ[gotypes.Int64], v: "123", wantErr: false, want: &cueast.BasicLit{Kind: cuetoken.INT, Value: "123"}}, + {name: "uint", typ: gotypes.Typ[gotypes.Uint], v: "123", wantErr: false, want: &cueast.BasicLit{Kind: cuetoken.INT, Value: "123"}}, + {name: "uint8", typ: gotypes.Typ[gotypes.Uint8], v: "123", wantErr: false, want: &cueast.BasicLit{Kind: cuetoken.INT, Value: "123"}}, + {name: "uint16", typ: gotypes.Typ[gotypes.Uint16], v: "123", wantErr: false, want: &cueast.BasicLit{Kind: cuetoken.INT, Value: "123"}}, + {name: "uint32", typ: gotypes.Typ[gotypes.Uint32], v: "123", wantErr: false, want: &cueast.BasicLit{Kind: cuetoken.INT, Value: "123"}}, + {name: "uint64", typ: gotypes.Typ[gotypes.Uint64], v: "123", wantErr: false, want: &cueast.BasicLit{Kind: cuetoken.INT, Value: "123"}}, + {name: "float32", typ: gotypes.Typ[gotypes.Float32], v: "123.456", wantErr: false, want: &cueast.BasicLit{Kind: cuetoken.FLOAT, Value: "123.456"}}, + {name: "float64", typ: gotypes.Typ[gotypes.Float64], v: "123.456", wantErr: false, want: &cueast.BasicLit{Kind: cuetoken.FLOAT, Value: "123.456"}}, + {name: "string", typ: gotypes.Typ[gotypes.String], v: "abc", wantErr: false, want: &cueast.BasicLit{Kind: cuetoken.STRING, Value: `"abc"`}}, + {name: "bool", typ: gotypes.Typ[gotypes.Bool], v: "true", wantErr: false, want: cueast.NewBool(true)}, + {name: "bool", typ: gotypes.Typ[gotypes.Bool], v: "false", wantErr: false, want: cueast.NewBool(false)}, + {name: "int_error", typ: gotypes.Typ[gotypes.Int], v: "abc", wantErr: true}, + {name: "uint_error", typ: gotypes.Typ[gotypes.Uint], v: "abc", wantErr: true}, + {name: "float_error", typ: gotypes.Typ[gotypes.Float64], v: "abc", wantErr: true}, + {name: "bool_error", typ: gotypes.Typ[gotypes.Bool], v: "abc", wantErr: true}, + {name: "type_error", typ: gotypes.Typ[gotypes.Complex64], v: "abc", wantErr: true}, + {name: "int_overflow", typ: gotypes.Typ[gotypes.Int], v: overflowInt64, wantErr: true}, + {name: "uint_overflow", typ: gotypes.Typ[gotypes.Uint], v: overflowUint64, wantErr: true}, + {name: "int64_overflow", typ: gotypes.Typ[gotypes.Int64], v: overflowInt64, wantErr: true}, + {name: "uint64_overflow", typ: gotypes.Typ[gotypes.Uint64], v: overflowUint64, wantErr: true}, + {name: "float64_overflow", typ: gotypes.Typ[gotypes.Float64], v: overflowFloat64, wantErr: true}, + } + + for _, tt := range tests { + got, err := basicLabel(tt.typ, tt.v) + if tt.wantErr { + assert.Error(t, err, tt.name) + } else { + assert.NoError(t, err, tt.name) + assert.Equal(t, tt.want, got, tt.name) + } + } +}