@barefootjs/mojolicious 0.15.2 → 0.17.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 (53) hide show
  1. package/dist/adapter/analysis/component-tree.d.ts +30 -0
  2. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  3. package/dist/adapter/emit-context.d.ts +96 -0
  4. package/dist/adapter/emit-context.d.ts.map +1 -0
  5. package/dist/adapter/expr/array-method.d.ts +75 -0
  6. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  7. package/dist/adapter/expr/emitters.d.ts +77 -0
  8. package/dist/adapter/expr/emitters.d.ts.map +1 -0
  9. package/dist/adapter/expr/operand.d.ts +34 -0
  10. package/dist/adapter/expr/operand.d.ts.map +1 -0
  11. package/dist/adapter/index.js +1490 -1334
  12. package/dist/adapter/lib/constants.d.ts +27 -0
  13. package/dist/adapter/lib/constants.d.ts.map +1 -0
  14. package/dist/adapter/lib/ir-scope.d.ts +40 -0
  15. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  16. package/dist/adapter/lib/perl-naming.d.ts +29 -0
  17. package/dist/adapter/lib/perl-naming.d.ts.map +1 -0
  18. package/dist/adapter/lib/types.d.ts +28 -0
  19. package/dist/adapter/lib/types.d.ts.map +1 -0
  20. package/dist/adapter/memo/seed.d.ts +43 -0
  21. package/dist/adapter/memo/seed.d.ts.map +1 -0
  22. package/dist/adapter/mojo-adapter.d.ts +46 -104
  23. package/dist/adapter/mojo-adapter.d.ts.map +1 -1
  24. package/dist/adapter/props/prop-classes.d.ts +48 -0
  25. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  26. package/dist/adapter/spread/spread-codegen.d.ts +64 -0
  27. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  28. package/dist/adapter/value/parsed-literal.d.ts +26 -0
  29. package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
  30. package/dist/build.js +1490 -1334
  31. package/dist/index.js +1490 -1334
  32. package/dist/test-render.d.ts.map +1 -1
  33. package/lib/BarefootJS/Backend/Mojo.pm +1 -1
  34. package/lib/Mojolicious/Plugin/BarefootJS/DevReload.pm +1 -1
  35. package/lib/Mojolicious/Plugin/BarefootJS.pm +1 -1
  36. package/package.json +3 -3
  37. package/src/__tests__/mojo-adapter.test.ts +163 -62
  38. package/src/__tests__/query-href.test.ts +94 -0
  39. package/src/adapter/analysis/component-tree.ts +128 -0
  40. package/src/adapter/emit-context.ts +107 -0
  41. package/src/adapter/expr/array-method.ts +408 -0
  42. package/src/adapter/expr/emitters.ts +607 -0
  43. package/src/adapter/expr/operand.ts +55 -0
  44. package/src/adapter/lib/constants.ts +39 -0
  45. package/src/adapter/lib/ir-scope.ts +70 -0
  46. package/src/adapter/lib/perl-naming.ts +36 -0
  47. package/src/adapter/lib/types.ts +31 -0
  48. package/src/adapter/memo/seed.ts +126 -0
  49. package/src/adapter/mojo-adapter.ts +230 -1476
  50. package/src/adapter/props/prop-classes.ts +87 -0
  51. package/src/adapter/spread/spread-codegen.ts +181 -0
  52. package/src/adapter/value/parsed-literal.ts +34 -0
  53. package/src/test-render.ts +2 -0
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The contract the extracted expression-emitter modules depend on instead of
3
+ * the concrete `MojoAdapter`.
4
+ *
5
+ * The Mojo adapter's top-level expression lowering is mutually recursive with
6
+ * the adapter's own const/record resolution and its filter-predicate emitter,
7
+ * so the extracted `MojoTopLevelEmitter` still needs to call back into shared
8
+ * per-compile state and recursive entry points. `MojoEmitContext` is that
9
+ * seam: the emitter takes a `MojoEmitContext` built by the adapter's private
10
+ * `emitCtx` getter (the adapter does NOT `implements` this interface, so the
11
+ * wrapped members stay private and off its exported public type — matching the
12
+ * Go adapter's `emitCtx`). The emitter depends on this narrow interface rather
13
+ * than the full ~3k-line class, so the coupling is explicit and it's
14
+ * unit-testable against a stub.
15
+ *
16
+ * Keep this surface minimal: add a member only when an extracted module
17
+ * genuinely needs it, so the seam documents the real cross-module coupling
18
+ * rather than re-exposing the whole adapter.
19
+ */
20
+
21
+ import type { ParsedExpr, CompilerError, IRMetadata } from '@barefootjs/jsx'
22
+
23
+ export interface MojoEmitContext {
24
+ /**
25
+ * (#1922) Local binding names the request-scoped `searchParams()` env signal
26
+ * is imported under. Non-empty enables the env-signal method-call lowering.
27
+ */
28
+ readonly _searchParamsLocals: Set<string>
29
+
30
+ /**
31
+ * Inline a module-scope pure string-literal const by name as the resolved
32
+ * literal value, or null when the name is not such a const.
33
+ */
34
+ resolveModuleStringConst(name: string): string | null
35
+
36
+ /** Resolve a literal const (`const totalPages = 5`) to its Perl value, or null. */
37
+ resolveLiteralConst(name: string): string | null
38
+
39
+ /**
40
+ * Resolve a static property access on a module object-literal const
41
+ * (`variantClasses.ghost`) to its Perl value at compile time, or null.
42
+ */
43
+ resolveStaticRecordLiteral(objectName: string, key: string): string | null
44
+
45
+ /** Whether a getter/prop name resolves to a string-typed SSR value. */
46
+ _isStringValueName(name: string): boolean
47
+
48
+ /** Record a BF101 unsupported-expression diagnostic. */
49
+ _recordExprBF101(message: string, reason?: string): void
50
+
51
+ /** Lower a filter/predicate body to its Perl form, bound to `param`. */
52
+ _renderPerlFilterExprPublic(expr: ParsedExpr, param: string): string
53
+ }
54
+
55
+ /**
56
+ * The contract the extracted object-literal / conditional-spread lowering
57
+ * (`spread/spread-codegen.ts`) depends on. The spread lowering recurses into
58
+ * the core expression lowering and records its own BF101 diagnostics, so it
59
+ * needs the recursive entry point plus the per-compile bookkeeping the
60
+ * adapter owns. Declared separately from `MojoEmitContext` so each extracted
61
+ * module's real coupling is documented precisely.
62
+ */
63
+ export interface MojoSpreadContext {
64
+ /** Component name, for diagnostic source locations. */
65
+ readonly componentName: string
66
+
67
+ /** Per-compile diagnostic list the spread lowering appends to. */
68
+ readonly errors: CompilerError[]
69
+
70
+ /** Local-constant metadata, for resolving `Record[key]` spread values. */
71
+ readonly localConstants: IRMetadata['localConstants']
72
+
73
+ /** Prop params, for classifying a bare-identifier index as a prop. */
74
+ readonly propsParams: { name: string }[]
75
+
76
+ /**
77
+ * Lower a JS expression to its Perl form (the core recursive entry).
78
+ *
79
+ * When the IR already carries a structured `ParsedExpr` tree, pass it as
80
+ * `preParsed` so the converter threads it straight through instead of
81
+ * re-parsing `expr` — mirrors go-template's
82
+ * `convertExpressionToGo(jsExpr, out?, preParsed?)`. With `preParsed` set,
83
+ * `expr` is unused for parsing (the converter derives any diagnostic text
84
+ * from the tree), so callers may pass `''`.
85
+ */
86
+ convertExpressionToPerl(expr: string, preParsed?: ParsedExpr): string
87
+ }
88
+
89
+ /**
90
+ * The contract the extracted in-template memo / context seeding
91
+ * (`memo/seed.ts`) depends on. The seed lowering recurses into the core
92
+ * expression lowering to compute a derived signal/memo value or a context
93
+ * default; that recursive entry is its only adapter coupling.
94
+ */
95
+ export interface MojoMemoContext {
96
+ /**
97
+ * Lower a JS expression to its Perl form (the core recursive entry).
98
+ *
99
+ * When the IR already carries a structured `ParsedExpr` tree, pass it as
100
+ * `preParsed` so the converter threads it straight through instead of
101
+ * re-parsing `expr` — mirrors go-template's
102
+ * `convertExpressionToGo(jsExpr, out?, preParsed?)`. With `preParsed` set,
103
+ * `expr` is unused for parsing (the converter derives any diagnostic text
104
+ * from the tree), so callers may pass `''`.
105
+ */
106
+ convertExpressionToPerl(expr: string, preParsed?: ParsedExpr): string
107
+ }
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Array / string method lowering for the Mojolicious EP template adapter.
3
+ *
4
+ * Extracted from `mojo-adapter.ts` (domain-module refactor, issue #2018
5
+ * track D). Pure free functions shared by both the filter-context emitter
6
+ * and the top-level emitter — they take an `emit` callback for receiver /
7
+ * argument recursion and read no adapter instance state.
8
+ */
9
+
10
+ import {
11
+ serializeParsedExpr,
12
+ freeVarsInBody,
13
+ } from '@barefootjs/jsx'
14
+ import type {
15
+ ParsedExpr,
16
+ ArrayMethod,
17
+ SortComparator,
18
+ FlatDepth,
19
+ } from '@barefootjs/jsx'
20
+
21
+ /**
22
+ * Lower an `arr.<method>(...)` / `str.<method>(...)` value-builtin call to
23
+ * its Perl form. The IR lifts these into the dedicated `array-method` kind at
24
+ * parse time (see the `arrayMethod` emitter arms), so this is the single
25
+ * place every adapter-supported array/string method is mapped. An unhandled
26
+ * `ArrayMethod` variant throws rather than emitting a silent no-op — the
27
+ * drift defence we already apply to `ParsedExpr.kind` extended to its
28
+ * sub-discriminator.
29
+ */
30
+ export function renderArrayMethod(
31
+ method: ArrayMethod,
32
+ object: ParsedExpr,
33
+ args: ParsedExpr[],
34
+ emit: (e: ParsedExpr) => string,
35
+ ): string {
36
+ switch (method) {
37
+ case 'join': {
38
+ // arr.join(sep) → join(sep, @{arr}). The default `${obj}->{join}`
39
+ // hash-lookup fallback would emit invalid Perl, which is why the
40
+ // IR carves out a dedicated method node instead of routing
41
+ // through the generic call dispatcher. `.join()` defaults the
42
+ // separator to `,` (JS) and ignores any extra argument.
43
+ const obj = emit(object)
44
+ const sep = args.length >= 1 ? emit(args[0]) : `','`
45
+ return `join(${sep}, @{${obj}})`
46
+ }
47
+ case 'includes': {
48
+ // Both `arr.includes(x)` and `str.includes(sub)` route here —
49
+ // the parser can't disambiguate the receiver type. The Mojo
50
+ // runtime's `bf->includes($recv, $elem)` inspects `ref($recv)`
51
+ // and dispatches: ARRAY ref scans the list with `eq`, scalar
52
+ // falls back to `index(..., ...) != -1`. Helper lives in
53
+ // packages/adapter-perl/lib/BarefootJS.pm.
54
+ //
55
+ // The `bf->` (no `$`) form matches every other helper emit —
56
+ // in real Mojolicious `bf` is a controller helper; the
57
+ // standalone test-render in test-render.ts rewrites the bare
58
+ // `bf->` to `$bf->` so both render paths stay consistent.
59
+ const obj = emit(object)
60
+ const needle = emit(args[0])
61
+ return `bf->includes(${obj}, ${needle})`
62
+ }
63
+ case 'indexOf':
64
+ case 'lastIndexOf': {
65
+ // Array `.indexOf(x)` / `.lastIndexOf(x)` value-equality
66
+ // search. The Perl helpers (`bf->index_of`, `bf->last_index_of`)
67
+ // walk the array forward / backward and compare with `eq`
68
+ // (with defined/undef parity). The existing `.find` lowering
69
+ // uses Perl `grep` for struct-field find — disjoint surface,
70
+ // disjoint helpers.
71
+ const fn = method === 'indexOf' ? 'index_of' : 'last_index_of'
72
+ const obj = emit(object)
73
+ const needle = emit(args[0])
74
+ return `bf->${fn}(${obj}, ${needle})`
75
+ }
76
+ case 'at': {
77
+ // `.at(i)` with negative-index support — `.at(-1)` is the
78
+ // last element. The Mojo helper wraps the same `length + i`
79
+ // arithmetic the Go `bf_at` does so the lowering stays
80
+ // symmetric across adapters. `.at()` with no argument is `.at(0)`
81
+ // (the first element); extra arguments are ignored.
82
+ const obj = emit(object)
83
+ const idx = args.length >= 1 ? emit(args[0]) : '0'
84
+ return `bf->at(${obj}, ${idx})`
85
+ }
86
+ case 'concat': {
87
+ // `.concat(other)` merges two arrays. Returns a new ARRAY
88
+ // ref so the result composes with `.join(...)` / other
89
+ // array-shape methods downstream (the canonical Tier A
90
+ // conformance fixture chains `.concat(...).join(' ')`).
91
+ // `.concat()` with no argument is a shallow copy — indistinguishable
92
+ // from the receiver in an SSR snapshot, so it lowers to the receiver.
93
+ if (args.length === 0) {
94
+ return emit(object)
95
+ }
96
+ const a = emit(object)
97
+ const b = emit(args[0])
98
+ return `bf->concat(${a}, ${b})`
99
+ }
100
+ case 'slice': {
101
+ // `.slice()` / `.slice(start)` / `.slice(start, end)`. The Mojo
102
+ // helper mirrors the Go arithmetic (negative-index normalisation,
103
+ // out-of-bounds clamping, empty result on start >= end). A
104
+ // missing `start` defaults to 0 (full copy); an absent `end`
105
+ // lowers as `undef`, which the helper treats as "to length". JS
106
+ // ignores a third+ argument. Returns a new ARRAY ref so the
107
+ // result composes with `.join(...)` downstream.
108
+ const recv = emit(object)
109
+ const start = args.length >= 1 ? emit(args[0]) : '0'
110
+ const end = args.length >= 2 ? emit(args[1]) : 'undef'
111
+ return `bf->slice(${recv}, ${start}, ${end})`
112
+ }
113
+ case 'reverse':
114
+ case 'toReversed': {
115
+ // Both shapes share a lowering — see the parser arm + Go
116
+ // emit for the SSR-mutation-rationale. Returns a new ARRAY
117
+ // ref so the result composes with `.join(...)` downstream.
118
+ const recv = emit(object)
119
+ return `bf->reverse(${recv})`
120
+ }
121
+ case 'toLowerCase': {
122
+ // Perl's native `lc` is the obvious lowering — no helper
123
+ // method needed. The receiver flows through `emit` so any
124
+ // upstream coercion (`$value`, `$bf->string(...)`, etc.)
125
+ // composes naturally.
126
+ const recv = emit(object)
127
+ return `lc(${recv})`
128
+ }
129
+ case 'toUpperCase': {
130
+ // Perl's native `uc` — mirrors `toLowerCase` exactly.
131
+ const recv = emit(object)
132
+ return `uc(${recv})`
133
+ }
134
+ case 'trim': {
135
+ // No Perl native `trim`; route through the `bf->trim`
136
+ // helper so the regex stays in one place (and so an undef
137
+ // receiver doesn't trigger a warning about applying `s///`
138
+ // to undef).
139
+ const recv = emit(object)
140
+ return `bf->trim(${recv})`
141
+ }
142
+ case 'toFixed': {
143
+ // `.toFixed(digits?)` — Number → fixed-decimal string. `bf->to_fixed`
144
+ // mirrors JS rounding + zero-padding (default 0 digits). #1897.
145
+ const recv = emit(object)
146
+ const digits = args.length >= 1 ? emit(args[0]) : '0'
147
+ return `bf->to_fixed(${recv}, ${digits})`
148
+ }
149
+ case 'split': {
150
+ // `.split()` / `.split(sep)` / `.split(sep, limit)` — string →
151
+ // ARRAY ref via `bf->split`. With no separator the helper returns
152
+ // the whole string as a single element; otherwise it quotemetas
153
+ // the separator (literal match, not regex) and keeps trailing
154
+ // empties (`-1`), staying byte-equal with Go's `bf_split`. The
155
+ // optional `limit` caps the pieces; JS ignores a third+ argument.
156
+ // See #1448 Tier B.
157
+ const recv = emit(object)
158
+ if (args.length === 0) {
159
+ return `bf->split(${recv})`
160
+ }
161
+ const sep = emit(args[0])
162
+ if (args.length === 1) {
163
+ return `bf->split(${recv}, ${sep})`
164
+ }
165
+ const limit = emit(args[1])
166
+ return `bf->split(${recv}, ${sep}, ${limit})`
167
+ }
168
+ case 'startsWith':
169
+ case 'endsWith': {
170
+ // `.startsWith(prefix, position?)` / `.endsWith(suffix,
171
+ // endPosition?)` — string → boolean. The Perl helpers
172
+ // (`bf->starts_with` / `bf->ends_with`) do a `substr`-anchored
173
+ // comparison so the search string is matched literally (no regex
174
+ // metachar surprises) and undef receivers stay quiet. The optional
175
+ // second argument re-anchors the test; JS ignores a third+
176
+ // argument. See #1448 Tier B.
177
+ const fn = method === 'startsWith' ? 'starts_with' : 'ends_with'
178
+ const recv = emit(object)
179
+ const arg = emit(args[0])
180
+ if (args.length >= 2) {
181
+ return `bf->${fn}(${recv}, ${arg}, ${emit(args[1])})`
182
+ }
183
+ return `bf->${fn}(${recv}, ${arg})`
184
+ }
185
+ case 'replace': {
186
+ // `.replace(old, new)` — string-pattern form, first occurrence.
187
+ // The `bf->replace` helper splices via index/substr (not `s///`)
188
+ // so both the pattern and the replacement are literal — no Perl
189
+ // regex metacharacters and no `$1` / `$&` interpolation in the
190
+ // replacement, keeping it byte-equal with Go's `bf_replace`. The
191
+ // regex-pattern form is refused upstream at the parser. See
192
+ // #1448 Tier B.
193
+ const recv = emit(object)
194
+ const oldS = emit(args[0])
195
+ const newS = emit(args[1])
196
+ return `bf->replace(${recv}, ${oldS}, ${newS})`
197
+ }
198
+ case 'repeat': {
199
+ // `.repeat(n)` — string repeated `n` times. The `bf->repeat`
200
+ // helper wraps Perl's `x` operator with the same negative-count
201
+ // → "" clamp and integer truncation Go's `bf_repeat` applies, so
202
+ // the two adapters stay byte-equal. Full JS arity: the no-argument
203
+ // form is `repeat(0)` → ""; a second+ argument is ignored.
204
+ // See #1448 Tier B.
205
+ const recv = emit(object)
206
+ const count = args.length === 0 ? '0' : emit(args[0])
207
+ return `bf->repeat(${recv}, ${count})`
208
+ }
209
+ case 'padStart':
210
+ case 'padEnd': {
211
+ // `.padStart(target, pad?)` / `.padEnd(target, pad?)`. The
212
+ // `bf->pad_*` helpers default the pad to a single space when the
213
+ // arg is omitted and measure length in characters, matching Go's
214
+ // rune-based `bf_pad_*`. Full JS arity: the no-argument form is
215
+ // `padStart(0)` → the receiver unchanged; a third+ argument is
216
+ // ignored. See #1448 Tier B.
217
+ const fn = method === 'padStart' ? 'pad_start' : 'pad_end'
218
+ const recv = emit(object)
219
+ if (args.length === 0) {
220
+ return `bf->${fn}(${recv}, 0)`
221
+ }
222
+ const target = emit(args[0])
223
+ if (args.length === 1) {
224
+ return `bf->${fn}(${recv}, ${target})`
225
+ }
226
+ const pad = emit(args[1])
227
+ return `bf->${fn}(${recv}, ${target}, ${pad})`
228
+ }
229
+ default: {
230
+ // TS-level exhaustiveness guard. If this throws at runtime, the
231
+ // IR was constructed against a newer `ArrayMethod` variant that
232
+ // this adapter hasn't been updated for — loud failure is better
233
+ // than emitting a silent empty string downstream.
234
+ const _exhaustive: never = method
235
+ throw new Error(
236
+ `renderArrayMethod: unhandled ArrayMethod '${(_exhaustive as string)}'`,
237
+ )
238
+ }
239
+ }
240
+ }
241
+
242
+ /** Escape a string for embedding in a Perl single-quoted literal. */
243
+ function escapePerlSingleQuote(s: string): string {
244
+ // Double every backslash and escape apostrophes: Perl single-quote
245
+ // un-escaping then restores the original bytes (a JSON `\\` must reach
246
+ // `JSON::PP->decode` as `\\`, so it travels as `\\\\` in the literal).
247
+ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
248
+ }
249
+
250
+ /**
251
+ * Build the `base_env` hashref argument for an evaluator call: the
252
+ * comparator / reducer body's free variables (body idents minus the
253
+ * callback params), each materialised to its SSR value via `emit`. An
254
+ * empty capture set yields `{}` — the runtime's seed-once env.
255
+ */
256
+ function emitEvalEnvArg(
257
+ body: ParsedExpr,
258
+ params: string[],
259
+ emit: (e: ParsedExpr) => string,
260
+ ): string {
261
+ const free = freeVarsInBody(body, new Set(params))
262
+ if (free.length === 0) return '{}'
263
+ const pairs = free.map(
264
+ n => `'${escapePerlSingleQuote(n)}' => ${emit({ kind: 'identifier', name: n })}`,
265
+ )
266
+ return `{ ${pairs.join(', ')} }`
267
+ }
268
+
269
+ /**
270
+ * Emit a `.sort(cmp)` / `.toSorted(cmp)` via the runtime evaluator (#2018):
271
+ * the comparator body travels as serialized-ParsedExpr JSON, evaluated per
272
+ * comparison against `{paramA, paramB, …captured}`. Returns null when the
273
+ * body can't be evaluated (e.g. a `localeCompare` comparator —
274
+ * `serializeParsedExpr` refuses it), so the caller falls back to the
275
+ * structured `bf->sort`. A `||`-chained multi-key comparator needs no
276
+ * special handling — JS `0 || next` is exactly the tie-break semantics.
277
+ */
278
+ export function renderSortEval(
279
+ recv: string,
280
+ body: ParsedExpr,
281
+ params: string[],
282
+ emit: (e: ParsedExpr) => string,
283
+ ): string | null {
284
+ if (params.length < 2) return null
285
+ const [paramA, paramB] = params
286
+ const json = serializeParsedExpr(body)
287
+ if (json === null) return null
288
+ const env = emitEvalEnvArg(body, [paramA, paramB], emit)
289
+ return `bf->sort_eval(${recv}, '${escapePerlSingleQuote(json)}', '${paramA}', '${paramB}', ${env})`
290
+ }
291
+
292
+ /**
293
+ * Emit a `.reduce(fn, init)` / `.reduceRight(fn, init)` via the runtime
294
+ * evaluator (#2018): the reducer body travels as serialized-ParsedExpr JSON,
295
+ * folded over the receiver from `init` in `direction` order. Returns null when
296
+ * the body can't be evaluated (→ caller falls back to `bf->reduce`). A numeric
297
+ * seed passes through as a bare Perl number; a concat seed as a single-quoted
298
+ * string.
299
+ */
300
+ export function renderReduceEval(
301
+ recv: string,
302
+ body: ParsedExpr,
303
+ params: string[],
304
+ init: ParsedExpr,
305
+ direction: 'left' | 'right',
306
+ emit: (e: ParsedExpr) => string,
307
+ ): string | null {
308
+ if (params.length < 2) return null
309
+ const [paramAcc, paramItem] = params
310
+ const json = serializeParsedExpr(body)
311
+ if (json === null) return null
312
+ // Only literal seeds round-trip to a Perl scalar here: a string literal as a
313
+ // single-quoted Perl string, a number literal as a bare numeric. Anything
314
+ // else (an expression seed, an omitted seed) can't be materialised → null,
315
+ // and the caller records BF101.
316
+ let initPerl: string
317
+ if (init.kind === 'literal' && init.literalType === 'string') {
318
+ initPerl = `'${escapePerlSingleQuote(String(init.value))}'`
319
+ } else if (init.kind === 'literal' && init.literalType === 'number') {
320
+ initPerl = String(init.value)
321
+ } else {
322
+ return null
323
+ }
324
+ const env = emitEvalEnvArg(body, [paramAcc, paramItem], emit)
325
+ return `bf->reduce_eval(${recv}, '${escapePerlSingleQuote(json)}', '${paramAcc}', '${paramItem}', ${initPerl}, '${direction}', ${env})`
326
+ }
327
+
328
+ /**
329
+ * Emit a higher-order predicate call via the runtime evaluator (#2018, P2):
330
+ * `bf->filter_eval` / `bf->every_eval` / `bf->some_eval` / `bf->find_eval` /
331
+ * `bf->find_index_eval`, carrying the serialized predicate body + captured env
332
+ * hashref. Generalizes the inline `grep` / `bf->find` lowering to the same
333
+ * JS-faithful evaluator the Go adapter uses (cross-adapter isomorphism).
334
+ * Returns null when the predicate is outside the evaluator surface (e.g. a
335
+ * method-call predicate — `serializeParsedExpr` refuses it), so the caller
336
+ * falls back to the `grep` / `bf->find` form. `forward` (find / findIndex
337
+ * family only) selects the search direction — `false` = findLast /
338
+ * findLastIndex.
339
+ */
340
+ export function renderPredicateEval(
341
+ funcName: string,
342
+ recv: string,
343
+ predicate: ParsedExpr,
344
+ param: string,
345
+ emit: (e: ParsedExpr) => string,
346
+ forward?: boolean,
347
+ ): string | null {
348
+ const json = serializeParsedExpr(predicate)
349
+ if (json === null) return null
350
+ const env = emitEvalEnvArg(predicate, [param], emit)
351
+ const fwd = forward === undefined ? '' : `, ${forward ? 1 : 0}`
352
+ return `bf->${funcName}(${recv}, '${escapePerlSingleQuote(json)}', '${param}'${fwd}, ${env})`
353
+ }
354
+
355
+ /**
356
+ * Emit a `.flatMap(proj)` via the runtime evaluator (#2018, P3/P5): the
357
+ * projection `body` serializes to JSON and `bf->flat_map_eval` projects +
358
+ * flattens one level. The sole flatMap lowering now (the structured
359
+ * `bf->flat_map` / `bf->flat_map_tuple` fallback was retired with the
360
+ * `ParsedExpr` collapse); returns null when the projection is outside the
361
+ * evaluator surface, and the caller records BF101.
362
+ */
363
+ export function renderFlatMapEval(
364
+ recv: string,
365
+ body: ParsedExpr,
366
+ param: string,
367
+ emit: (e: ParsedExpr) => string,
368
+ ): string | null {
369
+ const json = serializeParsedExpr(body)
370
+ if (json === null) return null
371
+ const env = emitEvalEnvArg(body, [param], emit)
372
+ return `bf->flat_map_eval(${recv}, '${escapePerlSingleQuote(json)}', '${param}', ${env})`
373
+ }
374
+
375
+ /**
376
+ * Shared Mojo emit for `.sort(cmp)` / `.toSorted(cmp)` (#1448 Tier B).
377
+ * Used by both the filter-context emitter and the top-level emitter,
378
+ * plus the loop-hoist path in `renderLoop` — same emit shape across
379
+ * all three so a regression in any one path surfaces consistently.
380
+ *
381
+ * The Perl helper accepts a hash-ref opts bag whose `keys` entry is
382
+ * an ordered list of per-key hashes (room for a future `nulls` knob
383
+ * without arity churn), and returns a fresh ARRAY ref so downstream
384
+ * composition (`@{bf->sort(...)}` in `join(...)`, etc.) stays
385
+ * straightforward.
386
+ */
387
+ export function renderSortMethod(recv: string, c: SortComparator): string {
388
+ // One hash per comparison key, in priority order, under `keys`. A
389
+ // simple comparator yields a one-element list; a `||`-chained
390
+ // multi-key comparator yields one per operand. `bf->sort` walks them
391
+ // in order, falling through to the next on a tie.
392
+ const keyHashes = c.keys.map((k) => {
393
+ const keyEntry =
394
+ k.key.kind === 'self'
395
+ ? `key_kind => 'self'`
396
+ : `key_kind => 'field', key => '${k.key.field}'`
397
+ return `{ ${keyEntry}, compare_type => '${k.type}', direction => '${k.direction}' }`
398
+ })
399
+ return `bf->sort(${recv}, { keys => [${keyHashes.join(', ')}] })`
400
+ }
401
+
402
+ // `.flat(depth?)` → `bf->flat($recv, $depth)`. The `Infinity` form lowers
403
+ // to the `-1` sentinel (flatten fully); a finite depth flattens that many
404
+ // levels (`0` = shallow copy). See `sub flat` in BarefootJS.pm. (#1448)
405
+ export function renderFlatMethod(recv: string, depth: FlatDepth): string {
406
+ const d = depth === 'infinity' ? -1 : depth
407
+ return `bf->flat(${recv}, ${d})`
408
+ }