@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.
- package/dist/adapter/index.js +109 -145
- package/dist/adapter/mojo-adapter.d.ts +16 -46
- package/dist/adapter/mojo-adapter.d.ts.map +1 -1
- package/dist/build.js +109 -145
- package/dist/index.js +109 -145
- 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 +292 -0
- package/src/adapter/mojo-adapter.ts +213 -294
- 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
|
|
@@ -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
|
|
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
|
-
//
|
|
1196
|
-
// `
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1199
|
-
// `
|
|
1200
|
-
//
|
|
1201
|
-
//
|
|
1202
|
-
//
|
|
1203
|
-
//
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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: `
|
|
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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
1462
|
-
|
|
1463
|
-
|
|
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
|
-
|
|
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 === '!=') &&
|
|
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
|
-
|
|
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 === '!=') &&
|
|
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(
|
|
1934
|
-
//
|
|
1935
|
-
//
|
|
1936
|
-
//
|
|
1937
|
-
|
|
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
|
-
|
|
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(
|
|
1945
|
-
//
|
|
1946
|
-
//
|
|
1947
|
-
|
|
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
|
|