Skip to content

Commit

Permalink
feat(gazelle): A bazel-gazelle language extension for rules_cue
Browse files Browse the repository at this point in the history
gazelle/cue contains a go_library which can be linked into a gazelle_binary to automate generation of bazel BUILD files for .cue files.

When a .cue file declares a package and the package name matches the directory in which it lives, it becomes part of a cue_library(name="cue_default_library", ...) target.

When a .cue file does not declare a package, it is turned into a cue_binary named after the file's base name, e.g. foo.cue becomes cue_binary(name="foo").
tnarg committed Apr 21, 2020
1 parent 62c7dca commit b49de6e
Showing 6 changed files with 486 additions and 0 deletions.
17 changes: 17 additions & 0 deletions BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
load("@bazel_gazelle//:def.bzl", "DEFAULT_LANGUAGES", "gazelle_binary", "gazelle")

gazelle_binary(
name = "gazelle_binary",
languages = DEFAULT_LANGUAGES + ["@com_github_tnarg_rules_cue//gazelle/cue:go_default_library"],
msan = "off",
pure = "off",
race = "off",
static = "off",
visibility = ["//visibility:public"],
)

# gazelle:prefix github.com/tnarg/rules_cue
gazelle(
name = "gazelle",
gazelle = "//:gazelle_binary",
)
24 changes: 24 additions & 0 deletions gazelle/cue/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")

go_library(
name = "go_default_library",
srcs = [
"config.go",
"cue.go",
"generate.go",
"resolve.go",
],
importpath = "github.com/tnarg/rules_cue/gazelle/cue",
visibility = ["//visibility:public"],
deps = [
"@bazel_gazelle//config:go_default_library",
"@bazel_gazelle//label:go_default_library",
"@bazel_gazelle//language:go_default_library",
"@bazel_gazelle//repo:go_default_library",
"@bazel_gazelle//resolve:go_default_library",
"@bazel_gazelle//rule:go_default_library",
"@com_github_iancoleman_strcase//:go_default_library",
"@org_cuelang_go//cue/ast:go_default_library",
"@org_cuelang_go//cue/parser:go_default_library",
],
)
98 changes: 98 additions & 0 deletions gazelle/cue/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package cuelang

import (
"flag"
"fmt"
"go/build"
"log"
"path"
"strings"

"github.com/bazelbuild/bazel-gazelle/config"
"github.com/bazelbuild/bazel-gazelle/rule"
)

type cueConfig struct {
// prefix is a prefix of an import path, used to generate importpath
// attributes. Set with -go_prefix or # gazelle:prefix.
prefix string
prefixRel string
}

// KnownDirectives returns a list of directive keys that this
// Configurer can interpret. Gazelle prints errors for directives that
// are not recoginized by any Configurer.
func (s *cueLang) KnownDirectives() []string {
return []string{"prefix"}
}

// RegisterFlags registers command-line flags used by the
// extension. This method is called once with the root configuration
// when Gazelle starts. RegisterFlags may set an initial values in
// Config.Exts. When flags are set, they should modify these values.
func (s *cueLang) RegisterFlags(fs *flag.FlagSet, cmd string, c *config.Config) {
c.Exts[cueName] = &cueConfig{}
}

// CheckFlags validates the configuration after command line flags are
// parsed. This is called once with the root configuration when
// Gazelle starts. CheckFlags may set default values in flags or make
// implied changes.
func (s *cueLang) CheckFlags(fs *flag.FlagSet, c *config.Config) error {
return nil
}

