diff --git a/cmd/cue/cmd/exp.go b/cmd/cue/cmd/exp.go new file mode 100644 index 000000000..c70232fec --- /dev/null +++ b/cmd/cue/cmd/exp.go @@ -0,0 +1,265 @@ +// Copyright 2024 CUE 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 cmd + +import ( + "fmt" + "go/format" + "os" + "path/filepath" + "sort" + "strings" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/ast" + "cuelang.org/go/cue/build" + "cuelang.org/go/cue/load" + "cuelang.org/go/mod/module" + "github.com/spf13/cobra" +) + +func newExpCmd(c *Command) *cobra.Command { + cmd := &cobra.Command{ + // Experimental commands are hidden by design. + Hidden: true, + + Use: "exp [arguments]", + Short: "experimental commands", + Long: `exp groups commands which are still in an experimental stage. + +Experimental commands may be changed or removed at any time, +as the objective tends to be to gain experience and then move the feature elsewhere. +`, + RunE: mkRunE(c, func(cmd *Command, args []string) error { + stderr := cmd.Stderr() + if len(args) == 0 { + fmt.Fprintln(stderr, "exp must be run as one of its subcommands") + } else { + fmt.Fprintf(stderr, "exp must be run as one of its subcommands: unknown subcommand %q\n", args[0]) + } + fmt.Fprintln(stderr, "Run 'cue help exp' for known subcommands.") + return ErrPrintedError + }), + } + + cmd.AddCommand(newExpGenGoTypesCmd(c)) + return cmd +} + +func newExpGenGoTypesCmd(c *Command) *cobra.Command { + cmd := &cobra.Command{ + Use: "gengotypes", + Short: "generate Go types from CUE definitions", + Long: `XXX write a summary once the feature set is finished +`, + RunE: mkRunE(c, runExpGenGoTypes), + } + + return cmd +} + +func runExpGenGoTypes(cmd *Command, args []string) error { + insts := load.Instances(args, &load.Config{}) + done := make(map[*build.Instance]bool) + for len(insts) > 0 { // we append imports to this list + inst := insts[0] + insts = insts[1:] + if err := inst.Err; err != nil { + return err + } + if done[inst] { + continue + } + println(inst.ImportPath) + done[inst] = true + + val := cmd.ctx.BuildInstance(inst) + if err := val.Validate(); err != nil { + return err + } + iter, err := val.Fields(cue.Definitions(true)) + if err != nil { + return err + } + p := printer{ + pkgRoot: val, + importedAs: make(map[string]string), + } + for iter.Next() { + sel := iter.Selector() + if !sel.IsDefinition() { + continue + } + // TODO(mvdan): surely there is a better way to handle definition hashes. + name := strings.TrimPrefix(sel.String(), "#") + val := iter.Value() + p.emitDocs(name, val.Doc()) + p.appendf("type %s ", name) + if err := p.emitType(val); err != nil { + return err + } + p.appendf("\n\n") + } + + typesBuf := p.dst + p.dst = nil + + // TODO(mvdan): we should refuse to generate for packages which are not + // part of the main module, as they may be inside the read-only module cache. + for _, imp := range inst.Imports { + if !done[imp] && p.importedAs[imp.ImportPath] != "" { + insts = append(insts, imp) + } + } + + p.appendf("// Code generated by \"cue exp gengotypes\"; DO NOT EDIT.\n\n") + p.appendf("package %s\n\n", inst.PkgName) + // TODO(mvdan): maps.Keys etc + var imported []string + for _, path := range p.importedAs { + imported = append(imported, path) + } + // TODO: need to remove dupes here too + sort.Strings(imported) + if len(imported) > 0 { + p.appendf("import (\n") + for _, path := range imported { + p.appendf("\t%q\n", path) + } + p.appendf(")\n") + } + p.appendf("%s", typesBuf) + formatted, err := format.Source(p.dst) + if err != nil { + fmt.Fprintf(os.Stderr, "-- %s/cue_gen.go --\n%s\n--\n", inst.Dir, p.dst) + return err + } + outfile := filepath.Join(inst.Dir, "cue_gen.go") + if err := os.WriteFile(outfile, formatted, 0o666); err != nil { + return err + } + } + return nil +} + +type printer struct { + dst []byte + + // importedAs records which packages need to be importedAs in the generated Go package. + // This is collected as we emit types, given that some CUE fields and types are omitted + // and we don't want to end up with unused Go imports. + // + // The keys are the full CUE import paths; the values are the unqualified Go import paths. + importedAs map[string]string + + // pkgRoot is the root value of the CUE package, necessary to tell if a referenced value + // belongs to the current package or not. + pkgRoot cue.Value +} + +func (p *printer) appendf(format string, args ...any) { + p.dst = fmt.Appendf(p.dst, format, args...) +} + +func (p *printer) emitType(val cue.Value) error { + // References to existing names, either from the same package or an imported package. + // TODO(mvdan): surely there is a better way to check whether ReferencePath returned "no path", + // such as a possible path.IsValid method? + if root, path := val.ReferencePath(); len(path.Selectors()) > 0 { + if root != p.pkgRoot { + inst := root.BuildInstance() + + // Go has no notion of qualified import paths; if a CUE file imports + // "foo.com/bar:qualified", we import just "foo.com/bar" on the Go side. + // TODO(mvdan): deal with multiple packages existing in the same directory. + parts := module.ParseImportPath(inst.ImportPath) + p.importedAs[inst.ImportPath] = parts.Unqualified().String() + + p.appendf("%s.", inst.PkgName) + } + // TODO(mvdan): surely there is a better way to handle definition hashes. + p.appendf("%s", strings.ReplaceAll(path.String(), "#", "")) + return nil + } + // Inline types. + switch k := val.IncompleteKind(); k { + case cue.StructKind: + if elem := val.LookupPath(cue.MakePath(cue.AnyString)); elem.Err() == nil { + p.appendf("map[string]") + if err := p.emitType(elem); err != nil { + return err + } + break + } + // TODO: treat a single embedding like `{[string]: int}` like we would `[string]: int` + p.appendf("struct {\n") + iter, err := val.Fields(cue.Optional(true)) + if err != nil { + return err + } + for iter.Next() { + sel := iter.Selector() + val := iter.Value() + cueName := sel.Unquoted() + p.emitDocs(cueName, val.Doc()) + optional := sel.ConstraintType()&cue.OptionalConstraint != 0 + + goName := strings.Title(cueName) + goAttr := val.Attribute("go") + if s, _ := goAttr.String(0); s != "" { + goName = s + } + + p.appendf("%s ", goName) + if err := p.emitType(val); err != nil { + return err + } + p.appendf(" `json:\"%s", cueName) + if optional { + p.appendf(",omitempty") + } + p.appendf("\"`") + p.appendf("\n\n") + } + p.appendf("}") + case cue.ListKind: + // We mainly care about patterns like [...string]. + // Anything else can convert into []any as a fallback. + p.appendf("[]") + elem := val.LookupPath(cue.MakePath(cue.AnyIndex)) + if err := p.emitType(elem); err != nil { + return err + } + case cue.StringKind: + p.appendf("string") + case cue.IntKind: + p.appendf("int64") + default: + p.appendf("any /* TODO: IncompleteKind: %s */", k) + } + return nil +} + +func (p *printer) emitDocs(name string, groups []*ast.CommentGroup) { + // TODO(mvdan): place the comment group starting with `// $name ...` first. + for i, group := range groups { + if i > 0 { + p.appendf("//\n") + } + for _, line := range group.List { + p.appendf("%s\n", line.Text) + } + } +} diff --git a/cmd/cue/cmd/root.go b/cmd/cue/cmd/root.go index ae48255f1..04ef81e3d 100644 --- a/cmd/cue/cmd/root.go +++ b/cmd/cue/cmd/root.go @@ -234,6 +234,7 @@ func New(args []string) (*Command, error) { newVetCmd(c), // Hidden + newExpCmd(c), newAddCmd(c), newLSPCmd(c), } { diff --git a/cmd/cue/cmd/testdata/script/exp_gengotypes.txtar b/cmd/cue/cmd/testdata/script/exp_gengotypes.txtar new file mode 100644 index 000000000..14bc89fbe --- /dev/null +++ b/cmd/cue/cmd/testdata/script/exp_gengotypes.txtar @@ -0,0 +1,241 @@ +exec cue exp gengotypes ./root +# ! stderr . + +# Check how many files were generated, and see that it aligns with how many files we expect. +find-files . +stdout -count=3 'cue_gen.go$' +stdout -count=3 'cue_gen.go.want$' +# No Go file is generated for imported itself, as it's only loaded as part of subpackage instances. +! stdout imported${/}gen_go.cue +# No bad or unused packages should have been generated at all. +! stdout 'bad_.*${/}gen_go.cue' +! stdout 'unused.*${/}gen_go.cue' + +# Check the contents of the generated files. +cmp root/cue_gen.go root/cue_gen.go.want +cmp imported/subinst/cue_gen.go imported/subinst/cue_gen.go.want +cmp imported/indirect/cue_gen.go imported/indirect/cue_gen.go.want + +# The resulting Go should work correctly. +# TODO: for now we just check that it builds; check that all types work +# with cue.Value.Decode and cue.Context.Encode. +go build ./... + +-- go.mod -- +module "foo.test/bar" + +go 1.22 +-- cue.mod/module.cue -- +module: "foo.test/bar" +language: version: "v0.11.0" +-- root/root.cue -- +package root + +// These should be ignored as they are not public definitions. +regular: string +_hidden: string +_#hiddenDef: string + +#emptyStruct: {} + +#localStruct: { + // TODO(mvdan): keep the embedding on the Go side to avoid repeating types + #embeddedStruct + + regular: int + optional?: int + required!: int + withAttr: int @go(WithAttrChangedName) + + // withDoc is a great field. + // + // It deserves multiple paragraphs of documentation + // as there is a lot to write about it. + withDoc: int + + withInlineDoc: int // this is an inline comment + + withInnerDoc: { + // This is documentation inside a struct, but not attached to any of its fields. + + innerDocField: int + } +} + +// Actually, this field needed even more documentation. +#localStruct: withDoc: _ + +#embeddedStruct: { + embedded1: int + { + embedded2: int + } +} +-- root/types.cue -- +package root + +import "time" + +#types: { + // The field names below are capitalized to avoid name clashes. + + Null: null + Bool: bool + Int: int + Float: float + String: string + Bytes: bytes + + Number: number + Uint: uint + Int8: int8 + Rune: rune + + IntList: [...int] + IntMap: [string]: int + + Time: time.Time + Duration: time.Duration +} +-- root/import.cue -- +package root + +import ( + "foo.test/bar/imported/subinst:imported" + "foo.test/bar/imported/unused" +) + +#remoteStructs: { + inst: imported.#InstanceStruct +} + +_unusedImport: unused.#Unused +-- root/cue_gen.go.want -- +// Code generated by "cue exp gengotypes"; DO NOT EDIT. + +package root + +import ( + "foo.test/bar/imported/subinst" + "time" +) + +type remoteStructs struct { + Inst imported.InstanceStruct `json:"inst"` +} + +type emptyStruct struct { +} + +type types struct { + Null any/* TODO: IncompleteKind: null */ `json:"Null"` + + Bool any/* TODO: IncompleteKind: bool */ `json:"Bool"` + + Int int64 `json:"Int"` + + Float any/* TODO: IncompleteKind: float */ `json:"Float"` + + String string `json:"String"` + + Bytes any/* TODO: IncompleteKind: bytes */ `json:"Bytes"` + + Number any/* TODO: IncompleteKind: number */ `json:"Number"` + + Uint int64 `json:"Uint"` + + Int8 int64 `json:"Int8"` + + Rune int64 `json:"Rune"` + + IntList []int64 `json:"IntList"` + + IntMap map[string]int64 `json:"IntMap"` + + Time time.Time `json:"Time"` + + Duration time.Duration `json:"Duration"` +} + +type localStruct struct { + Regular int64 `json:"regular"` + + Optional int64 `json:"optional,omitempty"` + + Required int64 `json:"required"` + + WithAttrChangedName int64 `json:"withAttr"` + + // withDoc is a great field. + // + // It deserves multiple paragraphs of documentation + // as there is a lot to write about it. + // + // Actually, this field needed even more documentation. + WithDoc int64 `json:"withDoc"` + + WithInlineDoc int64 `json:"withInlineDoc"` + + Embedded1 int64 `json:"embedded1"` + + Embedded2 int64 `json:"embedded2"` + + WithInnerDoc struct { + InnerDocField int64 `json:"innerDocField"` + } `json:"withInnerDoc"` +} + +type embeddedStruct struct { + Embedded1 int64 `json:"embedded1"` + + Embedded2 int64 `json:"embedded2"` +} +-- imported/imported.cue -- +package imported + +import "foo.test/bar/imported/indirect" + +#InstanceStruct: { + instanceField: int + + indirectField: indirect.#Indirect +} +-- imported/subinst/imported.cue -- +package imported + +// TODO(mvdan): if instanceStruct here is not capitalized, the Go imported reference won't work. + +#InstanceStruct: _ +-- imported/subinst/cue_gen.go.want -- +// Code generated by "cue exp gengotypes"; DO NOT EDIT. + +package imported + +import ( + "foo.test/bar/imported/indirect" +) + +type InstanceStruct struct { + InstanceField int64 `json:"instanceField"` + + IndirectField indirect.Indirect `json:"indirectField"` +} +-- imported/indirect/indirect.cue -- +package indirect + +#Indirect: int +-- imported/indirect/cue_gen.go.want -- +// Code generated by "cue exp gengotypes"; DO NOT EDIT. + +package indirect + +type Indirect int64 +-- imported/unused/unused.cue -- +package unused + +#Unused: int +-- bad_syntax/pkg.cue -- +package bad_syntax + +// This CUE package is not referenced nor used anywhere, so it should not be loaded. +{ bad syntax