fix(expr): tokenize and parse like the runner (whitespace, errors, positions, depth), built-ins win over extensions
- commit
- 5f9df8b2555896813a127bd6b39e85ce307f9c83
- author
- brice · · 2026-10-12 18:18 UTC
- parent
- 3b23706
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
18 files · +1372 -586
M expr/PORTING.md +194 -92
| @@ -1,26 +1,54 @@ | |||
| 1 | 1 | # Porting notes for package expr | |
| 2 | 2 | ||
| 3 | - | The package is a port of `@actions/expressions` 0.3.61 (MIT, GitHub, | |
| 4 | - | actions/languageservices). The JavaScript is the specification wherever this | |
| 5 | - | package could be checked against it: `testdata/oracle.json.gz` holds what the | |
| 6 | - | reference answers for 33 575 expressions (tokens with positions, error | |
| 7 | - | messages, result kinds, string coercions, `toJSON` output), and | |
| 8 | - | `TestOracle` replays it. `gen/oracle.mjs` regenerates it, `gen/golden.mjs` the | |
| 9 | - | `testdata/tojson` goldens, and `gen/casing_dump.mjs` + `gen/casing.go` the | |
| 10 | - | casing tables (`go generate ./expr`; Node 25 / V8 14.1, Unicode 16). The | |
| 11 | - | tables are relative to the Go toolchain's own Unicode tables | |
| 12 | - | (`unicode.Version`); `TestCasingTablesMatchGoUnicodeVersion` fails with the | |
| 13 | - | regeneration command when the toolchain changes. The oracle records the | |
| 14 | - | npm integrity (sha512) of the reference tarball and the Node/V8/Unicode | |
| 15 | - | versions in its `reference` field, and `TestOracle` logs them. | |
| 3 | + | The package implements the expression language of GitHub Actions as **the C# | |
| 4 | + | runner** (actions/runner, `src/Sdk/DTExpressions2`) runs it on github.com. It | |
| 5 | + | started as a port of `@actions/expressions` 0.3.61 (MIT, GitHub, | |
| 6 | + | actions/languageservices), the JavaScript used by the editors, and was then | |
| 7 | + | checked against the runner. Where the two differ the runner wins, because it | |
| 8 | + | is what users see at run time; the differences are listed below, with the C# | |
| 9 | + | source that decides each of them. The JavaScript is still the reference for | |
| 10 | + | the token stream and positions of `Lex`, which `Parse` no longer uses. | |
| 16 | 11 | ||
| 17 | - | The C# runner (actions/runner, `DistributedTask/Expressions2`) is the | |
| 18 | - | implementation that runs on github.com. It was not available while this was | |
| 19 | - | written. Nothing below has been checked against it. | |
| 12 | + | Three kinds of evidence back the tests: | |
| 20 | 13 | ||
| 21 | - | ## Limits added by this port (not in the JavaScript) | |
| 14 | + | - `testdata/oracle.json.gz`: what the JavaScript answers on 34 765 expressions | |
| 15 | + | (tokens, error messages, result kinds, string forms, `toJSON`), generated by | |
| 16 | + | `gen/oracle.mjs`; `TestOracle` replays it, and excuses a mismatch only where | |
| 17 | + | the runner gives this package's answer. | |
| 18 | + | - `testdata/runner.json.gz`: what the runner's own code answers on the same | |
| 19 | + | expressions. `gen/runner` compiles `src/Sdk/DTExpressions2` (plus stubs for | |
| 20 | + | the few assemblies it needs, see `gen/runner/README.md`) and runs it; | |
| 21 | + | `TestRunnerOracle` holds the package to it: 34 765 cases, the only | |
| 22 | + | differences are negative zero (below) and error texts of Json.NET. | |
| 23 | + | The same driver recorded `testdata/tojson/*.runner`, `ordinal_cases.txt` | |
| 24 | + | and `number_cases.txt`, and checked the number formatting on 200 000 random | |
| 25 | + | doubles. | |
| 26 | + | - `testdata/languageservices/*.json`: the 1 020 cross-language fixtures of | |
| 27 | + | actions/languageservices (see `xlang_test.go`): 1 018 pass, 2 are known | |
| 28 | + | differences with a reason: `case/27` (the runner reports an even `case()` on | |
| 29 | + | the `(`, not the name) and `syntax-errors/memory-errors/0` (the runner's | |
| 30 | + | memory accounting). `testdata/languageservices/SOURCE` has the commit. | |
| 22 | 31 | ||
| 23 | - | The reference has no bound on what an evaluation may build. These are | |
| 32 | + | The runner's own expression tests (`ExpressionParserL0`, `ConditionFunctionsL0`) | |
| 33 | + | are ported in `runner_l0_test.go`. The casing tables of the JavaScript | |
| 34 | + | (`casing.go`, `gen/casing*`) remain for `Lex`/oracle support only. | |
| 35 | + | ||
| 36 | + | Regenerating: `dotnet build -c Release expr/gen/runner` then the `*.mjs` scripts | |
| 37 | + | in `expr/gen/runner` (they need a clone of actions/runner and the .NET SDK, not | |
| 38 | + | `go test`). Runner commit used: `5f0b3ddcc6ee9fe9441e00d55e85b94ea4aad798`, | |
| 39 | + | .NET 9.0.20. | |
| 40 | + | ||
| 41 | + | Caveat for all of the above: the runner has two copies of the engine, | |
| 42 | + | `src/Sdk/DTExpressions2` (used by the legacy template evaluator) and | |
| 43 | + | `src/Sdk/Expressions` (used by the newer `WorkflowTemplateEvaluator`, which the | |
| 44 | + | runner currently runs next to the legacy one and compares). They are almost the | |
| 45 | + | same code; the differences found are noted where they matter (negative zero, | |
| 46 | + | `StrictJsonParsing`, `AlwaysTraceRealized`). The differential run uses | |
| 47 | + | `DTExpressions2`. | |
| 48 | + | ||
| 49 | + | ## Limits added by this port (not in the JavaScript or the runner) | |
| 50 | + | ||
| 51 | + | The JavaScript has no bound on what an evaluation may build. These are | |
| 24 | 52 | additions, each reported as an `*EvalError` (or `*Error` for the parser) and | |
| 25 | 53 | none changes the result of an evaluation that stays within them. | |
| 26 | 54 | ||
| @@ -38,8 +66,8 @@ none changes the result of an evaluation that stays within them. | |||
| 38 | 66 | loops of filters, `join` and `contains`, and passes `ctx` to extension | |
| 39 | 67 | functions. | |
| 40 | 68 | - **Evaluation depth** of 1000 for hand-built trees (parsed trees stay far | |
| 41 | - | below it), **JSON depth** `MaxJSONDepth` = 1000 for `FromJSON` and for the | |
| 42 | - | encoder (cyclic values built with `Array.Append` yield an error from | |
| 69 | + | below it), **JSON depth** `MaxJSONDepth` = 1000 for the | |
| 70 | + | encoder (`FromJSON` is limited to 64 levels like Json.NET) (cyclic values built with `Array.Append` yield an error from | |
| 43 | 71 | `EncodeJSON`, `"null"` from `ToJSON`, an `*EvalError` from `toJSON()`). | |
| 44 | 72 | The JS recursion limit is engine-dependent, around 5 000 to 10 000. | |
| 45 | 73 | - **Built-in arity is re-checked at evaluation**, so a hand-built AST gets an | |
| @@ -47,8 +75,8 @@ none changes the result of an evaluation that stays within them. | |||
| 47 | 75 | - **Input length** is checked before the input is converted, without | |
| 48 | 76 | allocating. | |
| 49 | 77 | ||
| 50 | - | Other limits that exist in the JS and are ported: expression length 21000 | |
| 51 | - | UTF-16 units and parser depth 50 (see below). | |
| 78 | + | Limits that the runner has and this package follows: expression length 21000 | |
| 79 | + | UTF-16 units and tree depth 50 (see below). | |
| 52 | 80 | ||
| 53 | 81 | ## Performance notes (deferred) | |
| 54 | 82 | ||
| @@ -58,11 +86,10 @@ UTF-16 units and parser depth 50 (see below). | |||
| 58 | 86 | same contexts, add a prepared `Environment` (fold once, reuse) and cache | |
| 59 | 87 | folded names in the AST. | |
| 60 | 88 | ||
| 61 | - | ## Where the JavaScript is believed to differ from the C# runner | |
| 89 | + | ## Where the JavaScript differs from the C# runner (and what this package does) | |
| 62 | 90 | ||
| 63 | - | Each item is implemented as the JavaScript does. Check the runner's tests | |
| 64 | - | (`src/Test/L0/Expressions*`, `Sdk/Expressions2/...`) and decide for each one | |
| 65 | - | whether to keep the JS behaviour or follow the runner. | |
| 91 | + | Each item names the runner source that decides it. All are resolved; the | |
| 92 | + | package follows the runner. | |
| 66 | 93 | ||
| 67 | 94 | - [x] **String escapes: every `''` is unescaped.** Resolved: follows the C#. | |
| 68 | 95 | `LexicalAnalyzer.ReadStringToken` (`Tokens/LexicalAnalyzer.cs`, the | |
| @@ -204,41 +231,88 @@ whether to keep the JS behaviour or follow the runner. | |||
| 204 | 231 | `join(success(), format('{2}'))` is `true` in the runner and an error in | |
| 205 | 232 | the JS. Built-ins now receive the call and evaluate what they need; | |
| 206 | 233 | extension functions still get evaluated arguments. | |
| 207 | - | - [ ] **Index access**: `a[b]` where `b` is a number uses `floor(b)` and | |
| 208 | - | rejects NaN, negatives and infinities; where `b` is a string it is a key | |
| 209 | - | or a decimal index (`'1'`, `' 1 '`, `'1.9'` all index 1 of an array). | |
| 210 | - | Booleans and null index as `1`/`0` on arrays and as `"true"`/`""` keys on | |
| 211 | - | dictionaries. | |
| 212 | - | - [ ] **Filtered arrays** (`a.*`): a dictionary filtered by `*` yields its | |
| 213 | - | values; `.*.b` skips elements without `b`; `.*.*` flattens one level; | |
| 214 | - | arrays produced by `*` are fresh, so `a.* == a.*` is false. | |
| 215 | - | - [ ] **Comparison**: `null` is only ordered against numbers/booleans (it | |
| 216 | - | becomes 0); arrays and dictionaries are never ordered and `==` is | |
| 217 | - | identity; `NaN` is unordered and unequal to itself; `true > false`. | |
| 218 | - | - [ ] **`&&`/`||` return an operand**, not a boolean (`'' || 'x'` is `'x'`). | |
| 219 | - | - [ ] **Limits**: max expression length 21000 (UTF-16 units; the JS throws a | |
| 220 | - | bare `Error("ErrorExceededMaxLength")`), max parse depth 50. The depth | |
| 221 | - | counting (including `index()` and the flat logical chains) is ported | |
| 222 | - | literally; C# `MaxDepth` is 50 with its own counting. Cross-check with | |
| 223 | - | deep-nesting tests. There is no node-count or evaluation-memory limit | |
| 224 | - | here; the C# runner has `MaxMemory` (1 MB) and a result-size limit. | |
| 225 | - | - [ ] **Trailing comma in calls**: `contains('a', 'b',)` parses (the JS loop | |
| 226 | - | only requires a comma when the next token is not `)`). `f(,)` does not. | |
| 227 | - | - [ ] **Whitespace**: only space, `\t`, `\r`, `\n` separate tokens. U+00A0 and | |
| 228 | - | other Unicode spaces end a word but are not skipped, so `1 \u00a0 2` | |
| 229 | - | reports an unexpected symbol. `\v`, `\f` likewise. | |
| 230 | - | - [ ] **Empty expression**: the JS parser returns `undefined` for no tokens. | |
| 231 | - | `Parse` returns a nil Node and no error; `Evaluate(nil)` errors. | |
| 232 | - | - [ ] **Unknown contexts at evaluation time** are null (the JS `Dictionary.get` | |
| 233 | - | returns `undefined` and `visitContextAccess` then crashes later); here | |
| 234 | - | declared-but-absent contexts are null, never a crash. | |
| 235 | - | - [ ] **Error positions**: the JS EOF token's start column is negative after a | |
| 236 | - | trailing newline (stale `start`); ported as is | |
| 237 | - | (`TestEOFTokenPosition`). | |
| 238 | - | - [ ] **Function names and context names are case-insensitive**; the JS | |
| 239 | - | lower-cases with `toLowerCase`, as does this port (`foldKey`). Extension | |
| 240 | - | functions override built-ins at evaluation (`functions?.get(...) || | |
| 241 | - | wellKnownFunctions[...]`) but built-ins win at validation time (arity). | |
| 234 | + | - [x] **Large integers in `fromJSON`**: found by the differential run. Json.NET | |
| 235 | + | reads an integer that does not fit in 64 bits as a `BigInteger`, and | |
| 236 | + | `(Double)BigInteger` truncates toward zero to 53 bits instead of rounding | |
| 237 | + | to nearest, so `fromJSON('18446744073709551615')` is 2^64-2048, not 2^64, | |
| 238 | + | and does not equal the literal `18446744073709551615` of an expression | |
| 239 | + | (which `Double.TryParse` rounds to nearest). Reproduced in | |
| 240 | + | `jsonParser.number` (`TestFromJSONBigIntegers`); checked on 57 values. | |
| 241 | + | - [x] **Index access**: resolved, same in the C# (`Sdk/Operators/Index.cs`, | |
| 242 | + | `IndexHelper`): a number is floored, NaN, negatives and numbers above | |
| 243 | + | Int32.MaxValue give no index; a primitive also has a string form for | |
| 244 | + | dictionary keys, so booleans and null are `"true"`/`""` there and 1/0 on | |
| 245 | + | arrays; `' 1 '` and `'1.9'` index element 1. Differences found: the | |
| 246 | + | index is only evaluated when the object is an array or a dictionary, and | |
| 247 | + | its errors are not wrapped (see the lazy evaluation item above). | |
| 248 | + | `TestRunnerSemantics`. | |
| 249 | + | - [x] **Filtered arrays** (`a.*`): resolved, same in the C# | |
| 250 | + | (`Index.HandleFilteredArray`): `*` on a dictionary yields its values, | |
| 251 | + | `.*.b` skips elements without `b`, `.*.*` flattens one level, and every | |
| 252 | + | `*` builds a new array, so `a.* == a.*` is false. `TestRunnerSemantics`. | |
| 253 | + | - [x] **Comparison**: resolved, same in the C# (`EvaluationResult.cs` | |
| 254 | + | `CoerceTypes`/`AbstractEqual`/`AbstractGreaterThan`): null and booleans | |
| 255 | + | become numbers when compared with another kind, a string compared with a | |
| 256 | + | number is parsed with `ParseNumber`, arrays and dictionaries compare by | |
| 257 | + | reference and are never ordered, NaN is unequal to itself and unordered. | |
| 258 | + | Strings compare with `OrdinalIgnoreCase` (see above). `TestRunnerSemantics`. | |
| 259 | + | - [x] **`&&`/`||` return an operand**: resolved, same in the C# (`And.cs`, | |
| 260 | + | `Or.cs`: they return `result.Value` of the deciding operand). | |
| 261 | + | - [x] **Limits**: resolved, follows the C#. Length 21000 UTF-16 units | |
| 262 | + | (`ExpressionConstants.MaxLength`): "Exceeded max expression length 21000", | |
| 263 | + | without a token (the JS throws a bare `ErrorExceededMaxLength`). Depth 50 | |
| 264 | + | is checked on the finished tree (`ExpressionParser.CheckMaxDepth`): the | |
| 265 | + | root is depth 1, an operand of an operator, an argument of a call and the | |
| 266 | + | two sides of an index are one deeper, **parentheses add nothing**, and a | |
| 267 | + | chain of `&&` or `||` is one node, nested ones included, so | |
| 268 | + | `((((...1...))))` with 500 parentheses and `1 || (1 || (1 ...))` parse; | |
| 269 | + | `"!" * 50 + "true"` and 50 comparisons chained with `==` do not. The error | |
| 270 | + | has no token: "Exceeded max expression depth 50". The JS limit counted | |
| 271 | + | parser recursion (parentheses included). Memory: the runner has | |
| 272 | + | `EvaluationOptions.MaxMemory` (1 MiB by default) and fails with "The | |
| 273 | + | maximum allowed memory size was exceeded while evaluating the following | |
| 274 | + | expression: ...", counting strings at 26 bytes + 2 per UTF-16 unit and 24 | |
| 275 | + | per object, per call depth (`EvaluationMemory`). This package keeps its | |
| 276 | + | own output budget (see above), which is not byte-for-byte the same, so | |
| 277 | + | the fixture `syntax-errors.json` "memory-errors" is a known difference. | |
| 278 | + | - [x] **Trailing comma in calls**: resolved, follows the C#. `contains('a', 'b',)` | |
| 279 | + | is "Unexpected symbol: ')'" (the tokenizer rejects a `)` after a `,`). The JS | |
| 280 | + | accepted it. `f(,)` and `contains(,'a')` fail as before. | |
| 281 | + | - [x] **Whitespace**: resolved, follows the C#. `Char.IsWhiteSpace` separates | |
| 282 | + | tokens: U+0009..U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000..U+200A, | |
| 283 | + | U+2028, U+2029, U+202F, U+205F, U+3000. So `1 \\u00a0 2` is "Unexpected | |
| 284 | + | symbol: '2'" (not the NBSP), and `\\v1` is 1. The JS skipped only space, | |
| 285 | + | tab, CR and LF. U+FEFF is not white space. | |
| 286 | + | - [x] **Empty expression**: same in the C# (`CreateTree` returns null when there | |
| 287 | + | is no token). `Parse` returns a nil Node and no error; `Evaluate(nil)` | |
| 288 | + | errors; callers (the template layer) decide. | |
| 289 | + | - [x] **Unknown contexts at evaluation time** are null: in the runner a | |
| 290 | + | declared named value is evaluated by the caller's node; here an absent | |
| 291 | + | key of `EvalContext.Contexts` is null. | |
| 292 | + | - [x] **Error positions**: resolved, follows the C#. A `ParseException` names | |
| 293 | + | the token and its **absolute 1-based index in the expression** | |
| 294 | + | (`Located at position N within expression: <whole expression>`); | |
| 295 | + | `Error.Pos` is the same place as a zero-based line and column. | |
| 296 | + | Arity and even-argument errors name the `(` of the call (the token | |
| 297 | + | `FlushTopEndParameters` holds), not the function: `contains()` is | |
| 298 | + | "Too few parameters supplied: '('. Located at position 9". The | |
| 299 | + | end-of-input error is "Unexpected end of expression: '<last token>'" | |
| 300 | + | (the JS said "Unexpected symbol: 'EOF'"), and the JS's negative EOF column | |
| 301 | + | is gone with the JS lexer. Unexpected-symbol tokens are the | |
| 302 | + | boundary-delimited run (`ReadKeywordToken`): `github..event` reports `.` | |
| 303 | + | where the JS reported `.event`. Lexing and parsing are interleaved, so the | |
| 304 | + | first problem in reading order wins (the JS lexed everything first). | |
| 305 | + | `TestParseErrorPositions`, and `TestRunnerOracle` on 1 700 error cases. | |
| 306 | + | One fixture still differs, see `xlangKnownDifferences`: `case/27` expects | |
| 307 | + | the name for an even `case()`, the runner passes the `(`. | |
| 308 | + | - [x] **Function names and context names are case-insensitive**: same in the | |
| 309 | + | C# (`StringComparer.OrdinalIgnoreCase` dictionaries; see the folding | |
| 310 | + | item). Built-ins win over extension functions of the same name at | |
| 311 | + | validation **and at evaluation** (`ExpressionParser.TryGetFunctionInfo` | |
| 312 | + | looks in `ExpressionConstants.WellKnownFunctions` first and builds the | |
| 313 | + | node from it); the JS let an extension override a built-in at | |
| 314 | + | evaluation. An extension supplied under a built-in's name is now never | |
| 315 | + | called. | |
| 242 | 316 | ||
| 243 | 317 | ## JavaScript behaviours deliberately NOT ported | |
| 244 | 318 | ||
| @@ -257,36 +331,64 @@ a bug. | |||
| 257 | 331 | evaluating e.g. `github.true` with an empty context: the reference does not | |
| 258 | 332 | handle a missing root context. Here it is null. | |
| 259 | 333 | - Recursion limits: JS recursion depth for `fromJSON` input is engine | |
| 260 | - | dependent (about 5 000 to 10 000 levels). `FromJSON` accepts `MaxJSONDepth` | |
| 261 | - | (1000) levels. | |
| 262 | - | - Lone surrogates: JS strings are UTF-16 and can hold them; Go strings are | |
| 263 | - | UTF-8. They are kept as WTF-8 (`0xED 0xA0-0xBF xx`) so that `fromJSON` / | |
| 264 | - | `toJSON` round-trip, `==`/`<`/`contains`/`startsWith`/`endsWith` compare | |
| 265 | - | them like code units, case mapping copies them, and `format`/`join` rejoin a | |
| 266 | - | high surrogate followed by a low one into the character they form. This is | |
| 267 | - | covered by oracle cases. Two surrogate strings concatenated by other means | |
| 268 | - | (an extension function) are not rejoined. | |
| 334 | + | dependent. `FromJSON` follows Json.NET's `MaxDepth` of 64. | |
| 335 | + | - Lone surrogates: JS strings are UTF-16 and can hold them. The runner cannot | |
| 336 | + | (Json.NET reads an unpaired `\ud800` as U+FFFD), and neither can Go strings; | |
| 337 | + | the WTF-8 machinery of the first version (`wtf8Surrogate`, `normalizeWTF8`) | |
| 338 | + | is only used by the case-folding and comparison helpers for robustness and | |
| 339 | + | by the oracle tests. | |
| 340 | + | ||
| 341 | + | ## Still open | |
| 342 | + | ||
| 343 | + | - [ ] **Memory accounting.** The runner caps an evaluation at `MaxMemory` (1 MiB) | |
| 344 | + | counted by `EvaluationMemory`/`MemoryCounter` (26 bytes + 2 per UTF-16 unit | |
| 345 | + | per string, 24 per object, trimmed per call depth); this package caps | |
| 346 | + | `MaxResultBytes` (1 MiB of output, different units) and accepts | |
| 347 | + | `format(...)` chains the runner rejects. Fixture | |
| 348 | + | `syntax-errors/memory-errors/0` is the known difference. Reproducing | |
| 349 | + | `EvaluationMemory` exactly would need the node depth of every value; | |
| 350 | + | worth doing only if the error text matters ("The maximum allowed memory | |
| 351 | + | size was exceeded while evaluating the following expression: ...", which | |
| 352 | + | needs `ConvertToExpression` of the root, also not ported). | |
| 353 | + | - [ ] **Negative zero.** `-0` is `0` here. `DTExpressions2/EvaluationResult.cs` | |
| 354 | + | (the legacy engine) prints `-0` for `format('{0}', -0)` and `toJSON(-0)`, | |
| 355 | + | because .NET Core prints `-0`; `Expressions/EvaluationResult.cs` (the engine of | |
| 356 | + | `WorkflowTemplateEvaluator`) maps it to `0`, and the cross-language fixture | |
| 357 | + | `coerce_string.json` expects `0`. If the legacy engine is what a user sees, | |
| 358 | + | `-0` would be right in `format()`; the choice was made for the fixture. | |
| 359 | + | `toJSON` goes through `ConvertToString` in both and so follows the same | |
| 360 | + | choice. | |
| 361 | + | - [ ] **Comments and constructors in `fromJSON`.** Json.NET accepts `/* */` and | |
| 362 | + | `//` comments and `new Date(1)` constructors; the first is read in a | |
| 363 | + | surprising way (`/*x*/1` is the string `x`). They fail here. | |
| 364 | + | - [ ] **`fromJSON` error texts.** Json.NET messages ("Unexpected character | |
| 365 | + | encountered while parsing value: a. Path '', line 0, position 0.") are | |
| 366 | + | not reproduced; the outer message "Error parsing JSON when evaluating | |
| 367 | + | fromJson" is the JS one, the runner's exception message is the Json.NET | |
| 368 | + | one. Only the failure is checked. | |
| 369 | + | - [ ] **`StrictJsonParsing`.** The newer engine has an option that switches | |
| 370 | + | `fromJSON` to `System.Text.Json` (strict, 64 levels, different | |
| 371 | + | messages); default off, "temporary for telemetry". Not implemented. | |
| 372 | + | - [ ] **Unicode version of the folding table** is that of the .NET 9 runtime that | |
| 373 | + | generated `fold_tables.go`; a runner on another runtime may fold newly | |
| 374 | + | assigned letters differently. | |
| 375 | + | - [ ] **Tracing and secret masking** (`ITraceWriter`, `ISecretMasker`, | |
| 376 | + | `EvaluationResult` tracing) are runner features outside this package. | |
| 269 | 377 | ||
| 270 | - | ## Other things to check once the runner is available | |
| 378 | + | ## Left to the callers | |
| 271 | 379 | ||
| 272 | - | - [ ] Run the runner's expression test vectors (`ExpressionParserL0`, | |
| 273 | - | `ExpressionTests`, `ExpressionValuesL0`, `ExpressionTestUtil`) through a | |
| 274 | - | table adapter; list every disagreement here before deciding. | |
| 275 | - | - [ ] Decide whether `EvalContext` needs a hook for the functions the runner | |
| 276 | - | implements natively with special semantics: `success()`, `failure()`, | |
| 277 | - | `always()`, `cancelled()` (take no arguments; read job status) and | |
| 278 | - | `hashFiles()` (variadic glob patterns, string result). The API supports | |
| 279 | - | them through `FuncInfo` + `Func` without changes; arity is declared by | |
| 280 | - | the caller. | |
| 281 | - | - [ ] Context names the runner allows per location (`github`, `env`, `vars`, | |
| 282 | - | `job`, `jobs`, `steps`, `runner`, `secrets`, `strategy`, `matrix`, | |
| 283 | - | `needs`, `inputs`) and which functions each location may call | |
| 284 | - | (`success`... only in `if`/`jobs.*.if`...). This belongs to the template | |
| 285 | - | layer, not to package expr. | |
| 286 | - | - [ ] `format()` argument limit is 255 in the JS (`MAX_ARGUMENTS`); the C# | |
| 287 | - | limit is `ExpressionConstants.MaxArguments` (check). | |
| 288 | - | - [x] Case-insensitive comparison and `contains`: `OrdinalIgnoreCase` is the | |
| 289 | - | truth, see above. | |
| 290 | - | - [ ] Secret masking and the `${{ }}` splitting of a larger string are not | |
| 291 | - | part of this package (they belong to the template layer): `Parse` | |
| 292 | - | takes the text between the braces only. | |
| 380 | + | - Run the runner's expression test vectors: done for `ExpressionParserL0` and | |
| 381 | + | `ConditionFunctionsL0` (`runner_l0_test.go`). The runner has no other | |
| 382 | + | expression tests in `src/Test/L0` (the larger suites are in the closed | |
| 383 | + | service); the cross-language fixtures cover the rest. | |
| 384 | + | - The functions the runner implements natively with special semantics: | |
| 385 | + | `success()`, `failure()`, `always()`, `cancelled()` (no arguments; read job | |
| 386 | + | status, or `action_status` in a composite action's main step) and | |
| 387 | + | `hashFiles()` (variadic, string result) are supplied by the caller through | |
| 388 | + | `FuncInfo` + `Func`; `runner_l0_test.go` shows the declaration the runner | |
| 389 | + | uses (`FunctionInfo(name, 0, 0)`). | |
| 390 | + | - Context names per location and which functions each location may call belong | |
| 391 | + | to the template layer. | |
| 392 | + | - The `format()` argument limit is 255 in both (`Byte.MaxValue`). | |
| 393 | + | - Secret masking and the `${{ }}` splitting of a larger string are not part of | |
| 394 | + | this package: `Parse` takes the text between the braces only. | |
| @@ -1,26 +1,54 @@ | |||||
| 1 | # Porting notes for package expr | 1 | # Porting notes for package expr | ||
| 2 | 2 | ||||
| 3 | - | The package is a port of `@actions/expressions` 0.3.61 (MIT, GitHub, | 3 | + | The package implements the expression language of GitHub Actions as **the C# |
| 4 | - | actions/languageservices). The JavaScript is the specification wherever this | 4 | + | runner** (actions/runner, `src/Sdk/DTExpressions2`) runs it on github.com. It |
| 5 | - | package could be checked against it: `testdata/oracle.json.gz` holds what the | 5 | + | started as a port of `@actions/expressions` 0.3.61 (MIT, GitHub, |
| 6 | - | reference answers for 33 575 expressions (tokens with positions, error | 6 | + | actions/languageservices), the JavaScript used by the editors, and was then |
| 7 | - | messages, result kinds, string coercions, `toJSON` output), and | 7 | + | checked against the runner. Where the two differ the runner wins, because it |
| 8 | - | `TestOracle` replays it. `gen/oracle.mjs` regenerates it, `gen/golden.mjs` the | 8 | + | is what users see at run time; the differences are listed below, with the C# |
| 9 | - | `testdata/tojson` goldens, and `gen/casing_dump.mjs` + `gen/casing.go` the | 9 | + | source that decides each of them. The JavaScript is still the reference for |
| 10 | - | casing tables (`go generate ./expr`; Node 25 / V8 14.1, Unicode 16). The | 10 | + | the token stream and positions of `Lex`, which `Parse` no longer uses. |
| 11 | - | tables are relative to the Go toolchain's own Unicode tables | |||
| 12 | - | (`unicode.Version`); `TestCasingTablesMatchGoUnicodeVersion` fails with the | |||
| 13 | - | regeneration command when the toolchain changes. The oracle records the | |||
| 14 | - | npm integrity (sha512) of the reference tarball and the Node/V8/Unicode | |||
| 15 | - | versions in its `reference` field, and `TestOracle` logs them. | |||
| 16 | 11 | ||||
| 17 | - | The C# runner (actions/runner, `DistributedTask/Expressions2`) is the | 12 | + | Three kinds of evidence back the tests: |
| 18 | - | implementation that runs on github.com. It was not available while this was | |||
| 19 | - | written. Nothing below has been checked against it. | |||
| 20 | 13 | ||||
| 21 | - | ## Limits added by this port (not in the JavaScript) | 14 | + | - `testdata/oracle.json.gz`: what the JavaScript answers on 34 765 expressions |
| 15 | + | (tokens, error messages, result kinds, string forms, `toJSON`), generated by | |||
| 16 | + | `gen/oracle.mjs`; `TestOracle` replays it, and excuses a mismatch only where | |||
| 17 | + | the runner gives this package's answer. | |||
| 18 | + | - `testdata/runner.json.gz`: what the runner's own code answers on the same | |||
| 19 | + | expressions. `gen/runner` compiles `src/Sdk/DTExpressions2` (plus stubs for | |||
| 20 | + | the few assemblies it needs, see `gen/runner/README.md`) and runs it; | |||
| 21 | + | `TestRunnerOracle` holds the package to it: 34 765 cases, the only | |||
| 22 | + | differences are negative zero (below) and error texts of Json.NET. | |||
| 23 | + | The same driver recorded `testdata/tojson/*.runner`, `ordinal_cases.txt` | |||
| 24 | + | and `number_cases.txt`, and checked the number formatting on 200 000 random | |||
| 25 | + | doubles. | |||
| 26 | + | - `testdata/languageservices/*.json`: the 1 020 cross-language fixtures of | |||
| 27 | + | actions/languageservices (see `xlang_test.go`): 1 018 pass, 2 are known | |||
| 28 | + | differences with a reason: `case/27` (the runner reports an even `case()` on | |||
| 29 | + | the `(`, not the name) and `syntax-errors/memory-errors/0` (the runner's | |||
| 30 | + | memory accounting). `testdata/languageservices/SOURCE` has the commit. | |||
| 22 | 31 | ||||
| 23 | - | The reference has no bound on what an evaluation may build. These are | 32 | + | The runner's own expression tests (`ExpressionParserL0`, `ConditionFunctionsL0`) |
| 33 | + | are ported in `runner_l0_test.go`. The casing tables of the JavaScript | |||
| 34 | + | (`casing.go`, `gen/casing*`) remain for `Lex`/oracle support only. | |||
| 35 | + | ||||
| 36 | + | Regenerating: `dotnet build -c Release expr/gen/runner` then the `*.mjs` scripts | |||
| 37 | + | in `expr/gen/runner` (they need a clone of actions/runner and the .NET SDK, not | |||
| 38 | + | `go test`). Runner commit used: `5f0b3ddcc6ee9fe9441e00d55e85b94ea4aad798`, | |||
| 39 | + | .NET 9.0.20. | |||
| 40 | + | ||||
| 41 | + | Caveat for all of the above: the runner has two copies of the engine, | |||
| 42 | + | `src/Sdk/DTExpressions2` (used by the legacy template evaluator) and | |||
| 43 | + | `src/Sdk/Expressions` (used by the newer `WorkflowTemplateEvaluator`, which the | |||
| 44 | + | runner currently runs next to the legacy one and compares). They are almost the | |||
| 45 | + | same code; the differences found are noted where they matter (negative zero, | |||
| 46 | + | `StrictJsonParsing`, `AlwaysTraceRealized`). The differential run uses | |||
| 47 | + | `DTExpressions2`. | |||
| 48 | + | ||||
| 49 | + | ## Limits added by this port (not in the JavaScript or the runner) | |||
| 50 | + | ||||
| 51 | + | The JavaScript has no bound on what an evaluation may build. These are | |||
| 24 | additions, each reported as an `*EvalError` (or `*Error` for the parser) and | 52 | additions, each reported as an `*EvalError` (or `*Error` for the parser) and | ||
| 25 | none changes the result of an evaluation that stays within them. | 53 | none changes the result of an evaluation that stays within them. | ||
| 26 | 54 | ||||
| @@ -38,8 +66,8 @@ none changes the result of an evaluation that stays within them. | |||||
| 38 | loops of filters, `join` and `contains`, and passes `ctx` to extension | 66 | loops of filters, `join` and `contains`, and passes `ctx` to extension | ||
| 39 | functions. | 67 | functions. | ||
| 40 | - **Evaluation depth** of 1000 for hand-built trees (parsed trees stay far | 68 | - **Evaluation depth** of 1000 for hand-built trees (parsed trees stay far | ||
| 41 | - | below it), **JSON depth** `MaxJSONDepth` = 1000 for `FromJSON` and for the | 69 | + | below it), **JSON depth** `MaxJSONDepth` = 1000 for the |
| 42 | - | encoder (cyclic values built with `Array.Append` yield an error from | 70 | + | encoder (`FromJSON` is limited to 64 levels like Json.NET) (cyclic values built with `Array.Append` yield an error from |
| 43 | `EncodeJSON`, `"null"` from `ToJSON`, an `*EvalError` from `toJSON()`). | 71 | `EncodeJSON`, `"null"` from `ToJSON`, an `*EvalError` from `toJSON()`). | ||
| 44 | The JS recursion limit is engine-dependent, around 5 000 to 10 000. | 72 | The JS recursion limit is engine-dependent, around 5 000 to 10 000. | ||
| 45 | - **Built-in arity is re-checked at evaluation**, so a hand-built AST gets an | 73 | - **Built-in arity is re-checked at evaluation**, so a hand-built AST gets an | ||
| @@ -47,8 +75,8 @@ none changes the result of an evaluation that stays within them. | |||||
| 47 | - **Input length** is checked before the input is converted, without | 75 | - **Input length** is checked before the input is converted, without | ||
| 48 | allocating. | 76 | allocating. | ||
| 49 | 77 | ||||
| 50 | - | Other limits that exist in the JS and are ported: expression length 21000 | 78 | + | Limits that the runner has and this package follows: expression length 21000 |
| 51 | - | UTF-16 units and parser depth 50 (see below). | 79 | + | UTF-16 units and tree depth 50 (see below). |
| 52 | 80 | ||||
| 53 | ## Performance notes (deferred) | 81 | ## Performance notes (deferred) | ||
| 54 | 82 | ||||
| @@ -58,11 +86,10 @@ UTF-16 units and parser depth 50 (see below). | |||||
| 58 | same contexts, add a prepared `Environment` (fold once, reuse) and cache | 86 | same contexts, add a prepared `Environment` (fold once, reuse) and cache | ||
| 59 | folded names in the AST. | 87 | folded names in the AST. | ||
| 60 | 88 | ||||
| 61 | - | ## Where the JavaScript is believed to differ from the C# runner | 89 | + | ## Where the JavaScript differs from the C# runner (and what this package does) |
| 62 | 90 | ||||
| 63 | - | Each item is implemented as the JavaScript does. Check the runner's tests | 91 | + | Each item names the runner source that decides it. All are resolved; the |
| 64 | - | (`src/Test/L0/Expressions*`, `Sdk/Expressions2/...`) and decide for each one | 92 | + | package follows the runner. |
| 65 | - | whether to keep the JS behaviour or follow the runner. | |||
| 66 | 93 | ||||
| 67 | - [x] **String escapes: every `''` is unescaped.** Resolved: follows the C#. | 94 | - [x] **String escapes: every `''` is unescaped.** Resolved: follows the C#. | ||
| 68 | `LexicalAnalyzer.ReadStringToken` (`Tokens/LexicalAnalyzer.cs`, the | 95 | `LexicalAnalyzer.ReadStringToken` (`Tokens/LexicalAnalyzer.cs`, the | ||
| @@ -204,41 +231,88 @@ whether to keep the JS behaviour or follow the runner. | |||||
| 204 | `join(success(), format('{2}'))` is `true` in the runner and an error in | 231 | `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; | 232 | the JS. Built-ins now receive the call and evaluate what they need; | ||
| 206 | extension functions still get evaluated arguments. | 233 | extension functions still get evaluated arguments. | ||
| 207 | - | - [ ] **Index access**: `a[b]` where `b` is a number uses `floor(b)` and | 234 | + | - [x] **Large integers in `fromJSON`**: found by the differential run. Json.NET |
| 208 | - | rejects NaN, negatives and infinities; where `b` is a string it is a key | 235 | + | reads an integer that does not fit in 64 bits as a `BigInteger`, and |
| 209 | - | or a decimal index (`'1'`, `' 1 '`, `'1.9'` all index 1 of an array). | 236 | + | `(Double)BigInteger` truncates toward zero to 53 bits instead of rounding |
| 210 | - | Booleans and null index as `1`/`0` on arrays and as `"true"`/`""` keys on | 237 | + | to nearest, so `fromJSON('18446744073709551615')` is 2^64-2048, not 2^64, |
| 211 | - | dictionaries. | 238 | + | and does not equal the literal `18446744073709551615` of an expression |
| 212 | - | - [ ] **Filtered arrays** (`a.*`): a dictionary filtered by `*` yields its | 239 | + | (which `Double.TryParse` rounds to nearest). Reproduced in |
| 213 | - | values; `.*.b` skips elements without `b`; `.*.*` flattens one level; | 240 | + | `jsonParser.number` (`TestFromJSONBigIntegers`); checked on 57 values. |
| 214 | - | arrays produced by `*` are fresh, so `a.* == a.*` is false. | 241 | + | - [x] **Index access**: resolved, same in the C# (`Sdk/Operators/Index.cs`, |
| 215 | - | - [ ] **Comparison**: `null` is only ordered against numbers/booleans (it | 242 | + | `IndexHelper`): a number is floored, NaN, negatives and numbers above |
| 216 | - | becomes 0); arrays and dictionaries are never ordered and `==` is | 243 | + | Int32.MaxValue give no index; a primitive also has a string form for |
| 217 | - | identity; `NaN` is unordered and unequal to itself; `true > false`. | 244 | + | dictionary keys, so booleans and null are `"true"`/`""` there and 1/0 on |
| 218 | - | - [ ] **`&&`/`||` return an operand**, not a boolean (`'' || 'x'` is `'x'`). | 245 | + | arrays; `' 1 '` and `'1.9'` index element 1. Differences found: the |
| 219 | - | - [ ] **Limits**: max expression length 21000 (UTF-16 units; the JS throws a | 246 | + | index is only evaluated when the object is an array or a dictionary, and |
| 220 | - | bare `Error("ErrorExceededMaxLength")`), max parse depth 50. The depth | 247 | + | its errors are not wrapped (see the lazy evaluation item above). |
| 221 | - | counting (including `index()` and the flat logical chains) is ported | 248 | + | `TestRunnerSemantics`. |
| 222 | - | literally; C# `MaxDepth` is 50 with its own counting. Cross-check with | 249 | + | - [x] **Filtered arrays** (`a.*`): resolved, same in the C# |
| 223 | - | deep-nesting tests. There is no node-count or evaluation-memory limit | 250 | + | (`Index.HandleFilteredArray`): `*` on a dictionary yields its values, |
| 224 | - | here; the C# runner has `MaxMemory` (1 MB) and a result-size limit. | 251 | + | `.*.b` skips elements without `b`, `.*.*` flattens one level, and every |
| 225 | - | - [ ] **Trailing comma in calls**: `contains('a', 'b',)` parses (the JS loop | 252 | + | `*` builds a new array, so `a.* == a.*` is false. `TestRunnerSemantics`. |
| 226 | - | only requires a comma when the next token is not `)`). `f(,)` does not. | 253 | + | - [x] **Comparison**: resolved, same in the C# (`EvaluationResult.cs` |
| 227 | - | - [ ] **Whitespace**: only space, `\t`, `\r`, `\n` separate tokens. U+00A0 and | 254 | + | `CoerceTypes`/`AbstractEqual`/`AbstractGreaterThan`): null and booleans |
| 228 | - | other Unicode spaces end a word but are not skipped, so `1 \u00a0 2` | 255 | + | become numbers when compared with another kind, a string compared with a |
| 229 | - | reports an unexpected symbol. `\v`, `\f` likewise. | 256 | + | number is parsed with `ParseNumber`, arrays and dictionaries compare by |
| 230 | - | - [ ] **Empty expression**: the JS parser returns `undefined` for no tokens. | 257 | + | reference and are never ordered, NaN is unequal to itself and unordered. |
| 231 | - | `Parse` returns a nil Node and no error; `Evaluate(nil)` errors. | 258 | + | Strings compare with `OrdinalIgnoreCase` (see above). `TestRunnerSemantics`. |
| 232 | - | - [ ] **Unknown contexts at evaluation time** are null (the JS `Dictionary.get` | 259 | + | - [x] **`&&`/`||` return an operand**: resolved, same in the C# (`And.cs`, |
| 233 | - | returns `undefined` and `visitContextAccess` then crashes later); here | 260 | + | `Or.cs`: they return `result.Value` of the deciding operand). |
| 234 | - | declared-but-absent contexts are null, never a crash. | 261 | + | - [x] **Limits**: resolved, follows the C#. Length 21000 UTF-16 units |
| 235 | - | - [ ] **Error positions**: the JS EOF token's start column is negative after a | 262 | + | (`ExpressionConstants.MaxLength`): "Exceeded max expression length 21000", |
| 236 | - | trailing newline (stale `start`); ported as is | 263 | + | without a token (the JS throws a bare `ErrorExceededMaxLength`). Depth 50 |
| 237 | - | (`TestEOFTokenPosition`). | 264 | + | is checked on the finished tree (`ExpressionParser.CheckMaxDepth`): the |
| 238 | - | - [ ] **Function names and context names are case-insensitive**; the JS | 265 | + | root is depth 1, an operand of an operator, an argument of a call and the |
| 239 | - | lower-cases with `toLowerCase`, as does this port (`foldKey`). Extension | 266 | + | two sides of an index are one deeper, **parentheses add nothing**, and a |
| 240 | - | functions override built-ins at evaluation (`functions?.get(...) || | 267 | + | chain of `&&` or `||` is one node, nested ones included, so |
| 241 | - | wellKnownFunctions[...]`) but built-ins win at validation time (arity). | 268 | + | `((((...1...))))` with 500 parentheses and `1 || (1 || (1 ...))` parse; |
| 269 | + | `"!" * 50 + "true"` and 50 comparisons chained with `==` do not. The error | |||
| 270 | + | has no token: "Exceeded max expression depth 50". The JS limit counted | |||
| 271 | + | parser recursion (parentheses included). Memory: the runner has | |||
| 272 | + | `EvaluationOptions.MaxMemory` (1 MiB by default) and fails with "The | |||
| 273 | + | maximum allowed memory size was exceeded while evaluating the following | |||
| 274 | + | expression: ...", counting strings at 26 bytes + 2 per UTF-16 unit and 24 | |||
| 275 | + | per object, per call depth (`EvaluationMemory`). This package keeps its | |||
| 276 | + | own output budget (see above), which is not byte-for-byte the same, so | |||
| 277 | + | the fixture `syntax-errors.json` "memory-errors" is a known difference. | |||
| 278 | + | - [x] **Trailing comma in calls**: resolved, follows the C#. `contains('a', 'b',)` | |||
| 279 | + | is "Unexpected symbol: ')'" (the tokenizer rejects a `)` after a `,`). The JS | |||
| 280 | + | accepted it. `f(,)` and `contains(,'a')` fail as before. | |||
| 281 | + | - [x] **Whitespace**: resolved, follows the C#. `Char.IsWhiteSpace` separates | |||
| 282 | + | tokens: U+0009..U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000..U+200A, | |||
| 283 | + | U+2028, U+2029, U+202F, U+205F, U+3000. So `1 \\u00a0 2` is "Unexpected | |||
| 284 | + | symbol: '2'" (not the NBSP), and `\\v1` is 1. The JS skipped only space, | |||
| 285 | + | tab, CR and LF. U+FEFF is not white space. | |||
| 286 | + | - [x] **Empty expression**: same in the C# (`CreateTree` returns null when there | |||
| 287 | + | is no token). `Parse` returns a nil Node and no error; `Evaluate(nil)` | |||
| 288 | + | errors; callers (the template layer) decide. | |||
| 289 | + | - [x] **Unknown contexts at evaluation time** are null: in the runner a | |||
| 290 | + | declared named value is evaluated by the caller's node; here an absent | |||
| 291 | + | key of `EvalContext.Contexts` is null. | |||
| 292 | + | - [x] **Error positions**: resolved, follows the C#. A `ParseException` names | |||
| 293 | + | the token and its **absolute 1-based index in the expression** | |||
| 294 | + | (`Located at position N within expression: <whole expression>`); | |||
| 295 | + | `Error.Pos` is the same place as a zero-based line and column. | |||
| 296 | + | Arity and even-argument errors name the `(` of the call (the token | |||
| 297 | + | `FlushTopEndParameters` holds), not the function: `contains()` is | |||
| 298 | + | "Too few parameters supplied: '('. Located at position 9". The | |||
| 299 | + | end-of-input error is "Unexpected end of expression: '<last token>'" | |||
| 300 | + | (the JS said "Unexpected symbol: 'EOF'"), and the JS's negative EOF column | |||
| 301 | + | is gone with the JS lexer. Unexpected-symbol tokens are the | |||
| 302 | + | boundary-delimited run (`ReadKeywordToken`): `github..event` reports `.` | |||
| 303 | + | where the JS reported `.event`. Lexing and parsing are interleaved, so the | |||
| 304 | + | first problem in reading order wins (the JS lexed everything first). | |||
| 305 | + | `TestParseErrorPositions`, and `TestRunnerOracle` on 1 700 error cases. | |||
| 306 | + | One fixture still differs, see `xlangKnownDifferences`: `case/27` expects | |||
| 307 | + | the name for an even `case()`, the runner passes the `(`. | |||
| 308 | + | - [x] **Function names and context names are case-insensitive**: same in the | |||
| 309 | + | C# (`StringComparer.OrdinalIgnoreCase` dictionaries; see the folding | |||
| 310 | + | item). Built-ins win over extension functions of the same name at | |||
| 311 | + | validation **and at evaluation** (`ExpressionParser.TryGetFunctionInfo` | |||
| 312 | + | looks in `ExpressionConstants.WellKnownFunctions` first and builds the | |||
| 313 | + | node from it); the JS let an extension override a built-in at | |||
| 314 | + | evaluation. An extension supplied under a built-in's name is now never | |||
| 315 | + | called. | |||
| 242 | 316 | ||||
| 243 | ## JavaScript behaviours deliberately NOT ported | 317 | ## JavaScript behaviours deliberately NOT ported | ||
| 244 | 318 | ||||
| @@ -257,36 +331,64 @@ a bug. | |||||
| 257 | evaluating e.g. `github.true` with an empty context: the reference does not | 331 | evaluating e.g. `github.true` with an empty context: the reference does not | ||
| 258 | handle a missing root context. Here it is null. | 332 | handle a missing root context. Here it is null. | ||
| 259 | - Recursion limits: JS recursion depth for `fromJSON` input is engine | 333 | - Recursion limits: JS recursion depth for `fromJSON` input is engine | ||
| 260 | - | dependent (about 5 000 to 10 000 levels). `FromJSON` accepts `MaxJSONDepth` | 334 | + | dependent. `FromJSON` follows Json.NET's `MaxDepth` of 64. |
| 261 | - | (1000) levels. | 335 | + | - Lone surrogates: JS strings are UTF-16 and can hold them. The runner cannot |
| 262 | - | - Lone surrogates: JS strings are UTF-16 and can hold them; Go strings are | 336 | + | (Json.NET reads an unpaired `\ud800` as U+FFFD), and neither can Go strings; |
| 263 | - | UTF-8. They are kept as WTF-8 (`0xED 0xA0-0xBF xx`) so that `fromJSON` / | 337 | + | the WTF-8 machinery of the first version (`wtf8Surrogate`, `normalizeWTF8`) |
| 264 | - | `toJSON` round-trip, `==`/`<`/`contains`/`startsWith`/`endsWith` compare | 338 | + | is only used by the case-folding and comparison helpers for robustness and |
| 265 | - | them like code units, case mapping copies them, and `format`/`join` rejoin a | 339 | + | by the oracle tests. |
| 266 | - | high surrogate followed by a low one into the character they form. This is | 340 | + | |
| 267 | - | covered by oracle cases. Two surrogate strings concatenated by other means | 341 | + | ## Still open |
| 268 | - | (an extension function) are not rejoined. | 342 | + | |
| 343 | + | - [ ] **Memory accounting.** The runner caps an evaluation at `MaxMemory` (1 MiB) | |||
| 344 | + | counted by `EvaluationMemory`/`MemoryCounter` (26 bytes + 2 per UTF-16 unit | |||
| 345 | + | per string, 24 per object, trimmed per call depth); this package caps | |||
| 346 | + | `MaxResultBytes` (1 MiB of output, different units) and accepts | |||
| 347 | + | `format(...)` chains the runner rejects. Fixture | |||
| 348 | + | `syntax-errors/memory-errors/0` is the known difference. Reproducing | |||
| 349 | + | `EvaluationMemory` exactly would need the node depth of every value; | |||
| 350 | + | worth doing only if the error text matters ("The maximum allowed memory | |||
| 351 | + | size was exceeded while evaluating the following expression: ...", which | |||
| 352 | + | needs `ConvertToExpression` of the root, also not ported). | |||
| 353 | + | - [ ] **Negative zero.** `-0` is `0` here. `DTExpressions2/EvaluationResult.cs` | |||
| 354 | + | (the legacy engine) prints `-0` for `format('{0}', -0)` and `toJSON(-0)`, | |||
| 355 | + | because .NET Core prints `-0`; `Expressions/EvaluationResult.cs` (the engine of | |||
| 356 | + | `WorkflowTemplateEvaluator`) maps it to `0`, and the cross-language fixture | |||
| 357 | + | `coerce_string.json` expects `0`. If the legacy engine is what a user sees, | |||
| 358 | + | `-0` would be right in `format()`; the choice was made for the fixture. | |||
| 359 | + | `toJSON` goes through `ConvertToString` in both and so follows the same | |||
| 360 | + | choice. | |||
| 361 | + | - [ ] **Comments and constructors in `fromJSON`.** Json.NET accepts `/* */` and | |||
| 362 | + | `//` comments and `new Date(1)` constructors; the first is read in a | |||
| 363 | + | surprising way (`/*x*/1` is the string `x`). They fail here. | |||
| 364 | + | - [ ] **`fromJSON` error texts.** Json.NET messages ("Unexpected character | |||
| 365 | + | encountered while parsing value: a. Path '', line 0, position 0.") are | |||
| 366 | + | not reproduced; the outer message "Error parsing JSON when evaluating | |||
| 367 | + | fromJson" is the JS one, the runner's exception message is the Json.NET | |||
| 368 | + | one. Only the failure is checked. | |||
| 369 | + | - [ ] **`StrictJsonParsing`.** The newer engine has an option that switches | |||
| 370 | + | `fromJSON` to `System.Text.Json` (strict, 64 levels, different | |||
| 371 | + | messages); default off, "temporary for telemetry". Not implemented. | |||
| 372 | + | - [ ] **Unicode version of the folding table** is that of the .NET 9 runtime that | |||
| 373 | + | generated `fold_tables.go`; a runner on another runtime may fold newly | |||
| 374 | + | assigned letters differently. | |||
| 375 | + | - [ ] **Tracing and secret masking** (`ITraceWriter`, `ISecretMasker`, | |||
| 376 | + | `EvaluationResult` tracing) are runner features outside this package. | |||
| 269 | 377 | ||||
| 270 | - | ## Other things to check once the runner is available | 378 | + | ## Left to the callers |
| 271 | 379 | ||||
| 272 | - | - [ ] Run the runner's expression test vectors (`ExpressionParserL0`, | 380 | + | - Run the runner's expression test vectors: done for `ExpressionParserL0` and |
| 273 | - | `ExpressionTests`, `ExpressionValuesL0`, `ExpressionTestUtil`) through a | 381 | + | `ConditionFunctionsL0` (`runner_l0_test.go`). The runner has no other |
| 274 | - | table adapter; list every disagreement here before deciding. | 382 | + | expression tests in `src/Test/L0` (the larger suites are in the closed |
| 275 | - | - [ ] Decide whether `EvalContext` needs a hook for the functions the runner | 383 | + | service); the cross-language fixtures cover the rest. |
| 276 | - | implements natively with special semantics: `success()`, `failure()`, | 384 | + | - The functions the runner implements natively with special semantics: |
| 277 | - | `always()`, `cancelled()` (take no arguments; read job status) and | 385 | + | `success()`, `failure()`, `always()`, `cancelled()` (no arguments; read job |
| 278 | - | `hashFiles()` (variadic glob patterns, string result). The API supports | 386 | + | status, or `action_status` in a composite action's main step) and |
| 279 | - | them through `FuncInfo` + `Func` without changes; arity is declared by | 387 | + | `hashFiles()` (variadic, string result) are supplied by the caller through |
| 280 | - | the caller. | 388 | + | `FuncInfo` + `Func`; `runner_l0_test.go` shows the declaration the runner |
| 281 | - | - [ ] Context names the runner allows per location (`github`, `env`, `vars`, | 389 | + | uses (`FunctionInfo(name, 0, 0)`). |
| 282 | - | `job`, `jobs`, `steps`, `runner`, `secrets`, `strategy`, `matrix`, | 390 | + | - Context names per location and which functions each location may call belong |
| 283 | - | `needs`, `inputs`) and which functions each location may call | 391 | + | to the template layer. |
| 284 | - | (`success`... only in `if`/`jobs.*.if`...). This belongs to the template | 392 | + | - The `format()` argument limit is 255 in both (`Byte.MaxValue`). |
| 285 | - | layer, not to package expr. | 393 | + | - Secret masking and the `${{ }}` splitting of a larger string are not part of |
| 286 | - | - [ ] `format()` argument limit is 255 in the JS (`MAX_ARGUMENTS`); the C# | 394 | + | this package: `Parse` takes the text between the braces only. |
| 287 | - | limit is `ExpressionConstants.MaxArguments` (check). | |||
| 288 | - | - [x] Case-insensitive comparison and `contains`: `OrdinalIgnoreCase` is the | |||
| 289 | - | truth, see above. | |||
| 290 | - | - [ ] Secret masking and the `${{ }}` splitting of a larger string are not | |||
| 291 | - | part of this package (they belong to the template layer): `Parse` | |||
| 292 | - | takes the text between the braces only. | |||
M expr/doc.go +9 -6
| @@ -6,12 +6,15 @@ | |||
| 6 | 6 | // Package expr implements GitHub Actions expressions: the language found | |
| 7 | 7 | // between the braces of ${{ }} in workflow files. | |
| 8 | 8 | // | |
| 9 | - | // The behaviour is a faithful port of GitHub's own implementation published | |
| 10 | - | // as the npm package @actions/expressions (MIT, actions/languageservices): | |
| 11 | - | // same tokens, precedence, error messages, coercions and built-in functions. | |
| 12 | - | // Where that implementation is believed to differ from the hosted service | |
| 13 | - | // (the C# runner in actions/runner), the Go code follows the JavaScript and | |
| 14 | - | // says so in a comment; PORTING.md lists what remains to be checked. | |
| 9 | + | // The behaviour is that of the C# runner (actions/runner, MIT), the | |
| 10 | + | // implementation that evaluates expressions on github.com: its tokenizer and | |
| 11 | + | // parser, error messages, coercions, case-insensitive comparison, number | |
| 12 | + | // formatting, JSON reader and writer, and built-in functions. It was first | |
| 13 | + | // ported from GitHub's editor library, the npm package @actions/expressions | |
| 14 | + | // (MIT, actions/languageservices), then checked against the runner on 34 765 | |
| 15 | + | // expressions and against the shared cross-language fixtures; where the two | |
| 16 | + | // differ, the runner wins. PORTING.md lists every difference with the C# | |
| 17 | + | // source that decides it. | |
| 15 | 18 | // | |
| 16 | 19 | // The entry points are [Parse], which turns the text of one expression into a | |
| 17 | 20 | // [Node], and [Evaluate], which computes its [Value] against the named | |
| @@ -6,12 +6,15 @@ | |||||
| 6 | // Package expr implements GitHub Actions expressions: the language found | 6 | // Package expr implements GitHub Actions expressions: the language found | ||
| 7 | // between the braces of ${{ }} in workflow files. | 7 | // between the braces of ${{ }} in workflow files. | ||
| 8 | // | 8 | // | ||
| 9 | - | // The behaviour is a faithful port of GitHub's own implementation published | 9 | + | // The behaviour is that of the C# runner (actions/runner, MIT), the |
| 10 | - | // as the npm package @actions/expressions (MIT, actions/languageservices): | 10 | + | // implementation that evaluates expressions on github.com: its tokenizer and |
| 11 | - | // same tokens, precedence, error messages, coercions and built-in functions. | 11 | + | // parser, error messages, coercions, case-insensitive comparison, number |
| 12 | - | // Where that implementation is believed to differ from the hosted service | 12 | + | // formatting, JSON reader and writer, and built-in functions. It was first |
| 13 | - | // (the C# runner in actions/runner), the Go code follows the JavaScript and | 13 | + | // ported from GitHub's editor library, the npm package @actions/expressions |
| 14 | - | // says so in a comment; PORTING.md lists what remains to be checked. | 14 | + | // (MIT, actions/languageservices), then checked against the runner on 34 765 |
| 15 | + | // expressions and against the shared cross-language fixtures; where the two | |||
| 16 | + | // differ, the runner wins. PORTING.md lists every difference with the C# | |||
| 17 | + | // source that decides it. | |||
| 15 | // | 18 | // | ||
| 16 | // The entry points are [Parse], which turns the text of one expression into a | 19 | // The entry points are [Parse], which turns the text of one expression into a | ||
| 17 | // [Node], and [Evaluate], which computes its [Value] against the named | 20 | // [Node], and [Evaluate], which computes its [Value] against the named | ||
M expr/eval.go +21 -26
| @@ -42,8 +42,8 @@ type EvalContext struct { | |||
| 42 | 42 | Contexts map[string]Value | |
| 43 | 43 | // Funcs implements the extra functions declared to [Parse], such as | |
| 44 | 44 | // hashFiles, success, always, cancelled and failure. Names are matched | |
| 45 | - | // case-insensitively. As in the reference implementation these take | |
| 46 | - | // precedence over the built-in functions. | |
| 45 | + | // case-insensitively. The built-in functions cannot be | |
| 46 | + | // replaced: as in the runner, they win over a function of the same name. | |
| 47 | 47 | Funcs map[string]Func | |
| 48 | 48 | // MaxResultBytes is the output budget of the evaluation, see | |
| 49 | 49 | // [MaxResultBytes]. Zero means the default; a negative value removes the | |
| @@ -233,25 +233,22 @@ func (e *evaluator) logical(l *Logical) (Value, error) { | |||
| 233 | 233 | ||
| 234 | 234 | func (e *evaluator) call(c *FunctionCall) (Value, error) { | |
| 235 | 235 | name := foldKey(c.Name.Lexeme) | |
| 236 | - | ext, isExt := e.funcs[name] | |
| 237 | - | isExt = isExt && ext != nil | |
| 238 | - | var impl builtinFunc | |
| 239 | - | if !isExt { | |
| 240 | - | // Arity of the built-ins is checked by the parser, and again here | |
| 241 | - | // so that a hand-built tree cannot reach an implementation with the | |
| 242 | - | // wrong number of arguments. | |
| 243 | - | f, ok := builtinFuncs[name] | |
| 244 | - | if !ok { | |
| 245 | - | return nil, evalErrorf("function %s is not implemented", c.Name.Lexeme) | |
| 246 | - | } | |
| 236 | + | // Built-ins win over extension functions of the same name, as in the | |
| 237 | + | // runner (ExpressionParser.TryGetFunctionInfo looks in WellKnownFunctions | |
| 238 | + | // first); the JavaScript reference let an extension override them here. | |
| 239 | + | if f, ok := builtinFuncs[name]; ok { | |
| 240 | + | // Arity is checked by the parser, and again here so that a hand-built | |
| 241 | + | // tree cannot reach an implementation with the wrong number of | |
| 242 | + | // arguments. | |
| 247 | 243 | if t, bad := arityError(name, f, len(c.Args)); bad { | |
| 248 | 244 | return nil, &EvalError{Message: t.description() + ": '" + c.Name.Lexeme + "'"} | |
| 249 | 245 | } | |
| 250 | - | impl = builtinImpls[name] | |
| 251 | - | } | |
| 252 | - | if !isExt { | |
| 253 | 246 | // Built-ins evaluate their own arguments, lazily. | |
| 254 | - | return impl(e, c) | |
| 247 | + | return builtinImpls[name](e, c) | |
| 248 | + | } | |
| 249 | + | ext, ok := e.funcs[name] | |
| 250 | + | if !ok || ext == nil { | |
| 251 | + | return nil, evalErrorf("function %s is not implemented", c.Name.Lexeme) | |
| 255 | 252 | } | |
| 256 | 253 | args := make([]Value, len(c.Args)) | |
| 257 | 254 | for i, a := range c.Args { | |
| @@ -261,17 +258,15 @@ func (e *evaluator) call(c *FunctionCall) (Value, error) { | |||
| 261 | 258 | } | |
| 262 | 259 | args[i] = v | |
| 263 | 260 | } | |
| 264 | - | { | |
| 265 | - | v, err := ext(e.ctx, args) | |
| 266 | - | if err != nil { | |
| 267 | - | var ee *EvalError | |
| 268 | - | if errors.As(err, &ee) { | |
| 269 | - | return nil, err | |
| 270 | - | } | |
| 271 | - | return nil, &EvalError{Message: err.Error(), Err: err} | |
| 261 | + | v, err := ext(e.ctx, args) | |
| 262 | + | if err != nil { | |
| 263 | + | var ee *EvalError | |
| 264 | + | if errors.As(err, &ee) { | |
| 265 | + | return nil, err | |
| 272 | 266 | } | |
| 273 | - | return v, nil | |
| 267 | + | return nil, &EvalError{Message: err.Error(), Err: err} | |
| 274 | 268 | } | |
| 269 | + | return v, nil | |
| 275 | 270 | } | |
| 276 | 271 | ||
| 277 | 272 | // indexer is idxHelper of the reference: how an index value is read, as a | |
| @@ -42,8 +42,8 @@ type EvalContext struct { | |||||
| 42 | Contexts map[string]Value | 42 | Contexts map[string]Value | ||
| 43 | // Funcs implements the extra functions declared to [Parse], such as | 43 | // Funcs implements the extra functions declared to [Parse], such as | ||
| 44 | // hashFiles, success, always, cancelled and failure. Names are matched | 44 | // hashFiles, success, always, cancelled and failure. Names are matched | ||
| 45 | - | // case-insensitively. As in the reference implementation these take | 45 | + | // case-insensitively. The built-in functions cannot be |
| 46 | - | // precedence over the built-in functions. | 46 | + | // replaced: as in the runner, they win over a function of the same name. |
| 47 | Funcs map[string]Func | 47 | Funcs map[string]Func | ||
| 48 | // MaxResultBytes is the output budget of the evaluation, see | 48 | // MaxResultBytes is the output budget of the evaluation, see | ||
| 49 | // [MaxResultBytes]. Zero means the default; a negative value removes the | 49 | // [MaxResultBytes]. Zero means the default; a negative value removes the | ||
| @@ -233,25 +233,22 @@ func (e *evaluator) logical(l *Logical) (Value, error) { | |||||
| 233 | 233 | ||||
| 234 | func (e *evaluator) call(c *FunctionCall) (Value, error) { | 234 | func (e *evaluator) call(c *FunctionCall) (Value, error) { | ||
| 235 | name := foldKey(c.Name.Lexeme) | 235 | name := foldKey(c.Name.Lexeme) | ||
| 236 | - | ext, isExt := e.funcs[name] | 236 | + | // Built-ins win over extension functions of the same name, as in the |
| 237 | - | isExt = isExt && ext != nil | 237 | + | // runner (ExpressionParser.TryGetFunctionInfo looks in WellKnownFunctions |
| 238 | - | var impl builtinFunc | 238 | + | // first); the JavaScript reference let an extension override them here. |
| 239 | - | if !isExt { | 239 | + | if f, ok := builtinFuncs[name]; ok { |
| 240 | - | // Arity of the built-ins is checked by the parser, and again here | 240 | + | // Arity is checked by the parser, and again here so that a hand-built |
| 241 | - | // so that a hand-built tree cannot reach an implementation with the | 241 | + | // tree cannot reach an implementation with the wrong number of |
| 242 | - | // wrong number of arguments. | 242 | + | // arguments. |
| 243 | - | f, ok := builtinFuncs[name] | |||
| 244 | - | if !ok { | |||
| 245 | - | return nil, evalErrorf("function %s is not implemented", c.Name.Lexeme) | |||
| 246 | - | } | |||
| 247 | if t, bad := arityError(name, f, len(c.Args)); bad { | 243 | if t, bad := arityError(name, f, len(c.Args)); bad { | ||
| 248 | return nil, &EvalError{Message: t.description() + ": '" + c.Name.Lexeme + "'"} | 244 | return nil, &EvalError{Message: t.description() + ": '" + c.Name.Lexeme + "'"} | ||
| 249 | } | 245 | } | ||
| 250 | - | impl = builtinImpls[name] | |||
| 251 | - | } | |||
| 252 | - | if !isExt { | |||
| 253 | // Built-ins evaluate their own arguments, lazily. | 246 | // Built-ins evaluate their own arguments, lazily. | ||
| 254 | - | return impl(e, c) | 247 | + | return builtinImpls[name](e, c) |
| 248 | + | } | |||
| 249 | + | ext, ok := e.funcs[name] | |||
| 250 | + | if !ok || ext == nil { | |||
| 251 | + | return nil, evalErrorf("function %s is not implemented", c.Name.Lexeme) | |||
| 255 | } | 252 | } | ||
| 256 | args := make([]Value, len(c.Args)) | 253 | args := make([]Value, len(c.Args)) | ||
| 257 | for i, a := range c.Args { | 254 | for i, a := range c.Args { | ||
| @@ -261,17 +258,15 @@ func (e *evaluator) call(c *FunctionCall) (Value, error) { | |||||
| 261 | } | 258 | } | ||
| 262 | args[i] = v | 259 | args[i] = v | ||
| 263 | } | 260 | } | ||
| 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 *EvalError |
| 267 | - | var ee *EvalError | 264 | + | if errors.As(err, &ee) { |
| 268 | - | if errors.As(err, &ee) { | 265 | + | return nil, err |
| 269 | - | return nil, err | |||
| 270 | - | } | |||
| 271 | - | return nil, &EvalError{Message: err.Error(), Err: err} | |||
| 272 | } | 266 | } | ||
| 273 | - | return v, nil | 267 | + | return nil, &EvalError{Message: err.Error(), Err: err} |
| 274 | } | 268 | } | ||
| 269 | + | return v, nil | |||
| 275 | } | 270 | } | ||
| 276 | 271 | ||||
| 277 | // indexer is idxHelper of the reference: how an index value is read, as a | 272 | // indexer is idxHelper of the reference: how an index value is read, as a | ||
M expr/eval_test.go +71 -58
| @@ -81,14 +81,14 @@ func TestEvalLiteralsAndOperators(t *testing.T) { | |||
| 81 | 81 | {"0x10", "Number:16"}, | |
| 82 | 82 | {"0o17", "Number:15"}, | |
| 83 | 83 | {"0xffffffff", "Number:-1"}, // read as a 32-bit integer, like the runner | |
| 84 | - | {"0b101", "PARSE Unexpected symbol: '0b101'. Located at position 1 within expression: 0b101"}, | |
| 85 | - | {"0X10", "PARSE Unexpected symbol: '0X10'. Located at position 1 within expression: 0X10"}, | |
| 84 | + | {"0b101", "PARSE Unexpected symbol: '0b101'"}, | |
| 85 | + | {"0X10", "PARSE Unexpected symbol: '0X10'"}, | |
| 86 | 86 | {"'0b11' == 3", "Boolean:false"}, | |
| 87 | 87 | {"'0x1F' == 31", "Boolean:true"}, | |
| 88 | 88 | {"'infinity' == Infinity", "Boolean:true"}, | |
| 89 | 89 | {"'\u0085 1 \u0085' == 1", "Boolean:true"}, | |
| 90 | 90 | {"'\ufeff1' == 1", "Boolean:false"}, | |
| 91 | - | {"-0x10", "PARSE Unexpected symbol: '-0x10'. Located at position 1 within expression: -0x10"}, | |
| 91 | + | {"-0x10", "PARSE Unexpected symbol: '-0x10'"}, | |
| 92 | 92 | {"NaN", "Number:NaN"}, | |
| 93 | 93 | {"Infinity", "Number:Infinity"}, | |
| 94 | 94 | {"-Infinity", "Number:-Infinity"}, | |
| @@ -290,7 +290,7 @@ func TestEvalAccess(t *testing.T) { | |||
| 290 | 290 | {"steps[10].outputs.r", "String:ten"}, | |
| 291 | 291 | {"steps['10'].outputs.r", "String:ten"}, | |
| 292 | 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'. Located at position 7 within expression: steps.10.outputs.r"}, | |
| 293 | + | {"steps.10.outputs.r", "PARSE Unexpected symbol: '10.outputs.r'"}, | |
| 294 | 294 | {"steps[2].outputs.r", "String:two"}, | |
| 295 | 295 | {"matrix.obj[0]", "Null:"}, | |
| 296 | 296 | {"matrix.node['0']", "Number:14"}, | |
| @@ -366,9 +366,9 @@ func TestEvalBuiltinFunctions(t *testing.T) { | |||
| 366 | 366 | {"contains('Dž', 'dž')", "Boolean:true"}, | |
| 367 | 367 | {"contains('A\u03a3', 'a\u03c2')", "Boolean:true"}, | |
| 368 | 368 | {"contains('K', 'k')", "Boolean:true"}, // KELVIN SIGN lower-cases to k | |
| 369 | - | {"contains()", "PARSE Too few parameters supplied: 'contains'"}, | |
| 370 | - | {"contains(1)", "PARSE Too few parameters supplied: 'contains'"}, | |
| 371 | - | {"contains(1, 2, 3)", "PARSE Too many parameters supplied: 'contains'"}, | |
| 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 | 372 | ||
| 373 | 373 | // startsWith, endsWith | |
| 374 | 374 | {"startsWith('abc', 'AB')", "Boolean:true"}, | |
| @@ -383,7 +383,7 @@ func TestEvalBuiltinFunctions(t *testing.T) { | |||
| 383 | 383 | {"endsWith('abc', 'abcd')", "Boolean:false"}, | |
| 384 | 384 | {"endsWith(1.50, '5')", "Boolean:true"}, | |
| 385 | 385 | {"endsWith(1.50, 5)", "Boolean:true"}, | |
| 386 | - | {"endsWith('abc')", "PARSE Too few parameters supplied: 'endsWith'"}, | |
| 386 | + | {"endsWith('abc')", "PARSE Too few parameters supplied: '('"}, | |
| 387 | 387 | {"STARTSWITH('abc', 'a')", "Boolean:true"}, | |
| 388 | 388 | ||
| 389 | 389 | // join | |
| @@ -400,8 +400,8 @@ func TestEvalBuiltinFunctions(t *testing.T) { | |||
| 400 | 400 | {"join(fromJSON('[1,2,3]'), null)", "String:123"}, | |
| 401 | 401 | {"join(fromJSON('[1,2,3]'), github)", "String:1,2,3"}, // a non-primitive separator is ignored | |
| 402 | 402 | {"join(fromJSON('[null,true,\"a\",1.5,[1],{}]'))", "String:,true,a,1.5,Array,Object"}, | |
| 403 | - | {"join()", "PARSE Too few parameters supplied: 'join'"}, | |
| 404 | - | {"join(1, 2, 3)", "PARSE Too many parameters supplied: 'join'"}, | |
| 403 | + | {"join()", "PARSE Too few parameters supplied: '('"}, | |
| 404 | + | {"join(1, 2, 3)", "PARSE Too many parameters supplied: '('"}, | |
| 405 | 405 | ||
| 406 | 406 | // toJSON | |
| 407 | 407 | {"toJSON(null)", "String:null"}, | |
| @@ -417,8 +417,8 @@ func TestEvalBuiltinFunctions(t *testing.T) { | |||
| 417 | 417 | {"toJSON(fromJSON('[]'))", "String:[]"}, | |
| 418 | 418 | {"toJSON(fromJSON('{}'))", "String:{}"}, | |
| 419 | 419 | {"toJSON(fromJSON('[[],{}]'))", "String:[\n [],\n {}\n]"}, | |
| 420 | - | {"toJSON(1, 2)", "PARSE Too many parameters supplied: 'toJSON'"}, | |
| 421 | - | {"toJSON()", "PARSE Too few parameters supplied: 'toJSON'"}, | |
| 420 | + | {"toJSON(1, 2)", "PARSE Too many parameters supplied: '('"}, | |
| 421 | + | {"toJSON()", "PARSE Too few parameters supplied: '('"}, | |
| 422 | 422 | {"fromJSON(toJSON(github.event.commits[0])).author.name", "String:Ann"}, | |
| 423 | 423 | ||
| 424 | 424 | // fromJSON | |
| @@ -442,7 +442,7 @@ func TestEvalBuiltinFunctions(t *testing.T) { | |||
| 442 | 442 | {"fromJSON(1)", "Number:1"}, | |
| 443 | 443 | {"fromJSON(true)", "Boolean:true"}, | |
| 444 | 444 | {"fromJSON(matrix.node)", "ERR Error parsing JSON when evaluating fromJson"}, // the string "Array" | |
| 445 | - | {"fromJSON(1, 2)", "PARSE Too many parameters supplied: 'fromJSON'"}, | |
| 445 | + | {"fromJSON(1, 2)", "PARSE Too many parameters supplied: '('"}, | |
| 446 | 446 | ||
| 447 | 447 | // format | |
| 448 | 448 | {"format('a')", "String:a"}, | |
| @@ -493,7 +493,7 @@ func TestEvalBuiltinFunctions(t *testing.T) { | |||
| 493 | 493 | {"format('{0}}', 1)", "ERR The following format string is invalid: {0}}"}, | |
| 494 | 494 | // The index is a byte: a longer number makes the string invalid. | |
| 495 | 495 | {"format('{12345678901234567890123}', 1)", "ERR The following format string is invalid: {12345678901234567890123}"}, | |
| 496 | - | {"format()", "PARSE Too few parameters supplied: 'format'"}, | |
| 496 | + | {"format()", "PARSE Too few parameters supplied: '('"}, | |
| 497 | 497 | ||
| 498 | 498 | // case | |
| 499 | 499 | {"case(true, 'a', 'b')", "String:a"}, | |
| @@ -506,9 +506,9 @@ func TestEvalBuiltinFunctions(t *testing.T) { | |||
| 506 | 506 | {"case(1, 2, 3)", "ERR case predicate must evaluate to a boolean value"}, | |
| 507 | 507 | {"case('true', 2, 3)", "ERR case predicate must evaluate to a boolean value"}, | |
| 508 | 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: 'case'"}, | |
| 510 | - | {"case(true, 1)", "PARSE Too few parameters supplied: 'case'"}, | |
| 511 | - | {"case()", "PARSE Too few parameters supplied: 'case'"}, | |
| 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 | 512 | // Predicates after the first true one are not checked... | |
| 513 | 513 | {"case(true, 1, 'bad', 2, 3)", "Number:1"}, | |
| 514 | 514 | // ...but earlier ones are. | |
| @@ -523,7 +523,7 @@ func TestEvalErrorMessages(t *testing.T) { | |||
| 523 | 523 | {"", "PARSE Unexpected end of expression: 'x'"}, // replaced below: the empty expression is not an error | |
| 524 | 524 | }[:0]) | |
| 525 | 525 | cases := []evalCase{ | |
| 526 | - | {"1 +", "PARSE Unexpected symbol: '+'. Located at position 3 within expression: 1 +"}, | |
| 526 | + | {"1 +", "PARSE Unexpected symbol: '+'"}, | |
| 527 | 527 | {"a b", "PARSE Unrecognized named-value: 'a'"}, | |
| 528 | 528 | {"github github", "PARSE Unexpected symbol: 'github'"}, | |
| 529 | 529 | {"github.event_name ==", "PARSE Unexpected end of expression: '=='"}, | |
| @@ -531,9 +531,9 @@ func TestEvalErrorMessages(t *testing.T) { | |||
| 531 | 531 | {"(github.event_name", "PARSE Unexpected end of expression: 'event_name'"}, | |
| 532 | 532 | {"github.event_name)", "PARSE Unexpected symbol: ')'"}, | |
| 533 | 533 | {"contains('a',", "PARSE Unexpected end of expression: ','"}, | |
| 534 | - | {"contains('a'", "PARSE Unexpected symbol: 'EOF'"}, | |
| 535 | - | {"contains('a' 'b')", "PARSE Unexpected symbol: 'b'"}, | |
| 536 | - | {"foo(", "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 | 537 | {"foo()", "PARSE Unrecognized function: 'foo'"}, | |
| 538 | 538 | {"foo", "PARSE Unrecognized named-value: 'foo'"}, | |
| 539 | 539 | {"unknown(1)", "PARSE Unrecognized function: 'unknown'"}, | |
| @@ -545,22 +545,22 @@ func TestEvalErrorMessages(t *testing.T) { | |||
| 545 | 545 | {"1 !", "PARSE Unexpected symbol: '!'"}, | |
| 546 | 546 | {"[1]", "PARSE Unexpected symbol: '['"}, | |
| 547 | 547 | {"github.[0]", "PARSE Unexpected symbol: '['"}, | |
| 548 | - | {"github.", "PARSE Unexpected symbol: 'EOF'"}, | |
| 548 | + | {"github.", "PARSE Unexpected end of expression: '.'"}, | |
| 549 | 549 | {"github[", "PARSE Unexpected end of expression: '['"}, | |
| 550 | 550 | {"github[]", "PARSE Unexpected symbol: ']'"}, | |
| 551 | - | {"github[*", "PARSE Unexpected symbol: 'EOF'"}, | |
| 551 | + | {"github[*", "PARSE Unexpected end of expression: '*'"}, | |
| 552 | 552 | {"github.true", "Null:"}, // keywords are plain identifiers after a dot | |
| 553 | 553 | {"env.true", "Null:"}, | |
| 554 | - | {"1 = 1", "PARSE Unexpected symbol: '='. Located at position 3 within expression: 1 = 1"}, | |
| 555 | - | {"1 & 1", "PARSE Unexpected symbol: '&'. Located at position 3 within expression: 1 & 1"}, | |
| 556 | - | {"1 | 1", "PARSE Unexpected symbol: '|'. Located at position 3 within expression: 1 | 1"}, | |
| 557 | - | {"1 + 1", "PARSE Unexpected symbol: '+'. Located at position 3 within expression: 1 + 1"}, | |
| 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 | 558 | {"a-b", "PARSE Unrecognized named-value: 'a-b'"}, | |
| 559 | - | {"1a", "PARSE Unexpected symbol: '1a'. Located at position 1 within expression: 1a"}, | |
| 560 | - | {"'unterminated", "PARSE Unexpected symbol: ''unterminated'. Located at position 1 within expression: 'unterminated"}, | |
| 561 | - | {"@", "PARSE Unexpected symbol: '@'. Located at position 1 within expression: @"}, | |
| 562 | - | {"${{ 1 }}", "PARSE Unexpected symbol: '${{'. Located at position 1 within expression: ${{ 1 }}"}, | |
| 563 | - | {"1 \u00a0 2", "PARSE Unexpected symbol: '\u00a0'. Located at position 3 within expression: 1 \u00a0 2"}, | |
| 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 | 564 | } | |
| 565 | 565 | runEvalCases(t, cases) | |
| 566 | 566 | } | |
| @@ -577,6 +577,9 @@ func TestEmptyExpression(t *testing.T) { | |||
| 577 | 577 | } | |
| 578 | 578 | } | |
| 579 | 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. | |
| 580 | 583 | func TestParseErrorPositions(t *testing.T) { | |
| 581 | 584 | tests := []struct { | |
| 582 | 585 | src string | |
| @@ -584,14 +587,16 @@ func TestParseErrorPositions(t *testing.T) { | |||
| 584 | 587 | msg string | |
| 585 | 588 | }{ | |
| 586 | 589 | {"1 ==\n 2 ===\n 3", 1, 5, "Unexpected symbol: '='. Located at position 11 within expression: 1 ==\n 2 ===\n 3"}, | |
| 587 | - | {"true &&\n false ||\n nope", 2, 2, "Unrecognized named-value: 'nope'"}, | |
| 588 | - | {"\n\n bogus", 2, 2, "Unrecognized named-value: 'bogus'"}, | |
| 589 | - | {"github github", 0, 7, "Unexpected symbol: 'github'"}, | |
| 590 | - | {" ) ", 0, 2, "Unexpected symbol: ')'"}, | |
| 591 | - | {"contains('a',", 0, 12, "Unexpected end of expression: ','"}, | |
| 592 | - | {"foo()", 0, 0, "Unrecognized function: 'foo'"}, | |
| 593 | - | {"1 && foo()", 0, 5, "Unrecognized function: 'foo'"}, | |
| 594 | - | {"'a'\n 'b'\n zzz", 1, 1, "Unexpected symbol: 'b'"}, | |
| 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"}, | |
| 595 | 600 | } | |
| 596 | 601 | for _, tc := range tests { | |
| 597 | 602 | _, err := Parse(tc.src, testContextNames, nil) | |
| @@ -618,7 +623,7 @@ func TestParseErrorTypes(t *testing.T) { | |||
| 618 | 623 | {"contains(1)", ErrorTooFewParameters}, | |
| 619 | 624 | {"contains(1,2,3)", ErrorTooManyParameters}, | |
| 620 | 625 | {"case(true, 1, 2, 3)", ErrorEvenParameters}, | |
| 621 | - | {strings.Repeat("(", 60) + "1" + strings.Repeat(")", 60), ErrorExceededMaxDepth}, | |
| 626 | + | {strings.Repeat("!", 60) + "1", ErrorExceededMaxDepth}, | |
| 622 | 627 | {"1 = 1", ErrorUnexpectedSymbol}, | |
| 623 | 628 | } | |
| 624 | 629 | for _, tc := range tests { | |
| @@ -630,6 +635,10 @@ func TestParseErrorTypes(t *testing.T) { | |||
| 630 | 635 | } | |
| 631 | 636 | } | |
| 632 | 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. | |
| 633 | 642 | func TestLimits(t *testing.T) { | |
| 634 | 643 | deepParens := func(n int) string { return strings.Repeat("(", n) + "1" + strings.Repeat(")", n) } | |
| 635 | 644 | tests := []struct { | |
| @@ -637,9 +646,7 @@ func TestLimits(t *testing.T) { | |||
| 637 | 646 | src string | |
| 638 | 647 | ok bool | |
| 639 | 648 | }{ | |
| 640 | - | // Each "(" costs one level, plus one for the outermost expression. | |
| 641 | - | {"49 parens", deepParens(49), true}, | |
| 642 | - | {"50 parens", deepParens(50), false}, | |
| 649 | + | {"parentheses do not count", deepParens(500), true}, | |
| 643 | 650 | {"49 nots", strings.Repeat("!", 49) + "true", true}, | |
| 644 | 651 | {"50 nots", strings.Repeat("!", 50) + "true", false}, | |
| 645 | 652 | {"49 calls", strings.Repeat("format(", 49) + "'x'" + strings.Repeat(")", 49), true}, | |
| @@ -647,7 +654,9 @@ func TestLimits(t *testing.T) { | |||
| 647 | 654 | {"long dotted path", "github" + strings.Repeat(".event", 49), true}, | |
| 648 | 655 | {"too long dotted path", "github" + strings.Repeat(".event", 50), false}, | |
| 649 | 656 | {"long flat ||", "1" + strings.Repeat(" || 1", 3000), true}, | |
| 650 | - | {"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}, | |
| 651 | 660 | } | |
| 652 | 661 | for _, tc := range tests { | |
| 653 | 662 | _, err := Parse(tc.src, testContextNames, nil) | |
| @@ -655,18 +664,18 @@ func TestLimits(t *testing.T) { | |||
| 655 | 664 | t.Errorf("%s: err = %v, want ok=%v", tc.name, err, tc.ok) | |
| 656 | 665 | } | |
| 657 | 666 | var pe *Error | |
| 658 | - | if err != nil && errors.As(err, &pe) && pe.Type == ErrorExceededMaxDepth && pe.Message != "Exceeded max expression depth 50: '1'" && !strings.HasPrefix(pe.Message, "Exceeded max expression depth 50: '") { | |
| 667 | + | if err != nil && errors.As(err, &pe) && pe.Type == ErrorExceededMaxDepth && pe.Message != "Exceeded max expression depth 50" { | |
| 659 | 668 | t.Errorf("%s: message %q", tc.name, pe.Message) | |
| 660 | 669 | } | |
| 661 | 670 | } | |
| 662 | 671 | ||
| 663 | - | // Length: counted in UTF-16 code units, error is the bare token. | |
| 672 | + | // Length: counted in UTF-16 code units, no token in the message. | |
| 664 | 673 | long := "'" + strings.Repeat("a", MaxExpressionLength-2) + "'" | |
| 665 | 674 | if _, err := Parse(long, nil, nil); err != nil { | |
| 666 | 675 | t.Errorf("expression of exactly %d characters: %v", MaxExpressionLength, err) | |
| 667 | 676 | } | |
| 668 | 677 | _, err := Parse(long+" ", nil, nil) | |
| 669 | - | if err == nil || err.Error() != "ErrorExceededMaxLength" { | |
| 678 | + | if err == nil || err.Error() != "Exceeded max expression length 21000" { | |
| 670 | 679 | t.Errorf("expression of %d characters: err = %v", MaxExpressionLength+1, err) | |
| 671 | 680 | } | |
| 672 | 681 | // 10501 emoji are 21002 UTF-16 units but only 10501 runes. | |
| @@ -681,8 +690,8 @@ func TestLimits(t *testing.T) { | |||
| 681 | 690 | } | |
| 682 | 691 | ||
| 683 | 692 | func TestDepthMessage(t *testing.T) { | |
| 684 | - | _, err := Parse(strings.Repeat("(", 60)+"1"+strings.Repeat(")", 60), nil, nil) | |
| 685 | - | want := "Exceeded max expression depth 50: '('" | |
| 693 | + | _, err := Parse(strings.Repeat("!", 60)+"1", nil, nil) | |
| 694 | + | want := "Exceeded max expression depth 50" | |
| 686 | 695 | if err == nil || err.Error() != want { | |
| 687 | 696 | t.Errorf("got %v, want %q", err, want) | |
| 688 | 697 | } | |
| @@ -698,19 +707,23 @@ func TestExtensionFunctions(t *testing.T) { | |||
| 698 | 707 | for _, c := range []struct{ src, err string }{ | |
| 699 | 708 | {"hashFiles('a')", ""}, | |
| 700 | 709 | {"HASHFILES('a', 'b', 'c', 'd')", ""}, | |
| 701 | - | {"hashFiles()", "Too few parameters supplied: 'hashFiles'"}, | |
| 710 | + | {"hashFiles()", "Too few parameters supplied: '('"}, | |
| 702 | 711 | {"success()", ""}, | |
| 703 | - | {"success(1)", "Too many parameters supplied: 'success'"}, | |
| 712 | + | {"success(1)", "Too many parameters supplied: '('"}, | |
| 704 | 713 | {"bounded(1, 2)", ""}, | |
| 705 | - | {"bounded(1, 2, 3)", "Too many parameters supplied: 'bounded'"}, | |
| 706 | - | {"contains()", "Too few parameters supplied: 'contains'"}, | |
| 714 | + | {"bounded(1, 2, 3)", "Too many parameters supplied: '('"}, | |
| 715 | + | {"contains()", "Too few parameters supplied: '('"}, | |
| 707 | 716 | {"nothere()", "Unrecognized function: 'nothere'"}, | |
| 708 | 717 | } { | |
| 709 | 718 | _, err := Parse(c.src, nil, funcs) | |
| 719 | + | msg := "" | |
| 720 | + | if err != nil { | |
| 721 | + | msg, _, _ = strings.Cut(err.Error(), ". Located at position ") | |
| 722 | + | } | |
| 710 | 723 | switch { | |
| 711 | 724 | case c.err == "" && err != nil: | |
| 712 | 725 | t.Errorf("%s: %v", c.src, err) | |
| 713 | - | case c.err != "" && (err == nil || err.Error() != c.err): | |
| 726 | + | case c.err != "" && (err == nil || msg != c.err): | |
| 714 | 727 | t.Errorf("%s: err = %v, want %q", c.src, err, c.err) | |
| 715 | 728 | } | |
| 716 | 729 | } | |
| @@ -725,7 +738,7 @@ func TestExtensionFunctions(t *testing.T) { | |||
| 725 | 738 | }, | |
| 726 | 739 | "success": func(context.Context, []Value) (Value, error) { return Boolean(true), nil }, | |
| 727 | 740 | "boom": func(context.Context, []Value) (Value, error) { return nil, errors.New("kaput") }, | |
| 728 | - | // An extra function replaces a built-in of the same name. | |
| 741 | + | // An extra function does not replace a built-in of the same name. | |
| 729 | 742 | "contains": func(context.Context, []Value) (Value, error) { return String("mine"), nil }, | |
| 730 | 743 | }, | |
| 731 | 744 | } | |
| @@ -734,7 +747,7 @@ func TestExtensionFunctions(t *testing.T) { | |||
| 734 | 747 | t.Fatal(err) | |
| 735 | 748 | } | |
| 736 | 749 | v, err := Evaluate(context.Background(), node, ctx) | |
| 737 | - | if err != nil || CoerceString(v) != "mine" || calls != 1 { | |
| 750 | + | if err != nil || CoerceString(v) != "false" || calls != 1 { | |
| 738 | 751 | t.Errorf("got %v, %v, %d calls", v, err, calls) | |
| 739 | 752 | } | |
| 740 | 753 | node, err = Parse("boom()", nil, []FuncInfo{{Name: "boom"}}) | |
| @@ -770,7 +783,7 @@ func TestContextNames(t *testing.T) { | |||
| 770 | 783 | t.Errorf("absent context: %v, %v", v, err) | |
| 771 | 784 | } | |
| 772 | 785 | // Names that were not declared to Parse are errors, even if present at evaluation. | |
| 773 | - | if _, err := Parse("secrets.X", []string{"github"}, nil); err == nil || err.Error() != "Unrecognized named-value: 'secrets'" { | |
| 786 | + | if _, err := Parse("secrets.X", []string{"github"}, nil); err == nil || !strings.HasPrefix(err.Error(), "Unrecognized named-value: 'secrets'. Located at position 1 ") { | |
| 774 | 787 | t.Errorf("undeclared context: %v", err) | |
| 775 | 788 | } | |
| 776 | 789 | // A nil Value in a context behaves as null. | |
| @@ -81,14 +81,14 @@ func TestEvalLiteralsAndOperators(t *testing.T) { | |||||
| 81 | {"0x10", "Number:16"}, | 81 | {"0x10", "Number:16"}, | ||
| 82 | {"0o17", "Number:15"}, | 82 | {"0o17", "Number:15"}, | ||
| 83 | {"0xffffffff", "Number:-1"}, // read as a 32-bit integer, like the runner | 83 | {"0xffffffff", "Number:-1"}, // read as a 32-bit integer, like the runner | ||
| 84 | - | {"0b101", "PARSE Unexpected symbol: '0b101'. Located at position 1 within expression: 0b101"}, | 84 | + | {"0b101", "PARSE Unexpected symbol: '0b101'"}, |
| 85 | - | {"0X10", "PARSE Unexpected symbol: '0X10'. Located at position 1 within expression: 0X10"}, | 85 | + | {"0X10", "PARSE Unexpected symbol: '0X10'"}, |
| 86 | {"'0b11' == 3", "Boolean:false"}, | 86 | {"'0b11' == 3", "Boolean:false"}, | ||
| 87 | {"'0x1F' == 31", "Boolean:true"}, | 87 | {"'0x1F' == 31", "Boolean:true"}, | ||
| 88 | {"'infinity' == Infinity", "Boolean:true"}, | 88 | {"'infinity' == Infinity", "Boolean:true"}, | ||
| 89 | {"'\u0085 1 \u0085' == 1", "Boolean:true"}, | 89 | {"'\u0085 1 \u0085' == 1", "Boolean:true"}, | ||
| 90 | {"'\ufeff1' == 1", "Boolean:false"}, | 90 | {"'\ufeff1' == 1", "Boolean:false"}, | ||
| 91 | - | {"-0x10", "PARSE Unexpected symbol: '-0x10'. Located at position 1 within expression: -0x10"}, | 91 | + | {"-0x10", "PARSE Unexpected symbol: '-0x10'"}, |
| 92 | {"NaN", "Number:NaN"}, | 92 | {"NaN", "Number:NaN"}, | ||
| 93 | {"Infinity", "Number:Infinity"}, | 93 | {"Infinity", "Number:Infinity"}, | ||
| 94 | {"-Infinity", "Number:-Infinity"}, | 94 | {"-Infinity", "Number:-Infinity"}, | ||
| @@ -290,7 +290,7 @@ func TestEvalAccess(t *testing.T) { | |||||
| 290 | {"steps[10].outputs.r", "String:ten"}, | 290 | {"steps[10].outputs.r", "String:ten"}, | ||
| 291 | {"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. | 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'. Located at position 7 within expression: steps.10.outputs.r"}, | 293 | + | {"steps.10.outputs.r", "PARSE Unexpected symbol: '10.outputs.r'"}, |
| 294 | {"steps[2].outputs.r", "String:two"}, | 294 | {"steps[2].outputs.r", "String:two"}, | ||
| 295 | {"matrix.obj[0]", "Null:"}, | 295 | {"matrix.obj[0]", "Null:"}, | ||
| 296 | {"matrix.node['0']", "Number:14"}, | 296 | {"matrix.node['0']", "Number:14"}, | ||
| @@ -366,9 +366,9 @@ func TestEvalBuiltinFunctions(t *testing.T) { | |||||
| 366 | {"contains('Dž', 'dž')", "Boolean:true"}, | 366 | {"contains('Dž', 'dž')", "Boolean:true"}, | ||
| 367 | {"contains('A\u03a3', 'a\u03c2')", "Boolean:true"}, | 367 | {"contains('A\u03a3', 'a\u03c2')", "Boolean:true"}, | ||
| 368 | {"contains('K', 'k')", "Boolean:true"}, // KELVIN SIGN lower-cases to k | 368 | {"contains('K', 'k')", "Boolean:true"}, // KELVIN SIGN lower-cases to k | ||
| 369 | - | {"contains()", "PARSE Too few parameters supplied: 'contains'"}, | 369 | + | {"contains()", "PARSE Too few parameters supplied: '('"}, |
| 370 | - | {"contains(1)", "PARSE Too few parameters supplied: 'contains'"}, | 370 | + | {"contains(1)", "PARSE Too few parameters supplied: '('"}, |
| 371 | - | {"contains(1, 2, 3)", "PARSE Too many parameters supplied: 'contains'"}, | 371 | + | {"contains(1, 2, 3)", "PARSE Too many parameters supplied: '('"}, |
| 372 | 372 | ||||
| 373 | // startsWith, endsWith | 373 | // startsWith, endsWith | ||
| 374 | {"startsWith('abc', 'AB')", "Boolean:true"}, | 374 | {"startsWith('abc', 'AB')", "Boolean:true"}, | ||
| @@ -383,7 +383,7 @@ func TestEvalBuiltinFunctions(t *testing.T) { | |||||
| 383 | {"endsWith('abc', 'abcd')", "Boolean:false"}, | 383 | {"endsWith('abc', 'abcd')", "Boolean:false"}, | ||
| 384 | {"endsWith(1.50, '5')", "Boolean:true"}, | 384 | {"endsWith(1.50, '5')", "Boolean:true"}, | ||
| 385 | {"endsWith(1.50, 5)", "Boolean:true"}, | 385 | {"endsWith(1.50, 5)", "Boolean:true"}, | ||
| 386 | - | {"endsWith('abc')", "PARSE Too few parameters supplied: 'endsWith'"}, | 386 | + | {"endsWith('abc')", "PARSE Too few parameters supplied: '('"}, |
| 387 | {"STARTSWITH('abc', 'a')", "Boolean:true"}, | 387 | {"STARTSWITH('abc', 'a')", "Boolean:true"}, | ||
| 388 | 388 | ||||
| 389 | // join | 389 | // join | ||
| @@ -400,8 +400,8 @@ func TestEvalBuiltinFunctions(t *testing.T) { | |||||
| 400 | {"join(fromJSON('[1,2,3]'), null)", "String:123"}, | 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 | 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"}, | 402 | {"join(fromJSON('[null,true,\"a\",1.5,[1],{}]'))", "String:,true,a,1.5,Array,Object"}, | ||
| 403 | - | {"join()", "PARSE Too few parameters supplied: 'join'"}, | 403 | + | {"join()", "PARSE Too few parameters supplied: '('"}, |
| 404 | - | {"join(1, 2, 3)", "PARSE Too many parameters supplied: 'join'"}, | 404 | + | {"join(1, 2, 3)", "PARSE Too many parameters supplied: '('"}, |
| 405 | 405 | ||||
| 406 | // toJSON | 406 | // toJSON | ||
| 407 | {"toJSON(null)", "String:null"}, | 407 | {"toJSON(null)", "String:null"}, | ||
| @@ -417,8 +417,8 @@ func TestEvalBuiltinFunctions(t *testing.T) { | |||||
| 417 | {"toJSON(fromJSON('[]'))", "String:[]"}, | 417 | {"toJSON(fromJSON('[]'))", "String:[]"}, | ||
| 418 | {"toJSON(fromJSON('{}'))", "String:{}"}, | 418 | {"toJSON(fromJSON('{}'))", "String:{}"}, | ||
| 419 | {"toJSON(fromJSON('[[],{}]'))", "String:[\n [],\n {}\n]"}, | 419 | {"toJSON(fromJSON('[[],{}]'))", "String:[\n [],\n {}\n]"}, | ||
| 420 | - | {"toJSON(1, 2)", "PARSE Too many parameters supplied: 'toJSON'"}, | 420 | + | {"toJSON(1, 2)", "PARSE Too many parameters supplied: '('"}, |
| 421 | - | {"toJSON()", "PARSE Too few parameters supplied: 'toJSON'"}, | 421 | + | {"toJSON()", "PARSE Too few parameters supplied: '('"}, |
| 422 | {"fromJSON(toJSON(github.event.commits[0])).author.name", "String:Ann"}, | 422 | {"fromJSON(toJSON(github.event.commits[0])).author.name", "String:Ann"}, | ||
| 423 | 423 | ||||
| 424 | // fromJSON | 424 | // fromJSON | ||
| @@ -442,7 +442,7 @@ func TestEvalBuiltinFunctions(t *testing.T) { | |||||
| 442 | {"fromJSON(1)", "Number:1"}, | 442 | {"fromJSON(1)", "Number:1"}, | ||
| 443 | {"fromJSON(true)", "Boolean:true"}, | 443 | {"fromJSON(true)", "Boolean:true"}, | ||
| 444 | {"fromJSON(matrix.node)", "ERR Error parsing JSON when evaluating fromJson"}, // the string "Array" | 444 | {"fromJSON(matrix.node)", "ERR Error parsing JSON when evaluating fromJson"}, // the string "Array" | ||
| 445 | - | {"fromJSON(1, 2)", "PARSE Too many parameters supplied: 'fromJSON'"}, | 445 | + | {"fromJSON(1, 2)", "PARSE Too many parameters supplied: '('"}, |
| 446 | 446 | ||||
| 447 | // format | 447 | // format | ||
| 448 | {"format('a')", "String:a"}, | 448 | {"format('a')", "String:a"}, | ||
| @@ -493,7 +493,7 @@ func TestEvalBuiltinFunctions(t *testing.T) { | |||||
| 493 | {"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. | 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}"}, | 495 | {"format('{12345678901234567890123}', 1)", "ERR The following format string is invalid: {12345678901234567890123}"}, | ||
| 496 | - | {"format()", "PARSE Too few parameters supplied: 'format'"}, | 496 | + | {"format()", "PARSE Too few parameters supplied: '('"}, |
| 497 | 497 | ||||
| 498 | // case | 498 | // case | ||
| 499 | {"case(true, 'a', 'b')", "String:a"}, | 499 | {"case(true, 'a', 'b')", "String:a"}, | ||
| @@ -506,9 +506,9 @@ func TestEvalBuiltinFunctions(t *testing.T) { | |||||
| 506 | {"case(1, 2, 3)", "ERR case predicate must evaluate to a boolean value"}, | 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"}, | 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"}, | 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: 'case'"}, | 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: 'case'"}, | 510 | + | {"case(true, 1)", "PARSE Too few parameters supplied: '('"}, |
| 511 | - | {"case()", "PARSE Too few parameters supplied: 'case'"}, | 511 | + | {"case()", "PARSE Too few parameters supplied: '('"}, |
| 512 | // Predicates after the first true one are not checked... | 512 | // Predicates after the first true one are not checked... | ||
| 513 | {"case(true, 1, 'bad', 2, 3)", "Number:1"}, | 513 | {"case(true, 1, 'bad', 2, 3)", "Number:1"}, | ||
| 514 | // ...but earlier ones are. | 514 | // ...but earlier ones are. | ||
| @@ -523,7 +523,7 @@ func TestEvalErrorMessages(t *testing.T) { | |||||
| 523 | {"", "PARSE Unexpected end of expression: 'x'"}, // replaced below: the empty expression is not an error | 523 | {"", "PARSE Unexpected end of expression: 'x'"}, // replaced below: the empty expression is not an error | ||
| 524 | }[:0]) | 524 | }[:0]) | ||
| 525 | cases := []evalCase{ | 525 | cases := []evalCase{ | ||
| 526 | - | {"1 +", "PARSE Unexpected symbol: '+'. Located at position 3 within expression: 1 +"}, | 526 | + | {"1 +", "PARSE Unexpected symbol: '+'"}, |
| 527 | {"a b", "PARSE Unrecognized named-value: 'a'"}, | 527 | {"a b", "PARSE Unrecognized named-value: 'a'"}, | ||
| 528 | {"github github", "PARSE Unexpected symbol: 'github'"}, | 528 | {"github github", "PARSE Unexpected symbol: 'github'"}, | ||
| 529 | {"github.event_name ==", "PARSE Unexpected end of expression: '=='"}, | 529 | {"github.event_name ==", "PARSE Unexpected end of expression: '=='"}, | ||
| @@ -531,9 +531,9 @@ func TestEvalErrorMessages(t *testing.T) { | |||||
| 531 | {"(github.event_name", "PARSE Unexpected end of expression: 'event_name'"}, | 531 | {"(github.event_name", "PARSE Unexpected end of expression: 'event_name'"}, | ||
| 532 | {"github.event_name)", "PARSE Unexpected symbol: ')'"}, | 532 | {"github.event_name)", "PARSE Unexpected symbol: ')'"}, | ||
| 533 | {"contains('a',", "PARSE Unexpected end of expression: ','"}, | 533 | {"contains('a',", "PARSE Unexpected end of expression: ','"}, | ||
| 534 | - | {"contains('a'", "PARSE Unexpected symbol: 'EOF'"}, | 534 | + | {"contains('a'", "PARSE Unexpected end of expression: ''a''"}, |
| 535 | - | {"contains('a' 'b')", "PARSE Unexpected symbol: 'b'"}, | 535 | + | {"contains('a' 'b')", "PARSE Unexpected symbol: ''b''"}, |
| 536 | - | {"foo(", "PARSE Unexpected end of expression: '('"}, | 536 | + | {"foo(", "PARSE Unrecognized function: 'foo'"}, |
| 537 | {"foo()", "PARSE Unrecognized function: 'foo'"}, | 537 | {"foo()", "PARSE Unrecognized function: 'foo'"}, | ||
| 538 | {"foo", "PARSE Unrecognized named-value: 'foo'"}, | 538 | {"foo", "PARSE Unrecognized named-value: 'foo'"}, | ||
| 539 | {"unknown(1)", "PARSE Unrecognized function: 'unknown'"}, | 539 | {"unknown(1)", "PARSE Unrecognized function: 'unknown'"}, | ||
| @@ -545,22 +545,22 @@ func TestEvalErrorMessages(t *testing.T) { | |||||
| 545 | {"1 !", "PARSE Unexpected symbol: '!'"}, | 545 | {"1 !", "PARSE Unexpected symbol: '!'"}, | ||
| 546 | {"[1]", "PARSE Unexpected symbol: '['"}, | 546 | {"[1]", "PARSE Unexpected symbol: '['"}, | ||
| 547 | {"github.[0]", "PARSE Unexpected symbol: '['"}, | 547 | {"github.[0]", "PARSE Unexpected symbol: '['"}, | ||
| 548 | - | {"github.", "PARSE Unexpected symbol: 'EOF'"}, | 548 | + | {"github.", "PARSE Unexpected end of expression: '.'"}, |
| 549 | {"github[", "PARSE Unexpected end of expression: '['"}, | 549 | {"github[", "PARSE Unexpected end of expression: '['"}, | ||
| 550 | {"github[]", "PARSE Unexpected symbol: ']'"}, | 550 | {"github[]", "PARSE Unexpected symbol: ']'"}, | ||
| 551 | - | {"github[*", "PARSE Unexpected symbol: 'EOF'"}, | 551 | + | {"github[*", "PARSE Unexpected end of expression: '*'"}, |
| 552 | {"github.true", "Null:"}, // keywords are plain identifiers after a dot | 552 | {"github.true", "Null:"}, // keywords are plain identifiers after a dot | ||
| 553 | {"env.true", "Null:"}, | 553 | {"env.true", "Null:"}, | ||
| 554 | - | {"1 = 1", "PARSE Unexpected symbol: '='. Located at position 3 within expression: 1 = 1"}, | 554 | + | {"1 = 1", "PARSE Unexpected symbol: '='"}, |
| 555 | - | {"1 & 1", "PARSE Unexpected symbol: '&'. Located at position 3 within expression: 1 & 1"}, | 555 | + | {"1 & 1", "PARSE Unexpected symbol: '&'"}, |
| 556 | - | {"1 | 1", "PARSE Unexpected symbol: '|'. Located at position 3 within expression: 1 | 1"}, | 556 | + | {"1 | 1", "PARSE Unexpected symbol: '|'"}, |
| 557 | - | {"1 + 1", "PARSE Unexpected symbol: '+'. Located at position 3 within expression: 1 + 1"}, | 557 | + | {"1 + 1", "PARSE Unexpected symbol: '+'"}, |
| 558 | {"a-b", "PARSE Unrecognized named-value: 'a-b'"}, | 558 | {"a-b", "PARSE Unrecognized named-value: 'a-b'"}, | ||
| 559 | - | {"1a", "PARSE Unexpected symbol: '1a'. Located at position 1 within expression: 1a"}, | 559 | + | {"1a", "PARSE Unexpected symbol: '1a'"}, |
| 560 | - | {"'unterminated", "PARSE Unexpected symbol: ''unterminated'. Located at position 1 within expression: 'unterminated"}, | 560 | + | {"'unterminated", "PARSE Unexpected symbol: ''unterminated'"}, |
| 561 | - | {"@", "PARSE Unexpected symbol: '@'. Located at position 1 within expression: @"}, | 561 | + | {"@", "PARSE Unexpected symbol: '@'"}, |
| 562 | - | {"${{ 1 }}", "PARSE Unexpected symbol: '${{'. Located at position 1 within expression: ${{ 1 }}"}, | 562 | + | {"${{ 1 }}", "PARSE Unexpected symbol: '${{'"}, |
| 563 | - | {"1 \u00a0 2", "PARSE Unexpected symbol: '\u00a0'. Located at position 3 within expression: 1 \u00a0 2"}, | 563 | + | {"1 \u00a0 2", "PARSE Unexpected symbol: '2'"}, // U+00A0 is white space for .NET |
| 564 | } | 564 | } | ||
| 565 | runEvalCases(t, cases) | 565 | runEvalCases(t, cases) | ||
| 566 | } | 566 | } | ||
| @@ -577,6 +577,9 @@ func TestEmptyExpression(t *testing.T) { | |||||
| 577 | } | 577 | } | ||
| 578 | } | 578 | } | ||
| 579 | 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. | |||
| 580 | func TestParseErrorPositions(t *testing.T) { | 583 | func TestParseErrorPositions(t *testing.T) { | ||
| 581 | tests := []struct { | 584 | tests := []struct { | ||
| 582 | src string | 585 | src string | ||
| @@ -584,14 +587,16 @@ func TestParseErrorPositions(t *testing.T) { | |||||
| 584 | msg string | 587 | msg string | ||
| 585 | }{ | 588 | }{ | ||
| 586 | {"1 ==\n 2 ===\n 3", 1, 5, "Unexpected symbol: '='. Located at position 11 within expression: 1 ==\n 2 ===\n 3"}, | 589 | {"1 ==\n 2 ===\n 3", 1, 5, "Unexpected symbol: '='. Located at position 11 within expression: 1 ==\n 2 ===\n 3"}, | ||
| 587 | - | {"true &&\n false ||\n nope", 2, 2, "Unrecognized named-value: 'nope'"}, | 590 | + | {"true &&\n false ||\n nope", 2, 2, "Unrecognized named-value: 'nope'. Located at position 22 within expression: true &&\n false ||\n nope"}, |
| 588 | - | {"\n\n bogus", 2, 2, "Unrecognized named-value: 'bogus'"}, | 591 | + | {"\n\n bogus", 2, 2, "Unrecognized named-value: 'bogus'. Located at position 5 within expression: \n\n bogus"}, |
| 589 | - | {"github github", 0, 7, "Unexpected symbol: 'github'"}, | 592 | + | {"github github", 0, 7, "Unexpected symbol: 'github'. Located at position 8 within expression: github github"}, |
| 590 | - | {" ) ", 0, 2, "Unexpected symbol: ')'"}, | 593 | + | {" ) ", 0, 2, "Unexpected symbol: ')'. Located at position 3 within expression: ) "}, |
| 591 | - | {"contains('a',", 0, 12, "Unexpected end of expression: ','"}, | 594 | + | {"contains('a',", 0, 12, "Unexpected end of expression: ','. Located at position 13 within expression: contains('a',"}, |
| 592 | - | {"foo()", 0, 0, "Unrecognized function: 'foo'"}, | 595 | + | {"foo()", 0, 0, "Unrecognized function: 'foo'. Located at position 1 within expression: foo()"}, |
| 593 | - | {"1 && foo()", 0, 5, "Unrecognized function: 'foo'"}, | 596 | + | {"1 && foo()", 0, 5, "Unrecognized function: 'foo'. Located at position 6 within expression: 1 && foo()"}, |
| 594 | - | {"'a'\n 'b'\n zzz", 1, 1, "Unexpected symbol: 'b'"}, | 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"}, | |||
| 595 | } | 600 | } | ||
| 596 | for _, tc := range tests { | 601 | for _, tc := range tests { | ||
| 597 | _, err := Parse(tc.src, testContextNames, nil) | 602 | _, err := Parse(tc.src, testContextNames, nil) | ||
| @@ -618,7 +623,7 @@ func TestParseErrorTypes(t *testing.T) { | |||||
| 618 | {"contains(1)", ErrorTooFewParameters}, | 623 | {"contains(1)", ErrorTooFewParameters}, | ||
| 619 | {"contains(1,2,3)", ErrorTooManyParameters}, | 624 | {"contains(1,2,3)", ErrorTooManyParameters}, | ||
| 620 | {"case(true, 1, 2, 3)", ErrorEvenParameters}, | 625 | {"case(true, 1, 2, 3)", ErrorEvenParameters}, | ||
| 621 | - | {strings.Repeat("(", 60) + "1" + strings.Repeat(")", 60), ErrorExceededMaxDepth}, | 626 | + | {strings.Repeat("!", 60) + "1", ErrorExceededMaxDepth}, |
| 622 | {"1 = 1", ErrorUnexpectedSymbol}, | 627 | {"1 = 1", ErrorUnexpectedSymbol}, | ||
| 623 | } | 628 | } | ||
| 624 | for _, tc := range tests { | 629 | for _, tc := range tests { | ||
| @@ -630,6 +635,10 @@ func TestParseErrorTypes(t *testing.T) { | |||||
| 630 | } | 635 | } | ||
| 631 | } | 636 | } | ||
| 632 | 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. | |||
| 633 | func TestLimits(t *testing.T) { | 642 | func TestLimits(t *testing.T) { | ||
| 634 | deepParens := func(n int) string { return strings.Repeat("(", n) + "1" + strings.Repeat(")", n) } | 643 | deepParens := func(n int) string { return strings.Repeat("(", n) + "1" + strings.Repeat(")", n) } | ||
| 635 | tests := []struct { | 644 | tests := []struct { | ||
| @@ -637,9 +646,7 @@ func TestLimits(t *testing.T) { | |||||
| 637 | src string | 646 | src string | ||
| 638 | ok bool | 647 | ok bool | ||
| 639 | }{ | 648 | }{ | ||
| 640 | - | // Each "(" costs one level, plus one for the outermost expression. | 649 | + | {"parentheses do not count", deepParens(500), true}, |
| 641 | - | {"49 parens", deepParens(49), true}, | |||
| 642 | - | {"50 parens", deepParens(50), false}, | |||
| 643 | {"49 nots", strings.Repeat("!", 49) + "true", true}, | 650 | {"49 nots", strings.Repeat("!", 49) + "true", true}, | ||
| 644 | {"50 nots", strings.Repeat("!", 50) + "true", false}, | 651 | {"50 nots", strings.Repeat("!", 50) + "true", false}, | ||
| 645 | {"49 calls", strings.Repeat("format(", 49) + "'x'" + strings.Repeat(")", 49), true}, | 652 | {"49 calls", strings.Repeat("format(", 49) + "'x'" + strings.Repeat(")", 49), true}, | ||
| @@ -647,7 +654,9 @@ func TestLimits(t *testing.T) { | |||||
| 647 | {"long dotted path", "github" + strings.Repeat(".event", 49), true}, | 654 | {"long dotted path", "github" + strings.Repeat(".event", 49), true}, | ||
| 648 | {"too long dotted path", "github" + strings.Repeat(".event", 50), false}, | 655 | {"too long dotted path", "github" + strings.Repeat(".event", 50), false}, | ||
| 649 | {"long flat ||", "1" + strings.Repeat(" || 1", 3000), true}, | 656 | {"long flat ||", "1" + strings.Repeat(" || 1", 3000), true}, | ||
| 650 | - | {"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}, | |||
| 651 | } | 660 | } | ||
| 652 | for _, tc := range tests { | 661 | for _, tc := range tests { | ||
| 653 | _, err := Parse(tc.src, testContextNames, nil) | 662 | _, err := Parse(tc.src, testContextNames, nil) | ||
| @@ -655,18 +664,18 @@ func TestLimits(t *testing.T) { | |||||
| 655 | t.Errorf("%s: err = %v, want ok=%v", tc.name, err, tc.ok) | 664 | t.Errorf("%s: err = %v, want ok=%v", tc.name, err, tc.ok) | ||
| 656 | } | 665 | } | ||
| 657 | var pe *Error | 666 | var pe *Error | ||
| 658 | - | if err != nil && errors.As(err, &pe) && pe.Type == ErrorExceededMaxDepth && pe.Message != "Exceeded max expression depth 50: '1'" && !strings.HasPrefix(pe.Message, "Exceeded max expression depth 50: '") { | 667 | + | if err != nil && errors.As(err, &pe) && pe.Type == ErrorExceededMaxDepth && pe.Message != "Exceeded max expression depth 50" { |
| 659 | t.Errorf("%s: message %q", tc.name, pe.Message) | 668 | t.Errorf("%s: message %q", tc.name, pe.Message) | ||
| 660 | } | 669 | } | ||
| 661 | } | 670 | } | ||
| 662 | 671 | ||||
| 663 | - | // Length: counted in UTF-16 code units, error is the bare token. | 672 | + | // Length: counted in UTF-16 code units, no token in the message. |
| 664 | long := "'" + strings.Repeat("a", MaxExpressionLength-2) + "'" | 673 | long := "'" + strings.Repeat("a", MaxExpressionLength-2) + "'" | ||
| 665 | if _, err := Parse(long, nil, nil); err != nil { | 674 | if _, err := Parse(long, nil, nil); err != nil { | ||
| 666 | t.Errorf("expression of exactly %d characters: %v", MaxExpressionLength, err) | 675 | t.Errorf("expression of exactly %d characters: %v", MaxExpressionLength, err) | ||
| 667 | } | 676 | } | ||
| 668 | _, err := Parse(long+" ", nil, nil) | 677 | _, err := Parse(long+" ", nil, nil) | ||
| 669 | - | if err == nil || err.Error() != "ErrorExceededMaxLength" { | 678 | + | if err == nil || err.Error() != "Exceeded max expression length 21000" { |
| 670 | t.Errorf("expression of %d characters: err = %v", MaxExpressionLength+1, err) | 679 | t.Errorf("expression of %d characters: err = %v", MaxExpressionLength+1, err) | ||
| 671 | } | 680 | } | ||
| 672 | // 10501 emoji are 21002 UTF-16 units but only 10501 runes. | 681 | // 10501 emoji are 21002 UTF-16 units but only 10501 runes. | ||
| @@ -681,8 +690,8 @@ func TestLimits(t *testing.T) { | |||||
| 681 | } | 690 | } | ||
| 682 | 691 | ||||
| 683 | func TestDepthMessage(t *testing.T) { | 692 | func TestDepthMessage(t *testing.T) { | ||
| 684 | - | _, err := Parse(strings.Repeat("(", 60)+"1"+strings.Repeat(")", 60), nil, nil) | 693 | + | _, err := Parse(strings.Repeat("!", 60)+"1", nil, nil) |
| 685 | - | want := "Exceeded max expression depth 50: '('" | 694 | + | want := "Exceeded max expression depth 50" |
| 686 | if err == nil || err.Error() != want { | 695 | if err == nil || err.Error() != want { | ||
| 687 | t.Errorf("got %v, want %q", err, want) | 696 | t.Errorf("got %v, want %q", err, want) | ||
| 688 | } | 697 | } | ||
| @@ -698,19 +707,23 @@ func TestExtensionFunctions(t *testing.T) { | |||||
| 698 | for _, c := range []struct{ src, err string }{ | 707 | for _, c := range []struct{ src, err string }{ | ||
| 699 | {"hashFiles('a')", ""}, | 708 | {"hashFiles('a')", ""}, | ||
| 700 | {"HASHFILES('a', 'b', 'c', 'd')", ""}, | 709 | {"HASHFILES('a', 'b', 'c', 'd')", ""}, | ||
| 701 | - | {"hashFiles()", "Too few parameters supplied: 'hashFiles'"}, | 710 | + | {"hashFiles()", "Too few parameters supplied: '('"}, |
| 702 | {"success()", ""}, | 711 | {"success()", ""}, | ||
| 703 | - | {"success(1)", "Too many parameters supplied: 'success'"}, | 712 | + | {"success(1)", "Too many parameters supplied: '('"}, |
| 704 | {"bounded(1, 2)", ""}, | 713 | {"bounded(1, 2)", ""}, | ||
| 705 | - | {"bounded(1, 2, 3)", "Too many parameters supplied: 'bounded'"}, | 714 | + | {"bounded(1, 2, 3)", "Too many parameters supplied: '('"}, |
| 706 | - | {"contains()", "Too few parameters supplied: 'contains'"}, | 715 | + | {"contains()", "Too few parameters supplied: '('"}, |
| 707 | {"nothere()", "Unrecognized function: 'nothere'"}, | 716 | {"nothere()", "Unrecognized function: 'nothere'"}, | ||
| 708 | } { | 717 | } { | ||
| 709 | _, err := Parse(c.src, nil, funcs) | 718 | _, err := Parse(c.src, nil, funcs) | ||
| 719 | + | msg := "" | |||
| 720 | + | if err != nil { | |||
| 721 | + | msg, _, _ = strings.Cut(err.Error(), ". Located at position ") | |||
| 722 | + | } | |||
| 710 | switch { | 723 | switch { | ||
| 711 | case c.err == "" && err != nil: | 724 | case c.err == "" && err != nil: | ||
| 712 | t.Errorf("%s: %v", c.src, err) | 725 | t.Errorf("%s: %v", c.src, err) | ||
| 713 | - | case c.err != "" && (err == nil || err.Error() != c.err): | 726 | + | case c.err != "" && (err == nil || msg != c.err): |
| 714 | t.Errorf("%s: err = %v, want %q", c.src, err, c.err) | 727 | t.Errorf("%s: err = %v, want %q", c.src, err, c.err) | ||
| 715 | } | 728 | } | ||
| 716 | } | 729 | } | ||
| @@ -725,7 +738,7 @@ func TestExtensionFunctions(t *testing.T) { | |||||
| 725 | }, | 738 | }, | ||
| 726 | "success": func(context.Context, []Value) (Value, error) { return Boolean(true), nil }, | 739 | "success": func(context.Context, []Value) (Value, error) { return Boolean(true), nil }, | ||
| 727 | "boom": func(context.Context, []Value) (Value, error) { return nil, errors.New("kaput") }, | 740 | "boom": func(context.Context, []Value) (Value, error) { return nil, errors.New("kaput") }, | ||
| 728 | - | // An extra function replaces a built-in of the same name. | 741 | + | // An extra function does not replace a built-in of the same name. |
| 729 | "contains": func(context.Context, []Value) (Value, error) { return String("mine"), nil }, | 742 | "contains": func(context.Context, []Value) (Value, error) { return String("mine"), nil }, | ||
| 730 | }, | 743 | }, | ||
| 731 | } | 744 | } | ||
| @@ -734,7 +747,7 @@ func TestExtensionFunctions(t *testing.T) { | |||||
| 734 | t.Fatal(err) | 747 | t.Fatal(err) | ||
| 735 | } | 748 | } | ||
| 736 | v, err := Evaluate(context.Background(), node, ctx) | 749 | v, err := Evaluate(context.Background(), node, ctx) | ||
| 737 | - | if err != nil || CoerceString(v) != "mine" || calls != 1 { | 750 | + | if err != nil || CoerceString(v) != "false" || calls != 1 { |
| 738 | t.Errorf("got %v, %v, %d calls", v, err, calls) | 751 | t.Errorf("got %v, %v, %d calls", v, err, calls) | ||
| 739 | } | 752 | } | ||
| 740 | node, err = Parse("boom()", nil, []FuncInfo{{Name: "boom"}}) | 753 | node, err = Parse("boom()", nil, []FuncInfo{{Name: "boom"}}) | ||
| @@ -770,7 +783,7 @@ func TestContextNames(t *testing.T) { | |||||
| 770 | t.Errorf("absent context: %v, %v", v, err) | 783 | t.Errorf("absent context: %v, %v", v, err) | ||
| 771 | } | 784 | } | ||
| 772 | // Names that were not declared to Parse are errors, even if present at evaluation. | 785 | // Names that were not declared to Parse are errors, even if present at evaluation. | ||
| 773 | - | if _, err := Parse("secrets.X", []string{"github"}, nil); err == nil || err.Error() != "Unrecognized named-value: 'secrets'" { | 786 | + | if _, err := Parse("secrets.X", []string{"github"}, nil); err == nil || !strings.HasPrefix(err.Error(), "Unrecognized named-value: 'secrets'. Located at position 1 ") { |
| 774 | t.Errorf("undeclared context: %v", err) | 787 | t.Errorf("undeclared context: %v", err) | ||
| 775 | } | 788 | } | ||
| 776 | // A nil Value in a context behaves as null. | 789 | // A nil Value in a context behaves as null. | ||
M expr/example_test.go +4 -4
| @@ -27,10 +27,10 @@ func ExampleParse() { | |||
| 27 | 27 | } | |
| 28 | 28 | // Output: | |
| 29 | 29 | // true <nil> | |
| 30 | - | // secrets.TOKEN -> Unrecognized named-value: 'secrets' (line 0, column 0) | |
| 31 | - | // contains('a') -> Too few parameters supplied: 'contains' (line 0, column 0) | |
| 32 | - | // github.ref == -> Unexpected end of expression: '==' (line 0, column 11) | |
| 33 | - | // nope() -> Unrecognized function: 'nope' (line 0, column 0) | |
| 30 | + | // secrets.TOKEN -> Unrecognized named-value: 'secrets'. Located at position 1 within expression: secrets.TOKEN (line 0, column 0) | |
| 31 | + | // contains('a') -> Too few parameters supplied: '('. Located at position 9 within expression: contains('a') (line 0, column 8) | |
| 32 | + | // github.ref == -> Unexpected end of expression: '=='. Located at position 12 within expression: github.ref == (line 0, column 11) | |
| 33 | + | // nope() -> Unrecognized function: 'nope'. Located at position 1 within expression: nope() (line 0, column 0) | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 36 | func ExampleEvaluate() { | |
| @@ -27,10 +27,10 @@ func ExampleParse() { | |||||
| 27 | } | 27 | } | ||
| 28 | // Output: | 28 | // Output: | ||
| 29 | // true <nil> | 29 | // true <nil> | ||
| 30 | - | // secrets.TOKEN -> Unrecognized named-value: 'secrets' (line 0, column 0) | 30 | + | // secrets.TOKEN -> Unrecognized named-value: 'secrets'. Located at position 1 within expression: secrets.TOKEN (line 0, column 0) |
| 31 | - | // contains('a') -> Too few parameters supplied: 'contains' (line 0, column 0) | 31 | + | // contains('a') -> Too few parameters supplied: '('. Located at position 9 within expression: contains('a') (line 0, column 8) |
| 32 | - | // github.ref == -> Unexpected end of expression: '==' (line 0, column 11) | 32 | + | // github.ref == -> Unexpected end of expression: '=='. Located at position 12 within expression: github.ref == (line 0, column 11) |
| 33 | - | // nope() -> Unrecognized function: 'nope' (line 0, column 0) | 33 | + | // nope() -> Unrecognized function: 'nope'. Located at position 1 within expression: nope() (line 0, column 0) |
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | func ExampleEvaluate() { | 36 | func ExampleEvaluate() { | ||
M expr/fuzz_test.go +1 -1
| @@ -81,7 +81,7 @@ func FuzzParse(f *testing.F) { | |||
| 81 | 81 | } | |
| 82 | 82 | return | |
| 83 | 83 | } | |
| 84 | - | if node == nil && strings.TrimFunc(s, func(r rune) bool { return r == ' ' || r == '\t' || r == '\r' || r == '\n' }) != "" { | |
| 84 | + | if node == nil && strings.TrimFunc(s, isNetSpace) != "" { | |
| 85 | 85 | t.Fatalf("empty tree for non-blank input %q", s) | |
| 86 | 86 | } | |
| 87 | 87 | if d := nodeDepth(node); d > 2*MaxParserDepth+2 { | |
| @@ -81,7 +81,7 @@ func FuzzParse(f *testing.F) { | |||||
| 81 | } | 81 | } | ||
| 82 | return | 82 | return | ||
| 83 | } | 83 | } | ||
| 84 | - | if node == nil && strings.TrimFunc(s, func(r rune) bool { return r == ' ' || r == '\t' || r == '\r' || r == '\n' }) != "" { | 84 | + | if node == nil && strings.TrimFunc(s, isNetSpace) != "" { |
| 85 | t.Fatalf("empty tree for non-blank input %q", s) | 85 | t.Fatalf("empty tree for non-blank input %q", s) | ||
| 86 | } | 86 | } | ||
| 87 | if d := nodeDepth(node); d > 2*MaxParserDepth+2 { | 87 | if d := nodeDepth(node); d > 2*MaxParserDepth+2 { | ||
M expr/json.go +21 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "math" | |
| 9 | + | "math/big" | |
| 9 | 10 | "strconv" | |
| 10 | 11 | "strings" | |
| 11 | 12 | "unicode" | |
| @@ -157,10 +158,30 @@ func (p *jsonParser) number() (Value, error) { | |||
| 157 | 158 | p.i = start | |
| 158 | 159 | return nil, fmt.Errorf("input string %q is not a valid number", tok) | |
| 159 | 160 | } | |
| 161 | + | if isBigInteger(body) { | |
| 162 | + | // Json.NET reads an integer that does not fit in 64 bits as a | |
| 163 | + | // BigInteger, and (Double)BigInteger truncates toward zero to 53 bits | |
| 164 | + | // instead of rounding to nearest. | |
| 165 | + | if _, err := strconv.ParseInt(tok, 10, 64); err != nil { | |
| 166 | + | n, _ := new(big.Int).SetString(tok, 10) | |
| 167 | + | f, _ := new(big.Float).SetPrec(53).SetMode(big.ToZero).SetInt(n).Float64() | |
| 168 | + | return Number(f), nil | |
| 169 | + | } | |
| 170 | + | } | |
| 160 | 171 | f, _ := strconv.ParseFloat(tok, 64) // range errors give ±Inf | |
| 161 | 172 | return Number(f), nil | |
| 162 | 173 | } | |
| 163 | 174 | ||
| 175 | + | // isBigInteger reports whether s is only digits. | |
| 176 | + | func isBigInteger(s string) bool { | |
| 177 | + | for i := 0; i < len(s); i++ { | |
| 178 | + | if s[i] < '0' || s[i] > '9' { | |
| 179 | + | return false | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | return s != "" | |
| 183 | + | } | |
| 184 | + | ||
| 164 | 185 | // str reads a string literal; p.i is on the opening quote. | |
| 165 | 186 | func (p *jsonParser) str() (string, error) { | |
| 166 | 187 | quote := p.s[p.i] | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "math" | 8 | "math" | ||
| 9 | + | "math/big" | |||
| 9 | "strconv" | 10 | "strconv" | ||
| 10 | "strings" | 11 | "strings" | ||
| 11 | "unicode" | 12 | "unicode" | ||
| @@ -157,10 +158,30 @@ func (p *jsonParser) number() (Value, error) { | |||||
| 157 | p.i = start | 158 | p.i = start | ||
| 158 | return nil, fmt.Errorf("input string %q is not a valid number", tok) | 159 | return nil, fmt.Errorf("input string %q is not a valid number", tok) | ||
| 159 | } | 160 | } | ||
| 161 | + | if isBigInteger(body) { | |||
| 162 | + | // Json.NET reads an integer that does not fit in 64 bits as a | |||
| 163 | + | // BigInteger, and (Double)BigInteger truncates toward zero to 53 bits | |||
| 164 | + | // instead of rounding to nearest. | |||
| 165 | + | if _, err := strconv.ParseInt(tok, 10, 64); err != nil { | |||
| 166 | + | n, _ := new(big.Int).SetString(tok, 10) | |||
| 167 | + | f, _ := new(big.Float).SetPrec(53).SetMode(big.ToZero).SetInt(n).Float64() | |||
| 168 | + | return Number(f), nil | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 160 | f, _ := strconv.ParseFloat(tok, 64) // range errors give ±Inf | 171 | f, _ := strconv.ParseFloat(tok, 64) // range errors give ±Inf | ||
| 161 | return Number(f), nil | 172 | return Number(f), nil | ||
| 162 | } | 173 | } | ||
| 163 | 174 | ||||
| 175 | + | // isBigInteger reports whether s is only digits. | |||
| 176 | + | func isBigInteger(s string) bool { | |||
| 177 | + | for i := 0; i < len(s); i++ { | |||
| 178 | + | if s[i] < '0' || s[i] > '9' { | |||
| 179 | + | return false | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | return s != "" | |||
| 183 | + | } | |||
| 184 | + | ||||
| 164 | // str reads a string literal; p.i is on the opening quote. | 185 | // str reads a string literal; p.i is on the opening quote. | ||
| 165 | func (p *jsonParser) str() (string, error) { | 186 | func (p *jsonParser) str() (string, error) { | ||
| 166 | quote := p.s[p.i] | 187 | quote := p.s[p.i] | ||
M expr/json_test.go +28 -0
| @@ -100,6 +100,34 @@ func TestFromJSONNumbers(t *testing.T) { | |||
| 100 | 100 | } | |
| 101 | 101 | } | |
| 102 | 102 | ||
| 103 | + | // TestFromJSONBigIntegers: Json.NET gives integers beyond 64 bits as BigInteger | |
| 104 | + | // and (Double)BigInteger truncates; established with the runner's code | |
| 105 | + | // (fromJSON('N') == N, where the literal N rounds to nearest). | |
| 106 | + | func TestFromJSONBigIntegers(t *testing.T) { | |
| 107 | + | for _, tc := range []struct { | |
| 108 | + | src string | |
| 109 | + | want float64 | |
| 110 | + | }{ | |
| 111 | + | {"9223372036854775807", 9223372036854775807}, // fits a long: nearest | |
| 112 | + | {"-9223372036854775808", -9223372036854775808}, // exact | |
| 113 | + | {"12345678901234567890", 12345678901234567890}, // 64 bits, above long: truncated to the same double here | |
| 114 | + | {"18446744073709551615", 18446744073709549568}, // 2^64-1 truncates to 2^64-2048, not 2^64 | |
| 115 | + | {"18446744073709551617", 18446744073709551616}, // 2^64+1 truncates to 2^64 | |
| 116 | + | {"-18446744073709551615", -18446744073709549568}, | |
| 117 | + | {"12345678901234567890.0", 12345678901234567168}, // a decimal point: an ordinary float | |
| 118 | + | {"1e30", 1e30}, | |
| 119 | + | } { | |
| 120 | + | v, err := FromJSON([]byte(tc.src)) | |
| 121 | + | if err != nil { | |
| 122 | + | t.Errorf("FromJSON(%q): %v", tc.src, err) | |
| 123 | + | continue | |
| 124 | + | } | |
| 125 | + | if got := float64(v.(Number)); got != tc.want { | |
| 126 | + | t.Errorf("FromJSON(%q) = %v, want %v", tc.src, got, tc.want) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 103 | 131 | func TestFromJSONInvalidUTF8(t *testing.T) { | |
| 104 | 132 | v, err := FromJSON([]byte("\"a\xffb\"")) | |
| 105 | 133 | if err != nil { | |
| @@ -100,6 +100,34 @@ func TestFromJSONNumbers(t *testing.T) { | |||||
| 100 | } | 100 | } | ||
| 101 | } | 101 | } | ||
| 102 | 102 | ||||
| 103 | + | // TestFromJSONBigIntegers: Json.NET gives integers beyond 64 bits as BigInteger | |||
| 104 | + | // and (Double)BigInteger truncates; established with the runner's code | |||
| 105 | + | // (fromJSON('N') == N, where the literal N rounds to nearest). | |||
| 106 | + | func TestFromJSONBigIntegers(t *testing.T) { | |||
| 107 | + | for _, tc := range []struct { | |||
| 108 | + | src string | |||
| 109 | + | want float64 | |||
| 110 | + | }{ | |||
| 111 | + | {"9223372036854775807", 9223372036854775807}, // fits a long: nearest | |||
| 112 | + | {"-9223372036854775808", -9223372036854775808}, // exact | |||
| 113 | + | {"12345678901234567890", 12345678901234567890}, // 64 bits, above long: truncated to the same double here | |||
| 114 | + | {"18446744073709551615", 18446744073709549568}, // 2^64-1 truncates to 2^64-2048, not 2^64 | |||
| 115 | + | {"18446744073709551617", 18446744073709551616}, // 2^64+1 truncates to 2^64 | |||
| 116 | + | {"-18446744073709551615", -18446744073709549568}, | |||
| 117 | + | {"12345678901234567890.0", 12345678901234567168}, // a decimal point: an ordinary float | |||
| 118 | + | {"1e30", 1e30}, | |||
| 119 | + | } { | |||
| 120 | + | v, err := FromJSON([]byte(tc.src)) | |||
| 121 | + | if err != nil { | |||
| 122 | + | t.Errorf("FromJSON(%q): %v", tc.src, err) | |||
| 123 | + | continue | |||
| 124 | + | } | |||
| 125 | + | if got := float64(v.(Number)); got != tc.want { | |||
| 126 | + | t.Errorf("FromJSON(%q) = %v, want %v", tc.src, got, tc.want) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 103 | func TestFromJSONInvalidUTF8(t *testing.T) { | 131 | func TestFromJSONInvalidUTF8(t *testing.T) { | ||
| 104 | v, err := FromJSON([]byte("\"a\xffb\"")) | 132 | v, err := FromJSON([]byte("\"a\xffb\"")) | ||
| 105 | if err != nil { | 133 | if err != nil { | ||
M expr/lexer.go +5 -2
| @@ -81,8 +81,11 @@ func tokenString(t Token) string { | |||
| 81 | 81 | return t.Lexeme | |
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | - | // Lex splits an expression into tokens. The result always ends with a | |
| 85 | - | // TokenEOF token. | |
| 84 | + | // Lex splits an expression into tokens the way the JavaScript reference's | |
| 85 | + | // lexer does, ending with a TokenEOF token. [Parse] does not use it: it | |
| 86 | + | // follows the C# runner, whose tokenizer is context sensitive and lazy (see | |
| 87 | + | // parse_runner.go). Lex is kept because the oracle tests replay the reference's | |
| 88 | + | // tokens with it. | |
| 86 | 89 | func Lex(input string) ([]Token, error) { | |
| 87 | 90 | if exceedsUTF16Len(input, MaxExpressionLength) { | |
| 88 | 91 | // The reference throws a bare Error("ErrorExceededMaxLength") here, | |
| @@ -81,8 +81,11 @@ func tokenString(t Token) string { | |||||
| 81 | return t.Lexeme | 81 | return t.Lexeme | ||
| 82 | } | 82 | } | ||
| 83 | 83 | ||||
| 84 | - | // Lex splits an expression into tokens. The result always ends with a | 84 | + | // Lex splits an expression into tokens the way the JavaScript reference's |
| 85 | - | // TokenEOF token. | 85 | + | // lexer does, ending with a TokenEOF token. [Parse] does not use it: it |
| 86 | + | // follows the C# runner, whose tokenizer is context sensitive and lazy (see | |||
| 87 | + | // parse_runner.go). Lex is kept because the oracle tests replay the reference's | |||
| 88 | + | // tokens with it. | |||
| 86 | func Lex(input string) ([]Token, error) { | 89 | func Lex(input string) ([]Token, error) { | ||
| 87 | if exceedsUTF16Len(input, MaxExpressionLength) { | 90 | if exceedsUTF16Len(input, MaxExpressionLength) { | ||
| 88 | // The reference throws a bare Error("ErrorExceededMaxLength") here, | 91 | // The reference throws a bare Error("ErrorExceededMaxLength") here, | ||
A expr/parse_runner.go +790 -0
| @@ -0,0 +1,790 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package expr | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "unicode/utf16" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // This file is a port of the runner's tokenizer and parser: | |
| 11 | + | // src/Sdk/DTExpressions2/Expressions2/Tokens/LexicalAnalyzer.cs, Tokens/Token.cs | |
| 12 | + | // and ExpressionParser.cs. They differ from the JavaScript reference's | |
| 13 | + | // (lexer.go, parser.go) in ways users can see: | |
| 14 | + | // | |
| 15 | + | // - the tokenizer is context sensitive: each token is checked against the | |
| 16 | + | // one before it, so "a b", "1 2" or "f(1,)" fail at the second token; | |
| 17 | + | // - tokens are produced on demand and the parser stops at the first | |
| 18 | + | // unexpected token or unknown name, in order of appearance (the JavaScript | |
| 19 | + | // lexes everything first, so a later lexical error wins there); | |
| 20 | + | // - white space is Char.IsWhiteSpace (U+0085 and U+00A0 included); | |
| 21 | + | // - "Unexpected end of expression" is reported for an unfinished expression | |
| 22 | + | // where the JavaScript says "Unexpected symbol: 'EOF'"; | |
| 23 | + | // - error positions are absolute indexes in the expression, not columns; | |
| 24 | + | // - the depth limit applies to the shape of the finished tree, in which | |
| 25 | + | // parentheses do not count and a flat run of && or || is one node; | |
| 26 | + | // - the "too long" and "too deep" errors carry no token. | |
| 27 | + | // | |
| 28 | + | // Parse builds the same AST as the JavaScript-style parser did. | |
| 29 | + | ||
| 30 | + | type csKind int | |
| 31 | + | ||
| 32 | + | const ( | |
| 33 | + | csStartGroup csKind = iota | |
| 34 | + | csStartIndex | |
| 35 | + | csStartParameters | |
| 36 | + | csEndGroup | |
| 37 | + | csEndIndex | |
| 38 | + | csEndParameters | |
| 39 | + | csSeparator | |
| 40 | + | csDereference | |
| 41 | + | csWildcard | |
| 42 | + | csLogicalOperator | |
| 43 | + | csNull | |
| 44 | + | csBoolean | |
| 45 | + | csNumber | |
| 46 | + | csString | |
| 47 | + | csPropertyName | |
| 48 | + | csFunction | |
| 49 | + | csNamedValue | |
| 50 | + | csUnexpected | |
| 51 | + | ) | |
| 52 | + | ||
| 53 | + | type csToken struct { | |
| 54 | + | kind csKind | |
| 55 | + | raw string | |
| 56 | + | index int | |
| 57 | + | value Value // Boolean, Number or String for literals | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func (t *csToken) isOperator() bool { | |
| 61 | + | switch t.kind { | |
| 62 | + | case csStartGroup, csStartIndex, csStartParameters, csEndGroup, csEndIndex, csEndParameters, csSeparator, csDereference, csLogicalOperator: | |
| 63 | + | return true | |
| 64 | + | } | |
| 65 | + | return false | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // leftToRight reports whether the token is a left-associative operator. | |
| 69 | + | func (t *csToken) leftToRight() bool { | |
| 70 | + | switch { | |
| 71 | + | case t.kind == csStartGroup: | |
| 72 | + | return false | |
| 73 | + | case t.kind == csLogicalOperator && t.raw == "!": | |
| 74 | + | return false | |
| 75 | + | } | |
| 76 | + | return t.isOperator() | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (t *csToken) precedence() int { | |
| 80 | + | switch t.kind { | |
| 81 | + | case csStartGroup: | |
| 82 | + | return 20 | |
| 83 | + | case csStartIndex, csStartParameters, csDereference: | |
| 84 | + | return 19 | |
| 85 | + | case csLogicalOperator: | |
| 86 | + | switch t.raw { | |
| 87 | + | case "!": | |
| 88 | + | return 16 | |
| 89 | + | case ">", ">=", "<", "<=": | |
| 90 | + | return 11 | |
| 91 | + | case "==", "!=": | |
| 92 | + | return 10 | |
| 93 | + | case "&&": | |
| 94 | + | return 6 | |
| 95 | + | case "||": | |
| 96 | + | return 5 | |
| 97 | + | } | |
| 98 | + | case csEndGroup, csEndIndex, csEndParameters, csSeparator: | |
| 99 | + | return 1 | |
| 100 | + | } | |
| 101 | + | return 0 | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func (t *csToken) operandCount() int { | |
| 105 | + | switch t.kind { | |
| 106 | + | case csStartIndex, csDereference: | |
| 107 | + | return 2 | |
| 108 | + | case csLogicalOperator: | |
| 109 | + | if t.raw == "!" { | |
| 110 | + | return 1 | |
| 111 | + | } | |
| 112 | + | return 2 | |
| 113 | + | } | |
| 114 | + | return 0 | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // csLexer is LexicalAnalyzer. | |
| 118 | + | type csLexer struct { | |
| 119 | + | units []uint16 | |
| 120 | + | index int | |
| 121 | + | last *csToken | |
| 122 | + | unclosed []*csToken // stack of "(", "[" and function "(" | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func (l *csLexer) substr(from, to int) string { return string(utf16.Decode(l.units[from:to])) } | |
| 126 | + | ||
| 127 | + | func isCSWhiteSpace(c uint16) bool { return isNetSpace(rune(c)) } | |
| 128 | + | ||
| 129 | + | func isCSBoundary(c uint16) bool { | |
| 130 | + | switch c { | |
| 131 | + | case '(', '[', ')', ']', ',', '.', '!', '>', '<', '=', '&', '|': | |
| 132 | + | return true | |
| 133 | + | } | |
| 134 | + | return isCSWhiteSpace(c) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func (l *csLexer) topUnclosed() csKind { | |
| 138 | + | if len(l.unclosed) == 0 { | |
| 139 | + | return -1 | |
| 140 | + | } | |
| 141 | + | return l.unclosed[len(l.unclosed)-1].kind | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // next returns the next token, or nil at the end of the expression. | |
| 145 | + | func (l *csLexer) next() *csToken { | |
| 146 | + | for l.index < len(l.units) && isCSWhiteSpace(l.units[l.index]) { | |
| 147 | + | l.index++ | |
| 148 | + | } | |
| 149 | + | if l.index >= len(l.units) { | |
| 150 | + | return nil | |
| 151 | + | } | |
| 152 | + | c := l.units[l.index] | |
| 153 | + | var tok *csToken | |
| 154 | + | switch c { | |
| 155 | + | case '(': | |
| 156 | + | if l.last != nil && l.last.kind == csFunction { | |
| 157 | + | tok = l.create(csStartParameters, "(", l.index, nil) | |
| 158 | + | } else { | |
| 159 | + | tok = l.create(csStartGroup, "(", l.index, nil) | |
| 160 | + | } | |
| 161 | + | l.index++ | |
| 162 | + | case '[': | |
| 163 | + | tok = l.create(csStartIndex, "[", l.index, nil) | |
| 164 | + | l.index++ | |
| 165 | + | case ')': | |
| 166 | + | if l.topUnclosed() == csStartParameters { | |
| 167 | + | tok = l.create(csEndParameters, ")", l.index, nil) | |
| 168 | + | } else { | |
| 169 | + | tok = l.create(csEndGroup, ")", l.index, nil) | |
| 170 | + | } | |
| 171 | + | l.index++ | |
| 172 | + | case ']': | |
| 173 | + | tok = l.create(csEndIndex, "]", l.index, nil) | |
| 174 | + | l.index++ | |
| 175 | + | case ',': | |
| 176 | + | tok = l.create(csSeparator, ",", l.index, nil) | |
| 177 | + | l.index++ | |
| 178 | + | case '*': | |
| 179 | + | tok = l.create(csWildcard, "*", l.index, nil) | |
| 180 | + | l.index++ | |
| 181 | + | case '\'': | |
| 182 | + | tok = l.readString() | |
| 183 | + | case '!', '>', '<', '=', '&', '|': | |
| 184 | + | tok = l.readOperator() | |
| 185 | + | default: | |
| 186 | + | switch { | |
| 187 | + | case c == '.': | |
| 188 | + | if l.last == nil || l.last.kind == csSeparator || l.last.kind == csStartGroup || l.last.kind == csStartIndex || | |
| 189 | + | l.last.kind == csStartParameters || l.last.kind == csLogicalOperator { | |
| 190 | + | tok = l.readNumber() | |
| 191 | + | } else { | |
| 192 | + | tok = l.create(csDereference, ".", l.index, nil) | |
| 193 | + | l.index++ | |
| 194 | + | } | |
| 195 | + | case c == '-' || c == '+' || (c >= '0' && c <= '9'): | |
| 196 | + | tok = l.readNumber() | |
| 197 | + | default: | |
| 198 | + | tok = l.readKeyword() | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | l.last = tok | |
| 202 | + | return tok | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func (l *csLexer) readNumber() *csToken { | |
| 206 | + | start := l.index | |
| 207 | + | for { | |
| 208 | + | l.index++ | |
| 209 | + | if !(l.index < len(l.units) && (!isCSBoundary(l.units[l.index]) || l.units[l.index] == '.')) { | |
| 210 | + | break | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | str := l.substr(start, l.index) | |
| 214 | + | d := parseNumber(str) | |
| 215 | + | if d != d { // NaN | |
| 216 | + | return l.create(csUnexpected, str, start, nil) | |
| 217 | + | } | |
| 218 | + | return l.create(csNumber, str, start, Number(d)) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func isLegalKeyword(s string) bool { return isLegalIdentifier(s) } | |
| 222 | + | ||
| 223 | + | func (l *csLexer) readKeyword() *csToken { | |
| 224 | + | start := l.index | |
| 225 | + | l.index++ | |
| 226 | + | for l.index < len(l.units) && !isCSBoundary(l.units[l.index]) { | |
| 227 | + | l.index++ | |
| 228 | + | } | |
| 229 | + | str := l.substr(start, l.index) | |
| 230 | + | if !isLegalKeyword(str) { | |
| 231 | + | return l.create(csUnexpected, str, start, nil) | |
| 232 | + | } | |
| 233 | + | if l.last != nil && l.last.kind == csDereference { | |
| 234 | + | return l.create(csPropertyName, str, start, nil) | |
| 235 | + | } | |
| 236 | + | switch str { | |
| 237 | + | case "null": | |
| 238 | + | return l.create(csNull, str, start, Null{}) | |
| 239 | + | case "true": | |
| 240 | + | return l.create(csBoolean, str, start, Boolean(true)) | |
| 241 | + | case "false": | |
| 242 | + | return l.create(csBoolean, str, start, Boolean(false)) | |
| 243 | + | case "NaN": | |
| 244 | + | return l.create(csNumber, str, start, Number(nan)) | |
| 245 | + | case "Infinity": | |
| 246 | + | return l.create(csNumber, str, start, Number(posInf)) | |
| 247 | + | } | |
| 248 | + | // Lookahead: a function is a name followed by "(", white space allowed. | |
| 249 | + | t := l.index | |
| 250 | + | for t < len(l.units) && isCSWhiteSpace(l.units[t]) { | |
| 251 | + | t++ | |
| 252 | + | } | |
| 253 | + | if t < len(l.units) && l.units[t] == '(' { | |
| 254 | + | return l.create(csFunction, str, start, nil) | |
| 255 | + | } | |
| 256 | + | return l.create(csNamedValue, str, start, nil) | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func (l *csLexer) readString() *csToken { | |
| 260 | + | start := l.index | |
| 261 | + | var b strings.Builder | |
| 262 | + | closed := false | |
| 263 | + | l.index++ // the opening quote | |
| 264 | + | var run []uint16 | |
| 265 | + | for l.index < len(l.units) { | |
| 266 | + | c := l.units[l.index] | |
| 267 | + | l.index++ | |
| 268 | + | if c == '\'' { | |
| 269 | + | if l.index >= len(l.units) || l.units[l.index] != '\'' { | |
| 270 | + | closed = true | |
| 271 | + | break | |
| 272 | + | } | |
| 273 | + | l.index++ // an escaped quote | |
| 274 | + | } | |
| 275 | + | run = append(run, c) | |
| 276 | + | } | |
| 277 | + | b.WriteString(string(utf16.Decode(run))) | |
| 278 | + | raw := l.substr(start, l.index) | |
| 279 | + | if closed { | |
| 280 | + | return l.create(csString, raw, start, String(b.String())) | |
| 281 | + | } | |
| 282 | + | return l.create(csUnexpected, raw, start, nil) | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | func (l *csLexer) readOperator() *csToken { | |
| 286 | + | start := l.index | |
| 287 | + | l.index++ | |
| 288 | + | if l.index < len(l.units) { | |
| 289 | + | l.index++ | |
| 290 | + | switch raw := l.substr(start, start+2); raw { | |
| 291 | + | case "!=", ">=", "<=", "==", "&&", "||": | |
| 292 | + | return l.create(csLogicalOperator, raw, start, nil) | |
| 293 | + | } | |
| 294 | + | l.index-- // back up | |
| 295 | + | } | |
| 296 | + | switch raw := l.substr(start, start+1); raw { | |
| 297 | + | case "!", ">", "<": | |
| 298 | + | return l.create(csLogicalOperator, raw, start, nil) | |
| 299 | + | } | |
| 300 | + | for l.index < len(l.units) && !isCSBoundary(l.units[l.index]) { | |
| 301 | + | l.index++ | |
| 302 | + | } | |
| 303 | + | return l.create(csUnexpected, l.substr(start, l.index), start, nil) | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // lastIs reports whether the previous token is one of kinds; a nil kind list | |
| 307 | + | // entry (csUnexpected here) stands for "no previous token". | |
| 308 | + | func (l *csLexer) lastIs(kinds ...csKind) bool { | |
| 309 | + | if l.last == nil { | |
| 310 | + | for _, k := range kinds { | |
| 311 | + | if k == -1 { | |
| 312 | + | return true | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | return false | |
| 316 | + | } | |
| 317 | + | for _, k := range kinds { | |
| 318 | + | if k == l.last.kind { | |
| 319 | + | return true | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | return false | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | const csFirst csKind = -1 // "is the first token" | |
| 326 | + | ||
| 327 | + | // create builds a token after checking that it may follow the previous one | |
| 328 | + | // and that brackets are balanced; an illegal one becomes csUnexpected. | |
| 329 | + | func (l *csLexer) create(kind csKind, raw string, index int, v Value) *csToken { | |
| 330 | + | var legal bool | |
| 331 | + | switch kind { | |
| 332 | + | case csStartGroup: | |
| 333 | + | legal = l.lastIs(csFirst, csSeparator, csStartGroup, csStartParameters, csStartIndex, csLogicalOperator) | |
| 334 | + | case csStartIndex: | |
| 335 | + | legal = l.lastIs(csEndGroup, csEndParameters, csEndIndex, csWildcard, csPropertyName, csNamedValue) | |
| 336 | + | case csStartParameters: | |
| 337 | + | legal = l.lastIs(csFunction) | |
| 338 | + | case csEndGroup, csEndIndex: | |
| 339 | + | legal = l.lastIs(csEndGroup, csEndParameters, csEndIndex, csWildcard, csNull, csBoolean, csNumber, csString, csPropertyName, csNamedValue) | |
| 340 | + | case csEndParameters: | |
| 341 | + | legal = l.lastIs(csStartParameters, csEndGroup, csEndParameters, csEndIndex, csWildcard, csNull, csBoolean, csNumber, csString, csPropertyName, csNamedValue) | |
| 342 | + | case csSeparator: | |
| 343 | + | legal = l.lastIs(csEndGroup, csEndParameters, csEndIndex, csWildcard, csNull, csBoolean, csNumber, csString, csPropertyName, csNamedValue) | |
| 344 | + | case csDereference: | |
| 345 | + | legal = l.lastIs(csEndGroup, csEndParameters, csEndIndex, csWildcard, csPropertyName, csNamedValue) | |
| 346 | + | case csWildcard: | |
| 347 | + | legal = l.lastIs(csStartIndex, csDereference) | |
| 348 | + | case csLogicalOperator: | |
| 349 | + | if raw == "!" { | |
| 350 | + | legal = l.lastIs(csFirst, csSeparator, csStartGroup, csStartParameters, csStartIndex, csLogicalOperator) | |
| 351 | + | } else { | |
| 352 | + | legal = l.lastIs(csEndGroup, csEndParameters, csEndIndex, csWildcard, csNull, csBoolean, csNumber, csString, csPropertyName, csNamedValue) | |
| 353 | + | } | |
| 354 | + | case csNull, csBoolean, csNumber, csString, csFunction, csNamedValue: | |
| 355 | + | legal = l.lastIs(csFirst, csSeparator, csStartIndex, csStartGroup, csStartParameters, csLogicalOperator) | |
| 356 | + | case csPropertyName: | |
| 357 | + | legal = l.lastIs(csDereference) | |
| 358 | + | } | |
| 359 | + | if !legal { | |
| 360 | + | return &csToken{kind: csUnexpected, raw: raw, index: index} | |
| 361 | + | } | |
| 362 | + | tok := &csToken{kind: kind, raw: raw, index: index, value: v} | |
| 363 | + | switch kind { | |
| 364 | + | case csStartGroup, csStartIndex, csStartParameters: | |
| 365 | + | l.unclosed = append(l.unclosed, tok) | |
| 366 | + | case csEndGroup: | |
| 367 | + | if l.topUnclosed() != csStartGroup { | |
| 368 | + | return &csToken{kind: csUnexpected, raw: raw, index: index} | |
| 369 | + | } | |
| 370 | + | l.unclosed = l.unclosed[:len(l.unclosed)-1] | |
| 371 | + | case csEndIndex: | |
| 372 | + | if l.topUnclosed() != csStartIndex { | |
| 373 | + | return &csToken{kind: csUnexpected, raw: raw, index: index} | |
| 374 | + | } | |
| 375 | + | l.unclosed = l.unclosed[:len(l.unclosed)-1] | |
| 376 | + | case csEndParameters: | |
| 377 | + | if l.topUnclosed() != csStartParameters { | |
| 378 | + | return &csToken{kind: csUnexpected, raw: raw, index: index} | |
| 379 | + | } | |
| 380 | + | l.unclosed = l.unclosed[:len(l.unclosed)-1] | |
| 381 | + | case csSeparator: | |
| 382 | + | if l.topUnclosed() != csStartParameters { | |
| 383 | + | return &csToken{kind: csUnexpected, raw: raw, index: index} | |
| 384 | + | } | |
| 385 | + | } | |
| 386 | + | return tok | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | // csParser is ExpressionParser.CreateTree: a shunting-yard parser. | |
| 390 | + | type csParser struct { | |
| 391 | + | expression string | |
| 392 | + | units []uint16 | |
| 393 | + | lex *csLexer | |
| 394 | + | contexts map[string]bool | |
| 395 | + | funcs map[string]FuncInfo | |
| 396 | + | operands []Node | |
| 397 | + | operators []*csToken | |
| 398 | + | last *csToken // the previous token | |
| 399 | + | cur *csToken | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | // fail builds the ParseException of the runner: with a token, the message names | |
| 403 | + | // it and its absolute position; without, only the description. | |
| 404 | + | func (p *csParser) fail(t ErrorType, tok *csToken) *Error { | |
| 405 | + | if tok == nil { | |
| 406 | + | return &Error{Type: t, Message: t.description()} | |
| 407 | + | } | |
| 408 | + | return &Error{ | |
| 409 | + | Type: t, | |
| 410 | + | Message: t.description() + ": '" + tok.raw + "'. Located at position " + itoa(tok.index+1) + " within expression: " + p.expression, | |
| 411 | + | Pos: p.position(tok.index), | |
| 412 | + | Token: tok.raw, | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | func itoa(n int) string { return strings.TrimSpace(strings.Repeat(" ", 0) + intString(n)) } | |
| 417 | + | ||
| 418 | + | func intString(n int) string { | |
| 419 | + | if n == 0 { | |
| 420 | + | return "0" | |
| 421 | + | } | |
| 422 | + | var b [20]byte | |
| 423 | + | i := len(b) | |
| 424 | + | for n > 0 { | |
| 425 | + | i-- | |
| 426 | + | b[i] = byte('0' + n%10) | |
| 427 | + | n /= 10 | |
| 428 | + | } | |
| 429 | + | return string(b[i:]) | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | // position converts an absolute index into the line and column of the | |
| 433 | + | // JavaScript-style Position (line breaks are U+000A). | |
| 434 | + | func (p *csParser) position(index int) Position { | |
| 435 | + | line, lineStart := 0, 0 | |
| 436 | + | for i := 0; i < index && i < len(p.units); i++ { | |
| 437 | + | if p.units[i] == '\n' { | |
| 438 | + | line++ | |
| 439 | + | lineStart = i + 1 | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | return Position{Line: line, Column: index - lineStart} | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | func (p *csParser) token(t *csToken, typ TokenType) Token { | |
| 446 | + | end := t.index + len(utf16.Encode([]rune(t.raw))) | |
| 447 | + | return Token{Type: typ, Lexeme: t.raw, Range: Range{Start: p.position(t.index), End: p.position(end)}, Value: t.value} | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | func (p *csParser) wellKnownOrExtension(name string) (FuncInfo, bool) { | |
| 451 | + | k := foldKey(name) | |
| 452 | + | if f, ok := builtinFuncs[k]; ok { | |
| 453 | + | return f, true | |
| 454 | + | } | |
| 455 | + | f, ok := p.funcs[k] | |
| 456 | + | return f, ok | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | // parseRunner parses expression the way the runner does. | |
| 460 | + | func parseRunner(expression string, contexts []string, funcs []FuncInfo) (Node, error) { | |
| 461 | + | p := &csParser{contexts: make(map[string]bool, len(contexts)), funcs: make(map[string]FuncInfo, len(funcs))} | |
| 462 | + | if exceedsUTF16Len(expression, MaxExpressionLength) { | |
| 463 | + | return nil, p.fail(ErrorExceededMaxLength, nil) | |
| 464 | + | } | |
| 465 | + | p.units = utf16.Encode([]rune(expression)) | |
| 466 | + | p.expression = string(utf16.Decode(p.units)) | |
| 467 | + | for _, c := range contexts { | |
| 468 | + | p.contexts[foldKey(c)] = true | |
| 469 | + | } | |
| 470 | + | for _, f := range funcs { | |
| 471 | + | p.funcs[foldKey(f.Name)] = f | |
| 472 | + | } | |
| 473 | + | p.lex = &csLexer{units: p.units} | |
| 474 | + | return p.createTree() | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | func (p *csParser) createTree() (Node, error) { | |
| 478 | + | for { | |
| 479 | + | tok := p.lex.next() | |
| 480 | + | if tok == nil { | |
| 481 | + | break | |
| 482 | + | } | |
| 483 | + | p.cur = tok | |
| 484 | + | switch { | |
| 485 | + | case tok.kind == csUnexpected: | |
| 486 | + | return nil, p.fail(ErrorUnexpectedSymbol, tok) | |
| 487 | + | case tok.isOperator(): | |
| 488 | + | if err := p.pushOperator(tok); err != nil { | |
| 489 | + | return nil, err | |
| 490 | + | } | |
| 491 | + | default: | |
| 492 | + | if err := p.pushOperand(tok); err != nil { | |
| 493 | + | return nil, err | |
| 494 | + | } | |
| 495 | + | } | |
| 496 | + | p.last = tok | |
| 497 | + | } | |
| 498 | + | if p.last == nil { | |
| 499 | + | return nil, nil | |
| 500 | + | } | |
| 501 | + | if len(p.operators) > 0 { | |
| 502 | + | unexpected := false | |
| 503 | + | switch p.last.kind { | |
| 504 | + | case csEndGroup, csEndIndex, csEndParameters: | |
| 505 | + | case csFunction: | |
| 506 | + | unexpected = true | |
| 507 | + | default: | |
| 508 | + | unexpected = p.last.isOperator() | |
| 509 | + | } | |
| 510 | + | if unexpected || len(p.lex.unclosed) > 0 { | |
| 511 | + | return nil, p.fail(ErrorUnexpectedEndOfExpression, p.last) | |
| 512 | + | } | |
| 513 | + | } | |
| 514 | + | for len(p.operators) > 0 { | |
| 515 | + | if err := p.flushTop(); err != nil { | |
| 516 | + | return nil, err | |
| 517 | + | } | |
| 518 | + | } | |
| 519 | + | if len(p.operands) != 1 { | |
| 520 | + | return nil, p.fail(ErrorUnexpectedSymbol, p.last) | |
| 521 | + | } | |
| 522 | + | root := p.operands[0] | |
| 523 | + | if exceedsDepth(root, 1) { | |
| 524 | + | return nil, p.fail(ErrorExceededMaxDepth, nil) | |
| 525 | + | } | |
| 526 | + | return root, nil | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | func (p *csParser) pushOperand(tok *csToken) error { | |
| 530 | + | var n Node | |
| 531 | + | switch tok.kind { | |
| 532 | + | case csFunction: | |
| 533 | + | if _, ok := p.wellKnownOrExtension(tok.raw); !ok { | |
| 534 | + | return p.fail(ErrorUnrecognizedFunction, tok) | |
| 535 | + | } | |
| 536 | + | n = &FunctionCall{Name: p.token(tok, TokenIdentifier)} | |
| 537 | + | case csNamedValue: | |
| 538 | + | if !p.contexts[foldKey(tok.raw)] { | |
| 539 | + | return p.fail(ErrorUnrecognizedContext, tok) | |
| 540 | + | } | |
| 541 | + | n = &ContextAccess{Name: p.token(tok, TokenIdentifier)} | |
| 542 | + | case csNull: | |
| 543 | + | n = &Literal{Value: Null{}, Token: p.token(tok, TokenNull)} | |
| 544 | + | case csBoolean: | |
| 545 | + | typ := TokenFalse | |
| 546 | + | if tok.value == Boolean(true) { | |
| 547 | + | typ = TokenTrue | |
| 548 | + | } | |
| 549 | + | n = &Literal{Value: tok.value, Token: p.token(tok, typ)} | |
| 550 | + | case csNumber: | |
| 551 | + | n = &Literal{Value: tok.value, Token: p.token(tok, TokenNumber)} | |
| 552 | + | case csString: | |
| 553 | + | n = &Literal{Value: tok.value, Token: p.token(tok, TokenString)} | |
| 554 | + | case csPropertyName: | |
| 555 | + | n = &Literal{Value: String(tok.raw), Token: p.token(tok, TokenIdentifier)} | |
| 556 | + | case csWildcard: | |
| 557 | + | n = &Star{} | |
| 558 | + | default: | |
| 559 | + | return p.fail(ErrorUnexpectedSymbol, tok) | |
| 560 | + | } | |
| 561 | + | p.operands = append(p.operands, n) | |
| 562 | + | return nil | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | func (p *csParser) pushOperator(tok *csToken) error { | |
| 566 | + | if tok.leftToRight() { | |
| 567 | + | prec := tok.precedence() | |
| 568 | + | for len(p.operators) > 0 { | |
| 569 | + | top := p.operators[len(p.operators)-1] | |
| 570 | + | if prec <= top.precedence() && top.kind != csStartGroup && top.kind != csStartIndex && | |
| 571 | + | top.kind != csStartParameters && top.kind != csSeparator { | |
| 572 | + | if err := p.flushTop(); err != nil { | |
| 573 | + | return err | |
| 574 | + | } | |
| 575 | + | continue | |
| 576 | + | } | |
| 577 | + | break | |
| 578 | + | } | |
| 579 | + | } | |
| 580 | + | p.operators = append(p.operators, tok) | |
| 581 | + | switch tok.kind { | |
| 582 | + | case csEndGroup, csEndIndex, csEndParameters: | |
| 583 | + | return p.flushTop() | |
| 584 | + | } | |
| 585 | + | return nil | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | func (p *csParser) popOperator(want csKind) (*csToken, error) { | |
| 589 | + | if len(p.operators) == 0 || p.operators[len(p.operators)-1].kind != want { | |
| 590 | + | return nil, p.fail(ErrorUnexpectedSymbol, p.cur) | |
| 591 | + | } | |
| 592 | + | t := p.operators[len(p.operators)-1] | |
| 593 | + | p.operators = p.operators[:len(p.operators)-1] | |
| 594 | + | return t, nil | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | // popOperands pops n operands, returned in their written order. | |
| 598 | + | func (p *csParser) popOperands(n int) ([]Node, error) { | |
| 599 | + | if len(p.operands) < n { | |
| 600 | + | return nil, p.fail(ErrorUnexpectedSymbol, p.cur) | |
| 601 | + | } | |
| 602 | + | out := append([]Node(nil), p.operands[len(p.operands)-n:]...) | |
| 603 | + | p.operands = p.operands[:len(p.operands)-n] | |
| 604 | + | return out, nil | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | func (p *csParser) flushTop() error { | |
| 608 | + | top := p.operators[len(p.operators)-1] | |
| 609 | + | switch top.kind { | |
| 610 | + | case csEndIndex: | |
| 611 | + | if _, err := p.popOperator(csEndIndex); err != nil { | |
| 612 | + | return err | |
| 613 | + | } | |
| 614 | + | op, err := p.popOperator(csStartIndex) | |
| 615 | + | if err != nil { | |
| 616 | + | return err | |
| 617 | + | } | |
| 618 | + | args, err := p.popOperands(op.operandCount()) | |
| 619 | + | if err != nil { | |
| 620 | + | return err | |
| 621 | + | } | |
| 622 | + | p.operands = append(p.operands, &IndexAccess{Expr: args[0], Index: args[1]}) | |
| 623 | + | return nil | |
| 624 | + | case csEndGroup: | |
| 625 | + | if _, err := p.popOperator(csEndGroup); err != nil { | |
| 626 | + | return err | |
| 627 | + | } | |
| 628 | + | if _, err := p.popOperator(csStartGroup); err != nil { | |
| 629 | + | return err | |
| 630 | + | } | |
| 631 | + | if len(p.operands) == 0 { | |
| 632 | + | return p.fail(ErrorUnexpectedSymbol, p.cur) | |
| 633 | + | } | |
| 634 | + | g := &Grouping{Group: p.operands[len(p.operands)-1]} | |
| 635 | + | p.operands[len(p.operands)-1] = g | |
| 636 | + | return nil | |
| 637 | + | case csEndParameters: | |
| 638 | + | return p.flushEndParameters() | |
| 639 | + | } | |
| 640 | + | op, _ := p.popOperator(top.kind) | |
| 641 | + | args, err := p.popOperands(op.operandCount()) | |
| 642 | + | if err != nil { | |
| 643 | + | return err | |
| 644 | + | } | |
| 645 | + | switch { | |
| 646 | + | case op.kind == csStartIndex || op.kind == csDereference: | |
| 647 | + | p.operands = append(p.operands, &IndexAccess{Expr: args[0], Index: args[1]}) | |
| 648 | + | case op.kind == csLogicalOperator && op.raw == "!": | |
| 649 | + | p.operands = append(p.operands, &Unary{Operator: p.token(op, TokenBang), Expr: args[0]}) | |
| 650 | + | case op.kind == csLogicalOperator && (op.raw == "&&" || op.raw == "||"): | |
| 651 | + | typ := TokenAnd | |
| 652 | + | if op.raw == "||" { | |
| 653 | + | typ = TokenOr | |
| 654 | + | } | |
| 655 | + | l := &Logical{Operator: p.token(op, typ)} | |
| 656 | + | for _, a := range args { | |
| 657 | + | // A nested chain of the same operator is merged, as the runner does. | |
| 658 | + | if nested, ok := a.(*Logical); ok && nested.Operator.Type == typ { | |
| 659 | + | l.Args = append(l.Args, nested.Args...) | |
| 660 | + | } else { | |
| 661 | + | l.Args = append(l.Args, a) | |
| 662 | + | } | |
| 663 | + | } | |
| 664 | + | p.operands = append(p.operands, l) | |
| 665 | + | case op.kind == csLogicalOperator: | |
| 666 | + | var typ TokenType | |
| 667 | + | switch op.raw { | |
| 668 | + | case "!=": | |
| 669 | + | typ = TokenBangEqual | |
| 670 | + | case "==": | |
| 671 | + | typ = TokenEqualEqual | |
| 672 | + | case ">": | |
| 673 | + | typ = TokenGreater | |
| 674 | + | case ">=": | |
| 675 | + | typ = TokenGreaterEqual | |
| 676 | + | case "<": | |
| 677 | + | typ = TokenLess | |
| 678 | + | default: | |
| 679 | + | typ = TokenLessEqual | |
| 680 | + | } | |
| 681 | + | p.operands = append(p.operands, &Binary{Left: args[0], Operator: p.token(op, typ), Right: args[1]}) | |
| 682 | + | default: | |
| 683 | + | return p.fail(ErrorUnexpectedSymbol, op) | |
| 684 | + | } | |
| 685 | + | return nil | |
| 686 | + | } | |
| 687 | + | ||
| 688 | + | func (p *csParser) flushEndParameters() error { | |
| 689 | + | end, err := p.popOperator(csEndParameters) | |
| 690 | + | if err != nil { | |
| 691 | + | return err | |
| 692 | + | } | |
| 693 | + | if end != p.cur || p.last == nil { | |
| 694 | + | return p.fail(ErrorUnexpectedSymbol, p.cur) | |
| 695 | + | } | |
| 696 | + | count := 0 | |
| 697 | + | if p.last.kind != csStartParameters { | |
| 698 | + | count = 1 | |
| 699 | + | for len(p.operators) > 0 && p.operators[len(p.operators)-1].kind == csSeparator { | |
| 700 | + | count++ | |
| 701 | + | p.operators = p.operators[:len(p.operators)-1] | |
| 702 | + | } | |
| 703 | + | } | |
| 704 | + | args, err := p.popOperands(count) | |
| 705 | + | if err != nil { | |
| 706 | + | return err | |
| 707 | + | } | |
| 708 | + | if len(p.operands) == 0 { | |
| 709 | + | return p.fail(ErrorUnexpectedSymbol, p.cur) | |
| 710 | + | } | |
| 711 | + | call, ok := p.operands[len(p.operands)-1].(*FunctionCall) | |
| 712 | + | if !ok { | |
| 713 | + | return p.fail(ErrorUnexpectedSymbol, p.cur) | |
| 714 | + | } | |
| 715 | + | call.Args = args | |
| 716 | + | open, err := p.popOperator(csStartParameters) | |
| 717 | + | if err != nil { | |
| 718 | + | return err | |
| 719 | + | } | |
| 720 | + | info, _ := p.wellKnownOrExtension(call.Name.Lexeme) | |
| 721 | + | switch { | |
| 722 | + | case len(args) < info.MinArgs: | |
| 723 | + | return p.fail(ErrorTooFewParameters, open) | |
| 724 | + | case info.MaxArgs >= 0 && len(args) > info.MaxArgs: | |
| 725 | + | return p.fail(ErrorTooManyParameters, open) | |
| 726 | + | case strings.EqualFold(info.Name, "case") && len(args)%2 == 0: | |
| 727 | + | return p.fail(ErrorEvenParameters, open) | |
| 728 | + | } | |
| 729 | + | return nil | |
| 730 | + | } | |
| 731 | + | ||
| 732 | + | // exceedsDepth reports whether the tree is deeper than MaxParserDepth, counted | |
| 733 | + | // as the runner does (CheckMaxDepth): the root is at depth 1, the operands of | |
| 734 | + | // an operator, the arguments of a call and the two parts of an index access | |
| 735 | + | // are one deeper, parentheses add nothing, and a run of && or || is one node, | |
| 736 | + | // also when parenthesized. | |
| 737 | + | func exceedsDepth(n Node, depth int) bool { | |
| 738 | + | for { | |
| 739 | + | g, ok := n.(*Grouping) | |
| 740 | + | if !ok { | |
| 741 | + | break | |
| 742 | + | } | |
| 743 | + | n = g.Group | |
| 744 | + | } | |
| 745 | + | if depth > MaxParserDepth { | |
| 746 | + | return true | |
| 747 | + | } | |
| 748 | + | switch n := n.(type) { | |
| 749 | + | case *Unary: | |
| 750 | + | return exceedsDepth(n.Expr, depth+1) | |
| 751 | + | case *Binary: | |
| 752 | + | return exceedsDepth(n.Left, depth+1) || exceedsDepth(n.Right, depth+1) | |
| 753 | + | case *Logical: | |
| 754 | + | for _, a := range flatOperands(n, nil) { | |
| 755 | + | if exceedsDepth(a, depth+1) { | |
| 756 | + | return true | |
| 757 | + | } | |
| 758 | + | } | |
| 759 | + | case *FunctionCall: | |
| 760 | + | for _, a := range n.Args { | |
| 761 | + | if exceedsDepth(a, depth+1) { | |
| 762 | + | return true | |
| 763 | + | } | |
| 764 | + | } | |
| 765 | + | case *IndexAccess: | |
| 766 | + | return exceedsDepth(n.Expr, depth+1) || exceedsDepth(n.Index, depth+1) | |
| 767 | + | } | |
| 768 | + | return false | |
| 769 | + | } | |
| 770 | + | ||
| 771 | + | // flatOperands lists the operands of a && or || chain with nested chains of | |
| 772 | + | // the same operator (parenthesized or not) merged into it. | |
| 773 | + | func flatOperands(l *Logical, out []Node) []Node { | |
| 774 | + | for _, a := range l.Args { | |
| 775 | + | inner := a | |
| 776 | + | for { | |
| 777 | + | g, ok := inner.(*Grouping) | |
| 778 | + | if !ok { | |
| 779 | + | break | |
| 780 | + | } | |
| 781 | + | inner = g.Group | |
| 782 | + | } | |
| 783 | + | if nested, ok := inner.(*Logical); ok && nested.Operator.Type == l.Operator.Type { | |
| 784 | + | out = flatOperands(nested, out) | |
| 785 | + | } else { | |
| 786 | + | out = append(out, a) | |
| 787 | + | } | |
| 788 | + | } | |
| 789 | + | return out | |
| 790 | + | } | |
| @@ -0,0 +1,790 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package expr | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | "unicode/utf16" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // This file is a port of the runner's tokenizer and parser: | |||
| 11 | + | // src/Sdk/DTExpressions2/Expressions2/Tokens/LexicalAnalyzer.cs, Tokens/Token.cs | |||
| 12 | + | // and ExpressionParser.cs. They differ from the JavaScript reference's | |||
| 13 | + | // (lexer.go, parser.go) in ways users can see: | |||
| 14 | + | // | |||
| 15 | + | // - the tokenizer is context sensitive: each token is checked against the | |||
| 16 | + | // one before it, so "a b", "1 2" or "f(1,)" fail at the second token; | |||
| 17 | + | // - tokens are produced on demand and the parser stops at the first | |||
| 18 | + | // unexpected token or unknown name, in order of appearance (the JavaScript | |||
| 19 | + | // lexes everything first, so a later lexical error wins there); | |||
| 20 | + | // - white space is Char.IsWhiteSpace (U+0085 and U+00A0 included); | |||
| 21 | + | // - "Unexpected end of expression" is reported for an unfinished expression | |||
| 22 | + | // where the JavaScript says "Unexpected symbol: 'EOF'"; | |||
| 23 | + | // - error positions are absolute indexes in the expression, not columns; | |||
| 24 | + | // - the depth limit applies to the shape of the finished tree, in which | |||
| 25 | + | // parentheses do not count and a flat run of && or || is one node; | |||
| 26 | + | // - the "too long" and "too deep" errors carry no token. | |||
| 27 | + | // | |||
| 28 | + | // Parse builds the same AST as the JavaScript-style parser did. | |||
| 29 | + | ||||
| 30 | + | type csKind int | |||
| 31 | + | ||||
| 32 | + | const ( | |||
| 33 | + | csStartGroup csKind = iota | |||
| 34 | + | csStartIndex | |||
| 35 | + | csStartParameters | |||
| 36 | + | csEndGroup | |||
| 37 | + | csEndIndex | |||
| 38 | + | csEndParameters | |||
| 39 | + | csSeparator | |||
| 40 | + | csDereference | |||
| 41 | + | csWildcard | |||
| 42 | + | csLogicalOperator | |||
| 43 | + | csNull | |||
| 44 | + | csBoolean | |||
| 45 | + | csNumber | |||
| 46 | + | csString | |||
| 47 | + | csPropertyName | |||
| 48 | + | csFunction | |||
| 49 | + | csNamedValue | |||
| 50 | + | csUnexpected | |||
| 51 | + | ) | |||
| 52 | + | ||||
| 53 | + | type csToken struct { | |||
| 54 | + | kind csKind | |||
| 55 | + | raw string | |||
| 56 | + | index int | |||
| 57 | + | value Value // Boolean, Number or String for literals | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func (t *csToken) isOperator() bool { | |||
| 61 | + | switch t.kind { | |||
| 62 | + | case csStartGroup, csStartIndex, csStartParameters, csEndGroup, csEndIndex, csEndParameters, csSeparator, csDereference, csLogicalOperator: | |||
| 63 | + | return true | |||
| 64 | + | } | |||
| 65 | + | return false | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // leftToRight reports whether the token is a left-associative operator. | |||
| 69 | + | func (t *csToken) leftToRight() bool { | |||
| 70 | + | switch { | |||
| 71 | + | case t.kind == csStartGroup: | |||
| 72 | + | return false | |||
| 73 | + | case t.kind == csLogicalOperator && t.raw == "!": | |||
| 74 | + | return false | |||
| 75 | + | } | |||
| 76 | + | return t.isOperator() | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (t *csToken) precedence() int { | |||
| 80 | + | switch t.kind { | |||
| 81 | + | case csStartGroup: | |||
| 82 | + | return 20 | |||
| 83 | + | case csStartIndex, csStartParameters, csDereference: | |||
| 84 | + | return 19 | |||
| 85 | + | case csLogicalOperator: | |||
| 86 | + | switch t.raw { | |||
| 87 | + | case "!": | |||
| 88 | + | return 16 | |||
| 89 | + | case ">", ">=", "<", "<=": | |||
| 90 | + | return 11 | |||
| 91 | + | case "==", "!=": | |||
| 92 | + | return 10 | |||
| 93 | + | case "&&": | |||
| 94 | + | return 6 | |||
| 95 | + | case "||": | |||
| 96 | + | return 5 | |||
| 97 | + | } | |||
| 98 | + | case csEndGroup, csEndIndex, csEndParameters, csSeparator: | |||
| 99 | + | return 1 | |||
| 100 | + | } | |||
| 101 | + | return 0 | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func (t *csToken) operandCount() int { | |||
| 105 | + | switch t.kind { | |||
| 106 | + | case csStartIndex, csDereference: | |||
| 107 | + | return 2 | |||
| 108 | + | case csLogicalOperator: | |||
| 109 | + | if t.raw == "!" { | |||
| 110 | + | return 1 | |||
| 111 | + | } | |||
| 112 | + | return 2 | |||
| 113 | + | } | |||
| 114 | + | return 0 | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // csLexer is LexicalAnalyzer. | |||
| 118 | + | type csLexer struct { | |||
| 119 | + | units []uint16 | |||
| 120 | + | index int | |||
| 121 | + | last *csToken | |||
| 122 | + | unclosed []*csToken // stack of "(", "[" and function "(" | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func (l *csLexer) substr(from, to int) string { return string(utf16.Decode(l.units[from:to])) } | |||
| 126 | + | ||||
| 127 | + | func isCSWhiteSpace(c uint16) bool { return isNetSpace(rune(c)) } | |||
| 128 | + | ||||
| 129 | + | func isCSBoundary(c uint16) bool { | |||
| 130 | + | switch c { | |||
| 131 | + | case '(', '[', ')', ']', ',', '.', '!', '>', '<', '=', '&', '|': | |||
| 132 | + | return true | |||
| 133 | + | } | |||
| 134 | + | return isCSWhiteSpace(c) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func (l *csLexer) topUnclosed() csKind { | |||
| 138 | + | if len(l.unclosed) == 0 { | |||
| 139 | + | return -1 | |||
| 140 | + | } | |||
| 141 | + | return l.unclosed[len(l.unclosed)-1].kind | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // next returns the next token, or nil at the end of the expression. | |||
| 145 | + | func (l *csLexer) next() *csToken { | |||
| 146 | + | for l.index < len(l.units) && isCSWhiteSpace(l.units[l.index]) { | |||
| 147 | + | l.index++ | |||
| 148 | + | } | |||
| 149 | + | if l.index >= len(l.units) { | |||
| 150 | + | return nil | |||
| 151 | + | } | |||
| 152 | + | c := l.units[l.index] | |||
| 153 | + | var tok *csToken | |||
| 154 | + | switch c { | |||
| 155 | + | case '(': | |||
| 156 | + | if l.last != nil && l.last.kind == csFunction { | |||
| 157 | + | tok = l.create(csStartParameters, "(", l.index, nil) | |||
| 158 | + | } else { | |||
| 159 | + | tok = l.create(csStartGroup, "(", l.index, nil) | |||
| 160 | + | } | |||
| 161 | + | l.index++ | |||
| 162 | + | case '[': | |||
| 163 | + | tok = l.create(csStartIndex, "[", l.index, nil) | |||
| 164 | + | l.index++ | |||
| 165 | + | case ')': | |||
| 166 | + | if l.topUnclosed() == csStartParameters { | |||
| 167 | + | tok = l.create(csEndParameters, ")", l.index, nil) | |||
| 168 | + | } else { | |||
| 169 | + | tok = l.create(csEndGroup, ")", l.index, nil) | |||
| 170 | + | } | |||
| 171 | + | l.index++ | |||
| 172 | + | case ']': | |||
| 173 | + | tok = l.create(csEndIndex, "]", l.index, nil) | |||
| 174 | + | l.index++ | |||
| 175 | + | case ',': | |||
| 176 | + | tok = l.create(csSeparator, ",", l.index, nil) | |||
| 177 | + | l.index++ | |||
| 178 | + | case '*': | |||
| 179 | + | tok = l.create(csWildcard, "*", l.index, nil) | |||
| 180 | + | l.index++ | |||
| 181 | + | case '\'': | |||
| 182 | + | tok = l.readString() | |||
| 183 | + | case '!', '>', '<', '=', '&', '|': | |||
| 184 | + | tok = l.readOperator() | |||
| 185 | + | default: | |||
| 186 | + | switch { | |||
| 187 | + | case c == '.': | |||
| 188 | + | if l.last == nil || l.last.kind == csSeparator || l.last.kind == csStartGroup || l.last.kind == csStartIndex || | |||
| 189 | + | l.last.kind == csStartParameters || l.last.kind == csLogicalOperator { | |||
| 190 | + | tok = l.readNumber() | |||
| 191 | + | } else { | |||
| 192 | + | tok = l.create(csDereference, ".", l.index, nil) | |||
| 193 | + | l.index++ | |||
| 194 | + | } | |||
| 195 | + | case c == '-' || c == '+' || (c >= '0' && c <= '9'): | |||
| 196 | + | tok = l.readNumber() | |||
| 197 | + | default: | |||
| 198 | + | tok = l.readKeyword() | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | l.last = tok | |||
| 202 | + | return tok | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func (l *csLexer) readNumber() *csToken { | |||
| 206 | + | start := l.index | |||
| 207 | + | for { | |||
| 208 | + | l.index++ | |||
| 209 | + | if !(l.index < len(l.units) && (!isCSBoundary(l.units[l.index]) || l.units[l.index] == '.')) { | |||
| 210 | + | break | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | str := l.substr(start, l.index) | |||
| 214 | + | d := parseNumber(str) | |||
| 215 | + | if d != d { // NaN | |||
| 216 | + | return l.create(csUnexpected, str, start, nil) | |||
| 217 | + | } | |||
| 218 | + | return l.create(csNumber, str, start, Number(d)) | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func isLegalKeyword(s string) bool { return isLegalIdentifier(s) } | |||
| 222 | + | ||||
| 223 | + | func (l *csLexer) readKeyword() *csToken { | |||
| 224 | + | start := l.index | |||
| 225 | + | l.index++ | |||
| 226 | + | for l.index < len(l.units) && !isCSBoundary(l.units[l.index]) { | |||
| 227 | + | l.index++ | |||
| 228 | + | } | |||
| 229 | + | str := l.substr(start, l.index) | |||
| 230 | + | if !isLegalKeyword(str) { | |||
| 231 | + | return l.create(csUnexpected, str, start, nil) | |||
| 232 | + | } | |||
| 233 | + | if l.last != nil && l.last.kind == csDereference { | |||
| 234 | + | return l.create(csPropertyName, str, start, nil) | |||
| 235 | + | } | |||
| 236 | + | switch str { | |||
| 237 | + | case "null": | |||
| 238 | + | return l.create(csNull, str, start, Null{}) | |||
| 239 | + | case "true": | |||
| 240 | + | return l.create(csBoolean, str, start, Boolean(true)) | |||
| 241 | + | case "false": | |||
| 242 | + | return l.create(csBoolean, str, start, Boolean(false)) | |||
| 243 | + | case "NaN": | |||
| 244 | + | return l.create(csNumber, str, start, Number(nan)) | |||
| 245 | + | case "Infinity": | |||
| 246 | + | return l.create(csNumber, str, start, Number(posInf)) | |||
| 247 | + | } | |||
| 248 | + | // Lookahead: a function is a name followed by "(", white space allowed. | |||
| 249 | + | t := l.index | |||
| 250 | + | for t < len(l.units) && isCSWhiteSpace(l.units[t]) { | |||
| 251 | + | t++ | |||
| 252 | + | } | |||
| 253 | + | if t < len(l.units) && l.units[t] == '(' { | |||
| 254 | + | return l.create(csFunction, str, start, nil) | |||
| 255 | + | } | |||
| 256 | + | return l.create(csNamedValue, str, start, nil) | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | func (l *csLexer) readString() *csToken { | |||
| 260 | + | start := l.index | |||
| 261 | + | var b strings.Builder | |||
| 262 | + | closed := false | |||
| 263 | + | l.index++ // the opening quote | |||
| 264 | + | var run []uint16 | |||
| 265 | + | for l.index < len(l.units) { | |||
| 266 | + | c := l.units[l.index] | |||
| 267 | + | l.index++ | |||
| 268 | + | if c == '\'' { | |||
| 269 | + | if l.index >= len(l.units) || l.units[l.index] != '\'' { | |||
| 270 | + | closed = true | |||
| 271 | + | break | |||
| 272 | + | } | |||
| 273 | + | l.index++ // an escaped quote | |||
| 274 | + | } | |||
| 275 | + | run = append(run, c) | |||
| 276 | + | } | |||
| 277 | + | b.WriteString(string(utf16.Decode(run))) | |||
| 278 | + | raw := l.substr(start, l.index) | |||
| 279 | + | if closed { | |||
| 280 | + | return l.create(csString, raw, start, String(b.String())) | |||
| 281 | + | } | |||
| 282 | + | return l.create(csUnexpected, raw, start, nil) | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | func (l *csLexer) readOperator() *csToken { | |||
| 286 | + | start := l.index | |||
| 287 | + | l.index++ | |||
| 288 | + | if l.index < len(l.units) { | |||
| 289 | + | l.index++ | |||
| 290 | + | switch raw := l.substr(start, start+2); raw { | |||
| 291 | + | case "!=", ">=", "<=", "==", "&&", "||": | |||
| 292 | + | return l.create(csLogicalOperator, raw, start, nil) | |||
| 293 | + | } | |||
| 294 | + | l.index-- // back up | |||
| 295 | + | } | |||
| 296 | + | switch raw := l.substr(start, start+1); raw { | |||
| 297 | + | case "!", ">", "<": | |||
| 298 | + | return l.create(csLogicalOperator, raw, start, nil) | |||
| 299 | + | } | |||
| 300 | + | for l.index < len(l.units) && !isCSBoundary(l.units[l.index]) { | |||
| 301 | + | l.index++ | |||
| 302 | + | } | |||
| 303 | + | return l.create(csUnexpected, l.substr(start, l.index), start, nil) | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // lastIs reports whether the previous token is one of kinds; a nil kind list | |||
| 307 | + | // entry (csUnexpected here) stands for "no previous token". | |||
| 308 | + | func (l *csLexer) lastIs(kinds ...csKind) bool { | |||
| 309 | + | if l.last == nil { | |||
| 310 | + | for _, k := range kinds { | |||
| 311 | + | if k == -1 { | |||
| 312 | + | return true | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | return false | |||
| 316 | + | } | |||
| 317 | + | for _, k := range kinds { | |||
| 318 | + | if k == l.last.kind { | |||
| 319 | + | return true | |||
| 320 | + | } | |||
| 321 | + | } | |||
| 322 | + | return false | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | const csFirst csKind = -1 // "is the first token" | |||
| 326 | + | ||||
| 327 | + | // create builds a token after checking that it may follow the previous one | |||
| 328 | + | // and that brackets are balanced; an illegal one becomes csUnexpected. | |||
| 329 | + | func (l *csLexer) create(kind csKind, raw string, index int, v Value) *csToken { | |||
| 330 | + | var legal bool | |||
| 331 | + | switch kind { | |||
| 332 | + | case csStartGroup: | |||
| 333 | + | legal = l.lastIs(csFirst, csSeparator, csStartGroup, csStartParameters, csStartIndex, csLogicalOperator) | |||
| 334 | + | case csStartIndex: | |||
| 335 | + | legal = l.lastIs(csEndGroup, csEndParameters, csEndIndex, csWildcard, csPropertyName, csNamedValue) | |||
| 336 | + | case csStartParameters: | |||
| 337 | + | legal = l.lastIs(csFunction) | |||
| 338 | + | case csEndGroup, csEndIndex: | |||
| 339 | + | legal = l.lastIs(csEndGroup, csEndParameters, csEndIndex, csWildcard, csNull, csBoolean, csNumber, csString, csPropertyName, csNamedValue) | |||
| 340 | + | case csEndParameters: | |||
| 341 | + | legal = l.lastIs(csStartParameters, csEndGroup, csEndParameters, csEndIndex, csWildcard, csNull, csBoolean, csNumber, csString, csPropertyName, csNamedValue) | |||
| 342 | + | case csSeparator: | |||
| 343 | + | legal = l.lastIs(csEndGroup, csEndParameters, csEndIndex, csWildcard, csNull, csBoolean, csNumber, csString, csPropertyName, csNamedValue) | |||
| 344 | + | case csDereference: | |||
| 345 | + | legal = l.lastIs(csEndGroup, csEndParameters, csEndIndex, csWildcard, csPropertyName, csNamedValue) | |||
| 346 | + | case csWildcard: | |||
| 347 | + | legal = l.lastIs(csStartIndex, csDereference) | |||
| 348 | + | case csLogicalOperator: | |||
| 349 | + | if raw == "!" { | |||
| 350 | + | legal = l.lastIs(csFirst, csSeparator, csStartGroup, csStartParameters, csStartIndex, csLogicalOperator) | |||
| 351 | + | } else { | |||
| 352 | + | legal = l.lastIs(csEndGroup, csEndParameters, csEndIndex, csWildcard, csNull, csBoolean, csNumber, csString, csPropertyName, csNamedValue) | |||
| 353 | + | } | |||
| 354 | + | case csNull, csBoolean, csNumber, csString, csFunction, csNamedValue: | |||
| 355 | + | legal = l.lastIs(csFirst, csSeparator, csStartIndex, csStartGroup, csStartParameters, csLogicalOperator) | |||
| 356 | + | case csPropertyName: | |||
| 357 | + | legal = l.lastIs(csDereference) | |||
| 358 | + | } | |||
| 359 | + | if !legal { | |||
| 360 | + | return &csToken{kind: csUnexpected, raw: raw, index: index} | |||
| 361 | + | } | |||
| 362 | + | tok := &csToken{kind: kind, raw: raw, index: index, value: v} | |||
| 363 | + | switch kind { | |||
| 364 | + | case csStartGroup, csStartIndex, csStartParameters: | |||
| 365 | + | l.unclosed = append(l.unclosed, tok) | |||
| 366 | + | case csEndGroup: | |||
| 367 | + | if l.topUnclosed() != csStartGroup { | |||
| 368 | + | return &csToken{kind: csUnexpected, raw: raw, index: index} | |||
| 369 | + | } | |||
| 370 | + | l.unclosed = l.unclosed[:len(l.unclosed)-1] | |||
| 371 | + | case csEndIndex: | |||
| 372 | + | if l.topUnclosed() != csStartIndex { | |||
| 373 | + | return &csToken{kind: csUnexpected, raw: raw, index: index} | |||
| 374 | + | } | |||
| 375 | + | l.unclosed = l.unclosed[:len(l.unclosed)-1] | |||
| 376 | + | case csEndParameters: | |||
| 377 | + | if l.topUnclosed() != csStartParameters { | |||
| 378 | + | return &csToken{kind: csUnexpected, raw: raw, index: index} | |||
| 379 | + | } | |||
| 380 | + | l.unclosed = l.unclosed[:len(l.unclosed)-1] | |||
| 381 | + | case csSeparator: | |||
| 382 | + | if l.topUnclosed() != csStartParameters { | |||
| 383 | + | return &csToken{kind: csUnexpected, raw: raw, index: index} | |||
| 384 | + | } | |||
| 385 | + | } | |||
| 386 | + | return tok | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | // csParser is ExpressionParser.CreateTree: a shunting-yard parser. | |||
| 390 | + | type csParser struct { | |||
| 391 | + | expression string | |||
| 392 | + | units []uint16 | |||
| 393 | + | lex *csLexer | |||
| 394 | + | contexts map[string]bool | |||
| 395 | + | funcs map[string]FuncInfo | |||
| 396 | + | operands []Node | |||
| 397 | + | operators []*csToken | |||
| 398 | + | last *csToken // the previous token | |||
| 399 | + | cur *csToken | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | // fail builds the ParseException of the runner: with a token, the message names | |||
| 403 | + | // it and its absolute position; without, only the description. | |||
| 404 | + | func (p *csParser) fail(t ErrorType, tok *csToken) *Error { | |||
| 405 | + | if tok == nil { | |||
| 406 | + | return &Error{Type: t, Message: t.description()} | |||
| 407 | + | } | |||
| 408 | + | return &Error{ | |||
| 409 | + | Type: t, | |||
| 410 | + | Message: t.description() + ": '" + tok.raw + "'. Located at position " + itoa(tok.index+1) + " within expression: " + p.expression, | |||
| 411 | + | Pos: p.position(tok.index), | |||
| 412 | + | Token: tok.raw, | |||
| 413 | + | } | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | func itoa(n int) string { return strings.TrimSpace(strings.Repeat(" ", 0) + intString(n)) } | |||
| 417 | + | ||||
| 418 | + | func intString(n int) string { | |||
| 419 | + | if n == 0 { | |||
| 420 | + | return "0" | |||
| 421 | + | } | |||
| 422 | + | var b [20]byte | |||
| 423 | + | i := len(b) | |||
| 424 | + | for n > 0 { | |||
| 425 | + | i-- | |||
| 426 | + | b[i] = byte('0' + n%10) | |||
| 427 | + | n /= 10 | |||
| 428 | + | } | |||
| 429 | + | return string(b[i:]) | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | // position converts an absolute index into the line and column of the | |||
| 433 | + | // JavaScript-style Position (line breaks are U+000A). | |||
| 434 | + | func (p *csParser) position(index int) Position { | |||
| 435 | + | line, lineStart := 0, 0 | |||
| 436 | + | for i := 0; i < index && i < len(p.units); i++ { | |||
| 437 | + | if p.units[i] == '\n' { | |||
| 438 | + | line++ | |||
| 439 | + | lineStart = i + 1 | |||
| 440 | + | } | |||
| 441 | + | } | |||
| 442 | + | return Position{Line: line, Column: index - lineStart} | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | func (p *csParser) token(t *csToken, typ TokenType) Token { | |||
| 446 | + | end := t.index + len(utf16.Encode([]rune(t.raw))) | |||
| 447 | + | return Token{Type: typ, Lexeme: t.raw, Range: Range{Start: p.position(t.index), End: p.position(end)}, Value: t.value} | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | func (p *csParser) wellKnownOrExtension(name string) (FuncInfo, bool) { | |||
| 451 | + | k := foldKey(name) | |||
| 452 | + | if f, ok := builtinFuncs[k]; ok { | |||
| 453 | + | return f, true | |||
| 454 | + | } | |||
| 455 | + | f, ok := p.funcs[k] | |||
| 456 | + | return f, ok | |||
| 457 | + | } | |||
| 458 | + | ||||
| 459 | + | // parseRunner parses expression the way the runner does. | |||
| 460 | + | func parseRunner(expression string, contexts []string, funcs []FuncInfo) (Node, error) { | |||
| 461 | + | p := &csParser{contexts: make(map[string]bool, len(contexts)), funcs: make(map[string]FuncInfo, len(funcs))} | |||
| 462 | + | if exceedsUTF16Len(expression, MaxExpressionLength) { | |||
| 463 | + | return nil, p.fail(ErrorExceededMaxLength, nil) | |||
| 464 | + | } | |||
| 465 | + | p.units = utf16.Encode([]rune(expression)) | |||
| 466 | + | p.expression = string(utf16.Decode(p.units)) | |||
| 467 | + | for _, c := range contexts { | |||
| 468 | + | p.contexts[foldKey(c)] = true | |||
| 469 | + | } | |||
| 470 | + | for _, f := range funcs { | |||
| 471 | + | p.funcs[foldKey(f.Name)] = f | |||
| 472 | + | } | |||
| 473 | + | p.lex = &csLexer{units: p.units} | |||
| 474 | + | return p.createTree() | |||
| 475 | + | } | |||
| 476 | + | ||||
| 477 | + | func (p *csParser) createTree() (Node, error) { | |||
| 478 | + | for { | |||
| 479 | + | tok := p.lex.next() | |||
| 480 | + | if tok == nil { | |||
| 481 | + | break | |||
| 482 | + | } | |||
| 483 | + | p.cur = tok | |||
| 484 | + | switch { | |||
| 485 | + | case tok.kind == csUnexpected: | |||
| 486 | + | return nil, p.fail(ErrorUnexpectedSymbol, tok) | |||
| 487 | + | case tok.isOperator(): | |||
| 488 | + | if err := p.pushOperator(tok); err != nil { | |||
| 489 | + | return nil, err | |||
| 490 | + | } | |||
| 491 | + | default: | |||
| 492 | + | if err := p.pushOperand(tok); err != nil { | |||
| 493 | + | return nil, err | |||
| 494 | + | } | |||
| 495 | + | } | |||
| 496 | + | p.last = tok | |||
| 497 | + | } | |||
| 498 | + | if p.last == nil { | |||
| 499 | + | return nil, nil | |||
| 500 | + | } | |||
| 501 | + | if len(p.operators) > 0 { | |||
| 502 | + | unexpected := false | |||
| 503 | + | switch p.last.kind { | |||
| 504 | + | case csEndGroup, csEndIndex, csEndParameters: | |||
| 505 | + | case csFunction: | |||
| 506 | + | unexpected = true | |||
| 507 | + | default: | |||
| 508 | + | unexpected = p.last.isOperator() | |||
| 509 | + | } | |||
| 510 | + | if unexpected || len(p.lex.unclosed) > 0 { | |||
| 511 | + | return nil, p.fail(ErrorUnexpectedEndOfExpression, p.last) | |||
| 512 | + | } | |||
| 513 | + | } | |||
| 514 | + | for len(p.operators) > 0 { | |||
| 515 | + | if err := p.flushTop(); err != nil { | |||
| 516 | + | return nil, err | |||
| 517 | + | } | |||
| 518 | + | } | |||
| 519 | + | if len(p.operands) != 1 { | |||
| 520 | + | return nil, p.fail(ErrorUnexpectedSymbol, p.last) | |||
| 521 | + | } | |||
| 522 | + | root := p.operands[0] | |||
| 523 | + | if exceedsDepth(root, 1) { | |||
| 524 | + | return nil, p.fail(ErrorExceededMaxDepth, nil) | |||
| 525 | + | } | |||
| 526 | + | return root, nil | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | func (p *csParser) pushOperand(tok *csToken) error { | |||
| 530 | + | var n Node | |||
| 531 | + | switch tok.kind { | |||
| 532 | + | case csFunction: | |||
| 533 | + | if _, ok := p.wellKnownOrExtension(tok.raw); !ok { | |||
| 534 | + | return p.fail(ErrorUnrecognizedFunction, tok) | |||
| 535 | + | } | |||
| 536 | + | n = &FunctionCall{Name: p.token(tok, TokenIdentifier)} | |||
| 537 | + | case csNamedValue: | |||
| 538 | + | if !p.contexts[foldKey(tok.raw)] { | |||
| 539 | + | return p.fail(ErrorUnrecognizedContext, tok) | |||
| 540 | + | } | |||
| 541 | + | n = &ContextAccess{Name: p.token(tok, TokenIdentifier)} | |||
| 542 | + | case csNull: | |||
| 543 | + | n = &Literal{Value: Null{}, Token: p.token(tok, TokenNull)} | |||
| 544 | + | case csBoolean: | |||
| 545 | + | typ := TokenFalse | |||
| 546 | + | if tok.value == Boolean(true) { | |||
| 547 | + | typ = TokenTrue | |||
| 548 | + | } | |||
| 549 | + | n = &Literal{Value: tok.value, Token: p.token(tok, typ)} | |||
| 550 | + | case csNumber: | |||
| 551 | + | n = &Literal{Value: tok.value, Token: p.token(tok, TokenNumber)} | |||
| 552 | + | case csString: | |||
| 553 | + | n = &Literal{Value: tok.value, Token: p.token(tok, TokenString)} | |||
| 554 | + | case csPropertyName: | |||
| 555 | + | n = &Literal{Value: String(tok.raw), Token: p.token(tok, TokenIdentifier)} | |||
| 556 | + | case csWildcard: | |||
| 557 | + | n = &Star{} | |||
| 558 | + | default: | |||
| 559 | + | return p.fail(ErrorUnexpectedSymbol, tok) | |||
| 560 | + | } | |||
| 561 | + | p.operands = append(p.operands, n) | |||
| 562 | + | return nil | |||
| 563 | + | } | |||
| 564 | + | ||||
| 565 | + | func (p *csParser) pushOperator(tok *csToken) error { | |||
| 566 | + | if tok.leftToRight() { | |||
| 567 | + | prec := tok.precedence() | |||
| 568 | + | for len(p.operators) > 0 { | |||
| 569 | + | top := p.operators[len(p.operators)-1] | |||
| 570 | + | if prec <= top.precedence() && top.kind != csStartGroup && top.kind != csStartIndex && | |||
| 571 | + | top.kind != csStartParameters && top.kind != csSeparator { | |||
| 572 | + | if err := p.flushTop(); err != nil { | |||
| 573 | + | return err | |||
| 574 | + | } | |||
| 575 | + | continue | |||
| 576 | + | } | |||
| 577 | + | break | |||
| 578 | + | } | |||
| 579 | + | } | |||
| 580 | + | p.operators = append(p.operators, tok) | |||
| 581 | + | switch tok.kind { | |||
| 582 | + | case csEndGroup, csEndIndex, csEndParameters: | |||
| 583 | + | return p.flushTop() | |||
| 584 | + | } | |||
| 585 | + | return nil | |||
| 586 | + | } | |||
| 587 | + | ||||
| 588 | + | func (p *csParser) popOperator(want csKind) (*csToken, error) { | |||
| 589 | + | if len(p.operators) == 0 || p.operators[len(p.operators)-1].kind != want { | |||
| 590 | + | return nil, p.fail(ErrorUnexpectedSymbol, p.cur) | |||
| 591 | + | } | |||
| 592 | + | t := p.operators[len(p.operators)-1] | |||
| 593 | + | p.operators = p.operators[:len(p.operators)-1] | |||
| 594 | + | return t, nil | |||
| 595 | + | } | |||
| 596 | + | ||||
| 597 | + | // popOperands pops n operands, returned in their written order. | |||
| 598 | + | func (p *csParser) popOperands(n int) ([]Node, error) { | |||
| 599 | + | if len(p.operands) < n { | |||
| 600 | + | return nil, p.fail(ErrorUnexpectedSymbol, p.cur) | |||
| 601 | + | } | |||
| 602 | + | out := append([]Node(nil), p.operands[len(p.operands)-n:]...) | |||
| 603 | + | p.operands = p.operands[:len(p.operands)-n] | |||
| 604 | + | return out, nil | |||
| 605 | + | } | |||
| 606 | + | ||||
| 607 | + | func (p *csParser) flushTop() error { | |||
| 608 | + | top := p.operators[len(p.operators)-1] | |||
| 609 | + | switch top.kind { | |||
| 610 | + | case csEndIndex: | |||
| 611 | + | if _, err := p.popOperator(csEndIndex); err != nil { | |||
| 612 | + | return err | |||
| 613 | + | } | |||
| 614 | + | op, err := p.popOperator(csStartIndex) | |||
| 615 | + | if err != nil { | |||
| 616 | + | return err | |||
| 617 | + | } | |||
| 618 | + | args, err := p.popOperands(op.operandCount()) | |||
| 619 | + | if err != nil { | |||
| 620 | + | return err | |||
| 621 | + | } | |||
| 622 | + | p.operands = append(p.operands, &IndexAccess{Expr: args[0], Index: args[1]}) | |||
| 623 | + | return nil | |||
| 624 | + | case csEndGroup: | |||
| 625 | + | if _, err := p.popOperator(csEndGroup); err != nil { | |||
| 626 | + | return err | |||
| 627 | + | } | |||
| 628 | + | if _, err := p.popOperator(csStartGroup); err != nil { | |||
| 629 | + | return err | |||
| 630 | + | } | |||
| 631 | + | if len(p.operands) == 0 { | |||
| 632 | + | return p.fail(ErrorUnexpectedSymbol, p.cur) | |||
| 633 | + | } | |||
| 634 | + | g := &Grouping{Group: p.operands[len(p.operands)-1]} | |||
| 635 | + | p.operands[len(p.operands)-1] = g | |||
| 636 | + | return nil | |||
| 637 | + | case csEndParameters: | |||
| 638 | + | return p.flushEndParameters() | |||
| 639 | + | } | |||
| 640 | + | op, _ := p.popOperator(top.kind) | |||
| 641 | + | args, err := p.popOperands(op.operandCount()) | |||
| 642 | + | if err != nil { | |||
| 643 | + | return err | |||
| 644 | + | } | |||
| 645 | + | switch { | |||
| 646 | + | case op.kind == csStartIndex || op.kind == csDereference: | |||
| 647 | + | p.operands = append(p.operands, &IndexAccess{Expr: args[0], Index: args[1]}) | |||
| 648 | + | case op.kind == csLogicalOperator && op.raw == "!": | |||
| 649 | + | p.operands = append(p.operands, &Unary{Operator: p.token(op, TokenBang), Expr: args[0]}) | |||
| 650 | + | case op.kind == csLogicalOperator && (op.raw == "&&" || op.raw == "||"): | |||
| 651 | + | typ := TokenAnd | |||
| 652 | + | if op.raw == "||" { | |||
| 653 | + | typ = TokenOr | |||
| 654 | + | } | |||
| 655 | + | l := &Logical{Operator: p.token(op, typ)} | |||
| 656 | + | for _, a := range args { | |||
| 657 | + | // A nested chain of the same operator is merged, as the runner does. | |||
| 658 | + | if nested, ok := a.(*Logical); ok && nested.Operator.Type == typ { | |||
| 659 | + | l.Args = append(l.Args, nested.Args...) | |||
| 660 | + | } else { | |||
| 661 | + | l.Args = append(l.Args, a) | |||
| 662 | + | } | |||
| 663 | + | } | |||
| 664 | + | p.operands = append(p.operands, l) | |||
| 665 | + | case op.kind == csLogicalOperator: | |||
| 666 | + | var typ TokenType | |||
| 667 | + | switch op.raw { | |||
| 668 | + | case "!=": | |||
| 669 | + | typ = TokenBangEqual | |||
| 670 | + | case "==": | |||
| 671 | + | typ = TokenEqualEqual | |||
| 672 | + | case ">": | |||
| 673 | + | typ = TokenGreater | |||
| 674 | + | case ">=": | |||
| 675 | + | typ = TokenGreaterEqual | |||
| 676 | + | case "<": | |||
| 677 | + | typ = TokenLess | |||
| 678 | + | default: | |||
| 679 | + | typ = TokenLessEqual | |||
| 680 | + | } | |||
| 681 | + | p.operands = append(p.operands, &Binary{Left: args[0], Operator: p.token(op, typ), Right: args[1]}) | |||
| 682 | + | default: | |||
| 683 | + | return p.fail(ErrorUnexpectedSymbol, op) | |||
| 684 | + | } | |||
| 685 | + | return nil | |||
| 686 | + | } | |||
| 687 | + | ||||
| 688 | + | func (p *csParser) flushEndParameters() error { | |||
| 689 | + | end, err := p.popOperator(csEndParameters) | |||
| 690 | + | if err != nil { | |||
| 691 | + | return err | |||
| 692 | + | } | |||
| 693 | + | if end != p.cur || p.last == nil { | |||
| 694 | + | return p.fail(ErrorUnexpectedSymbol, p.cur) | |||
| 695 | + | } | |||
| 696 | + | count := 0 | |||
| 697 | + | if p.last.kind != csStartParameters { | |||
| 698 | + | count = 1 | |||
| 699 | + | for len(p.operators) > 0 && p.operators[len(p.operators)-1].kind == csSeparator { | |||
| 700 | + | count++ | |||
| 701 | + | p.operators = p.operators[:len(p.operators)-1] | |||
| 702 | + | } | |||
| 703 | + | } | |||
| 704 | + | args, err := p.popOperands(count) | |||
| 705 | + | if err != nil { | |||
| 706 | + | return err | |||
| 707 | + | } | |||
| 708 | + | if len(p.operands) == 0 { | |||
| 709 | + | return p.fail(ErrorUnexpectedSymbol, p.cur) | |||
| 710 | + | } | |||
| 711 | + | call, ok := p.operands[len(p.operands)-1].(*FunctionCall) | |||
| 712 | + | if !ok { | |||
| 713 | + | return p.fail(ErrorUnexpectedSymbol, p.cur) | |||
| 714 | + | } | |||
| 715 | + | call.Args = args | |||
| 716 | + | open, err := p.popOperator(csStartParameters) | |||
| 717 | + | if err != nil { | |||
| 718 | + | return err | |||
| 719 | + | } | |||
| 720 | + | info, _ := p.wellKnownOrExtension(call.Name.Lexeme) | |||
| 721 | + | switch { | |||
| 722 | + | case len(args) < info.MinArgs: | |||
| 723 | + | return p.fail(ErrorTooFewParameters, open) | |||
| 724 | + | case info.MaxArgs >= 0 && len(args) > info.MaxArgs: | |||
| 725 | + | return p.fail(ErrorTooManyParameters, open) | |||
| 726 | + | case strings.EqualFold(info.Name, "case") && len(args)%2 == 0: | |||
| 727 | + | return p.fail(ErrorEvenParameters, open) | |||
| 728 | + | } | |||
| 729 | + | return nil | |||
| 730 | + | } | |||
| 731 | + | ||||
| 732 | + | // exceedsDepth reports whether the tree is deeper than MaxParserDepth, counted | |||
| 733 | + | // as the runner does (CheckMaxDepth): the root is at depth 1, the operands of | |||
| 734 | + | // an operator, the arguments of a call and the two parts of an index access | |||
| 735 | + | // are one deeper, parentheses add nothing, and a run of && or || is one node, | |||
| 736 | + | // also when parenthesized. | |||
| 737 | + | func exceedsDepth(n Node, depth int) bool { | |||
| 738 | + | for { | |||
| 739 | + | g, ok := n.(*Grouping) | |||
| 740 | + | if !ok { | |||
| 741 | + | break | |||
| 742 | + | } | |||
| 743 | + | n = g.Group | |||
| 744 | + | } | |||
| 745 | + | if depth > MaxParserDepth { | |||
| 746 | + | return true | |||
| 747 | + | } | |||
| 748 | + | switch n := n.(type) { | |||
| 749 | + | case *Unary: | |||
| 750 | + | return exceedsDepth(n.Expr, depth+1) | |||
| 751 | + | case *Binary: | |||
| 752 | + | return exceedsDepth(n.Left, depth+1) || exceedsDepth(n.Right, depth+1) | |||
| 753 | + | case *Logical: | |||
| 754 | + | for _, a := range flatOperands(n, nil) { | |||
| 755 | + | if exceedsDepth(a, depth+1) { | |||
| 756 | + | return true | |||
| 757 | + | } | |||
| 758 | + | } | |||
| 759 | + | case *FunctionCall: | |||
| 760 | + | for _, a := range n.Args { | |||
| 761 | + | if exceedsDepth(a, depth+1) { | |||
| 762 | + | return true | |||
| 763 | + | } | |||
| 764 | + | } | |||
| 765 | + | case *IndexAccess: | |||
| 766 | + | return exceedsDepth(n.Expr, depth+1) || exceedsDepth(n.Index, depth+1) | |||
| 767 | + | } | |||
| 768 | + | return false | |||
| 769 | + | } | |||
| 770 | + | ||||
| 771 | + | // flatOperands lists the operands of a && or || chain with nested chains of | |||
| 772 | + | // the same operator (parenthesized or not) merged into it. | |||
| 773 | + | func flatOperands(l *Logical, out []Node) []Node { | |||
| 774 | + | for _, a := range l.Args { | |||
| 775 | + | inner := a | |||
| 776 | + | for { | |||
| 777 | + | g, ok := inner.(*Grouping) | |||
| 778 | + | if !ok { | |||
| 779 | + | break | |||
| 780 | + | } | |||
| 781 | + | inner = g.Group | |||
| 782 | + | } | |||
| 783 | + | if nested, ok := inner.(*Logical); ok && nested.Operator.Type == l.Operator.Type { | |||
| 784 | + | out = flatOperands(nested, out) | |||
| 785 | + | } else { | |||
| 786 | + | out = append(out, a) | |||
| 787 | + | } | |||
| 788 | + | } | |||
| 789 | + | return out | |||
| 790 | + | } | |||
M expr/parser.go +4 -319
| @@ -18,8 +18,8 @@ type FuncInfo struct { | |||
| 18 | 18 | MaxArgs int | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | - | // builtinFuncs are the functions of the reference's wellKnownFunctions, keyed | |
| 22 | - | // by lower-cased name. They take precedence over caller-supplied FuncInfo for | |
| 21 | + | // builtinFuncs are the functions of ExpressionConstants.WellKnownFunctions, keyed | |
| 22 | + | // by upper-cased name (see foldKey). They take precedence over caller-supplied FuncInfo for | |
| 23 | 23 | // validation. | |
| 24 | 24 | var builtinFuncs = map[string]FuncInfo{ | |
| 25 | 25 | "CASE": {"case", 3, math.MaxInt}, | |
| @@ -43,239 +43,11 @@ var builtinFuncs = map[string]FuncInfo{ | |||
| 43 | 43 | // "" or " ", is not an error: the reference returns nothing for it, and Parse | |
| 44 | 44 | // returns a nil [Node], which [Evaluate] rejects. | |
| 45 | 45 | func Parse(expr string, contexts []string, funcs []FuncInfo) (Node, error) { | |
| 46 | - | tokens, err := Lex(expr) | |
| 47 | - | if err != nil { | |
| 48 | - | return nil, err | |
| 49 | - | } | |
| 50 | - | p := &parser{ | |
| 51 | - | tokens: tokens, | |
| 52 | - | contexts: make(map[string]bool, len(contexts)), | |
| 53 | - | funcs: make(map[string]FuncInfo, len(funcs)), | |
| 54 | - | } | |
| 55 | - | for _, c := range contexts { | |
| 56 | - | p.contexts[foldKey(c)] = true | |
| 57 | - | } | |
| 58 | - | for _, f := range funcs { | |
| 59 | - | p.funcs[foldKey(f.Name)] = f | |
| 60 | - | } | |
| 61 | - | return p.parse() | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | // parser is a port of Parser in parser.js: recursive descent, with the same | |
| 65 | - | // precedence (lowest first): ||, &&, == !=, < <= > >=, !, then index and | |
| 66 | - | // call. | |
| 67 | - | type parser struct { | |
| 68 | - | tokens []Token | |
| 69 | - | offset int | |
| 70 | - | depth int | |
| 71 | - | contexts map[string]bool | |
| 72 | - | funcs map[string]FuncInfo | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | func (p *parser) parse() (Node, error) { | |
| 76 | - | if p.atEnd() { | |
| 77 | - | return nil, nil | |
| 78 | - | } | |
| 79 | - | n, err := p.expression() | |
| 80 | - | if err != nil { | |
| 81 | - | return nil, err | |
| 82 | - | } | |
| 83 | - | if !p.atEnd() { | |
| 84 | - | return nil, newTokenError(ErrorUnexpectedSymbol, p.peek()) | |
| 85 | - | } | |
| 86 | - | return n, nil | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | func (p *parser) expression() (Node, error) { | |
| 90 | - | if err := p.incrDepth(); err != nil { | |
| 91 | - | return nil, err | |
| 92 | - | } | |
| 93 | - | n, err := p.logicalOr() | |
| 94 | - | p.depth-- | |
| 95 | - | return n, err | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | func (p *parser) logicalOr() (Node, error) { | |
| 99 | - | n, err := p.logicalAnd() | |
| 100 | - | if err != nil || !p.check(TokenOr) { | |
| 101 | - | return n, err | |
| 102 | - | } | |
| 103 | - | return p.logicalChain(TokenOr, n, p.logicalAnd) | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | func (p *parser) logicalAnd() (Node, error) { | |
| 107 | - | n, err := p.equality() | |
| 108 | - | if err != nil || !p.check(TokenAnd) { | |
| 109 | - | return n, err | |
| 110 | - | } | |
| 111 | - | return p.logicalChain(TokenAnd, n, p.equality) | |
| 112 | - | } | |
| 113 | - | ||
| 114 | - | // logicalChain parses the rest of a flat chain of operator operands, the | |
| 115 | - | // first of which is already parsed. | |
| 116 | - | func (p *parser) logicalChain(op TokenType, first Node, operand func() (Node, error)) (Node, error) { | |
| 117 | - | if err := p.incrDepth(); err != nil { | |
| 118 | - | return nil, err | |
| 119 | - | } | |
| 120 | - | logical := &Logical{Operator: p.peek(), Args: []Node{first}} | |
| 121 | - | for p.match(op) { | |
| 122 | - | right, err := operand() | |
| 123 | - | if err != nil { | |
| 124 | - | return nil, err | |
| 125 | - | } | |
| 126 | - | logical.Args = append(logical.Args, right) | |
| 127 | - | } | |
| 128 | - | p.depth-- | |
| 129 | - | return logical, nil | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | func (p *parser) equality() (Node, error) { | |
| 133 | - | n, err := p.comparison() | |
| 134 | - | if err != nil { | |
| 135 | - | return nil, err | |
| 136 | - | } | |
| 137 | - | for p.match(TokenBangEqual, TokenEqualEqual) { | |
| 138 | - | op := p.previous() | |
| 139 | - | right, err := p.comparison() | |
| 140 | - | if err != nil { | |
| 141 | - | return nil, err | |
| 142 | - | } | |
| 143 | - | n = &Binary{Left: n, Operator: op, Right: right} | |
| 144 | - | } | |
| 145 | - | return n, nil | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | func (p *parser) comparison() (Node, error) { | |
| 149 | - | n, err := p.unary() | |
| 150 | - | if err != nil { | |
| 151 | - | return nil, err | |
| 152 | - | } | |
| 153 | - | for p.match(TokenGreater, TokenGreaterEqual, TokenLess, TokenLessEqual) { | |
| 154 | - | op := p.previous() | |
| 155 | - | right, err := p.unary() | |
| 156 | - | if err != nil { | |
| 157 | - | return nil, err | |
| 158 | - | } | |
| 159 | - | n = &Binary{Left: n, Operator: op, Right: right} | |
| 160 | - | } | |
| 161 | - | return n, nil | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | func (p *parser) unary() (Node, error) { | |
| 165 | - | if p.match(TokenBang) { | |
| 166 | - | if err := p.incrDepth(); err != nil { | |
| 167 | - | return nil, err | |
| 168 | - | } | |
| 169 | - | op := p.previous() | |
| 170 | - | operand, err := p.unary() | |
| 171 | - | if err != nil { | |
| 172 | - | return nil, err | |
| 173 | - | } | |
| 174 | - | p.depth-- | |
| 175 | - | return &Unary{Operator: op, Expr: operand}, nil | |
| 176 | - | } | |
| 177 | - | return p.index() | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | func (p *parser) index() (Node, error) { | |
| 181 | - | n, err := p.call() | |
| 182 | - | if err != nil { | |
| 183 | - | return nil, err | |
| 184 | - | } | |
| 185 | - | depthIncreased := 0 | |
| 186 | - | switch n.(type) { | |
| 187 | - | case *Grouping, *FunctionCall, *ContextAccess: | |
| 188 | - | loop: | |
| 189 | - | for { | |
| 190 | - | switch { | |
| 191 | - | case p.match(TokenLeftBracket): | |
| 192 | - | var idx Node | |
| 193 | - | if p.match(TokenStar) { | |
| 194 | - | idx = &Star{} | |
| 195 | - | } else if idx, err = p.expression(); err != nil { | |
| 196 | - | return nil, err | |
| 197 | - | } | |
| 198 | - | if err := p.consume(TokenRightBracket, ErrorUnexpectedSymbol); err != nil { | |
| 199 | - | return nil, err | |
| 200 | - | } | |
| 201 | - | if err := p.incrDepth(); err != nil { | |
| 202 | - | return nil, err | |
| 203 | - | } | |
| 204 | - | depthIncreased++ | |
| 205 | - | n = &IndexAccess{Expr: n, Index: idx} | |
| 206 | - | case p.match(TokenDot): | |
| 207 | - | if err := p.incrDepth(); err != nil { | |
| 208 | - | return nil, err | |
| 209 | - | } | |
| 210 | - | depthIncreased++ | |
| 211 | - | switch { | |
| 212 | - | case p.match(TokenIdentifier): | |
| 213 | - | prop := p.previous() | |
| 214 | - | n = &IndexAccess{Expr: n, Index: &Literal{Value: String(prop.Lexeme), Token: prop}} | |
| 215 | - | case p.match(TokenStar): | |
| 216 | - | n = &IndexAccess{Expr: n, Index: &Star{}} | |
| 217 | - | default: | |
| 218 | - | return nil, newTokenError(ErrorUnexpectedSymbol, p.peek()) | |
| 219 | - | } | |
| 220 | - | default: | |
| 221 | - | break loop | |
| 222 | - | } | |
| 223 | - | } | |
| 224 | - | } | |
| 225 | - | p.depth -= depthIncreased | |
| 226 | - | return n, nil | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | func (p *parser) call() (Node, error) { | |
| 230 | - | if !p.check(TokenIdentifier) { | |
| 231 | - | return p.primary() | |
| 232 | - | } | |
| 233 | - | ident := p.next() | |
| 234 | - | if !p.match(TokenLeftParen) { | |
| 235 | - | if !p.contexts[foldKey(ident.Lexeme)] { | |
| 236 | - | return nil, newTokenError(ErrorUnrecognizedContext, ident) | |
| 237 | - | } | |
| 238 | - | return &ContextAccess{Name: ident}, nil | |
| 239 | - | } | |
| 240 | - | var args []Node | |
| 241 | - | for !p.match(TokenRightParen) { | |
| 242 | - | arg, err := p.expression() | |
| 243 | - | if err != nil { | |
| 244 | - | return nil, err | |
| 245 | - | } | |
| 246 | - | args = append(args, arg) | |
| 247 | - | if !p.check(TokenRightParen) { | |
| 248 | - | // A trailing comma before ")" is accepted, as in the reference. | |
| 249 | - | if err := p.consume(TokenComma, ErrorUnexpectedSymbol); err != nil { | |
| 250 | - | return nil, err | |
| 251 | - | } | |
| 252 | - | } | |
| 253 | - | } | |
| 254 | - | if err := p.validateFunction(ident, len(args)); err != nil { | |
| 255 | - | return nil, err | |
| 256 | - | } | |
| 257 | - | return &FunctionCall{Name: ident, Args: args}, nil | |
| 258 | - | } | |
| 259 | - | ||
| 260 | - | // validateFunction checks that the function exists and accepts argCount | |
| 261 | - | // arguments (funcs.js validateFunction). | |
| 262 | - | func (p *parser) validateFunction(ident Token, argCount int) error { | |
| 263 | - | name := foldKey(ident.Lexeme) | |
| 264 | - | f, ok := builtinFuncs[name] | |
| 265 | - | if !ok { | |
| 266 | - | f, ok = p.funcs[name] | |
| 267 | - | if !ok { | |
| 268 | - | return newTokenError(ErrorUnrecognizedFunction, ident) | |
| 269 | - | } | |
| 270 | - | } | |
| 271 | - | if t, bad := arityError(name, f, argCount); bad { | |
| 272 | - | return newTokenError(t, ident) | |
| 273 | - | } | |
| 274 | - | return nil | |
| 46 | + | return parseRunner(expr, contexts, funcs) | |
| 275 | 47 | } | |
| 276 | 48 | ||
| 277 | 49 | // arityError reports whether argCount is wrong for f, and how. name is the | |
| 278 | - | // lower-cased function name: case also needs an odd number of arguments. | |
| 50 | + | // upper-cased function name (see foldKey): case also needs an odd number of arguments. | |
| 279 | 51 | func arityError(name string, f FuncInfo, argCount int) (ErrorType, bool) { | |
| 280 | 52 | switch { | |
| 281 | 53 | case argCount < f.MinArgs: | |
| @@ -287,90 +59,3 @@ func arityError(name string, f FuncInfo, argCount int) (ErrorType, bool) { | |||
| 287 | 59 | } | |
| 288 | 60 | return 0, false | |
| 289 | 61 | } | |
| 290 | - | ||
| 291 | - | func (p *parser) primary() (Node, error) { | |
| 292 | - | switch { | |
| 293 | - | case p.match(TokenFalse): | |
| 294 | - | return &Literal{Value: Boolean(false), Token: p.previous()}, nil | |
| 295 | - | case p.match(TokenTrue): | |
| 296 | - | return &Literal{Value: Boolean(true), Token: p.previous()}, nil | |
| 297 | - | case p.match(TokenNull): | |
| 298 | - | return &Literal{Value: Null{}, Token: p.previous()}, nil | |
| 299 | - | case p.match(TokenNumber): | |
| 300 | - | return &Literal{Value: p.previous().Value, Token: p.previous()}, nil | |
| 301 | - | case p.match(TokenString): | |
| 302 | - | return &Literal{Value: p.previous().Value, Token: p.previous()}, nil | |
| 303 | - | case p.match(TokenLeftParen): | |
| 304 | - | n, err := p.expression() | |
| 305 | - | if err != nil { | |
| 306 | - | return nil, err | |
| 307 | - | } | |
| 308 | - | if p.atEnd() { | |
| 309 | - | // Back up to the last token before the EOF. | |
| 310 | - | return nil, newTokenError(ErrorUnexpectedEndOfExpression, p.previous()) | |
| 311 | - | } | |
| 312 | - | if err := p.consume(TokenRightParen, ErrorUnexpectedSymbol); err != nil { | |
| 313 | - | return nil, err | |
| 314 | - | } | |
| 315 | - | return &Grouping{Group: n}, nil | |
| 316 | - | case p.atEnd(): | |
| 317 | - | return nil, newTokenError(ErrorUnexpectedEndOfExpression, p.previous()) | |
| 318 | - | } | |
| 319 | - | return nil, newTokenError(ErrorUnexpectedSymbol, p.peek()) | |
| 320 | - | } | |
| 321 | - | ||
| 322 | - | // match consumes the next token if it has one of the given types. | |
| 323 | - | func (p *parser) match(types ...TokenType) bool { | |
| 324 | - | for _, t := range types { | |
| 325 | - | if p.check(t) { | |
| 326 | - | p.next() | |
| 327 | - | return true | |
| 328 | - | } | |
| 329 | - | } | |
| 330 | - | return false | |
| 331 | - | } | |
| 332 | - | ||
| 333 | - | func (p *parser) check(t TokenType) bool { | |
| 334 | - | if p.atEnd() { | |
| 335 | - | return false | |
| 336 | - | } | |
| 337 | - | return p.peek().Type == t | |
| 338 | - | } | |
| 339 | - | ||
| 340 | - | func (p *parser) atEnd() bool { return p.peek().Type == TokenEOF } | |
| 341 | - | ||
| 342 | - | func (p *parser) next() Token { | |
| 343 | - | if !p.atEnd() { | |
| 344 | - | p.offset++ | |
| 345 | - | } | |
| 346 | - | return p.previous() | |
| 347 | - | } | |
| 348 | - | ||
| 349 | - | func (p *parser) peek() Token { return p.tokens[p.offset] } | |
| 350 | - | ||
| 351 | - | // previous returns the token before the current one. At offset 0 the | |
| 352 | - | // reference reads tokens[-1], which is undefined and crashes; here it | |
| 353 | - | // returns the zero Token. That is unreachable: previous is only used after | |
| 354 | - | // a token was consumed. | |
| 355 | - | func (p *parser) previous() Token { | |
| 356 | - | if p.offset == 0 { | |
| 357 | - | return Token{} | |
| 358 | - | } | |
| 359 | - | return p.tokens[p.offset-1] | |
| 360 | - | } | |
| 361 | - | ||
| 362 | - | func (p *parser) consume(t TokenType, errType ErrorType) error { | |
| 363 | - | if p.check(t) { | |
| 364 | - | p.next() | |
| 365 | - | return nil | |
| 366 | - | } | |
| 367 | - | return newTokenError(errType, p.peek()) | |
| 368 | - | } | |
| 369 | - | ||
| 370 | - | func (p *parser) incrDepth() error { | |
| 371 | - | p.depth++ | |
| 372 | - | if p.depth > MaxParserDepth { | |
| 373 | - | return newTokenError(ErrorExceededMaxDepth, p.peek()) | |
| 374 | - | } | |
| 375 | - | return nil | |
| 376 | - | } | |
| @@ -18,8 +18,8 @@ type FuncInfo struct { | |||||
| 18 | MaxArgs int | 18 | MaxArgs int | ||
| 19 | } | 19 | } | ||
| 20 | 20 | ||||
| 21 | - | // builtinFuncs are the functions of the reference's wellKnownFunctions, keyed | 21 | + | // builtinFuncs are the functions of ExpressionConstants.WellKnownFunctions, keyed |
| 22 | - | // by lower-cased name. They take precedence over caller-supplied FuncInfo for | 22 | + | // by upper-cased name (see foldKey). They take precedence over caller-supplied FuncInfo for |
| 23 | // validation. | 23 | // validation. | ||
| 24 | var builtinFuncs = map[string]FuncInfo{ | 24 | var builtinFuncs = map[string]FuncInfo{ | ||
| 25 | "CASE": {"case", 3, math.MaxInt}, | 25 | "CASE": {"case", 3, math.MaxInt}, | ||
| @@ -43,239 +43,11 @@ var builtinFuncs = map[string]FuncInfo{ | |||||
| 43 | // "" or " ", is not an error: the reference returns nothing for it, and Parse | 43 | // "" or " ", is not an error: the reference returns nothing for it, and Parse | ||
| 44 | // returns a nil [Node], which [Evaluate] rejects. | 44 | // returns a nil [Node], which [Evaluate] rejects. | ||
| 45 | func Parse(expr string, contexts []string, funcs []FuncInfo) (Node, error) { | 45 | func Parse(expr string, contexts []string, funcs []FuncInfo) (Node, error) { | ||
| 46 | - | tokens, err := Lex(expr) | 46 | + | return parseRunner(expr, contexts, funcs) |
| 47 | - | if err != nil { | |||
| 48 | - | return nil, err | |||
| 49 | - | } | |||
| 50 | - | p := &parser{ | |||
| 51 | - | tokens: tokens, | |||
| 52 | - | contexts: make(map[string]bool, len(contexts)), | |||
| 53 | - | funcs: make(map[string]FuncInfo, len(funcs)), | |||
| 54 | - | } | |||
| 55 | - | for _, c := range contexts { | |||
| 56 | - | p.contexts[foldKey(c)] = true | |||
| 57 | - | } | |||
| 58 | - | for _, f := range funcs { | |||
| 59 | - | p.funcs[foldKey(f.Name)] = f | |||
| 60 | - | } | |||
| 61 | - | return p.parse() | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | // parser is a port of Parser in parser.js: recursive descent, with the same | |||
| 65 | - | // precedence (lowest first): ||, &&, == !=, < <= > >=, !, then index and | |||
| 66 | - | // call. | |||
| 67 | - | type parser struct { | |||
| 68 | - | tokens []Token | |||
| 69 | - | offset int | |||
| 70 | - | depth int | |||
| 71 | - | contexts map[string]bool | |||
| 72 | - | funcs map[string]FuncInfo | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | func (p *parser) parse() (Node, error) { | |||
| 76 | - | if p.atEnd() { | |||
| 77 | - | return nil, nil | |||
| 78 | - | } | |||
| 79 | - | n, err := p.expression() | |||
| 80 | - | if err != nil { | |||
| 81 | - | return nil, err | |||
| 82 | - | } | |||
| 83 | - | if !p.atEnd() { | |||
| 84 | - | return nil, newTokenError(ErrorUnexpectedSymbol, p.peek()) | |||
| 85 | - | } | |||
| 86 | - | return n, nil | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | func (p *parser) expression() (Node, error) { | |||
| 90 | - | if err := p.incrDepth(); err != nil { | |||
| 91 | - | return nil, err | |||
| 92 | - | } | |||
| 93 | - | n, err := p.logicalOr() | |||
| 94 | - | p.depth-- | |||
| 95 | - | return n, err | |||
| 96 | - | } | |||
| 97 | - | ||||
| 98 | - | func (p *parser) logicalOr() (Node, error) { | |||
| 99 | - | n, err := p.logicalAnd() | |||
| 100 | - | if err != nil || !p.check(TokenOr) { | |||
| 101 | - | return n, err | |||
| 102 | - | } | |||
| 103 | - | return p.logicalChain(TokenOr, n, p.logicalAnd) | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | - | func (p *parser) logicalAnd() (Node, error) { | |||
| 107 | - | n, err := p.equality() | |||
| 108 | - | if err != nil || !p.check(TokenAnd) { | |||
| 109 | - | return n, err | |||
| 110 | - | } | |||
| 111 | - | return p.logicalChain(TokenAnd, n, p.equality) | |||
| 112 | - | } | |||
| 113 | - | ||||
| 114 | - | // logicalChain parses the rest of a flat chain of operator operands, the | |||
| 115 | - | // first of which is already parsed. | |||
| 116 | - | func (p *parser) logicalChain(op TokenType, first Node, operand func() (Node, error)) (Node, error) { | |||
| 117 | - | if err := p.incrDepth(); err != nil { | |||
| 118 | - | return nil, err | |||
| 119 | - | } | |||
| 120 | - | logical := &Logical{Operator: p.peek(), Args: []Node{first}} | |||
| 121 | - | for p.match(op) { | |||
| 122 | - | right, err := operand() | |||
| 123 | - | if err != nil { | |||
| 124 | - | return nil, err | |||
| 125 | - | } | |||
| 126 | - | logical.Args = append(logical.Args, right) | |||
| 127 | - | } | |||
| 128 | - | p.depth-- | |||
| 129 | - | return logical, nil | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | func (p *parser) equality() (Node, error) { | |||
| 133 | - | n, err := p.comparison() | |||
| 134 | - | if err != nil { | |||
| 135 | - | return nil, err | |||
| 136 | - | } | |||
| 137 | - | for p.match(TokenBangEqual, TokenEqualEqual) { | |||
| 138 | - | op := p.previous() | |||
| 139 | - | right, err := p.comparison() | |||
| 140 | - | if err != nil { | |||
| 141 | - | return nil, err | |||
| 142 | - | } | |||
| 143 | - | n = &Binary{Left: n, Operator: op, Right: right} | |||
| 144 | - | } | |||
| 145 | - | return n, nil | |||
| 146 | - | } | |||
| 147 | - | ||||
| 148 | - | func (p *parser) comparison() (Node, error) { | |||
| 149 | - | n, err := p.unary() | |||
| 150 | - | if err != nil { | |||
| 151 | - | return nil, err | |||
| 152 | - | } | |||
| 153 | - | for p.match(TokenGreater, TokenGreaterEqual, TokenLess, TokenLessEqual) { | |||
| 154 | - | op := p.previous() | |||
| 155 | - | right, err := p.unary() | |||
| 156 | - | if err != nil { | |||
| 157 | - | return nil, err | |||
| 158 | - | } | |||
| 159 | - | n = &Binary{Left: n, Operator: op, Right: right} | |||
| 160 | - | } | |||
| 161 | - | return n, nil | |||
| 162 | - | } | |||
| 163 | - | ||||
| 164 | - | func (p *parser) unary() (Node, error) { | |||
| 165 | - | if p.match(TokenBang) { | |||
| 166 | - | if err := p.incrDepth(); err != nil { | |||
| 167 | - | return nil, err | |||
| 168 | - | } | |||
| 169 | - | op := p.previous() | |||
| 170 | - | operand, err := p.unary() | |||
| 171 | - | if err != nil { | |||
| 172 | - | return nil, err | |||
| 173 | - | } | |||
| 174 | - | p.depth-- | |||
| 175 | - | return &Unary{Operator: op, Expr: operand}, nil | |||
| 176 | - | } | |||
| 177 | - | return p.index() | |||
| 178 | - | } | |||
| 179 | - | ||||
| 180 | - | func (p *parser) index() (Node, error) { | |||
| 181 | - | n, err := p.call() | |||
| 182 | - | if err != nil { | |||
| 183 | - | return nil, err | |||
| 184 | - | } | |||
| 185 | - | depthIncreased := 0 | |||
| 186 | - | switch n.(type) { | |||
| 187 | - | case *Grouping, *FunctionCall, *ContextAccess: | |||
| 188 | - | loop: | |||
| 189 | - | for { | |||
| 190 | - | switch { | |||
| 191 | - | case p.match(TokenLeftBracket): | |||
| 192 | - | var idx Node | |||
| 193 | - | if p.match(TokenStar) { | |||
| 194 | - | idx = &Star{} | |||
| 195 | - | } else if idx, err = p.expression(); err != nil { | |||
| 196 | - | return nil, err | |||
| 197 | - | } | |||
| 198 | - | if err := p.consume(TokenRightBracket, ErrorUnexpectedSymbol); err != nil { | |||
| 199 | - | return nil, err | |||
| 200 | - | } | |||
| 201 | - | if err := p.incrDepth(); err != nil { | |||
| 202 | - | return nil, err | |||
| 203 | - | } | |||
| 204 | - | depthIncreased++ | |||
| 205 | - | n = &IndexAccess{Expr: n, Index: idx} | |||
| 206 | - | case p.match(TokenDot): | |||
| 207 | - | if err := p.incrDepth(); err != nil { | |||
| 208 | - | return nil, err | |||
| 209 | - | } | |||
| 210 | - | depthIncreased++ | |||
| 211 | - | switch { | |||
| 212 | - | case p.match(TokenIdentifier): | |||
| 213 | - | prop := p.previous() | |||
| 214 | - | n = &IndexAccess{Expr: n, Index: &Literal{Value: String(prop.Lexeme), Token: prop}} | |||
| 215 | - | case p.match(TokenStar): | |||
| 216 | - | n = &IndexAccess{Expr: n, Index: &Star{}} | |||
| 217 | - | default: | |||
| 218 | - | return nil, newTokenError(ErrorUnexpectedSymbol, p.peek()) | |||
| 219 | - | } | |||
| 220 | - | default: | |||
| 221 | - | break loop | |||
| 222 | - | } | |||
| 223 | - | } | |||
| 224 | - | } | |||
| 225 | - | p.depth -= depthIncreased | |||
| 226 | - | return n, nil | |||
| 227 | - | } | |||
| 228 | - | ||||
| 229 | - | func (p *parser) call() (Node, error) { | |||
| 230 | - | if !p.check(TokenIdentifier) { | |||
| 231 | - | return p.primary() | |||
| 232 | - | } | |||
| 233 | - | ident := p.next() | |||
| 234 | - | if !p.match(TokenLeftParen) { | |||
| 235 | - | if !p.contexts[foldKey(ident.Lexeme)] { | |||
| 236 | - | return nil, newTokenError(ErrorUnrecognizedContext, ident) | |||
| 237 | - | } | |||
| 238 | - | return &ContextAccess{Name: ident}, nil | |||
| 239 | - | } | |||
| 240 | - | var args []Node | |||
| 241 | - | for !p.match(TokenRightParen) { | |||
| 242 | - | arg, err := p.expression() | |||
| 243 | - | if err != nil { | |||
| 244 | - | return nil, err | |||
| 245 | - | } | |||
| 246 | - | args = append(args, arg) | |||
| 247 | - | if !p.check(TokenRightParen) { | |||
| 248 | - | // A trailing comma before ")" is accepted, as in the reference. | |||
| 249 | - | if err := p.consume(TokenComma, ErrorUnexpectedSymbol); err != nil { | |||
| 250 | - | return nil, err | |||
| 251 | - | } | |||
| 252 | - | } | |||
| 253 | - | } | |||
| 254 | - | if err := p.validateFunction(ident, len(args)); err != nil { | |||
| 255 | - | return nil, err | |||
| 256 | - | } | |||
| 257 | - | return &FunctionCall{Name: ident, Args: args}, nil | |||
| 258 | - | } | |||
| 259 | - | ||||
| 260 | - | // validateFunction checks that the function exists and accepts argCount | |||
| 261 | - | // arguments (funcs.js validateFunction). | |||
| 262 | - | func (p *parser) validateFunction(ident Token, argCount int) error { | |||
| 263 | - | name := foldKey(ident.Lexeme) | |||
| 264 | - | f, ok := builtinFuncs[name] | |||
| 265 | - | if !ok { | |||
| 266 | - | f, ok = p.funcs[name] | |||
| 267 | - | if !ok { | |||
| 268 | - | return newTokenError(ErrorUnrecognizedFunction, ident) | |||
| 269 | - | } | |||
| 270 | - | } | |||
| 271 | - | if t, bad := arityError(name, f, argCount); bad { | |||
| 272 | - | return newTokenError(t, ident) | |||
| 273 | - | } | |||
| 274 | - | return nil | |||
| 275 | } | 47 | } | ||
| 276 | 48 | ||||
| 277 | // arityError reports whether argCount is wrong for f, and how. name is the | 49 | // arityError reports whether argCount is wrong for f, and how. name is the | ||
| 278 | - | // lower-cased function name: case also needs an odd number of arguments. | 50 | + | // upper-cased function name (see foldKey): case also needs an odd number of arguments. |
| 279 | func arityError(name string, f FuncInfo, argCount int) (ErrorType, bool) { | 51 | func arityError(name string, f FuncInfo, argCount int) (ErrorType, bool) { | ||
| 280 | switch { | 52 | switch { | ||
| 281 | case argCount < f.MinArgs: | 53 | case argCount < f.MinArgs: | ||
| @@ -287,90 +59,3 @@ func arityError(name string, f FuncInfo, argCount int) (ErrorType, bool) { | |||||
| 287 | } | 59 | } | ||
| 288 | return 0, false | 60 | return 0, false | ||
| 289 | } | 61 | } | ||
| 290 | - | ||||
| 291 | - | func (p *parser) primary() (Node, error) { | |||
| 292 | - | switch { | |||
| 293 | - | case p.match(TokenFalse): | |||
| 294 | - | return &Literal{Value: Boolean(false), Token: p.previous()}, nil | |||
| 295 | - | case p.match(TokenTrue): | |||
| 296 | - | return &Literal{Value: Boolean(true), Token: p.previous()}, nil | |||
| 297 | - | case p.match(TokenNull): | |||
| 298 | - | return &Literal{Value: Null{}, Token: p.previous()}, nil | |||
| 299 | - | case p.match(TokenNumber): | |||
| 300 | - | return &Literal{Value: p.previous().Value, Token: p.previous()}, nil | |||
| 301 | - | case p.match(TokenString): | |||
| 302 | - | return &Literal{Value: p.previous().Value, Token: p.previous()}, nil | |||
| 303 | - | case p.match(TokenLeftParen): | |||
| 304 | - | n, err := p.expression() | |||
| 305 | - | if err != nil { | |||
| 306 | - | return nil, err | |||
| 307 | - | } | |||
| 308 | - | if p.atEnd() { | |||
| 309 | - | // Back up to the last token before the EOF. | |||
| 310 | - | return nil, newTokenError(ErrorUnexpectedEndOfExpression, p.previous()) | |||
| 311 | - | } | |||
| 312 | - | if err := p.consume(TokenRightParen, ErrorUnexpectedSymbol); err != nil { | |||
| 313 | - | return nil, err | |||
| 314 | - | } | |||
| 315 | - | return &Grouping{Group: n}, nil | |||
| 316 | - | case p.atEnd(): | |||
| 317 | - | return nil, newTokenError(ErrorUnexpectedEndOfExpression, p.previous()) | |||
| 318 | - | } | |||
| 319 | - | return nil, newTokenError(ErrorUnexpectedSymbol, p.peek()) | |||
| 320 | - | } | |||
| 321 | - | ||||
| 322 | - | // match consumes the next token if it has one of the given types. | |||
| 323 | - | func (p *parser) match(types ...TokenType) bool { | |||
| 324 | - | for _, t := range types { | |||
| 325 | - | if p.check(t) { | |||
| 326 | - | p.next() | |||
| 327 | - | return true | |||
| 328 | - | } | |||
| 329 | - | } | |||
| 330 | - | return false | |||
| 331 | - | } | |||
| 332 | - | ||||
| 333 | - | func (p *parser) check(t TokenType) bool { | |||
| 334 | - | if p.atEnd() { | |||
| 335 | - | return false | |||
| 336 | - | } | |||
| 337 | - | return p.peek().Type == t | |||
| 338 | - | } | |||
| 339 | - | ||||
| 340 | - | func (p *parser) atEnd() bool { return p.peek().Type == TokenEOF } | |||
| 341 | - | ||||
| 342 | - | func (p *parser) next() Token { | |||
| 343 | - | if !p.atEnd() { | |||
| 344 | - | p.offset++ | |||
| 345 | - | } | |||
| 346 | - | return p.previous() | |||
| 347 | - | } | |||
| 348 | - | ||||
| 349 | - | func (p *parser) peek() Token { return p.tokens[p.offset] } | |||
| 350 | - | ||||
| 351 | - | // previous returns the token before the current one. At offset 0 the | |||
| 352 | - | // reference reads tokens[-1], which is undefined and crashes; here it | |||
| 353 | - | // returns the zero Token. That is unreachable: previous is only used after | |||
| 354 | - | // a token was consumed. | |||
| 355 | - | func (p *parser) previous() Token { | |||
| 356 | - | if p.offset == 0 { | |||
| 357 | - | return Token{} | |||
| 358 | - | } | |||
| 359 | - | return p.tokens[p.offset-1] | |||
| 360 | - | } | |||
| 361 | - | ||||
| 362 | - | func (p *parser) consume(t TokenType, errType ErrorType) error { | |||
| 363 | - | if p.check(t) { | |||
| 364 | - | p.next() | |||
| 365 | - | return nil | |||
| 366 | - | } | |||
| 367 | - | return newTokenError(errType, p.peek()) | |||
| 368 | - | } | |||
| 369 | - | ||||
| 370 | - | func (p *parser) incrDepth() error { | |||
| 371 | - | p.depth++ | |||
| 372 | - | if p.depth > MaxParserDepth { | |||
| 373 | - | return newTokenError(ErrorExceededMaxDepth, p.peek()) | |||
| 374 | - | } | |||
| 375 | - | return nil | |||
| 376 | - | } | |||
D expr/runner_oracle_wip_test.go +0 -66
| @@ -1,66 +0,0 @@ | |||
| 1 | - | //go:build wip | |
| 2 | - | ||
| 3 | - | package expr | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "encoding/json" | |
| 7 | - | "os" | |
| 8 | - | "sort" | |
| 9 | - | "testing" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // runnerResidual lists, by reason, the corpus expressions on which this package | |
| 13 | - | // knowingly differs from the runner. It is filled by classifyResidual. | |
| 14 | - | func classifyResidual(g, r runnerCase) string { | |
| 15 | - | return "" | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | func TestRunnerOracle(t *testing.T) { | |
| 19 | - | of, ctxValues, ctxNames := loadOracle(t) | |
| 20 | - | rf := loadRunner(t) | |
| 21 | - | if len(rf.Cases) != len(of.Cases) { | |
| 22 | - | t.Fatalf("runner answers %d cases, oracle has %d", len(rf.Cases), len(of.Cases)) | |
| 23 | - | } | |
| 24 | - | t.Logf("runner: %s @ %s, %s", rf.Reference.Source, rf.Reference.Commit, rf.Reference.Runtime) | |
| 25 | - | ec := EvalContext{Contexts: ctxValues, Funcs: oracleFuncs, MaxResultBytes: -1} | |
| 26 | - | residual := map[string]int{} | |
| 27 | - | failures := 0 | |
| 28 | - | for i, rc := range rf.Cases { | |
| 29 | - | if rc.E != of.Cases[i].E { | |
| 30 | - | t.Fatalf("case %d: runner expression %q, oracle %q", i, rc.E, of.Cases[i].E) | |
| 31 | - | } | |
| 32 | - | g := goAnswer(rc.E, ctxNames, ec) | |
| 33 | - | if sameAnswer(g, rc) { | |
| 34 | - | continue | |
| 35 | - | } | |
| 36 | - | if reason := classifyResidual(g, rc); reason != "" { | |
| 37 | - | residual[reason]++ | |
| 38 | - | continue | |
| 39 | - | } | |
| 40 | - | failures++ | |
| 41 | - | if p := os.Getenv("WIP_OUT"); p != "" { | |
| 42 | - | f, _ := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) | |
| 43 | - | b1, _ := json.Marshal(g) | |
| 44 | - | b2, _ := json.Marshal(rc) | |
| 45 | - | _, _ = f.WriteString(string(b1) + "\n" + string(b2) + "\n") | |
| 46 | - | _ = f.Close() | |
| 47 | - | } | |
| 48 | - | if failures <= 30 { | |
| 49 | - | gj, _ := json.Marshal(g) | |
| 50 | - | rj, _ := json.Marshal(rc) | |
| 51 | - | t.Errorf("%q:\n go %s\n runner %s", clip(rc.E), gj, rj) | |
| 52 | - | } | |
| 53 | - | } | |
| 54 | - | reasons := make([]string, 0, len(residual)) | |
| 55 | - | for k := range residual { | |
| 56 | - | reasons = append(reasons, k) | |
| 57 | - | } | |
| 58 | - | sort.Strings(reasons) | |
| 59 | - | for _, k := range reasons { | |
| 60 | - | t.Logf("known difference from the runner (%d cases): %s", residual[k], k) | |
| 61 | - | } | |
| 62 | - | t.Logf("%d cases, %d differences from the runner", len(rf.Cases), failures) | |
| 63 | - | if failures > 0 { | |
| 64 | - | t.Fatalf("%d mismatches with the runner", failures) | |
| 65 | - | } | |
| 66 | - | } | |
| @@ -1,66 +0,0 @@ | |||||
| 1 | - | //go:build wip | |||
| 2 | - | ||||
| 3 | - | package expr | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "encoding/json" | |||
| 7 | - | "os" | |||
| 8 | - | "sort" | |||
| 9 | - | "testing" | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | // runnerResidual lists, by reason, the corpus expressions on which this package | |||
| 13 | - | // knowingly differs from the runner. It is filled by classifyResidual. | |||
| 14 | - | func classifyResidual(g, r runnerCase) string { | |||
| 15 | - | return "" | |||
| 16 | - | } | |||
| 17 | - | ||||
| 18 | - | func TestRunnerOracle(t *testing.T) { | |||
| 19 | - | of, ctxValues, ctxNames := loadOracle(t) | |||
| 20 | - | rf := loadRunner(t) | |||
| 21 | - | if len(rf.Cases) != len(of.Cases) { | |||
| 22 | - | t.Fatalf("runner answers %d cases, oracle has %d", len(rf.Cases), len(of.Cases)) | |||
| 23 | - | } | |||
| 24 | - | t.Logf("runner: %s @ %s, %s", rf.Reference.Source, rf.Reference.Commit, rf.Reference.Runtime) | |||
| 25 | - | ec := EvalContext{Contexts: ctxValues, Funcs: oracleFuncs, MaxResultBytes: -1} | |||
| 26 | - | residual := map[string]int{} | |||
| 27 | - | failures := 0 | |||
| 28 | - | for i, rc := range rf.Cases { | |||
| 29 | - | if rc.E != of.Cases[i].E { | |||
| 30 | - | t.Fatalf("case %d: runner expression %q, oracle %q", i, rc.E, of.Cases[i].E) | |||
| 31 | - | } | |||
| 32 | - | g := goAnswer(rc.E, ctxNames, ec) | |||
| 33 | - | if sameAnswer(g, rc) { | |||
| 34 | - | continue | |||
| 35 | - | } | |||
| 36 | - | if reason := classifyResidual(g, rc); reason != "" { | |||
| 37 | - | residual[reason]++ | |||
| 38 | - | continue | |||
| 39 | - | } | |||
| 40 | - | failures++ | |||
| 41 | - | if p := os.Getenv("WIP_OUT"); p != "" { | |||
| 42 | - | f, _ := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) | |||
| 43 | - | b1, _ := json.Marshal(g) | |||
| 44 | - | b2, _ := json.Marshal(rc) | |||
| 45 | - | _, _ = f.WriteString(string(b1) + "\n" + string(b2) + "\n") | |||
| 46 | - | _ = f.Close() | |||
| 47 | - | } | |||
| 48 | - | if failures <= 30 { | |||
| 49 | - | gj, _ := json.Marshal(g) | |||
| 50 | - | rj, _ := json.Marshal(rc) | |||
| 51 | - | t.Errorf("%q:\n go %s\n runner %s", clip(rc.E), gj, rj) | |||
| 52 | - | } | |||
| 53 | - | } | |||
| 54 | - | reasons := make([]string, 0, len(residual)) | |||
| 55 | - | for k := range residual { | |||
| 56 | - | reasons = append(reasons, k) | |||
| 57 | - | } | |||
| 58 | - | sort.Strings(reasons) | |||
| 59 | - | for _, k := range reasons { | |||
| 60 | - | t.Logf("known difference from the runner (%d cases): %s", residual[k], k) | |||
| 61 | - | } | |||
| 62 | - | t.Logf("%d cases, %d differences from the runner", len(rf.Cases), failures) | |||
| 63 | - | if failures > 0 { | |||
| 64 | - | t.Fatalf("%d mismatches with the runner", failures) | |||
| 65 | - | } | |||
| 66 | - | } | |||
A expr/runner_semantics_test.go +115 -0
| @@ -0,0 +1,115 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package expr | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // TestRunnerSemantics pins behaviours of the PORTING.md list that the C# runner | |
| 11 | + | // and the JavaScript reference share or differ on. Every expectation was | |
| 12 | + | // obtained by running the expression through the runner's own code | |
| 13 | + | // (gen/runner); the bulk is also covered by TestRunnerOracle. | |
| 14 | + | func TestRunnerSemantics(t *testing.T) { | |
| 15 | + | ctx := map[string]Value{"github": mustJSON(t, `{ | |
| 16 | + | "commits": [{"id": "1"}, {"id": "2", "x": 1}, {"y": 3}], | |
| 17 | + | "obj": {"a": {"a": 1}, "K": "v", "b": {"c": 2}}, | |
| 18 | + | "nested": [[1, 2], [3], []]}`)} | |
| 19 | + | tests := []struct{ src, want string }{ | |
| 20 | + | // Index access: strings and numbers index arrays by their numeric value, | |
| 21 | + | // booleans and null become 1 and 0, negative, NaN and infinite give null. | |
| 22 | + | {"github.commits['1'].id", "String:2"}, | |
| 23 | + | {"github.commits[' 1 '].id", "String:2"}, | |
| 24 | + | {"github.commits['1.9'].id", "String:2"}, | |
| 25 | + | {"github.commits[1.9].id", "String:2"}, | |
| 26 | + | {"github.commits[true].id", "String:2"}, | |
| 27 | + | {"github.commits[null].id", "String:1"}, | |
| 28 | + | {"github.commits[-1]", "Null:"}, | |
| 29 | + | {"github.commits[NaN]", "Null:"}, | |
| 30 | + | {"github.commits[Infinity]", "Null:"}, | |
| 31 | + | {"github.commits['x']", "Null:"}, | |
| 32 | + | {"github.commits[5].id", "Null:"}, | |
| 33 | + | // On a dictionary only the string form counts, case-insensitively. | |
| 34 | + | {"github.obj[true]", "Null:"}, | |
| 35 | + | {"github.obj[null]", "Null:"}, | |
| 36 | + | {"github.obj[1]", "Null:"}, | |
| 37 | + | {"github.obj['K']", "String:v"}, | |
| 38 | + | {"GITHUB.OBJ.K", "String:v"}, | |
| 39 | + | {"github.OBJ.k", "String:v"}, | |
| 40 | + | {"github.nope.deeper", "Null:"}, | |
| 41 | + | // Filters. | |
| 42 | + | {"toJSON(github.obj.*.a)", "String:[\n 1\n]"}, | |
| 43 | + | {"toJSON(github.commits.*.id)", "String:[\n \"1\",\n \"2\"\n]"}, | |
| 44 | + | {"toJSON(github.commits.*.*)", "String:[\n \"1\",\n \"2\",\n 1,\n 3\n]"}, | |
| 45 | + | {"toJSON(github.nested.*.*)", "String:[\n 1,\n 2,\n 3\n]"}, | |
| 46 | + | {"toJSON(github.nested.*[0])", "String:[\n 1,\n 3\n]"}, | |
| 47 | + | {"github.commits.* == github.commits.*", "Boolean:false"}, | |
| 48 | + | {"github.obj.* == github.obj.*", "Boolean:false"}, | |
| 49 | + | {"github.commits == github.commits", "Boolean:true"}, | |
| 50 | + | {"github.obj == github.obj", "Boolean:true"}, | |
| 51 | + | // Comparison. | |
| 52 | + | {"null < 1", "Boolean:true"}, | |
| 53 | + | {"null > -1", "Boolean:true"}, | |
| 54 | + | {"null == 0", "Boolean:true"}, | |
| 55 | + | {"null < true", "Boolean:true"}, | |
| 56 | + | {"null < ''", "Boolean:false"}, | |
| 57 | + | {"null == ''", "Boolean:true"}, | |
| 58 | + | {"NaN == NaN", "Boolean:false"}, | |
| 59 | + | {"NaN != NaN", "Boolean:true"}, | |
| 60 | + | {"NaN < 1", "Boolean:false"}, | |
| 61 | + | {"true > false", "Boolean:true"}, | |
| 62 | + | {"true > 0", "Boolean:true"}, | |
| 63 | + | // && and || return an operand. | |
| 64 | + | {"'' || 'x'", "String:x"}, | |
| 65 | + | {"'a' && 'b'", "String:b"}, | |
| 66 | + | {"0 && 'x'", "Number:0"}, | |
| 67 | + | {"null || false", "Boolean:false"}, | |
| 68 | + | // White space may surround "." and "[" and sit between a function name | |
| 69 | + | // and its "(" (the lexer looks ahead past it). | |
| 70 | + | {"github . obj . K", "String:v"}, | |
| 71 | + | {"github.obj.\nK", "String:v"}, | |
| 72 | + | {"github.obj .K", "String:v"}, | |
| 73 | + | {"github [ 'obj' ].K", "String:v"}, | |
| 74 | + | {"toJSON (1)", "String:1"}, | |
| 75 | + | // White space is Char.IsWhiteSpace: U+00A0, U+000B and U+0085 are skipped. | |
| 76 | + | {"1\u00a0", "Number:1"}, | |
| 77 | + | {"\u00a01", "Number:1"}, | |
| 78 | + | {"\v1", "Number:1"}, | |
| 79 | + | {"'a'\u0085", "String:a"}, | |
| 80 | + | } | |
| 81 | + | for _, tc := range tests { | |
| 82 | + | node, err := Parse(tc.src, []string{"github"}, nil) | |
| 83 | + | if err != nil { | |
| 84 | + | t.Errorf("Parse(%q): %v", tc.src, err) | |
| 85 | + | continue | |
| 86 | + | } | |
| 87 | + | v, err := Evaluate(context.Background(), node, EvalContext{Contexts: ctx}) | |
| 88 | + | if err != nil { | |
| 89 | + | t.Errorf("Evaluate(%q): %v", tc.src, err) | |
| 90 | + | continue | |
| 91 | + | } | |
| 92 | + | if got := v.Kind().String() + ":" + CoerceString(v); got != tc.want { | |
| 93 | + | t.Errorf("%s = %q, want %q", tc.src, got, tc.want) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Parse errors. | |
| 98 | + | for _, tc := range []struct{ src, want string }{ | |
| 99 | + | {"contains('a','b',)", "Unexpected symbol: ')'. Located at position 18 within expression: contains('a','b',)"}, | |
| 100 | + | {"contains(,'a')", "Unexpected symbol: ','. Located at position 10 within expression: contains(,'a')"}, | |
| 101 | + | {"[1] == [1]", "Unexpected symbol: '['. Located at position 1 within expression: [1] == [1]"}, | |
| 102 | + | {"1 \u00a0 2", "Unexpected symbol: '2'. Located at position 5 within expression: 1 \u00a0 2"}, | |
| 103 | + | } { | |
| 104 | + | if _, err := Parse(tc.src, []string{"github"}, nil); err == nil || err.Error() != tc.want { | |
| 105 | + | t.Errorf("Parse(%q) = %v, want %q", tc.src, err, tc.want) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // An empty expression is not an error and has no tree. | |
| 110 | + | for _, src := range []string{"", " ", "\n", "\u00a0"} { | |
| 111 | + | if n, err := Parse(src, nil, nil); n != nil || err != nil { | |
| 112 | + | t.Errorf("Parse(%q) = %v, %v; want nil, nil", src, n, err) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | } | |
| @@ -0,0 +1,115 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package expr | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // TestRunnerSemantics pins behaviours of the PORTING.md list that the C# runner | |||
| 11 | + | // and the JavaScript reference share or differ on. Every expectation was | |||
| 12 | + | // obtained by running the expression through the runner's own code | |||
| 13 | + | // (gen/runner); the bulk is also covered by TestRunnerOracle. | |||
| 14 | + | func TestRunnerSemantics(t *testing.T) { | |||
| 15 | + | ctx := map[string]Value{"github": mustJSON(t, `{ | |||
| 16 | + | "commits": [{"id": "1"}, {"id": "2", "x": 1}, {"y": 3}], | |||
| 17 | + | "obj": {"a": {"a": 1}, "K": "v", "b": {"c": 2}}, | |||
| 18 | + | "nested": [[1, 2], [3], []]}`)} | |||
| 19 | + | tests := []struct{ src, want string }{ | |||
| 20 | + | // Index access: strings and numbers index arrays by their numeric value, | |||
| 21 | + | // booleans and null become 1 and 0, negative, NaN and infinite give null. | |||
| 22 | + | {"github.commits['1'].id", "String:2"}, | |||
| 23 | + | {"github.commits[' 1 '].id", "String:2"}, | |||
| 24 | + | {"github.commits['1.9'].id", "String:2"}, | |||
| 25 | + | {"github.commits[1.9].id", "String:2"}, | |||
| 26 | + | {"github.commits[true].id", "String:2"}, | |||
| 27 | + | {"github.commits[null].id", "String:1"}, | |||
| 28 | + | {"github.commits[-1]", "Null:"}, | |||
| 29 | + | {"github.commits[NaN]", "Null:"}, | |||
| 30 | + | {"github.commits[Infinity]", "Null:"}, | |||
| 31 | + | {"github.commits['x']", "Null:"}, | |||
| 32 | + | {"github.commits[5].id", "Null:"}, | |||
| 33 | + | // On a dictionary only the string form counts, case-insensitively. | |||
| 34 | + | {"github.obj[true]", "Null:"}, | |||
| 35 | + | {"github.obj[null]", "Null:"}, | |||
| 36 | + | {"github.obj[1]", "Null:"}, | |||
| 37 | + | {"github.obj['K']", "String:v"}, | |||
| 38 | + | {"GITHUB.OBJ.K", "String:v"}, | |||
| 39 | + | {"github.OBJ.k", "String:v"}, | |||
| 40 | + | {"github.nope.deeper", "Null:"}, | |||
| 41 | + | // Filters. | |||
| 42 | + | {"toJSON(github.obj.*.a)", "String:[\n 1\n]"}, | |||
| 43 | + | {"toJSON(github.commits.*.id)", "String:[\n \"1\",\n \"2\"\n]"}, | |||
| 44 | + | {"toJSON(github.commits.*.*)", "String:[\n \"1\",\n \"2\",\n 1,\n 3\n]"}, | |||
| 45 | + | {"toJSON(github.nested.*.*)", "String:[\n 1,\n 2,\n 3\n]"}, | |||
| 46 | + | {"toJSON(github.nested.*[0])", "String:[\n 1,\n 3\n]"}, | |||
| 47 | + | {"github.commits.* == github.commits.*", "Boolean:false"}, | |||
| 48 | + | {"github.obj.* == github.obj.*", "Boolean:false"}, | |||
| 49 | + | {"github.commits == github.commits", "Boolean:true"}, | |||
| 50 | + | {"github.obj == github.obj", "Boolean:true"}, | |||
| 51 | + | // Comparison. | |||
| 52 | + | {"null < 1", "Boolean:true"}, | |||
| 53 | + | {"null > -1", "Boolean:true"}, | |||
| 54 | + | {"null == 0", "Boolean:true"}, | |||
| 55 | + | {"null < true", "Boolean:true"}, | |||
| 56 | + | {"null < ''", "Boolean:false"}, | |||
| 57 | + | {"null == ''", "Boolean:true"}, | |||
| 58 | + | {"NaN == NaN", "Boolean:false"}, | |||
| 59 | + | {"NaN != NaN", "Boolean:true"}, | |||
| 60 | + | {"NaN < 1", "Boolean:false"}, | |||
| 61 | + | {"true > false", "Boolean:true"}, | |||
| 62 | + | {"true > 0", "Boolean:true"}, | |||
| 63 | + | // && and || return an operand. | |||
| 64 | + | {"'' || 'x'", "String:x"}, | |||
| 65 | + | {"'a' && 'b'", "String:b"}, | |||
| 66 | + | {"0 && 'x'", "Number:0"}, | |||
| 67 | + | {"null || false", "Boolean:false"}, | |||
| 68 | + | // White space may surround "." and "[" and sit between a function name | |||
| 69 | + | // and its "(" (the lexer looks ahead past it). | |||
| 70 | + | {"github . obj . K", "String:v"}, | |||
| 71 | + | {"github.obj.\nK", "String:v"}, | |||
| 72 | + | {"github.obj .K", "String:v"}, | |||
| 73 | + | {"github [ 'obj' ].K", "String:v"}, | |||
| 74 | + | {"toJSON (1)", "String:1"}, | |||
| 75 | + | // White space is Char.IsWhiteSpace: U+00A0, U+000B and U+0085 are skipped. | |||
| 76 | + | {"1\u00a0", "Number:1"}, | |||
| 77 | + | {"\u00a01", "Number:1"}, | |||
| 78 | + | {"\v1", "Number:1"}, | |||
| 79 | + | {"'a'\u0085", "String:a"}, | |||
| 80 | + | } | |||
| 81 | + | for _, tc := range tests { | |||
| 82 | + | node, err := Parse(tc.src, []string{"github"}, nil) | |||
| 83 | + | if err != nil { | |||
| 84 | + | t.Errorf("Parse(%q): %v", tc.src, err) | |||
| 85 | + | continue | |||
| 86 | + | } | |||
| 87 | + | v, err := Evaluate(context.Background(), node, EvalContext{Contexts: ctx}) | |||
| 88 | + | if err != nil { | |||
| 89 | + | t.Errorf("Evaluate(%q): %v", tc.src, err) | |||
| 90 | + | continue | |||
| 91 | + | } | |||
| 92 | + | if got := v.Kind().String() + ":" + CoerceString(v); got != tc.want { | |||
| 93 | + | t.Errorf("%s = %q, want %q", tc.src, got, tc.want) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // Parse errors. | |||
| 98 | + | for _, tc := range []struct{ src, want string }{ | |||
| 99 | + | {"contains('a','b',)", "Unexpected symbol: ')'. Located at position 18 within expression: contains('a','b',)"}, | |||
| 100 | + | {"contains(,'a')", "Unexpected symbol: ','. Located at position 10 within expression: contains(,'a')"}, | |||
| 101 | + | {"[1] == [1]", "Unexpected symbol: '['. Located at position 1 within expression: [1] == [1]"}, | |||
| 102 | + | {"1 \u00a0 2", "Unexpected symbol: '2'. Located at position 5 within expression: 1 \u00a0 2"}, | |||
| 103 | + | } { | |||
| 104 | + | if _, err := Parse(tc.src, []string{"github"}, nil); err == nil || err.Error() != tc.want { | |||
| 105 | + | t.Errorf("Parse(%q) = %v, want %q", tc.src, err, tc.want) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // An empty expression is not an error and has no tree. | |||
| 110 | + | for _, src := range []string{"", " ", "\n", "\u00a0"} { | |||
| 111 | + | if n, err := Parse(src, nil, nil); n != nil || err != nil { | |||
| 112 | + | t.Errorf("Parse(%q) = %v, %v; want nil, nil", src, n, err) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | } | |||
A expr/runner_strict_test.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package expr | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/json" | |
| 7 | + | "sort" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // knownRunnerDifference classifies the answers of the corpus on which this | |
| 12 | + | // package knowingly differs from the runner. It returns the reason, or "". | |
| 13 | + | func knownRunnerDifference(g, r runnerCase) string { | |
| 14 | + | switch { | |
| 15 | + | case r.E == "-0" || r.E == "toJSON(-0)": | |
| 16 | + | // .NET Core formats negative zero as "-0". The runner's newer copy | |
| 17 | + | // (src/Sdk/Expressions/EvaluationResult.cs) maps it back to "0" and the | |
| 18 | + | // shared fixtures (coerce_string.json) say "0", so "0" is kept. | |
| 19 | + | return "negative zero is written 0" | |
| 20 | + | } | |
| 21 | + | return "" | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // TestRunnerOracle holds the package to the answers of the runner's own code on | |
| 25 | + | // the 34 765 expressions of the oracle corpus: same result kind, string form | |
| 26 | + | // and toJSON text, same error phase and kind (the error texts of the runner's | |
| 27 | + | // Json.NET are not reproduced). | |
| 28 | + | func TestRunnerOracle(t *testing.T) { | |
| 29 | + | of, ctxValues, ctxNames := loadOracle(t) | |
| 30 | + | rf := loadRunner(t) | |
| 31 | + | if len(rf.Cases) != len(of.Cases) { | |
| 32 | + | t.Fatalf("runner answers %d cases, oracle has %d", len(rf.Cases), len(of.Cases)) | |
| 33 | + | } | |
| 34 | + | t.Logf("runner: %s @ %s, %s", rf.Reference.Source, rf.Reference.Commit, rf.Reference.Runtime) | |
| 35 | + | ec := EvalContext{Contexts: ctxValues, Funcs: oracleFuncs, MaxResultBytes: -1} | |
| 36 | + | known := map[string]int{} | |
| 37 | + | failures := 0 | |
| 38 | + | for i, rc := range rf.Cases { | |
| 39 | + | if rc.E != of.Cases[i].E { | |
| 40 | + | t.Fatalf("case %d: runner expression %q, oracle %q", i, rc.E, of.Cases[i].E) | |
| 41 | + | } | |
| 42 | + | g := goAnswer(rc.E, ctxNames, ec) | |
| 43 | + | if sameAnswer(g, rc) { | |
| 44 | + | continue | |
| 45 | + | } | |
| 46 | + | if reason := knownRunnerDifference(g, rc); reason != "" { | |
| 47 | + | known[reason]++ | |
| 48 | + | continue | |
| 49 | + | } | |
| 50 | + | failures++ | |
| 51 | + | if failures <= 30 { | |
| 52 | + | gj, _ := json.Marshal(g) | |
| 53 | + | rj, _ := json.Marshal(rc) | |
| 54 | + | t.Errorf("%q:\n go %s\n runner %s", clip(rc.E), gj, rj) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | reasons := make([]string, 0, len(known)) | |
| 58 | + | for k := range known { | |
| 59 | + | reasons = append(reasons, k) | |
| 60 | + | } | |
| 61 | + | sort.Strings(reasons) | |
| 62 | + | for _, k := range reasons { | |
| 63 | + | t.Logf("known difference from the runner (%d cases): %s", known[k], k) | |
| 64 | + | } | |
| 65 | + | t.Logf("%d cases, %d failures", len(rf.Cases), failures) | |
| 66 | + | if failures > 0 { | |
| 67 | + | t.Fatalf("%d mismatches with the runner", failures) | |
| 68 | + | } | |
| 69 | + | } | |
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package expr | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "sort" | |||
| 8 | + | "testing" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // knownRunnerDifference classifies the answers of the corpus on which this | |||
| 12 | + | // package knowingly differs from the runner. It returns the reason, or "". | |||
| 13 | + | func knownRunnerDifference(g, r runnerCase) string { | |||
| 14 | + | switch { | |||
| 15 | + | case r.E == "-0" || r.E == "toJSON(-0)": | |||
| 16 | + | // .NET Core formats negative zero as "-0". The runner's newer copy | |||
| 17 | + | // (src/Sdk/Expressions/EvaluationResult.cs) maps it back to "0" and the | |||
| 18 | + | // shared fixtures (coerce_string.json) say "0", so "0" is kept. | |||
| 19 | + | return "negative zero is written 0" | |||
| 20 | + | } | |||
| 21 | + | return "" | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // TestRunnerOracle holds the package to the answers of the runner's own code on | |||
| 25 | + | // the 34 765 expressions of the oracle corpus: same result kind, string form | |||
| 26 | + | // and toJSON text, same error phase and kind (the error texts of the runner's | |||
| 27 | + | // Json.NET are not reproduced). | |||
| 28 | + | func TestRunnerOracle(t *testing.T) { | |||
| 29 | + | of, ctxValues, ctxNames := loadOracle(t) | |||
| 30 | + | rf := loadRunner(t) | |||
| 31 | + | if len(rf.Cases) != len(of.Cases) { | |||
| 32 | + | t.Fatalf("runner answers %d cases, oracle has %d", len(rf.Cases), len(of.Cases)) | |||
| 33 | + | } | |||
| 34 | + | t.Logf("runner: %s @ %s, %s", rf.Reference.Source, rf.Reference.Commit, rf.Reference.Runtime) | |||
| 35 | + | ec := EvalContext{Contexts: ctxValues, Funcs: oracleFuncs, MaxResultBytes: -1} | |||
| 36 | + | known := map[string]int{} | |||
| 37 | + | failures := 0 | |||
| 38 | + | for i, rc := range rf.Cases { | |||
| 39 | + | if rc.E != of.Cases[i].E { | |||
| 40 | + | t.Fatalf("case %d: runner expression %q, oracle %q", i, rc.E, of.Cases[i].E) | |||
| 41 | + | } | |||
| 42 | + | g := goAnswer(rc.E, ctxNames, ec) | |||
| 43 | + | if sameAnswer(g, rc) { | |||
| 44 | + | continue | |||
| 45 | + | } | |||
| 46 | + | if reason := knownRunnerDifference(g, rc); reason != "" { | |||
| 47 | + | known[reason]++ | |||
| 48 | + | continue | |||
| 49 | + | } | |||
| 50 | + | failures++ | |||
| 51 | + | if failures <= 30 { | |||
| 52 | + | gj, _ := json.Marshal(g) | |||
| 53 | + | rj, _ := json.Marshal(rc) | |||
| 54 | + | t.Errorf("%q:\n go %s\n runner %s", clip(rc.E), gj, rj) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | reasons := make([]string, 0, len(known)) | |||
| 58 | + | for k := range known { | |||
| 59 | + | reasons = append(reasons, k) | |||
| 60 | + | } | |||
| 61 | + | sort.Strings(reasons) | |||
| 62 | + | for _, k := range reasons { | |||
| 63 | + | t.Logf("known difference from the runner (%d cases): %s", known[k], k) | |||
| 64 | + | } | |||
| 65 | + | t.Logf("%d cases, %d failures", len(rf.Cases), failures) | |||
| 66 | + | if failures > 0 { | |||
| 67 | + | t.Fatalf("%d mismatches with the runner", failures) | |||
| 68 | + | } | |||
| 69 | + | } | |||
M expr/testutil_test.go +5 -1
| @@ -4,6 +4,7 @@ package expr | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "strings" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| @@ -52,7 +53,10 @@ func evalString(tb testing.TB, src string) string { | |||
| 52 | 53 | tb.Helper() | |
| 53 | 54 | node, err := Parse(src, testContextNames, nil) | |
| 54 | 55 | if err != nil { | |
| 55 | - | return "PARSE " + err.Error() | |
| 56 | + | // The runner appends ". Located at position N within expression: ..." | |
| 57 | + | // to errors that name a token; TestRunnerErrorPositions covers that. | |
| 58 | + | msg, _, _ := strings.Cut(err.Error(), ". Located at position ") | |
| 59 | + | return "PARSE " + msg | |
| 56 | 60 | } | |
| 57 | 61 | v, err := Evaluate(context.Background(), node, EvalContext{Contexts: testContexts(tb)}) | |
| 58 | 62 | if err != nil { | |
| @@ -4,6 +4,7 @@ package expr | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "strings" | |||
| 7 | "testing" | 8 | "testing" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| @@ -52,7 +53,10 @@ func evalString(tb testing.TB, src string) string { | |||||
| 52 | tb.Helper() | 53 | tb.Helper() | ||
| 53 | node, err := Parse(src, testContextNames, nil) | 54 | node, err := Parse(src, testContextNames, nil) | ||
| 54 | if err != nil { | 55 | if err != nil { | ||
| 55 | - | return "PARSE " + err.Error() | 56 | + | // The runner appends ". Located at position N within expression: ..." |
| 57 | + | // to errors that name a token; TestRunnerErrorPositions covers that. | |||
| 58 | + | msg, _, _ := strings.Cut(err.Error(), ". Located at position ") | |||
| 59 | + | return "PARSE " + msg | |||
| 56 | } | 60 | } | ||
| 57 | v, err := Evaluate(context.Background(), node, EvalContext{Contexts: testContexts(tb)}) | 61 | v, err := Evaluate(context.Background(), node, EvalContext{Contexts: testContexts(tb)}) | ||
| 58 | if err != nil { | 62 | if err != nil { | ||
M expr/xlang_test.go +8 -6
| @@ -59,11 +59,8 @@ type xlangCase struct { | |||
| 59 | 59 | // xlangKnownDifferences maps "file: group (expr prefix)" keys to the reason | |
| 60 | 60 | // the fixture is not satisfied. Keep it empty if possible. | |
| 61 | 61 | var xlangKnownDifferences = map[string]string{ | |
| 62 | - | "syntax-errors: memory-errors#0": "memory accounting differs from the runner's EvaluationMemory", | |
| 63 | - | "syntax-errors: parsing-errors#25": "position is the column in the line, the runner reports the index in the expression", | |
| 64 | - | "syntax-errors: parsing-errors#26": "position is the column in the line, the runner reports the index in the expression", | |
| 65 | - | "syntax-errors: parsing-errors#27": "position is the column in the line, the runner reports the index in the expression", | |
| 66 | - | "syntax-errors: parsing-errors#28": "position is the column in the line, the runner reports the index in the expression", | |
| 62 | + | "case: case#27": "the runner reports an even number of case() arguments on the '(' token (position 5), not on the name: ExpressionParser.FlushTopEndParameters passes the popped StartParameters token; checked by running the runner's code", | |
| 63 | + | "syntax-errors: memory-errors#0": "memory accounting differs from the runner's EvaluationMemory", | |
| 67 | 64 | } | |
| 68 | 65 | ||
| 69 | 66 | // xlangKind is the kind name the fixtures use. | |
| @@ -178,7 +175,12 @@ func runXlangCase(tb testing.TB, c xlangCase) string { | |||
| 178 | 175 | if !errors.As(err, &pe) { | |
| 179 | 176 | return fmt.Sprintf("parse error has type %T", err) | |
| 180 | 177 | } | |
| 181 | - | full := fmt.Sprintf("%s. Located at position %d within expression: %s", pe.Message, pe.Pos.Column+1, c.Expr) | |
| 178 | + | // The TypeScript runner builds the suffix from a column; this | |
| 179 | + | // package, like the C# runner, already has it in the message. | |
| 180 | + | full := pe.Message | |
| 181 | + | if !strings.Contains(full, ". Located at position ") { | |
| 182 | + | full = fmt.Sprintf("%s. Located at position %d within expression: %s", pe.Message, pe.Pos.Column+1, c.Expr) | |
| 183 | + | } | |
| 182 | 184 | if strings.Contains(full, c.Err.Value) { | |
| 183 | 185 | return "" | |
| 184 | 186 | } | |
| @@ -59,11 +59,8 @@ type xlangCase struct { | |||||
| 59 | // xlangKnownDifferences maps "file: group (expr prefix)" keys to the reason | 59 | // xlangKnownDifferences maps "file: group (expr prefix)" keys to the reason | ||
| 60 | // the fixture is not satisfied. Keep it empty if possible. | 60 | // the fixture is not satisfied. Keep it empty if possible. | ||
| 61 | var xlangKnownDifferences = map[string]string{ | 61 | var xlangKnownDifferences = map[string]string{ | ||
| 62 | - | "syntax-errors: memory-errors#0": "memory accounting differs from the runner's EvaluationMemory", | 62 | + | "case: case#27": "the runner reports an even number of case() arguments on the '(' token (position 5), not on the name: ExpressionParser.FlushTopEndParameters passes the popped StartParameters token; checked by running the runner's code", |
| 63 | - | "syntax-errors: parsing-errors#25": "position is the column in the line, the runner reports the index in the expression", | 63 | + | "syntax-errors: memory-errors#0": "memory accounting differs from the runner's EvaluationMemory", |
| 64 | - | "syntax-errors: parsing-errors#26": "position is the column in the line, the runner reports the index in the expression", | |||
| 65 | - | "syntax-errors: parsing-errors#27": "position is the column in the line, the runner reports the index in the expression", | |||
| 66 | - | "syntax-errors: parsing-errors#28": "position is the column in the line, the runner reports the index in the expression", | |||
| 67 | } | 64 | } | ||
| 68 | 65 | ||||
| 69 | // xlangKind is the kind name the fixtures use. | 66 | // xlangKind is the kind name the fixtures use. | ||
| @@ -178,7 +175,12 @@ func runXlangCase(tb testing.TB, c xlangCase) string { | |||||
| 178 | if !errors.As(err, &pe) { | 175 | if !errors.As(err, &pe) { | ||
| 179 | return fmt.Sprintf("parse error has type %T", err) | 176 | return fmt.Sprintf("parse error has type %T", err) | ||
| 180 | } | 177 | } | ||
| 181 | - | full := fmt.Sprintf("%s. Located at position %d within expression: %s", pe.Message, pe.Pos.Column+1, c.Expr) | 178 | + | // The TypeScript runner builds the suffix from a column; this |
| 179 | + | // package, like the C# runner, already has it in the message. | |||
| 180 | + | full := pe.Message | |||
| 181 | + | if !strings.Contains(full, ". Located at position ") { | |||
| 182 | + | full = fmt.Sprintf("%s. Located at position %d within expression: %s", pe.Message, pe.Pos.Column+1, c.Expr) | |||
| 183 | + | } | |||
| 182 | if strings.Contains(full, c.Err.Value) { | 184 | if strings.Contains(full, c.Err.Value) { | ||
| 183 | return "" | 185 | return "" | ||
| 184 | } | 186 | } | ||
M template/read_test.go +5 -4
| @@ -22,17 +22,18 @@ const head = "on: push\njobs:\n a:\n runs-on: x\n steps:\n" | |||
| 22 | 22 | ||
| 23 | 23 | func TestReadErrors(t *testing.T) { | |
| 24 | 24 | // Expected messages come from @actions/workflow-parser 0.3.61 run on the | |
| 25 | - | // same inputs. | |
| 25 | + | // same inputs, except that expression errors carry the position suffix of | |
| 26 | + | // the C# runner (package expr follows it). | |
| 26 | 27 | tests := []struct { | |
| 27 | 28 | name, src string | |
| 28 | 29 | want []string | |
| 29 | 30 | }{ | |
| 30 | 31 | {"unknown context, block scalar", head + " - run: |\n echo hi\n echo ${{ foo.bar }}\n", | |
| 31 | - | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}}, | |
| 32 | + | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'. Located at position 1 within expression: foo.bar"}}, | |
| 32 | 33 | {"unknown context, plain", head + " - run: echo ${{ foo.bar }} abc\n", | |
| 33 | - | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}}, | |
| 34 | + | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'. Located at position 1 within expression: foo.bar"}}, | |
| 34 | 35 | {"unknown context, double quoted", head + " - run: \"echo ${{ foo.bar }} abc\"\n", | |
| 35 | - | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}}, | |
| 36 | + | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'. Located at position 1 within expression: foo.bar"}}, | |
| 36 | 37 | {"bad expression and insert in a name", head + " - run: echo ${{ 1 + }}\n - name: ${{ insert }}\n run: x\n", | |
| 37 | 38 | []string{"a.yml (Line: 6, Col: 14): Unexpected symbol: '+'. Located at position 3 within expression: 1 +", "a.yml (Line: 7, Col: 15): Unexpected value '${{ insert }}'"}}, | |
| 38 | 39 | {"not closed", head + " - run: echo ${{ 1\n", | |
| @@ -22,17 +22,18 @@ const head = "on: push\njobs:\n a:\n runs-on: x\n steps:\n" | |||||
| 22 | 22 | ||||
| 23 | func TestReadErrors(t *testing.T) { | 23 | func TestReadErrors(t *testing.T) { | ||
| 24 | // Expected messages come from @actions/workflow-parser 0.3.61 run on the | 24 | // Expected messages come from @actions/workflow-parser 0.3.61 run on the | ||
| 25 | - | // same inputs. | 25 | + | // same inputs, except that expression errors carry the position suffix of |
| 26 | + | // the C# runner (package expr follows it). | |||
| 26 | tests := []struct { | 27 | tests := []struct { | ||
| 27 | name, src string | 28 | name, src string | ||
| 28 | want []string | 29 | want []string | ||
| 29 | }{ | 30 | }{ | ||
| 30 | {"unknown context, block scalar", head + " - run: |\n echo hi\n echo ${{ foo.bar }}\n", | 31 | {"unknown context, block scalar", head + " - run: |\n echo hi\n echo ${{ foo.bar }}\n", | ||
| 31 | - | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}}, | 32 | + | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'. Located at position 1 within expression: foo.bar"}}, |
| 32 | {"unknown context, plain", head + " - run: echo ${{ foo.bar }} abc\n", | 33 | {"unknown context, plain", head + " - run: echo ${{ foo.bar }} abc\n", | ||
| 33 | - | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}}, | 34 | + | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'. Located at position 1 within expression: foo.bar"}}, |
| 34 | {"unknown context, double quoted", head + " - run: \"echo ${{ foo.bar }} abc\"\n", | 35 | {"unknown context, double quoted", head + " - run: \"echo ${{ foo.bar }} abc\"\n", | ||
| 35 | - | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}}, | 36 | + | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'. Located at position 1 within expression: foo.bar"}}, |
| 36 | {"bad expression and insert in a name", head + " - run: echo ${{ 1 + }}\n - name: ${{ insert }}\n run: x\n", | 37 | {"bad expression and insert in a name", head + " - run: echo ${{ 1 + }}\n - name: ${{ insert }}\n run: x\n", | ||
| 37 | []string{"a.yml (Line: 6, Col: 14): Unexpected symbol: '+'. Located at position 3 within expression: 1 +", "a.yml (Line: 7, Col: 15): Unexpected value '${{ insert }}'"}}, | 38 | []string{"a.yml (Line: 6, Col: 14): Unexpected symbol: '+'. Located at position 3 within expression: 1 +", "a.yml (Line: 7, Col: 15): Unexpected value '${{ insert }}'"}}, | ||
| 38 | {"not closed", head + " - run: echo ${{ 1\n", | 39 | {"not closed", head + " - run: echo ${{ 1\n", | ||
M workflow/convert_test.go +22 -1
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | 9 | "reflect" | |
| 10 | + | "regexp" | |
| 10 | 11 | "sort" | |
| 11 | 12 | "strings" | |
| 12 | 13 | "testing" | |
| @@ -50,6 +51,26 @@ func errorTexts(errs []Error) []string { | |||
| 50 | 51 | return out | |
| 51 | 52 | } | |
| 52 | 53 | ||
| 54 | + | // The reference (@actions/workflow-parser) words expression parse errors like | |
| 55 | + | // the JavaScript expression library: no position suffix, and the function name | |
| 56 | + | // in arity errors. package expr follows the C# runner, which is what runs the | |
| 57 | + | // workflow: "<description>: '<token>'. Located at position N within | |
| 58 | + | // expression: <expression>", with the "(" token in arity errors. The tests | |
| 59 | + | // compare after reducing both to the part the two share. | |
| 60 | + | var ( | |
| 61 | + | runnerSuffix = regexp.MustCompile(`(?s)\. Located at position \d+ within expression: .*$`) | |
| 62 | + | runnerArity = regexp.MustCompile(`((?:Too few|Too many) parameters supplied|Even number of parameters supplied, requires an odd number of parameters): '[^']*'`) | |
| 63 | + | ) | |
| 64 | + | ||
| 65 | + | func runnerNeutral(msgs []string) []string { | |
| 66 | + | out := make([]string, len(msgs)) | |
| 67 | + | for i, m := range msgs { | |
| 68 | + | m = runnerSuffix.ReplaceAllString(m, "") | |
| 69 | + | out[i] = runnerArity.ReplaceAllString(m, "$1: '...'") | |
| 70 | + | } | |
| 71 | + | return out | |
| 72 | + | } | |
| 73 | + | ||
| 53 | 74 | // isYAMLError recognises the reference's YAML syntax errors, whose text is | |
| 54 | 75 | // the JavaScript library's and cannot be reproduced word for word. | |
| 55 | 76 | func isYAMLError(s string) bool { return strings.Contains(s, " at line ") } | |
| @@ -101,7 +122,7 @@ func TestConvertAgainstReference(t *testing.T) { | |||
| 101 | 122 | } | |
| 102 | 123 | return | |
| 103 | 124 | } | |
| 104 | - | if !reflect.DeepEqual(nonNil(got), nonNil(want.Errors)) { | |
| 125 | + | if !reflect.DeepEqual(runnerNeutral(nonNil(got)), runnerNeutral(nonNil(want.Errors))) { | |
| 105 | 126 | t.Errorf("errors differ\n got: %q\nwant: %q", got, want.Errors) | |
| 106 | 127 | } | |
| 107 | 128 | if len(want.Errors) > 0 { | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "os" | 7 | "os" | ||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | "reflect" | 9 | "reflect" | ||
| 10 | + | "regexp" | |||
| 10 | "sort" | 11 | "sort" | ||
| 11 | "strings" | 12 | "strings" | ||
| 12 | "testing" | 13 | "testing" | ||
| @@ -50,6 +51,26 @@ func errorTexts(errs []Error) []string { | |||||
| 50 | return out | 51 | return out | ||
| 51 | } | 52 | } | ||
| 52 | 53 | ||||
| 54 | + | // The reference (@actions/workflow-parser) words expression parse errors like | |||
| 55 | + | // the JavaScript expression library: no position suffix, and the function name | |||
| 56 | + | // in arity errors. package expr follows the C# runner, which is what runs the | |||
| 57 | + | // workflow: "<description>: '<token>'. Located at position N within | |||
| 58 | + | // expression: <expression>", with the "(" token in arity errors. The tests | |||
| 59 | + | // compare after reducing both to the part the two share. | |||
| 60 | + | var ( | |||
| 61 | + | runnerSuffix = regexp.MustCompile(`(?s)\. Located at position \d+ within expression: .*$`) | |||
| 62 | + | runnerArity = regexp.MustCompile(`((?:Too few|Too many) parameters supplied|Even number of parameters supplied, requires an odd number of parameters): '[^']*'`) | |||
| 63 | + | ) | |||
| 64 | + | ||||
| 65 | + | func runnerNeutral(msgs []string) []string { | |||
| 66 | + | out := make([]string, len(msgs)) | |||
| 67 | + | for i, m := range msgs { | |||
| 68 | + | m = runnerSuffix.ReplaceAllString(m, "") | |||
| 69 | + | out[i] = runnerArity.ReplaceAllString(m, "$1: '...'") | |||
| 70 | + | } | |||
| 71 | + | return out | |||
| 72 | + | } | |||
| 73 | + | ||||
| 53 | // isYAMLError recognises the reference's YAML syntax errors, whose text is | 74 | // isYAMLError recognises the reference's YAML syntax errors, whose text is | ||
| 54 | // the JavaScript library's and cannot be reproduced word for word. | 75 | // the JavaScript library's and cannot be reproduced word for word. | ||
| 55 | func isYAMLError(s string) bool { return strings.Contains(s, " at line ") } | 76 | func isYAMLError(s string) bool { return strings.Contains(s, " at line ") } | ||
| @@ -101,7 +122,7 @@ func TestConvertAgainstReference(t *testing.T) { | |||||
| 101 | } | 122 | } | ||
| 102 | return | 123 | return | ||
| 103 | } | 124 | } | ||
| 104 | - | if !reflect.DeepEqual(nonNil(got), nonNil(want.Errors)) { | 125 | + | if !reflect.DeepEqual(runnerNeutral(nonNil(got)), runnerNeutral(nonNil(want.Errors))) { |
| 105 | t.Errorf("errors differ\n got: %q\nwant: %q", got, want.Errors) | 126 | t.Errorf("errors differ\n got: %q\nwant: %q", got, want.Errors) | ||
| 106 | } | 127 | } | ||
| 107 | if len(want.Errors) > 0 { | 128 | if len(want.Errors) > 0 { | ||