@barefootjs/jinja 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +66 -0
  2. package/dist/adapter/analysis/component-tree.d.ts +26 -0
  3. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  4. package/dist/adapter/boolean-result.d.ts +84 -0
  5. package/dist/adapter/boolean-result.d.ts.map +1 -0
  6. package/dist/adapter/emit-context.d.ts +106 -0
  7. package/dist/adapter/emit-context.d.ts.map +1 -0
  8. package/dist/adapter/expr/array-method.d.ts +75 -0
  9. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  10. package/dist/adapter/expr/emitters.d.ts +143 -0
  11. package/dist/adapter/expr/emitters.d.ts.map +1 -0
  12. package/dist/adapter/expr/operand.d.ts +25 -0
  13. package/dist/adapter/expr/operand.d.ts.map +1 -0
  14. package/dist/adapter/index.d.ts +6 -0
  15. package/dist/adapter/index.d.ts.map +1 -0
  16. package/dist/adapter/index.js +189097 -0
  17. package/dist/adapter/jinja-adapter.d.ts +394 -0
  18. package/dist/adapter/jinja-adapter.d.ts.map +1 -0
  19. package/dist/adapter/lib/constants.d.ts +21 -0
  20. package/dist/adapter/lib/constants.d.ts.map +1 -0
  21. package/dist/adapter/lib/ir-scope.d.ts +50 -0
  22. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  23. package/dist/adapter/lib/jinja-naming.d.ts +77 -0
  24. package/dist/adapter/lib/jinja-naming.d.ts.map +1 -0
  25. package/dist/adapter/lib/types.d.ts +27 -0
  26. package/dist/adapter/lib/types.d.ts.map +1 -0
  27. package/dist/adapter/memo/seed.d.ts +81 -0
  28. package/dist/adapter/memo/seed.d.ts.map +1 -0
  29. package/dist/adapter/props/prop-classes.d.ts +33 -0
  30. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  31. package/dist/adapter/spread/spread-codegen.d.ts +61 -0
  32. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  33. package/dist/adapter/value/parsed-literal.d.ts +28 -0
  34. package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
  35. package/dist/build.d.ts +28 -0
  36. package/dist/build.d.ts.map +1 -0
  37. package/dist/build.js +189117 -0
  38. package/dist/conformance-pins.d.ts +12 -0
  39. package/dist/conformance-pins.d.ts.map +1 -0
  40. package/dist/index.d.ts +9 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +189118 -0
  43. package/package.json +66 -0
  44. package/python/VERSION +1 -0
  45. package/python/barefootjs/__init__.py +57 -0
  46. package/python/barefootjs/backend_jinja.py +125 -0
  47. package/python/barefootjs/evaluator.py +787 -0
  48. package/python/barefootjs/runtime.py +1334 -0
  49. package/python/barefootjs/search_params.py +89 -0
  50. package/python/pyproject.toml +32 -0
  51. package/python/tests/__init__.py +0 -0
  52. package/python/tests/test_eval_vectors.py +88 -0
  53. package/python/tests/test_evaluator.py +406 -0
  54. package/python/tests/test_helper_vectors.py +288 -0
  55. package/python/tests/test_omit.py +62 -0
  56. package/python/tests/test_props_attr.py +54 -0
  57. package/python/tests/test_query.py +41 -0
  58. package/python/tests/test_render.py +102 -0
  59. package/python/tests/test_render_child.py +96 -0
  60. package/python/tests/test_search_params.py +50 -0
  61. package/python/tests/test_spread_attrs.py +86 -0
  62. package/python/tests/test_template_primitives.py +347 -0
  63. package/python/tests/vector-divergences.json +42 -0
  64. package/src/__tests__/jinja-adapter-unit.test.ts +390 -0
  65. package/src/__tests__/jinja-adapter.test.ts +53 -0
  66. package/src/__tests__/jinja-counter.test.ts +62 -0
  67. package/src/__tests__/jinja-query-href.test.ts +99 -0
  68. package/src/__tests__/jinja-spread-attrs.test.ts +225 -0
  69. package/src/adapter/analysis/component-tree.ts +119 -0
  70. package/src/adapter/boolean-result.ts +176 -0
  71. package/src/adapter/emit-context.ts +118 -0
  72. package/src/adapter/expr/array-method.ts +346 -0
  73. package/src/adapter/expr/emitters.ts +608 -0
  74. package/src/adapter/expr/operand.ts +35 -0
  75. package/src/adapter/index.ts +6 -0
  76. package/src/adapter/jinja-adapter.ts +1747 -0
  77. package/src/adapter/lib/constants.ts +33 -0
  78. package/src/adapter/lib/ir-scope.ts +95 -0
  79. package/src/adapter/lib/jinja-naming.ts +114 -0
  80. package/src/adapter/lib/types.ts +30 -0
  81. package/src/adapter/memo/seed.ts +132 -0
  82. package/src/adapter/props/prop-classes.ts +65 -0
  83. package/src/adapter/spread/spread-codegen.ts +166 -0
  84. package/src/adapter/value/parsed-literal.ts +76 -0
  85. package/src/build.ts +37 -0
  86. package/src/conformance-pins.ts +101 -0
  87. package/src/index.ts +9 -0
  88. package/src/test-render.ts +714 -0
