@barefootjs/mojolicious 0.16.0 → 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 -1336
  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 -1336
  31. package/dist/index.js +1490 -1336
  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 +124 -60
  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 +223 -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
  }
@@ -1657,99 +1398,6 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1657
1398
  return emitParsedExpr(expr, new MojoFilterEmitter(param, localVarMap, n => this._isStringValueName(n)))
1658
1399
  }
1659
1400
 
1660
- /**
1661
- * Render a complex block body filter into a Perl condition.
1662
- * Handles patterns like: filter(t => { const f = filter(); if (...) return ...; })
1663
- */
1664
- private renderBlockBodyCondition(
1665
- statements: ParsedStatement[],
1666
- param: string
1667
- ): string {
1668
- const localVarMap = new Map<string, string>()
1669
- const paths = this.collectReturnPaths(statements, [], localVarMap, param)
1670
-
1671
- if (paths.length === 0) return '1'
1672
- if (paths.length === 1) return this.buildSinglePathCondition(paths[0], param, localVarMap)
1673
-
1674
- // Multiple paths: build OR condition
1675
- const parts: string[] = []
1676
- for (const path of paths) {
1677
- if (path.result.kind === 'literal' && path.result.literalType === 'boolean' && path.result.value === false) continue
1678
- const cond = this.buildSinglePathCondition(path, param, localVarMap)
1679
- if (cond !== '0') parts.push(cond)
1680
- }
1681
-
1682
- if (parts.length === 0) return '0'
1683
- if (parts.length === 1) return parts[0]
1684
- return `(${parts.join(' || ')})`
1685
- }
1686
-
1687
- private collectReturnPaths(
1688
- statements: ParsedStatement[],
1689
- currentConditions: ParsedExpr[],
1690
- localVarMap: Map<string, string>,
1691
- param: string
1692
- ): Array<{ conditions: ParsedExpr[]; result: ParsedExpr }> {
1693
- const paths: Array<{ conditions: ParsedExpr[]; result: ParsedExpr }> = []
1694
-
1695
- for (const stmt of statements) {
1696
- if (stmt.kind === 'var-decl') {
1697
- if (stmt.init.kind === 'call' && stmt.init.callee.kind === 'identifier') {
1698
- localVarMap.set(stmt.name, stmt.init.callee.name)
1699
- }
1700
- } else if (stmt.kind === 'return') {
1701
- paths.push({ conditions: [...currentConditions], result: stmt.value })
1702
- break
1703
- } else if (stmt.kind === 'if') {
1704
- const thenPaths = this.collectReturnPaths(stmt.consequent, [...currentConditions, stmt.condition], localVarMap, param)
1705
- paths.push(...thenPaths)
1706
-
1707
- if (stmt.alternate) {
1708
- const negated: ParsedExpr = { kind: 'unary', op: '!', argument: stmt.condition }
1709
- const elsePaths = this.collectReturnPaths(stmt.alternate, [...currentConditions, negated], localVarMap, param)
1710
- paths.push(...elsePaths)
1711
- } else {
1712
- currentConditions.push({ kind: 'unary', op: '!', argument: stmt.condition })
1713
- }
1714
- }
1715
- }
1716
-
1717
- return paths
1718
- }
1719
-
1720
- private buildSinglePathCondition(
1721
- path: { conditions: ParsedExpr[]; result: ParsedExpr },
1722
- param: string,
1723
- localVarMap: Map<string, string>
1724
- ): string {
1725
- if (path.result.kind === 'literal' && path.result.literalType === 'boolean') {
1726
- if (path.result.value === true) {
1727
- if (path.conditions.length === 0) return '1'
1728
- return this.renderConditionsAnd(path.conditions, param, localVarMap)
1729
- }
1730
- return '0'
1731
- }
1732
-
1733
- if (path.conditions.length === 0) {
1734
- return this.renderPerlFilterExpr(path.result, param, localVarMap)
1735
- }
1736
-
1737
- const condPart = this.renderConditionsAnd(path.conditions, param, localVarMap)
1738
- const resultPart = this.renderPerlFilterExpr(path.result, param, localVarMap)
1739
- return `(${condPart} && ${resultPart})`
1740
- }
1741
-
1742
- private renderConditionsAnd(
1743
- conditions: ParsedExpr[],
1744
- param: string,
1745
- localVarMap: Map<string, string>
1746
- ): string {
1747
- if (conditions.length === 0) return '1'
1748
- if (conditions.length === 1) return this.renderPerlFilterExpr(conditions[0], param, localVarMap)
1749
- const parts = conditions.map(c => this.renderPerlFilterExpr(c, param, localVarMap))
1750
- return `(${parts.join(' && ')})`
1751
- }
1752
-
1753
1401
  // ===========================================================================
1754
1402
  // Expression Conversion: JS → Perl
1755
1403
  // ===========================================================================
@@ -1863,145 +1511,48 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1863
1511
  return true
1864
1512
  }
1865
1513
 
1866
-
1867
1514
  /**
1868
- * Lower a conditional inline-object spread expression
1869
- * `(COND ? { 'aria-describedby': describedBy } : {})`
1870
- * (either branch possibly `{}`) into a Perl inline ternary of
1871
- * hashrefs for `bf->spread_attrs`:
1872
- * `$describedBy ? { 'aria-describedby' => $describedBy } : {}`
1873
- *
1874
- * The condition is translated via `convertExpressionToPerl` (a bare
1875
- * prop ident becomes `$describedBy`; Perl truthiness handles the
1876
- * test). Object literals become Perl hashrefs with `=>`; string-
1877
- * literal keys are quoted, values resolve via `convertExpressionToPerl`.
1878
- *
1879
- * Returns null when the expression is NOT this shape, or when a part
1880
- * can't be faithfully lowered (non-static key, etc.) so the caller
1881
- * falls back to the standard `convertExpressionToPerl` path (which
1882
- * records BF101). Scoped strictly to ternary-of-object-literals so no
1883
- * 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.
1884
1521
  */
1885
- private conditionalSpreadToPerl(expr: string): string | null {
1886
- const sf = ts.createSourceFile('__spread.ts', `(${expr})`, ts.ScriptTarget.Latest, true)
1887
- if (sf.statements.length !== 1) return null
1888
- const stmt = sf.statements[0]
1889
- if (!ts.isExpressionStatement(stmt)) return null
1890
- let node: ts.Expression = stmt.expression
1891
- while (ts.isParenthesizedExpression(node)) node = node.expression
1892
- if (!ts.isConditionalExpression(node)) return null
1893
- const unwrap = (e: ts.Expression): ts.Expression => {
1894
- let n = e
1895
- while (ts.isParenthesizedExpression(n)) n = n.expression
1896
- return n
1897
- }
1898
- const whenTrue = unwrap(node.whenTrue)
1899
- const whenFalse = unwrap(node.whenFalse)
1900
- if (!ts.isObjectLiteralExpression(whenTrue) || !ts.isObjectLiteralExpression(whenFalse)) {
1901
- 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),
1902
1531
  }
