@barefootjs/rust 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 (87) hide show
  1. package/README.md +194 -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 +85 -0
  5. package/dist/adapter/boolean-result.d.ts.map +1 -0
  6. package/dist/adapter/emit-context.d.ts +107 -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/index.d.ts +6 -0
  13. package/dist/adapter/index.d.ts.map +1 -0
  14. package/dist/adapter/index.js +189091 -0
  15. package/dist/adapter/lib/constants.d.ts +25 -0
  16. package/dist/adapter/lib/constants.d.ts.map +1 -0
  17. package/dist/adapter/lib/ir-scope.d.ts +50 -0
  18. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  19. package/dist/adapter/lib/minijinja-naming.d.ts +64 -0
  20. package/dist/adapter/lib/minijinja-naming.d.ts.map +1 -0
  21. package/dist/adapter/lib/types.d.ts +32 -0
  22. package/dist/adapter/lib/types.d.ts.map +1 -0
  23. package/dist/adapter/memo/seed.d.ts +84 -0
  24. package/dist/adapter/memo/seed.d.ts.map +1 -0
  25. package/dist/adapter/minijinja-adapter.d.ts +421 -0
  26. package/dist/adapter/minijinja-adapter.d.ts.map +1 -0
  27. package/dist/adapter/props/prop-classes.d.ts +33 -0
  28. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  29. package/dist/adapter/spread/spread-codegen.d.ts +63 -0
  30. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  31. package/dist/adapter/value/parsed-literal.d.ts +28 -0
  32. package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
  33. package/dist/build.d.ts +29 -0
  34. package/dist/build.d.ts.map +1 -0
  35. package/dist/build.js +189111 -0
  36. package/dist/conformance-pins.d.ts +13 -0
  37. package/dist/conformance-pins.d.ts.map +1 -0
  38. package/dist/index.d.ts +12 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +189112 -0
  41. package/package.json +67 -0
  42. package/runtime/Cargo.lock +124 -0
  43. package/runtime/Cargo.toml +21 -0
  44. package/runtime/src/backend_minijinja.rs +176 -0
  45. package/runtime/src/bin/bf-render.rs +147 -0
  46. package/runtime/src/evaluator.rs +770 -0
  47. package/runtime/src/lib.rs +19 -0
  48. package/runtime/src/manifest.rs +258 -0
  49. package/runtime/src/num.rs +558 -0
  50. package/runtime/src/runtime.rs +1548 -0
  51. package/runtime/src/search_params.rs +173 -0
  52. package/runtime/tests/eval_vectors.rs +94 -0
  53. package/runtime/tests/evaluator.rs +407 -0
  54. package/runtime/tests/helper_vectors.rs +348 -0
  55. package/runtime/tests/manifest.rs +169 -0
  56. package/runtime/tests/omit.rs +79 -0
  57. package/runtime/tests/props_attr.rs +75 -0
  58. package/runtime/tests/query.rs +50 -0
  59. package/runtime/tests/render_child.rs +210 -0
  60. package/runtime/tests/search_params.rs +68 -0
  61. package/runtime/tests/spread_attrs.rs +94 -0
  62. package/runtime/tests/template_primitives.rs +376 -0
  63. package/runtime/tests/vector-divergences.json +33 -0
  64. package/src/__tests__/minijinja-adapter-unit.test.ts +392 -0
  65. package/src/__tests__/minijinja-adapter.test.ts +58 -0
  66. package/src/__tests__/minijinja-counter.test.ts +61 -0
  67. package/src/__tests__/minijinja-query-href.test.ts +101 -0
  68. package/src/__tests__/minijinja-spread-attrs.test.ts +227 -0
  69. package/src/adapter/analysis/component-tree.ts +119 -0
  70. package/src/adapter/boolean-result.ts +177 -0
  71. package/src/adapter/emit-context.ts +119 -0
  72. package/src/adapter/expr/array-method.ts +346 -0
  73. package/src/adapter/expr/emitters.ts +608 -0
  74. package/src/adapter/index.ts +6 -0
  75. package/src/adapter/lib/constants.ts +37 -0
  76. package/src/adapter/lib/ir-scope.ts +95 -0
  77. package/src/adapter/lib/minijinja-naming.ts +85 -0
  78. package/src/adapter/lib/types.ts +35 -0
  79. package/src/adapter/memo/seed.ts +135 -0
  80. package/src/adapter/minijinja-adapter.ts +1796 -0
  81. package/src/adapter/props/prop-classes.ts +65 -0
  82. package/src/adapter/spread/spread-codegen.ts +168 -0
  83. package/src/adapter/value/parsed-literal.ts +76 -0
  84. package/src/build.ts +38 -0
  85. package/src/conformance-pins.ts +101 -0
  86. package/src/index.ts +12 -0
  87. package/src/test-render.ts +680 -0