// Configure modifies the configuration using directives and other
// information extracted from a build file. Configure is called in
// each directory.
//
// c is the configuration for the current directory. It starts out as
// a copy of the configuration for the parent directory.
//
// rel is the slash-separated relative path from the repository root
// to the current directory. It is "" for the root directory itself.
//
// f is the build file for the current directory or nil if there is no
// existing build file.
func (s *cueLang) Configure(c *config.Config, rel string, f *rule.File) {
var conf *cueConfig
if raw, ok := c.Exts[cueName]; !ok {
conf = &cueConfig{}
} else {
tmp := *(raw.(*cueConfig))
conf = &tmp
}
c.Exts[cueName] = conf

// We follow the same pattern as the go language to allow
// vendoring of cue repositories.
if path.Base(rel) == "vendor" {
conf.prefix = ""
conf.prefixRel = rel
}

if f != nil {
for _, d := range f.Directives {
switch d.Key {
case "prefix":
if err := checkPrefix(d.Value); err != nil {
log.Print(err)
return
}
conf.prefix = d.Value
conf.prefixRel = rel
return
}
}
}
}

// checkPrefix checks that a string may be used as a prefix. We forbid local
// (relative) imports and those beginning with "/". We allow the empty string,
// but generated rules must not have an empty importpath.
func checkPrefix(prefix string) error {
if strings.HasPrefix(prefix, "/") || build.IsLocalImport(prefix) {
return fmt.Errorf("invalid prefix: %q", prefix)
}
return nil
}
77 changes: 77 additions & 0 deletions gazelle/cue/cue.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package cuelang

import (
"fmt"

"github.com/bazelbuild/bazel-gazelle/config"
"github.com/bazelbuild/bazel-gazelle/language"
"github.com/bazelbuild/bazel-gazelle/rule"
)

const (
cueName = "cue"
)

var _ = fmt.Printf

type cueLang struct{}

// NewLanguage returns an instace of the Gazelle plugin for rules_cue.
func NewLanguage() language.Language {
return &cueLang{}
}

func (cl *cueLang) Name() string { return cueName }

// Kinds returns a map of maps rule names (kinds) and information on
// how to match and merge attributes that may be found in rules of
// those kinds. All kinds of rules generated for this language may be
// found here.
func (cl *cueLang) Kinds() map[string]rule.KindInfo {
return map[string]rule.KindInfo{
"cue_library": {
MatchAttrs: []string{"importpath"},
NonEmptyAttrs: map[string]bool{
"deps": true,
"srcs": true,
},
MergeableAttrs: map[string]bool{
"srcs": true,
"importpath": true,
},
ResolveAttrs: map[string]bool{"deps": true},
},
"cue_binary": {
MatchAny: true,
NonEmptyAttrs: map[string]bool{
"deps": true,
"src": true,
},
MergeableAttrs: map[string]bool{
"escape": true,
"output_format": true,
"src": true,
},
ResolveAttrs: map[string]bool{"deps": true},
},
}
}

// Loads returns .bzl files and symbols they define. Every rule
// generated by GenerateRules, now or in the past, should be loadable
// from one of these files.
func (cl *cueLang) Loads() []rule.LoadInfo {
return []rule.LoadInfo{
{
Name: "@com_github_tnarg_rules_cue//cue:cue.bzl",
Symbols: []string{"cue_binary", "cue_library"},
},
}
}

// Fix repairs deprecated usage of language-specific rules in f. This
// is called before the file is indexed. Unless c.ShouldFix is true,
// fixes that delete or rename rules should not be performed.
func (s *cueLang) Fix(c *config.Config, f *rule.File) {
// Currently a noop
}
157 changes: 157 additions & 0 deletions gazelle/cue/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package cuelang

import (
"log"
"path"
"path/filepath"
"sort"
"strings"

"cuelang.org/go/cue/ast"
"cuelang.org/go/cue/parser"
"github.com/bazelbuild/bazel-gazelle/config"
"github.com/bazelbuild/bazel-gazelle/language"
"github.com/bazelbuild/bazel-gazelle/rule"
"github.com/iancoleman/strcase"
)

const (
cueDefaultLibrary = "cue_default_library"
)

