@pyreon/compiler 0.12.5 → 0.12.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/compiler",
3
- "version": "0.12.5",
3
+ "version": "0.12.7",
4
4
  "description": "Template and JSX compiler for Pyreon",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/compiler#readme",
6
6
  "bugs": {
package/src/jsx.ts CHANGED
@@ -138,7 +138,8 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
138
138
  function wrap(expr: ts.Expression): void {
139
139
  const start = expr.getStart(sf)
140
140
  const end = expr.getEnd()
141
- replacements.push({ start, end, text: `() => ${code.slice(start, end)}` })
141
+ const exprText = inlineVarsInText(code.slice(start, end))
142
+ replacements.push({ start, end, text: `() => ${exprText}` })
142
143
  }
143
144
 
144
145
  /** Try to hoist or wrap an expression, pushing a replacement if needed. */
@@ -227,7 +228,7 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
227
228
  } else if (shouldWrap(expr)) {
228
229
  const start = expr.getStart(sf)
229
230
  const end = expr.getEnd()
230
- replacements.push({ start, end, text: `_rp(() => ${code.slice(start, end)})` })
231
+ replacements.push({ start, end, text: `_rp(() => ${inlineVarsInText(code.slice(start, end))})` })
231
232
  needsRpImport = true
232
233
  }
233
234
  } else {
@@ -255,6 +256,223 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
255
256
  ts.forEachChild(expr, walk)
256
257
  }
257
258
 