@@ -0,0 +1,558 @@
1
+ //! Centralized value representation, i64/u64/f64 unification, and
2
+ //! JS-number semantics.
3
+ //!
4
+ //! Port of the numeric primitives scattered across
5
+ //! `packages/adapter-jinja/python/barefootjs/runtime.py` (`_format_js_number`,
6
+ //! `js_number`, `.mod`, `.floor`/`.ceil`/`.round`, `.to_fixed`) and
7
+ //! `packages/adapter-jinja/python/barefootjs/evaluator.py` (`_to_number`,
8
+ //! `_format_number`, `_strict_eq`, `_same_value_zero`, JS `/` and `%`
9
+ //! special-casing). Both `runtime.rs` and `evaluator.rs` route EVERY
10
+ //! number-shaped operation through here so there is exactly one place that
11
+ //! understands number unification and exactly one JS-`Number`-to-string
12
+ //! formatter.
13
+ //!
14
+ //! ## `JsValue`: why not bare `serde_json::Value`
15
+ //!
16
+ //! The design contract describes the evaluator as operating "over
17
+ //! `serde_json::Value`". That is true at the JSON boundary (ParsedExpr
18
+ //! trees parsed from JSON, `bf-render`'s payload vars, `encode_json`'s
19
+ //! output) but CANNOT be literal for values flowing *through* evaluation:
20
+ //! `serde_json::Value::Number` is structurally incapable of holding NaN or
21
+ //! +/-Infinity (`serde_json::Number::from_f64` returns `None` for
22
+ //! non-finite input, and `Value::from(f64::NAN)` silently becomes
23
+ //! `Value::Null` -- JSON has no non-finite numeric literal, by design).
24
+ //! Python's port has no such gap (`float('nan')` is a first-class Python
25
+ //! value that flows through dict/list/env storage unchanged), and the
26
+ //! golden vectors require the SAME fidelity: a division by zero, a failed
27
+ //! `Number("nan")` coercion, or `Infinity - Infinity` inside a `fold`/`sort`
28
+ //! body must survive as a real NaN/Infinity through env storage, array
29
+ //! construction, and comparison -- not collapse to `null` mid-expression.
30
+ //!
31
+ //! `JsValue` closes that gap: it is a `serde_json::Value`-shaped enum whose
32
+ //! `Number` variant is a bare `f64` (so it can hold NaN/Infinity natively),
33
+ //! used as the ONE internal working value type for both `evaluator.rs` and
34
+ //! `runtime.rs`. It converts losslessly to/from `serde_json::Value` on the
35
+ //! way IN (JSON can't spell non-finite literals, so there's nothing to
36
+ //! lose) and lossily (non-finite -> `null`, recursively) on the way OUT via
37
+ //! [`JsValue::to_json`] -- which is exactly `encode_json`'s documented
38
+ //! `_prepare_for_json` contract, so that conversion is implemented ONCE,
39
+ //! here, and reused by `backend_minijinja::encode_json`.
40
+ //!
41
+ //! This is flagged as a considered, necessary deviation from the design
42
+ //! doc's literal wording, not a scope expansion: no new module was added
43
+ //! (this type lives in `num.rs`, the file already chartered to own
44
+ //! "ALL i64/u64/f64 unification"), and every other file's shape is
45
+ //! unchanged.
46
+ //!
47
+ //! ## Divergences that VANISH in this backend
48
+ //!
49
+ //! Two Python-only workarounds disappear here because Rust's `f64` already
50
+ //! implements IEEE-754 semantics natively (Python's `float` does NOT:
51
+ //! `1.0 / 0.0` raises `ZeroDivisionError`, `math.fmod(x, 0)` raises
52
+ //! `ValueError`, `math.floor(nan)` raises `ValueError`):
53
+ //!
54
+ //! * JS `/` needs NO manual zero-divisor special-casing (unlike
55
+ //! `evaluator.py`'s `_binary` `"/"` arm) -- plain `l / r` on `f64`
56
+ //! already yields `+Infinity` / `-Infinity` / `NaN` exactly like JS.
57
+ //! * JS `%` needs NO manual zero-divisor special-casing (unlike
58
+ //! `runtime.py`'s `mod` / `evaluator.py`'s `_binary` `"%"` arm) --
59
+ //! Rust's `%` operator on `f64` IS C `fmod` (remainder with the
60
+ //! dividend's sign), and `x % 0.0` is already `NaN` under IEEE-754.
61
+ //! * `Math.floor` / `Math.ceil` / `Math.round` need NO NaN/Infinity guard
62
+ //! (unlike the Python port's `_is_nan`/`_is_inf` checks, needed only
63
+ //! because `math.floor(float('nan'))` raises in Python) -- `f64::floor`
64
+ //! / `f64::ceil` pass NaN/Infinity through unchanged per IEEE-754.
65
+ //!
66
+ //! See `tests/vector-divergences.json` for where this removes Python-only
67
+ //! divergence declarations (`add/beyond the safe-integer edge...`,
68
+ //! `div/zero divisor yields Infinity`) -- those two keys simply have no
69
+ //! entry in this backend's declaration file.
70
+
71
+ use serde_json::Value as JsonValue;
72
+ use std::collections::BTreeMap;
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // The internal JS-value domain. See the module docstring for why this
76
+ // exists instead of using `serde_json::Value` directly for in-flight
77
+ // evaluation results.
78
+ // ---------------------------------------------------------------------------
79
+
80
+ #[derive(Debug, Clone, PartialEq)]
81
+ pub enum JsValue {
82
+ Null,
83
+ Bool(bool),
84
+ /// Always `f64` -- JS has exactly one number type, and unlike
85
+ /// `serde_json::Value::Number` this CAN hold NaN / +Infinity /
86
+ /// -Infinity (see module docstring).
87
+ Number(f64),
88
+ String(String),
89
+ Array(Vec<JsValue>),
90
+ /// `BTreeMap` (not insertion-order) is deliberate: nothing in the
91
+ /// ParsedExpr subset or the runtime helper catalogue depends on JS
92
+ /// object key insertion order, and a sorted map gives `encode_json`
93
+ /// (`bf.json` / `bf-p` / `bf-scope`) its "canonical sorted keys"
94
+ /// contract for free, in one place, rather than re-sorting at the
95
+ /// JSON-encoding boundary.
96
+ Object(BTreeMap<String, JsValue>),
97
+ }
98
+
99
+ impl JsValue {
100
+ pub const fn null() -> JsValue {
101
+ JsValue::Null
102
+ }
103
+
104
+ pub fn as_str(&self) -> Option<&str> {
105
+ match self {
106
+ JsValue::String(s) => Some(s.as_str()),
107
+ _ => None,
108
+ }
109
+ }
110
+
111
+ pub fn as_array(&self) -> Option<&[JsValue]> {
112
+ match self {
113
+ JsValue::Array(a) => Some(a.as_slice()),
114
+ _ => None,
115
+ }
116
+ }
117
+
118
+ pub fn as_object(&self) -> Option<&BTreeMap<String, JsValue>> {
119
+ match self {
120
+ JsValue::Object(o) => Some(o),
121
+ _ => None,
122
+ }
123
+ }
124
+
125
+ pub fn as_bool(&self) -> Option<bool> {
126
+ match self {
127
+ JsValue::Bool(b) => Some(*b),
128
+ _ => None,
129
+ }
130
+ }
131
+
132
+ /// Convert a decoded JSON document into the working value domain.
133
+ /// Lossless: JSON cannot spell a non-finite literal, so there is
134
+ /// nothing to collapse on the way in.
135
+ pub fn from_json(v: &JsonValue) -> JsValue {
136
+ match v {
137
+ JsonValue::Null => JsValue::Null,
138
+ JsonValue::Bool(b) => JsValue::Bool(*b),
139
+ JsonValue::Number(n) => JsValue::Number(n.as_f64().unwrap_or(f64::NAN)),
140
+ JsonValue::String(s) => JsValue::String(s.clone()),
141
+ JsonValue::Array(a) => JsValue::Array(a.iter().map(JsValue::from_json).collect()),
142
+ JsonValue::Object(o) => {
143
+ JsValue::Object(o.iter().map(|(k, v)| (k.clone(), JsValue::from_json(v))).collect())
144
+ }
145
+ }
146
+ }
147
+
148
+ /// Convert to a JSON document. Non-finite numbers become `null`,
149
+ /// recursively, at ANY depth -- this IS `encode_json`'s
150
+ /// `_prepare_for_json` contract (see `backend_minijinja::encode_json`,
151
+ /// which calls this and then `serde_json::to_string`).
152
+ pub fn to_json(&self) -> JsonValue {
153
+ match self {
154
+ JsValue::Null => JsonValue::Null,
155
+ JsValue::Bool(b) => JsonValue::Bool(*b),
156
+ JsValue::Number(n) => number_to_json(*n),
157
+ JsValue::String(s) => JsonValue::String(s.clone()),
158
+ JsValue::Array(a) => JsonValue::Array(a.iter().map(JsValue::to_json).collect()),
159
+ JsValue::Object(o) => {
160
+ JsonValue::Object(o.iter().map(|(k, v)| (k.clone(), v.to_json())).collect())
161
+ }
162
+ }
163
+ }
164
+ }
165
+
166
+ /// `f64` -> `serde_json::Value::Number`, matching JS `JSON.stringify`'s
167
+ /// integral-number spelling (`42`, never `42.0`) for the common case
168
+ /// (whole numbers within `i64`/`u64` range spell as a JSON integer;
169
+ /// everything else -- fractional, huge magnitude, or non-finite -- goes
170
+ /// through `Number::from_f64`, with non-finite collapsing to `null`).
171
+ /// `serde_json::Number::from_f64` alone is NOT sufficient here: it always
172
+ /// produces a JSON float token (`42.0`), which is JS-INCORRECT for a whole
173
+ /// number (`JSON.stringify(42) === "42"`, not `"42.0"`).
174
+ fn number_to_json(n: f64) -> JsonValue {
175
+ // `i64` range covers every practical prop/index/count value; `as i64`
176
+ // is exact here because `fract() == 0.0` already guarantees no
177
+ // fractional bits, and -0.0 casts to plain 0.
178
+ if n.is_finite() && n.fract() == 0.0 && n.abs() < 9_223_372_036_854_775_807.0 {
179
+ return JsonValue::Number(serde_json::Number::from(n as i64));
180
+ }
181
+ match serde_json::Number::from_f64(n) {
182
+ Some(num) => JsonValue::Number(num),
183
+ None => JsonValue::Null, // NaN / +-Infinity
184
+ }
185
+ }
186
+
187
+ impl From<f64> for JsValue {
188
+ fn from(n: f64) -> Self {
189
+ JsValue::Number(n)
190
+ }
191
+ }
192
+
193
+ impl From<bool> for JsValue {
194
+ fn from(b: bool) -> Self {
195
+ JsValue::Bool(b)
196
+ }
197
+ }
198
+
199
+ impl From<String> for JsValue {
200
+ fn from(s: String) -> Self {
201
+ JsValue::String(s)
202
+ }
203
+ }
204
+
205
+ impl From<&str> for JsValue {
206
+ fn from(s: &str) -> Self {
207
+ JsValue::String(s.to_string())
208
+ }
209
+ }
210
+
211
+ impl From<Vec<JsValue>> for JsValue {
212
+ fn from(a: Vec<JsValue>) -> Self {
213
+ JsValue::Array(a)
214
+ }
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Numeric-string grammar (mirrors Python's `looks_like_number` /
219
+ // `parse_number_literal`, itself a mirror of Perl's
220
+ // `Scalar::Util::looks_like_number`).
221
+ //
222
+ // Rather than hand-rolling the regex `^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$`
223
+ // plus `^[+-]?(inf(inity)?|nan)$` (no `regex` crate is available in this
224
+ // crate's dependency set -- see the design doc's fixed dependency list),
225
+ // this delegates to `f64::from_str`, whose documented grammar
226
+ // (`Sign? ('inf' | 'infinity' | 'nan' | Number)` where `Number` is exactly
227
+ // the digit/decimal-point/exponent shape above) is already isomorphic to
228
+ // the Python/Perl grammar. `looks_like_number_rust_grammar_matches_python`
229
+ // below pins the equivalence against representative cases (including the
230
+ // ones the golden vectors exercise) so any future libstd grammar drift is
231
+ // caught here rather than as a mysterious golden-vector failure.
232
+ // ---------------------------------------------------------------------------
233
+
234
+ /// Mirror of `evaluator.looks_like_number` / `Scalar::Util::looks_like_number`
235
+ /// for a (possibly padded) string.
236
+ pub fn looks_like_number(s: &str) -> bool {
237
+ let t = s.trim();
238
+ if t.is_empty() {
239
+ return false;
240
+ }
241
+ t.parse::<f64>().is_ok()
242
+ }
243
+
244
+ /// Parse a string already known to satisfy [`looks_like_number`] into an
245
+ /// `f64`. Mirrors `evaluator.parse_number_literal`.
246
+ pub fn parse_number_literal(s: &str) -> f64 {
247
+ s.trim().parse::<f64>().unwrap_or(f64::NAN)
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // JsValue <-> f64 / kind unification.
252
+ // ---------------------------------------------------------------------------
253
+
254
+ pub fn is_number(v: &JsValue) -> bool {
255
+ matches!(v, JsValue::Number(_))
256
+ }
257
+
258
+ pub fn is_string(v: &JsValue) -> bool {
259
+ matches!(v, JsValue::String(_))
260
+ }
261
+
262
+ pub fn is_bool(v: &JsValue) -> bool {
263
+ matches!(v, JsValue::Bool(_))
264
+ }
265
+
266
+ pub fn is_null(v: &JsValue) -> bool {
267
+ matches!(v, JsValue::Null)
268
+ }
269
+
270
+ /// Extract the numeric value of a `JsValue::Number` as `f64`. Non-number
271
+ /// values yield `NaN`.
272
+ pub fn to_f64(v: &JsValue) -> f64 {
273
+ match v {
274
+ JsValue::Number(n) => *n,
275
+ _ => f64::NAN,
276
+ }
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // JS Number::toString (ECMA-262 7.1.12.1), the "custom formatter" fallback
281
+ // and `js_string`'s number branch both route through this. Port of Python's
282
+ // `_format_js_number` / `_format_number`, made exponent-notation-correct
283
+ // (Rust's `f64::Display`/`ToString` NEVER emits exponential notation, no
284
+ // matter how large or small the magnitude -- the documented trap this
285
+ // module exists to close; Python's `repr(float)` fallback carries the same
286
+ // unresolved-divergence caveat for extreme magnitudes, which this port
287
+ // removes instead of carrying forward).
288
+ //
289
+ // Algorithm: obtain the shortest round-trip significant-digit string via
290
+ // `{:e}` (Rust's `LowerExp`, backed by the same shortest-round-trip
291
+ // decimal-conversion core as `Display`, just normalized to `d.ddddEe`
292
+ // form), then apply the ECMA-262 placement rules verbatim.
293
+ // ---------------------------------------------------------------------------
294
+
295
+ pub fn format_js_number(n: f64) -> String {
296
+ if n.is_nan() {
297
+ return "NaN".to_string();
298
+ }
299
+ if n.is_infinite() {
300
+ return if n > 0.0 { "Infinity".to_string() } else { "-Infinity".to_string() };
301
+ }
302
+ if n == 0.0 {
303
+ // Normalizes -0.0 to JS's "0" spelling (String(-0) === "0").
304
+ return "0".to_string();
305
+ }
306
+ if n < 0.0 {
307
+ return format!("-{}", format_js_number(-n));
308
+ }
309
+
310
+ // n is finite, positive, nonzero. `digits` is the shortest round-trip
311
+ // significant-digit string (k digits, no trailing zeros, first digit
312
+ // nonzero); `n_exp` is the ECMA-262 exponent such that
313
+ // `n == (digits as integer) * 10^(n_exp - k)`.
314
+ let (digits, n_exp) = shortest_digits(n);
315
+ let k = digits.len() as i32;
316
+
317
+ if k <= n_exp && n_exp <= 21 {
318
+ // Integer-valued spelling, zero-padded on the right.
319
+ let mut s = digits;
320
+ s.push_str(&"0".repeat((n_exp - k) as usize));
321
+ s
322
+ } else if 0 < n_exp && n_exp <= 21 {
323
+ // Decimal point lands inside the digit string.
324
+ format!("{}.{}", &digits[..n_exp as usize], &digits[n_exp as usize..])
325
+ } else if -6 < n_exp && n_exp <= 0 {
326
+ // Leading "0." plus zero-padding before the digits.
327
+ format!("0.{}{}", "0".repeat((-n_exp) as usize), digits)
328
+ } else {
329
+ // Exponential notation: d(.ddd)?e[+-]N.
330
+ let exp = n_exp - 1;
331
+ let mantissa = if k == 1 {
332
+ digits
333
+ } else {
334
+ format!("{}.{}", &digits[..1], &digits[1..])
335
+ };
336
+ let sign = if exp >= 0 { "+" } else { "-" };
337
+ format!("{}e{}{}", mantissa, sign, exp.abs())
338
+ }
339
+ }
340
+
341
+ /// Returns (shortest round-trip significant digits, ECMA-262 `n` exponent)
342
+ /// for a finite, positive, nonzero `f64`.
343
+ fn shortest_digits(n: f64) -> (String, i32) {
344
+ // Rust's `{:e}` always normalizes to exactly one digit before the
345
+ // decimal point ("d" or "d.ddd") using the shortest round-trip decimal
346
+ // expansion, e.g. `1e2`, `1.23456e2`, `3.0000000000000004e-1`.
347
+ let s = format!("{:e}", n);
348
+ let (mantissa, exp_str) = s.split_once('e').expect("LowerExp always emits 'e'");
349
+ let exp: i32 = exp_str.parse().expect("LowerExp exponent is a plain integer");
350
+ let digits: String = mantissa.chars().filter(|c| *c != '.').collect();
351
+ // value == d.ddd * 10^exp == (digits as integer) * 10^(exp - (k - 1))
352
+ // ECMA form: value == (digits as integer) * 10^(n_exp - k)
353
+ // => n_exp - k == exp - (k - 1) => n_exp == exp + 1
354
+ (digits, exp + 1)
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Math.floor / Math.ceil / Math.round -- no NaN/Infinity guard needed (see
359
+ // module docstring): `f64::floor`/`f64::ceil` already pass them through.
360
+ // `Math.round` rounds half toward +Infinity (`Math.round(-1.5) === -1`, NOT
361
+ // -2), matching `runtime.py`'s `round` / `evaluator.py`'s `_math_round`.
362
+ // ---------------------------------------------------------------------------
363
+
364
+ pub fn js_floor(n: f64) -> f64 {
365
+ n.floor()
366
+ }
367
+
368
+ pub fn js_ceil(n: f64) -> f64 {
369
+ n.ceil()
370
+ }
371
+
372
+ pub fn js_round(n: f64) -> f64 {
373
+ (n + 0.5).floor()
374
+ }
375
+
376
+ /// JS `%`: remainder with the dividend's sign. Rust's `%` on `f64` already
377
+ /// implements C `fmod` semantics (IEEE-754 remainder), so this is a
378
+ /// documentation-only wrapper -- see the module docstring for why no
379
+ /// zero-divisor special-casing (needed in the Python port) is needed here.
380
+ pub fn js_mod(a: f64, b: f64) -> f64 {
381
+ a % b
382
+ }
383
+
384
+ /// JS `Number.prototype.toFixed(digits)`. Mirrors `runtime.py.to_fixed` /
385
+ /// `bf.go`'s `ToFixed`: JS rounds the scaled value half toward +Infinity
386
+ /// (`(2.5).toFixed(0) === "3"`), not Rust's `format!`'s round-half-to-even,
387
+ /// so the value is pre-rounded via [`js_round`]-equivalent scaling before
388
+ /// formatting.
389
+ pub fn to_fixed(n: f64, digits: i32) -> String {
390
+ if n.is_nan() {
391
+ return "NaN".to_string();
392
+ }
393
+ if n.is_infinite() {
394
+ return if n < 0.0 { "-Infinity".to_string() } else { "Infinity".to_string() };
395
+ }
396
+ let digits = digits.max(0);
397
+ let factor = 10f64.powi(digits);
398
+ let rounded = (n * factor + 0.5).floor();
399
+ format!("{:.*}", digits as usize, rounded / factor)
400
+ }
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // JS strict equality (`===`) and SameValueZero (`Array.prototype.includes`
404
+ // membership), kind-aware (a JS number never `===` a JS string, even when
405
+ // they "look like" the same value: `2 === "2"` is false). Port of
406
+ // `evaluator._strict_eq` / `evaluator._same_value_zero`; ALSO used by
407
+ // `runtime.rs`'s `index_of` / `last_index_of` (in place of the Python
408
+ // port's native `==`, which the Python module docstring documents as
409
+ // "mostly" JS-strict-equality-equivalent but with an acknowledged,
410
+ // unexercised gap: Python `bool` is an `int` subclass, so `True == 1`,
411
+ // which JS `true === 1` is not). Routing `index_of`/`last_index_of` through
412
+ // this kind-aware `strict_eq` closes that gap entirely rather than
413
+ // reproducing it -- documented in `runtime.rs`.
414
+ // ---------------------------------------------------------------------------
415
+
416
+ pub fn strict_eq(l: &JsValue, r: &JsValue) -> bool {
417
+ let (ln, rn) = (is_number(l), is_number(r));
418
+ if ln && rn {
419
+ let (lf, rf) = (to_f64(l), to_f64(r));
420
+ if lf.is_nan() || rf.is_nan() {
421
+ return false;
422
+ }
423
+ return lf == rf;
424
+ }
425
+ if ln != rn {
426
+ return false;
427
+ }
428
+ match (l, r) {
429
+ (JsValue::Null, JsValue::Null) => true,
430
+ (JsValue::Null, _) | (_, JsValue::Null) => false,
431
+ (JsValue::Bool(a), JsValue::Bool(b)) => a == b,
432
+ (JsValue::Bool(_), _) | (_, JsValue::Bool(_)) => false,
433
+ (JsValue::String(a), JsValue::String(b)) => a == b,
434
+ _ => false,
435
+ }
436
+ }
437
+
438
+ /// SameValueZero: `===` except `NaN` equals itself (and +0/-0 are not
439
+ /// distinguished, which this domain doesn't track separately either).
440
+ pub fn same_value_zero(l: &JsValue, r: &JsValue) -> bool {
441
+ if is_number(l) && is_number(r) {
442
+ let (lf, rf) = (to_f64(l), to_f64(r));
443
+ if lf.is_nan() && rf.is_nan() {
444
+ return true;
445
+ }
446
+ }
447
+ strict_eq(l, r)
448
+ }
449
+
450
+ #[cfg(test)]
451
+ mod tests {
452
+ use super::*;
453
+
454
+ #[test]
455
+ #[allow(clippy::approx_constant)] // literal test data ported from Python, not an approximation of pi
456
+ fn format_js_number_matches_ecma262() {
457
+ assert_eq!(format_js_number(0.0), "0");
458
+ assert_eq!(format_js_number(-0.0), "0");
459
+ assert_eq!(format_js_number(f64::NAN), "NaN");
460
+ assert_eq!(format_js_number(f64::INFINITY), "Infinity");
461
+ assert_eq!(format_js_number(f64::NEG_INFINITY), "-Infinity");
462
+ assert_eq!(format_js_number(42.0), "42");
463
+ assert_eq!(format_js_number(-7.0), "-7");
464
+ assert_eq!(format_js_number(3.14), "3.14");
465
+ assert_eq!(format_js_number(0.5), "0.5");
466
+ assert_eq!(format_js_number(1.0), "1");
467
+ assert_eq!(format_js_number(0.30000000000000004), "0.30000000000000004");
468
+ assert_eq!(format_js_number(9007199254740992.0), "9007199254740992");
469
+ assert_eq!(format_js_number(1e20), "100000000000000000000");
470
+ assert_eq!(format_js_number(1e21), "1e+21");
471
+ assert_eq!(format_js_number(123456789012345680000.0), "123456789012345680000");
472
+ assert_eq!(format_js_number(1.5e21), "1.5e+21");
473
+ assert_eq!(format_js_number(0.0001), "0.0001");
474
+ assert_eq!(format_js_number(0.000001), "0.000001");
475
+ assert_eq!(format_js_number(0.0000001), "1e-7");
476
+ assert_eq!(format_js_number(-0.0000001), "-1e-7");
477
+ assert_eq!(format_js_number(100.0), "100");
478
+ }
479
+
480
+ #[test]
481
+ fn looks_like_number_rust_grammar_matches_python() {
482
+ for s in ["42", "3.14", "-0.5", "1e3", "1E3", "+5", ".5", "5.", " 8 ", "NaN", "nan", "Infinity", "-inf"] {
483
+ assert!(looks_like_number(s), "expected {s:?} to look like a number");
484
+ }
485
+ for s in ["", " ", "not a num", "1,000", "5e", "abc", "1_000", "0x1"] {
486
+ assert!(!looks_like_number(s), "expected {s:?} to NOT look like a number");
487
+ }
488
+ }
489
+
490
+ #[test]
491
+ fn parse_number_literal_trims_and_parses() {
492
+ assert_eq!(parse_number_literal(" 8 "), 8.0);
493
+ assert_eq!(parse_number_literal("1e3"), 1000.0);
494
+ assert_eq!(parse_number_literal("-0.5"), -0.5);
495
+ assert!(parse_number_literal("nan").is_nan());
496
+ }
497
+
498
+ #[test]
499
+ fn js_round_half_toward_positive_infinity() {
500
+ assert_eq!(js_round(3.5), 4.0);
501
+ assert_eq!(js_round(3.4), 3.0);
502
+ assert_eq!(js_round(-1.5), -1.0);
503
+ assert_eq!(js_round(-1.6), -2.0);
504
+ }
505
+
506
+ #[test]
507
+ fn js_mod_matches_js_percent() {
508
+ assert_eq!(js_mod(5.0, 3.0), 2.0);
509
+ assert_eq!(js_mod(-5.0, 3.0), -2.0);
510
+ assert!(js_mod(5.0, 0.0).is_nan());
511
+ }
512
+
513
+ #[test]
514
+ #[allow(clippy::approx_constant)] // literal test data ported from Python, not an approximation of pi
515
+ fn to_fixed_rounds_half_up_and_handles_nonfinite() {
516
+ assert_eq!(to_fixed(316.0, 2), "316.00");
517
+ assert_eq!(to_fixed(3.14159, 2), "3.14");
518
+ assert_eq!(to_fixed(2.5, 0), "3");
519
+ assert_eq!(to_fixed(1.005, 2), "1.00");
520
+ assert_eq!(to_fixed(f64::NAN, 2), "NaN");
521
+ assert_eq!(to_fixed(f64::INFINITY, 2), "Infinity");
522
+ assert_eq!(to_fixed(f64::NEG_INFINITY, 2), "-Infinity");
523
+ }
524
+
525
+ #[test]
526
+ fn strict_eq_is_kind_aware() {
527
+ assert!(strict_eq(&JsValue::Number(2.0), &JsValue::Number(2.0)));
528
+ assert!(!strict_eq(&JsValue::Number(2.0), &JsValue::String("2".into())));
529
+ assert!(!strict_eq(&JsValue::Bool(true), &JsValue::Number(1.0)));
530
+ assert!(!strict_eq(&JsValue::Number(f64::NAN), &JsValue::Number(f64::NAN)));
531
+ assert!(same_value_zero(&JsValue::Number(f64::NAN), &JsValue::Number(f64::NAN)));
532
+ assert!(!same_value_zero(&JsValue::Number(2.0), &JsValue::String("2".into())));
533
+ }
534
+
535
+ #[test]
536
+ fn json_round_trip_collapses_non_finite_on_the_way_out_only() {
537
+ let inf = JsValue::Number(f64::INFINITY);
538
+ // Non-finite survives WITHIN the working domain...
539
+ assert!(matches!(inf, JsValue::Number(n) if n.is_infinite()));
540
+ // ...and only collapses to JSON null at the to_json boundary.
541
+ assert_eq!(inf.to_json(), JsonValue::Null);
542
+
543
+ let nested = JsValue::Array(vec![JsValue::Number(f64::NAN), JsValue::Number(1.0)]);
544
+ // `1.0` -> JSON `1`, not `1.0` (matches `JSON.stringify`'s
545
+ // integral-number spelling -- see `number_to_json`).
546
+ assert_eq!(nested.to_json(), serde_json::json!([null, 1]));
547
+ }
548
+
549
+ #[test]
550
+ fn number_to_json_matches_js_integral_spelling() {
551
+ assert_eq!(serde_json::to_string(&JsValue::Number(42.0).to_json()).unwrap(), "42");
552
+ assert_eq!(serde_json::to_string(&JsValue::Number(-7.0).to_json()).unwrap(), "-7");
553
+ assert_eq!(serde_json::to_string(&JsValue::Number(0.0).to_json()).unwrap(), "0");
554
+ assert_eq!(serde_json::to_string(&JsValue::Number(-0.0).to_json()).unwrap(), "0");
555
+ assert_eq!(serde_json::to_string(&JsValue::Number(1.5).to_json()).unwrap(), "1.5");
556
+ assert_eq!(serde_json::to_string(&JsValue::Number(0.19999999999999998).to_json()).unwrap(), "0.19999999999999998");
557
+ }
558
+ }