@barefootjs/mojolicious 0.16.0 → 0.17.1

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 +85 -0
  6. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  7. package/dist/adapter/expr/emitters.d.ts +78 -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 +1482 -1339
  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 +33 -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 +34 -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 +1482 -1339
  31. package/dist/index.js +1482 -1339
  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 +286 -69
  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 +429 -0
  42. package/src/adapter/expr/emitters.ts +639 -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 +57 -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 +73 -0
  49. package/src/adapter/mojo-adapter.ts +248 -1485
  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,639 @@
1
+ /**
2
+ * ParsedExpr → Perl emitters for the Mojolicious EP template adapter.
3
+ *
4
+ * Extracted from `mojo-adapter.ts` (domain-module refactor, issue #2018
5
+ * track D). Two `ParsedExprEmitter` implementations:
6
+ *
7
+ * - `MojoFilterEmitter` — filter/predicate context (loop param + local
8
+ * aliases + bare `$name` signal fallback); self-contained, reads no
9
+ * adapter state.
10
+ * - `MojoTopLevelEmitter` — top-level / stash context; depends on the
11
+ * adapter only through the narrow `MojoEmitContext` seam.
12
+ */
13
+
14
+ import {
15
+ type ParsedExprEmitter,
16
+ type HigherOrderMethod,
17
+ type ArrayMethod,
18
+ type LiteralType,
19
+ type ParsedExpr,
20
+ type ObjectLiteralProperty,
21
+ type FlatDepth,
22
+ type TemplatePart,
23
+ emitParsedExpr,
24
+ identifierPath,
25
+ matchSearchParamsMethodCall,
26
+ sortComparatorFromArrow,
27
+ asCallbackMethodCall,
28
+ } from '@barefootjs/jsx'
29
+
30
+ import type { MojoEmitContext } from '../emit-context.ts'
31
+ import { MOJO_TEMPLATE_PRIMITIVES } from '../lib/constants.ts'
32
+ import { emitIndexAccessPerl, isStringTypedOperand } from './operand.ts'
33
+ import {
34
+ renderArrayMethod,
35
+ renderSortMethod,
36
+ renderSortEval,
37
+ renderReduceEval,
38
+ renderPredicateEval,
39
+ renderFlatMethod,
40
+ renderFlatMapEval,
41
+ renderMapEval,
42
+ } from './array-method.ts'
43
+
44
+ /**
45
+ * Local shape for the predicate-method fallback chain (#2018 P5). The
46
+ * higher-order callback arrives as a generic `call`; `callbackMethod` recovers
47
+ * `{ method, object, param, predicate }` from it before threading it through
48
+ * the inline `grep` / `bf->find` lowering the old `higher-order` node fed.
49
+ */
50
+ type PredicateCall = {
51
+ method: HigherOrderMethod
52
+ object: ParsedExpr
53
+ param: string
54
+ predicate: ParsedExpr
55
+ }
56
+
57
+ /**
58
+ * Lowering for the predicate body of a filter / every / some / find,
59
+ * plus the same shape used by `renderBlockBodyCondition` for complex
60
+ * block-body filters. Identifiers resolve against:
61
+ * - the predicate's loop param (`$param`),
62
+ * - `localVarMap` aliases declared inside the block body, then
63
+ * - a bare `$name` fallback for signals captured by the closure.
64
+ *
65
+ * Methods that have no filter-context meaning (template-literal,
66
+ * arrow-fn, conditional, unsupported) fall back to the `'1'` literal
67
+ * the original switch's `default` arm returned — those shapes never
68
+ * arose inside the predicates the adapter actually accepts.
69
+ */
70
+ // Predicate-shaped higher-order methods (filter/every/some land here through
71
+ // nested `.filter(...)` chains). Module-level so `callbackMethod`, which runs
72
+ // for many nodes during template generation, reuses one set per process.
73
+ const PREDICATE_METHODS: ReadonlySet<string> = new Set([
74
+ 'filter', 'find', 'findIndex', 'findLast', 'findLastIndex', 'every', 'some',
75
+ ])
76
+
77
+ export class MojoFilterEmitter implements ParsedExprEmitter {
78
+ constructor(
79
+ private readonly param: string,
80
+ private readonly localVarMap: Map<string, string>,
81
+ // Reports whether a getter/prop name is string-typed, so `===`/`!==`
82
+ // against it lowers to `eq`/`ne` (#1672). Defaults to "never" for callers
83
+ // that don't thread it through.
84
+ private readonly isStringName: (n: string) => boolean = () => false,
85
+ // Records a BF101 for nested callback shapes this emitter can only
86
+ // degrade — `find*` and the non-predicate methods (#2038). Optional so
87
+ // emitter construction stays possible without an adapter; a missing hook
88
+ // keeps the old silent-degrade emit.
89
+ private readonly onUnsupported?: (message: string, reason?: string) => void,
90
+ ) {}
91
+
92
+ identifier(name: string): string {
93
+ if (name === this.param) return `$${this.param}`
94
+ const signal = this.localVarMap.get(name)
95
+ if (signal) return `$${signal}`
96
+ return `$${name}`
97
+ }
98
+
99
+ literal(value: string | number | boolean | null, literalType: LiteralType): string {
100
+ if (literalType === 'string') return `'${value}'`
101
+ if (literalType === 'boolean') return value ? '1' : '0'
102
+ if (literalType === 'null') return 'undef'
103
+ return String(value)
104
+ }
105
+
106
+ member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
107
+ // `.length` on a higher-order result (e.g.
108
+ // `x.tags.filter(t => t.active).length > 0` inside the outer
109
+ // filter predicate, #1443). The higher-order emit produces an
110
+ // anonymous array ref `[grep ...]`; reading `->{length}` on that
111
+ // is undef at runtime, which is why the pre-#1443 `containsHigherOrder`
112
+ // gate refused this shape outright. Lowering `.length` to
113
+ // `scalar(@{...})` makes the result a real Perl integer.
114
+ if (property === 'length' && (asCallbackMethodCall(object) !== null || object.kind === 'array-literal')) {
115
+ return `scalar(@{${emit(object)}})`
116
+ }
117
+ return `${emit(object)}->{${property}}`
118
+ }
119
+
120
+ indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
121
+ return emitIndexAccessPerl(object, index, emit, this.isStringName)
122
+ }
123
+
124
+ call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
125
+ // Signal getter calls: filter() → $filter
126
+ if (callee.kind === 'identifier' && args.length === 0) {
127
+ return `$${callee.name}`
128
+ }
129
+ return emit(callee)
130
+ }
131
+
132
+ unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
133
+ const arg = emit(argument)
134
+ if (op === '!') {
135
+ // Wrap binary/logical operands in parens to dodge Perl precedence surprises.
136
+ const needsParens = argument.kind === 'binary' || argument.kind === 'logical'
137
+ return needsParens ? `!(${arg})` : `!${arg}`
138
+ }
139
+ if (op === '-') return `-${arg}`
140
+ return arg
141
+ }
142
+
143
+ binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
144
+ const l = emit(left)
145
+ const r = emit(right)
146
+ // String equality: `eq`/`ne` when EITHER operand is string-typed — a string
147
+ // literal, a string signal getter, or a string prop. Numeric `==`/`!=`
148
+ // would coerce both sides to 0 and match unrelated non-numeric strings (#1672).
149
+ const isStr = (e: ParsedExpr) => isStringTypedOperand(e, this.isStringName)
150
+ const stringCmp = isStr(left) || isStr(right)
151
+ if ((op === '===' || op === '==') && stringCmp) {
152
+ return `${l} eq ${r}`
153
+ }
154
+ if ((op === '!==' || op === '!=') && stringCmp) {
155
+ return `${l} ne ${r}`
156
+ }
157
+ const opMap: Record<string, string> = {
158
+ '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
159
+ '+': '+', '-': '-', '*': '*', '/': '/',
160
+ }
161
+ return `${l} ${opMap[op] ?? op} ${r}`
162
+ }
163
+
164
+ logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
165
+ const l = emit(left)
166
+ const r = emit(right)
167
+ if (op === '&&') return `(${l} && ${r})`
168
+ if (op === '||') return `(${l} || ${r})`
169
+ return `(${l} // ${r})`
170
+ }
171
+
172
+ callbackMethod(
173
+ method: string,
174
+ object: ParsedExpr,
175
+ arrow: Extract<ParsedExpr, { kind: 'arrow' }>,
176
+ _restArgs: ParsedExpr[],
177
+ emit: (e: ParsedExpr) => string,
178
+ ): string {
179
+ // Filter context only meaningfully handles the predicate methods
180
+ // (filter / every / some land here through nested `.filter(...)` chains).
181
+ // Sort / reduce / flatMap have no scalar Perl form here — the old
182
+ // `default` arm degraded them to the truthy sentinel, silently rewriting
183
+ // the predicate, so surface BF101 instead (#2038).
184
+ if (!PREDICATE_METHODS.has(method)) {
185
+ this.onUnsupported?.(
186
+ `Filter predicate contains a nested '.${method}(...)' callback, which has no Perl scalar form`,
187
+ `Rewrite the predicate without a nested callback method, or add /* @client */ for client-only evaluation (no SSR).`,
188
+ )
189
+ return '1'
190
+ }
191
+ // The predicate body is also a filter context, but with this
192
+ // callback's own `param` (potentially shadowing the outer one),
193
+ // so we spin up a nested emitter with the inner param.
194
+ const param = arrow.params[0]
195
+ const predicate = arrow.body
196
+ const arrayExpr = emit(object)
197
+ const predBody = emitParsedExpr(predicate, new MojoFilterEmitter(param, this.localVarMap, this.isStringName, this.onUnsupported))
198
+ const grepBody = predBody.replace(new RegExp(`\\$${param}\\b`, 'g'), '$_')
199
+ if (method === 'filter') return `[grep { ${grepBody} } @{${arrayExpr}}]`
200
+ if (method === 'every') return `!(grep { !(${grepBody}) } @{${arrayExpr}})`
201
+ if (method === 'some') return `!!(grep { ${grepBody} } @{${arrayExpr}})`
202
+ // `find` / `findIndex` / `findLast` / `findLastIndex` return an element
203
+ // (or index), not a boolean — there is no inline grep form. Degrading to
204
+ // the receiver silently changes predicate semantics, so be loud (#2038).
205
+ this.onUnsupported?.(
206
+ `Filter predicate contains a nested '.${method}(...)' callback, which has no Perl scalar form`,
207
+ `Rewrite the predicate without a nested callback method, or add /* @client */ for client-only evaluation (no SSR).`,
208
+ )
209
+ return arrayExpr
210
+ }
211
+
212
+ arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
213
+ // Perl array ref: `[$a, $b]`. Filter-context use is rare (the
214
+ // outer emitter routes most array-literal arrivals via
215
+ // MojoTopLevelEmitter), but #1443's chain
216
+ // `[a, b].filter(Boolean).join(' ')` can land here when the
217
+ // outer `.filter()` recurses into a nested filter whose own
218
+ // source is an array literal.
219
+ return `[${elements.map(emit).join(', ')}]`
220
+ }
221
+
222
+ arrayMethod(
223
+ method: ArrayMethod,
224
+ object: ParsedExpr,
225
+ args: ParsedExpr[],
226
+ emit: (e: ParsedExpr) => string,
227
+ ): string {
228
+ // Filter-context array methods are vanishingly rare — predicates
229
+ // operate on scalars, not arrays. Defer to the top-level rendering
230
+ // (`join(sep, @{...})`) for any case that does land here so the
231
+ // emission stays consistent across contexts.
232
+ return renderArrayMethod(method, object, args, emit)
233
+ }
234
+
235
+ flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
236
+ return renderFlatMethod(emit(object), depth)
237
+ }
238
+
239
+ conditional(_test: ParsedExpr, _consequent: ParsedExpr, _alternate: ParsedExpr): string {
240
+ return '1'
241
+ }
242
+
243
+ templateLiteral(_parts: TemplatePart[]): string {
244
+ return '1'
245
+ }
246
+
247
+ arrow(_params: string[], _body: ParsedExpr, _emit: (e: ParsedExpr) => string): string {
248
+ // A standalone arrow only reaches here outside a callback position, which
249
+ // never arises in a predicate context — emit the truthy sentinel like the
250
+ // pre-#2018 `arrowFn` fallback.
251
+ return '1'
252
+ }
253
+
254
+ regex(_raw: string): string {
255
+ // A standalone regex has no filter-predicate meaning — truthy sentinel.
256
+ return '1'
257
+ }
258
+
259
+ unsupported(_raw: string, _reason: string): string {
260
+ return '1'
261
+ }
262
+
263
+ objectLiteral(_properties: ObjectLiteralProperty[], _raw: string, _emit: (e: ParsedExpr) => string): string {
264
+ // Filter-predicate context: an object literal is not a boolean leaf, so
265
+ // emit the truthy sentinel exactly as `unsupported` does (byte-identical
266
+ // with the pre-`object-literal` fallback; Roadmap A-1). Object *values*
267
+ // are lowered to Perl hashrefs in the conditional/attr paths, not here.
268
+ return '1'
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Lowering for top-level expressions whose identifiers resolve against
274
+ * the Mojo template's stash (signals, props, locals introduced by
275
+ * `% my $x = ...;` lines). Differs from the filter emitter mainly in
276
+ * - `.length` → `scalar(@{...})` (filter contexts never see arrays
277
+ * in lvalue position),
278
+ * - `conditional` is supported (filter predicates can't return
279
+ * ternaries),
280
+ * - the `unsupported` fallback drops to the regex pipeline so legacy
281
+ * shapes the AST can't classify still emit something coherent.
282
+ */
283
+ export class MojoTopLevelEmitter implements ParsedExprEmitter {
284
+ constructor(private readonly ctx: MojoEmitContext) {}
285
+
286
+ identifier(name: string): string {
287
+ // `undefined` / `null` nested inside a larger expression tree
288
+ // (#1897, pagination's `props.isActive ? 'page' : undefined`) — the
289
+ // top-level short-circuits don't see them.
290
+ if (name === 'undefined' || name === 'null') return 'undef'
291
+ // Module pure-string const (e.g. `const baseClasses = '...'` used in a
292
+ // className template literal): inline the literal value rather than emit
293
+ // `$baseClasses` against a stash variable that is never bound.
294
+ const inlined = this.ctx.resolveModuleStringConst(name)
295
+ if (inlined !== null) return inlined
296
+ // Same for a literal const of any scope (`const totalPages = 5`,
297
+ // #1897 pagination's `Page {currentPage()} of {totalPages}`).
298
+ const literalConst = this.ctx.resolveLiteralConst(name)
299
+ if (literalConst !== null) return literalConst
300
+ return `$${name}`
301
+ }
302
+
303
+ literal(value: string | number | boolean | null, literalType: LiteralType): string {
304
+ if (literalType === 'string') return `'${value}'`
305
+ if (literalType === 'boolean') return value ? '1' : '0'
306
+ if (literalType === 'null') return 'undef'
307
+ return String(value)
308
+ }
309
+
310
+ member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
311
+ // `props.x` flattens to the bare `$x` the Mojo SSR caller binds each
312
+ // prop to (props arrive as individual `my $x = ...` vars, not a
313
+ // `$props` hashref).
314
+ if (object.kind === 'identifier' && object.name === 'props') {
315
+ return `$${property}`
316
+ }
317
+ // Static property access on a module object-literal const
318
+ // (`variantClasses.ghost`, #1897) resolves at compile time — the
319
+ // generic hash lowering below would dereference a Perl var that
320
+ // doesn't exist server-side.
321
+ if (object.kind === 'identifier') {
322
+ const staticValue = this.ctx.resolveStaticRecordLiteral(object.name, property)
323
+ if (staticValue !== null) return staticValue
324
+ }
325
+ const obj = emit(object)
326
+ if (property === 'length') return `scalar(@{${obj}})`
327
+ return `${obj}->{${property}}`
328
+ }
329
+
330
+ indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
331
+ return emitIndexAccessPerl(object, index, emit, n => this.ctx._isStringValueName(n))
332
+ }
333
+
334
+ call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
335
+ // Signal getter: count() → $count
336
+ if (callee.kind === 'identifier' && args.length === 0) {
337
+ return `$${callee.name}`
338
+ }
339
+ // Env-signal method call (#1922): `searchParams().get('sort')` is a real
340
+ // method call on the per-request `$searchParams` reader object, not the
341
+ // generic hash deref `member` would emit (`$searchParams->{get}`, which
342
+ // drops the arg). Matches the local import binding (incl. an alias).
343
+ if (this.ctx._searchParamsLocals.size > 0) {
344
+ const sp = matchSearchParamsMethodCall(callee, args, this.ctx._searchParamsLocals)
345
+ if (sp) {
346
+ return `$searchParams->${sp.method}(${sp.args.map(emit).join(', ')})`
347
+ }
348
+ }
349
+ // Identifier-path templatePrimitive (#1189): `JSON.stringify(x)` /
350
+ // `Math.floor(x)` → `bf->json($x)` / `bf->floor($x)`. Args render
351
+ // recursively through this same emitter so prop refs / signal calls
352
+ // inside them get the standard transforms. Mirrors the Go adapter's
353
+ // `call()` primitive dispatch. A wrong-arity call records BF101 and
354
+ // returns the safe `''` placeholder (never silently emits a bad call).
355
+ const path = identifierPath(callee)
356
+ const spec = path ? MOJO_TEMPLATE_PRIMITIVES[path] : undefined
357
+ if (path && spec) {
358
+ if (args.length === spec.arity) {
359
+ return spec.emit(args.map(emit))
360
+ }
361
+ this.ctx._recordExprBF101(
362
+ `templatePrimitive '${path}' expects ${spec.arity} arg(s), got ${args.length}`,
363
+ `Call '${path}' with exactly ${spec.arity} argument(s).`,
364
+ )
365
+ // Don't fall through to the generic `emit(callee)` below — for a
366
+ // member callee (`JSON.stringify`) that emits an invalid Perl
367
+ // hash-deref (`$JSON->{stringify}`). Return the same safe
368
+ // empty-string placeholder the other BF101 paths use.
369
+ return "''"
370
+ }
371
+ // Array methods (`.join` and any others added to ArrayMethod, #1443)
372
+ // are lifted into the `array-method` IR kind at parse time, so they
373
+ // never reach this dispatcher. Per-method detection here would mix
374
+ // value-builtin lowering with signal-call lowering — keeping them
375
+ // separated forces every adapter to declare the full array-method
376
+ // surface in one place (the `arrayMethod` emitter below).
377
+ return emit(callee)
378
+ }
379
+
380
+ unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
381
+ const arg = emit(argument)
382
+ if (op === '!') return `!${arg}`
383
+ if (op === '-') return `-${arg}`
384
+ return arg
385
+ }
386
+
387
+ binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
388
+ const l = emit(left)
389
+ const r = emit(right)
390
+ // String equality: `eq`/`ne` when EITHER operand is string-typed — a string
391
+ // literal (`role() === 'admin'`), a string signal getter (`sel()`), or a
392
+ // string prop (`props.x`). Falling back to numeric `==`/`!=` would make
393
+ // Perl coerce both sides to 0 and match unrelated non-numeric strings
394
+ // (`"b" == "a"` → true), so all loop items render their true branch (#1672).
395
+ const isStr = (e: ParsedExpr) => isStringTypedOperand(e, n => this.ctx._isStringValueName(n))
396
+ const stringCmp = isStr(left) || isStr(right)
397
+ if ((op === '===' || op === '==') && stringCmp) {
398
+ return `${l} eq ${r}`
399
+ }
400
+ if ((op === '!==' || op === '!=') && stringCmp) {
401
+ return `${l} ne ${r}`
402
+ }
403
+ const opMap: Record<string, string> = {
404
+ '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
405
+ '+': '+', '-': '-', '*': '*',
406
+ }
407
+ return `${l} ${opMap[op] ?? op} ${r}`
408
+ }
409
+
410
+ logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
411
+ const l = emit(left)
412
+ const r = emit(right)
413
+ if (op === '&&') return `(${l} && ${r})`
414
+ if (op === '||') return `(${l} || ${r})`
415
+ return `(${l} // ${r})`
416
+ }
417
+
418
+ callbackMethod(
419
+ method: string,
420
+ object: ParsedExpr,
421
+ arrow: Extract<ParsedExpr, { kind: 'arrow' }>,
422
+ restArgs: ParsedExpr[],
423
+ emit: (e: ParsedExpr) => string,
424
+ ): string {
425
+ const recv = emit(object)
426
+ const body = arrow.body
427
+ const params = arrow.params
428
+
429
+ // sort / toSorted: eval-first, then the structured `bf->sort` fallback
430
+ // (recovered from the arrow by `sortComparatorFromArrow`, e.g. a
431
+ // `localeCompare` comparator), then BF101.
432
+ if (method === 'sort' || method === 'toSorted') {
433
+ const evalForm = renderSortEval(recv, body, params, emit)
434
+ if (evalForm !== null) return evalForm
435
+ const c = sortComparatorFromArrow(arrow)
436
+ if (c !== null) return renderSortMethod(recv, c)
437
+ this.ctx._recordExprBF101(
438
+ `.${method}(...) comparator is not lowerable to a template sort`,
439
+ `Pre-sort the array in the route handler, or mark the loop @client-only.`,
440
+ )
441
+ return "''"
442
+ }
443
+
444
+ // reduce / reduceRight: eval-only (the arithmetic catalogue always
445
+ // serializes); BF101 when the body is outside the evaluator surface or the
446
+ // seed isn't a literal.
447
+ if (method === 'reduce' || method === 'reduceRight') {
448
+ const direction = method === 'reduceRight' ? 'right' : 'left'
449
+ const init = restArgs[0]
450
+ const evalForm =
451
+ init !== undefined ? renderReduceEval(recv, body, params, init, direction, emit) : null
452
+ if (evalForm !== null) return evalForm
453
+ this.ctx._recordExprBF101(
454
+ `.${method}(...) is not lowerable to a template fold`,
455
+ `Pre-compute the fold in the route handler, or mark the loop @client-only.`,
456
+ )
457
+ return "''"
458
+ }
459
+
460
+ // flatMap: eval-only; BF101 when the projection is outside the surface.
461
+ if (method === 'flatMap') {
462
+ const evalForm = renderFlatMapEval(recv, body, params[0], emit)
463
+ if (evalForm !== null) return evalForm
464
+ this.ctx._recordExprBF101(
465
+ `.flatMap(...) projection is not lowerable to a template flat-map`,
466
+ `Pre-compute the projection in the route handler, or mark the loop @client-only.`,
467
+ )
468
+ return "''"
469
+ }
470
+
471
+ // Value-producing map (#2073): eval-only; BF101 when the projection is
472
+ // outside the surface. (The JSX-returning `.map` is an IRLoop upstream.)
473
+ if (method === 'map') {
474
+ const evalForm = renderMapEval(recv, body, params[0], emit)
475
+ if (evalForm !== null) return evalForm
476
+ this.ctx._recordExprBF101(
477
+ `.map(...) projection is not lowerable to a template map`,
478
+ `Pre-compute the projection in the route handler, or mark the position @client-only.`,
479
+ )
480
+ return "''"
481
+ }
482
+
483
+ // Predicate methods: filter / find / every / some / findIndex / findLast /
484
+ // findLastIndex. Eval-first (#2018 P2), then the inline `grep` / `bf->find`
485
+ // fallback for a predicate the evaluator can't model.
486
+ const cb: PredicateCall = {
487
+ method: method as HigherOrderMethod,
488
+ object,
489
+ param: params[0],
490
+ predicate: body,
491
+ }
492
+ return this.renderPredicate(cb, recv, emit)
493
+ }
494
+
495
+ private renderPredicate(
496
+ cb: PredicateCall,
497
+ arrayExpr: string,
498
+ emit: (e: ParsedExpr) => string,
499
+ ): string {
500
+ const { method, param, predicate } = cb
501
+ // Evaluator path (#2018 P2): serialize the predicate body + emit the
502
+ // matching `bf->*_eval` helper (isomorphic with the Go adapter). Falls
503
+ // back to the inline `grep` / `bf->find` lowering below for a predicate
504
+ // the evaluator can't model (e.g. a method-call predicate).
505
+ const evalFn: Record<string, [string, boolean?]> = {
506
+ filter: ['filter_eval'], every: ['every_eval'], some: ['some_eval'],
507
+ find: ['find_eval', true], findLast: ['find_eval', false],
508
+ findIndex: ['find_index_eval', true], findLastIndex: ['find_index_eval', false],
509
+ }
510
+ // `.filter(Boolean)` (identity predicate `_t => _t`) keeps the inline
511
+ // `grep { $_ }` form — it composes through the array-method chain
512
+ // (`.filter(Boolean).join(' ')` in the registry Slot) and renders
513
+ // identically to a truthiness filter.
514
+ const isIdentity =
515
+ method === 'filter' && predicate.kind === 'identifier' && predicate.name === param
516
+ const spec = evalFn[method]
517
+ if (spec && !isIdentity) {
518
+ const evalForm = renderPredicateEval(spec[0], arrayExpr, predicate, param, emit, spec[1])
519
+ if (evalForm !== null) return evalForm
520
+ }
521
+
522
+ const predBody = this.ctx._renderPerlFilterExprPublic(predicate, param)
523
+ const grepBody = predBody.replace(new RegExp(`\\$${param}\\b`, 'g'), '$_')
524
+ if (method === 'filter') return `[grep { ${grepBody} } @{${arrayExpr}}]`
525
+ if (method === 'every') return `!(grep { !(${grepBody}) } @{${arrayExpr}})`
526
+ if (method === 'some') return `!!(grep { ${grepBody} } @{${arrayExpr}})`
527
+ // `.find` / `.findIndex` / `.findLast` / `.findLastIndex` → the runtime
528
+ // helpers (`bf->find` / `find_index` / `find_last` / `find_last_index`),
529
+ // which call the predicate as a per-element coderef — same shape Xslate
530
+ // emits via a Kolon lambda. The JS camelCase names map to the snake_case
531
+ // helpers (like index_of / last_index_of).
532
+ const findHelper: Record<string, string> = {
533
+ find: 'find',
534
+ findIndex: 'find_index',
535
+ findLast: 'find_last',
536
+ findLastIndex: 'find_last_index',
537
+ }
538
+ if (findHelper[method]) {
539
+ return `bf->${findHelper[method]}(${arrayExpr}, sub { my $${param} = $_[0]; ${predBody} })`
540
+ }
541
+ return arrayExpr
542
+ }
543
+
544
+ arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
545
+ // Perl array ref. Identifiers inside elements resolve through the
546
+ // top-level emitter so `[className, childClass]` becomes
547
+ // `[$className, $childClass]` (the registry Slot's chain in
548
+ // #1443). Empty `[]` stays as `[]` — a valid empty Perl array
549
+ // ref that grep/join handle naturally.
550
+ return `[${elements.map(emit).join(', ')}]`
551
+ }
552
+
553
+ arrayMethod(
554
+ method: ArrayMethod,
555
+ object: ParsedExpr,
556
+ args: ParsedExpr[],
557
+ emit: (e: ParsedExpr) => string,
558
+ ): string {
559
+ return renderArrayMethod(method, object, args, emit)
560
+ }
561
+
562
+ flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
563
+ return renderFlatMethod(emit(object), depth)
564
+ }
565
+
566
+ conditional(
567
+ test: ParsedExpr,
568
+ consequent: ParsedExpr,
569
+ alternate: ParsedExpr,
570
+ emit: (e: ParsedExpr) => string,
571
+ ): string {
572
+ return `(${emit(test)} ? ${emit(consequent)} : ${emit(alternate)})`
573
+ }
574
+
575
+ templateLiteral(parts: TemplatePart[], emit: (e: ParsedExpr) => string): string {
576
+ // `` `n=${count() + 1}` `` → Perl string concatenation
577
+ // (`"n=" . ($count + 1)`), NOT double-quote interpolation. Perl only
578
+ // interpolates simple `$var` reads inside `"..."`, so complex `${...}`
579
+ // parts — arithmetic, helper calls (`bf->json(...)`), ternaries —
580
+ // would render unevaluated if inlined into a quoted string.
581
+ // - Static chunks are emitted as quoted literals with the sigils
582
+ // that interpolate inside `"..."` (`$`/`@`) plus `"`/`\` escaped,
583
+ // so literal text survives verbatim.
584
+ // - Expression terms whose Perl precedence is below `.` (binary /
585
+ // logical / conditional) wrap in parens so they bind before the
586
+ // concatenation.
587
+ const terms: string[] = []
588
+ for (const part of parts) {
589
+ if (part.type === 'string') {
590
+ if (part.value !== '') {
591
+ terms.push(`"${part.value.replace(/[\\"$@]/g, m => `\\${m}`)}"`)
592
+ }
593
+ } else {
594
+ const rendered = emit(part.expr)
595
+ const needsParens =
596
+ part.expr.kind === 'binary' ||
597
+ part.expr.kind === 'logical' ||
598
+ part.expr.kind === 'conditional'
599
+ terms.push(needsParens ? `(${rendered})` : rendered)
600
+ }
601
+ }
602
+ if (terms.length === 0) return '""'
603
+ return terms.join(' . ')
604
+ }
605
+
606
+ arrow(_params: string[], _body: ParsedExpr, _emit: (e: ParsedExpr) => string): string {
607
+ // A bare arrow function never stands alone at a render position (it's
608
+ // only meaningful as a callback argument, handled by `callbackMethod`).
609
+ // Return the safe Perl empty-string literal `''` — consistent with the
610
+ // BF101 / `unsupported` paths — so a stray emit can't produce a `<%= %>`
611
+ // syntax error.
612
+ return "''"
613
+ }
614
+
615
+ regex(_raw: string): string {
616
+ // A standalone regex literal has no template render form (it only appears
617
+ // as a `.replace(/re/, …)` argument the parser refuses upstream). Mirror
618
+ // `arrow` / `unsupported` with the safe Perl empty-string literal.
619
+ return "''"
620
+ }
621
+
622
+ unsupported(_raw: string, _reason: string): string {
623
+ // Unreachable in the parse-first flow: `convertExpressionToPerl`
624
+ // gates on `isSupported` before dispatching, and `isSupported`
625
+ // recurses, so a top-level supported expression never contains an
626
+ // `unsupported` node. Return a safe Perl empty-string literal in
627
+ // case a future caller renders a node tree directly.
628
+ return "''"
629
+ }
630
+
631
+ objectLiteral(_properties: ObjectLiteralProperty[], _raw: string, _emit: (e: ParsedExpr) => string): string {
632
+ // Mirror `unsupported`: a bare object literal reaching the dispatcher
633
+ // lowers to the safe Perl empty-string literal, exactly as before the
634
+ // `object-literal` kind existed (byte-identical; Roadmap A-1). Object
635
+ // values that round-trip to a Perl hashref go through the dedicated
636
+ // `objectLiteralToPerlHashref` lowering in the conditional/attr paths.
637
+ return "''"
638
+ }
639
+ }