@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
@@ -32,26 +32,21 @@ import {
32
32
  type AdapterOutput,
33
33
  type AdapterGenerateOptions,
34
34
  type TemplateSections,
35
- type ParsedExprEmitter,
36
- type HigherOrderMethod,
37
- type ArrayMethod,
38
- type LiteralType,
39
35
  type IRNodeEmitter,
40
36
  type EmitIRNode,
41
37
  type AttrValueEmitter,
42
38
  isBooleanAttr,
43
39
  parseExpression,
40
+ stringifyParsedExpr,
44
41
  parseStyleObjectEntries,
45
42
  isSupported,
46
43
  exprToString,
47
44
  parseProviderObjectLiteral,
48
- identifierPath,
49
45
  emitParsedExpr,
50
46
  emitIRNode,
51
47
  emitAttrValue,
52
48
  augmentInheritedPropAccesses,
53
49
  parseRecordIndexAccess,
54
- evalStringArrayJoin,
55
50
  extractArrowBodyExpression,
56
51
  collectContextConsumers,
57
52
  isLowerableObjectRestDestructure,
@@ -59,158 +54,45 @@ import {
59
54
  collectModuleStringConsts,
60
55
  lookupStaticRecordLiteral,
61
56
  searchParamsLocalNames,
62
- matchSearchParamsMethodCall
57
+ prepareLoweringMatchers,
58
+ queryHrefArgs,
59
+ sortComparatorFromArrow,
63
60
  } from '@barefootjs/jsx'
64
61
  import { isAriaBooleanAttr, isBooleanResultExpr } from './boolean-result.ts'
65
-
66
- /**
67
- * Mojo adapter's IRNode render context. Mojo's lowering currently
68
- * doesn't consume any render-position flags (`isRootOfClientComponent`
69
- * is handled differently here than in Hono/Go), so the Ctx is empty.
70
- * Kept as a named alias so future flags can extend it without changing
71
- * the `IRNodeEmitter` interface.
72
- */
73
- type MojoRenderCtx = Record<string, never>
74
- import type { ParsedExpr, ParsedStatement, SortComparator, ReduceOp, FlatDepth, FlatMapOp, TemplatePart } from '@barefootjs/jsx'
62
+ import type { ParsedExpr, LoweringMatcher } from '@barefootjs/jsx'
75
63
  import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
76
64
 
77
- interface PrimitiveSpec {
78
- arity: number
79
- emit: (args: string[]) => string
80
- }
81
-
82
- /**
83
- * Single source of truth for the Mojolicious adapter's
84
- * template-primitive surface. Each entry pairs the expected arity
85
- * with the emit function. Adding / removing a primitive is a
86
- * one-line change.
87
- *
88
- * The emit fn returns a Perl expression (no surrounding `<%= %>`)
89
- * suitable for embedding inside the Mojo template action —
90
- * `bf->json($val)`, `bf->floor($val)`, etc. Args arrive already
91
- * Perl-rendered via `convertExpressionToPerl` recursion, so a
92
- * caller passing `props.config` reaches the emit fn as `$config`.
93
- */
94
- const MOJO_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
95
- 'JSON.stringify': { arity: 1, emit: (args) => `bf->json(${args[0]})` },
96
- 'String': { arity: 1, emit: (args) => `bf->string(${args[0]})` },
97
- 'Number': { arity: 1, emit: (args) => `bf->number(${args[0]})` },
98
- 'Math.floor': { arity: 1, emit: (args) => `bf->floor(${args[0]})` },
99
- 'Math.ceil': { arity: 1, emit: (args) => `bf->ceil(${args[0]})` },
100
- 'Math.round': { arity: 1, emit: (args) => `bf->round(${args[0]})` },
101
- }
102
-
103
- /**
104
- * Module-scope `templatePrimitives` map derived once from the spec
105
- * record. Per-instance derivation would re-build the same Map on
106
- * every `new MojoAdapter()` call.
107
- */
108
- const MOJO_PRIMITIVE_EMIT_MAP: Record<string, (args: string[]) => string> =
109
- Object.fromEntries(
110
- Object.entries(MOJO_TEMPLATE_PRIMITIVES).map(([k, v]) => [k, v.emit])
111
- )
112
-
113
- /**
114
- * Find the `children` prop's `jsx-children` payload (#1326). Narrowed
115
- * via the AttrValue `kind` discriminator so adapter code stays type-
116
- * safe if the IR shape evolves — adding a new AttrValue variant or
117
- * renaming `children` to `jsxChildren` becomes a TS compile error
118
- * here instead of silently dropping the children at runtime.
119
- */
120
- function resolveJsxChildrenProp(props: readonly IRProp[]): IRNode[] {
121
- const prop = props.find(p => p.name === 'children')
122
- if (!prop) return []
123
- if (prop.value.kind !== 'jsx-children') return []
124
- return prop.value.children
125
- }
126
-
127
- export interface MojoAdapterOptions {
128
- /** Base path for client JS files (default: '/static/components/') */
129
- clientJsBasePath?: string
130
-
131
- /** Path to barefoot.js runtime (default: '/static/components/barefoot.js') */
132
- barefootJsPath?: string
133
- }
134
-
135
- /**
136
- * Parse a const initializer's source text. Returns the unescaped string
137
- * value when the whole initializer is a single string literal (or a
138
- * no-substitution template literal), else `null`. Uses the TS parser so
139
- * escapes/quotes resolve exactly as JS would, matching the value the Hono
140
- * reference inlines at runtime.
141
- */
142
- function parsePureStringLiteral(source: string): string | null {
143
- const sf = ts.createSourceFile(
144
- '__const.ts',
145
- `const __x = (${source});`,
146
- ts.ScriptTarget.Latest,
147
- /*setParentNodes*/ false,
148
- )
149
- const stmt = sf.statements[0]
150
- if (!stmt || !ts.isVariableStatement(stmt)) return null
151
- const decl = stmt.declarationList.declarations[0]
152
- let init = decl?.initializer
153
- while (init && ts.isParenthesizedExpression(init)) init = init.expression
154
- if (!init) return null
155
- if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
156
- return init.text
157
- }
158
- // `[<literals>].join(' ')` module consts (e.g. Switch's `trackStateClasses`)
159
- // → inline the flattened string byte-for-byte. See `evalStringArrayJoin`.
160
- return evalStringArrayJoin(source)
161
- }
65
+ import type { MojoRenderCtx } from './lib/types.ts'
66
+ import { MOJO_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
67
+ import { perlHashKey, perlIdentifierFromMarkerId } from './lib/perl-naming.ts'
68
+ import {
69
+ resolveJsxChildrenProp,
70
+ collectRootScopeNodes,
71
+ } from './lib/ir-scope.ts'
72
+ import { renderSortMethod, renderSortEval } from './expr/array-method.ts'
73
+ import { MojoFilterEmitter, MojoTopLevelEmitter } from './expr/emitters.ts'
74
+ import type { MojoEmitContext, MojoSpreadContext, MojoMemoContext } from './emit-context.ts'
75
+ import {
76
+ hasClientInteractivity,
77
+ collectImportedLoopChildComponentErrors,
78
+ } from './analysis/component-tree.ts'
79
+ import {
80
+ conditionalSpreadToPerl,
81
+ objectLiteralExprToPerlHashref,
82
+ } from './spread/spread-codegen.ts'
83
+ import {
84
+ generateContextConsumerSeed,
85
+ generateDerivedMemoSeed,
86
+ } from './memo/seed.ts'
87
+ import {
88
+ collectProviderDataNames,
89
+ collectBooleanTypedProps,
90
+ collectNullableOptionalProps,
91
+ collectStringValueNames,
92
+ } from './props/prop-classes.ts'
162
93
 
163
- /**
164
- * (#checkbox) Quote a `render_child` named-arg / hashref key when it isn't a
165
- * bare Perl identifier. A JSX attribute name like `data-slot` would otherwise
166
- * emit `data-slot => '...'`, which Perl parses as the subtraction
167
- * `data - slot`. Identifier-safe names (`className`, `size`, `_bf_slot`) pass
168
- * through unquoted to keep the generated template readable.
169
- */
170
- function perlHashKey(name: string): string {
171
- return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : `'${name.replace(/'/g, "\\'")}'`
172
- }
173
-
174
- /**
175
- * Collect the component's root scope element node(s) — the elements that
176
- * become the rendered root and so carry `data-key` for a keyed loop item. A
177
- * plain element root is itself; an `if-statement` (early-return) root
178
- * contributes the top element of each branch (`consequent` + the `alternate`
179
- * chain), since exactly one branch renders at runtime. Non-element branch
180
- * tops (fragments / nested shapes) are walked one level so an
181
- * `if (…) return <A/>` still resolves to `<A>`. (#1297)
182
- */
183
- function collectRootScopeNodes(node: IRNode): Set<IRNode> {
184
- const out = new Set<IRNode>()
185
- const visit = (n: IRNode | null): void => {
186
- if (!n) return
187
- if (n.type === 'element') { out.add(n); return }
188
- if (n.type === 'if-statement') {
189
- const s = n as IRIfStatement
190
- visit(s.consequent)
191
- visit(s.alternate)
192
- return
193
- }
194
- if (n.type === 'fragment') {
195
- for (const c of (n as IRFragment).children) visit(c)
196
- }
197
- }
198
- visit(node)
199
- return out
200
- }
201
-
202
- /**
203
- * True when every `$var` the lowered (Perl / Kolon) expression references is
204
- * in the available set — i.e. the template already has that var in scope.
205
- * Guards in-template memo seeding from referencing an out-of-scope binding
206
- * (which would trip Perl strict mode). (#1297)
207
- */
208
- function referencedVarsAreAvailable(expr: string, available: ReadonlySet<string>): boolean {
209
- for (const m of expr.matchAll(/\$([A-Za-z_]\w*)/g)) {
210
- if (!available.has(m[1])) return false
211
- }
212
- return true
213
- }
94
+ export type { MojoAdapterOptions } from './lib/types.ts'
95
+ import type { MojoAdapterOptions } from './lib/types.ts'
214
96
 
215
97
  export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRenderCtx> {
216
98
  name = 'mojolicious'
@@ -254,6 +136,14 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
254
136
  private propsObjectName: string | null = null
255
137
  private propsParams: { name: string }[] = []
256
138
  private booleanTypedProps: Set<string> = new Set()
139
+ /**
140
+ * (#1971) Names that resolve to a real SSR template var — prop param, signal
141
+ * getter, or memo. A `<Ctx.Provider value>` member referencing a name NOT in
142
+ * this set is a client-only function (a local handler const like `scrollPrev`
143
+ * or a signal setter like `setCanScrollPrev`) with no SSR value: it would
144
+ * emit an undeclared `$var`, so it's lowered to `undef` instead.
145
+ */
146
+ private providerDataNames: Set<string> = new Set()
257
147
  /**
258
148
  * Names (signal getters + props) whose value is a string, so `===`/`!==`
259
149
  * against them lowers to Perl `eq`/`ne` rather than numeric `==`/`!=`.
@@ -269,7 +159,15 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
269
159
  * the generic hash deref. Set at `generate()` entry from `ir.metadata.imports`;
270
160
  * read by the top-level ParsedExpr emitter.
271
161
  */
272
- _searchParamsLocals: Set<string> = new Set()
162
+ private _searchParamsLocals: Set<string> = new Set()
163
+
164
+ /**
165
+ * Call-lowering matchers active for this component (#2057). Bound at
166
+ * `generate()` entry via `prepareLoweringMatchers` and read by the top-level
167
+ * emitter. Covers both userland plugins and the compiler's built-in plugins
168
+ * (e.g. `queryHref` → `bf->query`, #2042) — one uniform path, no per-API branch.
169
+ */
170
+ private _loweringMatchers: LoweringMatcher[] = []
273
171
  /**
274
172
  * Module-scope pure string-literal constants (`const X = 'literal'` at
275
173
  * file top-level), keyed by name → resolved literal value. Populated at
@@ -336,56 +234,14 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
336
234
  // serialization happens before `generate`, so this mutation doesn't reach it).
337
235
  augmentInheritedPropAccesses(ir)
338
236
  this.propsParams = ir.metadata.propsParams.map(p => ({ name: p.name }))
339
- // Props whose declared TS type is boolean — a bare binding of one
340
- // (`data-active={props.isActive}`) must stringify as JS
341
- // `String(boolean)` ("true"/"false"), not Perl's native `1`/`''`
342
- // (#1897, pagination's data-active).
343
- this.booleanTypedProps = new Set(
344
- ir.metadata.propsParams
345
- .filter(prop => prop.type?.primitive === 'boolean' || prop.type?.raw === 'boolean')
346
- .map(prop => prop.name),
347
- )
348
- // No-destructure-default props → `undef` when the caller omits them
349
- // → guard their bare-reference attribute emission with Perl `defined`
350
- // so the attribute drops instead of rendering `attr=""` (Hono-style
351
- // nullish omission). A prop WITH a destructure default (`value = ''`)
352
- // is never `undef` in the body and must stay unconditional, so it is
353
- // excluded. This mirrors the Go adapter's nillable-field guard: there
354
- // the witness is the resolved `interface{}` field type; here it is
355
- // the absence of a default (the analyzer reports `rows` — a
356
- // `TextareaHTMLAttributes` member destructured without a default — as
357
- // no-default, `type.kind: 'unknown'`).
358
- // Excludes concrete-primitive types (`string`/`number`/`boolean`)
359
- // to match the Go adapter's scope, which guards only `interface{}`
360
- // (nillable) fields and leaves concrete fields unconditional. So a
361
- // required, no-default `string` prop still emits `attr=""` like Hono,
362
- // and only nillable (`unknown`/object/array) no-default props guard.
363
- this.nullableOptionalProps = new Set(
364
- ir.metadata.propsParams
365
- .filter(
366
- p =>
367
- p.defaultValue === undefined &&
368
- !p.isRest &&
369
- p.type?.kind !== 'primitive',
370
- )
371
- .map(p => p.name),
372
- )
373
- // Record string-typed signals and props so equality comparisons against
374
- // them lower to `eq`/`ne` (#1672). A signal is string-typed when its
375
- // inferred type is `string` (the analyzer infers this from a string-literal
376
- // initial value) or, defensively, when its initial value is a bare string
377
- // literal; a prop when its annotated type is `string`.
378
- this.stringValueNames = new Set<string>()
379
- for (const s of ir.metadata.signals) {
380
- if (isStringTypeInfo(s.type) || isBareStringLiteral(s.initialValue)) {
381
- this.stringValueNames.add(s.getter)
382
- }
383
- }
384
- for (const p of ir.metadata.propsParams) {
385
- if (isStringTypeInfo(p.type)) this.stringValueNames.add(p.name)
386
- }
237
+ // Per-compile prop classifications (see `props/prop-classes.ts`).
238
+ this.providerDataNames = collectProviderDataNames(ir)
239
+ this.booleanTypedProps = collectBooleanTypedProps(ir)
240
+ this.nullableOptionalProps = collectNullableOptionalProps(ir)
241
+ this.stringValueNames = collectStringValueNames(ir)
387
242
  this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
388
243
  this._searchParamsLocals = searchParamsLocalNames(ir.metadata)
244
+ this._loweringMatchers = prepareLoweringMatchers(ir.metadata)
389
245
  this.localConstants = ir.metadata.localConstants ?? []
390
246
  this.loopBoundNames.clear()
391
247
  this.errors = []
@@ -403,7 +259,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
403
259
  // sibling templates are registered on the same template instance
404
260
  // at render time.
405
261
  if (!options?.siblingTemplatesRegistered) {
406
- this.checkImportedLoopChildComponents(ir)
262
+ this.errors.push(...collectImportedLoopChildComponentErrors(ir, this.componentName))
407
263
  }
408
264
 
409
265
  this.rootScopeNodes = collectRootScopeNodes(ir.root)
@@ -420,14 +276,14 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
420
276
  // from the active provider value (or the `createContext` default) so the
421
277
  // body's `$x` resolves. The provider side pushes the value via
422
278
  // `emitProvider`; here the consumer reads it. (#1297)
423
- const ctxSeed = this.generateContextConsumerSeed(ir)
279
+ const ctxSeed = generateContextConsumerSeed(ir)
424
280
 
425
281
  // Prop/signal-derived memos that aren't statically evaluable (e.g.
426
282
  // `createMemo(() => props.value * 10)`) have a `null` SSR default, so
427
283
  // their `$x` would render empty. Compute them in-template from the
428
284
  // already-seeded prop/signal vars — mirroring Go's generated child
429
285
  // constructor that evaluates the memo from the passed prop. (#1297)
430
- const memoSeed = this.generateDerivedMemoSeed(ir)
286
+ const memoSeed = generateDerivedMemoSeed(this.memoCtx, ir)
431
287
 
432
288
  const template = `${scriptReg}${ctxSeed}${memoSeed}${templateBody}\n`
433
289
 
@@ -513,7 +369,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
513
369
  * function-scope consts never reach the per-render stash, so a bare
514
370
  * `$totalPages` faults under strict mode.
515
371
  */
516
- resolveLiteralConst(name: string): string | null {
372
+ private resolveLiteralConst(name: string): string | null {
517
373
  if (this.loopBoundNames?.has?.(name)) return null
518
374
  const c = (this.localConstants ?? []).find(lc => lc.name === name)
519
375
  if (c?.value === undefined) return null
@@ -524,7 +380,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
524
380
  return null
525
381
  }
526
382
 
527
- resolveStaticRecordLiteral(objectName: string, key: string): string | null {
383
+ private resolveStaticRecordLiteral(objectName: string, key: string): string | null {
528
384
  if (this.loopBoundNames?.has?.(objectName)) return null
529
385
  const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
530
386
  if (!hit) return null
@@ -533,7 +389,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
533
389
  : `'${hit.text.replace(/[\\']/g, m => `\\${m}`)}'`
534
390
  }
535
391
 
536
- resolveModuleStringConst(name: string): string | null {
392
+ private resolveModuleStringConst(name: string): string | null {
537
393
  // A loop body introduces `my $<param>` / `my $<index>` bindings that
538
394
  // shadow a module const of the same name — never inline inside one.
539
395
  if (this.loopBoundNames.has(name)) return null
@@ -547,7 +403,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
547
403
  // ===========================================================================
548
404
 
549
405
  private generateScriptRegistrations(ir: ComponentIR, scriptBaseName?: string): string {
550
- const hasInteractivity = this.hasClientInteractivity(ir)
406
+ const hasInteractivity = hasClientInteractivity(ir)
551
407
  if (!hasInteractivity) return ''
552
408
 
553
409
  const name = scriptBaseName ?? ir.metadata.componentName
@@ -561,15 +417,6 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
561
417
  return lines.join('\n')
562
418
  }
563
419
 
564
- private hasClientInteractivity(ir: ComponentIR): boolean {
565
- return (
566
- ir.metadata.signals.length > 0 ||
567
- ir.metadata.effects.length > 0 ||
568
- ir.metadata.onMounts.length > 0 ||
569
- (ir.metadata.clientAnalysis?.needsInit ?? false)
570
- )
571
- }
572
-
573
420
  // ===========================================================================
574
421
  // Node Rendering
575
422
  // ===========================================================================
@@ -685,109 +532,29 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
685
532
  const key = `'${m.name.replace(/[\\']/g, c => `\\${c}`)}'`
686
533
  if (m.kind === 'function' || /^on[A-Z]/.test(m.name)) return `${key} => undef`
687
534
  const src = m.kind === 'getter' ? m.body : m.expr
535
+ // (#1971) A member whose value is a bare identifier that doesn't resolve
536
+ // to a prop/signal/memo is a client-only function reference (a local
537
+ // handler const like `scrollPrev`, or a signal setter like
538
+ // `setCanScrollPrev`) — no SSR value, and emitting `$scrollPrev` would
539
+ // trip Perl strict mode on an undeclared var. Lower to undef.
540
+ if (this.isClientOnlyContextIdentifier(src)) return `${key} => undef`
688
541
  return `${key} => ${this.convertExpressionToPerl(src)}`
689
542
  })
690
543
  return `{ ${entries.join(', ')} }`
691
544
  }
692
545
 
693
- /** Perl literal for a context-consumer's `createContext` default. */
694
- private contextDefaultPerl(c: ContextConsumer): string {
695
- const d = c.defaultValue
696
- if (d === null || d === undefined) return 'undef'
697
- if (typeof d === 'string') return `'${d.replace(/[\\']/g, m => `\\${m}`)}'`
698
- if (typeof d === 'boolean') return d ? '1' : '0'
699
- return String(d)
700
- }
701
-
702
- /**
703
- * Emit one `% my $<local> = bf->use_context(...)` seed line per context
704
- * consumer so the template body's bare `$<local>` resolves to the active
705
- * provider value (or the `createContext` default). (#1297)
706
- */
707
- private generateContextConsumerSeed(ir: ComponentIR): string {
708
- const consumers = collectContextConsumers(ir.metadata)
709
- if (consumers.length === 0) return ''
710
- return (
711
- consumers
712
- .map(
713
- c =>
714
- `% my $${c.localName} = bf->use_context('${c.contextName}', ${this.contextDefaultPerl(c)});`,
715
- )
716
- .join('\n') + '\n'
717
- )
718
- }
719
-
720
546
  /**
721
- * Seed memos whose SSR default is `null` (not statically evaluable) by
722
- * computing them in-template from the already-seeded prop / signal vars.
723
- * Targets the prop-derived memo shape (`createMemo(() => props.value * 10)`)
724
- * that the static `extractSsrDefaults` evaluator can't fold without this
725
- * the memo's `$x` renders empty (the reason `props-reactivity-comparison`
726
- * was skipped). Only emitted when the lowered expression references vars the
727
- * template already has in scope (props params + signals + prior memos), so a
728
- * memo over an out-of-scope binding stays on the null path rather than
729
- * tripping Perl strict mode. (#1297)
547
+ * (#1971) True when `src` is a bare identifier that doesn't resolve to a
548
+ * prop/signal/memo or an SSR-inlinable module string const i.e. a
549
+ * client-only function reference in a context value (a local handler const
550
+ * like `scrollPrev`, or a signal setter like `setCanScrollPrev`). See
551
+ * `providerDataNames`. Module-scope string consts (`carouselClasses`) ARE
552
+ * SSR-resolvable via `moduleStringConsts`, so they're excluded here.
730
553
  */
731
- private generateDerivedMemoSeed(ir: ComponentIR): string {
732
- const memos = ir.metadata.memos ?? []
733
- const signals = ir.metadata.signals ?? []
734
- if (memos.length === 0 && signals.length === 0) return ''
735
- // Props seed first; each signal/memo adds its own name as it lands so a
736
- // later one can reference an earlier one.
737
- const available = new Set<string>(ir.metadata.propsParams.map(p => p.name))
738
- const lines: string[] = []
739
-
740
- // Prop/signal-derived signals (`createSignal(props.defaultOn ?? false)`):
741
- // a loop-child render receives no stash seed for the signal, so its `$on`
742
- // would trip strict mode; and even when an entry render seeds it, the
743
- // static default can't capture the per-call prop. Seed it in-template from
744
- // the passed prop — but ONLY when the init lowers cleanly AND references an
745
- // in-scope var (i.e. it's genuinely derived). Object/array/constant inits
746
- // (`createSignal({…})`, `createSignal([…])`, `createSignal('b')`) keep the
747
- // existing ssr-defaults seeding, so the spread / loop fixtures are
748
- // untouched.
749
- for (const signal of signals) {
750
- const perl = this.tryLowerToPerl(signal.initialValue, available)
751
- if (perl !== null) lines.push(`% my $${signal.getter} = ${perl};`)
752
- available.add(signal.getter)
753
- }
754
-
755
- for (const memo of memos) {
756
- // Seed every memo whose body lowers cleanly — not just the ones whose
757
- // static SSR default is null. A statically-foldable prop-derived memo
758
- // (`createMemo(() => props.disabled ?? false)` → default `false`)
759
- // still depends on the per-call prop: the static stash seed bakes in
760
- // the absent-prop fold, so a caller passing `disabled => 1` would
761
- // render the default branch (#1897, select's disabled item). The
762
- // in-template recomputation reads the prop lexical the stash already
763
- // seeded, so it's correct per call; block-bodied arrows /
764
- // out-of-scope references fall back to the static ssr-defaults seed.
765
- const body = extractArrowBodyExpression(memo.computation)
766
- if (body !== null) {
767
- const perl = this.tryLowerToPerl(body, available)
768
- if (perl !== null) lines.push(`% my $${memo.name} = ${perl};`)
769
- }
770
- available.add(memo.name)
771
- }
772
- return lines.length > 0 ? lines.join('\n') + '\n' : ''
773
- }
774
-
775
- /**
776
- * Lower a signal init / memo body to Perl for an in-template SSR seed, or
777
- * `null` when it shouldn't be seeded this way. Returns null — without
778
- * recording a BF101 — when the expression isn't a supported shape
779
- * (`isSupported` pre-check, so object/array literals don't fail the build),
780
- * when the lowering references no in-scope var (a constant — keep the
781
- * existing ssr-defaults seeding), or when it references an out-of-scope
782
- * binding. (#1297)
783
- */
784
- private tryLowerToPerl(expr: string, available: ReadonlySet<string>): string | null {
785
- const trimmed = expr.trim()
786
- if (!trimmed) return null
787
- if (!isSupported(parseExpression(trimmed)).supported) return null
788
- const perl = this.convertExpressionToPerl(trimmed)
789
- if (perl === '' || !/\$[A-Za-z_]\w*/.test(perl)) return null
790
- return referencedVarsAreAvailable(perl, available) ? perl : null
554
+ private isClientOnlyContextIdentifier(src: string): boolean {
555
+ const t = src.trim()
556
+ if (!/^[A-Za-z_$][\w$]*$/.test(t)) return false
557
+ return !this.providerDataNames.has(t) && !this.moduleStringConsts.has(t)
791
558
  }
792
559
 
793
560
  emitAsync(node: IRAsync, _ctx: MojoRenderCtx, _emit: EmitIRNode<MojoRenderCtx>): string {
@@ -923,90 +690,6 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
923
690
  return `<%== bf->comment("cond-start:${condId}") %>${content}<%== bf->comment("cond-end:${condId}") %>`
924
691
  }
925
692
 
926
- // ===========================================================================
927
- // Imported-component-in-loop check (BF103, #1266)
928
- // ===========================================================================
929
-
930
- /**
931
- * Push a `BF103` diagnostic for every component reference inside a
932
- * loop body whose name is imported from a relative-path module.
933
- * Mirror of the Go adapter's check — the Mojo adapter has the same
934
- * cross-template-registration constraint at request time.
935
- */
936
- private checkImportedLoopChildComponents(ir: ComponentIR): void {
937
- // Collect every name imported from a relative-path module (no
938
- // case filter — `IRComponent` nodes only exist for PascalCase JSX
939
- // usages, so a lowercase utility import in the set can't match
940
- // anyway, and any heuristic on the import name itself would be
941
- // strictly less robust than the structural IR check below).
942
- const relativeImports = new Set<string>()
943
- for (const imp of ir.metadata.templateImports ?? ir.metadata.imports ?? []) {
944
- if (!imp.source.startsWith('./') && !imp.source.startsWith('../')) continue
945
- if (imp.isTypeOnly) continue
946
- for (const spec of imp.specifiers) {
947
- relativeImports.add(spec.alias ?? spec.name)
948
- }
949
- }
950
- if (relativeImports.size === 0) return
951
-
952
- const loc = { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } }
953
- const visit = (node: IRNode, inLoop: boolean): void => {
954
- switch (node.type) {
955
- case 'component': {
956
- const comp = node as IRComponent
957
- if (inLoop && relativeImports.has(comp.name)) {
958
- this.errors.push({
959
- code: 'BF103',
960
- severity: 'error',
961
- 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.`,
962
- loc: comp.loc ?? loc,
963
- suggestion: {
964
- message:
965
- `Options:\n` +
966
- ` 1. Compile '${comp.name}' (its source file) with the same adapter and register the resulting Mojo template alongside the parent at render time.\n` +
967
- ` 2. Inline <${comp.name}> directly inside the loop body so no cross-file template lookup is needed.\n` +
968
- ` 3. Mark the loop position as @client-only so the template is materialised on the client instead of at SSR time.`,
969
- },
970
- })
971
- }
972
- for (const child of comp.children) visit(child, inLoop)
973
- break
974
- }
975
- case 'element':
976
- for (const child of (node as IRElement).children) visit(child, inLoop)
977
- break
978
- case 'fragment':
979
- for (const child of (node as IRFragment).children) visit(child, inLoop)
980
- break
981
- case 'conditional': {
982
- const cond = node as IRConditional
983
- visit(cond.whenTrue, inLoop)
984
- if (cond.whenFalse) visit(cond.whenFalse, inLoop)
985
- break
986
- }
987
- case 'loop':
988
- for (const child of (node as IRLoop).children) visit(child, true)
989
- break
990
- case 'if-statement': {
991
- const stmt = node as IRIfStatement
992
- visit(stmt.consequent, inLoop)
993
- if (stmt.alternate) visit(stmt.alternate, inLoop)
994
- break
995
- }
996
- case 'provider':
997
- for (const child of (node as IRProvider).children) visit(child, inLoop)
998
- break
999
- case 'async': {
1000
- const a = node as IRAsync
1001
- visit(a.fallback, inLoop)
1002
- for (const child of a.children) visit(child, inLoop)
1003
- break
1004
- }
1005
- }
1006
- }
1007
- visit(ir.root, false)
1008
- }
1009
-
1010
693
  // ===========================================================================
1011
694
  // Loop Rendering
1012
695
  // ===========================================================================
@@ -1051,11 +734,11 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1051
734
  }
1052
735
 
1053
736
  const rawArray = this.convertExpressionToPerl(loop.array)
1054
- // Apply sort if present (#1448 Tier B): wrap the loop array in
1055
- // the shared `bf->sort` helper. The same `renderSortMethod`
1056
- // feeds both this loop-chain hoist and the standalone
1057
- // `sortMethod()` arm on the emitter, so a regression in either
1058
- // path surfaces with the identical emit shape.
737
+ // Apply sort if present (#1448 Tier B): wrap the loop array in the
738
+ // shared sort helper. The same `renderSortEval` / `renderSortMethod`
739
+ // pair feeds both this loop-chain hoist and the emitter's
740
+ // `callbackMethod` sort arm, so a regression in either path surfaces
741
+ // with the identical emit shape.
1059
742
  //
1060
743
  // Sort hoist: the loop bound (`0..$#{…}`) and the per-item
1061
744
  // lookup (`…->[$_i]`) both reference the same array — if the
@@ -1107,7 +790,49 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1107
790
  // each get their own reconciliation range (#1087).
1108
791
  lines.push(`<%== bf->comment("loop:${loop.markerId}") %>`)
1109
792
  if (sortedHoist && loop.sortComparator) {
1110
- lines.push(`% my $${sortedHoist} = ${renderSortMethod(rawArray, loop.sortComparator)};`)
793
+ // Evaluator-first (#2018 P3): serialize the comparator + emit
794
+ // `bf->sort_eval`; fall back to the structured `bf->sort` for a
795
+ // comparator the evaluator can't model (e.g. `localeCompare`).
796
+ //
797
+ // The hoisted sort runs OUTSIDE this loop, so this loop's bound names
798
+ // must not shadow the comparator's captured free vars while emitting the
799
+ // env — otherwise a captured var that happens to share a loop-param name
800
+ // is blocked from inlining its module const and renders as an undefined
801
+ // `$name` (strict-mode fault, Copilot review #2035). Drop this loop's
802
+ // bound names for the sort emit, then restore (a nested loop's outer
803
+ // bindings, ref-counted, stay in effect).
804
+ for (const n of loopBound) {
805
+ const c = (this.loopBoundNames.get(n) ?? 1) - 1
806
+ if (c <= 0) this.loopBoundNames.delete(n)
807
+ else this.loopBoundNames.set(n, c)
808
+ }
809
+ const sortEmit = (e: ParsedExpr) => this.convertExpressionToPerl('', e)
810
+ // `loop.sortComparator` is the generic `IRLoopSort` (#2018 P5): serialize
811
+ // the arrow body for the evaluator (eval-first), recover the structured
812
+ // comparator from the arrow for the `localeCompare` fallback.
813
+ const sortArrow = loop.sortComparator.arrow
814
+ let sorted: string | null = null
815
+ if (sortArrow.kind === 'arrow') {
816
+ sorted = renderSortEval(rawArray, sortArrow.body, sortArrow.params, sortEmit)
817
+ }
818
+ if (sorted === null) {
819
+ const structured = sortComparatorFromArrow(sortArrow)
820
+ if (structured !== null) sorted = renderSortMethod(rawArray, structured)
821
+ }
822
+ if (sorted === null) {
823
+ // Neither the evaluator nor the structured fallback can model this
824
+ // comparator — record BF101 and fall through with the unsorted array
825
+ // so the hoist line stays syntactically valid.
826
+ this._recordExprBF101(
827
+ `.sort(...) loop comparator is not lowerable to a template sort`,
828
+ `Pre-sort the array in the route handler, or mark the loop @client-only.`,
829
+ )
830
+ sorted = rawArray
831
+ }
832
+ for (const n of loopBound) {
833
+ this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1)
834
+ }
835
+ lines.push(`% my $${sortedHoist} = ${sorted};`)
1111
836
  }
1112
837
  lines.push(`% for my ${indexVar} (0..$#{${array}}) {`)
1113
838
  if (loop.iterationShape !== 'keys') {
@@ -1130,12 +855,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1130
855
  // Handle filter().map() pattern by wrapping children in if-condition
1131
856
  if (loop.filterPredicate) {
1132
857
  let filterCond: string
1133
- if (loop.filterPredicate.blockBody) {
1134
- filterCond = this.renderBlockBodyCondition(
1135
- loop.filterPredicate.blockBody,
1136
- loop.filterPredicate.param
1137
- )
1138
- } else if (loop.filterPredicate.predicate) {
858
+ if (loop.filterPredicate.predicate) {
1139
859
  filterCond = this.renderPerlFilterExpr(
1140
860
  loop.filterPredicate.predicate,
1141
861
  loop.filterPredicate.param
@@ -1195,6 +915,17 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1195
915
  if (value.parts) {
1196
916
  return `${perlHashKey(name)} => ${this.convertTemplateLiteralPartsToPerl(value.parts)}`
1197
917
  }
918
+ // Inline object-literal child prop (carousel's `opts={{ align: 'start' }}`):
919
+ // lower to a Perl hashref so the child can serialize it (`data-opts`),
920
+ // instead of refusing the bare object with BF101. (#1971 Perl) Read the
921
+ // IR-carried structured `ParsedExpr` tree (#2018) instead of re-parsing
922
+ // `value.expr` with `ts.createSourceFile`; the lowering returns null for
923
+ // any non-object-literal shape, so the common non-object case falls
924
+ // straight through to the bare-expression path below.
925
+ if (value.parsed) {
926
+ const hashref = objectLiteralExprToPerlHashref(this.spreadCtx, value.parsed)
927
+ if (hashref !== null) return `${perlHashKey(name)} => ${hashref}`
928
+ }
1198
929
  return `${perlHashKey(name)} => ${this.convertExpressionToPerl(value.expr)}`
1199
930
  },
1200
931
  emitSpread: (value) => {
@@ -1511,7 +1242,9 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1511
1242
  // OMITS the key (`bf->spread_attrs` does NOT filter empty
1512
1243
  // strings, so we cannot always-include it). Mirrors the Go
1513
1244
  // adapter's IIFE-of-maps lowering (#textarea).
1514
- const ternaryHashref = this.conditionalSpreadToPerl(trimmed)
1245
+ // Read the spread's IR-carried `ParsedExpr` tree (#2018) instead of
1246
+ // re-parsing `trimmed` with `ts.createSourceFile`.
1247
+ const ternaryHashref = conditionalSpreadToPerl(this.spreadCtx, value.parsed)
1515
1248
  if (ternaryHashref !== null) {
1516
1249
  return `<%== bf->spread_attrs(${ternaryHashref}) %>`
1517
1250
  }
@@ -1528,7 +1261,15 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1528
1261
  if (localConst?.value !== undefined) {
1529
1262
  const initTrimmed = localConst.value.trim()
1530
1263
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(initTrimmed)) {
1531
- const resolved = this.conditionalSpreadToPerl(initTrimmed)
1264
+ // The local const's initializer text isn't carried as a structured
1265
+ // tree on the spread attr, so parse it once via the shared
1266
+ // `parseExpression` (the analyzer's own entry) — not
1267
+ // `ts.createSourceFile` — mirroring go-template's same local-const
1268
+ // resolution path.
1269
+ const resolved = conditionalSpreadToPerl(
1270
+ this.spreadCtx,
1271
+ parseExpression(initTrimmed),
1272
+ )
1532
1273
  if (resolved !== null) {
1533
1274
  return `<%== bf->spread_attrs(${resolved}) %>`
1534
1275
  }
@@ -1583,6 +1324,13 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1583
1324
  const parts: string[] = []
1584
1325
 
1585
1326
  for (const attr of element.attrs) {
1327
+ // `/* @client */` attribute bindings are deferred to hydrate: the
1328
+ // client runtime sets/patches the attribute in a mount effect (the
1329
+ // CSR template omits it; ir-to-client-js emits the setAttribute
1330
+ // effect). Skip SSR emission so the server omits the attribute and
1331
+ // the unsupported-expression lowering is never reached for a deferred
1332
+ // predicate (no BF101 / BF102). #1966
1333
+ if (attr.clientOnly) continue
1586
1334
  // Rewrite JSX special-prop names to their HTML-attribute
1587
1335
  // counterparts (#1475). `className` → `class` was already
1588
1336
  // wired in; the `key` → `data-key` rewrite matches the
@@ -1650,99 +1398,6 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1650
1398
  return emitParsedExpr(expr, new MojoFilterEmitter(param, localVarMap, n => this._isStringValueName(n)))
1651
1399
  }
1652
1400
 
1653
- /**
1654
- * Render a complex block body filter into a Perl condition.
1655
- * Handles patterns like: filter(t => { const f = filter(); if (...) return ...; })
1656
- */
1657
- private renderBlockBodyCondition(
1658
- statements: ParsedStatement[],
1659
- param: string
1660
- ): string {
1661
- const localVarMap = new Map<string, string>()
1662
- const paths = this.collectReturnPaths(statements, [], localVarMap, param)
1663
-
1664
- if (paths.length === 0) return '1'
1665
- if (paths.length === 1) return this.buildSinglePathCondition(paths[0], param, localVarMap)
1666
-
1667
- // Multiple paths: build OR condition
1668
- const parts: string[] = []
1669
- for (const path of paths) {
1670
- if (path.result.kind === 'literal' && path.result.literalType === 'boolean' && path.result.value === false) continue
1671
- const cond = this.buildSinglePathCondition(path, param, localVarMap)
1672
- if (cond !== '0') parts.push(cond)
1673
- }
1674
-
1675
- if (parts.length === 0) return '0'
1676
- if (parts.length === 1) return parts[0]
1677
- return `(${parts.join(' || ')})`
1678
- }
1679
-
1680
- private collectReturnPaths(
1681
- statements: ParsedStatement[],
1682
- currentConditions: ParsedExpr[],
1683
- localVarMap: Map<string, string>,
1684
- param: string
1685
- ): Array<{ conditions: ParsedExpr[]; result: ParsedExpr }> {
1686
- const paths: Array<{ conditions: ParsedExpr[]; result: ParsedExpr }> = []
1687
-
1688
- for (const stmt of statements) {
1689
- if (stmt.kind === 'var-decl') {
1690
- if (stmt.init.kind === 'call' && stmt.init.callee.kind === 'identifier') {
1691
- localVarMap.set(stmt.name, stmt.init.callee.name)
1692
- }
1693
- } else if (stmt.kind === 'return') {
1694
- paths.push({ conditions: [...currentConditions], result: stmt.value })
1695
- break
1696
- } else if (stmt.kind === 'if') {
1697
- const thenPaths = this.collectReturnPaths(stmt.consequent, [...currentConditions, stmt.condition], localVarMap, param)
1698
- paths.push(...thenPaths)
1699
-
1700
- if (stmt.alternate) {
1701
- const negated: ParsedExpr = { kind: 'unary', op: '!', argument: stmt.condition }
1702
- const elsePaths = this.collectReturnPaths(stmt.alternate, [...currentConditions, negated], localVarMap, param)
1703
- paths.push(...elsePaths)
1704
- } else {
1705
- currentConditions.push({ kind: 'unary', op: '!', argument: stmt.condition })
1706
- }
1707
- }
1708
- }
1709
-
1710
- return paths
1711
- }
1712
-
1713
- private buildSinglePathCondition(
1714
- path: { conditions: ParsedExpr[]; result: ParsedExpr },
1715
- param: string,
1716
- localVarMap: Map<string, string>
1717
- ): string {
1718
- if (path.result.kind === 'literal' && path.result.literalType === 'boolean') {
1719
- if (path.result.value === true) {
1720
- if (path.conditions.length === 0) return '1'
1721
- return this.renderConditionsAnd(path.conditions, param, localVarMap)
1722
- }
1723
- return '0'
1724
- }
1725
-
1726
- if (path.conditions.length === 0) {
1727
- return this.renderPerlFilterExpr(path.result, param, localVarMap)
1728
- }
1729
-
1730
- const condPart = this.renderConditionsAnd(path.conditions, param, localVarMap)
1731
- const resultPart = this.renderPerlFilterExpr(path.result, param, localVarMap)
1732
- return `(${condPart} && ${resultPart})`
1733
- }
1734
-
1735
- private renderConditionsAnd(
1736
- conditions: ParsedExpr[],
1737
- param: string,
1738
- localVarMap: Map<string, string>
1739
- ): string {
1740
- if (conditions.length === 0) return '1'
1741
- if (conditions.length === 1) return this.renderPerlFilterExpr(conditions[0], param, localVarMap)
1742
- const parts = conditions.map(c => this.renderPerlFilterExpr(c, param, localVarMap))
1743
- return `(${parts.join(' && ')})`
1744
- }
1745
-
1746
1401
  // ===========================================================================
1747
1402
  // Expression Conversion: JS → Perl
1748
1403
  // ===========================================================================
@@ -1856,145 +1511,48 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1856
1511
  return true
1857
1512
  }
1858
1513
 
1859
-
1860
1514
  /**
1861
- * Lower a conditional inline-object spread expression
1862
- * `(COND ? { 'aria-describedby': describedBy } : {})`
1863
- * (either branch possibly `{}`) into a Perl inline ternary of
1864
- * hashrefs for `bf->spread_attrs`:
1865
- * `$describedBy ? { 'aria-describedby' => $describedBy } : {}`
1866
- *
1867
- * The condition is translated via `convertExpressionToPerl` (a bare
1868
- * prop ident becomes `$describedBy`; Perl truthiness handles the
1869
- * test). Object literals become Perl hashrefs with `=>`; string-
1870
- * literal keys are quoted, values resolve via `convertExpressionToPerl`.
1871
- *
1872
- * Returns null when the expression is NOT this shape, or when a part
1873
- * can't be faithfully lowered (non-static key, etc.) so the caller
1874
- * falls back to the standard `convertExpressionToPerl` path (which
1875
- * records BF101). Scoped strictly to ternary-of-object-literals so no
1876
- * other spread shape regresses.
1515
+ * Build the EmitContext seam the top-level `ParsedExpr` emitter depends on.
1516
+ * Built as a private object (the adapter does NOT `implements MojoEmitContext`)
1517
+ * so the wrapped bookkeeping `_searchParamsLocals`, the const/record
1518
+ * resolvers, BF101 recording, the filter-predicate entry — stays private and
1519
+ * off the exported adapter's public type, matching the Go adapter's
1520
+ * `emitCtx` and the `spreadCtx` / `memoCtx` seams below.
1877
1521
  */
1878
- private conditionalSpreadToPerl(expr: string): string | null {
1879
- const sf = ts.createSourceFile('__spread.ts', `(${expr})`, ts.ScriptTarget.Latest, true)
1880
- if (sf.statements.length !== 1) return null
1881
- const stmt = sf.statements[0]
1882
- if (!ts.isExpressionStatement(stmt)) return null
1883
- let node: ts.Expression = stmt.expression
1884
- while (ts.isParenthesizedExpression(node)) node = node.expression
1885
- if (!ts.isConditionalExpression(node)) return null
1886
- const unwrap = (e: ts.Expression): ts.Expression => {
1887
- let n = e
1888
- while (ts.isParenthesizedExpression(n)) n = n.expression
1889
- return n
1890
- }
1891
- const whenTrue = unwrap(node.whenTrue)
1892
- const whenFalse = unwrap(node.whenFalse)
1893
- if (!ts.isObjectLiteralExpression(whenTrue) || !ts.isObjectLiteralExpression(whenFalse)) {
1894
- return null
1522
+ private get emitCtx(): MojoEmitContext {
1523
+ return {
1524
+ _searchParamsLocals: this._searchParamsLocals,
1525
+ resolveModuleStringConst: (name) => this.resolveModuleStringConst(name),
1526
+ resolveLiteralConst: (name) => this.resolveLiteralConst(name),
1527
+ resolveStaticRecordLiteral: (o, k) => this.resolveStaticRecordLiteral(o, k),
1528
+ _isStringValueName: (name) => this._isStringValueName(name),
1529
+ _recordExprBF101: (message, reason) => this._recordExprBF101(message, reason),
1530
+ _renderPerlFilterExprPublic: (e, p) => this._renderPerlFilterExprPublic(e, p),
1895
1531
  }
1896
- const condPerl = this.convertExpressionToPerl(node.condition.getText(sf))
1897
- const truePerl = this.objectLiteralToPerlHashref(whenTrue, sf)
1898
- const falsePerl = this.objectLiteralToPerlHashref(whenFalse, sf)
1899
- if (truePerl === null || falsePerl === null) return null
1900
- return `${condPerl} ? ${truePerl} : ${falsePerl}`
1901
1532
  }
1902
1533
 
1903
1534
  /**
1904
- * Convert a static object literal into a Perl hashref string for a
1905
- * conditional spread. Only static string/identifier keys are allowed;
1906
- * values resolve via `convertExpressionToPerl`. Returns null for any
1907
- * computed/spread/dynamic key. Empty object → `{}`.
1535
+ * Build the narrow context the extracted spread lowering depends on. Passing
1536
+ * a purpose-built object (rather than `this`) keeps the adapter's bookkeeping
1537
+ * members private they stay internal implementation detail, not part of the
1538
+ * exported class's public surface.
1908
1539
  */
1909
- private objectLiteralToPerlHashref(
1910
- obj: ts.ObjectLiteralExpression,
1911
- sf: ts.SourceFile,
1912
- ): string | null {
1913
- const entries: string[] = []
1914
- for (const prop of obj.properties) {
1915
- if (!ts.isPropertyAssignment(prop)) return null
1916
- let key: string
1917
- if (ts.isIdentifier(prop.name)) {
1918
- key = prop.name.text
1919
- } else if (ts.isStringLiteral(prop.name) || ts.isNoSubstitutionTemplateLiteral(prop.name)) {
1920
- key = prop.name.text
1921
- } else {
1922
- return null
1923
- }
1924
- const initNode = (() => {
1925
- let n: ts.Expression = prop.initializer
1926
- while (ts.isParenthesizedExpression(n)) n = n.expression
1927
- return n
1928
- })()
1929
- const indexed = this.recordIndexAccessToPerl(initNode)
1930
- if (
1931
- indexed === null &&
1932
- ts.isElementAccessExpression(initNode) &&
1933
- initNode.argumentExpression &&
1934
- !ts.isNumericLiteral(initNode.argumentExpression) &&
1935
- !ts.isStringLiteral(initNode.argumentExpression)
1936
- ) {
1937
- // Variable-index record access (`sizeMap[size]`) that the
1938
- // static-inline path couldn't resolve — a non-scalar record
1939
- // value, or a non-const receiver. Since #1897 made variable
1940
- // indices parseable (`index-access`), the generic value lowering
1941
- // would now emit `$sizeMap->{$size}` against an UNBOUND module
1942
- // const instead of refusing. Record BF101 and bail so the whole
1943
- // spread surfaces the out-of-shape diagnostic, matching the
1944
- // pre-#1897 behaviour (the refusal then was a side effect of the
1945
- // value lowering). (A bound receiver — a signal getter like
1946
- // `selected()[index]` — is an attribute value, not a spread
1947
- // member, and never reaches here.)
1948
- this.errors.push({
1949
- code: 'BF101',
1950
- severity: 'error',
1951
- message: `Spread object value '${initNode.getText(sf)}' indexes a record map whose values aren't scalar literals — it can't lower to an inline Perl hashref.`,
1952
- loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1953
- suggestion: {
1954
- message: 'Index a record whose values are number/string literals, or move the spread into a `\'use client\'` component so hydration computes it.',
1955
- },
1956
- })
1957
- return null
1958
- }
1959
- const valPerl =
1960
- indexed !== null
1961
- ? indexed
1962
- : this.convertExpressionToPerl(prop.initializer.getText(sf))
1963
- entries.push(`'${key.replace(/'/g, "\\'")}' => ${valPerl}`)
1540
+ private get spreadCtx(): MojoSpreadContext {
1541
+ return {
1542
+ componentName: this.componentName,
1543
+ errors: this.errors,
1544
+ localConstants: this.localConstants,
1545
+ propsParams: this.propsParams,
1546
+ convertExpressionToPerl: (e, preParsed) => this.convertExpressionToPerl(e, preParsed),
1964
1547
  }
1965
- return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`
1966
1548
  }
1967
1549
 
1968
- /**
1969
- * Lower a spread-object VALUE of the form `IDENT[KEY]` where:
1970
- * - `IDENT` resolves via `localConstants` to a MODULE-scope object
1971
- * literal whose property values are all scalar (number/string)
1972
- * literals under static (string-literal or identifier) keys
1973
- * (a `Record<staticKeys, scalar>` map like `sizeMap`), AND
1974
- * - `KEY` is a bare identifier that is a prop.
1975
- * Emits an inline indexed Perl hashref:
1976
- * `{ 'sm' => 16, 'md' => 20, ... }->{$size}`
1977
- *
1978
- * Returns the Perl string when convertible, else `null` so the caller
1979
- * falls back to its normal value lowering (which records BF101 for an
1980
- * unsupported shape). Mirror of the Go adapter's `recordIndexAccessToGoMap`.
1981
- */
1982
- private recordIndexAccessToPerl(val: ts.Expression): string | null {
1983
- // Shared structural parse (single source of truth in `@barefootjs/jsx`);
1984
- // this wrapper only does the Perl-specific emit (single-quote escaping)
1985
- // from the structured result.
1986
- const parsed = parseRecordIndexAccess(val, this.localConstants, this.propsParams)
1987
- if (!parsed) return null
1988
- const entries = parsed.entries.map(e => {
1989
- const mapVal =
1990
- e.value.kind === 'number' ? e.value.text : `'${e.value.text.replace(/'/g, "\\'")}'`
1991
- return `'${e.key.replace(/'/g, "\\'")}' => ${mapVal}`
1992
- })
1993
- return `{ ${entries.join(', ')} }->{$${parsed.indexPropName}}`
1550
+ /** Build the narrow context the extracted memo seeding depends on. */
1551
+ private get memoCtx(): MojoMemoContext {
1552
+ return { convertExpressionToPerl: (e, preParsed) => this.convertExpressionToPerl(e, preParsed) }
1994
1553
  }
1995
1554
 
1996
-
1997
- private convertExpressionToPerl(expr: string): string {
1555
+ private convertExpressionToPerl(expr: string, preParsed?: ParsedExpr): string {
1998
1556
  // Parse-first lowering — parity with the Go adapter's
1999
1557
  // `convertExpressionToGo`. Parse the JS expression once, gate it on
2000
1558
  // the shared `isSupported`, and render every supported shape through
@@ -2004,16 +1562,48 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
2004
1562
  // string-rewriting pipeline. Unsupported shapes (un-lowered methods,
2005
1563
  // unparseable hand-written JS, etc.) surface as BF101 with the
2006
1564
  // `/* @client */` escape hatch instead of being silently mangled.
2007
- const trimmed = expr.trim()
2008
- if (trimmed === '') return "''"
1565
+ //
1566
+ // `preParsed` is the IR-carried `ParsedExpr` tree (cf. go-template's
1567
+ // `convertExpressionToGo(jsExpr, out?, preParsed?)`); when present it is
1568
+ // used directly instead of re-parsing `expr`, so spread condition/value
1569
+ // lowering threads the carried tree through without a stringify→re-parse
1570
+ // round-trip. The diagnostic text is then derived from the tree
1571
+ // (`stringifyParsedExpr`) so callers can pass `''` for `expr`.
1572
+ let parsed: ParsedExpr
1573
+ if (preParsed) {
1574
+ parsed = preParsed
1575
+ } else {
1576
+ const trimmed = expr.trim()
1577
+ if (trimmed === '') return "''"
1578
+ parsed = parseExpression(trimmed)
1579
+ }
1580
+
1581
+ // Registered call lowerings (#2057) — including the built-in `queryHref`
1582
+ // plugin (#2042), which lowers `queryHref(base, { … })` to a neutral
1583
+ // `guard-list` on the `query` helper → `bf->query(base, <triples>)`.
1584
+ // Recognised before the support gate because the object-literal arg is
1585
+ // otherwise `unsupported` (BF101). The `bf->query` helper includes a pair iff
1586
+ // its guard is truthy AND its value is a non-empty string (the client's
1587
+ // `if (value)`): a plain `key: v` passes guard `1`, a conditional
1588
+ // `key: cond ? v : undefined` passes the lowered cond. Only the `query`
1589
+ // helper renders to `bf->query`; another guard-list helper must not be
1590
+ // silently mis-rendered as a query.
1591
+ if (parsed.kind === 'call') {
1592
+ for (const matcher of this._loweringMatchers) {
1593
+ const node = matcher(parsed.callee, parsed.args)
1594
+ if (node?.kind === 'guard-list' && node.helper === 'query') {
1595
+ const argsGo = queryHrefArgs(node, n => this.renderParsedExprToPerl(n))
1596
+ return `bf->query(${argsGo.join(', ')})`
1597
+ }
1598
+ }
1599
+ }
2009
1600
 
2010
- const parsed = parseExpression(trimmed)
2011
1601
  const support = isSupported(parsed)
2012
1602
  if (!support.supported) {
2013
1603
  this.errors.push({
2014
1604
  code: 'BF101',
2015
1605
  severity: 'error',
2016
- message: `Expression not supported: ${trimmed}`,
1606
+ message: `Expression not supported: ${preParsed ? stringifyParsedExpr(parsed) : expr.trim()}`,
2017
1607
  loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
2018
1608
  suggestion: {
2019
1609
  message: support.reason
@@ -2038,7 +1628,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
2038
1628
  * (#1250 phase 1B).
2039
1629
  */
2040
1630
  private renderParsedExprToPerl(expr: ParsedExpr): string {
2041
- return emitParsedExpr(expr, new MojoTopLevelEmitter(this))
1631
+ return emitParsedExpr(expr, new MojoTopLevelEmitter(this.emitCtx))
2042
1632
  }
2043
1633
 
2044
1634
  /**
@@ -2048,11 +1638,11 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
2048
1638
  */
2049
1639
  /** Whether `name` (a signal getter or prop) holds a string value, so an
2050
1640
  * equality comparison against it should use Perl `eq`/`ne` (#1672). */
2051
- _isStringValueName(name: string): boolean {
1641
+ private _isStringValueName(name: string): boolean {
2052
1642
  return this.stringValueNames.has(name)
2053
1643
  }
2054
1644
 
2055
- _recordExprBF101(message: string, reason?: string): void {
1645
+ private _recordExprBF101(message: string, reason?: string): void {
2056
1646
  this.errors.push({
2057
1647
  code: 'BF101',
2058
1648
  severity: 'error',
@@ -2067,845 +1657,9 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
2067
1657
  }
2068
1658
 
2069
1659
  /** Internal hook for higher-order: predicate body re-uses the filter emitter. */
2070
- _renderPerlFilterExprPublic(expr: ParsedExpr, param: string): string {
1660
+ private _renderPerlFilterExprPublic(expr: ParsedExpr, param: string): string {
2071
1661
  return this.renderPerlFilterExpr(expr, param)
2072
1662
  }
2073
1663
  }
2074
1664
 
2075
- // ===========================================================================
2076
- // ParsedExpr emitters (#1250 phase 1B)
2077
- // ===========================================================================
2078
-
2079
- /**
2080
- * Lowering for `array-method` IR nodes (#1443) — shared between the
2081
- * filter and top-level emitters so the Embedded Perl form stays
2082
- * consistent regardless of which context the chain lands in.
2083
- *
2084
- * The exhaustive switch on `method` paired with `assertNever` makes
2085
- * adding a new variant to `ArrayMethod` a TS compile error here, not
2086
- * a silent runtime no-op — the drift defence we already apply to
2087
- * `ParsedExpr.kind` extended to its sub-discriminator.
2088
- */
2089
- function renderArrayMethod(
2090
- method: ArrayMethod,
2091
- object: ParsedExpr,
2092
- args: ParsedExpr[],
2093
- emit: (e: ParsedExpr) => string,
2094
- ): string {
2095
- switch (method) {
2096
- case 'join': {
2097
- // arr.join(sep) → join(sep, @{arr}). The default `${obj}->{join}`
2098
- // hash-lookup fallback would emit invalid Perl, which is why the
2099
- // IR carves out a dedicated method node instead of routing
2100
- // through the generic call dispatcher. `.join()` defaults the
2101
- // separator to `,` (JS) and ignores any extra argument.
2102
- const obj = emit(object)
2103
- const sep = args.length >= 1 ? emit(args[0]) : `','`
2104
- return `join(${sep}, @{${obj}})`
2105
- }
2106
- case 'includes': {
2107
- // Both `arr.includes(x)` and `str.includes(sub)` route here —
2108
- // the parser can't disambiguate the receiver type. The Mojo
2109
- // runtime's `bf->includes($recv, $elem)` inspects `ref($recv)`
2110
- // and dispatches: ARRAY ref scans the list with `eq`, scalar
2111
- // falls back to `index(..., ...) != -1`. Helper lives in
2112
- // packages/adapter-perl/lib/BarefootJS.pm.
2113
- //
2114
- // The `bf->` (no `$`) form matches every other helper emit —
2115
- // in real Mojolicious `bf` is a controller helper; the
2116
- // standalone test-render in test-render.ts rewrites the bare
2117
- // `bf->` to `$bf->` so both render paths stay consistent.
2118
- const obj = emit(object)
2119
- const needle = emit(args[0])
2120
- return `bf->includes(${obj}, ${needle})`
2121
- }
2122
- case 'indexOf':
2123
- case 'lastIndexOf': {
2124
- // Array `.indexOf(x)` / `.lastIndexOf(x)` value-equality
2125
- // search. The Perl helpers (`bf->index_of`, `bf->last_index_of`)
2126
- // walk the array forward / backward and compare with `eq`
2127
- // (with defined/undef parity). The existing `.find` lowering
2128
- // uses Perl `grep` for struct-field find — disjoint surface,
2129
- // disjoint helpers.
2130
- const fn = method === 'indexOf' ? 'index_of' : 'last_index_of'
2131
- const obj = emit(object)
2132
- const needle = emit(args[0])
2133
- return `bf->${fn}(${obj}, ${needle})`
2134
- }
2135
- case 'at': {
2136
- // `.at(i)` with negative-index support — `.at(-1)` is the
2137
- // last element. The Mojo helper wraps the same `length + i`
2138
- // arithmetic the Go `bf_at` does so the lowering stays
2139
- // symmetric across adapters. `.at()` with no argument is `.at(0)`
2140
- // (the first element); extra arguments are ignored.
2141
- const obj = emit(object)
2142
- const idx = args.length >= 1 ? emit(args[0]) : '0'
2143
- return `bf->at(${obj}, ${idx})`
2144
- }
2145
- case 'concat': {
2146
- // `.concat(other)` merges two arrays. Returns a new ARRAY
2147
- // ref so the result composes with `.join(...)` / other
2148
- // array-shape methods downstream (the canonical Tier A
2149
- // conformance fixture chains `.concat(...).join(' ')`).
2150
- // `.concat()` with no argument is a shallow copy — indistinguishable
2151
- // from the receiver in an SSR snapshot, so it lowers to the receiver.
2152
- if (args.length === 0) {
2153
- return emit(object)
2154
- }
2155
- const a = emit(object)
2156
- const b = emit(args[0])
2157
- return `bf->concat(${a}, ${b})`
2158
- }
2159
- case 'slice': {
2160
- // `.slice()` / `.slice(start)` / `.slice(start, end)`. The Mojo
2161
- // helper mirrors the Go arithmetic (negative-index normalisation,
2162
- // out-of-bounds clamping, empty result on start >= end). A
2163
- // missing `start` defaults to 0 (full copy); an absent `end`
2164
- // lowers as `undef`, which the helper treats as "to length". JS
2165
- // ignores a third+ argument. Returns a new ARRAY ref so the
2166
- // result composes with `.join(...)` downstream.
2167
- const recv = emit(object)
2168
- const start = args.length >= 1 ? emit(args[0]) : '0'
2169
- const end = args.length >= 2 ? emit(args[1]) : 'undef'
2170
- return `bf->slice(${recv}, ${start}, ${end})`
2171
- }
2172
- case 'reverse':
2173
- case 'toReversed': {
2174
- // Both shapes share a lowering — see the parser arm + Go
2175
- // emit for the SSR-mutation-rationale. Returns a new ARRAY
2176
- // ref so the result composes with `.join(...)` downstream.
2177
- const recv = emit(object)
2178
- return `bf->reverse(${recv})`
2179
- }
2180
- case 'toLowerCase': {
2181
- // Perl's native `lc` is the obvious lowering — no helper
2182
- // method needed. The receiver flows through `emit` so any
2183
- // upstream coercion (`$value`, `$bf->string(...)`, etc.)
2184
- // composes naturally.
2185
- const recv = emit(object)
2186
- return `lc(${recv})`
2187
- }
2188
- case 'toUpperCase': {
2189
- // Perl's native `uc` — mirrors `toLowerCase` exactly.
2190
- const recv = emit(object)
2191
- return `uc(${recv})`
2192
- }
2193
- case 'trim': {
2194
- // No Perl native `trim`; route through the `bf->trim`
2195
- // helper so the regex stays in one place (and so an undef
2196
- // receiver doesn't trigger a warning about applying `s///`
2197
- // to undef).
2198
- const recv = emit(object)
2199
- return `bf->trim(${recv})`
2200
- }
2201
- case 'toFixed': {
2202
- // `.toFixed(digits?)` — Number → fixed-decimal string. `bf->to_fixed`
2203
- // mirrors JS rounding + zero-padding (default 0 digits). #1897.
2204
- const recv = emit(object)
2205
- const digits = args.length >= 1 ? emit(args[0]) : '0'
2206
- return `bf->to_fixed(${recv}, ${digits})`
2207
- }
2208
- case 'split': {
2209
- // `.split()` / `.split(sep)` / `.split(sep, limit)` — string →
2210
- // ARRAY ref via `bf->split`. With no separator the helper returns
2211
- // the whole string as a single element; otherwise it quotemetas
2212
- // the separator (literal match, not regex) and keeps trailing
2213
- // empties (`-1`), staying byte-equal with Go's `bf_split`. The
2214
- // optional `limit` caps the pieces; JS ignores a third+ argument.
2215
- // See #1448 Tier B.
2216
- const recv = emit(object)
2217
- if (args.length === 0) {
2218
- return `bf->split(${recv})`
2219
- }
2220
- const sep = emit(args[0])
2221
- if (args.length === 1) {
2222
- return `bf->split(${recv}, ${sep})`
2223
- }
2224
- const limit = emit(args[1])
2225
- return `bf->split(${recv}, ${sep}, ${limit})`
2226
- }
2227
- case 'startsWith':
2228
- case 'endsWith': {
2229
- // `.startsWith(prefix, position?)` / `.endsWith(suffix,
2230
- // endPosition?)` — string → boolean. The Perl helpers
2231
- // (`bf->starts_with` / `bf->ends_with`) do a `substr`-anchored
2232
- // comparison so the search string is matched literally (no regex
2233
- // metachar surprises) and undef receivers stay quiet. The optional
2234
- // second argument re-anchors the test; JS ignores a third+
2235
- // argument. See #1448 Tier B.
2236
- const fn = method === 'startsWith' ? 'starts_with' : 'ends_with'
2237
- const recv = emit(object)
2238
- const arg = emit(args[0])
2239
- if (args.length >= 2) {
2240
- return `bf->${fn}(${recv}, ${arg}, ${emit(args[1])})`
2241
- }
2242
- return `bf->${fn}(${recv}, ${arg})`
2243
- }
2244
- case 'replace': {
2245
- // `.replace(old, new)` — string-pattern form, first occurrence.
2246
- // The `bf->replace` helper splices via index/substr (not `s///`)
2247
- // so both the pattern and the replacement are literal — no Perl
2248
- // regex metacharacters and no `$1` / `$&` interpolation in the
2249
- // replacement, keeping it byte-equal with Go's `bf_replace`. The
2250
- // regex-pattern form is refused upstream at the parser. See
2251
- // #1448 Tier B.
2252
- const recv = emit(object)
2253
- const oldS = emit(args[0])
2254
- const newS = emit(args[1])
2255
- return `bf->replace(${recv}, ${oldS}, ${newS})`
2256
- }
2257
- case 'repeat': {
2258
- // `.repeat(n)` — string repeated `n` times. The `bf->repeat`
2259
- // helper wraps Perl's `x` operator with the same negative-count
2260
- // → "" clamp and integer truncation Go's `bf_repeat` applies, so
2261
- // the two adapters stay byte-equal. Full JS arity: the no-argument
2262
- // form is `repeat(0)` → ""; a second+ argument is ignored.
2263
- // See #1448 Tier B.
2264
- const recv = emit(object)
2265
- const count = args.length === 0 ? '0' : emit(args[0])
2266
- return `bf->repeat(${recv}, ${count})`
2267
- }
2268
- case 'padStart':
2269
- case 'padEnd': {
2270
- // `.padStart(target, pad?)` / `.padEnd(target, pad?)`. The
2271
- // `bf->pad_*` helpers default the pad to a single space when the
2272
- // arg is omitted and measure length in characters, matching Go's
2273
- // rune-based `bf_pad_*`. Full JS arity: the no-argument form is
2274
- // `padStart(0)` → the receiver unchanged; a third+ argument is
2275
- // ignored. See #1448 Tier B.
2276
- const fn = method === 'padStart' ? 'pad_start' : 'pad_end'
2277
- const recv = emit(object)
2278
- if (args.length === 0) {
2279
- return `bf->${fn}(${recv}, 0)`
2280
- }
2281
- const target = emit(args[0])
2282
- if (args.length === 1) {
2283
- return `bf->${fn}(${recv}, ${target})`
2284
- }
2285
- const pad = emit(args[1])
2286
- return `bf->${fn}(${recv}, ${target}, ${pad})`
2287
- }
2288
- default: {
2289
- // TS-level exhaustiveness guard. If this throws at runtime, the
2290
- // IR was constructed against a newer `ArrayMethod` variant that
2291
- // this adapter hasn't been updated for — loud failure is better
2292
- // than emitting a silent empty string downstream.
2293
- const _exhaustive: never = method
2294
- throw new Error(
2295
- `renderArrayMethod: unhandled ArrayMethod '${(_exhaustive as string)}'`,
2296
- )
2297
- }
2298
- }
2299
- }
2300
-
2301
- /**
2302
- * Shared Mojo emit for `.sort(cmp)` / `.toSorted(cmp)` (#1448 Tier B).
2303
- * Used by both the filter-context emitter and the top-level emitter,
2304
- * plus the loop-hoist path in `renderLoop` — same emit shape across
2305
- * all three so a regression in any one path surfaces consistently.
2306
- *
2307
- * The Perl helper accepts a hash-ref opts bag whose `keys` entry is
2308
- * an ordered list of per-key hashes (room for a future `nulls` knob
2309
- * without arity churn), and returns a fresh ARRAY ref so downstream
2310
- * composition (`@{bf->sort(...)}` in `join(...)`, etc.) stays
2311
- * straightforward.
2312
- */
2313
- /**
2314
- * Encode an `IRLoop.markerId` into a Perl-identifier-safe suffix
2315
- * for the `bf_iter_…` hoist var. Collision-free for marker ids
2316
- * that differ in any character — `-` and `_` map to distinct
2317
- * encodings (`_x2d` vs `__`) so `l-0` and `l_0` stay distinct.
2318
- *
2319
- * Today the IR only emits `l<digits>` so the encoding is mostly
2320
- * an identity, but pinning collision-freeness up front avoids a
2321
- * silent variable-shadow bug if a future marker generator widens
2322
- * the alphabet.
2323
- */
2324
- function perlIdentifierFromMarkerId(markerId: string): string {
2325
- return markerId.replace(/[^a-zA-Z0-9]/g, (ch) =>
2326
- ch === '_' ? '__' : `_x${ch.charCodeAt(0).toString(16)}`
2327
- )
2328
- }
2329
-
2330
- function renderSortMethod(recv: string, c: SortComparator): string {
2331
- // One hash per comparison key, in priority order, under `keys`. A
2332
- // simple comparator yields a one-element list; a `||`-chained
2333
- // multi-key comparator yields one per operand. `bf->sort` walks them
2334
- // in order, falling through to the next on a tie.
2335
- const keyHashes = c.keys.map((k) => {
2336
- const keyEntry =
2337
- k.key.kind === 'self'
2338
- ? `key_kind => 'self'`
2339
- : `key_kind => 'field', key => '${k.key.field}'`
2340
- return `{ ${keyEntry}, compare_type => '${k.type}', direction => '${k.direction}' }`
2341
- })
2342
- return `bf->sort(${recv}, { keys => [${keyHashes.join(', ')}] })`
2343
- }
2344
-
2345
- /**
2346
- * Render a `.reduce(fn, init)` arithmetic fold (#1448 Tier C) as a
2347
- * `bf->reduce(...)` call. The structured `ReduceOp` maps to the Perl
2348
- * helper's options hash:
2349
- *
2350
- * bf->reduce($recv, { op => '+', key_kind => 'field', key => 'duration',
2351
- * type => 'numeric', init => 0 })
2352
- *
2353
- * A numeric init passes through as a bare Perl number (`0`, `-1`); a
2354
- * string init (concat fold) is re-quoted from its literal contents.
2355
- */
2356
- function renderReduceMethod(recv: string, op: ReduceOp, direction: 'left' | 'right'): string {
2357
- const keyEntry =
2358
- op.key.kind === 'self'
2359
- ? `key_kind => 'self'`
2360
- : `key_kind => 'field', key => '${op.key.field}'`
2361
- // `op.init` is the decoded seed value. A numeric seed is already a
2362
- // canonical decimal literal Perl reads directly; a concat seed is the
2363
- // string contents, embedded in a single-quoted Perl literal. The `'`
2364
- // escape is REQUIRED: a seed decoded from a double-quoted JS literal
2365
- // (e.g. `"a'b"`) is escape-free yet contains an apostrophe. A literal
2366
- // backslash can't occur (it would need a `\\` escape, which the parser
2367
- // refuses), but escaping it too keeps this self-contained.
2368
- const init =
2369
- op.type === 'string'
2370
- ? `'${op.init.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
2371
- : op.init
2372
- // `direction` is "left" (reduce) or "right" (reduceRight); the Perl
2373
- // helper reverses the list for "right". Only observable for concat.
2374
- return `bf->reduce(${recv}, { op => '${op.op}', ${keyEntry}, type => '${op.type}', init => ${init}, direction => '${direction}' })`
2375
- }
2376
-
2377
- // `.flat(depth?)` → `bf->flat($recv, $depth)`. The `Infinity` form lowers
2378
- // to the `-1` sentinel (flatten fully); a finite depth flattens that many
2379
- // levels (`0` = shallow copy). See `sub flat` in BarefootJS.pm. (#1448)
2380
- function renderFlatMethod(recv: string, depth: FlatDepth): string {
2381
- const d = depth === 'infinity' ? -1 : depth
2382
- return `bf->flat(${recv}, ${d})`
2383
- }
2384
-
2385
- // `.flatMap(i => i)` / `.flatMap(i => i.field)` → `bf->flat_map($recv,
2386
- // 'self'|'field', 'field')`, and the array-literal tuple form
2387
- // `i => [i.a, i.b]` → `bf->flat_map_tuple($recv, ['field','a'], ...)`
2388
- // (one arrayref per leaf). The field key is the raw JS prop name (Perl
2389
- // hashes are keyed by it), mirroring `bf->reduce`. See `sub flat_map` /
2390
- // `sub flat_map_tuple` in BarefootJS.pm.
2391
- function renderFlatMapMethod(recv: string, op: FlatMapOp): string {
2392
- const proj = op.projection
2393
- if (proj.kind === 'tuple') {
2394
- const specs = proj.elements
2395
- .map(l => (l.kind === 'self' ? `['self', '']` : `['field', '${l.field}']`))
2396
- .join(', ')
2397
- return `bf->flat_map_tuple(${recv}, ${specs})`
2398
- }
2399
- if (proj.kind === 'self') return `bf->flat_map(${recv}, 'self', '')`
2400
- return `bf->flat_map(${recv}, 'field', '${proj.field}')`
2401
- }
2402
-
2403
- /** True when `type` is the `string` primitive. */
2404
- function isStringTypeInfo(type: TypeInfo | undefined): boolean {
2405
- return type?.kind === 'primitive' && type.primitive === 'string'
2406
- }
2407
-
2408
- /** True when `initialValue` is a bare string-literal expression (`'x'` /
2409
- * `"x"`), used as a fallback for signals whose type wasn't inferred. */
2410
- function isBareStringLiteral(initialValue: string | undefined): boolean {
2411
- if (!initialValue) return false
2412
- const v = initialValue.trim()
2413
- return (v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))
2414
- }
2415
-
2416
- /**
2417
- * Whether a comparison operand is string-typed, so JS `===`/`!==` against it
2418
- * must lower to Perl `eq`/`ne` instead of numeric `==`/`!=` (#1672). Covers a
2419
- * string literal, a string-signal getter call (`sel()`), and a string prop
2420
- * access (`props.x`). `isStringName` reports whether a getter/prop name is
2421
- * known-string. Loop-element fields (`t.id`) on untyped arrays have no known
2422
- * type and stay undetected — a separate, narrower gap.
2423
- */
2424
- function isStringTypedOperand(expr: ParsedExpr, isStringName: (n: string) => boolean): boolean {
2425
- if (expr.kind === 'literal' && expr.literalType === 'string') return true
2426
- if (expr.kind === 'call' && expr.callee.kind === 'identifier' && expr.args.length === 0) {
2427
- return isStringName(expr.callee.name)
2428
- }
2429
- if (expr.kind === 'member' && expr.object.kind === 'identifier' && expr.object.name === 'props') {
2430
- return isStringName(expr.property)
2431
- }
2432
- return false
2433
- }
2434
-
2435
- /**
2436
- * Lower `arr[index]` to a Perl deref. Perl distinguishes array
2437
- * (`->[$i]`) from hash (`->{$k}`) access, which JS's single `[]` does
2438
- * not — so we pick by the index expression's type: a string-typed key
2439
- * derefs the hash, anything else (the common loop-index / arithmetic
2440
- * case, e.g. `selected()[index]`) derefs the array. #1897.
2441
- */
2442
- function emitIndexAccessPerl(
2443
- object: ParsedExpr,
2444
- index: ParsedExpr,
2445
- emit: (e: ParsedExpr) => string,
2446
- isStringName: (n: string) => boolean,
2447
- ): string {
2448
- const i = emit(index)
2449
- return isStringTypedOperand(index, isStringName)
2450
- ? `${emit(object)}->{${i}}`
2451
- : `${emit(object)}->[${i}]`
2452
- }
2453
-
2454
- /**
2455
- * Lowering for the predicate body of a filter / every / some / find,
2456
- * plus the same shape used by `renderBlockBodyCondition` for complex
2457
- * block-body filters. Identifiers resolve against:
2458
- * - the predicate's loop param (`$param`),
2459
- * - `localVarMap` aliases declared inside the block body, then
2460
- * - a bare `$name` fallback for signals captured by the closure.
2461
- *
2462
- * Methods that have no filter-context meaning (template-literal,
2463
- * arrow-fn, conditional, unsupported) fall back to the `'1'` literal
2464
- * the original switch's `default` arm returned — those shapes never
2465
- * arose inside the predicates the adapter actually accepts.
2466
- */
2467
- class MojoFilterEmitter implements ParsedExprEmitter {
2468
- constructor(
2469
- private readonly param: string,
2470
- private readonly localVarMap: Map<string, string>,
2471
- // Reports whether a getter/prop name is string-typed, so `===`/`!==`
2472
- // against it lowers to `eq`/`ne` (#1672). Defaults to "never" for callers
2473
- // that don't thread it through.
2474
- private readonly isStringName: (n: string) => boolean = () => false,
2475
- ) {}
2476
-
2477
- identifier(name: string): string {
2478
- if (name === this.param) return `$${this.param}`
2479
- const signal = this.localVarMap.get(name)
2480
- if (signal) return `$${signal}`
2481
- return `$${name}`
2482
- }
2483
-
2484
- literal(value: string | number | boolean | null, literalType: LiteralType): string {
2485
- if (literalType === 'string') return `'${value}'`
2486
- if (literalType === 'boolean') return value ? '1' : '0'
2487
- if (literalType === 'null') return 'undef'
2488
- return String(value)
2489
- }
2490
-
2491
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
2492
- // `.length` on a higher-order result (e.g.
2493
- // `x.tags.filter(t => t.active).length > 0` inside the outer
2494
- // filter predicate, #1443). The higher-order emit produces an
2495
- // anonymous array ref `[grep ...]`; reading `->{length}` on that
2496
- // is undef at runtime, which is why the pre-#1443 `containsHigherOrder`
2497
- // gate refused this shape outright. Lowering `.length` to
2498
- // `scalar(@{...})` makes the result a real Perl integer.
2499
- if (property === 'length' && (object.kind === 'higher-order' || object.kind === 'array-literal')) {
2500
- return `scalar(@{${emit(object)}})`
2501
- }
2502
- return `${emit(object)}->{${property}}`
2503
- }
2504
-
2505
- indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2506
- return emitIndexAccessPerl(object, index, emit, this.isStringName)
2507
- }
2508
-
2509
- call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
2510
- // Signal getter calls: filter() → $filter
2511
- if (callee.kind === 'identifier' && args.length === 0) {
2512
- return `$${callee.name}`
2513
- }
2514
- return emit(callee)
2515
- }
2516
-
2517
- unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2518
- const arg = emit(argument)
2519
- if (op === '!') {
2520
- // Wrap binary/logical operands in parens to dodge Perl precedence surprises.
2521
- const needsParens = argument.kind === 'binary' || argument.kind === 'logical'
2522
- return needsParens ? `!(${arg})` : `!${arg}`
2523
- }
2524
- if (op === '-') return `-${arg}`
2525
- return arg
2526
- }
2527
-
2528
- binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2529
- const l = emit(left)
2530
- const r = emit(right)
2531
- // String equality: `eq`/`ne` when EITHER operand is string-typed — a string
2532
- // literal, a string signal getter, or a string prop. Numeric `==`/`!=`
2533
- // would coerce both sides to 0 and match unrelated non-numeric strings (#1672).
2534
- const isStr = (e: ParsedExpr) => isStringTypedOperand(e, this.isStringName)
2535
- const stringCmp = isStr(left) || isStr(right)
2536
- if ((op === '===' || op === '==') && stringCmp) {
2537
- return `${l} eq ${r}`
2538
- }
2539
- if ((op === '!==' || op === '!=') && stringCmp) {
2540
- return `${l} ne ${r}`
2541
- }
2542
- const opMap: Record<string, string> = {
2543
- '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
2544
- '+': '+', '-': '-', '*': '*', '/': '/',
2545
- }
2546
- return `${l} ${opMap[op] ?? op} ${r}`
2547
- }
2548
-
2549
- logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2550
- const l = emit(left)
2551
- const r = emit(right)
2552
- if (op === '&&') return `(${l} && ${r})`
2553
- if (op === '||') return `(${l} || ${r})`
2554
- return `(${l} // ${r})`
2555
- }
2556
-
2557
- higherOrder(
2558
- method: HigherOrderMethod,
2559
- object: ParsedExpr,
2560
- param: string,
2561
- predicate: ParsedExpr,
2562
- emit: (e: ParsedExpr) => string,
2563
- ): string {
2564
- // The predicate body is also a filter context, but with this
2565
- // higher-order's own `param` (potentially shadowing the outer one),
2566
- // so we spin up a nested emitter with the inner param.
2567
- const arrayExpr = emit(object)
2568
- const predBody = emitParsedExpr(predicate, new MojoFilterEmitter(param, this.localVarMap, this.isStringName))
2569
- const grepBody = predBody.replace(new RegExp(`\\$${param}\\b`, 'g'), '$_')
2570
- if (method === 'filter') return `[grep { ${grepBody} } @{${arrayExpr}}]`
2571
- if (method === 'every') return `!(grep { !(${grepBody}) } @{${arrayExpr}})`
2572
- if (method === 'some') return `!!(grep { ${grepBody} } @{${arrayExpr}})`
2573
- return arrayExpr
2574
- }
2575
-
2576
- arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
2577
- // Perl array ref: `[$a, $b]`. Filter-context use is rare (the
2578
- // outer emitter routes most array-literal arrivals via
2579
- // MojoTopLevelEmitter), but #1443's chain
2580
- // `[a, b].filter(Boolean).join(' ')` can land here when the
2581
- // outer `.filter()` recurses into a nested filter whose own
2582
- // source is an array literal.
2583
- return `[${elements.map(emit).join(', ')}]`
2584
- }
2585
-
2586
- arrayMethod(
2587
- method: ArrayMethod,
2588
- object: ParsedExpr,
2589
- args: ParsedExpr[],
2590
- emit: (e: ParsedExpr) => string,
2591
- ): string {
2592
- // Filter-context array methods are vanishingly rare — predicates
2593
- // operate on scalars, not arrays. Defer to the top-level rendering
2594
- // (`join(sep, @{...})`) for any case that does land here so the
2595
- // emission stays consistent across contexts.
2596
- return renderArrayMethod(method, object, args, emit)
2597
- }
2598
-
2599
- sortMethod(
2600
- _method: 'sort' | 'toSorted',
2601
- object: ParsedExpr,
2602
- comparator: SortComparator,
2603
- emit: (e: ParsedExpr) => string,
2604
- ): string {
2605
- return renderSortMethod(emit(object), comparator)
2606
- }
2607
-
2608
- reduceMethod(method: 'reduce' | 'reduceRight', object: ParsedExpr, reduceOp: ReduceOp, emit: (e: ParsedExpr) => string): string {
2609
- return renderReduceMethod(emit(object), reduceOp, method === 'reduceRight' ? 'right' : 'left')
2610
- }
2611
-
2612
- flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
2613
- return renderFlatMethod(emit(object), depth)
2614
- }
2615
-
2616
- flatMapMethod(object: ParsedExpr, op: FlatMapOp, emit: (e: ParsedExpr) => string): string {
2617
- return renderFlatMapMethod(emit(object), op)
2618
- }
2619
-
2620
- conditional(_test: ParsedExpr, _consequent: ParsedExpr, _alternate: ParsedExpr): string {
2621
- return '1'
2622
- }
2623
-
2624
- templateLiteral(_parts: TemplatePart[]): string {
2625
- return '1'
2626
- }
2627
-
2628
- arrowFn(_param: string, _body: ParsedExpr): string {
2629
- return '1'
2630
- }
2631
-
2632
- unsupported(_raw: string, _reason: string): string {
2633
- return '1'
2634
- }
2635
- }
2636
-
2637
- /**
2638
- * Lowering for top-level expressions whose identifiers resolve against
2639
- * the Mojo template's stash (signals, props, locals introduced by
2640
- * `% my $x = ...;` lines). Differs from the filter emitter mainly in
2641
- * - `.length` → `scalar(@{...})` (filter contexts never see arrays
2642
- * in lvalue position),
2643
- * - `conditional` is supported (filter predicates can't return
2644
- * ternaries),
2645
- * - the `unsupported` fallback drops to the regex pipeline so legacy
2646
- * shapes the AST can't classify still emit something coherent.
2647
- */
2648
- class MojoTopLevelEmitter implements ParsedExprEmitter {
2649
- constructor(private readonly adapter: MojoAdapter) {}
2650
-
2651
- identifier(name: string): string {
2652
- // `undefined` / `null` nested inside a larger expression tree
2653
- // (#1897, pagination's `props.isActive ? 'page' : undefined`) — the
2654
- // top-level short-circuits don't see them.
2655
- if (name === 'undefined' || name === 'null') return 'undef'
2656
- // Module pure-string const (e.g. `const baseClasses = '...'` used in a
2657
- // className template literal): inline the literal value rather than emit
2658
- // `$baseClasses` against a stash variable that is never bound.
2659
- const inlined = this.adapter.resolveModuleStringConst(name)
2660
- if (inlined !== null) return inlined
2661
- // Same for a literal const of any scope (`const totalPages = 5`,
2662
- // #1897 pagination's `Page {currentPage()} of {totalPages}`).
2663
- const literalConst = this.adapter.resolveLiteralConst(name)
2664
- if (literalConst !== null) return literalConst
2665
- return `$${name}`
2666
- }
2667
-
2668
- literal(value: string | number | boolean | null, literalType: LiteralType): string {
2669
- if (literalType === 'string') return `'${value}'`
2670
- if (literalType === 'boolean') return value ? '1' : '0'
2671
- if (literalType === 'null') return 'undef'
2672
- return String(value)
2673
- }
2674
-
2675
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
2676
- // `props.x` flattens to the bare `$x` the Mojo SSR caller binds each
2677
- // prop to (props arrive as individual `my $x = ...` vars, not a
2678
- // `$props` hashref).
2679
- if (object.kind === 'identifier' && object.name === 'props') {
2680
- return `$${property}`
2681
- }
2682
- // Static property access on a module object-literal const
2683
- // (`variantClasses.ghost`, #1897) resolves at compile time — the
2684
- // generic hash lowering below would dereference a Perl var that
2685
- // doesn't exist server-side.
2686
- if (object.kind === 'identifier') {
2687
- const staticValue = this.adapter.resolveStaticRecordLiteral(object.name, property)
2688
- if (staticValue !== null) return staticValue
2689
- }
2690
- const obj = emit(object)
2691
- if (property === 'length') return `scalar(@{${obj}})`
2692
- return `${obj}->{${property}}`
2693
- }
2694
-
2695
- indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2696
- return emitIndexAccessPerl(object, index, emit, n => this.adapter._isStringValueName(n))
2697
- }
2698
-
2699
- call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
2700
- // Signal getter: count() → $count
2701
- if (callee.kind === 'identifier' && args.length === 0) {
2702
- return `$${callee.name}`
2703
- }
2704
- // Env-signal method call (#1922): `searchParams().get('sort')` is a real
2705
- // method call on the per-request `$searchParams` reader object, not the
2706
- // generic hash deref `member` would emit (`$searchParams->{get}`, which
2707
- // drops the arg). Matches the local import binding (incl. an alias).
2708
- if (this.adapter._searchParamsLocals.size > 0) {
2709
- const sp = matchSearchParamsMethodCall(callee, args, this.adapter._searchParamsLocals)
2710
- if (sp) {
2711
- return `$searchParams->${sp.method}(${sp.args.map(emit).join(', ')})`
2712
- }
2713
- }
2714
- // Identifier-path templatePrimitive (#1189): `JSON.stringify(x)` /
2715
- // `Math.floor(x)` → `bf->json($x)` / `bf->floor($x)`. Args render
2716
- // recursively through this same emitter so prop refs / signal calls
2717
- // inside them get the standard transforms. Mirrors the Go adapter's
2718
- // `call()` primitive dispatch. A wrong-arity call records BF101 and
2719
- // returns the safe `''` placeholder (never silently emits a bad call).
2720
- const path = identifierPath(callee)
2721
- const spec = path ? MOJO_TEMPLATE_PRIMITIVES[path] : undefined
2722
- if (path && spec) {
2723
- if (args.length === spec.arity) {
2724
- return spec.emit(args.map(emit))
2725
- }
2726
- this.adapter._recordExprBF101(
2727
- `templatePrimitive '${path}' expects ${spec.arity} arg(s), got ${args.length}`,
2728
- `Call '${path}' with exactly ${spec.arity} argument(s).`,
2729
- )
2730
- // Don't fall through to the generic `emit(callee)` below — for a
2731
- // member callee (`JSON.stringify`) that emits an invalid Perl
2732
- // hash-deref (`$JSON->{stringify}`). Return the same safe
2733
- // empty-string placeholder the other BF101 paths use.
2734
- return "''"
2735
- }
2736
- // Array methods (`.join` and any others added to ArrayMethod, #1443)
2737
- // are lifted into the `array-method` IR kind at parse time, so they
2738
- // never reach this dispatcher. Per-method detection here would mix
2739
- // value-builtin lowering with signal-call lowering — keeping them
2740
- // separated forces every adapter to declare the full array-method
2741
- // surface in one place (the `arrayMethod` emitter below).
2742
- return emit(callee)
2743
- }
2744
-
2745
- unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2746
- const arg = emit(argument)
2747
- if (op === '!') return `!${arg}`
2748
- if (op === '-') return `-${arg}`
2749
- return arg
2750
- }
2751
-
2752
- binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2753
- const l = emit(left)
2754
- const r = emit(right)
2755
- // String equality: `eq`/`ne` when EITHER operand is string-typed — a string
2756
- // literal (`role() === 'admin'`), a string signal getter (`sel()`), or a
2757
- // string prop (`props.x`). Falling back to numeric `==`/`!=` would make
2758
- // Perl coerce both sides to 0 and match unrelated non-numeric strings
2759
- // (`"b" == "a"` → true), so all loop items render their true branch (#1672).
2760
- const isStr = (e: ParsedExpr) => isStringTypedOperand(e, n => this.adapter._isStringValueName(n))
2761
- const stringCmp = isStr(left) || isStr(right)
2762
- if ((op === '===' || op === '==') && stringCmp) {
2763
- return `${l} eq ${r}`
2764
- }
2765
- if ((op === '!==' || op === '!=') && stringCmp) {
2766
- return `${l} ne ${r}`
2767
- }
2768
- const opMap: Record<string, string> = {
2769
- '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
2770
- '+': '+', '-': '-', '*': '*',
2771
- }
2772
- return `${l} ${opMap[op] ?? op} ${r}`
2773
- }
2774
-
2775
- logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2776
- const l = emit(left)
2777
- const r = emit(right)
2778
- if (op === '&&') return `(${l} && ${r})`
2779
- if (op === '||') return `(${l} || ${r})`
2780
- return `(${l} // ${r})`
2781
- }
2782
-
2783
- higherOrder(
2784
- method: HigherOrderMethod,
2785
- object: ParsedExpr,
2786
- param: string,
2787
- predicate: ParsedExpr,
2788
- emit: (e: ParsedExpr) => string,
2789
- ): string {
2790
- const arrayExpr = emit(object)
2791
- const predBody = this.adapter._renderPerlFilterExprPublic(predicate, param)
2792
- const grepBody = predBody.replace(new RegExp(`\\$${param}\\b`, 'g'), '$_')
2793
- if (method === 'filter') return `[grep { ${grepBody} } @{${arrayExpr}}]`
2794
- if (method === 'every') return `!(grep { !(${grepBody}) } @{${arrayExpr}})`
2795
- if (method === 'some') return `!!(grep { ${grepBody} } @{${arrayExpr}})`
2796
- // `.find` / `.findIndex` / `.findLast` / `.findLastIndex` → the runtime
2797
- // helpers (`bf->find` / `find_index` / `find_last` / `find_last_index`),
2798
- // which call the predicate as a per-element coderef — same shape Xslate
2799
- // emits via a Kolon lambda. The JS camelCase names map to the snake_case
2800
- // helpers (like index_of / last_index_of).
2801
- const findHelper: Record<string, string> = {
2802
- find: 'find',
2803
- findIndex: 'find_index',
2804
- findLast: 'find_last',
2805
- findLastIndex: 'find_last_index',
2806
- }
2807
- if (findHelper[method]) {
2808
- return `bf->${findHelper[method]}(${arrayExpr}, sub { my $${param} = $_[0]; ${predBody} })`
2809
- }
2810
- return arrayExpr
2811
- }
2812
-
2813
- arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
2814
- // Perl array ref. Identifiers inside elements resolve through the
2815
- // top-level emitter so `[className, childClass]` becomes
2816
- // `[$className, $childClass]` (the registry Slot's chain in
2817
- // #1443). Empty `[]` stays as `[]` — a valid empty Perl array
2818
- // ref that grep/join handle naturally.
2819
- return `[${elements.map(emit).join(', ')}]`
2820
- }
2821
-
2822
- arrayMethod(
2823
- method: ArrayMethod,
2824
- object: ParsedExpr,
2825
- args: ParsedExpr[],
2826
- emit: (e: ParsedExpr) => string,
2827
- ): string {
2828
- return renderArrayMethod(method, object, args, emit)
2829
- }
2830
-
2831
- sortMethod(
2832
- _method: 'sort' | 'toSorted',
2833
- object: ParsedExpr,
2834
- comparator: SortComparator,
2835
- emit: (e: ParsedExpr) => string,
2836
- ): string {
2837
- return renderSortMethod(emit(object), comparator)
2838
- }
2839
-
2840
- reduceMethod(method: 'reduce' | 'reduceRight', object: ParsedExpr, reduceOp: ReduceOp, emit: (e: ParsedExpr) => string): string {
2841
- return renderReduceMethod(emit(object), reduceOp, method === 'reduceRight' ? 'right' : 'left')
2842
- }
2843
-
2844
- flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
2845
- return renderFlatMethod(emit(object), depth)
2846
- }
2847
-
2848
- flatMapMethod(object: ParsedExpr, op: FlatMapOp, emit: (e: ParsedExpr) => string): string {
2849
- return renderFlatMapMethod(emit(object), op)
2850
- }
2851
-
2852
- conditional(
2853
- test: ParsedExpr,
2854
- consequent: ParsedExpr,
2855
- alternate: ParsedExpr,
2856
- emit: (e: ParsedExpr) => string,
2857
- ): string {
2858
- return `(${emit(test)} ? ${emit(consequent)} : ${emit(alternate)})`
2859
- }
2860
-
2861
- templateLiteral(parts: TemplatePart[], emit: (e: ParsedExpr) => string): string {
2862
- // `` `n=${count() + 1}` `` → Perl string concatenation
2863
- // (`"n=" . ($count + 1)`), NOT double-quote interpolation. Perl only
2864
- // interpolates simple `$var` reads inside `"..."`, so complex `${...}`
2865
- // parts — arithmetic, helper calls (`bf->json(...)`), ternaries —
2866
- // would render unevaluated if inlined into a quoted string.
2867
- // - Static chunks are emitted as quoted literals with the sigils
2868
- // that interpolate inside `"..."` (`$`/`@`) plus `"`/`\` escaped,
2869
- // so literal text survives verbatim.
2870
- // - Expression terms whose Perl precedence is below `.` (binary /
2871
- // logical / conditional) wrap in parens so they bind before the
2872
- // concatenation.
2873
- const terms: string[] = []
2874
- for (const part of parts) {
2875
- if (part.type === 'string') {
2876
- if (part.value !== '') {
2877
- terms.push(`"${part.value.replace(/[\\"$@]/g, m => `\\${m}`)}"`)
2878
- }
2879
- } else {
2880
- const rendered = emit(part.expr)
2881
- const needsParens =
2882
- part.expr.kind === 'binary' ||
2883
- part.expr.kind === 'logical' ||
2884
- part.expr.kind === 'conditional'
2885
- terms.push(needsParens ? `(${rendered})` : rendered)
2886
- }
2887
- }
2888
- if (terms.length === 0) return '""'
2889
- return terms.join(' . ')
2890
- }
2891
-
2892
- arrowFn(_param: string, _body: ParsedExpr): string {
2893
- // A bare arrow function never stands alone at a render position (it's
2894
- // only meaningful as a higher-order predicate, handled above). Return
2895
- // the safe Perl empty-string literal `''` — consistent with the BF101
2896
- // / `unsupported` paths — so a stray emit can't produce a `<%= %>`
2897
- // syntax error.
2898
- return "''"
2899
- }
2900
-
2901
- unsupported(_raw: string, _reason: string): string {
2902
- // Unreachable in the parse-first flow: `convertExpressionToPerl`
2903
- // gates on `isSupported` before dispatching, and `isSupported`
2904
- // recurses, so a top-level supported expression never contains an
2905
- // `unsupported` node. Return a safe Perl empty-string literal in
2906
- // case a future caller renders a node tree directly.
2907
- return "''"
2908
- }
2909
- }
2910
-
2911
1665
  export const mojoAdapter = new MojoAdapter()