detached 7f4d0a4

feat(expr): add the evaluator and built-in functions

commit
7f4d0a46f7f2cbad330e2aac1af521606a0d0530
author
brice · · 2026-10-12 14:46 UTC
parent
a3227f4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +613 -0
A expr/doc.go +27 -0
@@ -0,0 +1,27 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package expr implements GitHub Actions expressions: the language found
4+// between the braces of ${{ }} in workflow files.
5+//
6+// The behaviour is a faithful port of GitHub's own implementation published
7+// as the npm package @actions/expressions (MIT, actions/languageservices):
8+// same tokens, precedence, error messages, coercions and built-in functions.
9+// Where that implementation is believed to differ from the hosted service
10+// (the C# runner in actions/runner), the Go code follows the JavaScript and
11+// says so in a comment; PORTING.md lists what remains to be checked.
12+//
13+// The entry points are [Parse], which turns the text of one expression into a
14+// [Node], and [Evaluate], which computes its [Value] against the named
15+// contexts and functions of an [EvalContext]:
16+//
17+// node, err := expr.Parse("github.ref == 'refs/heads/main'", []string{"github"}, nil)
18+// if err != nil { /* *expr.Error */ }
19+// v, err := expr.Evaluate(node, expr.EvalContext{Contexts: map[string]expr.Value{"github": github}})
20+// if err != nil { /* *expr.EvalError */ }
21+// if expr.Truthy(v) { /* ... */ }
22+//
23+// Values are the JSON-like [Null], [Boolean], [Number], [String], [*Array]
24+// and [*Dictionary]. Dictionaries keep insertion order and look keys up
25+// case-insensitively. [FromJSON] and [ToJSON] convert to and from JSON text
26+// exactly as the fromJSON and toJSON functions do.
27+package expr
@@ -0,0 +1,27 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package expr implements GitHub Actions expressions: the language found
4+// between the braces of ${{ }} in workflow files.
5+//
6+// The behaviour is a faithful port of GitHub's own implementation published
7+// as the npm package @actions/expressions (MIT, actions/languageservices):
8+// same tokens, precedence, error messages, coercions and built-in functions.
9+// Where that implementation is believed to differ from the hosted service
10+// (the C# runner in actions/runner), the Go code follows the JavaScript and
11+// says so in a comment; PORTING.md lists what remains to be checked.
12+//
13+// The entry points are [Parse], which turns the text of one expression into a
14+// [Node], and [Evaluate], which computes its [Value] against the named
15+// contexts and functions of an [EvalContext]:
16+//
17+// node, err := expr.Parse("github.ref == 'refs/heads/main'", []string{"github"}, nil)
18+// if err != nil { /* *expr.Error */ }
19+// v, err := expr.Evaluate(node, expr.EvalContext{Contexts: map[string]expr.Value{"github": github}})
20+// if err != nil { /* *expr.EvalError */ }
21+// if expr.Truthy(v) { /* ... */ }
22+//
23+// Values are the JSON-like [Null], [Boolean], [Number], [String], [*Array]
24+// and [*Dictionary]. Dictionaries keep insertion order and look keys up
25+// case-insensitively. [FromJSON] and [ToJSON] convert to and from JSON text
26+// exactly as the fromJSON and toJSON functions do.
27+package expr
A expr/eval.go +380 -0
@@ -0,0 +1,380 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import (
6+ "math"
7+ "sort"
8+)
9+
10+// Func implements a function callable from expressions. It receives the
11+// evaluated arguments in order and returns the result or an error, which
12+// [Evaluate] returns as is. Arity is not checked at call time: declare it in
13+// a [FuncInfo] given to [Parse].
14+type Func func(args []Value) (Value, error)
15+
16+// EvalContext is the environment an expression is evaluated in.
17+type EvalContext struct {
18+ // Contexts maps context names ("github", "env", "matrix"...) to their
19+ // values. Names are matched case-insensitively; when several keys differ
20+ // only by case, the first in sorted order wins. A name that is absent
21+ // evaluates to null.
22+ Contexts map[string]Value
23+ // Funcs implements the extra functions declared to [Parse], such as
24+ // hashFiles, success, always, cancelled and failure. Names are matched
25+ // case-insensitively. As in the reference implementation these take
26+ // precedence over the built-in functions.
27+ Funcs map[string]Func
28+}
29+
30+// Evaluate computes the value of a parsed expression.
31+//
32+// Values of the context are never modified, and results may share them with
33+// the context: treat the result as read-only. The error is an [*EvalError].
34+func Evaluate(node Node, ctx EvalContext) (Value, error) {
35+ if node == nil {
36+ return nil, &EvalError{Message: "cannot evaluate an empty expression"}
37+ }
38+ ev := &evaluator{
39+ contexts: make(map[string]Value, len(ctx.Contexts)),
40+ funcs: make(map[string]Func, len(ctx.Funcs)),
41+ }
42+ names := make([]string, 0, len(ctx.Contexts))
43+ for k := range ctx.Contexts {
44+ names = append(names, k)
45+ }
46+ sort.Strings(names)
47+ for _, k := range names {
48+ if _, dup := ev.contexts[foldKey(k)]; !dup {
49+ ev.contexts[foldKey(k)] = ctx.Contexts[k]
50+ }
51+ }
52+ names = names[:0]
53+ for k := range ctx.Funcs {
54+ names = append(names, k)
55+ }
56+ sort.Strings(names)
57+ for _, k := range names {
58+ if _, dup := ev.funcs[foldKey(k)]; !dup {
59+ ev.funcs[foldKey(k)] = ctx.Funcs[k]
60+ }
61+ }
62+ v, err := ev.eval(node)
63+ if err != nil {
64+ return nil, err
65+ }
66+ return normalize(v), nil
67+}
68+
69+type evaluator struct {
70+ contexts map[string]Value
71+ funcs map[string]Func
72+}
73+
74+func (e *evaluator) eval(n Node) (Value, error) {
75+ switch n := n.(type) {
76+ case *Literal:
77+ return n.Value, nil
78+ case *Unary:
79+ r, err := e.eval(n.Expr)
80+ if err != nil {
81+ return nil, err
82+ }
83+ return Boolean(!Truthy(r)), nil
84+ case *Binary:
85+ return e.binary(n)
86+ case *Logical:
87+ return e.logical(n)
88+ case *Grouping:
89+ return e.eval(n.Group)
90+ case *ContextAccess:
91+ if v, ok := e.contexts[foldKey(n.Name.Lexeme)]; ok {
92+ return normalize(v), nil
93+ }
94+ return Null{}, nil
95+ case *IndexAccess:
96+ return e.indexAccess(n)
97+ case *FunctionCall:
98+ return e.call(n)
99+ }
100+ // *Star is only valid as an index and never evaluated on its own; the
101+ // reference throws "Method not implemented." for it.
102+ return nil, &EvalError{Message: "Method not implemented."}
103+}
104+
105+func (e *evaluator) binary(b *Binary) (Value, error) {
106+ // Both operands are always evaluated: comparisons do not short-circuit.
107+ left, err := e.eval(b.Left)
108+ if err != nil {
109+ return nil, err
110+ }
111+ right, err := e.eval(b.Right)
112+ if err != nil {
113+ return nil, err
114+ }
115+ switch b.Operator.Type {
116+ case TokenEqualEqual:
117+ return Boolean(equals(left, right)), nil
118+ case TokenBangEqual:
119+ return Boolean(!equals(left, right)), nil
120+ case TokenGreater:
121+ return Boolean(greaterThan(left, right)), nil
122+ case TokenGreaterEqual:
123+ return Boolean(equals(left, right) || greaterThan(left, right)), nil
124+ case TokenLess:
125+ return Boolean(lessThan(left, right)), nil
126+ case TokenLessEqual:
127+ return Boolean(equals(left, right) || lessThan(left, right)), nil
128+ }
129+ return nil, evalErrorf("unknown binary operator: %s", b.Operator.Lexeme)
130+}
131+
132+// logical returns the operand that decided the chain, not a boolean:
133+// "a || b" is a when a is truthy, else b; "a && b" is a when a is falsy, else b.
134+func (e *evaluator) logical(l *Logical) (Value, error) {
135+ var result Value
136+ for _, arg := range l.Args {
137+ r, err := e.eval(arg)
138+ if err != nil {
139+ return nil, err
140+ }
141+ result = r
142+ if (l.Operator.Type == TokenAnd && !Truthy(r)) || (l.Operator.Type == TokenOr && Truthy(r)) {
143+ break
144+ }
145+ }
146+ return result, nil
147+}
148+
149+func (e *evaluator) call(c *FunctionCall) (Value, error) {
150+ args := make([]Value, len(c.Args))
151+ for i, a := range c.Args {
152+ v, err := e.eval(a)
153+ if err != nil {
154+ return nil, err
155+ }
156+ args[i] = v
157+ }
158+ name := foldKey(c.Name.Lexeme)
159+ if f, ok := e.funcs[name]; ok && f != nil {
160+ return f(args)
161+ }
162+ if f, ok := builtinImpls[name]; ok {
163+ return f(args)
164+ }
165+ return nil, evalErrorf("function %s is not implemented", c.Name.Lexeme)
166+}
167+
168+// indexer is idxHelper of the reference: how an index value is read, as a
169+// star, as a string key and as an array position.
170+type indexer struct {
171+ star bool
172+ hasStr bool
173+ str string
174+ hasInt bool
175+ // intv is only meaningful when hasInt; it is a float because positions
176+ // beyond the int range must simply compare as out of bounds.
177+ intv float64
178+}
179+
180+func newIndexer(star bool, v Value) indexer {
181+ ix := indexer{star: star}
182+ if star || v == nil {
183+ return ix
184+ }
185+ v = normalize(v)
186+ if isPrimitive(v) {
187+ ix.hasStr = true
188+ ix.str = CoerceString(v)
189+ }
190+ f := toNumber(v)
191+ if !math.IsNaN(f) && !math.IsInf(f, 0) && f >= 0 {
192+ ix.hasInt = true
193+ ix.intv = math.Floor(f)
194+ }
195+ return ix
196+}
197+
198+// pos returns the position as an int when it is within [0, n).
199+func (ix indexer) pos(n int) (int, bool) {
200+ if !ix.hasInt || ix.intv >= float64(n) {
201+ return 0, false
202+ }
203+ return int(ix.intv), true
204+}
205+
206+func (e *evaluator) indexAccess(ia *IndexAccess) (Value, error) {
207+ var ix indexer
208+ if _, isStar := ia.Index.(*Star); isStar {
209+ ix = newIndexer(true, nil)
210+ } else {
211+ v, err := e.eval(ia.Index)
212+ if err != nil {
213+ return nil, &EvalError{Message: "could not evaluate index for index access: " + err.Error(), Err: err}
214+ }
215+ ix = newIndexer(false, v)
216+ }
217+ obj, err := e.eval(ia.Expr)
218+ if err != nil {
219+ return nil, err
220+ }
221+ switch o := normalize(obj).(type) {
222+ case *Array:
223+ if o.filtered {
224+ return filteredArrayAccess(o, ix), nil
225+ }
226+ return arrayAccess(o, ix), nil
227+ case *Dictionary:
228+ return objectAccess(o, ix), nil
229+ }
230+ if ix.star {
231+ return &Array{filtered: true}, nil
232+ }
233+ return Null{}, nil
234+}
235+
236+// filteredArrayAccess applies an index to each element of a filtered array
237+// and collects the hits: "a.*.b" is the b of every element that has one.
238+func filteredArrayAccess(fa *Array, ix indexer) *Array {
239+ result := &Array{filtered: true}
240+ for _, item := range fa.items {
241+ switch it := normalize(item).(type) {
242+ case *Dictionary:
243+ if ix.star {
244+ result.items = append(result.items, it.vals...)
245+ } else if ix.hasStr {
246+ if v, ok := it.Get(ix.str); ok {
247+ result.items = append(result.items, v)
248+ }
249+ }
250+ case *Array:
251+ if ix.star {
252+ result.items = append(result.items, it.items...)
253+ } else if i, ok := ix.pos(len(it.items)); ok {
254+ result.items = append(result.items, it.items[i])
255+ }
256+ }
257+ }
258+ return result
259+}
260+
261+func arrayAccess(a *Array, ix indexer) Value {
262+ if ix.star {
263+ return &Array{items: append([]Value(nil), a.items...), filtered: true}
264+ }
265+ if i, ok := ix.pos(len(a.items)); ok {
266+ return normalize(a.items[i])
267+ }
268+ return Null{}
269+}
270+
271+func objectAccess(d *Dictionary, ix indexer) Value {
272+ if ix.star {
273+ return &Array{items: append([]Value(nil), d.vals...), filtered: true}
274+ }
275+ if ix.hasStr {
276+ if v, ok := d.Get(ix.str); ok {
277+ return v
278+ }
279+ }
280+ return Null{}
281+}
282+
283+// coerceTypes converts two values of different kinds to a common kind the
284+// way the reference does, which is like the JavaScript abstract equality
285+// algorithm except that arrays and dictionaries are never converted: null
286+// and booleans become numbers, and a string compared with a number becomes
287+// a number.
288+func coerceTypes(l, r Value) (Value, Value) {
289+ l, r = normalize(l), normalize(r)
290+ if l.Kind() == r.Kind() {
291+ return l, r
292+ }
293+ switch l.Kind() {
294+ case KindNumber:
295+ if r.Kind() == KindString {
296+ return l, Number(toNumber(r))
297+ }
298+ case KindString:
299+ if r.Kind() == KindNumber {
300+ return Number(toNumber(l)), r
301+ }
302+ case KindNull, KindBoolean:
303+ return coerceTypes(Number(toNumber(l)), r)
304+ }
305+ switch r.Kind() {
306+ case KindNull, KindBoolean:
307+ return coerceTypes(l, Number(toNumber(r)))
308+ }
309+ return l, r
310+}
311+
312+// equals is the == operator: loose equality, case-insensitive for strings
313+// (see toUpperSpecial), false for NaN, and by identity for arrays and
314+// dictionaries.
315+func equals(lhs, rhs Value) bool {
316+ l, r := coerceTypes(lhs, rhs)
317+ if l.Kind() != r.Kind() {
318+ return false
319+ }
320+ switch lv := l.(type) {
321+ case Null:
322+ return true
323+ case Number:
324+ return float64(lv) == float64(r.(Number)) // false when either is NaN
325+ case String:
326+ return toUpperSpecial(string(lv)) == toUpperSpecial(string(r.(String)))
327+ case Boolean:
328+ return lv == r.(Boolean)
329+ case *Array:
330+ return lv == r.(*Array)
331+ case *Dictionary:
332+ return lv == r.(*Dictionary)
333+ }
334+ return false
335+}
336+
337+// compare implements < and >: it returns -1, 0 or 1, and ok false when the
338+// values are not ordered (different kinds after coercion, NaN, null, arrays,
339+// dictionaries).
340+func compare(lhs, rhs Value) (c int, ok bool) {
341+ l, r := coerceTypes(lhs, rhs)
342+ if l.Kind() != r.Kind() {
343+ return 0, false
344+ }
345+ switch lv := l.(type) {
346+ case Number:
347+ lf, rf := float64(lv), float64(r.(Number))
348+ switch {
349+ case math.IsNaN(lf) || math.IsNaN(rf):
350+ return 0, false
351+ case lf < rf:
352+ return -1, true
353+ case lf > rf:
354+ return 1, true
355+ }
356+ return 0, true
357+ case String:
358+ return compareUTF16(toUpperSpecial(string(lv)), toUpperSpecial(string(r.(String)))), true
359+ case Boolean:
360+ // false < true
361+ switch rb := r.(Boolean); {
362+ case lv == rb:
363+ return 0, true
364+ case !bool(lv):
365+ return -1, true
366+ }
367+ return 1, true
368+ }
369+ return 0, false
370+}
371+
372+func greaterThan(l, r Value) bool {
373+ c, ok := compare(l, r)
374+ return ok && c > 0
375+}
376+
377+func lessThan(l, r Value) bool {
378+ c, ok := compare(l, r)
379+ return ok && c < 0
380+}
@@ -0,0 +1,380 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import (
6+ "math"
7+ "sort"
8+)
9+
10+// Func implements a function callable from expressions. It receives the
11+// evaluated arguments in order and returns the result or an error, which
12+// [Evaluate] returns as is. Arity is not checked at call time: declare it in
13+// a [FuncInfo] given to [Parse].
14+type Func func(args []Value) (Value, error)
15+
16+// EvalContext is the environment an expression is evaluated in.
17+type EvalContext struct {
18+ // Contexts maps context names ("github", "env", "matrix"...) to their
19+ // values. Names are matched case-insensitively; when several keys differ
20+ // only by case, the first in sorted order wins. A name that is absent
21+ // evaluates to null.
22+ Contexts map[string]Value
23+ // Funcs implements the extra functions declared to [Parse], such as
24+ // hashFiles, success, always, cancelled and failure. Names are matched
25+ // case-insensitively. As in the reference implementation these take
26+ // precedence over the built-in functions.
27+ Funcs map[string]Func
28+}
29+
30+// Evaluate computes the value of a parsed expression.
31+//
32+// Values of the context are never modified, and results may share them with
33+// the context: treat the result as read-only. The error is an [*EvalError].
34+func Evaluate(node Node, ctx EvalContext) (Value, error) {
35+ if node == nil {
36+ return nil, &EvalError{Message: "cannot evaluate an empty expression"}
37+ }
38+ ev := &evaluator{
39+ contexts: make(map[string]Value, len(ctx.Contexts)),
40+ funcs: make(map[string]Func, len(ctx.Funcs)),
41+ }
42+ names := make([]string, 0, len(ctx.Contexts))
43+ for k := range ctx.Contexts {
44+ names = append(names, k)
45+ }
46+ sort.Strings(names)
47+ for _, k := range names {
48+ if _, dup := ev.contexts[foldKey(k)]; !dup {
49+ ev.contexts[foldKey(k)] = ctx.Contexts[k]
50+ }
51+ }
52+ names = names[:0]
53+ for k := range ctx.Funcs {
54+ names = append(names, k)
55+ }
56+ sort.Strings(names)
57+ for _, k := range names {
58+ if _, dup := ev.funcs[foldKey(k)]; !dup {
59+ ev.funcs[foldKey(k)] = ctx.Funcs[k]
60+ }
61+ }
62+ v, err := ev.eval(node)
63+ if err != nil {
64+ return nil, err
65+ }
66+ return normalize(v), nil
67+}
68+
69+type evaluator struct {
70+ contexts map[string]Value
71+ funcs map[string]Func
72+}
73+
74+func (e *evaluator) eval(n Node) (Value, error) {
75+ switch n := n.(type) {
76+ case *Literal:
77+ return n.Value, nil
78+ case *Unary:
79+ r, err := e.eval(n.Expr)
80+ if err != nil {
81+ return nil, err
82+ }
83+ return Boolean(!Truthy(r)), nil
84+ case *Binary:
85+ return e.binary(n)
86+ case *Logical:
87+ return e.logical(n)
88+ case *Grouping:
89+ return e.eval(n.Group)
90+ case *ContextAccess:
91+ if v, ok := e.contexts[foldKey(n.Name.Lexeme)]; ok {
92+ return normalize(v), nil
93+ }
94+ return Null{}, nil
95+ case *IndexAccess:
96+ return e.indexAccess(n)
97+ case *FunctionCall:
98+ return e.call(n)
99+ }
100+ // *Star is only valid as an index and never evaluated on its own; the
101+ // reference throws "Method not implemented." for it.
102+ return nil, &EvalError{Message: "Method not implemented."}
103+}
104+
105+func (e *evaluator) binary(b *Binary) (Value, error) {
106+ // Both operands are always evaluated: comparisons do not short-circuit.
107+ left, err := e.eval(b.Left)
108+ if err != nil {
109+ return nil, err
110+ }
111+ right, err := e.eval(b.Right)
112+ if err != nil {
113+ return nil, err
114+ }
115+ switch b.Operator.Type {
116+ case TokenEqualEqual:
117+ return Boolean(equals(left, right)), nil
118+ case TokenBangEqual:
119+ return Boolean(!equals(left, right)), nil
120+ case TokenGreater:
121+ return Boolean(greaterThan(left, right)), nil
122+ case TokenGreaterEqual:
123+ return Boolean(equals(left, right) || greaterThan(left, right)), nil
124+ case TokenLess:
125+ return Boolean(lessThan(left, right)), nil
126+ case TokenLessEqual:
127+ return Boolean(equals(left, right) || lessThan(left, right)), nil
128+ }
129+ return nil, evalErrorf("unknown binary operator: %s", b.Operator.Lexeme)
130+}
131+
132+// logical returns the operand that decided the chain, not a boolean:
133+// "a || b" is a when a is truthy, else b; "a && b" is a when a is falsy, else b.
134+func (e *evaluator) logical(l *Logical) (Value, error) {
135+ var result Value
136+ for _, arg := range l.Args {
137+ r, err := e.eval(arg)
138+ if err != nil {
139+ return nil, err
140+ }
141+ result = r
142+ if (l.Operator.Type == TokenAnd && !Truthy(r)) || (l.Operator.Type == TokenOr && Truthy(r)) {
143+ break
144+ }
145+ }
146+ return result, nil
147+}
148+
149+func (e *evaluator) call(c *FunctionCall) (Value, error) {
150+ args := make([]Value, len(c.Args))
151+ for i, a := range c.Args {
152+ v, err := e.eval(a)
153+ if err != nil {
154+ return nil, err
155+ }
156+ args[i] = v
157+ }
158+ name := foldKey(c.Name.Lexeme)
159+ if f, ok := e.funcs[name]; ok && f != nil {
160+ return f(args)
161+ }
162+ if f, ok := builtinImpls[name]; ok {
163+ return f(args)
164+ }
165+ return nil, evalErrorf("function %s is not implemented", c.Name.Lexeme)
166+}
167+
168+// indexer is idxHelper of the reference: how an index value is read, as a
169+// star, as a string key and as an array position.
170+type indexer struct {
171+ star bool
172+ hasStr bool
173+ str string
174+ hasInt bool
175+ // intv is only meaningful when hasInt; it is a float because positions
176+ // beyond the int range must simply compare as out of bounds.
177+ intv float64
178+}
179+
180+func newIndexer(star bool, v Value) indexer {
181+ ix := indexer{star: star}
182+ if star || v == nil {
183+ return ix
184+ }
185+ v = normalize(v)
186+ if isPrimitive(v) {
187+ ix.hasStr = true
188+ ix.str = CoerceString(v)
189+ }
190+ f := toNumber(v)
191+ if !math.IsNaN(f) && !math.IsInf(f, 0) && f >= 0 {
192+ ix.hasInt = true
193+ ix.intv = math.Floor(f)
194+ }
195+ return ix
196+}
197+
198+// pos returns the position as an int when it is within [0, n).
199+func (ix indexer) pos(n int) (int, bool) {
200+ if !ix.hasInt || ix.intv >= float64(n) {
201+ return 0, false
202+ }
203+ return int(ix.intv), true
204+}
205+
206+func (e *evaluator) indexAccess(ia *IndexAccess) (Value, error) {
207+ var ix indexer
208+ if _, isStar := ia.Index.(*Star); isStar {
209+ ix = newIndexer(true, nil)
210+ } else {
211+ v, err := e.eval(ia.Index)
212+ if err != nil {
213+ return nil, &EvalError{Message: "could not evaluate index for index access: " + err.Error(), Err: err}
214+ }
215+ ix = newIndexer(false, v)
216+ }
217+ obj, err := e.eval(ia.Expr)
218+ if err != nil {
219+ return nil, err
220+ }
221+ switch o := normalize(obj).(type) {
222+ case *Array:
223+ if o.filtered {
224+ return filteredArrayAccess(o, ix), nil
225+ }
226+ return arrayAccess(o, ix), nil
227+ case *Dictionary:
228+ return objectAccess(o, ix), nil
229+ }
230+ if ix.star {
231+ return &Array{filtered: true}, nil
232+ }
233+ return Null{}, nil
234+}
235+
236+// filteredArrayAccess applies an index to each element of a filtered array
237+// and collects the hits: "a.*.b" is the b of every element that has one.
238+func filteredArrayAccess(fa *Array, ix indexer) *Array {
239+ result := &Array{filtered: true}
240+ for _, item := range fa.items {
241+ switch it := normalize(item).(type) {
242+ case *Dictionary:
243+ if ix.star {
244+ result.items = append(result.items, it.vals...)
245+ } else if ix.hasStr {
246+ if v, ok := it.Get(ix.str); ok {
247+ result.items = append(result.items, v)
248+ }
249+ }
250+ case *Array:
251+ if ix.star {
252+ result.items = append(result.items, it.items...)
253+ } else if i, ok := ix.pos(len(it.items)); ok {
254+ result.items = append(result.items, it.items[i])
255+ }
256+ }
257+ }
258+ return result
259+}
260+
261+func arrayAccess(a *Array, ix indexer) Value {
262+ if ix.star {
263+ return &Array{items: append([]Value(nil), a.items...), filtered: true}
264+ }
265+ if i, ok := ix.pos(len(a.items)); ok {
266+ return normalize(a.items[i])
267+ }
268+ return Null{}
269+}
270+
271+func objectAccess(d *Dictionary, ix indexer) Value {
272+ if ix.star {
273+ return &Array{items: append([]Value(nil), d.vals...), filtered: true}
274+ }
275+ if ix.hasStr {
276+ if v, ok := d.Get(ix.str); ok {
277+ return v
278+ }
279+ }
280+ return Null{}
281+}
282+
283+// coerceTypes converts two values of different kinds to a common kind the
284+// way the reference does, which is like the JavaScript abstract equality
285+// algorithm except that arrays and dictionaries are never converted: null
286+// and booleans become numbers, and a string compared with a number becomes
287+// a number.
288+func coerceTypes(l, r Value) (Value, Value) {
289+ l, r = normalize(l), normalize(r)
290+ if l.Kind() == r.Kind() {
291+ return l, r
292+ }
293+ switch l.Kind() {
294+ case KindNumber:
295+ if r.Kind() == KindString {
296+ return l, Number(toNumber(r))
297+ }
298+ case KindString:
299+ if r.Kind() == KindNumber {
300+ return Number(toNumber(l)), r
301+ }
302+ case KindNull, KindBoolean:
303+ return coerceTypes(Number(toNumber(l)), r)
304+ }
305+ switch r.Kind() {
306+ case KindNull, KindBoolean:
307+ return coerceTypes(l, Number(toNumber(r)))
308+ }
309+ return l, r
310+}
311+
312+// equals is the == operator: loose equality, case-insensitive for strings
313+// (see toUpperSpecial), false for NaN, and by identity for arrays and
314+// dictionaries.
315+func equals(lhs, rhs Value) bool {
316+ l, r := coerceTypes(lhs, rhs)
317+ if l.Kind() != r.Kind() {
318+ return false
319+ }
320+ switch lv := l.(type) {
321+ case Null:
322+ return true
323+ case Number:
324+ return float64(lv) == float64(r.(Number)) // false when either is NaN
325+ case String:
326+ return toUpperSpecial(string(lv)) == toUpperSpecial(string(r.(String)))
327+ case Boolean:
328+ return lv == r.(Boolean)
329+ case *Array:
330+ return lv == r.(*Array)
331+ case *Dictionary:
332+ return lv == r.(*Dictionary)
333+ }
334+ return false
335+}
336+
337+// compare implements < and >: it returns -1, 0 or 1, and ok false when the
338+// values are not ordered (different kinds after coercion, NaN, null, arrays,
339+// dictionaries).
340+func compare(lhs, rhs Value) (c int, ok bool) {
341+ l, r := coerceTypes(lhs, rhs)
342+ if l.Kind() != r.Kind() {
343+ return 0, false
344+ }
345+ switch lv := l.(type) {
346+ case Number:
347+ lf, rf := float64(lv), float64(r.(Number))
348+ switch {
349+ case math.IsNaN(lf) || math.IsNaN(rf):
350+ return 0, false
351+ case lf < rf:
352+ return -1, true
353+ case lf > rf:
354+ return 1, true
355+ }
356+ return 0, true
357+ case String:
358+ return compareUTF16(toUpperSpecial(string(lv)), toUpperSpecial(string(r.(String)))), true
359+ case Boolean:
360+ // false < true
361+ switch rb := r.(Boolean); {
362+ case lv == rb:
363+ return 0, true
364+ case !bool(lv):
365+ return -1, true
366+ }
367+ return 1, true
368+ }
369+ return 0, false
370+}
371+
372+func greaterThan(l, r Value) bool {
373+ c, ok := compare(l, r)
374+ return ok && c > 0
375+}
376+
377+func lessThan(l, r Value) bool {
378+ c, ok := compare(l, r)
379+ return ok && c < 0
380+}
A expr/funcs.go +206 -0
@@ -0,0 +1,206 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import (
6+ "math"
7+ "strconv"
8+ "strings"
9+)
10+
11+// builtinImpls are the implementations of the built-in functions, keyed by
12+// lower-cased name. Arity is validated by the parser, so each may assume its
13+// documented argument count.
14+var builtinImpls = map[string]Func{
15+ "case": funcCase,
16+ "contains": funcContains,
17+ "endswith": funcEndsWith,
18+ "format": funcFormat,
19+ "fromjson": funcFromJSON,
20+ "join": funcJoin,
21+ "startswith": funcStartsWith,
22+ "tojson": funcToJSON,
23+}
24+
25+// funcCase is case(pred1, val1, pred2, val2, ..., default).
26+//
27+// The JavaScript reference ships it behind an experimental feature flag
28+// (allowCaseFunction) that only affects editor completion; the parser always
29+// accepts it, and so does this package.
30+func funcCase(args []Value) (Value, error) {
31+ for i := 0; i < len(args)-1; i += 2 {
32+ pred, ok := normalize(args[i]).(Boolean)
33+ if !ok {
34+ return nil, evalErrorf("case predicate must evaluate to a boolean value")
35+ }
36+ if pred {
37+ return args[i+1], nil
38+ }
39+ }
40+ return args[len(args)-1], nil
41+}
42+
43+// funcContains is contains(search, item): substring test, case-insensitive
44+// via toLowerCase, when both are primitive; membership by == in an array;
45+// false otherwise.
46+func funcContains(args []Value) (Value, error) {
47+ left, right := normalize(args[0]), normalize(args[1])
48+ switch {
49+ case isPrimitive(left):
50+ if isPrimitive(right) {
51+ return Boolean(strings.Contains(jsToLower(CoerceString(left)), jsToLower(CoerceString(right)))), nil
52+ }
53+ case left.Kind() == KindArray:
54+ for _, v := range left.(*Array).items {
55+ if equals(right, v) {
56+ return Boolean(true), nil
57+ }
58+ }
59+ }
60+ return Boolean(false), nil
61+}
62+
63+func funcStartsWith(args []Value) (Value, error) {
64+ ls, rs, ok := primitivePair(args)
65+ return Boolean(ok && strings.HasPrefix(toUpperSpecial(ls), toUpperSpecial(rs))), nil
66+}
67+
68+func funcEndsWith(args []Value) (Value, error) {
69+ ls, rs, ok := primitivePair(args)
70+ return Boolean(ok && strings.HasSuffix(toUpperSpecial(ls), toUpperSpecial(rs))), nil
71+}
72+
73+// primitivePair returns the string forms of the two arguments, and false
74+// when either is an array or a dictionary.
75+func primitivePair(args []Value) (string, string, bool) {
76+ if !isPrimitive(args[0]) || !isPrimitive(args[1]) {
77+ return "", "", false
78+ }
79+ return CoerceString(args[0]), CoerceString(args[1]), true
80+}
81+
82+// funcJoin is join(array, separator). A primitive first argument is just
83+// converted to a string, ignoring the separator; a dictionary gives "". A
84+// separator that is not primitive means ",".
85+func funcJoin(args []Value) (Value, error) {
86+ first := normalize(args[0])
87+ if isPrimitive(first) {
88+ return String(CoerceString(first)), nil
89+ }
90+ arr, ok := first.(*Array)
91+ if !ok {
92+ return String(""), nil
93+ }
94+ sep := ","
95+ if len(args) > 1 && isPrimitive(args[1]) {
96+ sep = CoerceString(args[1])
97+ }
98+ parts := make([]string, len(arr.items))
99+ for i, it := range arr.items {
100+ parts[i] = CoerceString(it)
101+ }
102+ return String(strings.Join(parts, sep)), nil
103+}
104+
105+func funcToJSON(args []Value) (Value, error) {
106+ return String(ToJSON(args[0])), nil
107+}
108+
109+// funcFromJSON is fromJSON(value). Blank input is "empty input"; anything
110+// that is not valid JSON is "Error parsing JSON when evaluating fromJson"
111+// wrapping the syntax error.
112+func funcFromJSON(args []Value) (Value, error) {
113+ in := CoerceString(args[0])
114+ if jsTrim(in) == "" {
115+ return nil, evalErrorf("empty input")
116+ }
117+ v, err := FromJSON([]byte(in))
118+ if err != nil {
119+ return nil, &EvalError{Message: "Error parsing JSON when evaluating fromJson", Err: err}
120+ }
121+ return v, nil
122+}
123+
124+// funcFormat is format(fmt, args...): "{N}" inserts argument N, "{{" and "}}"
125+// are literal braces. Anything between the digits of "{N" and the closing
126+// brace is ignored ("{0:x}" is "{0}"). Every other use of a brace is an
127+// error, as is a reference to a missing argument.
128+func funcFormat(args []Value) (Value, error) {
129+ fs := CoerceString(args[0])
130+ var out strings.Builder
131+ index := 0
132+ for index < len(fs) {
133+ lbrace := indexByteFrom(fs, '{', index)
134+ rbrace := indexByteFrom(fs, '}', index)
135+ switch {
136+ case lbrace >= 0 && (rbrace < 0 || rbrace > lbrace):
137+ // Escaped left brace.
138+ if charAt(fs, lbrace+1) == '{' {
139+ out.WriteString(fs[index : lbrace+1])
140+ index = lbrace + 2
141+ continue
142+ }
143+ // Left brace, number, optional format specifiers, right brace.
144+ if rbrace > lbrace+1 {
145+ if argIndex, ok := readArgIndex(fs, lbrace+1); ok {
146+ if 1+argIndex > float64(len(args)-1) {
147+ return nil, evalErrorf("The following format string references more arguments than were supplied: %s", fs)
148+ }
149+ out.WriteString(fs[index:lbrace])
150+ out.WriteString(CoerceString(args[1+int(argIndex)]))
151+ index = rbrace + 1
152+ continue
153+ }
154+ }
155+ return nil, evalErrorf("The following format string is invalid: %s", fs)
156+ case rbrace >= 0:
157+ // Escaped right brace.
158+ if charAt(fs, rbrace+1) != '}' {
159+ return nil, evalErrorf("The following format string is invalid: %s", fs)
160+ }
161+ out.WriteString(fs[index : rbrace+1])
162+ index = rbrace + 2
163+ default:
164+ out.WriteString(fs[index:])
165+ index = len(fs)
166+ }
167+ }
168+ return String(out.String()), nil
169+}
170+
171+func indexByteFrom(s string, c byte, from int) int {
172+ if from >= len(s) {
173+ return -1
174+ }
175+ i := strings.IndexByte(s[from:], c)
176+ if i < 0 {
177+ return -1
178+ }
179+ return from + i
180+}
181+
182+// charAt returns the byte at i, or 0 past the end (safeCharAt).
183+func charAt(s string, i int) byte {
184+ if i < len(s) {
185+ return s[i]
186+ }
187+ return 0
188+}
189+
190+// readArgIndex reads the decimal digits at s[start:]. The index is a float
191+// because parseInt in the reference yields Infinity for absurdly long digit
192+// runs, which then fails the argument-count check.
193+func readArgIndex(s string, start int) (float64, bool) {
194+ n := 0
195+ for charAt(s, start+n) >= '0' && charAt(s, start+n) <= '9' {
196+ n++
197+ }
198+ if n == 0 {
199+ return 0, false
200+ }
201+ f, _ := strconv.ParseFloat(s[start:start+n], 64)
202+ if math.IsNaN(f) {
203+ return 0, false
204+ }
205+ return f, true
206+}
@@ -0,0 +1,206 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import (
6+ "math"
7+ "strconv"
8+ "strings"
9+)
10+
11+// builtinImpls are the implementations of the built-in functions, keyed by
12+// lower-cased name. Arity is validated by the parser, so each may assume its
13+// documented argument count.
14+var builtinImpls = map[string]Func{
15+ "case": funcCase,
16+ "contains": funcContains,
17+ "endswith": funcEndsWith,
18+ "format": funcFormat,
19+ "fromjson": funcFromJSON,
20+ "join": funcJoin,
21+ "startswith": funcStartsWith,
22+ "tojson": funcToJSON,
23+}
24+
25+// funcCase is case(pred1, val1, pred2, val2, ..., default).
26+//
27+// The JavaScript reference ships it behind an experimental feature flag
28+// (allowCaseFunction) that only affects editor completion; the parser always
29+// accepts it, and so does this package.
30+func funcCase(args []Value) (Value, error) {
31+ for i := 0; i < len(args)-1; i += 2 {
32+ pred, ok := normalize(args[i]).(Boolean)
33+ if !ok {
34+ return nil, evalErrorf("case predicate must evaluate to a boolean value")
35+ }
36+ if pred {
37+ return args[i+1], nil
38+ }
39+ }
40+ return args[len(args)-1], nil
41+}
42+
43+// funcContains is contains(search, item): substring test, case-insensitive
44+// via toLowerCase, when both are primitive; membership by == in an array;
45+// false otherwise.
46+func funcContains(args []Value) (Value, error) {
47+ left, right := normalize(args[0]), normalize(args[1])
48+ switch {
49+ case isPrimitive(left):
50+ if isPrimitive(right) {
51+ return Boolean(strings.Contains(jsToLower(CoerceString(left)), jsToLower(CoerceString(right)))), nil
52+ }
53+ case left.Kind() == KindArray:
54+ for _, v := range left.(*Array).items {
55+ if equals(right, v) {
56+ return Boolean(true), nil
57+ }
58+ }
59+ }
60+ return Boolean(false), nil
61+}
62+
63+func funcStartsWith(args []Value) (Value, error) {
64+ ls, rs, ok := primitivePair(args)
65+ return Boolean(ok && strings.HasPrefix(toUpperSpecial(ls), toUpperSpecial(rs))), nil
66+}
67+
68+func funcEndsWith(args []Value) (Value, error) {
69+ ls, rs, ok := primitivePair(args)
70+ return Boolean(ok && strings.HasSuffix(toUpperSpecial(ls), toUpperSpecial(rs))), nil
71+}
72+
73+// primitivePair returns the string forms of the two arguments, and false
74+// when either is an array or a dictionary.
75+func primitivePair(args []Value) (string, string, bool) {
76+ if !isPrimitive(args[0]) || !isPrimitive(args[1]) {
77+ return "", "", false
78+ }
79+ return CoerceString(args[0]), CoerceString(args[1]), true
80+}
81+
82+// funcJoin is join(array, separator). A primitive first argument is just
83+// converted to a string, ignoring the separator; a dictionary gives "". A
84+// separator that is not primitive means ",".
85+func funcJoin(args []Value) (Value, error) {
86+ first := normalize(args[0])
87+ if isPrimitive(first) {
88+ return String(CoerceString(first)), nil
89+ }
90+ arr, ok := first.(*Array)
91+ if !ok {
92+ return String(""), nil
93+ }
94+ sep := ","
95+ if len(args) > 1 && isPrimitive(args[1]) {
96+ sep = CoerceString(args[1])
97+ }
98+ parts := make([]string, len(arr.items))
99+ for i, it := range arr.items {
100+ parts[i] = CoerceString(it)
101+ }
102+ return String(strings.Join(parts, sep)), nil
103+}
104+
105+func funcToJSON(args []Value) (Value, error) {
106+ return String(ToJSON(args[0])), nil
107+}
108+
109+// funcFromJSON is fromJSON(value). Blank input is "empty input"; anything
110+// that is not valid JSON is "Error parsing JSON when evaluating fromJson"
111+// wrapping the syntax error.
112+func funcFromJSON(args []Value) (Value, error) {
113+ in := CoerceString(args[0])
114+ if jsTrim(in) == "" {
115+ return nil, evalErrorf("empty input")
116+ }
117+ v, err := FromJSON([]byte(in))
118+ if err != nil {
119+ return nil, &EvalError{Message: "Error parsing JSON when evaluating fromJson", Err: err}
120+ }
121+ return v, nil
122+}
123+
124+// funcFormat is format(fmt, args...): "{N}" inserts argument N, "{{" and "}}"
125+// are literal braces. Anything between the digits of "{N" and the closing
126+// brace is ignored ("{0:x}" is "{0}"). Every other use of a brace is an
127+// error, as is a reference to a missing argument.
128+func funcFormat(args []Value) (Value, error) {
129+ fs := CoerceString(args[0])
130+ var out strings.Builder
131+ index := 0
132+ for index < len(fs) {
133+ lbrace := indexByteFrom(fs, '{', index)
134+ rbrace := indexByteFrom(fs, '}', index)
135+ switch {
136+ case lbrace >= 0 && (rbrace < 0 || rbrace > lbrace):
137+ // Escaped left brace.
138+ if charAt(fs, lbrace+1) == '{' {
139+ out.WriteString(fs[index : lbrace+1])
140+ index = lbrace + 2
141+ continue
142+ }
143+ // Left brace, number, optional format specifiers, right brace.
144+ if rbrace > lbrace+1 {
145+ if argIndex, ok := readArgIndex(fs, lbrace+1); ok {
146+ if 1+argIndex > float64(len(args)-1) {
147+ return nil, evalErrorf("The following format string references more arguments than were supplied: %s", fs)
148+ }
149+ out.WriteString(fs[index:lbrace])
150+ out.WriteString(CoerceString(args[1+int(argIndex)]))
151+ index = rbrace + 1
152+ continue
153+ }
154+ }
155+ return nil, evalErrorf("The following format string is invalid: %s", fs)
156+ case rbrace >= 0:
157+ // Escaped right brace.
158+ if charAt(fs, rbrace+1) != '}' {
159+ return nil, evalErrorf("The following format string is invalid: %s", fs)
160+ }
161+ out.WriteString(fs[index : rbrace+1])
162+ index = rbrace + 2
163+ default:
164+ out.WriteString(fs[index:])
165+ index = len(fs)
166+ }
167+ }
168+ return String(out.String()), nil
169+}
170+
171+func indexByteFrom(s string, c byte, from int) int {
172+ if from >= len(s) {
173+ return -1
174+ }
175+ i := strings.IndexByte(s[from:], c)
176+ if i < 0 {
177+ return -1
178+ }
179+ return from + i
180+}
181+
182+// charAt returns the byte at i, or 0 past the end (safeCharAt).
183+func charAt(s string, i int) byte {
184+ if i < len(s) {
185+ return s[i]
186+ }
187+ return 0
188+}
189+
190+// readArgIndex reads the decimal digits at s[start:]. The index is a float
191+// because parseInt in the reference yields Infinity for absurdly long digit
192+// runs, which then fails the argument-count check.
193+func readArgIndex(s string, start int) (float64, bool) {
194+ n := 0
195+ for charAt(s, start+n) >= '0' && charAt(s, start+n) <= '9' {
196+ n++
197+ }
198+ if n == 0 {
199+ return 0, false
200+ }
201+ f, _ := strconv.ParseFloat(s[start:start+n], 64)
202+ if math.IsNaN(f) {
203+ return 0, false
204+ }
205+ return f, true
206+}

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