Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions cmd/cue/cmd/testdata/script/inject_cross_package.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Test that @tag(name,scope=mod) injects tag values into fields in imported
# packages within the same module (issue #1070).

# scope=mod: tag injection works even when the tagged field is in an imported package.
exec cue eval -t name=foo ./schema/v1alpha1
cmp stdout expect-v1alpha1

# scope=mod: works with the -e flag to extract a specific field.
exec cue eval -t name=foo -e test ./schema/v1alpha1
cmp stdout expect-v1alpha1-e

# scope=mod tags can be injected when evaluating multiple packages at once.
exec cue eval -t name=foo ./schema/...
cmp stdout expect-all

# Without scope=mod, cross-package tag injection still fails (regression guard):
# a package whose import chain has no scope=mod tag for "name" should error.
! exec cue eval -t name=foo ./schema/v1alpha2
stderr 'no tag for "name"'

# Invalid scope value is an error.
! exec cue eval ./invalid
stderr 'invalid tag scope "bad"'

-- cue.mod/module.cue --
module: "mod.test/test"
language: version: "v0.9.0"

-- schema/base/base.cue --
package base

#metadata: {
name: string @tag(name,scope=mod)
}

#base: {
metadata: #metadata
}

-- schema/v1alpha1/test.cue --
package v1alpha1

import "mod.test/test/schema/base"

test: base.#base & {
metadata: name: string
}

-- schema/v1alpha2/test.cue --
package v1alpha2

// This package does NOT import base, so there is no scope=mod tag for "name"
// anywhere in its import chain.
x: string @tag(other)

-- invalid/invalid.cue --
package invalid

x: string @tag(foo,scope=bad)

-- expect-v1alpha1 --
test: {
metadata: {
name: "foo"
}
}
-- expect-v1alpha1-e --
metadata: {
name: "foo"
}
-- expect-all --
#metadata: {
name: "foo"
}
#base: {
metadata: {
name: "foo"
}
}
// ---
test: {
metadata: {
name: "foo"
}
}
// ---
x: string
24 changes: 24 additions & 0 deletions cue/load/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,30 @@ func Instances(args []string, c *Config) []*build.Instance {
tg.tags = append(tg.tags, tags...)
}

// Collect module-scoped tags (scope=mod) from transitive imports within
// the same module. These tags are injected even when their package is
// not a root package but is merely imported.
if c.Module != "" {
// `capacity` is over-estimated because it counts shared and out-of-module
// deps, but it avoids rehashing as findModuleScopedTags adds entries.
capacity := len(a)
for _, p := range a {
capacity += len(p.Deps)
}
visited := make(map[string]struct{}, capacity)
// Seed visited with root package paths to avoid double-processing.
for _, p := range a {
visited[p.ImportPath] = struct{}{}
}
for _, p := range a {
tags, err := findModuleScopedTags(p, c.Module, visited)
if err != nil {
p.ReportError(err)
}
tg.tags = append(tg.tags, tags...)
}
}

// TODO(api): have API call that returns an error which is the aggregate
// of all build errors. Certain errors, like these, hold across builds.
if err := tg.injectTags(c.Tags); err != nil {
Expand Down
61 changes: 60 additions & 1 deletion cue/load/tags.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,25 @@ func varToString(s string, err error) (ast.Expr, error) {
return ast.NewString(s), nil
}

// tagScope controls in which packages a tag is applied.
type tagScope int

const (
// tagScopePkg is the default scope: the tag is only applied when its
// package is specified directly on the command line (a "root" package).
tagScopePkg tagScope = iota

// tagScopeMod causes the tag to be applied even when the package is merely
// imported, as long as the package belongs to the same module as the root
// package. Declared with @tag(name,scope=mod).
tagScopeMod
)

// A tag binds an identifier to a field to allow passing command-line values.
//
// A tag is of the form
//
// @tag(<name>,[type=(string|int|number|bool)][,short=<shorthand>+])
// @tag(<name>,[type=(string|int|number|bool)][,short=<shorthand>+][,scope=(pkg|mod)])
//
// The name is mandatory and type defaults to string. Tags are set using the -t
// option on the command line. -t name=value will parse value for the type
Expand All @@ -155,6 +169,11 @@ func varToString(s string, err error) (ast.Expr, error) {
// Tags also allow shorthands. If a shorthand bar is declared for a tag with
// name foo, then -t bar is identical to -t foo=bar.
//
// The scope parameter controls cross-package injection. The default scope is
// "pkg", meaning the tag is only applied when its package is a root package.
// With scope=mod, the tag is also applied when the package is imported from
// another package in the same module.
//
// It is a deliberate choice to not allow other values to be associated with
// shorthands than the shorthand name itself. Doing so would create a powerful
// mechanism that would assign different values to different fields based on the
Expand All @@ -164,6 +183,7 @@ type tag struct {
kind cue.Kind
shorthands []string
vars string // -T flag
scope tagScope
hasReplacement bool

field *ast.Field
Expand Down Expand Up @@ -207,6 +227,17 @@ func parseTag(pos token.Pos, body string) (t *tag, err errors.Error) {
t.vars = s
}

if s, ok, _ := a.Lookup(1, "scope"); ok {
switch s {
case "pkg":
// pkg is the default (zero value); accept it without assignment.
case "mod":
t.scope = tagScopeMod
default:
return t, errors.Newf(pos, "invalid tag scope %q", s)
}
}

return t, nil
}

Expand Down Expand Up @@ -349,6 +380,34 @@ func (tg *tagger) injectTags(tags []string) errors.Error {
return nil
}

// findModuleScopedTags collects tags with scope=mod from the transitive imports
// of inst that belong to modPath. visited is mutated to track seen import paths
// and must be pre-seeded with the root package paths to avoid double-processing.
func findModuleScopedTags(inst *build.Instance, modPath string, visited map[string]struct{}) ([]*tag, errors.Error) {
var tags []*tag
var errs errors.Error
for _, imp := range inst.Imports {
if _, ok := visited[imp.ImportPath]; ok {
continue
}
visited[imp.ImportPath] = struct{}{}
if imp.Module != modPath {
continue
}
impTags, err := findTags(imp)
errs = errors.Append(errs, err)
for _, t := range impTags {
if t.scope == tagScopeMod {
tags = append(tags, t)
}
}
childTags, err := findModuleScopedTags(imp, modPath, visited)
errs = errors.Append(errs, err)
tags = append(tags, childTags...)
}
return tags, errs
}

func shouldBuildFile(f *ast.File, tagIsSet func(key string) bool) errors.Error {
ok, attr, err := buildattr.ShouldBuildFile(f, tagIsSet)
if err != nil {
Expand Down