259
+ // ── Prop-derived variable tracking ─────────────────────────────────────────
260
+ // Pre-pass: find variables derived from props/splitProps results inside
261
+ // component functions. These are inlined at JSX use sites so the compiler's
262
+ // existing wrapping makes them reactive.
263
+ //
264
+ // Example:
265
+ // const align = props.alignX ?? 'left'
266
+ // return <div class={align}> ← inlined to: class={props.alignX ?? 'left'}
267
+ // ← compiler wraps: class={() => props.alignX ?? 'left'}
268
+
269
+ /** Names that refer to the props object or splitProps results. */
270
+ const propsNames = new Set<string>()
271
+
272
+ /** Map of variable name → source text of the original expression. */
273
+ const propDerivedVars = new Map<string, string>()
274
+
275
+ /** Check if an expression reads from a tracked props-like object. */
276
+ function readsFromProps(node: ts.Node): boolean {
277
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
278
+ return propsNames.has(node.expression.text)
279
+ }
280
+ if (ts.isElementAccessExpression(node) && ts.isIdentifier(node.expression)) {
281
+ return propsNames.has(node.expression.text)
282
+ }
283
+ // Check children recursively — e.g. props.x ?? 'default'
284
+ let found = false
285
+ ts.forEachChild(node, (child) => {
286
+ if (found) return
287
+ if (readsFromProps(child)) found = true
288
+ })
289
+ return found
290
+ }
291
+
292
+ /** Pre-pass: scan a function body for prop-derived variable declarations.
293
+ * callbackDepth tracks nesting inside callback arguments (map, filter, etc.)
294
+ * to avoid tracking variables declared inside callbacks as prop-derived. */
295
+ let _callbackDepth = 0
296
+ function scanForPropDerivedVars(node: ts.Node): void {
297
+ // Track callback nesting — don't track vars inside callbacks
298
+ if ((ts.isArrowFunction(node) || ts.isFunctionExpression(node))) {
299
+ const parent = node.parent
300
+ if (parent && ts.isCallExpression(parent) && parent.arguments.includes(node as any)) {
301
+ _callbackDepth++
302
+ ts.forEachChild(node, scanForPropDerivedVars)
303
+ _callbackDepth--
304
+ return
305
+ }
306
+ }
307
+ // Track the function's first parameter as a props name.
308
+ // Only for COMPONENT functions — not callbacks like .map(item => <div>...)
309
+ // Heuristic: component functions are named declarations, const assignments,
310
+ // or export defaults — NOT inline arguments to calls like .map(), .filter().
311
+ if ((ts.isFunctionDeclaration(node) || ts.isArrowFunction(node) || ts.isFunctionExpression(node))
312
+ && node.parameters.length > 0) {
313
+
314
+ // Skip functions that are arguments to a call (map/filter callbacks)
315
+ const parent = node.parent
316
+ if (parent && ts.isCallExpression(parent) && parent.arguments.includes(node as any)) {
317
+ ts.forEachChild(node, scanForPropDerivedVars)
318
+ return
319
+ }
320
+
321
+ const firstParam = node.parameters[0]!
322
+ if (ts.isIdentifier(firstParam.name)) {
323
+ // Check if this function returns JSX (is a component)
324
+ let hasJSX = false
325
+ ts.forEachChild(node, function checkJSX(n) {
326
+ if (hasJSX) return
327
+ if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) {
328
+ hasJSX = true
329
+ return
330
+ }
331
+ ts.forEachChild(n, checkJSX)
332
+ })
333
+ if (hasJSX) propsNames.add(firstParam.name.text)
334
+ }
335
+ }
336
+
337
+ // Track splitProps results: const [own, rest] = splitProps(props, [...])
338
+ if (ts.isVariableStatement(node)) {
339
+ for (const decl of node.declarationList.declarations) {
340
+ if (ts.isArrayBindingPattern(decl.name) && decl.initializer
341
+ && ts.isCallExpression(decl.initializer)) {
342
+ const callee = decl.initializer.expression
343
+ if (ts.isIdentifier(callee) && callee.text === 'splitProps') {
344
+ for (const el of decl.name.elements) {
345
+ if (ts.isBindingElement(el) && ts.isIdentifier(el.name)) {
346
+ propsNames.add(el.name.text)
347
+ }
348
+ }
349
+ }
350
+ }
351
+
352
+ // Track: const x = props.y ?? z OR const x = own.y
353
+ // Skip let/var — mutable variables can be reassigned, unsafe to inline
354
+ // Skip declarations inside callbacks (map, filter, etc.)
355
+ if (!(node.declarationList.flags & ts.NodeFlags.Const)) continue
356
+ if (_callbackDepth > 0) continue
357
+ if (ts.isIdentifier(decl.name) && decl.initializer) {
358
+ if (readsFromProps(decl.initializer)) {
359
+ const varName = decl.name.text
360
+ const exprText = code.slice(decl.initializer.getStart(sf), decl.initializer.getEnd())
361
+ propDerivedVars.set(varName, exprText)
362
+ }
363
+ }
364
+ }
365
+ }
366
+
367
+ ts.forEachChild(node, scanForPropDerivedVars)
368
+ }
369
+
370
+ // Run pre-pass
371
+ scanForPropDerivedVars(sf)
372
+
373
+ // Transitive resolution: if const b = a + 1 where a is prop-derived,
374
+ // then b is also prop-derived. Inline b → (a + 1) → ((props.x) + 1).
375
+ // Fixed-point iteration until no new variables are added.
376
+ let changed = true
377
+ while (changed) {
378
+ changed = false
379
+ sf.forEachChild(function scanTransitive(node) {
380
+ if (!ts.isVariableStatement(node)) { ts.forEachChild(node, scanTransitive); return }
381
+ for (const decl of node.declarationList.declarations) {
382
+ if (!ts.isIdentifier(decl.name) || !decl.initializer) continue
383
+ const varName = decl.name.text
384
+ if (propDerivedVars.has(varName)) continue // already tracked
385
+ if (node.declarationList.flags & ts.NodeFlags.Let) continue // skip let
386
+ // Check if the initializer references any existing prop-derived var
387
+ let usesPropVar = false
388
+ ts.forEachChild(decl.initializer, function check(n) {
389
+ if (usesPropVar) return
390
+ if (ts.isIdentifier(n) && propDerivedVars.has(n.text)) {
391
+ const parent = n.parent
392
+ if (parent && ts.isPropertyAccessExpression(parent) && parent.name === n) return
393
+ usesPropVar = true
394
+ }
395
+ ts.forEachChild(n, check)
396
+ })
397
+ if (usesPropVar) {
398
+ const exprText = code.slice(decl.initializer.getStart(sf), decl.initializer.getEnd())
399
+ propDerivedVars.set(varName, exprText)
400
+ changed = true
401
+ }
402
+ }
403
+ })
404
+ }
405
+
406
+ // Resolve transitive inlining: if b's expr references a, replace a with its expr
407
+ for (const [varName, expr] of propDerivedVars) {
408
+ let resolved = expr
409
+ for (const [depName, depExpr] of propDerivedVars) {
410
+ if (depName === varName) continue
411
+ const re = new RegExp(`(?<![.\\w])${depName}(?![\\w:=])`, 'g')
412
+ if (re.test(resolved)) {
413
+ resolved = resolved.replace(re, `(${depExpr})`)
414
+ }
415
+ }
416
+ if (resolved !== expr) propDerivedVars.set(varName, resolved)
417
+ }
418
+
419
+ /**
420
+ * Enhanced dynamic check — combines containsCall with props awareness.
421
+ * Returns true if an expression is reactive (contains signal calls,
422
+ * accesses props members, or references prop-derived variables).
423
+ */
424
+ /**
425
+ * Replace prop-derived variable names in a source text with their original expressions.
426
+ * Simple regex-based replacement — safe because variable names are identifiers.
427
+ */
428
+ function inlineVarsInText(text: string): string {
429
+ if (propDerivedVars.size === 0) return text
430
+ let result = text
431
+ for (const [varName, expr] of propDerivedVars) {
432
+ // Replace standalone variable references only.
433
+ // Must NOT match:
434
+ // - property access: obj.varName (preceded by .)
435
+ // - property name in object: { varName: ... } (followed by :)
436
+ // - part of longer identifier: varNameExtra
437
+ // Lookbehind ensures not preceded by . or alphanumeric
438
+ // Lookahead ensures not followed by alphanumeric, :, or = (JSX attr name)
439
+ const re = new RegExp(`(?<![.\\w])${varName}(?![\\w:=])`, 'g')
440
+ result = result.replace(re, `(${expr})`)
441
+ }
442
+ return result
443
+ }
444
+
445
+ function isDynamic(node: ts.Node): boolean {
446
+ if (containsCall(node)) return true
447
+ return accessesProps(node)
448
+ }
449
+
450
+ /** Check if an expression accesses a tracked props object or a prop-derived variable. */
451
+ function accessesProps(node: ts.Node): boolean {
452
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
453
+ if (propsNames.has(node.expression.text)) return true
454
+ }
455
+ if (ts.isIdentifier(node) && propDerivedVars.has(node.text)) {
456
+ const parent = node.parent
457
+ if (parent && ts.isPropertyAccessExpression(parent) && parent.name === node) return false
458
+ return true
459
+ }
460
+ let found = false
461
+ ts.forEachChild(node, (child) => {
462
+ if (found) return
463
+ if (ts.isArrowFunction(child) || ts.isFunctionExpression(child)) return
464
+ if (accessesProps(child)) found = true
465
+ })
466
+ return found
467
+ }
468
+
469
+ function shouldWrap(node: ts.Expression): boolean {
470
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return false
471
+ if (isStatic(node)) return false
472
+ if (ts.isCallExpression(node) && isPureStaticCall(node)) return false
473
+ return isDynamic(node)
474
+ }
475
+
258
476
  function walk(node: ts.Node): void {
259
477
  if (ts.isJsxElement(node) && tryTemplateEmit(node)) return
260
478
  if (ts.isJsxSelfClosingElement(node) || ts.isJsxElement(node)) checkForWarnings(node)
@@ -484,7 +702,7 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
484
702
  if (ts.isArrowFunction(exprNode) || ts.isFunctionExpression(exprNode)) {
485
703
  return { expr: `(${sliceExpr(exprNode)})()`, isReactive: true }
486
704
  }
487
- return { expr: sliceExpr(exprNode), isReactive: containsCall(exprNode) }
705
+ return { expr: sliceExpr(exprNode), isReactive: isDynamic(exprNode) }
488
706
  }
