@barefootjs/mojolicious 0.5.0 → 0.5.2

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.
@@ -22,6 +22,7 @@ import type {
22
22
  IRTemplatePart,
23
23
  AttrValue,
24
24
  CompilerError,
25
+ TypeInfo,
25
26
  TemplatePrimitiveRegistry,
26
27
  } from '@barefootjs/jsx'
27
28
  import {
@@ -39,10 +40,8 @@ import {
39
40
  isBooleanAttr,
40
41
  parseExpression,
41
42
  isSupported,
42
- containsHigherOrder,
43
43
  exprToString,
44
44
  identifierPath,
45
- stringifyParsedExpr,
46
45
  emitParsedExpr,
47
46
  emitIRNode,
48
47
  emitAttrValue,
@@ -86,19 +85,6 @@ const MOJO_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
86
85
  'Math.round': { arity: 1, emit: (args) => `bf->round(${args[0]})` },
87
86
  }
88
87
 
89
- /**
90
- * Cheap substring pre-check: skip the (expensive) `parseExpression`
91
- * call when no primitive callee path appears in the source string.
92
- * The common case is "no primitive present"; building the regex
93
- * once from the registry keys keeps the gate in sync as new
94
- * primitives land.
95
- */
96
- const PRIMITIVE_SUBSTRING_RE = new RegExp(
97
- Object.keys(MOJO_TEMPLATE_PRIMITIVES)
98
- .map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
99
- .join('|')
100
- )
101
-
102
88
  /**
103
89
  * Module-scope `templatePrimitives` map derived once from the spec
104
90
  * record. Per-instance derivation would re-build the same Map on
@@ -157,21 +143,6 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
157
143
  private options: Required<MojoAdapterOptions>
158
144
  private errors: CompilerError[] = []
159
145
  private inLoop: boolean = false
160
- /**
161
- * Re-entry guard for `convertHigherOrderExpr` (#1421).
162
- *
163
- * `MojoTopLevelEmitter.unsupported` falls back to the regex pipeline
164
- * via `_convertExpressionToPerlPublic`, which re-detects the
165
- * `.filter|every|some` short-circuit and re-enters
166
- * `convertHigherOrderExpr` with the same raw text. When the parser
167
- * carries the full original expression down to every nested
168
- * `unsupported` node (e.g. an array-literal callee that the AST
169
- * can't classify), the cycle has no terminator and the JS stack
170
- * blows. The guard records the expression on entry, emits BF101 on
171
- * second visit, and bails out — so the user sees an actionable
172
- * diagnostic instead of `RangeError: Maximum call stack size`.
173
- */
174
- private higherOrderInFlight: Set<string> = new Set()
175
146
  /**
176
147
  * SolidJS-style props identifier (`function(props: P)`) and the
177
148
  * analyzer-extracted prop names. Stashed at `generate()` entry so
@@ -181,6 +152,13 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
181
152
  */
182
153
  private propsObjectName: string | null = null
183
154
  private propsParams: { name: string }[] = []
155
+ /**
156
+ * Names (signal getters + props) whose value is a string, so `===`/`!==`
157
+ * against them lowers to Perl `eq`/`ne` rather than numeric `==`/`!=`.
158
+ * Perl's numeric `==` coerces non-numeric strings to 0, making `"b" == "a"`
159
+ * true — selecting the string operator from the operand's type avoids that.
160
+ */
161
+ private stringValueNames: Set<string> = new Set()
184
162
 
185
163
  constructor(options: MojoAdapterOptions = {}) {
186
164
  super()
@@ -194,8 +172,21 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
194
172
  this.componentName = ir.metadata.componentName
195
173
  this.propsObjectName = ir.metadata.propsObjectName ?? null
196
174
  this.propsParams = ir.metadata.propsParams.map(p => ({ name: p.name }))
175
+ // Record string-typed signals and props so equality comparisons against
176
+ // them lower to `eq`/`ne` (#1672). A signal is string-typed when its
177
+ // inferred type is `string` (the analyzer infers this from a string-literal
178
+ // initial value) or, defensively, when its initial value is a bare string
179
+ // literal; a prop when its annotated type is `string`.
180
+ this.stringValueNames = new Set<string>()
181
+ for (const s of ir.metadata.signals) {
182
+ if (isStringTypeInfo(s.type) || isBareStringLiteral(s.initialValue)) {
183
+ this.stringValueNames.add(s.getter)
184
+ }
185
+ }
186
+ for (const p of ir.metadata.propsParams) {
187
+ if (isStringTypeInfo(p.type)) this.stringValueNames.add(p.name)
188
+ }
197
189
  this.errors = []
198
- this.higherOrderInFlight = new Set()
199
190
  this.childrenCaptureCounter = 0
200
191
 
201
192
  // Mirror of the Go adapter's BF103 check (#1266): when a child
@@ -598,9 +589,19 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
598
589
  : loop.index ? `$${loop.index}` : '$_i'
599
590
  const prevInLoop = this.inLoop
600
591
  this.inLoop = true
601
- const children = this.renderChildren(loop.children)
592
+ const renderedChildren = this.renderChildren(loop.children)
602
593
  this.inLoop = prevInLoop
603
594
 
595
+ // Whole-item conditional (#1665): prepend an always-present
596
+ // `<!--bf-loop-i:KEY-->` anchor before each item's (possibly empty)
597
+ // conditional content so the client's `mapArrayAnchored` can hydrate
598
+ // every SSR-rendered item by its anchor. `bf->comment` prepends `bf-`,
599
+ // so `"loop-i:" . KEY` yields `<!--bf-loop-i:KEY-->`.
600
+ const children =
601
+ loop.bodyIsItemConditional && loop.key
602
+ ? `<%== bf->comment("loop-i:" . ${this.convertExpressionToPerl(loop.key)}) %>\n${renderedChildren}`
603
+ : renderedChildren
604
+
604
605
  const lines: string[] = []
605
606
  // Scoped per-call-site marker so sibling `.map()`s under the same parent
606
607
  // each get their own reconciliation range (#1087).
@@ -981,7 +982,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
981
982
  // those would yield runtime errors in Perl, which is the user's
982
983
  // signal to refactor. Wholesale refusal would also block the
983
984
  // canonical case the issue exists to enable.
984
- return emitParsedExpr(expr, new MojoFilterEmitter(param, localVarMap))
985
+ return emitParsedExpr(expr, new MojoFilterEmitter(param, localVarMap, n => this._isStringValueName(n)))
985
986
  }
986
987
 
987
988
  /**
@@ -1192,261 +1193,41 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1192
1193
 
1193
1194
 
1194
1195
  private convertExpressionToPerl(expr: string): string {
1195
- // Handle higher-order array methods via ParsedExpr AST.
1196
- // `filter|every|some` lower to Embedded Perl (grep). The rest
1197
- // (`reduce|reduceRight|forEach|flatMap|flat`) can't lower to EP
1198
- // at all route them through the same AST path so
1199
- // `convertHigherOrderExpr`'s `isSupported` gate emits BF101
1200
- // instead of falling into the regex pipeline that mangles
1201
- // `$items->{reduce}->{...}` etc.
1202
- // `findLast|findLastIndex` are caught by the explicit Mojo-gap
1203
- // refusal below (alongside `find|findIndex`).
1204
- if (/\.\s*(?:filter|every|some|reduce|reduceRight|forEach|flatMap|flat)\s*\(/.test(expr)) {
1205
- return this.convertHigherOrderExpr(expr)
1206
- }
1207
-
1208
- // #1448 Tier A — JS Array / String methods that the regex
1209
- // pipeline silently mangles into `${obj}->{<method>}(...)` hash
1210
- // lookups that fail at render time. Route them through the same
1211
- // AST path so `isSupported`'s `UNSUPPORTED_METHODS` gate fires
1212
- // BF101 with the offending expression, matching Go's behaviour.
1213
- // Each method name drops off the regex as its lowering lands
1214
- // (the regex stays in sync with `UNSUPPORTED_METHODS` —
1215
- // `convertHigherOrderExpr` intercepts via `isSupported`).
1216
- if (/\.\s*(?:includes|indexOf|lastIndexOf|at|concat|slice|reverse|toReversed|toLowerCase|toUpperCase|trim)\s*\(/.test(expr)) {
1217
- return this.convertHigherOrderExpr(expr)
1218
- }
1219
-
1220
- // #1443/#1448: `.join(sep)` is lifted by the parser to the
1221
- // `array-method` IR kind, and `renderArrayMethod`'s `case 'join'`
1222
- // already emits the correct `join(sep, @{arr})`. Route the
1223
- // text-expression form through the same AST path so the
1224
- // regex pipeline below doesn't mangle it into a
1225
- // `${arr}->{join}(sep)` Perl hash-lookup that errors at render.
1226
- if (/\.\s*join\s*\(/.test(expr)) {
1227
- return this.convertHigherOrderExpr(expr)
1228
- }
1229
-
1230
- // #1448 catalog — Mojo-specific gap: `.find` / `.findIndex` /
1231
- // `.findLast` / `.findLastIndex` have no AST lowering yet (no
1232
- // `array-method` IR variant, no emitter), and the regex pipeline
1233
- // silently mangles them into `${obj}->{find}(...)` hash lookups.
1234
- // Emit BF101 here until either a parser-level `array-method`
1235
- // extension or a `convertHigherOrderExpr` carve-out lands.
1236
- const mojoOnlyMatch = /\.\s*(?<method>find|findIndex|findLast|findLastIndex)\s*\(/.exec(expr)
1237
- if (mojoOnlyMatch) {
1238
- const methodName = mojoOnlyMatch.groups!.method!
1196
+ // Parse-first lowering parity with the Go adapter's
1197
+ // `convertExpressionToGo`. Parse the JS expression once, gate it on
1198
+ // the shared `isSupported`, and render every supported shape through
1199
+ // the AST emitter (`renderParsedExprToPerl`). The parser's
1200
+ // `UNSUPPORTED_METHODS` is the single source of truth for what's
1201
+ // refused there are no per-method routing regexes and no regex
1202
+ // string-rewriting pipeline. Unsupported shapes (un-lowered methods,
1203
+ // unparseable hand-written JS, etc.) surface as BF101 with the
1204
+ // `/* @client */` escape hatch instead of being silently mangled.
1205
+ const trimmed = expr.trim()
1206
+ if (trimmed === '') return "''"
1207
+
1208
+ const parsed = parseExpression(trimmed)
1209
+ const support = isSupported(parsed)
1210
+ if (!support.supported) {
1239
1211
  this.errors.push({
1240
1212
  code: 'BF101',
1241
1213
  severity: 'error',
1242
- message: `Mojo adapter has not lowered Array.prototype.${methodName} yet: ${expr.trim()}`,
1214
+ message: `Expression not supported: ${trimmed}`,
1243
1215
  loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1244
1216
  suggestion: {
1245
- message: 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Perl',
1217
+ message: support.reason
1218
+ ? `${support.reason}\n\nOptions:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Perl`
1219
+ : 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Perl',
1246
1220
  },
1247
1221
  })
1222
+ // Safe Perl empty-string literal — valid in every context the
1223
+ // result might land in (`<%= '' %>`, `% if ('') {`, attribute
1224
+ // interpolation, template-literal substitution).
1248
1225
  return "''"
1249
1226
  }
1250
1227
 
1251
- // templatePrimitives substitution (#1189): rewrite identifier-path
1252
- // calls like `JSON.stringify(props.config)` / `Math.floor(x)` to
1253
- // their Mojo helper-call form (`bf->json($config)` etc.) BEFORE
1254
- // the regex pipeline below runs. Using the AST avoids fighting
1255
- // the existing regex transforms — a registered call's args go
1256
- // back through `convertExpressionToPerl` recursively so prop
1257
- // refs / signal calls / member access in the args still get the
1258
- // standard transforms.
1259
- expr = this.rewriteTemplatePrimitives(expr)
1260
-
1261
- // Signal getter calls: count() → $count
1262
- let result = expr.replace(/\b([a-z_]\w*)\(\)/g, (_, name) => `$${name}`)
1263
-
1264
- // Props access: props.xxx → $xxx
1265
- result = result.replace(/\bprops\.(\w+)/g, (_, prop) => `$${prop}`)
1266
-
1267
- // Bare identifier property access: item.field → $item->{field}
1268
- // Must run before $-prefixed property access to catch bare identifiers
1269
- // Use negative lookbehind to skip $-prefixed variables (avoid $$var double-prefix)
1270
- result = result.replace(/(?<!\$)\b([a-z_]\w*)\.(\w+)/g, (match, obj, field) => {
1271
- if (match.startsWith('$')) return match
1272
- return `$${obj}->{${field}}`
1273
- })
1274
-
1275
- // $-prefixed property access: $item.field → $item->{field}
1276
- result = result.replace(/\$(\w+)\.(\w+)/g, (_, obj, field) => `$${obj}->{${field}}`)
1277
-
1278
- // Chained property access: $item->{field}.sub → $item->{field}->{sub}
1279
- result = result.replace(/\}->\{(\w+)\}\.(\w+)/g, (_, f1, f2) => `}->{${f1}}->{${f2}}`)
1280
-
1281
- // .length → scalar(@{...})
1282
- result = result.replace(/\$(\w+)->\{length\}/g, (_, arr) => `scalar(@{$${arr}})`)
1283
-
1284
- // Nullish coalescing: a ?? b → a // b (Perl defined-or)
1285
- result = result.replace(/\?\?/g, '//')
1286
-
1287
- // String comparison: expr === 'str' → expr eq 'str', expr !== 'str' → expr ne 'str'
1288
- result = result.replace(/\s*===\s*(['"])/g, ' eq $1')
1289
- result = result.replace(/\s*!==\s*(['"])/g, ' ne $1')
1290
- // Also handle: 'str' === expr
1291
- result = result.replace(/(['"])\s*===\s*/g, '$1 eq ')
1292
- result = result.replace(/(['"])\s*!==\s*/g, '$1 ne ')
1293
-
1294
- // Numeric comparison (remaining === / !==)
1295
- result = result.replace(/===/g, '==')
1296
- result = result.replace(/!==/g, '!=')
1297
-
1298
- // Logical not: !expr → !expr (works in Perl too)
1299
- // No conversion needed
1300
-
1301
- // Template literals: `str ${expr}` → "str $expr"
1302
- result = result.replace(/`([^`]*)`/g, (_, content) => {
1303
- const perlStr = content.replace(/\$\{([^}]+)\}/g, (_: string, e: string) => `${this.convertExpressionToPerl(e)}`)
1304
- return `"${perlStr}"`
1305
- })
1306
-
1307
- // Ensure top-level identifiers become variables
1308
- if (/^[a-z_]\w*$/i.test(result) && !result.startsWith('$')) {
1309
- result = `$${result}`
1310
- }
1311
-
1312
- return result
1228
+ return this.renderParsedExprToPerl(parsed)
1313
1229
  }
