1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
|
//! Lua bridge for the extension UI event system (plan §7.6).
//!
//! This module bridges the native event machinery in `tui_event.zig` to
//! Lua: it lets a Lua `panto.ext.on(name, handler)` callback participate in
//! the SAME `EventBus` the native side fires, receive a bridged `event`
//! object (`getComponent`/`setComponent` + payload fields), and either
//! pass through a native default component, wrap it, or install a
//! brand-new component DEFINED IN LUA.
//!
//! It is owned by the long-lived `LuaRuntime` (one `lua_State` for the
//! whole session) and shares that state. It never opens its own state.
//!
//! ## Synchronous rendering (NOT the coroutine scheduler)
//!
//! A Lua-defined component's `render(width)` is on the engine's hot path:
//! the differential renderer calls it and needs the produced lines back
//! IMMEDIATELY. So the bridged vtable calls into Lua **synchronously** via
//! `lua_pcallk` (the same model as `LuaRuntime.runCommand`), NOT through
//! the libuv coroutine scheduler used for async tool batches. A render
//! callback may not yield; if a Lua component blocks, that is a bug in the
//! extension, and we surface a safe fallback line rather than hanging or
//! corrupting the frame.
//!
//! ## Cache-derived `firstLineChanged`
//!
//! Each bridged component owns a native `RenderCache` (the same one native
//! components use). The Lua side only returns an array of line strings per
//! `render`; the bridge calls `cache.store(lines)` and derives
//! `firstLineChanged` from the cache diff exactly like a native component.
//! This keeps the cache-derived dirty-model invariant (§3.3) and frees Lua
//! authors from implementing `firstLineChanged` correctly. A Lua-provided
//! `firstLineChanged` override could be added later; cache-derived is the
//! correct, safe default.
//!
//! ## Lifetime / ownership (§6, §7.4)
//!
//! Bridged components and Lua handlers reference Lua functions/tables that
//! must survive as long as the engine borrows them. Both are `luaL_ref`'d
//! into the registry; the refs are owned by this bridge and released at
//! runtime teardown. A bridged component (its `luaL_ref` + `RenderCache`)
//! is heap-allocated and tracked in `components`, freed on `invalidate`
//! and at teardown. There is no "active component": each `emit` that
//! produces a Lua component mints a fresh instance keyed by that
//! boundary's own event, exactly like native components.
const std = @import("std");
const lua_bridge = @import("lua_bridge.zig");
const ui_event = @import("tui_event.zig");
const component = @import("tui_component.zig");
const components = @import("tui_components.zig");
const theme = @import("tui_theme.zig");
const engine = @import("tui_engine.zig");
const c = lua_bridge.c;
const Component = component.Component;
const RenderCache = component.RenderCache;
const Event = ui_event.Event;
const Payload = ui_event.Payload;
const EventBus = ui_event.EventBus;
const Handler = ui_event.Handler;
// Metatable names (registered via `luaL_newmetatable`, looked up by
// `luaL_checkudata`/`luaL_testudata`). The `event` userdata identifies a
// bridged event object; the `component` userdata is a native-component
// passthrough handle.
const eventMtName = "panto.event";
const nativeCompMtName = "panto.component";
/// A component DEFINED IN LUA, bridged to the native `Component` vtable.
///
/// `table_ref` is a `luaL_ref` to the Lua table implementing
/// `render(self, width) -> {strings}` plus optional `firstLineChanged` /
/// `handleInput` / `invalidate` methods. The bridge owns a `RenderCache`
/// and derives the dirty signal from it.
pub const BridgedComponent = struct {
bridge: *EventBridge,
/// `luaL_ref` to the Lua component table.
table_ref: c_int,
cache: RenderCache,
/// True once freed, to make double-free / use-after-free a no-op.
dead: bool = false,
fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
_ = alloc;
const self: *BridgedComponent = @ptrCast(@alignCast(ptr));
return self.bridge.renderBridged(self, width);
}
fn firstLineChangedImpl(ptr: *anyopaque) ?usize {
const self: *BridgedComponent = @ptrCast(@alignCast(ptr));
return self.cache.firstLineChanged();
}
fn invalidateImpl(ptr: *anyopaque) void {
const self: *BridgedComponent = @ptrCast(@alignCast(ptr));
self.cache.invalidate();
}
fn handleInputImpl(ptr: *anyopaque, data: []const u8) void {
const self: *BridgedComponent = @ptrCast(@alignCast(ptr));
self.bridge.handleInputBridged(self, data);
}
/// The shared vtable for every Lua-backed component. Its ADDRESS is the
/// discriminator used by `EventBridge.releaseOverride` to recognize a
/// `Component` as bridged: a `Component` whose `vtable` pointer equals
/// `&BridgedComponent.vtable` is known to have `ptr == *BridgedComponent`.
/// Native (non-Lua) components carry a different vtable address, so the
/// release hook can safely leave them alone. `pub` so the discriminator is
/// addressable from the release path (and tests).
pub const vtable = Component.VTable{
.render = renderImpl,
.firstLineChanged = firstLineChangedImpl,
.invalidate = invalidateImpl,
.handleInput = handleInputImpl,
};
pub fn comp(self: *BridgedComponent) Component {
return .{ .ptr = self, .vtable = &vtable };
}
};
/// A registered Lua `on` handler, bridged to a native `Handler`.
///
/// `fn_ref` is a `luaL_ref` to the Lua handler function. The native
/// `Handler.ctx` points at this struct; `Handler.callback` is
/// `nativeHandlerCallback`, which builds the bridged `event` object, calls
/// the Lua function, and reads back any component it set.
pub const LuaHandler = struct {
bridge: *EventBridge,
fn_ref: c_int,
/// Bridge-owned copy of the event name this handler subscribed to.
event_name: []const u8,
};
/// The bridge: owns the Lua-side event wiring and the bridged
/// components/handlers. Lives inside the `LuaRuntime`.
pub const EventBridge = struct {
alloc: std.mem.Allocator,
L: *c.lua_State,
/// The native bus a Lua `emit` drives, and that fires Lua handlers.
/// Set once the App is constructed (`attachBus`); null before then, in
/// which case a Lua `emit` is a no-op (nothing to fire into yet).
bus: ?*EventBus = null,
/// Owned Lua handler bridges (one per `panto.ext.on` registration).
handlers: std.ArrayListUnmanaged(*LuaHandler) = .empty,
/// Owned bridged components, tracked so their refs + caches are freed
/// at teardown. Each is heap-allocated; an `invalidate` frees it.
components: std.ArrayListUnmanaged(*BridgedComponent) = .empty,
/// During a native `emit`, the event currently being presented to a
/// Lua handler. The bridged `event` userdata's methods read/write
/// through this. Single-threaded; valid only for the duration of one
/// handler call. `null` outside a handler.
active_event: ?*Event = null,
pub fn init(alloc: std.mem.Allocator, L: *c.lua_State) EventBridge {
return .{ .alloc = alloc, .L = L };
}
pub fn deinit(self: *EventBridge) void {
for (self.handlers.items) |h| {
c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, h.fn_ref);
self.alloc.free(h.event_name);
self.alloc.destroy(h);
}
self.handlers.deinit(self.alloc);
for (self.components.items) |comp| self.freeComponentInner(comp);
self.components.deinit(self.alloc);
}
/// Bind the native `EventBus` this bridge fires into / drives via
/// `emit`. Called once during startup wiring, after the App's bus
/// exists. Also registers every harvested Lua `on` handler into the
/// bus, in registration order.
pub fn attachBus(self: *EventBridge, bus: *EventBus) !void {
self.bus = bus;
for (self.handlers.items) |h| {
try bus.on(eventNameOf(h), .{ .ctx = h, .callback = nativeHandlerCallback });
}
}
/// The event name a handler subscribed to is stored alongside its ref
/// at registration time. We keep it on the LuaHandler via a parallel
/// slice; but to avoid a second allocation we look it up lazily. (Set
/// in `registerOnHandler`.)
fn eventNameOf(h: *LuaHandler) []const u8 {
return h.event_name;
}
/// Record a Lua `on` handler: `luaL_ref` the function at stack index
/// `fn_idx` and remember the event name. Registration into the bus
/// happens later in `attachBus` (the bus may not exist yet at harvest
/// time). Order of calls here is the registration order (§7.3).
pub fn registerOnHandler(self: *EventBridge, event_name: []const u8, fn_idx: c_int) !void {
// Dupe the event name into bridge-owned storage.
const name_copy = try self.alloc.dupe(u8, event_name);
errdefer self.alloc.free(name_copy);
// luaL_ref pops the top of stack, so push a copy of the function.
c.lua_pushvalue(self.L, fn_idx);
const ref = c.luaL_ref(self.L, lua_bridge.LUA_REGISTRYINDEX);
errdefer c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, ref);
const h = try self.alloc.create(LuaHandler);
h.* = .{ .bridge = self, .fn_ref = ref, .event_name = name_copy };
try self.handlers.append(self.alloc, h);
}
/// Number of registered Lua `on` handlers (diagnostic/test helper).
pub fn handlerCount(self: *const EventBridge) usize {
return self.handlers.items.len;
}
// -- bridged component construction ------------------------------------
/// Build a native `Component` backed by the Lua component table at
/// stack index `table_idx`. `luaL_ref`s the table, allocates a tracked
/// `BridgedComponent`, and returns its `comp()`. The component lives
/// until `invalidate` (which frees it) or runtime teardown.
///
/// No "active component": each call mints a fresh instance.
fn makeBridgedComponent(self: *EventBridge, table_idx: c_int) !Component {
c.lua_pushvalue(self.L, table_idx);
const ref = c.luaL_ref(self.L, lua_bridge.LUA_REGISTRYINDEX);
errdefer c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, ref);
const bc = try self.alloc.create(BridgedComponent);
errdefer self.alloc.destroy(bc);
bc.* = .{ .bridge = self, .table_ref = ref, .cache = RenderCache.init(self.alloc) };
try self.components.append(self.alloc, bc);
return bc.comp();
}
/// Free a bridged component's Lua ref + cache and destroy it. Idempotent
/// via the `dead` flag, BUT note it does NOT remove `bc` from
/// `self.components` and it `destroy`s the allocation — so it must only be
/// used in one of two safe ways:
/// - at teardown (`deinit`), iterating the whole list once; or
/// - via `releaseOverride`, which removes `bc` from `self.components`
/// BEFORE calling this, so teardown never revisits a destroyed pointer.
fn freeComponentInner(self: *EventBridge, bc: *BridgedComponent) void {
if (bc.dead) return;
bc.dead = true;
c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, bc.table_ref);
bc.cache.deinit();
self.alloc.destroy(bc);
}
/// Release a superseded override component handed back by the App's
/// mid-stream swap (`App.override_release_fn`). This is the leak-prevention
/// point: when a `tool_details` (or any lifecycle) handler swaps a NEW
/// component over a PRIOR Lua-backed override, the prior one must drop its
/// `luaL_ref` + `RenderCache`, or it leaks for the life of the runtime
/// (one leak per swapped tool call).
///
/// Discriminator: a `Component` is Lua-backed iff its `vtable` pointer is
/// `&BridgedComponent.vtable` (the single shared vtable address; see
/// `BridgedComponent.vtable`). For such a component `ptr` is a
/// `*BridgedComponent` we own and track in `self.components`. Any other
/// vtable belongs to a NATIVE component the App owns; we must NOT touch it
/// (it is not ours to free), so the hook is a no-op for it.
///
/// Ownership: we remove `bc` from `self.components` first, then free it, so
/// teardown's `deinit` loop never revisits the destroyed pointer.
pub fn releaseOverride(self: *EventBridge, old: Component) void {
if (old.vtable != &BridgedComponent.vtable) return; // native: not ours
const bc: *BridgedComponent = @ptrCast(@alignCast(old.ptr));
// Remove from the tracked list before freeing (swap-remove is fine;
// order does not matter for teardown).
for (self.components.items, 0..) |item, i| {
if (item == bc) {
_ = self.components.swapRemove(i);
break;
}
}
self.freeComponentInner(bc);
}
/// The `*anyopaque`-typed release callback the App invokes via
/// `override_release_fn`. `ctx` is the `*EventBridge`.
pub fn releaseOverrideThunk(ctx: *anyopaque, old: Component) void {
const self: *EventBridge = @ptrCast(@alignCast(ctx));
self.releaseOverride(old);
}
// -- bridged render / input (SYNCHRONOUS pcall) ------------------------
/// Render a Lua-defined component synchronously: push its table +
/// `render` method + width, `lua_pcallk`, marshal the returned
/// array-of-strings into the component's `RenderCache`, and return the
/// cached lines. On ANY Lua error or bad return, return a single dim
/// error line instead of crashing the render loop (the frame must
/// always complete). Every returned line is truncated to `width` so
/// the engine's width contract (§3.1) is never violated.
fn renderBridged(self: *EventBridge, bc: *BridgedComponent, width: usize) anyerror![]const []const u8 {
const L = self.L;
if (bc.dead) return &.{};
const base = c.lua_gettop(L);
defer c.lua_settop(L, base); // restore stack no matter what
// Push the component table, then its `render` method.
_ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(bc.table_ref));
if (c.lua_type(L, -1) != lua_bridge.T_TABLE) return self.fallbackLines(bc, width, "component is not a table");
_ = c.lua_getfield(L, -1, "render");
if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) {
return self.fallbackLines(bc, width, "component has no render()");
}
// Stack: ..., table, render. Call render(table, width) -> lines.
c.lua_pushvalue(L, -2); // self (the table)
c.lua_pushinteger(L, @intCast(width));
if (c.lua_pcallk(L, 2, 1, 0, 0, null) != 0) {
return self.fallbackLines(bc, width, luaErrText(L));
}
// Stack top: the returned lines table.
const raw = lua_bridge.readLinesArray(L, -1, self.alloc) catch {
return self.fallbackLines(bc, width, "render() must return an array of strings");
};
defer {
for (raw) |ln| self.alloc.free(ln);
self.alloc.free(raw);
}
// Enforce the width contract: truncate every line to `width`
// columns. Build a transient view of truncated borrows for the
// cache to dupe.
var view: std.ArrayListUnmanaged([]const u8) = .empty;
defer view.deinit(self.alloc);
for (raw) |ln| {
const vis = components.truncateToCols(ln, width);
try view.append(self.alloc, vis);
}
try bc.cache.store(view.items);
return cacheLines(&bc.cache);
}
/// Build the cache's single dim fallback line for a render failure and
/// return it. Keeps the render loop alive on a misbehaving extension.
///
/// The composed line MUST satisfy the engine's width contract (§3.1):
/// visible width <= `width`, or the engine rejects it with
/// `Error.LineOverflow` and crashes the very frame the fallback exists to
/// save. The dim escapes are zero visible width, so we build the PLAIN
/// error text, truncate THAT to `width` columns (codepoint-safe), and only
/// then wrap it in the dim style.
fn fallbackLines(self: *EventBridge, bc: *BridgedComponent, width: usize, why: []const u8) anyerror![]const []const u8 {
_ = self;
const dim = theme.default.fg(.dim);
// Plain (escape-free) error text first, into a scratch buffer.
var plain_buf: [256]u8 = undefined;
const plain = std.fmt.bufPrint(&plain_buf, "[lua component error: {s}]", .{why}) catch "[lua component error]";
// Truncate the visible text to the width contract (codepoint-safe).
const vis = components.truncateToCols(plain, width);
// Compose the styled line; if even the styled form overflows the
// compose buffer, fall back to the bare truncated plain text (which
// already satisfies the width contract).
var styled_buf: [512]u8 = undefined;
const styled = std.fmt.bufPrint(&styled_buf, "{s}{s}{s}", .{ dim.open(), vis, dim.close() }) catch vis;
const one = [_][]const u8{styled};
try bc.cache.store(&one);
return cacheLines(&bc.cache);
}
/// Feed input to a Lua component's optional `handleInput(self, data)`
/// method, synchronously. Errors are swallowed (input handling must
/// never crash the loop); a component without the method ignores input.
fn handleInputBridged(self: *EventBridge, bc: *BridgedComponent, data: []const u8) void {
const L = self.L;
if (bc.dead) return;
const base = c.lua_gettop(L);
defer c.lua_settop(L, base);
_ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(bc.table_ref));
if (c.lua_type(L, -1) != lua_bridge.T_TABLE) return;
_ = c.lua_getfield(L, -1, "handleInput");
if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) return;
c.lua_pushvalue(L, -2); // self
_ = c.lua_pushlstring(L, data.ptr, data.len);
// Any error: ignore (best-effort input).
_ = c.lua_pcallk(L, 2, 0, 0, 0, null);
// Mark the cache dirty after input. The engine reads
// `firstLineChanged()` BEFORE calling `render`, so a clean cache means
// the component is SKIPPED and the Lua-side mutation would never reach
// the screen. Native components dirty on every input mutation (see
// InputBox.applyKey -> markDirty); the bridge must do the same on the
// component's behalf. We can't know what changed inside Lua, so we
// re-dirty from 0; the post-render diff in `store` then recovers the
// true lowest-changed line (preserving the streaming-tail property for
// append-style edits). Dirty unconditionally even on a Lua error: the
// handler may have partially mutated state before throwing.
bc.cache.markDirty();
}
};
// ===========================================================================
// Native handler callback: native EventBus -> Lua `on` handler
// ===========================================================================
/// The native `Handler.callback` for a bridged Lua handler. Builds the
/// `event` userdata, calls the Lua function with it (synchronously, under
/// a traceback errfunc), and lets the Lua side read/replace the component
/// via `event:getComponent()` / `event:setComponent()`. Errors in the Lua
/// handler are logged and swallowed — a broken handler must not abort the
/// event dispatch or the render loop.
fn nativeHandlerCallback(ctx: *anyopaque, event: *Event) void {
const h: *LuaHandler = @ptrCast(@alignCast(ctx));
const self = h.bridge;
const L = self.L;
// Make this event the active one so the userdata methods read/write it.
const prev = self.active_event;
self.active_event = event;
defer self.active_event = prev;
const base = c.lua_gettop(L);
defer c.lua_settop(L, base);
// Traceback errfunc beneath the call.
_ = c.lua_getglobal(L, "debug");
_ = c.lua_getfield(L, -1, "traceback");
c.lua_copy(L, -1, -2);
c.lua_settop(L, c.lua_gettop(L) - 1); // pop `debug` table
const errfunc_idx = c.lua_gettop(L);
_ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(h.fn_ref));
pushEventObject(self) catch {
c.lua_settop(L, base);
return;
};
if (c.lua_pcallk(L, 1, 0, errfunc_idx, 0, null) != 0) {
var len: usize = 0;
const msg = c.lua_tolstring(L, -1, &len);
if (msg != null) {
std.log.err("lua event handler '{s}' failed: {s}", .{ h.event_name, msg[0..len] });
}
}
}
// ===========================================================================
// The bridged `event` object (userdata + metatable)
// ===========================================================================
/// Push an `event` userdata onto the stack, carrying a pointer to the
/// bridge (which reaches the active `*Event`). Its metatable exposes
/// `getComponent`, `setComponent`, and read-only payload fields via
/// `__index`.
fn pushEventObject(bridge: *EventBridge) !void {
const L = bridge.L;
const ud: **EventBridge = @ptrCast(@alignCast(c.lua_newuserdatauv(L, @sizeOf(*EventBridge), 0).?));
ud.* = bridge;
ensureEventMetatable(L);
_ = c.lua_setmetatable(L, -2);
}
/// Lazily create the `event` userdata metatable (by name, so
/// `luaL_checkudata` recognizes it) and leave it on the stack. Its
/// `__index` is a function resolving `getComponent`/`setComponent` and
/// payload fields.
fn ensureEventMetatable(L: *c.lua_State) void {
// luaL_newmetatable pushes the metatable; returns 1 if freshly created.
if (c.luaL_newmetatable(L, eventMtName) != 0) {
c.lua_pushcclosure(L, eventIndexThunk, 0);
c.lua_setfield(L, -2, "__index");
}
}
/// `__index(event_ud, key)`: dispatch method names and payload fields.
fn eventIndexThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
const L = L_opt.?;
const ud: **EventBridge = @ptrCast(@alignCast(c.luaL_checkudata(L, 1, eventMtName) orelse return 0));
const bridge = ud.*;
var klen: usize = 0;
const kptr = c.lua_tolstring(L, 2, &klen);
if (kptr == null) return 0;
const key = kptr[0..klen];
if (std.mem.eql(u8, key, "getComponent")) {
c.lua_pushcclosure(L, eventGetComponentThunk, 0);
return 1;
}
if (std.mem.eql(u8, key, "setComponent")) {
c.lua_pushcclosure(L, eventSetComponentThunk, 0);
return 1;
}
// Payload fields + the event name.
const ev = bridge.active_event orelse {
c.lua_pushnil(L);
return 1;
};
if (std.mem.eql(u8, key, "name")) {
_ = c.lua_pushlstring(L, ev.name.ptr, ev.name.len);
return 1;
}
pushPayloadField(L, ev, key);
return 1;
}
/// Push the payload field named `key` for `ev`, or nil if absent. Marshals
/// the tagged-union variant's fields onto simple Lua values (§7.2).
/// Push a string field as a Lua string, or `nil` when the slice is empty.
/// Empty lifecycle fields (e.g. `delta` at a non-delta boundary, `tool_name`
/// before `tool_details`) read as nil on the Lua side, so handlers can branch
/// on presence (`if event.tool_name then ...`).
fn pushStringOrNil(L: *c.lua_State, s: []const u8) void {
if (s.len == 0) {
c.lua_pushnil(L);
} else {
_ = c.lua_pushlstring(L, s.ptr, s.len);
}
}
fn pushPayloadField(L: *c.lua_State, ev: *Event, key: []const u8) void {
switch (ev.payload) {
.tool => |t| {
// index/tool_name plus the lifecycle fields: `id` (resolved
// call id), `delta` (this args chunk on tool_delta), `input`
// (accumulated/final args), `output` (the result text on
// tool_result). String fields return nil when empty/absent so a
// handler can test `if event.delta then ...`.
if (std.mem.eql(u8, key, "index")) {
c.lua_pushinteger(L, @intCast(t.index));
return;
}
if (std.mem.eql(u8, key, "tool_name")) return pushStringOrNil(L, t.tool_name);
if (std.mem.eql(u8, key, "id")) return pushStringOrNil(L, t.id);
if (std.mem.eql(u8, key, "delta")) return pushStringOrNil(L, t.delta);
if (std.mem.eql(u8, key, "input")) return pushStringOrNil(L, t.input);
if (std.mem.eql(u8, key, "output")) return pushStringOrNil(L, t.output);
},
.thinking => |t| {
// index plus `delta` (this chunk on *_delta) and `text` (the
// accumulated/final buffer). Empty -> nil.
if (std.mem.eql(u8, key, "index")) {
c.lua_pushinteger(L, @intCast(t.index));
return;
}
if (std.mem.eql(u8, key, "delta")) return pushStringOrNil(L, t.delta);
if (std.mem.eql(u8, key, "text")) return pushStringOrNil(L, t.text);
},
.assistant_text => |t| {
// Same lifecycle fields as thinking.
if (std.mem.eql(u8, key, "index")) {
c.lua_pushinteger(L, @intCast(t.index));
return;
}
if (std.mem.eql(u8, key, "delta")) return pushStringOrNil(L, t.delta);
if (std.mem.eql(u8, key, "text")) return pushStringOrNil(L, t.text);
},
.user_message => |m| {
if (std.mem.eql(u8, key, "text")) {
_ = c.lua_pushlstring(L, m.text.ptr, m.text.len);
return;
}
},
.session_start => |s| {
if (std.mem.eql(u8, key, "version")) {
_ = c.lua_pushlstring(L, s.version.ptr, s.version.len);
return;
}
if (std.mem.eql(u8, key, "cwd")) {
_ = c.lua_pushlstring(L, s.cwd.ptr, s.cwd.len);
return;
}
if (std.mem.eql(u8, key, "model")) {
_ = c.lua_pushlstring(L, s.model.ptr, s.model.len);
return;
}
},
.compaction => |cm| {
if (std.mem.eql(u8, key, "summary")) {
_ = c.lua_pushlstring(L, cm.summary.ptr, cm.summary.len);
return;
}
},
.custom => {},
}
c.lua_pushnil(L);
}
/// `event:getComponent()` -> the current component as a native-passthrough
/// userdata (or nil). The userdata wraps the `Component` (vtable+ptr) so
/// Lua can pass it straight back to `setComponent` unchanged (§7.5 wrap
/// pattern: the inner is opaque to Lua but re-settable / wrappable).
fn eventGetComponentThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
const L = L_opt.?;
// arg 1 is the event userdata (method call `event:getComponent()`).
const ud: **EventBridge = @ptrCast(@alignCast(c.luaL_checkudata(L, 1, eventMtName) orelse return 0));
const bridge = ud.*;
const ev = bridge.active_event orelse {
c.lua_pushnil(L);
return 1;
};
if (ev.getComponent()) |comp| {
pushNativeComponent(L, comp);
} else {
c.lua_pushnil(L);
}
return 1;
}
/// `event:setComponent(c)` where `c` is EITHER a native-passthrough
/// userdata (from `getComponent`) OR a Lua component table. A table is
/// bridged into a native `Component` (§7.6); a userdata passes the native
/// component straight through.
fn eventSetComponentThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
const L = L_opt.?;
const ud: **EventBridge = @ptrCast(@alignCast(c.luaL_checkudata(L, 1, eventMtName) orelse return 0));
const bridge = ud.*;
const ev = bridge.active_event orelse return 0;
const ty = c.lua_type(L, 2);
if (ty == lua_bridge.T_USERDATA) {
// Native passthrough: recover the Component from the userdata.
if (c.luaL_testudata(L, 2, nativeCompMtName)) |raw| {
const cud: *Component = @ptrCast(@alignCast(raw));
ev.setComponent(cud.*);
return 0;
}
return c.luaL_error(L, "setComponent: unknown userdata (expected a component)");
}
if (ty == lua_bridge.T_TABLE) {
const comp = bridge.makeBridgedComponent(2) catch {
return c.luaL_error(L, "setComponent: failed to bridge Lua component");
};
ev.setComponent(comp);
return 0;
}
return c.luaL_error(L, "setComponent: argument must be a component (table or native handle)");
}
/// Push a native `Component` as a passthrough userdata with the
/// native-component metatable (so `setComponent` can recover it).
fn pushNativeComponent(L: *c.lua_State, comp: Component) void {
const ud: *Component = @ptrCast(@alignCast(c.lua_newuserdatauv(L, @sizeOf(Component), 0).?));
ud.* = comp;
ensureNativeCompMetatable(L);
_ = c.lua_setmetatable(L, -2);
}
fn ensureNativeCompMetatable(L: *c.lua_State) void {
// A bare named metatable (no methods); identity-only so
// `luaL_testudata` can recognize the passthrough handle.
_ = c.luaL_newmetatable(L, nativeCompMtName);
}
/// Read the top-of-stack Lua error as text (after a failed pcall).
fn luaErrText(L: *c.lua_State) []const u8 {
var len: usize = 0;
const ptr = c.lua_tolstring(L, -1, &len);
if (ptr == null) return "lua error";
return ptr[0..len];
}
/// Re-type the cache's owned lines as `[]const []const u8` for the vtable
/// return (mirrors the native components' `cacheLines`).
fn cacheLines(cache: *RenderCache) []const []const u8 {
const owned = cache.lines orelse return &.{};
return @ptrCast(owned);
}
// ===========================================================================
// emit: Lua `panto.ext.emit(name, data)` -> native EventBus
// ===========================================================================
/// C thunk installed as `panto.ext.emit` by the runtime (`installEmit`),
/// carrying the `*EventBridge` as a light-userdata upvalue. Fires a custom
/// event on the native bus so native AND Lua handlers run. The `data`
/// argument is currently surfaced to handlers only as a `.custom` payload
/// (opaque); structured custom-data marshalling can be added later. The
/// chosen component (if any handler set one) is NOT auto-mounted here —
/// imperative mounting from a bare `emit` is a future refinement; for now
/// `emit` drives handler side effects and component selection for events
/// the app fires at real component-creation boundaries.
pub fn emitThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
const L = L_opt.?;
const self_ptr = c.lua_touserdata(L, c.lua_upvalueindex(1));
if (self_ptr == null) return 0;
const bridge: *EventBridge = @ptrCast(@alignCast(self_ptr.?));
const bus = bridge.bus orelse return 0; // no bus yet: no-op
var nlen: usize = 0;
const nptr = c.lua_tolstring(L, 1, &nlen);
if (nptr == null) return c.luaL_error(L, "emit: first argument must be an event name string");
const name = nptr[0..nlen];
var ev = Event.init(name, null, .{ .custom = .{} });
_ = bus.emit(&ev);
return 0;
}
// ===========================================================================
// Tests
// ===========================================================================
const testing = std.testing;
/// Test helper: harvest every `panto.ext.on` registration currently in the
/// state's on-registrations table into `bridge`, in order. Mirrors what
/// `LuaRuntime.harvestAndStoreOnHandlers` does, without the full runtime.
fn harvestOnInto(bridge: *EventBridge) !void {
const L = bridge.L;
_ = c.lua_rawgetp(L, lua_bridge.LUA_REGISTRYINDEX, &lua_bridge.on_registrations_key);
defer c.lua_settop(L, c.lua_gettop(L) - 1);
const n: usize = @intCast(c.lua_rawlen(L, -1));
var i: usize = 1;
while (i <= n) : (i += 1) {
_ = c.lua_rawgeti(L, -1, @intCast(i));
defer c.lua_settop(L, c.lua_gettop(L) - 1);
_ = c.lua_getfield(L, -1, "event");
var elen: usize = 0;
const eptr = c.lua_tolstring(L, -1, &elen);
const name = if (eptr != null) eptr[0..elen] else "";
c.lua_settop(L, c.lua_gettop(L) - 1);
_ = c.lua_getfield(L, -1, "handler");
try bridge.registerOnHandler(name, -1);
c.lua_settop(L, c.lua_gettop(L) - 1);
}
}
fn runScript(L: *c.lua_State, src: [:0]const u8) !void {
// `panto` is not a global; extensions reach it via `require('panto')`.
// Prepend the require so these bridge tests can keep using `panto.ext`
// without each script repeating the boilerplate.
var buf: [8192]u8 = undefined;
const wrapped = std.fmt.bufPrintZ(&buf, "local panto = require(\"panto\")\n{s}", .{src}) catch return error.LuaScriptFailed;
if (c.luaL_loadstring(L, wrapped.ptr) != 0 or c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) {
var len: usize = 0;
const msg = c.lua_tolstring(L, -1, &len);
std.debug.print("lua error: {s}\n", .{msg[0..len]});
return error.LuaScriptFailed;
}
}
test "Lua on-handler fires through the native bus into Lua" {
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
// A handler that records the tool_name it saw into a global.
try runScript(L,
\\seen = nil
\\panto.ext.on("tool", function(e) seen = e.tool_name end)
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
try testing.expectEqual(@as(usize, 1), bridge.handlerCount());
// Fire a native tool event; the Lua handler should observe tool_name.
_ = bus.fire("tool", null, .{ .tool = .{ .index = 3, .tool_name = "skill" } });
_ = c.lua_getglobal(L, "seen");
var len: usize = 0;
const ptr = c.lua_tolstring(L, -1, &len);
try testing.expect(ptr != null);
try testing.expectEqualStrings("skill", ptr[0..len]);
c.lua_settop(L, c.lua_gettop(L) - 1);
}
test "Lua-defined component renders lines through the bridged vtable" {
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
// A handler that sets a Lua component whose render returns two lines.
try runScript(L,
\\panto.ext.on("tool", function(e)
\\ e:setComponent({
\\ render = function(self, width) return { "hello", "world" } end,
\\ })
\\end)
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
const chosen = bus.fire("tool", null, .{ .tool = .{ .index = 0, .tool_name = "x" } });
try testing.expect(chosen != null);
// Render the bridged component at width 80.
const lines = try chosen.?.render(80, testing.allocator);
try testing.expectEqual(@as(usize, 2), lines.len);
try testing.expectEqualStrings("hello", lines[0]);
try testing.expectEqualStrings("world", lines[1]);
// firstLineChanged is cache-derived: first render dirties from 0.
try testing.expectEqual(@as(?usize, 0), chosen.?.firstLineChanged());
// A second identical render reports no change.
_ = try chosen.?.render(80, testing.allocator);
try testing.expectEqual(@as(?usize, null), chosen.?.firstLineChanged());
}
test "bridged component render truncates to the width contract" {
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
try runScript(L,
\\panto.ext.on("tool", function(e)
\\ e:setComponent({
\\ render = function(self, width) return { "abcdefghij" } end,
\\ })
\\end)
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
const chosen = bus.fire("tool", null, .{ .tool = .{} }).?;
const lines = try chosen.render(4, testing.allocator);
try testing.expectEqual(@as(usize, 1), lines.len);
// Truncated to 4 columns.
try testing.expectEqualStrings("abcd", lines[0]);
}
test "bridged component render error yields a safe fallback line, not a crash" {
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
try runScript(L,
\\panto.ext.on("tool", function(e)
\\ e:setComponent({
\\ render = function(self, width) error("boom") end,
\\ })
\\end)
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
const chosen = bus.fire("tool", null, .{ .tool = .{} }).?;
const lines = try chosen.render(80, testing.allocator);
try testing.expectEqual(@as(usize, 1), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[0], "lua component error") != null);
}
test "wrap pattern: Lua reads the native default, wraps it, and renders through it" {
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
// The native default: a component rendering one line "DEFAULT".
const NativeDefault = struct {
cache: RenderCache,
fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
_ = width;
_ = alloc;
const self: *@This() = @ptrCast(@alignCast(ptr));
const one = [_][]const u8{"DEFAULT"};
try self.cache.store(&one);
const owned = self.cache.lines orelse return &.{};
return @ptrCast(owned);
}
fn fcImpl(ptr: *anyopaque) ?usize {
const self: *@This() = @ptrCast(@alignCast(ptr));
return self.cache.firstLineChanged();
}
fn invImpl(ptr: *anyopaque) void {
const self: *@This() = @ptrCast(@alignCast(ptr));
self.cache.invalidate();
}
const vt = Component.VTable{ .render = renderImpl, .firstLineChanged = fcImpl, .invalidate = invImpl };
fn comp(self: *@This()) Component {
return .{ .ptr = self, .vtable = &vt };
}
};
var nd = NativeDefault{ .cache = RenderCache.init(testing.allocator) };
defer nd.cache.deinit();
// Handler: read the native default via getComponent, store it, set a Lua
// component that renders "[" .. inner_first_line .. "]".
try runScript(L,
\\panto.ext.on("tool", function(e)
\\ local inner = e:getComponent() -- native passthrough handle
\\ e:setComponent({
\\ inner = inner,
\\ render = function(self, width)
\\ -- We cannot call the native inner's render from Lua (it has no
\\ -- Lua method); the wrap here decorates around it. Prove we held
\\ -- the handle by setting it back is exercised by passthrough test.
\\ return { "wrapped" }
\\ end,
\\ })
\\end)
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
const chosen = bus.fire("tool", nd.comp(), .{ .tool = .{} }).?;
// The chosen component is the Lua wrapper, not the native default.
try testing.expect(chosen.ptr != nd.comp().ptr);
const lines = try chosen.render(80, testing.allocator);
try testing.expectEqual(@as(usize, 1), lines.len);
try testing.expectEqualStrings("wrapped", lines[0]);
}
test "skills-style claim-by-name (§7.5) works end-to-end through the Lua bridge" {
// The canonical extension pattern: a Lua handler subscribes to `tool`,
// returns early UNLESS event.tool_name matches its tool, and otherwise
// setComponent's a Lua-defined component. We fire two tool events through
// the NATIVE bus and assert only the matching name is claimed (its Lua
// component renders), while a non-matching name keeps the native default.
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
// NOTE: the `event` object is only valid DURING the handler call; a
// component must capture any payload it needs into its own state at handler
// time, NOT close over `e` and read it at render time. Here we snapshot the
// name into a local that the render closure captures.
try runScript(L,
\\panto.ext.on("tool", function(e)
\\ if e.tool_name ~= "skill" then return end -- claim-by-name
\\ local name = e.tool_name -- snapshot at handler time
\\ e:setComponent({
\\ render = function(self, width) return { "SKILL:" .. name } end,
\\ })
\\end)
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
// A native default the handler may or may not replace.
const Native = struct {
cache: RenderCache,
fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
_ = width;
_ = alloc;
const self: *@This() = @ptrCast(@alignCast(ptr));
const one = [_][]const u8{"NATIVE-DEFAULT"};
try self.cache.store(&one);
const owned = self.cache.lines orelse return &.{};
return @ptrCast(owned);
}
fn fcImpl(ptr: *anyopaque) ?usize {
const self: *@This() = @ptrCast(@alignCast(ptr));
return self.cache.firstLineChanged();
}
fn invImpl(ptr: *anyopaque) void {
const self: *@This() = @ptrCast(@alignCast(ptr));
self.cache.invalidate();
}
const vt = Component.VTable{ .render = renderImpl, .firstLineChanged = fcImpl, .invalidate = invImpl };
fn comp(self: *@This()) Component {
return .{ .ptr = self, .vtable = &vt };
}
};
// Non-matching name ("read"): the handler returns early, the default stays.
{
var nd = Native{ .cache = RenderCache.init(testing.allocator) };
defer nd.cache.deinit();
const chosen = bus.fire("tool", nd.comp(), .{ .tool = .{ .index = 0, .tool_name = "read" } }).?;
try testing.expectEqual(@as(*anyopaque, nd.comp().ptr), chosen.ptr); // unchanged
const lines = try chosen.render(80, testing.allocator);
try testing.expectEqualStrings("NATIVE-DEFAULT", lines[0]);
}
// Matching name ("skill"): the handler claims it with a Lua component.
{
var nd = Native{ .cache = RenderCache.init(testing.allocator) };
defer nd.cache.deinit();
const chosen = bus.fire("tool", nd.comp(), .{ .tool = .{ .index = 1, .tool_name = "skill" } }).?;
try testing.expect(chosen.ptr != nd.comp().ptr); // replaced by the Lua component
const lines = try chosen.render(80, testing.allocator);
try testing.expectEqual(@as(usize, 1), lines.len);
try testing.expectEqualStrings("SKILL:skill", lines[0]);
}
}
test "native-passthrough get/set round-trip: setComponent(getComponent()) keeps the native default" {
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
const Native = struct {
line_storage: [1][]const u8 = undefined,
fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
_ = width;
_ = alloc;
const self: *@This() = @ptrCast(@alignCast(ptr));
self.line_storage[0] = "N";
return self.line_storage[0..];
}
fn fcImpl(ptr: *anyopaque) ?usize {
_ = ptr;
return 0;
}
fn invImpl(ptr: *anyopaque) void {
_ = ptr;
}
const vt = Component.VTable{ .render = renderImpl, .firstLineChanged = fcImpl, .invalidate = invImpl };
fn comp(self: *@This()) Component {
return .{ .ptr = self, .vtable = &vt };
}
};
var native = Native{};
// Handler passes the native default straight back through.
try runScript(L,
\\panto.ext.on("tool", function(e)
\\ e:setComponent(e:getComponent())
\\end)
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
const chosen = bus.fire("tool", native.comp(), .{ .tool = .{} }).?;
// Round-trip preserved the SAME native component pointer.
try testing.expectEqual(@as(*anyopaque, native.comp().ptr), chosen.ptr);
}
test "Lua emit drives the native bus (custom event reaches a native handler)" {
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
// Install the real emit (carrying the bridge) so Lua emit reaches the bus.
lua_bridge.installEmit(L, @ptrCast(&bridge), emitThunk);
try bridge.attachBus(&bus);
// A NATIVE handler that flips a flag when "custom-thing" fires.
const Flag = struct {
hit: bool = false,
fn cb(ctx: *anyopaque, ev: *Event) void {
_ = ev;
const self: *@This() = @ptrCast(@alignCast(ctx));
self.hit = true;
}
};
var flag = Flag{};
try bus.on("custom-thing", .{ .ctx = &flag, .callback = Flag.cb });
// Fire it from Lua.
try runScript(L, "panto.ext.emit(\"custom-thing\", {})");
try testing.expect(flag.hit);
}
test "bridged render: a long error on a NARROW width still satisfies the width contract" {
// Regression: the safe fallback line must itself obey the engine's width
// contract (visible width <= width). Otherwise the engine rejects it with
// LineOverflow and crashes the exact frame the fallback exists to save.
// We render a crashing component at a width far narrower than the error
// text and assert the produced line's visible width fits.
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
try runScript(L,
\\panto.ext.on("tool", function(e)
\\ e:setComponent({
\\ render = function(self, width) error("a very long error message that definitely exceeds a narrow terminal width") end,
\\ })
\\end)
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
const chosen = bus.fire("tool", null, .{ .tool = .{} }).?;
const width: usize = 10;
const lines = try chosen.render(width, testing.allocator);
try testing.expectEqual(@as(usize, 1), lines.len);
// The crux: visible width must fit, even though the raw error is long.
try testing.expect(engine.visibleWidth(lines[0]) <= width);
}
test "bridged render: non-array / nil / non-string returns each yield a safe fallback" {
// render() must return an array of strings. A table-with-non-strings, a
// bare nil, and a non-table scalar each route to the fallback line rather
// than crashing or leaking.
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
// Three components, each returning a malformed render value.
try runScript(L,
\\components = {
\\ function(self, width) return { {} } end, -- array of a table (non-string)
\\ function(self, width) return nil end, -- nil
\\ function(self, width) return 42 end, -- non-table scalar
\\}
\\idx = 0
\\panto.ext.on("tool", function(e)
\\ idx = idx + 1
\\ e:setComponent({ render = components[idx] })
\\end)
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
inline for (0..3) |_| {
const chosen = bus.fire("tool", null, .{ .tool = .{} }).?;
const lines = try chosen.render(80, testing.allocator);
try testing.expectEqual(@as(usize, 1), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[0], "lua component error") != null);
}
}
test "bridged render: empty array renders zero lines (no fallback)" {
// An empty table is a VALID render result (zero lines), distinct from a
// malformed return. It must produce no lines and no error.
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
try runScript(L,
\\panto.ext.on("tool", function(e)
\\ e:setComponent({ render = function(self, width) return {} end })
\\end)
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
const chosen = bus.fire("tool", null, .{ .tool = .{} }).?;
const lines = try chosen.render(80, testing.allocator);
try testing.expectEqual(@as(usize, 0), lines.len);
}
test "bridged render: UTF-8 line truncates on codepoint boundaries to the width" {
// The width contract counts visible columns as codepoints. A multibyte
// line must truncate on a codepoint boundary, never mid-sequence, and the
// result's visible width must fit.
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
// "ééééé" is 5 codepoints, 10 bytes. Truncating to 3 columns must yield
// exactly 3 codepoints (6 bytes), not split a 2-byte sequence.
try runScript(L,
\\panto.ext.on("tool", function(e)
\\ e:setComponent({ render = function(self, width) return { "\195\169\195\169\195\169\195\169\195\169" } end })
\\end)
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
const chosen = bus.fire("tool", null, .{ .tool = .{} }).?;
const lines = try chosen.render(3, testing.allocator);
try testing.expectEqual(@as(usize, 1), lines.len);
try testing.expectEqual(@as(usize, 3), engine.visibleWidth(lines[0]));
// 3 codepoints * 2 bytes each = 6 bytes, intact.
try testing.expectEqual(@as(usize, 6), lines[0].len);
}
test "bridged firstLineChanged is cache-derived: append stays near the tail" {
// The bridge owns a native RenderCache, so a streaming component that
// appends a line reports firstLineChanged near the TAIL (the append
// boundary), not 0 — the same streaming-tail property native components
// get. We drive a Lua component whose render output grows by one line.
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
// `n` lines on each render, controlled by a global the test bumps.
try runScript(L,
\\n = 2
\\panto.ext.on("tool", function(e)
\\ e:setComponent({
\\ render = function(self, width)
\\ local t = {}
\\ for i = 1, n do t[i] = "line" .. i end
\\ return t
\\ end,
\\ })
\\end)
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
const chosen = bus.fire("tool", null, .{ .tool = .{} }).?;
// First render: 2 lines, dirty from 0.
_ = try chosen.render(80, testing.allocator);
try testing.expectEqual(@as(?usize, 0), chosen.firstLineChanged());
// No change: null.
_ = try chosen.render(80, testing.allocator);
try testing.expectEqual(@as(?usize, null), chosen.firstLineChanged());
// Append a 3rd line: the cache diff reports the boundary (index 2), NOT 0.
try runScript(L, "n = 3");
chosen.invalidate(); // re-dirty so render recomputes
_ = try chosen.render(80, testing.allocator);
// invalidate() drops to a full re-dirty (from 0), so this proves the cache
// path is wired; the precise tail-index is covered by native RenderCache
// tests. Re-render once more with no change to confirm it settles to null.
_ = try chosen.render(80, testing.allocator);
try testing.expectEqual(@as(?usize, null), chosen.firstLineChanged());
}
test "bridged firstLineChanged: a mid-line replace reports the changed line, shrink reports the boundary" {
// Cache-derived diff on REPLACE and SHRINK, mirroring native RenderCache
// expectations through the bridge.
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
try runScript(L,
\\lines = { "a", "b", "c" }
\\panto.ext.on("tool", function(e)
\\ e:setComponent({ render = function(self, width) return lines end })
\\end)
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
const chosen = bus.fire("tool", null, .{ .tool = .{} }).?;
_ = try chosen.render(80, testing.allocator); // dirty from 0
_ = try chosen.render(80, testing.allocator); // null
try testing.expectEqual(@as(?usize, null), chosen.firstLineChanged());
// Replace the MIDDLE line. Diff must report index 1.
try runScript(L, "lines = { \"a\", \"B\", \"c\" }");
chosen.invalidate();
_ = try chosen.render(80, testing.allocator);
// After invalidate the first render dirties from 0; settle, then mutate
// WITHOUT invalidate to exercise the pure diff path is not possible here
// (the bridge re-dirties only via invalidate/markDirty). The diff index on
// a settled-then-changed render is covered natively; here we confirm a
// shrink settles cleanly.
_ = try chosen.render(80, testing.allocator);
// Shrink to 1 line.
try runScript(L, "lines = { \"a\" }");
chosen.invalidate();
_ = try chosen.render(80, testing.allocator);
const shrunk = try chosen.render(80, testing.allocator);
try testing.expectEqual(@as(usize, 1), shrunk.len);
try testing.expectEqualStrings("a", shrunk[0]);
}
test "bridged handleInput round-trips: a Lua method mutates state the next render reflects" {
// handleInput(self, data) is bridged synchronously. A component that
// accumulates input bytes must show them on the subsequent render.
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
try runScript(L,
\\panto.ext.on("tool", function(e)
\\ e:setComponent({
\\ buf = "",
\\ handleInput = function(self, data) self.buf = self.buf .. data end,
\\ render = function(self, width) return { self.buf } end,
\\ })
\\end)
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
const chosen = bus.fire("tool", null, .{ .tool = .{} }).?;
// Render once and settle the cache to clean (firstLineChanged == null).
_ = try chosen.render(80, testing.allocator);
_ = try chosen.render(80, testing.allocator);
try testing.expectEqual(@as(?usize, null), chosen.firstLineChanged());
// After input, the cache MUST be dirty so the engine (which reads
// firstLineChanged BEFORE render) actually re-renders the component.
chosen.handleInput("he");
try testing.expect(chosen.firstLineChanged() != null);
chosen.handleInput("llo");
try testing.expect(chosen.firstLineChanged() != null);
const lines = try chosen.render(80, testing.allocator);
try testing.expectEqual(@as(usize, 1), lines.len);
try testing.expectEqualStrings("hello", lines[0]);
}
test "bridged component: invalidate frees the ref+cache eagerly; teardown is leak-free" {
// invalidate() on the COMPONENT vtable maps to cache.invalidate (re-dirty),
// NOT a free — freeing happens at teardown for all tracked components. This
// test sets several Lua components, renders them, and relies on the leak-
// checked test allocator: if any ref or cache leaks, the allocator reports
// it at deinit and the test fails.
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit(); // frees all tracked BridgedComponents
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
try runScript(L,
\\panto.ext.on("tool", function(e)
\\ e:setComponent({ render = function(self, width) return { "x", "y" } end })
\\end)
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
// Create several distinct bridged components (each fire makes a new one).
var i: usize = 0;
while (i < 5) : (i += 1) {
const chosen = bus.fire("tool", null, .{ .tool = .{} }).?;
_ = try chosen.render(40, testing.allocator);
chosen.invalidate(); // re-dirty; must not leak the cache on next render
_ = try chosen.render(40, testing.allocator);
}
// bridge.deinit() (deferred) frees every tracked component's ref + cache;
// the leak-checked allocator asserts no leaks.
}
test "lifecycle payload fields marshal to Lua: tool id/delta/input/output, thinking/assistant delta/text" {
// Every new lifecycle field must be readable from a Lua handler, and an
// empty field must read as nil so handlers can branch on presence.
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
// Handlers stash what they saw into Lua globals we then assert on.
try runScript(L,
\\seen = {}
\\panto.ext.on("tool_delta", function(e)
\\ seen.delta = e.delta; seen.tn = e.tool_name; seen.idx = e.index
\\end)
\\panto.ext.on("tool_call_complete", function(e)
\\ seen.input = e.input; seen.id = e.id
\\end)
\\panto.ext.on("tool_result", function(e)
\\ seen.output = e.output; seen.rid = e.id
\\end)
\\panto.ext.on("thinking_delta", function(e)
\\ seen.t_delta = e.delta; seen.t_text = e.text
\\end)
\\panto.ext.on("assistant_text_complete", function(e)
\\ seen.a_text = e.text; seen.a_delta = e.delta -- delta empty -> nil
\\end)
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
_ = bus.fire("tool_delta", null, .{ .tool = .{ .index = 7, .tool_name = "read", .delta = "{\"p" } });
_ = bus.fire("tool_call_complete", null, .{ .tool = .{ .index = 7, .tool_name = "read", .id = "call_1", .input = "{\"path\":\"x\"}" } });
_ = bus.fire("tool_result", null, .{ .tool = .{ .index = 7, .id = "call_1", .output = "file contents" } });
_ = bus.fire("thinking_delta", null, .{ .thinking = .{ .index = 2, .delta = "hm", .text = "hm" } });
_ = bus.fire("assistant_text_complete", null, .{ .assistant_text = .{ .index = 0, .text = "done" } });
// Assert via Lua, surfacing failures as a thrown Lua error.
try runScript(L,
\\assert(seen.delta == "{\"p", "tool_delta.delta")
\\assert(seen.tn == "read", "tool_delta.tool_name")
\\assert(seen.idx == 7, "tool_delta.index")
\\assert(seen.input == "{\"path\":\"x\"}", "tool_call_complete.input")
\\assert(seen.id == "call_1", "tool_call_complete.id")
\\assert(seen.output == "file contents", "tool_result.output")
\\assert(seen.rid == "call_1", "tool_result.id")
\\assert(seen.t_delta == "hm" and seen.t_text == "hm", "thinking_delta")
\\assert(seen.a_text == "done", "assistant_text_complete.text")
\\assert(seen.a_delta == nil, "empty delta must be nil")
);
}
test "lifecycle handlers fire for every new event name" {
// Confirm dispatch is purely by event-name string: a handler on each new
// lifecycle event name actually runs. No new dispatch machinery exists;
// this guards that the names are routed.
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
try runScript(L,
\\fired = {}
\\for _, name in ipairs({
\\ "tool", "tool_details", "tool_delta", "tool_call_complete", "tool_result",
\\ "thinking", "thinking_delta", "thinking_complete",
\\ "assistant_text", "assistant_text_delta", "assistant_text_complete",
\\}) do
\\ panto.ext.on(name, function(e) fired[name] = (fired[name] or 0) + 1 end)
\\end
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
_ = bus.fire("tool", null, .{ .tool = .{} });
_ = bus.fire("tool_details", null, .{ .tool = .{ .tool_name = "x", .id = "c" } });
_ = bus.fire("tool_delta", null, .{ .tool = .{ .delta = "a" } });
_ = bus.fire("tool_call_complete", null, .{ .tool = .{ .input = "{}" } });
_ = bus.fire("tool_result", null, .{ .tool = .{ .output = "r" } });
_ = bus.fire("thinking", null, .{ .thinking = .{} });
_ = bus.fire("thinking_delta", null, .{ .thinking = .{ .delta = "t" } });
_ = bus.fire("thinking_complete", null, .{ .thinking = .{ .text = "t" } });
_ = bus.fire("assistant_text", null, .{ .assistant_text = .{} });
_ = bus.fire("assistant_text_delta", null, .{ .assistant_text = .{ .delta = "a" } });
_ = bus.fire("assistant_text_complete", null, .{ .assistant_text = .{ .text = "a" } });
try runScript(L,
\\for _, name in ipairs({
\\ "tool", "tool_details", "tool_delta", "tool_call_complete", "tool_result",
\\ "thinking", "thinking_delta", "thinking_complete",
\\ "assistant_text", "assistant_text_delta", "assistant_text_complete",
\\}) do
\\ assert(fired[name] == 1, "handler did not fire exactly once for " .. name)
\\end
);
}
test "claim-by-name at tool_details swaps over the start default; releasing the prior override is leak-free" {
// The revised lifecycle: `tool` fires at block_start (name unknown), a Lua
// handler may set a placeholder; then `tool_details` fires with the name
// and a handler claims the call by name and swaps a NEW Lua component over
// the prior one. The superseded override is handed to releaseOverride,
// which must drop its luaL_ref + cache (the leak-prevention path). The
// leak-checked test allocator asserts no leak across the swap.
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
try runScript(L,
\\-- At block_start the name is unknown; set a placeholder Lua component.
\\panto.ext.on("tool", function(e)
\\ e:setComponent({ render = function(self, w) return { "tool (?)" } end })
\\end)
\\-- At tool_details, claim by name and swap in the real component.
\\panto.ext.on("tool_details", function(e)
\\ if e.tool_name ~= "skill" then return end
\\ e:setComponent({ render = function(self, w) return { "SKILL" } end })
\\end)
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
// block_start: handler sets the placeholder Lua component.
const start = bus.fire("tool", null, .{ .tool = .{ .index = 0 } }).?;
{
const lines = try start.render(40, testing.allocator);
try testing.expectEqual(@as(usize, 1), lines.len);
try testing.expectEqualStrings("tool (?)", lines[0]);
}
// Two distinct Lua components were created so far would leak if not freed;
// track the count before the swap.
try testing.expectEqual(@as(usize, 1), bridge.components.items.len);
// tool_details: seed the event with the CURRENT override (the placeholder),
// mirroring how the App seeds getComponent with the current component. The
// handler claims "skill" and swaps in "SKILL".
const details = bus.fire("tool_details", start, .{ .tool = .{ .index = 0, .tool_name = "skill", .id = "c0" } }).?;
try testing.expect(details.ptr != start.ptr); // a NEW component
{
const lines = try details.render(40, testing.allocator);
try testing.expectEqualStrings("SKILL", lines[0]);
}
// Now two bridged components are tracked (placeholder + skill).
try testing.expectEqual(@as(usize, 2), bridge.components.items.len);
// The App's mid-stream swap hands the SUPERSEDED override (the placeholder
// `start`) back to the release hook. Exercise it directly.
bridge.releaseOverride(start);
// The placeholder is freed and untracked; only the skill component remains.
try testing.expectEqual(@as(usize, 1), bridge.components.items.len);
// The surviving component still renders correctly after the release.
const lines = try details.render(40, testing.allocator);
try testing.expectEqualStrings("SKILL", lines[0]);
// releaseOverride on a NATIVE component is a no-op (different vtable).
const Native = struct {
cache: RenderCache,
fn r(ptr: *anyopaque, w: usize, a: std.mem.Allocator) anyerror![]const []const u8 {
_ = w;
_ = a;
const s: *@This() = @ptrCast(@alignCast(ptr));
const one = [_][]const u8{"N"};
try s.cache.store(&one);
return @ptrCast(s.cache.lines orelse return &.{});
}
fn fc(ptr: *anyopaque) ?usize {
const s: *@This() = @ptrCast(@alignCast(ptr));
return s.cache.firstLineChanged();
}
fn inv(ptr: *anyopaque) void {
const s: *@This() = @ptrCast(@alignCast(ptr));
s.cache.invalidate();
}
const vt = Component.VTable{ .render = r, .firstLineChanged = fc, .invalidate = inv };
fn comp(s: *@This()) Component {
return .{ .ptr = s, .vtable = &vt };
}
};
var native = Native{ .cache = RenderCache.init(testing.allocator) };
defer native.cache.deinit();
bridge.releaseOverride(native.comp()); // must NOT touch our tracked list
try testing.expectEqual(@as(usize, 1), bridge.components.items.len);
// bridge.deinit() frees the remaining skill component; allocator asserts no leak.
}
test "intra-emit clobber: two handlers each mint a Lua component in ONE emit; both are tracked and freed at teardown (no true leak)" {
// §7.3 "last-wins-blind": when two handlers for the SAME event each call
// setComponent within a single emit, the bus keeps only the last as
// `current`. The App records only that last one as the entry's override,
// so the FIRST handler's freshly-minted Lua component is never handed to
// `releaseOverride` (the App never sees it). It is therefore NOT released
// mid-stream — it lingers in `bridge.components` until teardown.
//
// This test pins that behavior precisely:
// - it is NOT a true leak: `bridge.deinit` frees every tracked component
// (the leak-checked allocator below would fail otherwise);
// - but it IS per-emit accumulation: a clobbering handler chain grows
// `bridge.components` by one orphan per clobber for the runtime's life.
//
// The documented mitigation is the §7.3 wrap pattern (getComponent ->
// wrap -> setComponent), under which no component is orphaned because each
// handler decorates the current one instead of minting a rival. A handler
// that clobbers blind is at fault per the plan; the resource is still
// reclaimed at teardown, so correctness (no UAF / no true leak) holds.
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
lua_bridge.install(L);
lua_bridge.installTestPantoTable(L);
var bridge = EventBridge.init(testing.allocator, L);
defer bridge.deinit();
var bus = EventBus.init(testing.allocator);
defer bus.deinit();
// Two blind-clobber handlers for the same event: each mints its own Lua
// component, ignoring the current one.
try runScript(L,
\\panto.ext.on("tool", function(e)
\\ e:setComponent({ render = function(self, w) return { "FIRST" } end })
\\end)
\\panto.ext.on("tool", function(e)
\\ e:setComponent({ render = function(self, w) return { "SECOND" } end })
\\end)
);
try harvestOnInto(&bridge);
try bridge.attachBus(&bus);
const chosen = bus.fire("tool", null, .{ .tool = .{ .index = 0 } }).?;
// The LAST handler won.
{
const lines = try chosen.render(40, testing.allocator);
try testing.expectEqualStrings("SECOND", lines[0]);
}
// BOTH components were minted and tracked: the first is now an orphan that
// the App can never release (it only saw `chosen`). It is reclaimed only
// at teardown.
try testing.expectEqual(@as(usize, 2), bridge.components.items.len);
// bridge.deinit() (deferred) frees BOTH — the leak-checked allocator proves
// there is no TRUE leak even though the orphan was never released mid-stream.
}
|