489
707
 
490
708
  /** Build a setter expression for an attribute. */
@@ -579,7 +797,7 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
579
797
  const expr = sliceExpr(attr.expression)
580
798
  // Use runtime-dom's applyProps which handles class, style, events, etc.
581
799
  needsApplyPropsImport = true
582
- if (containsCall(attr.expression)) {
800
+ if (isDynamic(attr.expression)) {
583
801
  reactiveBindExprs.push(`_applyProps(${varName}, ${expr})`)
584
802
  } else {
585
803
  bindLines.push(`_applyProps(${varName}, ${expr})`)
@@ -629,7 +847,7 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
629
847
  // When r.name() changes, r.email()'s _bind doesn't re-run.
630
848
  needsBindImportGlobal = true
631
849
  const d = nextDisp()
632
- bindLines.push(`const ${d} = _bind(() => { ${tVar}.data = ${expr} })`)
850
+ bindLines.push(`const ${d} = _bind(() => { ${tVar}.data = ${inlineVarsInText(expr)} })`)
633
851
  }
634
852
  return needsPlaceholder ? '<!>' : ''
635
853
  }
@@ -753,7 +971,7 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
753
971
  if (reactiveBindExprs.length > 0) {
754
972
  needsBindImportGlobal = true
755
973
  const combinedName = nextDisp()
756
- const combinedBody = reactiveBindExprs.join('; ')
974
+ const combinedBody = reactiveBindExprs.map(inlineVarsInText).join('; ')
757
975
  bindLines.push(`const ${combinedName} = _bind(() => { ${combinedBody} })`)
758
976
  }
