File: generate.go

package info (click to toggle)
golang-github-cue-lang-cue 0.12.0.-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 19,072 kB
  • sloc: sh: 57; makefile: 17
file content (494 lines) | stat: -rw-r--r-- 16,101 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
// 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 gotypes

import (
	"bytes"
	"fmt"
	goformat "go/format"
	"os"
	"path/filepath"
	"slices"
	"sort"
	"strings"

	"cuelang.org/go/cue"
	"cuelang.org/go/cue/ast"
	"cuelang.org/go/cue/build"
	"cuelang.org/go/mod/module"
)

// Generate produces Go type definitions from exported CUE definitions.
// See the help text for `cue help exp gengotypes`.
func Generate(ctx *cue.Context, insts ...*build.Instance) error {
	// record which package instances have already been generated
	instDone := make(map[*build.Instance]bool)

	goPkgNamesDoneByDir := make(map[string]string)

	// ensure we don't modify the parameter slice
	insts = slices.Clip(insts)
	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 instDone[inst] {
			continue
		}
		instDone[inst] = true

		instVal := ctx.BuildInstance(inst)
		if err := instVal.Validate(); err != nil {
			return err
		}
		g := generator{
			pkgRoot:    instVal,
			defsAdded:  make(map[string]bool),
			importedAs: make(map[string]string),
		}
		iter, err := instVal.Fields(cue.Definitions(true))
		if err != nil {
			return err
		}
		for iter.Next() {
			if sel := iter.Selector(); sel.IsDefinition() {
				g.addDef(cue.MakePath(sel))
			}
		}
		// TODO: support ignoring an entire package via a @go(-) package attribute.
		// TODO: support ignoring an entire file via a @go(-) file attribute above a package clause.
		for len(g.defsList) > 0 {
			path := g.defsList[0]
			g.defsList = g.defsList[1:]

			val := instVal.LookupPath(path)
			goAttr := goValueAttr(val)
			goName := goNameFromPath(path, true)
			if goName == "" {
				return fmt.Errorf("unexpected path in defsList: %q", path.String())
			}
			if s, _ := goAttr.String(0); s != "" {
				if s == "-" {
					continue
				}
				goName = s
			}

			g.emitDocs(goName, val.Doc())
			g.appendf("type %s ", goName)
			if err := g.emitType(val, false); err != nil {
				return err
			}
			g.appendf("\n\n")
		}

		typesBuf := g.dst
		g.dst = nil

		// TODO: 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 !instDone[imp] && g.importedAs[imp.ImportPath] != "" {
				insts = append(insts, imp)
			}
		}

		g.appendf("// Code generated by \"cue exp gengotypes\"; DO NOT EDIT.\n\n")
		goPkgName := goPkgNameForInstance(inst, instVal)
		if prev, ok := goPkgNamesDoneByDir[inst.Dir]; ok && prev != goPkgName {
			return fmt.Errorf("cannot generate two Go packages in one directory; %s and %s", prev, goPkgName)
		} else {
			goPkgNamesDoneByDir[inst.Dir] = goPkgName
		}
		g.appendf("package %s\n\n", goPkgName)
		// TODO: imported := slices.Sorted(maps.Values(g.importedAs))
		var imported []string
		for _, path := range g.importedAs {
			imported = append(imported, path)
		}
		sort.Strings(imported)
		imported = slices.Compact(imported)
		if len(imported) > 0 {
			g.appendf("import (\n")
			for _, path := range imported {
				g.appendf("\t%q\n", path)
			}
			g.appendf(")\n")
		}
		g.appendf("%s", typesBuf)
		// The generated file is named after the CUE package, not the generated Go package,
		// as we can have multiple CUE packages in one directory all generating to one Go package.
		// To keep the filename short for common cases, if we are generating a CUE package
		// whose package name is implied from its import path, omit the package name element.
		basename := "cue_types_gen.go"
		ip := module.ParseImportPath(inst.ImportPath)
		ip1 := ip
		ip1.Qualifier = ""
		ip1.ExplicitQualifier = false
		ip1 = module.ParseImportPath(ip1.String())
		if ip.Qualifier != ip1.Qualifier {
			basename = fmt.Sprintf("cue_types_%s_gen.go", inst.PkgName)
		}
		outpath := filepath.Join(inst.Dir, basename)

		formatted, err := goformat.Source(g.dst)
		if err != nil {
			// Showing the generated Go code helps debug where the syntax error is.
			// This should only occur if our code generator is buggy.
			lines := bytes.Split(g.dst, []byte("\n"))
			var withLineNums []byte
			for i, line := range lines {
				withLineNums = fmt.Appendf(withLineNums, "% 4d: %s\n", i+1, line)
			}
			fmt.Fprintf(os.Stderr, "-- %s --\n%s\n--\n", filepath.ToSlash(outpath), withLineNums)
			return err
		}
		if err := os.WriteFile(outpath, formatted, 0o666); err != nil {
			return err
		}
	}
	return nil
}