1903
- const condPerl = this.convertExpressionToPerl(node.condition.getText(sf))
1904
- const truePerl = this.objectLiteralToPerlHashref(whenTrue, sf)
1905
- const falsePerl = this.objectLiteralToPerlHashref(whenFalse, sf)
1906
- if (truePerl === null || falsePerl === null) return null
1907
- return `${condPerl} ? ${truePerl} : ${falsePerl}`
1908
1532
  }
1909
1533
 
1910
1534
  /**
1911
- * Convert a static object literal into a Perl hashref string for a
1912
- * conditional spread. Only static string/identifier keys are allowed;
1913
- * values resolve via `convertExpressionToPerl`. Returns null for any
1914
- * 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.
1915
1539
  */
1916
- private objectLiteralToPerlHashref(
1917
- obj: ts.ObjectLiteralExpression,
1918
- sf: ts.SourceFile,
1919
- ): string | null {
1920
- const entries: string[] = []
1921
- for (const prop of obj.properties) {
1922
- if (!ts.isPropertyAssignment(prop)) return null
1923
- let key: string
1924
- if (ts.isIdentifier(prop.name)) {
1925
- key = prop.name.text
1926
- } else if (ts.isStringLiteral(prop.name) || ts.isNoSubstitutionTemplateLiteral(prop.name)) {
1927
- key = prop.name.text
1928
- } else {
1929
- return null
1930
- }
1931
- const initNode = (() => {
1932
- let n: ts.Expression = prop.initializer
1933
- while (ts.isParenthesizedExpression(n)) n = n.expression
1934
- return n
1935
- })()
1936
- const indexed = this.recordIndexAccessToPerl(initNode)
1937
- if (
1938
- indexed === null &&
1939
- ts.isElementAccessExpression(initNode) &&
1940
- initNode.argumentExpression &&
1941
- !ts.isNumericLiteral(initNode.argumentExpression) &&
1942
- !ts.isStringLiteral(initNode.argumentExpression)
1943
- ) {
1944
- // Variable-index record access (`sizeMap[size]`) that the
1945
- // static-inline path couldn't resolve — a non-scalar record
1946
- // value, or a non-const receiver. Since #1897 made variable
1947
- // indices parseable (`index-access`), the generic value lowering
1948
- // would now emit `$sizeMap->{$size}` against an UNBOUND module
1949
- // const instead of refusing. Record BF101 and bail so the whole
1950
- // spread surfaces the out-of-shape diagnostic, matching the
1951
- // pre-#1897 behaviour (the refusal then was a side effect of the
1952
- // value lowering). (A bound receiver — a signal getter like
1953
- // `selected()[index]` — is an attribute value, not a spread
1954
- // member, and never reaches here.)
1955
- this.errors.push({
1956
- code: 'BF101',
1957
- severity: 'error',
1958
- 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.`,
1959
- loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1960
- suggestion: {
1961
- message: 'Index a record whose values are number/string literals, or move the spread into a `\'use client\'` component so hydration computes it.',
1962
- },
1963
- })
1964
- return null
1965
- }
1966
- const valPerl =
1967
- indexed !== null
1968
- ? indexed
1969
- : this.convertExpressionToPerl(prop.initializer.getText(sf))
1970
- 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),
1971
1547
  }
1972
- return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`
1973
1548
  }
1974
1549
 
1975
- /**
1976
- * Lower a spread-object VALUE of the form `IDENT[KEY]` where:
1977
- * - `IDENT` resolves via `localConstants` to a MODULE-scope object
1978
- * literal whose property values are all scalar (number/string)
1979
- * literals under static (string-literal or identifier) keys
1980
- * (a `Record<staticKeys, scalar>` map like `sizeMap`), AND
1981
- * - `KEY` is a bare identifier that is a prop.
1982
- * Emits an inline indexed Perl hashref:
1983
- * `{ 'sm' => 16, 'md' => 20, ... }->{$size}`
1984
- *
1985
- * Returns the Perl string when convertible, else `null` so the caller
1986
- * falls back to its normal value lowering (which records BF101 for an
1987
- * unsupported shape). Mirror of the Go adapter's `recordIndexAccessToGoMap`.
1988
- */
1989
- private recordIndexAccessToPerl(val: ts.Expression): string | null {
1990
- // Shared structural parse (single source of truth in `@barefootjs/jsx`);
1991
- // this wrapper only does the Perl-specific emit (single-quote escaping)
1992
- // from the structured result.
1993
- const parsed = parseRecordIndexAccess(val, this.localConstants, this.propsParams)
1994
- if (!parsed) return null
1995
- const entries = parsed.entries.map(e => {
1996
- const mapVal =
1997
- e.value.kind === 'number' ? e.value.text : `'${e.value.text.replace(/'/g, "\\'")}'`
1998
- return `'${e.key.replace(/'/g, "\\'")}' => ${mapVal}`
1999
- })
2000
- 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) }
2001
1553
  }
2002
1554
 