1314
- /**
1315
- * Walk the parsed AST of `expr` and substitute each registered
1316
- * primitive call (e.g. `JSON.stringify(props.config)`) with its
1317
- * Mojo helper-call equivalent (e.g. `bf->json($config)`). All
1318
- * other shapes round-trip back to source text via
1319
- * `stringifyParsedExpr`, so the result is still a JS-shaped
1320
- * string that the existing regex pipeline in
1321
- * `convertExpressionToPerl` can finish translating.
1322
- *
1323
- * Bails out (returns the input unchanged) when:
1324
- * - the expression doesn't parse cleanly,
1325
- * - no primitive call is found in the AST, or
1326
- * - a primitive's arity doesn't match the registered shape
1327
- * (BF101 is recorded so the user sees the diagnostic).
1328
- *
1329
- * Identifier-path-only matching (#1187 R1) — same constraint the
1330
- * Go adapter applies in #1188.
1331
- */
1332
- private rewriteTemplatePrimitives(expr: string): string {
1333
- // Common case: no registered primitive substring — skip the
1334
- // TS parser entirely. `parseExpression` invokes
1335
- // `ts.createSourceFile`, which is the dominant compile-hot-path
1336
- // cost added by this PR.
1337
- if (!PRIMITIVE_SUBSTRING_RE.test(expr)) return expr
1338
-
1339
- const parsed = parseExpression(expr)
1340
- if (parsed.kind === 'unsupported') return expr
1341
-
1342
- let mutated = false
1343
- const walk = (n: ParsedExpr): ParsedExpr => {
1344
- if (n.kind === 'call') {
1345
- const path = identifierPath(n.callee)
1346
- const spec = path ? MOJO_TEMPLATE_PRIMITIVES[path] : undefined
1347
- if (path && spec) {
1348
- if (n.args.length !== spec.arity) {
1349
- this.errors.push({
1350
- code: 'BF101',
1351
- severity: 'error',
1352
- message: `templatePrimitive '${path}' expects ${spec.arity} arg(s), got ${n.args.length}`,
1353
- loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1354
- suggestion: {
1355
- message: `Call '${path}' with exactly ${spec.arity} argument(s), or wrap the JSX expression in /* @client */ to defer evaluation.`,
1356
- },
1357
- })
1358
- return { kind: 'call', callee: walk(n.callee), args: n.args.map(walk) }
1359
- }
1360
- // Render each arg through the AST-aware sub-pipeline:
1361
- // walk for nested primitive substitution, then pass the
1362
- // resulting AST node directly to convertExpressionToPerl
1363
- // via stringification. The substring pre-check above
1364
- // guards against re-parsing strings that don't carry a
1365
- // primitive, so the recursive cost stays bounded.
1366
- const renderedArgs = n.args.map(a => this.convertExpressionToPerl(stringifyParsedExpr(walk(a))))
1367
- mutated = true
1368
- return { kind: 'identifier', name: spec.emit(renderedArgs) }
1369
- }
1370
- }
1371
- switch (n.kind) {
1372
- case 'call':
1373
- return { kind: 'call', callee: walk(n.callee), args: n.args.map(walk) }
1374
- case 'member':
1375
- return { kind: 'member', object: walk(n.object), property: n.property, computed: n.computed }
1376
- case 'binary':
1377
- return { kind: 'binary', op: n.op, left: walk(n.left), right: walk(n.right) }
1378
- case 'unary':
1379
- return { kind: 'unary', op: n.op, argument: walk(n.argument) }
1380
- case 'logical':
1381
- return { kind: 'logical', op: n.op, left: walk(n.left), right: walk(n.right) }
1382
- case 'conditional':
1383
- return { kind: 'conditional', test: walk(n.test), consequent: walk(n.consequent), alternate: walk(n.alternate) }
1384
- default:
1385
- return n
1386
- }
1387
- }
1388
1230
 
1389
- const transformed = walk(parsed)
1390
- if (!mutated) return expr
1391
- return stringifyParsedExpr(transformed)
1392
- }
1393
-
1394
- /**
1395
- * Convert expressions containing higher-order array methods to Perl.
1396
- * Parses the full expression as AST and renders recursively.
1397
- *
1398
- * Handles patterns like:
1399
- * - todos().filter(t => !t.done).length → scalar(grep { !$_->{done} } @{$todos})
1400
- * - todos().every(t => t.done) → !(grep { !$_->{done} } @{$todos})
1401
- * - todos().filter(t => t.done).length > 0 → scalar(grep { $_->{done} } @{$todos}) > 0
1402
- */
1403
- private convertHigherOrderExpr(expr: string): string {
1404
- if (this.higherOrderInFlight.has(expr)) {
1405
- this.errors.push({
1406
- code: 'BF101',
1407
- severity: 'error',
1408
- message: `Cannot lower higher-order chain to Embedded Perl: ${expr.trim()}`,
1409
- loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1410
- suggestion: {
1411
- message: "The Mojo adapter cannot lower this `.filter()` / `.every()` / `.some()` chain — typically because the array source is a JS array literal or a non-signal expression the AST classifier doesn't recognise. Move the expression into a `'use client'` component (so hydration computes it client-side), or rewrite it to operate on a signal getter or a prop directly.",
1412
- },
1413
- })
1414
- // Return a Perl empty-string literal — safe in every context the
1415
- // result might land in (`<%= '' %>`, `% if ('') {`, attribute
1416
- // interpolation, template-literal substitution). Returning a raw
1417
- // empty string here would produce `<%= %>`, which Embedded Perl
1418
- // rejects as a syntax error and would mask the BF101 diagnostic
1419
- // behind an opaque template-compilation failure.
1420
- return "''"
1421
- }
1422
- this.higherOrderInFlight.add(expr)
1423
- try {
1424
- const parsed = parseExpression(expr)
1425
- // Parity gate with the Go adapter's `convertExpressionToGo`: if the
1426
- // parsed expression isn't supported (e.g. `.reduce()` / `.forEach()`,
1427
- // destructured filter param, function-keyword callback) we cannot
1428
- // lower it to Embedded Perl. Emit BF101 and return a safe Perl
1429
- // empty-string literal so downstream concatenation doesn't blow up.
1430
- const support = isSupported(parsed)
1431
- if (!support.supported) {
1432
- this.errors.push({
1433
- code: 'BF101',
1434
- severity: 'error',
1435
- message: `Cannot lower higher-order chain to Embedded Perl: ${expr.trim()}`,
1436
- loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1437
- suggestion: {
1438
- message: support.reason
1439
- ? `${support.reason}\n\nOptions:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Perl`
1440
- : 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Perl',
1441
- },
1442
- })
1443
- return "''"
1444
- }
1445
- return this.renderParsedExprToPerl(parsed)
1446
- } finally {
1447
- this.higherOrderInFlight.delete(expr)
1448
- }
1449
- }
1450
1231
 
