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