// GenerateRules extracts build metadata from source files in a
// directory. GenerateRules is called in each directory where an
// update is requested in depth-first post-order.
//
// args contains the arguments for GenerateRules. This is passed as a
// struct to avoid breaking implementations in the future when new
// fields are added.
//
// empty is a list of empty rules that may be deleted after merge.
//
// gen is a list of generated rules that may be updated or added.
//
// Any non-fatal errors this function encounters should be logged
// using log.Print.
func (cl *cueLang) GenerateRules(args language.GenerateArgs) language.GenerateResult {
cueFiles := make(map[string]*ast.File)
for _, f := range append(args.RegularFiles, args.GenFiles...) {
// Only generate Cue entries for cue files (.cue)
if !strings.HasSuffix(f, ".cue") {
continue
}

pth := filepath.Join(args.Dir, f)
cueFile, err := parser.ParseFile(pth, nil)
if err != nil {
log.Printf("parsing cue file: path=%q, err=%+v", pth, err)
continue
}
cueFiles[f] = cueFile
}

expectedPkgName := path.Base(args.Rel)

// categorize cue files into binary and library sources
// cue_libary names are based on cue package name.
var cueLibSrcs []string
cueBinSrc := make(map[string]string)
cueImports := make(map[string][]string)

for fname, cueFile := range cueFiles {
var target string
pkg := cueFile.PackageName()
if pkg == "" {
target = binName(fname)
cueBinSrc[target] = fname
} else if pkg == expectedPkgName {
target = cueDefaultLibrary
cueLibSrcs = append(cueLibSrcs, fname)
} else {
log.Printf("ignoring cue file: path=%q, pkg=%q", fname, pkg)
}
for _, imprt := range cueFile.Imports {
imprt := strings.Trim(imprt.Path.Value, "\"")
cueImports[target] = append(cueImports[target], imprt)
}
}

var res language.GenerateResult
if cueLibSrcs != nil {
rule := rule.NewRule("cue_library", cueDefaultLibrary)
rule.SetAttr("srcs", cueLibSrcs)
rule.SetAttr("visibility", []string{"//visibility:public"})
rule.SetAttr("importpath", computeImportPath(args))
imprts := cueImports[cueDefaultLibrary]
sort.Strings(imprts)
rule.SetPrivateAttr(config.GazelleImportsKey, imprts)
res.Gen = append(res.Gen, rule)
}

for tgt, src := range cueBinSrc {
rule := rule.NewRule("cue_binary", tgt)
rule.SetAttr("src", src)
rule.SetAttr("visibility", []string{"//visibility:public"})
imprts := cueImports[tgt]
sort.Strings(imprts)
rule.SetPrivateAttr(config.GazelleImportsKey, imprts)
res.Gen = append(res.Gen, rule)
}

res.Imports = make([]interface{}, len(res.Gen))
for i, r := range res.Gen {
res.Imports[i] = r.PrivateAttr(config.GazelleImportsKey)
}

res.Empty = generateEmpty(args.File, cueLibSrcs, cueBinSrc)

return res
}

func computeImportPath(args language.GenerateArgs) string {
c := args.Config
var conf *cueConfig
if raw, ok := c.Exts[cueName]; ok {
conf = raw.(*cueConfig)
} else {
conf = &cueConfig{}
}

suffix, err := filepath.Rel(conf.prefixRel, args.Rel)
if err != nil {
log.Printf("Failed to compute importpath: rel=%q, prefixRel=%q, err=%+v", args.Rel, conf.prefixRel, err)
return args.Rel
}
if suffix == "." {
return conf.prefix
}

return filepath.Join(conf.prefix, suffix)
}

func binName(basename string) string {
parts := strings.Split(basename, ".")
return strcase.ToSnake(strings.Join(parts[:len(parts)-1], "_"))
}

