@barefootjs/erb 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 (68) hide show
  1. package/dist/adapter/analysis/component-tree.d.ts +24 -0
  2. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  3. package/dist/adapter/boolean-result.d.ts +66 -0
  4. package/dist/adapter/boolean-result.d.ts.map +1 -0
  5. package/dist/adapter/emit-context.d.ts +107 -0
  6. package/dist/adapter/emit-context.d.ts.map +1 -0
  7. package/dist/adapter/erb-adapter.d.ts +374 -0
  8. package/dist/adapter/erb-adapter.d.ts.map +1 -0
  9. package/dist/adapter/expr/array-method.d.ts +87 -0
  10. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  11. package/dist/adapter/expr/emitters.d.ts +102 -0
  12. package/dist/adapter/expr/emitters.d.ts.map +1 -0
  13. package/dist/adapter/expr/operand.d.ts +34 -0
  14. package/dist/adapter/expr/operand.d.ts.map +1 -0
  15. package/dist/adapter/index.d.ts +6 -0
  16. package/dist/adapter/index.d.ts.map +1 -0
  17. package/dist/adapter/index.js +189154 -0
  18. package/dist/adapter/lib/constants.d.ts +25 -0
  19. package/dist/adapter/lib/constants.d.ts.map +1 -0
  20. package/dist/adapter/lib/ir-scope.d.ts +27 -0
  21. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  22. package/dist/adapter/lib/ruby-naming.d.ts +65 -0
  23. package/dist/adapter/lib/ruby-naming.d.ts.map +1 -0
  24. package/dist/adapter/lib/types.d.ts +28 -0
  25. package/dist/adapter/lib/types.d.ts.map +1 -0
  26. package/dist/adapter/memo/seed.d.ts +35 -0
  27. package/dist/adapter/memo/seed.d.ts.map +1 -0
  28. package/dist/adapter/props/prop-classes.d.ts +50 -0
  29. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  30. package/dist/adapter/spread/spread-codegen.d.ts +63 -0
  31. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  32. package/dist/adapter/value/parsed-literal.d.ts +21 -0
  33. package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
  34. package/dist/build.d.ts +28 -0
  35. package/dist/build.d.ts.map +1 -0
  36. package/dist/build.js +189174 -0
  37. package/dist/conformance-pins.d.ts +13 -0
  38. package/dist/conformance-pins.d.ts.map +1 -0
  39. package/dist/index.d.ts +9 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +189172 -0
  42. package/lib/barefoot_js/backend/erb.rb +123 -0
  43. package/lib/barefoot_js/dev_reload.rb +159 -0
  44. package/lib/barefoot_js/evaluator.rb +714 -0
  45. package/lib/barefoot_js/search_params.rb +63 -0
  46. package/lib/barefoot_js.rb +1155 -0
  47. package/package.json +67 -0
  48. package/src/__tests__/erb-adapter.test.ts +298 -0
  49. package/src/adapter/analysis/component-tree.ts +122 -0
  50. package/src/adapter/boolean-result.ts +157 -0
  51. package/src/adapter/emit-context.ts +119 -0
  52. package/src/adapter/erb-adapter.ts +1768 -0
  53. package/src/adapter/expr/array-method.ts +424 -0
  54. package/src/adapter/expr/emitters.ts +629 -0
  55. package/src/adapter/expr/operand.ts +55 -0
  56. package/src/adapter/index.ts +6 -0
  57. package/src/adapter/lib/constants.ts +37 -0
  58. package/src/adapter/lib/ir-scope.ts +51 -0
  59. package/src/adapter/lib/ruby-naming.ts +127 -0
  60. package/src/adapter/lib/types.ts +31 -0
  61. package/src/adapter/memo/seed.ts +75 -0
  62. package/src/adapter/props/prop-classes.ts +89 -0
  63. package/src/adapter/spread/spread-codegen.ts +176 -0
  64. package/src/adapter/value/parsed-literal.ts +29 -0
  65. package/src/build.ts +37 -0
  66. package/src/conformance-pins.ts +100 -0
  67. package/src/index.ts +9 -0
  68. package/src/test-render.ts +668 -0