759
977
 
@@ -999,20 +1217,6 @@ function isPureStaticCall(node: ts.CallExpression): boolean {
999
1217
  return node.arguments.every((arg) => !ts.isSpreadElement(arg) && isStatic(arg))
1000
1218
  }
1001
1219
 
1002
- function shouldWrap(node: ts.Expression): boolean {
1003
- // Already a function — user explicitly wrapped or it's a callback
1004
- if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return false
1005
- // Static literal — no signals involved
1006
- if (isStatic(node)) return false
1007
- // Pure call with static args — result is constant
1008
- if (ts.isCallExpression(node) && isPureStaticCall(node)) return false
1009
- // Only wrap if the expression tree contains a call — signal reads are always
1010
- // function calls (e.g. `count()`, `name()`). Plain identifiers, object literals
1011
- // like `style={{ color: "red" }}`, array literals, and member accesses are
1012
- // left as-is to avoid unnecessary reactive wrappers.
1013
- return containsCall(node)
1014
- }
1015
-
1016
1220
  function containsCall(node: ts.Node): boolean {
1017
1221
  if (ts.isCallExpression(node)) {
1018
1222
  // Skip pure calls with static args
@@ -1246,3 +1246,122 @@ describe('JSX transform — per-text-node bind', () => {
1246
1246
  expect(result).toContain('_bindText(c,')
1247
1247
  })
1248
1248
  })
