Skip to content

Commit

Permalink
cmd/cue: add exp gengotypes
Browse files Browse the repository at this point in the history
Signed-off-by: Daniel Martí <[email protected]>
Change-Id: Ifd21c87196643fdb44900f9258493b67d614e11a
Dispatch-Trailer: {"type":"trybot","CL":1206583,"patchset":1,"ref":"refs/changes/83/1206583/1","targetBranch":"master"}
  • Loading branch information
mvdan authored and cueckoo committed Jan 3, 2025
1 parent 04d90f3 commit 283b740
Show file tree
Hide file tree
Showing 3 changed files with 507 additions and 0 deletions.
265 changes: 265 additions & 0 deletions cmd/cue/cmd/exp.go
Original file line number Diff line number Diff line change
@@ -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 <cmd> [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)
}
}
}
1 change: 1 addition & 0 deletions cmd/cue/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ func New(args []string) (*Command, error) {
newVetCmd(c),

// Hidden
newExpCmd(c),
newAddCmd(c),
newLSPCmd(c),
} {
Expand Down
Loading

0 comments on commit 283b740

Please sign in to comment.