@barefootjs/mojolicious 0.5.1 → 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.
- package/dist/adapter/index.js +106 -144
- package/dist/adapter/mojo-adapter.d.ts +16 -46
- package/dist/adapter/mojo-adapter.d.ts.map +1 -1
- package/dist/build.js +106 -144
- package/dist/index.js +106 -144
- package/dist/test-render.d.ts +5 -0
- package/dist/test-render.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/evaluate-signal-init.test.ts +35 -0
- package/src/__tests__/mojo-adapter.test.ts +189 -72
- package/src/adapter/mojo-adapter.ts +202 -293
- package/src/test-render.ts +62 -37
|
@@ -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
|
-
//
|
|
1206
|
-
// `
|
|
1207
|
-
//
|
|
1208
|
-
//
|
|
1209
|
-
// `
|
|
1210
|
-
//
|
|
1211
|
-
//
|
|
1212
|
-
//
|
|
1213
|
-
//
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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: `
|
|
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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
1472
|
-
|
|
1473
|
-
|
|
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
|
-
|
|
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 === '!=') &&
|
|
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
|
-
|
|
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 === '!=') &&
|
|
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(
|
|
1944
|
-
//
|
|
1945
|
-
//
|
|
1946
|
-
//
|
|
1947
|
-
|
|
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
|
-
|
|
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(
|
|
1955
|
-
//
|
|
1956
|
-
//
|
|
1957
|
-
|
|
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
|
|