detached 3b23706

fix(expr): evaluate built-in arguments lazily and parse format strings like the runner

commit
3b23706640897154fba558d888167abc634722be
author
brice · · 2026-10-12 18:05 UTC
parent
900025e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +332 -122
M expr/PORTING.md +38 -11
@@ -72,17 +72,30 @@ whether to keep the JS behaviour or follow the runner.
7272 a bug of the editor library. Tests: `TestEvalLiteralsAndOperators`; the
7373 oracle excuses the JS mismatch because `testdata/runner.json.gz` agrees
7474 with this package.
75-- [ ] **`case()` availability.** The JS gates it behind the experimental flag
76- `allowCaseFunction`, but only in completion; the parser and the
77- evaluator always accept it. Here it is always on. Confirm whether the
78- hosted service accepts `case` and with which arity rules (the JS: at
79- least 3 arguments, an odd number, predicates must be booleans).
80-- [ ] **`format()`**: the reference uses `parseInt` on the digits, so
81- `{99999999999999999999}` is an Infinity-like index and reports "more
82- arguments than were supplied". The `{0:x}` form silently ignores the
83- specifier; C# may throw. The error texts are the JS ones. Check the C#
84- `Format` node for the exact messages and for `{N}` with `N` > 255 or
85- leading zeros.
75+- [x] **`case()` availability.** Resolved: the runner has `case` always
76+ (`ExpressionConstants`: `AddFunction<Case>("case", 3, Byte.MaxValue)`),
77+ with an odd number of arguments enforced at parse time
78+ (`ExpressionParser.FlushTopEndParameters`: `EvenParameters`, reported on
79+ the `(` token) and at evaluation (`Sdk/Functions/Case.cs`), predicates
80+ must be booleans ("case predicate must evaluate to a boolean value"), and
81+ only the matching branch is evaluated. `ExpressionParserL0` has a
82+ regression test that `case(...)` must not turn on "allow unknown
83+ keywords" (ported in `runner_l0_test.go`). Here it is always on, with
84+ the maximum of 255 arguments (the JS has no maximum).
85+- [x] **`format()`**: resolved, follows the C#. `Sdk/Functions/Format.cs`: the
86+ format string is scanned first (syntax and "more arguments than were
87+ supplied" are reported before any argument is evaluated), arguments are
88+ then evaluated lazily, once each. The index is a **byte**
89+ (`Byte.TryParse`): `{255}` is the last valid index, `{256}` and
90+ `{12345678901234567890123}` are "format string is invalid", not "more
91+ arguments". After the digits only `}` or `:specifiers}` are allowed
92+ (`{0 }`, `{0,5}` are invalid); **specifiers are an error** for every
93+ value: `The format specifiers 'x' are not valid for objects of type
94+ 'Number'` (kind names `Null Boolean Number String Array Object`).
95+ Messages are the same as the JS ones otherwise (`ExpressionResources.resx`:
96+ `InvalidFormatString`, `InvalidFormatArgIndex`, `InvalidFormatSpecifiers`).
97+ The runner has the same 255-argument cap as the JS (`Byte.MaxValue`,
98+ `ExpressionConstants`).
8699 - [x] **`contains()`, `startsWith`/`endsWith`, `==`, `<`, `>` and key lookup use
87100 `OrdinalIgnoreCase`.** Resolved: follows the C#. Every string comparison
88101 in the runner is `StringComparison.OrdinalIgnoreCase`
@@ -177,6 +190,20 @@ whether to keep the JS behaviour or follow the runner.
177190 `JsonConvert.SerializeObject`; the real one passes the runner's
178191 `VssJsonMediaTypeFormatter` settings, which do not change string
179192 escaping.
193+- [x] **Lazy evaluation of built-in function arguments and of index
194+ expressions**: found by the differential run, not in the original list.
195+ In the runner a function evaluates only the arguments it uses
196+ (`Parameters[i].Evaluate(context)` inside `EvaluateCore`): `case` stops
197+ at the first true predicate, `contains`/`startsWith`/`endsWith` do not
198+ evaluate the second argument when the first is not primitive (or, for
199+ `contains`, not a non-empty array), `join` evaluates the separator only
200+ with two or more items, `format` evaluates only referenced arguments. And
201+ `a[b]` (`Index.cs`) evaluates `b` only if `a` is an array or a
202+ dictionary, and does not wrap its errors (the JS evaluates `b` first and
203+ wraps them in "could not evaluate index for index access"). So
204+ `join(success(), format('{2}'))` is `true` in the runner and an error in
205+ the JS. Built-ins now receive the call and evaluate what they need;
206+ extension functions still get evaluated arguments.
180207 - [ ] **Index access**: `a[b]` where `b` is a number uses `floor(b)` and
181208 rejects NaN, negatives and infinities; where `b` is a string it is a key
182209 or a decimal index (`'1'`, `' 1 '`, `'1.9'` all index 1 of an array).
@@ -72,17 +72,30 @@ whether to keep the JS behaviour or follow the runner.
72 a bug of the editor library. Tests: `TestEvalLiteralsAndOperators`; the72 a bug of the editor library. Tests: `TestEvalLiteralsAndOperators`; the
73 oracle excuses the JS mismatch because `testdata/runner.json.gz` agrees73 oracle excuses the JS mismatch because `testdata/runner.json.gz` agrees
74 with this package.74 with this package.
75-- [ ] **`case()` availability.** The JS gates it behind the experimental flag75+- [x] **`case()` availability.** Resolved: the runner has `case` always
76- `allowCaseFunction`, but only in completion; the parser and the76+ (`ExpressionConstants`: `AddFunction<Case>("case", 3, Byte.MaxValue)`),
77- evaluator always accept it. Here it is always on. Confirm whether the77+ with an odd number of arguments enforced at parse time
78- hosted service accepts `case` and with which arity rules (the JS: at78+ (`ExpressionParser.FlushTopEndParameters`: `EvenParameters`, reported on
79- least 3 arguments, an odd number, predicates must be booleans).79+ the `(` token) and at evaluation (`Sdk/Functions/Case.cs`), predicates
80-- [ ] **`format()`**: the reference uses `parseInt` on the digits, so80+ must be booleans ("case predicate must evaluate to a boolean value"), and
81- `{99999999999999999999}` is an Infinity-like index and reports "more81+ only the matching branch is evaluated. `ExpressionParserL0` has a
82- arguments than were supplied". The `{0:x}` form silently ignores the82+ regression test that `case(...)` must not turn on "allow unknown
83- specifier; C# may throw. The error texts are the JS ones. Check the C#83+ keywords" (ported in `runner_l0_test.go`). Here it is always on, with
84- `Format` node for the exact messages and for `{N}` with `N` > 255 or84+ the maximum of 255 arguments (the JS has no maximum).
85- leading zeros.85+- [x] **`format()`**: resolved, follows the C#. `Sdk/Functions/Format.cs`: the
86+ format string is scanned first (syntax and "more arguments than were
87+ supplied" are reported before any argument is evaluated), arguments are
88+ then evaluated lazily, once each. The index is a **byte**
89+ (`Byte.TryParse`): `{255}` is the last valid index, `{256}` and
90+ `{12345678901234567890123}` are "format string is invalid", not "more
91+ arguments". After the digits only `}` or `:specifiers}` are allowed
92+ (`{0 }`, `{0,5}` are invalid); **specifiers are an error** for every
93+ value: `The format specifiers 'x' are not valid for objects of type
94+ 'Number'` (kind names `Null Boolean Number String Array Object`).
95+ Messages are the same as the JS ones otherwise (`ExpressionResources.resx`:
96+ `InvalidFormatString`, `InvalidFormatArgIndex`, `InvalidFormatSpecifiers`).
97+ The runner has the same 255-argument cap as the JS (`Byte.MaxValue`,
98+ `ExpressionConstants`).
86 - [x] **`contains()`, `startsWith`/`endsWith`, `==`, `<`, `>` and key lookup use99 - [x] **`contains()`, `startsWith`/`endsWith`, `==`, `<`, `>` and key lookup use
87 `OrdinalIgnoreCase`.** Resolved: follows the C#. Every string comparison100 `OrdinalIgnoreCase`.** Resolved: follows the C#. Every string comparison
88 in the runner is `StringComparison.OrdinalIgnoreCase`101 in the runner is `StringComparison.OrdinalIgnoreCase`
@@ -177,6 +190,20 @@ whether to keep the JS behaviour or follow the runner.
177 `JsonConvert.SerializeObject`; the real one passes the runner's190 `JsonConvert.SerializeObject`; the real one passes the runner's
178 `VssJsonMediaTypeFormatter` settings, which do not change string191 `VssJsonMediaTypeFormatter` settings, which do not change string
179 escaping.192 escaping.
193+- [x] **Lazy evaluation of built-in function arguments and of index
194+ expressions**: found by the differential run, not in the original list.
195+ In the runner a function evaluates only the arguments it uses
196+ (`Parameters[i].Evaluate(context)` inside `EvaluateCore`): `case` stops
197+ at the first true predicate, `contains`/`startsWith`/`endsWith` do not
198+ evaluate the second argument when the first is not primitive (or, for
199+ `contains`, not a non-empty array), `join` evaluates the separator only
200+ with two or more items, `format` evaluates only referenced arguments. And
201+ `a[b]` (`Index.cs`) evaluates `b` only if `a` is an array or a
202+ dictionary, and does not wrap its errors (the JS evaluates `b` first and
203+ wraps them in "could not evaluate index for index access"). So
204+ `join(success(), format('{2}'))` is `true` in the runner and an error in
205+ the JS. Built-ins now receive the call and evaluate what they need;
206+ extension functions still get evaluated arguments.
180 - [ ] **Index access**: `a[b]` where `b` is a number uses `floor(b)` and207 - [ ] **Index access**: `a[b]` where `b` is a number uses `floor(b)` and
181 rejects NaN, negatives and infinities; where `b` is a string it is a key208 rejects NaN, negatives and infinities; where `b` is a string it is a key
182 or a decimal index (`'1'`, `' 1 '`, `'1.9'` all index 1 of an array).209 or a decimal index (`'1'`, `' 1 '`, `'1.9'` all index 1 of an array).
M expr/eval.go +30 -13
@@ -249,6 +249,10 @@ func (e *evaluator) call(c *FunctionCall) (Value, error) {
249249 }
250250 impl = builtinImpls[name]
251251 }
252+ if !isExt {
253+ // Built-ins evaluate their own arguments, lazily.
254+ return impl(e, c)
255+ }
252256 args := make([]Value, len(c.Args))
253257 for i, a := range c.Args {
254258 v, err := e.eval(a)
@@ -257,7 +261,7 @@ func (e *evaluator) call(c *FunctionCall) (Value, error) {
257261 }
258262 args[i] = v
259263 }
260- if isExt {
264+ {
261265 v, err := ext(e.ctx, args)
262266 if err != nil {
263267 var ee *EvalError
@@ -268,7 +272,6 @@ func (e *evaluator) call(c *FunctionCall) (Value, error) {
268272 }
269273 return v, nil
270274 }
271- return impl(e, args)
272275 }
273276
274277 // indexer is idxHelper of the reference: how an index value is read, as a
@@ -309,36 +312,50 @@ func (ix indexer) pos(n int) (int, bool) {
309312 return int(ix.intv), true
310313 }
311314
315+// indexAccess evaluates a[b] as Index.cs does: the object first, then, only if
316+// it is an array or a dictionary, the index, whose errors are reported as they
317+// are (the JavaScript reference evaluates the index first and wraps its errors
318+// in "could not evaluate index for index access").
312319 func (e *evaluator) indexAccess(ia *IndexAccess) (Value, error) {
313- var ix indexer
314- if _, isStar := ia.Index.(*Star); isStar {
315- ix = newIndexer(true, nil)
316- } else {
317- v, err := e.eval(ia.Index)
318- if err != nil {
319- return nil, &EvalError{Message: "could not evaluate index for index access: " + err.Error(), Err: err}
320- }
321- ix = newIndexer(false, v)
322- }
320+ _, isStar := ia.Index.(*Star)
323321 obj, err := e.eval(ia.Expr)
324322 if err != nil {
325323 return nil, err
326324 }
327325 switch o := normalize(obj).(type) {
328326 case *Array:
327+ ix, err := e.indexer(ia, isStar)
328+ if err != nil {
329+ return nil, err
330+ }
329331 if o.filtered {
330332 return e.filteredArrayAccess(o, ix)
331333 }
332334 return e.arrayAccess(o, ix)
333335 case *Dictionary:
336+ ix, err := e.indexer(ia, isStar)
337+ if err != nil {
338+ return nil, err
339+ }
334340 return e.objectAccess(o, ix)
335341 }
336- if ix.star {
342+ if isStar {
337343 return &Array{filtered: true}, nil
338344 }
339345 return Null{}, nil
340346 }
341347
348+func (e *evaluator) indexer(ia *IndexAccess, isStar bool) (indexer, error) {
349+ if isStar {
350+ return newIndexer(true, nil), nil
351+ }
352+ v, err := e.eval(ia.Index)
353+ if err != nil {
354+ return indexer{}, err
355+ }
356+ return newIndexer(false, v), nil
357+}
358+
342359 // filteredArrayAccess applies an index to each element of a filtered array
343360 // and collects the hits: "a.*.b" is the b of every element that has one.
344361 func (e *evaluator) filteredArrayAccess(fa *Array, ix indexer) (Value, error) {
@@ -249,6 +249,10 @@ func (e *evaluator) call(c *FunctionCall) (Value, error) {
249 }249 }
250 impl = builtinImpls[name]250 impl = builtinImpls[name]
251 }251 }
252+ if !isExt {
253+ // Built-ins evaluate their own arguments, lazily.
254+ return impl(e, c)
255+ }
252 args := make([]Value, len(c.Args))256 args := make([]Value, len(c.Args))
253 for i, a := range c.Args {257 for i, a := range c.Args {
254 v, err := e.eval(a)258 v, err := e.eval(a)
@@ -257,7 +261,7 @@ func (e *evaluator) call(c *FunctionCall) (Value, error) {
257 }261 }
258 args[i] = v262 args[i] = v
259 }263 }
260- if isExt {264+ {
261 v, err := ext(e.ctx, args)265 v, err := ext(e.ctx, args)
262 if err != nil {266 if err != nil {
263 var ee *EvalError267 var ee *EvalError
@@ -268,7 +272,6 @@ func (e *evaluator) call(c *FunctionCall) (Value, error) {
268 }272 }
269 return v, nil273 return v, nil
270 }274 }
271- return impl(e, args)
272 }275 }
273 276
274 // indexer is idxHelper of the reference: how an index value is read, as a277 // indexer is idxHelper of the reference: how an index value is read, as a
@@ -309,36 +312,50 @@ func (ix indexer) pos(n int) (int, bool) {
309 return int(ix.intv), true312 return int(ix.intv), true
310 }313 }
311 314
315+// indexAccess evaluates a[b] as Index.cs does: the object first, then, only if
316+// it is an array or a dictionary, the index, whose errors are reported as they
317+// are (the JavaScript reference evaluates the index first and wraps its errors
318+// in "could not evaluate index for index access").
312 func (e *evaluator) indexAccess(ia *IndexAccess) (Value, error) {319 func (e *evaluator) indexAccess(ia *IndexAccess) (Value, error) {
313- var ix indexer320+ _, isStar := ia.Index.(*Star)
314- if _, isStar := ia.Index.(*Star); isStar {
315- ix = newIndexer(true, nil)
316- } else {
317- v, err := e.eval(ia.Index)
318- if err != nil {
319- return nil, &EvalError{Message: "could not evaluate index for index access: " + err.Error(), Err: err}
320- }
321- ix = newIndexer(false, v)
322- }
323 obj, err := e.eval(ia.Expr)321 obj, err := e.eval(ia.Expr)
324 if err != nil {322 if err != nil {
325 return nil, err323 return nil, err
326 }324 }
327 switch o := normalize(obj).(type) {325 switch o := normalize(obj).(type) {
328 case *Array:326 case *Array:
327+ ix, err := e.indexer(ia, isStar)
328+ if err != nil {
329+ return nil, err
330+ }
329 if o.filtered {331 if o.filtered {
330 return e.filteredArrayAccess(o, ix)332 return e.filteredArrayAccess(o, ix)
331 }333 }
332 return e.arrayAccess(o, ix)334 return e.arrayAccess(o, ix)
333 case *Dictionary:335 case *Dictionary:
336+ ix, err := e.indexer(ia, isStar)
337+ if err != nil {
338+ return nil, err
339+ }
334 return e.objectAccess(o, ix)340 return e.objectAccess(o, ix)
335 }341 }
336- if ix.star {342+ if isStar {
337 return &Array{filtered: true}, nil343 return &Array{filtered: true}, nil
338 }344 }
339 return Null{}, nil345 return Null{}, nil
340 }346 }
341 347
348+func (e *evaluator) indexer(ia *IndexAccess, isStar bool) (indexer, error) {
349+ if isStar {
350+ return newIndexer(true, nil), nil
351+ }
352+ v, err := e.eval(ia.Index)
353+ if err != nil {
354+ return indexer{}, err
355+ }
356+ return newIndexer(false, v), nil
357+}
358+
342 // filteredArrayAccess applies an index to each element of a filtered array359 // filteredArrayAccess applies an index to each element of a filtered array
343 // and collects the hits: "a.*.b" is the b of every element that has one.360 // and collects the hits: "a.*.b" is the b of every element that has one.
344 func (e *evaluator) filteredArrayAccess(fa *Array, ix indexer) (Value, error) {361 func (e *evaluator) filteredArrayAccess(fa *Array, ix indexer) (Value, error) {
M expr/eval_test.go +14 -3
@@ -458,8 +458,18 @@ func TestEvalBuiltinFunctions(t *testing.T) {
458458 {"format('{{0}}', 1)", "String:{0}"},
459459 {"format('{{{0}}}', 1)", "String:{1}"},
460460 {"format('{0}', 'a', 'b')", "String:a"}, // extra arguments are fine
461- {"format('{0:x}', 1)", "String:1"}, // a format specifier is skipped
462- {"format('{0 anything}', 1)", "String:1"},
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"},
463473 {"format('{00}', 1)", "String:1"},
464474 {"format('{01}', 1, 2)", "String:2"},
465475 {"format('{0}', null)", "String:"},
@@ -481,7 +491,8 @@ func TestEvalBuiltinFunctions(t *testing.T) {
481491 {"format('0}', 1)", "ERR The following format string is invalid: 0}"},
482492 {"format('{{0}', 1)", "ERR The following format string is invalid: {{0}"},
483493 {"format('{0}}', 1)", "ERR The following format string is invalid: {0}}"},
484- {"format('{12345678901234567890123}', 1)", "ERR The following format string references more arguments than were supplied: {12345678901234567890123}"},
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}"},
485496 {"format()", "PARSE Too few parameters supplied: 'format'"},
486497
487498 // case
@@ -458,8 +458,18 @@ func TestEvalBuiltinFunctions(t *testing.T) {
458 {"format('{{0}}', 1)", "String:{0}"},458 {"format('{{0}}', 1)", "String:{0}"},
459 {"format('{{{0}}}', 1)", "String:{1}"},459 {"format('{{{0}}}', 1)", "String:{1}"},
460 {"format('{0}', 'a', 'b')", "String:a"}, // extra arguments are fine460 {"format('{0}', 'a', 'b')", "String:a"}, // extra arguments are fine
461- {"format('{0:x}', 1)", "String:1"}, // a format specifier is skipped461+ // The runner rejects format specifiers (Format.cs) and anything but
462- {"format('{0 anything}', 1)", "String:1"},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"},
463 {"format('{00}', 1)", "String:1"},473 {"format('{00}', 1)", "String:1"},
464 {"format('{01}', 1, 2)", "String:2"},474 {"format('{01}', 1, 2)", "String:2"},
465 {"format('{0}', null)", "String:"},475 {"format('{0}', null)", "String:"},
@@ -481,7 +491,8 @@ func TestEvalBuiltinFunctions(t *testing.T) {
481 {"format('0}', 1)", "ERR The following format string is invalid: 0}"},491 {"format('0}', 1)", "ERR The following format string is invalid: 0}"},
482 {"format('{{0}', 1)", "ERR The following format string is invalid: {{0}"},492 {"format('{{0}', 1)", "ERR The following format string is invalid: {{0}"},
483 {"format('{0}}', 1)", "ERR The following format string is invalid: {0}}"},493 {"format('{0}}', 1)", "ERR The following format string is invalid: {0}}"},
484- {"format('{12345678901234567890123}', 1)", "ERR The following format string references more arguments than were supplied: {12345678901234567890123}"},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}"},
485 {"format()", "PARSE Too few parameters supplied: 'format'"},496 {"format()", "PARSE Too few parameters supplied: 'format'"},
486 497
487 // case498 // case
M expr/funcs.go +250 -95
@@ -4,58 +4,88 @@ package expr
44
55 import (
66 "errors"
7- "math"
87 "strconv"
98 "strings"
9+ "unicode"
1010 )
1111
1212 // builtinFunc implements a built-in function. It runs inside the evaluator so
13-// that it can charge the output budget and watch the context.
14-type builtinFunc func(e *evaluator, args []Value) (Value, error)
13+// that it can charge the output budget and watch the context, and it receives
14+// the call, not the evaluated arguments: like the runner's functions
15+// (Sdk/Functions/*.cs) it evaluates only the arguments it needs, in its own
16+// order, so an argument that is never reached cannot fail.
17+type builtinFunc func(e *evaluator, c *FunctionCall) (Value, error)
1518
1619 // builtinImpls are the implementations of the built-in functions, keyed by
17-// lower-cased name. Arity is validated by the parser, so each may assume its
18-// documented argument count.
19-var builtinImpls = map[string]builtinFunc{
20- "CASE": funcCase,
21- "CONTAINS": funcContains,
22- "ENDSWITH": funcEndsWith,
23- "FORMAT": funcFormat,
24- "FROMJSON": funcFromJSON,
25- "JOIN": funcJoin,
26- "STARTSWITH": funcStartsWith,
27- "TOJSON": funcToJSON,
20+// upper-cased name (see foldKey). Arity is validated by the parser, so each
21+// may assume its documented argument count.
22+var builtinImpls map[string]builtinFunc
23+
24+// The map is filled in init: the implementations call back into the evaluator,
25+// which would make a package-level initializer cyclic.
26+func init() {
27+ builtinImpls = map[string]builtinFunc{
28+ "CASE": funcCase,
29+ "CONTAINS": funcContains,
30+ "ENDSWITH": funcEndsWith,
31+ "FORMAT": funcFormat,
32+ "FROMJSON": funcFromJSON,
33+ "JOIN": funcJoin,
34+ "STARTSWITH": funcStartsWith,
35+ "TOJSON": funcToJSON,
36+ }
2837 }
2938
30-// funcCase is case(pred1, val1, pred2, val2, ..., default).
31-//
32-// The JavaScript reference ships it behind an experimental feature flag
33-// (allowCaseFunction) that only affects editor completion; the parser always
34-// accepts it, and so does this package.
35-func funcCase(e *evaluator, args []Value) (Value, error) {
36- for i := 0; i < len(args)-1; i += 2 {
37- pred, ok := normalize(args[i]).(Boolean)
39+// funcCase is case(pred1, val1, pred2, val2, ..., default): the predicates are
40+// evaluated in order up to the first true one, then only its value (Case.cs).
41+// A predicate that is not a boolean is an error. The JavaScript reference
42+// ships case behind an experimental feature flag that only affects editor
43+// completion; the parser always accepts it, and so does the runner.
44+func funcCase(e *evaluator, c *FunctionCall) (Value, error) {
45+ n := len(c.Args)
46+ if n%2 == 0 {
47+ return nil, evalErrorf("case requires an odd number of arguments")
48+ }
49+ for i := 0; i < n-1; i += 2 {
50+ p, err := e.eval(c.Args[i])
51+ if err != nil {
52+ return nil, err
53+ }
54+ pred, ok := normalize(p).(Boolean)
3855 if !ok {
3956 return nil, evalErrorf("case predicate must evaluate to a boolean value")
4057 }
4158 if pred {
42- return args[i+1], nil
59+ return e.eval(c.Args[i+1])
4360 }
4461 }
45- return args[len(args)-1], nil
62+ return e.eval(c.Args[n-1])
4663 }
4764
48-// funcContains is contains(search, item): substring test, case-insensitive
49-// (OrdinalIgnoreCase), when both are primitive; membership by == in an array;
50-// false otherwise.
51-func funcContains(e *evaluator, args []Value) (Value, error) {
52- left, right := normalize(args[0]), normalize(args[1])
65+// funcContains is contains(search, item) (Contains.cs): when search is a
66+// primitive and so is item, a case-insensitive (OrdinalIgnoreCase) substring
67+// test on their string forms; when search is a non-empty array, == against each
68+// element; false otherwise. item is only evaluated when it can matter.
69+func funcContains(e *evaluator, c *FunctionCall) (Value, error) {
70+ l, err := e.eval(c.Args[0])
71+ if err != nil {
72+ return nil, err
73+ }
74+ left := normalize(l)
5375 switch {
5476 case isPrimitive(left):
55- if isPrimitive(right) {
77+ r, err := e.eval(c.Args[1])
78+ if err != nil {
79+ return nil, err
80+ }
81+ if right := normalize(r); isPrimitive(right) {
5682 return Boolean(jsIncludes(ordinalFold(CoerceString(left)), ordinalFold(CoerceString(right)))), nil
5783 }
58- case left.Kind() == KindArray:
84+ case left.Kind() == KindArray && len(left.(*Array).items) > 0:
85+ right, err := e.eval(c.Args[1])
86+ if err != nil {
87+ return nil, err
88+ }
5989 for _, v := range left.(*Array).items {
6090 if err := e.tick(); err != nil {
6191 return nil, err
@@ -68,40 +98,57 @@ func funcContains(e *evaluator, args []Value) (Value, error) {
6898 return Boolean(false), nil
6999 }
70100
71-func funcStartsWith(e *evaluator, args []Value) (Value, error) {
72- ls, rs, ok := primitivePair(args)
73- return Boolean(ok && jsHasPrefix(ordinalFold(ls), ordinalFold(rs))), nil
101+func funcStartsWith(e *evaluator, c *FunctionCall) (Value, error) {
102+ ls, rs, ok, err := e.primitivePair(c)
103+ return Boolean(ok && jsHasPrefix(ordinalFold(ls), ordinalFold(rs))), err
74104 }
75105
76-func funcEndsWith(e *evaluator, args []Value) (Value, error) {
77- ls, rs, ok := primitivePair(args)
78- return Boolean(ok && jsHasSuffix(ordinalFold(ls), ordinalFold(rs))), nil
106+func funcEndsWith(e *evaluator, c *FunctionCall) (Value, error) {
107+ ls, rs, ok, err := e.primitivePair(c)
108+ return Boolean(ok && jsHasSuffix(ordinalFold(ls), ordinalFold(rs))), err
79109 }
80110
81-// primitivePair returns the string forms of the two arguments, and false
82-// when either is an array or a dictionary.
83-func primitivePair(args []Value) (string, string, bool) {
84- if !isPrimitive(args[0]) || !isPrimitive(args[1]) {
85- return "", "", false
111+// primitivePair evaluates the two arguments, the second only if the first is
112+// primitive, and returns their string forms; ok is false when either is an
113+// array or a dictionary.
114+func (e *evaluator) primitivePair(c *FunctionCall) (ls, rs string, ok bool, err error) {
115+ l, err := e.eval(c.Args[0])
116+ if err != nil || !isPrimitive(l) {
117+ return "", "", false, err
86118 }
87- return CoerceString(args[0]), CoerceString(args[1]), true
119+ r, err := e.eval(c.Args[1])
120+ if err != nil || !isPrimitive(r) {
121+ return "", "", false, err
122+ }
123+ return CoerceString(l), CoerceString(r), true, nil
88124 }
89125
90-// funcJoin is join(array, separator). A primitive first argument is just
91-// converted to a string, ignoring the separator; a dictionary gives "". A
92-// separator that is not primitive means ",".
93-func funcJoin(e *evaluator, args []Value) (Value, error) {
94- first := normalize(args[0])
126+// funcJoin is join(array, separator) (Join.cs). A primitive first argument is
127+// just converted to a string, ignoring the separator; a dictionary or an empty
128+// array gives "". The separator is evaluated only when there are at least two
129+// items to separate, and a separator that is not primitive means ",".
130+func funcJoin(e *evaluator, c *FunctionCall) (Value, error) {
131+ f, err := e.eval(c.Args[0])
132+ if err != nil {
133+ return nil, err
134+ }
135+ first := normalize(f)
95136 if isPrimitive(first) {
96137 return String(CoerceString(first)), nil
97138 }
98139 arr, ok := first.(*Array)
99- if !ok {
140+ if !ok || len(arr.items) == 0 {
100141 return String(""), nil
101142 }
102143 sep := ","
103- if len(args) > 1 && isPrimitive(args[1]) {
104- sep = CoerceString(args[1])
144+ if len(arr.items) > 1 && len(c.Args) > 1 {
145+ sv, err := e.eval(c.Args[1])
146+ if err != nil {
147+ return nil, err
148+ }
149+ if isPrimitive(sv) {
150+ sep = CoerceString(sv)
151+ }
105152 }
106153 var out strings.Builder
107154 for i, it := range arr.items {
@@ -121,17 +168,21 @@ func funcJoin(e *evaluator, args []Value) (Value, error) {
121168 }
122169 out.WriteString(part)
123170 }
124- return String(normalizeWTF8(out.String())), nil
171+ return String(out.String()), nil
125172 }
126173
127174 // funcToJSON is toJSON(value). The output counts against the budget and is
128175 // cut off as soon as it exceeds what is left.
129-func funcToJSON(e *evaluator, args []Value) (Value, error) {
176+func funcToJSON(e *evaluator, c *FunctionCall) (Value, error) {
177+ v, err := e.eval(c.Args[0])
178+ if err != nil {
179+ return nil, err
180+ }
130181 max := -1
131182 if e.limit >= 0 {
132183 max = e.limit - e.used
133184 }
134- out, err := EncodeJSON(args[0], max)
185+ out, err := EncodeJSON(v, max)
135186 switch {
136187 case errors.Is(err, errJSONTooBig):
137188 return nil, e.budgetError()
@@ -145,11 +196,15 @@ func funcToJSON(e *evaluator, args []Value) (Value, error) {
145196 }
146197
147198 // funcFromJSON is fromJSON(value). Blank input is "empty input"; anything
148-// that is not valid JSON is "Error parsing JSON when evaluating fromJson"
149-// wrapping the syntax error.
150-func funcFromJSON(e *evaluator, args []Value) (Value, error) {
151- in := CoerceString(args[0])
152- if jsTrim(in) == "" {
199+// that is not readable by Json.NET is "Error parsing JSON when evaluating
200+// fromJson" wrapping the reader's error.
201+func funcFromJSON(e *evaluator, c *FunctionCall) (Value, error) {
202+ a, err := e.eval(c.Args[0])
203+ if err != nil {
204+ return nil, err
205+ }
206+ in := CoerceString(a)
207+ if strings.TrimFunc(in, isNetSpace) == "" {
153208 return nil, evalErrorf("empty input")
154209 }
155210 v, err := FromJSON([]byte(in))
@@ -159,13 +214,39 @@ func funcFromJSON(e *evaluator, args []Value) (Value, error) {
159214 return v, nil
160215 }
161216
162-// funcFormat is format(fmt, args...): "{N}" inserts argument N, "{{" and "}}"
163-// are literal braces. Anything between the digits of "{N" and the closing
164-// brace is ignored ("{0:x}" is "{0}"). Every other use of a brace is an
165-// error, as is a reference to a missing argument.
166-func funcFormat(e *evaluator, args []Value) (Value, error) {
167- fs := CoerceString(args[0])
168- var out strings.Builder
217+// formatPiece is a segment of the result of format(): literal text, or the
218+// index of an argument (evaluated when the result is assembled) with the
219+// format specifiers written after the colon.
220+type formatPiece struct {
221+ text string
222+ arg int
223+ specs string
224+ isArg bool
225+}
226+
227+// funcFormat is format(fmt, args...) (Format.cs): "{N}" inserts argument N,
228+// "{{" and "}}" are literal braces. The format string is scanned completely
229+// first (so a syntax error or a reference to a missing argument is reported
230+// before any argument is evaluated), then the arguments are evaluated, once
231+// each, in the order they appear. N is at most 255 (a byte). Format specifiers
232+// ("{0:x}") are not supported for any value: they are an error.
233+func funcFormat(e *evaluator, c *FunctionCall) (Value, error) {
234+ f, err := e.eval(c.Args[0])
235+ if err != nil {
236+ return nil, err
237+ }
238+ fs := CoerceString(f)
239+ var pieces []formatPiece
240+ text := func(t string) error {
241+ if t == "" {
242+ return nil
243+ }
244+ if err := e.charge(utf16Len(t)); err != nil {
245+ return err
246+ }
247+ pieces = append(pieces, formatPiece{text: t})
248+ return nil
249+ }
169250 index := 0
170251 for index < len(fs) {
171252 lbrace := indexByteFrom(fs, '{', index)
@@ -174,27 +255,26 @@ func funcFormat(e *evaluator, args []Value) (Value, error) {
174255 case lbrace >= 0 && (rbrace < 0 || rbrace > lbrace):
175256 // Escaped left brace.
176257 if charAt(fs, lbrace+1) == '{' {
177- if err := e.charge(lbrace + 1 - index); err != nil {
258+ if err := text(fs[index : lbrace+1]); err != nil {
178259 return nil, err
179260 }
180- out.WriteString(fs[index : lbrace+1])
181261 index = lbrace + 2
182262 continue
183263 }
184264 // Left brace, number, optional format specifiers, right brace.
185265 if rbrace > lbrace+1 {
186- if argIndex, ok := readArgIndex(fs, lbrace+1); ok {
187- if 1+argIndex > float64(len(args)-1) {
188- return nil, evalErrorf("The following format string references more arguments than were supplied: %s", fs)
266+ if argIndex, end, ok := readArgIndex(fs, lbrace+1); ok {
267+ if specs, closing, ok := readFormatSpecifiers(fs, end+1); ok {
268+ if argIndex > len(c.Args)-2 {
269+ return nil, evalErrorf("The following format string references more arguments than were supplied: %s", fs)
270+ }
271+ if err := text(fs[index:lbrace]); err != nil {
272+ return nil, err
273+ }
274+ pieces = append(pieces, formatPiece{arg: argIndex, specs: specs, isArg: true})
275+ index = closing + 1
276+ continue
189277 }
190- arg := CoerceString(args[1+int(argIndex)])
191- if err := e.charge(lbrace - index + len(arg)); err != nil {
192- return nil, err
193- }
194- out.WriteString(fs[index:lbrace])
195- out.WriteString(arg)
196- index = rbrace + 1
197- continue
198278 }
199279 }
200280 return nil, evalErrorf("The following format string is invalid: %s", fs)
@@ -203,20 +283,50 @@ func funcFormat(e *evaluator, args []Value) (Value, error) {
203283 if charAt(fs, rbrace+1) != '}' {
204284 return nil, evalErrorf("The following format string is invalid: %s", fs)
205285 }
206- if err := e.charge(rbrace + 1 - index); err != nil {
286+ if err := text(fs[index : rbrace+1]); err != nil {
207287 return nil, err
208288 }
209- out.WriteString(fs[index : rbrace+1])
210289 index = rbrace + 2
211290 default:
212- if err := e.charge(len(fs) - index); err != nil {
291+ if err := text(fs[index:]); err != nil {
213292 return nil, err
214293 }
215- out.WriteString(fs[index:])
216294 index = len(fs)
217295 }
218296 }
219- return String(normalizeWTF8(out.String())), nil
297+ // Assemble: arguments are evaluated lazily, once each, in the order of
298+ // their first appearance.
299+ type argValue struct {
300+ str string
301+ kind Kind
302+ done bool
303+ }
304+ cache := make([]argValue, len(c.Args)-1)
305+ var out strings.Builder
306+ for _, p := range pieces {
307+ if !p.isArg {
308+ out.WriteString(p.text)
309+ continue
310+ }
311+ av := &cache[p.arg]
312+ if !av.done {
313+ v, err := e.eval(c.Args[1+p.arg])
314+ if err != nil {
315+ return nil, err
316+ }
317+ *av = argValue{str: CoerceString(v), kind: normalize(v).Kind(), done: true}
318+ }
319+ if p.specs != "" {
320+ return nil, evalErrorf("The format specifiers '%s' are not valid for objects of type '%s'", p.specs, av.kind)
321+ }
322+ if av.str != "" {
323+ if err := e.charge(utf16Len(av.str)); err != nil {
324+ return nil, err
325+ }
326+ }
327+ out.WriteString(av.str)
328+ }
329+ return String(out.String()), nil
220330 }
221331
222332 func indexByteFrom(s string, c byte, from int) int {
@@ -238,20 +348,65 @@ func charAt(s string, i int) byte {
238348 return 0
239349 }
240350
241-// readArgIndex reads the decimal digits at s[start:]. The index is a float
242-// because parseInt in the reference yields Infinity for absurdly long digit
243-// runs, which then fails the argument-count check.
244-func readArgIndex(s string, start int) (float64, bool) {
351+// readArgIndex reads the digits at s[start:] as the index of a format
352+// argument: a byte, so at most 255 (Byte.TryParse). It returns the index of the
353+// last digit. Digits are Unicode decimal digits for the scan but only ASCII
354+// ones parse, so any other digit makes the format string invalid.
355+func readArgIndex(s string, start int) (value, end int, ok bool) {
245356 n := 0
246- for charAt(s, start+n) >= '0' && charAt(s, start+n) <= '9' {
247- n++
357+ for i := start; i < len(s); {
358+ r, w := decodeRune(s[i:])
359+ if !unicode.IsDigit(r) {
360+ break
361+ }
362+ i += w
363+ n = i - start
248364 }
249365 if n == 0 {
250- return 0, false
366+ return 0, 0, false
367+ }
368+ v, err := strconv.ParseUint(s[start:start+n], 10, 8)
369+ if err != nil || !isASCII(s[start:start+n]) {
370+ return 0, 0, false
371+ }
372+ return int(v), start + n - 1, true
373+}
374+
375+// readFormatSpecifiers reads what follows the digits of "{N": either the
376+// closing brace, or ":" and specifiers up to a closing brace, where "}}" is a
377+// literal brace inside the specifiers. It returns the specifiers and the index
378+// of the closing brace.
379+func readFormatSpecifiers(s string, start int) (specs string, closing int, ok bool) {
380+ switch charAt(s, start) {
381+ case '}':
382+ return "", start, true
383+ case ':':
384+ default:
385+ return "", 0, false
251386 }
252- f, _ := strconv.ParseFloat(s[start:start+n], 64)
253- if math.IsNaN(f) {
254- return 0, false
387+ var b strings.Builder
388+ for i := start + 1; i < len(s); {
389+ if s[i] != '}' {
390+ b.WriteByte(s[i])
391+ i++
392+ } else if charAt(s, i+1) == '}' {
393+ b.WriteByte('}')
394+ i += 2
395+ } else {
396+ return b.String(), i, true
397+ }
398+ }
399+ return "", 0, false
400+}
401+
402+// utf16Len is the length of s in UTF-16 code units, the length of a .NET string.
403+func utf16Len(s string) int {
404+ n := 0
405+ for _, r := range s {
406+ n++
407+ if r >= 0x10000 {
408+ n++
409+ }
255410 }
256- return f, true
411+ return n
257412 }
@@ -4,58 +4,88 @@ package expr
4 4
5 import (5 import (
6 "errors"6 "errors"
7- "math"
8 "strconv"7 "strconv"
9 "strings"8 "strings"
9+ "unicode"
10 )10 )
11 11
12 // builtinFunc implements a built-in function. It runs inside the evaluator so12 // builtinFunc implements a built-in function. It runs inside the evaluator so
13-// that it can charge the output budget and watch the context.13+// that it can charge the output budget and watch the context, and it receives
14-type builtinFunc func(e *evaluator, args []Value) (Value, error)14+// the call, not the evaluated arguments: like the runner's functions
15+// (Sdk/Functions/*.cs) it evaluates only the arguments it needs, in its own
16+// order, so an argument that is never reached cannot fail.
17+type builtinFunc func(e *evaluator, c *FunctionCall) (Value, error)
15 18
16 // builtinImpls are the implementations of the built-in functions, keyed by19 // builtinImpls are the implementations of the built-in functions, keyed by
17-// lower-cased name. Arity is validated by the parser, so each may assume its20+// upper-cased name (see foldKey). Arity is validated by the parser, so each
18-// documented argument count.21+// may assume its documented argument count.
19-var builtinImpls = map[string]builtinFunc{22+var builtinImpls map[string]builtinFunc
20- "CASE": funcCase,23+
21- "CONTAINS": funcContains,24+// The map is filled in init: the implementations call back into the evaluator,
22- "ENDSWITH": funcEndsWith,25+// which would make a package-level initializer cyclic.
23- "FORMAT": funcFormat,26+func init() {
24- "FROMJSON": funcFromJSON,27+ builtinImpls = map[string]builtinFunc{
25- "JOIN": funcJoin,28+ "CASE": funcCase,
26- "STARTSWITH": funcStartsWith,29+ "CONTAINS": funcContains,
27- "TOJSON": funcToJSON,30+ "ENDSWITH": funcEndsWith,
31+ "FORMAT": funcFormat,
32+ "FROMJSON": funcFromJSON,
33+ "JOIN": funcJoin,
34+ "STARTSWITH": funcStartsWith,
35+ "TOJSON": funcToJSON,
36+ }
28 }37 }
29 38
30-// funcCase is case(pred1, val1, pred2, val2, ..., default).39+// funcCase is case(pred1, val1, pred2, val2, ..., default): the predicates are
31-//40+// evaluated in order up to the first true one, then only its value (Case.cs).
32-// The JavaScript reference ships it behind an experimental feature flag41+// A predicate that is not a boolean is an error. The JavaScript reference
33-// (allowCaseFunction) that only affects editor completion; the parser always42+// ships case behind an experimental feature flag that only affects editor
34-// accepts it, and so does this package.43+// completion; the parser always accepts it, and so does the runner.
35-func funcCase(e *evaluator, args []Value) (Value, error) {44+func funcCase(e *evaluator, c *FunctionCall) (Value, error) {
36- for i := 0; i < len(args)-1; i += 2 {45+ n := len(c.Args)
37- pred, ok := normalize(args[i]).(Boolean)46+ if n%2 == 0 {
47+ return nil, evalErrorf("case requires an odd number of arguments")
48+ }
49+ for i := 0; i < n-1; i += 2 {
50+ p, err := e.eval(c.Args[i])
51+ if err != nil {
52+ return nil, err
53+ }
54+ pred, ok := normalize(p).(Boolean)
38 if !ok {55 if !ok {
39 return nil, evalErrorf("case predicate must evaluate to a boolean value")56 return nil, evalErrorf("case predicate must evaluate to a boolean value")
40 }57 }
41 if pred {58 if pred {
42- return args[i+1], nil59+ return e.eval(c.Args[i+1])
43 }60 }
44 }61 }
45- return args[len(args)-1], nil62+ return e.eval(c.Args[n-1])
46 }63 }
47 64
48-// funcContains is contains(search, item): substring test, case-insensitive65+// funcContains is contains(search, item) (Contains.cs): when search is a
49-// (OrdinalIgnoreCase), when both are primitive; membership by == in an array;66+// primitive and so is item, a case-insensitive (OrdinalIgnoreCase) substring
50-// false otherwise.67+// test on their string forms; when search is a non-empty array, == against each
51-func funcContains(e *evaluator, args []Value) (Value, error) {68+// element; false otherwise. item is only evaluated when it can matter.
52- left, right := normalize(args[0]), normalize(args[1])69+func funcContains(e *evaluator, c *FunctionCall) (Value, error) {
70+ l, err := e.eval(c.Args[0])
71+ if err != nil {
72+ return nil, err
73+ }
74+ left := normalize(l)
53 switch {75 switch {
54 case isPrimitive(left):76 case isPrimitive(left):
55- if isPrimitive(right) {77+ r, err := e.eval(c.Args[1])
78+ if err != nil {
79+ return nil, err
80+ }
81+ if right := normalize(r); isPrimitive(right) {
56 return Boolean(jsIncludes(ordinalFold(CoerceString(left)), ordinalFold(CoerceString(right)))), nil82 return Boolean(jsIncludes(ordinalFold(CoerceString(left)), ordinalFold(CoerceString(right)))), nil
57 }83 }
58- case left.Kind() == KindArray:84+ case left.Kind() == KindArray && len(left.(*Array).items) > 0:
85+ right, err := e.eval(c.Args[1])
86+ if err != nil {
87+ return nil, err
88+ }
59 for _, v := range left.(*Array).items {89 for _, v := range left.(*Array).items {
60 if err := e.tick(); err != nil {90 if err := e.tick(); err != nil {
61 return nil, err91 return nil, err
@@ -68,40 +98,57 @@ func funcContains(e *evaluator, args []Value) (Value, error) {
68 return Boolean(false), nil98 return Boolean(false), nil
69 }99 }
70 100
71-func funcStartsWith(e *evaluator, args []Value) (Value, error) {101+func funcStartsWith(e *evaluator, c *FunctionCall) (Value, error) {
72- ls, rs, ok := primitivePair(args)102+ ls, rs, ok, err := e.primitivePair(c)
73- return Boolean(ok && jsHasPrefix(ordinalFold(ls), ordinalFold(rs))), nil103+ return Boolean(ok && jsHasPrefix(ordinalFold(ls), ordinalFold(rs))), err
74 }104 }
75 105
76-func funcEndsWith(e *evaluator, args []Value) (Value, error) {106+func funcEndsWith(e *evaluator, c *FunctionCall) (Value, error) {
77- ls, rs, ok := primitivePair(args)107+ ls, rs, ok, err := e.primitivePair(c)
78- return Boolean(ok && jsHasSuffix(ordinalFold(ls), ordinalFold(rs))), nil108+ return Boolean(ok && jsHasSuffix(ordinalFold(ls), ordinalFold(rs))), err
79 }109 }
80 110
81-// primitivePair returns the string forms of the two arguments, and false111+// primitivePair evaluates the two arguments, the second only if the first is
82-// when either is an array or a dictionary.112+// primitive, and returns their string forms; ok is false when either is an
83-func primitivePair(args []Value) (string, string, bool) {113+// array or a dictionary.
84- if !isPrimitive(args[0]) || !isPrimitive(args[1]) {114+func (e *evaluator) primitivePair(c *FunctionCall) (ls, rs string, ok bool, err error) {
85- return "", "", false115+ l, err := e.eval(c.Args[0])
116+ if err != nil || !isPrimitive(l) {
117+ return "", "", false, err
86 }118 }
87- return CoerceString(args[0]), CoerceString(args[1]), true119+ r, err := e.eval(c.Args[1])
120+ if err != nil || !isPrimitive(r) {
121+ return "", "", false, err
122+ }
123+ return CoerceString(l), CoerceString(r), true, nil
88 }124 }
89 125
90-// funcJoin is join(array, separator). A primitive first argument is just126+// funcJoin is join(array, separator) (Join.cs). A primitive first argument is
91-// converted to a string, ignoring the separator; a dictionary gives "". A127+// just converted to a string, ignoring the separator; a dictionary or an empty
92-// separator that is not primitive means ",".128+// array gives "". The separator is evaluated only when there are at least two
93-func funcJoin(e *evaluator, args []Value) (Value, error) {129+// items to separate, and a separator that is not primitive means ",".
94- first := normalize(args[0])130+func funcJoin(e *evaluator, c *FunctionCall) (Value, error) {
131+ f, err := e.eval(c.Args[0])
132+ if err != nil {
133+ return nil, err
134+ }
135+ first := normalize(f)
95 if isPrimitive(first) {136 if isPrimitive(first) {
96 return String(CoerceString(first)), nil137 return String(CoerceString(first)), nil
97 }138 }
98 arr, ok := first.(*Array)139 arr, ok := first.(*Array)
99- if !ok {140+ if !ok || len(arr.items) == 0 {
100 return String(""), nil141 return String(""), nil
101 }142 }
102 sep := ","143 sep := ","
103- if len(args) > 1 && isPrimitive(args[1]) {144+ if len(arr.items) > 1 && len(c.Args) > 1 {
104- sep = CoerceString(args[1])145+ sv, err := e.eval(c.Args[1])
146+ if err != nil {
147+ return nil, err
148+ }
149+ if isPrimitive(sv) {
150+ sep = CoerceString(sv)
151+ }
105 }152 }
106 var out strings.Builder153 var out strings.Builder
107 for i, it := range arr.items {154 for i, it := range arr.items {
@@ -121,17 +168,21 @@ func funcJoin(e *evaluator, args []Value) (Value, error) {
121 }168 }
122 out.WriteString(part)169 out.WriteString(part)
123 }170 }
124- return String(normalizeWTF8(out.String())), nil171+ return String(out.String()), nil
125 }172 }
126 173
127 // funcToJSON is toJSON(value). The output counts against the budget and is174 // funcToJSON is toJSON(value). The output counts against the budget and is
128 // cut off as soon as it exceeds what is left.175 // cut off as soon as it exceeds what is left.
129-func funcToJSON(e *evaluator, args []Value) (Value, error) {176+func funcToJSON(e *evaluator, c *FunctionCall) (Value, error) {
177+ v, err := e.eval(c.Args[0])
178+ if err != nil {
179+ return nil, err
180+ }
130 max := -1181 max := -1
131 if e.limit >= 0 {182 if e.limit >= 0 {
132 max = e.limit - e.used183 max = e.limit - e.used
133 }184 }
134- out, err := EncodeJSON(args[0], max)185+ out, err := EncodeJSON(v, max)
135 switch {186 switch {
136 case errors.Is(err, errJSONTooBig):187 case errors.Is(err, errJSONTooBig):
137 return nil, e.budgetError()188 return nil, e.budgetError()
@@ -145,11 +196,15 @@ func funcToJSON(e *evaluator, args []Value) (Value, error) {
145 }196 }
146 197
147 // funcFromJSON is fromJSON(value). Blank input is "empty input"; anything198 // funcFromJSON is fromJSON(value). Blank input is "empty input"; anything
148-// that is not valid JSON is "Error parsing JSON when evaluating fromJson"199+// that is not readable by Json.NET is "Error parsing JSON when evaluating
149-// wrapping the syntax error.200+// fromJson" wrapping the reader's error.
150-func funcFromJSON(e *evaluator, args []Value) (Value, error) {201+func funcFromJSON(e *evaluator, c *FunctionCall) (Value, error) {
151- in := CoerceString(args[0])202+ a, err := e.eval(c.Args[0])
152- if jsTrim(in) == "" {203+ if err != nil {
204+ return nil, err
205+ }
206+ in := CoerceString(a)
207+ if strings.TrimFunc(in, isNetSpace) == "" {
153 return nil, evalErrorf("empty input")208 return nil, evalErrorf("empty input")
154 }209 }
155 v, err := FromJSON([]byte(in))210 v, err := FromJSON([]byte(in))
@@ -159,13 +214,39 @@ func funcFromJSON(e *evaluator, args []Value) (Value, error) {
159 return v, nil214 return v, nil
160 }215 }
161 216
162-// funcFormat is format(fmt, args...): "{N}" inserts argument N, "{{" and "}}"217+// formatPiece is a segment of the result of format(): literal text, or the
163-// are literal braces. Anything between the digits of "{N" and the closing218+// index of an argument (evaluated when the result is assembled) with the
164-// brace is ignored ("{0:x}" is "{0}"). Every other use of a brace is an219+// format specifiers written after the colon.
165-// error, as is a reference to a missing argument.220+type formatPiece struct {
166-func funcFormat(e *evaluator, args []Value) (Value, error) {221+ text string
167- fs := CoerceString(args[0])222+ arg int
168- var out strings.Builder223+ specs string
224+ isArg bool
225+}
226+
227+// funcFormat is format(fmt, args...) (Format.cs): "{N}" inserts argument N,
228+// "{{" and "}}" are literal braces. The format string is scanned completely
229+// first (so a syntax error or a reference to a missing argument is reported
230+// before any argument is evaluated), then the arguments are evaluated, once
231+// each, in the order they appear. N is at most 255 (a byte). Format specifiers
232+// ("{0:x}") are not supported for any value: they are an error.
233+func funcFormat(e *evaluator, c *FunctionCall) (Value, error) {
234+ f, err := e.eval(c.Args[0])
235+ if err != nil {
236+ return nil, err
237+ }
238+ fs := CoerceString(f)
239+ var pieces []formatPiece
240+ text := func(t string) error {
241+ if t == "" {
242+ return nil
243+ }
244+ if err := e.charge(utf16Len(t)); err != nil {
245+ return err
246+ }
247+ pieces = append(pieces, formatPiece{text: t})
248+ return nil
249+ }
169 index := 0250 index := 0
170 for index < len(fs) {251 for index < len(fs) {
171 lbrace := indexByteFrom(fs, '{', index)252 lbrace := indexByteFrom(fs, '{', index)
@@ -174,27 +255,26 @@ func funcFormat(e *evaluator, args []Value) (Value, error) {
174 case lbrace >= 0 && (rbrace < 0 || rbrace > lbrace):255 case lbrace >= 0 && (rbrace < 0 || rbrace > lbrace):
175 // Escaped left brace.256 // Escaped left brace.
176 if charAt(fs, lbrace+1) == '{' {257 if charAt(fs, lbrace+1) == '{' {
177- if err := e.charge(lbrace + 1 - index); err != nil {258+ if err := text(fs[index : lbrace+1]); err != nil {
178 return nil, err259 return nil, err
179 }260 }
180- out.WriteString(fs[index : lbrace+1])
181 index = lbrace + 2261 index = lbrace + 2
182 continue262 continue
183 }263 }
184 // Left brace, number, optional format specifiers, right brace.264 // Left brace, number, optional format specifiers, right brace.
185 if rbrace > lbrace+1 {265 if rbrace > lbrace+1 {
186- if argIndex, ok := readArgIndex(fs, lbrace+1); ok {266+ if argIndex, end, ok := readArgIndex(fs, lbrace+1); ok {
187- if 1+argIndex > float64(len(args)-1) {267+ if specs, closing, ok := readFormatSpecifiers(fs, end+1); ok {
188- return nil, evalErrorf("The following format string references more arguments than were supplied: %s", fs)268+ if argIndex > len(c.Args)-2 {
269+ return nil, evalErrorf("The following format string references more arguments than were supplied: %s", fs)
270+ }
271+ if err := text(fs[index:lbrace]); err != nil {
272+ return nil, err
273+ }
274+ pieces = append(pieces, formatPiece{arg: argIndex, specs: specs, isArg: true})
275+ index = closing + 1
276+ continue
189 }277 }
190- arg := CoerceString(args[1+int(argIndex)])
191- if err := e.charge(lbrace - index + len(arg)); err != nil {
192- return nil, err
193- }
194- out.WriteString(fs[index:lbrace])
195- out.WriteString(arg)
196- index = rbrace + 1
197- continue
198 }278 }
199 }279 }
200 return nil, evalErrorf("The following format string is invalid: %s", fs)280 return nil, evalErrorf("The following format string is invalid: %s", fs)
@@ -203,20 +283,50 @@ func funcFormat(e *evaluator, args []Value) (Value, error) {
203 if charAt(fs, rbrace+1) != '}' {283 if charAt(fs, rbrace+1) != '}' {
204 return nil, evalErrorf("The following format string is invalid: %s", fs)284 return nil, evalErrorf("The following format string is invalid: %s", fs)
205 }285 }
206- if err := e.charge(rbrace + 1 - index); err != nil {286+ if err := text(fs[index : rbrace+1]); err != nil {
207 return nil, err287 return nil, err
208 }288 }
209- out.WriteString(fs[index : rbrace+1])
210 index = rbrace + 2289 index = rbrace + 2
211 default:290 default:
212- if err := e.charge(len(fs) - index); err != nil {291+ if err := text(fs[index:]); err != nil {
213 return nil, err292 return nil, err
214 }293 }
215- out.WriteString(fs[index:])
216 index = len(fs)294 index = len(fs)
217 }295 }
218 }296 }
219- return String(normalizeWTF8(out.String())), nil297+ // Assemble: arguments are evaluated lazily, once each, in the order of
298+ // their first appearance.
299+ type argValue struct {
300+ str string
301+ kind Kind
302+ done bool
303+ }
304+ cache := make([]argValue, len(c.Args)-1)
305+ var out strings.Builder
306+ for _, p := range pieces {
307+ if !p.isArg {
308+ out.WriteString(p.text)
309+ continue
310+ }
311+ av := &cache[p.arg]
312+ if !av.done {
313+ v, err := e.eval(c.Args[1+p.arg])
314+ if err != nil {
315+ return nil, err
316+ }
317+ *av = argValue{str: CoerceString(v), kind: normalize(v).Kind(), done: true}
318+ }
319+ if p.specs != "" {
320+ return nil, evalErrorf("The format specifiers '%s' are not valid for objects of type '%s'", p.specs, av.kind)
321+ }
322+ if av.str != "" {
323+ if err := e.charge(utf16Len(av.str)); err != nil {
324+ return nil, err
325+ }
326+ }
327+ out.WriteString(av.str)
328+ }
329+ return String(out.String()), nil
220 }330 }
221 331
222 func indexByteFrom(s string, c byte, from int) int {332 func indexByteFrom(s string, c byte, from int) int {
@@ -238,20 +348,65 @@ func charAt(s string, i int) byte {
238 return 0348 return 0
239 }349 }
240 350
241-// readArgIndex reads the decimal digits at s[start:]. The index is a float351+// readArgIndex reads the digits at s[start:] as the index of a format
242-// because parseInt in the reference yields Infinity for absurdly long digit352+// argument: a byte, so at most 255 (Byte.TryParse). It returns the index of the
243-// runs, which then fails the argument-count check.353+// last digit. Digits are Unicode decimal digits for the scan but only ASCII
244-func readArgIndex(s string, start int) (float64, bool) {354+// ones parse, so any other digit makes the format string invalid.
355+func readArgIndex(s string, start int) (value, end int, ok bool) {
245 n := 0356 n := 0
246- for charAt(s, start+n) >= '0' && charAt(s, start+n) <= '9' {357+ for i := start; i < len(s); {
247- n++358+ r, w := decodeRune(s[i:])
359+ if !unicode.IsDigit(r) {
360+ break
361+ }
362+ i += w
363+ n = i - start
248 }364 }
249 if n == 0 {365 if n == 0 {
250- return 0, false366+ return 0, 0, false
367+ }
368+ v, err := strconv.ParseUint(s[start:start+n], 10, 8)
369+ if err != nil || !isASCII(s[start:start+n]) {
370+ return 0, 0, false
371+ }
372+ return int(v), start + n - 1, true
373+}
374+
375+// readFormatSpecifiers reads what follows the digits of "{N": either the
376+// closing brace, or ":" and specifiers up to a closing brace, where "}}" is a
377+// literal brace inside the specifiers. It returns the specifiers and the index
378+// of the closing brace.
379+func readFormatSpecifiers(s string, start int) (specs string, closing int, ok bool) {
380+ switch charAt(s, start) {
381+ case '}':
382+ return "", start, true
383+ case ':':
384+ default:
385+ return "", 0, false
251 }386 }
252- f, _ := strconv.ParseFloat(s[start:start+n], 64)387+ var b strings.Builder
253- if math.IsNaN(f) {388+ for i := start + 1; i < len(s); {
254- return 0, false389+ if s[i] != '}' {
390+ b.WriteByte(s[i])
391+ i++
392+ } else if charAt(s, i+1) == '}' {
393+ b.WriteByte('}')
394+ i += 2
395+ } else {
396+ return b.String(), i, true
397+ }
398+ }
399+ return "", 0, false
400+}
401+
402+// utf16Len is the length of s in UTF-16 code units, the length of a .NET string.
403+func utf16Len(s string) int {
404+ n := 0
405+ for _, r := range s {
406+ n++
407+ if r >= 0x10000 {
408+ n++
409+ }
255 }410 }
256- return f, true411+ return n
257 }412 }

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