@barefootjs/mojolicious 0.5.1 → 0.5.3

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
@@ -991,7 +982,7 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
991
982
  // those would yield runtime errors in Perl, which is the user's
992
983
  // signal to refactor. Wholesale refusal would also block the
993
984
  // canonical case the issue exists to enable.
994
- return emitParsedExpr(expr, new MojoFilterEmitter(param, localVarMap))
985
+ return emitParsedExpr(expr, new MojoFilterEmitter(param, localVarMap, n => this._isStringValueName(n)))
995
986
  }
996
987
 
997
988
  /**
@@ -1202,261 +1193,41 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1202
1193
 
1203
1194
 
1204
1195
  private convertExpressionToPerl(expr: string): string {
1205
- // Handle higher-order array methods via ParsedExpr AST.
1206
- // `filter|every|some` lower to Embedded Perl (grep). The rest
1207
- // (`reduce|reduceRight|forEach|flatMap|flat`) can't lower to EP
1208
- // at all route them through the same AST path so
1209
- // `convertHigherOrderExpr`'s `isSupported` gate emits BF101
1210
- // instead of falling into the regex pipeline that mangles
1211
- // `$items->{reduce}->{...}` etc.
1212
- // `findLast|findLastIndex` are caught by the explicit Mojo-gap
1213
- // refusal below (alongside `find|findIndex`).
1214
- if (/\.\s*(?:filter|every|some|reduce|reduceRight|forEach|flatMap|flat)\s*\(/.test(expr)) {
1215
- return this.convertHigherOrderExpr(expr)
1216
- }
1217
-
1218
- // #1448 Tier A — JS Array / String methods that the regex
1219
- // pipeline silently mangles into `${obj}->{<method>}(...)` hash
1220
- // lookups that fail at render time. Route them through the same
1221
- // AST path so `isSupported`'s `UNSUPPORTED_METHODS` gate fires
1222
- // BF101 with the offending expression, matching Go's behaviour.
1223
- // Each method name drops off the regex as its lowering lands
1224
- // (the regex stays in sync with `UNSUPPORTED_METHODS` —
1225
- // `convertHigherOrderExpr` intercepts via `isSupported`).
1226
- if (/\.\s*(?:includes|indexOf|lastIndexOf|at|concat|slice|reverse|toReversed|toLowerCase|toUpperCase|trim)\s*\(/.test(expr)) {
1227
- return this.convertHigherOrderExpr(expr)
1228
- }
1229
-
1230
- // #1443/#1448: `.join(sep)` is lifted by the parser to the
1231
- // `array-method` IR kind, and `renderArrayMethod`'s `case 'join'`
1232
- // already emits the correct `join(sep, @{arr})`. Route the
1233
- // text-expression form through the same AST path so the
1234
- // regex pipeline below doesn't mangle it into a
1235
- // `${arr}->{join}(sep)` Perl hash-lookup that errors at render.
1236
- if (/\.\s*join\s*\(/.test(expr)) {
1237
- return this.convertHigherOrderExpr(expr)
1238
- }
1239
-
1240
- // #1448 catalog — Mojo-specific gap: `.find` / `.findIndex` /
1241
- // `.findLast` / `.findLastIndex` have no AST lowering yet (no
1242
- // `array-method` IR variant, no emitter), and the regex pipeline
1243
- // silently mangles them into `${obj}->{find}(...)` hash lookups.
1244
- // Emit BF101 here until either a parser-level `array-method`
1245
- // extension or a `convertHigherOrderExpr` carve-out lands.
1246
- const mojoOnlyMatch = /\.\s*(?<method>find|findIndex|findLast|findLastIndex)\s*\(/.exec(expr)
1247
- if (mojoOnlyMatch) {
1248
- 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) {
1249
1211
  this.errors.push({
1250
1212
  code: 'BF101',
1251
1213
  severity: 'error',
1252
- message: `Mojo adapter has not lowered Array.prototype.${methodName} yet: ${expr.trim()}`,
1214
+ message: `Expression not supported: ${trimmed}`,
1253
1215
  loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1254
1216
  suggestion: {
1255
- 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',
1256
1220
  },
1257
1221
  })
1222
+ // Safe Perl empty-string literal — valid in every context the
1223
+ // result might land in (`<%= '' %>`, `% if ('') {`, attribute
1224
+ // interpolation, template-literal substitution).
1258
1225
  return "''"
1259
1226
  }
1260
1227
 
1261
- // templatePrimitives substitution (#1189): rewrite identifier-path
1262
- // calls like `JSON.stringify(props.config)` / `Math.floor(x)` to
1263
- // their Mojo helper-call form (`bf->json($config)` etc.) BEFORE
1264
- // the regex pipeline below runs. Using the AST avoids fighting
1265
- // the existing regex transforms — a registered call's args go
1266
- // back through `convertExpressionToPerl` recursively so prop
1267
- // refs / signal calls / member access in the args still get the
1268
- // standard transforms.
1269
- expr = this.rewriteTemplatePrimitives(expr)
1270
-
1271
- // Signal getter calls: count() → $count
1272
- let result = expr.replace(/\b([a-z_]\w*)\(\)/g, (_, name) => `$${name}`)
1273
-
1274
- // Props access: props.xxx → $xxx
1275
- result = result.replace(/\bprops\.(\w+)/g, (_, prop) => `$${prop}`)
1276
-
1277
- // Bare identifier property access: item.field → $item->{field}
1278
- // Must run before $-prefixed property access to catch bare identifiers
1279
- // Use negative lookbehind to skip $-prefixed variables (avoid $$var double-prefix)
1280
- result = result.replace(/(?<!\$)\b([a-z_]\w*)\.(\w+)/g, (match, obj, field) => {
1281
- if (match.startsWith('$')) return match
1282
- return `$${obj}->{${field}}`
1283
- })
1284
-
1285
- // $-prefixed property access: $item.field → $item->{field}
1286
- result = result.replace(/\$(\w+)\.(\w+)/g, (_, obj, field) => `$${obj}->{${field}}`)
1287
-
1288
- // Chained property access: $item->{field}.sub → $item->{field}->{sub}
1289
- result = result.replace(/\}->\{(\w+)\}\.(\w+)/g, (_, f1, f2) => `}->{${f1}}->{${f2}}`)
1290
-
1291
- // .length → scalar(@{...})
1292
- result = result.replace(/\$(\w+)->\{length\}/g, (_, arr) => `scalar(@{$${arr}})`)
1293
-
1294
- // Nullish coalescing: a ?? b → a // b (Perl defined-or)
1295
- result = result.replace(/\?\?/g, '//')
1296
-
1297
- // String comparison: expr === 'str' → expr eq 'str', expr !== 'str' → expr ne 'str'
1298
- result = result.replace(/\s*===\s*(['"])/g, ' eq $1')
1299
- result = result.replace(/\s*!==\s*(['"])/g, ' ne $1')
1300
- // Also handle: 'str' === expr
1301
- result = result.replace(/(['"])\s*===\s*/g, '$1 eq ')
1302
- result = result.replace(/(['"])\s*!==\s*/g, '$1 ne ')
1303
-
1304
- // Numeric comparison (remaining === / !==)
1305
- result = result.replace(/===/g, '==')
1306
- result = result.replace(/!==/g, '!=')
1307
-
1308
- // Logical not: !expr → !expr (works in Perl too)
1309
- // No conversion needed
1310
-
1311
- // Template literals: `str ${expr}` → "str $expr"
1312
- result = result.replace(/`([^`]*)`/g, (_, content) => {
1313
- const perlStr = content.replace(/\$\{([^}]+)\}/g, (_: string, e: string) => `${this.convertExpressionToPerl(e)}`)
1314
- return `"${perlStr}"`
1315
- })
1316
-
1317
- // Ensure top-level identifiers become variables
1318
- if (/^[a-z_]\w*$/i.test(result) && !result.startsWith('$')) {
1319
- result = `$${result}`
1320
- }
1321
-
1322
- return result
1228
+ return this.renderParsedExprToPerl(parsed)
1323
1229
  }
1324
- /**
1325
- * Walk the parsed AST of `expr` and substitute each registered
1326
- * primitive call (e.g. `JSON.stringify(props.config)`) with its
1327
- * Mojo helper-call equivalent (e.g. `bf->json($config)`). All
1328
- * other shapes round-trip back to source text via
1329
- * `stringifyParsedExpr`, so the result is still a JS-shaped
1330
- * string that the existing regex pipeline in
1331
- * `convertExpressionToPerl` can finish translating.
1332
- *
1333
- * Bails out (returns the input unchanged) when:
1334
- * - the expression doesn't parse cleanly,
1335
- * - no primitive call is found in the AST, or
1336
- * - a primitive's arity doesn't match the registered shape
1337
- * (BF101 is recorded so the user sees the diagnostic).
1338
- *
1339
- * Identifier-path-only matching (#1187 R1) — same constraint the
1340
- * Go adapter applies in #1188.
1341
- */
1342
- private rewriteTemplatePrimitives(expr: string): string {
1343
- // Common case: no registered primitive substring — skip the
1344
- // TS parser entirely. `parseExpression` invokes
1345
- // `ts.createSourceFile`, which is the dominant compile-hot-path
1346
- // cost added by this PR.
1347
- if (!PRIMITIVE_SUBSTRING_RE.test(expr)) return expr
1348
-
1349
- const parsed = parseExpression(expr)
1350
- if (parsed.kind === 'unsupported') return expr
1351
-
1352
- let mutated = false
1353
- const walk = (n: ParsedExpr): ParsedExpr => {
1354
- if (n.kind === 'call') {
1355
- const path = identifierPath(n.callee)
1356
- const spec = path ? MOJO_TEMPLATE_PRIMITIVES[path] : undefined
1357
- if (path && spec) {
1358
- if (n.args.length !== spec.arity) {
1359
- this.errors.push({
1360
- code: 'BF101',
1361
- severity: 'error',
1362
- message: `templatePrimitive '${path}' expects ${spec.arity} arg(s), got ${n.args.length}`,
1363
- loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1364
- suggestion: {
1365
- message: `Call '${path}' with exactly ${spec.arity} argument(s), or wrap the JSX expression in /* @client */ to defer evaluation.`,
1366
- },
1367
- })
1368
- return { kind: 'call', callee: walk(n.callee), args: n.args.map(walk) }
1369
- }
1370
- // Render each arg through the AST-aware sub-pipeline:
1371
- // walk for nested primitive substitution, then pass the
1372
- // resulting AST node directly to convertExpressionToPerl
1373
- // via stringification. The substring pre-check above
1374
- // guards against re-parsing strings that don't carry a
1375
- // primitive, so the recursive cost stays bounded.
1376
- const renderedArgs = n.args.map(a => this.convertExpressionToPerl(stringifyParsedExpr(walk(a))))
1377
- mutated = true
1378
- return { kind: 'identifier', name: spec.emit(renderedArgs) }
1379
- }
1380
- }
1381
- switch (n.kind) {
1382
- case 'call':
1383
- return { kind: 'call', callee: walk(n.callee), args: n.args.map(walk) }
1384
- case 'member':
1385
- return { kind: 'member', object: walk(n.object), property: n.property, computed: n.computed }
1386
- case 'binary':
1387
- return { kind: 'binary', op: n.op, left: walk(n.left), right: walk(n.right) }
1388
- case 'unary':
1389
- return { kind: 'unary', op: n.op, argument: walk(n.argument) }
1390
- case 'logical':
1391
- return { kind: 'logical', op: n.op, left: walk(n.left), right: walk(n.right) }
1392
- case 'conditional':
1393
- return { kind: 'conditional', test: walk(n.test), consequent: walk(n.consequent), alternate: walk(n.alternate) }
1394
- default:
1395
- return n
1396
- }
1397
- }
1398
1230
 
1399
- const transformed = walk(parsed)
1400
- if (!mutated) return expr
1401
- return stringifyParsedExpr(transformed)
1402
- }
1403
-
1404
- /**
1405
- * Convert expressions containing higher-order array methods to Perl.
1406
- * Parses the full expression as AST and renders recursively.
1407
- *
1408
- * Handles patterns like:
1409
- * - todos().filter(t => !t.done).length → scalar(grep { !$_->{done} } @{$todos})
1410
- * - todos().every(t => t.done) → !(grep { !$_->{done} } @{$todos})
1411
- * - todos().filter(t => t.done).length > 0 → scalar(grep { $_->{done} } @{$todos}) > 0
1412
- */
1413
- private convertHigherOrderExpr(expr: string): string {
1414
- if (this.higherOrderInFlight.has(expr)) {
1415
- this.errors.push({
1416
- code: 'BF101',
1417
- severity: 'error',
1418
- message: `Cannot lower higher-order chain to Embedded Perl: ${expr.trim()}`,
1419
- loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1420
- suggestion: {
1421
- 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.",
1422
- },
1423
- })
1424
- // Return a Perl empty-string literal — safe in every context the
1425
- // result might land in (`<%= '' %>`, `% if ('') {`, attribute
1426
- // interpolation, template-literal substitution). Returning a raw
1427
- // empty string here would produce `<%= %>`, which Embedded Perl
1428
- // rejects as a syntax error and would mask the BF101 diagnostic
1429
- // behind an opaque template-compilation failure.
1430
- return "''"
1431
- }
1432
- this.higherOrderInFlight.add(expr)
1433
- try {
1434
- const parsed = parseExpression(expr)
1435
- // Parity gate with the Go adapter's `convertExpressionToGo`: if the
1436
- // parsed expression isn't supported (e.g. `.reduce()` / `.forEach()`,
1437
- // destructured filter param, function-keyword callback) we cannot
1438
- // lower it to Embedded Perl. Emit BF101 and return a safe Perl
1439
- // empty-string literal so downstream concatenation doesn't blow up.
1440
- const support = isSupported(parsed)
1441
- if (!support.supported) {
1442
- this.errors.push({
1443
- code: 'BF101',
1444
- severity: 'error',
1445
- message: `Cannot lower higher-order chain to Embedded Perl: ${expr.trim()}`,
1446
- loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
1447
- suggestion: {
1448
- message: support.reason
1449
- ? `${support.reason}\n\nOptions:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Perl`
1450
- : 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in Perl',
1451
- },
1452
- })
1453
- return "''"
1454
- }
1455
- return this.renderParsedExprToPerl(parsed)
1456
- } finally {
1457
- this.higherOrderInFlight.delete(expr)
1458
- }
1459
- }
1460
1231
 
1461
1232
  /**
1462
1233
  * Render a full ParsedExpr tree to Perl for top-level (non-filter)
@@ -1468,9 +1239,29 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1468
1239
  return emitParsedExpr(expr, new MojoTopLevelEmitter(this))
1469
1240
  }
1470
1241
 
1471
- /** Internal hook exposed to the top-level emitter for unsupported nodes. */
1472
- _convertExpressionToPerlPublic(raw: string): string {
1473
- 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
+ })
1474
1265
  }
1475
1266
 
1476
1267
  /** Internal hook for higher-order: predicate body re-uses the filter emitter. */
@@ -1654,6 +1445,38 @@ function renderSortMethod(recv: string, c: SortComparator): string {
1654
1445
  return `bf->sort(${recv}, { keys => [${keyHashes.join(', ')}] })`
1655
1446
  }
1656
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
+
1657
1480
  /**
1658
1481
  * Lowering for the predicate body of a filter / every / some / find,
1659
1482
  * plus the same shape used by `renderBlockBodyCondition` for complex
@@ -1671,6 +1494,10 @@ class MojoFilterEmitter implements ParsedExprEmitter {
1671
1494
  constructor(
1672
1495
  private readonly param: string,
1673
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,
1674
1501
  ) {}
1675
1502
 
1676
1503
  identifier(name: string): string {
@@ -1723,10 +1550,15 @@ class MojoFilterEmitter implements ParsedExprEmitter {
1723
1550
  binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
1724
1551
  const l = emit(left)
1725
1552
  const r = emit(right)
1726
- 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) {
1727
1559
  return `${l} eq ${r}`
1728
1560
  }
1729
- if ((op === '!==' || op === '!=') && right.kind === 'literal' && right.literalType === 'string') {
1561
+ if ((op === '!==' || op === '!=') && stringCmp) {
1730
1562
  return `${l} ne ${r}`
1731
1563
  }
1732
1564
  const opMap: Record<string, string> = {
@@ -1755,7 +1587,7 @@ class MojoFilterEmitter implements ParsedExprEmitter {
1755
1587
  // higher-order's own `param` (potentially shadowing the outer one),
1756
1588
  // so we spin up a nested emitter with the inner param.
1757
1589
  const arrayExpr = emit(object)
1758
- const predBody = emitParsedExpr(predicate, new MojoFilterEmitter(param, this.localVarMap))
1590
+ const predBody = emitParsedExpr(predicate, new MojoFilterEmitter(param, this.localVarMap, this.isStringName))
1759
1591
  const grepBody = predBody.replace(new RegExp(`\\$${param}\\b`, 'g'), '$_')
1760
1592
  if (method === 'filter') return `[grep { ${grepBody} } @{${arrayExpr}}]`
1761
1593
  if (method === 'every') return `!(grep { !(${grepBody}) } @{${arrayExpr}})`
@@ -1838,6 +1670,12 @@ class MojoTopLevelEmitter implements ParsedExprEmitter {
1838
1670
  }
1839
1671
 
1840
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
+ }
1841
1679
  const obj = emit(object)
1842
1680
  if (property === 'length') return `scalar(@{${obj}})`
1843
1681
  return `${obj}->{${property}}`
@@ -1848,6 +1686,28 @@ class MojoTopLevelEmitter implements ParsedExprEmitter {
1848
1686
  if (callee.kind === 'identifier' && args.length === 0) {
1849
1687
  return `$${callee.name}`
1850
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
+ }
1851
1711
  // Array methods (`.join` and any others added to ArrayMethod, #1443)
1852
1712
  // are lifted into the `array-method` IR kind at parse time, so they
1853
1713
  // never reach this dispatcher. Per-method detection here would mix
@@ -1867,10 +1727,17 @@ class MojoTopLevelEmitter implements ParsedExprEmitter {
1867
1727
  binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string {
1868
1728
  const l = emit(left)
1869
1729
  const r = emit(right)
1870
- 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) {
1871
1738
  return `${l} eq ${r}`
1872
1739
  }
1873
- if ((op === '!==' || op === '!=') && right.kind === 'literal' && right.literalType === 'string') {
1740
+ if ((op === '!==' || op === '!=') && stringCmp) {
1874
1741
  return `${l} ne ${r}`
1875
1742
  }
1876
1743
  const opMap: Record<string, string> = {
@@ -1895,6 +1762,16 @@ class MojoTopLevelEmitter implements ParsedExprEmitter {
1895
1762
  predicate: ParsedExpr,
1896
1763
  emit: (e: ParsedExpr) => string,
1897
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
+ }
1898
1775
  const arrayExpr = emit(object)
1899
1776
  const predBody = this.adapter._renderPerlFilterExprPublic(predicate, param)
1900
1777
  const grepBody = predBody.replace(new RegExp(`\\$${param}\\b`, 'g'), '$_')
@@ -1940,21 +1817,53 @@ class MojoTopLevelEmitter implements ParsedExprEmitter {
1940
1817
  return `(${emit(test)} ? ${emit(consequent)} : ${emit(alternate)})`
1941
1818
  }
1942
1819
 
1943
- templateLiteral(_parts: TemplatePart[]): string {
1944
- // Template literals don't appear at top level inside Mojo expressions
1945
- // they're handled by `convertTemplateLiteralPartsToPerl` at the
1946
- // attribute / interpolation layer, not the expression dispatcher.
1947
- 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(' . ')
1948
1849
  }
1949
1850
 
1950
1851
  arrowFn(_param: string, _body: ParsedExpr): string {
1951
- 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 "''"
1952
1858
  }
1953
1859
 
1954
- unsupported(raw: string, _reason: string): string {
1955
- // Legacy fallback: the regex pipeline handles shapes the AST can't
1956
- // classify (mostly hand-written JS that pre-dates the parser).
1957
- 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 "''"
1958
1867
  }
1959
1868
  }
1960
1869