@barefootjs/mojolicious 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.
@@ -0,0 +1,1931 @@
1
+ /**
2
+ * BarefootJS Mojolicious EP Template Adapter
3
+ *
4
+ * Generates Mojolicious EP template files (.html.ep) from BarefootJS IR.
5
+ */
6
+
7
+ import type {
8
+ ComponentIR,
9
+ IRNode,
10
+ IRElement,
11
+ IRText,
12
+ IRExpression,
13
+ IRConditional,
14
+ IRLoop,
15
+ IRComponent,
16
+ IRFragment,
17
+ IRSlot,
18
+ IRIfStatement,
19
+ IRProvider,
20
+ IRAsync,
21
+ IRProp,
22
+ IRTemplatePart,
23
+ AttrValue,
24
+ CompilerError,
25
+ TemplatePrimitiveRegistry,
26
+ } from '@barefootjs/jsx'
27
+ import {
28
+ BaseAdapter,
29
+ type AdapterOutput,
30
+ type AdapterGenerateOptions,
31
+ type TemplateSections,
32
+ type ParsedExprEmitter,
33
+ type HigherOrderMethod,
34
+ type ArrayMethod,
35
+ type LiteralType,
36
+ type IRNodeEmitter,
37
+ type EmitIRNode,
38
+ type AttrValueEmitter,
39
+ isBooleanAttr,
40
+ parseExpression,
41
+ isSupported,
42
+ containsHigherOrder,
43
+ exprToString,
44
+ identifierPath,
45
+ stringifyParsedExpr,
46
+ emitParsedExpr,
47
+ emitIRNode,
48
+ emitAttrValue,
49
+ } from '@barefootjs/jsx'
50
+ import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result'
51
+
52
+ /**
53
+ * Mojo adapter's IRNode render context. Mojo's lowering currently
54
+ * doesn't consume any render-position flags (`isRootOfClientComponent`
55
+ * is handled differently here than in Hono/Go), so the Ctx is empty.
56
+ * Kept as a named alias so future flags can extend it without changing
57
+ * the `IRNodeEmitter` interface.
58
+ */
59
+ type MojoRenderCtx = Record<string, never>
60
+ import type { ParsedExpr, ParsedStatement, SortComparator, TemplatePart } from '@barefootjs/jsx'
61
+ import { BF_SLOT, BF_COND } from '@barefootjs/shared'
62
+
63
+ interface PrimitiveSpec {
64
+ arity: number
65
+ emit: (args: string[]) => string
66
+ }
67
+
68
+ /**
69
+ * Single source of truth for the Mojolicious adapter's
70
+ * template-primitive surface. Each entry pairs the expected arity
71
+ * with the emit function. Adding / removing a primitive is a
72
+ * one-line change.
73
+ *
74
+ * The emit fn returns a Perl expression (no surrounding `<%= %>`)
75
+ * suitable for embedding inside the Mojo template action —
76
+ * `bf->json($val)`, `bf->floor($val)`, etc. Args arrive already
77
+ * Perl-rendered via `convertExpressionToPerl` recursion, so a
78
+ * caller passing `props.config` reaches the emit fn as `$config`.
79
+ */
80
+ const MOJO_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
81
+ 'JSON.stringify': { arity: 1, emit: (args) => `bf->json(${args[0]})` },
82
+ 'String': { arity: 1, emit: (args) => `bf->string(${args[0]})` },
83
+ 'Number': { arity: 1, emit: (args) => `bf->number(${args[0]})` },
84
+ 'Math.floor': { arity: 1, emit: (args) => `bf->floor(${args[0]})` },
85
+ 'Math.ceil': { arity: 1, emit: (args) => `bf->ceil(${args[0]})` },
86
+ 'Math.round': { arity: 1, emit: (args) => `bf->round(${args[0]})` },
87
+ }
88
+
89
+ /**
90
+ * Cheap substring pre-check: skip the (expensive) `parseExpression`
91
+ * call when no primitive callee path appears in the source string.
92
+ * The common case is "no primitive present"; building the regex
93
+ * once from the registry keys keeps the gate in sync as new
94
+ * primitives land.
95
+ */
96
+ const PRIMITIVE_SUBSTRING_RE = new RegExp(
97
+ Object.keys(MOJO_TEMPLATE_PRIMITIVES)
98
+ .map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
99
+ .join('|')
100
+ )
101
+
102
+ /**
103
+ * Module-scope `templatePrimitives` map derived once from the spec
104
+ * record. Per-instance derivation would re-build the same Map on
105
+ * every `new MojoAdapter()` call.
106
+ */
107
+ const MOJO_PRIMITIVE_EMIT_MAP: Record<string, (args: string[]) => string> =
108
+ Object.fromEntries(
109
+ Object.entries(MOJO_TEMPLATE_PRIMITIVES).map(([k, v]) => [k, v.emit])
110
+ )
111
+
112
+ /**
113
+ * Find the `children` prop's `jsx-children` payload (#1326). Narrowed
114
+ * via the AttrValue `kind` discriminator so adapter code stays type-
115
+ * safe if the IR shape evolves — adding a new AttrValue variant or
116
+ * renaming `children` to `jsxChildren` becomes a TS compile error
117
+ * here instead of silently dropping the children at runtime.
118
+ */
119
+ function resolveJsxChildrenProp(props: readonly IRProp[]): IRNode[] {
120
+ const prop = props.find(p => p.name === 'children')
121
+ if (!prop) return []
122
+ if (prop.value.kind !== 'jsx-children') return []
123
+ return prop.value.children
124
+ }
125
+
126
+ export interface MojoAdapterOptions {
127
+ /** Base path for client JS files (default: '/static/components/') */
128
+ clientJsBasePath?: string
129
+
130
+ /** Path to barefoot.js runtime (default: '/static/components/barefoot.js') */
131
+ barefootJsPath?: string
132
+ }
133
+
134
+ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRenderCtx> {
135
+ name = 'mojolicious'
136
+ extension = '.html.ep'
137
+ templatesPerComponent = true
138
+
139
+ /**
140
+ * Identifier-path callees the Mojo runtime can render in template
141
+ * scope. The relocate pass consults this map to mark matching
142
+ * calls as template-safe so the surrounding expression stays
143
+ * inlinable; the SSR template emitter substitutes the JS call
144
+ * with the registered Perl helper invocation.
145
+ *
146
+ * The per-callee arity is read directly off `MOJO_TEMPLATE_PRIMITIVES`
147
+ * at substitution time, so this exposed shape stays as the
148
+ * `TemplateAdapter` interface expects (`emit`-only) without
149
+ * carrying a parallel arity map.
150
+ */
151
+ templatePrimitives: TemplatePrimitiveRegistry = MOJO_PRIMITIVE_EMIT_MAP
152
+
153
+ private componentName: string = ''
154
+ private options: Required<MojoAdapterOptions>
155
+ private errors: CompilerError[] = []
156
+ private inLoop: boolean = false
157
+ /**
158
+ * Re-entry guard for `convertHigherOrderExpr` (#1421).
159
+ *
160
+ * `MojoTopLevelEmitter.unsupported` falls back to the regex pipeline
161
+ * via `_convertExpressionToPerlPublic`, which re-detects the
162
+ * `.filter|every|some` short-circuit and re-enters
163
+ * `convertHigherOrderExpr` with the same raw text. When the parser
164
+ * carries the full original expression down to every nested
165
+ * `unsupported` node (e.g. an array-literal callee that the AST
166
+ * can't classify), the cycle has no terminator and the JS stack
167
+ * blows. The guard records the expression on entry, emits BF101 on
168
+ * second visit, and bails out — so the user sees an actionable
169
+ * diagnostic instead of `RangeError: Maximum call stack size`.
170
+ */
171
+ private higherOrderInFlight: Set<string> = new Set()
172
+ /**
173
+ * SolidJS-style props identifier (`function(props: P)`) and the
174
+ * analyzer-extracted prop names. Stashed at `generate()` entry so
175
+ * the per-attribute `emitSpread` callback can build a propsObject
176
+ * spread bag as an inline Perl hashref literal without re-walking
177
+ * the IR (#1407 follow-up).
178
+ */
179
+ private propsObjectName: string | null = null
180
+ private propsParams: { name: string }[] = []
181
+
182
+ constructor(options: MojoAdapterOptions = {}) {
183
+ super()
184
+ this.options = {
185
+ clientJsBasePath: options.clientJsBasePath ?? '/static/components/',
186
+ barefootJsPath: options.barefootJsPath ?? '/static/components/barefoot.js',
187
+ }
188
+ }
189
+
190
+ generate(ir: ComponentIR, options?: AdapterGenerateOptions): AdapterOutput {
191
+ this.componentName = ir.metadata.componentName
192
+ this.propsObjectName = ir.metadata.propsObjectName ?? null
193
+ this.propsParams = ir.metadata.propsParams.map(p => ({ name: p.name }))
194
+ this.errors = []
195
+ this.higherOrderInFlight = new Set()
196
+ this.childrenCaptureCounter = 0
197
+
198
+ // Mirror of the Go adapter's BF103 check (#1266): when a child
199
+ // component referenced inside a loop body is imported from a
200
+ // sibling .tsx, the Mojo adapter emits a `<%== bf->render(...)
201
+ // %>`-style cross-template call that resolves only if the user
202
+ // has compiled the sibling file and registered the resulting
203
+ // template alongside the parent. When that doesn't happen the
204
+ // failure is silent at build time and surfaces at request time —
205
+ // surface it loudly here so the user can act on it. Suppressed
206
+ // when the caller (e.g. the barefoot CLI) guarantees that all
207
+ // sibling templates are registered on the same template instance
208
+ // at render time.
209
+ if (!options?.siblingTemplatesRegistered) {
210
+ this.checkImportedLoopChildComponents(ir)
211
+ }
212
+
213
+ const templateBody = ir.root.type === 'if-statement'
214
+ ? this.renderIfStatement(ir.root as IRIfStatement)
215
+ : this.renderNode(ir.root)
216
+
217
+ // Generate script registration
218
+ const scriptReg = options?.skipScriptRegistration
219
+ ? ''
220
+ : this.generateScriptRegistrations(ir, options?.scriptBaseName)
221
+
222
+ const template = `${scriptReg}${templateBody}\n`
223
+
224
+ // Merge collected errors into IR errors
225
+ if (this.errors.length > 0) {
226
+ ir.errors.push(...this.errors)
227
+ }
228
+
229
+ // Mojo templates have no JS-style imports / types / default-export sections.
230
+ // The `templatesPerComponent` mode emits one file per component using the
231
+ // raw `template` value; sections are populated for contract uniformity so
232
+ // the compiler never has to fall back to string-parsing the template.
233
+ const sections: TemplateSections = {
234
+ imports: '',
235
+ types: '',
236
+ component: template,
237
+ defaultExport: '',
238
+ }
239
+
240
+ return {
241
+ template,
242
+ sections,
243
+ extension: this.extension,
244
+ }
245
+ }
246
+
247
+ // ===========================================================================
248
+ // Script Registration
249
+ // ===========================================================================
250
+
251
+ private generateScriptRegistrations(ir: ComponentIR, scriptBaseName?: string): string {
252
+ const hasInteractivity = this.hasClientInteractivity(ir)
253
+ if (!hasInteractivity) return ''
254
+
255
+ const name = scriptBaseName ?? ir.metadata.componentName
256
+ const runtimePath = this.options.barefootJsPath
257
+ const clientJsPath = `${this.options.clientJsBasePath}${name}.client.js`
258
+
259
+ const lines: string[] = []
260
+ lines.push(`% bf->register_script('${runtimePath}');`)
261
+ lines.push(`% bf->register_script('${clientJsPath}');`)
262
+ lines.push('')
263
+ return lines.join('\n')
264
+ }
265
+
266
+ private hasClientInteractivity(ir: ComponentIR): boolean {
267
+ return (
268
+ ir.metadata.signals.length > 0 ||
269
+ ir.metadata.effects.length > 0 ||
270
+ ir.metadata.onMounts.length > 0 ||
271
+ (ir.metadata.clientAnalysis?.needsInit ?? false)
272
+ )
273
+ }
274
+
275
+ // ===========================================================================
276
+ // Node Rendering
277
+ // ===========================================================================
278
+
279
+ /**
280
+ * Public entry point for node rendering. Delegates to the shared
281
+ * `IRNodeEmitter` dispatcher (#1290 step 1); per-kind logic lives in
282
+ * the `IRNodeEmitter` methods below.
283
+ */
284
+ renderNode(node: IRNode): string {
285
+ return emitIRNode<MojoRenderCtx>(node, this, {} as MojoRenderCtx)
286
+ }
287
+
288
+ // ===========================================================================
289
+ // IRNodeEmitter implementation (Mojo / Perl)
290
+ // ===========================================================================
291
+
292
+ emitElement(node: IRElement, _ctx: MojoRenderCtx, _emit: EmitIRNode<MojoRenderCtx>): string {
293
+ return this.renderElement(node)
294
+ }
295
+
296
+ emitText(node: IRText): string {
297
+ return node.value
298
+ }
299
+
300
+ emitExpression(node: IRExpression): string {
301
+ return this.renderExpression(node)
302
+ }
303
+
304
+ emitConditional(node: IRConditional, _ctx: MojoRenderCtx, _emit: EmitIRNode<MojoRenderCtx>): string {
305
+ return this.renderConditional(node)
306
+ }
307
+
308
+ emitLoop(node: IRLoop, _ctx: MojoRenderCtx, _emit: EmitIRNode<MojoRenderCtx>): string {
309
+ return this.renderLoop(node)
310
+ }
311
+
312
+ emitComponent(node: IRComponent, _ctx: MojoRenderCtx, _emit: EmitIRNode<MojoRenderCtx>): string {
313
+ return this.renderComponent(node)
314
+ }
315
+
316
+ emitFragment(node: IRFragment, _ctx: MojoRenderCtx, _emit: EmitIRNode<MojoRenderCtx>): string {
317
+ return this.renderFragment(node)
318
+ }
319
+
320
+ emitSlot(node: IRSlot): string {
321
+ return this.renderSlot(node)
322
+ }
323
+
324
+ emitIfStatement(node: IRIfStatement, _ctx: MojoRenderCtx, _emit: EmitIRNode<MojoRenderCtx>): string {
325
+ return this.renderIfStatement(node)
326
+ }
327
+
328
+ emitProvider(node: IRProvider, _ctx: MojoRenderCtx, _emit: EmitIRNode<MojoRenderCtx>): string {
329
+ return this.renderChildren(node.children)
330
+ }
331
+
332
+ emitAsync(node: IRAsync, _ctx: MojoRenderCtx, _emit: EmitIRNode<MojoRenderCtx>): string {
333
+ return this.renderAsync(node)
334
+ }
335
+
336
+ // ===========================================================================
337
+ // Element Rendering
338
+ // ===========================================================================
339
+
340
+ renderElement(element: IRElement): string {
341
+ const tag = element.tag
342
+ const attrs = this.renderAttributes(element)
343
+ const children = this.renderChildren(element.children)
344
+
345
+ let hydrationAttrs = ''
346
+ if (element.needsScope) {
347
+ hydrationAttrs += ` ${this.renderScopeMarker('')}`
348
+ }
349
+ if (element.slotId) {
350
+ hydrationAttrs += ` ${this.renderSlotMarker(element.slotId)}`
351
+ }
352
+
353
+ const voidElements = [
354
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
355
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
356
+ ]
357
+
358
+ if (voidElements.includes(tag.toLowerCase())) {
359
+ return `<${tag}${attrs}${hydrationAttrs}>`
360
+ }
361
+
362
+ return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
363
+ }
364
+
365
+ // ===========================================================================
366
+ // Expression Rendering
367
+ // ===========================================================================
368
+
369
+ renderExpression(expr: IRExpression): string {
370
+ if (expr.clientOnly) {
371
+ if (expr.slotId) {
372
+ return `<%== bf->comment("client:${expr.slotId}") %>`
373
+ }
374
+ return ''
375
+ }
376
+
377
+ const perlExpr = this.convertExpressionToPerl(expr.expr)
378
+
379
+ if (expr.slotId) {
380
+ return `<%== bf->text_start("${expr.slotId}") %><%= ${perlExpr} %><%== bf->text_end %>`
381
+ }
382
+
383
+ return `<%= ${perlExpr} %>`
384
+ }
385
+
386
+ // ===========================================================================
387
+ // Conditional Rendering
388
+ // ===========================================================================
389
+
390
+ renderConditional(cond: IRConditional): string {
391
+ if (cond.clientOnly && cond.slotId) {
392
+ return `<%== bf->comment("cond-start:${cond.slotId}") %><%== bf->comment("cond-end:${cond.slotId}") %>`
393
+ }
394
+
395
+ const condition = this.convertExpressionToPerl(cond.condition)
396
+ const whenTrue = this.renderNode(cond.whenTrue)
397
+ const whenFalse = this.renderNodeOrNull(cond.whenFalse)
398
+
399
+ // When slotId is present, add bf-c marker.
400
+ // Use comment markers for fragments (multiple sibling elements), attribute for single elements.
401
+ const isFragmentBranch = cond.whenTrue.type === 'fragment' || cond.whenFalse.type === 'fragment'
402
+ const useCommentMarkers = cond.slotId && isFragmentBranch
403
+
404
+ let markedTrue = whenTrue
405
+ let markedFalse = whenFalse
406
+ if (cond.slotId && !useCommentMarkers) {
407
+ markedTrue = this.addCondMarkerToFirstElement(whenTrue, cond.slotId)
408
+ markedFalse = whenFalse ? this.addCondMarkerToFirstElement(whenFalse, cond.slotId) : whenFalse
409
+ }
410
+
411
+ let result: string
412
+ if (useCommentMarkers) {
413
+ // Fragment branches: use comment markers
414
+ const inner = whenFalse
415
+ ? `\n% if (${condition}) {\n${whenTrue}\n% } else {\n${whenFalse}\n% }\n`
416
+ : `\n% if (${condition}) {\n${whenTrue}\n% }\n`
417
+ result = `<%== bf->comment("cond-start:${cond.slotId}") %>${inner}<%== bf->comment("cond-end:${cond.slotId}") %>`
418
+ } else if (markedFalse) {
419
+ result = `\n% if (${condition}) {\n${markedTrue}\n% } else {\n${markedFalse}\n% }\n`
420
+ } else if (cond.slotId) {
421
+ // Conditional with no else: wrap with comment markers for client hydration
422
+ result = `<%== bf->comment("cond-start:${cond.slotId}") %>\n% if (${condition}) {\n${whenTrue}\n% }\n<%== bf->comment("cond-end:${cond.slotId}") %>`
423
+ } else {
424
+ result = `\n% if (${condition}) {\n${whenTrue}\n% }\n`
425
+ }
426
+
427
+ return result
428
+ }
429
+
430
+ private renderNodeOrNull(node: IRNode): string | null {
431
+ if (node.type === 'expression' && (node.expr === 'null' || node.expr === 'undefined')) {
432
+ return null
433
+ }
434
+ return this.renderNode(node)
435
+ }
436
+
437
+ /**
438
+ * Add bf-c attribute to the first HTML element in a branch.
439
+ * If no element found, wrap with comment markers.
440
+ */
441
+ private addCondMarkerToFirstElement(content: string, condId: string): string {
442
+ // Match first HTML open tag
443
+ const match = content.match(/^(<\w+)([\s>])/)
444
+ if (match) {
445
+ return content.replace(/^(<\w+)([\s>])/, `$1 ${BF_COND}="${condId}"$2`)
446
+ }
447
+ // Fall back to comment markers for non-element content
448
+ return `<%== bf->comment("cond-start:${condId}") %>${content}<%== bf->comment("cond-end:${condId}") %>`
449
+ }
450
+
451
+ // ===========================================================================
452
+ // Imported-component-in-loop check (BF103, #1266)
453
+ // ===========================================================================
454
+
455
+ /**
456
+ * Push a `BF103` diagnostic for every component reference inside a
457
+ * loop body whose name is imported from a relative-path module.
458
+ * Mirror of the Go adapter's check — the Mojo adapter has the same
459
+ * cross-template-registration constraint at request time.
460
+ */
461
+ private checkImportedLoopChildComponents(ir: ComponentIR): void {
462
+ // Collect every name imported from a relative-path module (no
463
+ // case filter — `IRComponent` nodes only exist for PascalCase JSX
464
+ // usages, so a lowercase utility import in the set can't match
465
+ // anyway, and any heuristic on the import name itself would be
466
+ // strictly less robust than the structural IR check below).
467
+ const relativeImports = new Set<string>()
468
+ for (const imp of ir.metadata.templateImports ?? ir.metadata.imports ?? []) {
469
+ if (!imp.source.startsWith('./') && !imp.source.startsWith('../')) continue
470
+ if (imp.isTypeOnly) continue
471
+ for (const spec of imp.specifiers) {
472
+ relativeImports.add(spec.alias ?? spec.name)
473
+ }
474
+ }
475
+ if (relativeImports.size === 0) return
476
+
477
+ const loc = { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } }
478
+ const visit = (node: IRNode, inLoop: boolean): void => {
479
+ switch (node.type) {
480
+ case 'component': {
481
+ const comp = node as IRComponent
482
+ if (inLoop && relativeImports.has(comp.name)) {
483
+ this.errors.push({
484
+ code: 'BF103',
485
+ severity: 'error',
486
+ message: `Component <${comp.name}> is imported from a sibling module and used inside a loop. The Mojo adapter emits a cross-template call; the child template must be registered alongside the parent at render time.`,
487
+ loc: comp.loc ?? loc,
488
+ suggestion: {
489
+ message:
490
+ `Options:\n` +
491
+ ` 1. Compile '${comp.name}' (its source file) with the same adapter and register the resulting Mojo template alongside the parent at render time.\n` +
492
+ ` 2. Inline <${comp.name}> directly inside the loop body so no cross-file template lookup is needed.\n` +
493
+ ` 3. Mark the loop position as @client-only so the template is materialised on the client instead of at SSR time.`,
494
+ },
495
+ })
496
+ }
497
+ for (const child of comp.children) visit(child, inLoop)
498
+ break
499
+ }
500
+ case 'element':
501
+ for (const child of (node as IRElement).children) visit(child, inLoop)
502
+ break
503
+ case 'fragment':
504
+ for (const child of (node as IRFragment).children) visit(child, inLoop)
505
+ break
506
+ case 'conditional': {
507
+ const cond = node as IRConditional
508
+ visit(cond.whenTrue, inLoop)
509
+ if (cond.whenFalse) visit(cond.whenFalse, inLoop)
510
+ break
511
+ }
512
+ case 'loop':
513
+ for (const child of (node as IRLoop).children) visit(child, true)
514
+ break
515
+ case 'if-statement': {
516
+ const stmt = node as IRIfStatement
517
+ visit(stmt.consequent, inLoop)
518
+ if (stmt.alternate) visit(stmt.alternate, inLoop)
519
+ break
520
+ }
521
+ case 'provider':
522
+ for (const child of (node as IRProvider).children) visit(child, inLoop)
523
+ break
524
+ case 'async': {
525
+ const a = node as IRAsync
526
+ visit(a.fallback, inLoop)
527
+ for (const child of a.children) visit(child, inLoop)
528
+ break
529
+ }
530
+ }
531
+ }
532
+ visit(ir.root, false)
533
+ }
534
+
535
+ // ===========================================================================
536
+ // Loop Rendering
537
+ // ===========================================================================
538
+
539
+ renderLoop(loop: IRLoop): string {
540
+ // Client-only loops: skip SSR rendering entirely
541
+ if (loop.clientOnly) return ''
542
+
543
+ // An array/object-destructure loop param (`([emoji, users]) => ...`
544
+ // or `({ name, age }) => ...`) lowers to invalid Perl — the adapter
545
+ // would otherwise emit `% my $[emoji, users] = $entries->[$_i];`,
546
+ // which is a parse error. Surface this at build time (#1266)
547
+ // instead of shipping the broken template line for the user to
548
+ // discover at request time.
549
+ //
550
+ // Check the IR's structured `paramBindings` field rather than
551
+ // string-matching `loop.param`: Phase 1 populates `paramBindings`
552
+ // iff the param is a destructure pattern (array or object); a
553
+ // simple identifier leaves it `undefined`. The structured check is
554
+ // robust to whitespace / formatting variants in the source.
555
+ if (loop.paramBindings && loop.paramBindings.length > 0) {
556
+ this.errors.push({
557
+ code: 'BF104',
558
+ severity: 'error',
559
+ message: `Loop callback uses an array/object destructure pattern (\`${loop.param}\`) that the Mojo adapter cannot lower — Perl scalar bindings can't unpack a tuple in a single \`my\` declaration.`,
560
+ loc: loop.loc ?? { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
561
+ suggestion: {
562
+ message:
563
+ `Options:\n` +
564
+ ` 1. Rename the parameter to a single name and access tuple elements with index syntax in the body (e.g. \`entry => entry->[0]\` instead of \`([k, v]) => ...\`).\n` +
565
+ ` 2. Mark the loop position as @client-only so the destructure runs in JS on the client.\n` +
566
+ ` 3. Move the loop into a primitive that the adapter registers explicitly.`,
567
+ },
568
+ })
569
+ }
570
+
571
+ const rawArray = this.convertExpressionToPerl(loop.array)
572
+ // Apply sort if present (#1448 Tier B): wrap the loop array in
573
+ // the shared `bf->sort` helper. The same `renderSortMethod`
574
+ // feeds both this loop-chain hoist and the standalone
575
+ // `sortMethod()` arm on the emitter, so a regression in either
576
+ // path surfaces with the identical emit shape.
577
+ //
578
+ // Sort hoist: the loop bound (`0..$#{…}`) and the per-item
579
+ // lookup (`…->[$_i]`) both reference the same array — if the
580
+ // expression is a method call like `bf->sort(...)`, naive
581
+ // splicing would call the helper twice per render. Bind the
582
+ // sorted result to a `my` local so the helper runs once.
583
+ let sortedHoist: string | null = null
584
+ let array = rawArray
585
+ if (loop.sortComparator) {
586
+ sortedHoist = `bf_iter_${perlIdentifierFromMarkerId(loop.markerId)}`
587
+ array = `$${sortedHoist}`
588
+ }
589
+ const param = loop.param
590
+ const indexVar = loop.index ? `$${loop.index}` : '$_i'
591
+ const prevInLoop = this.inLoop
592
+ this.inLoop = true
593
+ const children = this.renderChildren(loop.children)
594
+ this.inLoop = prevInLoop
595
+
596
+ const lines: string[] = []
597
+ // Scoped per-call-site marker so sibling `.map()`s under the same parent
598
+ // each get their own reconciliation range (#1087).
599
+ lines.push(`<%== bf->comment("loop:${loop.markerId}") %>`)
600
+ if (sortedHoist && loop.sortComparator) {
601
+ lines.push(`% my $${sortedHoist} = ${renderSortMethod(rawArray, loop.sortComparator)};`)
602
+ }
603
+ lines.push(`% for my ${indexVar} (0..$#{${array}}) {`)
604
+ lines.push(`% my $${param} = ${array}->[${indexVar}];`)
605
+
606
+ // Handle filter().map() pattern by wrapping children in if-condition
607
+ if (loop.filterPredicate) {
608
+ let filterCond: string
609
+ if (loop.filterPredicate.blockBody) {
610
+ filterCond = this.renderBlockBodyCondition(
611
+ loop.filterPredicate.blockBody,
612
+ loop.filterPredicate.param
613
+ )
614
+ } else if (loop.filterPredicate.predicate) {
615
+ filterCond = this.renderPerlFilterExpr(
616
+ loop.filterPredicate.predicate,
617
+ loop.filterPredicate.param
618
+ )
619
+ } else {
620
+ filterCond = '1'
621
+ }
622
+ // Map filter param to loop param (e.g., $t → $todo)
623
+ if (loop.filterPredicate.param !== param) {
624
+ filterCond = filterCond.replace(
625
+ new RegExp(`\\$${loop.filterPredicate.param}\\b`, 'g'),
626
+ `$${param}`
627
+ )
628
+ }
629
+ lines.push(`% if (${filterCond}) {`)
630
+ lines.push(children)
631
+ lines.push(`% }`)
632
+ } else {
633
+ lines.push(children)
634
+ }
635
+
636
+ lines.push(`% }`)
637
+ lines.push(`<%== bf->comment("/loop:${loop.markerId}") %>`)
638
+
639
+ return lines.join('\n')
640
+ }
641
+
642
+ // ===========================================================================
643
+ // Component Rendering
644
+ // ===========================================================================
645
+
646
+ /**
647
+ * AttrValue lowering for component invocation props (Mojo / Perl
648
+ * named-arg form). Routed through the shared dispatcher so a new
649
+ * AttrValue kind becomes a TS compile error here (#1290 step 2).
650
+ *
651
+ * `jsx-children` returns empty — children are captured via Mojo's
652
+ * `begin %>…<% end` block below, not threaded through the
653
+ * `render_child` named-arg list.
654
+ */
655
+ private readonly componentPropEmitter: AttrValueEmitter = {
656
+ emitLiteral: (value, name) => `${name} => '${value.value}'`,
657
+ emitExpression: (value, name) => {
658
+ // The IR producer collapses component-prop `template` kinds
659
+ // into `expression` for client-runtime reasons but preserves
660
+ // the parsed parts on `v.parts`. Prefer the structured form
661
+ // when available — the bare-expression path can't handle
662
+ // `${MAP[KEY]}` shapes (the JS object literal leaks into the
663
+ // Perl template).
664
+ if (value.parts) {
665
+ return `${name} => ${this.convertTemplateLiteralPartsToPerl(value.parts)}`
666
+ }
667
+ return `${name} => ${this.convertExpressionToPerl(value.expr)}`
668
+ },
669
+ emitSpread: (value) => {
670
+ // Perl has no JS-style spread — emit the source as a hash
671
+ // dereference so its entries flatten into the named-arg list
672
+ // `render_child` accepts. `$props` already comes in as a hashref
673
+ // by `render_child`'s calling convention; deref with `%{}`. If
674
+ // `convertExpressionToPerl` already produced a hash variable
675
+ // (`%foo`), leave as-is.
676
+ const perlExpr = this.convertExpressionToPerl(value.expr)
677
+ return perlExpr.startsWith('%') ? perlExpr : `%{${perlExpr}}`
678
+ },
679
+ emitTemplate: (value, name) =>
680
+ `${name} => ${this.convertTemplateLiteralPartsToPerl(value.parts)}`,
681
+ emitBooleanAttr: (_value, name) => `${name} => 1`,
682
+ emitBooleanShorthand: (_value, name) => `${name} => 1`,
683
+ // JSX children flow through Mojo's `begin %>…<% end` capture
684
+ // below; they're not part of the named-arg list.
685
+ emitJsxChildren: () => '',
686
+ }
687
+
688
+ renderComponent(comp: IRComponent): string {
689
+ const propParts: string[] = []
690
+ for (const p of comp.props) {
691
+ // Skip callback props (onXxx) — event handlers are client-only for SSR.
692
+ if (p.name.match(/^on[A-Z]/) && p.value.kind === 'expression') continue
693
+ const lowered = emitAttrValue(p.value, this.componentPropEmitter, p.name)
694
+ if (lowered) propParts.push(lowered)
695
+ }
696
+ // Pass slot ID so the child renderer can set correct scope ID for hydration
697
+ // Skip for loop children — they use ComponentName_random pattern instead
698
+ if (comp.slotId && !this.inLoop) {
699
+ propParts.push(`_bf_slot => '${comp.slotId}'`)
700
+ }
701
+ const propsStr = propParts.length > 0 ? ', ' + propParts.join(', ') : ''
702
+ const tplName = this.toTemplateName(comp.name)
703
+ // Resolve the effective children: a nested `<Box>…</Box>` populates
704
+ // `comp.children`; an attribute-form `<Box children={<jsx/>} />`
705
+ // lands in a `jsx-children` AttrValue on the corresponding prop
706
+ // (#1326). The parent's scope marker is already attached to each
707
+ // hoisted root by the IR collector (`needsScope: true`), so the
708
+ // adapter just needs to render the IR through the same children
709
+ // pipeline as the nested form. Narrow the prop value via the
710
+ // `kind` discriminator instead of casting to a hand-written shape;
711
+ // any future change to the `jsx-children` AttrValue surface will
712
+ // surface here as a TS compile error.
713
+ const effectiveChildren: IRNode[] = comp.children.length > 0
714
+ ? comp.children
715
+ : resolveJsxChildrenProp(comp.props)
716
+ if (effectiveChildren.length > 0) {
717
+ // Forward JSX children via Mojo's `begin %>...<% end` capture so
718
+ // dynamic segments inside the children (signals, conditionals)
719
+ // get evaluated in the parent's template scope before reaching
720
+ // the child renderer. The capture has to live in a separate
721
+ // action — embedding it inside the `<%== ... %>` that wraps
722
+ // `render_child` would let the inner `%>` close the outer tag.
723
+ // `render_child` materializes the resulting CODE ref into the
724
+ // captured Mojo::ByteStream.
725
+ const childrenBody = this.renderChildren(effectiveChildren)
726
+ const varName = `$bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
727
+ return `<% my ${varName} = begin %>${childrenBody}<% end %><%== bf->render_child('${tplName}'${propsStr}, children => ${varName}) %>`
728
+ }
729
+ return `<%== bf->render_child('${tplName}'${propsStr}) %>`
730
+ }
731
+
732
+ private childrenCaptureCounter = 0
733
+
734
+ private toTemplateName(componentName: string): string {
735
+ // Convert PascalCase to snake_case for Mojo template naming
736
+ return componentName
737
+ .replace(/([A-Z])/g, '_$1')
738
+ .toLowerCase()
739
+ .replace(/^_/, '')
740
+ }
741
+
742
+ // ===========================================================================
743
+ // If-Statement (Conditional Return) Rendering
744
+ // ===========================================================================
745
+
746
+ private renderIfStatement(ifStmt: IRIfStatement): string {
747
+ const condition = this.convertExpressionToPerl(ifStmt.condition)
748
+ const consequent = ifStmt.consequent.type === 'if-statement'
749
+ ? this.renderIfStatement(ifStmt.consequent as IRIfStatement)
750
+ : this.renderNode(ifStmt.consequent)
751
+ let result = `% if (${condition}) {\n${consequent}\n`
752
+
753
+ if (ifStmt.alternate) {
754
+ if (ifStmt.alternate.type === 'if-statement') {
755
+ const altResult = this.renderIfStatement(ifStmt.alternate as IRIfStatement)
756
+ // Replace leading "% if" with "% } elsif"
757
+ result += altResult.replace(/^% if/, '% } elsif')
758
+ } else {
759
+ const alternate = this.renderNode(ifStmt.alternate)
760
+ result += `% } else {\n${alternate}\n`
761
+ }
762
+ }
763
+
764
+ result += `% }`
765
+ return result
766
+ }
767
+
768
+ // ===========================================================================
769
+ // Fragment & Slot Rendering
770
+ // ===========================================================================
771
+
772
+ private renderFragment(fragment: IRFragment): string {
773
+ const children = this.renderChildren(fragment.children)
774
+ if (fragment.needsScopeComment) {
775
+ return `<%== bf->scope_comment %>${children}`
776
+ }
777
+ return children
778
+ }
779
+
780
+ private renderSlot(_slot: IRSlot): string {
781
+ return `<%= content %>`
782
+ }
783
+
784
+ renderAsync(node: IRAsync): string {
785
+ const fallback = this.renderNode(node.fallback)
786
+ const children = this.renderChildren(node.children)
787
+ // Use the BarefootJS.pm streaming helpers for OOS streaming.
788
+ // bf->async_boundary() wraps the fallback in a <div bf-async="aX"> placeholder.
789
+ // The resolved content is rendered below for non-streaming fallback;
790
+ // in streaming mode, Mojo's write_chunk delivers it as a resolve chunk.
791
+ //
792
+ // The fallback is captured into a CODE ref via `begin %>…<% end` in
793
+ // its own action — embedding the `begin/end` inside the `<%== ... %>`
794
+ // that wraps `async_boundary` would let the inner `%>` close the
795
+ // outer tag, leaving the trailing `)` in plain template text and
796
+ // breaking Mojo's lexer (#1298). Same shape as `renderComponent`'s
797
+ // children capture.
798
+ const fallbackVar = `$bf_async_fallback_${node.id}`
799
+ return `<% my ${fallbackVar} = begin %>${fallback}<% end %><%== bf->async_boundary('${node.id}', ${fallbackVar}) %>\n${children}`
800
+ }
801
+
802
+ // ===========================================================================
803
+ // Attribute Rendering
804
+ // ===========================================================================
805
+
806
+ /**
807
+ * AttrValue lowering for intrinsic-element attributes (Mojo / EP
808
+ * template). Routed through the shared dispatcher (#1290 step 2).
809
+ */
810
+ private readonly elementAttrEmitter: AttrValueEmitter = {
811
+ emitLiteral: (value, name) => `${name}="${value.value}"`,
812
+ emitExpression: (value, name) => {
813
+ // Refuse shapes that the regex pipeline silently mangles into
814
+ // invalid Perl (#1322). Object literals (`style={{...}}`) and
815
+ // tagged-template-literal call expressions (`cn\`base \${tone()}\``)
816
+ // have no idiomatic Mojo template form; the Go adapter raises
817
+ // BF101 here via `convertExpressionToGo` + `isSupported`. Lift the
818
+ // same gate so the user gets a clear diagnostic instead of broken
819
+ // output. The check runs before `convertExpressionToPerl` so the
820
+ // regex pipeline never produces template-text fragments for a
821
+ // shape we've already rejected.
822
+ if (this.refuseUnsupportedAttrExpression(value.expr, name)) {
823
+ return ''
824
+ }
825
+ if (isBooleanAttr(name) || value.presenceOrUndefined) {
826
+ // Boolean attributes: render conditionally (present or absent).
827
+ return `<%= ${this.convertExpressionToPerl(value.expr)} ? '${name}' : '' %>`
828
+ }
829
+ // Boolean-result handling (#1466 follow-up). Two trigger paths:
830
+ //
831
+ // - `isBooleanResultExpr(expr)` — the JS source structurally
832
+ // evaluates to a boolean (comparison, `!`, literal,
833
+ // both-sides-boolean logical / conditional).
834
+ // - `isAriaBooleanAttr(name)` — the attribute is one of the
835
+ // ARIA tri-state / boolean-state names whose spec values are
836
+ // `"true" | "false" (| "mixed")`. The expression itself can
837
+ // be opaque (e.g. `accepted()` — a call expression we can't
838
+ // classify from source text), so we lean on the attribute
839
+ // name as the type witness.
840
+ //
841
+ // Without either, Perl's auto-stringification turns a JS-false
842
+ // comparison into `''` (and a JS-true comparison into `'1'`),
843
+ // which renders as `attr=""` / `attr="1"` — diverging from
844
+ // Hono's / Go's `attr="false"` / `attr="true"`. Routing through
845
+ // the `bf->bool_str` Perl helper realigns the wire bytes with
846
+ // JS `String(boolean)` semantics.
847
+ const perl = this.convertExpressionToPerl(value.expr)
848
+ if (isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name)) {
849
+ return `${name}="<%= bf->bool_str(${perl}) %>"`
850
+ }
851
+ return `${name}="<%= ${perl} %>"`
852
+ },
853
+ emitBooleanAttr: (_value, name) => name,
854
+ emitTemplate: (value, name) =>
855
+ `${name}="<%= ${this.convertTemplateLiteralPartsToPerl(value.parts)} %>"`,
856
+ // Spread attributes (`<div {...attrs()} />`) lower through the
857
+ // `bf->spread_attrs` Perl runtime helper (#1407), mirroring the
858
+ // Go adapter's `bf_spread_attrs` and the JS `spreadAttrs` from
859
+ // `@barefootjs/client/runtime`. The bag's source JS expression
860
+ // is translated to a Perl expression via `convertExpressionToPerl`
861
+ // (e.g. `attrs()` → `$attrs`, `props.bag` → `$bag`); the helper
862
+ // accepts a hashref and emits a Mojo::ByteStream with sorted,
863
+ // escaped `key="value"` pairs.
864
+ //
865
+ // No struct-field plumbing is needed on Perl: templates evaluate
866
+ // expressions inline against the props hash, so the spread
867
+ // identifier resolves directly. `IRAttribute.slotId` is set by
868
+ // the IR pass but the Mojo adapter ignores it — the slot field
869
+ // exists only for the Go adapter's static-typed Props struct.
870
+ //
871
+ // Gate unsupported shapes (object literals, tagged-template
872
+ // literals, etc.) up front via `refuseUnsupportedAttrExpression`
873
+ // so a spread like `{...{id: 'x'}}` surfaces BF101 instead of
874
+ // letting `convertExpressionToPerl` emit invalid Embedded Perl
875
+ // that would crash at render time (#1413 review).
876
+ emitSpread: (value) => {
877
+ if (this.refuseUnsupportedAttrExpression(value.expr, '...')) {
878
+ return ''
879
+ }
880
+ // SolidJS-style props identifier (`(props: P) { <el {...props}/> }`)
881
+ // has no matching `$props` variable in Mojo's template scope —
882
+ // Perl props arrive as a flat hash with one key per `propsParams`
883
+ // entry, not as a single nested object. Emit an inline hashref
884
+ // literal that enumerates the analyzer-extracted props params
885
+ // so `$bf->spread_attrs(...)` gets a real hashref (#1407
886
+ // follow-up; matches the Go adapter's same-shape map-literal
887
+ // path). For `restPropsName` and other identifier shapes, the
888
+ // standard `convertExpressionToPerl` translation handles it
889
+ // (rest binding name → `$<name>` resolves against the hashref
890
+ // the caller / harness placed under that key).
891
+ const trimmed = value.expr.trim()
892
+ if (this.propsObjectName && this.propsObjectName === trimmed) {
893
+ const entries = this.propsParams.map(p =>
894
+ `${JSON.stringify(p.name)} => $${p.name}`,
895
+ )
896
+ return `<%== bf->spread_attrs({${entries.join(', ')}}) %>`
897
+ }
898
+ const perlExpr = this.convertExpressionToPerl(value.expr)
899
+ return `<%== bf->spread_attrs(${perlExpr}) %>`
900
+ },
901
+ // Neither variant is legal on intrinsic elements.
902
+ emitBooleanShorthand: () => '',
903
+ emitJsxChildren: () => '',
904
+ }
905
+
906
+ private renderAttributes(element: IRElement): string {
907
+ const parts: string[] = []
908
+
909
+ for (const attr of element.attrs) {
910
+ // Rewrite JSX special-prop names to their HTML-attribute
911
+ // counterparts (#1475). `className` → `class` was already
912
+ // wired in; the `key` → `data-key` rewrite matches the
913
+ // canonical Hono attribute name the client runtime
914
+ // reconciles against. Hono SSR strips raw `key` via its JSX
915
+ // runtime; the Mojo template path has no such layer so the
916
+ // rewrite happens at attribute-emit time.
917
+ let attrName: string
918
+ if (attr.name === 'className') attrName = 'class'
919
+ else if (attr.name === 'key') attrName = 'data-key'
920
+ else attrName = attr.name
921
+ const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
922
+ if (lowered) parts.push(lowered)
923
+ }
924
+
925
+ return parts.length > 0 ? ' ' + parts.join(' ') : ''
926
+ }
927
+
928
+ // ===========================================================================
929
+ // Hydration Markers
930
+ // ===========================================================================
931
+
932
+ renderScopeMarker(_instanceIdExpr: string): string {
933
+ // bf-s is the addressable scope id (#1249 — bare, no `~` prefix).
934
+ // hydration_attrs adds bf-h / bf-m / bf-r conditionally.
935
+ return `bf-s="<%= bf->scope_attr %>" <%== bf->hydration_attrs %> <%== bf->props_attr %>`
936
+ }
937
+
938
+ renderSlotMarker(slotId: string): string {
939
+ return `${BF_SLOT}="${slotId}"`
940
+ }
941
+
942
+ renderCondMarker(condId: string): string {
943
+ return `${BF_COND}="${condId}"`
944
+ }
945
+
946
+ // ===========================================================================
947
+ // Filter Predicate Rendering (ParsedExpr → Perl)
948
+ // ===========================================================================
949
+
950
+ /**
951
+ * Convert a ParsedExpr AST to Perl expression string for filter
952
+ * predicates. Wraps the shared ParsedExpr dispatcher with a
953
+ * `MojoFilterEmitter` carrying the predicate's loop param and
954
+ * any block-body local var aliases (#1250 phase 1B).
955
+ */
956
+ private renderPerlFilterExpr(
957
+ expr: ParsedExpr,
958
+ param: string,
959
+ localVarMap: Map<string, string> = new Map(),
960
+ ): string {
961
+ // Nested higher-order in filter predicates was refused outright
962
+ // until #1443 PR4 because the `member` emit produced
963
+ // `[ ... ]->{length}` for `.length` on a `[grep ...]` anonymous
964
+ // array ref — undef at runtime. With `MojoFilterEmitter.member`
965
+ // now lowering `.length` on a higher-order object to
966
+ // `scalar(@{...})`, the canonical
967
+ // `x.tags.filter(t => t.active).length > 0` shape lowers
968
+ // cleanly. Predicates that combine a nested higher-order with
969
+ // something OTHER than `.length` (e.g. `.includes`, `.join`)
970
+ // still fall back to whatever the emitter produces — most of
971
+ // those would yield runtime errors in Perl, which is the user's
972
+ // signal to refactor. Wholesale refusal would also block the
973
+ // canonical case the issue exists to enable.
974
+ return emitParsedExpr(expr, new MojoFilterEmitter(param, localVarMap))
975
+ }
976
+
977
+ /**
978
+ * Render a complex block body filter into a Perl condition.
979
+ * Handles patterns like: filter(t => { const f = filter(); if (...) return ...; })
980
+ */
981
+ private renderBlockBodyCondition(
982
+ statements: ParsedStatement[],
983
+ param: string
984
+ ): string {
985
+ const localVarMap = new Map<string, string>()
986
+ const paths = this.collectReturnPaths(statements, [], localVarMap, param)
987
+
988
+ if (paths.length === 0) return '1'
989
+ if (paths.length === 1) return this.buildSinglePathCondition(paths[0], param, localVarMap)
990
+
991
+ // Multiple paths: build OR condition
992
+ const parts: string[] = []
993
+ for (const path of paths) {
994
+ if (path.result.kind === 'literal' && path.result.literalType === 'boolean' && path.result.value === false) continue
995
+ const cond = this.buildSinglePathCondition(path, param, localVarMap)
996
+ if (cond !== '0') parts.push(cond)
997
+ }
998
+
999
+ if (parts.length === 0) return '0'
1000
+ if (parts.length === 1) return parts[0]
1001
+ return `(${parts.join(' || ')})`
1002
+ }
1003
+
1004
+ private collectReturnPaths(
1005
+ statements: ParsedStatement[],
1006
+ currentConditions: ParsedExpr[],
1007
+ localVarMap: Map<string, string>,
1008
+ param: string
1009
+ ): Array<{ conditions: ParsedExpr[]; result: ParsedExpr }> {
1010
+ const paths: Array<{ conditions: ParsedExpr[]; result: ParsedExpr }> = []
1011
+
1012
+ for (const stmt of statements) {
1013
+ if (stmt.kind === 'var-decl') {
1014
+ if (stmt.init.kind === 'call' && stmt.init.callee.kind === 'identifier') {
1015
+ localVarMap.set(stmt.name, stmt.init.callee.name)
1016
+ }
1017
+ } else if (stmt.kind === 'return') {
1018
+ paths.push({ conditions: [...currentConditions], result: stmt.value })
1019
+ break
1020
+ } else if (stmt.kind === 'if') {
1021
+ const thenPaths = this.collectReturnPaths(stmt.consequent, [...currentConditions, stmt.condition], localVarMap, param)
1022
+ paths.push(...thenPaths)
1023
+
1024
+ if (stmt.alternate) {
1025
+ const negated: ParsedExpr = { kind: 'unary', op: '!', argument: stmt.condition }
1026
+ const elsePaths = this.collectReturnPaths(stmt.alternate, [...currentConditions, negated], localVarMap, param)
1027
+ paths.push(...elsePaths)
1028
+ } else {
1029
+ currentConditions.push({ kind: 'unary', op: '!', argument: stmt.condition })
1030
+ }
1031
+ }
1032
+ }
1033
+
1034
+ return paths
1035
+ }
1036
+
1037
+ private buildSinglePathCondition(
1038
+ path: { conditions: ParsedExpr[]; result: ParsedExpr },
1039
+ param: string,
1040
+ localVarMap: Map<string, string>
1041
+ ): string {
1042
+ if (path.result.kind === 'literal' && path.result.literalType === 'boolean') {
1043
+ if (path.result.value === true) {
1044
+ if (path.conditions.length === 0) return '1'
1045
+ return this.renderConditionsAnd(path.conditions, param, localVarMap)
1046
+ }
1047
+ return '0'
1048
+ }
1049
+
1050
+ if (path.conditions.length === 0) {
1051
+ return this.renderPerlFilterExpr(path.result, param, localVarMap)
1052
+ }
1053
+
1054
+ const condPart = this.renderConditionsAnd(path.conditions, param, localVarMap)
1055
+ const resultPart = this.renderPerlFilterExpr(path.result, param, localVarMap)
1056
+ return `(${condPart} && ${resultPart})`
1057
+ }
1058
+
1059
+ private renderConditionsAnd(
1060
+ conditions: ParsedExpr[],
1061
+ param: string,
1062
+ localVarMap: Map<string, string>
1063
+ ): string {
1064
+ if (conditions.length === 0) return '1'
1065
+ if (conditions.length === 1) return this.renderPerlFilterExpr(conditions[0], param, localVarMap)
1066
+ const parts = conditions.map(c => this.renderPerlFilterExpr(c, param, localVarMap))
1067
+ return `(${parts.join(' && ')})`
1068
+ }
1069
+
1070
+ // ===========================================================================
1071
+ // Expression Conversion: JS → Perl
1072
+ // ===========================================================================
1073
+
1074
+ private convertTemplateLiteralPartsToPerl(literalParts: IRTemplatePart[]): string {
1075
+ const parts: string[] = []
1076
+ for (const part of literalParts) {
1077
+ if (part.type === 'string') {
1078
+ // The IR producer may leave `${ident}` / `${_p.ident}`
1079
+ // interpolations in `string` parts when it can't statically
1080
+ // inline them (typically a destructured prop the caller will
1081
+ // supply at hydrate time, e.g. `${className}` in shadcn-style
1082
+ // composition). Substitute those to their Perl variable form
1083
+ // before quoting, otherwise the single-quoted literal here
1084
+ // passes the JS-shape interpolation through verbatim into the
1085
+ // rendered HTML.
1086
+ parts.push(this.substituteJsInterpolationsToPerl(part.value))
1087
+ } else if (part.type === 'ternary') {
1088
+ const cond = this.convertExpressionToPerl(part.condition)
1089
+ parts.push(`(${cond} ? '${part.whenTrue}' : '${part.whenFalse}')`)
1090
+ } else if (part.type === 'lookup') {
1091
+ // `${MAP[KEY]}` against a Record<T, string> literal — emit a
1092
+ // Perl anonymous hash with an immediate `->{ $key } // ''`
1093
+ // lookup. The `//''` guard turns a miss into an empty string,
1094
+ // matching the go-template adapter's "empty when no case
1095
+ // matches" semantics. Pass `key` through `convertExpressionToPerl`
1096
+ // so its top-level-identifier tail (`variant` → `$variant`) and
1097
+ // existing `props.x` rule apply uniformly.
1098
+ const keyExpr = this.convertExpressionToPerl(part.key)
1099
+ const entries = Object.entries(part.cases)
1100
+ .map(([k, v]) => `'${k}' => '${v}'`)
1101
+ .join(', ')
1102
+ parts.push(`({ ${entries} }->{${keyExpr}} // '')`)
1103
+ }
1104
+ }
1105
+ // Join with Perl string concatenation
1106
+ return parts.length === 1 ? parts[0] : parts.join(' . ')
1107
+ }
1108
+
1109
+ /**
1110
+ * Translate `${EXPR}` interpolations in a static template-part string
1111
+ * into Perl variable references and concatenate them with the
1112
+ * surrounding literal text. Used by `convertTemplateLiteralPartsToPerl`
1113
+ * when a `string` part still carries unresolved interpolations (e.g.
1114
+ * `${className}` from a destructured prop the IR analyzer couldn't
1115
+ * inline statically).
1116
+ */
1117
+ private substituteJsInterpolationsToPerl(s: string): string {
1118
+ const segments: string[] = []
1119
+ const re = /\$\{([^}]+)\}/g
1120
+ let lastIndex = 0
1121
+ let m: RegExpExecArray | null
1122
+ while ((m = re.exec(s)) !== null) {
1123
+ if (m.index > lastIndex) {
1124
+ segments.push(`'${s.slice(lastIndex, m.index)}'`)
1125
+ }
1126
+ segments.push(this.convertExpressionToPerl(m[1].trim()))
1127
+ lastIndex = re.lastIndex
1128
+ }
1129
+ if (lastIndex < s.length) {
1130
+ segments.push(`'${s.slice(lastIndex)}'`)
1131
+ }
1132
+ if (segments.length === 0) return `''`
1133
+ return segments.length === 1 ? segments[0] : `(${segments.join(' . ')})`
1134
+ }
1135
+
1136
+ /**
1137
+ * Refuse JS expression shapes that have no idiomatic Mojo template
1138
+ * representation (#1322). Currently catches:
1139
+ *
1140
+ * - Object literals (`style={{ background: bg(), color: fg() }}`):
1141
+ * the regex pipeline strips signal calls but leaves the
1142
+ * surrounding `{ k: v, ... }` syntax intact, producing invalid
1143
+ * Perl inside `<%= ... %>`.
1144
+ * - Tagged-template-literal call expressions
1145
+ * (`className={cn\`base \${tone()}\`}`): regex translation
1146
+ * produces malformed Perl with no callable target.
1147
+ *
1148
+ * Records `BF101` with the same shape the Go adapter emits via
1149
+ * `convertExpressionToGo`, so cross-adapter diagnostics stay
1150
+ * consistent. Returns `true` when the shape was rejected (caller
1151
+ * should drop the attribute / skip the emit).
1152
+ */
1153
+ private refuseUnsupportedAttrExpression(expr: string, attrName: string): boolean {
1154
+ // Strip leading parens / whitespace so wrapped forms reach the
1155
+ // shape pre-check — `style={({ a: b() })}` and
1156
+ // `className={(cn`base ${tone()}`)}` are the same logical shape
1157
+ // as their unwrapped variants and should hit the same gate.
1158
+ let probe = expr.trim()
1159
+ while (probe.startsWith('(')) probe = probe.slice(1).trimStart()
1160
+ const startsAsObjectLiteral = probe.startsWith('{')
1161
+ const hasTaggedTemplate = /[A-Za-z_$][\w$]*\s*`/.test(probe)
1162
+ if (!startsAsObjectLiteral && !hasTaggedTemplate) return false
1163
+ const parsed = parseExpression(expr.trim())
1164
+ const support = isSupported(parsed)
1165
+ if (parsed.kind !== 'unsupported' && support.supported) return false
1166
+ // Surface the `isSupported` reason so the diagnostic is as
1167
+ // actionable as the Go adapter's BF101 — keeps cross-adapter
1168
+ // diagnostics aligned on the same expression-shape gate.
1169
+ const reason = support.reason ?? (parsed.kind === 'unsupported' ? parsed.reason : undefined)
1170
+ const reasonLine = reason ? `\n${reason}` : ''
1171
+ this.errors.push({
1172
+ code: 'BF101',
1173
+ severity: 'error',
1174
+ message: `Expression not supported on attribute '${attrName}': ${expr.trim()}${reasonLine}`,
1175
+ loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1176
+ suggestion: {
1177
+ message: 'The Mojo adapter cannot lower JS object literals or tagged-template-literal expressions into Embedded Perl. Move the expression into a `\'use client\'` component (so hydration computes it), or expand it into discrete attributes whose values are values the adapter can lower.',
1178
+ },
1179
+ })
1180
+ return true
1181
+ }
1182
+
1183
+
1184
+ private convertExpressionToPerl(expr: string): string {
1185
+ // Handle higher-order array methods via ParsedExpr AST.
1186
+ // `filter|every|some` lower to Embedded Perl (grep). The rest
1187
+ // (`reduce|reduceRight|forEach|flatMap|flat|findLast|findLastIndex`)
1188
+ // can't lower to EP at all — route them through the same AST path
1189
+ // so `convertHigherOrderExpr`'s `isSupported` gate emits BF101
1190
+ // instead of falling into the regex pipeline that mangles
1191
+ // `$items->{reduce}->{...}` etc.
1192
+ if (/\.\s*(?:filter|every|some|reduce|reduceRight|forEach|flatMap|flat|findLast|findLastIndex)\s*\(/.test(expr)) {
1193
+ return this.convertHigherOrderExpr(expr)
1194
+ }
1195
+
1196
+ // #1448 Tier A — JS Array / String methods that the regex
1197
+ // pipeline silently mangles into `${obj}->{<method>}(...)` hash
1198
+ // lookups that fail at render time. Route them through the same
1199
+ // AST path so `isSupported`'s `UNSUPPORTED_METHODS` gate fires
1200
+ // BF101 with the offending expression, matching Go's behaviour.
1201
+ // Each method name drops off the regex as its lowering lands
1202
+ // (the regex stays in sync with `UNSUPPORTED_METHODS` —
1203
+ // `convertHigherOrderExpr` intercepts via `isSupported`).
1204
+ if (/\.\s*(?:includes|indexOf|lastIndexOf|at|concat|slice|reverse|toReversed|toLowerCase|toUpperCase|trim)\s*\(/.test(expr)) {
1205
+ return this.convertHigherOrderExpr(expr)
1206
+ }
1207
+
1208
+ // #1443/#1448: `.join(sep)` is lifted by the parser to the
1209
+ // `array-method` IR kind, and `renderArrayMethod`'s `case 'join'`
1210
+ // already emits the correct `join(sep, @{arr})`. Route the
1211
+ // text-expression form through the same AST path so the
1212
+ // regex pipeline below doesn't mangle it into a
1213
+ // `${arr}->{join}(sep)` Perl hash-lookup that errors at render.
1214
+ if (/\.\s*join\s*\(/.test(expr)) {
1215
+ return this.convertHigherOrderExpr(expr)
1216
+ }
1217
+
1218
+ // #1448 catalog — Mojo-specific gap: `.find` / `.findIndex`
1219
+ // have no AST lowering yet (no `array-method` IR variant, no
1220
+ // emitter), and the regex pipeline silently mangles them into
1221
+ // `${obj}->{find}(...)` hash lookups. Emit BF101 here until
1222
+ // either a parser-level `array-method` extension or a
1223
+ // `convertHigherOrderExpr` carve-out lands.
1224
+ const mojoOnlyMatch = /\.\s*(?<method>find|findIndex)\s*\(/.exec(expr)
1225
+ if (mojoOnlyMatch) {
1226
+ const methodName = mojoOnlyMatch.groups!.method!
1227
+ this.errors.push({
1228
+ code: 'BF101',
1229
+ severity: 'error',
1230
+ message: `Mojo adapter has not lowered Array.prototype.${methodName} yet: ${expr.trim()}`,
1231
+ loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1232
+ suggestion: {
1233
+ message: 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Perl',
1234
+ },
1235
+ })
1236
+ return "''"
1237
+ }
1238
+
1239
+ // templatePrimitives substitution (#1189): rewrite identifier-path
1240
+ // calls like `JSON.stringify(props.config)` / `Math.floor(x)` to
1241
+ // their Mojo helper-call form (`bf->json($config)` etc.) BEFORE
1242
+ // the regex pipeline below runs. Using the AST avoids fighting
1243
+ // the existing regex transforms — a registered call's args go
1244
+ // back through `convertExpressionToPerl` recursively so prop
1245
+ // refs / signal calls / member access in the args still get the
1246
+ // standard transforms.
1247
+ expr = this.rewriteTemplatePrimitives(expr)
1248
+
1249
+ // Signal getter calls: count() → $count
1250
+ let result = expr.replace(/\b([a-z_]\w*)\(\)/g, (_, name) => `$${name}`)
1251
+
1252
+ // Props access: props.xxx → $xxx
1253
+ result = result.replace(/\bprops\.(\w+)/g, (_, prop) => `$${prop}`)
1254
+
1255
+ // Bare identifier property access: item.field → $item->{field}
1256
+ // Must run before $-prefixed property access to catch bare identifiers
1257
+ // Use negative lookbehind to skip $-prefixed variables (avoid $$var double-prefix)
1258
+ result = result.replace(/(?<!\$)\b([a-z_]\w*)\.(\w+)/g, (match, obj, field) => {
1259
+ if (match.startsWith('$')) return match
1260
+ return `$${obj}->{${field}}`
1261
+ })
1262
+
1263
+ // $-prefixed property access: $item.field → $item->{field}
1264
+ result = result.replace(/\$(\w+)\.(\w+)/g, (_, obj, field) => `$${obj}->{${field}}`)
1265
+
1266
+ // Chained property access: $item->{field}.sub → $item->{field}->{sub}
1267
+ result = result.replace(/\}->\{(\w+)\}\.(\w+)/g, (_, f1, f2) => `}->{${f1}}->{${f2}}`)
1268
+
1269
+ // .length → scalar(@{...})
1270
+ result = result.replace(/\$(\w+)->\{length\}/g, (_, arr) => `scalar(@{$${arr}})`)
1271
+
1272
+ // Nullish coalescing: a ?? b → a // b (Perl defined-or)
1273
+ result = result.replace(/\?\?/g, '//')
1274
+
1275
+ // String comparison: expr === 'str' → expr eq 'str', expr !== 'str' → expr ne 'str'
1276
+ result = result.replace(/\s*===\s*(['"])/g, ' eq $1')
1277
+ result = result.replace(/\s*!==\s*(['"])/g, ' ne $1')
1278
+ // Also handle: 'str' === expr
1279
+ result = result.replace(/(['"])\s*===\s*/g, '$1 eq ')
1280
+ result = result.replace(/(['"])\s*!==\s*/g, '$1 ne ')
1281
+
1282
+ // Numeric comparison (remaining === / !==)
1283
+ result = result.replace(/===/g, '==')
1284
+ result = result.replace(/!==/g, '!=')
1285
+
1286
+ // Logical not: !expr → !expr (works in Perl too)
1287
+ // No conversion needed
1288
+
1289
+ // Template literals: `str ${expr}` → "str $expr"
1290
+ result = result.replace(/`([^`]*)`/g, (_, content) => {
1291
+ const perlStr = content.replace(/\$\{([^}]+)\}/g, (_: string, e: string) => `${this.convertExpressionToPerl(e)}`)
1292
+ return `"${perlStr}"`
1293
+ })
1294
+
1295
+ // Ensure top-level identifiers become variables
1296
+ if (/^[a-z_]\w*$/i.test(result) && !result.startsWith('$')) {
1297
+ result = `$${result}`
1298
+ }
1299
+
1300
+ return result
1301
+ }
1302
+ /**
1303
+ * Walk the parsed AST of `expr` and substitute each registered
1304
+ * primitive call (e.g. `JSON.stringify(props.config)`) with its
1305
+ * Mojo helper-call equivalent (e.g. `bf->json($config)`). All
1306
+ * other shapes round-trip back to source text via
1307
+ * `stringifyParsedExpr`, so the result is still a JS-shaped
1308
+ * string that the existing regex pipeline in
1309
+ * `convertExpressionToPerl` can finish translating.
1310
+ *
1311
+ * Bails out (returns the input unchanged) when:
1312
+ * - the expression doesn't parse cleanly,
1313
+ * - no primitive call is found in the AST, or
1314
+ * - a primitive's arity doesn't match the registered shape
1315
+ * (BF101 is recorded so the user sees the diagnostic).
1316
+ *
1317
+ * Identifier-path-only matching (#1187 R1) — same constraint the
1318
+ * Go adapter applies in #1188.
1319
+ */
1320
+ private rewriteTemplatePrimitives(expr: string): string {
1321
+ // Common case: no registered primitive substring — skip the
1322
+ // TS parser entirely. `parseExpression` invokes
1323
+ // `ts.createSourceFile`, which is the dominant compile-hot-path
1324
+ // cost added by this PR.
1325
+ if (!PRIMITIVE_SUBSTRING_RE.test(expr)) return expr
1326
+
1327
+ const parsed = parseExpression(expr)
1328
+ if (parsed.kind === 'unsupported') return expr
1329
+
1330
+ let mutated = false
1331
+ const walk = (n: ParsedExpr): ParsedExpr => {
1332
+ if (n.kind === 'call') {
1333
+ const path = identifierPath(n.callee)
1334
+ const spec = path ? MOJO_TEMPLATE_PRIMITIVES[path] : undefined
1335
+ if (path && spec) {
1336
+ if (n.args.length !== spec.arity) {
1337
+ this.errors.push({
1338
+ code: 'BF101',
1339
+ severity: 'error',
1340
+ message: `templatePrimitive '${path}' expects ${spec.arity} arg(s), got ${n.args.length}`,
1341
+ loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1342
+ suggestion: {
1343
+ message: `Call '${path}' with exactly ${spec.arity} argument(s), or wrap the JSX expression in /* @client */ to defer evaluation.`,
1344
+ },
1345
+ })
1346
+ return { kind: 'call', callee: walk(n.callee), args: n.args.map(walk) }
1347
+ }
1348
+ // Render each arg through the AST-aware sub-pipeline:
1349
+ // walk for nested primitive substitution, then pass the
1350
+ // resulting AST node directly to convertExpressionToPerl
1351
+ // via stringification. The substring pre-check above
1352
+ // guards against re-parsing strings that don't carry a
1353
+ // primitive, so the recursive cost stays bounded.
1354
+ const renderedArgs = n.args.map(a => this.convertExpressionToPerl(stringifyParsedExpr(walk(a))))
1355
+ mutated = true
1356
+ return { kind: 'identifier', name: spec.emit(renderedArgs) }
1357
+ }
1358
+ }
1359
+ switch (n.kind) {
1360
+ case 'call':
1361
+ return { kind: 'call', callee: walk(n.callee), args: n.args.map(walk) }
1362
+ case 'member':
1363
+ return { kind: 'member', object: walk(n.object), property: n.property, computed: n.computed }
1364
+ case 'binary':
1365
+ return { kind: 'binary', op: n.op, left: walk(n.left), right: walk(n.right) }
1366
+ case 'unary':
1367
+ return { kind: 'unary', op: n.op, argument: walk(n.argument) }
1368
+ case 'logical':
1369
+ return { kind: 'logical', op: n.op, left: walk(n.left), right: walk(n.right) }
1370
+ case 'conditional':
1371
+ return { kind: 'conditional', test: walk(n.test), consequent: walk(n.consequent), alternate: walk(n.alternate) }
1372
+ default:
1373
+ return n
1374
+ }
1375
+ }
1376
+
1377
+ const transformed = walk(parsed)
1378
+ if (!mutated) return expr
1379
+ return stringifyParsedExpr(transformed)
1380
+ }
1381
+
1382
+ /**
1383
+ * Convert expressions containing higher-order array methods to Perl.
1384
+ * Parses the full expression as AST and renders recursively.
1385
+ *
1386
+ * Handles patterns like:
1387
+ * - todos().filter(t => !t.done).length → scalar(grep { !$_->{done} } @{$todos})
1388
+ * - todos().every(t => t.done) → !(grep { !$_->{done} } @{$todos})
1389
+ * - todos().filter(t => t.done).length > 0 → scalar(grep { $_->{done} } @{$todos}) > 0
1390
+ */
1391
+ private convertHigherOrderExpr(expr: string): string {
1392
+ if (this.higherOrderInFlight.has(expr)) {
1393
+ this.errors.push({
1394
+ code: 'BF101',
1395
+ severity: 'error',
1396
+ message: `Cannot lower higher-order chain to Embedded Perl: ${expr.trim()}`,
1397
+ loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1398
+ suggestion: {
1399
+ message: "The Mojo adapter cannot lower this `.filter()` / `.every()` / `.some()` chain — typically because the array source is a JS array literal or a non-signal expression the AST classifier doesn't recognise. Move the expression into a `'use client'` component (so hydration computes it client-side), or rewrite it to operate on a signal getter or a prop directly.",
1400
+ },
1401
+ })
1402
+ // Return a Perl empty-string literal — safe in every context the
1403
+ // result might land in (`<%= '' %>`, `% if ('') {`, attribute
1404
+ // interpolation, template-literal substitution). Returning a raw
1405
+ // empty string here would produce `<%= %>`, which Embedded Perl
1406
+ // rejects as a syntax error and would mask the BF101 diagnostic
1407
+ // behind an opaque template-compilation failure.
1408
+ return "''"
1409
+ }
1410
+ this.higherOrderInFlight.add(expr)
1411
+ try {
1412
+ const parsed = parseExpression(expr)
1413
+ // Parity gate with the Go adapter's `convertExpressionToGo`: if the
1414
+ // parsed expression isn't supported (e.g. `.reduce()` / `.forEach()`,
1415
+ // destructured filter param, function-keyword callback) we cannot
1416
+ // lower it to Embedded Perl. Emit BF101 and return a safe Perl
1417
+ // empty-string literal so downstream concatenation doesn't blow up.
1418
+ const support = isSupported(parsed)
1419
+ if (!support.supported) {
1420
+ this.errors.push({
1421
+ code: 'BF101',
1422
+ severity: 'error',
1423
+ message: `Cannot lower higher-order chain to Embedded Perl: ${expr.trim()}`,
1424
+ loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1425
+ suggestion: {
1426
+ message: support.reason
1427
+ ? `${support.reason}\n\nOptions:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Perl`
1428
+ : 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Perl',
1429
+ },
1430
+ })
1431
+ return "''"
1432
+ }
1433
+ return this.renderParsedExprToPerl(parsed)
1434
+ } finally {
1435
+ this.higherOrderInFlight.delete(expr)
1436
+ }
1437
+ }
1438
+
1439
+ /**
1440
+ * Render a full ParsedExpr tree to Perl for top-level (non-filter)
1441
+ * expressions where identifiers are signals / stash vars. Delegates
1442
+ * to the shared ParsedExpr dispatcher with `MojoTopLevelEmitter`
1443
+ * (#1250 phase 1B).
1444
+ */
1445
+ private renderParsedExprToPerl(expr: ParsedExpr): string {
1446
+ return emitParsedExpr(expr, new MojoTopLevelEmitter(this))
1447
+ }
1448
+
1449
+ /** Internal hook exposed to the top-level emitter for unsupported nodes. */
1450
+ _convertExpressionToPerlPublic(raw: string): string {
1451
+ return this.convertExpressionToPerl(raw)
1452
+ }
1453
+
1454
+ /** Internal hook for higher-order: predicate body re-uses the filter emitter. */
1455
+ _renderPerlFilterExprPublic(expr: ParsedExpr, param: string): string {
1456
+ return this.renderPerlFilterExpr(expr, param)
1457
+ }
1458
+ }
1459
+
1460
+ // ===========================================================================
1461
+ // ParsedExpr emitters (#1250 phase 1B)
1462
+ // ===========================================================================
1463
+
1464
+ /**
1465
+ * Lowering for `array-method` IR nodes (#1443) — shared between the
1466
+ * filter and top-level emitters so the Embedded Perl form stays
1467
+ * consistent regardless of which context the chain lands in.
1468
+ *
1469
+ * The exhaustive switch on `method` paired with `assertNever` makes
1470
+ * adding a new variant to `ArrayMethod` a TS compile error here, not
1471
+ * a silent runtime no-op — the drift defence we already apply to
1472
+ * `ParsedExpr.kind` extended to its sub-discriminator.
1473
+ */
1474
+ function renderArrayMethod(
1475
+ method: ArrayMethod,
1476
+ object: ParsedExpr,
1477
+ args: ParsedExpr[],
1478
+ emit: (e: ParsedExpr) => string,
1479
+ ): string {
1480
+ switch (method) {
1481
+ case 'join': {
1482
+ // arr.join(sep) → join(sep, @{arr}). The default `${obj}->{join}`
1483
+ // hash-lookup fallback would emit invalid Perl, which is why the
1484
+ // IR carves out a dedicated method node instead of routing
1485
+ // through the generic call dispatcher.
1486
+ const obj = emit(object)
1487
+ const sep = emit(args[0])
1488
+ return `join(${sep}, @{${obj}})`
1489
+ }
1490
+ case 'includes': {
1491
+ // Both `arr.includes(x)` and `str.includes(sub)` route here —
1492
+ // the parser can't disambiguate the receiver type. The Mojo
1493
+ // runtime's `bf->includes($recv, $elem)` inspects `ref($recv)`
1494
+ // and dispatches: ARRAY ref scans the list with `eq`, scalar
1495
+ // falls back to `index(..., ...) != -1`. Helper lives in
1496
+ // packages/adapter-mojolicious/lib/BarefootJS.pm.
1497
+ //
1498
+ // The `bf->` (no `$`) form matches every other helper emit —
1499
+ // in real Mojolicious `bf` is a controller helper; the
1500
+ // standalone test-render in test-render.ts rewrites the bare
1501
+ // `bf->` to `$bf->` so both render paths stay consistent.
1502
+ const obj = emit(object)
1503
+ const needle = emit(args[0])
1504
+ return `bf->includes(${obj}, ${needle})`
1505
+ }
1506
+ case 'indexOf':
1507
+ case 'lastIndexOf': {
1508
+ // Array `.indexOf(x)` / `.lastIndexOf(x)` value-equality
1509
+ // search. The Perl helpers (`bf->index_of`, `bf->last_index_of`)
1510
+ // walk the array forward / backward and compare with `eq`
1511
+ // (with defined/undef parity). The existing `.find` lowering
1512
+ // uses Perl `grep` for struct-field find — disjoint surface,
1513
+ // disjoint helpers.
1514
+ const fn = method === 'indexOf' ? 'index_of' : 'last_index_of'
1515
+ const obj = emit(object)
1516
+ const needle = emit(args[0])
1517
+ return `bf->${fn}(${obj}, ${needle})`
1518
+ }
1519
+ case 'at': {
1520
+ // `.at(i)` with negative-index support — `.at(-1)` is the
1521
+ // last element. The Mojo helper wraps the same `length + i`
1522
+ // arithmetic the Go `bf_at` does so the lowering stays
1523
+ // symmetric across adapters.
1524
+ const obj = emit(object)
1525
+ const idx = emit(args[0])
1526
+ return `bf->at(${obj}, ${idx})`
1527
+ }
1528
+ case 'concat': {
1529
+ // `.concat(other)` merges two arrays. Returns a new ARRAY
1530
+ // ref so the result composes with `.join(...)` / other
1531
+ // array-shape methods downstream (the canonical Tier A
1532
+ // conformance fixture chains `.concat(...).join(' ')`).
1533
+ const a = emit(object)
1534
+ const b = emit(args[0])
1535
+ return `bf->concat(${a}, ${b})`
1536
+ }
1537
+ case 'slice': {
1538
+ // `.slice(start)` / `.slice(start, end)`. The Mojo helper
1539
+ // mirrors the Go arithmetic (negative-index normalisation,
1540
+ // out-of-bounds clamping, empty result on start >= end).
1541
+ // Absent `end` lowers as `undef`, which the helper treats as
1542
+ // "to length". Returns a new ARRAY ref so the result composes
1543
+ // with `.join(...)` downstream.
1544
+ const recv = emit(object)
1545
+ const start = emit(args[0])
1546
+ const end = args.length === 2 ? emit(args[1]) : 'undef'
1547
+ return `bf->slice(${recv}, ${start}, ${end})`
1548
+ }
1549
+ case 'reverse':
1550
+ case 'toReversed': {
1551
+ // Both shapes share a lowering — see the parser arm + Go
1552
+ // emit for the SSR-mutation-rationale. Returns a new ARRAY
1553
+ // ref so the result composes with `.join(...)` downstream.
1554
+ const recv = emit(object)
1555
+ return `bf->reverse(${recv})`
1556
+ }
1557
+ case 'toLowerCase': {
1558
+ // Perl's native `lc` is the obvious lowering — no helper
1559
+ // method needed. The receiver flows through `emit` so any
1560
+ // upstream coercion (`$value`, `$bf->string(...)`, etc.)
1561
+ // composes naturally.
1562
+ const recv = emit(object)
1563
+ return `lc(${recv})`
1564
+ }
1565
+ case 'toUpperCase': {
1566
+ // Perl's native `uc` — mirrors `toLowerCase` exactly.
1567
+ const recv = emit(object)
1568
+ return `uc(${recv})`
1569
+ }
1570
+ case 'trim': {
1571
+ // No Perl native `trim`; route through the `bf->trim`
1572
+ // helper so the regex stays in one place (and so an undef
1573
+ // receiver doesn't trigger a warning about applying `s///`
1574
+ // to undef).
1575
+ const recv = emit(object)
1576
+ return `bf->trim(${recv})`
1577
+ }
1578
+ default: {
1579
+ // TS-level exhaustiveness guard. If this throws at runtime, the
1580
+ // IR was constructed against a newer `ArrayMethod` variant that
1581
+ // this adapter hasn't been updated for — loud failure is better
1582
+ // than emitting a silent empty string downstream.
1583
+ const _exhaustive: never = method
1584
+ throw new Error(
1585
+ `renderArrayMethod: unhandled ArrayMethod '${(_exhaustive as string)}'`,
1586
+ )
1587
+ }
1588
+ }
1589
+ }
1590
+
1591
+ /**
1592
+ * Shared Mojo emit for `.sort(cmp)` / `.toSorted(cmp)` (#1448 Tier B).
1593
+ * Used by both the filter-context emitter and the top-level emitter,
1594
+ * plus the loop-hoist path in `renderLoop` — same emit shape across
1595
+ * all three so a regression in any one path surfaces consistently.
1596
+ *
1597
+ * The Perl helper accepts a hash-ref opts bag (room for a future
1598
+ * `nulls` knob without arity churn), and returns a fresh ARRAY ref
1599
+ * so downstream composition (`@{bf->sort(...)}` in `join(...)`, etc.)
1600
+ * stays straightforward.
1601
+ */
1602
+ /**
1603
+ * Encode an `IRLoop.markerId` into a Perl-identifier-safe suffix
1604
+ * for the `bf_iter_…` hoist var. Collision-free for marker ids
1605
+ * that differ in any character — `-` and `_` map to distinct
1606
+ * encodings (`_x2d` vs `__`) so `l-0` and `l_0` stay distinct.
1607
+ *
1608
+ * Today the IR only emits `l<digits>` so the encoding is mostly
1609
+ * an identity, but pinning collision-freeness up front avoids a
1610
+ * silent variable-shadow bug if a future marker generator widens
1611
+ * the alphabet.
1612
+ */
1613
+ function perlIdentifierFromMarkerId(markerId: string): string {
1614
+ return markerId.replace(/[^a-zA-Z0-9]/g, (ch) =>
1615
+ ch === '_' ? '__' : `_x${ch.charCodeAt(0).toString(16)}`
1616
+ )
1617
+ }
1618
+
1619
+ function renderSortMethod(recv: string, c: SortComparator): string {
1620
+ const keyEntry =
1621
+ c.key.kind === 'self'
1622
+ ? `key_kind => 'self'`
1623
+ : `key_kind => 'field', key => '${c.key.field}'`
1624
+ return `bf->sort(${recv}, { ${keyEntry}, compare_type => '${c.type}', direction => '${c.direction}' })`
1625
+ }
1626
+
1627
+ /**
1628
+ * Lowering for the predicate body of a filter / every / some / find,
1629
+ * plus the same shape used by `renderBlockBodyCondition` for complex
1630
+ * block-body filters. Identifiers resolve against:
1631
+ * - the predicate's loop param (`$param`),
1632
+ * - `localVarMap` aliases declared inside the block body, then
1633
+ * - a bare `$name` fallback for signals captured by the closure.
1634
+ *
1635
+ * Methods that have no filter-context meaning (template-literal,
1636
+ * arrow-fn, conditional, unsupported) fall back to the `'1'` literal
1637
+ * the original switch's `default` arm returned — those shapes never
1638
+ * arose inside the predicates the adapter actually accepts.
1639
+ */
1640
+ class MojoFilterEmitter implements ParsedExprEmitter {
1641
+ constructor(
1642
+ private readonly param: string,
1643
+ private readonly localVarMap: Map<string, string>,
1644
+ ) {}
1645
+
1646
+ identifier(name: string): string {
1647
+ if (name === this.param) return `$${this.param}`
1648
+ const signal = this.localVarMap.get(name)
1649
+ if (signal) return `$${signal}`
1650
+ return `$${name}`
1651
+ }
1652
+
1653
+ literal(value: string | number | boolean | null, literalType: LiteralType): string {
1654
+ if (literalType === 'string') return `'${value}'`
1655
+ if (literalType === 'boolean') return value ? '1' : '0'
1656
+ if (literalType === 'null') return 'undef'
1657
+ return String(value)
1658
+ }
1659
+
1660
+ member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
1661
+ // `.length` on a higher-order result (e.g.
1662
+ // `x.tags.filter(t => t.active).length > 0` inside the outer
1663
+ // filter predicate, #1443). The higher-order emit produces an
1664
+ // anonymous array ref `[grep ...]`; reading `->{length}` on that
1665
+ // is undef at runtime, which is why the pre-#1443 `containsHigherOrder`
1666
+ // gate refused this shape outright. Lowering `.length` to
1667
+ // `scalar(@{...})` makes the result a real Perl integer.
1668
+ if (property === 'length' && (object.kind === 'higher-order' || object.kind === 'array-literal')) {
1669
+ return `scalar(@{${emit(object)}})`
1670
+ }
1671
+ return `${emit(object)}->{${property}}`
1672
+ }
1673
+
1674
+ call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
1675
+ // Signal getter calls: filter() → $filter
1676
+ if (callee.kind === 'identifier' && args.length === 0) {
1677
+ return `$${callee.name}`
1678
+ }
1679
+ return emit(callee)
1680
+ }
1681
+
1682
+ unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
1683
+ const arg = emit(argument)
1684
+ if (op === '!') {
1685
+ // Wrap binary/logical operands in parens to dodge Perl precedence surprises.
1686
+ const needsParens = argument.kind === 'binary' || argument.kind === 'logical'
1687
+ return needsParens ? `!(${arg})` : `!${arg}`
1688
+ }
1689
+ if (op === '-') return `-${arg}`
1690
+ return arg
1691
+ }
1692
+
1693
+ binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
1694
+ const l = emit(left)
1695
+ const r = emit(right)
1696
+ if ((op === '===' || op === '==') && right.kind === 'literal' && right.literalType === 'string') {
1697
+ return `${l} eq ${r}`
1698
+ }
1699
+ if ((op === '!==' || op === '!=') && right.kind === 'literal' && right.literalType === 'string') {
1700
+ return `${l} ne ${r}`
1701
+ }
1702
+ const opMap: Record<string, string> = {
1703
+ '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
1704
+ '+': '+', '-': '-', '*': '*', '/': '/',
1705
+ }
1706
+ return `${l} ${opMap[op] ?? op} ${r}`
1707
+ }
1708
+
1709
+ logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
1710
+ const l = emit(left)
1711
+ const r = emit(right)
1712
+ if (op === '&&') return `(${l} && ${r})`
1713
+ if (op === '||') return `(${l} || ${r})`
1714
+ return `(${l} // ${r})`
1715
+ }
1716
+
1717
+ higherOrder(
1718
+ method: HigherOrderMethod,
1719
+ object: ParsedExpr,
1720
+ param: string,
1721
+ predicate: ParsedExpr,
1722
+ emit: (e: ParsedExpr) => string,
1723
+ ): string {
1724
+ // The predicate body is also a filter context, but with this
1725
+ // higher-order's own `param` (potentially shadowing the outer one),
1726
+ // so we spin up a nested emitter with the inner param.
1727
+ const arrayExpr = emit(object)
1728
+ const predBody = emitParsedExpr(predicate, new MojoFilterEmitter(param, this.localVarMap))
1729
+ const grepBody = predBody.replace(new RegExp(`\\$${param}\\b`, 'g'), '$_')
1730
+ if (method === 'filter') return `[grep { ${grepBody} } @{${arrayExpr}}]`
1731
+ if (method === 'every') return `!(grep { !(${grepBody}) } @{${arrayExpr}})`
1732
+ if (method === 'some') return `!!(grep { ${grepBody} } @{${arrayExpr}})`
1733
+ return arrayExpr
1734
+ }
1735
+
1736
+ arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
1737
+ // Perl array ref: `[$a, $b]`. Filter-context use is rare (the
1738
+ // outer emitter routes most array-literal arrivals via
1739
+ // MojoTopLevelEmitter), but #1443's chain
1740
+ // `[a, b].filter(Boolean).join(' ')` can land here when the
1741
+ // outer `.filter()` recurses into a nested filter whose own
1742
+ // source is an array literal.
1743
+ return `[${elements.map(emit).join(', ')}]`
1744
+ }
1745
+
1746
+ arrayMethod(
1747
+ method: ArrayMethod,
1748
+ object: ParsedExpr,
1749
+ args: ParsedExpr[],
1750
+ emit: (e: ParsedExpr) => string,
1751
+ ): string {
1752
+ // Filter-context array methods are vanishingly rare — predicates
1753
+ // operate on scalars, not arrays. Defer to the top-level rendering
1754
+ // (`join(sep, @{...})`) for any case that does land here so the
1755
+ // emission stays consistent across contexts.
1756
+ return renderArrayMethod(method, object, args, emit)
1757
+ }
1758
+
1759
+ sortMethod(
1760
+ _method: 'sort' | 'toSorted',
1761
+ object: ParsedExpr,
1762
+ comparator: SortComparator,
1763
+ emit: (e: ParsedExpr) => string,
1764
+ ): string {
1765
+ return renderSortMethod(emit(object), comparator)
1766
+ }
1767
+
1768
+ conditional(_test: ParsedExpr, _consequent: ParsedExpr, _alternate: ParsedExpr): string {
1769
+ return '1'
1770
+ }
1771
+
1772
+ templateLiteral(_parts: TemplatePart[]): string {
1773
+ return '1'
1774
+ }
1775
+
1776
+ arrowFn(_param: string, _body: ParsedExpr): string {
1777
+ return '1'
1778
+ }
1779
+
1780
+ unsupported(_raw: string, _reason: string): string {
1781
+ return '1'
1782
+ }
1783
+ }
1784
+
1785
+ /**
1786
+ * Lowering for top-level expressions whose identifiers resolve against
1787
+ * the Mojo template's stash (signals, props, locals introduced by
1788
+ * `% my $x = ...;` lines). Differs from the filter emitter mainly in
1789
+ * - `.length` → `scalar(@{...})` (filter contexts never see arrays
1790
+ * in lvalue position),
1791
+ * - `conditional` is supported (filter predicates can't return
1792
+ * ternaries),
1793
+ * - the `unsupported` fallback drops to the regex pipeline so legacy
1794
+ * shapes the AST can't classify still emit something coherent.
1795
+ */
1796
+ class MojoTopLevelEmitter implements ParsedExprEmitter {
1797
+ constructor(private readonly adapter: MojoAdapter) {}
1798
+
1799
+ identifier(name: string): string {
1800
+ return `$${name}`
1801
+ }
1802
+
1803
+ literal(value: string | number | boolean | null, literalType: LiteralType): string {
1804
+ if (literalType === 'string') return `'${value}'`
1805
+ if (literalType === 'boolean') return value ? '1' : '0'
1806
+ if (literalType === 'null') return 'undef'
1807
+ return String(value)
1808
+ }
1809
+
1810
+ member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
1811
+ const obj = emit(object)
1812
+ if (property === 'length') return `scalar(@{${obj}})`
1813
+ return `${obj}->{${property}}`
1814
+ }
1815
+
1816
+ call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
1817
+ // Signal getter: count() → $count
1818
+ if (callee.kind === 'identifier' && args.length === 0) {
1819
+ return `$${callee.name}`
1820
+ }
1821
+ // Array methods (`.join` and any others added to ArrayMethod, #1443)
1822
+ // are lifted into the `array-method` IR kind at parse time, so they
1823
+ // never reach this dispatcher. Per-method detection here would mix
1824
+ // value-builtin lowering with signal-call lowering — keeping them
1825
+ // separated forces every adapter to declare the full array-method
1826
+ // surface in one place (the `arrayMethod` emitter below).
1827
+ return emit(callee)
1828
+ }
1829
+
1830
+ unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
1831
+ const arg = emit(argument)
1832
+ if (op === '!') return `!${arg}`
1833
+ if (op === '-') return `-${arg}`
1834
+ return arg
1835
+ }
1836
+
1837
+ binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
1838
+ const l = emit(left)
1839
+ const r = emit(right)
1840
+ if ((op === '===' || op === '==') && right.kind === 'literal' && right.literalType === 'string') {
1841
+ return `${l} eq ${r}`
1842
+ }
1843
+ if ((op === '!==' || op === '!=') && right.kind === 'literal' && right.literalType === 'string') {
1844
+ return `${l} ne ${r}`
1845
+ }
1846
+ const opMap: Record<string, string> = {
1847
+ '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
1848
+ '+': '+', '-': '-', '*': '*',
1849
+ }
1850
+ return `${l} ${opMap[op] ?? op} ${r}`
1851
+ }
1852
+
1853
+ logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
1854
+ const l = emit(left)
1855
+ const r = emit(right)
1856
+ if (op === '&&') return `(${l} && ${r})`
1857
+ if (op === '||') return `(${l} || ${r})`
1858
+ return `(${l} // ${r})`
1859
+ }
1860
+
1861
+ higherOrder(
1862
+ method: HigherOrderMethod,
1863
+ object: ParsedExpr,
1864
+ param: string,
1865
+ predicate: ParsedExpr,
1866
+ emit: (e: ParsedExpr) => string,
1867
+ ): string {
1868
+ const arrayExpr = emit(object)
1869
+ const predBody = this.adapter._renderPerlFilterExprPublic(predicate, param)
1870
+ const grepBody = predBody.replace(new RegExp(`\\$${param}\\b`, 'g'), '$_')
1871
+ if (method === 'filter') return `[grep { ${grepBody} } @{${arrayExpr}}]`
1872
+ if (method === 'every') return `!(grep { !(${grepBody}) } @{${arrayExpr}})`
1873
+ if (method === 'some') return `!!(grep { ${grepBody} } @{${arrayExpr}})`
1874
+ return arrayExpr
1875
+ }
1876
+
1877
+ arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
1878
+ // Perl array ref. Identifiers inside elements resolve through the
1879
+ // top-level emitter so `[className, childClass]` becomes
1880
+ // `[$className, $childClass]` (the registry Slot's chain in
1881
+ // #1443). Empty `[]` stays as `[]` — a valid empty Perl array
1882
+ // ref that grep/join handle naturally.
1883
+ return `[${elements.map(emit).join(', ')}]`
1884
+ }
1885
+
1886
+ arrayMethod(
1887
+ method: ArrayMethod,
1888
+ object: ParsedExpr,
1889
+ args: ParsedExpr[],
1890
+ emit: (e: ParsedExpr) => string,
1891
+ ): string {
1892
+ return renderArrayMethod(method, object, args, emit)
1893
+ }
1894
+
1895
+ sortMethod(
1896
+ _method: 'sort' | 'toSorted',
1897
+ object: ParsedExpr,
1898
+ comparator: SortComparator,
1899
+ emit: (e: ParsedExpr) => string,
1900
+ ): string {
1901
+ return renderSortMethod(emit(object), comparator)
1902
+ }
1903
+
1904
+ conditional(
1905
+ test: ParsedExpr,
1906
+ consequent: ParsedExpr,
1907
+ alternate: ParsedExpr,
1908
+ emit: (e: ParsedExpr) => string,
1909
+ ): string {
1910
+ return `(${emit(test)} ? ${emit(consequent)} : ${emit(alternate)})`
1911
+ }
1912
+
1913
+ templateLiteral(_parts: TemplatePart[]): string {
1914
+ // Template literals don't appear at top level inside Mojo expressions
1915
+ // — they're handled by `convertTemplateLiteralPartsToPerl` at the
1916
+ // attribute / interpolation layer, not the expression dispatcher.
1917
+ return ''
1918
+ }
1919
+
1920
+ arrowFn(_param: string, _body: ParsedExpr): string {
1921
+ return ''
1922
+ }
1923
+
1924
+ unsupported(raw: string, _reason: string): string {
1925
+ // Legacy fallback: the regex pipeline handles shapes the AST can't
1926
+ // classify (mostly hand-written JS that pre-dates the parser).
1927
+ return this.adapter._convertExpressionToPerlPublic(raw)
1928
+ }
1929
+ }
1930
+
1931
+ export const mojoAdapter = new MojoAdapter()