detached ee5c1ce

feat(template): add the YAML reader, expression splitting and the evaluator

commit
ee5c1ceef6340d2f065913e3f27f308b42f58706
author
brice · · 2026-10-12 15:31 UTC
committer
brice · · 2026-10-12 16:17 UTC
parent
3694c60
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +1145 -7
M go.mod +1 -1
@@ -2,4 +2,4 @@ module bnema.dev/ermite-actions
22
33 go 1.27.1
44
5-require go.yaml.in/yaml/v3 v3.0.5 // indirect
5+require go.yaml.in/yaml/v3 v3.0.5
@@ -2,4 +2,4 @@ module bnema.dev/ermite-actions
2 2
3 go 1.27.13 go 1.27.1
4 4
5-require go.yaml.in/yaml/v3 v3.0.5 // indirect5+require go.yaml.in/yaml/v3 v3.0.5
M template/context.go +14 -1
@@ -20,12 +20,25 @@ type Error struct {
2020 Pos Pos
2121 // Raw is the message without the "file (Line: l, Col: c): " prefix.
2222 Raw string
23+
24+ // fileOnly makes the prefix the file name alone, for errors of the YAML
25+ // parser, which the reference reports without a position.
26+ fileOnly bool
27+
28+ cause error
2329 }
2430
31+// Unwrap returns the error of package expr behind an evaluation error, if any.
32+func (e *Error) Unwrap() error { return e.cause }
33+
2534 // Error returns the message with its prefix, as the reference does:
2635 // "file.yml (Line: 3, Col: 5): message".
2736 func (e *Error) Error() string {
28- if p := errorPrefix(e.File, e.Pos); p != "" {
37+ pos := e.Pos
38+ if e.fileOnly {
39+ pos = Pos{}
40+ }
41+ if p := errorPrefix(e.File, pos); p != "" {
2942 return p + ": " + e.Raw
3043 }
3144 return e.Raw
@@ -20,12 +20,25 @@ type Error struct {
20 Pos Pos20 Pos Pos
21 // Raw is the message without the "file (Line: l, Col: c): " prefix.21 // Raw is the message without the "file (Line: l, Col: c): " prefix.
22 Raw string22 Raw string
23+
24+ // fileOnly makes the prefix the file name alone, for errors of the YAML
25+ // parser, which the reference reports without a position.
26+ fileOnly bool
27+
28+ cause error
23 }29 }
24 30
31+// Unwrap returns the error of package expr behind an evaluation error, if any.
32+func (e *Error) Unwrap() error { return e.cause }
33+
25 // Error returns the message with its prefix, as the reference does:34 // Error returns the message with its prefix, as the reference does:
26 // "file.yml (Line: 3, Col: 5): message".35 // "file.yml (Line: 3, Col: 5): message".
27 func (e *Error) Error() string {36 func (e *Error) Error() string {
28- if p := errorPrefix(e.File, e.Pos); p != "" {37+ pos := e.Pos
38+ if e.fileOnly {
39+ pos = Pos{}
40+ }
41+ if p := errorPrefix(e.File, pos); p != "" {
29 return p + ": " + e.Raw42 return p + ": " + e.Raw
30 }43 }
31 return e.Raw44 return e.Raw
M template/definfo.go +2 -1
@@ -39,7 +39,8 @@ func newDefinitionInfo(s *Schema, parent *DefinitionInfo, d *Definition) *Defini
3939 }
4040 }
4141 } else if parent != nil {
42- di.allowed = parent.allowed
42+ di.allowed, di.named, di.funcs = parent.allowed, parent.named, parent.funcs
43+ return di
4344 }
4445 di.named, di.funcs = splitAllowedContext(di.allowed)
4546 return di
@@ -39,7 +39,8 @@ func newDefinitionInfo(s *Schema, parent *DefinitionInfo, d *Definition) *Defini
39 }39 }
40 }40 }
41 } else if parent != nil {41 } else if parent != nil {
42- di.allowed = parent.allowed42+ di.allowed, di.named, di.funcs = parent.allowed, parent.named, parent.funcs
43+ return di
43 }44 }
44 di.named, di.funcs = splitAllowedContext(di.allowed)45 di.named, di.funcs = splitAllowedContext(di.allowed)
45 return di46 return di
A template/eval.go +228 -0
@@ -0,0 +1,228 @@
1+// SPDX-License-Identifier: MIT
2+
3+package template
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "math"
10+ "strconv"
11+ "strings"
12+
13+ "bnema.dev/ermite-actions/expr"
14+)
15+
16+// Env is what expressions are evaluated against: the values of the named
17+// contexts ("github", "needs", "matrix"...) and the implementations of the
18+// extra functions ("success", "hashFiles"...). Which of them an expression may
19+// use was decided when it was read, from the schema.
20+type Env struct {
21+ Contexts map[string]expr.Value
22+ Funcs map[string]expr.Func
23+ // MaxResultBytes is the output budget of one expression, see
24+ // [expr.EvalContext]. Zero means the default.
25+ MaxResultBytes int
26+}
27+
28+func (e Env) evalContext() expr.EvalContext {
29+ return expr.EvalContext{Contexts: e.Contexts, Funcs: e.Funcs, MaxResultBytes: e.MaxResultBytes}
30+}
31+
32+// MaxEvalDepth bounds the nesting of tokens that [Evaluate] follows.
33+const MaxEvalDepth = 100
34+
35+func (t *BasicExpression) errorf(format string, a ...any) error {
36+ return &Error{File: t.file, Pos: t.pos, Raw: fmt.Sprintf(format, a...)}
37+}
38+
39+// EvaluateExpression computes one expression. Errors are [*Error] values
40+// positioned at the expression and wrap the underlying error of package expr.
41+func EvaluateExpression(ctx context.Context, t *BasicExpression, env Env) (expr.Value, error) {
42+ if t.parseErr != nil {
43+ return nil, &Error{File: t.file, Pos: t.pos, Raw: t.parseErr.Error(), cause: t.parseErr}
44+ }
45+ if t.node == nil {
46+ return nil, t.errorf("An expression was expected")
47+ }
48+ v, err := expr.Evaluate(ctx, t.node, env.evalContext())
49+ if err != nil {
50+ return nil, &Error{File: t.file, Pos: t.pos, Raw: err.Error(), cause: err}
51+ }
52+ return v, nil
53+}
54+
55+// Evaluate converts a token to a value of package expr, computing the
56+// expressions in it: literals become the corresponding scalars, sequences
57+// arrays and mappings dictionaries. A mapping key that is ${{ insert }} must
58+// have a mapping as value, whose entries are merged in place; a key that is
59+// another expression is evaluated and must produce a scalar.
60+func Evaluate(ctx context.Context, t Token, env Env) (expr.Value, error) {
61+ return evaluate(ctx, t, env, 0)
62+}
63+
64+func evaluate(ctx context.Context, t Token, env Env, depth int) (expr.Value, error) {
65+ if depth > MaxEvalDepth {
66+ return nil, &Error{File: t.File(), Pos: t.Pos(), Raw: "Maximum object depth exceeded"}
67+ }
68+ switch t := t.(type) {
69+ case *Null:
70+ return expr.Null{}, nil
71+ case *Boolean:
72+ return expr.Boolean(t.Value), nil
73+ case *Number:
74+ return expr.Number(t.Value), nil
75+ case *String:
76+ return expr.String(t.Value), nil
77+ case *BasicExpression:
78+ return EvaluateExpression(ctx, t, env)
79+ case *InsertExpression:
80+ return nil, &Error{File: t.file, Pos: t.pos, Raw: "The directive 'insert' is only allowed as a mapping key"}
81+ case *Sequence:
82+ out := expr.NewArray()
83+ for _, it := range t.Items {
84+ v, err := evaluate(ctx, it, env, depth+1)
85+ if err != nil {
86+ return nil, err
87+ }
88+ out.Append(v)
89+ }
90+ return out, nil
91+ case *Mapping:
92+ out := expr.NewDictionary()
93+ if err := evaluateMapping(ctx, t, env, depth, out); err != nil {
94+ return nil, err
95+ }
96+ return out, nil
97+ }
98+ return nil, errors.New("unknown token")
99+}
100+
101+func evaluateMapping(ctx context.Context, m *Mapping, env Env, depth int, out *expr.Dictionary) error {
102+ for _, p := range m.Pairs {
103+ if _, ok := p.Key.(*InsertExpression); ok {
104+ v, err := evaluate(ctx, p.Value, env, depth+1)
105+ if err != nil {
106+ return err
107+ }
108+ d, ok := v.(*expr.Dictionary)
109+ if !ok {
110+ return &Error{File: p.Key.File(), Pos: p.Key.Pos(), Raw: fmt.Sprintf("Expected a mapping for ${{ insert }} but got %s", v.Kind())}
111+ }
112+ for _, kv := range d.Pairs() {
113+ out.Add(kv.Key, kv.Value)
114+ }
115+ continue
116+ }
117+ kv, err := evaluate(ctx, p.Key, env, depth+1)
118+ if err != nil {
119+ return err
120+ }
121+ if k := kv.Kind(); k == expr.KindArray || k == expr.KindDictionary {
122+ return &Error{File: p.Key.File(), Pos: p.Key.Pos(), Raw: fmt.Sprintf("A mapping key must be a scalar but the expression produced %s", k)}
123+ }
124+ v, err := evaluate(ctx, p.Value, env, depth+1)
125+ if err != nil {
126+ return err
127+ }
128+ out.Add(expr.CoerceString(kv), v)
129+ }
130+ return nil
131+}
132+
133+// EvaluateString computes a token that must produce a string. Numbers,
134+// booleans and null are converted as GitHub does (null is the empty string);
135+// a sequence or mapping is an error.
136+func EvaluateString(ctx context.Context, t Token, env Env) (string, error) {
137+ if s, ok := t.(*String); ok {
138+ return s.Value, nil
139+ }
140+ v, err := Evaluate(ctx, t, env)
141+ if err != nil {
142+ return "", err
143+ }
144+ if k := v.Kind(); k == expr.KindArray || k == expr.KindDictionary {
145+ return "", &Error{File: t.File(), Pos: t.Pos(), Raw: fmt.Sprintf("Unexpected type '%s' encountered while reading 'string'. The type 'StringToken' was expected.", tokenKindOf(v))}
146+ }
147+ return expr.CoerceString(v), nil
148+}
149+
150+// EvaluateBool computes a token that must produce a boolean. A string
151+// "true" or "false" (any case) is accepted, as is the result of an
152+// expression of that kind; anything else is an error.
153+func EvaluateBool(ctx context.Context, t Token, env Env) (bool, error) {
154+ if b, ok := t.(*Boolean); ok {
155+ return b.Value, nil
156+ }
157+ v, err := Evaluate(ctx, t, env)
158+ if err != nil {
159+ return false, err
160+ }
161+ switch x := v.(type) {
162+ case expr.Boolean:
163+ return bool(x), nil
164+ case expr.String:
165+ switch lowerASCII(string(x)) {
166+ case "true":
167+ return true, nil
168+ case "false":
169+ return false, nil
170+ }
171+ }
172+ return false, &Error{File: t.File(), Pos: t.Pos(), Raw: fmt.Sprintf("Unexpected type '%s' encountered while reading 'boolean'. The type 'BooleanToken' was expected.", tokenKindOf(v))}
173+}
174+
175+// EvaluateNumber computes a token that must produce a number. Numeric strings
176+// are accepted.
177+func EvaluateNumber(ctx context.Context, t Token, env Env) (float64, error) {
178+ if n, ok := t.(*Number); ok {
179+ return n.Value, nil
180+ }
181+ v, err := Evaluate(ctx, t, env)
182+ if err != nil {
183+ return 0, err
184+ }
185+ switch x := v.(type) {
186+ case expr.Number:
187+ return float64(x), nil
188+ case expr.String:
189+ if f, ok := parseDecimal(string(x)); ok {
190+ return f, nil
191+ }
192+ }
193+ return 0, &Error{File: t.File(), Pos: t.Pos(), Raw: fmt.Sprintf("Unexpected type '%s' encountered while reading 'number'. The type 'NumberToken' was expected.", tokenKindOf(v))}
194+}
195+
196+func tokenKindOf(v expr.Value) string {
197+ switch v.Kind() {
198+ case expr.KindNull:
199+ return KindNull.String()
200+ case expr.KindBoolean:
201+ return KindBoolean.String()
202+ case expr.KindNumber:
203+ return KindNumber.String()
204+ case expr.KindString:
205+ return KindString.String()
206+ case expr.KindArray:
207+ return KindSequence.String()
208+ }
209+ return KindMapping.String()
210+}
211+
212+func lowerASCII(s string) string {
213+ b := []byte(s)
214+ for i, c := range b {
215+ if c >= 'A' && c <= 'Z' {
216+ b[i] = c + 32
217+ }
218+ }
219+ return string(b)
220+}
221+
222+func parseDecimal(s string) (float64, bool) {
223+ f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
224+ if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
225+ return 0, false
226+ }
227+ return f, true
228+}
@@ -0,0 +1,228 @@
1+// SPDX-License-Identifier: MIT
2+
3+package template
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "math"
10+ "strconv"
11+ "strings"
12+
13+ "bnema.dev/ermite-actions/expr"
14+)
15+
16+// Env is what expressions are evaluated against: the values of the named
17+// contexts ("github", "needs", "matrix"...) and the implementations of the
18+// extra functions ("success", "hashFiles"...). Which of them an expression may
19+// use was decided when it was read, from the schema.
20+type Env struct {
21+ Contexts map[string]expr.Value
22+ Funcs map[string]expr.Func
23+ // MaxResultBytes is the output budget of one expression, see
24+ // [expr.EvalContext]. Zero means the default.
25+ MaxResultBytes int
26+}
27+
28+func (e Env) evalContext() expr.EvalContext {
29+ return expr.EvalContext{Contexts: e.Contexts, Funcs: e.Funcs, MaxResultBytes: e.MaxResultBytes}
30+}
31+
32+// MaxEvalDepth bounds the nesting of tokens that [Evaluate] follows.
33+const MaxEvalDepth = 100
34+
35+func (t *BasicExpression) errorf(format string, a ...any) error {
36+ return &Error{File: t.file, Pos: t.pos, Raw: fmt.Sprintf(format, a...)}
37+}
38+
39+// EvaluateExpression computes one expression. Errors are [*Error] values
40+// positioned at the expression and wrap the underlying error of package expr.
41+func EvaluateExpression(ctx context.Context, t *BasicExpression, env Env) (expr.Value, error) {
42+ if t.parseErr != nil {
43+ return nil, &Error{File: t.file, Pos: t.pos, Raw: t.parseErr.Error(), cause: t.parseErr}
44+ }
45+ if t.node == nil {
46+ return nil, t.errorf("An expression was expected")
47+ }
48+ v, err := expr.Evaluate(ctx, t.node, env.evalContext())
49+ if err != nil {
50+ return nil, &Error{File: t.file, Pos: t.pos, Raw: err.Error(), cause: err}
51+ }
52+ return v, nil
53+}
54+
55+// Evaluate converts a token to a value of package expr, computing the
56+// expressions in it: literals become the corresponding scalars, sequences
57+// arrays and mappings dictionaries. A mapping key that is ${{ insert }} must
58+// have a mapping as value, whose entries are merged in place; a key that is
59+// another expression is evaluated and must produce a scalar.
60+func Evaluate(ctx context.Context, t Token, env Env) (expr.Value, error) {
61+ return evaluate(ctx, t, env, 0)
62+}
63+
64+func evaluate(ctx context.Context, t Token, env Env, depth int) (expr.Value, error) {
65+ if depth > MaxEvalDepth {
66+ return nil, &Error{File: t.File(), Pos: t.Pos(), Raw: "Maximum object depth exceeded"}
67+ }
68+ switch t := t.(type) {
69+ case *Null:
70+ return expr.Null{}, nil
71+ case *Boolean:
72+ return expr.Boolean(t.Value), nil
73+ case *Number:
74+ return expr.Number(t.Value), nil
75+ case *String:
76+ return expr.String(t.Value), nil
77+ case *BasicExpression:
78+ return EvaluateExpression(ctx, t, env)
79+ case *InsertExpression:
80+ return nil, &Error{File: t.file, Pos: t.pos, Raw: "The directive 'insert' is only allowed as a mapping key"}
81+ case *Sequence:
82+ out := expr.NewArray()
83+ for _, it := range t.Items {
84+ v, err := evaluate(ctx, it, env, depth+1)
85+ if err != nil {
86+ return nil, err
87+ }
88+ out.Append(v)
89+ }
90+ return out, nil
91+ case *Mapping:
92+ out := expr.NewDictionary()
93+ if err := evaluateMapping(ctx, t, env, depth, out); err != nil {
94+ return nil, err
95+ }
96+ return out, nil
97+ }
98+ return nil, errors.New("unknown token")
99+}
100+
101+func evaluateMapping(ctx context.Context, m *Mapping, env Env, depth int, out *expr.Dictionary) error {
102+ for _, p := range m.Pairs {
103+ if _, ok := p.Key.(*InsertExpression); ok {
104+ v, err := evaluate(ctx, p.Value, env, depth+1)
105+ if err != nil {
106+ return err
107+ }
108+ d, ok := v.(*expr.Dictionary)
109+ if !ok {
110+ return &Error{File: p.Key.File(), Pos: p.Key.Pos(), Raw: fmt.Sprintf("Expected a mapping for ${{ insert }} but got %s", v.Kind())}
111+ }
112+ for _, kv := range d.Pairs() {
113+ out.Add(kv.Key, kv.Value)
114+ }
115+ continue
116+ }
117+ kv, err := evaluate(ctx, p.Key, env, depth+1)
118+ if err != nil {
119+ return err
120+ }
121+ if k := kv.Kind(); k == expr.KindArray || k == expr.KindDictionary {
122+ return &Error{File: p.Key.File(), Pos: p.Key.Pos(), Raw: fmt.Sprintf("A mapping key must be a scalar but the expression produced %s", k)}
123+ }
124+ v, err := evaluate(ctx, p.Value, env, depth+1)
125+ if err != nil {
126+ return err
127+ }
128+ out.Add(expr.CoerceString(kv), v)
129+ }
130+ return nil
131+}
132+
133+// EvaluateString computes a token that must produce a string. Numbers,
134+// booleans and null are converted as GitHub does (null is the empty string);
135+// a sequence or mapping is an error.
136+func EvaluateString(ctx context.Context, t Token, env Env) (string, error) {
137+ if s, ok := t.(*String); ok {
138+ return s.Value, nil
139+ }
140+ v, err := Evaluate(ctx, t, env)
141+ if err != nil {
142+ return "", err
143+ }
144+ if k := v.Kind(); k == expr.KindArray || k == expr.KindDictionary {
145+ return "", &Error{File: t.File(), Pos: t.Pos(), Raw: fmt.Sprintf("Unexpected type '%s' encountered while reading 'string'. The type 'StringToken' was expected.", tokenKindOf(v))}
146+ }
147+ return expr.CoerceString(v), nil
148+}
149+
150+// EvaluateBool computes a token that must produce a boolean. A string
151+// "true" or "false" (any case) is accepted, as is the result of an
152+// expression of that kind; anything else is an error.
153+func EvaluateBool(ctx context.Context, t Token, env Env) (bool, error) {
154+ if b, ok := t.(*Boolean); ok {
155+ return b.Value, nil
156+ }
157+ v, err := Evaluate(ctx, t, env)
158+ if err != nil {
159+ return false, err
160+ }
161+ switch x := v.(type) {
162+ case expr.Boolean:
163+ return bool(x), nil
164+ case expr.String:
165+ switch lowerASCII(string(x)) {
166+ case "true":
167+ return true, nil
168+ case "false":
169+ return false, nil
170+ }
171+ }
172+ return false, &Error{File: t.File(), Pos: t.Pos(), Raw: fmt.Sprintf("Unexpected type '%s' encountered while reading 'boolean'. The type 'BooleanToken' was expected.", tokenKindOf(v))}
173+}
174+
175+// EvaluateNumber computes a token that must produce a number. Numeric strings
176+// are accepted.
177+func EvaluateNumber(ctx context.Context, t Token, env Env) (float64, error) {
178+ if n, ok := t.(*Number); ok {
179+ return n.Value, nil
180+ }
181+ v, err := Evaluate(ctx, t, env)
182+ if err != nil {
183+ return 0, err
184+ }
185+ switch x := v.(type) {
186+ case expr.Number:
187+ return float64(x), nil
188+ case expr.String:
189+ if f, ok := parseDecimal(string(x)); ok {
190+ return f, nil
191+ }
192+ }
193+ return 0, &Error{File: t.File(), Pos: t.Pos(), Raw: fmt.Sprintf("Unexpected type '%s' encountered while reading 'number'. The type 'NumberToken' was expected.", tokenKindOf(v))}
194+}
195+
196+func tokenKindOf(v expr.Value) string {
197+ switch v.Kind() {
198+ case expr.KindNull:
199+ return KindNull.String()
200+ case expr.KindBoolean:
201+ return KindBoolean.String()
202+ case expr.KindNumber:
203+ return KindNumber.String()
204+ case expr.KindString:
205+ return KindString.String()
206+ case expr.KindArray:
207+ return KindSequence.String()
208+ }
209+ return KindMapping.String()
210+}
211+
212+func lowerASCII(s string) string {
213+ b := []byte(s)
214+ for i, c := range b {
215+ if c >= 'A' && c <= 'Z' {
216+ b[i] = c + 32
217+ }
218+ }
219+ return string(b)
220+}
221+
222+func parseDecimal(s string) (float64, bool) {
223+ f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
224+ if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
225+ return 0, false
226+ }
227+ return f, true
228+}
A template/read_test.go +131 -0
@@ -0,0 +1,131 @@
1+// SPDX-License-Identifier: MIT
2+
3+package template
4+
5+import (
6+ "strings"
7+ "testing"
8+)
9+
10+func readWorkflow(t testing.TB, src string) (Token, []string) {
11+ t.Helper()
12+ s, err := WorkflowSchema()
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ ctx := NewContext(s)
17+ tok := ReadYAML(ctx, "workflow-root-strict", "a.yml", []byte(src))
18+ return tok, ctx.Messages()
19+}
20+
21+const head = "on: push\njobs:\n a:\n runs-on: x\n steps:\n"
22+
23+func TestReadErrors(t *testing.T) {
24+ // Expected messages come from @actions/workflow-parser 0.3.61 run on the
25+ // same inputs.
26+ tests := []struct {
27+ name, src string
28+ want []string
29+ }{
30+ {"unknown context, block scalar", head + " - run: |\n echo hi\n echo ${{ foo.bar }}\n",
31+ []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}},
32+ {"unknown context, plain", head + " - run: echo ${{ foo.bar }} abc\n",
33+ []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}},
34+ {"unknown context, double quoted", head + " - run: \"echo ${{ foo.bar }} abc\"\n",
35+ []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}},
36+ {"bad expression and insert in a name", head + " - run: echo ${{ 1 + }}\n - name: ${{ insert }}\n run: x\n",
37+ []string{"a.yml (Line: 6, Col: 14): Unexpected symbol: '+'. Located at position 3 within expression: 1 +", "a.yml (Line: 7, Col: 15): Unexpected value '${{ insert }}'"}},
38+ {"not closed", head + " - run: echo ${{ 1\n",
39+ []string{"a.yml (Line: 6, Col: 14): The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found."}},
40+ {"empty document", "", []string{"a.yml: Expected a scalar value, a sequence, or a mapping"}},
41+ {"comment only", "# c\n", []string{"a.yml: Expected a scalar value, a sequence, or a mapping"}},
42+ {"sequence root", "[1,2]", []string{"a.yml (Line: 1, Col: 1): A sequence was not expected"}},
43+ {"duplicate key", "on: push\non: pull_request\njobs: {}", []string{"a.yml (Line: 2, Col: 1): 'on' is already defined"}},
44+ {"alias is resolved", "on: push\njobs:\n a: &x\n runs-on: x\n steps: [{run: a}]\n b: *x\n", nil},
45+ {"valid", head + " - run: echo ${{ github.ref }}\n", nil},
46+ }
47+ for _, tc := range tests {
48+ t.Run(tc.name, func(t *testing.T) {
49+ _, got := readWorkflow(t, tc.src)
50+ if strings.Join(got, "\n") != strings.Join(tc.want, "\n") {
51+ t.Errorf("errors:\n got %q\nwant %q", got, tc.want)
52+ }
53+ })
54+ }
55+}
56+
57+func TestReadSyntaxErrorsHaveNoTokens(t *testing.T) {
58+ for _, src := range []string{"on: [push\n", "a: b: c", "on: push\n---\nx: 1\n", "\t"} {
59+ tok, errs := readWorkflow(t, src)
60+ if tok != nil || len(errs) == 0 {
61+ t.Errorf("%q: tok=%v errs=%q", src, tok, errs)
62+ }
63+ }
64+}
65+
66+func TestMixedStringBecomesFormat(t *testing.T) {
67+ tok, errs := readWorkflow(t, head+" - run: it's ${{ github.ref }} {x} ${{ 1 }}\n")
68+ if len(errs) > 0 {
69+ t.Fatal(errs)
70+ }
71+ run := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping).Find("steps").(*Sequence).Items[0].(*Mapping).Find("run")
72+ be, ok := run.(*BasicExpression)
73+ if !ok {
74+ t.Fatalf("run is %T", run)
75+ }
76+ if want := `format('it''s {0} {{x}} {1}', github.ref, 1)`; be.Expression != want {
77+ t.Errorf("expression = %s, want %s", be.Expression, want)
78+ }
79+ if len(be.Original) != 2 {
80+ t.Errorf("originals = %d", len(be.Original))
81+ }
82+}
83+
84+func TestEscapedLiteralExpression(t *testing.T) {
85+ tok, errs := readWorkflow(t, head+" - run: ${{ '{{ literal }}' }}\n")
86+ if len(errs) > 0 {
87+ t.Fatal(errs)
88+ }
89+ run := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping).Find("steps").(*Sequence).Items[0].(*Mapping).Find("run")
90+ if s, ok := run.(*String); !ok || s.Value != "{{ literal }}" {
91+ t.Errorf("run = %#v", run)
92+ }
93+}
94+
95+func TestScalarTypes(t *testing.T) {
96+ tok, errs := readWorkflow(t, "on: push\njobs:\n a:\n runs-on: x\n timeout-minutes: 0x10\n continue-on-error: yes\n steps: [{run: 1}]\n")
97+ // yes is a string in YAML 1.2, so continue-on-error is rejected.
98+ if len(errs) == 0 || !strings.Contains(errs[0], "Unexpected value 'yes'") {
99+ t.Errorf("errs = %q", errs)
100+ }
101+ job := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping)
102+ if n, ok := job.Find("timeout-minutes").(*Number); !ok || n.Value != 16 {
103+ t.Errorf("timeout-minutes = %#v", job.Find("timeout-minutes"))
104+ }
105+ step := job.Find("steps").(*Sequence).Items[0].(*Mapping)
106+ if s, ok := step.Find("run").(*String); !ok || s.Value != "1" {
107+ t.Errorf("run = %#v", step.Find("run"))
108+ }
109+}
110+
111+func TestAliasBombIsRejected(t *testing.T) {
112+ var b strings.Builder
113+ b.WriteString("a0: &a0 [x, x, x, x, x, x, x, x, x, x]\n")
114+ for i := 1; i < 8; i++ {
115+ b.WriteString("a")
116+ b.WriteString(string(rune('0' + i)))
117+ b.WriteString(": &a")
118+ b.WriteString(string(rune('0' + i)))
119+ b.WriteString(" [")
120+ for j := 0; j < 10; j++ {
121+ b.WriteString("*a")
122+ b.WriteString(string(rune('0' + i - 1)))
123+ b.WriteString(",")
124+ }
125+ b.WriteString("]\n")
126+ }
127+ _, errs := readWorkflow(t, b.String())
128+ if len(errs) == 0 || !strings.Contains(strings.Join(errs, "\n"), "too large") {
129+ t.Errorf("errs = %q", errs)
130+ }
131+}
@@ -0,0 +1,131 @@
1+// SPDX-License-Identifier: MIT
2+
3+package template
4+
5+import (
6+ "strings"
7+ "testing"
8+)
9+
10+func readWorkflow(t testing.TB, src string) (Token, []string) {
11+ t.Helper()
12+ s, err := WorkflowSchema()
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ ctx := NewContext(s)
17+ tok := ReadYAML(ctx, "workflow-root-strict", "a.yml", []byte(src))
18+ return tok, ctx.Messages()
19+}
20+
21+const head = "on: push\njobs:\n a:\n runs-on: x\n steps:\n"
22+
23+func TestReadErrors(t *testing.T) {
24+ // Expected messages come from @actions/workflow-parser 0.3.61 run on the
25+ // same inputs.
26+ tests := []struct {
27+ name, src string
28+ want []string
29+ }{
30+ {"unknown context, block scalar", head + " - run: |\n echo hi\n echo ${{ foo.bar }}\n",
31+ []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}},
32+ {"unknown context, plain", head + " - run: echo ${{ foo.bar }} abc\n",
33+ []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}},
34+ {"unknown context, double quoted", head + " - run: \"echo ${{ foo.bar }} abc\"\n",
35+ []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}},
36+ {"bad expression and insert in a name", head + " - run: echo ${{ 1 + }}\n - name: ${{ insert }}\n run: x\n",
37+ []string{"a.yml (Line: 6, Col: 14): Unexpected symbol: '+'. Located at position 3 within expression: 1 +", "a.yml (Line: 7, Col: 15): Unexpected value '${{ insert }}'"}},
38+ {"not closed", head + " - run: echo ${{ 1\n",
39+ []string{"a.yml (Line: 6, Col: 14): The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found."}},
40+ {"empty document", "", []string{"a.yml: Expected a scalar value, a sequence, or a mapping"}},
41+ {"comment only", "# c\n", []string{"a.yml: Expected a scalar value, a sequence, or a mapping"}},
42+ {"sequence root", "[1,2]", []string{"a.yml (Line: 1, Col: 1): A sequence was not expected"}},
43+ {"duplicate key", "on: push\non: pull_request\njobs: {}", []string{"a.yml (Line: 2, Col: 1): 'on' is already defined"}},
44+ {"alias is resolved", "on: push\njobs:\n a: &x\n runs-on: x\n steps: [{run: a}]\n b: *x\n", nil},
45+ {"valid", head + " - run: echo ${{ github.ref }}\n", nil},
46+ }
47+ for _, tc := range tests {
48+ t.Run(tc.name, func(t *testing.T) {
49+ _, got := readWorkflow(t, tc.src)
50+ if strings.Join(got, "\n") != strings.Join(tc.want, "\n") {
51+ t.Errorf("errors:\n got %q\nwant %q", got, tc.want)
52+ }
53+ })
54+ }
55+}
56+
57+func TestReadSyntaxErrorsHaveNoTokens(t *testing.T) {
58+ for _, src := range []string{"on: [push\n", "a: b: c", "on: push\n---\nx: 1\n", "\t"} {
59+ tok, errs := readWorkflow(t, src)
60+ if tok != nil || len(errs) == 0 {
61+ t.Errorf("%q: tok=%v errs=%q", src, tok, errs)
62+ }
63+ }
64+}
65+
66+func TestMixedStringBecomesFormat(t *testing.T) {
67+ tok, errs := readWorkflow(t, head+" - run: it's ${{ github.ref }} {x} ${{ 1 }}\n")
68+ if len(errs) > 0 {
69+ t.Fatal(errs)
70+ }
71+ run := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping).Find("steps").(*Sequence).Items[0].(*Mapping).Find("run")
72+ be, ok := run.(*BasicExpression)
73+ if !ok {
74+ t.Fatalf("run is %T", run)
75+ }
76+ if want := `format('it''s {0} {{x}} {1}', github.ref, 1)`; be.Expression != want {
77+ t.Errorf("expression = %s, want %s", be.Expression, want)
78+ }
79+ if len(be.Original) != 2 {
80+ t.Errorf("originals = %d", len(be.Original))
81+ }
82+}
83+
84+func TestEscapedLiteralExpression(t *testing.T) {
85+ tok, errs := readWorkflow(t, head+" - run: ${{ '{{ literal }}' }}\n")
86+ if len(errs) > 0 {
87+ t.Fatal(errs)
88+ }
89+ run := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping).Find("steps").(*Sequence).Items[0].(*Mapping).Find("run")
90+ if s, ok := run.(*String); !ok || s.Value != "{{ literal }}" {
91+ t.Errorf("run = %#v", run)
92+ }
93+}
94+
95+func TestScalarTypes(t *testing.T) {
96+ tok, errs := readWorkflow(t, "on: push\njobs:\n a:\n runs-on: x\n timeout-minutes: 0x10\n continue-on-error: yes\n steps: [{run: 1}]\n")
97+ // yes is a string in YAML 1.2, so continue-on-error is rejected.
98+ if len(errs) == 0 || !strings.Contains(errs[0], "Unexpected value 'yes'") {
99+ t.Errorf("errs = %q", errs)
100+ }
101+ job := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping)
102+ if n, ok := job.Find("timeout-minutes").(*Number); !ok || n.Value != 16 {
103+ t.Errorf("timeout-minutes = %#v", job.Find("timeout-minutes"))
104+ }
105+ step := job.Find("steps").(*Sequence).Items[0].(*Mapping)
106+ if s, ok := step.Find("run").(*String); !ok || s.Value != "1" {
107+ t.Errorf("run = %#v", step.Find("run"))
108+ }
109+}
110+
111+func TestAliasBombIsRejected(t *testing.T) {
112+ var b strings.Builder
113+ b.WriteString("a0: &a0 [x, x, x, x, x, x, x, x, x, x]\n")
114+ for i := 1; i < 8; i++ {
115+ b.WriteString("a")
116+ b.WriteString(string(rune('0' + i)))
117+ b.WriteString(": &a")
118+ b.WriteString(string(rune('0' + i)))
119+ b.WriteString(" [")
120+ for j := 0; j < 10; j++ {
121+ b.WriteString("*a")
122+ b.WriteString(string(rune('0' + i - 1)))
123+ b.WriteString(",")
124+ }
125+ b.WriteString("]\n")
126+ }
127+ _, errs := readWorkflow(t, b.String())
128+ if len(errs) == 0 || !strings.Contains(strings.Join(errs, "\n"), "too large") {
129+ t.Errorf("errs = %q", errs)
130+ }
131+}
A template/reader.go +252 -0
@@ -0,0 +1,252 @@
1+// SPDX-License-Identifier: MIT
2+
3+package template
4+
5+import (
6+ "fmt"
7+ "sort"
8+ "strings"
9+)
10+
11+// ReadYAML parses a YAML document and reads it as a template of the schema
12+// definition rootType (for workflows, "workflow-root-strict"). Problems
13+// found in the document, such as syntax errors, unexpected keys or
14+// misplaced expressions, are recorded in ctx; the returned token holds what
15+// could be read and is nil when the document is not valid YAML.
16+//
17+// An empty document is an error, as in the reference.
18+func ReadYAML(ctx *Context, rootType, file string, content []byte) (root Token) {
19+ res := readYAMLDocument(file, content)
20+ for _, e := range res.errs {
21+ ctx.add(e)
22+ }
23+ if len(res.errs) > 0 {
24+ return nil
25+ }
26+ defer func() {
27+ if r := recover(); r != nil {
28+ f, ok := r.(fatal)
29+ if !ok {
30+ panic(r)
31+ }
32+ ctx.ErrorInFile(file, f.msg)
33+ }
34+ }()
35+ rd := &reader{ctx: ctx, schema: ctx.schema, file: file}
36+ if res.root == nil {
37+ fatalf("Expected a scalar value, a sequence, or a mapping")
38+ }
39+ def := newDefinitionInfo(ctx.schema, nil, ctx.schema.get(rootType))
40+ return rd.readValue(res.root, def)
41+}
42+
43+type reader struct {
44+ ctx *Context
45+ schema *Schema
46+ file string
47+ // noExpressions is set when reading the result of an evaluation, whose
48+ // strings are text even if they contain ${{.
49+ noExpressions bool
50+}
51+
52+func (r *reader) origin(n *rnode) Origin { return Origin{File: r.file, Pos: n.pos} }
53+
54+func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token {
55+ switch n.kind {
56+ case rLiteral:
57+ scalar := r.parseScalar(n, n.lit, def)
58+ return r.validate(scalar, def)
59+ case rSeq:
60+ seq := NewSequence(r.origin(n))
61+ defs := def.schema.definitionsOfType(def.Definition, DefSequence)
62+ if len(defs) > 0 {
63+ itemDef := def.child(defs[0].ItemType)
64+ for _, it := range n.items {
65+ seq.Items = append(seq.Items, r.readValue(it, itemDef))
66+ }
67+ } else {
68+ r.ctx.ErrorAt(seq, "A sequence was not expected")
69+ }
70+ seq.def = def
71+ return seq
72+ }
73+ m := NewMapping(r.origin(n))
74+ defs := def.schema.definitionsOfType(def.Definition, DefMapping)
75+ if len(defs) > 0 {
76+ if len(defs) > 1 || len(defs[0].Properties) > 0 || defs[0].LooseKeyType == "" {
77+ r.mappingWithProperties(n, m, def, defs)
78+ } else {
79+ keyDef := def.child(defs[0].LooseKeyType)
80+ valDef := def.child(defs[0].LooseValueType)
81+ r.mappingAllLoose(n, m, def, keyDef, valDef, defs[0])
82+ }
83+ } else {
84+ r.ctx.ErrorAt(m, "A mapping was not expected")
85+ }
86+ if m.def == nil {
87+ m.def = def
88+ }
89+ return m
90+}
91+
92+// key reads the key of a mapping entry; a key that is not a scalar is an
93+// error and reads as an empty string.
94+func (r *reader) key(n *rnode, i int, def *DefinitionInfo) Token {
95+ k := n.keys[i]
96+ if k.badKey {
97+ r.ctx.ErrorAt(NewString(r.origin(k), ""), "Unexpected value ''")
98+ }
99+ return r.parseScalar(k, k.lit, def)
100+}
101+
102+func (r *reader) mappingWithProperties(n *rnode, m *Mapping, def *DefinitionInfo, defs []*Definition) {
103+ var looseKey, looseVal string
104+ if defs[0].LooseKeyType != "" {
105+ looseKey, looseVal = defs[0].LooseKeyType, defs[0].LooseValueType
106+ }
107+ var looseKeyDef, looseValDef *DefinitionInfo
108+ upperKeys := map[string]bool{}
109+ hasExpressionKey := false
110+ for i := range n.keys {
111+ keyScalar := r.key(n, i, def)
112+ val := n.vals[i]
113+ if IsExpression(keyScalar) {
114+ hasExpressionKey = true
115+ if len(def.allowed) > 0 {
116+ m.Add(keyScalar, r.readValue(val, def.child("any")))
117+ } else {
118+ r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context")
119+ }
120+ continue
121+ }
122+ nextKey, ok := keyScalar.(*String)
123+ if !ok {
124+ nextKey = &String{tokenBase: *keyScalar.base(), Value: keyScalar.String()}
125+ }
126+ if nextKey.Value != "" {
127+ up := strings.ToUpper(nextKey.Value)
128+ if upperKeys[up] {
129+ r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value)
130+ continue
131+ }
132+ upperKeys[up] = true
133+ }
134+ var prop *Property
135+ prop, defs = matchPropertyAndFilter(defs, nextKey.Value)
136+ if prop != nil {
137+ if backgroundStepProperties[nextKey.Value] {
138+ r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value)
139+ continue
140+ }
141+ nd := def.child(prop.Type)
142+ nextKey.def = nd
143+ m.Add(nextKey, r.readValue(val, nd))
144+ continue
145+ }
146+ if looseKey != "" {
147+ if looseKeyDef == nil {
148+ looseKeyDef = def.child(looseKey)
149+ looseValDef = def.child(looseVal)
150+ }
151+ r.validate(nextKey, looseKeyDef)
152+ nextKey.def = def.child(defs[0].LooseValueType)
153+ m.Add(nextKey, r.readValue(val, looseValDef))
154+ continue
155+ }
156+ r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value)
157+ }
158+ if len(defs) == 1 {
159+ m.def = newDefinitionInfo(def.schema, def, defs[0])
160+ }
161+ switch {
162+ case len(defs) > 1:
163+ hits := map[string]int{}
164+ for _, d := range defs {
165+ for _, k := range d.PropertyNames {
166+ hits[k]++
167+ }
168+ }
169+ var props []string
170+ for k, c := range hits {
171+ if c == 1 && !backgroundStepOnlyProperties[k] {
172+ props = append(props, k)
173+ }
174+ }
175+ sort.Strings(props)
176+ r.ctx.Errorf(m, "There's not enough info to determine what you meant. Add one of these properties: %s", strings.Join(props, ", "))
177+ case len(defs) == 1 && !hasExpressionKey:
178+ for _, name := range defs[0].PropertyNames {
179+ if defs[0].Properties[name].Required && !upperKeys[strings.ToUpper(name)] {
180+ r.ctx.Errorf(m, "Required property is missing: %s", name)
181+ }
182+ }
183+ }
184+}
185+
186+var (
187+ backgroundStepProperties = map[string]bool{"background": true, "wait": true, "wait-all": true, "cancel": true}
188+ backgroundStepOnlyProperties = map[string]bool{"wait": true, "wait-all": true, "cancel": true}
189+)
190+
191+func (r *reader) mappingAllLoose(n *rnode, m *Mapping, def, keyDef, valDef *DefinitionInfo, md *Definition) {
192+ upperKeys := map[string]bool{}
193+ for i := range n.keys {
194+ keyScalar := r.key(n, i, def)
195+ keyScalar.base().def = keyDef
196+ val := n.vals[i]
197+ if IsExpression(keyScalar) {
198+ if len(def.allowed) > 0 {
199+ m.Add(keyScalar, r.readValue(val, valDef))
200+ } else {
201+ r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context")
202+ }
203+ continue
204+ }
205+ nextKey, ok := keyScalar.(*String)
206+ if !ok {
207+ nextKey = &String{tokenBase: *keyScalar.base(), Value: keyScalar.String()}
208+ }
209+ if nextKey.Value != "" {
210+ up := strings.ToUpper(nextKey.Value)
211+ if upperKeys[up] {
212+ r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value)
213+ continue
214+ }
215+ upperKeys[up] = true
216+ }
217+ r.validate(nextKey, keyDef)
218+ nextKey.def = def.child(md.LooseValueType)
219+ m.Add(nextKey, r.readValue(val, valDef))
220+ }
221+}
222+
223+// validate checks a scalar against the scalar definitions of def. A literal
224+// that is not a string may be converted to one.
225+func (r *reader) validate(scalar Token, def *DefinitionInfo) Token {
226+ switch scalar.Kind() {
227+ case KindNull, KindBoolean, KindNumber, KindString:
228+ for _, d := range def.schema.scalarDefinitions(def.Definition) {
229+ if d.isMatch(scalar) {
230+ scalar.base().def = newDefinitionInfo(def.schema, def, d)
231+ return scalar
232+ }
233+ }
234+ if scalar.Kind() != KindString {
235+ sl := &String{tokenBase: *scalar.base(), Value: scalar.String()}
236+ for _, d := range def.schema.scalarDefinitions(def.Definition) {
237+ if d.isMatch(sl) {
238+ sl.def = newDefinitionInfo(def.schema, def, d)
239+ return sl
240+ }
241+ }
242+ }
243+ r.ctx.ErrorAt(scalar, fmt.Sprintf("Unexpected value '%s'", scalar.String()))
244+ case KindBasicExpression:
245+ if len(def.allowed) == 0 {
246+ r.ctx.ErrorAt(scalar, "A template expression is not allowed in this context")
247+ }
248+ default:
249+ r.ctx.ErrorAt(scalar, fmt.Sprintf("Unexpected value '%s'", scalar.String()))
250+ }
251+ return scalar
252+}
@@ -0,0 +1,252 @@
1+// SPDX-License-Identifier: MIT
2+
3+package template
4+
5+import (
6+ "fmt"
7+ "sort"
8+ "strings"
9+)
10+
11+// ReadYAML parses a YAML document and reads it as a template of the schema
12+// definition rootType (for workflows, "workflow-root-strict"). Problems
13+// found in the document, such as syntax errors, unexpected keys or
14+// misplaced expressions, are recorded in ctx; the returned token holds what
15+// could be read and is nil when the document is not valid YAML.
16+//
17+// An empty document is an error, as in the reference.
18+func ReadYAML(ctx *Context, rootType, file string, content []byte) (root Token) {
19+ res := readYAMLDocument(file, content)
20+ for _, e := range res.errs {
21+ ctx.add(e)
22+ }
23+ if len(res.errs) > 0 {
24+ return nil
25+ }
26+ defer func() {
27+ if r := recover(); r != nil {
28+ f, ok := r.(fatal)
29+ if !ok {
30+ panic(r)
31+ }
32+ ctx.ErrorInFile(file, f.msg)
33+ }
34+ }()
35+ rd := &reader{ctx: ctx, schema: ctx.schema, file: file}
36+ if res.root == nil {
37+ fatalf("Expected a scalar value, a sequence, or a mapping")
38+ }
39+ def := newDefinitionInfo(ctx.schema, nil, ctx.schema.get(rootType))
40+ return rd.readValue(res.root, def)
41+}
42+
43+type reader struct {
44+ ctx *Context
45+ schema *Schema
46+ file string
47+ // noExpressions is set when reading the result of an evaluation, whose
48+ // strings are text even if they contain ${{.
49+ noExpressions bool
50+}
51+
52+func (r *reader) origin(n *rnode) Origin { return Origin{File: r.file, Pos: n.pos} }
53+
54+func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token {
55+ switch n.kind {
56+ case rLiteral:
57+ scalar := r.parseScalar(n, n.lit, def)
58+ return r.validate(scalar, def)
59+ case rSeq:
60+ seq := NewSequence(r.origin(n))
61+ defs := def.schema.definitionsOfType(def.Definition, DefSequence)
62+ if len(defs) > 0 {
63+ itemDef := def.child(defs[0].ItemType)
64+ for _, it := range n.items {
65+ seq.Items = append(seq.Items, r.readValue(it, itemDef))
66+ }
67+ } else {
68+ r.ctx.ErrorAt(seq, "A sequence was not expected")
69+ }
70+ seq.def = def
71+ return seq
72+ }
73+ m := NewMapping(r.origin(n))
74+ defs := def.schema.definitionsOfType(def.Definition, DefMapping)
75+ if len(defs) > 0 {
76+ if len(defs) > 1 || len(defs[0].Properties) > 0 || defs[0].LooseKeyType == "" {
77+ r.mappingWithProperties(n, m, def, defs)
78+ } else {
79+ keyDef := def.child(defs[0].LooseKeyType)
80+ valDef := def.child(defs[0].LooseValueType)
81+ r.mappingAllLoose(n, m, def, keyDef, valDef, defs[0])
82+ }
83+ } else {
84+ r.ctx.ErrorAt(m, "A mapping was not expected")
85+ }
86+ if m.def == nil {
87+ m.def = def
88+ }
89+ return m
90+}
91+
92+// key reads the key of a mapping entry; a key that is not a scalar is an
93+// error and reads as an empty string.
94+func (r *reader) key(n *rnode, i int, def *DefinitionInfo) Token {
95+ k := n.keys[i]
96+ if k.badKey {
97+ r.ctx.ErrorAt(NewString(r.origin(k), ""), "Unexpected value ''")
98+ }
99+ return r.parseScalar(k, k.lit, def)
100+}
101+
102+func (r *reader) mappingWithProperties(n *rnode, m *Mapping, def *DefinitionInfo, defs []*Definition) {
103+ var looseKey, looseVal string
104+ if defs[0].LooseKeyType != "" {
105+ looseKey, looseVal = defs[0].LooseKeyType, defs[0].LooseValueType
106+ }
107+ var looseKeyDef, looseValDef *DefinitionInfo
108+ upperKeys := map[string]bool{}
109+ hasExpressionKey := false
110+ for i := range n.keys {
111+ keyScalar := r.key(n, i, def)
112+ val := n.vals[i]
113+ if IsExpression(keyScalar) {
114+ hasExpressionKey = true
115+ if len(def.allowed) > 0 {
116+ m.Add(keyScalar, r.readValue(val, def.child("any")))
117+ } else {
118+ r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context")
119+ }
120+ continue
121+ }
122+ nextKey, ok := keyScalar.(*String)
123+ if !ok {
124+ nextKey = &String{tokenBase: *keyScalar.base(), Value: keyScalar.String()}
125+ }
126+ if nextKey.Value != "" {
127+ up := strings.ToUpper(nextKey.Value)
128+ if upperKeys[up] {
129+ r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value)
130+ continue
131+ }
132+ upperKeys[up] = true
133+ }
134+ var prop *Property
135+ prop, defs = matchPropertyAndFilter(defs, nextKey.Value)
136+ if prop != nil {
137+ if backgroundStepProperties[nextKey.Value] {
138+ r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value)
139+ continue
140+ }
141+ nd := def.child(prop.Type)
142+ nextKey.def = nd
143+ m.Add(nextKey, r.readValue(val, nd))
144+ continue
145+ }
146+ if looseKey != "" {
147+ if looseKeyDef == nil {
148+ looseKeyDef = def.child(looseKey)
149+ looseValDef = def.child(looseVal)
150+ }
151+ r.validate(nextKey, looseKeyDef)
152+ nextKey.def = def.child(defs[0].LooseValueType)
153+ m.Add(nextKey, r.readValue(val, looseValDef))
154+ continue
155+ }
156+ r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value)
157+ }
158+ if len(defs) == 1 {
159+ m.def = newDefinitionInfo(def.schema, def, defs[0])
160+ }
161+ switch {
162+ case len(defs) > 1:
163+ hits := map[string]int{}
164+ for _, d := range defs {
165+ for _, k := range d.PropertyNames {
166+ hits[k]++
167+ }
168+ }
169+ var props []string
170+ for k, c := range hits {
171+ if c == 1 && !backgroundStepOnlyProperties[k] {
172+ props = append(props, k)
173+ }
174+ }
175+ sort.Strings(props)
176+ r.ctx.Errorf(m, "There's not enough info to determine what you meant. Add one of these properties: %s", strings.Join(props, ", "))
177+ case len(defs) == 1 && !hasExpressionKey:
178+ for _, name := range defs[0].PropertyNames {
179+ if defs[0].Properties[name].Required && !upperKeys[strings.ToUpper(name)] {
180+ r.ctx.Errorf(m, "Required property is missing: %s", name)
181+ }
182+ }
183+ }
184+}
185+
186+var (
187+ backgroundStepProperties = map[string]bool{"background": true, "wait": true, "wait-all": true, "cancel": true}
188+ backgroundStepOnlyProperties = map[string]bool{"wait": true, "wait-all": true, "cancel": true}
189+)
190+
191+func (r *reader) mappingAllLoose(n *rnode, m *Mapping, def, keyDef, valDef *DefinitionInfo, md *Definition) {
192+ upperKeys := map[string]bool{}
193+ for i := range n.keys {
194+ keyScalar := r.key(n, i, def)
195+ keyScalar.base().def = keyDef
196+ val := n.vals[i]
197+ if IsExpression(keyScalar) {
198+ if len(def.allowed) > 0 {
199+ m.Add(keyScalar, r.readValue(val, valDef))
200+ } else {
201+ r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context")
202+ }
203+ continue
204+ }
205+ nextKey, ok := keyScalar.(*String)
206+ if !ok {
207+ nextKey = &String{tokenBase: *keyScalar.base(), Value: keyScalar.String()}
208+ }
209+ if nextKey.Value != "" {
210+ up := strings.ToUpper(nextKey.Value)
211+ if upperKeys[up] {
212+ r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value)
213+ continue
214+ }
215+ upperKeys[up] = true
216+ }
217+ r.validate(nextKey, keyDef)
218+ nextKey.def = def.child(md.LooseValueType)
219+ m.Add(nextKey, r.readValue(val, valDef))
220+ }
221+}
222+
223+// validate checks a scalar against the scalar definitions of def. A literal
224+// that is not a string may be converted to one.
225+func (r *reader) validate(scalar Token, def *DefinitionInfo) Token {
226+ switch scalar.Kind() {
227+ case KindNull, KindBoolean, KindNumber, KindString:
228+ for _, d := range def.schema.scalarDefinitions(def.Definition) {
229+ if d.isMatch(scalar) {
230+ scalar.base().def = newDefinitionInfo(def.schema, def, d)
231+ return scalar
232+ }
233+ }
234+ if scalar.Kind() != KindString {
235+ sl := &String{tokenBase: *scalar.base(), Value: scalar.String()}
236+ for _, d := range def.schema.scalarDefinitions(def.Definition) {
237+ if d.isMatch(sl) {
238+ sl.def = newDefinitionInfo(def.schema, def, d)
239+ return sl
240+ }
241+ }
242+ }
243+ r.ctx.ErrorAt(scalar, fmt.Sprintf("Unexpected value '%s'", scalar.String()))
244+ case KindBasicExpression:
245+ if len(def.allowed) == 0 {
246+ r.ctx.ErrorAt(scalar, "A template expression is not allowed in this context")
247+ }
248+ default:
249+ r.ctx.ErrorAt(scalar, fmt.Sprintf("Unexpected value '%s'", scalar.String()))
250+ }
251+ return scalar
252+}
A template/scalar.go +243 -0
@@ -0,0 +1,243 @@
1+// SPDX-License-Identifier: MIT
2+
3+package template
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "strings"
9+ "unicode"
10+
11+ "bnema.dev/ermite-actions/expr"
12+)
13+
14+const (
15+ openExpression = "${{"
16+ insertDirective = "insert"
17+)
18+
19+// parseScalar splits a string that holds ${{ }} expressions into literal
20+// text and expression tokens. Strings without an expression, other scalars
21+// and strings with an error in an expression are returned as they are.
22+//
23+// The reference works on the raw source of a multi-line YAML scalar, which
24+// keeps the indentation of a block scalar; this port always works on the
25+// value, as the runner does.
26+func (r *reader) parseScalar(_ *rnode, token Token, def *DefinitionInfo) Token {
27+ s, ok := token.(*String)
28+ if r.noExpressions || !ok || s.Value == "" {
29+ return token
30+ }
31+ raw := s.Value
32+ startExpression := strings.Index(raw, openExpression)
33+ if startExpression < 0 {
34+ return token
35+ }
36+ var segments []Token
37+ encounteredError := false
38+ i := 0
39+ for i < len(raw) {
40+ switch {
41+ case i == startExpression:
42+ end := -1
43+ inString := false
44+ for i += len(openExpression); i < len(raw); i++ {
45+ if raw[i] == '\'' {
46+ inString = !inString
47+ } else if !inString && raw[i] == '}' && raw[i-1] == '}' {
48+ end = i
49+ i++
50+ break
51+ }
52+ }
53+ if end < startExpression {
54+ r.ctx.ErrorAt(token, "The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found.")
55+ return token
56+ }
57+ rawExpression := raw[startExpression+len(openExpression) : end-1]
58+ e := r.parseIntoExpressionToken(token, rawExpression, def)
59+ if e == nil {
60+ encounteredError = true
61+ } else {
62+ if _, isInsert := e.(*InsertExpression); isInsert && (startExpression != 0 || i < len(raw)) {
63+ r.ctx.ErrorAt(token, "The directive 'insert' is not allowed in this context. Directives are not supported for expressions that are embedded within a string. Directives are only supported when the entire value is an expression.")
64+ return token
65+ }
66+ segments = append(segments, e)
67+ }
68+ if j := strings.Index(raw[i:], openExpression); j >= 0 {
69+ startExpression = i + j
70+ } else {
71+ startExpression = -1
72+ }
73+ case i < startExpression:
74+ segments = r.addString(segments, token, raw[i:startExpression], s.def)
75+ i = startExpression
76+ default:
77+ segments = r.addString(segments, token, raw[i:], s.def)
78+ i = len(raw)
79+ }
80+ }
81+ if encounteredError {
82+ return token
83+ }
84+ if len(segments) == 1 {
85+ if be, ok := segments[0].(*BasicExpression); ok {
86+ if str, ok := expressionString(be.Expression); ok {
87+ return &String{tokenBase: tokenBase{file: r.file, pos: token.Pos(), def: s.def}, Value: str}
88+ }
89+ }
90+ return segments[0]
91+ }
92+ var format strings.Builder
93+ var args strings.Builder
94+ var originals []*BasicExpression
95+ argIndex := 0
96+ for _, seg := range segments {
97+ if ss, ok := seg.(*String); ok {
98+ text := strings.ReplaceAll(ss.Value, "'", "''")
99+ text = strings.ReplaceAll(text, "{", "{{")
100+ text = strings.ReplaceAll(text, "}", "}}")
101+ format.WriteString(text)
102+ continue
103+ }
104+ be := seg.(*BasicExpression)
105+ fmt.Fprintf(&format, "{%d}", argIndex)
106+ argIndex++
107+ args.WriteString(", ")
108+ args.WriteString(be.Expression)
109+ originals = append(originals, be)
110+ }
111+ t := &BasicExpression{
112+ tokenBase: tokenBase{file: r.file, pos: token.Pos(), def: def},
113+ Expression: "format('" + format.String() + "'" + args.String() + ")",
114+ Original: originals,
115+ Source: raw,
116+ }
117+ t.node, t.parseErr = parseWith(t.Expression, def)
118+ return t
119+}
120+
121+func (r *reader) addString(segs []Token, at Token, value string, def *DefinitionInfo) []Token {
122+ if n := len(segs); n > 0 {
123+ if last, ok := segs[n-1].(*String); ok {
124+ segs[n-1] = &String{tokenBase: tokenBase{file: r.file, pos: at.Pos(), def: def}, Value: last.Value + value}
125+ return segs
126+ }
127+ }
128+ return append(segs, &String{tokenBase: tokenBase{file: r.file, pos: at.Pos(), def: def}, Value: value})
129+}
130+
131+func (r *reader) parseIntoExpressionToken(token Token, rawExpression string, def *DefinitionInfo) Token {
132+ e, err := r.parseExpression(token, rawExpression, def)
133+ if err != nil {
134+ r.ctx.ErrorAt(token, err.Error())
135+ return nil
136+ }
137+ return e
138+}
139+
140+func (r *reader) parseExpression(token Token, value string, def *DefinitionInfo) (Token, error) {
141+ trimmed := strings.TrimSpace(value)
142+ if trimmed == "" {
143+ return nil, errors.New("An expression was expected")
144+ }
145+ isMatch, err := matchDirective(trimmed, insertDirective, 0)
146+ if err != nil {
147+ return nil, err
148+ }
149+ if isMatch {
150+ return &InsertExpression{tokenBase{file: r.file, pos: token.Pos(), def: def}}, nil
151+ }
152+ node, err := parseWith(trimmed, def)
153+ if err != nil {
154+ return nil, err
155+ }
156+ src, _ := token.(*String)
157+ t := &BasicExpression{
158+ tokenBase: tokenBase{file: r.file, pos: token.Pos(), def: def},
159+ Expression: trimmed,
160+ node: node,
161+ }
162+ if src != nil {
163+ t.Source = src.Value
164+ }
165+ return t, nil
166+}
167+
168+// parseWith parses an expression against the contexts and functions allowed
169+// by def. A nil def allows nothing.
170+func parseWith(text string, def *DefinitionInfo) (expr.Node, error) {
171+ var named []string
172+ var funcs []expr.FuncInfo
173+ if def != nil {
174+ named, funcs = def.named, def.funcs
175+ }
176+ return expr.Parse(text, named, funcs)
177+}
178+
179+// matchDirective reports whether trimmed is the directive followed by exactly
180+// expectedParameters parameters. It is a literal port of the reference,
181+// including the way it counts parameters.
182+func matchDirective(trimmed, directive string, expectedParameters int) (bool, error) {
183+ if !strings.HasPrefix(trimmed, directive) {
184+ return false, nil
185+ }
186+ rest := trimmed[len(directive):]
187+ if rest != "" {
188+ c := []rune(rest)[0]
189+ if !unicode.IsSpace(c) && c != '\ufeff' {
190+ return false, nil
191+ }
192+ }
193+ // The reference only counts the text after the last separator that is
194+ // outside quotes and parentheses; its loop never counts earlier ones.
195+ rs := []rune(trimmed)
196+ startIndex := len([]rune(directive))
197+ inString := false
198+ parens := 0
199+ for i := startIndex; i < len(rs); i++ {
200+ c := rs[i]
201+ switch {
202+ case unicode.IsSpace(c) && !inString && parens == 0:
203+ startIndex = i + 1
204+ case c == '\'':
205+ inString = !inString
206+ case c == '(' && !inString:
207+ parens++
208+ case c == ')' && !inString:
209+ parens--
210+ }
211+ }
212+ parameters := 0
213+ if startIndex < len(rs) {
214+ parameters = 1
215+ }
216+ if expectedParameters != parameters {
217+ return false, fmt.Errorf("Exactly %d parameter(s) were expected following the directive '%s'. Actual parameter count: %d", expectedParameters, directive, parameters)
218+ }
219+ return true, nil
220+}
221+
222+// expressionString returns the text of an expression that is a single quoted
223+// string, such as ${{ '{{ literal }}' }}, which is how GitHub lets a workflow
224+// contain a literal ${{.
225+func expressionString(trimmed string) (string, bool) {
226+ var out []byte
227+ inString := false
228+ for i := 0; i < len(trimmed); i++ {
229+ c := trimmed[i]
230+ switch {
231+ case c == '\'':
232+ inString = !inString
233+ if inString && i != 0 {
234+ out = append(out, c)
235+ }
236+ case !inString:
237+ return "", false
238+ default:
239+ out = append(out, c)
240+ }
241+ }
242+ return string(out), true
243+}
@@ -0,0 +1,243 @@
1+// SPDX-License-Identifier: MIT
2+
3+package template
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "strings"
9+ "unicode"
10+
11+ "bnema.dev/ermite-actions/expr"
12+)
13+
14+const (
15+ openExpression = "${{"
16+ insertDirective = "insert"
17+)
18+
19+// parseScalar splits a string that holds ${{ }} expressions into literal
20+// text and expression tokens. Strings without an expression, other scalars
21+// and strings with an error in an expression are returned as they are.
22+//
23+// The reference works on the raw source of a multi-line YAML scalar, which
24+// keeps the indentation of a block scalar; this port always works on the
25+// value, as the runner does.
26+func (r *reader) parseScalar(_ *rnode, token Token, def *DefinitionInfo) Token {
27+ s, ok := token.(*String)
28+ if r.noExpressions || !ok || s.Value == "" {
29+ return token
30+ }
31+ raw := s.Value
32+ startExpression := strings.Index(raw, openExpression)
33+ if startExpression < 0 {
34+ return token
35+ }
36+ var segments []Token
37+ encounteredError := false
38+ i := 0
39+ for i < len(raw) {
40+ switch {
41+ case i == startExpression:
42+ end := -1
43+ inString := false
44+ for i += len(openExpression); i < len(raw); i++ {
45+ if raw[i] == '\'' {
46+ inString = !inString
47+ } else if !inString && raw[i] == '}' && raw[i-1] == '}' {
48+ end = i
49+ i++
50+ break
51+ }
52+ }
53+ if end < startExpression {
54+ r.ctx.ErrorAt(token, "The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found.")
55+ return token
56+ }
57+ rawExpression := raw[startExpression+len(openExpression) : end-1]
58+ e := r.parseIntoExpressionToken(token, rawExpression, def)
59+ if e == nil {
60+ encounteredError = true
61+ } else {
62+ if _, isInsert := e.(*InsertExpression); isInsert && (startExpression != 0 || i < len(raw)) {
63+ r.ctx.ErrorAt(token, "The directive 'insert' is not allowed in this context. Directives are not supported for expressions that are embedded within a string. Directives are only supported when the entire value is an expression.")
64+ return token
65+ }
66+ segments = append(segments, e)
67+ }
68+ if j := strings.Index(raw[i:], openExpression); j >= 0 {
69+ startExpression = i + j
70+ } else {
71+ startExpression = -1
72+ }
73+ case i < startExpression:
74+ segments = r.addString(segments, token, raw[i:startExpression], s.def)
75+ i = startExpression
76+ default:
77+ segments = r.addString(segments, token, raw[i:], s.def)
78+ i = len(raw)
79+ }
80+ }
81+ if encounteredError {
82+ return token
83+ }
84+ if len(segments) == 1 {
85+ if be, ok := segments[0].(*BasicExpression); ok {
86+ if str, ok := expressionString(be.Expression); ok {
87+ return &String{tokenBase: tokenBase{file: r.file, pos: token.Pos(), def: s.def}, Value: str}
88+ }
89+ }
90+ return segments[0]
91+ }
92+ var format strings.Builder
93+ var args strings.Builder
94+ var originals []*BasicExpression
95+ argIndex := 0
96+ for _, seg := range segments {
97+ if ss, ok := seg.(*String); ok {
98+ text := strings.ReplaceAll(ss.Value, "'", "''")
99+ text = strings.ReplaceAll(text, "{", "{{")
100+ text = strings.ReplaceAll(text, "}", "}}")
101+ format.WriteString(text)
102+ continue
103+ }
104+ be := seg.(*BasicExpression)
105+ fmt.Fprintf(&format, "{%d}", argIndex)
106+ argIndex++
107+ args.WriteString(", ")
108+ args.WriteString(be.Expression)
109+ originals = append(originals, be)
110+ }
111+ t := &BasicExpression{
112+ tokenBase: tokenBase{file: r.file, pos: token.Pos(), def: def},
113+ Expression: "format('" + format.String() + "'" + args.String() + ")",
114+ Original: originals,
115+ Source: raw,
116+ }
117+ t.node, t.parseErr = parseWith(t.Expression, def)
118+ return t
119+}
120+
121+func (r *reader) addString(segs []Token, at Token, value string, def *DefinitionInfo) []Token {
122+ if n := len(segs); n > 0 {
123+ if last, ok := segs[n-1].(*String); ok {
124+ segs[n-1] = &String{tokenBase: tokenBase{file: r.file, pos: at.Pos(), def: def}, Value: last.Value + value}
125+ return segs
126+ }
127+ }
128+ return append(segs, &String{tokenBase: tokenBase{file: r.file, pos: at.Pos(), def: def}, Value: value})
129+}
130+
131+func (r *reader) parseIntoExpressionToken(token Token, rawExpression string, def *DefinitionInfo) Token {
132+ e, err := r.parseExpression(token, rawExpression, def)
133+ if err != nil {
134+ r.ctx.ErrorAt(token, err.Error())
135+ return nil
136+ }
137+ return e
138+}
139+
140+func (r *reader) parseExpression(token Token, value string, def *DefinitionInfo) (Token, error) {
141+ trimmed := strings.TrimSpace(value)
142+ if trimmed == "" {
143+ return nil, errors.New("An expression was expected")
144+ }
145+ isMatch, err := matchDirective(trimmed, insertDirective, 0)
146+ if err != nil {
147+ return nil, err
148+ }
149+ if isMatch {
150+ return &InsertExpression{tokenBase{file: r.file, pos: token.Pos(), def: def}}, nil
151+ }
152+ node, err := parseWith(trimmed, def)
153+ if err != nil {
154+ return nil, err
155+ }
156+ src, _ := token.(*String)
157+ t := &BasicExpression{
158+ tokenBase: tokenBase{file: r.file, pos: token.Pos(), def: def},
159+ Expression: trimmed,
160+ node: node,
161+ }
162+ if src != nil {
163+ t.Source = src.Value
164+ }
165+ return t, nil
166+}
167+
168+// parseWith parses an expression against the contexts and functions allowed
169+// by def. A nil def allows nothing.
170+func parseWith(text string, def *DefinitionInfo) (expr.Node, error) {
171+ var named []string
172+ var funcs []expr.FuncInfo
173+ if def != nil {
174+ named, funcs = def.named, def.funcs
175+ }
176+ return expr.Parse(text, named, funcs)
177+}
178+
179+// matchDirective reports whether trimmed is the directive followed by exactly
180+// expectedParameters parameters. It is a literal port of the reference,
181+// including the way it counts parameters.
182+func matchDirective(trimmed, directive string, expectedParameters int) (bool, error) {
183+ if !strings.HasPrefix(trimmed, directive) {
184+ return false, nil
185+ }
186+ rest := trimmed[len(directive):]
187+ if rest != "" {
188+ c := []rune(rest)[0]
189+ if !unicode.IsSpace(c) && c != '\ufeff' {
190+ return false, nil
191+ }
192+ }
193+ // The reference only counts the text after the last separator that is
194+ // outside quotes and parentheses; its loop never counts earlier ones.
195+ rs := []rune(trimmed)
196+ startIndex := len([]rune(directive))
197+ inString := false
198+ parens := 0
199+ for i := startIndex; i < len(rs); i++ {
200+ c := rs[i]
201+ switch {
202+ case unicode.IsSpace(c) && !inString && parens == 0:
203+ startIndex = i + 1
204+ case c == '\'':
205+ inString = !inString
206+ case c == '(' && !inString:
207+ parens++
208+ case c == ')' && !inString:
209+ parens--
210+ }
211+ }
212+ parameters := 0
213+ if startIndex < len(rs) {
214+ parameters = 1
215+ }
216+ if expectedParameters != parameters {
217+ return false, fmt.Errorf("Exactly %d parameter(s) were expected following the directive '%s'. Actual parameter count: %d", expectedParameters, directive, parameters)
218+ }
219+ return true, nil
220+}
221+
222+// expressionString returns the text of an expression that is a single quoted
223+// string, such as ${{ '{{ literal }}' }}, which is how GitHub lets a workflow
224+// contain a literal ${{.
225+func expressionString(trimmed string) (string, bool) {
226+ var out []byte
227+ inString := false
228+ for i := 0; i < len(trimmed); i++ {
229+ c := trimmed[i]
230+ switch {
231+ case c == '\'':
232+ inString = !inString
233+ if inString && i != 0 {
234+ out = append(out, c)
235+ }
236+ case !inString:
237+ return "", false
238+ default:
239+ out = append(out, c)
240+ }
241+ }
242+ return string(out), true
243+}
M template/tokens.go +10 -4
@@ -140,7 +140,8 @@ type BasicExpression struct {
140140 // Source is the unprocessed text of the value the token was read from.
141141 Source string
142142
143- node expr.Node
143+ node expr.Node
144+ parseErr error
144145 }
145146
146147 // InsertExpression is the ${{ insert }} directive.
@@ -192,15 +193,20 @@ func NewSequence(o Origin, items ...Token) *Sequence {
192193 // NewMapping returns an empty mapping token.
193194 func NewMapping(o Origin) *Mapping { return &Mapping{tokenBase: mk(o)} }
194195
195-// NewExpression returns an expression token. It is parsed when first
196-// evaluated, against the contexts and functions allowed by def, or, when def
197-// is nil, against whatever the evaluation environment provides.
196+// NewExpression returns an expression token. The text is parsed at once
197+// against the contexts and functions allowed by def; with a nil def no
198+// context and no function besides the built-in ones is allowed. A parse error
199+// is returned by [Evaluate] and [BasicExpression.Err].
198200 func NewExpression(o Origin, expression string, def *DefinitionInfo) *BasicExpression {
199201 t := &BasicExpression{tokenBase: mk(o), Expression: expression}
200202 t.def = def
203+ t.node, t.parseErr = parseWith(expression, def)
201204 return t
202205 }
203206
207+// Err returns the error of parsing the expression, or nil.
208+func (t *BasicExpression) Err() error { return t.parseErr }
209+
204210 // Add appends a pair.
205211 func (m *Mapping) Add(key, value Token) { m.Pairs = append(m.Pairs, Pair{key, value}) }
206212
@@ -140,7 +140,8 @@ type BasicExpression struct {
140 // Source is the unprocessed text of the value the token was read from.140 // Source is the unprocessed text of the value the token was read from.
141 Source string141 Source string
142 142
143- node expr.Node143+ node expr.Node
144+ parseErr error
144 }145 }
145 146
146 // InsertExpression is the ${{ insert }} directive.147 // InsertExpression is the ${{ insert }} directive.
@@ -192,15 +193,20 @@ func NewSequence(o Origin, items ...Token) *Sequence {
192 // NewMapping returns an empty mapping token.193 // NewMapping returns an empty mapping token.
193 func NewMapping(o Origin) *Mapping { return &Mapping{tokenBase: mk(o)} }194 func NewMapping(o Origin) *Mapping { return &Mapping{tokenBase: mk(o)} }
194 195
195-// NewExpression returns an expression token. It is parsed when first196+// NewExpression returns an expression token. The text is parsed at once
196-// evaluated, against the contexts and functions allowed by def, or, when def197+// against the contexts and functions allowed by def; with a nil def no
197-// is nil, against whatever the evaluation environment provides.198+// context and no function besides the built-in ones is allowed. A parse error
199+// is returned by [Evaluate] and [BasicExpression.Err].
198 func NewExpression(o Origin, expression string, def *DefinitionInfo) *BasicExpression {200 func NewExpression(o Origin, expression string, def *DefinitionInfo) *BasicExpression {
199 t := &BasicExpression{tokenBase: mk(o), Expression: expression}201 t := &BasicExpression{tokenBase: mk(o), Expression: expression}
200 t.def = def202 t.def = def
203+ t.node, t.parseErr = parseWith(expression, def)
201 return t204 return t
202 }205 }
203 206
207+// Err returns the error of parsing the expression, or nil.
208+func (t *BasicExpression) Err() error { return t.parseErr }
209+
204 // Add appends a pair.210 // Add appends a pair.
205 func (m *Mapping) Add(key, value Token) { m.Pairs = append(m.Pairs, Pair{key, value}) }211 func (m *Mapping) Add(key, value Token) { m.Pairs = append(m.Pairs, Pair{key, value}) }
206 212
A template/yaml.go +264 -0
@@ -0,0 +1,264 @@
1+// SPDX-License-Identifier: MIT
2+
3+package template
4+
5+import (
6+ "bytes"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "math"
11+ "regexp"
12+ "strconv"
13+ "strings"
14+
15+ "go.yaml.in/yaml/v3"
16+)
17+
18+// MaxNodes is the number of YAML nodes a document may expand to, counting
19+// every use of an alias. The reference expands aliases without a limit, so a
20+// small document of nested aliases can allocate gigabytes.
21+const MaxNodes = 200_000
22+
23+type rkind int
24+
25+const (
26+ rLiteral rkind = iota
27+ rSeq
28+ rMap
29+)
30+
31+// rnode is a YAML node after alias resolution: the tree the template reader
32+// walks.
33+type rnode struct {
34+ kind rkind
35+ pos Pos
36+ lit Token // rLiteral: a *String, *Number, *Boolean or *Null
37+ items []*rnode
38+ keys []*rnode // rMap: the keys, rLiteral nodes with string tokens
39+ vals []*rnode // rMap: the values, parallel to keys
40+ // quoted and block describe how a string scalar was written; they only
41+ // matter to the position of expressions inside it.
42+ quoted bool
43+ block bool
44+ // badKey is set on a key that is a collection.
45+ badKey bool
46+}
47+
48+// yamlResult is the outcome of reading a YAML document.
49+type yamlResult struct {
50+ root *rnode // nil for an empty document
51+ errs []*Error
52+}
53+
54+type yamlBuilder struct {
55+ file string
56+ nodes int
57+ errs []*Error
58+ stack map[*yaml.Node]bool
59+ over bool
60+}
61+
62+// readYAMLDocument parses content with yaml.v3 and builds the tree. Syntax
63+// errors are returned in errs, with root nil.
64+func readYAMLDocument(file string, content []byte) (res yamlResult) {
65+ defer func() {
66+ if r := recover(); r != nil {
67+ res = yamlResult{errs: []*Error{{File: file, Raw: fmt.Sprintf("%v", r)}}}
68+ }
69+ }()
70+ dec := yaml.NewDecoder(bytes.NewReader(content))
71+ var doc yaml.Node
72+ err := dec.Decode(&doc)
73+ if errors.Is(err, io.EOF) {
74+ return yamlResult{}
75+ }
76+ if err != nil {
77+ return yamlResult{errs: []*Error{yamlError(file, err)}}
78+ }
79+ var second yaml.Node
80+ if err := dec.Decode(&second); err == nil {
81+ e := &Error{File: file, Raw: "Source contains multiple documents; please use YAML.parseAllDocuments()"}
82+ e.Pos = Pos{second.Line, second.Column}
83+ e.fileOnly = true
84+ e.Raw += fmt.Sprintf(" at line %d, column %d", second.Line, second.Column)
85+ return yamlResult{errs: []*Error{e}}
86+ } else if !errors.Is(err, io.EOF) {
87+ return yamlResult{errs: []*Error{yamlError(file, err)}}
88+ }
89+ if len(doc.Content) == 0 {
90+ return yamlResult{}
91+ }
92+ b := &yamlBuilder{file: file, stack: map[*yaml.Node]bool{}}
93+ root := b.build(doc.Content[0], false)
94+ if b.over {
95+ return yamlResult{errs: b.errs}
96+ }
97+ return yamlResult{root: root, errs: b.errs}
98+}
99+
100+var yamlLineRE = regexp.MustCompile(`^(?:yaml: )?(?:unmarshal errors:\n\s*)?(?:line (\d+): )?`)
101+
102+// yamlError converts an error of yaml.v3 ("yaml: line 3: did not find ...")
103+// to an Error. The wording is yaml.v3's, not the JavaScript library's, and
104+// only the line is known.
105+func yamlError(file string, err error) *Error {
106+ msg := err.Error()
107+ e := &Error{File: file, fileOnly: true}
108+ if m := yamlLineRE.FindStringSubmatch(msg); m != nil {
109+ if m[1] != "" {
110+ e.Pos.Line, _ = strconv.Atoi(m[1])
111+ }
112+ msg = msg[len(m[0]):]
113+ }
114+ e.Raw = msg
115+ if e.Pos.Line > 0 {
116+ e.Raw += fmt.Sprintf(" at line %d", e.Pos.Line)
117+ }
118+ return e
119+}
120+
121+func (b *yamlBuilder) count() bool {
122+ b.nodes++
123+ if b.nodes > MaxNodes {
124+ if !b.over {
125+ b.over = true
126+ b.errs = append(b.errs, &Error{File: b.file, Raw: fmt.Sprintf("The document is too large: it has more than %d nodes once aliases are expanded", MaxNodes)})
127+ }
128+ return false
129+ }
130+ return true
131+}
132+
133+func (b *yamlBuilder) build(n *yaml.Node, asKey bool) *rnode {
134+ if b.over || !b.count() {
135+ return &rnode{kind: rLiteral, lit: NewNull(Origin{})}
136+ }
137+ pos := Pos{n.Line, n.Column}
138+ o := Origin{File: b.file, Pos: pos}
139+ switch n.Kind {
140+ case yaml.AliasNode:
141+ target := n.Alias
142+ if target == nil || b.stack[target] {
143+ b.errs = append(b.errs, &Error{File: b.file, Pos: pos, Raw: fmt.Sprintf("Circular reference through the YAML alias '*%s'", n.Value)})
144+ return &rnode{kind: rLiteral, pos: pos, lit: NewNull(o)}
145+ }
146+ b.stack[target] = true
147+ r := b.build(target, asKey)
148+ delete(b.stack, target)
149+ return r
150+ case yaml.SequenceNode:
151+ if asKey {
152+ return &rnode{kind: rLiteral, pos: pos, lit: NewString(o, ""), badKey: true}
153+ }
154+ r := &rnode{kind: rSeq, pos: pos}
155+ b.stack[n] = true
156+ for _, c := range n.Content {
157+ r.items = append(r.items, b.build(c, false))
158+ }
159+ delete(b.stack, n)
160+ return r
161+ case yaml.MappingNode:
162+ if asKey {
163+ return &rnode{kind: rLiteral, pos: pos, lit: NewString(o, ""), badKey: true}
164+ }
165+ r := &rnode{kind: rMap, pos: pos}
166+ b.stack[n] = true
167+ for i := 0; i+1 < len(n.Content); i += 2 {
168+ r.keys = append(r.keys, b.build(n.Content[i], true))
169+ r.vals = append(r.vals, b.build(n.Content[i+1], false))
170+ }
171+ delete(b.stack, n)
172+ return r
173+ case yaml.ScalarNode:
174+ r := &rnode{kind: rLiteral, pos: pos}
175+ if asKey {
176+ // Keys are always strings, whatever they look like.
177+ r.lit = NewString(o, n.Value)
178+ } else {
179+ r.lit = resolveScalar(o, n)
180+ }
181+ r.quoted = n.Style&(yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle) != 0
182+ r.block = n.Style&(yaml.LiteralStyle|yaml.FoldedStyle) != 0
183+ return r
184+ }
185+ return &rnode{kind: rLiteral, pos: pos, lit: NewNull(o)}
186+}
187+
188+// resolveScalar gives a scalar its type the way the YAML 1.2 core schema of
189+// the JavaScript yaml library does: only plain scalars are resolved, and an
190+// explicit !!tag overrides the guess.
191+func resolveScalar(o Origin, n *yaml.Node) Token {
192+ if n.Style&yaml.TaggedStyle != 0 {
193+ switch n.ShortTag() {
194+ case "!!str":
195+ return NewString(o, n.Value)
196+ case "!!null", "!!bool", "!!int", "!!float":
197+ if t := resolvePlain(o, n.Value); t.Kind() != KindString {
198+ return t
199+ }
200+ if n.ShortTag() == "!!null" && n.Value == "" {
201+ return NewNull(o)
202+ }
203+ }
204+ return NewString(o, n.Value)
205+ }
206+ if n.Style&(yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle|yaml.LiteralStyle|yaml.FoldedStyle) != 0 {
207+ return NewString(o, n.Value)
208+ }
209+ return resolvePlain(o, n.Value)
210+}
211+
212+var (
213+ reInt = regexp.MustCompile(`^[-+]?[0-9]+$`)
214+ reOct = regexp.MustCompile(`^0o[0-7]+$`)
215+ reHex = regexp.MustCompile(`^0x[0-9a-fA-F]+$`)
216+ reFloat = regexp.MustCompile(`^[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?$`)
217+ reInf = regexp.MustCompile(`^[-+]?\.(?:inf|Inf|INF)$`)
218+ reNaN = regexp.MustCompile(`^\.(?:nan|NaN|NAN)$`)
219+)
220+
221+func resolvePlain(o Origin, s string) Token {
222+ switch s {
223+ case "", "~", "null", "Null", "NULL":
224+ return NewNull(o)
225+ case "true", "True", "TRUE":
226+ return NewBoolean(o, true)
227+ case "false", "False", "FALSE":
228+ return NewBoolean(o, false)
229+ }
230+ switch {
231+ case reInt.MatchString(s):
232+ f, _ := strconv.ParseFloat(s, 64)
233+ return NewNumber(o, f)
234+ case reOct.MatchString(s):
235+ return NewNumber(o, parseBase(s[2:], 8))
236+ case reHex.MatchString(s):
237+ return NewNumber(o, parseBase(s[2:], 16))
238+ case reFloat.MatchString(s):
239+ f, _ := strconv.ParseFloat(s, 64)
240+ return NewNumber(o, f)
241+ case reInf.MatchString(s):
242+ if s[0] == '-' {
243+ return NewNumber(o, math.Inf(-1))
244+ }
245+ return NewNumber(o, math.Inf(1))
246+ case reNaN.MatchString(s):
247+ return NewNumber(o, math.NaN())
248+ }
249+ return NewString(o, s)
250+}
251+
252+// parseBase converts digits in base 8 or 16 to a float64 without overflowing,
253+// like JavaScript's parseInt.
254+func parseBase(digits string, base int) float64 {
255+ var f float64
256+ for _, c := range strings.ToLower(digits) {
257+ d := int(c - '0')
258+ if c >= 'a' {
259+ d = int(c-'a') + 10
260+ }
261+ f = f*float64(base) + float64(d)
262+ }
263+ return f
264+}
@@ -0,0 +1,264 @@
1+// SPDX-License-Identifier: MIT
2+
3+package template
4+
5+import (
6+ "bytes"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "math"
11+ "regexp"
12+ "strconv"
13+ "strings"
14+
15+ "go.yaml.in/yaml/v3"
16+)
17+
18+// MaxNodes is the number of YAML nodes a document may expand to, counting
19+// every use of an alias. The reference expands aliases without a limit, so a
20+// small document of nested aliases can allocate gigabytes.
21+const MaxNodes = 200_000
22+
23+type rkind int
24+
25+const (
26+ rLiteral rkind = iota
27+ rSeq
28+ rMap
29+)
30+
31+// rnode is a YAML node after alias resolution: the tree the template reader
32+// walks.
33+type rnode struct {
34+ kind rkind
35+ pos Pos
36+ lit Token // rLiteral: a *String, *Number, *Boolean or *Null
37+ items []*rnode
38+ keys []*rnode // rMap: the keys, rLiteral nodes with string tokens
39+ vals []*rnode // rMap: the values, parallel to keys
40+ // quoted and block describe how a string scalar was written; they only
41+ // matter to the position of expressions inside it.
42+ quoted bool
43+ block bool
44+ // badKey is set on a key that is a collection.
45+ badKey bool
46+}
47+
48+// yamlResult is the outcome of reading a YAML document.
49+type yamlResult struct {
50+ root *rnode // nil for an empty document
51+ errs []*Error
52+}
53+
54+type yamlBuilder struct {
55+ file string
56+ nodes int
57+ errs []*Error
58+ stack map[*yaml.Node]bool
59+ over bool
60+}
61+
62+// readYAMLDocument parses content with yaml.v3 and builds the tree. Syntax
63+// errors are returned in errs, with root nil.
64+func readYAMLDocument(file string, content []byte) (res yamlResult) {
65+ defer func() {
66+ if r := recover(); r != nil {
67+ res = yamlResult{errs: []*Error{{File: file, Raw: fmt.Sprintf("%v", r)}}}
68+ }
69+ }()
70+ dec := yaml.NewDecoder(bytes.NewReader(content))
71+ var doc yaml.Node
72+ err := dec.Decode(&doc)
73+ if errors.Is(err, io.EOF) {
74+ return yamlResult{}
75+ }
76+ if err != nil {
77+ return yamlResult{errs: []*Error{yamlError(file, err)}}
78+ }
79+ var second yaml.Node
80+ if err := dec.Decode(&second); err == nil {
81+ e := &Error{File: file, Raw: "Source contains multiple documents; please use YAML.parseAllDocuments()"}
82+ e.Pos = Pos{second.Line, second.Column}
83+ e.fileOnly = true
84+ e.Raw += fmt.Sprintf(" at line %d, column %d", second.Line, second.Column)
85+ return yamlResult{errs: []*Error{e}}
86+ } else if !errors.Is(err, io.EOF) {
87+ return yamlResult{errs: []*Error{yamlError(file, err)}}
88+ }
89+ if len(doc.Content) == 0 {
90+ return yamlResult{}
91+ }
92+ b := &yamlBuilder{file: file, stack: map[*yaml.Node]bool{}}
93+ root := b.build(doc.Content[0], false)
94+ if b.over {
95+ return yamlResult{errs: b.errs}
96+ }
97+ return yamlResult{root: root, errs: b.errs}
98+}
99+
100+var yamlLineRE = regexp.MustCompile(`^(?:yaml: )?(?:unmarshal errors:\n\s*)?(?:line (\d+): )?`)
101+
102+// yamlError converts an error of yaml.v3 ("yaml: line 3: did not find ...")
103+// to an Error. The wording is yaml.v3's, not the JavaScript library's, and
104+// only the line is known.
105+func yamlError(file string, err error) *Error {
106+ msg := err.Error()
107+ e := &Error{File: file, fileOnly: true}
108+ if m := yamlLineRE.FindStringSubmatch(msg); m != nil {
109+ if m[1] != "" {
110+ e.Pos.Line, _ = strconv.Atoi(m[1])
111+ }
112+ msg = msg[len(m[0]):]
113+ }
114+ e.Raw = msg
115+ if e.Pos.Line > 0 {
116+ e.Raw += fmt.Sprintf(" at line %d", e.Pos.Line)
117+ }
118+ return e
119+}
120+
121+func (b *yamlBuilder) count() bool {
122+ b.nodes++
123+ if b.nodes > MaxNodes {
124+ if !b.over {
125+ b.over = true
126+ b.errs = append(b.errs, &Error{File: b.file, Raw: fmt.Sprintf("The document is too large: it has more than %d nodes once aliases are expanded", MaxNodes)})
127+ }
128+ return false
129+ }
130+ return true
131+}
132+
133+func (b *yamlBuilder) build(n *yaml.Node, asKey bool) *rnode {
134+ if b.over || !b.count() {
135+ return &rnode{kind: rLiteral, lit: NewNull(Origin{})}
136+ }
137+ pos := Pos{n.Line, n.Column}
138+ o := Origin{File: b.file, Pos: pos}
139+ switch n.Kind {
140+ case yaml.AliasNode:
141+ target := n.Alias
142+ if target == nil || b.stack[target] {
143+ b.errs = append(b.errs, &Error{File: b.file, Pos: pos, Raw: fmt.Sprintf("Circular reference through the YAML alias '*%s'", n.Value)})
144+ return &rnode{kind: rLiteral, pos: pos, lit: NewNull(o)}
145+ }
146+ b.stack[target] = true
147+ r := b.build(target, asKey)
148+ delete(b.stack, target)
149+ return r
150+ case yaml.SequenceNode:
151+ if asKey {
152+ return &rnode{kind: rLiteral, pos: pos, lit: NewString(o, ""), badKey: true}
153+ }
154+ r := &rnode{kind: rSeq, pos: pos}
155+ b.stack[n] = true
156+ for _, c := range n.Content {
157+ r.items = append(r.items, b.build(c, false))
158+ }
159+ delete(b.stack, n)
160+ return r
161+ case yaml.MappingNode:
162+ if asKey {
163+ return &rnode{kind: rLiteral, pos: pos, lit: NewString(o, ""), badKey: true}
164+ }
165+ r := &rnode{kind: rMap, pos: pos}
166+ b.stack[n] = true
167+ for i := 0; i+1 < len(n.Content); i += 2 {
168+ r.keys = append(r.keys, b.build(n.Content[i], true))
169+ r.vals = append(r.vals, b.build(n.Content[i+1], false))
170+ }
171+ delete(b.stack, n)
172+ return r
173+ case yaml.ScalarNode:
174+ r := &rnode{kind: rLiteral, pos: pos}
175+ if asKey {
176+ // Keys are always strings, whatever they look like.
177+ r.lit = NewString(o, n.Value)
178+ } else {
179+ r.lit = resolveScalar(o, n)
180+ }
181+ r.quoted = n.Style&(yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle) != 0
182+ r.block = n.Style&(yaml.LiteralStyle|yaml.FoldedStyle) != 0
183+ return r
184+ }
185+ return &rnode{kind: rLiteral, pos: pos, lit: NewNull(o)}
186+}
187+
188+// resolveScalar gives a scalar its type the way the YAML 1.2 core schema of
189+// the JavaScript yaml library does: only plain scalars are resolved, and an
190+// explicit !!tag overrides the guess.
191+func resolveScalar(o Origin, n *yaml.Node) Token {
192+ if n.Style&yaml.TaggedStyle != 0 {
193+ switch n.ShortTag() {
194+ case "!!str":
195+ return NewString(o, n.Value)
196+ case "!!null", "!!bool", "!!int", "!!float":
197+ if t := resolvePlain(o, n.Value); t.Kind() != KindString {
198+ return t
199+ }
200+ if n.ShortTag() == "!!null" && n.Value == "" {
201+ return NewNull(o)
202+ }
203+ }
204+ return NewString(o, n.Value)
205+ }
206+ if n.Style&(yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle|yaml.LiteralStyle|yaml.FoldedStyle) != 0 {
207+ return NewString(o, n.Value)
208+ }
209+ return resolvePlain(o, n.Value)
210+}
211+
212+var (
213+ reInt = regexp.MustCompile(`^[-+]?[0-9]+$`)
214+ reOct = regexp.MustCompile(`^0o[0-7]+$`)
215+ reHex = regexp.MustCompile(`^0x[0-9a-fA-F]+$`)
216+ reFloat = regexp.MustCompile(`^[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?$`)
217+ reInf = regexp.MustCompile(`^[-+]?\.(?:inf|Inf|INF)$`)
218+ reNaN = regexp.MustCompile(`^\.(?:nan|NaN|NAN)$`)
219+)
220+
221+func resolvePlain(o Origin, s string) Token {
222+ switch s {
223+ case "", "~", "null", "Null", "NULL":
224+ return NewNull(o)
225+ case "true", "True", "TRUE":
226+ return NewBoolean(o, true)
227+ case "false", "False", "FALSE":
228+ return NewBoolean(o, false)
229+ }
230+ switch {
231+ case reInt.MatchString(s):
232+ f, _ := strconv.ParseFloat(s, 64)
233+ return NewNumber(o, f)
234+ case reOct.MatchString(s):
235+ return NewNumber(o, parseBase(s[2:], 8))
236+ case reHex.MatchString(s):
237+ return NewNumber(o, parseBase(s[2:], 16))
238+ case reFloat.MatchString(s):
239+ f, _ := strconv.ParseFloat(s, 64)
240+ return NewNumber(o, f)
241+ case reInf.MatchString(s):
242+ if s[0] == '-' {
243+ return NewNumber(o, math.Inf(-1))
244+ }
245+ return NewNumber(o, math.Inf(1))
246+ case reNaN.MatchString(s):
247+ return NewNumber(o, math.NaN())
248+ }
249+ return NewString(o, s)
250+}
251+
252+// parseBase converts digits in base 8 or 16 to a float64 without overflowing,
253+// like JavaScript's parseInt.
254+func parseBase(digits string, base int) float64 {
255+ var f float64
256+ for _, c := range strings.ToLower(digits) {
257+ d := int(c - '0')
258+ if c >= 'a' {
259+ d = int(c-'a') + 10
260+ }
261+ f = f*float64(base) + float64(d)
262+ }
263+ return f
264+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 3

↑↓ moveenter openesc close