@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,89 @@
1
+ """Python port of packages/adapter-perl/lib/BarefootJS/SearchParams.pm.
2
+
3
+ Request-scoped SSR view of the query string behind the reactive
4
+ `searchParams()` environment signal. The framework integration builds one
5
+ per request from the request URL and threads it into the template scope as
6
+ `searchParams` (the camelCase JS name the adapters keep, like every other
7
+ signal/prop var); the compiled template reads it via
8
+ `{{ searchParams.get('key') }}`.
9
+
10
+ This runtime is template-engine- and framework-agnostic (stdlib only),
11
+ matching the rest of the `barefootjs` package.
12
+
13
+ Semantics mirror the browser's `URLSearchParams.get` exactly under the
14
+ adapters' `?? -> or` lowering: `get()` returns the first value for a key, or
15
+ `None` when the key is absent. The Jinja/Python lowering of `??` should
16
+ coalesce only `None` (Jinja's `x.get(k) or default` would ALSO coalesce a
17
+ present-but-empty string, which is wrong -- the emitter must use
18
+ `x.get(k); ... if v is not None else default` shaped logic, not a bare
19
+ `or`), preserving the distinction JS `??` draws between `null` and `''`.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from typing import Optional
26
+
27
+
28
+ def _decode(s: Optional[str]) -> str:
29
+ """Percent/`+`-decode a query-string component, mirroring
30
+ `URLSearchParams`'s `application/x-www-form-urlencoded` parsing. Never
31
+ raises on malformed input (lenient parsing, matching the browser)."""
32
+ if s is None:
33
+ s = ""
34
+ s = s.replace("+", " ")
35
+ raw = bytearray()
36
+ i = 0
37
+ n = len(s)
38
+ while i < n:
39
+ ch = s[i]
40
+ if ch == "%" and i + 2 < n and _is_hex_pair(s[i + 1], s[i + 2]):
41
+ raw.append(int(s[i + 1 : i + 3], 16))
42
+ i += 3
43
+ continue
44
+ raw.extend(ch.encode("utf-8"))
45
+ i += 1
46
+ try:
47
+ return raw.decode("utf-8")
48
+ except UnicodeDecodeError:
49
+ # Lenient: a byte run that isn't valid UTF-8 is kept (with
50
+ # replacement chars for the invalid bytes) rather than raising --
51
+ # mirrors Perl's `utf8::decode`, which never dies.
52
+ return raw.decode("utf-8", errors="replace")
53
+
54
+
55
+ def _is_hex_pair(a: str, b: str) -> bool:
56
+ return a in "0123456789ABCDEFabcdef" and b in "0123456789ABCDEFabcdef"
57
+
58
+
59
+ _PAIR_SPLIT_RE = re.compile(r"[&;]")
60
+
61
+
62
+ class SearchParams:
63
+ """new(query=''): parse a raw query string into the reader. A leading
64
+ '?' is tolerated, '+' decodes to a space, and %XX escapes are decoded."""
65
+
66
+ def __init__(self, query: str = ""):
67
+ query = query or ""
68
+ if query.startswith("?"):
69
+ query = query[1:]
70
+ values: dict[str, list[str]] = {}
71
+ for pair in _PAIR_SPLIT_RE.split(query):
72
+ if pair == "":
73
+ continue
74
+ if "=" in pair:
75
+ key, val = pair.split("=", 1)
76
+ else:
77
+ key, val = pair, None
78
+ key = _decode(key)
79
+ val_decoded = _decode(val) if val is not None else ""
80
+ values.setdefault(key, []).append(val_decoded)
81
+ self._values = values
82
+
83
+ def get(self, key: str) -> Optional[str]:
84
+ """First value for `key`, or `None` when the key is absent. A
85
+ present-but-empty value returns ''."""
86
+ vals = self._values.get(key)
87
+ if not vals:
88
+ return None
89
+ return vals[0]
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "barefootjs"
7
+ dynamic = ["version"]
8
+ description = "Python runtime for the @barefootjs/jinja adapter"
9
+ license = {text = "MIT"}
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "kobaken", email = "kentafly88@gmail.com" }]
12
+ keywords = ["jinja", "jinja2", "barefoot", "ssr", "templates"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ ]
22
+ dependencies = ["jinja2>=3.1"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/piconic-ai/barefootjs"
26
+ Repository = "https://github.com/piconic-ai/barefootjs"
27
+
28
+ [tool.setuptools.packages.find]
29
+ include = ["barefootjs*"]
30
+
31
+ [tool.setuptools.dynamic]
32
+ version = {file = "VERSION"}
File without changes
@@ -0,0 +1,88 @@
1
+ """Golden ParsedExpr-evaluator vectors, ported from
2
+ packages/adapter-perl/t/eval_vectors.t.
3
+
4
+ Runs `packages/adapter-tests/vectors/eval-vectors.json` -- generated
5
+ from the JS reference evaluator, shared with the Go and Perl evaluators --
6
+ against `barefootjs.evaluator.evaluate`. The evaluator is JS-faithful by
7
+ contract, so unlike the helper vectors there are NO Python-side
8
+ divergences here: each case's real ParsedExpr tree, evaluated against its
9
+ environment, must reproduce the JS-computed expect exactly.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import unittest
17
+
18
+ from barefootjs import evaluator
19
+
20
+ VECTORS_PATH = os.path.join(
21
+ os.path.dirname(__file__), "..", "..", "..", "adapter-tests", "vectors", "eval-vectors.json"
22
+ )
23
+
24
+
25
+ def _match(got, expect):
26
+ """Spec value-compat comparison -- non-finite sentinel hashes, booleans
27
+ by truthiness (but the evaluator result must ITSELF be a real bool, not
28
+ a truthy int -- matching a boolean-valued JS operator must return a real
29
+ boolean), numbers numerically, arrays/hashes recursively, strings by
30
+ equality."""
31
+ if expect is None:
32
+ return got is None
33
+ if isinstance(expect, dict) and "$num" in expect:
34
+ kind = expect["$num"]
35
+ if isinstance(got, bool) or not isinstance(got, (int, float)):
36
+ return False
37
+ g = float(got)
38
+ if kind == "NaN":
39
+ return g != g
40
+ inf = float("inf")
41
+ return g == (inf if kind == "Infinity" else -inf)
42
+ if isinstance(expect, bool):
43
+ return isinstance(got, bool) and got == expect
44
+ if isinstance(expect, list):
45
+ if not isinstance(got, list) or len(got) != len(expect):
46
+ return False
47
+ return all(_match(g, e) for g, e in zip(got, expect))
48
+ if isinstance(expect, dict):
49
+ if not isinstance(got, dict) or len(got) != len(expect):
50
+ return False
51
+ return all(k in got and _match(got[k], v) for k, v in expect.items())
52
+ if got is None or isinstance(got, (list, dict)):
53
+ return False
54
+ # Numeric comparison only when BOTH are real numbers (not
55
+ # numeric-looking strings) -- e.g. String(42) must return the string
56
+ # "42", and evaluating it as the number 42 must NOT pass.
57
+ want_num = isinstance(expect, (int, float)) and not isinstance(expect, bool)
58
+ got_num = isinstance(got, (int, float)) and not isinstance(got, bool)
59
+ if want_num != got_num:
60
+ return False
61
+ if want_num:
62
+ if isinstance(got, int) and isinstance(expect, int):
63
+ return got == expect
64
+ return float(got) == float(expect)
65
+ return got == expect
66
+
67
+
68
+ @unittest.skipUnless(os.path.exists(VECTORS_PATH), "eval vectors not available outside the monorepo checkout")
69
+ class EvalVectorsTest(unittest.TestCase):
70
+ def test_vectors(self):
71
+ with open(VECTORS_PATH, encoding="utf-8") as fh:
72
+ doc = json.load(fh)
73
+
74
+ self.assertTrue(doc["cases"], "eval-vectors.json contains no cases")
75
+
76
+ for case in doc["cases"]:
77
+ note, expr, env, expect = case["note"], case["expr"], case["env"], case["expect"]
78
+ with self.subTest(note=note):
79
+ try:
80
+ got = evaluator.evaluate(expr, env)
81
+ except Exception as exc: # noqa: BLE001
82
+ self.fail(f"{note} raised: {exc!r}")
83
+ continue
84
+ self.assertTrue(_match(got, expect), f"{note}: got {got!r}, want {expect!r}")
85
+
86
+
87
+ if __name__ == "__main__":
88
+ unittest.main()
@@ -0,0 +1,406 @@
1
+ """Hand-built ParsedExpr evaluator demonstrations, ported from
2
+ packages/adapter-perl/t/evaluator.t.
3
+
4
+ Mirrors the Go/Perl `eval_test.go` / `evaluator.t` demonstrations so all
5
+ three backends prove the SAME restriction-lifting on the SAME shapes (a
6
+ reducer/comparator/predicate body the fixed bf_reduce/bf_sort/bf_filter
7
+ catalogues can't express, but the evaluator handles as just another pure
8
+ expression).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import unittest
15
+
16
+ from barefootjs import evaluator
17
+
18
+
19
+ def nid(name):
20
+ return {"kind": "identifier", "name": name}
21
+
22
+
23
+ def nmem(obj, prop):
24
+ return {"kind": "member", "object": obj, "property": prop, "computed": False}
25
+
26
+
27
+ def nbin(op, left, right):
28
+ return {"kind": "binary", "op": op, "left": left, "right": right}
29
+
30
+
31
+ def nstr(value):
32
+ return {"kind": "literal", "value": value, "literalType": "string"}
33
+
34
+
35
+ def nnum(value):
36
+ return {"kind": "literal", "value": value, "literalType": "number"}
37
+
38
+
39
+ def ncall_math(fn, arg):
40
+ return {"kind": "call", "callee": nmem(nid("Math"), fn), "args": [arg]}
41
+
42
+
43
+ def nincludes(obj, needle):
44
+ return {"kind": "array-method", "method": "includes", "object": obj, "args": [needle]}
45
+
46
+
47
+ class EvaluatorTest(unittest.TestCase):
48
+ def test_fold_arbitrary_reducer_body(self):
49
+ # acc + item.price * item.qty
50
+ body = nbin("+", nid("acc"), nbin("*", nmem(nid("item"), "price"), nmem(nid("item"), "qty")))
51
+ items = [{"price": 5, "qty": 3}, {"price": 2, "qty": 4}]
52
+ self.assertEqual(evaluator.fold(items, body, "acc", "item", 0, "left"), 23)
53
+
54
+ def test_fold_direction_observable_for_string_concat(self):
55
+ body = nbin("+", nid("acc"), nid("item"))
56
+ items = ["a", "b", "c"]
57
+ self.assertEqual(evaluator.fold(items, body, "acc", "item", "", "left"), "abc")
58
+ self.assertEqual(evaluator.fold(items, body, "acc", "item", "", "right"), "cba")
59
+
60
+ def test_sort_by_arbitrary_comparator_abs_of_field_difference(self):
61
+ cmp = nbin("-", ncall_math("abs", nmem(nid("a"), "v")), ncall_math("abs", nmem(nid("b"), "v")))
62
+ items = [{"v": -5}, {"v": 3}, {"v": -1}]
63
+ sorted_items = evaluator.sort_by(items, cmp, "a", "b")
64
+ self.assertEqual([x["v"] for x in sorted_items], [-1, 3, -5])
65
+
66
+ def test_sort_by_descending_via_reversed_comparator(self):
67
+ cmp = nbin("-", nmem(nid("b"), "x"), nmem(nid("a"), "x"))
68
+ items = [{"x": 10}, {"x": 30}, {"x": 20}]
69
+ sorted_items = evaluator.sort_by(items, cmp, "a", "b")
70
+ self.assertEqual([x["x"] for x in sorted_items], [30, 20, 10])
71
+
72
+ def test_nonfinite_division_and_js_stringification(self):
73
+ def div(a, b):
74
+ return evaluator.evaluate(nbin("/", nid("a"), nid("b")), {"a": a, "b": b})
75
+
76
+ inf = float("inf")
77
+ self.assertEqual(div(1, 0), inf)
78
+ self.assertEqual(div(-1, 0), -inf)
79
+ nan = div(0, 0)
80
+ self.assertNotEqual(nan, nan)
81
+
82
+ self.assertEqual(evaluator._to_string(inf), "Infinity")
83
+ self.assertEqual(evaluator._to_string(-inf), "-Infinity")
84
+ self.assertEqual(evaluator._to_string(inf - inf), "NaN")
85
+
86
+ def test_captured_free_vars_via_base_env(self):
87
+ body = nbin("+", nid("acc"), nbin("*", nid("item"), nid("factor")))
88
+ total = evaluator.fold([1, 2, 3], body, "acc", "item", 0, "left", {"factor": 10})
89
+ self.assertEqual(total, 60)
90
+
91
+ cmp = nbin(
92
+ "-",
93
+ ncall_math("abs", nbin("-", nid("a"), nid("pivot"))),
94
+ ncall_math("abs", nbin("-", nid("b"), nid("pivot"))),
95
+ )
96
+ sorted_items = evaluator.sort_by([1, 8, 4], cmp, "a", "b", {"pivot": 5})
97
+ self.assertEqual(sorted_items, [4, 8, 1])
98
+
99
+ def test_boolean_valued_ops_return_real_booleans(self):
100
+ lt = evaluator.evaluate(nbin("<", nid("a"), nid("b")), {"a": 1, "b": 2})
101
+ self.assertIsInstance(lt, bool)
102
+ self.assertEqual(evaluator._to_string(lt), "true")
103
+
104
+ cat = evaluator.evaluate(nbin("+", nstr("x"), nbin("<", nid("a"), nid("b"))), {"a": 1, "b": 2})
105
+ self.assertEqual(cat, "xtrue")
106
+
107
+ eq = evaluator.evaluate(nbin("===", nid("a"), nid("b")), {"a": 1, "b": 1})
108
+ self.assertIsInstance(eq, bool)
109
+
110
+ not_ = evaluator.evaluate({"kind": "unary", "op": "!", "argument": nstr("")}, {})
111
+ self.assertEqual(evaluator._to_string(not_), "true")
112
+
113
+ b = evaluator.evaluate({"kind": "call", "callee": nid("Boolean"), "args": [nstr("")]}, {})
114
+ self.assertIsInstance(b, bool)
115
+ self.assertEqual(evaluator._to_string(b), "false")
116
+
117
+ # `.length` is a string/array property only; a numeric scalar has none.
118
+ length = evaluator.evaluate(nmem(nid("n"), "length"), {"n": 123})
119
+ self.assertIsNone(length)
120
+
121
+ def test_array_method_includes(self):
122
+ # `.includes` (#2075) is the one `array-method` in the evaluator
123
+ # subset, dispatching on the receiver type like the SSR template
124
+ # lowering does at runtime (`bf.includes`): array -> SameValueZero
125
+ # membership (the same value rules as `===`, so a numeric 2 does NOT
126
+ # match the string "2"); string -> substring search; anything else
127
+ # degrades to false rather than raising.
128
+ hit = evaluator.evaluate(nincludes(nid("tags"), nstr("go")), {"tags": ["perl", "go"]})
129
+ self.assertIsInstance(hit, bool)
130
+ self.assertTrue(hit)
131
+
132
+ miss = evaluator.evaluate(nincludes(nid("tags"), nstr("rust")), {"tags": ["perl", "go"]})
133
+ self.assertIsInstance(miss, bool)
134
+ self.assertFalse(miss)
135
+
136
+ # SameValueZero, not loose equality: the numeric element 2 matches
137
+ # the numeric needle 2, but the string needle "2" (a different JS
138
+ # type) does not -- mirroring `===`'s type-sensitivity.
139
+ num_hit = evaluator.evaluate(nincludes(nid("nums"), nnum(2)), {"nums": [1, 2, 3]})
140
+ self.assertTrue(num_hit)
141
+ num_vs_string = evaluator.evaluate(nincludes(nid("nums"), nstr("2")), {"nums": [1, 2, 3]})
142
+ self.assertFalse(num_vs_string)
143
+
144
+ sub = evaluator.evaluate(nincludes(nid("name"), nstr("ar")), {"name": "bare"})
145
+ self.assertTrue(sub)
146
+
147
+ # A non-array, non-string receiver (number, null, object) is not a
148
+ # JS `.includes` target; the evaluator degrades to false rather than
149
+ # raising.
150
+ scalar_recv = evaluator.evaluate(nincludes(nid("n"), nnum(1)), {"n": 42})
151
+ self.assertFalse(scalar_recv)
152
+ null_recv = evaluator.evaluate(nincludes(nid("n"), nstr("x")), {"n": None})
153
+ self.assertFalse(null_recv)
154
+
155
+ def test_sort_by_non_array_receiver_returns_empty_list(self):
156
+ cmp = nbin("-", nid("a"), nid("b"))
157
+ self.assertEqual(evaluator.sort_by(None, cmp, "a", "b"), [])
158
+ self.assertEqual(evaluator.sort_by(42, cmp, "a", "b"), [])
159
+
160
+ def test_sort_by_is_stable_for_equal_keys(self):
161
+ cmp = nbin("-", nmem(nid("a"), "k"), nmem(nid("b"), "k"))
162
+ eq = evaluator.sort_by(
163
+ [{"k": 1, "id": "a"}, {"k": 1, "id": "b"}, {"k": 1, "id": "c"}], cmp, "a", "b"
164
+ )
165
+ self.assertEqual([x["id"] for x in eq], ["a", "b", "c"])
166
+
167
+ mixed = evaluator.sort_by(
168
+ [{"k": 2, "id": "x"}, {"k": 1, "id": "y"}, {"k": 2, "id": "z"}], cmp, "a", "b"
169
+ )
170
+ self.assertEqual([x["id"] for x in mixed], ["y", "x", "z"])
171
+
172
+ def test_fold_json_and_sort_by_json_decode_and_evaluate(self):
173
+ rows = [{"duration": 95}, {"duration": 213}, {"duration": 185}]
174
+
175
+ reduce_body = json.dumps(nbin("+", nid("sum"), nmem(nid("t"), "duration")))
176
+ self.assertEqual(evaluator.fold_json(rows, reduce_body, "sum", "t", 0, "left", {}), 493)
177
+
178
+ labels = [{"label": "a"}, {"label": "b"}, {"label": "c"}]
179
+ concat_body = json.dumps(nbin("+", nid("acc"), nmem(nid("x"), "label")))
180
+ self.assertEqual(evaluator.fold_json(labels, concat_body, "acc", "x", "", "left", {}), "abc")
181
+ self.assertEqual(evaluator.fold_json(labels, concat_body, "acc", "x", "", "right", {}), "cba")
182
+
183
+ cmp_json = json.dumps(nbin("-", nmem(nid("a"), "duration"), nmem(nid("b"), "duration")))
184
+ sorted_rows = evaluator.sort_by_json(rows, cmp_json, "a", "b", {})
185
+ self.assertEqual([r["duration"] for r in sorted_rows], [95, 185, 213])
186
+
187
+ def test_filter_every_some_find_find_index_over_predicate(self):
188
+ rows = [{"age": 15}, {"age": 30}, {"age": 18}]
189
+ pred = nbin(">=", nmem(nid("u"), "age"), nnum(18))
190
+
191
+ f = evaluator.filter(rows, pred, "u")
192
+ self.assertEqual([r["age"] for r in f], [30, 18])
193
+
194
+ self.assertTrue(evaluator.some(rows, pred, "u"))
195
+ self.assertFalse(evaluator.every(rows, pred, "u"))
196
+
197
+ self.assertEqual(evaluator.find(rows, pred, "u", True)["age"], 30)
198
+ self.assertEqual(evaluator.find(rows, pred, "u", False)["age"], 18)
199
+ self.assertEqual(evaluator.find_index(rows, pred, "u", True), 1)
200
+ self.assertEqual(evaluator.find_index(rows, pred, "u", False), 2)
201
+
202
+ self.assertTrue(evaluator.every([], pred, "u"))
203
+ self.assertFalse(evaluator.some([], pred, "u"))
204
+ self.assertIsNone(evaluator.find([], pred, "u"))
205
+ self.assertEqual(evaluator.find_index([], pred, "u"), -1)
206
+
207
+ pred_json = json.dumps(pred)
208
+ fj = evaluator.filter_json(rows, pred_json, "u")
209
+ self.assertEqual([r["age"] for r in fj], [30, 18])
210
+ self.assertFalse(evaluator.every_json(rows, pred_json, "u"))
211
+ self.assertTrue(evaluator.some_json(rows, pred_json, "u"))
212
+ self.assertEqual(evaluator.find_json(rows, pred_json, "u", True)["age"], 30)
213
+ self.assertEqual(evaluator.find_index_json(rows, pred_json, "u", False), 2)
214
+
215
+ cap = nbin(">=", nmem(nid("u"), "age"), nid("threshold"))
216
+ hi = evaluator.filter(rows, cap, "u", {"threshold": 18})
217
+ lo = evaluator.filter(rows, cap, "u", {"threshold": 100})
218
+ self.assertEqual(len(hi), 2)
219
+ self.assertEqual(len(lo), 0)
220
+ self.assertEqual(evaluator.find_index(rows, cap, "u", True, {"threshold": 100}), -1)
221
+
222
+ def test_flat_map_projects_and_flattens_one_level(self):
223
+ rows = [{"tags": ["a", "b"]}, {"tags": ["c"]}]
224
+ field = nmem(nid("i"), "tags")
225
+ self.assertEqual(evaluator.flat_map(rows, field, "i"), ["a", "b", "c"])
226
+
227
+ pts = [{"x": 1, "y": 2}, {"x": 3, "y": 4}]
228
+ tuple_proj = {
229
+ "kind": "array-literal",
230
+ "elements": [nmem(nid("p"), "x"), nmem(nid("p"), "y")],
231
+ }
232
+ self.assertEqual(evaluator.flat_map(pts, tuple_proj, "p"), [1, 2, 3, 4])
233
+
234
+ fj = evaluator.flat_map_json(rows, json.dumps(field), "i")
235
+ self.assertEqual(fj, ["a", "b", "c"])
236
+
237
+ def test_map_items_projects_one_result_per_element_no_flatten(self):
238
+ tmpl = {
239
+ "kind": "template-literal",
240
+ "parts": [
241
+ {"type": "string", "value": "#"},
242
+ {"type": "expression", "expr": nid("t")},
243
+ ],
244
+ }
245
+ self.assertEqual(evaluator.map_items(["perl", "go"], tmpl, "t"), ["#perl", "#go"])
246
+
247
+ users = [{"name": "Ada"}, {"name": "Grace"}]
248
+ field = nmem(nid("u"), "name")
249
+ self.assertEqual(evaluator.map_items(users, field, "u"), ["Ada", "Grace"])
250
+
251
+ rows = [{"tags": ["a", "b"]}]
252
+ self.assertEqual(evaluator.map_items(rows, nmem(nid("i"), "tags"), "i"), [["a", "b"]])
253
+
254
+ mj = evaluator.map_json(users, json.dumps(field), "u")
255
+ self.assertEqual(mj, ["Ada", "Grace"])
256
+
257
+ def test_nested_map_call_inside_a_callback_body(self):
258
+ # #2094: a `.map(cb)` call nested INSIDE a callback body the
259
+ # evaluator is already given, e.g. a `.flatMap(p => p.tags.map(t =>
260
+ # '#'+t))` projection (the #1938 blog-showcase shape). The inner
261
+ # `.map` is a `call` node whose callee is `{kind: 'member', property:
262
+ # 'map', computed: false}` and whose first arg is an `arrow` node.
263
+ prefix_tmpl = {
264
+ "kind": "template-literal",
265
+ "parts": [
266
+ {"type": "string", "value": "#"},
267
+ {"type": "expression", "expr": nid("t")},
268
+ ],
269
+ }
270
+ inner_map = {
271
+ "kind": "call",
272
+ "callee": nmem(nmem(nid("p"), "tags"), "map"),
273
+ "args": [{"kind": "arrow", "params": ["t"], "body": prefix_tmpl}],
274
+ }
275
+ self.assertEqual(
276
+ evaluator.evaluate(inner_map, {"p": {"tags": ["a", "b"]}}),
277
+ ["#a", "#b"],
278
+ )
279
+
280
+ # 2-param arrow (value, index).
281
+ idx_body = nbin("+", nid("t"), ncall_math("abs", nid("i")))
282
+ inner_map_idx = {
283
+ "kind": "call",
284
+ "callee": nmem(nid("xs"), "map"),
285
+ "args": [{"kind": "arrow", "params": ["t", "i"], "body": idx_body}],
286
+ }
287
+ self.assertEqual(evaluator.evaluate(inner_map_idx, {"xs": [10, 20]}), [10, 21])
288
+
289
+ # doubly-nested .map + .join: posts.flatMap(p => p.tags.map(t =>
290
+ # '#'+t).join(', ')) -- the #1938 blog-showcase shape, one level
291
+ # flattened by the outer `flat_map` composition.
292
+ join_of_map = {
293
+ "kind": "array-method",
294
+ "method": "join",
295
+ "object": inner_map,
296
+ "args": [nstr(", ")],
297
+ }
298
+ self.assertEqual(
299
+ evaluator.evaluate(join_of_map, {"p": {"tags": ["a", "b"]}}), "#a, #b"
300
+ )
301
+ posts = [{"tags": ["a", "b"]}, {"tags": ["c"]}]
302
+ proj = {
303
+ "kind": "array-method",
304
+ "method": "join",
305
+ "object": {
306
+ "kind": "call",
307
+ "callee": nmem(nmem(nid("p"), "tags"), "map"),
308
+ "args": [{"kind": "arrow", "params": ["t"], "body": prefix_tmpl}],
309
+ },
310
+ "args": [nstr(", ")],
311
+ }
312
+ self.assertEqual(evaluator.flat_map(posts, proj, "p"), ["#a, #b", "#c"])
313
+
314
+ def test_nested_callback_non_list_receiver_evaluates_to_none(self):
315
+ # Cross-runtime contract (Copilot review on #2095): a nested
316
+ # `.map`/`.filter` whose receiver is not an array evaluates to
317
+ # None, matching Go nil / Perl undef / Rust Null / PHP null /
318
+ # Ruby nil -- never an empty list, so "missing" stays
319
+ # distinguishable from "empty array".
320
+ inner_map = {
321
+ "kind": "call",
322
+ "callee": nmem(nmem(nid("p"), "tags"), "map"),
323
+ "args": [{"kind": "arrow", "params": ["t"], "body": nid("t")}],
324
+ }
325
+ self.assertIsNone(evaluator.evaluate(inner_map, {"p": {}}))
326
+ self.assertIsNone(evaluator.evaluate(inner_map, {"p": {"tags": "not-a-list"}}))
327
+ self.assertEqual(evaluator.evaluate(inner_map, {"p": {"tags": []}}), [])
328
+
329
+ def test_nested_filter_call_inside_a_callback_body(self):
330
+ # #2094: a `.filter(cb)` call nested inside a callback body,
331
+ # composed with `.length` and a relational comparison (the doc /
332
+ # #2038 motivating shape): `.filter(u => u.active).length > 0`.
333
+ inner_filter = {
334
+ "kind": "call",
335
+ "callee": nmem(nid("users"), "filter"),
336
+ "args": [
337
+ {
338
+ "kind": "arrow",
339
+ "params": ["u"],
340
+ "body": nmem(nid("u"), "active"),
341
+ }
342
+ ],
343
+ }
344
+ composed = nbin(">", nmem(inner_filter, "length"), nnum(0))
345
+ self.assertTrue(
346
+ evaluator.evaluate(
347
+ composed,
348
+ {"users": [{"active": True}, {"active": False}]},
349
+ )
350
+ )
351
+ self.assertFalse(
352
+ evaluator.evaluate(
353
+ composed,
354
+ {"users": [{"active": False}, {"active": False}]},
355
+ )
356
+ )
357
+
358
+ # predicate with comparison + logical operators.
359
+ pred_body = {
360
+ "kind": "logical",
361
+ "op": "&&",
362
+ "left": nbin(">=", nmem(nid("u"), "age"), nnum(18)),
363
+ "right": nmem(nid("u"), "active"),
364
+ }
365
+ inner_filter2 = {
366
+ "kind": "call",
367
+ "callee": nmem(nid("users"), "filter"),
368
+ "args": [{"kind": "arrow", "params": ["u"], "body": pred_body}],
369
+ }
370
+ rows = [{"age": 20, "active": True}, {"age": 15, "active": True}, {"age": 40, "active": False}]
371
+ self.assertEqual(
372
+ [r["age"] for r in evaluator.evaluate(inner_filter2, {"users": rows})], [20]
373
+ )
374
+
375
+ def test_array_method_join(self):
376
+ # #2094: `.join(sep?)` as an `array-method` node, sharing the shape
377
+ # with `.includes` above. Default separator is `,`; a `None`
378
+ # (null/undefined) element joins as `''`, NOT the string "null"
379
+ # (which is what a bare ToString call on `None` produces).
380
+ def njoin(obj, sep=None):
381
+ node = {"kind": "array-method", "method": "join", "object": obj}
382
+ if sep is not None:
383
+ node["args"] = [sep]
384
+ return node
385
+
386
+ self.assertEqual(
387
+ evaluator.evaluate(njoin(nid("xs"), nstr("-")), {"xs": ["a", "b", "c"]}), "a-b-c"
388
+ )
389
+ self.assertEqual(
390
+ evaluator.evaluate(njoin(nid("xs")), {"xs": ["a", "b"]}), "a,b"
391
+ )
392
+ self.assertEqual(evaluator.evaluate(njoin(nid("xs")), {"xs": []}), "")
393
+ self.assertEqual(
394
+ evaluator.evaluate(njoin(nid("xs")), {"xs": ["a", None, "b"]}), "a,,b"
395
+ )
396
+
397
+ def test_length_member_read_on_arrays_and_strings(self):
398
+ # `.length` on both arrays and strings, needed for a composed case
399
+ # (`.filter(...).length > 0`); already handled by `_read_property`.
400
+ self.assertEqual(evaluator.evaluate(nmem(nid("xs"), "length"), {"xs": [1, 2, 3]}), 3)
401
+ self.assertEqual(evaluator.evaluate(nmem(nid("s"), "length"), {"s": "abc"}), 3)
402
+ self.assertEqual(evaluator.evaluate(nmem(nid("xs"), "length"), {"xs": []}), 0)
403
+
404
+
405
+ if __name__ == "__main__":
406
+ unittest.main()