// generator holds the state for generating Go code for one CUE package instance.
type generator struct {
	dst []byte

	// defsAdded records which definitions have already been added to [generator.defsList]
	// keyed by [cue.Path.String].
	defsAdded map[string]bool

	// defsList records the remaining definitions which need to be generated.
	// Any definition at the top level or underneath other definitions must be added here.
	defsList []cue.Path

	// importedAs records which CUE packages need to be imported as which Go packages 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 full CUE import paths; the values are their resulting 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 (g *generator) appendf(format string, args ...any) {
	g.dst = fmt.Appendf(g.dst, format, args...)
}

// addDef records that the definition at the given path needs to be generated
// as it is referenced by a Go type we have already generated.
func (g *generator) addDef(path cue.Path) {
	s := path.String()
	if g.defsAdded[s] {
		return
	}
	g.defsAdded[s] = true
	g.defsList = append(g.defsList, path)
}

// emitType generates a CUE value as a Go type.
// When possible, the Go type is emitted in the form of a reference.
// Otherwise, an inline Go type expression is used.
func (g *generator) emitType(val cue.Value, optional bool) error {
	goAttr := goValueAttr(val)
	// We prefer the form @go(Name,type=pkg.Baz) as it is explicit and extensible,
	// but we are also backwards compatible with @go(Name,pkg.Baz) as emitted by `cue get go`.
	attrType, _, _ := goAttr.Lookup(1, "type")
	if attrType == "" {
		attrType, _ = goAttr.String(1)
	}
	if attrType != "" {
		pkgPath, _, ok := cutLast(attrType, ".")
		if ok {
			// For "type=foo.Name", we need to ensure that "foo" is imported.
			g.importedAs[pkgPath] = pkgPath
			// For "type=foo/bar.Name", the selector is just "bar.Name".
			// Note that this doesn't support Go packages whose name does not match
			// the last element of their import path. That seems OK for now.
			_, attrType, _ = cutLast(attrType, "/")
		}
		g.appendf("%s", attrType)
		return nil
	}
	// TODO: support nullable types, such as `null | #SomeReference` and
	// `null | {foo: int}`.
	if g.emitTypeReference(val, optional) {
		return nil
	}

	switch k := val.IncompleteKind(); k {
	case cue.StructKind:
		if elem := val.LookupPath(cue.MakePath(cue.AnyString)); elem.Err() == nil {
			g.appendf("map[string]")
			if err := g.emitType(elem, false); err != nil {
				return err
			}
			break
		}
		// A disjunction of structs cannot be represented in Go, as it does not have sum types.
		// Fall back to a map of string to any, which is not ideal, but will work for any field.
		//
		// TODO: consider alternatives, such as:
		// * For `#StructFoo | #StructBar`, generate named types for each disjunct,
		//   and use `any` here as a sum type between them.
		// * For a disjunction of closed structs, generate a flat struct with the superset
		//   of all fields, akin to a C union.
		if op, _ := val.Expr(); op == cue.OrOp {
			g.appendf("map[string]any")
			break
		}
		// TODO: treat a single embedding like `{[string]: int}` like we would `[string]: int`
		if optional {
			g.appendf("*")
		}
		g.appendf("struct {\n")
		iter, err := val.Fields(cue.Definitions(true), cue.Optional(true))
		if err != nil {
			return err
		}
		for iter.Next() {
			sel := iter.Selector()
			val := iter.Value()
			if sel.IsDefinition() {
				// TODO: why does removing [cue.Definitions] above break the tests?
				continue
			}
			cueName := sel.String()
			cueName = strings.TrimRight(cueName, "?!")
			g.emitDocs(cueName, val.Doc())
			// TODO: should we ensure that optional fields are always nilable in Go?
			// On one hand this allows telling int64(0) apart from a missing field,
			// but on the other, it's often unnecessary and leads to clumsy types.
			// Perhaps add a @go() attribute parameter to require nullability.
			//
			// For now, only structs are always pointers when optional.
			// This is necessary to allow recursive Go types such as linked lists.
			// Pointers to structs are still OK in terms of UX, given that
			// one can do X.PtrY.Z without needing to do (*X.PtrY).Z.
			optional := sel.ConstraintType()&cue.OptionalConstraint != 0

			// We want the Go name from just this selector, even when it's not a definition.
			goName := goNameFromPath(cue.MakePath(sel), false)

			goAttr := val.Attribute("go")
			if s, _ := goAttr.String(0); s != "" {
				if s == "-" {
					continue
				}
				goName = s
			}

			g.appendf("%s ", goName)
			if err := g.emitType(val, optional); err != nil {
				return err
			}
			// TODO: should we generate cuego tags like `cue:"expr"`?
			// If not, at least move the /* CUE */ comments to the end of the line.
			omitEmpty := ""
			if optional {
				omitEmpty = ",omitempty"
			}
			g.appendf(" `json:\"%s%s\"`", cueName, omitEmpty)
			g.appendf("\n\n")
		}
		g.appendf("}")
	case cue.ListKind:
		// We mainly care about patterns like [...string].
		// Anything else can convert into []any as a fallback.
		g.appendf("[]")
		elem := val.LookupPath(cue.MakePath(cue.AnyIndex))
		if !elem.Exists() {
			// TODO: perhaps mention the original type.
			g.appendf("any /* CUE closed list */")
		} else if err := g.emitType(elem, false); err != nil {
			return err
		}

	case cue.NullKind:
		g.appendf("*struct{} /* CUE null */")
	case cue.BoolKind:
		g.appendf("bool")
	case cue.IntKind:
		g.appendf("int64")
	case cue.FloatKind:
		g.appendf("float64")
	case cue.StringKind:
		g.appendf("string")
	case cue.BytesKind:
		g.appendf("[]byte")

	case cue.NumberKind:
		// Can we do better for numbers?
		g.appendf("any /* CUE number; int64 or float64 */")

	case cue.TopKind:
		g.appendf("any /* CUE top */")

	// TODO: generate e.g. int8 where appropriate
	// TODO: uint64 would be marginally better than int64 for unsigned integer types

	default:
		// A disjunction of various kinds cannot be represented in Go, as it does not have sum types.
		// Also see the potential approaches in the TODO about disjunctions of structs.
		if op, _ := val.Expr(); op == cue.OrOp {
			g.appendf("any /* CUE disjunction: %s */", k)
			break
		}
		g.appendf("any /* TODO: IncompleteKind: %s */", k)
	}
	return nil
}

