main 98594d0

eval_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package expr
4
5import (
6	"context"
7	"errors"
8	"math"
9	"strings"
10	"testing"
11)
12
13type evalCase struct {
14	expr string
15	want string // "Kind:string", "PARSE message" or "ERR message"
16}
17
18func runEvalCases(t *testing.T, cases []evalCase) {
19	t.Helper()
20	for _, c := range cases {
21		t.Run(c.expr, func(t *testing.T) {
22			if got := evalString(t, c.expr); got != c.want {
23				t.Errorf("%s\n got: %q\nwant: %q", c.expr, got, c.want)
24			}
25		})
26	}
27}
28
29// Examples from the GitHub documentation of expressions.
30func TestEvalDocumentationExamples(t *testing.T) {
31	runEvalCases(t, []evalCase{
32		{"contains('Hello world', 'llo')", "Boolean:true"},
33		{"contains('Hello world', 'LLO')", "Boolean:true"},
34		{"contains(github.event.issue.labels.*.name, 'bug')", "Boolean:true"},
35		{"contains(github.event.issue.labels.*.name, 'BUG')", "Boolean:true"},
36		{"contains(github.event.issue.labels.*.name, 'nope')", "Boolean:false"},
37		{"startsWith('Hello world', 'He')", "Boolean:true"},
38		{"startsWith('Hello world', 'he')", "Boolean:true"},
39		{"startsWith('Hello world', 'world')", "Boolean:false"},
40		{"endsWith('Hello world', 'ld')", "Boolean:true"},
41		{"endsWith('Hello world', 'Hello')", "Boolean:false"},
42		{"format('Hello {0} {1} {2}', 'Mona', 'the', 'Octocat')", "String:Hello Mona the Octocat"},
43		{"format('{{Hello {0} {1} {2}!}}', 'Mona', 'the', 'Octocat')", "String:{Hello Mona the Octocat!}"},
44		{"join(github.event.issue.labels.*.name, ', ')", "String:bug, Docs"},
45		{"join(github.event.issue.labels.*.name)", "String:bug,Docs"},
46		{"toJSON(fromJSON('{\"a\":1}'))", "String:{\n  \"a\": 1\n}"},
47		{"fromJSON('{\"a\":[1,2]}').a[1]", "Number:2"},
48		{"fromJSON('true')", "Boolean:true"},
49		{"fromJSON('\"str\"')", "String:str"},
50		{"fromJSON('null')", "Null:"},
51		// The documented idiom for matrix from JSON and for converting a
52		// string to a number: fromJSON('6') is a number.
53		{"fromJSON('6') == 6", "Boolean:true"},
54		{"github.event_name == 'push' && contains(github.ref, 'main')", "Boolean:true"},
55		{"github.event_name == 'push' || false", "Boolean:true"},
56		{"!(github.event_name == 'push')", "Boolean:false"},
57		// Documented ternary idiom: cond && a || b.
58		{"github.event_name == 'push' && 'yes' || 'no'", "String:yes"},
59		{"github.event_name == 'pull_request' && 'yes' || 'no'", "String:no"},
60		// A missing property is null, so the default after || applies.
61		{"github.event.nope || 'default'", "String:default"},
62		{"env.MISSING || 'fallback'", "String:fallback"},
63		{"github.event.commits.*.id", "Array:Array"},
64		{"join(github.event.commits.*.id, '-')", "String:1-2-3"},
65	})
66}
67
68func TestEvalLiteralsAndOperators(t *testing.T) {
69	runEvalCases(t, []evalCase{
70		{"true", "Boolean:true"},
71		{"false", "Boolean:false"},
72		{"null", "Null:"},
73		{"1", "Number:1"},
74		{"-1", "Number:-1"},
75		{"+1", "Number:1"},
76		{"1.5", "Number:1.5"},
77		{".5", "Number:0.5"},
78		{"-.5", "Number:-0.5"},
79		{"5.", "Number:5"},
80		{"1e3", "Number:1000"},
81		{"0x10", "Number:16"},
82		{"0o17", "Number:15"},
83		{"0xffffffff", "Number:-1"}, // read as a 32-bit integer, like the runner
84		{"0b101", "PARSE Unexpected symbol: '0b101'"},
85		{"0X10", "PARSE Unexpected symbol: '0X10'"},
86		{"'0b11' == 3", "Boolean:false"},
87		{"'0x1F' == 31", "Boolean:true"},
88		{"'infinity' == Infinity", "Boolean:true"},
89		{"'\u0085 1 \u0085' == 1", "Boolean:true"},
90		{"'\ufeff1' == 1", "Boolean:false"},
91		{"-0x10", "PARSE Unexpected symbol: '-0x10'"},
92		{"NaN", "Number:NaN"},
93		{"Infinity", "Number:Infinity"},
94		{"-Infinity", "Number:-Infinity"},
95		{"'a'", "String:a"},
96		{"''", "String:"},
97		{"'it''s'", "String:it's"},
98		// Every doubled quote is unescaped, as the C# runner does (the
99		// JavaScript reference stops after the first).
100		{"'a''b''c'", "String:a'b'c"},
101		{"''''", "String:'"},
102		{"'a''''b'", "String:a''b"},
103
104		{"1 == 1", "Boolean:true"},
105		{"1 == 2", "Boolean:false"},
106		{"1 != 2", "Boolean:true"},
107		{"1 < 2", "Boolean:true"},
108		{"2 <= 2", "Boolean:true"},
109		{"2 > 3", "Boolean:false"},
110		{"3 >= 3", "Boolean:true"},
111
112		// Loose equality: strings compare to numbers as numbers, null and
113		// booleans become numbers.
114		{"1 == '1'", "Boolean:true"},
115		{"'1' == 1", "Boolean:true"},
116		{"'1.0' == 1", "Boolean:true"},
117		{"' 1 ' == 1", "Boolean:true"},
118		{"'0x10' == 16", "Boolean:true"},
119		{"'abc' == 0", "Boolean:false"},
120		{"'' == 0", "Boolean:true"},
121		{"'' == null", "Boolean:true"}, // both become 0
122		{"null == 0", "Boolean:true"},
123		{"null == false", "Boolean:true"},
124		{"null == ''", "Boolean:true"},
125		{"null == null", "Boolean:true"},
126		{"true == 1", "Boolean:true"},
127		{"true == '1'", "Boolean:true"},
128		{"true == 'true'", "Boolean:false"},
129		{"false == 0", "Boolean:true"},
130		{"false == ''", "Boolean:true"},
131		{"true == true", "Boolean:true"},
132		{"true != false", "Boolean:true"},
133		{"matrix.nothing == null", "Boolean:true"},
134		{"env.EMPTY == null", "Boolean:true"},
135		{"env.EMPTY == false", "Boolean:true"},
136
137		// NaN is never equal, never ordered.
138		{"NaN == NaN", "Boolean:false"},
139		{"NaN != NaN", "Boolean:true"},
140		{"NaN < 1", "Boolean:false"},
141		{"NaN > 1", "Boolean:false"},
142		{"NaN <= NaN", "Boolean:false"},
143		{"NaN >= NaN", "Boolean:false"},
144		{"'abc' > 1", "Boolean:false"},
145		{"'abc' < 1", "Boolean:false"},
146		{"'abc' != 1", "Boolean:true"},
147		{"Infinity == Infinity", "Boolean:true"},
148		{"-Infinity < Infinity", "Boolean:true"},
149
150		// Strings compare case-insensitively and by upper-cased value.
151		{"'a' == 'A'", "Boolean:true"},
152		{"'abc' == 'ABC'", "Boolean:true"},
153		{"'a' < 'B'", "Boolean:true"},
154		{"'B' > 'a'", "Boolean:true"},
155		{"'a' < 'A'", "Boolean:false"},
156		{"'a' <= 'A'", "Boolean:true"},
157		{"'Z' < 'a'", "Boolean:false"},
158		{"'_' < 'a'", "Boolean:false"}, // '_' (0x5F) sorts after upper-case letters
159		// OrdinalIgnoreCase folds one code point at a time: no ß to SS.
160		{"'ß' == 'SS'", "Boolean:false"},
161		{"'ß' == 'ss'", "Boolean:false"},
162		{"'ß' == 'ß'", "Boolean:true"},
163		{"'é' == 'É'", "Boolean:true"},
164		{"'dž' == 'Dž'", "Boolean:true"},
165		{"'ı' == 'I'", "Boolean:false"},
166		{"'\u212a' == 'k'", "Boolean:false"}, // Kelvin sign
167		{"'ſ' == 's'", "Boolean:false"},
168		{"'ς' == 'σ'", "Boolean:true"},  // both fold to Σ
169		{"'ı' == 'I'", "Boolean:false"}, // dotless i is not upper-cased
170		{"'ı' == 'i'", "Boolean:false"},
171		{"'ı' == 'ı'", "Boolean:true"},
172		{"'İ' == 'i'", "Boolean:false"},
173		{"'σ' == 'ς'", "Boolean:true"},
174		{"'dž' == 'DŽ'", "Boolean:true"},
175
176		// Booleans order false < true; null does not order (it becomes 0
177		// against a number but is only equal to itself).
178		{"false < true", "Boolean:true"},
179		{"true > false", "Boolean:true"},
180		{"true < false", "Boolean:false"},
181		{"true >= true", "Boolean:true"},
182		{"null < 1", "Boolean:true"},
183		{"null < null", "Boolean:false"},
184		{"null <= null", "Boolean:true"},
185		{"null >= null", "Boolean:true"},
186		{"true > 0", "Boolean:true"},
187
188		// Arrays and dictionaries are equal only by identity and never ordered.
189		{"matrix.node == matrix.node", "Boolean:true"},
190		{"matrix.node != matrix.node", "Boolean:false"},
191		{"matrix.node == fromJSON('[14,16,18]')", "Boolean:false"},
192		{"matrix.obj == matrix.obj", "Boolean:true"},
193		{"matrix.obj == fromJSON('{\"k\":\"v\"}')", "Boolean:false"},
194		{"matrix.node == 1", "Boolean:false"},
195		{"matrix.node < matrix.node", "Boolean:false"},
196		{"matrix.node <= matrix.node", "Boolean:true"},
197		{"matrix.node == null", "Boolean:false"},
198		{"matrix.obj == true", "Boolean:false"},
199		{"matrix.obj == 'Object'", "Boolean:false"},
200		// A filtered array is a new array each time.
201		{"github.event.commits.* == github.event.commits.*", "Boolean:false"},
202	})
203}
204
205func TestEvalLogicalReturnsOperands(t *testing.T) {
206	runEvalCases(t, []evalCase{
207		{"1 || 2", "Number:1"},
208		{"0 || 2", "Number:2"},
209		{"0 || ''", "String:"},
210		{"0 || '' || null", "Null:"},
211		{"0 || '' || 'x'", "String:x"},
212		{"null || null", "Null:"},
213		{"1 && 2", "Number:2"},
214		{"0 && 2", "Number:0"},
215		{"1 && 0 && 3", "Number:0"},
216		{"1 && 2 && 3", "Number:3"},
217		{"'a' && 'b' || 'c'", "String:b"},
218		{"'' && 'b' || 'c'", "String:c"},
219		{"NaN || 'x'", "String:x"},
220		{"NaN && 'x'", "Number:NaN"},
221		{"'0' || 'x'", "String:0"}, // the string "0" is truthy
222		{"matrix.node || 'x'", "Array:Array"},
223		{"matrix.obj && 'x'", "String:x"},
224		{"fromJSON('[]') && 'x'", "String:x"},
225		{"fromJSON('{}') || 'x'", "Object:Object"},
226		// Short-circuit: the failing call is never evaluated.
227		{"true || fromJSON('')", "Boolean:true"},
228		{"false && fromJSON('')", "Boolean:false"},
229		{"false || fromJSON('')", "ERR Error reading JToken from JsonReader. Path '', line 0, position 0."},
230		{"true && fromJSON('')", "ERR Error reading JToken from JsonReader. Path '', line 0, position 0."},
231		// Comparisons evaluate both sides.
232		{"false == fromJSON('')", "ERR Error reading JToken from JsonReader. Path '', line 0, position 0."},
233		{"fromJSON('') == false", "ERR Error reading JToken from JsonReader. Path '', line 0, position 0."},
234		// Precedence: && binds tighter than ||, ! tighter than comparison.
235		{"true || false && false", "Boolean:true"},
236		{"(true || false) && false", "Boolean:false"},
237		{"false && true || true", "Boolean:true"},
238		{"1 == 1 == true", "Boolean:true"},
239		{"1 < 2 == true", "Boolean:true"},
240		{"true == 1 < 2", "Boolean:true"},
241		{"!1 == 2", "Boolean:false"},
242		{"!(1 == 2)", "Boolean:true"},
243		{"!!1", "Boolean:true"},
244		{"!0", "Boolean:true"},
245		{"!''", "Boolean:true"},
246		{"!'0'", "Boolean:false"},
247		{"!null", "Boolean:true"},
248		{"!NaN", "Boolean:true"},
249		{"!matrix.node", "Boolean:false"},
250		{"!matrix.obj", "Boolean:false"},
251		{"!github.event.commits.*", "Boolean:false"},
252	})
253}
254
255func TestEvalAccess(t *testing.T) {
256	runEvalCases(t, []evalCase{
257		{"github.event_name", "String:push"},
258		{"GITHUB.EVENT_NAME", "String:push"},
259		{"GitHub.Event.Number", "Number:12"},
260		{"github['event_name']", "String:push"},
261		{"github['EVENT_NAME']", "String:push"},
262		{"github[ 'event_name' ]", "String:push"},
263		{"(github).event_name", "String:push"},
264		{"(github.event).number", "Number:12"},
265		{"github.nope", "Null:"},
266		{"github.nope.deeper", "Null:"},
267		{"github.nope[0]", "Null:"},
268		{"github.nope.*", "Array:Array"},
269		{"github.event.commits[0].id", "String:1"},
270		{"github.event.commits[2].id", "String:3"},
271		{"github.event.commits[3]", "Null:"},
272		{"github.event.commits[-1]", "Null:"},
273		{"github.event.commits[1.9].id", "String:2"},
274		{"github.event.commits['1'].id", "String:2"},
275		{"github.event.commits['1.9'].id", "String:2"},
276		{"github.event.commits[' 1 '].id", "String:2"},
277		{"github.event.commits[true].id", "String:2"},
278		{"github.event.commits[false].id", "String:1"},
279		{"github.event.commits[null].id", "String:1"},
280		{"github.event.commits[0x1].id", "String:2"},
281		{"github.event.commits[NaN]", "Null:"},
282		{"github.event.commits[Infinity]", "Null:"},
283		{"github.event.commits['a']", "Null:"},
284		{"github.event.commits[github]", "Null:"},
285		{"github.event.commits[0]['author']['name']", "String:Ann"},
286		{"github.event.commits[0].author.name", "String:Ann"},
287		{"github.event.commits[2].author.name", "Null:"},
288		{"github.event.commits[1e20]", "Null:"},
289		// Index by number or boolean on a dictionary uses the string form.
290		{"steps[10].outputs.r", "String:ten"},
291		{"steps['10'].outputs.r", "String:ten"},
292		// After an identifier, ".10" is not a number: the lexer reads a DOT and then "10.outputs.r" as one number-like word.
293		{"steps.10.outputs.r", "PARSE Unexpected symbol: '10.outputs.r'"},
294		{"steps[2].outputs.r", "String:two"},
295		{"matrix.obj[0]", "Null:"},
296		{"matrix.node['0']", "Number:14"},
297		{"matrix.node[0] == 14", "Boolean:true"},
298		{"matrix.ver", "String:3.10"},
299		{"matrix.ver == 3.1", "Boolean:true"},
300		{"matrix.ver == '3.10'", "Boolean:true"},
301		// Indexing a non-container gives null (or an empty filter with *).
302		{"'abc'[0]", "PARSE Unexpected symbol: '['"},
303		{"(1)[0]", "Null:"},
304		{"(true).a", "Null:"},
305		{"(null).a", "Null:"},
306		{"(1).*", "Array:Array"},
307		{"toJSON((1).*)", "String:[]"},
308		// Missing contexts at evaluation time are null.
309		{"toJSON(github.event.commits[0].added)", "String:[\n  \"a.txt\"\n]"},
310	})
311}
312
313func TestEvalFilters(t *testing.T) {
314	runEvalCases(t, []evalCase{
315		{"toJSON(github.event.commits.*.id)", "String:[\n  \"1\",\n  \"2\",\n  \"3\"\n]"},
316		{"toJSON(github.event.commits[*].id)", "String:[\n  \"1\",\n  \"2\",\n  \"3\"\n]"},
317		// Elements without the property are skipped.
318		{"toJSON(github.event.commits.*.author.name)", "String:[\n  \"Ann\",\n  \"Bob\"\n]"},
319		// ".*" on a filtered array flattens one level of arrays and objects.
320		{"toJSON(github.event.commits.*.added.*)", "String:[\n  \"a.txt\",\n  \"b\",\n  \"c\"\n]"},
321		{"toJSON(github.event.commits.*.added[0])", "String:[\n  \"a.txt\",\n  \"b\"\n]"},
322		{"toJSON(github.event.commits.*.added[1])", "String:[\n  \"c\"\n]"},
323		{"toJSON(github.event.nested.*.*)", "String:[\n  1,\n  2,\n  3,\n  4,\n  5,\n  6\n]"},
324		{"toJSON(github.event.nested.*[1])", "String:[\n  2,\n  5\n]"},
325		{"toJSON(github.event.nested[*][0])", "String:[\n  1,\n  3,\n  4\n]"},
326		{"toJSON(github.event.nested.*.*.*)", "String:[]"},
327		{"toJSON(matrix.obj.*)", "String:[\n  \"v\"\n]"},
328		{"toJSON(matrix.node.*)", "String:[\n  14,\n  16,\n  18\n]"},
329		{"toJSON(matrix.nothing.*)", "String:[]"},
330		{"toJSON(inputs.empty.*)", "String:[]"},
331		// Object members keep the order of the document (the JavaScript
332		// reference puts array-index keys first).
333		{"toJSON(steps.*.outputs.r)", "String:[\n  \"ten\",\n  \"two\"\n]"},
334		{"toJSON(fromJSON('{\"a\":{\"x\":1},\"b\":{\"x\":2}}').*.x)", "String:[\n  1,\n  2\n]"},
335		{"toJSON(fromJSON('[1,2]').*.x)", "String:[]"},
336		{"toJSON(fromJSON('[{\"a\":[1,2]},{\"a\":[3]}]').*.a.*)", "String:[\n  1,\n  2,\n  3\n]"},
337		{"contains(github.event.commits.*.message, 'FIX: BUG')", "Boolean:true"},
338		{"contains(github.event.commits.*.id, 2)", "Boolean:true"},
339		{"contains(github.event.commits.*.id, 4)", "Boolean:false"},
340		{"contains(matrix.node, 16)", "Boolean:true"},
341		{"contains(matrix.node, '16')", "Boolean:true"},
342		{"contains(matrix.node, 17)", "Boolean:false"},
343		{"join(matrix.node.*, '.')", "String:14.16.18"},
344	})
345}
346
347func TestEvalBuiltinFunctions(t *testing.T) {
348	runEvalCases(t, []evalCase{
349		// contains
350		{"contains('abc', 'B')", "Boolean:true"},
351		{"contains('abc', '')", "Boolean:true"},
352		{"contains('', '')", "Boolean:true"},
353		{"contains('', 'a')", "Boolean:false"},
354		{"contains(null, '')", "Boolean:true"},
355		{"contains(1234, 23)", "Boolean:true"},
356		{"contains(true, 'RU')", "Boolean:true"},
357		{"contains(github, 'a')", "Boolean:false"},
358		{"contains('a', github)", "Boolean:false"},
359		{"contains(fromJSON('[]'), null)", "Boolean:false"},
360		{"contains(fromJSON('[1,\"2\",null]'), null)", "Boolean:true"},
361		{"contains(fromJSON('[1,\"2\",null]'), '')", "Boolean:true"},
362		{"contains(fromJSON('[[1]]'), fromJSON('[1]'))", "Boolean:false"},
363		{"contains('İ', 'i')", "Boolean:false"},      // OrdinalIgnoreCase: İ is not i
364		{"contains('ß', 'ss')", "Boolean:false"},     // no ß folding
365		{"contains('\u212a', 'k')", "Boolean:false"}, // Kelvin sign
366		{"contains('Dž', 'dž')", "Boolean:true"},
367		{"contains('A\u03a3', 'a\u03c2')", "Boolean:true"},
368		{"contains('K', 'k')", "Boolean:true"}, // KELVIN SIGN lower-cases to k
369		{"contains()", "PARSE Too few parameters supplied: '('"},
370		{"contains(1)", "PARSE Too few parameters supplied: '('"},
371		{"contains(1, 2, 3)", "PARSE Too many parameters supplied: '('"},
372
373		// startsWith, endsWith
374		{"startsWith('abc', 'AB')", "Boolean:true"},
375		{"startsWith('abc', '')", "Boolean:true"},
376		{"startsWith(null, '')", "Boolean:true"},
377		{"startsWith(123, 12)", "Boolean:true"},
378		{"startsWith(github, 'a')", "Boolean:false"},
379		{"startsWith('a', github)", "Boolean:false"},
380		{"startsWith('ß', 'ss')", "Boolean:false"}, // no ß folding
381		{"startsWith('ı', 'I')", "Boolean:false"},
382		{"endsWith('abc', 'BC')", "Boolean:true"},
383		{"endsWith('abc', 'abcd')", "Boolean:false"},
384		{"endsWith(1.50, '5')", "Boolean:true"},
385		{"endsWith(1.50, 5)", "Boolean:true"},
386		{"endsWith('abc')", "PARSE Too few parameters supplied: '('"},
387		{"STARTSWITH('abc', 'a')", "Boolean:true"},
388
389		// join
390		{"join('a')", "String:a"},
391		{"join('a', 'b')", "String:a"},
392		{"join(null)", "String:"},
393		{"join(1.50)", "String:1.5"},
394		{"join(github)", "String:"},
395		{"join(fromJSON('[]'))", "String:"},
396		{"join(fromJSON('[1,2,3]'))", "String:1,2,3"},
397		{"join(fromJSON('[1,2,3]'), '')", "String:123"},
398		{"join(fromJSON('[1,2,3]'), ', ')", "String:1, 2, 3"},
399		{"join(fromJSON('[1,2,3]'), 1)", "String:11213"},
400		{"join(fromJSON('[1,2,3]'), null)", "String:123"},
401		{"join(fromJSON('[1,2,3]'), github)", "String:1,2,3"}, // a non-primitive separator is ignored
402		{"join(fromJSON('[null,true,\"a\",1.5,[1],{}]'))", "String:,true,a,1.5,Array,Object"},
403		{"join()", "PARSE Too few parameters supplied: '('"},
404		{"join(1, 2, 3)", "PARSE Too many parameters supplied: '('"},
405
406		// toJSON
407		{"toJSON(null)", "String:null"},
408		{"toJSON(true)", "String:true"},
409		{"toJSON(1)", "String:1"},
410		{"toJSON(NaN)", "String:NaN"},
411		{"toJSON(Infinity)", "String:Infinity"},
412		{"toJSON(-0)", "String:-0"},
413		{"toJSON('x')", "String:\"x\""},
414		{"toJSON(1e21)", "String:1E+21"},
415		{"toJSON(1e-7)", "String:1E-07"},
416		{"toJSON(0.1)", "String:0.1"},
417		{"toJSON(fromJSON('[]'))", "String:[]"},
418		{"toJSON(fromJSON('{}'))", "String:{}"},
419		{"toJSON(fromJSON('[[],{}]'))", "String:[\n  [],\n  {}\n]"},
420		{"toJSON(1, 2)", "PARSE Too many parameters supplied: '('"},
421		{"toJSON()", "PARSE Too few parameters supplied: '('"},
422		{"fromJSON(toJSON(github.event.commits[0])).author.name", "String:Ann"},
423
424		// fromJSON
425		{"fromJSON('')", "ERR Error reading JToken from JsonReader. Path '', line 0, position 0."},
426		{"fromJSON('  ')", "ERR Error reading JToken from JsonReader. Path '', line 1, position 2."},
427		{"fromJSON('\n')", "ERR Error reading JToken from JsonReader. Path '', line 0, position 0."},
428		{"fromJSON(null)", "ERR Error reading JToken from JsonReader. Path '', line 0, position 0."},
429		{"fromJSON('{')", "ERR Error reading JObject from JsonReader. Path '', line 1, position 1."},
430		// Json.NET is lenient: trailing commas, unquoted names, leading zeros,
431		// NaN and Infinity and anything after the first value are accepted.
432		{"fromJSON('[1,]')", "Array:Array"},
433		{"fromJSON('{a:1}').a", "Number:1"},
434		{"fromJSON('01')", "Number:1"},
435		{"fromJSON('NaN')", "Number:NaN"},
436		{"fromJSON('1 2')", "Number:1"}, // only the first value is read
437		{"fromJSON(' 1 ')", "Number:1"},
438		{"fromJSON('-1.5e2')", "Number:-150"},
439		{"fromJSON('1e999')", "Number:Infinity"},
440		{"fromJSON('\"\\u0041\"')", "String:A"},
441		{"fromJSON('\"\\ud83d\\ude00\"')", "String:😀"},
442		{"fromJSON(1)", "Number:1"},
443		{"fromJSON(true)", "Boolean:true"},
444		{"fromJSON(matrix.node)", "ERR Unexpected character encountered while parsing value: A. Path '', line 0, position 0."}, // the string "Array"
445		{"fromJSON(1, 2)", "PARSE Too many parameters supplied: '('"},
446
447		// format
448		{"format('a')", "String:a"},
449		{"format('')", "String:"},
450		{"format(null)", "String:"},
451		{"format(1)", "String:1"},
452		{"format('{0}', 1)", "String:1"},
453		{"format('{0}{0}', 'x')", "String:xx"},
454		{"format('{0}{1}{0}', 'a', 'b')", "String:aba"},
455		{"format('{{')", "String:{"},
456		{"format('}}')", "String:}"},
457		{"format('{{}}')", "String:{}"},
458		{"format('{{0}}', 1)", "String:{0}"},
459		{"format('{{{0}}}', 1)", "String:{1}"},
460		{"format('{0}', 'a', 'b')", "String:a"}, // extra arguments are fine
461		// The runner rejects format specifiers (Format.cs) and anything but
462		// digits between the index and the closing brace; the JavaScript
463		// reference skips them.
464		{"format('{0:x}', 1)", "ERR The format specifiers 'x' are not valid for objects of type 'Number'"},
465		{"format('{0:}', 1)", "String:1"},
466		{"format('{0:x}}y}', true)", "ERR The format specifiers 'x}y' are not valid for objects of type 'Boolean'"},
467		{"format('{0 anything}', 1)", "ERR The following format string is invalid: {0 anything}"},
468		{"format('{0 }', 1)", "ERR The following format string is invalid: {0 }"},
469		{"format('{0,5}', 1)", "ERR The following format string is invalid: {0,5}"},
470		{"format('{255}', 1)", "ERR The following format string references more arguments than were supplied: {255}"},
471		{"format('{256}', 1)", "ERR The following format string is invalid: {256}"},
472		{"format('{00}', 7)", "String:7"},
473		{"format('{00}', 1)", "String:1"},
474		{"format('{01}', 1, 2)", "String:2"},
475		{"format('{0}', null)", "String:"},
476		{"format('{0}', true)", "String:true"},
477		{"format('{0}', 0.1)", "String:0.1"},
478		{"format('{0}', github)", "String:Object"},
479		{"format('{0}', matrix.node)", "String:Array"},
480		{"format('{0}-{1}', 1, 'a')", "String:1-a"},
481		{"format('x{0}y', '😀')", "String:x😀y"},
482		{"format('{1}', 1)", "ERR The following format string references more arguments than were supplied: {1}"},
483		{"format('{0}')", "ERR The following format string references more arguments than were supplied: {0}"},
484		{"format('{', 1)", "ERR The following format string is invalid: {"},
485		{"format('}', 1)", "ERR The following format string is invalid: }"},
486		{"format('{}', 1)", "ERR The following format string is invalid: {}"},
487		{"format('{ 0}', 1)", "ERR The following format string is invalid: { 0}"},
488		{"format('{-1}', 1)", "ERR The following format string is invalid: {-1}"},
489		{"format('{a}', 1)", "ERR The following format string is invalid: {a}"},
490		{"format('{0', 1)", "ERR The following format string is invalid: {0"},
491		{"format('0}', 1)", "ERR The following format string is invalid: 0}"},
492		{"format('{{0}', 1)", "ERR The following format string is invalid: {{0}"},
493		{"format('{0}}', 1)", "ERR The following format string is invalid: {0}}"},
494		// The index is a byte: a longer number makes the string invalid.
495		{"format('{12345678901234567890123}', 1)", "ERR The following format string is invalid: {12345678901234567890123}"},
496		{"format()", "PARSE Too few parameters supplied: '('"},
497
498		// case
499		{"case(true, 'a', 'b')", "String:a"},
500		{"case(false, 'a', 'b')", "String:b"},
501		{"case(false, 1, true, 2, 3)", "Number:2"},
502		{"case(false, 1, false, 2, 3)", "Number:3"},
503		{"case(true, 1, true, 2, 3)", "Number:1"},
504		{"case(github.event_name == 'push', 'a', github.event_name == 'pr', 'b', 'c')", "String:a"},
505		{"CASE(true, matrix.node, 2)", "Array:Array"},
506		{"case(1, 2, 3)", "ERR case predicate must evaluate to a boolean value"},
507		{"case('true', 2, 3)", "ERR case predicate must evaluate to a boolean value"},
508		{"case(null, 2, 3)", "ERR case predicate must evaluate to a boolean value"},
509		{"case(true, 1, 2, 3)", "PARSE Even number of parameters supplied, requires an odd number of parameters: '('"},
510		{"case(true, 1)", "PARSE Too few parameters supplied: '('"},
511		{"case()", "PARSE Too few parameters supplied: '('"},
512		// Predicates after the first true one are not checked...
513		{"case(true, 1, 'bad', 2, 3)", "Number:1"},
514		// ...but earlier ones are.
515		{"case('bad', 1, true, 2, 3)", "ERR case predicate must evaluate to a boolean value"},
516		// The default is returned as is, whatever it is.
517		{"case(false, 1, null)", "Null:"},
518	})
519}
520
521func TestEvalErrorMessages(t *testing.T) {
522	runEvalCases(t, []evalCase{
523		{"", "PARSE Unexpected end of expression: 'x'"}, // replaced below: the empty expression is not an error
524	}[:0])
525	cases := []evalCase{
526		{"1 +", "PARSE Unexpected symbol: '+'"},
527		{"a b", "PARSE Unrecognized named-value: 'a'"},
528		{"github github", "PARSE Unexpected symbol: 'github'"},
529		{"github.event_name ==", "PARSE Unexpected end of expression: '=='"},
530		{"== 1", "PARSE Unexpected symbol: '=='"},
531		{"(github.event_name", "PARSE Unexpected end of expression: 'event_name'"},
532		{"github.event_name)", "PARSE Unexpected symbol: ')'"},
533		{"contains('a',", "PARSE Unexpected end of expression: ','"},
534		{"contains('a'", "PARSE Unexpected end of expression: ''a''"},
535		{"contains('a' 'b')", "PARSE Unexpected symbol: ''b''"},
536		{"foo(", "PARSE Unrecognized function: 'foo'"},
537		{"foo()", "PARSE Unrecognized function: 'foo'"},
538		{"foo", "PARSE Unrecognized named-value: 'foo'"},
539		{"unknown(1)", "PARSE Unrecognized function: 'unknown'"},
540		{"success()", "PARSE Unrecognized function: 'success'"}, // not declared in these tests
541		{"(", "PARSE Unexpected end of expression: '('"},
542		{")", "PARSE Unexpected symbol: ')'"},
543		{"()", "PARSE Unexpected symbol: ')'"},
544		{"!", "PARSE Unexpected end of expression: '!'"},
545		{"1 !", "PARSE Unexpected symbol: '!'"},
546		{"[1]", "PARSE Unexpected symbol: '['"},
547		{"github.[0]", "PARSE Unexpected symbol: '['"},
548		{"github.", "PARSE Unexpected end of expression: '.'"},
549		{"github[", "PARSE Unexpected end of expression: '['"},
550		{"github[]", "PARSE Unexpected symbol: ']'"},
551		{"github[*", "PARSE Unexpected end of expression: '*'"},
552		{"github.true", "Null:"}, // keywords are plain identifiers after a dot
553		{"env.true", "Null:"},
554		{"1 = 1", "PARSE Unexpected symbol: '='"},
555		{"1 & 1", "PARSE Unexpected symbol: '&'"},
556		{"1 | 1", "PARSE Unexpected symbol: '|'"},
557		{"1 + 1", "PARSE Unexpected symbol: '+'"},
558		{"a-b", "PARSE Unrecognized named-value: 'a-b'"},
559		{"1a", "PARSE Unexpected symbol: '1a'"},
560		{"'unterminated", "PARSE Unexpected symbol: ''unterminated'"},
561		{"@", "PARSE Unexpected symbol: '@'"},
562		{"${{ 1 }}", "PARSE Unexpected symbol: '${{'"},
563		{"1 \u00a0 2", "PARSE Unexpected symbol: '2'"}, // U+00A0 is white space for .NET
564	}
565	runEvalCases(t, cases)
566}
567
568func TestEmptyExpression(t *testing.T) {
569	for _, s := range []string{"", " ", "\n", " \t\r\n "} {
570		node, err := Parse(s, nil, nil)
571		if err != nil || node != nil {
572			t.Errorf("Parse(%q) = %v, %v; want nil, nil", s, node, err)
573		}
574	}
575	if _, err := Evaluate(context.Background(), nil, EvalContext{}); err == nil {
576		t.Error("Evaluate(nil) succeeded")
577	}
578}
579
580// TestParseErrorPositions: the runner (ParseException) reports the absolute
581// index of the token in the expression, 1-based, and the whole expression;
582// Error.Pos is the same place as a line and a column.
583func TestParseErrorPositions(t *testing.T) {
584	tests := []struct {
585		src       string
586		line, col int
587		msg       string
588	}{
589		{"1 ==\n 2 ===\n 3", 1, 5, "Unexpected symbol: '='. Located at position 11 within expression: 1 ==\n 2 ===\n 3"},
590		{"true &&\n  false ||\n  nope", 2, 2, "Unrecognized named-value: 'nope'. Located at position 22 within expression: true &&\n  false ||\n  nope"},
591		{"\n\n  bogus", 2, 2, "Unrecognized named-value: 'bogus'. Located at position 5 within expression: \n\n  bogus"},
592		{"github github", 0, 7, "Unexpected symbol: 'github'. Located at position 8 within expression: github github"},
593		{"  )  ", 0, 2, "Unexpected symbol: ')'. Located at position 3 within expression:   )  "},
594		{"contains('a',", 0, 12, "Unexpected end of expression: ','. Located at position 13 within expression: contains('a',"},
595		{"foo()", 0, 0, "Unrecognized function: 'foo'. Located at position 1 within expression: foo()"},
596		{"1 && foo()", 0, 5, "Unrecognized function: 'foo'. Located at position 6 within expression: 1 && foo()"},
597		{"'a'\n 'b'\n  zzz", 1, 1, "Unexpected symbol: ''b''. Located at position 6 within expression: 'a'\n 'b'\n  zzz"},
598		{"contains()", 0, 8, "Too few parameters supplied: '('. Located at position 9 within expression: contains()"},
599		{"1 2", 0, 2, "Unexpected symbol: '2'. Located at position 3 within expression: 1 2"},
600	}
601	for _, tc := range tests {
602		_, err := Parse(tc.src, testContextNames, nil)
603		var pe *Error
604		if !errors.As(err, &pe) {
605			t.Errorf("Parse(%q): error %v is not *Error", tc.src, err)
606			continue
607		}
608		if pe.Message != tc.msg || pe.Pos.Line != tc.line || pe.Pos.Column != tc.col {
609			t.Errorf("Parse(%q) = %q at %v; want %q at %d:%d", tc.src, pe.Message, pe.Pos, tc.msg, tc.line, tc.col)
610		}
611	}
612}
613
614func TestParseErrorTypes(t *testing.T) {
615	tests := []struct {
616		src  string
617		want ErrorType
618	}{
619		{"1 ==", ErrorUnexpectedEndOfExpression},
620		{")", ErrorUnexpectedSymbol},
621		{"nope", ErrorUnrecognizedContext},
622		{"nope()", ErrorUnrecognizedFunction},
623		{"contains(1)", ErrorTooFewParameters},
624		{"contains(1,2,3)", ErrorTooManyParameters},
625		{"case(true, 1, 2, 3)", ErrorEvenParameters},
626		{strings.Repeat("!", 60) + "1", ErrorExceededMaxDepth},
627		{"1 = 1", ErrorUnexpectedSymbol},
628	}
629	for _, tc := range tests {
630		_, err := Parse(tc.src, nil, nil)
631		var pe *Error
632		if !errors.As(err, &pe) || pe.Type != tc.want {
633			t.Errorf("Parse(%.30q): got %#v, want type %d", tc.src, err, tc.want)
634		}
635	}
636}
637
638// TestLimits: the expectations were established with the runner's own parser.
639// Depth is that of the finished tree (CheckMaxDepth): the root is 1, parentheses
640// add nothing, a run of && or || is a single node, a call adds one level for
641// its arguments, "a.b" and "a[b]" one level each, and "!" one.
642func TestLimits(t *testing.T) {
643	deepParens := func(n int) string { return strings.Repeat("(", n) + "1" + strings.Repeat(")", n) }
644	tests := []struct {
645		name string
646		src  string
647		ok   bool
648	}{
649		{"parentheses do not count", deepParens(500), true},
650		{"49 nots", strings.Repeat("!", 49) + "true", true},
651		{"50 nots", strings.Repeat("!", 50) + "true", false},
652		{"49 calls", strings.Repeat("format(", 49) + "'x'" + strings.Repeat(")", 49), true},
653		{"50 calls", strings.Repeat("format(", 50) + "'x'" + strings.Repeat(")", 50), false},
654		{"long dotted path", "github" + strings.Repeat(".event", 49), true},
655		{"too long dotted path", "github" + strings.Repeat(".event", 50), false},
656		{"long flat ||", "1" + strings.Repeat(" || 1", 3000), true},
657		{"nested || is flat", "1" + strings.Repeat(" || (1", 60) + strings.Repeat(")", 60), true},
658		{"49 ==", "1" + strings.Repeat(" == 1", 49), true},
659		{"50 ==", "1" + strings.Repeat(" == 1", 50), false},
660	}
661	for _, tc := range tests {
662		_, err := Parse(tc.src, testContextNames, nil)
663		if (err == nil) != tc.ok {
664			t.Errorf("%s: err = %v, want ok=%v", tc.name, err, tc.ok)
665		}
666		var pe *Error
667		if err != nil && errors.As(err, &pe) && pe.Type == ErrorExceededMaxDepth && pe.Message != "Exceeded max expression depth 50" {
668			t.Errorf("%s: message %q", tc.name, pe.Message)
669		}
670	}
671
672	// Length: counted in UTF-16 code units, no token in the message.
673	long := "'" + strings.Repeat("a", MaxExpressionLength-2) + "'"
674	if _, err := Parse(long, nil, nil); err != nil {
675		t.Errorf("expression of exactly %d characters: %v", MaxExpressionLength, err)
676	}
677	_, err := Parse(long+" ", nil, nil)
678	if err == nil || err.Error() != "Exceeded max expression length 21000" {
679		t.Errorf("expression of %d characters: err = %v", MaxExpressionLength+1, err)
680	}
681	// 10501 emoji are 21002 UTF-16 units but only 10501 runes.
682	emoji := "'" + strings.Repeat("😀", 10500) + "'"
683	if _, err := Parse(emoji, nil, nil); err == nil {
684		t.Errorf("a string of 10500 emoji (21002 UTF-16 units) was accepted")
685	}
686	emoji = "'" + strings.Repeat("😀", 10499) + "'"
687	if _, err := Parse(emoji, nil, nil); err != nil {
688		t.Errorf("a string of 10499 emoji (21000 UTF-16 units) was rejected: %v", err)
689	}
690}
691
692func TestDepthMessage(t *testing.T) {
693	_, err := Parse(strings.Repeat("!", 60)+"1", nil, nil)
694	want := "Exceeded max expression depth 50"
695	if err == nil || err.Error() != want {
696		t.Errorf("got %v, want %q", err, want)
697	}
698}
699
700func TestExtensionFunctions(t *testing.T) {
701	funcs := []FuncInfo{
702		{Name: "hashFiles", MinArgs: 1, MaxArgs: -1},
703		{Name: "success", MinArgs: 0, MaxArgs: 0},
704		{Name: "bounded", MinArgs: 1, MaxArgs: 2},
705		{Name: "contains", MinArgs: 0, MaxArgs: 0}, // built-ins win for validation
706	}
707	for _, c := range []struct{ src, err string }{
708		{"hashFiles('a')", ""},
709		{"HASHFILES('a', 'b', 'c', 'd')", ""},
710		{"hashFiles()", "Too few parameters supplied: '('"},
711		{"success()", ""},
712		{"success(1)", "Too many parameters supplied: '('"},
713		{"bounded(1, 2)", ""},
714		{"bounded(1, 2, 3)", "Too many parameters supplied: '('"},
715		{"contains()", "Too few parameters supplied: '('"},
716		{"nothere()", "Unrecognized function: 'nothere'"},
717	} {
718		_, err := Parse(c.src, nil, funcs)
719		msg := ""
720		if err != nil {
721			msg, _, _ = strings.Cut(err.Error(), ". Located at position ")
722		}
723		switch {
724		case c.err == "" && err != nil:
725			t.Errorf("%s: %v", c.src, err)
726		case c.err != "" && (err == nil || msg != c.err):
727			t.Errorf("%s: err = %v, want %q", c.src, err, c.err)
728		}
729	}
730
731	calls := 0
732	ctx := EvalContext{
733		Contexts: map[string]Value{"G": String("v")},
734		Funcs: map[string]Func{
735			"HashFiles": func(_ context.Context, args []Value) (Value, error) {
736				calls++
737				return String("h:" + CoerceString(args[0])), nil
738			},
739			"success": func(context.Context, []Value) (Value, error) { return Boolean(true), nil },
740			"boom":    func(context.Context, []Value) (Value, error) { return nil, errors.New("kaput") },
741			// An extra function does not replace a built-in of the same name.
742			"contains": func(context.Context, []Value) (Value, error) { return String("mine"), nil },
743		},
744	}
745	node, err := Parse("hashFiles(g) == 'h:v' && success() && contains('a','b')", []string{"g"}, funcs)
746	if err != nil {
747		t.Fatal(err)
748	}
749	v, err := Evaluate(context.Background(), node, ctx)
750	if err != nil || CoerceString(v) != "false" || calls != 1 {
751		t.Errorf("got %v, %v, %d calls", v, err, calls)
752	}
753	node, err = Parse("boom()", nil, []FuncInfo{{Name: "boom"}})
754	if err != nil {
755		t.Fatal(err)
756	}
757	if _, err = Evaluate(context.Background(), node, ctx); err == nil || err.Error() != "kaput" {
758		t.Errorf("function error not propagated: %v", err)
759	}
760	// A declared function without an implementation is an evaluation error.
761	node, _ = Parse("success()", nil, funcs)
762	if _, err = Evaluate(context.Background(), node, EvalContext{}); err == nil {
763		t.Error("calling an unimplemented function succeeded")
764	}
765}
766
767func TestContextNames(t *testing.T) {
768	node, err := Parse("GitHub.Ref", []string{"github"}, nil)
769	if err != nil {
770		t.Fatal(err)
771	}
772	for _, key := range []string{"github", "GITHUB", "GitHub"} {
773		d := NewDictionary()
774		d.Add("ref", String("main"))
775		v, err := Evaluate(context.Background(), node, EvalContext{Contexts: map[string]Value{key: d}})
776		if err != nil || CoerceString(v) != "main" {
777			t.Errorf("context key %q: %v, %v", key, v, err)
778		}
779	}
780	// A declared but absent context is null.
781	v, err := Evaluate(context.Background(), node, EvalContext{})
782	if err != nil || v.Kind() != KindNull {
783		t.Errorf("absent context: %v, %v", v, err)
784	}
785	// Names that were not declared to Parse are errors, even if present at evaluation.
786	if _, err := Parse("secrets.X", []string{"github"}, nil); err == nil || !strings.HasPrefix(err.Error(), "Unrecognized named-value: 'secrets'. Located at position 1 ") {
787		t.Errorf("undeclared context: %v", err)
788	}
789	// A nil Value in a context behaves as null.
790	node, _ = Parse("github.a.b", []string{"github"}, nil)
791	if v, err := Evaluate(context.Background(), node, EvalContext{Contexts: map[string]Value{"github": nil}}); err != nil || v.Kind() != KindNull {
792		t.Errorf("nil context value: %v, %v", v, err)
793	}
794	var nilDict *Dictionary
795	var nilArr *Array
796	node, _ = Parse("(github)[0] || x", []string{"github", "x"}, nil)
797	for _, bad := range []Value{nilDict, nilArr} {
798		if _, err := Evaluate(context.Background(), node, EvalContext{Contexts: map[string]Value{"github": bad}}); err != nil {
799			t.Errorf("typed nil %T: %v", bad, err)
800		}
801	}
802}
803
804func TestEvaluateDoesNotMutateContext(t *testing.T) {
805	ctxs := testContexts(t)
806	before := map[string]string{}
807	for k, v := range ctxs {
808		before[k] = ToJSON(v)
809	}
810	for _, src := range []string{"github.event.commits.*", "github.event.commits.*.added.*", "github.*", "matrix.node.*", "toJSON(steps.*)"} {
811		node, err := Parse(src, testContextNames, nil)
812		if err != nil {
813			t.Fatal(err)
814		}
815		v, err := Evaluate(context.Background(), node, EvalContext{Contexts: ctxs})
816		if err != nil {
817			t.Fatal(err)
818		}
819		if a, ok := v.(*Array); ok {
820			a.Append(String("tampered"))
821		}
822	}
823	for k, v := range ctxs {
824		if ToJSON(v) != before[k] {
825			t.Errorf("context %s was modified", k)
826		}
827	}
828}
829
830// TestFloatCoercion checks the string form of numbers against the C# runner:
831// Double.ToString("G15", InvariantCulture) (ExpressionConstants.NumberFormat,
832// EvaluationResult.ConvertToString). Each expectation was obtained by running
833// the runner's own code (gen/runner, --g15), and numberToCoercedString agrees
834// with it on 200 000 random doubles.
835func TestFloatCoercion(t *testing.T) {
836	tests := []struct {
837		in   float64
838		want string
839	}{
840		{0, "0"},
841		{math.Copysign(0, -1), "-0"}, // as the legacy engine of the runner
842		{1, "1"},
843		{-1, "-1"},
844		{1.5, "1.5"},
845		{0.1, "0.1"},
846		{0.30000000000000004, "0.3"},
847		{1.005, "1.005"},
848		{100, "100"},
849		{123456789012345, "123456789012345"},
850		{999999999999999, "999999999999999"},
851		{1e14, "100000000000000"},
852		{1e15, "1E+15"},
853		{1e16, "1E+16"},
854		{1e20, "1E+20"},
855		{1e21, "1E+21"},
856		{123456789012345680000, "1.23456789012346E+20"},
857		{1234567890123456, "1.23456789012346E+15"},
858		{0.0001, "0.0001"},
859		{0.00001, "1E-05"},
860		{1.5e-5, "1.5E-05"},
861		{1e-6, "1E-06"},
862		{1e-7, "1E-07"},
863		{5e-16, "5E-16"},
864		{-1e-16, "-1E-16"},
865		{9.999999999999995e-5, "0.0001"},
866		{99999999999999.95, "100000000000000"},
867		{999999999999999.5, "1E+15"},
868		{123456789.123456789, "123456789.123457"},
869		{123456789012345.5, "123456789012346"},
870		{math.NaN(), "NaN"},
871		{math.Inf(1), "Infinity"},
872		{math.Inf(-1), "-Infinity"},
873		{2.5, "2.5"},
874		{-0.25, "-0.25"},
875		{1.7976931348623157e308, "1.79769313486232E+308"},
876		{5e-324, "4.94065645841247E-324"},
877	}
878	for _, tc := range tests {
879		if got := CoerceString(Number(tc.in)); got != tc.want {
880			t.Errorf("CoerceString(%v) = %q, want %q", tc.in, got, tc.want)
881		}
882	}
883}
884
885func TestJSNumberToString(t *testing.T) {
886	one, two := 0.1, 0.2
887	sum := one + two // not a constant expression: Go would fold 0.1+0.2 exactly
888	tests := []struct {
889		in   float64
890		want string
891	}{
892		{0, "0"}, {1, "1"}, {-1.5, "-1.5"}, {100, "100"}, {1e20, "100000000000000000000"}, {1e21, "1e+21"},
893		{1.5e21, "1.5e+21"}, {1e-6, "0.000001"}, {1e-7, "1e-7"}, {1.5e-7, "1.5e-7"}, {123456.789, "123456.789"},
894		{0.000123, "0.000123"}, {5e-324, "5e-324"}, {1.7976931348623157e308, "1.7976931348623157e+308"},
895		{-1e-7, "-1e-7"}, {sum, "0.30000000000000004"}, {math.NaN(), "NaN"}, {math.Inf(-1), "-Infinity"},
896	}
897	for _, tc := range tests {
898		if got := jsNumberToString(tc.in); got != tc.want {
899			t.Errorf("jsNumberToString(%v) = %q, want %q", tc.in, got, tc.want)
900		}
901	}
902}
903
904func TestJSCasing(t *testing.T) {
905	upper := map[string]string{"abc": "ABC", "ß": "SS", "ʼn": "ʼN", "dž": "DŽ", "ı": "I", "ffi": "FFI", "ὒ": "Υ̓̀", "ᾀ": "ἈΙ", "σ": "Σ", "ς": "Σ", "😀": "😀", "é": "É"}
906	for in, want := range upper {
907		if got := jsToUpper(in); got != want {
908			t.Errorf("jsToUpper(%q) = %q, want %q", in, got, want)
909		}
910	}
911	special := map[string]string{"ı": "ı", "ıi": "ıI", "I": "I", "ß": "SS", "aıb": "AıB"}
912	for in, want := range special {
913		if got := toUpperSpecial(in); got != want {
914			t.Errorf("toUpperSpecial(%q) = %q, want %q", in, got, want)
915		}
916	}
917	lower := map[string]string{
918		"ABC": "abc", "İ": "i\u0307", "ΑΣ": "ας", "ΑΣΑ": "ασα", "Α Σ": "α σ", "ΑΣ.": "ας.", "Σ": "σ", "ΑΣ'Α": "ασ'α",
919		"K": "k", "Å": "å", "😀": "😀", "DŽ": "dž", "ΑΣΣ": "ασς", "Α.Σ": "α.ς",
920	}
921	for in, want := range lower {
922		if got := jsToLower(in); got != want {
923			t.Errorf("jsToLower(%q) = %q, want %q", in, got, want)
924		}
925	}
926}
927
928func TestCompareUTF16(t *testing.T) {
929	// U+10000 is the surrogate pair D800 DC00, which sorts before U+E000.
930	if got := compareUTF16("\U00010000", "\ue000"); got >= 0 {
931		t.Errorf("compareUTF16(U+10000, U+E000) = %d, want < 0", got)
932	}
933	if got := compareUTF16("\uffff", "\U00010000"); got <= 0 {
934		t.Errorf("compareUTF16(U+FFFF, U+10000) = %d, want > 0", got)
935	}
936	for _, c := range []struct {
937		a, b string
938		want int
939	}{{"a", "a", 0}, {"a", "b", -1}, {"b", "a", 1}, {"a", "ab", -1}, {"ab", "a", 1}, {"", "", 0}, {"", "a", -1}} {
940		if got := compareUTF16(c.a, c.b); got != c.want {
941			t.Errorf("compareUTF16(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
942		}
943	}
944	// And through the operators.
945	for _, c := range evalCasesUTF16 {
946		if got := evalString(t, c.expr); got != c.want {
947			t.Errorf("%s = %s, want %s", c.expr, got, c.want)
948		}
949	}
950}
951
952var evalCasesUTF16 = []evalCase{
953	// The JavaScript reference compares UTF-16 units, where U+10000 (D800 DC00)
954	// sorts before U+E000. The runner compares code points: it sorts after.
955	{"'\U00010000' < '\ue000'", "Boolean:false"},
956	{"'\ue000' > '\U00010000'", "Boolean:false"},
957	{"'\U00010000' >= '\ue000'", "Boolean:true"},
958	{"'\ue000' < '\U00010000'", "Boolean:true"},
959}
960
961func TestTokenString(t *testing.T) {
962	toks, err := Lex("a.b == 'x''y' && 1.5")
963	if err != nil {
964		t.Fatal(err)
965	}
966	var got []string
967	for _, tk := range toks {
968		got = append(got, tk.Type.String())
969	}
970	want := "IDENTIFIER DOT IDENTIFIER EQUAL_EQUAL STRING AND NUMBER EOF"
971	if strings.Join(got, " ") != want {
972		t.Errorf("tokens = %v, want %s", got, want)
973	}
974	if toks[4].Value.(String) != "x'y" || toks[4].Lexeme != "'x''y'" {
975		t.Errorf("string token = %#v", toks[4])
976	}
977	if TokenType(99).String() != "TokenType(99)" {
978		t.Error("unknown token type")
979	}
980}
981
982// The EOF token of the reference is positioned from a stale token start, so
983// after a trailing newline its column is negative. Kept as is.
984func TestEOFTokenPosition(t *testing.T) {
985	toks, err := Lex("a\n")
986	if err != nil {
987		t.Fatal(err)
988	}
989	eof := toks[len(toks)-1]
990	if eof.Type != TokenEOF || eof.Range.Start != (Position{Line: 1, Column: -1}) || eof.Range.End != (Position{Line: 1, Column: 0}) {
991		t.Errorf("EOF token = %+v", eof)
992	}
993}

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