inkferro_napi/lib.rs
1//! inkferro-napi: napi-rs v3 FFI bridge.
2//!
3//! # M3-E: the production render path
4//!
5//! `InkRoot` owns the persistent DOM arena, the per-frame [`FrameWriter`]
6//! transport, the JS transform dispatcher, and an internal `cursor_dirty` gate.
7//! `commit` mutates the arena (decode → `dom::apply`); `render_frame` reads it,
8//! styles it through the core entry, and diffs it through the writer.
9//!
10//! ## Field ownership
11//! - the `Arena` (`inkferro_core::dom`) — persisted across `commit()` calls;
12//! per ADR-3 the layout engine is rebuilt fresh each render from the arena
13//! (`render_styled` → `build_layout_engine`), so there is NO incremental
14//! engine-sync state here — `commit` is `decode_ops -> dom::apply`, full stop;
15//! - the `FrameWriter` (`inkferro_rt`) — the live diff baseline, advanced ONLY by
16//! interactive (`diff`) and `non-interactive` renders. `debug` / `screen-reader`
17//! renders deliberately route through a THROWAWAY writer so they never touch it
18//! (see `render_frame`);
19//! - the JS transform-dispatch `FunctionRef` — the pattern proven in M3-0;
20//! - `root_id` — the arena does not designate a root, so `InkRoot` records which
21//! id is the `Kind::Root`. The JS reconciler allocates id 0 as the root
22//! (matching the core corpus + M3-A seam tests), so it defaults to 0;
23//! - `cursor_dirty` + `cursor_position` — ink's `setCursorPosition` state
24//! (`log-update.ts:163-165`). `set_cursor(pos)` stores the position and flips
25//! the dirty gate; `render_frame` resolves the ACTIVE cursor
26//! (`active = cursor_dirty ? cursor_position : None`, ink `getActiveCursor`),
27//! passes it into `FrameParams.cursor` for the rt to compose the cursor escape
28//! bytes, then resets `cursor_dirty = false` after each frame (ink resets
29//! `cursorDirty` atop every render). A stale position thus never persists across
30//! non-dirty frames (#41/M3-K3).
31//!
32//! ## FunctionRef storage (JS callback that outlives the call that supplied it)
33//! The JS transform dispatcher arrives as `Function<'_, FnArgs<(u32, String, u32)>, String>`,
34//! a *scope-bound* handle valid only for the duration of `new()`. To call it from
35//! a *later* method, immediately `create_ref()` it into a
36//! `FunctionRef<FnArgs<(u32, String, u32)>, String>` and store THAT in the struct.
37//! `FunctionRef` is `Send + Sync` and holds a real napi reference (refcount 1), so
38//! it survives across calls and across turns. Never store `Function<'_>` (its
39//! lifetime is the call scope) and never store `Env`.
40//!
41//! ## borrow_back call shape (per-call, no leak by construction)
42//! Inside a method holding `env: Env`, re-materialize the callable with
43//! `self.dispatcher.borrow_back(&env)?` — a plain `napi_get_reference_value`, it
44//! creates NO new reference, so calling it 10k times cannot leak. Then
45//! `f.call(FnArgs((id, line, index)))` — the `FnArgs` tuple is spread as THREE
46//! positional JS arguments `(id, line, index)`, NOT one array/tuple. `index` is
47//! the per-write local 0-based line index (`usize` → `u32`) the core walk
48//! computes (ink `output.ts` `lines.entries()`).
49//!
50//! ## Transform dispatch + the error channel (`Transformer` returns `String`)
51//! The core walk reads a node's own transform through a
52//! [`TransformAccessor`](inkferro_core::render::walk::TransformAccessor):
53//! `Fn(u32) -> Option<Box<dyn Fn(&str, usize) -> String + 'a>>`. The boxed
54//! closure's signature is **infallible** (`-> String`, not `-> Result`), so a JS
55//! `throw` inside the dispatcher has no return-type channel to surface through.
56//!
57//! We bridge it with an out-of-band error cell. The accessor mints, for every
58//! node whose `has_transform` flag is set, a closure capturing the node id, a
59//! borrow of `self.dispatcher` (lifetime `'a`), `env` (Copy), and a
60//! `&RefCell<Option<Error>>`. Each invocation:
61//! 1. short-circuits (returns its input untouched) if the cell already holds an
62//! error — once one dispatcher throws we stop firing further JS calls for the
63//! rest of the walk;
64//! 2. otherwise `borrow_back`s the dispatcher and calls it; on `Ok(s)` returns
65//! `s`, on `Err(e)` stores `e` in the cell and returns the input untouched
66//! (a sentinel — the half-built string is discarded, never observed).
67//!
68//! After the walk, `render_frame` inspects the cell: a `Some(err)` becomes the
69//! method's `Err` return BEFORE any `FrameWriter` mutation. This is the
70//! throw-discard guarantee, realized by *ordering* (the M3-0 template): the whole
71//! styled frame is built into LOCALS by `render_styled`, and `FrameWriter` is
72//! mutated only on the success path after the cell is confirmed clean — so a
73//! thrown render leaves the writer's diff baseline EXACTLY as the prior frame left
74//! it, and the next `render_frame` emits the bytes it would have emitted had the
75//! failed call never happened.
76//!
77//! Mutating core's `Transformer` to return `Result` was rejected: it would change
78//! `render_styled`/`render_to_string` and risk corpus bytes. The error cell keeps
79//! core untouched (zero core edits in this task).
80//!
81//! ## Reentrancy (an EXPLICIT `rendering` guard is required — observed, not assumed)
82//! `render_frame` and `commit` take `&mut self`, and `render_frame` holds that
83//! `&mut` across the dispatcher call. The hypothesis going in was that napi-rs v3
84//! borrow-checks `&mut self` reentrancy and turns a re-entrant call into a
85//! catchable throw. **The probe disproved it.** With no guard, a transform
86//! dispatcher that re-enters:
87//! * `render_frame` → the host process **SEGFAULTS** (observed exit 139 on
88//! Node) — napi-rs hands the reentrant call a second, *aliasing* `&mut self`;
89//! * `commit` → returns without throwing, but the inner `&mut self.arena`
90//! aliases the outer render's `&self.arena` — undefined behavior that merely
91//! happened not to crash.
92//!
93//! Both are memory-unsafety. The fix is the `rendering: bool` field: every
94//! `render_frame`/`commit` entry reads it FIRST (before touching any other field)
95//! and, if set, returns a catchable typed error (`reentrancy_error`). The outer
96//! `render_frame` sets it via an RAII `RenderingGuard` that clears it on every
97//! exit (return / `Err` / panic). Post-guard, the SAME probe shows a clean
98//! catchable throw on BOTH Node and Bun (exit 0); the smoke suite pins this. The
99//! outer render still completes — the dispatcher catches the reentry error and
100//! returns normally — so the guard rejects only the reentrant call, not the frame
101//! that triggered it.
102//!
103//! ## Transactional `commit` (decode-then-apply, with an id bound)
104//! `commit` decodes the WHOLE op buffer first. `decode_ops` is all-or-nothing: on
105//! a truncated buffer / unknown opcode / bad tag it returns a typed `DecodeError`
106//! and the arena is never touched, so a rejected commit leaves prior DOM state
107//! intact for free. Only a fully-decoded `Vec<Op>` reaches `dom::apply`.
108//!
109//! Decode validates record *structure* but NOT id *magnitude*. The arena trusts
110//! its caller's ids: `Op::Create` is the only op that grows the backing vec
111//! (`Arena::insert` -> `Vec::resize_with(id + 1)`), and `Node` is ~700 B, so a
112//! structurally-valid `Create{id: 0xFFFFFFFF}` would request ~2.95 TB and abort
113//! the host process — an UNCATCHABLE allocation failure, not a JS throw. `commit`
114//! is the trust boundary that turns untrusted JS bytes into arena calls, so it
115//! enforces an id ceiling (`MAX_NODE_ID`) on every id-bearing op BEFORE `apply`,
116//! rejecting the whole buffer (no mutation) with a catchable `InvalidArg` error.
117//!
118//! ## Lifecycle without a Rust finalizer (Bun-safe subset)
119//! No `External`, no custom `finalize`/`Drop`-based cleanup, no threadsafe
120//! function. JS drives lifecycle: it allocates u32 ids and calls `free(id)` (the
121//! `Free` op) when it detaches a node. This is the napi subset Bun's N-API shim
122//! covers.
123//!
124//! BUILD caveat: `@napi-rs/cli` reads its `napi` config block from a
125//! `package.json` in the CWD, not from `--manifest-path`. Build from
126//! `__test__/` (it carries the config block + the CLI devDep).
127
128use std::cell::RefCell;
129
130use inkferro_core::dom::{Arena, Op, apply, decode_ops};
131use inkferro_core::input::{
132 EventType, InputEvent as CoreInputEvent, Key as CoreKey, Parser, parse_keypress,
133};
134use inkferro_core::layout::LayoutEngine;
135use inkferro_core::render::{ColorLevel, build_layout_engine, render_static, render_styled};
136use inkferro_rt::{CursorPos as RtCursorPos, FrameParams, FrameWriter};
137use napi::Env;
138use napi::bindgen_prelude::{Buffer, Error, FnArgs, Function, FunctionRef, Result, Status};
139use napi_derive::napi;
140
141/// A node's own boxed output transform, as produced by the render accessor — the
142/// `Some` payload of `inkferro_core`'s `TransformAccessor` return. Aliased so the
143/// accessor closure's signature stays readable (clippy `type_complexity`).
144type BoxedTransform<'a> = Box<dyn Fn(&str, usize) -> String + 'a>;
145
146/// The root DOM id. The JS reconciler allocates id 0 as the `Kind::Root`
147/// element (matching the core corpus and the M3-A seam tests), so render entries
148/// walk from here.
149const ROOT_ID: u32 = 0;
150
151/// Upper bound (inclusive) on any node id a `commit` buffer may reference.
152///
153/// The arena grows its backing `Vec<Option<Node>>` to `id + 1` on `Op::Create`
154/// (`Arena::insert`). `Node` is ~700 B, so an unbounded id is an OOM-abort
155/// primitive reachable from arbitrary JS bytes. This ceiling bounds the
156/// worst-case single-`Create` allocation to `(MAX_NODE_ID + 1) * ~700 B`
157/// ≈ 45 MB (`2^16` slots), which a host can satisfy without aborting, while
158/// sitting orders of magnitude above any id a real UI tree uses (the corpus,
159/// the M3-A seam tests, and the smoke suite all use single-digit ids). An id
160/// past this is treated as a malformed buffer and rejected wholesale.
161///
162/// Contract this bound assumes: the JS reconciler (M3-E/G) RECYCLES freed ids
163/// so this caps *concurrent live* nodes, not cumulative lifetime allocations.
164/// If the reconciler instead increments ids monotonically without reuse, a
165/// long-lived app could legitimately exceed this and start rejecting valid
166/// commits — that author must honor id-recycling or revisit this ceiling.
167const MAX_NODE_ID: u32 = (1 << 16) - 1;
168
169/// The largest id referenced by an op, if any (`None` for ops that carry no id,
170/// though every current `Op` variant carries at least one). Used to reject a
171/// `commit` buffer before any of it reaches the id-trusting arena.
172fn max_op_id(op: &Op) -> u32 {
173 match op {
174 Op::Create { id, .. }
175 | Op::SetText { id, .. }
176 | Op::SetStyle { id, .. }
177 | Op::SetAttribute { id, .. }
178 | Op::SetTransform { id, .. }
179 | Op::SetTextStyle { id, .. }
180 | Op::ClearTextStyle { id }
181 | Op::SetStatic { id, .. }
182 | Op::Hide { id }
183 | Op::Unhide { id }
184 | Op::Free { id } => *id,
185 Op::AppendChild { parent, child } | Op::RemoveChild { parent, child } => {
186 (*parent).max(*child)
187 }
188 Op::InsertBefore {
189 parent,
190 child,
191 before,
192 } => (*parent).max(*child).max(*before),
193 }
194}
195
196/// The render mode selector (`render_frame`'s `mode` arg).
197///
198/// Each arm sets ONLY fields that already exist on [`FrameParams`] and calls
199/// `FrameWriter::write_frame` unchanged — the M3-E scope rule (no new rt logic;
200/// rt is covenant-frozen). The screen-reader / non-interactive *behavioral*
201/// branches (SGR stripping, alt-screen, etc.) are M3-K2's JS-side job; M3-E only
202/// wires the existing knobs.
203#[napi(string_enum)]
204#[derive(Clone, Copy)]
205pub enum RenderMode {
206 /// Interactive incremental render: `debug=false`, `is_tty=true`. Advances the
207 /// live `FrameWriter` baseline; emits the line-diff bytes (BSU/ESU-wrapped).
208 Diff,
209 /// Plain full-frame render (`FrameParams.debug=true`, frame.rs:123): no diff,
210 /// clear, or sync. Routed through a THROWAWAY writer so it consumes NO diff
211 /// state — a debug render between two live frames cannot corrupt the next
212 /// diff's steady-gate or clear decision. `renderToString` is this mode.
213 Debug,
214 /// Screen-reader: a full plain frame, same no-diff-state-consumed property as
215 /// `Debug` (routed through the throwaway writer). The accessibility transform
216 /// of the *content* is M3-K2's JS concern; here it is the debug knob.
217 ScreenReader,
218 /// Non-interactive (`is_tty=false`): write_frame's non-TTY path — no clear, no
219 /// sync wrap, output padded with a trailing newline and diffed. A real render
220 /// that advances the live baseline.
221 NonInteractive,
222}
223
224/// Per-render geometry (`render_frame`'s `opts` arg). `cols` is the terminal
225/// width handed to the core layout; `rows` is the viewport height fed to
226/// write_frame's fullscreen/clear decisions.
227#[napi(object)]
228pub struct RenderOpts {
229 pub cols: u16,
230 pub rows: u16,
231 /// Detected terminal color level (chalk's `chalk.level`, 0–3). The JS drop-in
232 /// reads the GLOBAL `chalk.level` — exactly what ink uses and what the
233 /// conformance harness forces to 3 — and threads it here. Core colorize emits
234 /// SGR at this level for the core-colorize call sites (borders;
235 /// Box backgroundColor fill). `<Text>` color is colorized JS-side by chalk
236 /// (already level-aware) and arrives pre-styled. 0 → no SGR (non-color
237 /// terminal); 1/2 → downgraded codes; 3 → truecolor (the prior behavior).
238 pub color_level: u8,
239 /// Whether to include `plain_output` in the [`FrameResult`]. When
240 /// explicitly `false`, the result's `plain_output` is an empty string — the
241 /// `render_styled` computation still runs (its output is the transport
242 /// input), but the string is not marshaled back across the FFI boundary.
243 /// `None` (absent from JS) or `true` returns the full string, preserving
244 /// backward compatibility for callers that do not set it (e.g. the test
245 /// harness).
246 pub include_plain_output: Option<bool>,
247}
248
249/// The result of one `render_frame`. `bytes` is the exact transport payload the
250/// JS `Ink` class (the sole stream writer, M3-K1) writes verbatim; the rest are
251/// queryable side outputs.
252#[napi(object)]
253pub struct FrameResult {
254 /// The diffed transport bytes from `FrameWriter::write_frame`. Empty only when
255 /// the mode produced an empty write (handled as `None` by `render_frame`).
256 pub bytes: Buffer,
257 /// The rendered frame height in rows (`render_styled`'s height output).
258 pub output_height: u32,
259 /// The plain styled frame string (`render_styled`'s string output), before
260 /// any transport framing. Mode-independent.
261 pub plain_output: String,
262 /// The `<Static>` subtree's output for this frame — the text printed once
263 /// above the live region (ink's `renderer.ts` static branch, via
264 /// `render_static`). `""` when the tree has no static node (the common case);
265 /// otherwise the static body plus a trailing newline.
266 pub static_output: String,
267 /// Wall-clock milliseconds spent in the core render + transport write.
268 /// NONDETERMINISTIC — excluded from every byte/equality assertion.
269 pub render_time_ms: f64,
270 /// How many visible lines this frame actually rewrote
271 /// (`FrameWriter::last_changed_lines`). Pure ADDITIVE telemetry that rides
272 /// alongside `bytes` and does not alter a single transport byte: 0 for a
273 /// no-op timer-fire frame, the visible line count for a full repaint, the
274 /// differ's changed-line count for an incremental diff. Lets downstream
275 /// pacing (P5.3) tell a real-change frame from a no-op fire. Note a fully
276 /// no-op frame is returned as `None` by `render_frame`, so a delivered
277 /// `FrameResult` normally carries `changed_lines >= 1`.
278 pub changed_lines: u32,
279}
280
281/// A cursor position handed to [`InkRoot::set_cursor`]. Mirrors ink's
282/// `CursorPosition` (`cursor-helpers.ts:3-6`, `{x, y}`). `None` at the call site
283/// mirrors `setCursorPosition(undefined)`.
284#[napi(object)]
285pub struct CursorPos {
286 pub x: u32,
287 pub y: u32,
288}
289
290/// A node's computed layout, returned by [`InkRoot::measure`]. Field set extends
291/// ink's measure result to `left`/`top` for `useBoxMetrics` (M3-J). `left`/`top`
292/// are signed (negative margins can place a node outside its parent — matching
293/// the core [`Rect`](inkferro_core::layout::Rect)'s signed `x`/`y`); `width`/
294/// `height` are unsigned terminal cells.
295#[napi(object)]
296pub struct Rect {
297 pub width: u32,
298 pub height: u32,
299 pub left: i32,
300 pub top: i32,
301}
302
303impl Rect {
304 /// The zero Rect — the infallible sentinel `measure` returns for an unknown,
305 /// freed, out-of-range, or never-laid-out id (ink's `measureElement` on an
306 /// unmounted ref yields zeros).
307 fn zero() -> Self {
308 Rect {
309 width: 0,
310 height: 0,
311 left: 0,
312 top: 0,
313 }
314 }
315}
316
317/// The discriminant of an [`InputEvent`] — mirrors core's `InputEvent` enum
318/// arms. napi has no native tagged-union for `#[napi(object)]`, so the arm is
319/// carried as this string enum plus the optional payload fields.
320#[napi(string_enum)]
321pub enum InputEventKind {
322 /// A decoded key/text segment; the `key` and `input` fields are populated.
323 Key,
324 /// A bracketed-paste payload; the `paste` field is populated.
325 Paste,
326}
327
328/// A high-level input event from [`InkRoot::push_input`], mirroring core's
329/// [`InputEvent`](inkferro_core::input::InputEvent). The `kind` discriminant
330/// selects which payload is present: `Key` ⇒ (`key`, `input`); `Paste` ⇒
331/// (`paste`).
332#[napi(object)]
333pub struct InputEvent {
334 pub kind: InputEventKind,
335 /// The decoded key (ink `Key` shape), present iff `kind == Key`.
336 pub key: Option<Key>,
337 /// The resolved input string handed to `useInput`'s handler, derived exactly
338 /// as ink's `use-input.ts` `handleData` derives it. Present iff `kind == Key`.
339 pub input: Option<String>,
340 /// The bracketed-paste payload (decoded to text), present iff `kind == Paste`.
341 pub paste: Option<String>,
342}
343
344impl InputEvent {
345 /// Mirror one core [`CoreInputEvent`] into the napi shape, deriving the
346 /// JS-facing [`Key`] + `input` string for a key event exactly as ink's
347 /// `useInput` does.
348 fn from_core(ev: CoreInputEvent) -> Self {
349 match ev {
350 CoreInputEvent::Key(k) => {
351 let input = derive_input(&k);
352 let mut key = Key::from_core(&k);
353 // ink sets key.shift for a lone uppercase A-Z input AFTER deriving
354 // the input string (use-input.ts:240-242) — apply on the SAME
355 // input so the returned Key matches ink for typed capitals.
356 apply_uppercase_shift(&mut key, &input);
357 InputEvent {
358 kind: InputEventKind::Key,
359 key: Some(key),
360 input: Some(input),
361 paste: None,
362 }
363 }
364 // Paste payloads are raw bytes upstream; decode lossily to a JS string
365 // (mirrors ink delivering the paste as a string `input`).
366 CoreInputEvent::Paste(bytes) => InputEvent {
367 kind: InputEventKind::Paste,
368 key: None,
369 input: None,
370 paste: Some(String::from_utf8_lossy(&bytes).into_owned()),
371 },
372 }
373 }
374}
375
376/// The JS-facing key object — a 1:1 mirror of ink's `Key` type
377/// (`use-input.ts:9-124`), NOT the lower-level `parseKeypress` output. napi-rs
378/// renames each snake_case field to the camelCase ink name automatically
379/// (`up_arrow` → `upArrow`, …); `return` is a Rust keyword so it is the raw
380/// identifier `r#return`, which napi still emits as `return`. The
381/// FIELD-NAME-EXACT covenant (M3-F) is asserted by the spike via `Object.keys`.
382#[napi(object)]
383pub struct Key {
384 pub up_arrow: bool,
385 pub down_arrow: bool,
386 pub left_arrow: bool,
387 pub right_arrow: bool,
388 pub page_down: bool,
389 pub page_up: bool,
390 pub home: bool,
391 pub end: bool,
392 pub r#return: bool,
393 pub escape: bool,
394 pub ctrl: bool,
395 pub shift: bool,
396 pub tab: bool,
397 pub backspace: bool,
398 pub delete: bool,
399 pub meta: bool,
400 // Kitty keyboard protocol modifiers (fork fields; ink derives these from
401 // `parseKeypress` with `?? false`). `super` is a Rust keyword and CANNOT be a
402 // raw identifier (unlike `r#return`), so the field is `super_key` with an
403 // explicit `js_name` override emitting the ink-exact JS field `super`.
404 #[napi(js_name = "super")]
405 pub super_key: bool,
406 pub hyper: bool,
407 pub caps_lock: bool,
408 pub num_lock: bool,
409 /// Kitty event type: `"press"`, `"repeat"`, or `"release"`; `None` for legacy
410 /// (non-kitty) keys, mirroring ink's optional `eventType`.
411 pub event_type: Option<String>,
412}
413
414impl Key {
415 /// Derive the JS-facing `Key` from a core `parseKeypress` result, EXACTLY as
416 /// ink's `useInput` `handleData` does (`use-input.ts:179-202`): name-equality
417 /// booleans, raw modifiers, and kitty fields (`?? false`).
418 fn from_core(k: &CoreKey) -> Self {
419 Key {
420 up_arrow: k.name == "up",
421 down_arrow: k.name == "down",
422 left_arrow: k.name == "left",
423 right_arrow: k.name == "right",
424 page_down: k.name == "pagedown",
425 page_up: k.name == "pageup",
426 home: k.name == "home",
427 end: k.name == "end",
428 r#return: k.name == "return",
429 escape: k.name == "escape",
430 ctrl: k.ctrl,
431 shift: k.shift,
432 tab: k.name == "tab",
433 backspace: k.name == "backspace",
434 delete: k.name == "delete",
435 meta: k.meta,
436 super_key: k.super_key,
437 hyper: k.hyper,
438 caps_lock: k.caps_lock,
439 num_lock: k.num_lock,
440 event_type: k.event_type.map(|t| {
441 match t {
442 EventType::Press => "press",
443 EventType::Repeat => "repeat",
444 EventType::Release => "release",
445 }
446 .to_owned()
447 }),
448 }
449 }
450}
451
452/// ink's `nonAlphanumericKeys` (`parse-keypress.ts:101`): the legacy `keyName`
453/// table values plus `"backspace"`. `useInput` blanks the `input` string for any
454/// non-kitty key whose name is in this set (`use-input.ts:227-232`).
455const NON_ALPHANUMERIC_KEYS: &[&str] = &[
456 "f1",
457 "f2",
458 "f3",
459 "f4",
460 "f5",
461 "f6",
462 "f7",
463 "f8",
464 "f9",
465 "f10",
466 "f11",
467 "f12",
468 "up",
469 "down",
470 "right",
471 "left",
472 "clear",
473 "end",
474 "home",
475 "insert",
476 "delete",
477 "pageup",
478 "pagedown",
479 "tab",
480 "backspace",
481];
482
483/// Resolve the `input` string ink's `useInput` hands its handler, ported 1:1 from
484/// `use-input.ts:204-238`. Folded into the FFI so the JS `useInput` (M3-I) drops
485/// its in-hook `parseKeypress`.
486fn derive_input(k: &CoreKey) -> String {
487 let mut input: String = if k.is_kitty_protocol {
488 // Kitty: printable keys use the text-as-codepoints field (or name);
489 // Ctrl+letter still flows the letter name so `exitOnCtrlC` works; all
490 // other kitty keys are suppressed.
491 if k.is_printable == Some(true) {
492 k.text.clone().unwrap_or_else(|| k.name.clone())
493 } else if k.ctrl && k.name.chars().count() == 1 {
494 k.name.clone()
495 } else {
496 String::new()
497 }
498 } else if k.ctrl {
499 k.name.clone()
500 } else {
501 k.sequence.clone()
502 };
503
504 if !k.is_kitty_protocol && NON_ALPHANUMERIC_KEYS.contains(&k.name.as_str()) {
505 input = String::new();
506 }
507
508 // Strip a leading ESC from broken/incomplete sequences parseKeypress did not
509 // fully resolve (e.g. a flushed "[").
510 if let Some(stripped) = input.strip_prefix('\u{1b}') {
511 input = stripped.to_owned();
512 }
513
514 // The caller applies the lone-uppercase-A-Z ⇒ Shift rule on this returned
515 // string via `apply_uppercase_shift` (use-input.ts:240-242).
516 input
517}
518
519/// ink sets `key.shift = true` when the resolved `input` is a single uppercase
520/// A-Z (`use-input.ts:240-242`). Applied AFTER `derive_input`, on the same
521/// `input`, so the returned `Key.shift` matches ink for typed capitals.
522fn apply_uppercase_shift(key: &mut Key, input: &str) {
523 let mut chars = input.chars();
524 if let (Some(c), None) = (chars.next(), chars.next())
525 && c.is_ascii_uppercase()
526 {
527 key.shift = true;
528 }
529}
530
531/// The real `InkRoot`: owns the persistent DOM arena, the per-frame transport
532/// writer, the JS transform dispatcher, and the cursor-dirty gate.
533#[napi]
534pub struct InkRoot {
535 /// Persistent DOM. Mutated by `commit`; read by render. Survives across
536 /// every `commit`/render call (ADR-3: persistence lives here, the layout
537 /// engine is rebuilt per frame from this).
538 arena: Arena,
539 /// Live per-frame line-diff transport. Advanced ONLY by `Diff` and
540 /// `NonInteractive` renders; `Debug`/`ScreenReader` use a throwaway writer.
541 frame_writer: FrameWriter,
542 /// The id of the `Kind::Root` node the reconciler created (conventionally 0).
543 root_id: u32,
544 /// The stored JS transform dispatcher. Survives across calls because it is a
545 /// real napi reference, not a scope-bound `Function`. The third `u32` arg is
546 /// the per-write LOCAL 0-based line index (ink's `lines.entries()` index),
547 /// threaded from the core walk into the `(line, index) => string` JS callback.
548 dispatcher: FunctionRef<FnArgs<(u32, String, u32)>, String>,
549 /// The `FrameParams.cursor_dirty` input. Held here so `set_cursor` can flip it
550 /// and `render_frame` resets it after EVERY frame (mirroring ink resetting
551 /// `cursorDirty = false` atop every render/sync, `log-update.ts:64`/`143`).
552 cursor_dirty: bool,
553 /// The stored cursor position (`render.setCursorPosition`'s `cursorPosition`,
554 /// `log-update.ts:164`). `set_cursor(Some)` stores it; `set_cursor(None)`
555 /// clears it. The per-frame ACTIVE cursor is resolved as
556 /// `active = cursor_dirty ? cursor_position : None` (ink's `getActiveCursor`,
557 /// `log-update.ts:43`) and passed into `FrameParams.cursor`. Because
558 /// `render_frame` resets `cursor_dirty = false` after each frame, a STALE
559 /// position never persists across non-dirty frames — exactly ink's "only use
560 /// cursor if setCursorPosition was called since last render" semantics.
561 ///
562 /// Stored in rt's [`RtCursorPos`] form (the `usize` arithmetic type), mapped
563 /// from the napi `u32` [`CursorPos`] at the `set_cursor` boundary.
564 cursor_position: Option<RtCursorPos>,
565 /// The terminal width (`cols`) of the most recent render. `measure` (M3-F)
566 /// rebuilds the per-frame layout engine at THIS width to read `computed(id)`
567 /// — ADR-3's per-frame-rebuild model means there is no stored engine to read,
568 /// so measure must rebuild coherently at the last-rendered geometry. `None`
569 /// until the first render, which makes `measure` return a zero Rect for a
570 /// never-laid-out tree (ink's `measureElement` on an unmounted ref → zeros).
571 last_render_width: Option<u16>,
572 /// The persistent terminal input parser (`Parser::feed`, M2). Its kitty /
573 /// legacy segmenter state machine carries partial-sequence state across
574 /// `push_input` calls (a CSI split across two chunks must resume), so it is
575 /// owned here and NEVER recreated per call.
576 input_parser: Parser,
577 /// Reentrancy guard. Set true while a `render_frame`/`commit` body runs; a
578 /// re-entrant `commit`/`render_frame` (e.g. a transform dispatcher calling
579 /// back into this `InkRoot` mid-walk) reads it FIRST and returns a catchable
580 /// typed error, touching no field. Empirically REQUIRED: napi-rs v3 here does
581 /// NOT borrow-check `&mut self` reentrancy — without this guard a reentrant
582 /// call gets a second aliasing `&mut self` and the process SEGFAULTS (observed
583 /// exit 139); the guard converts that UB into a clean JS-catchable throw.
584 rendering: bool,
585}
586
587#[napi]
588impl InkRoot {
589 /// `new(transform_dispatcher)` — immediately `create_ref()` the supplied
590 /// `Function` and store the resulting `FunctionRef`. The `Function` handle
591 /// itself does not outlive this call; the `FunctionRef` does. The arena and
592 /// frame writer start empty; the JS reconciler builds the tree via `commit`.
593 #[napi(constructor)]
594 pub fn new(transform_dispatcher: Function<FnArgs<(u32, String, u32)>, String>) -> Result<Self> {
595 Ok(InkRoot {
596 arena: Arena::new(),
597 frame_writer: FrameWriter::new(),
598 root_id: ROOT_ID,
599 dispatcher: transform_dispatcher.create_ref()?,
600 cursor_dirty: false,
601 cursor_position: None,
602 last_render_width: None,
603 input_parser: Parser::new(),
604 rendering: false,
605 })
606 }
607
608 /// `commit(ops)` — apply a batch of DOM mutations transactionally.
609 ///
610 /// Decodes the ENTIRE op buffer first (M3-C `decode_ops`). Because the
611 /// decoder is all-or-nothing, a malformed buffer returns a typed napi error
612 /// with ZERO arena mutation — prior DOM state is preserved. Only a fully
613 /// decoded `Vec<Op>` is handed to `dom::apply`. The id-magnitude bound runs
614 /// before `apply` to keep the arena's `resize_with` out of OOM-abort range.
615 #[napi]
616 pub fn commit(&mut self, ops: Buffer) -> Result<()> {
617 // Reentrancy guard FIRST — before any field borrow. A transform dispatcher
618 // re-entering `commit` mid-render would otherwise take `&mut self.arena`
619 // while the render holds `&self.arena` (aliasing UB). Bail on the bare
620 // bool read; touch nothing else (see `rendering` field docs).
621 if self.rendering {
622 return Err(reentrancy_error("commit"));
623 }
624 let ops = decode_ops(ops.as_ref())
625 .map_err(|e| Error::new(Status::InvalidArg, format!("op-buffer decode failed: {e}")))?;
626 if let Some(id) = ops.iter().map(max_op_id).find(|&id| id > MAX_NODE_ID) {
627 return Err(Error::new(
628 Status::InvalidArg,
629 format!("op-buffer rejected: node id {id} exceeds MAX_NODE_ID {MAX_NODE_ID}"),
630 ));
631 }
632 apply(&mut self.arena, &ops);
633 Ok(())
634 }
635
636 /// `render_frame(env, mode, opts) -> Option<FrameResult>` — the production
637 /// render path (M3-E keystone).
638 ///
639 /// Pipeline: style the arena through the core entry (`render_styled`, M3-B),
640 /// dispatching each `has_transform` node's transform to the stored JS
641 /// dispatcher mid-walk; render the `<Static>` subtree once via `render_static`
642 /// (renderer.ts's second pass — `""` when the tree has no static node); then
643 /// wire the live `(string, height)` plus the static string into
644 /// `FrameWriter::write_frame` and return its diffed bytes.
645 ///
646 /// Ordering (the M3-0 throw-discard template): `render_styled` builds the
647 /// whole styled string into LOCALS, firing every dispatcher call during its
648 /// walk — BEFORE any `FrameWriter` mutation. A dispatcher throw is captured in
649 /// `err_cell` and surfaces as this method's `Err` *before* `write_frame` runs,
650 /// so a failed render leaves the writer's diff baseline untouched.
651 ///
652 /// Returns `None` when `write_frame` produced no bytes (a no-change frame:
653 /// `FrameWriter` returns an empty `Vec` for an unchanged output), mirroring
654 /// rt's "willRender" no-op semantics; `Some(FrameResult)` otherwise.
655 #[napi]
656 pub fn render_frame(
657 &mut self,
658 env: Env,
659 mode: RenderMode,
660 opts: RenderOpts,
661 ) -> Result<Option<FrameResult>> {
662 // Reentrancy guard FIRST — before any field borrow. A reentrant
663 // `render_frame` (transform dispatcher calling back mid-walk) would
664 // otherwise get a second aliasing `&mut self` and SEGFAULT under napi-rs
665 // v3 (no catchable borrow flag here). Bail on the bare bool read.
666 if self.rendering {
667 return Err(reentrancy_error("render_frame"));
668 }
669 // Run the body under an RAII guard that sets `rendering` true and clears
670 // it on EVERY way out — normal return, `Err`, or an unwinding panic — so
671 // the instance can never be left permanently locked. The guard borrows
672 // ONLY `&mut self.rendering`; the body borrows the OTHER fields it needs
673 // disjointly (`arena`, `frame_writer`, `dispatcher`, plus the copy-fields
674 // `root_id`/`cursor_dirty`), so the two coexist without raw pointers or
675 // unsafe. `cursor_dirty` is read-only in M3-E (no per-frame mutation here
676 // — that is M3-F/M3-K1), so passing it by value loses nothing.
677 let InkRoot {
678 arena,
679 frame_writer,
680 root_id,
681 dispatcher,
682 cursor_dirty,
683 cursor_position,
684 last_render_width,
685 input_parser: _,
686 rendering,
687 } = self;
688 // Record this frame's width so `measure` can rebuild the per-frame layout
689 // engine at the SAME geometry (ADR-3 per-frame rebuild: no stored engine
690 // to read, so measure rebuilds coherently at the last-rendered width).
691 // Set unconditionally — even a no-change frame (`None` result) lays the
692 // tree out at this width, so a subsequent `measure` reads valid rects.
693 *last_render_width = Some(opts.cols);
694 // Resolve the ACTIVE cursor for this frame: `getActiveCursor`
695 // (`log-update.ts:43`) = `cursorDirty ? cursorPosition : undefined`. A
696 // non-dirty frame sees `None`, so a stale position never re-emits.
697 let active_cursor = if *cursor_dirty {
698 *cursor_position
699 } else {
700 None
701 };
702 // Debug/ScreenReader renders are pure queries (throwaway writer): they must
703 // NOT consume the cursor gate meant for the next real frame (ink's
704 // `renderToString` uses a separate log-update). So only Diff/NonInteractive
705 // reset `cursorDirty`.
706 let is_debug = matches!(mode, RenderMode::Debug | RenderMode::ScreenReader);
707 let _guard = RenderingGuard::engage(rendering);
708 let result = render_frame_impl(
709 arena,
710 frame_writer,
711 dispatcher,
712 *root_id,
713 active_cursor,
714 env,
715 mode,
716 opts,
717 );
718 // Reset the dirty gate after a real frame: ink resets `cursorDirty = false`
719 // atop every render/sync (`log-update.ts:64`/`143`). Done here in the
720 // wrapper (which holds `&mut` via the destructure) AFTER the impl ran on a
721 // copy of the resolved `active_cursor`. The stored `cursor_position`
722 // survives so the next render re-resolves `None` until `set_cursor` is
723 // called again. Skip for debug/screen-reader (query-only).
724 if !is_debug {
725 *cursor_dirty = false;
726 }
727 result
728 }
729
730 /// `render_to_string(width, colorLevel)` — one-shot debug render: a plain full
731 /// frame of the committed arena at `width`, with NO diff-state mutation (it
732 /// routes through `render_frame`'s `Debug` mode + throwaway writer). This is
733 /// the surface the npm `renderToString` (M3-L) wires; it returns the visible
734 /// string, so it MUST honor the detected color level just like `render_frame`.
735 ///
736 /// `color_level` is the JS-detected chalk.level (0–3, the SAME value the npm
737 /// `renderToString` threads into `render_frame` for the static-capture pass),
738 /// so the returned border/Box-bg SGR matches ink: none at level 0, downgraded
739 /// at 1/2, truecolor at 3.
740 ///
741 /// Returns the plain styled output string (mode-independent: the same string
742 /// `render_styled` produces), which is what `renderToString` returns. The
743 /// transport bytes are irrelevant to a string query, so they are discarded.
744 #[napi]
745 pub fn render_to_string(&mut self, env: Env, width: u16, color_level: u8) -> Result<String> {
746 let opts = RenderOpts {
747 cols: width,
748 rows: 0,
749 color_level,
750 include_plain_output: Some(true),
751 };
752 let result = self.render_frame(env, RenderMode::Debug, opts)?;
753 // Debug mode never returns `None` for a non-empty arena, but an empty
754 // arena renders an empty frame → empty write → `None`; map that to "".
755 Ok(result.map(|r| r.plain_output).unwrap_or_default())
756 }
757
758 /// `free(id)` — JS-driven lifecycle without a Rust finalizer.
759 ///
760 /// JS owns the u32 id space and calls this on `detachDeletedInstance` (M3-G).
761 /// It applies a single `Free` op to the arena (drop one slot, no cascade —
762 /// `dom::apply` semantics).
763 ///
764 /// Reentrancy-guarded like `commit`/`render_frame`: `free` is a third
765 /// `&mut self.arena` mutation, so a transform dispatcher re-entering it
766 /// mid-walk would take `&mut self.arena` while the render holds `&self.arena`
767 /// (aliasing UB) AND drop a node out of the very tree being walked. It reads
768 /// `rendering` FIRST and rejects a reentrant call with the same catchable
769 /// typed error. Returning `Result<()>` is what surfaces that throw to JS — the
770 /// reconciler's `detachDeletedInstance` must handle the (vanishingly rare)
771 /// reentry rejection.
772 #[napi]
773 pub fn free(&mut self, id: u32) -> Result<()> {
774 // Reentrancy guard FIRST — before any field borrow (see `commit`).
775 if self.rendering {
776 return Err(reentrancy_error("free"));
777 }
778 apply(&mut self.arena, &[Op::Free { id }]);
779 Ok(())
780 }
781
782 /// `set_cursor(pos)` — store the cursor position and flip the `cursor_dirty`
783 /// gate.
784 ///
785 /// Mirrors ink's `render.setCursorPosition` (`log-update.ts:163-166`)
786 /// EXACTLY: `cursorPosition = position; cursorDirty = true`. inkferro now
787 /// STORES `pos` in [`cursor_position`](Self::cursor_position) (mapping the
788 /// napi `u32` `CursorPos` onto rt's `usize` [`RtCursorPos`]) AND sets the
789 /// dirty gate. The next `render_frame` resolves the ACTIVE cursor as
790 /// `active = cursor_dirty ? cursor_position : None` (`getActiveCursor`,
791 /// `log-update.ts:43`), passes it into `FrameParams.cursor`, and the rt
792 /// composes the ink-faithful cursor escape bytes — so a cursor-only change on
793 /// unchanged content now produces a frame (the `useCursor`/IME behavior).
794 ///
795 /// `None` mirrors `setCursorPosition(undefined)`: it CLEARS the stored
796 /// position and (still) sets dirty, so the next render's active cursor is
797 /// `None` and the rt emits the hide sequence if a cursor was shown.
798 ///
799 /// Reentrancy-guarded like `commit`: it writes `&mut self.cursor_dirty`/
800 /// `&mut self.cursor_position`, which a transform dispatcher re-entering
801 /// mid-render would alias against the render's read of the same fields. Reads
802 /// `rendering` FIRST and rejects a reentrant call with the same catchable
803 /// typed error, touching nothing else.
804 #[napi]
805 pub fn set_cursor(&mut self, pos: Option<CursorPos>) -> Result<()> {
806 // Reentrancy guard FIRST — before any field write (see `commit`).
807 if self.rendering {
808 return Err(reentrancy_error("set_cursor"));
809 }
810 // Store the position (mapping napi u32 -> rt usize) and flip the gate.
811 // ink sets `cursorPosition = position; cursorDirty = true` unconditionally
812 // (`log-update.ts:163-165`), for both a set (`Some`) and a clear (`None`).
813 self.cursor_position = pos.map(|p| RtCursorPos {
814 x: p.x as usize,
815 y: p.y as usize,
816 });
817 self.cursor_dirty = true;
818 Ok(())
819 }
820
821 /// `measure(id) -> Rect` — read a node's computed layout (M3-F).
822 ///
823 /// Mirrors ink's `measureElement` (`dom.ts`): returns a node's computed
824 /// `{width, height, left, top}`. inkferro has no stored engine (ADR-3 rebuilds
825 /// per frame), so `measure` rebuilds the layout engine from the persistent
826 /// arena at the last-rendered WIDTH and reads `computed(id)`.
827 ///
828 /// SEMANTICS — current-DOM-at-last-width, NOT a frozen last-layout snapshot.
829 /// The rebuild reads the arena as it stands NOW, laid out at the last render's
830 /// width. In ink's reconciler every `commit` is followed by a render in
831 /// `resetAfterCommit`, so the arena and the last layout never diverge at a
832 /// `measure` call (the M3-J call pattern is render→measure). Reading the live
833 /// arena is also what makes the trust cases fall out for free: a node freed
834 /// after the last render rebuilds WITHOUT it, so `computed(id)` is `None` →
835 /// zero Rect — exactly the freed-id contract below. A cached last-layout rect
836 /// would instead return STALE non-zero geometry for that freed id, which is
837 /// why this rebuilds rather than caches. The only divergence from ink is the
838 /// pathological commit-without-render-then-measure, which the reconciler flow
839 /// does not produce.
840 ///
841 /// TRUST BOUNDARY: `id` is an arbitrary u32 from JS. An unknown, freed,
842 /// out-of-range, or never-laid-out id (including the no-render-yet case, where
843 /// `last_render_width` is `None`) returns a ZERO Rect — NEVER a panic, NEVER
844 /// an `Err`. This matches ink's `measureElement` on an unmounted ref (yields
845 /// zeros). `measure` is therefore infallible by contract.
846 ///
847 /// Reentrancy: it takes `&self`, but a `&self` read aliased against
848 /// `render_frame`'s `&mut self` is STILL UB under napi-rs v3. So it is guarded
849 /// too — but, because `measure` must stay infallible for bad input, a reentry
850 /// returns the SAME safe zero-Rect sentinel instead of erroring.
851 #[napi]
852 pub fn measure(&self, id: u32) -> Rect {
853 // Reentrancy guard FIRST. A `&self` read concurrent with the outer
854 // render's `&mut self` is UB; return the infallible zero sentinel rather
855 // than rebuild a layout against an aliased arena.
856 if self.rendering {
857 return Rect::zero();
858 }
859 // No render has happened yet → the tree was never laid out → zeros, like
860 // ink's `measureElement` on an unmounted ref.
861 let Some(width) = self.last_render_width else {
862 return Rect::zero();
863 };
864 // Rebuild the per-frame engine at the last-rendered geometry and read the
865 // computed rect. `build_layout_engine` is the same seam `render_frame`
866 // uses, so the measurement is coherent with the displayed frame. A `None`
867 // (degenerate tree) or an unknown/freed `id` (`computed` → `None`) both
868 // collapse to a zero Rect — no panic, no error.
869 let rect = build_layout_engine(&self.arena, self.root_id, width)
870 .and_then(|(engine, _root_rect)| engine.computed(id));
871 match rect {
872 Some(r) => Rect {
873 width: u32::from(r.width),
874 height: u32::from(r.height),
875 left: r.x,
876 top: r.y,
877 },
878 None => Rect::zero(),
879 }
880 }
881
882 /// `measureAbsolute(id) -> Rect` — like [`InkRoot::measure`], but `left`/
883 /// `top` are ABSOLUTE (root-relative) coordinates: the sum of the rounded
884 /// parent-relative offsets down the ancestor chain, i.e. the cell where the
885 /// renderer paints the node (#124). `width`/`height` are identical to
886 /// `measure`'s.
887 ///
888 /// Mirrors the jacob314/ink fork's `getBoundingBox` accumulation
889 /// (`measure-element.ts`: summing `getComputedLeft/Top` up `parentNode`),
890 /// which compat6's `getBoundingBox` shim is built on. ADDITIVE: `measure`'s
891 /// parent-relative contract is untouched (wire/API covenant).
892 ///
893 /// Same semantics, trust boundary, and reentrancy contract as `measure`:
894 /// rebuilds the layout at the last-rendered width; an unknown, freed,
895 /// out-of-range, never-laid-out, or no-render-yet id returns the ZERO Rect
896 /// — never a panic, never an `Err`; a reentrant call returns the same zero
897 /// sentinel.
898 #[napi]
899 pub fn measure_absolute(&self, id: u32) -> Rect {
900 // Reentrancy guard FIRST (see `measure` — `&self` aliased against the
901 // outer render's `&mut self` is UB under napi-rs v3).
902 if self.rendering {
903 return Rect::zero();
904 }
905 // No render yet → never laid out → zeros.
906 let Some(width) = self.last_render_width else {
907 return Rect::zero();
908 };
909 // Same rebuild seam as `measure`; `computed_absolute` reads the
910 // absolute rect the same `calculate` post-pass produced.
911 let rect = build_layout_engine(&self.arena, self.root_id, width)
912 .and_then(|(engine, _root_rect)| engine.computed_absolute(id));
913 match rect {
914 Some(r) => Rect {
915 width: u32::from(r.width),
916 height: u32::from(r.height),
917 left: r.x,
918 top: r.y,
919 },
920 None => Rect::zero(),
921 }
922 }
923
924 /// `clear() -> Buffer` — ink's `log.clear()` (M3-K3): erase the live frame and
925 /// zero the diff baseline, returning the erase bytes for the JS `Ink` (the sole
926 /// stream writer) to emit.
927 ///
928 /// Thin bridge over [`FrameWriter::clear`]: returns `eraseLines(prevHeight)` and
929 /// zeroes the [`LineDiff`](inkferro_rt) baseline so the next repaint is full,
930 /// while PRESERVING `last_output*` (so `restore_last_output`/`sync_baseline` can
931 /// repaint / re-pin afterwards). The one shared erase-emitting gesture the K3
932 /// orchestration composes: interactive `writeToStdout`, `instance.clear()`, and
933 /// resize-shrink all start here.
934 ///
935 /// Reentrancy-guarded like `commit`/`render_frame`: it mutates
936 /// `&mut self.frame_writer`, which a transform dispatcher re-entering mid-render
937 /// would alias against the render's own `&mut frame_writer` (the napi-v3
938 /// segfault-on-reentry path). Reads `rendering` FIRST and rejects a reentrant
939 /// call with the same catchable typed error, touching nothing else.
940 #[napi]
941 pub fn clear(&mut self) -> Result<Buffer> {
942 // Reentrancy guard FIRST — before the `&mut self.frame_writer` borrow.
943 if self.rendering {
944 return Err(reentrancy_error("clear"));
945 }
946 Ok(self.frame_writer.clear().into())
947 }
948
949 /// `sync_baseline()` — ink's `log.sync(lastOutputToRender || lastOutput + '\n')`
950 /// (M3-K3): re-pin the diff baseline to the current on-screen frame WITHOUT
951 /// emitting any bytes.
952 ///
953 /// Thin bridge over [`FrameWriter::sync_baseline`]. Returns nothing (it writes
954 /// no bytes — the pure-path `LineDiff::sync` is byte-free): `instance.clear()`
955 /// composes `write(clear()); sync_baseline()` so a subsequent unchanged
956 /// re-render diffs to a no-op (ink's "unmount's final onRender sees it as
957 /// unchanged and log-update skips it").
958 ///
959 /// Reentrancy-guarded like `clear`: it mutates `&mut self.frame_writer`.
960 #[napi]
961 pub fn sync_baseline(&mut self) -> Result<()> {
962 // Reentrancy guard FIRST — before the `&mut self.frame_writer` borrow.
963 if self.rendering {
964 return Err(reentrancy_error("sync_baseline"));
965 }
966 self.frame_writer.sync_baseline();
967 Ok(())
968 }
969
970 /// `restore_last_output() -> Buffer` — ink's `restoreLastOutput()` (M3-K3):
971 /// repaint the last frame from the cleared baseline, returning the repaint bytes
972 /// for the JS `Ink` (the sole stream writer) to emit.
973 ///
974 /// Thin bridge over [`FrameWriter::restore_last_output`]: after a `clear()`
975 /// zeroed the baseline, this diff is a bootstrap that re-emits the FULL last
976 /// frame. The interactive `writeToStdout` composes `clear() -> data ->
977 /// restore_last_output()` so an app `console.log` is sandwiched between an erase
978 /// of the live region and its repaint, BSU/ESU-wrapped by the JS caller.
979 ///
980 /// Reentrancy-guarded like `clear`: it mutates `&mut self.frame_writer`.
981 #[napi]
982 pub fn restore_last_output(&mut self) -> Result<Buffer> {
983 // Reentrancy guard FIRST — before the `&mut self.frame_writer` borrow.
984 if self.rendering {
985 return Err(reentrancy_error("restore_last_output"));
986 }
987 Ok(self.frame_writer.restore_last_output().into())
988 }
989
990 /// `composeConsoleWrite(data, sync) -> Buffer` — the FUSED interactive
991 /// `writeToStdout` console-interleave (P1.2 / #1): one buffer carrying
992 /// `bsu? + clear() + data + restoreLastOutput() + esu?`, the exact
993 /// concatenation of the five writes the JS multi-write path used to make
994 /// (ink `ink.tsx:687-698`). `sync` is the JS `shouldSync()` result
995 /// (`stdout.isTTY && interactive`) — resolved JS-side because the InkRoot
996 /// holds no stream/TTY state. The JS `Ink` class stays the SOLE stream
997 /// writer: this returns bytes for ONE `stdout.write`.
998 ///
999 /// Thin bridge over [`FrameWriter::compose_console_write`], which composes
1000 /// the SAME `clear`/`restore_last_output` primitives (and their state
1001 /// transitions) the old path triggered — byte-identical in both the
1002 /// rendered and the nothing-rendered-yet (empty erase/repaint) states.
1003 ///
1004 /// Reentrancy-guarded like `clear`: it mutates `&mut self.frame_writer`.
1005 #[napi]
1006 pub fn compose_console_write(&mut self, data: Buffer, sync: bool) -> Result<Buffer> {
1007 // Reentrancy guard FIRST — before the `&mut self.frame_writer` borrow.
1008 if self.rendering {
1009 return Err(reentrancy_error("compose_console_write"));
1010 }
1011 Ok(self.frame_writer.compose_console_write(&data, sync).into())
1012 }
1013
1014 /// `composeConsolePrefix(sync) -> Buffer` — the stdout-side OPENING half of
1015 /// the interactive `writeToStderr` interleave: `bsu? + clear()`. Paired
1016 /// with [`compose_console_suffix`](Self::compose_console_suffix) so the JS
1017 /// path is exactly 3 writes: prefix->stdout, data->stderr, suffix->stdout —
1018 /// concatenating (per stream) to the same bytes as the old 5-write shape
1019 /// (ink `ink.tsx:719-727`).
1020 ///
1021 /// Reentrancy-guarded like `clear`: it mutates `&mut self.frame_writer`.
1022 #[napi]
1023 pub fn compose_console_prefix(&mut self, sync: bool) -> Result<Buffer> {
1024 // Reentrancy guard FIRST — before the `&mut self.frame_writer` borrow.
1025 if self.rendering {
1026 return Err(reentrancy_error("compose_console_prefix"));
1027 }
1028 Ok(self.frame_writer.compose_console_prefix(sync).into())
1029 }
1030
1031 /// `composeConsoleSuffix(sync) -> Buffer` — the stdout-side CLOSING half of
1032 /// the interactive `writeToStderr` interleave: `restoreLastOutput() +
1033 /// esu?`. MUST follow the matching
1034 /// [`compose_console_prefix`](Self::compose_console_prefix) (whose
1035 /// `clear()` zeroed the diff baseline) so the restore is the full-frame
1036 /// bootstrap.
1037 ///
1038 /// Reentrancy-guarded like `clear`: it mutates `&mut self.frame_writer`.
1039 #[napi]
1040 pub fn compose_console_suffix(&mut self, sync: bool) -> Result<Buffer> {
1041 // Reentrancy guard FIRST — before the `&mut self.frame_writer` borrow.
1042 if self.rendering {
1043 return Err(reentrancy_error("compose_console_suffix"));
1044 }
1045 Ok(self.frame_writer.compose_console_suffix(sync).into())
1046 }
1047
1048 /// `forget_last_output()` — ink's `resized()` `lastOutput = '';
1049 /// lastOutputToRender = '';` (`ink.tsx:466-467`): zero the writer's
1050 /// `last_output`/`last_output_to_render` so the post-clear re-render of the
1051 /// reflowed (possibly byte-IDENTICAL) frame is FORCED to repaint.
1052 ///
1053 /// Thin bridge over [`FrameWriter::forget_last_output`]. Emits no bytes. The
1054 /// resize-shrink path composes `write(clear()); forget_last_output();
1055 /// <re-render>`: `clear()` erases + zeroes the diff baseline, this zeroes
1056 /// `last_output` so the steady gate (`output != last_output`) re-opens even on
1057 /// an unchanged reflow. This REPLACES the pre-#41 `setCursor(undefined)` hack
1058 /// (which opened the gate via `cursor_dirty`) now that the cursor gate keys on
1059 /// POSITION change, not `cursor_dirty`.
1060 ///
1061 /// Reentrancy-guarded like `clear`: it mutates `&mut self.frame_writer`.
1062 #[napi]
1063 pub fn forget_last_output(&mut self) -> Result<()> {
1064 // Reentrancy guard FIRST — before the `&mut self.frame_writer` borrow.
1065 if self.rendering {
1066 return Err(reentrancy_error("forget_last_output"));
1067 }
1068 self.frame_writer.forget_last_output();
1069 Ok(())
1070 }
1071
1072 /// `reset_static_output()` — ink's `handleStaticChange` (`ink.tsx:522-525`),
1073 /// the Rust half (#118): zero ONLY the writer's accumulated
1074 /// `full_static_output` when the `<Static>` node's IDENTITY changes, so the
1075 /// clear-branch replay (`clearTerminal + fullStaticOutput + output`,
1076 /// ink `ink.tsx:1066`) never re-emits a dead `<Static>` instance's items.
1077 /// The JS `Ink.handleStaticChange` calls this alongside its own debug-side
1078 /// `fullStaticOutput = ''` reset; everything else in the writer
1079 /// (`last_output*`, diff baseline, cursor state) survives.
1080 ///
1081 /// Thin bridge over [`FrameWriter::reset_static_output`]. Emits no bytes.
1082 /// ADDITIVE method (wire-format covenant: no existing surface changed).
1083 ///
1084 /// Reentrancy-guarded like `clear`: it mutates `&mut self.frame_writer`.
1085 #[napi]
1086 pub fn reset_static_output(&mut self) -> Result<()> {
1087 // Reentrancy guard FIRST — before the `&mut self.frame_writer` borrow.
1088 if self.rendering {
1089 return Err(reentrancy_error("reset_static_output"));
1090 }
1091 self.frame_writer.reset_static_output();
1092 Ok(())
1093 }
1094
1095 /// `push_input(bytes) -> Vec<InputEvent>` — feed the persistent input parser
1096 /// (M3-F).
1097 ///
1098 /// Wraps core's [`Parser::feed`](inkferro_core::input::Parser::feed) (the
1099 /// ported `input-parser.ts` + `parse-keypress.ts` + kitty pipeline). The
1100 /// `Parser` is owned by `InkRoot` (the `input_parser` field) and NEVER
1101 /// recreated per call: its kitty/legacy segmenter is a state machine that
1102 /// buffers partial sequences across chunks, so a CSI sequence split across two
1103 /// `push_input` calls must resume from the carried state.
1104 ///
1105 /// Each core [`CoreInputEvent`] is mirrored into a `#[napi(object)]`
1106 /// [`InputEvent`]. For a key event the JS-facing [`Key`] is derived EXACTLY as
1107 /// ink's `useInput` `handleData` derives it from `parseKeypress`'s output
1108 /// (`use-input.ts:179-242`): the camelCase boolean fields (`upArrow`, …,
1109 /// `return`, `escape`, `tab`, `backspace`, `delete`) plus the raw modifiers
1110 /// and kitty fields, AND the resolved `input` string. This collapses ink's
1111 /// in-hook `parseKeypress` into the FFI, so the JS `useInput` (M3-I) becomes a
1112 /// thin subscriber.
1113 ///
1114 /// Reentrancy-guarded: `feed` mutates `&mut self.input_parser`, which a
1115 /// dispatcher re-entering mid-render would alias. (Inputs are dispatched on
1116 /// the JS main thread between renders, so a real reentry is pathological, but
1117 /// the guard keeps the memory-safety invariant total.) Reads `rendering` FIRST
1118 /// and rejects with the same catchable typed error.
1119 #[napi]
1120 pub fn push_input(&mut self, bytes: Buffer) -> Result<Vec<InputEvent>> {
1121 // Reentrancy guard FIRST — before the `&mut self.input_parser` borrow.
1122 if self.rendering {
1123 return Err(reentrancy_error("push_input"));
1124 }
1125 Ok(self
1126 .input_parser
1127 .feed(bytes.as_ref())
1128 .into_iter()
1129 .map(InputEvent::from_core)
1130 .collect())
1131 }
1132
1133 /// `has_pending_escape() -> bool` — whether a bare/partial escape is buffered
1134 /// in the parser awaiting the host's escape-flush timer (M3-I2 / task #52).
1135 ///
1136 /// Thin read over core's
1137 /// [`Parser::has_pending_escape`](inkferro_core::input::Parser::has_pending_escape)
1138 /// (`input/mod.rs` → `segmenter.rs`): `true` only while a lone `\x1b` (or a
1139 /// short incomplete escape) is buffered, and explicitly `false` while
1140 /// assembling a paste-start marker (`ESC [ 2 0 0`) or any other partial CSI —
1141 /// so an Esc-PREFIXED sequence (e.g. `\x1b[A` → upArrow) never reads as a bare
1142 /// Esc. The JS `App` polls this after draining a readable burst and, when set,
1143 /// arms the 20ms `schedulePendingInputFlush` timer that surfaces the buffered
1144 /// Esc via `flush_pending_escape`.
1145 ///
1146 /// Reentrancy: it takes `&self`, but — exactly like `measure` — a `&self` read
1147 /// aliased against `render_frame`'s `&mut self` is STILL UB under napi-rs v3.
1148 /// Because this method must stay infallible (it has no error channel a JS
1149 /// poll could branch on), a reentry returns the SAME safe `false` sentinel
1150 /// `measure` mirrors with its zero-Rect: under render there is, by definition,
1151 /// no host escape-flush timer to arm, so reporting "no pending escape" is the
1152 /// correct conservative answer.
1153 #[napi]
1154 pub fn has_pending_escape(&self) -> bool {
1155 // Reentrancy guard FIRST. A `&self` read concurrent with the outer
1156 // render's `&mut self` is UB; return the infallible `false` sentinel
1157 // rather than read an aliased parser.
1158 if self.rendering {
1159 return false;
1160 }
1161 self.input_parser.has_pending_escape()
1162 }
1163
1164 /// `flush_pending_escape() -> Vec<InputEvent>` — take any buffered escape
1165 /// bytes as literal input, decoded to the SAME `InputEvent` shape `push_input`
1166 /// returns (M3-I2 / task #52).
1167 ///
1168 /// Wraps core's
1169 /// [`Parser::flush_pending_escape`](inkferro_core::input::Parser::flush_pending_escape),
1170 /// which `mem::take`s the buffered bytes. A lone `\x1b` here decodes — via the
1171 /// SAME `parse_keypress` + [`InputEvent::from_core`] mapping `push_input` uses
1172 /// — to a normal key event `{kind:'Key', input:'', key:{escape:true, …}}`, so
1173 /// JS receives a parsed Esc keypress, NOT raw bytes. `None` (nothing pending)
1174 /// yields an empty `Vec`.
1175 ///
1176 /// CRITICAL — the flushed bytes MUST be decoded with `parse_keypress`
1177 /// directly, NOT re-fed through `self.input_parser.feed`. The segmenter treats
1178 /// a lone `\x1b` as an INCOMPLETE sequence and would re-buffer it (returning
1179 /// `[]` and re-arming `has_pending_escape`), so a `feed`-based flush would
1180 /// never surface the Esc. Decoding the taken bytes directly is what makes the
1181 /// post-flush `has_pending_escape()` go `false` — the discriminating invariant.
1182 ///
1183 /// Reentrancy-guarded like `push_input`: `flush_pending_escape` mutates
1184 /// `&mut self.input_parser`, which a dispatcher re-entering mid-render would
1185 /// alias. Reads `rendering` FIRST and rejects with the same catchable typed
1186 /// error.
1187 #[napi]
1188 pub fn flush_pending_escape(&mut self) -> Result<Vec<InputEvent>> {
1189 // Reentrancy guard FIRST — before the `&mut self.input_parser` borrow.
1190 if self.rendering {
1191 return Err(reentrancy_error("flush_pending_escape"));
1192 }
1193 // `None` ⇒ nothing pending ⇒ no events. Some(bytes) ⇒ decode the taken
1194 // bytes through `parse_keypress` (NOT `feed`, which would re-buffer a lone
1195 // ESC) so the bare Esc surfaces as a normal `{key:{escape:true}}` event.
1196 Ok(self
1197 .input_parser
1198 .flush_pending_escape()
1199 .map(|bytes| {
1200 vec![InputEvent::from_core(CoreInputEvent::Key(parse_keypress(
1201 &bytes,
1202 )))]
1203 })
1204 .unwrap_or_default())
1205 }
1206}
1207
1208/// RAII guard for the `rendering` reentrancy flag: sets it true on `engage` and
1209/// clears it on `Drop` — so a normal return, an `Err`, or an unwinding panic all
1210/// leave `rendering == false` and the instance re-callable. Holds a borrow of
1211/// ONLY the bool (not the whole `InkRoot`), so the render body can borrow the
1212/// other fields disjointly with no raw pointers or unsafe.
1213struct RenderingGuard<'a>(&'a mut bool);
1214
1215impl<'a> RenderingGuard<'a> {
1216 fn engage(flag: &'a mut bool) -> Self {
1217 *flag = true;
1218 RenderingGuard(flag)
1219 }
1220}
1221
1222impl Drop for RenderingGuard<'_> {
1223 fn drop(&mut self) {
1224 *self.0 = false;
1225 }
1226}
1227
1228/// The catchable typed error a reentrant `commit`/`render_frame` returns.
1229fn reentrancy_error(method: &str) -> Error {
1230 Error::new(
1231 Status::GenericFailure,
1232 format!(
1233 "InkRoot.{method} re-entered during a render — a transform dispatcher must not call \
1234 back into commit/render_frame (the receiver is already borrowed; reentry is rejected \
1235 to avoid memory corruption)"
1236 ),
1237 )
1238}
1239
1240/// The `render_frame` body, hoisted to a free function taking the `InkRoot`
1241/// fields it needs by DISJOINT borrow so it can run alongside the
1242/// `RenderingGuard`'s `&mut self.rendering` borrow. See `InkRoot::render_frame`
1243/// for the guard wiring and the throw-discard ordering contract.
1244#[allow(clippy::too_many_arguments)]
1245fn render_frame_impl(
1246 arena: &Arena,
1247 frame_writer: &mut FrameWriter,
1248 dispatcher: &FunctionRef<FnArgs<(u32, String, u32)>, String>,
1249 root_id: u32,
1250 cursor: Option<RtCursorPos>,
1251 env: Env,
1252 mode: RenderMode,
1253 opts: RenderOpts,
1254) -> Result<Option<FrameResult>> {
1255 let start = std::time::Instant::now();
1256
1257 // ── 1. Style pass (accumulate-into-locals; dispatcher throws land here) ──
1258 // The error cell is the out-of-band channel for the infallible `Transformer`
1259 // signature. The accessor mints a per-id closure that dispatches to JS; a
1260 // throw is stored here and short-circuits the rest of the walk. `borrow_back`
1261 // happens INSIDE the closure so the captured dispatcher borrow stays `'a`
1262 // across the recursion.
1263 let err_cell: RefCell<Option<Error>> = RefCell::new(None);
1264 let err_ref = &err_cell;
1265 let transform_of = |id: u32| -> Option<BoxedTransform<'_>> {
1266 let node = arena.get(id)?;
1267 if !node.has_transform {
1268 return None;
1269 }
1270 Some(Box::new(move |s: &str, index: usize| -> String {
1271 // Once any dispatcher throws, stop firing JS calls — return the input
1272 // untouched so the remaining walk is a cheap no-op.
1273 if err_ref.borrow().is_some() {
1274 return s.to_owned();
1275 }
1276 let f = match dispatcher.borrow_back(&env) {
1277 Ok(f) => f,
1278 Err(e) => {
1279 *err_ref.borrow_mut() = Some(e);
1280 return s.to_owned();
1281 }
1282 };
1283 // `index` is the per-write LOCAL 0-based line index the core walk
1284 // computes (ink `output.ts` `lines.entries()`); forward it verbatim as
1285 // the 3rd JS arg (`usize` → `u32`, mirroring `height as u32`).
1286 match f.call((id, s.to_owned(), index as u32).into()) {
1287 Ok(out) => out,
1288 Err(e) => {
1289 // First throw wins; later nodes short-circuit above.
1290 *err_ref.borrow_mut() = Some(e);
1291 s.to_owned()
1292 }
1293 }
1294 }))
1295 };
1296
1297 // Convert the JS-detected chalk.level (`opts.color_level`, 0–3) to a
1298 // ColorLevel and thread it into both render passes, so core colorize (borders;
1299 // Box bg fill) emits SGR at the SAME level chalk would — no SGR at level 0,
1300 // downgraded codes at 1/2, truecolor at 3. `<Text>` color is already chalk-
1301 // colorized JS-side and unaffected.
1302 let color_level = ColorLevel::from_u8(opts.color_level);
1303
1304 let (plain_output, height) =
1305 render_styled(arena, root_id, opts.cols, &transform_of, color_level);
1306
1307 // Static (second) render pass: the `<Static>` subtree rendered once, above the
1308 // live region (ink's renderer.ts static branch). `transform_of` is `Copy`
1309 // (shared refs only) so reusing it here is free, and a dispatcher throw fired
1310 // during this pass lands in the SAME `err_cell` — which is exactly why this
1311 // call MUST precede the throw-discard barrier below. No static node → "" (the
1312 // common case), keeping the no-static path byte-identical to before.
1313 let static_output = render_static(arena, root_id, opts.cols, &transform_of, color_level);
1314
1315 // ── 2. Throw-discard barrier: surface any dispatcher throw BEFORE any
1316 // FrameWriter mutation. `transform_of` is `Copy` (captures only shared
1317 // refs), so NLL ends its borrow of `arena`/`err_cell` at its last use
1318 // (`render_static` above) — `err_cell` is then free to move out. Both
1319 // render passes feed this one cell, so a throw in EITHER pass aborts the
1320 // frame before any transport write.
1321 if let Some(err) = err_cell.into_inner() {
1322 return Err(err);
1323 }
1324
1325 // ── 3. Transport write. Build the exact FrameParams for the mode, then drive
1326 // write_frame. Debug/ScreenReader use a THROWAWAY writer so they consume
1327 // no diff state; Diff/NonInteractive advance the live writer.
1328 let debug = matches!(mode, RenderMode::Debug | RenderMode::ScreenReader);
1329 let is_tty = !matches!(mode, RenderMode::NonInteractive);
1330
1331 let params = FrameParams {
1332 is_tty,
1333 viewport_rows: opts.rows as usize,
1334 output: &plain_output,
1335 output_height: height as usize,
1336 // Static (second-pass) output: the `<Static>` subtree printed once above
1337 // the live region (renderer.ts static branch). "" when the tree has no
1338 // static node (the common case) — then `write_frame` sees the same empty
1339 // static channel it always did, so the no-static path is unchanged.
1340 static_output: &static_output,
1341 is_unmounting: false,
1342 // `cursor_dirty` is retained on the params (the rt no longer keys its
1343 // cursor-render gate off it — that is the resolved `cursor` vs the
1344 // writer's previous cursor). Set it to whether an active cursor is present
1345 // so the field stays semantically meaningful, though the rt ignores it.
1346 cursor_dirty: cursor.is_some(),
1347 // The resolved ACTIVE cursor (`cursorDirty ? cursorPosition : None`,
1348 // computed by the wrapper). The rt composes the ink-faithful cursor escape
1349 // bytes from this against its `previous_cursor_position`.
1350 cursor,
1351 // `interactive`/`is_in_ci` resolve sync wrapping; deferring to the CI flag
1352 // (`None`) with `is_in_ci=false` keeps the interactive path synchronized
1353 // (matches ink's default TTY behavior). M3-K1 owns any override.
1354 interactive: None,
1355 is_in_ci: false,
1356 debug,
1357 };
1358
1359 let (bytes, changed_lines) = if debug {
1360 // Throwaway writer: a debug/screen-reader render is a pure query of the
1361 // arena at this width and must NOT advance the live diff baseline. Its
1362 // `changed_lines` telemetry is read off the same throwaway writer.
1363 let mut w = FrameWriter::new();
1364 let b = w.write_frame(¶ms);
1365 (b, w.last_changed_lines())
1366 } else {
1367 let b = frame_writer.write_frame(¶ms);
1368 (b, frame_writer.last_changed_lines())
1369 };
1370
1371 let render_time_ms = start.elapsed().as_secs_f64() * 1000.0;
1372
1373 // No-change frame: write_frame returns an empty Vec (rt "willRender" no-op).
1374 // Mirror it as `None`.
1375 if bytes.is_empty() {
1376 return Ok(None);
1377 }
1378
1379 Ok(Some(FrameResult {
1380 bytes: bytes.into(),
1381 output_height: height as u32,
1382 plain_output: if opts.include_plain_output.unwrap_or(true) {
1383 plain_output
1384 } else {
1385 String::new()
1386 },
1387 // Surface the real second-pass string (the same value written into
1388 // `FrameParams.static_output` above). "" when the tree has no static node.
1389 static_output,
1390 render_time_ms,
1391 // Additive per-frame telemetry: how many visible lines this frame
1392 // rewrote (read off the writer that produced `bytes`). Byte-inert.
1393 changed_lines,
1394 }))
1395}