doc/ref: added field attributes

Field attributes are like Go struct tags.

Attributes are needed for maintaining meta data for mapping
CUE to other languages like protobuf, Go, XML, and even JSON,
as well as preserving information when converting such languages
to CUE.

This proposal does not use back quotes for tags, like Go,
as this would be ambiguous if back quotes are used as
identifier literals.

The use of @ has a precedence in other languages, like Swift.

Change-Id: I618caad6b43d598a7462872c484509be9b00047f
Reviewed-on: https://cue-review.googlesource.com/c/cue/+/1662
Reviewed-by: Marcel van Lohuizen <mpvl@google.com>
diff --git a/cue/ast.go b/cue/ast.go
index 9bfc98e..b6924b4 100644
--- a/cue/ast.go
+++ b/cue/ast.go
@@ -280,12 +280,16 @@
 			}
 
 		case *ast.BasicLit, *ast.Ident:
+			attrs, err := createAttrs(v.ctx(), newNode(n), n.Attrs)
+			if err != nil {
+				return err
+			}
 			f, ok := v.nodeLabel(x)
 			if !ok {
 				return v.error(n.Label, "invalid field name: %v", n.Label)
 			}
 			if f != 0 {
-				v.object.insertValue(v.ctx(), f, v.walk(n.Value))
+				v.object.insertValue(v.ctx(), f, v.walk(n.Value), attrs)
 			}
 
 		default:
diff --git a/cue/ast/ast.go b/cue/ast/ast.go
index a468a8a..b7d05c6 100644
--- a/cue/ast/ast.go
+++ b/cue/ast/ast.go
@@ -264,6 +264,16 @@
 	return strings.Join(lines, "\n")
 }
 
+// An Attribute provides meta data about a field.
+type Attribute struct {
+	comments
+	At   token.Pos
+	Text string // must be a valid attribute format.
+}
+
+func (a *Attribute) Pos() token.Pos { return a.At }
+func (a *Attribute) End() token.Pos { return a.At.Add(len(a.Text)) }
+
 // A Field represents a field declaration in a struct.
 type Field struct {
 	comments
@@ -272,10 +282,17 @@
 	// No colon: Value must be an StructLit with one field.
 	Colon token.Pos
 	Value Expr // the value associated with this field.
+
+	Attrs []*Attribute
 }
 
 func (d *Field) Pos() token.Pos { return d.Label.Pos() }
-func (d *Field) End() token.Pos { return d.Value.End() }
+func (d *Field) End() token.Pos {
+	if len(d.Attrs) > 0 {
+		return d.Attrs[len(d.Attrs)-1].End()
+	}
+	return d.Value.End()
+}
 
 // An Alias binds another field to the alias name in the current struct.
 type Alias struct {
diff --git a/cue/ast/walk.go b/cue/ast/walk.go
index 4e53391..ce697ce 100644
--- a/cue/ast/walk.go
+++ b/cue/ast/walk.go
@@ -87,11 +87,17 @@
 			walk(v, c)
 		}
 
+	case *Attribute:
+		// nothing to do
+
 	case *Field:
 		walk(v, n.Label)
 		if n.Value != nil {
 			walk(v, n.Value)
 		}
