main 98594d0

eval.go RawHistoryFolder

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}

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