2003
-
2004
- private convertExpressionToPerl(expr: string): string {
1555
+ private convertExpressionToPerl(expr: string, preParsed?: ParsedExpr): string {
2005
1556
  // Parse-first lowering — parity with the Go adapter's
2006
1557
  // `convertExpressionToGo`. Parse the JS expression once, gate it on
2007
1558
  // the shared `isSupported`, and render every supported shape through
@@ -2011,16 +1562,48 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
2011
1562
  // string-rewriting pipeline. Unsupported shapes (un-lowered methods,
2012
1563
  // unparseable hand-written JS, etc.) surface as BF101 with the
2013
1564
  // `/* @client */` escape hatch instead of being silently mangled.
2014
- const trimmed = expr.trim()
2015
- 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
+ }
2016
1600
 
2017
- const parsed = parseExpression(trimmed)
2018
1601
  const support = isSupported(parsed)
2019
1602
  if (!support.supported) {
2020
1603
  this.errors.push({
2021
1604
  code: 'BF101',
2022
1605
  severity: 'error',
2023
- message: `Expression not supported: ${trimmed}`,
1606
+ message: `Expression not supported: ${preParsed ? stringifyParsedExpr(parsed) : expr.trim()}`,
2024
1607
  loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
2025
1608
  suggestion: {
2026
1609
  message: support.reason
@@ -2045,7 +1628,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
2045
1628
  * (#1250 phase 1B).
2046
1629
  */
2047
1630
  private renderParsedExprToPerl(expr: ParsedExpr): string {
2048
- return emitParsedExpr(expr, new MojoTopLevelEmitter(this))
1631
+ return emitParsedExpr(expr, new MojoTopLevelEmitter(this.emitCtx))
2049
1632
  }
2050
1633
 
2051
1634
  /**
@@ -2055,11 +1638,11 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
2055
1638
  */
2056
1639
  /** Whether `name` (a signal getter or prop) holds a string value, so an
2057
1640
  * equality comparison against it should use Perl `eq`/`ne` (#1672). */
2058
- _isStringValueName(name: string): boolean {
1641
+ private _isStringValueName(name: string): boolean {
2059
1642
  return this.stringValueNames.has(name)
2060
1643
  }
2061
1644
 
2062
- _recordExprBF101(message: string, reason?: string): void {
1645
+ private _recordExprBF101(message: string, reason?: string): void {
2063
1646
  this.errors.push({
2064
1647
  code: 'BF101',
2065
1648
  severity: 'error',
@@ -2074,845 +1657,9 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
2074
1657
  }
2075
1658
 
2076
1659
  /** Internal hook for higher-order: predicate body re-uses the filter emitter. */
2077
- _renderPerlFilterExprPublic(expr: ParsedExpr, param: string): string {
1660
+ private _renderPerlFilterExprPublic(expr: ParsedExpr, param: string): string {
2078
1661
  return this.renderPerlFilterExpr(expr, param)
2079
1662
  }
2080
1663
  }
2081
1664
 
2082
- // ===========================================================================
2083
- // ParsedExpr emitters (#1250 phase 1B)
2084
- // ===========================================================================
2085
-
2086
- /**
2087
- * Lowering for `array-method` IR nodes (#1443) — shared between the
2088
- * filter and top-level emitters so the Embedded Perl form stays
2089
- * consistent regardless of which context the chain lands in.
2090
- *
2091
- * The exhaustive switch on `method` paired with `assertNever` makes
2092
- * adding a new variant to `ArrayMethod` a TS compile error here, not
2093
- * a silent runtime no-op — the drift defence we already apply to
2094
- * `ParsedExpr.kind` extended to its sub-discriminator.
2095
- */
2096
- function renderArrayMethod(
2097
- method: ArrayMethod,
2098
- object: ParsedExpr,
2099
- args: ParsedExpr[],
2100
- emit: (e: ParsedExpr) => string,
2101
- ): string {
2102
- switch (method) {
2103
- case 'join': {
2104
- // arr.join(sep) → join(sep, @{arr}). The default `${obj}->{join}`
2105
- // hash-lookup fallback would emit invalid Perl, which is why the
2106
- // IR carves out a dedicated method node instead of routing
2107
- // through the generic call dispatcher. `.join()` defaults the
2108
- // separator to `,` (JS) and ignores any extra argument.
2109
- const obj = emit(object)
2110
- const sep = args.length >= 1 ? emit(args[0]) : `','`
2111
- return `join(${sep}, @{${obj}})`
2112
- }
2113
- case 'includes': {
2114
- // Both `arr.includes(x)` and `str.includes(sub)` route here —
2115
- // the parser can't disambiguate the receiver type. The Mojo
2116
- // runtime's `bf->includes($recv, $elem)` inspects `ref($recv)`
2117
- // and dispatches: ARRAY ref scans the list with `eq`, scalar
2118
- // falls back to `index(..., ...) != -1`. Helper lives in
2119
- // packages/adapter-perl/lib/BarefootJS.pm.
2120
- //
2121
- // The `bf->` (no `$`) form matches every other helper emit —
2122
- // in real Mojolicious `bf` is a controller helper; the
2123
- // standalone test-render in test-render.ts rewrites the bare
2124
- // `bf->` to `$bf->` so both render paths stay consistent.
2125
- const obj = emit(object)
2126
- const needle = emit(args[0])
2127
- return `bf->includes(${obj}, ${needle})`
2128
- }
2129
- case 'indexOf':
2130
- case 'lastIndexOf': {
2131
- // Array `.indexOf(x)` / `.lastIndexOf(x)` value-equality
2132
- // search. The Perl helpers (`bf->index_of`, `bf->last_index_of`)
2133
- // walk the array forward / backward and compare with `eq`
2134
- // (with defined/undef parity). The existing `.find` lowering
2135
- // uses Perl `grep` for struct-field find — disjoint surface,
2136
- // disjoint helpers.
2137
- const fn = method === 'indexOf' ? 'index_of' : 'last_index_of'
2138
- const obj = emit(object)
2139
- const needle = emit(args[0])
2140
- return `bf->${fn}(${obj}, ${needle})`
2141
- }
2142
- case 'at': {
2143
- // `.at(i)` with negative-index support — `.at(-1)` is the
2144
- // last element. The Mojo helper wraps the same `length + i`
2145
- // arithmetic the Go `bf_at` does so the lowering stays
2146
- // symmetric across adapters. `.at()` with no argument is `.at(0)`
2147
- // (the first element); extra arguments are ignored.
2148
- const obj = emit(object)
2149
- const idx = args.length >= 1 ? emit(args[0]) : '0'
2150
- return `bf->at(${obj}, ${idx})`
2151
- }
2152
- case 'concat': {
2153
- // `.concat(other)` merges two arrays. Returns a new ARRAY
2154
- // ref so the result composes with `.join(...)` / other
2155
- // array-shape methods downstream (the canonical Tier A
2156
- // conformance fixture chains `.concat(...).join(' ')`).
2157
- // `.concat()` with no argument is a shallow copy — indistinguishable
2158
- // from the receiver in an SSR snapshot, so it lowers to the receiver.
2159
- if (args.length === 0) {
2160
- return emit(object)
2161
- }
2162
- const a = emit(object)
2163
- const b = emit(args[0])
2164
- return `bf->concat(${a}, ${b})`
2165
- }
2166
- case 'slice': {
2167
- // `.slice()` / `.slice(start)` / `.slice(start, end)`. The Mojo
2168
- // helper mirrors the Go arithmetic (negative-index normalisation,
2169
- // out-of-bounds clamping, empty result on start >= end). A
2170
- // missing `start` defaults to 0 (full copy); an absent `end`
2171
- // lowers as `undef`, which the helper treats as "to length". JS
2172
- // ignores a third+ argument. Returns a new ARRAY ref so the
2173
- // result composes with `.join(...)` downstream.
2174
- const recv = emit(object)
2175
- const start = args.length >= 1 ? emit(args[0]) : '0'
2176
- const end = args.length >= 2 ? emit(args[1]) : 'undef'
2177
- return `bf->slice(${recv}, ${start}, ${end})`
2178
- }
2179
- case 'reverse':
2180
- case 'toReversed': {
2181
- // Both shapes share a lowering — see the parser arm + Go
2182
- // emit for the SSR-mutation-rationale. Returns a new ARRAY
2183
- // ref so the result composes with `.join(...)` downstream.
2184
- const recv = emit(object)
2185
- return `bf->reverse(${recv})`
2186
- }
2187
- case 'toLowerCase': {
2188
- // Perl's native `lc` is the obvious lowering — no helper
2189
- // method needed. The receiver flows through `emit` so any
2190
- // upstream coercion (`$value`, `$bf->string(...)`, etc.)
2191
- // composes naturally.
2192
- const recv = emit(object)
2193
- return `lc(${recv})`
2194
- }
2195
- case 'toUpperCase': {
2196
- // Perl's native `uc` — mirrors `toLowerCase` exactly.
2197
- const recv = emit(object)
2198
- return `uc(${recv})`
2199
- }
2200
- case 'trim': {
2201
- // No Perl native `trim`; route through the `bf->trim`
2202
- // helper so the regex stays in one place (and so an undef
2203
- // receiver doesn't trigger a warning about applying `s///`
2204
- // to undef).
2205
- const recv = emit(object)
2206
- return `bf->trim(${recv})`
2207
- }
2208
- case 'toFixed': {
2209
- // `.toFixed(digits?)` — Number → fixed-decimal string. `bf->to_fixed`
2210
- // mirrors JS rounding + zero-padding (default 0 digits). #1897.
2211
- const recv = emit(object)
2212
- const digits = args.length >= 1 ? emit(args[0]) : '0'
2213
- return `bf->to_fixed(${recv}, ${digits})`
2214
- }
2215
- case 'split': {
2216
- // `.split()` / `.split(sep)` / `.split(sep, limit)` — string →
2217
- // ARRAY ref via `bf->split`. With no separator the helper returns
2218
- // the whole string as a single element; otherwise it quotemetas
2219
- // the separator (literal match, not regex) and keeps trailing
2220
- // empties (`-1`), staying byte-equal with Go's `bf_split`. The
2221
- // optional `limit` caps the pieces; JS ignores a third+ argument.
2222
- // See #1448 Tier B.
2223
- const recv = emit(object)
2224
- if (args.length === 0) {
2225
- return `bf->split(${recv})`
2226
- }
2227
- const sep = emit(args[0])
2228
- if (args.length === 1) {
2229
- return `bf->split(${recv}, ${sep})`
2230
- }
2231
- const limit = emit(args[1])
2232
- return `bf->split(${recv}, ${sep}, ${limit})`
2233
- }
2234
- case 'startsWith':
2235
- case 'endsWith': {
2236
- // `.startsWith(prefix, position?)` / `.endsWith(suffix,
2237
- // endPosition?)` — string → boolean. The Perl helpers
2238
- // (`bf->starts_with` / `bf->ends_with`) do a `substr`-anchored
2239
- // comparison so the search string is matched literally (no regex
2240
- // metachar surprises) and undef receivers stay quiet. The optional
2241
- // second argument re-anchors the test; JS ignores a third+
2242
- // argument. See #1448 Tier B.
2243
- const fn = method === 'startsWith' ? 'starts_with' : 'ends_with'
2244
- const recv = emit(object)
2245
- const arg = emit(args[0])
2246
- if (args.length >= 2) {
2247
- return `bf->${fn}(${recv}, ${arg}, ${emit(args[1])})`
2248
- }
2249
- return `bf->${fn}(${recv}, ${arg})`
2250
- }
2251
- case 'replace': {
2252
- // `.replace(old, new)` — string-pattern form, first occurrence.
2253
- // The `bf->replace` helper splices via index/substr (not `s///`)
2254
- // so both the pattern and the replacement are literal — no Perl
2255
- // regex metacharacters and no `$1` / `$&` interpolation in the
2256
- // replacement, keeping it byte-equal with Go's `bf_replace`. The
2257
- // regex-pattern form is refused upstream at the parser. See
2258
- // #1448 Tier B.
2259
- const recv = emit(object)
2260
- const oldS = emit(args[0])
2261
- const newS = emit(args[1])
2262
- return `bf->replace(${recv}, ${oldS}, ${newS})`
2263
- }
2264
- case 'repeat': {
2265
- // `.repeat(n)` — string repeated `n` times. The `bf->repeat`
2266
- // helper wraps Perl's `x` operator with the same negative-count
2267
- // → "" clamp and integer truncation Go's `bf_repeat` applies, so
2268
- // the two adapters stay byte-equal. Full JS arity: the no-argument
2269
- // form is `repeat(0)` → ""; a second+ argument is ignored.
2270
- // See #1448 Tier B.
2271
- const recv = emit(object)
2272
- const count = args.length === 0 ? '0' : emit(args[0])
2273
- return `bf->repeat(${recv}, ${count})`
2274
- }
2275
- case 'padStart':
2276
- case 'padEnd': {
2277
- // `.padStart(target, pad?)` / `.padEnd(target, pad?)`. The
2278
- // `bf->pad_*` helpers default the pad to a single space when the
2279
- // arg is omitted and measure length in characters, matching Go's
2280
- // rune-based `bf_pad_*`. Full JS arity: the no-argument form is
2281
- // `padStart(0)` → the receiver unchanged; a third+ argument is
2282
- // ignored. See #1448 Tier B.
2283
- const fn = method === 'padStart' ? 'pad_start' : 'pad_end'
2284
- const recv = emit(object)
2285
- if (args.length === 0) {
2286
- return `bf->${fn}(${recv}, 0)`
2287
- }
2288
- const target = emit(args[0])
2289
- if (args.length === 1) {
2290
- return `bf->${fn}(${recv}, ${target})`
2291
- }
2292
- const pad = emit(args[1])
2293
- return `bf->${fn}(${recv}, ${target}, ${pad})`
2294
- }
2295
- default: {
2296
- // TS-level exhaustiveness guard. If this throws at runtime, the
2297
- // IR was constructed against a newer `ArrayMethod` variant that
2298
- // this adapter hasn't been updated for — loud failure is better
2299
- // than emitting a silent empty string downstream.
2300
- const _exhaustive: never = method
2301
- throw new Error(
2302
- `renderArrayMethod: unhandled ArrayMethod '${(_exhaustive as string)}'`,
2303
- )
2304
- }
2305
- }
2306
- }
2307
-
2308
- /**
2309
- * Shared Mojo emit for `.sort(cmp)` / `.toSorted(cmp)` (#1448 Tier B).
2310
- * Used by both the filter-context emitter and the top-level emitter,
2311
- * plus the loop-hoist path in `renderLoop` — same emit shape across
2312
- * all three so a regression in any one path surfaces consistently.
2313
- *
2314
- * The Perl helper accepts a hash-ref opts bag whose `keys` entry is
2315
- * an ordered list of per-key hashes (room for a future `nulls` knob
2316
- * without arity churn), and returns a fresh ARRAY ref so downstream
2317
- * composition (`@{bf->sort(...)}` in `join(...)`, etc.) stays
2318
- * straightforward.
2319
- */
2320
- /**
2321
- * Encode an `IRLoop.markerId` into a Perl-identifier-safe suffix
2322
- * for the `bf_iter_…` hoist var. Collision-free for marker ids
2323
- * that differ in any character — `-` and `_` map to distinct
2324
- * encodings (`_x2d` vs `__`) so `l-0` and `l_0` stay distinct.
2325
- *
2326
- * Today the IR only emits `l<digits>` so the encoding is mostly
2327
- * an identity, but pinning collision-freeness up front avoids a
2328
- * silent variable-shadow bug if a future marker generator widens
2329
- * the alphabet.
2330
- */
2331
- function perlIdentifierFromMarkerId(markerId: string): string {
2332
- return markerId.replace(/[^a-zA-Z0-9]/g, (ch) =>
2333
- ch === '_' ? '__' : `_x${ch.charCodeAt(0).toString(16)}`
2334
- )
2335
- }
2336
-
2337
- function renderSortMethod(recv: string, c: SortComparator): string {
2338
- // One hash per comparison key, in priority order, under `keys`. A
2339
- // simple comparator yields a one-element list; a `||`-chained
2340
- // multi-key comparator yields one per operand. `bf->sort` walks them
2341
- // in order, falling through to the next on a tie.
2342
- const keyHashes = c.keys.map((k) => {
2343
- const keyEntry =
2344
- k.key.kind === 'self'
2345
- ? `key_kind => 'self'`
2346
- : `key_kind => 'field', key => '${k.key.field}'`
2347
- return `{ ${keyEntry}, compare_type => '${k.type}', direction => '${k.direction}' }`
2348
- })
2349
- return `bf->sort(${recv}, { keys => [${keyHashes.join(', ')}] })`
2350
- }
2351
-
2352
- /**
2353
- * Render a `.reduce(fn, init)` arithmetic fold (#1448 Tier C) as a
2354
- * `bf->reduce(...)` call. The structured `ReduceOp` maps to the Perl
2355
- * helper's options hash:
2356
- *
2357
- * bf->reduce($recv, { op => '+', key_kind => 'field', key => 'duration',
2358
- * type => 'numeric', init => 0 })
2359
- *
2360
- * A numeric init passes through as a bare Perl number (`0`, `-1`); a
2361
- * string init (concat fold) is re-quoted from its literal contents.
2362
- */
2363
- function renderReduceMethod(recv: string, op: ReduceOp, direction: 'left' | 'right'): string {
2364
- const keyEntry =
2365
- op.key.kind === 'self'
2366
- ? `key_kind => 'self'`
2367
- : `key_kind => 'field', key => '${op.key.field}'`
2368
- // `op.init` is the decoded seed value. A numeric seed is already a
2369
- // canonical decimal literal Perl reads directly; a concat seed is the
2370
- // string contents, embedded in a single-quoted Perl literal. The `'`
2371
- // escape is REQUIRED: a seed decoded from a double-quoted JS literal
2372
- // (e.g. `"a'b"`) is escape-free yet contains an apostrophe. A literal
2373
- // backslash can't occur (it would need a `\\` escape, which the parser
2374
- // refuses), but escaping it too keeps this self-contained.
2375
- const init =
2376
- op.type === 'string'
2377
- ? `'${op.init.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
2378
- : op.init
2379
- // `direction` is "left" (reduce) or "right" (reduceRight); the Perl
2380
- // helper reverses the list for "right". Only observable for concat.
2381
- return `bf->reduce(${recv}, { op => '${op.op}', ${keyEntry}, type => '${op.type}', init => ${init}, direction => '${direction}' })`
2382
- }
2383
-
2384
- // `.flat(depth?)` → `bf->flat($recv, $depth)`. The `Infinity` form lowers
2385
- // to the `-1` sentinel (flatten fully); a finite depth flattens that many
2386
- // levels (`0` = shallow copy). See `sub flat` in BarefootJS.pm. (#1448)
2387
- function renderFlatMethod(recv: string, depth: FlatDepth): string {
2388
- const d = depth === 'infinity' ? -1 : depth
2389
- return `bf->flat(${recv}, ${d})`
2390
- }
2391
-
2392
- // `.flatMap(i => i)` / `.flatMap(i => i.field)` → `bf->flat_map($recv,
2393
- // 'self'|'field', 'field')`, and the array-literal tuple form
2394
- // `i => [i.a, i.b]` → `bf->flat_map_tuple($recv, ['field','a'], ...)`
2395
- // (one arrayref per leaf). The field key is the raw JS prop name (Perl
2396
- // hashes are keyed by it), mirroring `bf->reduce`. See `sub flat_map` /
2397
- // `sub flat_map_tuple` in BarefootJS.pm.
2398
- function renderFlatMapMethod(recv: string, op: FlatMapOp): string {
2399
- const proj = op.projection
2400
- if (proj.kind === 'tuple') {
2401
- const specs = proj.elements
2402
- .map(l => (l.kind === 'self' ? `['self', '']` : `['field', '${l.field}']`))
2403
- .join(', ')
2404
- return `bf->flat_map_tuple(${recv}, ${specs})`
2405
- }
2406
- if (proj.kind === 'self') return `bf->flat_map(${recv}, 'self', '')`
2407
- return `bf->flat_map(${recv}, 'field', '${proj.field}')`
2408
- }
2409
-
2410
- /** True when `type` is the `string` primitive. */
2411
- function isStringTypeInfo(type: TypeInfo | undefined): boolean {
2412
- return type?.kind === 'primitive' && type.primitive === 'string'
2413
- }
2414
-
2415
- /** True when `initialValue` is a bare string-literal expression (`'x'` /
2416
- * `"x"`), used as a fallback for signals whose type wasn't inferred. */
2417
- function isBareStringLiteral(initialValue: string | undefined): boolean {
2418
- if (!initialValue) return false
2419
- const v = initialValue.trim()
2420
- return (v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))
2421
- }
2422
-
2423
- /**
2424
- * Whether a comparison operand is string-typed, so JS `===`/`!==` against it
2425
- * must lower to Perl `eq`/`ne` instead of numeric `==`/`!=` (#1672). Covers a
2426
- * string literal, a string-signal getter call (`sel()`), and a string prop
2427
- * access (`props.x`). `isStringName` reports whether a getter/prop name is
2428
- * known-string. Loop-element fields (`t.id`) on untyped arrays have no known
2429
- * type and stay undetected — a separate, narrower gap.
2430
- */
2431
- function isStringTypedOperand(expr: ParsedExpr, isStringName: (n: string) => boolean): boolean {
2432
- if (expr.kind === 'literal' && expr.literalType === 'string') return true
2433
- if (expr.kind === 'call' && expr.callee.kind === 'identifier' && expr.args.length === 0) {
2434
- return isStringName(expr.callee.name)
2435
- }
2436
- if (expr.kind === 'member' && expr.object.kind === 'identifier' && expr.object.name === 'props') {
2437
- return isStringName(expr.property)
2438
- }
2439
- return false
2440
- }
2441
-
2442
- /**
2443
- * Lower `arr[index]` to a Perl deref. Perl distinguishes array
2444
- * (`->[$i]`) from hash (`->{$k}`) access, which JS's single `[]` does
2445
- * not — so we pick by the index expression's type: a string-typed key
2446
- * derefs the hash, anything else (the common loop-index / arithmetic
2447
- * case, e.g. `selected()[index]`) derefs the array. #1897.
2448
- */
2449
- function emitIndexAccessPerl(
2450
- object: ParsedExpr,
2451
- index: ParsedExpr,
2452
- emit: (e: ParsedExpr) => string,
2453
- isStringName: (n: string) => boolean,
2454
- ): string {
2455
- const i = emit(index)
2456
- return isStringTypedOperand(index, isStringName)
2457
- ? `${emit(object)}->{${i}}`
2458
- : `${emit(object)}->[${i}]`
2459
- }
2460
-
2461
- /**
2462
- * Lowering for the predicate body of a filter / every / some / find,
2463
- * plus the same shape used by `renderBlockBodyCondition` for complex
2464
- * block-body filters. Identifiers resolve against:
2465
- * - the predicate's loop param (`$param`),
2466
- * - `localVarMap` aliases declared inside the block body, then
2467
- * - a bare `$name` fallback for signals captured by the closure.
2468
- *
2469
- * Methods that have no filter-context meaning (template-literal,
2470
- * arrow-fn, conditional, unsupported) fall back to the `'1'` literal
2471
- * the original switch's `default` arm returned — those shapes never
2472
- * arose inside the predicates the adapter actually accepts.
2473
- */
2474
- class MojoFilterEmitter implements ParsedExprEmitter {
2475
- constructor(
2476
- private readonly param: string,
2477
- private readonly localVarMap: Map<string, string>,
2478
- // Reports whether a getter/prop name is string-typed, so `===`/`!==`
2479
- // against it lowers to `eq`/`ne` (#1672). Defaults to "never" for callers
2480
- // that don't thread it through.
2481
- private readonly isStringName: (n: string) => boolean = () => false,
2482
- ) {}
2483
-
2484
- identifier(name: string): string {
2485
- if (name === this.param) return `$${this.param}`
2486
- const signal = this.localVarMap.get(name)
2487
- if (signal) return `$${signal}`
2488
- return `$${name}`
2489
- }
2490
-
2491
- literal(value: string | number | boolean | null, literalType: LiteralType): string {
2492
- if (literalType === 'string') return `'${value}'`
2493
- if (literalType === 'boolean') return value ? '1' : '0'
2494
- if (literalType === 'null') return 'undef'
2495
- return String(value)
2496
- }
2497
-
2498
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
2499
- // `.length` on a higher-order result (e.g.
2500
- // `x.tags.filter(t => t.active).length > 0` inside the outer
2501
- // filter predicate, #1443). The higher-order emit produces an
2502
- // anonymous array ref `[grep ...]`; reading `->{length}` on that
2503
- // is undef at runtime, which is why the pre-#1443 `containsHigherOrder`
2504
- // gate refused this shape outright. Lowering `.length` to
2505
- // `scalar(@{...})` makes the result a real Perl integer.
2506
- if (property === 'length' && (object.kind === 'higher-order' || object.kind === 'array-literal')) {
2507
- return `scalar(@{${emit(object)}})`
2508
- }
2509
- return `${emit(object)}->{${property}}`
2510
- }
2511
-
2512
- indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2513
- return emitIndexAccessPerl(object, index, emit, this.isStringName)
2514
- }
2515
-
2516
- call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
2517
- // Signal getter calls: filter() → $filter
2518
- if (callee.kind === 'identifier' && args.length === 0) {
2519
- return `$${callee.name}`
2520
- }
2521
- return emit(callee)
2522
- }
2523
-
2524
- unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2525
- const arg = emit(argument)
2526
- if (op === '!') {
2527
- // Wrap binary/logical operands in parens to dodge Perl precedence surprises.
2528
- const needsParens = argument.kind === 'binary' || argument.kind === 'logical'
2529
- return needsParens ? `!(${arg})` : `!${arg}`
2530
- }
2531
- if (op === '-') return `-${arg}`
2532
- return arg
2533
- }
2534
-
2535
- binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2536
- const l = emit(left)
2537
- const r = emit(right)
2538
- // String equality: `eq`/`ne` when EITHER operand is string-typed — a string
2539
- // literal, a string signal getter, or a string prop. Numeric `==`/`!=`
2540
- // would coerce both sides to 0 and match unrelated non-numeric strings (#1672).
2541
- const isStr = (e: ParsedExpr) => isStringTypedOperand(e, this.isStringName)
2542
- const stringCmp = isStr(left) || isStr(right)
2543
- if ((op === '===' || op === '==') && stringCmp) {
2544
- return `${l} eq ${r}`
2545
- }
2546
- if ((op === '!==' || op === '!=') && stringCmp) {
2547
- return `${l} ne ${r}`
2548
- }
2549
- const opMap: Record<string, string> = {
2550
- '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
2551
- '+': '+', '-': '-', '*': '*', '/': '/',
2552
- }
2553
- return `${l} ${opMap[op] ?? op} ${r}`
2554
- }
2555
-
2556
- logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2557
- const l = emit(left)
2558
- const r = emit(right)
2559
- if (op === '&&') return `(${l} && ${r})`
2560
- if (op === '||') return `(${l} || ${r})`
2561
- return `(${l} // ${r})`
2562
- }
2563
-
2564
- higherOrder(
2565
- method: HigherOrderMethod,
2566
- object: ParsedExpr,
2567
- param: string,
2568
- predicate: ParsedExpr,
2569
- emit: (e: ParsedExpr) => string,
2570
- ): string {
2571
- // The predicate body is also a filter context, but with this
2572
- // higher-order's own `param` (potentially shadowing the outer one),
2573
- // so we spin up a nested emitter with the inner param.
2574
- const arrayExpr = emit(object)
2575
- const predBody = emitParsedExpr(predicate, new MojoFilterEmitter(param, this.localVarMap, this.isStringName))
2576
- const grepBody = predBody.replace(new RegExp(`\\$${param}\\b`, 'g'), '$_')
2577
- if (method === 'filter') return `[grep { ${grepBody} } @{${arrayExpr}}]`
2578
- if (method === 'every') return `!(grep { !(${grepBody}) } @{${arrayExpr}})`
2579
- if (method === 'some') return `!!(grep { ${grepBody} } @{${arrayExpr}})`
2580
- return arrayExpr
2581
- }
2582
-
2583
- arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
2584
- // Perl array ref: `[$a, $b]`. Filter-context use is rare (the
2585
- // outer emitter routes most array-literal arrivals via
2586
- // MojoTopLevelEmitter), but #1443's chain
2587
- // `[a, b].filter(Boolean).join(' ')` can land here when the
2588
- // outer `.filter()` recurses into a nested filter whose own
2589
- // source is an array literal.
2590
- return `[${elements.map(emit).join(', ')}]`
2591
- }
2592
-
2593
- arrayMethod(
2594
- method: ArrayMethod,
2595
- object: ParsedExpr,
2596
- args: ParsedExpr[],
2597
- emit: (e: ParsedExpr) => string,
2598
- ): string {
2599
- // Filter-context array methods are vanishingly rare — predicates
2600
- // operate on scalars, not arrays. Defer to the top-level rendering
2601
- // (`join(sep, @{...})`) for any case that does land here so the
2602
- // emission stays consistent across contexts.
2603
- return renderArrayMethod(method, object, args, emit)
2604
- }
2605
-
2606
- sortMethod(
2607
- _method: 'sort' | 'toSorted',
2608
- object: ParsedExpr,
2609
- comparator: SortComparator,
2610
- emit: (e: ParsedExpr) => string,
2611
- ): string {
2612
- return renderSortMethod(emit(object), comparator)
2613
- }
2614
-
2615
- reduceMethod(method: 'reduce' | 'reduceRight', object: ParsedExpr, reduceOp: ReduceOp, emit: (e: ParsedExpr) => string): string {
2616
- return renderReduceMethod(emit(object), reduceOp, method === 'reduceRight' ? 'right' : 'left')
2617
- }
2618
-
2619
- flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
2620
- return renderFlatMethod(emit(object), depth)
2621
- }
2622
-
2623
- flatMapMethod(object: ParsedExpr, op: FlatMapOp, emit: (e: ParsedExpr) => string): string {
2624
- return renderFlatMapMethod(emit(object), op)
2625
- }
2626
-
2627
- conditional(_test: ParsedExpr, _consequent: ParsedExpr, _alternate: ParsedExpr): string {
2628
- return '1'
2629
- }
2630
-
2631
- templateLiteral(_parts: TemplatePart[]): string {
2632
- return '1'
2633
- }
2634
-
2635
- arrowFn(_param: string, _body: ParsedExpr): string {
2636
- return '1'
2637
- }
2638
-
2639
- unsupported(_raw: string, _reason: string): string {
2640
- return '1'
2641
- }
2642
- }
2643
-
2644
- /**
2645
- * Lowering for top-level expressions whose identifiers resolve against
2646
- * the Mojo template's stash (signals, props, locals introduced by
2647
- * `% my $x = ...;` lines). Differs from the filter emitter mainly in
2648
- * - `.length` → `scalar(@{...})` (filter contexts never see arrays
2649
- * in lvalue position),
2650
- * - `conditional` is supported (filter predicates can't return
2651
- * ternaries),
2652
- * - the `unsupported` fallback drops to the regex pipeline so legacy
2653
- * shapes the AST can't classify still emit something coherent.
2654
- */
2655
- class MojoTopLevelEmitter implements ParsedExprEmitter {
2656
- constructor(private readonly adapter: MojoAdapter) {}
2657
-
2658
- identifier(name: string): string {
2659
- // `undefined` / `null` nested inside a larger expression tree
2660
- // (#1897, pagination's `props.isActive ? 'page' : undefined`) — the
2661
- // top-level short-circuits don't see them.
2662
- if (name === 'undefined' || name === 'null') return 'undef'
2663
- // Module pure-string const (e.g. `const baseClasses = '...'` used in a
2664
- // className template literal): inline the literal value rather than emit
2665
- // `$baseClasses` against a stash variable that is never bound.
2666
- const inlined = this.adapter.resolveModuleStringConst(name)
2667
- if (inlined !== null) return inlined
2668
- // Same for a literal const of any scope (`const totalPages = 5`,
2669
- // #1897 pagination's `Page {currentPage()} of {totalPages}`).
2670
- const literalConst = this.adapter.resolveLiteralConst(name)
2671
- if (literalConst !== null) return literalConst
2672
- return `$${name}`
2673
- }
2674
-
2675
- literal(value: string | number | boolean | null, literalType: LiteralType): string {
2676
- if (literalType === 'string') return `'${value}'`
2677
- if (literalType === 'boolean') return value ? '1' : '0'
2678
- if (literalType === 'null') return 'undef'
2679
- return String(value)
2680
- }
2681
-
2682
- member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
2683
- // `props.x` flattens to the bare `$x` the Mojo SSR caller binds each
2684
- // prop to (props arrive as individual `my $x = ...` vars, not a
2685
- // `$props` hashref).
2686
- if (object.kind === 'identifier' && object.name === 'props') {
2687
- return `$${property}`
2688
- }
2689
- // Static property access on a module object-literal const
2690
- // (`variantClasses.ghost`, #1897) resolves at compile time — the
2691
- // generic hash lowering below would dereference a Perl var that
2692
- // doesn't exist server-side.
2693
- if (object.kind === 'identifier') {
2694
- const staticValue = this.adapter.resolveStaticRecordLiteral(object.name, property)
2695
- if (staticValue !== null) return staticValue
2696
- }
2697
- const obj = emit(object)
2698
- if (property === 'length') return `scalar(@{${obj}})`
2699
- return `${obj}->{${property}}`
2700
- }
2701
-
2702
- indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2703
- return emitIndexAccessPerl(object, index, emit, n => this.adapter._isStringValueName(n))
2704
- }
2705
-
2706
- call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
2707
- // Signal getter: count() → $count
2708
- if (callee.kind === 'identifier' && args.length === 0) {
2709
- return `$${callee.name}`
2710
- }
2711
- // Env-signal method call (#1922): `searchParams().get('sort')` is a real
2712
- // method call on the per-request `$searchParams` reader object, not the
2713
- // generic hash deref `member` would emit (`$searchParams->{get}`, which
2714
- // drops the arg). Matches the local import binding (incl. an alias).
2715
- if (this.adapter._searchParamsLocals.size > 0) {
2716
- const sp = matchSearchParamsMethodCall(callee, args, this.adapter._searchParamsLocals)
2717
- if (sp) {
2718
- return `$searchParams->${sp.method}(${sp.args.map(emit).join(', ')})`
2719
- }
2720
- }
2721
- // Identifier-path templatePrimitive (#1189): `JSON.stringify(x)` /
2722
- // `Math.floor(x)` → `bf->json($x)` / `bf->floor($x)`. Args render
2723
- // recursively through this same emitter so prop refs / signal calls
2724
- // inside them get the standard transforms. Mirrors the Go adapter's
2725
- // `call()` primitive dispatch. A wrong-arity call records BF101 and
2726
- // returns the safe `''` placeholder (never silently emits a bad call).
2727
- const path = identifierPath(callee)
2728
- const spec = path ? MOJO_TEMPLATE_PRIMITIVES[path] : undefined
2729
- if (path && spec) {
2730
- if (args.length === spec.arity) {
2731
- return spec.emit(args.map(emit))
2732
- }
2733
- this.adapter._recordExprBF101(
2734
- `templatePrimitive '${path}' expects ${spec.arity} arg(s), got ${args.length}`,
2735
- `Call '${path}' with exactly ${spec.arity} argument(s).`,
2736
- )
2737
- // Don't fall through to the generic `emit(callee)` below — for a
2738
- // member callee (`JSON.stringify`) that emits an invalid Perl
2739
- // hash-deref (`$JSON->{stringify}`). Return the same safe
2740
- // empty-string placeholder the other BF101 paths use.
2741
- return "''"
2742
- }
2743
- // Array methods (`.join` and any others added to ArrayMethod, #1443)
2744
- // are lifted into the `array-method` IR kind at parse time, so they
2745
- // never reach this dispatcher. Per-method detection here would mix
2746
- // value-builtin lowering with signal-call lowering — keeping them
2747
- // separated forces every adapter to declare the full array-method
2748
- // surface in one place (the `arrayMethod` emitter below).
2749
- return emit(callee)
2750
- }
2751
-
2752
- unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2753
- const arg = emit(argument)
2754
- if (op === '!') return `!${arg}`
2755
- if (op === '-') return `-${arg}`
2756
- return arg
2757
- }
2758
-
2759
- binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2760
- const l = emit(left)
2761
- const r = emit(right)
2762
- // String equality: `eq`/`ne` when EITHER operand is string-typed — a string
2763
- // literal (`role() === 'admin'`), a string signal getter (`sel()`), or a
2764
- // string prop (`props.x`). Falling back to numeric `==`/`!=` would make
2765
- // Perl coerce both sides to 0 and match unrelated non-numeric strings
2766
- // (`"b" == "a"` → true), so all loop items render their true branch (#1672).
2767
- const isStr = (e: ParsedExpr) => isStringTypedOperand(e, n => this.adapter._isStringValueName(n))
2768
- const stringCmp = isStr(left) || isStr(right)
2769
- if ((op === '===' || op === '==') && stringCmp) {
2770
- return `${l} eq ${r}`
2771
- }
2772
- if ((op === '!==' || op === '!=') && stringCmp) {
2773
- return `${l} ne ${r}`
2774
- }
2775
- const opMap: Record<string, string> = {
2776
- '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
2777
- '+': '+', '-': '-', '*': '*',
2778
- }
2779
- return `${l} ${opMap[op] ?? op} ${r}`
2780
- }
2781
-
2782
- logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
2783
- const l = emit(left)
2784
- const r = emit(right)
2785
- if (op === '&&') return `(${l} && ${r})`
2786
- if (op === '||') return `(${l} || ${r})`
2787
- return `(${l} // ${r})`
2788
- }
2789
-
2790
- higherOrder(
2791
- method: HigherOrderMethod,
2792
- object: ParsedExpr,
2793
- param: string,
2794
- predicate: ParsedExpr,
2795
- emit: (e: ParsedExpr) => string,
2796
- ): string {
2797
- const arrayExpr = emit(object)
2798
- const predBody = this.adapter._renderPerlFilterExprPublic(predicate, param)
2799
- const grepBody = predBody.replace(new RegExp(`\\$${param}\\b`, 'g'), '$_')
2800
- if (method === 'filter') return `[grep { ${grepBody} } @{${arrayExpr}}]`
2801
- if (method === 'every') return `!(grep { !(${grepBody}) } @{${arrayExpr}})`
2802
- if (method === 'some') return `!!(grep { ${grepBody} } @{${arrayExpr}})`
2803
- // `.find` / `.findIndex` / `.findLast` / `.findLastIndex` → the runtime
2804
- // helpers (`bf->find` / `find_index` / `find_last` / `find_last_index`),
2805
- // which call the predicate as a per-element coderef — same shape Xslate
2806
- // emits via a Kolon lambda. The JS camelCase names map to the snake_case
2807
- // helpers (like index_of / last_index_of).
2808
- const findHelper: Record<string, string> = {
2809
- find: 'find',
2810
- findIndex: 'find_index',
2811
- findLast: 'find_last',
2812
- findLastIndex: 'find_last_index',
2813
- }
2814
- if (findHelper[method]) {
2815
- return `bf->${findHelper[method]}(${arrayExpr}, sub { my $${param} = $_[0]; ${predBody} })`
2816
- }
2817
- return arrayExpr
2818
- }
2819
-
2820
- arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
2821
- // Perl array ref. Identifiers inside elements resolve through the
2822
- // top-level emitter so `[className, childClass]` becomes
2823
- // `[$className, $childClass]` (the registry Slot's chain in
2824
- // #1443). Empty `[]` stays as `[]` — a valid empty Perl array
2825
- // ref that grep/join handle naturally.
2826
- return `[${elements.map(emit).join(', ')}]`
2827
- }
2828
-
2829
- arrayMethod(
2830
- method: ArrayMethod,
2831
- object: ParsedExpr,
2832
- args: ParsedExpr[],
2833
- emit: (e: ParsedExpr) => string,
2834
- ): string {
2835
- return renderArrayMethod(method, object, args, emit)
2836
- }
2837
-
2838
- sortMethod(
2839
- _method: 'sort' | 'toSorted',
2840
- object: ParsedExpr,
2841
- comparator: SortComparator,
2842
- emit: (e: ParsedExpr) => string,
2843
- ): string {
2844
- return renderSortMethod(emit(object), comparator)
2845
- }
2846
-
2847
- reduceMethod(method: 'reduce' | 'reduceRight', object: ParsedExpr, reduceOp: ReduceOp, emit: (e: ParsedExpr) => string): string {
2848
- return renderReduceMethod(emit(object), reduceOp, method === 'reduceRight' ? 'right' : 'left')
2849
- }
2850
-
2851
- flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
2852
- return renderFlatMethod(emit(object), depth)
2853
- }
2854
-
2855
- flatMapMethod(object: ParsedExpr, op: FlatMapOp, emit: (e: ParsedExpr) => string): string {
2856
- return renderFlatMapMethod(emit(object), op)
2857
- }
2858
-
2859
- conditional(
2860
- test: ParsedExpr,
2861
- consequent: ParsedExpr,
2862
- alternate: ParsedExpr,
2863
- emit: (e: ParsedExpr) => string,
2864
- ): string {
2865
- return `(${emit(test)} ? ${emit(consequent)} : ${emit(alternate)})`
2866
- }
2867
-
2868
- templateLiteral(parts: TemplatePart[], emit: (e: ParsedExpr) => string): string {
2869
- // `` `n=${count() + 1}` `` → Perl string concatenation
2870
- // (`"n=" . ($count + 1)`), NOT double-quote interpolation. Perl only
2871
- // interpolates simple `$var` reads inside `"..."`, so complex `${...}`
2872
- // parts — arithmetic, helper calls (`bf->json(...)`), ternaries —
2873
- // would render unevaluated if inlined into a quoted string.
2874
- // - Static chunks are emitted as quoted literals with the sigils
2875
- // that interpolate inside `"..."` (`$`/`@`) plus `"`/`\` escaped,
2876
- // so literal text survives verbatim.
2877
- // - Expression terms whose Perl precedence is below `.` (binary /
2878
- // logical / conditional) wrap in parens so they bind before the
2879
- // concatenation.
2880
- const terms: string[] = []
2881
- for (const part of parts) {
2882
- if (part.type === 'string') {
2883
- if (part.value !== '') {
2884
- terms.push(`"${part.value.replace(/[\\"$@]/g, m => `\\${m}`)}"`)
2885
- }
2886
- } else {
2887
- const rendered = emit(part.expr)
2888
- const needsParens =
2889
- part.expr.kind === 'binary' ||
2890
- part.expr.kind === 'logical' ||
2891
- part.expr.kind === 'conditional'
2892
- terms.push(needsParens ? `(${rendered})` : rendered)
2893
- }
2894
- }
2895
- if (terms.length === 0) return '""'
2896
- return terms.join(' . ')
2897
- }
2898
-
2899
- arrowFn(_param: string, _body: ParsedExpr): string {
2900
- // A bare arrow function never stands alone at a render position (it's
2901
- // only meaningful as a higher-order predicate, handled above). Return
2902
- // the safe Perl empty-string literal `''` — consistent with the BF101
2903
- // / `unsupported` paths — so a stray emit can't produce a `<%= %>`
2904
- // syntax error.
2905
- return "''"
2906
- }
2907
-
2908
- unsupported(_raw: string, _reason: string): string {
2909
- // Unreachable in the parse-first flow: `convertExpressionToPerl`
2910
- // gates on `isSupported` before dispatching, and `isSupported`
2911
- // recurses, so a top-level supported expression never contains an
2912
- // `unsupported` node. Return a safe Perl empty-string literal in
2913
- // case a future caller renders a node tree directly.
2914
- return "''"
2915
- }
2916
- }
2917
-
2918
1665
  export const mojoAdapter = new MojoAdapter()