func cutLast(s, sep string) (before, after string, found bool) {
	if i := strings.LastIndex(s, sep); i >= 0 {
		return s[:i], s[i+len(sep):], true
	}
	return "", s, false
}

// goNameFromPath transforms a CUE path, such as "#foo.bar?",
// into a suitable name for a generated Go type, such as "Foo_bar".
// When defsOnly is true, all path elements must be definitions, or "" is returned.
func goNameFromPath(path cue.Path, defsOnly bool) string {
	export := true
	var sb strings.Builder
	for i, sel := range path.Selectors() {
		if defsOnly && !sel.IsDefinition() {
			return ""
		}
		if i > 0 {
			// To aid in readability, nested names are separated with underscores.
			sb.WriteString("_")
		}
		str := sel.String()
		str, hidden := strings.CutPrefix(str, "_")
		if hidden {
			// If any part of the path is hidden, we are not exporting.
			// TODO: we currently don't generate hidden definitions unless XXX
			export = false
		}
		// Leading or trailing characters for definitions, optional, or required
		// are not included as part of Go names.
		str = strings.TrimPrefix(str, "#")
		str = strings.TrimRight(str, "?!")
		sb.WriteString(str)
	}
	name := sb.String()
	if export {
		name = strings.Title(name)
	}
	// TODO: lowercase if not exporting
	return name
}