1249
+
1250
+ // ─── Reactive props auto-detection ──────────────────────────────────────────
1251
+
1252
+ describe('JSX transform — reactive props detection', () => {
1253
+ test('props.x in text child is reactive (wrapped in _bind)', () => {
1254
+ const result = t('function Comp(props) { return <div>{props.name}</div> }')
1255
+ expect(result).toContain('_bind(() => {')
1256
+ expect(result).toContain('props.name')
1257
+ })
1258
+
1259
+ test('props.x in attribute is reactive (wrapped in _bind)', () => {
1260
+ const result = t('function Comp(props) { return <div class={props.cls}></div> }')
1261
+ expect(result).toContain('_bind(() => {')
1262
+ expect(result).toContain('props.cls')
1263
+ })
1264
+
1265
+ test('prop-derived variable inlined in text child', () => {
1266
+ const result = t('function Comp(props) { const x = props.name ?? "anon"; return <div>{x}</div> }')
1267
+ expect(result).toContain('_bind(() => {')
1268
+ expect(result).toContain('props.name ?? "anon"')
1269
+ // x should be inlined, not used directly
1270
+ expect(result).not.toMatch(/__t\d+\.data = x\b/)
1271
+ })
1272
+
1273
+ test('prop-derived variable inlined in attribute', () => {
1274
+ const result = t('function Comp(props) { const align = props.alignX ?? "left"; return <div class={align}></div> }')
1275
+ expect(result).toContain('_bind(() => {')
1276
+ expect(result).toContain('props.alignX ?? "left"')
1277
+ })
1278
+
1279
+ test('splitProps results tracked as props-like', () => {
1280
+ const result = t('function Comp(props) { const [own, rest] = splitProps(props, ["x"]); const v = own.x ?? 5; return <div>{v}</div> }')
1281
+ expect(result).toContain('_bind(() => {')
1282
+ expect(result).toContain('own.x ?? 5')
1283
+ })
1284
+
1285
+ test('non-component function NOT tracked (no JSX)', () => {
1286
+ const result = t('function helper(props) { const x = props.y; return x }')
1287
+ expect(result).not.toContain('_bind')
1288
+ expect(result).not.toContain('_tpl')
1289
+ })
1290
+
1291
+ test('static values unchanged by props tracking', () => {
1292
+ const result = t('function Comp(props) { return <div class="static">text</div> }')
1293
+ expect(result).toContain('_tpl("<div class=\\"static\\">text</div>"')
1294
+ expect(result).not.toContain('_bind')
1295
+ })
1296
+
1297
+ test('signal calls still work alongside props detection', () => {
1298
+ const result = t('function Comp(props) { return <div>{count()}</div> }')
1299
+ expect(result).toContain('_bindText(count,')
1300
+ })
1301
+
1302
+ test('arrow function component detected', () => {
1303
+ const result = t('const Comp = (props) => <div>{props.x}</div>')
1304
+ expect(result).toContain('_bind(() => {')
1305
+ expect(result).toContain('props.x')
1306
+ })
1307
+ })
1308
+
1309
+ // ─── Transitive prop derivation ─────────────────────────────────────────────
1310
+
1311
+ describe('JSX transform — transitive prop derivation', () => {
1312
+ test('const b = a + 1 where a is prop-derived', () => {
1313
+ const result = t('function Comp(props) { const a = props.x; const b = a + 1; return <div>{b}</div> }')
1314
+ expect(result).toContain('_bind(() => {')
1315
+ expect(result).toContain('props.x')
1316
+ // b should be inlined transitively
1317
+ expect(result).not.toMatch(/__t\d+\.data = b\b/)
1318
+ })
1319
+
1320
+ test('deep chain: c = b * 2, b = a + 1, a = props.x', () => {
1321
+ const result = t('function Comp(props) { const a = props.x; const b = a + 1; const c = b * 2; return <div>{c}</div> }')
1322
+ expect(result).toContain('props.x')
1323
+ // Full chain inlined
1324
+ expect(result).toContain('_bind')
1325
+ })
1326
+
1327
+ test('non-prop-derived variable NOT inlined', () => {
1328
+ const result = t('function Comp(props) { const x = 42; return <div>{x}</div> }')
1329
+ // x = 42 is static, not prop-derived — should NOT be wrapped
1330
+ expect(result).not.toContain('_bind')
1331
+ })
1332
+
1333
+ test('let variables NOT tracked (mutable — can be reassigned)', () => {
1334
+ const result = t('function Comp(props) { let x = props.y; x = "override"; return <div>{x}</div> }')
1335
+ // let is excluded — x is NOT inlined, set statically
1336
+ expect(result).toContain('textContent = x')
1337
+ expect(result).not.toContain('_bind')
1338
+ })
1339
+
1340
+ test('mixed props and signals in same expression', () => {
1341
+ const result = t('function Comp(props) { return <div class={`${props.base} ${count()}`}></div> }')
1342
+ expect(result).toContain('_bind(() => {')
1343
+ })
1344
+
1345
+ test('prop-derived used in non-JSX stays static', () => {
1346
+ // The variable is still captured — only JSX usage is inlined
1347
+ const result = t('function Comp(props) { const x = props.y; console.log(x); return <div>{x}</div> }')
1348
+ // console.log(x) uses the captured value — compiler doesn't touch it
1349
+ expect(result).toContain('console.log(x)')
1350
+ // JSX usage is inlined
1351
+ expect(result).toContain('props.y')
1352
+ expect(result).toContain('_bind')
1353
+ })
1354
+
1355
+ test('.map() callback params NOT treated as props', () => {
1356
+ const result = t('function App(props) { return <div>{tabs.map((tab) => { const C = tab.component; return <div><C /></div> })}</div> }')
1357
+ // tab is a callback param, not a component's props — should NOT be tracked
1358
+ expect(result).not.toContain('(tab.component)')
1359
+ })
1360
+
1361
+ test('prop read with ?? default used multiple times', () => {
1362
+ const result = t('function Comp(props) { const x = props.a ?? "def"; return <div class={x}>{x}</div> }')
1363
+ // Both uses should be inlined
1364
+ const matches = result.match(/props\.a \?\? "def"/g)
1365
+ expect(matches?.length).toBeGreaterThanOrEqual(2)
1366
+ })
1367
+ })