func generateEmpty(f *rule.File, cueLibSrcs []string, cueBinSrc map[string]string) []*rule.Rule {
if f == nil {
return nil
}
var empty []*rule.Rule
for _, r := range f.Rules {
switch r.Kind() {
case "cue_library":
if r.Name() == cueDefaultLibrary && cueLibSrcs == nil {
empty = append(empty, rule.NewRule("cue_library", r.Name()))
}
case "cue_binary":
if _, ok := cueBinSrc[r.Name()]; !ok {
empty = append(empty, rule.NewRule("cue_binary", r.Name()))
}
default:
// ignore
}
}
return empty
}
113 changes: 113 additions & 0 deletions gazelle/cue/resolve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package cuelang

import (
"log"
"sort"

"github.com/bazelbuild/bazel-gazelle/config"
"github.com/bazelbuild/bazel-gazelle/label"
"github.com/bazelbuild/bazel-gazelle/repo"
"github.com/bazelbuild/bazel-gazelle/resolve"
"github.com/bazelbuild/bazel-gazelle/rule"
)

// Imports returns a list of ImportSpecs that can be used to import
// the rule r. This is used to populate RuleIndex.
//
// If nil is returned, the rule will not be indexed. If any non-nil
// slice is returned, including an empty slice, the rule will be
// indexed.
func (cl *cueLang) Imports(c *config.Config, r *rule.Rule, f *rule.File) []resolve.ImportSpec {
switch r.Kind() {
case "cue_library":
return []resolve.ImportSpec{
resolve.ImportSpec{
Lang: cueName,
Imp: r.AttrString("importpath"),
},
}
}
return nil
}

// Embeds returns a list of labels of rules that the given rule
// embeds. If a rule is embedded by another importable rule of the
// same language, only the embedding rule will be indexed. The
// embedding rule will inherit the imports of the embedded rule.
func (cl *cueLang) Embeds(r *rule.Rule, from label.Label) []label.Label {
// Cue doesn't have a concept of embedding as far as I know.
return nil
}

// Resolve translates imported libraries for a given rule into Bazel
// dependencies. A list of imported libraries is typically stored in a
// private attribute of the rule when it's generated (this interface
// doesn't dictate how that is stored or represented). Resolve
// generates a "deps" attribute (or the appropriate language-specific
// equivalent) for each import according to language-specific rules
// and heuristics.
func (cl *cueLang) Resolve(c *config.Config, ix *resolve.RuleIndex, rc *repo.RemoteCache, r *rule.Rule, importsRaw interface{}, from label.Label) {
if importsRaw == nil {
return
}
imports := importsRaw.([]string)
r.DelAttr("deps")
depSet := make(map[string]bool)
for _, imp := range imports {
if _, ok := stdlib[imp]; ok {
continue
}

res := ix.FindRulesByImport(
resolve.ImportSpec{
Lang: cueName,
Imp: imp,
}, cueName)
if res == nil {
log.Print("import error: from=%q, import=%q", from.String(), imp)
}
for _, entry := range res {
l := entry.Label.Rel(from.Repo, from.Pkg)
depSet[l.String()] = true
}
}
if len(depSet) > 0 {
deps := make([]string, 0, len(depSet))
for dep := range depSet {
deps = append(deps, dep)
}
sort.Strings(deps)
r.SetAttr("deps", deps)
}
}

var stdlib = map[string]bool{
"crypto/md5": true,
"crypto/sha1": true,
"crypto/sha256": true,
"crypto/sha512": true,
"encoding/base64": true,
"encoding/csv": true,
"encoding/hex": true,
"encoding/json": true,
"encoding/yaml": true,
"html": true,
"list": true,
"math": true,
"math/bits": true,
"net": true,
"path": true,
"regexp": true,
"strconv": true,
"strings": true,
"struct": true,
"text/tabwriter": true,
"text/template": true,
"time": true,
"tool": true,
"tool/cli": true,
"tool/exec": true,
"tool/file": true,
"tool/http": true,
"tool/os": true,
}

0 comments on commit b49de6e

Please sign in to comment.