+		for _, a := range n.Attrs {
+			walk(v, a)
+		}
 
 	case *StructLit:
 		for _, f := range n.Elts {
diff --git a/cue/attr.go b/cue/attr.go
new file mode 100644
index 0000000..a00982d
--- /dev/null
+++ b/cue/attr.go
@@ -0,0 +1,228 @@
+// Copyright 2019 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 cue
+
+import (
+	"sort"
+	"strings"
+
+	"cuelang.org/go/cue/ast"
+	"cuelang.org/go/cue/literal"
+)
+
+// This file includes functionality for parsing attributes.
+// These functions are slightly more permissive than the spec. Together with the
+// scanner and parser the full spec is implemented, though.
+
+// attributes is used to store per-key attribute text for a fields.
+// It deliberately does not implement the value interface, as it should
+// never act as a value in any way.
+type attributes struct {
+	attr []attr
+}
+type attr struct {
+	text   string
+	offset int
+}
+
+func (a *attr) key() string {
+	return a.text[1:a.offset]
+}
+
+func (a *attr) body() string {
+	return a.text[a.offset+1 : len(a.text)-1]
+}
+
+func createAttrs(ctx *context, src source, attrs []*ast.Attribute) (a *attributes, err evaluated) {
+	if len(attrs) == 0 {
+		return nil, nil
+	}
+	as := []attr{}
+	for _, a := range attrs {
+		index := strings.IndexByte(a.Text, '(')
+		n := len(a.Text)
+		if index < 2 || a.Text[0] != '@' || a.Text[n-1] != ')' {
+			return nil, ctx.mkErr(newNode(a), "invalid attribute %q", a.Text)
+		}
+		as = append(as, attr{a.Text[:n], index})
+	}
+
+	sort.Slice(as, func(i, j int) bool { return as[i].text < as[j].text })
+	for i := 1; i < len(as); i++ {
+		if ai, aj := as[i-1], as[i]; ai.key() == aj.key() {
+			n := newNode(attrs[0])
+			return nil, ctx.mkErr(n, "multiple attributes for key %q", ai.key())
+		}
+	}
+
+	for _, a := range attrs {
+		if err := parseAttrBody(ctx, src, a.Text, nil); err != nil {
+			return nil, err
+		}
+	}
+	return &attributes{as}, nil
+}
+
+// unifyAttrs merges the attributes from a and b. It may return either a or b
+// if a and b are identical.
+func unifyAttrs(ctx *context, src source, a, b *attributes) (atrs *attributes, err evaluated) {
+	if a == b {
+		return a, nil
+	}
+	if a == nil {
+		return b, nil
+	}
+	if b == nil {
+		return a, nil
+	}
+
+	if len(a.attr) == len(b.attr) {
+		for i, x := range a.attr {
+			if x != b.attr[i] {
+				goto notSame
+			}
+		}
+		return a, nil
+	}
+
+notSame:
+	as := append(a.attr, b.attr...)
+
+	// remove duplicates and error on conflicts
+	sort.Slice(as, func(i, j int) bool { return as[i].text < as[j].text })
+	k := 0
+	for i := 1; i < len(as); i++ {
+		if ak, ai := as[k], as[i]; ak.key() == ai.key() {
+			if ak.body() == ai.body() {
+				continue
+			}
+			return nil, ctx.mkErr(src, "conflicting attributes for key %q", ai.key())
+		}
+		k++
+		as[k] = as[i]
+	}
+
+	return &attributes{as[:k+1]}, nil
+}
+
+// parsedAttr holds positional information for a single parsedAttr.
+type parsedAttr struct {
+	fields []keyValue
+}
+
+type keyValue struct {
+	data  string
+	equal int // index of equal sign or 0 if non-existing
+}
+
+func (kv *keyValue) text() string  { return kv.data }
+func (kv *keyValue) key() string   { return kv.data[:kv.equal] }
+func (kv *keyValue) value() string { return kv.data[kv.equal+1:] }
+
+func parseAttrBody(ctx *context, src source, s string, a *parsedAttr) (err evaluated) {
+	i := 0
+	for {
+		// always scan at least one, possibly empty element.
+		n, err := scanAttributeElem(ctx, src, s[i:], a)
+		if err != nil {
+			return err
+		}
+		if i += n; i >= len(s) {
+			break
+		}
+		if s[i] != ',' {
+			return ctx.mkErr(src, "invalid attribute: expected comma")
+		}
+		i++
+	}
+	return nil
+}
+
+func scanAttributeElem(ctx *context, src source, s string, a *parsedAttr) (n int, err evaluated) {
+	// try CUE string
+	kv := keyValue{}
+	if n, kv.data, err = scanAttributeString(ctx, src, s); n == 0 {
+		// try key-value pair
+		p := strings.IndexAny(s, ",=") // ) is assumed to be stripped.
+		switch {
+		case p < 0:
+			kv.data = s
+			n = len(s)
+
+		default: // ','
+			n = p
+			kv.data = s[:n]
+
+		case s[p] == '=':
+			kv.equal = p
+			offset := p + 1
+			var str string
+			if p, str, err = scanAttributeString(ctx, src, s[offset:]); p > 0 {
+				n = offset + p
+				kv.data = s[:offset] + str
+			} else {
+				n = len(s)
+				if p = strings.IndexByte(s[offset:], ','); p >= 0 {
+					n = offset + p
+				}
+				kv.data = s[:n]
+			}
+		}
+	}
+	if a != nil {
+		a.fields = append(a.fields, kv)
+	}
+	return n, err
+}
+
+func scanAttributeString(ctx *context, src source, s string) (n int, str string, err evaluated) {
+	if s == "" || (s[0] != '#' && s[0] != '"' && s[0] != '\'') {
+		return 0, "", nil
+	}
+
+	nHash := 0
+	for {
+		if nHash < len(s) {
+			if s[nHash] == '#' {
+				nHash++
+				continue
+			}
+			if s[nHash] == '\'' || s[nHash] == '"' {
+				break
+			}
+		}
+		return nHash, s[:nHash], ctx.mkErr(src, "invalid attribute string")
+	}
+
+	// Determine closing quote.
+	nQuote := 1
+	if c := s[nHash]; nHash+6 < len(s) && s[nHash+1] == c && s[nHash+2] == c {
+		nQuote = 3
+	}
+	close := s[nHash:nHash+nQuote] + s[:nHash]
+
+	// Search for closing quote.
+	index := strings.Index(s[len(close):], close)
+	if index == -1 {
+		return len(s), "", ctx.mkErr(src, "attribute string not terminated")
+	}
+
+	index += 2 * len(close)
+	s, err2 := literal.Unquote(s[:index])
+	if err2 != nil {
+		return index, "", ctx.mkErr(src, "invalid attribute string: %v", err2)
+	}
+	return index, s, nil
+}
diff --git a/cue/attr_test.go b/cue/attr_test.go
new file mode 100644
index 0000000..d3f08fd
--- /dev/null
+++ b/cue/attr_test.go
@@ -0,0 +1,219 @@
+// Copyright 2019 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 cue
+
+import (
+	"fmt"
+	"reflect"
+	"strings"
+	"testing"
+
+	"cuelang.org/go/cue/ast"
+)
+
+func TestAttributeBody(t *testing.T) {
+	testdata := []struct {
+		in, out string
+		err     string
+	}{{
+		in:  "",
+		out: "[{ 0}]",
+	}, {
+		in:  "bb",
+		out: "[{bb 0}]",
+	}, {
+		in:  "a,",
+		out: "[{a 0} { 0}]",
+	}, {
+		in:  "a,b",
+		out: "[{a 0} {b 0}]",
+	}, {
+		in:  `foo,"bar",#"baz"#`,
+		out: "[{foo 0} {bar 0} {baz 0}]",
+	}, {
+		in:  `bar=str`,
+		out: "[{bar=str 3}]",
+	}, {
+		in:  `bar="str"`,
+		out: "[{bar=str 3}]",
+	}, {
+		in:  `bar=,baz=`,
+		out: "[{bar= 3} {baz= 3}]",
+	}, {
+		in:  `foo=1,bar="str",baz=free form`,
+		out: "[{foo=1 3} {bar=str 3} {baz=free form 3}]",
+	}, {
+		in: `"""
+		"""`,
+		out: "[{ 0}]",
+	}, {
+		in: `#'''
+			\#x20
+			'''#`,
+		out: "[{  0}]",
+	}, {
+		in:  "'' ,b",
+		err: "invalid attribute",
+	}, {
+		in:  "' ,b",
+		err: "not terminated",
+	}, {
+		in:  `"\ "`,
+		err: "invalid attribute",
+	}, {
+		in:  `# `,
+		err: "invalid attribute",
+	}}
+	for _, tc := range testdata {
+		t.Run(tc.in, func(t *testing.T) {
+			pa := &parsedAttr{}
+			err := parseAttrBody(&context{}, baseValue{}, tc.in, pa)
+
+			if tc.err != "" {
+				if !strings.Contains(debugStr(&context{}, err), tc.err) {
+					t.Errorf("error was %v; want %v", err, tc.err)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			if got := fmt.Sprint(pa.fields); got != tc.out {
+				t.Errorf("got %v; want %v", got, tc.out)
+			}
+		})
+	}
+}
+
+func TestCreateAttrs(t *testing.T) {
+	testdata := []struct {
+		// space-separated lists of attributes
+		in, out string
+		err     string
+	}{{
+		in:  "@foo()",
+		out: "foo:",
+	}, {
+		in:  "@b(bb) @aaa(aa,)",
+		out: "aaa:aa, b:bb",
+	}, {
+		in:  "@b(a,",
+		err: "invalid attribute",
+	}, {
+		in:  "@b(foo) @b(foo)",
+		err: "attributes",
+	}, {
+		in:  "@b('' ,b)",
+		err: "invalid attribute",
+	}}
+	for _, tc := range testdata {
+		t.Run(tc.in, func(t *testing.T) {
+			a := []*ast.Attribute{}
+			for _, s := range strings.Split(tc.in, " ") {
+				a = append(a, &ast.Attribute{Text: s})
+			}
+			attrs, err := createAttrs(&context{}, baseValue{}, a)
+
+			if tc.err != "" {
+				if !strings.Contains(debugStr(&context{}, err), tc.err) {
+					t.Errorf("error was %v; want %v", err, tc.err)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatal(err)
+			}
+			sa := []string{}
+			for _, a := range attrs.attr {
+				sa = append(sa, a.key()+":"+a.body())
+			}
+			if got := strings.Join(sa, " "); got != tc.out {
+				t.Errorf("got %v; want %v", got, tc.out)
+			}
+		})
+	}
+}
+
+func TestUnifyAttrs(t *testing.T) {
+	parse := func(s string) *attributes {
+		a := []*ast.Attribute{}
+		for _, s := range strings.Split(s, " ") {
+			a = append(a, &ast.Attribute{Text: s})
+		}
+		attrs, _ := createAttrs(&context{}, baseValue{}, a)
+		return attrs
+	}
+	foo := parse("@foo()")
+
+	testdata := []struct {
+		// space-separated lists of attributes
+		a, b, out *attributes
+		err       string
+	}{{
+		a:   nil,
+		b:   nil,
+		out: nil,
+	}, {
+		a:   nil,
+		b:   foo,
+		out: foo,
+	}, {
+		a:   foo,
+		b:   nil,
+		out: foo,
+	}, {
+		a:   foo,
+		b:   foo,
+		out: foo,
+	}, {
+		a:   foo,
+		b:   parse("@bar()"),
+		out: parse("@bar() @foo()"),
+	}, {
+		a:   foo,
+		b:   parse("@bar() @foo()"),
+		out: parse("@bar() @foo()"),
+	}, {
+		a:   parse("@bar() @foo()"),
+		b:   parse("@foo() @bar()"),
+		out: parse("@bar() @foo()"),
+	}, {
+		a:   parse("@bar() @foo()"),
+		b:   parse("@foo() @baz()"),
+		out: parse("@bar() @baz() @foo()"),
+	}, {
+		a:   parse("@foo(ab)"),
+		b:   parse("@foo(cd)"),
+		err: `conflicting attributes for key "foo"`,
+	}}
+	for _, tc := range testdata {
+		t.Run("", func(t *testing.T) {
+			attrs, err := unifyAttrs(&context{}, baseValue{}, tc.a, tc.b)
+			if tc.err != "" {
+				if !strings.Contains(debugStr(&context{}, err), tc.err) {
+					t.Errorf("error was %v; want %v", err, tc.err)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatal(err)
+			}
+			if !reflect.DeepEqual(attrs, tc.out) {
+				t.Errorf("\ngot:  %v;\nwant: %v", attrs, tc.out)
+			}
+		})
+	}
+}
diff --git a/cue/binop.go b/cue/binop.go
index 3e836c1..52f906c 100644
--- a/cue/binop.go
+++ b/cue/binop.go
@@ -530,7 +530,7 @@
 
 	for _, a := range x.arcs {
 		cp := ctx.copy(a.v)
-		obj.arcs = append(obj.arcs, arc{a.feature, cp, nil})
+		obj.arcs = append(obj.arcs, arc{a.feature, cp, nil, a.attrs})
 	}
 outer:
 	for _, a := range y.arcs {
@@ -539,10 +539,15 @@
 			if a.feature == b.feature {
 				v = mkBin(ctx, src.Pos(), opUnify, b.v, v)
 				obj.arcs[i].v = v
+				attrs, err := unifyAttrs(ctx, src, a.attrs, b.attrs)
+				if err != nil {
+					return err
+				}
+				obj.arcs[i].attrs = attrs
 				continue outer
 			}
 		}
-		obj.arcs = append(obj.arcs, arc{feature: a.feature, v: v})
+		obj.arcs = append(obj.arcs, arc{feature: a.feature, v: v, attrs: a.attrs})
 	}
 	sort.Stable(obj)
 
@@ -1049,7 +1054,7 @@
 			if isBottom(v) {
 				return v
 			}
-			arcs[i] = arc{x.arcs[i].feature, v, nil}
+			arcs[i] = arc{x.arcs[i].feature, v, nil, nil}
 		}
 
 		return lambda
diff --git a/cue/copy.go b/cue/copy.go
index 880c769..edefaa0 100644
--- a/cue/copy.go
+++ b/cue/copy.go
@@ -53,7 +53,7 @@
 
 		for i, a := range x.arcs {
 			v := ctx.copy(a.v)
-			arcs[i] = arc{a.feature, v, nil}
+			arcs[i] = arc{a.feature, v, nil, a.attrs}
 		}
 
 		comp := make([]*fieldComprehension, len(x.comprehensions))
diff --git a/cue/debug.go b/cue/debug.go
index d2d1807..a9dec8d 100644
--- a/cue/debug.go
+++ b/cue/debug.go
@@ -271,6 +271,11 @@
 		p.writef(str)
 		p.write(": ")
 		p.debugStr(n)
+		if x.attrs != nil {
+			for _, a := range x.attrs.attr {
+				p.write(" ", a.text)
+			}
+		}
 
 	case *fieldComprehension:
 		p.debugStr(x.clauses)
diff --git a/cue/export.go b/cue/export.go
index cb9f923..4de8767 100644
--- a/cue/export.go
+++ b/cue/export.go
@@ -30,7 +30,8 @@
 
 const (
 	exportEval exportMode = 1 << iota
-	exportRaw  exportMode = 0
+	exportAttrs
+	exportRaw exportMode = 0
 )
 
 func export(ctx *context, v value, m exportMode) ast.Expr {
@@ -264,6 +265,11 @@
 			} else {
 				f.Value = p.expr(v)
 			}
+			if a.attrs != nil { // TODO: && p.mode&exportAttrs != 0 {
+				for _, at := range a.attrs.attr {
+					f.Attrs = append(f.Attrs, &ast.Attribute{Text: at.text})
+				}
+			}
 			obj.Elts = append(obj.Elts, f)
 		}
 
diff --git a/cue/export_test.go b/cue/export_test.go
index 80d0448..c77f59a 100644
--- a/cue/export_test.go
+++ b/cue/export_test.go
@@ -190,7 +190,7 @@
 		in: `{
 			job <Name>: {
 				name:     Name
-				replicas: uint | *1
+				replicas: uint | *1 @protobuf(10)
 				command:  string
 			}
 			
@@ -206,12 +206,12 @@
 			job: {
 				list: {
 					name:     "list"
-					replicas: 1
+					replicas: 1 @protobuf(10)
 					command:  "ls"
 				}
 				nginx: {
 					name:     "nginx"
-					replicas: 2
+					replicas: 2 @protobuf(10)
 					command:  "nginx"
 				}
 			}
diff --git a/cue/format/format.go b/cue/format/format.go
index 23644fc..c2691c5 100644
--- a/cue/format/format.go
+++ b/cue/format/format.go
@@ -303,12 +303,17 @@
 			printBlank = true
 		}
 		for _, c := range c.cg[0].List {
+			isEnd := strings.HasPrefix(c.Text, "//")
 			if !printBlank {
-				f.Print(vtab)
+				if isEnd {
+					f.Print(vtab)
+				} else {
+					f.Print(blank)
+				}
 			}
 			f.Print(c.Slash)
 			f.Print(c)
-			if strings.HasPrefix(c.Text, "//") {
+			if isEnd {
 				f.Print(newline)
 			}
 		}
diff --git a/cue/format/node.go b/cue/format/node.go
index 2a1464f..3199995 100644
--- a/cue/format/node.go
+++ b/cue/format/node.go
@@ -168,6 +168,15 @@
 			f.visitComments(f.current.pos)
 		}
 
+		space := tab
+		for _, a := range n.Attrs {
+			if f.before(a) {
+				f.print(space, a.At, a)
+			}
+			f.after(a)
+			space = blank
+		}
+
 		if nextFF {
 			f.print(formfeed)
 		}
diff --git a/cue/format/printer.go b/cue/format/printer.go
index 10a2b7b..148cdfd 100644
--- a/cue/format/printer.go
+++ b/cue/format/printer.go
@@ -138,6 +138,11 @@
 		}
 		return
 
+	case *ast.Attribute:
+		data = x.Text
+		impliedComma = true
+		p.lastTok = token.ATTRIBUTE
+
 	case *ast.Comment:
 		// TODO: if implied comma, postpone comment
 		data = x.Text
diff --git a/cue/format/testdata/expressions.golden b/cue/format/testdata/expressions.golden
index 15c6377..830b215 100644
--- a/cue/format/testdata/expressions.golden
+++ b/cue/format/testdata/expressions.golden
@@ -1,8 +1,8 @@
 package expressions
 
 {
-	a:   1
-	aaa: 2
+	a:   1  // comment
+	aaa: 22 // comment
 
 	b: 3
 
@@ -27,6 +27,14 @@
 		aaa: 10
 	}
 
+	attrs: {
+		a:    8 @go(A) // comment
+		aa:   8 @go(A) // comment
+		bb:   9
+		bbb:  10  @go(Bbb) @xml(,attr)          // comment
+		bbbb: 100 @go(Bbbb) /* a */ @xml(,attr) // comment
+	}
+
 	e:  1 + 2*3
 	e:  1 * 2 * 3 // error
 	e:  >=2 & <=3
diff --git a/cue/format/testdata/expressions.input b/cue/format/testdata/expressions.input
index 6b324a3..f15e37e 100644
--- a/cue/format/testdata/expressions.input
+++ b/cue/format/testdata/expressions.input
@@ -1,8 +1,8 @@
 package expressions
 
 {
-    a: 1
-    aaa: 2
+    a: 1 // comment
+    aaa: 22 // comment
 
     b: 3
 
@@ -27,6 +27,14 @@
         aaa: 10
     }
 
+    attrs: {
+        a: 8 @go(A) // comment
+        aa: 8 @go(A) // comment
+        bb: 9
+        bbb: 10 @go(Bbb) @xml(,attr) // comment
+        bbbb: 100 @go(Bbbb) /* a */ @xml(,attr) // comment
+    }
+
     e: 1+2*3
     e: 1*2*3 // error
     e: >=2 & <=3
diff --git a/cue/parser/parser.go b/cue/parser/parser.go
index 2874049..74dab9c 100644
--- a/cue/parser/parser.go
+++ b/cue/parser/parser.go
@@ -698,6 +698,8 @@
 	this := &ast.Field{Label: nil}
 	m := this
 
+	allowComprehension := true
+
 	for i := 0; ; i++ {
 		tok := p.tok
 
@@ -735,6 +737,12 @@
 			break
 		}
 
+		// TODO: consider disallowing comprehensions with more than one label.
+		// This can be a bit awkward in some cases, but it would naturally
+		// enforce the proper style that a comprehension be defined in the
+		// smallest possible scope.
+		// allowComprehension = false
+
 		switch p.tok {
 		default:
 			if !allowEmit || p.tok != token.COMMA {
@@ -762,6 +770,17 @@
 	p.expect(token.COLON)
 	m.Value = p.parseRHS()
 
+	p.openList()
+	for p.tok == token.ATTRIBUTE {
+		allowComprehension = false
+		c := p.openComments()
+		a := &ast.Attribute{At: p.pos, Text: p.lit}
+		p.next()
+		c.closeNode(p, a)
+		this.Attrs = append(this.Attrs, a)
+	}
+	p.closeList()
+
 	decl = this
 	var arrow token.Pos
 	switch p.tok {
@@ -770,6 +789,9 @@
 		fallthrough
 
 	case token.FOR, token.IF:
+		if !allowComprehension {
+			p.error(p.pos, "comprehension not alowed for this field")
+		}
 		clauses := p.parseComprehensionClauses()
 		return &ast.ComprehensionDecl{
 			Field:   this,
diff --git a/cue/parser/parser_test.go b/cue/parser/parser_test.go
index 2d3d587..ca6292b 100644
--- a/cue/parser/parser_test.go
+++ b/cue/parser/parser_test.go
@@ -66,6 +66,15 @@
 		}`,
 		`{a: 1, b: "2", c: 3}`,
 	}, {
+		"attributes",
+		`a: 1 @xml(,attr)
+		 b: 2 @foo(a,b=4) @go(Foo)
+		 c: {
+			 d: "x" @go(D) @json(,omitempty)
+			 e: "y" @ts(,type=string)
+		 }`,
+		`a: 1 @xml(,attr), b: 2 @foo(a,b=4) @go(Foo), c: {d: "x" @go(D) @json(,omitempty), e: "y" @ts(,type=string)}`,
+	}, {
 		"not emitted",
 		`a: true
 		 b: "2"
@@ -73,7 +82,7 @@
 		`,
 		`a: true, b: "2", c: 3`,
 	}, {
-		"emitted refrencing non-emitted",
+		"emitted referencing non-emitted",
 		`a: 1
 		 b: "2"
 		 c: 3
@@ -256,8 +265,8 @@
 		 b: 6 // lineb
 			  // next
 			`, // next is followed by EOF. Ensure it doesn't move to file.
-		"<[d0// doc] [l4// line] a: 5>, " +
-			"<[l4// lineb] [4// next] b: 6>",
+		"<[d0// doc] [l5// line] a: 5>, " +
+			"<[l5// lineb] [5// next] b: 6>",
 	}, {
 		"alt comments",
 		`// a ...
@@ -276,9 +285,9 @@
 		// about c
 
 		`,
-		"<[d0// a ...] [l4// line a] [4// about a] a: 5>, " +
-			"<[d0// b ...] [l2// lineb] [4// about b] b: 6>, " +
-			"<[4// about c] c: 7>",
+		"<[d0// a ...] [l5// line a] [5// about a] a: 5>, " +
+			"<[d0// b ...] [l2// lineb] [5// about b] b: 6>, " +
+			"<[5// about c] c: 7>",
 	}, {
 		"expr comments",
 		`
@@ -286,7 +295,7 @@
 		   3 +  // 3 +
 		   4    // 4
 		   `,
-		"<[l4// 4] a: <[l2// 3 +] <[l2// 2 +] 2+3>+4>>",
+		"<[l5// 4] a: <[l2// 3 +] <[l2// 2 +] 2+3>+4>>",
 	}, {
 		"composit comments",
 		`a : {
@@ -315,10 +324,16 @@
 		"a: <[d2// end] {a: 1, b: 2, c: 3, d: 4}>, " +
 			"b: <[d2// end] [1, 2, 3, 4, 5]>, " +
 			"c: [1, 2, 3, <[l1// here] 4>, 5, 6, 7, <[l1// and here] 8>], " +
-			"d: {<[2/* 8 */] [l4// Hello] a: 1>, <[d0// Doc] b: 2>}, " +
+			"d: {<[2/* 8 */] [l5// Hello] a: 1>, <[d0// Doc] b: 2>}, " +
 			"e1: <[d2// comment in list body] []>, " +
 			"e2: <[d1// comment in struct body] {}>",
 	}, {
+		"attribute comments",
+		`
+		a: 1 /* a */ @a() /* b */ @b() /* c */ // d
+		`,
+		`<[l5/* c */ // d] a: <[1/* a */] 1> <[1/* b */] @a()> @b()>`,
+	}, {
 		"emit comments",
 		`// a comment at the beginning of the file
 
diff --git a/cue/parser/print.go b/cue/parser/print.go
index 01d5d1c..4bdcc13 100644
--- a/cue/parser/print.go
+++ b/cue/parser/print.go
@@ -140,9 +140,16 @@
 		if v.Value != nil {
 			out += ": "
 			out += debugStr(v.Value)
+			for _, a := range v.Attrs {
+				out += " "
+				out += debugStr(a)
+			}
 		}
 		return out
 
+	case *ast.Attribute:
+		return v.Text
+
 	case *ast.Ident:
 		return v.Name
 
diff --git a/cue/parser/walk.go b/cue/parser/walk.go
index 8589b09..01a8220 100644
--- a/cue/parser/walk.go
+++ b/cue/parser/walk.go
@@ -81,11 +81,17 @@
 			walk(v, c)
 		}
 
+	case *ast.Attribute:
+		// nothing to do
+
 	case *ast.Field:
 		walk(v, n.Label)
 		if n.Value != nil {
 			walk(v, n.Value)
 		}
+		for _, a := range n.Attrs {
+			walk(v, a)
+		}
 
 	case *ast.StructLit:
 		for _, f := range n.Elts {
diff --git a/cue/resolve_test.go b/cue/resolve_test.go
index 07f7972..ee120cf 100644
--- a/cue/resolve_test.go
+++ b/cue/resolve_test.go
@@ -606,6 +606,19 @@
 		`,
 		out: `<0>{a: true, b: true, c: false, d: true, e: false, f: true}`,
 	}, {
+		desc: "attributes",
+		in: `
+			a: { foo: 1 @foo() @baz(1) }
+			b: { foo: 1 @bar() @foo() }
+			c: a & b
+
+			e: a & { foo: 1 @foo(other) }
+		`,
+		out: `<0>{a: <1>{foo: 1 @baz(1) @foo()}, ` +
+			`b: <2>{foo: 1 @bar() @foo()}, ` +
+			`c: <3>{foo: 1 @bar() @baz(1) @foo()}, ` +
+			`e: _|_((<4>.a & <5>{foo: 1 @foo(other)}):conflicting attributes for key "foo")}`,
+	}, {
 		desc: "bounds",
 		in: `
 			i1: >1 & 5
diff --git a/cue/rewrite.go b/cue/rewrite.go
index 479605c..85130f4 100644
--- a/cue/rewrite.go
+++ b/cue/rewrite.go
@@ -44,7 +44,7 @@
 	changed := emit == x.emit
 	for i, a := range x.arcs {
 		v := rewrite(ctx, a.v, fn)
-		arcs[i] = arc{a.feature, v, nil}
+		arcs[i] = arc{a.feature, v, nil, a.attrs}
 		changed = changed || arcs[i].v != v
 	}
 	if !changed {
diff --git a/cue/rewrite_test.go b/cue/rewrite_test.go
index 19e8d8b..fbcf2c3 100644
--- a/cue/rewrite_test.go
+++ b/cue/rewrite_test.go
@@ -78,7 +78,7 @@
 		arcs := make(arcs, len(x.arcs))
 		for i, a := range x.arcs {
 			v := x.at(ctx, i)
-			arcs[i] = arc{a.feature, rewriteRec(ctx, a.v, v, m), nil}
+			arcs[i] = arc{a.feature, rewriteRec(ctx, a.v, v, m), nil, a.attrs}
 		}
 		t := x.template
 		if t != nil {
diff --git a/cue/scanner/scanner.go b/cue/scanner/scanner.go
index 21b0aed..c5e182f 100644
--- a/cue/scanner/scanner.go
+++ b/cue/scanner/scanner.go
@@ -562,6 +562,92 @@
 	return c[:i]
 }
 
+// scanAttribute scans aa full attribute of the form @foo(str). An attribute
+// is a lexical entry and as such whitespace is treated as normal characters
+// within the attribute.
+func (s *Scanner) scanAttribute() (tok token.Token, lit string) {
+	offs := s.offset - 1 // @ already consumed
+
+	s.scanIdentifier()
+
+	if s.ch != '(' {
+		s.error(s.offset, "invalid attribute: expected '('")
+		return token.ATTRIBUTE, string(s.src[offs:s.offset])
+	}
+	s.next()
+
+	for {
+		s.scanAttributeElem()
+		if s.ch != ',' {
+			break
+		}
+		s.next()
+	}
+
+	if s.ch == ')' {
+		s.next()
+	} else {
+		s.error(s.offset, "attribute missing ')'")
+	}
+	return token.ATTRIBUTE, string(s.src[offs:s.offset])
+}
+
+func (s *Scanner) scanAttributeElem() {
+	// try CUE string
+	if s.scanAttributeString() {
+		return
+	}
+
+	// try key-value pair
+	if s.scanIdentifier() != "" && s.ch == '=' {
+		s.next()
+		if s.scanAttributeString() {
+			return
+		}
+	}
+
+	// raw element or key-value pair with raw value
+	for s.ch != ',' && s.ch != ')' && s.ch != '\n' && s.ch != -1 {
+		switch s.ch {
+		case '#', '\'', '"', '(', '=':
+			s.error(s.offset, "illegal character in attribute")
+			s.recoverParen(1)
+			return
+		}
+		s.next()
+	}
+}
+
+func (s *Scanner) scanAttributeString() bool {
+	if s.ch == '#' || s.ch == '"' || s.ch == '\'' {
+		if _, tok, _ := s.Scan(); tok == token.INTERPOLATION {
+			s.error(s.offset, "interpolation not allowed in attribute")
+			s.popInterpolation()
+			s.recoverParen(1)
+		}
+		return true
+	}
+	return false
+}
+
+// recoverParen is an approximate recovery mechanism to recover from invalid
+// attributes.
+func (s *Scanner) recoverParen(open int) {
+	for {
+		switch s.ch {
+		case '\n', -1:
+			return
+		case '(':
+			open++
+		case ')':
+			if open--; open == 0 {
+				return
+			}
+		}
+		s.next()
+	}
+}
+
 func (s *Scanner) skipWhitespace(inc int) {
 	for {
 		switch s.ch {
@@ -594,10 +680,15 @@
 	return tok0
 }
 
-// ResumeInterpolation resumes scanning of a string interpolation.
-func (s *Scanner) ResumeInterpolation() string {
+func (s *Scanner) popInterpolation() quoteInfo {
 	quote := s.quoteStack[len(s.quoteStack)-1]
 	s.quoteStack = s.quoteStack[:len(s.quoteStack)-1]
+	return quote
+}
+
+// ResumeInterpolation resumes scanning of a string interpolation.
+func (s *Scanner) ResumeInterpolation() string {
+	quote := s.popInterpolation()
 	_, str := s.scanString(1, quote)
 	return str
 }
@@ -706,7 +797,7 @@
 			}
 			insertEOL = true
 		case '\n':
-			// we only reach here if s.insertSemi was
+			// we only reach here if s.insertComma was
 			// set in the first place and exited early
 			// from s.skipWhitespace()
 			s.insertEOL = false // newline consumed
@@ -738,6 +829,9 @@
 				quote.numChar = n + 1
 				tok, lit = s.scanString(quote.numChar+quote.numHash, quote)
 			}
+		case '@':
+			insertEOL = true
+			tok, lit = s.scanAttribute()
 		case ':':
 			tok = token.COLON
 		case ';':
diff --git a/cue/scanner/scanner_test.go b/cue/scanner/scanner_test.go
index 422f9a8..87746a6 100644
--- a/cue/scanner/scanner_test.go
+++ b/cue/scanner/scanner_test.go
@@ -62,9 +62,18 @@
 	{token.COMMENT, "/*\r*/", special},
 	{token.COMMENT, "//\r\n", special},
 
+	// Attributes
+	{token.ATTRIBUTE, "@foo()", special},
+	{token.ATTRIBUTE, "@foo(,,)", special},
+	{token.ATTRIBUTE, "@foo(a)", special},
+	{token.ATTRIBUTE, "@foo(aa=b)", special},
+	{token.ATTRIBUTE, "@foo(,a=b)", special},
+	{token.ATTRIBUTE, `@foo(",a=b")`, special},
+	{token.ATTRIBUTE, `@foo(##"\(),a=b"##)`, special},
+	{token.ATTRIBUTE, `@foo("",a="")`, special},
+
 	// Identifiers and basic type literals
 	{token.BOTTOM, "_|_", literal},
-	{token.BOTTOM, "_|_", literal},
 
 	{token.IDENT, "foobar", literal},
 	{token.IDENT, "a۰۱۸", literal},
@@ -251,6 +260,8 @@
 			if elit[1] == '/' {
 				elit = elit[0 : len(elit)-1]
 			}
+		case token.ATTRIBUTE:
+			elit = e.lit
 		case token.IDENT:
 			elit = e.lit
 		case token.COMMA:
@@ -715,6 +726,18 @@
 	{`?`, token.ILLEGAL, 0, "", "illegal character U+003F '?'"},
 	{`…`, token.ILLEGAL, 0, "", "illegal character U+2026 '…'"},
 	{`_|`, token.ILLEGAL, 0, "", "illegal token '_|'; expected '_'"},
+
+	{`@`, token.ATTRIBUTE, 1, `@`, "invalid attribute: expected '('"},
+	{`@foo`, token.ATTRIBUTE, 4, `@foo`, "invalid attribute: expected '('"},
+	{`@foo(`, token.ATTRIBUTE, 5, `@foo(`, "attribute missing ')'"},
+	{`@foo( `, token.ATTRIBUTE, 6, `@foo( `, "attribute missing ')'"},
+	{`@foo( "")`, token.ATTRIBUTE, 6, `@foo( "")`, "illegal character in attribute"},
+	{`@foo(a=b=c)`, token.ATTRIBUTE, 8, `@foo(a=b=c)`, "illegal character in attribute"},
+	{`@foo("" )`, token.ATTRIBUTE, 7, `@foo(""`, "attribute missing ')'"},
+	{`@foo(""`, token.ATTRIBUTE, 7, `@foo(""`, "attribute missing ')'"},
+	{`@foo(aa`, token.ATTRIBUTE, 7, `@foo(aa`, "attribute missing ')'"},
+	{`@foo("\(())")`, token.ATTRIBUTE, 7, `@foo("\(())")`, "interpolation not allowed in attribute"},
+
 	// {`' '`, STRING, 0, `' '`, ""},
 	// {"`\0`", STRING, 3, `'\0'`, "illegal character U+0027 ''' in escape sequence"},
 	// {`'\07'`, STRING, 4, `'\07'`, "illegal character U+0027 ''' in escape sequence"},
diff --git a/cue/strip.go b/cue/strip.go
index aaf585a..bc90263 100644
--- a/cue/strip.go
+++ b/cue/strip.go
@@ -35,7 +35,7 @@
 			arcs := make(arcs, len(x.arcs))
 			for i, a := range x.arcs {
 				v := rewrite(ctx, x.at(ctx, i), stripRewriter)
-				arcs[i] = arc{a.feature, v, nil}
+				arcs[i] = arc{a.feature, v, nil, a.attrs}
 			}
 			// TODO: verify that len(x.comprehensions) == 0
 			return &structLit{x.baseValue, x.emit, nil, nil, arcs, nil}, false
diff --git a/cue/token/token.go b/cue/token/token.go
index 87110b6..60e10bc 100644
--- a/cue/token/token.go
+++ b/cue/token/token.go
@@ -27,6 +27,7 @@
 	ILLEGAL Token = iota
 	EOF
 	COMMENT
+	ATTRIBUTE // @foo(bar,baz=4)
 
 	literalBeg
 	// Identifiers and basic type literals
@@ -115,6 +116,7 @@
 	// DURATION:      "DURATION", // TODO
 	STRING:        "STRING",
 	INTERPOLATION: "INTERPOLATION",
+	ATTRIBUTE:     "ATTRIBUTE",
 
 	ADD: "+",
 	SUB: "-",
diff --git a/cue/types.go b/cue/types.go
index 51f9ba7..1f0a368 100644
--- a/cue/types.go
+++ b/cue/types.go
@@ -21,6 +21,7 @@
 	"io"
 	"math"
 	"math/big"
+	"strconv"
 	"strings"
 
 	"cuelang.org/go/cue/ast"
@@ -130,13 +131,14 @@
 // An Iterator iterates over values.
 //
 type Iterator struct {
-	val  Value
-	ctx  *context
-	iter iterAtter
-	len  int
-	p    int
-	cur  Value
-	f    label
+	val   Value
+	ctx   *context
+	iter  iterAtter
+	len   int
+	p     int
+	cur   Value
+	f     label
+	attrs *attributes
 }
 
 // Next advances the iterator to the next value and reports whether there was
@@ -146,8 +148,8 @@
 		i.cur = Value{}
 		return false
 	}
-	eval, orig, f := i.iter.iterAt(i.ctx, i.p)
-	i.cur = i.val.makeChild(i.ctx, uint32(i.p), f, eval, orig)
+	eval, orig, f, attrs := i.iter.iterAt(i.ctx, i.p)
+	i.cur = i.val.makeChild(i.ctx, uint32(i.p), f, eval, orig, attrs)
 	i.f = f
 	i.p++
 	return true
@@ -442,6 +444,7 @@
 	index   uint32
 	v       evaluated
 	raw     value
+	attrs   *attributes
 }
 
 // Value holds any value, which may be a Boolean, Error, List, Null, Number,
@@ -453,22 +456,22 @@
 
 func newValueRoot(ctx *context, x value) Value {
 	v := x.evalPartial(ctx)
-	return Value{ctx.index, &valueData{nil, 0, 0, v, x}}
+	return Value{ctx.index, &valueData{nil, 0, 0, v, x, nil}}
 }
 
 func newChildValue(obj *structValue, i int) Value {
 	eval := obj.ctx.manifest(obj.n.at(obj.ctx, i))
 	a := obj.n.arcs[i]
-	return Value{obj.ctx.index, &valueData{obj.path, a.feature, uint32(i), eval, a.v}}
+	return Value{obj.ctx.index, &valueData{obj.path, a.feature, uint32(i), eval, a.v, a.attrs}}
 }
 
 func (v Value) ctx() *context {
 	return v.idx.newContext()
 }
 
-func (v Value) makeChild(ctx *context, i uint32, f label, eval evaluated, raw value) Value {
+func (v Value) makeChild(ctx *context, i uint32, f label, eval evaluated, raw value, attrs *attributes) Value {
 	eval = ctx.manifest(eval)
-	return Value{v.idx, &valueData{v.path, f, i, eval, raw}}
+	return Value{v.idx, &valueData{v.path, f, i, eval, raw, attrs}}
 }
 
 func (v Value) eval(ctx *context) value {
@@ -1061,3 +1064,101 @@
 		after(v)
 	}
 }
+
+// Attribute returns the attribute data for the given key.
+// The returned attribute will return an error for any of its methods if there
+// is no attribute for the requested key.
+func (v Value) Attribute(key string) Attribute {
+	// look up the attributes
+	if v.path == nil || v.path.attrs == nil {
+		return Attribute{err: errNotExists}
+	}
+	for _, a := range v.path.attrs.attr {
+		if a.key() != key {
+			continue
+		}
+		at := Attribute{}
+		if err := parseAttrBody(v.ctx(), nil, a.body(), &at.attr); err != nil {
+			return Attribute{err: err.(error)}
+		}
+		return at
+	}
+	return Attribute{err: errNotExists}
+}
+
+var (
+	errNoSuchAttribute = errors.New("entry for key does not exist")
+)
+
+// An Attribute contains meta data about a field.
+type Attribute struct {
+	attr parsedAttr
+	err  error
+}
+
+// Err returns the error associated with this Attribute or nil if this
+// attribute is valid.
+func (a *Attribute) Err() error {
+	return a.err
+}
+
+func (a *Attribute) hasPos(p int) error {
+	if a.err != nil {
+		return a.err
+	}
+	if p >= len(a.attr.fields) {
+		return fmt.Errorf("field does not exist")
+	}
+	return nil
+}
+
+// String reports the possibly empty string value at the given position or
+// an error the attribute is invalid or if the position does not exist.
+func (a *Attribute) String(pos int) (string, error) {
+	if err := a.hasPos(pos); err != nil {
+		return "", err
+	}
+	return a.attr.fields[pos].text(), nil
+}
+
+// Int reports the integer at the given position or an error if the attribute is
+// invalid, the position does not exist, or the value at the given position is
+// not an integer.
+func (a *Attribute) Int(pos int) (int64, error) {
+	if err := a.hasPos(pos); err != nil {
+		return 0, err
+	}
+	// TODO: use CUE's literal parser once it exists, allowing any of CUE's
+	// number types.
+	return strconv.ParseInt(a.attr.fields[pos].text(), 10, 64)
+}
+
+// Flag reports whether an entry with the given name exists at position pos or
+// onwards or an error if the attribute is invalid or if the first pos-1 entries
+// are not defined.
+func (a *Attribute) Flag(pos int, key string) (bool, error) {
+	if err := a.hasPos(pos - 1); err != nil {
+		return false, err
+	}
+	for _, kv := range a.attr.fields[pos:] {
+		if kv.text() == key {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
+// Lookup searches for an entry of the form key=value from position pos onwards
+// and reports the value if found. It reports an error if the attribute is
+// invalid or if the first pos-1 entries are not defined.
+func (a *Attribute) Lookup(pos int, key string) (val string, found bool, err error) {
+	if err := a.hasPos(pos - 1); err != nil {
+		return "", false, err
+	}
+	for _, kv := range a.attr.fields[pos:] {
+		if kv.key() == key {
+			return kv.value(), true, nil
+		}
+	}
+	return "", false, nil
+}
diff --git a/cue/types_test.go b/cue/types_test.go
index d895109..bef35e4 100644
--- a/cue/types_test.go
+++ b/cue/types_test.go
@@ -24,6 +24,7 @@
 	"strings"
 	"testing"
 
+	"cuelang.org/go/cue/errors"
 	"github.com/google/go-cmp/cmp"
 )
 
@@ -945,6 +946,293 @@
 	}
 }
 
+func cmpError(a, b error) bool {
+	if a == nil {
+		return b == nil
+	}
+	if b == nil {
+		return a == nil
+	}
+	return a.Error() == b.Error()
+}
+
+func TestAttributeErr(t *testing.T) {
+	const config = `
+	a: {
+		a: 0 @foo(a,b,c=1)
+		b: 1 @bar(a,b,c,d=1) @foo(a,,d=1)
+	}
+	`
+	testCases := []struct {
+		path string
+		attr string
+		err  error
+	}{{
+		path: "a",
+		attr: "foo",
+		err:  nil,
+	}, {
+		path: "a",
+		attr: "bar",
+		err:  errors.New("undefined value"),
+	}, {
+		path: "xx",
+		attr: "bar",
+		err:  errors.New("undefined value"),
+	}, {
+		path: "e",
+		attr: "bar",
+		err:  errors.New("undefined value"),
+	}}
+	for _, tc := range testCases {
+		t.Run(tc.path+"-"+tc.attr, func(t *testing.T) {
+			v := getInstance(t, config).Value().Lookup("a", tc.path)
+			a := v.Attribute(tc.attr)
+			err := a.Err()
+			if !cmpError(err, tc.err) {
+				t.Errorf("got %v; want %v", err, tc.err)
+			}
+		})
+	}
+}
+
+func TestAttributeString(t *testing.T) {
+	const config = `
+	a: {
+		a: 0 @foo(a,b,c=1)
+		b: 1 @bar(a,b,c,d=1) @foo(a,,d=1)
+	}
+	`
+	testCases := []struct {
+		path string
+		attr string
+		pos  int
+		str  string
+		err  error
+	}{{
+		path: "a",
+		attr: "foo",
+		pos:  0,
+		str:  "a",
+	}, {
+		path: "a",
+		attr: "foo",
+		pos:  2,
+		str:  "c=1",
+	}, {
+		path: "b",
+		attr: "bar",
+		pos:  3,
+		str:  "d=1",
+	}, {
+		path: "e",
+		attr: "bar",
+		err:  errors.New("undefined value"),
+	}, {
+		path: "b",
+		attr: "foo",
+		pos:  4,
+		err:  errors.New("field does not exist"),
+	}}
+	for _, tc := range testCases {
+		t.Run(fmt.Sprintf("%s.%s:%d", tc.path, tc.attr, tc.pos), func(t *testing.T) {
+			v := getInstance(t, config).Value().Lookup("a", tc.path)
+			a := v.Attribute(tc.attr)
+			got, err := a.String(tc.pos)
+			if !cmpError(err, tc.err) {
+				t.Errorf("err: got %v; want %v", err, tc.err)
+			}
+			if got != tc.str {
+				t.Errorf("str: got %v; want %v", got, tc.str)
+			}
+		})
+	}
+}
+
+func TestAttributeInt(t *testing.T) {
+	const config = `
+	a: {
+		a: 0 @foo(1,3,c=1)
+		b: 1 @bar(a,-4,c,d=1) @foo(a,,d=1)
+	}
+	`
+	testCases := []struct {
+		path string
+		attr string
+		pos  int
+		val  int64
+		err  error
+	}{{
+		path: "a",
+		attr: "foo",
+		pos:  0,
+		val:  1,
+	}, {
+		path: "b",
+		attr: "bar",
+		pos:  1,
+		val:  -4,
+	}, {
+		path: "e",
+		attr: "bar",
+		err:  errors.New("undefined value"),
+	}, {
+		path: "b",
+		attr: "foo",
+		pos:  4,
+		err:  errors.New("field does not exist"),
+	}, {
+		path: "a",
+		attr: "foo",
+		pos:  2,
+		err:  errors.New(`strconv.ParseInt: parsing "c=1": invalid syntax`),
+	}}
+	for _, tc := range testCases {
+		t.Run(fmt.Sprintf("%s.%s:%d", tc.path, tc.attr, tc.pos), func(t *testing.T) {
+			v := getInstance(t, config).Value().Lookup("a", tc.path)
+			a := v.Attribute(tc.attr)
+			got, err := a.Int(tc.pos)
+			if !cmpError(err, tc.err) {
+				t.Errorf("err: got %v; want %v", err, tc.err)
+			}
+			if got != tc.val {
+				t.Errorf("val: got %v; want %v", got, tc.val)
+			}
+		})
+	}
+}
+
+func TestAttributeFlag(t *testing.T) {
+	const config = `
+	a: {
+		a: 0 @foo(a,b,c=1)
+		b: 1 @bar(a,b,c,d=1) @foo(a,,d=1)
+	}
+	`
+	testCases := []struct {
+		path string
+		attr string
+		pos  int
+		flag string
+		val  bool
+		err  error
+	}{{
+		path: "a",
+		attr: "foo",
+		pos:  0,
+		flag: "a",
+		val:  true,
+	}, {
+		path: "b",
+		attr: "bar",
+		pos:  1,
+		flag: "a",
+		val:  false,
+	}, {
+		path: "b",
+		attr: "bar",
+		pos:  0,
+		flag: "c",
+		val:  true,
+	}, {
+		path: "e",
+		attr: "bar",
+		err:  errors.New("undefined value"),
+	}, {
+		path: "b",
+		attr: "foo",
+		pos:  4,
+		err:  errors.New("field does not exist"),
+	}}
+	for _, tc := range testCases {
+		t.Run(fmt.Sprintf("%s.%s:%d", tc.path, tc.attr, tc.pos), func(t *testing.T) {
+			v := getInstance(t, config).Value().Lookup("a", tc.path)
+			a := v.Attribute(tc.attr)
+			got, err := a.Flag(tc.pos, tc.flag)
+			if !cmpError(err, tc.err) {
+				t.Errorf("err: got %v; want %v", err, tc.err)
+			}
+			if got != tc.val {
+				t.Errorf("val: got %v; want %v", got, tc.val)
+			}
+		})
+	}
+}
+
+func TestAttributeLookup(t *testing.T) {
+	const config = `
+	a: {
+		a: 0 @foo(a,b,c=1)
+		b: 1 @bar(a,b,e=-5,d=1) @foo(a,,d=1)
+	}
+	`
+	testCases := []struct {
+		path string
+		attr string
+		pos  int
+		key  string
+		val  string
+		err  error
+	}{{
+		path: "a",
+		attr: "foo",
+		pos:  0,
+		key:  "c",
+		val:  "1",
+	}, {
+		path: "b",
+		attr: "bar",
+		pos:  1,
+		key:  "a",
+		val:  "",
+	}, {
+		path: "b",
+		attr: "bar",
+		pos:  0,
+		key:  "e",
+		val:  "-5",
+	}, {
+		path: "b",
+		attr: "bar",
+		pos:  0,
+		key:  "d",
+		val:  "1",
+	}, {
+		path: "b",
+		attr: "foo",
+		pos:  2,
+		key:  "d",
+		val:  "1",
+	}, {
+		path: "b",
+		attr: "foo",
+		pos:  2,
+		key:  "f",
+		val:  "",
+	}, {
+		path: "e",
+		attr: "bar",
+		err:  errors.New("undefined value"),
+	}, {
+		path: "b",
+		attr: "foo",
+		pos:  4,
+		err:  errors.New("field does not exist"),
+	}}
+	for _, tc := range testCases {
+		t.Run(fmt.Sprintf("%s.%s:%d", tc.path, tc.attr, tc.pos), func(t *testing.T) {
+			v := getInstance(t, config).Value().Lookup("a", tc.path)
+			a := v.Attribute(tc.attr)
+			got, _, err := a.Lookup(tc.pos, tc.key)
+			if !cmpError(err, tc.err) {
+				t.Errorf("err: got %v; want %v", err, tc.err)
+			}
+			if got != tc.val {
+				t.Errorf("val: got %v; want %v", got, tc.val)
+			}
+		})
+	}
+}
 func TestMashalJSON(t *testing.T) {
 	testCases := []struct {
 		value string
diff --git a/cue/value.go b/cue/value.go
index c65584e..f615bc3 100644
--- a/cue/value.go
+++ b/cue/value.go
@@ -60,9 +60,11 @@
 }
 
 type iterAtter interface {
+	// TODO: make iterAt return a struct type instead, possibly an arc!
+
 	// at returns the evaluated and its original value at the given position.
 	// If the original could not be found, it returns an error and nil.
-	iterAt(*context, int) (evaluated, value, label)
+	iterAt(*context, int) (evaluated, value, label, *attributes)
 }
 
 // caller must be implemented by any concrete lambdaKind
@@ -204,12 +206,12 @@
 func (x *bytesLit) kind() kind       { return bytesKind }
 func (x *bytesLit) strValue() string { return string(x.b) }
 
-func (x *bytesLit) iterAt(ctx *context, i int) (evaluated, value, label) {
+func (x *bytesLit) iterAt(ctx *context, i int) (evaluated, value, label, *attributes) {
 	if i >= len(x.b) {
-		return nil, nil, 0
+		return nil, nil, 0, nil
 	}
 	v := x.at(ctx, i)
-	return v, v, 0
+	return v, v, 0, nil
 }
 
 func (x *bytesLit) at(ctx *context, i int) evaluated {
@@ -258,13 +260,13 @@
 func (x *stringLit) kind() kind       { return stringKind }
 func (x *stringLit) strValue() string { return x.str }
 
-func (x *stringLit) iterAt(ctx *context, i int) (evaluated, value, label) {
+func (x *stringLit) iterAt(ctx *context, i int) (evaluated, value, label, *attributes) {
 	runes := []rune(x.str)
 	if i >= len(runes) {
-		return nil, nil, 0
+		return nil, nil, 0, nil
 	}
 	v := x.at(ctx, i)
-	return v, v, 0
+	return v, v, 0, nil
 }
 
 func (x *stringLit) at(ctx *context, i int) evaluated {
@@ -487,7 +489,7 @@
 // already have been evaluated. It returns an error and nil if there was an
 // issue evaluating the list itself.
 func (x *list) at(ctx *context, i int) evaluated {
-	e, _, _ := x.iterAt(ctx, i)
+	e, _, _, _ := x.iterAt(ctx, i)
 	if e == nil {
 		return ctx.mkErr(x, "index %d out of bounds", i)
 	}
@@ -497,24 +499,24 @@
 // iterAt returns the evaluated and original value of position i. List x must
 // already have been evaluated. It returns an error and nil if there was an
 // issue evaluating the list itself.
-func (x *list) iterAt(ctx *context, i int) (evaluated, value, label) {
+func (x *list) iterAt(ctx *context, i int) (evaluated, value, label, *attributes) {
 	if i < 0 {
-		return ctx.mkErr(x, "index %d out of bounds", i), nil, 0
+		return ctx.mkErr(x, "index %d out of bounds", i), nil, 0, nil
 	}
 	if i < len(x.a) {
-		return x.a[i].evalPartial(ctx), x.a[i], 0
+		return x.a[i].evalPartial(ctx), x.a[i], 0, nil
 	}
 	max := maxNum(x.len.(evaluated))
 	if max.kind().isGround() {
 		if max.kind()&intKind == bottomKind {
-			return ctx.mkErr(max, "length indicator of list not of type int"), nil, 0
+			return ctx.mkErr(max, "length indicator of list not of type int"), nil, 0, nil
 		}
 		n := max.(*numLit).intValue(ctx)
 		if i >= n {
-			return nil, nil, 0
+			return nil, nil, 0, nil
 		}
 	}
-	return x.typ.(evaluated), x.typ, 0
+	return x.typ.(evaluated), x.typ, 0, nil
 }
 
 func (x *list) isOpen() bool {
@@ -603,13 +605,13 @@
 	return nil, nil
 }
 
-func (x *structLit) iterAt(ctx *context, i int) (evaluated, value, label) {
+func (x *structLit) iterAt(ctx *context, i int) (evaluated, value, label, *attributes) {
 	x = x.expandFields(ctx)
 	if i >= len(x.arcs) {
-		return nil, nil, 0
+		return nil, nil, 0, nil
 	}
 	v := x.at(ctx, i)
-	return v, x.arcs[i].v, x.arcs[i].feature // TODO: return template & v for original?
+	return v, x.arcs[i].v, x.arcs[i].feature, x.arcs[i].attrs // TODO: return template & v for original?
 }
 
 func (x *structLit) at(ctx *context, i int) evaluated {
@@ -763,6 +765,7 @@
 
 	v     value
 	cache evaluated // also used as newValue during unification.
+	attrs *attributes
 }
 
 type arcInfo struct {
@@ -773,7 +776,7 @@
 var hiddenArc = &arcInfo{hidden: true}
 
 // insertValue is used during initialization but never during evaluation.
-func (x *structLit) insertValue(ctx *context, f label, value value) {
+func (x *structLit) insertValue(ctx *context, f label, value value, a *attributes) {
 	for i, p := range x.arcs {
 		if f != p.feature {
 			continue
@@ -781,7 +784,7 @@
 		x.arcs[i].v = mkBin(ctx, token.NoPos, opUnify, p.v, value)
 		return
 	}
-	x.arcs = append(x.arcs, arc{feature: f, v: value})
+	x.arcs = append(x.arcs, arc{feature: f, v: value, attrs: a})
 	sort.Stable(x)
 }
 
@@ -851,7 +854,7 @@
 	if v == nil {
 		panic("nil node")
 	}
-	x.arcs = append(x.arcs, arc{f, v, nil})
+	x.arcs = append(x.arcs, arc{f, v, nil, nil})
 }
 
 func (x *params) iterAt(ctx *context, i int) (evaluated, value) {
@@ -913,7 +916,7 @@
 		if isBottom(v) {
 			return v
 		}
-		arcs[i] = arc{a.feature, v, v}
+		arcs[i] = arc{a.feature, v, v, nil}
 	}
 	lambda := &lambdaExpr{x.baseValue, &params{arcs}, nil}
 	defer ctx.pushForwards(x, lambda).popForwards()
diff --git a/doc/ref/spec.md b/doc/ref/spec.md
index be367c3..b204d45 100644
--- a/doc/ref/spec.md
+++ b/doc/ref/spec.md
@@ -987,14 +987,18 @@
 field and is visible within the template value.
 
 ```
-StructLit     = "{" [ { Declaration "," } Declaration ] "}" .
+StructLit     = "{" [ Declaration { "," Declaration } [ "," ] ] "}" .
 Declaration   = FieldDecl | AliasDecl | ComprehensionDecl .
-FieldDecl     = Label { Label } ":" Expression .
+FieldDecl     = Label { Label } ":" Expression { attribute } .
 
 AliasDecl     = Label "=" Expression .
 Label         = identifier | simple_string_lit | TemplateLabel .
 TemplateLabel = "<" identifier ">" .
-Tag           = "#" identifier [ ":" json_string ] .
+
+attribute     = "@" identifier "(" attr_elem { "," attr_elem } ")" .
+attr_elem     =  attr_string | identifier "=" attr_string .
+attr_string   = { attr_char } | string_lit .
+attr_char     = /* an arbitrary Unicode code point except newline, ',', '"', `'`, '#', '=', '(', and ')' */ .
 ```
 
 ```
@@ -1021,6 +1025,44 @@
 {a: 1} & {a: 2}                        _|_
 ```
 
+Fields may be associated with attributes.
+Attributes define additional information about a field,
+such as a mapping to a protobuf tag or alternative
+name of the field when mapping to a different language.
+
+If a field has multiple attributes their identifiers must be unique.
+Attributes accumulate when unifying two fields, removing duplicate entries.
+It is an error for the resulting field to have two different attributes
+with the same identifier.
+
+Attributes are not directly part of the data model, but may be
+accessed through the API or other means of reflection.
+The interpretation of the attribute value
+(a comma-separated list of attribute elements) depends on the attribute.
+Interpolations are not allowed in attribute strings.
+
+The recommended convention, however, is to interpret the first
+`n` arguments as positional arguments,
+where duplicate conflicting entries are an error,
+and the remaining arguments as a combination of flags
+(an identifier) and key value pairs, separated by a `=`.
+
+```
+MyStruct1: {
+    field: string @go(Field)
+    attr:  int    @xml(,attr) @go(Attr)
+}
+
+MyStruct2: {
+    field: string @go(Field)
+    attr:  int    @xml(a1,attr) @go(Attr)
+}
+
+Combined: MyStruct1 & MyStruct2
+// field: string @go(Field)
+// attr:  int    @xml(,attr) @xml(a1,attr) @go(Attr)
+```
+
 In addition to fields, a struct literal may also define aliases.
 Aliases name values that can be referred to
 within the [scope](#declarations-and-scopes) of their
@@ -1927,8 +1969,14 @@
 in the clauses.
 Values of iterations that map to the same label unify into a single field.
 
+<!--
+TODO: consider allowing multiple labels for comprehensions
+(current implementation). Generally it is better to define comprehensions
+in the current scope, though, as it may prevent surprises given the
+restrictions on comprehensions.
+-->
 ```
-ComprehensionDecl   = Field [ "<-" ] Clauses .
+ComprehensionDecl   = Label ":" Expression [ "<-" ] Clauses .
 ListComprehension   = "[" Expression [ "<-" ] Clauses "]" .
 
 Clauses             = Clause { Clause } .