package commands import ( "bytes" gerrors "errors" "fmt" "io" "io/fs" "os" "path/filepath" "strings" "text/template" "cuelang.org/go/cue" "cuelang.org/go/cue/ast" "cuelang.org/go/cue/cuecontext" "cuelang.org/go/cue/errors" cload "cuelang.org/go/cue/load" "cuelang.org/go/cue/parser" "github.com/google/go-cmp/cmp" "github.com/grafana/cuetsy" "github.com/grafana/grafana/pkg/cmd/grafana-cli/utils" "github.com/grafana/grafana/pkg/schema/load" ) // FIXME almost this whole file is a sloppy, one-off hack that just goes around actually making // the API we need. Parts need to be factored out appropriately. var ctx = cuecontext.New() // The only import statement we currently allow in any models.cue file const allowedImport = "github.com/grafana/grafana/packages/grafana-schema/src/schema" var importMap = map[string]string{ allowedImport: "@grafana/schema", } // Hard-coded list of paths to skip. Remove a particular file as we're ready // to rely on the TypeScript auto-generated by cuetsy for that particular file. var skipPaths = []string{ "public/app/plugins/panel/barchart/models.cue", "public/app/plugins/panel/bargauge/models.cue", "public/app/plugins/panel/canvas/models.cue", "public/app/plugins/panel/dashlist/models.cue", "public/app/plugins/panel/gauge/models.cue", "public/app/plugins/panel/histogram/models.cue", "public/app/plugins/panel/heatmap-new/models.cue", "public/app/plugins/panel/stat/models.cue", "public/app/plugins/panel/candlestick/models.cue", "public/app/plugins/panel/state-timeline/models.cue", "public/app/plugins/panel/status-history/models.cue", "public/app/plugins/panel/table/models.cue", "public/app/plugins/panel/text/models.cue", "public/app/plugins/panel/timeseries/models.cue", // All the cue files in this dir have to be individually excluded, even // though the generator currently smooshes them all together "packages/grafana-schema/src/schema/graph.cue", "packages/grafana-schema/src/schema/legend.cue", "packages/grafana-schema/src/schema/mudball.cue", "packages/grafana-schema/src/schema/table.cue", "packages/grafana-schema/src/schema/text.cue", "packages/grafana-schema/src/schema/tooltip.cue", } const prefix = "/" //nolint: gocyclo func (cmd Command) generateTypescript(c utils.CommandLine) error { root := c.String("grafana-root") if root == "" { return gerrors.New("must provide path to the root of a Grafana repository checkout") } var fspaths load.BaseLoadPaths var err error fspaths.BaseCueFS, err = populateMapFSFromRoot(paths.BaseCueFS, root, "") if err != nil { return err } fspaths.DistPluginCueFS, err = populateMapFSFromRoot(paths.DistPluginCueFS, root, "") if err != nil { return err } overlay, err := defaultOverlay(fspaths) if err != nil { return err } // Prep the cue load config clcfg := &cload.Config{ Overlay: overlay, // FIXME these module paths won't work for things not under our cue.mod - AKA third-party plugins // ModuleRoot: prefix, Module: "github.com/grafana/grafana", } // One-time load of the panel-plugin scuemata family def, for unifying to easily apply cuetsy attributes clcfg.Dir = "cue/scuemata" v := ctx.BuildInstance(cload.Instances(nil, clcfg)[0]) if v.Err() != nil { return v.Err() } ppf := v.LookupPath(cue.ParsePath("#PanelSchema")) _ = ppf // FIXME hardcoding paths to exclude is not the way to handle this excl := map[string]bool{ "cue.mod": true, "cue/scuemata": true, "packages/grafana-schema/src/scuemata/dashboard": true, "packages/grafana-schema/src/scuemata/dashboard/dist": true, } exclude := func(path string) bool { dir := filepath.Dir(path) if excl[dir] { return true } for _, p := range skipPaths { if path == p { return true } } return false } outfiles := make(map[string][]byte) cuetsify := func(in fs.FS) error { seen := make(map[string]bool) return fs.WalkDir(in, ".", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } dir := filepath.Dir(path) if d.IsDir() || filepath.Ext(d.Name()) != ".cue" || seen[dir] || exclude(path) { return nil } seen[dir] = true clcfg.Dir = dir // FIXME Horrible hack to figure out the identifier used for // imported packages - intercept the parser called by the loader to // look at the ast.Files on their way in to building. // Much better if we could work backwards from the cue.Value, // maybe even directly in cuetsy itself, and figure out when a // referenced object is "out of bounds". // var imports sync.Map var imports []*ast.ImportSpec clcfg.ParseFile = func(name string, src interface{}) (*ast.File, error) { f, err := parser.ParseFile(name, src, parser.ParseComments) if err != nil { return nil, err } imports = append(imports, f.Imports...) return f, nil } // FIXME loading in this way causes all files in a dir to be loaded // as a single cue.Instance or cue.Value, which makes it quite // difficult to map them _back_ onto the original file and generate // discrete .gen.ts files for each .cue input. However, going one // .cue file at a time and passing it as the first arg to // load.Instances() means that the other files are ignored // completely, causing references between these files to be // unresolved, and thus encounter a different kind of error. insts := cload.Instances(nil, clcfg) if len(insts) > 1 { panic("extra instances") } bi := insts[0] // dumpBuildInst(bi) v := ctx.BuildInstance(bi) if v.Err() != nil { return v.Err() } var b []byte f := &tsFile{} seen := make(map[string]bool) // FIXME explicitly mapping path patterns to conversion patterns // is exactly what we want to avoid switch { // panel plugin models.cue files case strings.Contains(path, "public/app/plugins"): for _, im := range imports { ip := strings.Trim(im.Path.Value, "\"") if ip != allowedImport { // TODO make a specific error type for this return errors.Newf(im.Pos(), "import %q not allowed, panel plugins may only import from %q", ip, allowedImport) } // TODO this approach will silently swallow the unfixable // error case where multiple files in the same dir import // the same package to a different ident if !seen[ip] { seen[ip] = true f.Imports = append(f.Imports, convertImport(im)) } } // val := v.LookupPath(cue.ParsePath("Panel.lineages[0][0]")) // Extract the latest schema and its version number f.V = &tsModver{} lins := v.LookupPath(cue.ParsePath("Panel.lineages")) f.V.Lin, _ = lins.Len().Int64() f.V.Lin = f.V.Lin - 1 schs := lins.LookupPath(cue.MakePath(cue.Index(int(f.V.Lin)))) f.V.Sch, _ = schs.Len().Int64() f.V.Sch = f.V.Sch - 1 latest := schs.LookupPath(cue.MakePath(cue.Index(int(f.V.Sch)))) sch := latest.UnifyAccept(ppf, latest) b, err = cuetsy.Generate(sch, cuetsy.Config{}) default: b, err = cuetsy.Generate(v, cuetsy.Config{}) } if err != nil { return err } f.Body = string(b) var buf bytes.Buffer err = tsTemplate.Execute(&buf, f) outfiles[strings.Replace(path, ".cue", ".gen.ts", -1)] = buf.Bytes() return err }) } err = cuetsify(fspaths.BaseCueFS) if err != nil { return gerrors.New(errors.Details(err, nil)) } err = cuetsify(fspaths.DistPluginCueFS) if err != nil { return gerrors.New(errors.Details(err, nil)) } diff := c.Bool("diff") var derr bool for of, b := range outfiles { p := filepath.Join(root, of) if diff { if _, err := os.Stat(p); err != nil { if errors.Is(err, os.ErrNotExist) { fmt.Printf("%s: no generated code file to compare against\n", p) derr = true continue } return fmt.Errorf("%s: %w", p, err) } f, err := os.Open(filepath.Clean(p)) if err != nil { return fmt.Errorf("%s: %w", p, err) } ob, err := io.ReadAll(f) if err != nil { return err } dstr := cmp.Diff(string(ob), string(b)) if dstr != "" { derr = true fmt.Printf("%s would have changed:\n%s\n", p, dstr) } } else { err := os.WriteFile(p, b, 0644) if err != nil { return err } } } if derr { return errors.New("some files changed") } return nil } func convertImport(im *ast.ImportSpec) *tsImport { tsim := &tsImport{ Pkg: importMap[allowedImport], } if im.Name != nil && im.Name.String() != "" { tsim.Ident = im.Name.String() } else { sl := strings.Split(im.Path.Value, "/") final := sl[len(sl)-1] if idx := strings.Index(final, ":"); idx != -1 { tsim.Pkg = final[idx:] } else { tsim.Pkg = final } } return tsim } func defaultOverlay(p load.BaseLoadPaths) (map[string]cload.Source, error) { overlay := make(map[string]cload.Source) if err := toOverlay(prefix, p.BaseCueFS, overlay); err != nil { return nil, err } if err := toOverlay(prefix, p.DistPluginCueFS, overlay); err != nil { return nil, err } return overlay, nil } func toOverlay(prefix string, vfs fs.FS, overlay map[string]cload.Source) error { if !filepath.IsAbs(prefix) { return fmt.Errorf("must provide absolute path prefix when generating cue overlay, got %q", prefix) } err := fs.WalkDir(vfs, ".", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } f, err := vfs.Open(path) if err != nil { return err } defer func(f fs.File) { err := f.Close() if err != nil { return } }(f) b, err := io.ReadAll(f) if err != nil { return err } overlay[filepath.Join(prefix, path)] = cload.FromBytes(b) return nil }) if err != nil { return err } return nil } type tsFile struct { V *tsModver Imports []*tsImport Body string } type tsModver struct { Lin, Sch int64 } type tsImport struct { Ident string Pkg string } var tsTemplate = template.Must(template.New("cuetsygen").Parse( `//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // This file was autogenerated by cuetsy. DO NOT EDIT! //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ {{range .Imports}} import * as {{.Ident}} from '{{.Pkg}}';{{end}} {{if .V}} export const modelVersion = Object.freeze([{{ .V.Lin }}, {{ .V.Sch }}]); {{end}} {{.Body}}`))