1451
1232
  /**
1452
1233
  * Render a full ParsedExpr tree to Perl for top-level (non-filter)
@@ -1458,9 +1239,29 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1458
1239
  return emitParsedExpr(expr, new MojoTopLevelEmitter(this))
1459
1240
  }
1460
1241
 
1461
- /** Internal hook exposed to the top-level emitter for unsupported nodes. */
1462
- _convertExpressionToPerlPublic(raw: string): string {
1463
- return this.convertExpressionToPerl(raw)
1242
+ /**
1243
+ * Hook for the ParsedExpr emitters to record a BF101 while walking
1244
+ * the AST — used for Mojo-specific gaps (`.find` / `.findIndex` have
1245
+ * no Embedded-Perl lowering) and templatePrimitive arity errors.
1246
+ */
1247
+ /** Whether `name` (a signal getter or prop) holds a string value, so an
1248
+ * equality comparison against it should use Perl `eq`/`ne` (#1672). */
1249
+ _isStringValueName(name: string): boolean {
1250
+ return this.stringValueNames.has(name)
1251
+ }
1252
+
1253
+ _recordExprBF101(message: string, reason?: string): void {
1254
+ this.errors.push({
1255
+ code: 'BF101',
1256
+ severity: 'error',
1257
+ message,
1258
+ loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1259
+ suggestion: {
1260
+ message: reason
1261
+ ? `${reason}\n\nOptions:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Perl`
1262
+ : 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Perl',
1263
+ },
1264
+ })
1464
1265
  }
1465
1266
 
1466
1267
  /** Internal hook for higher-order: predicate body re-uses the filter emitter. */
@@ -1644,6 +1445,38 @@ function renderSortMethod(recv: string, c: SortComparator): string {
1644
1445
  return `bf->sort(${recv}, { keys => [${keyHashes.join(', ')}] })`
1645
1446
  }
1646
1447
 
1448
+ /** True when `type` is the `string` primitive. */
1449
+ function isStringTypeInfo(type: TypeInfo | undefined): boolean {
1450
+ return type?.kind === 'primitive' && type.primitive === 'string'
1451
+ }
1452
+
1453
+ /** True when `initialValue` is a bare string-literal expression (`'x'` /
1454
+ * `"x"`), used as a fallback for signals whose type wasn't inferred. */
1455
+ function isBareStringLiteral(initialValue: string | undefined): boolean {
1456
+ if (!initialValue) return false
1457
+ const v = initialValue.trim()
1458
+ return (v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))
1459
+ }
1460
+
1461
+ /**
1462
+ * Whether a comparison operand is string-typed, so JS `===`/`!==` against it
1463
+ * must lower to Perl `eq`/`ne` instead of numeric `==`/`!=` (#1672). Covers a
1464
+ * string literal, a string-signal getter call (`sel()`), and a string prop
1465
+ * access (`props.x`). `isStringName` reports whether a getter/prop name is
1466
+ * known-string. Loop-element fields (`t.id`) on untyped arrays have no known
1467
+ * type and stay undetected — a separate, narrower gap.
1468
+ */
1469
+ function isStringTypedOperand(expr: ParsedExpr, isStringName: (n: string) => boolean): boolean {
1470
+ if (expr.kind === 'literal' && expr.literalType === 'string') return true
1471
+ if (expr.kind === 'call' && expr.callee.kind === 'identifier' && expr.args.length === 0) {
1472
+ return isStringName(expr.callee.name)
1473
+ }
1474
+ if (expr.kind === 'member' && expr.object.kind === 'identifier' && expr.object.name === 'props') {
1475
+ return isStringName(expr.property)
1476
+ }
1477
+ return false
1478
+ }
1479
+
1647
1480
  /**
1648
1481
  * Lowering for the predicate body of a filter / every / some / find,
1649
1482
  * plus the same shape used by `renderBlockBodyCondition` for complex
@@ -1661,6 +1494,10 @@ class MojoFilterEmitter implements ParsedExprEmitter {
1661
1494
  constructor(
1662
1495
  private readonly param: string,
1663
1496
  private readonly localVarMap: Map<string, string>,
1497
+ // Reports whether a getter/prop name is string-typed, so `===`/`!==`
1498
+ // against it lowers to `eq`/`ne` (#1672). Defaults to "never" for callers
1499
+ // that don't thread it through.
1500
+ private readonly isStringName: (n: string) => boolean = () => false,
1664
1501
  ) {}
1665
1502
 
1666
1503
  identifier(name: string): string {
@@ -1713,10 +1550,15 @@ class MojoFilterEmitter implements ParsedExprEmitter {
1713
1550
  binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
1714
1551
  const l = emit(left)
1715
1552
  const r = emit(right)
1716
- if ((op === '===' || op === '==') && right.kind === 'literal' && right.literalType === 'string') {
1553
+ // String equality: `eq`/`ne` when EITHER operand is string-typed a string
1554
+ // literal, a string signal getter, or a string prop. Numeric `==`/`!=`
1555
+ // would coerce both sides to 0 and match unrelated non-numeric strings (#1672).
1556
+ const isStr = (e: ParsedExpr) => isStringTypedOperand(e, this.isStringName)
1557
+ const stringCmp = isStr(left) || isStr(right)
1558
+ if ((op === '===' || op === '==') && stringCmp) {
1717
1559
  return `${l} eq ${r}`
1718
1560
  }
1719
- if ((op === '!==' || op === '!=') && right.kind === 'literal' && right.literalType === 'string') {
1561
+ if ((op === '!==' || op === '!=') && stringCmp) {
1720
1562
  return `${l} ne ${r}`
1721
1563
  }
1722
1564
  const opMap: Record<string, string> = {
@@ -1745,7 +1587,7 @@ class MojoFilterEmitter implements ParsedExprEmitter {
1745
1587
  // higher-order's own `param` (potentially shadowing the outer one),
1746
1588
  // so we spin up a nested emitter with the inner param.
1747
1589
  const arrayExpr = emit(object)
1748
- const predBody = emitParsedExpr(predicate, new MojoFilterEmitter(param, this.localVarMap))
1590
+ const predBody = emitParsedExpr(predicate, new MojoFilterEmitter(param, this.localVarMap, this.isStringName))
1749
1591
  const grepBody = predBody.replace(new RegExp(`\\$${param}\\b`, 'g'), '$_')
1750
1592
  if (method === 'filter') return `[grep { ${grepBody} } @{${arrayExpr}}]`
1751
1593
  if (method === 'every') return `!(grep { !(${grepBody}) } @{${arrayExpr}})`
@@ -1828,6 +1670,12 @@ class MojoTopLevelEmitter implements ParsedExprEmitter {
1828
1670
  }
1829
1671
 
1830
1672
  member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
1673
+ // `props.x` flattens to the bare `$x` the Mojo SSR caller binds each
1674
+ // prop to (props arrive as individual `my $x = ...` vars, not a
1675
+ // `$props` hashref).
1676
+ if (object.kind === 'identifier' && object.name === 'props') {
1677
+ return `$${property}`
1678
+ }
1831
1679
  const obj = emit(object)
1832
1680
  if (property === 'length') return `scalar(@{${obj}})`
1833
1681
  return `${obj}->{${property}}`
@@ -1838,6 +1686,28 @@ class MojoTopLevelEmitter implements ParsedExprEmitter {
1838
1686
  if (callee.kind === 'identifier' && args.length === 0) {
1839
1687
  return `$${callee.name}`
1840
1688
  }
1689
+ // Identifier-path templatePrimitive (#1189): `JSON.stringify(x)` /
1690
+ // `Math.floor(x)` → `bf->json($x)` / `bf->floor($x)`. Args render
1691
+ // recursively through this same emitter so prop refs / signal calls
1692
+ // inside them get the standard transforms. Mirrors the Go adapter's
1693
+ // `call()` primitive dispatch. A wrong-arity call records BF101 and
1694
+ // returns the safe `''` placeholder (never silently emits a bad call).
1695
+ const path = identifierPath(callee)
1696
+ const spec = path ? MOJO_TEMPLATE_PRIMITIVES[path] : undefined
1697
+ if (path && spec) {
1698
+ if (args.length === spec.arity) {
1699
+ return spec.emit(args.map(emit))
1700
+ }
1701
+ this.adapter._recordExprBF101(
1702
+ `templatePrimitive '${path}' expects ${spec.arity} arg(s), got ${args.length}`,
1703
+ `Call '${path}' with exactly ${spec.arity} argument(s).`,
1704
+ )
1705
+ // Don't fall through to the generic `emit(callee)` below — for a
1706
+ // member callee (`JSON.stringify`) that emits an invalid Perl
1707
+ // hash-deref (`$JSON->{stringify}`). Return the same safe
1708
+ // empty-string placeholder the other BF101 paths use.
1709
+ return "''"
1710
+ }
1841
1711
  // Array methods (`.join` and any others added to ArrayMethod, #1443)
1842
1712
  // are lifted into the `array-method` IR kind at parse time, so they
1843
1713
  // never reach this dispatcher. Per-method detection here would mix
@@ -1857,10 +1727,17 @@ class MojoTopLevelEmitter implements ParsedExprEmitter {
1857
1727
  binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
1858
1728
  const l = emit(left)
1859
1729
  const r = emit(right)
1860
- if ((op === '===' || op === '==') && right.kind === 'literal' && right.literalType === 'string') {
1730
+ // String equality: `eq`/`ne` when EITHER operand is string-typed a string
1731
+ // literal (`role() === 'admin'`), a string signal getter (`sel()`), or a
1732
+ // string prop (`props.x`). Falling back to numeric `==`/`!=` would make
1733
+ // Perl coerce both sides to 0 and match unrelated non-numeric strings
1734
+ // (`"b" == "a"` → true), so all loop items render their true branch (#1672).
1735
+ const isStr = (e: ParsedExpr) => isStringTypedOperand(e, n => this.adapter._isStringValueName(n))
1736
+ const stringCmp = isStr(left) || isStr(right)
1737
+ if ((op === '===' || op === '==') && stringCmp) {
1861
1738
  return `${l} eq ${r}`
1862
1739
  }
1863
- if ((op === '!==' || op === '!=') && right.kind === 'literal' && right.literalType === 'string') {
1740
+ if ((op === '!==' || op === '!=') && stringCmp) {
1864
1741
  return `${l} ne ${r}`
1865
1742
  }
1866
1743
  const opMap: Record<string, string> = {
@@ -1885,6 +1762,16 @@ class MojoTopLevelEmitter implements ParsedExprEmitter {
1885
1762
  predicate: ParsedExpr,
1886
1763
  emit: (e: ParsedExpr) => string,
1887
1764
  ): string {
1765
+ // Mojo-specific gap: `.find` / `.findIndex` / `.findLast` /
1766
+ // `.findLastIndex` have no Embedded-Perl lowering yet. `isSupported`
1767
+ // accepts them (it's adapter-agnostic), so the refusal lands here
1768
+ // rather than at the support gate. BF101 until a lowering lands.
1769
+ if (method === 'find' || method === 'findIndex' || method === 'findLast' || method === 'findLastIndex') {
1770
+ this.adapter._recordExprBF101(
1771
+ `Mojo adapter has not lowered Array.prototype.${method} yet`,
1772
+ )
1773
+ return "''"
1774
+ }
1888
1775
  const arrayExpr = emit(object)
1889
1776
  const predBody = this.adapter._renderPerlFilterExprPublic(predicate, param)
1890
1777
  const grepBody = predBody.replace(new RegExp(`\\$${param}\\b`, 'g'), '$_')
@@ -1930,21 +1817,53 @@ class MojoTopLevelEmitter implements ParsedExprEmitter {
1930
1817
  return `(${emit(test)} ? ${emit(consequent)} : ${emit(alternate)})`
1931
1818
  }
1932
1819
 
1933
- templateLiteral(_parts: TemplatePart[]): string {
1934
- // Template literals don't appear at top level inside Mojo expressions
1935
- // they're handled by `convertTemplateLiteralPartsToPerl` at the
1936
- // attribute / interpolation layer, not the expression dispatcher.
1937
- return ''
1820
+ templateLiteral(parts: TemplatePart[], emit: (e: ParsedExpr) => string): string {
1821
+ // `` `n=${count() + 1}` `` Perl string concatenation
1822
+ // (`"n=" . ($count + 1)`), NOT double-quote interpolation. Perl only
1823
+ // interpolates simple `$var` reads inside `"..."`, so complex `${...}`
1824
+ // parts — arithmetic, helper calls (`bf->json(...)`), ternaries —
1825
+ // would render unevaluated if inlined into a quoted string.
1826
+ // - Static chunks are emitted as quoted literals with the sigils
1827
+ // that interpolate inside `"..."` (`$`/`@`) plus `"`/`\` escaped,
1828
+ // so literal text survives verbatim.
1829
+ // - Expression terms whose Perl precedence is below `.` (binary /
1830
+ // logical / conditional) wrap in parens so they bind before the
1831
+ // concatenation.
1832
+ const terms: string[] = []
1833
+ for (const part of parts) {
1834
+ if (part.type === 'string') {
1835
+ if (part.value !== '') {
1836
+ terms.push(`"${part.value.replace(/[\\"$@]/g, m => `\\${m}`)}"`)
1837
+ }
1838
+ } else {
1839
+ const rendered = emit(part.expr)
1840
+ const needsParens =
1841
+ part.expr.kind === 'binary' ||
1842
+ part.expr.kind === 'logical' ||
1843
+ part.expr.kind === 'conditional'
1844
+ terms.push(needsParens ? `(${rendered})` : rendered)
1845
+ }
1846
+ }
1847
+ if (terms.length === 0) return '""'
1848
+ return terms.join(' . ')
1938
1849
  }
1939
1850
 
1940
1851
  arrowFn(_param: string, _body: ParsedExpr): string {
1941
- return ''
1852
+ // A bare arrow function never stands alone at a render position (it's
1853
+ // only meaningful as a higher-order predicate, handled above). Return
1854
+ // the safe Perl empty-string literal `''` — consistent with the BF101
1855
+ // / `unsupported` paths — so a stray emit can't produce a `<%= %>`
1856
+ // syntax error.
1857
+ return "''"
1942
1858
  }
1943
1859
 
1944
- unsupported(raw: string, _reason: string): string {
1945
- // Legacy fallback: the regex pipeline handles shapes the AST can't
1946
- // classify (mostly hand-written JS that pre-dates the parser).
1947
- return this.adapter._convertExpressionToPerlPublic(raw)
1860
+ unsupported(_raw: string, _reason: string): string {
1861
+ // Unreachable in the parse-first flow: `convertExpressionToPerl`
1862
+ // gates on `isSupported` before dispatching, and `isSupported`
1863
+ // recurses, so a top-level supported expression never contains an
1864
+ // `unsupported` node. Return a safe Perl empty-string literal in
1865
+ // case a future caller renders a node tree directly.
1866
+ return "''"
1948
1867
  }
1949
1868
  }
1950
1869