@@ -0,0 +1,629 @@
1
+ /**
2
+ * ParsedExpr → Ruby emitters for the ERB template adapter.
3
+ *
4
+ * Ported from the Mojolicious adapter's `expr/emitters.ts` (issue #2018
5
+ * track D lineage), retargeted at Ruby / ERB's two-locals variable model
6
+ * (`bf`, `v`). Two `ParsedExprEmitter` implementations:
7
+ *
8
+ * - `ErbFilterEmitter` — filter/predicate context (loop param + local
9
+ * aliases + `v[:name]` / loop-bound-local fallback for other
10
+ * identifiers); self-contained aside from a couple of adapter-supplied
11
+ * predicates, reads no other adapter state.
12
+ * - `ErbTopLevelEmitter` — top-level / vars-Hash context; depends on the
13
+ * adapter only through the narrow `ErbEmitContext` seam.
14
+ *
15
+ * Two structural simplifications fall out of Ruby's richer surface
16
+ * relative to Perl (documented at each site below, not scattered as
17
+ * special cases):
18
+ *
19
+ * 1. **Predicate callbacks compile to real Ruby blocks** (`.select { |x|
20
+ * ... }`) instead of Perl's regex `$param → $_` substitution into a
21
+ * `grep { ... }` block body — Ruby blocks take named parameters, so
22
+ * no textual substitution is needed.
23
+ * 2. **`.length` on a higher-order chain result needs no special form**
24
+ * (`arr.select { ... }.length` just works) — Perl's anonymous-arrayref
25
+ * `scalar(@{[grep ...]})` workaround has no ERB analog to port.
26
+ *
27
+ * The one thing EP does NOT need that ERB does: JS `&&` / `||` / `!` /
28
+ * ternary tests must be wrapped in `bf.truthy?(...)`. Perl's own falsy set
29
+ * (`undef`, `0`, `''`, `'0'`) already tracks JS's closely enough that Mojo's
30
+ * native `&&`/`||`/`!`/`?:` work unwrapped; Ruby's falsy set is only
31
+ * `nil`/`false`, so `0`, `''`, and `NaN` are Ruby-truthy but JS-falsy. Every
32
+ * JS truthiness test in this file goes through `bf.truthy?` for that reason.
33
+ */
34
+
35
+ import {
36
+ type ParsedExprEmitter,
37
+ type HigherOrderMethod,
38
+ type ArrayMethod,
39
+ type LiteralType,
40
+ type ParsedExpr,
41
+ type ObjectLiteralProperty,
42
+ type FlatDepth,
43
+ type TemplatePart,
44
+ emitParsedExpr,
45
+ identifierPath,
46
+ matchSearchParamsMethodCall,
47
+ sortComparatorFromArrow,
48
+ } from '@barefootjs/jsx'
49
+
50
+ import type { ErbEmitContext } from '../emit-context.ts'
51
+ import { ERB_TEMPLATE_PRIMITIVES } from '../lib/constants.ts'
52
+ import { rubyLocal, rubyStringLiteral, rubySymbolLiteral } from '../lib/ruby-naming.ts'
53
+ import { emitIndexAccessRuby } from './operand.ts'
54
+ import {
55
+ renderArrayMethod,
56
+ renderSortMethod,
57
+ renderSortEval,
58
+ renderReduceEval,
59
+ renderPredicateEval,
60
+ renderFlatMethod,
61
+ renderFlatMapEval,
62
+ renderMapEval,
63
+ } from './array-method.ts'
64
+
65
+ /**
66
+ * Local shape for the predicate-method fallback chain. The higher-order
67
+ * callback arrives as a generic `call`; `callbackMethod` recovers
68
+ * `{ method, object, param, predicate }` from it before threading it
69
+ * through the Ruby block / `bf.find*` lowering.
70
+ */
71
+ type PredicateCall = {
72
+ method: HigherOrderMethod
73
+ object: ParsedExpr
74
+ param: string
75
+ predicate: ParsedExpr
76
+ }
77
+
78
+ // Predicate-shaped higher-order methods (filter/every/some land here through
79
+ // nested `.filter(...)` chains). Module-level so `callbackMethod`, which runs
80
+ // for many nodes during template generation, reuses one set per process.
81
+ const PREDICATE_METHODS: ReadonlySet<string> = new Set([
82
+ 'filter', 'find', 'findIndex', 'findLast', 'findLastIndex', 'every', 'some',
83
+ ])
84
+
85
+ /** `void` referencing an unused parameter without a lint complaint. */
86
+ function unusedIsStringName(_n: string): boolean {
87
+ return false
88
+ }
89
+
90
+ export class ErbFilterEmitter implements ParsedExprEmitter {
91
+ constructor(
92
+ private readonly param: string,
93
+ private readonly localVarMap: Map<string, string>,
94
+ // Whether `name` currently names a bare Ruby local bound by an
95
+ // ENCLOSING loop/block (distinct from `this.param`, which is this
96
+ // predicate's own — possibly nested — loop param). See
97
+ // `ErbEmitContext.isLoopBoundName`'s docstring for why ERB needs this
98
+ // and EP does not.
99
+ private readonly isLoopBoundOuter: (n: string) => boolean = () => false,
100
+ // Reports whether a getter/prop name is string-typed, for the Hash-vs-
101
+ // Array index-access split (#operand.ts). Defaults to "never" for
102
+ // callers that don't thread it through.
103
+ private readonly isStringName: (n: string) => boolean = unusedIsStringName,
104
+ // Records a BF101 for nested callback shapes this emitter can only
105
+ // degrade — `find*` and the non-predicate methods. Optional so emitter
106
+ // construction stays possible without an adapter; a missing hook keeps
107
+ // the old silent-degrade emit.
108
+ private readonly onUnsupported?: (message: string, reason?: string) => void,
109
+ ) {}
110
+
111
+ identifier(name: string): string {
112
+ if (name === this.param) return rubyLocal(this.param)
113
+ const signal = this.localVarMap.get(name)
114
+ if (signal) return `v[${rubySymbolLiteral(signal)}]`
115
+ if (this.isLoopBoundOuter(name)) return rubyLocal(name)
116
+ return `v[${rubySymbolLiteral(name)}]`
117
+ }
118
+
119
+ literal(value: string | number | boolean | null, literalType: LiteralType): string {
120
+ if (literalType === 'string') return rubyStringLiteral(String(value))
121
+ if (literalType === 'boolean') return value ? 'true' : 'false'
122
+ if (literalType === 'null') return 'nil'
123
+ return String(value)
124
+ }
125
+
126
+ member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
127
+ // `.length` needs no special higher-order form here — see the file
128
+ // docstring's simplification (2): `.select { ... }.length` just works
129
+ // in Ruby, unlike Perl's anonymous-arrayref `scalar(@{...})` detour.
130
+ if (property === 'length') return `${emit(object)}.length`
131
+ return `${emit(object)}[${rubySymbolLiteral(property)}]`
132
+ }
133
+
134
+ indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
135
+ return emitIndexAccessRuby(object, index, emit, this.isStringName)
136
+ }
137
+
138
+ call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
139
+ // Signal getter calls: filter() → v[:filter]
140
+ if (callee.kind === 'identifier' && args.length === 0) {
141
+ return `v[${rubySymbolLiteral(callee.name)}]`
142
+ }
143
+ return emit(callee)
144
+ }
145
+
146
+ unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
147
+ const arg = emit(argument)
148
+ // JS `!x` tests JS truthiness, not Ruby's — wrap. See file docstring.
149
+ if (op === '!') return `!bf.truthy?(${arg})`
150
+ if (op === '-') return `-(${arg})`
151
+ return arg
152
+ }
153
+
154
+ binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
155
+ const l = emit(left)
156
+ const r = emit(right)
157
+ // Unlike Perl's numeric `==` (which coerces a non-numeric string
158
+ // operand to 0, making `"b" == "a"` erroneously true), Ruby's `==`
159
+ // never coerces between mismatched types — `"b" == "a"` and
160
+ // `"b" == 0` are both correctly `false` with no operator selection
161
+ // needed. JS `===`/`!==` map straight onto Ruby `==`/`!=`.
162
+ const opMap: Record<string, string> = {
163
+ '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
164
+ '+': '+', '-': '-', '*': '*', '/': '/',
165
+ }
166
+ return `(${l} ${opMap[op] ?? op} ${r})`
167
+ }
168
+
169
+ logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
170
+ const l = emit(left)
171
+ const r = emit(right)
172
+ // JS `&&`/`||` are operand-returning under JS truthiness, which
173
+ // diverges from Ruby's own `&&`/`||` (Ruby only treats nil/false as
174
+ // falsy) — see file docstring. `??` only ever needs a null check, which
175
+ // Ruby and JS agree on.
176
+ if (op === '&&') return `(bf.truthy?(${l}) ? ${r} : ${l})`
177
+ if (op === '||') return `(bf.truthy?(${l}) ? ${l} : ${r})`
178
+ return `((${l}).nil? ? ${r} : ${l})`
179
+ }
180
+
181
+ callbackMethod(
182
+ method: string,
183
+ object: ParsedExpr,
184
+ arrow: Extract<ParsedExpr, { kind: 'arrow' }>,
185
+ _restArgs: ParsedExpr[],
186
+ emit: (e: ParsedExpr) => string,
187
+ ): string {
188
+ // Filter context only meaningfully handles the predicate methods
189
+ // (filter / every / some land here through nested `.filter(...)`
190
+ // chains). Sort / reduce / flatMap have no scalar Ruby form here —
191
+ // surface BF101 instead of silently rewriting the predicate.
192
+ if (!PREDICATE_METHODS.has(method)) {
193
+ this.onUnsupported?.(
194
+ `Filter predicate contains a nested '.${method}(...)' callback, which has no Ruby scalar form`,
195
+ `Rewrite the predicate without a nested callback method, or add /* @client */ for client-only evaluation (no SSR).`,
196
+ )
197
+ return 'true'
198
+ }
199
+ // The predicate body is also a filter context, but with this
200
+ // callback's own `param` (potentially shadowing the outer one), so we
201
+ // spin up a nested emitter with the inner param. The outer param/alias
202
+ // become part of "loop-bound outer" for the nested emitter — a real
203
+ // Ruby block nests lexically, so the substitution-free block-param
204
+ // approach composes for free (file docstring simplification 1).
205
+ const param = arrow.params[0]
206
+ const predicate = arrow.body
207
+ const arrayExpr = emit(object)
208
+ const nested = new ErbFilterEmitter(
209
+ param,
210
+ this.localVarMap,
211
+ (n) => n === this.param || this.isLoopBoundOuter(n),
212
+ this.isStringName,
213
+ this.onUnsupported,
214
+ )
215
+ const predBody = emitParsedExpr(predicate, nested)
216
+ const blockParam = rubyLocal(param)
217
+ if (method === 'filter') return `${arrayExpr}.select { |${blockParam}| bf.truthy?(${predBody}) }`
218
+ if (method === 'every') return `${arrayExpr}.all? { |${blockParam}| bf.truthy?(${predBody}) }`
219
+ if (method === 'some') return `${arrayExpr}.any? { |${blockParam}| bf.truthy?(${predBody}) }`
220
+ // `find` / `findIndex` / `findLast` / `findLastIndex` return an element
221
+ // (or index), not a boolean — there is no inline predicate form.
222
+ // Degrading to the receiver silently changes predicate semantics, so be
223
+ // loud.
224
+ this.onUnsupported?.(
225
+ `Filter predicate contains a nested '.${method}(...)' callback, which has no Ruby scalar form`,
226
+ `Rewrite the predicate without a nested callback method, or add /* @client */ for client-only evaluation (no SSR).`,
227
+ )
228
+ return arrayExpr
229
+ }
230
+
231
+ arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
232
+ return `[${elements.map(emit).join(', ')}]`
233
+ }
234
+
235
+ arrayMethod(
236
+ method: ArrayMethod,
237
+ object: ParsedExpr,
238
+ args: ParsedExpr[],
239
+ emit: (e: ParsedExpr) => string,
240
+ ): string {
241
+ return renderArrayMethod(method, object, args, emit)
242
+ }
243
+
244
+ flatMethod(
245
+ object: ParsedExpr,
246
+ depth: FlatDepth | { expr: ParsedExpr },
247
+ emit: (e: ParsedExpr) => string,
248
+ ): string {
249
+ return renderFlatMethod(emit(object), depth, emit)
250
+ }
251
+
252
+ conditional(_test: ParsedExpr, _consequent: ParsedExpr, _alternate: ParsedExpr): string {
253
+ return 'true'
254
+ }
255
+
256
+ templateLiteral(_parts: TemplatePart[]): string {
257
+ return 'true'
258
+ }
259
+
260
+ arrow(_params: string[], _body: ParsedExpr, _emit: (e: ParsedExpr) => string): string {
261
+ // A standalone arrow only reaches here outside a callback position,
262
+ // which never arises in a predicate context — emit the truthy sentinel.
263
+ return 'true'
264
+ }
265
+
266
+ regex(_raw: string): string {
267
+ return 'true'
268
+ }
269
+
270
+ unsupported(_raw: string, _reason: string): string {
271
+ return 'true'
272
+ }
273
+
274
+ objectLiteral(_properties: ObjectLiteralProperty[], _raw: string, _emit: (e: ParsedExpr) => string): string {
275
+ // Filter-predicate context: an object literal is not a boolean leaf, so
276
+ // emit the truthy sentinel exactly as `unsupported` does. Object
277
+ // *values* are lowered to Ruby Hashes in the conditional/attr paths,
278
+ // not here.
279
+ return 'true'
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Lowering for top-level expressions whose identifiers resolve against the
285
+ * ERB template's vars Hash (`v`) — signals, props, module consts — or,
286
+ * inside a loop body, a bare Ruby local. Differs from the filter emitter
287
+ * mainly in:
288
+ * - `conditional` is supported (filter predicates can't return ternaries),
289
+ * - templatePrimitive / searchParams / eval-catalogue call dispatch,
290
+ * - the `unsupported` fallback returns the safe empty-string Ruby literal.
291
+ */
292
+ export class ErbTopLevelEmitter implements ParsedExprEmitter {
293
+ constructor(private readonly ctx: ErbEmitContext) {}
294
+
295
+ identifier(name: string): string {
296
+ // `undefined` / `null` nested inside a larger expression tree (e.g.
297
+ // `props.isActive ? 'page' : undefined`) — the top-level short-circuits
298
+ // don't see them.
299
+ if (name === 'undefined' || name === 'null') return 'nil'
300
+ // A loop-bound name (this identifier resolves to a bare Ruby local
301
+ // introduced by an enclosing loop, not a vars-Hash entry) takes
302
+ // priority over const inlining — mirrors the Mojo adapter's
303
+ // `loopBoundNames` shadow guard, but here it ALSO decides the
304
+ // fundamental v[:name]-vs-bare-local rendering, not just the const
305
+ // fast path (ERB's two-locals variable model needs this distinction;
306
+ // Perl's uniform `$name` sigil does not — see `ErbEmitContext`).
307
+ if (this.ctx.isLoopBoundName(name)) return rubyLocal(name)
308
+ // Module pure-string const (e.g. `const baseClasses = '...'` used in a
309
+ // className template literal): inline the literal value rather than
310
+ // emit `v[:baseClasses]` against a vars-Hash key that is never seeded.
311
+ const inlined = this.ctx.resolveModuleStringConst(name)
312
+ if (inlined !== null) return inlined
313
+ // Same for a literal const of any scope (`const totalPages = 5`).
314
+ const literalConst = this.ctx.resolveLiteralConst(name)
315
+ if (literalConst !== null) return literalConst
316
+ return `v[${rubySymbolLiteral(name)}]`
317
+ }
318
+
319
+ literal(value: string | number | boolean | null, literalType: LiteralType): string {
320
+ if (literalType === 'string') return rubyStringLiteral(String(value))
321
+ if (literalType === 'boolean') return value ? 'true' : 'false'
322
+ if (literalType === 'null') return 'nil'
323
+ return String(value)
324
+ }
325
+
326
+ member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
327
+ // `props.x` flattens to the `v[:x]` the ERB SSR caller seeds each prop
328
+ // under (props arrive as vars-Hash entries, not a nested `props` Hash).
329
+ if (object.kind === 'identifier' && object.name === 'props') {
330
+ return `v[${rubySymbolLiteral(property)}]`
331
+ }
332
+ // Static property access on a module object-literal const
333
+ // (`variantClasses.ghost`) resolves at compile time — the generic Hash
334
+ // lowering below would read a vars-Hash key that doesn't exist
335
+ // server-side.
336
+ if (object.kind === 'identifier') {
337
+ const staticValue = this.ctx.resolveStaticRecordLiteral(object.name, property)
338
+ if (staticValue !== null) return staticValue
339
+ }
340
+ const obj = emit(object)
341
+ if (property === 'length') return `${obj}.length`
342
+ return `${obj}[${rubySymbolLiteral(property)}]`
343
+ }
344
+
345
+ indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
346
+ return emitIndexAccessRuby(object, index, emit, n => this.ctx._isStringValueName(n))
347
+ }
348
+
349
+ call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
350
+ // Signal getter: count() → v[:count]
351
+ if (callee.kind === 'identifier' && args.length === 0) {
352
+ return `v[${rubySymbolLiteral(callee.name)}]`
353
+ }
354
+ // Env-signal method call: `searchParams().get('sort')` is a real method
355
+ // call on the per-request search-params reader object (seeded under the
356
+ // reserved `v[:search_params]` key regardless of the local import
357
+ // alias), not the generic Hash-lookup `member` would emit. Matches the
358
+ // local import binding (incl. an alias).
359
+ if (this.ctx._searchParamsLocals.size > 0) {
360
+ const sp = matchSearchParamsMethodCall(callee, args, this.ctx._searchParamsLocals)
361
+ if (sp) {
362
+ return `v[:search_params].${sp.method}(${sp.args.map(emit).join(', ')})`
363
+ }
364
+ }
365
+ // Identifier-path templatePrimitive: `JSON.stringify(x)` /
366
+ // `Math.floor(x)` → `bf.json(v[:x])` / `bf.floor(v[:x])`. Args render
367
+ // recursively through this same emitter so prop refs / signal calls
368
+ // inside them get the standard transforms. A wrong-arity call records
369
+ // BF101 and returns the safe `''` placeholder (never silently emits a
370
+ // bad call).
371
+ const path = identifierPath(callee)
372
+ const spec = path ? ERB_TEMPLATE_PRIMITIVES[path] : undefined
373
+ if (path && spec) {
374
+ if (args.length === spec.arity) {
375
+ return spec.emit(args.map(emit))
376
+ }
377
+ this.ctx._recordExprBF101(
378
+ `templatePrimitive '${path}' expects ${spec.arity} arg(s), got ${args.length}`,
379
+ `Call '${path}' with exactly ${spec.arity} argument(s).`,
380
+ )
381
+ // Don't fall through to the generic `emit(callee)` below — for a
382
+ // member callee (`JSON.stringify`) that emits an invalid Ruby
383
+ // Hash-lookup (`v[:JSON][:stringify]`). Return the same safe
384
+ // empty-string placeholder the other BF101 paths use.
385
+ return "''"
386
+ }
387
+ // Array methods (`.join` and any others added to ArrayMethod) are
388
+ // lifted into the `array-method` IR kind at parse time, so they never
389
+ // reach this dispatcher.
390
+ return emit(callee)
391
+ }
392
+
393
+ unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string {
394
+ const arg = emit(argument)
395
+ if (op === '!') return `!bf.truthy?(${arg})`
396
+ if (op === '-') return `-(${arg})`
397
+ return arg
398
+ }
399
+
400
+ binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
401
+ const l = emit(left)
402
+ const r = emit(right)
403
+ const opMap: Record<string, string> = {
404
+ '===': '==', '!==': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
405
+ '+': '+', '-': '-', '*': '*',
406
+ }
407
+ return `(${l} ${opMap[op] ?? op} ${r})`
408
+ }
409
+
410
+ logical(op: '&&' | '||' | '??', left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
411
+ const l = emit(left)
412
+ const r = emit(right)
413
+ if (op === '&&') return `(bf.truthy?(${l}) ? ${r} : ${l})`
414
+ if (op === '||') return `(bf.truthy?(${l}) ? ${l} : ${r})`
415
+ return `((${l}).nil? ? ${r} : ${l})`
416
+ }
417
+
418
+ callbackMethod(
419
+ method: string,
420
+ object: ParsedExpr,
421
+ arrow: Extract<ParsedExpr, { kind: 'arrow' }>,
422
+ restArgs: ParsedExpr[],
423
+ emit: (e: ParsedExpr) => string,
424
+ ): string {
425
+ const recv = emit(object)
426
+ const body = arrow.body
427
+ const params = arrow.params
428
+
429
+ // sort / toSorted: eval-first, then the structured `bf.sort` fallback
430
+ // (recovered from the arrow by `sortComparatorFromArrow`, e.g. a
431
+ // `localeCompare` comparator), then BF101.
432
+ if (method === 'sort' || method === 'toSorted') {
433
+ const evalForm = renderSortEval(recv, body, params, emit)
434
+ if (evalForm !== null) return evalForm
435
+ const c = sortComparatorFromArrow(arrow)
436
+ if (c !== null) return renderSortMethod(recv, c)
437
+ this.ctx._recordExprBF101(
438
+ `.${method}(...) comparator is not lowerable to a template sort`,
439
+ `Pre-sort the array in the route handler, or mark the loop @client-only.`,
440
+ )
441
+ return "''"
442
+ }
443
+
444
+ // reduce / reduceRight: eval-only (the arithmetic catalogue always
445
+ // serializes); BF101 when the body is outside the evaluator surface or
446
+ // the seed isn't a literal.
447
+ if (method === 'reduce' || method === 'reduceRight') {
448
+ const direction = method === 'reduceRight' ? 'right' : 'left'
449
+ const init = restArgs[0]
450
+ const evalForm =
451
+ init !== undefined ? renderReduceEval(recv, body, params, init, direction, emit) : null
452
+ if (evalForm !== null) return evalForm
453
+ this.ctx._recordExprBF101(
454
+ `.${method}(...) is not lowerable to a template fold`,
455
+ `Pre-compute the fold in the route handler, or mark the loop @client-only.`,
456
+ )
457
+ return "''"
458
+ }
459
+
460
+ // flatMap: eval-only; BF101 when the projection is outside the surface.
461
+ if (method === 'flatMap') {
462
+ const evalForm = renderFlatMapEval(recv, body, params[0], emit)
463
+ if (evalForm !== null) return evalForm
464
+ this.ctx._recordExprBF101(
465
+ `.flatMap(...) projection is not lowerable to a template flat-map`,
466
+ `Pre-compute the projection in the route handler, or mark the loop @client-only.`,
467
+ )
468
+ return "''"
469
+ }
470
+
471
+ // Value-producing map: eval-only; BF101 when the projection is outside
472
+ // the surface. (The JSX-returning `.map` is an IRLoop upstream.)
473
+ if (method === 'map') {
474
+ const evalForm = renderMapEval(recv, body, params[0], emit)
475
+ if (evalForm !== null) return evalForm
476
+ this.ctx._recordExprBF101(
477
+ `.map(...) projection is not lowerable to a template map`,
478
+ `Pre-compute the projection in the route handler, or mark the position @client-only.`,
479
+ )
480
+ return "''"
481
+ }
482
+
483
+ // Predicate methods: filter / find / every / some / findIndex /
484
+ // findLast / findLastIndex. Eval-first, then the Ruby block / `bf.find*`
485
+ // fallback for a predicate the evaluator can't model.
486
+ const cb: PredicateCall = {
487
+ method: method as HigherOrderMethod,
488
+ object,
489
+ param: params[0],
490
+ predicate: body,
491
+ }
492
+ return this.renderPredicate(cb, recv, emit)
493
+ }
494
+
495
+ private renderPredicate(
496
+ cb: PredicateCall,
497
+ arrayExpr: string,
498
+ emit: (e: ParsedExpr) => string,
499
+ ): string {
500
+ const { method, param, predicate } = cb
501
+ // Evaluator path: serialize the predicate body + emit the matching
502
+ // `bf.*_eval` helper (isomorphic with the Go/Perl adapters). Falls back
503
+ // to the inline Ruby-block / `bf.find*` lowering below for a predicate
504
+ // the evaluator can't model (e.g. a method-call predicate).
505
+ const evalFn: Record<string, [string, boolean?]> = {
506
+ filter: ['filter_eval'], every: ['every_eval'], some: ['some_eval'],
507
+ find: ['find_eval', true], findLast: ['find_eval', false],
508
+ findIndex: ['find_index_eval', true], findLastIndex: ['find_index_eval', false],
509
+ }
510
+ // `.filter(Boolean)` (identity predicate `_t => _t`) keeps the inline
511
+ // `.select { |x| bf.truthy?(x) }` form — it composes through the
512
+ // array-method chain (`.filter(Boolean).join(' ')`) and renders
513
+ // identically to a truthiness filter.
514
+ const isIdentity =
515
+ method === 'filter' && predicate.kind === 'identifier' && predicate.name === param
516
+ const spec = evalFn[method]
517
+ if (spec && !isIdentity) {
518
+ const evalForm = renderPredicateEval(spec[0], arrayExpr, predicate, param, emit, spec[1])
519
+ if (evalForm !== null) return evalForm
520
+ }
521
+
522
+ const predBody = this.ctx._renderRubyFilterExprPublic(predicate, param)
523
+ const blockParam = rubyLocal(param)
524
+ if (method === 'filter') return `${arrayExpr}.select { |${blockParam}| bf.truthy?(${predBody}) }`
525
+ if (method === 'every') return `${arrayExpr}.all? { |${blockParam}| bf.truthy?(${predBody}) }`
526
+ if (method === 'some') return `${arrayExpr}.any? { |${blockParam}| bf.truthy?(${predBody}) }`
527
+ // `.find` / `.findIndex` / `.findLast` / `.findLastIndex` → the runtime
528
+ // helpers (`bf.find` / `find_index` / `find_last` / `find_last_index`),
529
+ // which call the predicate as a Ruby block per element (Ruby's native
530
+ // callable form — the direct analog of Xslate's Kolon lambda / Perl's
531
+ // coderef). The JS camelCase names map to the snake_case helpers.
532
+ const findHelper: Record<string, string> = {
533
+ find: 'find',
534
+ findIndex: 'find_index',
535
+ findLast: 'find_last',
536
+ findLastIndex: 'find_last_index',
537
+ }
538
+ if (findHelper[method]) {
539
+ return `bf.${findHelper[method]}(${arrayExpr}) { |${blockParam}| bf.truthy?(${predBody}) }`
540
+ }
541
+ return arrayExpr
542
+ }
543
+
544
+ arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string {
545
+ // Ruby array literal. Identifiers inside elements resolve through the
546
+ // top-level emitter so `[className, childClass]` becomes
547
+ // `[v[:className], v[:childClass]]`. Empty `[]` stays as `[]`.
548
+ return `[${elements.map(emit).join(', ')}]`
549
+ }
550
+
551
+ arrayMethod(
552
+ method: ArrayMethod,
553
+ object: ParsedExpr,
554
+ args: ParsedExpr[],
555
+ emit: (e: ParsedExpr) => string,
556
+ ): string {
557
+ return renderArrayMethod(method, object, args, emit)
558
+ }
559
+
560
+ flatMethod(
561
+ object: ParsedExpr,
562
+ depth: FlatDepth | { expr: ParsedExpr },
563
+ emit: (e: ParsedExpr) => string,
564
+ ): string {
565
+ return renderFlatMethod(emit(object), depth, emit)
566
+ }
567
+
568
+ conditional(
569
+ test: ParsedExpr,
570
+ consequent: ParsedExpr,
571
+ alternate: ParsedExpr,
572
+ emit: (e: ParsedExpr) => string,
573
+ ): string {
574
+ // JS ternary tests JS truthiness — wrap (see file docstring).
575
+ return `(bf.truthy?(${emit(test)}) ? ${emit(consequent)} : ${emit(alternate)})`
576
+ }
577
+
578
+ templateLiteral(parts: TemplatePart[], emit: (e: ParsedExpr) => string): string {
579
+ // `` `n=${count() + 1}` `` → Ruby string concatenation
580
+ // (`'n=' + bf.string(v[:count] + 1)`), NOT string interpolation — a
581
+ // literal chunk could itself contain `#{...}`-shaped text, and every
582
+ // dynamic term needs JS ToString semantics (`bf.string`, not Ruby's
583
+ // own `#{}` / `to_s`, e.g. JS `1.0.toString()` is `"1"`) rather than
584
+ // Ruby's native stringification.
585
+ const terms: string[] = []
586
+ for (const part of parts) {
587
+ if (part.type === 'string') {
588
+ if (part.value !== '') terms.push(rubyStringLiteral(part.value))
589
+ } else {
590
+ terms.push(`bf.string(${emit(part.expr)})`)
591
+ }
592
+ }
593
+ if (terms.length === 0) return "''"
594
+ return terms.join(' + ')
595
+ }
596
+
597
+ arrow(_params: string[], _body: ParsedExpr, _emit: (e: ParsedExpr) => string): string {
598
+ // A bare arrow function never stands alone at a render position (it's
599
+ // only meaningful as a callback argument, handled by `callbackMethod`).
600
+ // Return the safe Ruby empty-string literal — consistent with the
601
+ // BF101 / `unsupported` paths — so a stray emit can't produce an
602
+ // `<%= %>` syntax error.
603
+ return "''"
604
+ }
605
+
606
+ regex(_raw: string): string {
607
+ return "''"
608
+ }
609
+
610
+ unsupported(_raw: string, _reason: string): string {
611
+ // Unreachable in the parse-first flow: `convertExpressionToRuby` gates
612
+ // on `isSupported` before dispatching, and `isSupported` recurses, so a
613
+ // top-level supported expression never contains an `unsupported` node.
614
+ return "''"
615
+ }
616
+
617
+ objectLiteral(properties: ObjectLiteralProperty[], _raw: string, _emit: (e: ParsedExpr) => string): string {
618
+ // The shared `isSupported` gate only ever lets this dispatcher see an
619
+ // object literal as the EMPTY (`?? {}`) fallback operand of `??`
620
+ // (expression-parser.ts, `logical` case) — any other object literal is
621
+ // refused before reaching here. Emit Ruby's real empty Hash literal,
622
+ // matching the `'{}'` convention `objectLiteralToRubyHash` already uses
623
+ // for the zero-property case in the spread path. A populated literal is
624
+ // structurally unreachable given the gate, but still degrades safely to
625
+ // the pre-existing empty-string sentinel rather than silently dropping
626
+ // keys.
627
+ return properties.length === 0 ? '{}' : "''"
628
+ }
629
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Operand-type classification + index-access lowering for the ERB template
3
+ * adapter.
4
+ *
5
+ * Ported from the Mojolicious adapter's `expr/operand.ts` (issue #2018
6
+ * track D lineage). Pure functions over `ParsedExpr` — they take an
7
+ * `isStringName` predicate (supplied by the emitter from adapter state)
8
+ * rather than reading adapter instance state directly.
9
+ *
10
+ * `isStringTypedOperand` is byte-identical to the Mojo/Xslate adapters'
11
+ * copy. `emitIndexAccessRuby` is ERB-specific: Ruby's `[]` operator is
12
+ * syntactically the same for Array and Hash access (unlike Perl's
13
+ * `->[]`/`->{}` split), but this runtime's object values are JSON-shaped
14
+ * Ruby Hashes with SYMBOL keys — so a string-typed index still needs a
15
+ * `.to_sym` conversion to become a valid Hash key, while a non-string
16
+ * (numeric / loop-index) index passes straight through as an Array index.
17
+ */
18
+
19
+ import type { ParsedExpr } from '@barefootjs/jsx'
20
+
21
+ /**
22
+ * Whether a comparison/index operand is string-typed. Covers a string
23
+ * literal, a string-signal getter call (`sel()`), and a string prop access
24
+ * (`props.x`). `isStringName` reports whether a getter/prop name is
25
+ * known-string. Loop-element fields (`t.id`) on untyped arrays have no known
26
+ * type and stay undetected — a separate, narrower gap.
27
+ */
28
+ export function isStringTypedOperand(expr: ParsedExpr, isStringName: (n: string) => boolean): boolean {
29
+ if (expr.kind === 'literal' && expr.literalType === 'string') return true
30
+ if (expr.kind === 'call' && expr.callee.kind === 'identifier' && expr.args.length === 0) {
31
+ return isStringName(expr.callee.name)
32
+ }
33
+ if (expr.kind === 'member' && expr.object.kind === 'identifier' && expr.object.name === 'props') {
34
+ return isStringName(expr.property)
35
+ }
36
+ return false
37
+ }
38
+
39
+ /**
40
+ * Lower `arr[index]` to a Ruby `[]` access. A string-typed index reads a
41
+ * JSON-shaped Hash by symbol key (`.to_sym`); any other index (the common
42
+ * loop-index / arithmetic case, e.g. `selected()[index]`) reads an Array by
43
+ * its (already-numeric) value.
44
+ */
45
+ export function emitIndexAccessRuby(
46
+ object: ParsedExpr,
47
+ index: ParsedExpr,
48
+ emit: (e: ParsedExpr) => string,
49
+ isStringName: (n: string) => boolean,
50
+ ): string {
51
+ const i = emit(index)
52
+ return isStringTypedOperand(index, isStringName)
53
+ ? `${emit(object)}[(${i}).to_sym]`
54
+ : `${emit(object)}[${i}]`
55
+ }