1// SPDX-License-Identifier: MIT
2
3package template
4
5import (
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.
20type 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
28func (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.
33const MaxEvalDepth = 100
34
35func (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.
41func 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.
60func Evaluate(ctx context.Context, t Token, env Env) (expr.Value, error) {
61 return evaluate(ctx, t, env, 0)
62}
63
64func 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
101func 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.
136func 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.
153func 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.
177func 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
196func 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
212func 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
222func 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}