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}