// goValueAttr is like [cue.Value.Attribute] with the string parameter "go",
// but it supports [cue.DeclAttr] attributes as well and not just [cue.FieldAttr].
//
// TODO: surely this is a shortcoming of the method above?
func goValueAttr(val cue.Value) cue.Attribute {
	attrs := val.Attributes(cue.ValueAttr)
	for _, attr := range attrs {
		if attr.Name() == "go" {
			return attr
		}
	}
	return cue.Attribute{}
}

// goPkgNameForInstance determines what to name a Go package generated from a CUE instance.
// By default this is the CUE package name, but it can be overriden by a @go() package attribute.
func goPkgNameForInstance(inst *build.Instance, instVal cue.Value) string {
	attr := goValueAttr(instVal)
	if s, _ := attr.String(0); s != "" {
		return s
	}
	return inst.PkgName
}

// emitTypeReference attempts to generate a CUE value as a Go type via a reference,
// either to a type in the same Go package, or to a type in an imported package.
func (g *generator) emitTypeReference(val cue.Value, optional bool) bool {
	// References to existing names, either from the same package or an imported package.
	root, path := val.ReferencePath()
	// TODO: surely there is a better way to check whether ReferencePath returned "no path",
	// such as a possible path.IsValid method?
	if len(path.Selectors()) == 0 {
		return false
	}
	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: deal with multiple packages existing in the same directory.
	unqualifiedPath := module.ParseImportPath(inst.ImportPath).Unqualified().String()

	var sb strings.Builder
	if optional && cue.Dereference(val).IncompleteKind() == cue.StructKind {
		sb.WriteString("*")
	}
	if root != g.pkgRoot {
		sb.WriteString(goPkgNameForInstance(inst, root))
		sb.WriteString(".")
	}

	// As a special case, some CUE standard library types are allowed as references
	// even though they aren't definitions.
	defsOnly := true
	switch fmt.Sprintf("%s.%s", unqualifiedPath, path) {
	case "time.Duration":
		// Note that CUE represents durations as strings, but Go as int64.
		// TODO: can we do better here, such as a custom duration type?
		g.appendf("string /* CUE time.Duration */")
		return true
	case "time.Time":
		defsOnly = false
	}

	name := goNameFromPath(path, defsOnly)
	if name == "" {
		return false // Not a path we are generating.
	}

	sb.WriteString(name)
	g.appendf("%s", sb.String())

	// We did use a reference; if the referenced name was from another package,
	// we need to ensure that package is imported.
	// Otherwise, we need to ensure that the referenced local definition is generated.
	if root != g.pkgRoot {
		g.importedAs[inst.ImportPath] = unqualifiedPath
	} else {
		g.addDef(path)
	}
	return true
}

// emitDocs generates the documentation comments attached to the following declaration.
func (g *generator) emitDocs(name string, groups []*ast.CommentGroup) {
	// TODO: place the comment group starting with `// $name ...` first.
	// TODO: ensure that the Go name is used in the godoc.
	for i, group := range groups {
		if i > 0 {
			g.appendf("//\n")
		}
		for _, line := range group.List {
			g.appendf("%s\n", line.Text)
		}
	}
}