@@ -0,0 +1,787 @@
1
+ """Python port of packages/adapter-perl/lib/BarefootJS/Evaluator.pm.
2
+
3
+ Lightweight evaluator for the pure ``ParsedExpr`` subset, scoped to
4
+ higher-order callback bodies (reduce / sort / map / filter / find
5
+ ``(...) => expr``) -- issue #2018. Templates cannot carry a lambda in
6
+ expression position, which is why the adapters historically special-cased
7
+ these callbacks into fixed shapes (bf_sort's comparator catalogue,
8
+ bf_reduce's +/* fold). Instead, the callback BODY rides as a pure
9
+ ``ParsedExpr`` subtree (the structured IR the compiler already produces) and
10
+ is evaluated here against an environment (``{acc, item, ...captured free
11
+ vars}``).
12
+
13
+ ONE shared implementation across all backends (mirrors the Perl module,
14
+ which is shared by the Mojo + Xslate Perl backends), living alongside
15
+ ``search_params.py`` in the engine-agnostic core. The accepted subset and
16
+ its semantics are documented in ``spec/compiler.md`` ("ParsedExpr Evaluator
17
+ Semantics") and pinned isomorphically by the cross-language golden vectors
18
+ (``packages/adapter-tests/vectors/eval-vectors.json``), shared with
19
+ the Go evaluator (bf.go) and the Perl evaluator -- same input -> same output.
20
+
21
+ The coercion below is JS-faithful (ToNumber / ToString / ToBoolean, strict
22
+ equality) and deliberately distinct from the divergent ``bf.string`` /
23
+ ``bf.number`` helpers in ``runtime.py``, so the contract is unambiguous and
24
+ every template adapter stays byte-equal with each other and with Go.
25
+
26
+ Unlike the Perl port, this module does NOT need a separate SV-flag trick
27
+ (``B::svref_2object`` in Evaluator.pm) to tell the JS *string* "10" from the
28
+ JS *number* 10: Python's ``json.loads`` already decodes a JSON string to
29
+ ``str`` and a JSON number to ``int``/``float``, so the type distinction is
30
+ native. ``bool`` is checked before the general numeric branch everywhere
31
+ below because Python's ``bool`` is a subclass of ``int``.
32
+
33
+ This module intentionally does NOT import ``runtime.py`` (and vice versa is
34
+ fine -- ``runtime.py`` imports this module for the ``*_eval`` helpers), to
35
+ mirror the Perl structure: ``Evaluator.pm`` is a standalone unit with no
36
+ dependency on ``BarefootJS.pm``.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import functools
42
+ import json as _json
43
+ import math
44
+ import re
45
+ from typing import Any
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # JS numeric-string grammar (mirrors Perl's Scalar::Util::looks_like_number).
49
+ # Exposed (not underscore-prefixed) so runtime.py's JS-`Number()`-diverging
50
+ # `number()` helper can reuse the exact same "does this string look like a
51
+ # number" classifier -- the two modules apply different coercion RULES
52
+ # (Evaluator is JS-faithful; runtime.number() deliberately returns NaN for
53
+ # undef/empty-string) but share the same underlying string grammar, just as
54
+ # both Perl modules share one imported `Scalar::Util::looks_like_number`.
55
+ # ---------------------------------------------------------------------------
56
+ _NUM_RE = re.compile(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$")
57
+ _INF_NAN_RE = re.compile(r"^[+-]?(inf(inity)?|nan)$", re.IGNORECASE)
58
+
59
+
60
+ def looks_like_number(s: str) -> bool:
61
+ """Mirror Scalar::Util::looks_like_number for a (possibly padded) string."""
62
+ t = s.strip()
63
+ if t == "":
64
+ return False
65
+ return bool(_NUM_RE.match(t) or _INF_NAN_RE.match(t))
66
+
67
+
68
+ def parse_number_literal(s: str) -> float:
69
+ """Parse a string already known to satisfy `looks_like_number` into a float."""
70
+ t = s.strip()
71
+ if _INF_NAN_RE.match(t):
72
+ low = t.lower()
73
+ if "nan" in low:
74
+ return float("nan")
75
+ return float("-inf") if low.startswith("-") else float("inf")
76
+ return float(t)
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # evaluate(node, env): evaluate a decoded ParsedExpr node (a dict keyed by
81
+ # `kind`) against the environment dict, returning a Python value (float, str,
82
+ # bool, None for null, list, dict). The matching JSON entry point is
83
+ # eval_json() below.
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ def evaluate(node: Any, env: dict) -> Any:
88
+ if not isinstance(node, dict):
89
+ return None
90
+ kind = node.get("kind") or ""
91
+
92
+ if kind == "literal":
93
+ return node.get("value")
94
+ if kind == "identifier":
95
+ return env.get(node.get("name"))
96
+ if kind == "binary":
97
+ return _binary(
98
+ node.get("op"),
99
+ evaluate(node.get("left"), env),
100
+ evaluate(node.get("right"), env),
101
+ )
102
+ if kind == "unary":
103
+ return _unary(node.get("op"), evaluate(node.get("argument"), env))
104
+ if kind == "logical":
105
+ op = node.get("op")
106
+ left = evaluate(node.get("left"), env)
107
+ if op == "&&":
108
+ return evaluate(node.get("right"), env) if _truthy(left) else left
109
+ if op == "||":
110
+ return left if _truthy(left) else evaluate(node.get("right"), env)
111
+ # `??`
112
+ return left if left is not None else evaluate(node.get("right"), env)
113
+ if kind == "conditional":
114
+ return (
115
+ evaluate(node.get("consequent"), env)
116
+ if _truthy(evaluate(node.get("test"), env))
117
+ else evaluate(node.get("alternate"), env)
118
+ )
119
+ if kind == "member":
120
+ return _read_property(evaluate(node.get("object"), env), node.get("property"))
121
+ if kind == "index-access":
122
+ return _read_index(
123
+ evaluate(node.get("object"), env), evaluate(node.get("index"), env)
124
+ )
125
+ if kind == "call":
126
+ nested = _array_callback_call(node)
127
+ if nested is not None:
128
+ method, object_node, arrow_node = nested
129
+ return _array_callback(method, object_node, arrow_node, env)
130
+ name = _builtin_name(node.get("callee"))
131
+ if not name:
132
+ return None
133
+ args = [evaluate(a, env) for a in node.get("args") or []]
134
+ return _call_builtin(name, args)
135
+ if kind == "template-literal":
136
+ out = []
137
+ for p in node.get("parts") or []:
138
+ if (p.get("type") or "") == "string":
139
+ out.append(p.get("value") or "")
140
+ else:
141
+ out.append(_to_string(evaluate(p.get("expr"), env)))
142
+ return "".join(out)
143
+ if kind == "array-literal":
144
+ return [evaluate(e, env) for e in node.get("elements") or []]
145
+ if kind == "object-literal":
146
+ out: dict = {}
147
+ for prop in node.get("properties") or []:
148
+ out[prop.get("key")] = evaluate(prop.get("value"), env)
149
+ return out
150
+ if kind == "array-method":
151
+ method = node.get("method") or ""
152
+ if method == "includes":
153
+ args = node.get("args") or []
154
+ if len(args) == 1:
155
+ # `.includes(x)` (#2075) -- the one `array-method` in the
156
+ # evaluator subset, shared between `Array.prototype.includes`
157
+ # (SameValueZero membership) and `String.prototype.includes`
158
+ # (substring search), matching the receiver-type dispatch the SSR
159
+ # template lowering does at runtime (`bf.includes`). Mirrors the
160
+ # JS reference's `includes()` (eval-reference.ts) and the Perl
161
+ # port's identical `array-method`/`includes` arm
162
+ # (Evaluator.pm).
163
+ obj = evaluate(node.get("object"), env)
164
+ needle = evaluate(args[0], env)
165
+ if isinstance(obj, list):
166
+ return _bool(any(_same_value_zero(el, needle) for el in obj))
167
+ if _is_string(obj):
168
+ return _bool(_to_string(needle) in obj)
169
+ # Any other receiver is not a JS `.includes` target -- degrade
170
+ # to false rather than raising, mirroring the reference.
171
+ return _bool(False)
172
+ elif method == "join":
173
+ # `.join(sep?)` (#2094): a plain `array-method` node alongside
174
+ # `includes` above. JS semantics: default separator is `,`; a
175
+ # `null`/`undefined` element joins as `''` -- NOT the string
176
+ # "null" that a bare `_to_string(None)` ToString call would
177
+ # produce (see `_to_string`'s docstring-equivalent note above),
178
+ # so that special case is applied here, before stringifying,
179
+ # rather than inside `_to_string` itself.
180
+ args = node.get("args") or []
181
+ sep = _to_string(evaluate(args[0], env)) if len(args) >= 1 else ","
182
+ obj = evaluate(node.get("object"), env)
183
+ arr = obj if isinstance(obj, list) else []
184
+ return sep.join("" if el is None else _to_string(el) for el in arr)
185
+
186
+ # arrow-fn / higher-order / unsupported array-method: a callback body
187
+ # containing these is refused upstream (BF101); never reached here.
188
+ return None
189
+
190
+
191
+ def eval_json(json_str: str, env: dict) -> Any:
192
+ """Decode a ParsedExpr JSON string and evaluate it. Mirrors the Go EvalExpr
193
+ entry point and Perl's eval_json."""
194
+ return evaluate(_json.loads(json_str), env)
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # JS value classification.
199
+ # ---------------------------------------------------------------------------
200
+
201
+
202
+ def _is_string(v: Any) -> bool:
203
+ return isinstance(v, str)
204
+
205
+
206
+ def _is_number(v: Any) -> bool:
207
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
208
+
209
+
210
+ def _nan() -> float:
211
+ return float("nan")
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # JS coercion primitives (ToNumber / ToString / ToBoolean).
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ def _to_number(v: Any) -> float:
220
+ if v is None:
221
+ return 0.0
222
+ if isinstance(v, bool):
223
+ return 1.0 if v else 0.0
224
+ if _is_number(v):
225
+ return float(v)
226
+ if isinstance(v, str):
227
+ t = v.strip()
228
+ if t == "":
229
+ return 0.0
230
+ return parse_number_literal(t) if looks_like_number(t) else _nan()
231
+ return _nan() # list / dict
232
+
233
+
234
+ def _format_number(n: float) -> str:
235
+ # Shortest round-trip repr with JS's integral-float spelling (`1.0` ->
236
+ # `"1"`). Python's `repr(float)` has been shortest-round-trip since 3.1,
237
+ # matching JS engines' digit sequences for the common range; very
238
+ # large/small magnitudes can diverge in exponent-notation formatting --
239
+ # the same documented divergence region the Go runtime's evalToString
240
+ # carries (see bf.go / eval.go), not re-solved here either.
241
+ if n == 0:
242
+ return "0"
243
+ if n == int(n) and abs(n) < 1e21:
244
+ return str(int(n))
245
+ return repr(n)
246
+
247
+
248
+ def _to_string(v: Any) -> str:
249
+ if v is None:
250
+ return "null"
251
+ if isinstance(v, bool):
252
+ return "true" if v else "false"
253
+ if _is_number(v):
254
+ n = float(v)
255
+ if n != n:
256
+ return "NaN"
257
+ if n == float("inf"):
258
+ return "Infinity"
259
+ if n == float("-inf"):
260
+ return "-Infinity"
261
+ return _format_number(n)
262
+ return str(v)
263
+
264
+
265
+ def _truthy(v: Any) -> bool:
266
+ if v is None:
267
+ return False
268
+ if isinstance(v, bool):
269
+ return v
270
+ if isinstance(v, (int, float)):
271
+ n = float(v)
272
+ return n == n and n != 0 # nonzero and not NaN
273
+ if isinstance(v, str):
274
+ return v != "" # incl. the truthy "0"
275
+ return True # arrays / objects are always truthy in JS
276
+
277
+
278
+ def _bool(t: Any) -> bool:
279
+ return bool(t)
280
+
281
+
282
+ # ---------------------------------------------------------------------------
283
+ # Operators
284
+ # ---------------------------------------------------------------------------
285
+
286
+
287
+ def _binary(op: str, l: Any, r: Any) -> Any:
288
+ if op == "+":
289
+ # JS `+`: string concatenation once either operand is a string,
290
+ # numeric addition otherwise.
291
+ if _is_string(l) or _is_string(r):
292
+ return _to_string(l) + _to_string(r)
293
+ return _to_number(l) + _to_number(r)
294
+ if op == "-":
295
+ return _to_number(l) - _to_number(r)
296
+ if op == "*":
297
+ return _to_number(l) * _to_number(r)
298
+ if op == "/":
299
+ ln, rn = _to_number(l), _to_number(r)
300
+ if rn == 0:
301
+ if ln == 0 or ln != ln:
302
+ return _nan()
303
+ return float("inf") if ln > 0 else float("-inf")
304
+ return ln / rn
305
+ if op == "%":
306
+ rn = _to_number(r)
307
+ if rn == 0:
308
+ return _nan()
309
+ return math.fmod(_to_number(l), rn)
310
+ if op in ("<", "<=", ">", ">="):
311
+ return _relational(op, l, r)
312
+ if op == "===":
313
+ return _bool(_strict_eq(l, r))
314
+ if op == "!==":
315
+ return _bool(not _strict_eq(l, r))
316
+ # Loose equality / bitwise / shift are out of the subset.
317
+ return None
318
+
319
+
320
+ def _relational(op: str, l: Any, r: Any) -> bool:
321
+ # JS Abstract Relational Comparison: both strings -> compare by code
322
+ # unit; otherwise coerce both to numbers (a NaN operand makes it false).
323
+ if _is_string(l) and _is_string(r):
324
+ c = -1 if l < r else (1 if l > r else 0)
325
+ else:
326
+ ln, rn = _to_number(l), _to_number(r)
327
+ if ln != ln or rn != rn: # NaN -> false
328
+ return _bool(False)
329
+ c = -1 if ln < rn else (1 if ln > rn else 0)
330
+ if op == "<":
331
+ return _bool(c < 0)
332
+ if op == "<=":
333
+ return _bool(c <= 0)
334
+ if op == ">":
335
+ return _bool(c > 0)
336
+ if op == ">=":
337
+ return _bool(c >= 0)
338
+ return _bool(False)
339
+
340
+
341
+ def _strict_eq(l: Any, r: Any) -> bool:
342
+ # Strict `===`: equal JS type and value, no coercion.
343
+ ln, rn = _is_number(l), _is_number(r)
344
+ if ln and rn:
345
+ lf, rf = float(l), float(r)
346
+ if lf != lf or rf != rf: # NaN
347
+ return False
348
+ return lf == rf
349
+ if ln != rn: # one numeric, one not
350
+ return False
351
+ if l is None:
352
+ return r is None
353
+ if r is None:
354
+ return False
355
+ lb, rb = isinstance(l, bool), isinstance(r, bool)
356
+ if lb or rb:
357
+ if not (lb and rb):
358
+ return False
359
+ return bool(l) == bool(r)
360
+ if _is_string(l) and _is_string(r):
361
+ return l == r
362
+ return False
363
+
364
+
365
+ def _same_value_zero(l: Any, r: Any) -> bool:
366
+ """`Array.prototype.includes` membership test -- `===` except `NaN`
367
+ equals itself (and +0/-0 are not distinguished, which the JSON-decoded
368
+ values here can't represent anyway). Reuses `_strict_eq`'s type/value
369
+ rules and only special-cases the two-NaN case that `_strict_eq`
370
+ (deliberately, for `===`) reports as unequal."""
371
+ if _is_number(l) and _is_number(r):
372
+ lf, rf = float(l), float(r)
373
+ if lf != lf and rf != rf: # NaN sameValueZero NaN
374
+ return True
375
+ return _strict_eq(l, r)
376
+
377
+
378
+ def _unary(op: str, v: Any) -> Any:
379
+ if op == "!":
380
+ return _bool(not _truthy(v))
381
+ if op == "-":
382
+ return -_to_number(v)
383
+ if op == "+":
384
+ return _to_number(v)
385
+ return None
386
+
387
+
388
+ # ---------------------------------------------------------------------------
389
+ # Nested `.map`/`.filter` callback calls (#2094) -- widens the evaluator's
390
+ # "call" case to also execute a `.map(cb)` / `.filter(cb)` call nested INSIDE
391
+ # a callback body it's already given, e.g. a `.flatMap(p => p.tags.map(t =>
392
+ # '#'+t))` projection whose body itself contains a `.map`. Everything else
393
+ # nested (`.some`/`.find`/`.every`/`.sort`/`.reduce`/`.flat`/`.flatMap`,
394
+ # standalone arrows) stays refused -- that gating is the compiler's job
395
+ # upstream (`serializeParsedExpr`) and is not touched here. Mirrors Go's
396
+ # `evalArrayCallbackCall` / `evalArrayCallback`.
397
+ # ---------------------------------------------------------------------------
398
+
399
+
400
+ def _array_callback_call(node: dict) -> tuple[str, Any, dict] | None:
401
+ """Recognise a `call` node shaped like a nested `.map(cb)` / `.filter(cb)`
402
+ call: `callee` is a non-computed `member` node whose `property` is `map`
403
+ or `filter`, and the first (only) arg is an `arrow` node. Returns
404
+ `(method, object_node, arrow_node)` or `None` when the node doesn't match
405
+ (the caller then falls through to the ordinary builtin-name dispatch)."""
406
+ callee = node.get("callee")
407
+ if not isinstance(callee, dict) or (callee.get("kind") or "") != "member":
408
+ return None
409
+ if callee.get("computed"):
410
+ return None
411
+ prop = callee.get("property") or ""
412
+ if prop not in ("map", "filter"):
413
+ return None
414
+ raw_args = node.get("args") or []
415
+ if not raw_args:
416
+ return None
417
+ arrow_node = raw_args[0]
418
+ if not isinstance(arrow_node, dict) or (arrow_node.get("kind") or "") != "arrow":
419
+ return None
420
+ return prop, callee.get("object"), arrow_node
421
+
422
+
423
+ def _array_callback(method: str, object_node: Any, arrow_node: dict, env: dict) -> Any:
424
+ """Evaluate a nested `.map(cb)` / `.filter(cb)` call recognised by
425
+ `_array_callback_call`. `params` (plain strings, e.g. `["t"]` or
426
+ `["t", "i"]`) bind the 1st param to the element and the 2nd (if present)
427
+ to the integer index, in a COPY of the parent env (a fresh child scope
428
+ per call -- never mutating the parent dict in place across sibling
429
+ iterations, the classic Python gotcha)."""
430
+ obj = evaluate(object_node, env)
431
+ # Non-list receiver -> None, matching every other runtime evaluator
432
+ # (Go nil / Perl undef / Rust Null / PHP null / Ruby nil) so "missing"
433
+ # stays distinguishable from "empty array" downstream.
434
+ if not isinstance(obj, list):
435
+ return None
436
+ arr = obj
437
+ params = [p for p in (arrow_node.get("params") or [])]
438
+ body = arrow_node.get("body")
439
+
440
+ def call_cb(item: Any, index: int) -> Any:
441
+ inner = dict(env)
442
+ if len(params) > 0:
443
+ inner[params[0]] = item
444
+ if len(params) > 1:
445
+ inner[params[1]] = index
446
+ return evaluate(body, inner)
447
+
448
+ if method == "map":
449
+ return [call_cb(item, i) for i, item in enumerate(arr)]
450
+ return [item for i, item in enumerate(arr) if _truthy(call_cb(item, i))]
451
+
452
+
453
+ # ---------------------------------------------------------------------------
454
+ # Built-in calls (the deterministic allowlist). Locale-sensitive builtins
455
+ # (localeCompare) are deliberately excluded to keep the backends isomorphic.
456
+ # ---------------------------------------------------------------------------
457
+
458
+
459
+ def _builtin_name(callee: Any) -> str:
460
+ """Resolve a `call` callee to its builtin name (e.g. "Math.max"), or ''
461
+ when the callee is not an allowlisted builtin reference."""
462
+ if not isinstance(callee, dict):
463
+ return ""
464
+ kind = callee.get("kind") or ""
465
+ if kind == "identifier":
466
+ return callee.get("name") or ""
467
+ if kind == "member" and not callee.get("computed"):
468
+ obj = callee.get("object")
469
+ if not isinstance(obj, dict) or (obj.get("kind") or "") != "identifier":
470
+ return ""
471
+ return f"{obj.get('name') or ''}.{callee.get('property') or ''}"
472
+ return ""
473
+
474
+
475
+ def _safe_floor(n: float) -> float:
476
+ if n != n or n in (float("inf"), float("-inf")):
477
+ return n
478
+ return float(math.floor(n))
479
+
480
+
481
+ def _safe_ceil(n: float) -> float:
482
+ if n != n or n in (float("inf"), float("-inf")):
483
+ return n
484
+ return float(math.ceil(n))
485
+
486
+
487
+ def _math_round(n: float) -> float:
488
+ # Half rounds toward +Infinity (JS Math.round: 2.5 -> 3, -2.5 -> -2),
489
+ # matching runtime.py's `round` helper rather than half-away-from-zero.
490
+ if n != n or n in (float("inf"), float("-inf")):
491
+ return n
492
+ return float(math.floor(n + 0.5))
493
+
494
+
495
+ def _call_builtin(name: str, args: list) -> Any:
496
+ def arg(i: int) -> Any:
497
+ return args[i] if i < len(args) else None
498
+
499
+ if name == "Math.max":
500
+ m = float("-inf") # JS Math.max() with no args is -Infinity
501
+ for a in args:
502
+ n = _to_number(a)
503
+ if n != n: # any NaN argument -> NaN (JS / Go / Perl)
504
+ return n
505
+ if n > m:
506
+ m = n
507
+ return m
508
+ if name == "Math.min":
509
+ m = float("inf") # JS Math.min() with no args is +Infinity
510
+ for a in args:
511
+ n = _to_number(a)
512
+ if n != n:
513
+ return n
514
+ if n < m:
515
+ m = n
516
+ return m
517
+ if name == "Math.abs":
518
+ return abs(_to_number(arg(0)))
519
+ if name == "Math.floor":
520
+ return _safe_floor(_to_number(arg(0)))
521
+ if name == "Math.ceil":
522
+ return _safe_ceil(_to_number(arg(0)))
523
+ if name == "Math.round":
524
+ return _math_round(_to_number(arg(0)))
525
+ if name == "String":
526
+ return _to_string(arg(0))
527
+ if name == "Number":
528
+ return _to_number(arg(0))
529
+ if name == "Boolean":
530
+ return _bool(_truthy(arg(0)))
531
+ # Any other callee is outside the subset (refused upstream).
532
+ return None
533
+
534
+
535
+ # ---------------------------------------------------------------------------
536
+ # Member / index access
537
+ # ---------------------------------------------------------------------------
538
+
539
+
540
+ def _read_property(obj: Any, key: Any) -> Any:
541
+ if obj is None:
542
+ return None
543
+ if isinstance(obj, dict):
544
+ return obj.get(key)
545
+ if isinstance(obj, list):
546
+ return len(obj) if key == "length" else None
547
+ if isinstance(obj, str) and key == "length":
548
+ # `.length` is a string property only -- a numeric scalar (123) has
549
+ # no `.length` in the subset (JS `(123).length` is undefined ->
550
+ # null); matches the Go/Perl evaluators (numbers fall through here).
551
+ return len(obj)
552
+ return None
553
+
554
+
555
+ def _read_index(obj: Any, index: Any) -> Any:
556
+ if isinstance(obj, list):
557
+ f = _to_number(index)
558
+ if f != f or f in (float("inf"), float("-inf")):
559
+ return None
560
+ i = int(f)
561
+ if i != f or i < 0 or i >= len(obj):
562
+ return None
563
+ return obj[i]
564
+ if isinstance(obj, dict):
565
+ return obj.get(_to_string(index))
566
+ return None
567
+
568
+
569
+ # ---------------------------------------------------------------------------
570
+ # Evaluator-driven higher-order folds (the generalization of bf_reduce /
571
+ # bf_sort onto the evaluator).
572
+ # ---------------------------------------------------------------------------
573
+
574
+
575
+ def fold(
576
+ items: Any,
577
+ body: Any,
578
+ acc_name: str,
579
+ item_name: str,
580
+ init: Any,
581
+ direction: str = "left",
582
+ base_env: dict | None = None,
583
+ ) -> Any:
584
+ """Fold a list into a value via the evaluator. `body` is a pure ParsedExpr
585
+ node evaluated against `{acc_name: acc, item_name: item}` plus the
586
+ captured free vars in `base_env` per element; `init` seeds the
587
+ accumulator and `direction` is "left" (reduce) or "right" (reduceRight).
588
+ Generalizes bf_reduce -- any reducer body, not just the +/* arithmetic
589
+ catalogue, and acc may appear anywhere. Mirrors Go's FoldEval."""
590
+ arr = list(items) if isinstance(items, list) else []
591
+ if direction == "right":
592
+ arr = list(reversed(arr))
593
+ env = dict(base_env) if base_env else {}
594
+ acc = init
595
+ for item in arr:
596
+ env[acc_name] = acc
597
+ env[item_name] = item
598
+ acc = evaluate(body, env)
599
+ return acc
600
+
601
+
602
+ def sort_by(
603
+ items: Any,
604
+ cmp: Any,
605
+ param_a: str,
606
+ param_b: str,
607
+ base_env: dict | None = None,
608
+ ) -> list:
609
+ """Return a new list ordered by a ParsedExpr comparator `cmp` evaluated
610
+ against `{param_a: a, param_b: b}` plus the captured free vars in
611
+ `base_env`, to a number (negative / zero / positive, like a JS
612
+ comparator). Generalizes bf_sort -- any comparator body. Non-mutating.
613
+ Stable: Python's `sorted` carries a formal stability guarantee, so
614
+ (unlike the Perl port) no explicit original-index tie-break decoration
615
+ is needed. Mirrors Go's SortEval."""
616
+ if not isinstance(items, list):
617
+ return []
618
+ env = dict(base_env) if base_env else {}
619
+
620
+ def cmp_fn(a: Any, b: Any) -> int:
621
+ env[param_a] = a
622
+ env[param_b] = b
623
+ c = _to_number(evaluate(cmp, env))
624
+ # NaN comparator result -> keep order (matches JS + the Go/Perl sign
625
+ # test, which treat a NaN comparator result as "no reordering").
626
+ if c != c:
627
+ return 0
628
+ return -1 if c < 0 else (1 if c > 0 else 0)
629
+
630
+ return sorted(items, key=functools.cmp_to_key(cmp_fn))
631
+
632
+
633
+ def fold_json(
634
+ items: Any,
635
+ body_json: str,
636
+ acc_name: str,
637
+ item_name: str,
638
+ init: Any,
639
+ direction: str = "left",
640
+ base_env: dict | None = None,
641
+ ) -> Any:
642
+ return fold(items, _json.loads(body_json), acc_name, item_name, init, direction, base_env)
643
+
644
+
645
+ def sort_by_json(
646
+ items: Any,
647
+ cmp_json: str,
648
+ param_a: str,
649
+ param_b: str,
650
+ base_env: dict | None = None,
651
+ ) -> list:
652
+ return sort_by(items, _json.loads(cmp_json), param_a, param_b, base_env)
653
+
654
+
655
+ # ---------------------------------------------------------------------------
656
+ # Higher-order predicates (#2018, P2) -- the generalization of bf_filter /
657
+ # bf_find / bf_find_index / bf_every / bf_some onto the evaluator. Each
658
+ # mirrors the corresponding Go helper (FilterEval / EveryEval / SomeEval /
659
+ # FindEval / FindIndexEval).
660
+ # ---------------------------------------------------------------------------
661
+
662
+
663
+ def filter(items: Any, pred: Any, param: str, base_env: dict | None = None) -> list:
664
+ if not isinstance(items, list):
665
+ return []
666
+ env = dict(base_env) if base_env else {}
667
+ out = []
668
+ for item in items:
669
+ env[param] = item
670
+ if _truthy(evaluate(pred, env)):
671
+ out.append(item)
672
+ return out
673
+
674
+
675
+ def every(items: Any, pred: Any, param: str, base_env: dict | None = None) -> bool:
676
+ arr = items if isinstance(items, list) else []
677
+ env = dict(base_env) if base_env else {}
678
+ for item in arr:
679
+ env[param] = item
680
+ if not _truthy(evaluate(pred, env)):
681
+ return False
682
+ return True
683
+
684
+
685
+ def some(items: Any, pred: Any, param: str, base_env: dict | None = None) -> bool:
686
+ arr = items if isinstance(items, list) else []
687
+ env = dict(base_env) if base_env else {}
688
+ for item in arr:
689
+ env[param] = item
690
+ if _truthy(evaluate(pred, env)):
691
+ return True
692
+ return False
693
+
694
+
695
+ def find(
696
+ items: Any, pred: Any, param: str, forward: bool = True, base_env: dict | None = None
697
+ ) -> Any:
698
+ arr = items if isinstance(items, list) else []
699
+ if not forward:
700
+ arr = list(reversed(arr))
701
+ env = dict(base_env) if base_env else {}
702
+ for item in arr:
703
+ env[param] = item
704
+ if _truthy(evaluate(pred, env)):
705
+ return item
706
+ return None
707
+
708
+
709
+ def find_index(
710
+ items: Any, pred: Any, param: str, forward: bool = True, base_env: dict | None = None
711
+ ) -> int:
712
+ arr = items if isinstance(items, list) else []
713
+ env = dict(base_env) if base_env else {}
714
+ idxs = range(len(arr)) if forward else range(len(arr) - 1, -1, -1)
715
+ for i in idxs:
716
+ env[param] = arr[i]
717
+ if _truthy(evaluate(pred, env)):
718
+ return i
719
+ return -1
720
+
721
+
722
+ def flat_map(items: Any, proj: Any, param: str, base_env: dict | None = None) -> list:
723
+ arr = items if isinstance(items, list) else []
724
+ env = dict(base_env) if base_env else {}
725
+ out = []
726
+ for item in arr:
727
+ env[param] = item
728
+ v = evaluate(proj, env)
729
+ if isinstance(v, list):
730
+ out.extend(v)
731
+ else:
732
+ out.append(v)
733
+ return out
734
+
735
+
736
+ def map_items(items: Any, proj: Any, param: str, base_env: dict | None = None) -> list:
737
+ """Value-producing `.map(cb)` (#2073): project each element through
738
+ `proj`, one result per element (no flatten). Named `map_items` (not
739
+ `map`) so the Python builtin stays unshadowed -- mirrors the Perl port's
740
+ `map_items` naming (Perl's rename was to keep its own `map` builtin
741
+ unshadowed; Python has the identical concern for the `map` builtin)."""
742
+ arr = items if isinstance(items, list) else []
743
+ env = dict(base_env) if base_env else {}
744
+ out = []
745
+ for item in arr:
746
+ env[param] = item
747
+ out.append(evaluate(proj, env))
748
+ return out
749
+
750
+
751
+ # ---------------------------------------------------------------------------
752
+ # JSON-string seams -- the adapters emit `bf.filter_eval(recv, '<json>', ...)`;
753
+ # the predicate body arrives as a JSON string here, decoded then handed to
754
+ # the helper above (mirroring fold_json / sort_by_json).
755
+ # ---------------------------------------------------------------------------
756
+
757
+
758
+ def filter_json(items: Any, pred_json: str, param: str, base_env: dict | None = None) -> list:
759
+ return filter(items, _json.loads(pred_json), param, base_env)
760
+
761
+
762
+ def every_json(items: Any, pred_json: str, param: str, base_env: dict | None = None) -> bool:
763
+ return every(items, _json.loads(pred_json), param, base_env)
764
+
765
+
766
+ def some_json(items: Any, pred_json: str, param: str, base_env: dict | None = None) -> bool:
767
+ return some(items, _json.loads(pred_json), param, base_env)
768
+
769
+
770
+ def find_json(
771
+ items: Any, pred_json: str, param: str, forward: bool = True, base_env: dict | None = None
772
+ ) -> Any:
773
+ return find(items, _json.loads(pred_json), param, forward, base_env)
774
+
775
+
776
+ def find_index_json(
777
+ items: Any, pred_json: str, param: str, forward: bool = True, base_env: dict | None = None
778
+ ) -> int:
779
+ return find_index(items, _json.loads(pred_json), param, forward, base_env)
780
+
781
+
782
+ def flat_map_json(items: Any, proj_json: str, param: str, base_env: dict | None = None) -> list:
783
+ return flat_map(items, _json.loads(proj_json), param, base_env)
784
+
785
+
786
+ def map_json(items: Any, proj_json: str, param: str, base_env: dict | None = None) -> list:
787
+ return map_items(items, _json.loads(proj_json), param, base_env)